- Bahkan masalah dengan banyak kasus khusus seperti Advent of Code 2023 Day 12 pun bisa ditangani dengan pemrograman dinamis jika kita menemukan struktur yang berulang kali menyelesaikan submasalah yang sama
- Kuncinya adalah memecah masalah dengan rekursi, lalu mengurangi perhitungan berulang dengan memoisasi, dan memindahkannya ke perhitungan iteratif yang mengisi nilai yang diperlukan sesuai urutan dependensi
- Contoh Fibonacci menunjukkan bahwa rekursi naif berulang kali mengevaluasi
f(1), tetapi jika memakai cache, kita hanya perlu mengevaluasi n + 1 nilai darif(0)hinggaf(n) - Levenshtein distance dan Advent of Code Day 12 menunjukkan proses mengubah pemanggilan rekursif menjadi pengisian array dengan menggunakan indeks status seperti panjang string dan indeks aturan sebagai kunci cache
- Jika terbiasa dengan pemrograman dinamis, kita bukan hanya bisa meningkatkan performa, tetapi juga bisa melihat status antara dan hubungan dependensi dari algoritme, serta lebih mudah menemukan peluang optimasi memori
Namanya membingungkan, tetapi idenya sederhana
- Nama “dynamic programming” tidak berhubungan langsung dengan makna modern seperti “gaya pemrograman” atau “dynamic typing”
- Intinya adalah pendekatan perancangan algoritme yang membagi masalah menjadi masalah serupa yang lebih kecil, lalu menggunakan kembali hasilnya
- Ada catatan editorial yang menambahkan bahwa istilah ini masuk akal jika dilihat dari makna historis “programming”
- Titik awalnya biasanya berupa bentuk yang memecah masalah menjadi masalah kecil, seperti fungsi rekursif
- Jika submasalah yang sama muncul berkali-kali, maka secara alami diperlukan caching untuk menyimpan hasil perhitungan dan memakainya kembali
Caching dan iterasi lewat Fibonacci
- Fungsi Fibonacci didefinisikan sebagai
f(n) = f(n - 1) + f(n - 2), dan implementasi rekursif naif menghitung nilai yang sama berulang kali f(1)adalah nilai yang benar-benar dijumlahkan ke hasil akhir, sehingga makin besarf(n), makin cepat pula jumlah evaluasi pada rekursi naif meningkat- Jika hasilnya di-cache atau dimemoisasi, kita tidak perlu lagi menghitung ulang
f(4),f(3), danf(2)yang sudah pernah dihitung - Dengan cara ini, kita hanya mengevaluasi total 7 nilai dari
f(0)sampaif(6), dan secara umum berkurang menjadi n + 1 evaluasi - Selangkah lebih jauh, jika kita mengisi nilai yang diperlukan secara berurutan mulai dari
f(0)danf(1), pemanggilan rekursif pun hilangF[2] = F[1] + F[0]F[3] = F[2] + F[1]- Dengan cara yang sama, hitung hingga
F[6] = 8
- Pada Fibonacci, kita bahkan tidak memerlukan seluruh array; cukup simpan nilai sebelumnya dan nilai sebelum itu
- Alur ini menunjukkan jalur sistematis dari definisi matematis menuju implementasi iteratif
Meluas ke contoh edit distance
- Edit distance antara dua string adalah jumlah minimum operasi edit yang diperlukan untuk mengubah satu string menjadi string lain
- Masalahnya berubah tergantung jenis edit yang diizinkan
- Jika hanya substitusi karakter yang diizinkan: Hamming distance
- Jika penyisipan dan penghapusan juga diizinkan: Levenshtein distance
- Levenshtein distance dapat dibagi menjadi masalah yang lebih kecil berdasarkan karakter terakhir dari dua string
A,B- Jika karakter terakhir sama, abaikan kedua karakter itu dan gunakan distance untuk sisa string
- Jika karakter terakhir berbeda, pilih biaya minimum di antara substitusi, penghapusan, dan penyisipan
- Jika
Akosong, semua karakterBharus disisipkan, sehingga biayanyab - Jika
Bkosong, semua karakterAharus dihapus, sehingga biayanyaa
- Jika definisi ini langsung dipindahkan ke rekursi Python, performanya akan sangat lambat untuk string panjang atau string dengan banyak perbedaan
- Jika Fibonacci berkembang kira-kira menjadi dua cabang di tiap tingkat pohon pemanggilan, rekursi ini dalam beberapa kasus berkembang menjadi tiga cabang
- Dengan menambahkan
functools.cachedi Python, hasil perhitungan untuk kombinasi substring yang sama bisa digunakan kembali - Implementasi yang lebih baik tidak terus-menerus membuat string baru, melainkan hanya meneruskan string asli
A,Bserta panjang substringa,b - Pada tahap akhir, kita membuat sendiri array
cachedua dimensi, lalu mengisinya berurutan agarcache[a][b] = levenstein(A[:a], B[:b]) - Versi iteratif akan menelusuri
adanbdari 0 hingga panjang string, sambil merujuk ke nilai pada baris sebelumnya dan kolom sebelumnya yang sudah terisi
Penerapan pada Advent of Code 2023 Day 12
- Soal Advent of Code 12 Desember 2023 adalah masalah menyelesaikan nonogram satu dimensi
- Contoh input berbentuk
.??..??...?##. 1,1,3, dan?bisa menjadi.atau# - Pendekatan brute force memakai backtracking, tetapi jika ada
ntanda tanya, kita harus mengevaluasi 2^n kandidat, sehingga ukurannya tumbuh eksponensial - Muncul struktur di mana submasalah yang sama berulang
..#..??...?##. (1),1,3.#...??...?##. (1),1,3- Jika bagian depan yang sudah diproses dibuang, masing-masing menjadi masalah yang hampir sama seperti
.??...?##. 1,3dan..??...?##. 1,3
- Fungsi backtracking dasar menerima
conditionsdanrules, lalu menghitung jumlah susunan yang mungkin- Jika tidak ada aturan tersisa, periksa apakah masih ada
#pada kondisi yang tersisa - Jika tidak ada kondisi tersisa, periksa apakah masih ada aturan tersisa
- Jika karakter saat ini adalah
.atau?, lanjutkan dengan melewati satu posisi - Jika karakter saat ini adalah
#atau?, periksa ukuran aturan berikutnya dan kondisi pemisah, lalu pindah ke status berikutnya
- Jika tidak ada aturan tersisa, periksa apakah masih ada
- Di Python, memoisasi bisa diterapkan hanya dengan menambahkan
@cache - Untuk mengubahnya menjadi pemrograman dinamis, kita tidak lagi mengoper potongan string dan aturan, melainkan memakai offset string
idan offset aturanjsebagai status - Setelah itu, kita membuat
cache[i][j]secara langsung, dan mengganti rekursi dengan perhitungan iteratif menggunakan cara mengisi indeks dalam urutan terbalik - Contoh implementasi Rust disediakan melalui tautan Rust implementation di artikel
Apa yang terlihat saat cache diisi langsung
- Versi pemrograman dinamis untuk Advent of Code Day 12 bisa tampak lebih lambat daripada versi memoization
- Perbedaan ini mungkin disebabkan oleh implementasi Python yang belum dioptimalkan
- Saat menyusun cache secara langsung, kita bisa lebih jelas melihat nilai mana yang benar-benar diperlukan
- Pada masalah Day 12, versi pemrograman dinamis memperlihatkan bahwa hanya kolom sebelumnya yang diperlukan
- Karena itu, array dua dimensi dapat diganti menjadi dua array satu dimensi yang mewakili kolom sebelumnya dan kolom saat ini
Masalah latihan dan kesimpulan
- Pemrograman dinamis memang tidak sepele, tetapi juga bukan teknik yang mustahil diakses oleh kebanyakan programmer
- Jika memahami cara membagi masalah menjadi masalah-masalah kecil, dalam banyak situasi memoization saja sudah bisa jauh lebih baik daripada implementasi naif
- Jika makin mahir, kita bisa memahami satu keluarga algoritme, menangkap trade-off dengan lebih baik, dan menemukan optimasi tambahan
- Beberapa masalah berikut disarankan untuk latihan
- Setelah mengimplementasikannya, jangan lupa benchmarking dan profiling
1 komentar
Opini Hacker News
Saya suka bahwa tulisan ini menyoroti bahwa algoritma dynamic programming pada dasarnya hanyalah cara cerdas untuk melakukan cache pada rekursi. Berdasarkan pengalaman saya, menemukan solusi rekursif terlebih dahulu adalah titik awal terbaik untuk menemukan solusi dynamic programming, dan begitu menemukannya, memoization mudah dilakukan serta bisa memberi peningkatan kecepatan yang besar
Kadang bahkan bisa lebih cepat daripada dynamic programming bottom-up, karena hanya menghitung solusi yang benar-benar diperlukan. Intinya, tidak masalah jika pohon pemanggilan memiliki banyak submasalah, tetapi jumlah submasalah yang berbeda harus relatif sedikit. Tidak ada alasan untuk melakukan cache pada hasil yang hanya dibutuhkan sekali, dan kesulitannya ada pada membagi masalah awal menjadi jumlah submasalah berbeda yang cukup sedikit
Secara praktis, karena tail call elimination tidak selalu diterapkan, memang masuk akal melakukannya seperti itu, tetapi saya merasa akan lebih baik jika lebih dulu mempelajarinya dari sudut pandang yang lebih intuitif: cache rekursif top-down
Misalnya, melihat seri “Best Time to Buy and Sell Stock” di LeetCode, untuk soal seperti https://leetcode.com/problems/best-time-to-buy-and-sell-stoc... rasanya pendekatan mengisi array jauh lebih alami. Saya belum pernah menyelesaikannya dengan rekursi, dan juga tidak yakin apakah ada solusi rekursif yang alami
Tautan di atas adalah III, tetapi bagi yang baru pertama mencoba, mulai dari soal pertama https://leetcode.com/problems/best-time-to-buy-and-sell-stoc... bagus sebagai pengantar dynamic programming
Asal-usul nama “dynamic programming” berasal dari penemunya, Richard Bellman. Pada 1950 di RAND, ia sedang mencari nama untuk proses pengambilan keputusan bertahap, dan konon Menteri Pertahanan saat itu, Wilson, sangat membenci kata “research” secara patologis, sementara kata “mathematics” bahkan lebih harus dihindari
Bellman membutuhkan nama yang bisa menyembunyikan dari Wilson dan Angkatan Udara bahwa di dalam RAND ia sebenarnya sedang mengerjakan matematika. Jadi ia memilih “programming” karena membahas perencanaan, pengambilan keputusan, dan pemikiran, sementara “planning” tidak cocok karena berbagai alasan; lalu menambahkan “dynamic”, yang punya makna tepat dalam fisika klasik, untuk memuat konsep bertahap dan perubahan terhadap waktu
Ia juga menyukai fakta bahwa “dynamic” sebagai kata sifat sulit dipakai dengan makna negatif, dan karena nama itu juga sulit ditentang oleh anggota parlemen, ia memakai dynamic programming sebagai nama yang mencakup aktivitasnya
Sumber: https://alliance.seas.upenn.edu/~cis520/dynamic/2021/wiki/in...
Saya suka cara tulisan ini pertama-tama mengungkap masalah secara rekursif, lalu menambahkan caching secara bertahap, dan pada akhirnya mengurangi ukuran cache sebatas yang diperlukan
Saya sering mencoba langsung menuju solusi dynamic programming lalu mentok, atau memaksakan terlalu banyak usaha agar bisa berjalan. Ke depannya saya berniat memaksa diri mengikuti tahapannya secara berurutan
Salah satu penerapan keren dari dynamic programming adalah penyelarasan berpasangan pada sekuens nukleotida/protein
https://en.wikipedia.org/wiki/Sequence_alignment
https://en.wikipedia.org/wiki/Needleman%E2%80%93Wunsch_algor...
https://en.wikipedia.org/wiki/Smith%E2%80%93Waterman_algorit...
Ada seorang profesor algoritma yang sangat bagus, dan beliau lulusan UCLA. Kelas dynamic programming-nya luar biasa: pertama dimulai dari masalah yang solusi sederhananya punya kompleksitas waktu eksponensial, lalu masalahnya dipecah menjadi masalah-masalah yang lebih kecil untuk menurunkan kompleksitas ke tingkat polinomial, kemudian menerapkan memoization sehingga turun lagi menjadi linear
Andai saja bisa ingat masalah apa yang dipakai waktu itu
Semuanya adalah contoh klasik yang solusi naifnya tidak efisien dan dapat ditingkatkan secara besar dengan dynamic programming
Untuk contoh lebih banyak, lihat https://en.wikipedia.org/wiki/Dynamic_programming#Algorithms...
Setahu saya, jika ditambahkan constraint khusus seperti “dua mata kuliah ini harus diambil bersamaan”, masalahnya menjadi jauh lebih kompleks dan lebih sulit ditangani dibanding dynamic programming biasa
Sepertinya situs asli tidak sanggup menahan traffic, jadi saya tinggalkan link arsip
https://web.archive.org/web/20240114111200/https://qsantos.f...
Berkat dynamic programming, jumlah posisi Go yang legal dapat dihitung, dan nilainya adalah angka 171 digit
Cara naif membutuhkan waktu 3^(n^2) karena memeriksa semua kemungkinan posisi pada papan Go n×n, tetapi dynamic programming pada dasarnya menghilangkan satu dimensi sehingga kompleksitas waktunya turun menjadi O(n^5 * 5.4^n), dan kompleksitas ruangnya menjadi O(n * 5.4^n)
https://tromp.github.io/go/legal.html
https://tromp.github.io/go/gostate.pdf
Nama “Dynamic Programming” bisa terasa janggal karena programming di sini bukan berarti bidang pemrograman. Dalam kasus ini, maknanya lebih dekat ke optimisasi, mirip seperti linear programming
Dynamic programming dapat dilihat sebagai metode untuk menyelesaikan masalah pengambilan keputusan dalam waktu diskret, yaitu memilih urutan optimal {a_t} yang memaksimalkan \sum_t u_t(a_t) di bawah constraint. Caranya adalah mendefinisikan fungsi nilai V* sebagai V*(t) = max_{a_t}{ u_t(a_t) + V*(t-1) }, sehingga dimensi masalah optimisasi berkurang drastis
Saat mendengar “pemrograman dinamis”, apakah salah jika menganggapnya sekadar memoization? Bagian yang terlewat mungkin adalah cara memecah masalah secara cerdas agar memoization bisa dipakai
Pemrograman dinamis lebih dekat dengan memoization yang sistematis. Kita menyelesaikan submasalah yang makin besar hingga mencapai solusi untuk masalah keseluruhan. Istilah “algoritma induksi” juga cukup cocok, karena algoritma pemrograman dinamis yang tipikal pada dasarnya mirip dengan pembuktian induksi matematika. Sayangnya, istilah itu sudah punya makna lain
Setelah itu, kita melihat bahwa rekursi dan memoization punya overhead; jika tabel dibangun dari bawah ke atas dan pemanggilan rekursif dihilangkan, jadilah pemrograman dinamis
Langkah 3 adalah bagian yang paling khas dari pemrograman dinamis, tetapi menurut saya berhenti di langkah 2 pun masih bisa disebut pemrograman dinamis. Hanya saja tidak seefisien yang mungkin dicapai. Dengan kata lain, memoization adalah caching, dan langkah 3 adalah menanyakan apakah ada cara untuk mengisi cache itu terlebih dahulu
Secara umum, jika submasalah banyak bertumpang tindih, dan submasalah optimal harus menjadi bagian dari solusi optimal keseluruhan, maka ada peluang untuk memakai pemrograman dinamis. Mengatakan bahwa memoization saja adalah pemrograman dinamis mirip seperti mengatakan bahwa hanya hash table yang merupakan tipe data abstrak
Memoization pada dasarnya adalah strategi untuk membuat algoritma lebih cepat
Menyelesaikan Advent of Code tahun ini terasa menyenangkan. Jelas bahwa hari ke-1, terutama bagian 2, jauh lebih sulit daripada tahun-tahun sebelumnya, dan saya juga menulis tentang itu di https://blog.singleton.io/posts/2024-01-02-advent-of-code-20..., tetapi hal itu tidak tampak jelas hanya dengan membandingkan statistik 2022 saat ini dan statistik 2023 saat ini. Sebab orang-orang punya waktu satu tahun lebih lama untuk menyelesaikan puzzle 2022
Ketika saya mengambil statistik 2022 per 14 Januari 2023 https://web.archive.org/web/20230114172513/https://adventofc..., perbedaannya cukup besar. Jika menggambar statistik penyelesaian bagian 2 https://blog.singleton.io/static/imgs-aoc23/completion.png, ukuran kelompok awal pada hari ke-1 mirip, tetapi 2023 jelas terlihat lebih sulit daripada 2022 hingga hari ke-15
Rasio orang yang menyelesaikan bagian 1 tetapi tidak menyelesaikan bagian 2 https://blog.singleton.io/static/imgs-aoc23/ratios.png juga jauh lebih tinggi pada banyak tanggal di 2023, dan terutama mengisyaratkan bahwa hari ke-5, hari ke-10, hari ke-12, serta hari ke-22 bagian 2 sulit
Namun saya terkejut melihat betapa sulitnya hari ke-5 bagian 2. Saya memang berhasil menyelesaikannya tanpa menyerah, tetapi sempat merasa mungkin saya melewatkan sesuatu yang jelas sehingga menyelesaikannya dengan cara yang terlalu rumit; jadi saya lega setelah tahu bahwa memang itu soal yang cukup menantang
Contoh yang diberikan adalah
two1nine,eightwothree,abcone2threexyz,xtwone3four,4nineeightseven2,zoneight234,7pqrstsixteen, tetapi contoh penting sepertioneighttidak ada. Tanpa contoh seperti ini, sulit mengetahui secara tepat bagaimana nilai harus digantiPada 2022, selama beberapa hari pertama kebanyakan orang terus ikut, tingkat retensi pada banyak hari melampaui 80%, dan hampir semua orang menyelesaikan kedua bagian. Sebaliknya, pada hari ke-1 2023, dari orang yang menyelesaikan bagian 1, hanya 76% yang menyelesaikan sampai bagian 2, dan banyak orang menyerah pada hari ke-3 dan hari ke-5
Menariknya, beberapa hari terakhir tidak serendah itu, yang bisa dijelaskan oleh fakta bahwa Advent of Code 2023 lebih baru daripada 2022. Interpretasi saya, kelompok ini adalah orang-orang yang akan menembus semua tantangan sampai batas tertentu terlepas dari tingkat kesulitannya, sementara banyak orang lain berhenti jika merasa itu memakan terlalu banyak waktu