13. Dynamic programming — Pembahasan PR
Berkas .cpp jawabannya ada di
exercises/13/homework/solutions/.
Halaman ini dibuka sesudah mencoba sendiri. Bandingkan jalan pikiranmu dengan bagian Cara berpikir, bukan kodenya baris per baris: dua kode yang berbeda bentuk bisa sama-sama benar, dan yang perlu ditiru adalah cara sampai ke sana. Bagian Uji di tiap soal berisi masukan dan keluaran yang sudah diperiksa — jalankan jawabanmu sendiri dengan masukan itu sebelum merasa selesai.
Menjalankan salah satu jawabannya:
g++ -std=c++17 -O2 -o jawab exercises/13/homework/solutions/01-tangga.cpp
./jawab
Soal 1 — Naik tangga bertiga
Soal. Banyaknya cara mencapai anak tangga
ke-n dengan langkah 1, 2, atau 3.
Cara berpikir. Lima langkah:
- Keadaan.
dp[i]= banyaknya cara mencapai anak tangga ke-i. - Kasus dasar.
dp[0] = 1,dp[1] = 1,dp[2] = 2(yaitu1+1dan2). - Transisi.
dp[i] = dp[i-1] + dp[i-2] + dp[i-3]. - Urutan. Dari kecil ke besar.
- Jawaban.
dp[n].
Kasus dasar harus tiga buah, bukan dua: transisi untuk
i = 2 akan menyentuh dp[-1] kalau
dipaksakan.
Pembahasan.
dp[0] = 1;
if (n >= 1) dp[1] = 1;
if (n >= 2) dp[2] = 2;
for (int i = 3; i <= n; i++)
dp[i] = (dp[i - 1] + dp[i - 2] + dp[i - 3]) % MOD;
cout << dp[n] << "\n";Untuk n = 5 jawabannya 13.
Uji.
| Masukan | Keluaran | Catatan |
|---|---|---|
1 |
1 |
satu cara |
5 |
13 |
bisa dicek dengan tangan |
1000000 |
746580045 |
sudah dalam bentuk sisa modulo |
Baris terakhir sekaligus menguji dua hal: arraynya cukup besar untuk
n = 10⁶, dan % MOD diambil di
setiap penjumlahan, bukan hanya di akhir.
Kesalahan umum.
- Menulis
if (n >= 2) dp[2] = 2;tanpa penjagan >= 1dann >= 2, lalu membacadp[n]yang berisi sampah untuknkecil. - Lupa
% MODdi dalam perulangan, sehingga meluap walaupun jawabannya akhirnya di-modulo. - Memakai
intuntukdp. Penjumlahan tiga nilai hampir 10⁹ melewati batasintsebelum sempat di-modulo.
Soal 2 — Koin paling sedikit
Soal. Koin paling sedikit untuk mencapai
k, atau -1.
Cara berpikir. Keadaannya dp[v] = koin
paling sedikit untuk nilai v. Nilai awal BESAR
berarti "belum/tidak bisa dicapai". Transisinya mencoba setiap koin
sebagai koin terakhir yang diambil.
Kenapa memeriksa semua koin, bukan mengambil yang terbesar? Karena inilah soal yang membuat greedy gagal di Bab 12. DP mencoba semuanya, dan karena setiap nilai dihitung sekali, biayanya tetap murah.
Pembahasan.
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";Periksa: koin 1 3 4 dengan k = 6 memberi 2;
koin 2 4 dengan k = 7 memberi -1
karena semua koin genap.
Uji.
| Masukan | Keluaran | Catatan |
|---|---|---|
3 6 / 1 3 4 |
2 |
3 + 3; greedy menjawab 3 |
2 7 / 3 5 |
-1 |
7 tidak bisa dibentuk dari 3 dan 5 |
1 5 / 2 |
-1 |
nilai ganjil dari koin genap |
Dua baris terakhir menguji jalur "tidak mungkin". Yang membedakan
jawaban benar dan salah di sini adalah nilai penanda BESAR:
ia harus cukup besar untuk tidak pernah menang saat dibandingkan, tetapi
dp[v - c[i]] + 1 tidak boleh meluap karenanya.
Kesalahan umum.
- Memakai
BESARyang terlalu besar sehinggadp[v - c[i]] + 1meluap. Satu miliar aman untukint;2147483647tidak. - Mencetak
dp[k]tanpa memeriksa apakah nilainya masihBESAR.
Soal 3 — Banyaknya cara
Soal. Cacah cara membentuk k, urutan
tidak dibedakan.
Cara berpikir. Inilah satu-satunya soal DP di bab ini yang jawabannya bergantung pada urutan dua perulangan:
- koin di luar, nilai di dalam → mencacah kombinasi
(
1+3sama dengan3+1), - nilai di luar, koin di dalam → mencacah urutan
(
1+3berbeda dari3+1).
Soal ini meminta yang pertama. Bayangkan setiap koin ditawarkan sekali kepada seluruh nilai: karena koin 3 baru ditawarkan setelah koin 1 selesai, susunan yang memakai koin 1 sesudah koin 3 tidak pernah terbentuk.
Kasus dasarnya dp[0] = 1: ada tepat satu cara membentuk
nilai nol, yaitu tidak mengambil apa pun.
Pembahasan.
dp[0] = 1;
for (int i = 0; i < m; i++)
for (int v = c[i]; v <= k; v++)
dp[v] = (dp[v] + dp[v - c[i]]) % MOD;
cout << dp[k] << "\n";Untuk koin 1 3 4 dan k = 6 jawabannya 4:
1+1+1+1+1+1, 1+1+1+3, 3+3,
1+1+4.
Uji.
| Masukan | Keluaran | Susunannya |
|---|---|---|
3 6 / 1 3 4 |
4 |
1+1+1+1+1+1, 1+1+1+3, 1+1+4,
3+3 |
2 5 / 2 3 |
1 |
2 + 3 |
Kasus pertama adalah pemeriksaan bahwa urutan tidak
dianggap berbeda: 3 + 3 dan 1 + 1 + 4 dihitung
masing-masing sekali. Kalau jawabanmu jauh lebih besar, kemungkinan
besar urutan perulangannya terbalik — koin di dalam, nilai di luar —
yang menghitung 1 + 3 dan 3 + 1 sebagai dua
cara berbeda.
Kesalahan umum.
- Menukar urutan perulangan, lalu mendapat 9 alih-alih 4.
- Menulis
dp[0] = 0, sehingga seluruh jawaban menjadi nol.
Soal 4 — Knapsack 0/1
Soal. Nilai terbesar dengan batas berat
W.
Cara berpikir. Keadaan dua dimensi:
dp[i][b] = nilai terbesar memakai i barang
pertama dengan batas berat b. Untuk setiap barang hanya ada
dua pilihan — ambil atau tidak — dan DP mencoba keduanya.
Kata kunci "setiap barang paling banyak sekali" itulah yang membuat
baris i hanya boleh melihat baris i − 1. Kalau
ia melihat barisnya sendiri, barang yang sama bisa terambil berkali-kali
(dan itu justru rumus untuk soal koin yang boleh diulang).
Pembahasan.
for (int i = 1; i <= n; i++) {
for (int b = 0; b <= W; b++) {
dp[i][b] = dp[i - 1][b];
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];
}
}
cout << dp[n][W] << "\n";Pada contoh berkas PR, greedy menurut nilai per berat menjawab 10 sedangkan DP menjawab 14 — satu barang "efisien" seberat 6 kalah oleh dua barang seberat 5.
Uji.
| Masukan | Keluaran | Catatan |
|---|---|---|
1 1 / 5 10 |
0 |
satu-satunya barang tidak muat |
3 5 / 2 3 / 3 4 /
4 5 |
7 |
barang 1 dan 2 |
Kasus kedua sama dengan tabel penelusuran di Bab 13, jadi kamu bisa
membandingkan isi dp milikmu sel demi sel kalau jawabannya
meleset. Kasus pertama menguji cabang "tidak diambil": jawabannya
0, bukan nilai barangnya.
Kesalahan umum.
- Menyimpan data mulai dari indeks 0 tetapi menulis transisi dengan
dp[i-1], sehingga barang pertama terlewat. Pilih satu gaya penomoran dan konsisten. - Membuat array
dp[105][10005]sebagai variabel lokal di dalammain. Ukurannya sekitar 4 MB — terlalu besar untuk memori tumpukan. Letakkan sebagai variabel global.
Tantangan — Barisan naik terpanjang
Soal. Panjang barisan naik terpanjang,
n ≤ 5.000.
Cara berpikir. Keadaan yang tepat adalah
dp[i] = panjang barisan naik terpanjang yang
berakhir tepat di elemen ke-i. Kata
"berakhir tepat di" itu penting: tanpa itu, transisinya tidak bisa
ditulis, karena kita perlu tahu nilai elemen terakhir untuk memutuskan
apakah elemen berikutnya boleh disambung.
Transisinya: lihat semua j < i; kalau
a[j] < a[i], barisan yang berakhir di j
boleh dilanjutkan ke i.
Jawabannya bukan dp[n-1], melainkan nilai
terbesar di seluruh dp — karena barisan terpanjang
belum tentu berakhir di elemen terakhir.
Jumlah langkahnya O(n²) = 25 juta untuk n = 5.000.
Masuk. (Ada cara O(n log n) yang dipakai di soal berbatasan lebih besar,
tetapi itu di luar cakupan buku ini.)
Pembahasan.
int terpanjang = 0;
for (int i = 0; i < n; i++) {
dp[i] = 1;
for (int j = 0; j < i; j++)
if (a[j] < a[i] && dp[j] + 1 > dp[i]) dp[i] = dp[j] + 1;
if (dp[i] > terpanjang) terpanjang = dp[i];
}
cout << terpanjang << "\n";Untuk 3 1 4 1 5 9 2 6 jawabannya 4.
Uji.
| Masukan | Keluaran | Catatan |
|---|---|---|
1 / 7 |
1 |
satu elemen, panjangnya 1 |
5 / 5 4 3 2 1 |
1 |
menurun seluruhnya |
6 / 1 1 1 1 1 1 |
1 |
semua sama, dan "naik" berarti bertambah |
8 / 3 1 4 1 5 9 2 6 |
4 |
misalnya 1 4 5 9 |
Baris ketiga adalah kasus tepi yang paling sering salah: nilai yang
sama tidak boleh dianggap naik. Kalau syaratnya ditulis
a[j] <= a[i], jawabannya menjadi 6 dan seluruh subsoal
dengan data berulang ikut salah.
Kesalahan umum.
- Mencetak
dp[n-1]. Untuk1 2 3 0jawabannya 3, tetapidp[3] = 1. - Memakai
a[j] <= a[i], yang mengizinkan nilai sama dan mengubah soal menjadi "tidak menurun". - Lupa
dp[i] = 1sebagai nilai awal, sehingga elemen yang tidak bisa disambung berakhir bernilai nol.
Latihan bergaya OSN — pembahasan
Berkas jawabannya ada di
exercises/13/osn/solutions/.
Soal 1 — Jalur Grid Berhalangan
Keadaannya. dp[i][j] adalah banyaknya
jalur dari petak kiri atas sampai petak (i, j).
Cara berpikir. Sebuah petak hanya bisa dimasuki dari
atas atau dari kiri, dan kedua jalan itu tidak pernah menghasilkan jalur
yang sama. Jadi dp[i][j] = dp[i-1][j] + dp[i][j-1]. Petak
terhalang bernilai 0, karena tidak ada jalur yang boleh berakhir di
situ. Petak awal bernilai 1 — itu "jalur kosong" yang belum melangkah ke
mana-mana.
Perhatikan urutan pemeriksaannya di dalam kode: halangan diperiksa lebih dulu, baru petak awal. Kalau petak awal sendiri terhalang, jawabannya 0, dan urutan ini menanganinya tanpa cabang tambahan.
Karena setiap petak dihitung dari petak di atas dan di kirinya, perulangan harus berjalan dari baris atas ke bawah dan dari kolom kiri ke kanan.
Besarnya jawaban. Pada denah 17 × 17 tanpa halangan,
jumlah jalurnya 601.080.390. Masih muat di int, tetapi
tipis; long long menghilangkan keraguan.
Pembahasan.
vector<vector<long long>> dp(r, vector<long long>(c, 0));
for (int i = 0; i < r; i++) {
for (int j = 0; j < c; j++) {
if (peta[i][j] == '#') { dp[i][j] = 0; continue; }
if (i == 0 && j == 0) { dp[i][j] = 1; continue; }
long long total = 0;
if (i > 0) total += dp[i - 1][j];
if (j > 0) total += dp[i][j - 1];
dp[i][j] = total;
}
}
cout << dp[r - 1][c - 1] << "\n";Uji.
| Masukan | Keluaran | Yang diperiksa |
|---|---|---|
3 3 / ... / .#. /
... |
2 |
contoh soal |
2 2 / .. / .. |
2 |
denah terkecil |
2 2 / .# / #. |
0 |
tidak ada jalur |
17 17 tanpa halangan |
601080390 |
batas atas |
Kesalahan umum.
- Menetapkan
dp[0][j] = 1untuk seluruh baris pertama tanpa memeriksa halangan. Satu halangan di baris pertama seharusnya memutus seluruh sisa baris itu. - Membaca peta dengan
getlinesetelahcin >> r >> ctanpa membersihkan sisa baris, sehingga baris pertama peta terbaca kosong. Memakaicin >> peta[i]menghindari masalah ini seluruhnya. - Menjalankan perulangan dari kanan ke kiri, sehingga
dp[i][j-1]dibaca sebelum diisi.
Soal 2 — Tanpa Bertetangga
Keadaannya. dp[i] adalah keuntungan
terbesar yang bisa diperoleh dari i warung pertama.
Cara berpikir. Untuk warung ke-i hanya
ada dua kemungkinan, dan keduanya sudah mencakup semua kasus:
- warung itu tidak disewa, sehingga hasilnya sama
dengan
dp[i-1]; - warung itu disewa, sehingga tetangganya tidak boleh, dan hasilnya
dp[i-2] + A[i].
Ambil yang lebih besar. Keadaan awalnya dp[0] = 0 (tidak
ada warung) dan dp[1] = A[1].
Menghemat memori. Rumusnya hanya menyentuh dua nilai
terakhir, jadi seluruh array tidak perlu disimpan — cukup dua variabel
yang bergeser. Untuk N = 200.000 array
long long memakan 1,6 MB, yang sebenarnya masih aman;
tetapi kebiasaan ini menjadi penting saat tabelnya dua dimensi.
Pembahasan.
long long duaLalu = 0, satuLalu = 0;
for (int i = 0; i < n; i++) {
long long x;
cin >> x;
long long sekarang = satuLalu;
if (duaLalu + x > sekarang) sekarang = duaLalu + x;
duaLalu = satuLalu;
satuLalu = sekarang;
}
cout << satuLalu << "\n";Urutan tiga baris terakhir penting: duaLalu harus
menerima nilai satuLalu yang lama, bukan
yang baru.
Uji.
| Masukan | Keluaran | Yang diperiksa |
|---|---|---|
6 / 3 2 7 10 12 5 |
22 |
contoh soal |
1 / 5 |
5 |
satu warung |
2 / 4 9 |
9 |
dua warung, pilih yang besar |
3 / 0 0 0 |
0 |
semua nol |
Kesalahan umum.
- Mengambil warung berselang-seling secara greedy — ganjil semua atau
genap semua. Untuk
3 2 7 10 12 5cara itu menghasilkan 22 dan 17, jadi kebetulan benar; untuk2 1 1 2cara itu menghasilkan 3, padahal jawabannya 4. - Memakai
intuntuk total: paling banyak 10⁵ warung yang tidak bersebelahan bisa disewa, jadi totalnya bisa mencapai 10⁵ × 10⁹ = 10¹⁴. - Lupa bahwa jawabannya bisa berasal dari tidak menyewa apa pun, sehingga nilai awal diisi dengan angka negatif.
Soal 3 — Bagi Dua Sama Berat
Keadaannya. bisa[s] bernilai benar
kalau jumlah berat tepat s bisa dibentuk dari karung-karung
yang sudah diproses.
Cara berpikir. Dua truk berbobot sama berarti
masing-masing membawa setengah dari total. Kalau totalnya ganjil,
jawabannya langsung TIDAK — pemeriksaan satu baris yang
menghemat seluruh perhitungan.
Kalau genap, pertanyaannya menjadi: adakah sekumpulan karung yang
berjumlah tepat total / 2? Ini soal subset sum,
dan penyelesaiannya adalah tabel boolean sepanjang
total / 2 + 1. Mula-mula hanya jumlah 0 yang bisa dibentuk.
Setiap karung baru menambah kemungkinan: kalau s − w sudah
bisa dibentuk, maka s juga bisa.
Kenapa perulangan jumlahnya mundur. Kalau
s berjalan naik, nilai bisa[s - w] yang dibaca
mungkin baru saja diisi oleh karung yang sama pada
putaran ini, sehingga satu karung terpakai dua kali. Berjalan mundur
menjamin bisa[s - w] masih berisi keadaan sebelum karung
ini diproses. Ini perbedaan antara "setiap barang sekali" dan "barang
boleh diulang" yang disebut di bagian Tips.
Besarnya tabel. Total paling besar 100 × 100 = 10.000, jadi tabelnya paling panjang 5.001 dan seluruh perhitungannya 100 × 5.000 = 5 × 10⁵ langkah.
Pembahasan.
if (total % 2 != 0) { cout << "TIDAK\n"; return 0; }
int separuh = total / 2;
vector<bool> bisa(separuh + 1, false);
bisa[0] = true;
for (int i = 0; i < n; i++) {
for (int s = separuh; s >= w[i]; s--) {
if (bisa[s - w[i]]) bisa[s] = true;
}
}
cout << (bisa[separuh] ? "YA\n" : "TIDAK\n");Uji.
| Masukan | Keluaran | Yang diperiksa |
|---|---|---|
4 / 1 5 11 5 |
YA |
contoh soal |
3 / 1 2 5 |
TIDAK |
total genap, tetap tidak bisa |
2 / 3 3 |
YA |
dua karung sama berat |
1 / 4 |
TIDAK |
satu karung tidak bisa dibagi |
Kasus kedua penting: totalnya 8, jadi pemeriksaan ganjil-genap tidak menolongnya. Jawabannya harus datang dari tabelnya.
Kesalahan umum.
- Menjalankan perulangan jumlah dari kecil ke besar, sehingga satu
karung bisa dipakai berkali-kali dan
1 2 5dijawabYA. - Lupa
bisa[0] = true, sehingga seluruh tabel tetap salah. - Mengira total genap sudah cukup sebagai jawaban.
Kalau masih tersendat
- Tidak tahu apa keadaannya → Bab 13, langkah 1 dari lima langkah.
Tulis
dp[i]sebagai kalimat lengkap sebelum menulis kode. - Jawaban meleset sedikit → periksa kasus dasarnya;
dp[0] = 0dandp[0] = 1adalah dua soal yang berbeda. - Tidak yakin soal ini DP atau bukan → Bab 13, bagian Mengenali soal DP.
Kalau sebuah keadaan sudah bisa ditulis sebagai kalimat, sisanya biasanya mengikuti. Kalau belum bisa, menulis kode hanya akan memperpanjang kebingungan.