13. Dynamic programming
Dynamic programming (DP) adalah cara menyelesaikan soal dengan memecahnya menjadi submasalah yang lebih kecil, menghitung setiap submasalah tepat sekali, dan menyimpan jawabannya untuk dipakai lagi.
Dua syarat sebuah soal bisa diselesaikan dengan DP:
- Submasalah tumpang tindih. Submasalah yang sama
muncul berkali-kali — seperti
fib(43)yang dihitung ratusan juta kali di Bab 06. - Substruktur optimal. Jawaban terbaik untuk soal besar tersusun dari jawaban terbaik submasalahnya.
Cara paling ringkas untuk mengingat apa itu DP:
DP = brute force − pekerjaan yang terulang.
Bab 06 sudah memperlihatkan bedanya secara angka.
fib(45) yang ditulis rekursif polos memerlukan
3.672.623.805 pemanggilan, padahal jawaban yang benar-benar berbeda
hanya ada 46 buah: fib(0) sampai fib(45).
Sisanya perhitungan ulang. Begitu setiap jawaban disimpan, pekerjaannya
turun dari miliaran menjadi puluhan langkah — dan itulah seluruh isi
gagasan DP.
Dua gaya penulisan
Top-down (memoization). Tulis rekursi biasa, lalu tambahkan ingatan. Sudah kamu kerjakan di tantangan Bab 06.
long long ingat[100];
long long fib(int n) {
if (n <= 1) return n;
if (ingat[n] != -1) return ingat[n];
return ingat[n] = fib(n - 1) + fib(n - 2);
}Bottom-up (tabulasi). Isi tabel dari submasalah terkecil ke terbesar, tanpa rekursi.
long long dp[100];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];Keduanya menghitung hal yang sama:
| Top-down (memoization) | Bottom-up (tabulasi) | |
|---|---|---|
| bentuknya | rekursi + array ingatan | perulangan mengisi tabel |
| urutan pengisian | diurus sendiri oleh rekursi | harus kamu pikirkan |
| keadaan yang tak terpakai | tidak pernah dihitung | tetap ikut dihitung |
| risiko | tumpukan pemanggilan habis | tidak ada |
| kecepatan | sedikit lebih lambat | sedikit lebih cepat |
| paling enak dipakai saat | rekursinya sudah ditulis lebih dulu | keadaannya sederhana dan urutannya jelas |
Top-down lebih mudah ditulis dari rekursi yang sudah ada; bottom-up lebih cepat dan tidak berisiko kehabisan memori tumpukan. Buku ini memakai bottom-up untuk contoh-contoh berikutnya.
Array ingatan pada top-down harus diisi nilai penanda "belum
dihitung" yang tidak mungkin menjadi jawaban sungguhan.
Memakai 0 sebagai penanda pada soal yang jawabannya bisa
0 membuat keadaan itu dihitung ulang terus — dan kelebihan
kecepatannya hilang tanpa satu pun jawaban yang salah, sehingga
kesalahannya hanya terlihat sebagai Time Limit Exceeded.
Lima langkah menyusun DP
Setiap kali menghadapi soal DP, jawab lima pertanyaan ini di kertas sebelum menulis kode:
- Apa keadaannya (state)? Apa arti
dp[i]? Tulis sebagai kalimat lengkap. - Apa kasus dasarnya? Keadaan terkecil yang jawabannya sudah diketahui.
- Apa transisinya? Bagaimana
dp[i]dihitung dari keadaan yang lebih kecil. - Urutan pengisiannya bagaimana? Setiap keadaan yang dipakai harus sudah terisi lebih dulu.
- Jawabannya ada di keadaan mana?
Langkah 1 adalah yang tersulit, dan sisanya biasanya mengikuti.
Contoh 1: naik tangga
Ada tangga dengan
nanak tangga. Sekali melangkah bisa naik 1 atau 2 anak tangga. Ada berapa cara berbeda mencapai anak tangga ke-n?
- Keadaan.
dp[i]= banyaknya cara mencapai anak tangga ke-i. - Kasus dasar.
dp[0] = 1(satu cara: diam di bawah),dp[1] = 1. - Transisi. Untuk sampai di
i, langkah terakhirnya darii − 1atau darii − 2. Jadidp[i] = dp[i-1] + dp[i-2]. - Urutan. Dari kecil ke besar.
- Jawaban.
dp[n].
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2];
cout << dp[n] << "\n";Mengisi tabelnya untuk n = 6:
i |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
dp[i] |
1 | 1 | 2 | 3 | 5 | 8 | 13 |
Periksa dengan tangan untuk i = 3: cara mencapai anak
tangga ke-3 ada tiga, yaitu 1+1+1, 1+2, dan 2+1. Cocok dengan
dp[3] = 3.
Hasilnya barisan Fibonacci lagi — pertanda bahwa banyak soal yang kelihatan berbeda sebenarnya berkeadaan sama.
Buka
exercises/13/01-tangga.cpp
dan ubah agar langkahnya boleh 1, 2, atau 3 anak tangga. Hanya baris
transisinya yang berubah.
Contoh 2: koin paling sedikit
Ini soal yang membuat greedy gagal di Bab 12.
Diberikan pecahan koin
c[1..m]dan nilaik. Berapa koin paling sedikit untuk mencapai tepatk? Cetak-1kalau tidak mungkin.
- Keadaan.
dp[v]= koin paling sedikit untuk membentuk nilaiv. - Kasus dasar.
dp[0] = 0. - Transisi.
dp[v] = 1 + min(dp[v - c[i]])untuk setiap koinc[i]yang tidak melebihiv. - Urutan.
vdari 1 sampaik. - Jawaban.
dp[k], atau-1kalau nilainya masih tak terhingga.
const int BESAR = 1000000000;
dp[0] = 0;
for (int v = 1; v <= k; v++) {
dp[v] = BESAR;
for (int i = 0; i < m; i++) {
if (v >= c[i] && dp[v - c[i]] + 1 < dp[v])
dp[v] = dp[v - c[i]] + 1;
}
}
cout << (dp[k] >= BESAR ? -1 : dp[k]) << "\n";Mengisi tabelnya untuk koin {1, 3, 4}:
v |
0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
dp[v] |
0 | 1 | 2 | 1 | 1 | 2 | 2 |
Cara membaca satu selnya: dp[6] memeriksa tiga
kemungkinan koin terakhir — koin 1 memberi dp[5] + 1 = 3,
koin 3 memberi dp[3] + 1 = 2, koin 4 memberi
dp[2] + 1 = 3. Yang terkecil adalah 2, jadi
dp[6] = 2.
Perhatikan bahwa DP tidak perlu tahu koin mana yang "sebaiknya" diambil lebih dulu. Ia mencoba semuanya sebagai koin terakhir, lalu memakai jawaban submasalah yang sudah terbukti terbaik. Di situlah letak perbedaannya dengan greedy, yang hanya mencoba satu pilihan dan tidak pernah mundur.
Jumlah langkahnya k × m: satu keadaan untuk tiap nilai,
dan tiap keadaan memeriksa m koin. Untuk
k = 10⁶ dan m = 10 itu 10 juta — cepat.
Buka
exercises/13/02-koin.cpp.
Tambahkan perulangan yang mencetak seluruh isi dp[0..k],
jalankan untuk koin 1 3 4 dan k = 10, lalu
cocokkan baris pertama tabel di atas. Perhatikan
dp[10] = 3, sedangkan greedy menjawab 4.
Bandingkan dengan greedy {1, 3, 4} dan
k = 6: DP menjawab 2, greedy menjawab 3. DP memeriksa semua
kemungkinan tanpa mengulang pekerjaan.
Contoh 3: knapsack 0/1
Ada
nbarang; barang ke-iberatnyaw[i]dan nilainyav[i]. Tas hanya kuat menahan beratW. Setiap barang boleh diambil paling banyak sekali. Berapa nilai terbesar yang bisa dibawa?
Keadaan.
dp[i][b]= nilai terbesar memakaiibarang pertama dengan batas beratb.Kasus dasar.
dp[0][b] = 0untuk semuab.Transisi. Dua pilihan untuk barang ke-
i:- tidak diambil:
dp[i-1][b] - diambil (kalau
b >= w[i]):dp[i-1][b - w[i]] + v[i]
Ambil yang lebih besar.
- tidak diambil:
Urutan.
idari 1 sampain,bdari 0 sampaiW.Jawaban.
dp[n][W].
for (int i = 1; i <= n; i++) {
for (int b = 0; b <= W; b++) {
dp[i][b] = dp[i - 1][b]; // tidak diambil
if (b >= w[i] && dp[i - 1][b - w[i]] + v[i] > dp[i][b])
dp[i][b] = dp[i - 1][b - w[i]] + v[i]; // diambil
}
}Jumlah langkahnya n × W. Untuk n = 100 dan
W = 10.000 itu sejuta.
Menelusuri untuk W = 5 dan tiga barang — (berat 2, nilai
3), (berat 3, nilai 4), (berat 4, nilai 5). Setiap baris adalah keadaan
sesudah barang ke-i dipertimbangkan:
dp[i][b] |
b=0 | b=1 | b=2 | b=3 | b=4 | b=5 |
|---|---|---|---|---|---|---|
i=0 (tanpa barang) |
0 | 0 | 0 | 0 | 0 | 0 |
i=1 (berat 2) |
0 | 0 | 3 | 3 | 3 | 3 |
i=2 (+ berat 3) |
0 | 0 | 3 | 4 | 4 | 7 |
i=3 (+ berat 4) |
0 | 0 | 3 | 4 | 5 | 7 |
Jawabannya dp[3][5] = 7, yaitu barang pertama dan kedua
(berat 2 + 3 = 5, nilai 3 + 4 = 7). Telusuri satu sel untuk memastikan
paham: dp[2][5] memilih antara tidak mengambil barang kedua
(dp[1][5] = 3) dan mengambilnya
(dp[1][5-3] + 4 = dp[1][2] + 4 = 7). Yang lebih besar
7.
Perhatikan juga bahwa setiap baris hanya membaca baris tepat
di atasnya. Sifat itu yang membuat tabel knapsack bisa
dipadatkan menjadi satu baris saja untuk menghemat memori — teknik yang
berguna saat n besar, tetapi tidak perlu dipakai sebelum
versi dua dimensinya benar-benar dikuasai.
Knapsack adalah contoh terbaik kenapa greedy tidak cukup. Mengambil barang dengan nilai per berat tertinggi terdengar masuk akal, tetapi bisa salah: dua barang "kurang efisien" sering mengisi tas lebih baik daripada satu barang "efisien" yang besar. DP mencoba kedua kemungkinan itu untuk setiap barang.
Buka
exercises/13/03-knapsack.cpp.
Di dalamnya ada versi greedy (nilai per berat) dan versi DP. Jalankan
contoh datanya dan lihat greedy memberi jawaban lebih kecil.
Menghitung jumlah langkah DP
Rumusnya selalu:
jumlah langkah = banyaknya keadaan × biaya satu transisi
| Soal | Keadaan | Transisi | Total |
|---|---|---|---|
| naik tangga | n | O(1) | O(n) |
| koin paling sedikit | k | O(m) | O(k · m) |
| knapsack 0/1 | n × W | O(1) | O(n · W) |
Gunakan rumus itu bersama tabel batasan Bab 10: kalau banyaknya keadaan saja sudah 10¹⁰, keadaannya perlu dirancang ulang.
Mengenali soal DP
Empat tanda bahwa sebuah soal kemungkinan besar DP:
- Soal menanyakan paling banyak, paling sedikit, atau berapa cara — bukan "cetak salah satu contoh".
- Setiap langkah berupa pilihan (ambil atau tidak, langkah 1 atau 2, potong di sini atau tidak).
- Brute force yang mencoba semua pilihan jelas benar, tetapi jumlah kemungkinannya 2ⁿ atau n!.
- Pilihan yang sudah diambil mempengaruhi sisanya hanya lewat sedikit informasi — misalnya "sisa berat tas" atau "sudah sampai anak tangga ke berapa", bukan seluruh riwayat pilihan.
Tanda keempat yang paling menentukan, karena informasi sedikit itulah
yang menjadi keadaan dp. Kalau sisa soal ternyata
bergantung pada seluruh riwayat pilihan, keadaannya akan meledak dan
soal itu bukan DP sederhana.
Perbandingan dengan dua cara sebelumnya:
| Cara | Mencoba | Biaya |
|---|---|---|
| brute force | semua kemungkinan, berulang-ulang | 2ⁿ atau n! |
| greedy | satu kemungkinan saja | n log n, sering salah |
| DP | semua kemungkinan, masing-masing sekali | keadaan × transisi |
Kesalahan yang paling sering
- Keadaan tidak didefinisikan sebagai kalimat. Kalau
dp[i]tidak bisa dijelaskan dengan satu kalimat, transisinya pasti akan kacau. - Kasus dasar salah.
dp[0] = 0dandp[0] = 1adalah dua soal berbeda: yang satu menghitung jumlah, yang lain mencacah cara. - Urutan pengisian salah, sehingga keadaan yang dipakai belum terisi.
- Array kurang besar, sehingga jawaban untuk
kterakhir tertimpa.
Pekerjaan rumah
Soal 1 — Naik tangga bertiga
Buka
exercises/13/homework/01-tangga.cpp.
Baca n (≤ 1.000.000). Cetak banyaknya cara mencapai anak
tangga ke-n bila sekali melangkah boleh naik 1, 2, atau 3
anak tangga. Jawabannya besar, jadi cetak sisa baginya terhadap
1.000.000.007 (cara kerjanya dibahas di Bab 14; untuk sekarang cukup
ambil % 1000000007 setiap kali menjumlahkan).
Soal 2 — Koin paling sedikit
Buka
exercises/13/homework/02-koin.cpp.
Baca m dan k, lalu m pecahan
koin. Cetak jumlah koin paling sedikit untuk mencapai tepat
k, atau -1 kalau tidak mungkin. Uji dengan
koin 1 3 4 dan k = 6; jawabannya harus 2.
Soal 3 — Banyaknya cara
Buka
exercises/13/homework/03-cara-koin.cpp.
Dengan masukan yang sama seperti soal 2, cetak banyaknya
cara membentuk nilai k, dengan urutan pengambilan
tidak dianggap berbeda (3 + 1 sama dengan
1 + 3). Petunjuk: perulangan luar menelusuri koin,
perulangan dalam menelusuri nilai. Kalau urutannya dibalik, yang
terhitung adalah urutan yang berbeda.
Soal 4 — Knapsack 0/1
Buka
exercises/13/homework/04-knapsack.cpp.
Baca n dan W, lalu n pasang berat
dan nilai. Cetak nilai terbesar yang bisa dibawa.
Tantangan — Barisan naik terpanjang
Buka
exercises/13/homework/05-lis.cpp.
Baca n (≤ 5.000) lalu n bilangan. Cetak
panjang barisan naik terpanjang — yaitu banyaknya elemen terbanyak yang
bisa dipilih sambil mempertahankan urutan aslinya dan nilainya selalu
bertambah. Contoh: 3 1 4 1 5 9 2 6 menghasilkan 4 (misalnya
1 4 5 9). Keadaannya: dp[i] = panjang barisan
naik terpanjang yang berakhir di elemen
ke-i.
Tips OSN
Tanda pertama soal DP: "berapa banyak cara", "paling sedikit", atau "paling besar", dengan pilihan yang bertumpuk. Kalau setiap langkah menghadirkan beberapa pilihan dan pilihan sekarang mempengaruhi pilihan berikutnya, greedy biasanya gagal dan DP biasanya berhasil.
Rumuskan keadaannya dalam satu kalimat sebelum menulis
kode. Kalimat itu harus berbentuk "dp[...] adalah
... dari ... pertama". Kalau kalimatnya belum bisa ditulis, kodenya
belum boleh ditulis. Hampir semua kesalahan DP lahir dari keadaan yang
tidak jelas, bukan dari salah ketik.
Hitung ukuran tabelnya sebelum membuatnya. Jumlah keadaan dikalikan biaya per keadaan adalah jumlah langkahnya, dan jumlah keadaan dikalikan ukuran tipenya adalah memorinya:
| Tabel | Keadaan | Memori (long long) |
|---|---|---|
dp[200000] |
2 × 10⁵ | 1,6 MB |
dp[1000][1000] |
10⁶ | 8 MB |
dp[100][10000] |
10⁶ | 8 MB |
dp[5000][5000] |
2,5 × 10⁷ | 200 MB — hampir selalu melewati batas |
Kalau tabelnya terlalu besar, sering kali hanya satu atau dua baris terakhir yang benar-benar diperlukan. Soal 2 di bawah adalah contohnya.
Periksa keadaan awal secara terpisah. Kesalahan
paling sering pada DP bukan pada rumus perulangannya, melainkan pada
nilai awal: dp[0] seharusnya 1 atau 0, batas atas
seharusnya "tidak mungkin" bukan nol, dan petak pertama pada grid
seharusnya diperiksa apakah terhalang.
Arah perulangan menentukan makna. Pada DP yang memakai satu array untuk menghemat memori, perulangan mundur berarti "setiap barang dipakai paling banyak sekali", sedangkan perulangan maju berarti "barang boleh dipakai berkali-kali". Dua soal yang berbeda, dua arah yang berbeda, kode yang hampir sama.
Uji DP dengan brute force. Untuk N ≤
15, jawaban brute force pasti benar. Menjalankan keduanya pada ratusan
masukan acak kecil menangkap kesalahan keadaan awal dan kesalahan batas
yang tidak terlihat dari contoh soal.
Kalau jawaban DP-mu meleset sedikit, periksa dulu tiga tempat ini
sebelum mengubah rumusnya: nilai dp paling awal, batas
perulangan (apakah mencakup elemen terakhir), dan apakah jawabannya
diambil dari keadaan yang benar — sering yang dicetak
dp[n-1] padahal seharusnya dp[n], atau
sebaliknya.
Ringkasan
- DP adalah brute force dikurangi pekerjaan yang terulang: setiap submasalah dihitung tepat sekali dan jawabannya disimpan.
- Syaratnya submasalah tumpang tindih dan substruktur optimal.
- Dua gaya: top-down (rekursi + ingatan) dan bottom-up (mengisi tabel). Keduanya menghitung hal yang sama.
- Lima pertanyaan yang dijawab di kertas: keadaan, kasus dasar, transisi, urutan pengisian, letak jawaban. Keadaan harus bisa ditulis sebagai satu kalimat lengkap.
- Jumlah langkah = banyaknya keadaan × biaya satu transisi; bandingkan dengan tabel batasan Bab 10 sebelum menulis kode.
- Tanda soal DP: menanyakan paling banyak/paling sedikit/berapa cara, setiap langkah berupa pilihan, dan sisa soal hanya bergantung pada sedikit informasi.
Latihan bergaya OSN
Ketiga soal berikut mewakili tiga bentuk DP yang paling sering muncul: menghitung banyaknya cara pada grid, memilih dengan pantangan, dan memeriksa apakah sebuah jumlah bisa dibentuk.
Kerangka ketiganya ada di
exercises/13/osn/.
Soal 1 — Jalur Grid Berhalangan
Sebuah denah berbentuk kotak-kotak berukuran R baris dan
C kolom. Sebagian petaknya bisa dilewati, sebagian
terhalang. Seekor semut berada di petak kiri atas dan ingin sampai ke
petak kanan bawah. Dari sebuah petak, ia hanya boleh melangkah ke kanan
atau ke bawah, dan tidak boleh menginjak petak yang terhalang.
Hitung ada berapa jalur berbeda yang bisa ditempuh.
Format masukan. Baris pertama berisi dua bilangan
bulat R dan C. R baris berikutnya
masing-masing berisi C karakter: . untuk petak
yang bisa dilewati dan # untuk petak terhalang.
Format keluaran. Satu baris berisi banyaknya jalur.
Kalau tidak ada jalur sama sekali, cetak 0.
Batasan. 1 ≤ R, C ≤
17.
Contoh masukan
3 3
...
.#.
...
Contoh keluaran
2
Penjelasan. Tanpa halangan ada enam jalur. Petak tengah yang terhalang memutus empat di antaranya, menyisakan dua: menyusuri tepi atas lalu tepi kanan, atau tepi kiri lalu tepi bawah.
Kerangkanya:
exercises/13/osn/01-jalur-grid.cpp
Soal 2 — Tanpa Bertetangga
Sepanjang satu sisi jalan berjajar N warung, dan warung
ke-i menghasilkan keuntungan Aᵢ bila disewa.
Pemiliknya melarang dua warung yang bersebelahan disewa oleh orang yang
sama.
Tentukan total keuntungan terbesar yang bisa diperoleh dengan menyewa sekumpulan warung yang tidak ada dua yang bersebelahan. Boleh juga tidak menyewa sama sekali.
Format masukan. Baris pertama berisi bilangan bulat
N. Baris kedua berisi N bilangan bulat
A₁ A₂ … A_N.
Format keluaran. Satu baris berisi total keuntungan terbesar.
Batasan.
- 1 ≤
N≤ 200.000 - 0 ≤
Aᵢ≤ 10⁹
Contoh masukan
6
3 2 7 10 12 5
Contoh keluaran
22
Penjelasan. Pilihan terbaik adalah warung ke-1, ke-3, dan ke-5: 3 + 7 + 12 = 22. Pilihan 3 + 10 + 5 hanya menghasilkan 18, dan 10 + 12 tidak boleh karena kedua warungnya bersebelahan.
Kerangkanya:
exercises/13/osn/02-tanpa-bertetangga.cpp
Soal 3 — Bagi Dua Sama Berat
Panitia punya N karung dengan berat yang diketahui, dan
dua truk yang identik. Seluruh karung harus dimuat, dan panitia ingin
kedua truk membawa berat yang sama persis. Karung tidak
boleh dibelah.
Tentukan apakah pembagian seperti itu mungkin.
Format masukan. Baris pertama berisi bilangan bulat
N. Baris kedua berisi N bilangan bulat, berat
setiap karung.
Format keluaran. Satu baris berisi YA
atau TIDAK.
Batasan.
- 1 ≤
N≤ 100 - 1 ≤ berat ≤ 100
Contoh masukan
4
1 5 11 5
Contoh keluaran
YA
Penjelasan. Truk pertama memuat karung 1, 2, dan 4
dengan total 1 + 5 + 5 = 11; truk kedua memuat karung 3 yang beratnya
11. Untuk masukan 1 2 5, jawabannya TIDAK
karena totalnya 8 tetapi tidak ada bagian yang berjumlah 4.
Kerangkanya:
exercises/13/osn/03-bagi-dua.cpp
Bingung atau sudah selesai? Buka halaman pembahasan PR.