go
Algoritmalar dersleri
Algoritmalar/Tasarım Paradigmaları

Dinamik Programlama I: Temeller

Örtüşen alt problemler, memoization ve tabulation.

Ders 10 / 1835 dkOrta
Bu derste öğreneceklerin
  • Örtüşen alt problemler ve optimal alt yapı
  • Yukarıdan aşağı: memoization
  • Aşağıdan yukarı: tabulation
  • Durum, geçiş ve temel durum tanımlama
  • Merdiven çıkma ve Fibonacci
  • Minimum bozuk para (coin change)
  • Ev soyguncusu (house robber)
  • Alan optimizasyonu

Ö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.

main.go
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.")
}
Çıktı
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.
ZamanO(n)AlanO(n)

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)
main.go
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")
}
Çıktı
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:

ÖlçütMemoizationTabulation
YazımÖzyinelemeli, sezgiselDöngülü, açık
Yalnızca gereken durumları hesaplarEvetHayır, hepsini doldurur
Çağrı yığınıO(derinlik)Yok
Alan optimizasyonuZorKolay
Hata ayıklamaZor (derin özyineleme)Kolay (tablo görünür)
Doldurma sırasıKendiliğinden doğruSen belirlemelisin

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ı yol
main.go
package 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)
}
Çıktı
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
ZamanO(n × adım sayısı)AlanO(n)

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+3
main.go
package 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)
}
Çıktı
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]
ZamanO(tutar × para sayısı)AlanO(tutar)

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 + 1
main.go
package 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.")
}
Çıktı
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.
ZamanO(n)AlanO(1) optimize edilmiş

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.

main.go
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")
}
Çıktı
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

Alıştırma·Fibonacci'nin üç yolu
Kolay

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
main.go
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")
}
Çıktı
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
ZamanO(n)AlanO(n) / O(n) / O(1)

Üç 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.

Alıştırma·En uzun artan alt dizi
Orta

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
main.go
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)
}
Çıktı
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]
ZamanO(n²) / O(n log n)AlanO(n)

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.

Alıştırma·Bölünebilir mi?
Zor

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
main.go
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ı")
}
Çıktı
[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ı
ZamanO(n × toplam)AlanO(toplam)

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

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.
Bu dersi bitirdin mi?
İlerlemen bu tarayıcıda saklanır.