Büyük bir problemi çözmenin en eski stratejisi, onu parçalamaktır. Bir kütüphanedeki kitapları tek başına saymak zordur; ama işi dört kişiye bölüp her biri bir kanadı sayarsa ve sonunda toplarsanız çok daha hızlı bitirirsiniz. Bu strateji algoritma dünyasında böl ve fethet (divide and conquer) adını taşır ve üç adımdan oluşur.
Böl: Problemi aynı türden daha küçük alt problemlere ayır. Fethet: Alt problemleri özyinelemeli olarak çöz; yeterince küçüldüklerinde doğrudan cevapla. Birleştir: Alt problemlerin cevaplarından asıl cevabı oluştur.
Bu kalıbı zaten gördün: Birleştirmeli sıralama ve hızlı sıralama bunun örnekleridir; ikili arama da bir dejenere hâlidir (yalnızca bir alt probleme iner). Bu derste kalıbı genel olarak inceleyeceğiz: Yineleme bağıntılarıyla karmaşıklığı nasıl hesaplayacağını, Master teoremini, hızlı üs almayı, k'ıncı en küçük elemanı doğrusal beklenen sürede bulan quickselect'i, maksimum alt dizi problemini ve ters çevrim sayımını öğreneceksin.
Üç adım ve genel kalıp
function çöz(problem):
if problem yeterince küçük:
return doğrudan_çöz(problem) ← temel durum
alt1, alt2, ... = böl(problem) ← BÖL
c1 = çöz(alt1) ← FETHET
c2 = çöz(alt2)
return birleştir(c1, c2) ← BİRLEŞTİRKalıbın işe yaraması için iki koşul gerekir. Birincisi, alt problemler bağımsız olmalıdır — birbirlerinin sonucuna ihtiyaç duymamalıdır. (Duyuyorlarsa doğru araç dinamik programlamadır; onu Dinamik Programlama I dersinde göreceksin.) İkincisi, birleştirme adımı bölmeden kazandığın işten daha pahalı olmamalıdır.
Bu iki koşul sağlandığında kazanç büyük olabilir. Ama her zaman kazanç yoktur: Bir dizinin toplamını böl ve fethetle hesaplamak, doğrudan döngüyle hesaplamakla aynı karmaşıklıktadır — yalnızca daha karmaşık bir kodla.
Yineleme bağıntıları
Böl ve fethet algoritmalarının karmaşıklığı, kendini tekrar eden bir denklemle ifade edilir. Birleştirmeli sıralama için:
T(n) = 2·T(n/2) + O(n)
↑ ↑ ↑
alt problem boyut birleştirme
sayısı maliyeti
Açalım:
T(n) = 2T(n/2) + n
= 2(2T(n/4) + n/2) + n = 4T(n/4) + 2n
= 8T(n/8) + 3n
...
= 2^k·T(n/2^k) + k·n
n/2^k = 1 olduğunda k = log₂ n:
T(n) = n·T(1) + n·log₂ n = O(n log n)Ağaç olarak düşünmek daha sezgiseldir:
seviye 0: n → n iş
seviye 1: n/2 n/2 → n iş
seviye 2: n/4 n/4 n/4 n/4 → n iş
...
seviye log n: 1 1 1 ... 1 → n iş
log n seviye × her seviyede n iş = O(n log n)Master teoremi
Her yineleme bağıntısını elle açmak yorucudur. Master teoremi, en yaygın biçim için hazır bir cevap verir:
T(n) = a·T(n/b) + f(n) (a ≥ 1, b > 1)
Karşılaştırılacak büyüklük: n^(log_b a)
Durum 1: f(n) = O(n^(log_b a − ε)) → T(n) = Θ(n^(log_b a))
(birleştirme ucuz; alt problemler baskın)
Durum 2: f(n) = Θ(n^(log_b a)) → T(n) = Θ(n^(log_b a) · log n)
(dengeli; her seviye eşit iş yapar)
Durum 3: f(n) = Ω(n^(log_b a + ε)) → T(n) = Θ(f(n))
(birleştirme pahalı; kök baskın)Örneklerle bakalım:
package main
import (
"fmt"
"math"
)
// masterTheorem: a, b ve f(n)'in derecesi verildiğinde sonucu belirler
func masterTheorem(a, b int, fDegree float64) (string, string) {
critical := math.Log(float64(a)) / math.Log(float64(b))
const eps = 1e-9
switch {
case fDegree < critical-eps:
return "Durum 1", fmt.Sprintf("Θ(n^%.2f)", critical)
case math.Abs(fDegree-critical) < eps:
return "Durum 2", fmt.Sprintf("Θ(n^%.2f · log n)", critical)
default:
return "Durum 3", fmt.Sprintf("Θ(n^%.2f)", fDegree)
}
}
func main() {
examples := []struct {
name string
a, b int
fDegree float64
}{
{"ikili arama: T(n)=T(n/2)+O(1)", 1, 2, 0},
{"birleştirmeli: T(n)=2T(n/2)+O(n)", 2, 2, 1},
{"Karatsuba: T(n)=3T(n/2)+O(n)", 3, 2, 1},
{"matris çarpımı: T(n)=8T(n/2)+O(n²)", 8, 2, 2},
{"Strassen: T(n)=7T(n/2)+O(n²)", 7, 2, 2},
{"ağaç yüksekliği: T(n)=2T(n/2)+O(1)", 2, 2, 0},
{"pahalı birleştirme: T(n)=2T(n/2)+O(n²)", 2, 2, 2},
}
fmt.Printf("%-38s %10s %22s\n", "bağıntı", "durum", "sonuç")
for _, e := range examples {
durum, sonuc := masterTheorem(e.a, e.b, e.fDegree)
fmt.Printf("%-38s %10s %22s\n", e.name, durum, sonuc)
}
fmt.Println()
fmt.Println("Kritik büyüklük n^(log_b a) ile f(n)'i karşılaştırırız:")
fmt.Println(" f küçükse → alt problemler baskın")
fmt.Println(" eşitse → her seviye eşit iş, log n çarpanı eklenir")
fmt.Println(" f büyükse → birleştirme baskın")
}bağıntı durum sonuç ikili arama: T(n)=T(n/2)+O(1) Durum 2 Θ(n^0.00 · log n) birleştirmeli: T(n)=2T(n/2)+O(n) Durum 2 Θ(n^1.00 · log n) Karatsuba: T(n)=3T(n/2)+O(n) Durum 1 Θ(n^1.58) matris çarpımı: T(n)=8T(n/2)+O(n²) Durum 1 Θ(n^3.00) Strassen: T(n)=7T(n/2)+O(n²) Durum 1 Θ(n^2.81) ağaç yüksekliği: T(n)=2T(n/2)+O(1) Durum 1 Θ(n^1.00) pahalı birleştirme: T(n)=2T(n/2)+O(n²) Durum 3 Θ(n^2.00) Kritik büyüklük n^(log_b a) ile f(n)'i karşılaştırırız: f küçükse → alt problemler baskın eşitse → her seviye eşit iş, log n çarpanı eklenir f büyükse → birleştirme baskın
Master teoreminin sınırları da vardır: Alt problem boyutları eşit olmayan bağıntılara (T(n) = T(n/3) + T(2n/3) + n gibi) uygulanamaz. Bu durumlarda özyineleme ağacını elle çizmek ya da yerine koyma yöntemi kullanmak gerekir.
Hızlı üs alma
Böl ve fethetin en zarif uygulamalarından biri: a^n hesaplamak. Naif yöntem n çarpma yapar; böl ve fethet log n çarpmaya iner.
a^n = (a^(n/2))² n çift ise
a^n = a · (a^((n-1)/2))² n tek ise
2^10 = (2^5)²
2^5 = 2 · (2^2)²
2^2 = (2^1)²
2^1 = 2 · (2^0)²
2^0 = 1
Toplam: 4 çarpma (naif yöntemde 10)package main
import "fmt"
var multiplications int
// powNaive: O(n)
func powNaive(a, n int) int {
result := 1
for range n {
result *= a
multiplications++
}
return result
}
// powFast: O(log n) — böl ve fethet
func powFast(a, n int) int {
if n == 0 {
return 1
}
half := powFast(a, n/2)
multiplications++
result := half * half
if n%2 == 1 {
multiplications++
result *= a
}
return result
}
// powIterative: aynı fikrin döngü hâli (bitlere bakar)
func powIterative(a, n int) int {
result := 1
for n > 0 {
if n&1 == 1 {
result *= a
multiplications++
}
a *= a
multiplications++
n >>= 1
}
return result
}
// powMod: modüler üs alma — kriptografinin temel işlemi
func powMod(a, n, mod int) int {
result := 1
a %= mod
for n > 0 {
if n&1 == 1 {
result = result * a % mod
}
a = a * a % mod
n >>= 1
}
return result
}
func main() {
fmt.Printf("%6s %14s %10s %14s %10s\n", "n", "naif sonuç", "çarpma", "hızlı sonuç", "çarpma")
for _, n := range []int{1, 5, 10, 20, 30} {
multiplications = 0
naive := powNaive(2, n)
naiveMults := multiplications
multiplications = 0
fast := powFast(2, n)
fastMults := multiplications
fmt.Printf("%6d %14d %10d %14d %10d\n", n, naive, naiveMults, fast, fastMults)
}
fmt.Println()
multiplications = 0
fmt.Println("3^13 iteratif =", powIterative(3, 13), "| çarpma:", multiplications)
fmt.Println()
fmt.Println("modüler üs alma (RSA'nın temeli):")
fmt.Println(" 7^128 mod 13 =", powMod(7, 128, 13))
fmt.Println(" 2^1000 mod 1_000_000_007 =", powMod(2, 1000, 1_000_000_007))
fmt.Println("→ naif yöntemle 2^1000 hesaplamak taşma yaratırdı")
}n naif sonuç çarpma hızlı sonuç çarpma
1 2 1 2 2
5 32 5 32 5
10 1024 10 1024 6
20 1048576 20 1048576 7
30 1073741824 30 1073741824 9
3^13 iteratif = 1594323 | çarpma: 7
modüler üs alma (RSA'nın temeli):
7^128 mod 13 = 3
2^1000 mod 1_000_000_007 = 688423210
→ naif yöntemle 2^1000 hesaplamak taşma yaratırdıHızlı üs alma yalnızca sayılarla sınırlı değildir. Aynı fikir matris üs almada kullanılır ve Fibonacci sayılarını O(log n) sürede hesaplamayı mümkün kılar. Modüler sürümü ise açık anahtarlı şifrelemenin temel işlemidir: Çok büyük üsleri makul sürede hesaplamanın tek yolu budur.
Quickselect: k'ıncı en küçük eleman
Bir dizide k'ıncı en küçük elemanı bulmak istiyorsun. Sıralayıp k'ıncı elemanı almak O(n log n) sürer. Ama tüm diziyi sıralamaya gerek yok — yalnızca k'ıncı elemanın yerini bilmek yeterli.
Quickselect, hızlı sıralamanın bölümleme adımını kullanır ama yalnızca bir tarafa iner:
[7, 2, 9, 1, 5, 3] k = 3 (3. en küçük)
bölümleme (pivot=3): [2, 1 | 3 | 7, 9, 5]
indeks: 2
pivot indeksi 2, aranan indeks 2 (0 tabanlı k-1) → BULUNDU: 3
T(n) = T(n/2) + O(n) = O(n) ← sadece bir tarafa indiğimiz içinpackage main
import (
"fmt"
"math/rand/v2"
"slices"
)
var partitions int
func partition(data []int, lo, hi int) int {
partitions++
pivot := data[hi]
i := lo
for j := lo; j < hi; j++ {
if data[j] < pivot {
data[i], data[j] = data[j], data[i]
i++
}
}
data[i], data[hi] = data[hi], data[i]
return i
}
// quickselect: k. en küçük eleman (1 tabanlı) — beklenen O(n)
func quickselect(data []int, k int, r *rand.Rand) (int, bool) {
if k < 1 || k > len(data) {
return 0, false
}
work := slices.Clone(data)
target := k - 1
lo, hi := 0, len(work)-1
for lo <= hi {
// Rastgele pivot: en kötü durumu pratikte imkânsız kılar
pick := lo + r.IntN(hi-lo+1)
work[pick], work[hi] = work[hi], work[pick]
p := partition(work, lo, hi)
switch {
case p == target:
return work[p], true
case p < target:
lo = p + 1 // sağ tarafa in
default:
hi = p - 1 // sol tarafa in
}
}
return 0, false
}
// median: ortanca değer
func median(data []int, r *rand.Rand) float64 {
n := len(data)
if n == 0 {
return 0
}
if n%2 == 1 {
v, _ := quickselect(data, n/2+1, r)
return float64(v)
}
a, _ := quickselect(data, n/2, r)
b, _ := quickselect(data, n/2+1, r)
return float64(a+b) / 2
}
// topK: en büyük k eleman
func topK(data []int, k int, r *rand.Rand) []int {
if k >= len(data) {
out := slices.Clone(data)
slices.Sort(out)
return out
}
threshold, _ := quickselect(data, len(data)-k+1, r)
var out []int
for _, v := range data {
if v >= threshold {
out = append(out, v)
}
}
slices.Sort(out)
return out[max(0, len(out)-k):]
}
func main() {
r := rand.New(rand.NewPCG(3, 7))
data := []int{7, 2, 9, 1, 5, 3, 8, 6, 4}
fmt.Println("dizi:", data)
sorted := slices.Clone(data)
slices.Sort(sorted)
fmt.Println("sıralı hâli:", sorted)
fmt.Println()
for k := 1; k <= len(data); k++ {
partitions = 0
v, _ := quickselect(data, k, r)
fmt.Printf(" %d. en küçük = %-3d (sıralıdaki: %-3d doğru: %-5t bölümleme: %d)\n",
k, v, sorted[k-1], v == sorted[k-1], partitions)
}
fmt.Println()
fmt.Println("ortanca:", median(data, r))
fmt.Println("en büyük 3:", topK(data, 3, r))
// Büyük dizide karşılaştırma
fmt.Println()
const n = 10_000
big := make([]int, n)
for i := range big {
big[i] = r.IntN(100_000)
}
partitions = 0
v, _ := quickselect(big, n/2, r)
selectParts := partitions
bigSorted := slices.Clone(big)
slices.Sort(bigSorted)
fmt.Printf("n=%d için ortanca bulma:\n", n)
fmt.Println(" quickselect sonucu:", v, "doğru mu:", v == bigSorted[n/2-1])
fmt.Println(" quickselect bölümleme sayısı:", selectParts)
fmt.Println(" sıralama yapsaydık ~n log n =", n*14, "karşılaştırma gerekirdi")
}dizi: [7 2 9 1 5 3 8 6 4] sıralı hâli: [1 2 3 4 5 6 7 8 9] 1. en küçük = 1 (sıralıdaki: 1 doğru: true bölümleme: 3) 2. en küçük = 2 (sıralıdaki: 2 doğru: true bölümleme: 3) 3. en küçük = 3 (sıralıdaki: 3 doğru: true bölümleme: 3) 4. en küçük = 4 (sıralıdaki: 4 doğru: true bölümleme: 6) 5. en küçük = 5 (sıralıdaki: 5 doğru: true bölümleme: 2) 6. en küçük = 6 (sıralıdaki: 6 doğru: true bölümleme: 3) 7. en küçük = 7 (sıralıdaki: 7 doğru: true bölümleme: 3) 8. en küçük = 8 (sıralıdaki: 8 doğru: true bölümleme: 3) 9. en küçük = 9 (sıralıdaki: 9 doğru: true bölümleme: 1) ortanca: 5 en büyük 3: [7 8 9] n=10000 için ortanca bulma: quickselect sonucu: 50311 doğru mu: true quickselect bölümleme sayısı: 23 sıralama yapsaydık ~n log n = 140000 karşılaştırma gerekirdi
Quickselect'in beklenen süresi O(n)'dir ve bunun sebeği zarif bir toplamdır: Her adımda dizinin yarısını elediğin için toplam iş n + n/2 + n/4 + ... < 2n olur. En kötü durumda (her bölümleme dengesiz) O(n²) olur; rastgele pivot bunu pratikte ortadan kaldırır.
Garantili O(n) isteyen bir varyant da vardır — "beşlerin medyanı" yöntemiyle pivot seçen medyan-medyanların algoritması. Teorik olarak zariftir ama sabit çarpanı büyük olduğu için pratikte rastgele pivotlu quickselect tercih edilir.
Maksimum alt dizi toplamı
Bir dizideki en büyük toplamlı bitişik alt diziyi bul. Böl ve fethetle çözümü öğreticidir, çünkü birleştirme adımı ilk bakışta göze çarpmaz.
[-2, 1, -3, 4, -1, 2, 1, -5, 4]
Diziyi ortadan böl. En iyi alt dizi üç yerden biri olabilir:
1) tamamen SOL yarıda
2) tamamen SAĞ yarıda
3) ORTADAN GEÇEN (birleştirme adımı bunu bulur)
3. durum için: ortadan sola doğru en iyi + ortadan sağa doğru en iyipackage main
import "fmt"
// maxCrossing: ortadan geçen en iyi alt diziyi bulur — O(n)
func maxCrossing(data []int, lo, mid, hi int) int {
leftBest, sum := -1<<62, 0
for i := mid; i >= lo; i-- {
sum += data[i]
leftBest = max(leftBest, sum)
}
rightBest, sum := -1<<62, 0
for i := mid + 1; i <= hi; i++ {
sum += data[i]
rightBest = max(rightBest, sum)
}
return leftBest + rightBest
}
// maxSubarrayDC: böl ve fethet — O(n log n)
func maxSubarrayDC(data []int, lo, hi int) int {
if lo == hi {
return data[lo]
}
mid := lo + (hi-lo)/2
return max(
maxSubarrayDC(data, lo, mid),
maxSubarrayDC(data, mid+1, hi),
maxCrossing(data, lo, mid, hi),
)
}
// kadane: dinamik programlama — O(n), çok daha basit
func kadane(data []int) (best, start, end int) {
if len(data) == 0 {
return 0, -1, -1
}
best, current := data[0], data[0]
start, end, tempStart := 0, 0, 0
for i := 1; i < len(data); i++ {
if current < 0 {
current = data[i] // geçmişi at, buradan başla
tempStart = i
} else {
current += data[i]
}
if current > best {
best, start, end = current, tempStart, i
}
}
return best, start, end
}
func main() {
cases := [][]int{
{-2, 1, -3, 4, -1, 2, 1, -5, 4},
{1, 2, 3, 4, 5},
{-5, -2, -8, -1},
{5, -3, 5},
{42},
}
fmt.Printf("%-32s %10s %10s %8s %-18s\n", "dizi", "böl-fethet", "Kadane", "aynı", "en iyi aralık")
for _, c := range cases {
dc := maxSubarrayDC(c, 0, len(c)-1)
k, start, end := kadane(c)
fmt.Printf("%-32v %10d %10d %8t %-18v\n", c, dc, k, dc == k, c[start:end+1])
}
fmt.Println()
fmt.Println("Böl ve fethet: O(n log n) — öğretici ama optimal değil")
fmt.Println("Kadane algoritması: O(n) — dinamik programlama yaklaşımı")
}dizi böl-fethet Kadane aynı en iyi aralık [-2 1 -3 4 -1 2 1 -5 4 ] 6 6 true [4 -1 2 1 ] [1 2 3 4 5 ] 15 15 true [1 2 3 4 5 ] [-5 -2 -8 -1 ] -1 -1 true [-1 ] [5 -3 5 ] 7 7 true [5 -3 5 ] [42 ] 42 42 true [42 ] Böl ve fethet: O(n log n) — öğretici ama optimal değil Kadane algoritması: O(n) — dinamik programlama yaklaşımı
Bu örnek önemli bir ders içerir: Böl ve fethet her zaman en iyi çözüm değildir. Bu problem için Kadane algoritması hem daha hızlı (O(n)) hem daha kısadır. Böl ve fethet çözümü, yaklaşımın nasıl uygulandığını göstermek açısından değerlidir; ama pratikte doğrusal çözüm kullanılır.
Genel kural şudur: Alt problemlerin sonuçları birbirinden bağımsızsa ve birleştirme ucuzsa böl ve fethet kazandırır. Bu problemde ise "buraya kadarki en iyi" bilgisi soldan sağa taşınabildiği için tek geçiş yeterli oluyor.
Ters çevrim sayma
Bir dizide kaç tane i < j iken a[i] > a[j] olan çift var? Bu sayı, dizinin ne kadar "sırasız" olduğunun ölçüsüdür. Kaba kuvvet O(n²) sürer; birleştirmeli sıralamanın içine yerleştirilen basit bir sayaç bunu O(n log n)'e indirir.
Birleştirme sırasında: sağ diziden bir eleman alındığında,
sol dizide KALAN tüm elemanlar onunla ters çevrim oluşturur.
sol: [3, 5] sağ: [1, 4]
↑ ↑
1 < 3 → 1 alınır, sol'da kalan 2 eleman (3 ve 5) ters çevrim!
ters çevrim += 2package main
import (
"fmt"
"slices"
)
// mergeCount: birleştirirken ters çevrim sayar
func mergeCount(data, buf []int, lo, mid, hi int) int {
i, j, k := lo, mid, lo
inversions := 0
for i < mid && j < hi {
if data[i] <= data[j] {
buf[k] = data[i]
i++
} else {
buf[k] = data[j]
j++
inversions += mid - i // sol dizide kalan her eleman bir ters çevrim
}
k++
}
for i < mid {
buf[k] = data[i]
i, k = i+1, k+1
}
for j < hi {
buf[k] = data[j]
j, k = j+1, k+1
}
copy(data[lo:hi], buf[lo:hi])
return inversions
}
// countInversions: O(n log n)
func countInversions(data []int) int {
work := slices.Clone(data)
buf := make([]int, len(work))
var sort func(lo, hi int) int
sort = func(lo, hi int) int {
if hi-lo <= 1 {
return 0
}
mid := lo + (hi-lo)/2
count := sort(lo, mid) + sort(mid, hi)
return count + mergeCount(work, buf, lo, mid, hi)
}
return sort(0, len(work))
}
// bruteForce: O(n²)
func bruteForce(data []int) int {
count := 0
for i := range data {
for j := i + 1; j < len(data); j++ {
if data[i] > data[j] {
count++
}
}
}
return count
}
func main() {
cases := [][]int{
{1, 2, 3, 4, 5}, // sıralı: 0
{5, 4, 3, 2, 1}, // ters: n(n-1)/2 = 10
{2, 4, 1, 3, 5}, // karışık
{1, 3, 2, 3, 1}, // tekrarlı
{7}, // tek
{}, // boş
}
fmt.Printf("%-24s %14s %14s %8s\n", "dizi", "böl-fethet", "kaba kuvvet", "aynı")
for _, c := range cases {
fast := countInversions(c)
slow := bruteForce(c)
fmt.Printf("%-24v %14d %14d %8t\n", c, fast, slow, fast == slow)
}
// Büyük dizide fark
fmt.Println()
const n = 2000
descending := make([]int, n)
for i := range descending {
descending[i] = n - i
}
fmt.Printf("n=%d ters sıralı dizi:\n", n)
fmt.Println(" ters çevrim sayısı:", countInversions(descending))
fmt.Println(" teorik maksimum n(n-1)/2 =", n*(n-1)/2)
fmt.Println(" böl-fethet ~n log n =", n*11, "işlem")
fmt.Println(" kaba kuvvet n² =", n*n, "işlem")
}dizi böl-fethet kaba kuvvet aynı [1 2 3 4 5 ] 0 0 true [5 4 3 2 1 ] 10 10 true [2 4 1 3 5 ] 3 3 true [1 3 2 3 1 ] 4 4 true [7 ] 0 0 true [] 0 0 true n=2000 ters sıralı dizi: ters çevrim sayısı: 1999000 teorik maksimum n(n-1)/2 = 1999000 böl-fethet ~n log n = 22000 işlem kaba kuvvet n² = 4000000 işlem
Buradaki fikir çok geneldir: Var olan bir böl-ve-fethet algoritmasının içine ek bilgi toplamak. Birleştirmeli sıralama zaten diziyi gezip birleştiriyor; birleştirme adımına küçük bir sayaç eklemek neredeyse bedavadır. Aynı teknikle "her eleman için sağında kaçından büyük" gibi soruları da yanıtlayabilirsin.
Ters çevrim sayısının pratik anlamı vardır: İki sıralamanın ne kadar benzediğini ölçen istatistiksel bir ölçüttür ve öneri sistemlerinde, sıralama kalitesi değerlendirmede kullanılır. Ayrıca eklemeli sıralamanın yapacağı kaydırma sayısına tam olarak eşittir.
Böl ve fethet ne zaman işe yarar?
Kalıbı tanımak, onu doğru yerde kullanmanın yarısıdır. Aşağıdaki işaretler, bir problemin böl ve fethetle çözülebileceğini gösterir.
Problem kendine benzer alt problemlere ayrılabiliyorsa. Diziyi ikiye bölmek, aralığı yarıya indirmek, ağacın alt ağaçlarını ayrı ele almak — hepsi aynı türden daha küçük problemler üretir. Alt problemlerin asıl problemle aynı biçimde olması kritiktir; farklı türden bir problem üretiyorsan kalıp uymaz.
Alt problemler bağımsızsa. Sol yarının çözümü sağ yarının çözümüne ihtiyaç duymuyorsa böl ve fethet uygundur. Duyuyorsa alt problemler örtüşüyor demektir ve dinamik programlamaya geçmen gerekir. Fibonacci hesabını özyinelemeyle yapmanın neden korkunç yavaş olduğunun sebebi tam olarak budur: Aynı alt problem defalarca çözülür.
Birleştirme ucuzsa. Master teoreminin üçüncü durumu bu tuzağı anlatır: Birleştirme maliyeti çok büyükse bölmenin kazancı silinir. Birleştirme adımını tasarlarken maliyetini hesaplamayı ihmal etme.
Paralelleştirme isteniyorsa. Alt problemler bağımsız olduğu için ayrı işlemcilerde çalıştırılabilirler. Bu, böl ve fethetin modern donanımda ayrıca değerli olmasının sebebidir. Go'da bu, her alt problemi bir goroutine'de çalıştırmak kadar kolaydır — ama küçük alt problemlerde goroutine kurma maliyeti kazancı yer, bu yüzden belirli bir boyutun altında sıralı çalışmaya geçmek gerekir.
Temel durumu doğru seçmek. Özyinelemeyi tek elemana kadar indirmek zorunda değilsin. Yaklaşık yirmi elemanın altında basit bir algoritmaya geçmek, hem özyineleme yükünü hem de bellek ayırmayı azaltır. Gördüğün melez sıralama algoritmaları tam olarak bunu yapar.
Son olarak, kalıbın sınırını bilmek gerekir: Her problem bölünmeye uygun değildir. Bir dizinin toplamını ya da maksimumunu bulmak gibi problemlerde böl ve fethet, doğrudan döngüyle aynı karmaşıklığı verir ve yalnızca kodu karmaşıklaştırır. Kazanç, bölmenin gerçekten iş azalttığı durumlarda ortaya çıkar.
Sık yapılan hatalar
- Temel durumu yanlış yazmak. Boş veya tek elemanlı girdi için doğru cevabı düşünmeden kod yazmaya başlamak sonsuz özyinelemeye yol açar.
- Alt problemlerin bağımsız olduğunu varsaymak. Örtüşen alt problemler varsa böl ve fethet üstel sürede çalışır; dinamik programlama gerekir.
- Birleştirme maliyetini hesaba katmamak. Pahalı birleştirme, bölmenin kazancını silebilir.
- Master teoremini uygun olmayan bağıntılara uygulamak. Alt problem boyutları eşit değilse teorem geçerli değildir.
- Quickselect'te sabit pivot kullanmak. Sıralı girdide O(n²)'ye düşer.
- Böl ve fetheti her yere uygulamak. Bazı problemlerde doğrusal çözüm vardır ve daha basittir.
- Özyineleme derinliğini göz ardı etmek. Dengesiz bölmeler derinliği n'e çıkarabilir.
Alıştırmalar
Matris üs alma kullanarak n. Fibonacci sayısını O(log n) sürede hesapla. Sonucu naif özyinelemeli ve iteratif sürümlerle karşılaştır.
İpucu
[[1,1],[1,0]] matrisinin n. kuvvetinin sol üst elemanı F(n+1), sağ üst elemanı F(n)'dir. Matris çarpımını 2×2 için elle yaz.
Çözümü göster
package main
import "fmt"
type matrix [2][2]int
func multiply(a, b matrix) matrix {
return matrix{
{a[0][0]*b[0][0] + a[0][1]*b[1][0], a[0][0]*b[0][1] + a[0][1]*b[1][1]},
{a[1][0]*b[0][0] + a[1][1]*b[1][0], a[1][0]*b[0][1] + a[1][1]*b[1][1]},
}
}
// matrixPow: hızlı üs alma — O(log n) matris çarpımı
func matrixPow(m matrix, n int) matrix {
result := matrix{{1, 0}, {0, 1}} // birim matris
for n > 0 {
if n&1 == 1 {
result = multiply(result, m)
}
m = multiply(m, m)
n >>= 1
}
return result
}
// fibMatrix: O(log n)
func fibMatrix(n int) int {
if n <= 0 {
return 0
}
base := matrix{{1, 1}, {1, 0}}
return matrixPow(base, n)[0][1]
}
// fibIterative: O(n)
func fibIterative(n int) int {
if n <= 0 {
return 0
}
a, b := 0, 1
for range n - 1 {
a, b = b, a+b
}
return b
}
var naiveCalls 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)
}
func main() {
fmt.Printf("%6s %14s %14s %8s\n", "n", "matris", "iteratif", "aynı")
for _, n := range []int{0, 1, 5, 10, 30, 50, 80} {
m := fibMatrix(n)
it := fibIterative(n)
fmt.Printf("%6d %14d %14d %8t\n", n, m, it, m == it)
}
fmt.Println()
fmt.Println("naif özyinelemeli sürümün çağrı sayısı:")
for _, n := range []int{10, 20, 25, 30} {
naiveCalls = 0
v := fibNaive(n)
fmt.Printf(" F(%d) = %-8d çağrı: %d\n", n, v, naiveCalls)
}
fmt.Println()
fmt.Println("F(80) hesaplamak için:")
fmt.Println(" matris yöntemi: ~7 matris çarpımı")
fmt.Println(" iteratif: 79 toplama")
fmt.Println(" naif özyineleme: yaklaşık 2^80 çağrı — evren ömrü yetmez")
}n matris iteratif 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
naif özyinelemeli sürümün çağrı sayısı:
F(10) = 55 çağrı: 177
F(20) = 6765 çağrı: 21891
F(25) = 75025 çağrı: 242785
F(30) = 832040 çağrı: 2692537
F(80) hesaplamak için:
matris yöntemi: ~7 matris çarpımı
iteratif: 79 toplama
naif özyineleme: yaklaşık 2^80 çağrı — evren ömrü yetmezNaif özyinelemenin çağrı sayısına dikkat: n her arttığında neredeyse ikiye katlanıyor. Sebebi, aynı alt problemin defalarca çözülmesidir — F(30) hesaplarken F(10) yüzlerce kez yeniden hesaplanır. Bu, böl ve fethetin örtüşen alt problemlerde neden başarısız olduğunun en net örneğidir ve dinamik programlamanın varlık sebebidir.
Matris yöntemi pratikte iteratif sürümden hızlı değildir çünkü sabit çarpanı büyüktür; ama n çok büyük olduğunda (örneğin modüler aritmetikle F(10¹⁸) hesaplarken) tek uygulanabilir yöntemdir.
Düzlemde verilen noktalar arasında en yakın iki noktayı bul. Kaba kuvvet O(n²) sürer; böl ve fethetle bunu O(n log² n) seviyesine indir. Sonuçları kaba kuvvetle karşılaştırarak doğrula.
İpucu
Noktaları x'e göre sırala, ortadan böl. En yakın çift ya solda, ya sağda, ya da orta şeritte olabilir. Orta şeridi kontrol ederken yalnızca y farkı mevcut en küçük mesafeden küçük olan çiftlere bak.
Çözümü göster
package main
import (
"fmt"
"math"
"slices"
)
type Point struct {
X, Y float64
}
func dist(a, b Point) float64 {
dx, dy := a.X-b.X, a.Y-b.Y
return math.Sqrt(dx*dx + dy*dy)
}
// bruteForce: O(n²)
func bruteForce(points []Point) (float64, Point, Point) {
best := math.Inf(1)
var p1, p2 Point
for i := range points {
for j := i + 1; j < len(points); j++ {
if d := dist(points[i], points[j]); d < best {
best, p1, p2 = d, points[i], points[j]
}
}
}
return best, p1, p2
}
// closestPair: böl ve fethet — O(n log² n)
func closestPair(points []Point) (float64, Point, Point) {
if len(points) < 2 {
return math.Inf(1), Point{}, Point{}
}
byX := slices.Clone(points)
slices.SortFunc(byX, func(a, b Point) int {
if a.X < b.X {
return -1
}
if a.X > b.X {
return 1
}
return 0
})
return solve(byX)
}
func solve(byX []Point) (float64, Point, Point) {
n := len(byX)
if n <= 3 {
return bruteForce(byX) // küçük durumda doğrudan çöz
}
mid := n / 2
midX := byX[mid].X
dl, l1, l2 := solve(byX[:mid])
dr, r1, r2 := solve(byX[mid:])
best, p1, p2 := dl, l1, l2
if dr < best {
best, p1, p2 = dr, r1, r2
}
// Orta şerit: x farkı best'ten küçük olan noktalar
var strip []Point
for _, p := range byX {
if math.Abs(p.X-midX) < best {
strip = append(strip, p)
}
}
slices.SortFunc(strip, func(a, b Point) int {
if a.Y < b.Y {
return -1
}
if a.Y > b.Y {
return 1
}
return 0
})
// Geometrik gerçek: her nokta için en fazla 7 komşuyu kontrol etmek yeter
for i := range strip {
for j := i + 1; j < len(strip) && strip[j].Y-strip[i].Y < best; j++ {
if d := dist(strip[i], strip[j]); d < best {
best, p1, p2 = d, strip[i], strip[j]
}
}
}
return best, p1, p2
}
func main() {
points := []Point{
{2, 3}, {12, 30}, {40, 50}, {5, 1}, {12, 10}, {3, 4},
{20, 25}, {7, 8}, {1, 1}, {30, 35},
}
fmt.Println("nokta sayısı:", len(points))
bd, bp1, bp2 := bruteForce(points)
cd, cp1, cp2 := closestPair(points)
fmt.Printf("kaba kuvvet : mesafe=%.4f çift=(%.0f,%.0f)-(%.0f,%.0f)\n",
bd, bp1.X, bp1.Y, bp2.X, bp2.Y)
fmt.Printf("böl ve fethet: mesafe=%.4f çift=(%.0f,%.0f)-(%.0f,%.0f)\n",
cd, cp1.X, cp1.Y, cp2.X, cp2.Y)
fmt.Println("mesafeler eşit mi:", math.Abs(bd-cd) < 1e-9)
// Daha büyük deterministik veri kümesi
fmt.Println()
var big []Point
for i := range 200 {
x := float64((i*37)%211) * 1.5
y := float64((i*53)%197) * 1.3
big = append(big, Point{x, y})
}
bd2, _, _ := bruteForce(big)
cd2, _, _ := closestPair(big)
fmt.Printf("n=%d: kaba kuvvet=%.6f böl-fethet=%.6f aynı=%t\n",
len(big), bd2, cd2, math.Abs(bd2-cd2) < 1e-9)
fmt.Println("kaba kuvvet karşılaştırma sayısı:", len(big)*(len(big)-1)/2)
}nokta sayısı: 10 kaba kuvvet : mesafe=1.4142 çift=(2,3)-(3,4) böl ve fethet: mesafe=1.4142 çift=(2,3)-(3,4) mesafeler eşit mi: true n=200: kaba kuvvet=4.684015 böl-fethet=4.684015 aynı=true kaba kuvvet karşılaştırma sayısı: 19900
Bu problemin böl ve fethet çözümü, birleştirme adımının neden dikkatli tasarlanması gerektiğini gösteriyor. Naif bir birleştirme, orta şeritteki tüm çiftleri kontrol ederek O(n²)'ye dönerdi. Kurtarıcı, geometrik bir gözlemdir: Şeritte y'ye göre sıralanmış noktalar arasında, her nokta için yalnızca sabit sayıda komşuyu kontrol etmek yeterlidir — çünkü mevcut en küçük mesafeden daha yakın olabilecek nokta sayısı sınırlıdır.
Şeridi her seferinde yeniden sıralamak yerine baştan y'ye göre sıralanmış bir kopya taşırsan karmaşıklık O(n log n)'e iner. Bu, algoritma tasarımında sık görülen bir iyileştirmedir: Tekrar eden bir işi önceden bir kez yapmak.
Çok büyük sayıların çarpımını Karatsuba algoritmasıyla hesapla. Klasik yöntem O(n²) basamak çarpımı yapar; Karatsuba bunu O(n^1.585)'e indirir. Basamak dizileri üzerinde çalış ve sonuçları naif çarpımla karşılaştır.
İpucu
x = a·10^m + b, y = c·10^m + d ise x·y = ac·10^2m + (ad+bc)·10^m + bd. Karatsuba'nın hilesi: ad+bc = (a+b)(c+d) − ac − bd, yani dört çarpım yerine üç.
Çözümü göster
package main
import (
"fmt"
"strings"
)
var multiplications int
// naiveMultiply: klasik uzun çarpma — O(n²)
func naiveMultiply(x, y []int) []int {
result := make([]int, len(x)+len(y))
for i := range x {
for j := range y {
result[i+j] += x[i] * y[j]
multiplications++
}
}
normalize(result)
return trim(result)
}
// karatsuba: üç çarpımla böl ve fethet — O(n^1.585)
func karatsuba(x, y []int) []int {
n := max(len(x), len(y))
if n <= 8 { // küçük durumda naif yöntem daha hızlı
return naiveMultiply(x, y)
}
m := n / 2
a, b := split(x, m) // x = a·10^m + b
c, d := split(y, m) // y = c·10^m + d
ac := karatsuba(a, c)
bd := karatsuba(b, d)
abcd := karatsuba(add(a, b), add(c, d))
middle := sub(sub(abcd, ac), bd) // (a+b)(c+d) − ac − bd = ad + bc
// sonuç = ac·10^(2m) + middle·10^m + bd
result := add(add(shift(ac, 2*m), shift(middle, m)), bd)
normalize(result)
return trim(result)
}
// Yardımcılar: basamaklar küçükten büyüğe (little-endian) saklanır
func split(x []int, m int) (high, low []int) {
if len(x) <= m {
return []int{0}, slices_clone(x)
}
return slices_clone(x[m:]), slices_clone(x[:m])
}
func shift(x []int, m int) []int {
out := make([]int, len(x)+m)
copy(out[m:], x)
return out
}
func add(x, y []int) []int {
n := max(len(x), len(y))
out := make([]int, n+1)
for i := range n {
if i < len(x) {
out[i] += x[i]
}
if i < len(y) {
out[i] += y[i]
}
}
normalize(out)
return trim(out)
}
func sub(x, y []int) []int {
out := slices_clone(x)
for i := range y {
out[i] -= y[i]
}
// ödünç al
for i := 0; i < len(out)-1; i++ {
for out[i] < 0 {
out[i] += 10
out[i+1]--
}
}
return trim(out)
}
func normalize(x []int) {
carry := 0
for i := range x {
x[i] += carry
carry = x[i] / 10
x[i] %= 10
}
}
func trim(x []int) []int {
i := len(x) - 1
for i > 0 && x[i] == 0 {
i--
}
return x[:i+1]
}
func slices_clone(x []int) []int {
out := make([]int, len(x))
copy(out, x)
return out
}
func parse(s string) []int {
out := make([]int, len(s))
for i := range s {
out[len(s)-1-i] = int(s[i] - '0')
}
return trim(out)
}
func format(x []int) string {
var sb strings.Builder
for i := len(x) - 1; i >= 0; i-- {
fmt.Fprint(&sb, x[i])
}
return sb.String()
}
func main() {
cases := [][2]string{
{"1234", "5678"},
{"999", "999"},
{"12345678901234567890", "98765432109876543210"},
{"1", "0"},
}
for _, c := range cases {
x, y := parse(c[0]), parse(c[1])
multiplications = 0
naive := naiveMultiply(x, y)
naiveMults := multiplications
multiplications = 0
kara := karatsuba(x, y)
karaMults := multiplications
fmt.Printf("%s × %s\n", c[0], c[1])
fmt.Printf(" naif = %-42s (%d basamak çarpımı)\n", format(naive), naiveMults)
fmt.Printf(" Karatsuba = %-40s (%d basamak çarpımı)\n", format(kara), karaMults)
fmt.Printf(" aynı mı: %t\n\n", format(naive) == format(kara))
}
// Büyük sayılarda çarpım sayısı karşılaştırması
big1 := strings.Repeat("9", 128)
big2 := strings.Repeat("7", 128)
x, y := parse(big1), parse(big2)
multiplications = 0
naiveMultiply(x, y)
naiveCount := multiplications
multiplications = 0
karatsuba(x, y)
karaCount := multiplications
fmt.Println("128 basamaklı iki sayı:")
fmt.Println(" naif basamak çarpımı: ", naiveCount)
fmt.Println(" Karatsuba basamak çarpımı:", karaCount)
fmt.Printf(" kazanç: %.2f kat\n", float64(naiveCount)/float64(karaCount))
}1234 × 5678 naif = 7006652 (16 basamak çarpımı) Karatsuba = 7006652 (16 basamak çarpımı) aynı mı: true 999 × 999 naif = 998001 (9 basamak çarpımı) Karatsuba = 998001 (9 basamak çarpımı) aynı mı: true 12345678901234567890 × 98765432109876543210 naif = 1219326311370217952237463801111263526900 (400 basamak çarpımı) Karatsuba = 1219326311370217952237463801111263526900 (245 basamak çarpımı) aynı mı: true 1 × 0 naif = 0 (1 basamak çarpımı) Karatsuba = 0 (1 basamak çarpımı) aynı mı: true 128 basamaklı iki sayı: naif basamak çarpımı: 16384 Karatsuba basamak çarpımı: 5254 kazanç: 3.12 kat
Karatsuba, 1960'ta yirmi üç yaşındaki Anatoly Karatsuba tarafından bulundu ve o güne kadar imkânsız sanılan bir şeyi gösterdi: Çarpma, basamak sayısının karesinden daha hızlı yapılabilir. Kolmogorov'un aksi yönde bir varsayımı vardı; Karatsuba onu bir hafta içinde çürüttü.
Algoritmanın kalbindeki hile, ad + bc toplamını doğrudan hesaplamak yerine (a+b)(c+d) − ac − bd biçiminde yazmaktır. Böylece dört çarpım yerine üç çarpım yapılır. Master teoremiyle: T(n) = 3T(n/2) + O(n) → O(n^log₂3) ≈ O(n^1.585).
Küçük sayılarda naif yöntemin daha hızlı olduğuna dikkat et — Karatsuba'nın ek toplama ve çıkarma maliyeti küçük girdilerde kazancı yer. Bu yüzden gerçek uygulamalar bir eşik kullanır. Aynı fikir daha da ileri götürülmüştür: Toom-Cook ve Fourier dönüşümü tabanlı yöntemler, çok büyük sayılarda neredeyse doğrusal sürelere iner. Büyük sayı kütüphaneleri (Go'nun math/big paketi dâhil) sayı boyutuna göre bu algoritmalar arasında seçim yapar.
Kısa sınav
Böl ve fethet stratejisinin üç adımı nedir?
T(n) = 2T(n/2) + O(n) bağıntısının çözümü nedir?
Böl ve fethet, örtüşen alt problemler olduğunda neden verimsizdir?
Quickselect'in beklenen karmaşıklığı nedir?
Hızlı üs alma a^n'i kaç çarpmayla hesaplar?
Karatsuba algoritması klasik çarpmadan nasıl daha hızlıdır?
Özet
- Böl ve fethet üç adımdan oluşur: problemi aynı türden alt problemlere böl, özyinelemeli çöz, sonuçları birleştir.
- Kalıbın işe yaraması için alt problemler bağımsız olmalı ve birleştirme ucuz olmalıdır.
- Karmaşıklık, yineleme bağıntısıyla ifade edilir; özyineleme ağacını çizmek en sezgisel yöntemdir.
- Master teoremi
T(n) = aT(n/b) + f(n)biçimindeki bağıntıları üç duruma göre çözer. - Hızlı üs alma O(log n) çarpma yapar; modüler sürümü kriptografinin temelidir.
- Quickselect, k'ıncı en küçük elemanı beklenen O(n) sürede bulur; yalnızca bir tarafa iner.
- Var olan bir böl-ve-fethet algoritmasına ek bilgi toplamak güçlü bir tekniktir; ters çevrim sayma bunun örneğidir.
- Alt problemler örtüşüyorsa böl ve fethet üstel sürede çalışır; dinamik programlamaya geçilmelidir.
- Her problem için böl ve fethet en iyi çözüm değildir; maksimum alt dizi probleminde doğrusal çözüm daha iyidir.