Fibonacci adalah masalah DP paling sederhana yang menyentuh semua konsep inti: overlapping subproblems, optimal substructure, memoization, tabulation, dan optimasi ruang. Meski terlihat sepele, mempelajari Fibonacci secara mendalam memberi kita template mental untuk semua DP. Pada Chapter ini kita akan menelusuri empat versi implementasi — naif, memo, tabulation, dan space-optimized — sambil mengamati bagaimana setiap langkah memperbaiki kompleksitas.
Aturan: Recurrence Fibonacci #
Fibonacci didefinisikan sebagai:
fib(0) = 0,fib(1) = 1(base case)fib(n) = fib(n-1) + fib(n-2)untukn > 1
Ini adalah recurrence linier orde 2 — setiap nilai bergantung pada dua nilai sebelumnya. Inilah yang membuatnya ideal untuk dipelajari: cukup kompleks untuk menunjukkan masalah overlapping subproblems, namun cukup sederhana untuk dianalisis penuh.
Mekanisme: Empat Tahap Optimasi #
Tahap 1 — Rekursi naif: implementasi langsung dari recurrence. Tidak ada caching. Kompleksitas waktu O(φ^n) ≈ O(1.618^n), ruang O(n) untuk call stack.
Tahap 2 — Memoization (top-down): tambahkan cache. Setiap sub-masalah dihitung sekali. Kompleksitas waktu O(n), ruang O(n).
Tahap 3 — Tabulation (bottom-up): bangun tabel iteratif. Hilangkan overhead rekursi. Kompleksitas waktu O(n), ruang O(n).
Tahap 4 — Space-optimized: bila hanya butuh dp[i-1] dan dp[i-2], ganti array dengan dua variabel. Kompleksitas waktu O(n), ruang O(1).
Kode: Empat Versi Fibonacci #
#include <iostream>
#include <vector>
#include <chrono>
// Versi 1: rekursi naif
long long fib_naive(int n) {
if (n <= 0) return 0;
if (n == 1) return 1;
return fib_naive(n - 1) + fib_naive(n - 2);
}
// Versi 2: memoization (top-down)
long long fib_memo(int n, std::vector<long long>& memo) {
if (n <= 0) return 0;
if (n == 1) return 1;
if (memo[n] != -1) return memo[n];
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo);
return memo[n];
}
// Versi 3: tabulation (bottom-up)
long long fib_tabu(int n) {
if (n <= 0) return 0;
if (n == 1) return 1;
std::vector<long long> dp(n + 1);
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; ++i) dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
// Versi 4: space-optimized
long long fib_opt(int n) {
if (n <= 0) return 0;
if (n == 1) return 1;
long long prev2 = 0, prev1 = 1;
for (int i = 2; i <= n; ++i) {
long long cur = prev1 + prev2;
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
int main() {
// Bandingkan hasil untuk n kecil
int n = 40;
std::vector<long long> memo(n + 1, -1);
std::cout << "naif fib(" << n << ") = " << fib_naive(n) << '\n';
std::cout << "memo fib(" << n << ") = " << fib_memo(n, memo) << '\n';
std::cout << "tabu fib(" << n << ") = " << fib_tabu(n) << '\n';
std::cout << "opt fib(" << n << ") = " << fib_opt(n) << '\n';
// Versi cepat untuk n besar
std::cout << "opt fib(90) = " << fib_opt(90) << '\n';
return 0;
}Output yang Dihasilkan:
naif fib(40) = 102334155
memo fib(40) = 102334155
tabu fib(40) = 102334155
opt fib(40) = 102334155
opt fib(90) = 2880067194370816120- Versi 1 (
fib_naive): tidak ada cache. Memanggilfib(n-1)danfib(n-2)secara rekursif. Untukn=40, sekitar 331 juta panggilan — butuh detik. - Versi 2 (
fib_memo): cekmemo[n] != -1sebelum rekursi. Hanya2n-1panggilan rekursif unik. - Versi 3 (
fib_tabu): loop dari 2 hingga n, isidp[i] = dp[i-1] + dp[i-2]. Tidak ada rekursi sama sekali. - Versi 4 (
fib_opt): hanya simpanprev1danprev2. Setiap iterasi geser jendela. Memori total: 16 byte (dualong long).
Tabel Kompleksitas #
| Versi | Waktu | Ruang | Catatan |
|---|---|---|---|
| Naif | O(φ^n) | O(n) stack | Tidak praktis untuk n > 40 |
| Memoization | O(n) | O(n) | Lazy evaluation |
| Tabulation | O(n) | O(n) | Cache-friendly |
| Space-optimized | O(n) | O(1) | Optimal untuk Fibonacci |
Verifikasi Empirik: Jumlah Panggilan #
Mari kita ukur jumlah panggilan rekursif untuk naif vs memoization:
#include <iostream>
#include <vector>
long long g_naive_calls = 0;
long long g_memo_calls = 0;
long long fib_naive(int n) {
++g_naive_calls;
if (n <= 0) return 0;
if (n == 1) return 1;
return fib_naive(n - 1) + fib_naive(n - 2);
}
long long fib_memo(int n, std::vector<long long>& memo) {
++g_memo_calls;
if (n <= 0) return 0;
if (n == 1) return 1;
if (memo[n] != -1) return memo[n];
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo);
return memo[n];
}
int main() {
for (int n : {10, 20, 30, 40}) {
g_naive_calls = 0;
fib_naive(n);
std::vector<long long> memo(n + 1, -1);
g_memo_calls = 0;
fib_memo(n, memo);
std::cout << "n=" << n
<< " | naif: " << g_naive_calls << " panggilan"
<< " | memo: " << g_memo_calls << " panggilan"
<< " | rasio: " << (double)g_naive_calls / g_memo_calls
<< '\n';
}
return 0;
}Output yang Dihasilkan:
n=10 | naif: 177 panggilan | memo: 19 panggilan | rasio: 9.31579
n=20 | naif: 21891 panggilan | memo: 39 panggilan | rasio: 561.308
n=30 | naif: 2692537 panggilan | memo: 59 panggilan | rasio: 45636.2
n=40 | naif: 331160281 panggilan | memo: 79 panggilan | rasio: 4.1919e+06- Baris
++g_naive_calls;: counter global untuk setiap pemanggilan fungsi. Hanya untuk pengukuran, tidak untuk produksi. - Pola naif:
T(n) = T(n-1) + T(n-2) + 1≈φ^n. Rasio pertumbuhan ~φ ≈ 1.618per kenaikann. - Pola memo:
2n-1panggilan. Setiap state dihitung sekali, ditambah overhead traversal. - Rasio meledak: untuk
n=40, naif melakukan 4 juta kali lebih banyak panggilan dibanding memo. Inilah kekuatan mengeliminasi recomputation.
Formulasi Matematika vs DP #
Pengamat mungkin bertanya: kenapa tidak pakai rumus Binet?
fib(n) = (φ^n - (-φ)^(-n)) / √5Rumus ini memberi O(1) secara teoritis. Namun:
- Untuk
nbesar, presisidoubleataulong doubletidak cukup.fib(80)sudah kehilangan akurasi signifikan. - Komputasi
φ^nsecara presisi butuh big-number arithmetic, yang bukan O(1). - DP tetap menjadi pilihan terbaik untuk hasil eksak.
Generalisasi: Linear Recurrence #
Fibonacci adalah kasus khusus dari linear recurrence orde k:
a(n) = c1 * a(n-1) + c2 * a(n-2) + ... + ck * a(n-k)DP dengan sliding window kompleksitas O(n·k) waktu, O(k) ruang. Untuk k besar dan n sangat besar, ada optimasi matrix exponentiation O(k³ · log n) yang akan dibahas di bab lanjutan.
Ringkasan #
- Empat tahap optimasi Fibonacci: naif
O(φ^n)→ memoO(n)/O(n)→ tabulationO(n)/O(n)→ space-optimizedO(n)/O(1). - Memoization menambahkan cache tanpa mengubah struktur kode.
- Tabulation menghilangkan rekursi, lebih cepat secara empiris.
- Space optimization bekerja bila transisi hanya butuh beberapa nilai terakhir.
- Fibonacci adalah template mental untuk semua DP — kuasai ini sebelum lanjut ke masalah kompleks.