Önceki derste böl ve fethetin bir sınırını gördün: Alt problemler örtüşüyorsa — yani aynı alt problem tekrar tekrar karşımıza çıkıyorsa — özyineleme aynı işi defalarca yapar ve süre üstel hâle gelir. Naif Fibonacci hesabı bunun klasik örneğiydi: F(40) hesaplamak yüz milyondan fazla çağrı gerektirir, oysa yalnızca 40 farklı değer vardır.
Dinamik programlama (DP) bu israfa çok basit bir çözüm getirir: Hesapladığın sonucu sakla, tekrar ihtiyaç duyduğunda yeniden hesaplamak yerine hafızadan oku. Bu kadar. Adı kulağa karmaşık gelir ama fikir bir cümleyle özetlenebilir. (İsim de yanıltıcıdır; Richard Bellman 1950'lerde, çalıştığı kurumda "araştırma" kelimesinden hoşlanmayan bir yöneticiyi memnun etmek için bu kulağa etkileyici gelen adı seçtiğini anlatır.)
Bu derste DP'nin iki temel yazım biçimini öğreneceksin: yukarıdan aşağıya memoization ve aşağıdan yukarıya tabulation. Ama asıl öğrenmen gereken şey kod değil, düşünme biçimidir: Durumu nasıl tanımlarsın, geçişi nasıl yazarsın, temel durumu nasıl belirlersin. Bu üç soruyu yanıtlayabildiğin her problem bir DP problemidir.
Örtüşen alt problemler
Sorunun kaynağını görelim. Fibonacci'nin özyineleme ağacına bak:
F(5)
╱ ╲
F(4) F(3)
╱ ╲ ╱ ╲
F(3) F(2) F(2) F(1)
╱ ╲ ╱ ╲ ╱ ╲
F(2) F(1) F(1) F(0) F(1) F(0)
╱ ╲
F(1) F(0)
F(3) iki kez, F(2) üç kez, F(1) beş kez hesaplandı!
n büyüdükçe bu tekrar sayısı ÜSTEL olarak artıyor.Aşağıdaki görselleştirmede farklı n değerleri için özyineleme ağacını inceleyebilirsin; aynı düğümlerin kaç kez tekrarlandığına dikkat et:
Bir problemin DP ile çözülebilmesi için iki özellik gerekir:
Örtüşen alt problemler. Aynı alt problem birden fazla kez çözülüyor olmalı. Örtüşme yoksa (birleştirmeli sıralamada olduğu gibi) saklamanın bir faydası olmaz.
Optimal alt yapı. Problemin en iyi çözümü, alt problemlerin en iyi çözümlerinden kurulabilmelidir. Bu, DP'nin açgözlü yaklaşımla paylaştığı özelliktir.
Memoization: yukarıdan aşağıya
En kolay geçiş yolu budur: Özyinelemeli çözümünü yaz, sonra bir önbellek ekle. Fonksiyonun mantığı hiç değişmez.
package main
import "fmt"
var naiveCalls, memoCalls int
// fibNaive: O(2^n) — örtüşen alt problemler yüzünden felaket
func fibNaive(n int) int {
naiveCalls++
if n <= 1 {
return n
}
return fibNaive(n-1) + fibNaive(n-2)
}
// fibMemo: O(n) — sonuçlar saklanır
func fibMemo(n int, memo map[int]int) int {
memoCalls++
if n <= 1 {
return n
}
if v, ok := memo[n]; ok {
return v // önbellekten oku: iş yok
}
result := fibMemo(n-1, memo) + fibMemo(n-2, memo)
memo[n] = result
return result
}
// fibMemoSlice: harita yerine dilim — daha hızlı
func fibMemoSlice(n int) int {
memo := make([]int, n+1)
for i := range memo {
memo[i] = -1
}
var solve func(int) int
solve = func(k int) int {
if k <= 1 {
return k
}
if memo[k] != -1 {
return memo[k]
}
memo[k] = solve(k-1) + solve(k-2)
return memo[k]
}
return solve(n)
}
func main() {
fmt.Printf("%6s %14s %16s %14s %14s\n", "n", "sonuç", "naif çağrı", "memo çağrı", "oran")
for _, n := range []int{5, 10, 20, 25, 30} {
naiveCalls, memoCalls = 0, 0
v1 := fibNaive(n)
v2 := fibMemo(n, map[int]int{})
fmt.Printf("%6d %14d %16d %14d %14.0f\n",
n, v1, naiveCalls, memoCalls, float64(naiveCalls)/float64(memoCalls))
_ = v2
}
fmt.Println()
fmt.Println("memoization ile büyük değerler anında hesaplanır:")
for _, n := range []int{50, 80, 90} {
fmt.Printf(" F(%d) = %d\n", n, fibMemoSlice(n))
}
fmt.Println()
fmt.Println("naif sürümde F(90) hesaplamak evrenin yaşından uzun sürerdi.")
}n sonuç naif çağrı memo çağrı oran
5 5 15 9 2
10 55 177 19 9
20 6765 21891 39 561
25 75025 242785 49 4955
30 832040 2692537 59 45636
memoization ile büyük değerler anında hesaplanır:
F(50) = 12586269025
F(80) = 23416728348467685
F(90) = 2880067194370816120
naif sürümde F(90) hesaplamak evrenin yaşından uzun sürerdi.Memoization'ın güzelliği, var olan çözümü bozmamasıdır. Özyinelemeli mantık aynı kalır; yalnızca başına bir kontrol, sonuna bir kayıt eklenir. Bu yüzden DP'ye yeni başlarken önce özyinelemeli çözümü yazıp sonra memoize etmek en güvenli yoldur.
Dikkat edilecek nokta, önbellek anahtarının durumu tam olarak temsil etmesidir. Fonksiyon iki parametre alıyorsa anahtar da iki parçalı olmalıdır; aksi hâlde farklı durumların sonuçları birbirine karışır.
Tabulation: aşağıdan yukarıya
İkinci yaklaşım özyinelemeyi tamamen ortadan kaldırır: Temel durumlardan başla, tabloyu sırayla doldur, sonuca ulaş.
memoization (yukarıdan aşağıya): tabulation (aşağıdan yukarıya):
F(5) → F(4) → F(3) → F(2) → F(1) F(0)=0, F(1)=1
↑______|______|______| ↓
(geri dönerken doldurulur) F(2)=1, F(3)=2, F(4)=3, F(5)=5
(ileriye doğru doldurulur)package main
import "fmt"
// fibTable: klasik tabulation — O(n) zaman, O(n) bellek
func fibTable(n int) ([]int, int) {
if n <= 1 {
return []int{0, 1}[:n+1], n
}
dp := make([]int, n+1)
dp[0], dp[1] = 0, 1
for i := 2; i <= n; i++ {
dp[i] = dp[i-1] + dp[i-2]
}
return dp, dp[n]
}
// fibOptimized: alan optimizasyonu — O(1) bellek
// Yalnızca son iki değere ihtiyaç var, tüm tabloyu tutmaya gerek yok
func fibOptimized(n int) int {
if n <= 1 {
return n
}
prev2, prev1 := 0, 1
for range n - 1 {
prev2, prev1 = prev1, prev1+prev2
}
return prev1
}
func main() {
table, result := fibTable(10)
fmt.Println("DP tablosu:", table)
fmt.Println("F(10) =", result)
fmt.Println()
fmt.Printf("%6s %12s %12s %8s\n", "n", "tablolu", "optimize", "aynı")
for _, n := range []int{0, 1, 5, 10, 30, 50, 80} {
_, a := fibTable(n)
b := fibOptimized(n)
fmt.Printf("%6d %12d %12d %8t\n", n, a, b, a == b)
}
fmt.Println()
fmt.Println("Bellek kullanımı:")
fmt.Println(" tablolu: O(n) — n=1.000.000 için 8 MB")
fmt.Println(" optimize: O(1) — her n için 16 bayt")
}DP tablosu: [0 1 1 2 3 5 8 13 21 34 55]
F(10) = 55
n tablolu optimize aynı
0 0 0 true
1 1 1 true
5 5 5 true
10 55 55 true
30 832040 832040 true
50 12586269025 12586269025 true
80 23416728348467685 23416728348467685 true
Bellek kullanımı:
tablolu: O(n) — n=1.000.000 için 8 MB
optimize: O(1) — her n için 16 baytİki yaklaşım arasındaki seçim genelde tercih meselesidir, ama belirgin farkları vardır:
Pratikte şu yol iyi çalışır: Problemi memoization ile çöz (düşünmesi kolay), sonra gerekiyorsa tabulation'a çevir (daha hızlı ve alan optimizasyonuna açık).
Durum, geçiş ve temel durum
DP problemlerini çözmenin anahtarı koddan değil, üç sorudan geçer.
Durum nedir? Alt problemi tam olarak tanımlayan en küçük bilgi kümesi. "Bu noktaya kadar ne yaptım?" sorusunun cevabı. Fibonacci'de tek bir sayı (n) yeterlidir; bazı problemlerde iki, üç ya da daha fazla boyut gerekir.
Geçiş nedir? Bir durumun cevabı, daha küçük durumların cevaplarından nasıl hesaplanır? Bu, DP'nin kalbindeki yineleme bağıntısıdır.
Temel durum nedir? En küçük, doğrudan cevaplanabilen durumlar hangileridir?
Merdiven problemi: n basamaklı merdiveni 1 veya 2 adımla kaç farklı şekilde çıkarsın?
DURUM: dp[i] = i. basamağa kaç farklı yolla ulaşılır
GEÇİŞ: dp[i] = dp[i-1] + dp[i-2]
(i'ye ya (i-1)'den 1 adımla ya (i-2)'den 2 adımla gelinir)
TEMEL DURUM: dp[0] = 1 (hiç adım atmamak bir yoldur)
dp[1] = 1
n=4 için: dp = [1, 1, 2, 3, 5] → 5 farklı yolpackage main
import (
"fmt"
"slices"
)
// climbStairs: 1 veya 2 adımla — Fibonacci ile aynı bağıntı
func climbStairs(n int) int {
if n <= 1 {
return 1
}
prev2, prev1 := 1, 1
for range n - 1 {
prev2, prev1 = prev1, prev1+prev2
}
return prev1
}
// climbStairsSteps: izin verilen adım kümesi genelleştirildi
func climbStairsSteps(n int, steps []int) ([]int, int) {
dp := make([]int, n+1)
dp[0] = 1 // temel durum: hiç adım atmamak bir yoldur
for i := 1; i <= n; i++ {
for _, s := range steps {
if i-s >= 0 {
dp[i] += dp[i-s]
}
}
}
return dp, dp[n]
}
// minCostClimb: her basamağın bir maliyeti var, en az maliyetle çık
func minCostClimb(cost []int) ([]int, int) {
n := len(cost)
if n == 0 {
return nil, 0
}
dp := make([]int, n+1)
// dp[i] = i. basamağa ulaşmanın en az maliyeti
for i := 2; i <= n; i++ {
dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
}
return dp, dp[n]
}
// waysWithPath: yolları da göster (küçük n için)
func waysWithPath(n int, steps []int) [][]int {
var paths [][]int
var walk func(remaining int, path []int)
walk = func(remaining int, path []int) {
if remaining == 0 {
paths = append(paths, slices.Clone(path))
return
}
for _, s := range steps {
if s <= remaining {
walk(remaining-s, append(path, s))
}
}
}
walk(n, nil)
return paths
}
func main() {
fmt.Println("1 veya 2 adımla merdiven çıkma:")
for n := 0; n <= 8; n++ {
fmt.Printf(" n=%d → %d yol\n", n, climbStairs(n))
}
fmt.Println(" (Fibonacci dizisiyle aynı!)")
fmt.Println()
dp, ways := climbStairsSteps(6, []int{1, 2, 3})
fmt.Println("1, 2 veya 3 adımla n=6:")
fmt.Println(" DP tablosu:", dp)
fmt.Println(" toplam yol:", ways)
fmt.Println()
fmt.Println("n=4 için tüm yollar (adımlar 1,2):")
for _, p := range waysWithPath(4, []int{1, 2}) {
fmt.Println(" ", p)
}
fmt.Println()
costs := []int{10, 15, 20, 5, 30, 8}
costDP, minCost := minCostClimb(costs)
fmt.Println("basamak maliyetleri:", costs)
fmt.Println("maliyet DP tablosu:", costDP)
fmt.Println("en az maliyet:", minCost)
}1 veya 2 adımla merdiven çıkma: n=0 → 1 yol n=1 → 1 yol n=2 → 2 yol n=3 → 3 yol n=4 → 5 yol n=5 → 8 yol n=6 → 13 yol n=7 → 21 yol n=8 → 34 yol (Fibonacci dizisiyle aynı!) 1, 2 veya 3 adımla n=6: DP tablosu: [1 1 2 4 7 13 24] toplam yol: 24 n=4 için tüm yollar (adımlar 1,2): [1 1 1 1] [1 1 2] [1 2 1] [2 1 1] [2 2] basamak maliyetleri: [10 15 20 5 30 8] maliyet DP tablosu: [0 0 10 15 20 20 28] en az maliyet: 28
Merdiven probleminin Fibonacci ile aynı bağıntıya sahip olması tesadüf değildir: İki problem yapısal olarak aynıdır. DP'de sık rastlanan bir deneyimdir bu — farklı görünen problemler aynı yineleme bağıntısına indirgenir. Bu yüzden birkaç temel kalıbı öğrenmek, çok sayıda problemi çözmeni sağlar.
Minimum bozuk para
Açgözlü dersinde bu problemde açgözlü yaklaşımın başarısız olduğunu gördün. Şimdi doğru çözümü yazalım.
DURUM: dp[i] = i tutarını ödemek için gereken EN AZ para sayısı
GEÇİŞ: dp[i] = min(dp[i - c] + 1) tüm c paraları için, c ≤ i
TEMEL DURUM: dp[0] = 0 (sıfır tutar için sıfır para)
diğerleri başlangıçta "imkânsız" (sonsuz)
paralar {1,3,4}, tutar 6:
dp[0]=0
dp[1]=min(dp[0]+1)=1
dp[2]=min(dp[1]+1)=2
dp[3]=min(dp[2]+1, dp[0]+1)=1
dp[4]=min(dp[3]+1, dp[1]+1, dp[0]+1)=1
dp[5]=min(dp[4]+1, dp[2]+1, dp[1]+1)=2
dp[6]=min(dp[5]+1, dp[3]+1, dp[2]+1)=2 ← 3+3package main
import (
"fmt"
"slices"
)
const inf = 1 << 30
// minCoins: en az para sayısı + kullanılan paralar
func minCoins(coins []int, amount int) ([]int, []int, bool) {
dp := make([]int, amount+1)
choice := make([]int, amount+1)
for i := 1; i <= amount; i++ {
dp[i] = inf
choice[i] = -1
}
for i := 1; i <= amount; i++ {
for _, c := range coins {
if c <= i && dp[i-c] != inf && dp[i-c]+1 < dp[i] {
dp[i] = dp[i-c] + 1
choice[i] = c
}
}
}
if dp[amount] == inf {
return dp, nil, false
}
// Seçimleri geriye doğru takip et
var used []int
for a := amount; a > 0; a -= choice[a] {
used = append(used, choice[a])
}
slices.Sort(used)
return dp, used, true
}
// countWays: kaç FARKLI şekilde ödenebilir (sıra önemsiz)
func countWays(coins []int, amount int) int {
dp := make([]int, amount+1)
dp[0] = 1
// Dış döngü paralar: her para kombinasyonu bir kez sayılır
for _, c := range coins {
for i := c; i <= amount; i++ {
dp[i] += dp[i-c]
}
}
return dp[amount]
}
// countPermutations: sıra ÖNEMLİ olduğunda
func countPermutations(coins []int, amount int) int {
dp := make([]int, amount+1)
dp[0] = 1
// Dış döngü tutar: her sıra ayrı sayılır
for i := 1; i <= amount; i++ {
for _, c := range coins {
if c <= i {
dp[i] += dp[i-c]
}
}
}
return dp[amount]
}
func main() {
coins := []int{1, 3, 4}
dp, used, ok := minCoins(coins, 6)
fmt.Println("paralar:", coins, "tutar: 6")
fmt.Println("DP tablosu:", dp[:7])
fmt.Println("en az para:", len(used), "→", used, "(bulundu:", ok, ")")
fmt.Println()
fmt.Printf("%8s %14s %16s\n", "tutar", "en az para", "kullanılanlar")
for amount := 1; amount <= 12; amount++ {
_, u, ok := minCoins(coins, amount)
if !ok {
fmt.Printf("%8d %14s %16s\n", amount, "imkânsız", "-")
continue
}
fmt.Printf("%8d %14d %16v\n", amount, len(u), u)
}
fmt.Println()
fmt.Println("kaç farklı şekilde ödenebilir:")
for amount := 1; amount <= 8; amount++ {
fmt.Printf(" %d → kombinasyon: %-4d permütasyon: %d\n",
amount, countWays(coins, amount), countPermutations(coins, amount))
}
fmt.Println()
// Ödenemez tutar
odd := []int{3, 5}
_, _, ok2 := minCoins(odd, 7)
fmt.Println("paralar", odd, "ile 7 ödenebilir mi:", ok2)
_, u3, _ := minCoins(odd, 8)
fmt.Println("paralar", odd, "ile 8:", u3)
}paralar: [1 3 4] tutar: 6
DP tablosu: [0 1 2 1 1 2 2]
en az para: 2 → [3 3] (bulundu: true )
tutar en az para kullanılanlar
1 1 [ 1]
2 2 [ 1 1]
3 1 [ 3]
4 1 [ 4]
5 2 [ 1 4]
6 2 [ 3 3]
7 2 [ 3 4]
8 2 [ 4 4]
9 3 [ 1 4 4]
10 3 [ 3 3 4]
11 3 [ 3 4 4]
12 3 [ 4 4 4]
kaç farklı şekilde ödenebilir:
1 → kombinasyon: 1 permütasyon: 1
2 → kombinasyon: 1 permütasyon: 1
3 → kombinasyon: 2 permütasyon: 2
4 → kombinasyon: 3 permütasyon: 4
5 → kombinasyon: 3 permütasyon: 6
6 → kombinasyon: 4 permütasyon: 9
7 → kombinasyon: 5 permütasyon: 15
8 → kombinasyon: 6 permütasyon: 25
paralar [3 5] ile 7 ödenebilir mi: false
paralar [3 5] ile 8: [3 5]countWays ile countPermutations arasındaki farka dikkat et: Yalnızca döngülerin sırası değişiyor ama sonuç tamamen farklı. Dış döngü paralar olduğunda her kombinasyon bir kez sayılır (1+3 ile 3+1 aynı sayılır); dış döngü tutar olduğunda her sıra ayrı sayılır. Bu, DP'de döngü sırasının ne kadar önemli olduğunu gösteren klasik bir örnektir.
Ev soyguncusu
Bir sokakta evler sıralı; her evde belirli miktarda para var. Komşu iki evi birden soyamazsın. En fazla ne kadar alabilirsin?
DURUM: dp[i] = ilk i evi düşündüğümüzde alınabilecek en fazla para
GEÇİŞ: dp[i] = max(dp[i-1], ← bu evi soyMA
dp[i-2] + para[i-1]) ← bu evi soy, öncekini atla
TEMEL DURUM: dp[0] = 0, dp[1] = para[0]
evler [2, 7, 9, 3, 1]:
dp[0]=0
dp[1]=2
dp[2]=max(2, 0+7)=7
dp[3]=max(7, 2+9)=11
dp[4]=max(11, 7+3)=11
dp[5]=max(11, 11+1)=12 ← 2 + 9 + 1package main
import "fmt"
// rob: klasik tabulation
func rob(houses []int) ([]int, int) {
n := len(houses)
if n == 0 {
return nil, 0
}
dp := make([]int, n+1)
dp[1] = houses[0]
for i := 2; i <= n; i++ {
dp[i] = max(dp[i-1], dp[i-2]+houses[i-1])
}
return dp, dp[n]
}
// robOptimized: O(1) bellek — yalnızca son iki değer gerekli
func robOptimized(houses []int) int {
prev2, prev1 := 0, 0
for _, money := range houses {
prev2, prev1 = prev1, max(prev1, prev2+money)
}
return prev1
}
// robWithHouses: hangi evlerin soyulduğunu da döndürür
func robWithHouses(houses []int) (int, []int) {
n := len(houses)
if n == 0 {
return 0, nil
}
dp := make([]int, n+1)
dp[1] = houses[0]
for i := 2; i <= n; i++ {
dp[i] = max(dp[i-1], dp[i-2]+houses[i-1])
}
// Geriye doğru takip: hangi kararlar alındı?
var chosen []int
i := n
for i > 0 {
if i == 1 || dp[i] != dp[i-1] {
chosen = append([]int{i - 1}, chosen...) // bu ev soyuldu
i -= 2
continue
}
i-- // bu ev soyulmadı
}
return dp[n], chosen
}
// robCircular: evler DAİRE şeklinde — ilk ve son ev komşu
func robCircular(houses []int) int {
n := len(houses)
switch n {
case 0:
return 0
case 1:
return houses[0]
}
// İki senaryo: ilk evi dâhil et (sonu hariç) veya son evi dâhil et (başı hariç)
return max(robOptimized(houses[:n-1]), robOptimized(houses[1:]))
}
func main() {
houses := []int{2, 7, 9, 3, 1}
dp, best := rob(houses)
fmt.Println("evler:", houses)
fmt.Println("DP tablosu:", dp)
fmt.Println("en fazla para:", best)
total, chosen := robWithHouses(houses)
fmt.Printf("soyulan evler (indeks): %v → toplam %d\n", chosen, total)
fmt.Println()
fmt.Printf("%-26s %10s %10s %8s\n", "evler", "doğrusal", "daire", "optimize")
cases := [][]int{
{2, 7, 9, 3, 1},
{1, 2, 3, 1},
{5, 5, 5, 5},
{100},
{50, 1, 1, 50},
{},
}
for _, c := range cases {
_, linear := rob(c)
fmt.Printf("%-26v %10d %10d %8d\n", c, linear, robCircular(c), robOptimized(c))
}
fmt.Println()
fmt.Println("Dikkat: [50,1,1,50] dairesel dizilimde ilk ve son ev komşu;")
fmt.Println("bu yüzden ikisi birden alınamaz ve sonuç doğrusal hâlden düşük olur.")
}evler: [2 7 9 3 1] DP tablosu: [0 2 7 11 11 12] en fazla para: 12 soyulan evler (indeks): [0 2 4] → toplam 12 evler doğrusal daire optimize [2 7 9 3 1 ] 12 11 12 [1 2 3 1 ] 4 4 4 [5 5 5 5 ] 10 10 10 [100 ] 100 100 100 [50 1 1 50 ] 100 51 100 [] 0 0 0 Dikkat: [50,1,1,50] dairesel dizilimde ilk ve son ev komşu; bu yüzden ikisi birden alınamaz ve sonuç doğrusal hâlden düşük olur.
Bu problem, DP'nin karakteristik yapısını çok net gösterir: Her adımda iki seçenek vardır (al veya alma) ve her seçeneğin sonucu daha küçük bir alt problemin cevabına bağlıdır. "Al veya alma" yapısı, sırt çantası probleminden en uzun artan alt diziye kadar pek çok DP probleminin iskeletidir.
Dairesel varyantın çözümü de öğreticidir: Problemi iki ayrı doğrusal probleme indirgeyerek çözüyoruz. Bu, DP'de sık kullanılan bir tekniktir — zor bir kısıtı, o kısıtın olmadığı iki alt duruma bölmek.
Alan optimizasyonu
DP tablolarının çoğu, hesaplama sırasında yalnızca son birkaç satıra ihtiyaç duyar. Bunu fark etmek, bellek kullanımını dramatik biçimde azaltır.
package main
import "fmt"
// gridPaths2D: tam tablo — O(m×n) bellek
func gridPaths2D(rows, cols int) ([][]int, int) {
dp := make([][]int, rows)
for i := range dp {
dp[i] = make([]int, cols)
dp[i][0] = 1 // ilk sütun: tek yol (hep aşağı)
}
for j := range dp[0] {
dp[0][j] = 1 // ilk satır: tek yol (hep sağa)
}
for i := 1; i < rows; i++ {
for j := 1; j < cols; j++ {
dp[i][j] = dp[i-1][j] + dp[i][j-1]
}
}
return dp, dp[rows-1][cols-1]
}
// gridPaths1D: tek satır yeterli — O(n) bellek
func gridPaths1D(rows, cols int) int {
dp := make([]int, cols)
for j := range dp {
dp[j] = 1
}
for range rows - 1 {
for j := 1; j < cols; j++ {
dp[j] += dp[j-1] // dp[j] = üst, dp[j-1] = sol
}
}
return dp[cols-1]
}
// gridPathsWithObstacles: engelli ızgara
func gridPathsWithObstacles(grid [][]int) int {
if len(grid) == 0 || grid[0][0] == 1 {
return 0
}
rows, cols := len(grid), len(grid[0])
dp := make([]int, cols)
dp[0] = 1
for i := range rows {
for j := range cols {
if grid[i][j] == 1 {
dp[j] = 0 // engel: buraya ulaşılamaz
continue
}
if j > 0 {
dp[j] += dp[j-1]
}
}
}
return dp[cols-1]
}
func main() {
table, paths := gridPaths2D(3, 4)
fmt.Println("3×4 ızgarada sol üstten sağ alta yol sayısı:")
for _, row := range table {
fmt.Println(" ", row)
}
fmt.Println("toplam yol:", paths)
fmt.Println()
fmt.Printf("%-14s %12s %12s %8s\n", "ızgara", "2D tablo", "1D tablo", "aynı")
for _, dim := range [][2]int{{1, 1}, {2, 2}, {3, 4}, {5, 5}, {10, 10}} {
_, a := gridPaths2D(dim[0], dim[1])
b := gridPaths1D(dim[0], dim[1])
fmt.Printf("%-14s %12d %12d %8t\n",
fmt.Sprintf("%d×%d", dim[0], dim[1]), a, b, a == b)
}
fmt.Println()
obstacles := [][]int{
{0, 0, 0},
{0, 1, 0},
{0, 0, 0},
}
fmt.Println("engelli ızgara (1 = engel):")
for _, row := range obstacles {
fmt.Println(" ", row)
}
fmt.Println("yol sayısı:", gridPathsWithObstacles(obstacles))
fmt.Println()
fmt.Println("Bellek: 1000×1000 ızgara için")
fmt.Println(" 2D tablo: 1.000.000 hücre ≈ 8 MB")
fmt.Println(" 1D tablo: 1.000 hücre ≈ 8 KB")
}3×4 ızgarada sol üstten sağ alta yol sayısı: [1 1 1 1] [1 2 3 4] [1 3 6 10] toplam yol: 10 ızgara 2D tablo 1D tablo aynı 1×1 1 1 true 2×2 2 2 true 3×4 10 10 true 5×5 70 70 true 10×10 48620 48620 true engelli ızgara (1 = engel): [0 0 0] [0 1 0] [0 0 0] yol sayısı: 2 Bellek: 1000×1000 ızgara için 2D tablo: 1.000.000 hücre ≈ 8 MB 1D tablo: 1.000 hücre ≈ 8 KB
Alan optimizasyonunun mantığı şudur: Geçiş bağıntısı yalnızca dp[i-1] satırına bakıyorsa, dp[i-2] ve öncesini saklamanın anlamı yoktur. Tek satır tutup yerinde güncellemek yeterlidir — yeter ki güncelleme sırası, henüz ihtiyaç duyulan değerleri ezmesin.
Bu son nokta kritiktir: dp[j] += dp[j-1] yazımında dp[j] güncellenmeden önce hâlâ üst satırın değerini, dp[j-1] ise zaten güncellenmiş sol değeri taşır. Sıra tersine çevrilse sonuç yanlış olurdu. Alan optimizasyonu yaparken döngü yönünü dikkatle seçmek gerekir.
DP problemlerine yaklaşmak
DP, algoritma öğrenenlerin en çok zorlandığı konudur ve sebebi kodun karmaşıklığı değil — kodlar genelde çok kısadır. Zorluk, doğru durumu bulmaktır. Aşağıdaki süreç, bu işi sistematik hâle getirir.
Önce kaba kuvvet çözümünü yaz. Özyinelemeli, tüm seçenekleri deneyen, yavaş ama doğru bir çözüm. Bu adımı atlamak en yaygın hatadır. Kaba kuvvet çözümünü yazarken problemin yapısını keşfedersin ve fonksiyonun parametreleri sana durumu söyler.
Fonksiyonun parametrelerine bak: durum onlardır. solve(i, kalan) biçiminde bir özyineleme yazdıysan, durumun iki boyutludur: dp[i][kalan]. Bu basit gözlem, DP'nin en zor kısmını çözer.
Örtüşme olup olmadığını kontrol et. Aynı parametrelerle fonksiyon birden fazla kez çağrılıyor mu? Çağrı sayacı ekleyip ölçmek en hızlı yoldur. Örtüşme yoksa DP'ye gerek yoktur.
Memoize et. Parametreleri anahtar yapıp sonucu sakla. Bu noktada çözümün zaten doğru ve yeterince hızlı olması beklenir.
Gerekirse tabulation'a çevir. Özyineleme derinliği sorun oluyorsa ya da alan optimizasyonu istiyorsan, tabloyu döngüyle doldur. Doldurma sırasını belirlemek için soru şudur: Bir durumun cevabı hangi durumlara bağlı? Onlar önce hesaplanmış olmalı.
Sonra alan optimizasyonunu düşün. Tablonun tamamına ihtiyaç var mı, yoksa son bir-iki satır yeterli mi?
Bir de sık sorulan bir soruya cevap verelim: DP problemi olduğunu nasıl anlarsın? İşaretler şunlardır: "en az", "en fazla", "kaç farklı şekilde" gibi optimizasyon veya sayma soruları; her adımda seçim yapma yapısı; ve alt problemlerin doğal biçimde örtüşmesi. Buna karşılık "herhangi bir çözüm bul" tipi sorular genelde gezinme veya geri izleme problemleridir.
Sık yapılan hatalar
- Doğrudan tabulation yazmaya çalışmak. Önce özyinelemeli çözümü yaz; durum tanımı oradan gelir.
- Durumu eksik tanımlamak. Önbellek anahtarı, alt problemi tam olarak belirlemelidir; eksikse farklı durumların cevapları karışır.
- Temel durumları yanlış kurmak.
dp[0]için doğru değeri düşünmemek en yaygın hata kaynağıdır. Sayma problemlerinde genelde 1, minimizasyonda 0 olur. - Döngü sırasını gelişigüzel seçmek. Bozuk para örneğinde gördüğün gibi, döngü sırası sonucu tamamen değiştirebilir.
- Alan optimizasyonunda döngü yönünü yanlış seçmek. Henüz ihtiyaç duyulan değerleri ezmemek gerekir.
- "İmkânsız" durumu belirsiz bırakmak. Sonsuz yerine çok büyük bir sayı kullanırken taşmaya dikkat et.
- Örtüşme olmadığı hâlde DP kullanmak. Alt problemler örtüşmüyorsa memoization yalnızca bellek harcar.
Alıştırmalar
Fibonacci sayılarını üç farklı DP yaklaşımıyla hesapla: memoization, tabulation ve alan optimize edilmiş sürüm. Her birinin kullandığı bellek miktarını ve yaptığı işlem sayısını karşılaştır.
İpucu
Memoization'da önbellek erişim sayısını, tabulation'da tablo boyutunu, optimize sürümde kullanılan değişken sayısını raporla.
Çözümü göster
package main
import "fmt"
var memoHits, memoMisses, tableWrites, optimizedOps int
// 1) Memoization
func fibMemo(n int, memo []int) int {
if n <= 1 {
return n
}
if memo[n] != -1 {
memoHits++
return memo[n]
}
memoMisses++
memo[n] = fibMemo(n-1, memo) + fibMemo(n-2, memo)
return memo[n]
}
// 2) Tabulation
func fibTable(n int) int {
if n <= 1 {
return n
}
dp := make([]int, n+1)
dp[1] = 1
tableWrites = 2
for i := 2; i <= n; i++ {
dp[i] = dp[i-1] + dp[i-2]
tableWrites++
}
return dp[n]
}
// 3) Alan optimize edilmiş
func fibOptimized(n int) int {
if n <= 1 {
return n
}
prev2, prev1 := 0, 1
optimizedOps = 0
for range n - 1 {
prev2, prev1 = prev1, prev1+prev2
optimizedOps++
}
return prev1
}
func main() {
fmt.Printf("%6s %10s %10s %12s %12s %10s %12s\n",
"n", "sonuç", "memo isabet", "memo hesap", "memo bellek", "tablo yazma", "optimize işlem")
for _, n := range []int{5, 10, 20, 40, 80} {
memo := make([]int, n+1)
for i := range memo {
memo[i] = -1
}
memoHits, memoMisses = 0, 0
result := fibMemo(n, memo)
fibTable(n)
fibOptimized(n)
fmt.Printf("%6d %10d %10d %12d %12d %10d %12d\n",
n, result, memoHits, memoMisses, n+1, tableWrites, optimizedOps)
}
fmt.Println()
fmt.Println("Üç yaklaşımın da sonucu aynı:")
for _, n := range []int{0, 1, 10, 50} {
memo := make([]int, n+1)
for i := range memo {
memo[i] = -1
}
a, b, c := fibMemo(n, memo), fibTable(n), fibOptimized(n)
fmt.Printf(" F(%d): memo=%d tablo=%d optimize=%d aynı=%t\n",
n, a, b, c, a == b && b == c)
}
fmt.Println()
fmt.Println("Bellek kullanımı:")
fmt.Println(" memoization: O(n) tablo + O(n) çağrı yığını")
fmt.Println(" tabulation: O(n) tablo, çağrı yığını yok")
fmt.Println(" optimize: O(1) — iki değişken")
}n sonuç memo isabet memo hesap memo bellek tablo yazma optimize işlem
5 5 2 4 6 6 4
10 55 7 9 11 11 9
20 6765 17 19 21 21 19
40 102334155 37 39 41 41 39
80 23416728348467685 77 79 81 81 79
Üç yaklaşımın da sonucu aynı:
F(0): memo=0 tablo=0 optimize=0 aynı=true
F(1): memo=1 tablo=1 optimize=1 aynı=true
F(10): memo=55 tablo=55 optimize=55 aynı=true
F(50): memo=12586269025 tablo=12586269025 optimize=12586269025 aynı=true
Bellek kullanımı:
memoization: O(n) tablo + O(n) çağrı yığını
tabulation: O(n) tablo, çağrı yığını yok
optimize: O(1) — iki değişkenÜç yaklaşımın işlem sayıları neredeyse aynıdır — hepsi O(n). Fark bellek kullanımında ve çağrı yığını maliyetindedir. Memoization, çağrı yığını yüzünden en çok bellek kullanan sürümdür; çok büyük n değerlerinde yığın taşması riski taşır.
Pratikte tercih sırası şöyledir: Önce memoization ile çözümü doğrula (düşünmesi kolay), sonra tabulation'a geç (güvenli), gerekiyorsa alan optimizasyonu uygula (en verimli). Erken optimizasyon yapmak, doğru çalışan bir çözüme sahip olmadan bellek kurtarmaya çalışmak anlamına gelir.
Bir dizideki en uzun artan alt diziyi (bitişik olmak zorunda değil) bul. O(n²) DP çözümünü yaz, alt diziyi de geri döndür. Ardından ikili aramayla O(n log n) çözümü ekle ve uzunlukların aynı olduğunu doğrula.
İpucu
DP durumu: dp[i] = i. elemanla biten en uzun artan alt dizinin uzunluğu. Geçiş: dp[i] = 1 + max(dp[j]) öyle ki j < i ve data[j] < data[i].
Çözümü göster
package main
import (
"fmt"
"slices"
)
// lisDP: O(n²) — alt diziyi de döndürür
func lisDP(data []int) (int, []int, []int) {
n := len(data)
if n == 0 {
return 0, nil, nil
}
dp := make([]int, n) // dp[i] = i ile biten en uzun artan alt dizi uzunluğu
prev := make([]int, n) // geri takip için
for i := range dp {
dp[i] = 1
prev[i] = -1
}
bestLen, bestEnd := 1, 0
for i := 1; i < n; i++ {
for j := range i {
if data[j] < data[i] && dp[j]+1 > dp[i] {
dp[i] = dp[j] + 1
prev[i] = j
}
}
if dp[i] > bestLen {
bestLen, bestEnd = dp[i], i
}
}
// Alt diziyi geriye doğru kur
var seq []int
for i := bestEnd; i != -1; i = prev[i] {
seq = append(seq, data[i])
}
slices.Reverse(seq)
return bestLen, seq, dp
}
// lisBinary: O(n log n) — yalnızca uzunluk
func lisBinary(data []int) int {
// tails[k] = uzunluğu k+1 olan artan alt dizilerin en küçük son elemanı
var tails []int
for _, v := range data {
pos, found := slices.BinarySearch(tails, v)
if found {
continue // aynı değer: artan kesin olmalı
}
if pos == len(tails) {
tails = append(tails, v)
continue
}
tails[pos] = v // daha küçük bir son elemanla değiştir
}
return len(tails)
}
func main() {
cases := [][]int{
{10, 9, 2, 5, 3, 7, 101, 18},
{1, 2, 3, 4, 5},
{5, 4, 3, 2, 1},
{2, 2, 2, 2},
{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5},
{42},
{},
}
fmt.Printf("%-36s %8s %10s %8s\n", "dizi", "DP", "ikili", "aynı")
for _, c := range cases {
length, _, _ := lisDP(c)
fast := lisBinary(c)
fmt.Printf("%-36v %8d %10d %8t\n", c, length, fast, length == fast)
}
fmt.Println()
data := []int{10, 9, 2, 5, 3, 7, 101, 18}
length, seq, dp := lisDP(data)
fmt.Println("dizi:", data)
fmt.Println("dp tablosu:", dp)
fmt.Println("en uzun artan alt dizi:", seq, "uzunluk:", length)
fmt.Println()
data2 := []int{3, 1, 4, 1, 5, 9, 2, 6}
_, seq2, dp2 := lisDP(data2)
fmt.Println("dizi:", data2)
fmt.Println("dp tablosu:", dp2)
fmt.Println("alt dizi:", seq2)
}dizi DP ikili aynı [10 9 2 5 3 7 101 18 ] 4 4 true [1 2 3 4 5 ] 5 5 true [5 4 3 2 1 ] 1 1 true [2 2 2 2 ] 1 1 true [3 1 4 1 5 9 2 6 5 3 5 ] 4 4 true [42 ] 1 1 true [] 0 0 true dizi: [10 9 2 5 3 7 101 18] dp tablosu: [1 1 1 2 2 3 4 4] en uzun artan alt dizi: [2 5 7 101] uzunluk: 4 dizi: [3 1 4 1 5 9 2 6] dp tablosu: [1 1 2 1 3 4 2 4] alt dizi: [3 4 5 9]
DP çözümünün durum tanımına dikkat et: dp[i], "i. elemanla biten" en uzun alt dizinin uzunluğudur — "ilk i elemandaki en uzun" değil. Bu ayrım kritiktir; ikinci tanımla geçiş bağıntısı kurulamaz çünkü alt diziyi genişletmek için son elemanın ne olduğunu bilmen gerekir.
prev dizisi, DP'de çok kullanılan bir tekniktir: Yalnızca optimal değeri değil, o değere ulaşan kararları da geri takip edebilmek için her adımda nereden geldiğini kaydedersin. Bu olmadan "en uzun uzunluk 4" bilgisini alırsın ama hangi dört eleman olduğunu bilemezsin.
İkili arama çözümü daha hızlıdır ama yalnızca uzunluğu verir; alt diziyi de istiyorsan ek kayıtlar tutmak gerekir. Bu, DP'de sık görülen bir ödünleşmedir: Daha hızlı algoritma genelde daha az bilgi saklar.
Bir tamsayı dizisini, toplamları eşit iki gruba bölmek mümkün mü? Mümkünse gruplardan birini göster. Bu, alt küme toplamı probleminin bir varyantıdır ve bit kümesiyle alan optimizasyonu yapılabilir.
İpucu
Toplam tekse imkânsızdır. Değilse, "toplam/2 değerine ulaşan bir alt küme var mı?" sorusunu çöz. Durum: dp[s] = s toplamına ulaşılabilir mi.
Çözümü göster
package main
import (
"fmt"
"slices"
)
// canPartition: eşit toplamlı iki gruba bölünebilir mi
func canPartition(nums []int) (bool, []int, []int) {
total := 0
for _, v := range nums {
total += v
}
if total%2 != 0 {
return false, nil, nil // tek toplam: imkânsız
}
target := total / 2
n := len(nums)
// dp[i][s] = ilk i elemanla s toplamına ulaşılabilir mi
dp := make([][]bool, n+1)
for i := range dp {
dp[i] = make([]bool, target+1)
dp[i][0] = true // sıfır toplam her zaman mümkün (boş küme)
}
for i := 1; i <= n; i++ {
for s := range target + 1 {
dp[i][s] = dp[i-1][s] // bu elemanı almama
if s >= nums[i-1] && dp[i-1][s-nums[i-1]] {
dp[i][s] = true // bu elemanı alma
}
}
}
if !dp[n][target] {
return false, nil, nil
}
// Geriye doğru takip: hangi elemanlar seçildi?
var groupA []int
s := target
for i := n; i > 0; i-- {
if !dp[i-1][s] { // bu eleman alınmış olmalı
groupA = append(groupA, nums[i-1])
s -= nums[i-1]
}
}
slices.Sort(groupA)
// Kalanlar B grubuna
remaining := slices.Clone(nums)
var groupB []int
for _, v := range groupA {
if i := slices.Index(remaining, v); i != -1 {
remaining = slices.Delete(remaining, i, i+1)
}
}
groupB = remaining
slices.Sort(groupB)
return true, groupA, groupB
}
// canPartitionOptimized: tek boyutlu dizi — O(target) bellek
func canPartitionOptimized(nums []int) bool {
total := 0
for _, v := range nums {
total += v
}
if total%2 != 0 {
return false
}
target := total / 2
dp := make([]bool, target+1)
dp[0] = true
for _, num := range nums {
// TERSTEN gez: her eleman en fazla bir kez kullanılsın
for s := target; s >= num; s-- {
if dp[s-num] {
dp[s] = true
}
}
}
return dp[target]
}
func sum(xs []int) int {
total := 0
for _, x := range xs {
total += x
}
return total
}
func main() {
cases := [][]int{
{1, 5, 11, 5},
{1, 2, 3, 5},
{2, 2, 3, 5},
{1, 1, 1, 1},
{100, 50, 50},
{7},
{},
}
for _, c := range cases {
ok, a, b := canPartition(c)
opt := canPartitionOptimized(c)
fmt.Printf("%-22v toplam=%-5d bölünebilir=%-6t (optimize aynı: %t)\n",
c, sum(c), ok, ok == opt)
if ok {
fmt.Printf(" grup A: %-16v toplam %d\n", a, sum(a))
fmt.Printf(" grup B: %-16v toplam %d\n", b, sum(b))
}
}
fmt.Println()
fmt.Println("Ters yönde gezmek neden zorunlu?")
fmt.Println("Tek boyutlu dizide ileriye doğru gezsen, aynı eleman")
fmt.Println("birden fazla kez kullanılabilirdi — bu farklı bir problem olur")
fmt.Println("(sınırsız sırt çantası).")
// İleriye gezmenin etkisi
wrong := func(nums []int) bool {
total := sum(nums)
if total%2 != 0 {
return false
}
target := total / 2
dp := make([]bool, target+1)
dp[0] = true
for _, num := range nums {
for s := num; s <= target; s++ { // İLERİ: yanlış!
if dp[s-num] {
dp[s] = true
}
}
}
return dp[target]
}
fmt.Println()
test := []int{3, 3, 3, 3, 5}
fmt.Println("test dizisi:", test, "toplam:", sum(test))
fmt.Println(" doğru (ters gezme):", canPartitionOptimized(test))
fmt.Println(" yanlış (ileri gezme):", wrong(test), "← eleman tekrar kullanıldı")
}[1 5 11 5 ] toplam=22 bölünebilir=true (optimize aynı: true) grup A: [11 ] toplam 11 grup B: [1 5 5 ] toplam 11 [1 2 3 5 ] toplam=11 bölünebilir=false (optimize aynı: true) [2 2 3 5 ] toplam=12 bölünebilir=false (optimize aynı: true) [1 1 1 1 ] toplam=4 bölünebilir=true (optimize aynı: true) grup A: [1 1 ] toplam 2 grup B: [1 1 ] toplam 2 [100 50 50 ] toplam=200 bölünebilir=true (optimize aynı: true) grup A: [100 ] toplam 100 grup B: [50 50 ] toplam 100 [7 ] toplam=7 bölünebilir=false (optimize aynı: true) [] toplam=0 bölünebilir=true (optimize aynı: true) grup A: [] toplam 0 grup B: [] toplam 0 Ters yönde gezmek neden zorunlu? Tek boyutlu dizide ileriye doğru gezsen, aynı eleman birden fazla kez kullanılabilirdi — bu farklı bir problem olur (sınırsız sırt çantası). test dizisi: [3 3 3 3 5] toplam: 17 doğru (ters gezme): false yanlış (ileri gezme): false ← eleman tekrar kullanıldı
Son karşılaştırma, alan optimizasyonundaki en önemli ayrıntıyı gösteriyor: Döngü yönü. Tek boyutlu diziyi ileriye doğru gezdiğinde, aynı yinelemede güncellediğin bir değeri tekrar okursun — bu, aynı elemanı birden çok kez kullanmak anlamına gelir. Ters yönde gezmek, yalnızca önceki yinelemeden kalan değerleri okumanı garanti eder.
İki farklı problem arasındaki ayrım tam olarak buradan geçer: Her eleman en fazla bir kez kullanılabiliyorsa (0/1 sırt çantası) ters gezilir; sınırsız kullanılabiliyorsa (bozuk para) ileri gezilir. Aynı kodda tek satırlık bir fark, tamamen farklı bir problemi çözer.
Karmaşıklığın O(n × toplam) olduğuna dikkat et. Bu, girdi değerlerine bağlı olduğu için teknik olarak polinom değildir — "sözde polinom" (pseudo-polynomial) denir. Değerler çok büyükse bu çözüm pratik olmaz; problem NP-tam olan alt küme toplamı probleminin bir örneğidir.
Kısa sınav
Dinamik programlamanın uygulanabilmesi için hangi iki özellik gerekir?
Memoization ile tabulation arasındaki temel fark nedir?
Bir DP probleminde 'durum' ne anlama gelir?
Bozuk para probleminde döngülerin sırasını değiştirmek neyi değiştirir?
Alt küme toplamı probleminde tek boyutlu diziyi neden ters yönde gezmek gerekir?
Naif özyinelemeli Fibonacci neden üstel sürede çalışır?
Özet
- Dinamik programlama, örtüşen alt problemlerin sonuçlarını saklayarak tekrar hesaplamayı önler.
- İki özellik gerekir: örtüşen alt problemler ve optimal alt yapı.
- Memoization yukarıdan aşağıya çalışır; özyinelemeli çözüme önbellek eklemektir ve düşünmesi kolaydır.
- Tabulation aşağıdan yukarıya çalışır; çağrı yığını kullanmaz ve alan optimizasyonuna açıktır.
- Her DP problemi üç soruyla tanımlanır: durum nedir, geçiş nedir, temel durum nedir?
- Kaba kuvvet özyinelemesinin parametreleri genelde durumu doğrudan verir.
- Döngü sırası ve yönü sonucu tamamen değiştirebilir; kombinasyon/permütasyon ayrımı ve eleman tekrarı buna bağlıdır.
- Geri takip dizisi tutmak, yalnızca optimal değeri değil optimal kararları da öğrenmeni sağlar.
- Tablonun yalnızca son birkaç satırına ihtiyaç varsa alan O(n)'den O(1)'e inebilir.