Veri Yapıları · Ders Notu

Bölüm 4: Özyineleme

Özyineleme, bir problemi aynı problemin daha küçük bir örneğine indirgeyerek çözme yöntemidir. Java'da bunun karşılığı, kendini daha küçük bir girdiyle çağıran metottur. Yöntem ilk karşılaşmada tuhaf gelir: henüz yazılmakta olan bir metot nasıl kendini kullanabilir? Cevap iki parçalıdır. En küçük girdide metot kimseye sormadan cevabı verir. Daha büyük girdilerde ise küçük problemin cevabını alıp tek bir adımla büyüğünkini kurar. Not bu fikirle başlar; çağrıların bellekte çağrı yığınıyla nasıl yürütüldüğünü adım adım izler. Sonra özyinelemenin türleri gelir. Doğrusal özyineleme dizi toplamı, tersine çevirme ve hızlı üs almayla; ikili özyineleme Fibonacci ve ikili aramayla işlenir. Hanoi kuleleri ile geri izlemeyle alt küme ve permütasyon üretme bu türleri tamamlar. Son kesimler maliyet üzerinedir: yineleme bağıntıları, çağrı ağacı, alan maliyeti, kuyruk özyinelemesi ve özyinelemenin döngüye çevrilmesi. Bağlı listede özyineleme, konuyu sonraki veri yapılarına bağlar.

Öğrenme çıktıları

Özyinelemeli tasarım
Bir problemi taban durum ve özyinelemeli adımla tanımlamak ve bu tanımı Java'da doğru bir metoda dökmek.
Çağrı yığını
Özyinelemeli bir metodu çerçeve çerçeve izleyip çıktısını ve dönüş değerini bulmak; yığının en çok kaç çerçeveye ulaştığını söylemek.
Özyineleme türleri
Doğrusal, ikili ve çoklu özyinelemeyi ve geri izlemeyi örnekleriyle ayırt etmek.
Maliyet
Yineleme bağıntısını yazıp çağrı ağacıyla zaman ve alan maliyetini bulmak; gerektiğinde özyinelemeli metodu döngüye çevirmek.

4.1Özyineleme nedir?

Bir metot başka metotları çağırabilir; bunu programlamaya başlarken öğreniriz. main, System.out.println'i çağırır; bir sıralama metodu, iki öğenin yerini değiştiren yardımcı bir metodu çağırır. Özyinelemede çağrılan metot, çağıranın kendisidir. Böyle bir metot neden sonsuza kadar dönüp durmaz, nasıl tasarlanır ve satır satır nasıl çalışır? Kesimin konusu bu üç sorudur.

4.1.1Günlük hayattan bir örnek

Bir sinema gişesinin önünde uzun bir kuyruk var. Kuyrukta kaçıncı sırada olduğumuzu öğrenmek istiyoruz. Yerimizi bırakıp en öne yürüyemiyoruz; yürürsek sıramızı kaybederiz. Görebildiğimiz tek kişi hemen önümüzdeki kişidir.

Bu durumda şöyle bir yol izlenebilir. Önümüzdekine “Sen kaçıncısın?” diye sorarız. O da kendi sırasını bilmiyorsa aynı soruyu kendi önündekine sorar. Soru böylece kişiden kişiye öne doğru ilerler. En öndeki kişinin önünde kimse yoktur; o hiç düşünmeden “1” der. Cevap bu kez geriye doğru yol alır. En öndekinin arkasındaki kişi “önümdeki 1. ise ben 2.'yim” der ve bunu arkasına iletir. Herkes duyduğu sayıya 1 ekleyip arkadakine söyler. Cevap bize ulaştığında sıramızı öğrenmiş oluruz.

54321“Sen kaçıncısın?”en önde: “1”cevap geri gelirken her kişi +1 eklerben
Şekil 4.1: Kuyrukta sırayı öğrenmek. Mavi oklar soruyu öne taşır: her kişi önündekine “Sen kaçıncısın?” diye sorar. En öndeki kişi (turuncu) kimseye sormadan “1” der. Yeşil oklar cevabı geri taşır; her kişi duyduğu sayıya 1 ekler: 1, 2, 3, 4, 5. Soruyu ilk soran kişi (“ben”) 5. sıradadır.

Şekil 4.1'deki süreçte üç ayrı parça vardır:

  • Aynı soru, daha kısa kuyruk. Her kişi önündekine aynı soruyu sorar. Ama soru her seferinde bir kişi daha kısa bir kuyruk için sorulur. Kimse farklı bir iş yapmaz; herkes aynı kuralı uygular.
  • Durma noktası. En öndeki kişi soruyu başkasına aktarmadan cevaplar. Sorular burada durur. Böyle bir kişi olmasaydı soru sonsuza kadar öne aktarılırdı.
  • Geri dönüş. Cevap geri gelirken her kişi kendi payını, yani 1'i ekler. Asıl hesap bu dönüş yolunda yapılır.

Özyinelemeli bir metot da tam böyle çalışır. Kendini daha küçük bir girdiyle çağırır. En küçük girdide cevabı doğrudan verir. Dönüşte küçük problemin cevabını kullanarak kendi cevabını kurar. Kuyruktaki her kişi, metodun bir çağrısına karşılık gelir. Her kişinin kendi aklında tuttuğu bir bilgi vardır (“arkamdakine cevap vereceğim”); metot çağrılarında da her çağrının kendi değişkenleri vardır.

4.1.2Taban durum ve özyinelemeli adım

Özyinelemenin en bilinen örneği faktöriyeldir. n!, 1'den n'ye kadar tam sayıların çarpımıdır: 4! = 4 · 3 · 2 · 1 = 24. Bu tanımda bir düzen göze çarpar. 4!'ün içinde 3 · 2 · 1 = 3! zaten vardır. Dolayısıyla 4! = 4 · 3! yazılabilir. Aynı gözlem her n > 1 için geçerlidir. Faktöriyel bu yüzden kendisi cinsinden tanımlanabilir:

n! = 1 (n ≤ 1 ise)  ·  n! = n · (n − 1)! (n > 1 ise)

Tanımın iki satırı iki farklı iş görür. İlk satır, en küçük girdiler için cevabı başka hiçbir şeye başvurmadan verir. İkinci satır, büyük bir girdinin cevabını bir küçüğünün cevabından kurar. 4!'ü bulmak için ikinci satır üç kez uygulanır, sonra birinci satır devreye girer: 4! = 4 · 3! = 4 · 3 · 2! = 4 · 3 · 2 · 1! = 4 · 3 · 2 · 1 = 24.

Tanım 4.1

Özyineleme (recursion), bir metodun kendini daha küçük bir girdiyle çağırmasıdır. Özyinelemeli bir metot iki parçadan oluşur:

  • Taban durum (base case): kendini çağırmadan, doğrudan çözülen girdiler. Özyineleme burada durur.
  • Özyinelemeli adım (recursive step): problemi küçültüp taban duruma yaklaştıran ve küçük problemin cevabından büyüğünkini kuran kısım.

Faktöriyel tanımı Java'ya neredeyse kelimesi kelimesine çevrilir:

int faktoriyel(int n) {
  if (n <= 1)
    return 1;               // taban durum
  return n * faktoriyel(n - 1);
}

// main içinde:
System.out.println(faktoriyel(4));

İkinci satırdaki if (n <= 1) koşulu taban durumu yakalar ve 1 döndürür. Son return satırı özyinelemeli adımdır: faktoriyel(n - 1) çağrısı daha küçük bir girdiyle aynı metodu çalıştırır, dönen değer n ile çarpılır. main içindeki satır faktoriyel(4)'ü çağırır ve 24 yazdırır.

Taban durum neden bu kadar önemlidir? Metot her çağrıda kendini yeniden çağırıyorsa bir yerde bu zincirin kopması gerekir. Taban durum o kopma noktasıdır. Taban durum olmazsa çağrılar hiç bitmez. Her çağrı bellekte biraz yer tuttuğu için bellek bir noktada dolar ve Java programı StackOverflowError hatasıyla durdurur. Bu hatanın nereden geldiği 4.2.3'te ayrıntılı anlatılır.

faktoriyel(4) = 4 · faktoriyel(3)= 24faktoriyel(3) = 3 · faktoriyel(2)= 6faktoriyel(2) = 2 · faktoriyel(1)= 2faktoriyel(1) = 1 (taban durum)= 1↓ çağrılar iner · ↑ değerler döner
Şekil 4.2: faktoriyel(4)'ün açılımı. Çağrılar yukarıdan aşağı iner: her kutu bir küçük girdiyle çağrı yapar. En alttaki faktoriyel(1) taban durumdur ve 1 döndürür (turuncu). Değerler yeşil oklarla yukarı döner: 1, 2, 6, 24.

Şekil 4.2 özyinelemenin iki yönlü hareketini gösterir. İniş sırasında hiçbir çarpma yapılmaz; her çağrı yalnızca kendi n'sini aklında tutup bir alttakini bekler. Çarpmalar çıkış sırasında yapılır: önce 2 · 1 = 2, sonra 3 · 2 = 6, en son 4 · 6 = 24.

Bir ayrıntıyı atlamamak gerekir. Aynı anda dört ayrı faktoriyel çağrısı bellekte durur ve her birinin kendi n değişkeni vardır: 4, 3, 2 ve 1. Bu dört n birbirine karışmaz. faktoriyel(1) çalışırken n = 1'dir; ama faktoriyel(4)'ün n'si hâlâ 4'tür ve sırası gelince 4 ile çarpma yapılır. Her çağrı bellekte kendine ait bir alan açar. Bu alana çerçeve denir; çerçeveler 4.2'nin konusudur.

Sık yapılan hata

Özyinelemeli metodu, aynı değişkenleri paylaşan bir döngü gibi düşünmek. “faktoriyel(1) çağrılınca n 1 oldu, artık 4 * ... satırında da n 1'dir” gibi bir akıl yürütme yanlıştır. Parametreler ve yerel değişkenler her çağrıda yeniden oluşturulur. Bir çağrının değişkenini değiştirmek, öteki çağrıların değişkenlerini etkilemez.

4.1.3Özyinelemeli düşünmek

Özyinelemeli bir metot yazarken bütün çağrı zincirini kafada canlandırmaya çalışmak yorucudur ve çoğu zaman gereksizdir. Bunun yerine üç soru sorulur:

  1. Taban durum: En küçük girdi hangisidir ve cevabı nedir? Özyineleme burada durur.
  2. Küçültme: Problem, aynı türden daha küçük bir probleme nasıl indirgenir? Küçültme çoğunlukla n − 1, n / 2 ya da bir indisi i + 1 yapmak biçimindedir.
  3. Birleştirme: Küçük problemin cevabından büyük problemin cevabı nasıl kurulur?

Üçüncü soru cevaplanırken işimizi kolaylaştıran bir kabul vardır: küçük problemin doğru çözüldüğü kabul edilir. faktoriyel(n)'i yazarken faktoriyel(n - 1)'in nasıl çalıştığı düşünülmez; onun (n − 1)! değerini doğru döndürdüğü varsayılır ve yalnızca son adım düşünülür: “(n − 1)! elimdeyse n!'yi nasıl bulurum? n ile çarparım.”

Bu varsayım bir hile değildir; matematikteki tümevarım ilkesinin aynısıdır. Tümevarımla bir önermenin her doğal sayı için doğru olduğunu göstermek için iki şey yeterlidir. Önerme en küçük sayı için doğrudur. Önerme n − 1 için doğruysa n için de doğrudur. Bu ikisi gösterilince önerme 1 için, dolayısıyla 2 için, dolayısıyla 3 için doğru olur ve zincir hiç kopmaz. Özyinelemeli metotta da aynıdır: taban durum doğru cevabı veriyorsa ve her adım, küçük cevap doğruysa büyük cevabı doğru kuruyorsa, metot her girdi için doğru çalışır.

Bir koşul daha vardır: her özyinelemeli çağrı girdiyi taban duruma yaklaştırmalıdır. Tümevarım zinciri ancak her adım bir öncekine bağlanıyorsa işler. Girdi küçülmüyorsa ya da taban durumu atlayıp öteye geçiyorsa zincirin başı hiç gelmez.

Bu üç soruyu basit bir örnekle cevaplayalım. geriSay(n) metodu, n'den 1'e kadar sayıları birer satıra yazsın, en sonda da “Bitti” yazsın.

Örnek 4.1
  1. Taban durum. Yazılacak sayı kalmadıysa, yani n = 0 ise, yalnızca “Bitti” yazılır ve metot biter.
  2. Küçültme. n'den 1'e geri saymak, önce n'yi yazıp sonra n − 1'den 1'e geri saymaktır. Küçük problem geriSay(n - 1)'dir.
  3. Birleştirme. Burada bir değer döndürülmez; iş, yazdırma sırasını doğru kurmaktır. Önce n yazılır, sonra küçük problem çözülür.

Her çağrıda n 1 azalır. Pozitif bir n ile başlanırsa n sonlu adımda 0'a ulaşır ve özyineleme durur.

Bu üç cevap doğrudan koda dönüşür:

void geriSay(int n) {
  if (n == 0) {     // taban durum
    System.out.println("Bitti");
    return;
  }
  System.out.println(n);
  geriSay(n - 1);   // özyinelemeli adım
}
geriSay(3);         // main içinde

Kodun yapısı, tasarımdaki üç parçayı tek tek yansıtır. 2–5. satırlar taban durumdur: n == 0 ise “Bitti” yazılır ve return ile metottan çıkılır. return burada önemlidir; olmasaydı akış 6. satıra geçer, 0 yazdırılır ve geriSay(-1) çağrılırdı. 6. satır n'yi yazar. 7. satır özyinelemeli adımdır ve daha küçük problemi çözer.

Sık yapılan hata

Taban durumu yalnızca “normal” girdiler için düşünmek. geriSay'e negatif bir sayı verilirse n −1, −2, −3 diye azalır ve 0'a hiç ulaşmaz. Metodun yalnızca negatif olmayan girdilerle çağrılacağı biliniyorsa bu kabul edilebilir; bilinmiyorsa taban durumu n <= 0 diye yazmak daha güvenlidir.

4.1.4Kod izleme: geriSay(3)

Özyinelemeyi anlamanın en sağlam yolu, küçük bir girdiyle kodu satır satır izlemektir. main, geriSay(3)'ü çağırıyor. İzlemede her adımda hangi çağrının çalıştığı, hangilerinin beklediği ve ekrana ne yazıldığı not edilir.

Tablo 4.1: geriSay(3) izlemesi. Çağrılar sütunu, o anda bellekte bulunan çağrıları dıştan içe sıralar; en sağdaki çalışan çağrıdır.
AdımOlayÇağrılarEkran
1main, geriSay(3)'ü çağırırmain
2n = 3 ≠ 0: 3 yazılır, geriSay(2) çağrılırmain, gS(3)3
3n = 2 ≠ 0: 2 yazılır, geriSay(1) çağrılırmain, gS(3), gS(2)3 2
4n = 1 ≠ 0: 1 yazılır, geriSay(0) çağrılırmain, gS(3), gS(2), gS(1)3 2 1
5n = 0: taban durum, “Bitti” yazılırmain, gS(3), gS(2), gS(1), gS(0)3 2 1 Bitti
6geriSay(0) return ile bitermain, gS(3), gS(2), gS(1)3 2 1 Bitti
7geriSay(1) 7. satırdaki çağrıdan döner; kalan iş yok, bitermain, gS(3), gS(2)3 2 1 Bitti
8geriSay(2) döner ve bitermain, gS(3)3 2 1 Bitti
9geriSay(3) döner ve biter; akış main'e geçermain3 2 1 Bitti

Tabloda gS, geriSay'in kısaltmasıdır. Çağrılar sütunu, özyinelemenin bellekteki izini gösterir. Sütun 2–5. adımlarda uzar, 6–9. adımlarda kısalır. En uzun hâlinde main ile birlikte 5 çağrı aynı anda bellektedir. Toplam 4 geriSay çağrısı yapılmıştır: n = 3, 2, 1, 0.

geriSay(3)yaz 3geriSay(2)yaz 2geriSay(1)yaz 1geriSay(0)yaz Bitti→ çağrı · ← dönüş
geriSay(3)yaz 3döndügeriSay(2)yaz 2döndügeriSay(1)yaz 1döndügeriSay(0)yaz Bittidöndü→ çağrı · ← dönüş
Şekil 4.3: geriSay(3) çağrı zinciri. Üstte en derin an (Tablo 4.1, 5. adım): üç çağrı bekliyor, kırmızı çerçeveli geriSay(0) taban durumda “Bitti” yazıyor. Altta son durum: dört çağrı da döndü (yeşil). Mavi oklar çağrıları, yeşil oklar dönüşleri gösterir.

Dönüş yolunda (6–9. adımlar) ekrana hiçbir şey yazılmaz. Her çağrı 7. satırdaki özyinelemeli çağrıdan döndüğünde metodun sonuna gelmiştir; yapacak işi kalmamıştır. Bütün yazdırmalar iniş yolunda yapılır. Bu durum, yazdırma satırının çağrıdan önce gelmesinin sonucudur. Satırların yeri değişirse çıktı da değişir.

4.1.5İş çağrıdan önce mi, sonra mı?

Özyinelemeli bir metotta bir işin özyinelemeli çağrıdan önce mi, sonra mı yapıldığı sonucu doğrudan belirler. Bunu iki kısa metotla görelim. İkisi de taban durumda hiçbir şey yapmaz; tek fark yazdırma satırının yeridir.

void geriSay2(int n) {
  if (n == 0) return;
  System.out.print(n + " ");
  geriSay2(n - 1);     // sonra çağrı
}
void ileriSay(int n) {
  if (n == 0) return;
  ileriSay(n - 1);     // önce çağrı
  System.out.print(n + " ");
}

geriSay2(3) çıktısı 3 2 1'dir. Yazdırma çağrıdan önce olduğu için her çağrı önce kendi sayısını yazar, sonra daha küçük problemi çözmeye geçer. Yazdırmalar çağrılar inerken yapılır.

ileriSay(3) çıktısı ise 1 2 3'tür. Burada her çağrı önce özyinelemeli çağrıyı yapar ve onun bitmesini bekler. ileriSay(3), 3'ü yazmadan önce ileriSay(2)'nin bitmesini bekler. ileriSay(2) de 2'yi yazmadan önce ileriSay(1)'i bekler. ileriSay(1), ileriSay(0)'ı çağırır; o taban durumdur ve hemen döner. İlk yazdırmayı bu yüzden ileriSay(1) yapar: 1. Sonra ileriSay(2) kaldığı yerden devam eder ve 2'yi yazar. En son ileriSay(3) 3'ü yazar. Yazdırmalar çağrılar dönerken yapılır.

Örnek 4.2

ileriSay(3) izlemesi:

  1. ileriSay(3): n ≠ 0, ileriSay(2) çağrılır. 3'ün yazdırılması bekletilir.
  2. ileriSay(2): ileriSay(1) çağrılır. 2'nin yazdırılması bekletilir.
  3. ileriSay(1): ileriSay(0) çağrılır. 1'in yazdırılması bekletilir.
  4. ileriSay(0): taban durum, hemen döner.
  5. ileriSay(1) kaldığı yerden sürer: 1 yazar ve biter.
  6. ileriSay(2) sürer: 2 yazar ve biter.
  7. ileriSay(3) sürer: 3 yazar ve biter.

Çıktı: 1 2 3. En derin anda bellekte main ve dört ileriSay çağrısı vardır.

İlk biten çağrı, en son başlayan çağrıdır. Bu yüzden çağrıdan sonra yapılan işler, çağrıların başlama sırasının tersiyle yapılır. Çağrıdan sonra kalan iş kaybolmaz: çağrının bilgileri bellekte bekler ve sırası gelince bu iş yapılır. Bu gözlem bölüm boyunca birçok kez işe yarar. Tek yönlü bir bağlı listeyi sondan başa yazdırmak (4.8.3) ve Hanoi kulelerinde ikinci yarıyı taşımak (4.5.3) bu sayede mümkün olur.

Sık yapılan hata

ileriSay(3)'ün çıktısını 3 2 1 sanmak. Çağrılar 3, 2, 1 sırasıyla başlar, ama yazdırma satırı çağrıdan sonra olduğu için hiçbiri çağrı dönmeden yazamaz. Bir özyinelemeli metodun çıktısı sorulduğunda önce yazdırmanın çağrıdan önce mi, sonra mı yapıldığına bakılır.

4.2Çağrı yığını

Önceki kesimde aynı metodun birden çok çağrısının bellekte aynı anda durduğunu ve her birinin kendi değişkenlerini taşıdığını gördük. Java bu çağrıları nerede ve hangi düzenle tutar? Bu düzen anlaşılınca özyinelemeli bir metodun hem çıktısı hem de bellek maliyeti kolayca bulunur.

4.2.1Çerçeveler ve çağrı yığını

Bir metot çağrıldığında Java, o çağrı için bellekte küçük bir alan ayırır. Bu alanda üç bilgi bulunur: metodun parametreleri, metodun yerel değişkenleri ve metot bittiğinde programın nereden devam edeceği. Son bilgiye dönüş adresi denir. Örneğin faktoriyel(3), faktoriyel(2)'yi çağırdığında dönüş adresi “n * … çarpmasının yapılacağı yer”dir.

Tanım 4.2

Çerçeve (stack frame), çağrı yığınında bir metot çağrısının kaydıdır: parametreleri, yerel değişkenleri ve dönüşte devam edilecek yeri tutar. Çerçeveler çağrı yığını (call stack) denen bellek alanında saklanır. Bir çağrı yapılınca yeni çerçeve yığının üstüne eklenir; metot bitince çerçevesi üstten çıkarılır.

Çağrı yığını adını, yığın veri yapısının kuralına uymasından alır: son eklenen ilk çıkar. Bu kurala LIFO (Last-In, First-Out: son giren ilk çıkar) denir. Metot çağrılarının doğası da böyledir. main bir f metodunu, f de g'yi çağırırsa önce g biter, sonra f, en son main. En son başlayan çağrı ilk biter.

Çağrı yığınının üç özelliği özyinelemeyi anlamak için yeterlidir:

  • Yalnız en üstteki çerçeve çalışır. Alttaki çerçeveler askıdadır; her biri, hemen üstündeki çağrının sonucunu bekler.
  • Aynı metodun birden çok çerçevesi aynı anda yığında bulunabilir. Özyinelemede durum tam budur. Her çerçevenin n'si ayrıdır.
  • Bir çağrı bitince çerçevesi silinir. Dönüş değeri, artık en üstte olan çağıran çerçeveye verilir ve o çerçeve dönüş adresinden devam eder.
faktoriyel(1)n = 1return 1faktoriyel(2)n = 2bekliyor: 2 · ?faktoriyel(3)n = 3bekliyor: 3 · ?main()bekliyor: println(?)← üsther çerçevenin kendi n değişkeni var
Şekil 4.4: faktoriyel(3) en derindeyken çağrı yığını. Altta main, üstünde sırayla faktoriyel(3), faktoriyel(2) ve faktoriyel(1) çerçeveleri vardır. Yalnız en üstteki (kırmızı çerçeveli) çalışır ve 1 döndürmek üzeredir. Alttakiler bekler: faktoriyel(2) “2 · ?” çarpmasını, faktoriyel(3) “3 · ?” çarpmasını, main ise yazdırmayı tamamlamak için. Her çerçevenin kendi n değişkeni vardır.

Şekil 4.4, özyinelemenin bellek maliyetini de açıklar. Yığında aynı anda dört çerçeve vardır; faktoriyel(n) için bu sayı n + 1 olur (main dahil). Her çerçeve sabit büyüklükte yer kapladığından bellek kullanımı n ile doğru orantılı büyür: O(n).

Bu gözlem genel bir kurala dönüşür. Özyinelemeli bir metodun bellek maliyeti, yapılan toplam çağrı sayısıyla değil, yığında aynı anda bulunan en çok çerçeve sayısıyla belirlenir. Bu sayı, en derin çağrı zincirinin uzunluğudur. Toplam çağrı sayısı ile en derin zincir, ikili özyinelemede birbirinden çok farklıdır; bu fark 4.6.5'te ayrıntılı işlenir.

Sık yapılan hata

Çağrı yığınının derinliğini sayarken main'i unutmak ya da taban durum çağrısını saymamak. faktoriyel(4) çalışırken yığın en derin anda main ve faktoriyel(4), faktoriyel(3), faktoriyel(2), faktoriyel(1) çerçevelerini içerir: 4 + 1 = 5 çerçeve. Soru “main dahil” mi yoksa yalnız özyinelemeli çağrıları mı soruyor, buna dikkat etmek gerekir.

4.2.2Kod izleme: faktoriyel(4)

4.1.2'deki faktoriyel metodunu faktoriyel(4) çağrısıyla, çağrı yığınının her adımdaki durumuna bakarak izleyelim. Kod şöyledir:

int faktoriyel(int n) {
  if (n <= 1)
    return 1;               // taban durum
  return n * faktoriyel(n - 1);
}

// main içinde:
System.out.println(faktoriyel(4));

Her çağrıda yalnızca iki satır önemlidir. 2. satırdaki karşılaştırma taban durumu ayırır. 4. satır ise önce faktoriyel(n - 1)'i çağırır, dönen değeri n ile çarpar ve sonucu döndürür. Çarpma, çağrı dönmeden yapılamaz. Çağrı yapılırken çerçeve bu çarpmayı “yarım kalmış iş” olarak saklar.

Tablo 4.2: faktoriyel(4) izlemesi. Çağrı yığını alttan üste yazılmıştır; f, faktoriyel'in kısaltmasıdır.
AdımOlayÇağrı yığını (alttan üste)Dönen
1main, faktoriyel(4)'ü çağırırmain
2Yeni çerçeve: n = 4. 4 ≤ 1 mi? Hayırmain, f(4)
34 * faktoriyel(3): önce faktoriyel(3) bitmeli; bu çerçeve beklermain, f(4)
4Yeni çerçeve: n = 3. 3 ≤ 1 mi? Hayırmain, f(4), f(3)
53 * faktoriyel(2): bu çerçeve beklermain, f(4), f(3)
6Yeni çerçeve: n = 2. 2 ≤ 1 mi? Hayırmain, f(4), f(3), f(2)
72 * faktoriyel(1): bu çerçeve beklermain, f(4), f(3), f(2)
8Yeni çerçeve: n = 1. 1 ≤ 1 mi? Evetmain, f(4), f(3), f(2), f(1)
9Taban durum: return 1main, f(4), f(3), f(2), f(1)1
10faktoriyel(1) çıktı; 2 · 1 = 2, return 2main, f(4), f(3), f(2)2
11faktoriyel(2) çıktı; 3 · 2 = 6, return 6main, f(4), f(3)6
12faktoriyel(3) çıktı; 4 · 6 = 24, return 24main, f(4)24
13faktoriyel(4) çıktı; main 24 yazdırırmain

Tablodaki 2–8. adımlar iniş evresidir. Her adımda yığına bir çerçeve eklenir; hiçbir çarpma yapılmaz. 9. adımda taban duruma ulaşılır ve ilk somut değer, 1, üretilir. 10–13. adımlar dönüş evresidir. Her adımda bir çerçeve yığından çıkar ve hemen altındaki çerçeve yarım kalan çarpmasını tamamlar. Yığının bu biçimde geriye doğru boşalmasına çözülme (unwinding) de denir.

faktoriyel(4)4 · ? (bekliyor)faktoriyel(3)3 · ? (bekliyor)faktoriyel(2)2 · ? (bekliyor)faktoriyel(1)taban durum→ çağrı · ← dönen değer
faktoriyel(4)4 · 6faktoriyel(3)3 · 2= 6faktoriyel(2)2 · 1= 2faktoriyel(1)taban durum= 1→ çağrı · ← dönen değer
faktoriyel(4)4 · 6= 24faktoriyel(3)3 · 2= 6faktoriyel(2)2 · 1= 2faktoriyel(1)taban durum= 1→ çağrı · ← dönen değer
Şekil 4.5: faktoriyel(4) izlemesinden üç an. Üstte en derin an (Tablo 4.2, 8. adım): üç çağrı bekliyor (mavi), faktoriyel(1) taban durumda (turuncu). Ortada 11. adım: faktoriyel(1) ve faktoriyel(2) değerlerini döndürdü (yeşil), faktoriyel(3) 3 · 2 = 6 hesaplıyor. Altta son durum: faktoriyel(4) 4 · 6 = 24 döndürdü.

Maliyeti bu izlemeden okumak kolaydır. Toplam 4 çağrı yapılmıştır. Her çağrı sabit miktarda iş yapar: bir karşılaştırma, en çok bir çarpma ve bir dönüş. Zaman bu yüzden O(n)'dir. Yığın en derin anda 4 faktoriyel çerçevesi ve main içerir; bellek de O(n)'dir. Aynı çarpımı bir döngü O(1) bellekle yapar. Özyinelemeli sürüm bu ek belleği, tanıma birebir uyan kısa bir kod için öder.

Sık yapılan hata

int türünün sınırını unutmak. faktoriyel int döndürür ve int en çok 231 − 1 ≈ 2,1 milyar tutar. 12! = 479.001.600 sığar, ama 13! = 6.227.020.800 sığmaz ve sonuç sessizce yanlış çıkar. Daha büyük değerler için long (en çok 20!) ya da BigInteger kullanılır. Bu, özyinelemeden bağımsız bir taşmadır; çağrı yığınının taşmasıyla karıştırılmamalıdır.

4.2.3Yığın taşması

Çağrı yığını sınırsız değildir. JVM (Java Virtual Machine, Java sanal makinesi) her iş parçacığı için yığına sabit büyüklükte bir bellek ayırır. Bu büyüklük JVM'e ve işletim sistemine göre değişir ve JVM'in -Xss seçeneğiyle ayarlanabilir. Her çerçeve bu alandan biraz yer kaplar. Varsayılan ayarlarla özyineleme derinliği genellikle birkaç bin ile birkaç on bin çağrıyla sınırlıdır.

Aşağıdaki metot 1'den n'ye kadar sayıları toplar:

long topla(int n) {        // 1 + 2 + … + n
  if (n == 0) return 0;
  return n + topla(n - 1);
}
// main içinde:
System.out.println(topla(10_000));   // 50005000
System.out.println(topla(1_000_000)); // hata

topla(10_000) sorunsuz çalışır ve 50005000 yazar. Yığında aynı anda 10.001 topla çerçevesi bulunur; bu, varsayılan yığına sığar. topla(1_000_000) ise taban duruma ulaşmadan yığını doldurur. Bir milyon çerçeve için yer yoktur. Java bu durumda programı şu hatayla durdurur:

Exception in thread "main"
  java.lang.StackOverflowError
    at Ornek.topla(Ornek.java:3)
    at Ornek.topla(Ornek.java:3)
    …

Hata mesajının altındaki satırlar, hata anındaki çağrı yığınının dökümüdür. Aynı satırın (Ornek.topla(Ornek.java:3)) binlerce kez tekrarlanması, sorunun özyinelemeden geldiğini hemen gösterir. Bu döküme yığın izi (stack trace) denir.

Bir ölçüm fikir verir. Hiçbir iş yapmadan yalnız kendini çağıran bir metot, varsayılan ayarlarla yaklaşık 21.800 derinliğe inebildi. Çerçevesi biraz daha büyük olan topla için sınır daha düşüktü. Kesin değerin önemi yoktur ve makineden makineye değişir. Önemli olan sınırın var olması ve büyüklük derecesidir: on binler mertebesinde bir derinlik çoğu zaman sorun çıkarmaz, milyonlar mertebesinde bir derinlik ise neredeyse her zaman taşar.

Aynı toplam, özyinelemeye hiç gerek kalmadan hesaplanabilir. Bir döngü O(n) zaman ve O(1) bellek kullanır. Daha da iyisi, 1 + 2 + … + n = n(n + 1)/2 formülü sonucu O(1) zamanda verir. n = 1.000.000 için bu 500.000.500.000'dir; sonuç int'e sığmadığı için metodun long döndürmesi doğru bir seçimdir.

Sık yapılan hata

StackOverflowError'ı try–catch ile yakalayıp programı sürdürmeye çalışmak. Bu hata Exception değil, Error'dır; JVM'in ciddi bir kaynak sorununu bildirir. Doğru çözüm, özyinelemenin derinliğini azaltmak (örneğin problemi her adımda yarıya bölmek) ya da metodu döngüye çevirmektir.

4.2.4Taban durumu unutmak

Yığın taşmasının en sık nedeni çok büyük bir girdi değil, hatalı yazılmış bir taban durumdur. Üç tipik hata vardır. Üçünün de sonucu aynıdır, ama nedenleri farklıdır.

1. Taban durum yok.

int f(int n) {
  return n * f(n - 1);
}

Metot her çağrıda kendini yeniden çağırır. f(3) için girdiler 3, 2, 1, 0, −1, −2, … diye sürer. Hiçbir çağrı geri dönmez; yığın dolar.

2. Taban duruma ulaşılamıyor.

if (n == 1) return 1;
return n * f(n - 1);

Burada taban durum vardır, ama yalnız n = 1'i yakalar. Metot f(0) ile çağrılırsa girdiler 0, −1, −2, … diye azalır ve n hiçbir zaman 1 olmaz. Koşulu n <= 1 yapmak bu girdiyi de yakalar: f(0) hemen 1 döndürür. Benzer bir durum, girdinin her çağrıda 2 azaldığı metotlarda da görülür. n * f(n - 2) biçimindeki bir metot n == 1 taban durumuyla tek sayılarda durur, çift sayılarda 2, 0, −2, … diye sürer ve taşar.

3. Problem küçülmüyor.

if (n <= 1) return 1;
return n * f(n);

Bu kez taban durum doğrudur, ama özyinelemeli adım girdiyi küçültmez: f(n), yine f(n)'yi çağırır. f(1) doğru cevap verir; f(2) ise sonsuza kadar f(2)'yi çağırır. Taban durum vardır ama ona hiç yaklaşılmaz.

Örnek 4.3

Aşağıdaki metot n'ye kadar çift sayıların toplamını bulmak için yazılmıştır: int g(int n) { if (n == 0) return 0; return n + g(n - 2); }. g(6) ve g(5) ne yapar?

  1. g(6): 6 ≠ 0, 6 + g(4).
  2. g(4): 4 + g(2); g(2): 2 + g(0).
  3. g(0): taban durum, 0 döner. Dönüşte 2 + 0 = 2, 4 + 2 = 6, 6 + 6 = 12. g(6) = 12; 4 çağrı yapılmıştır.
  4. g(5): 5 + g(3); g(3): 3 + g(1); g(1): 1 + g(-1); … Girdi hep tek kalır ve 0'a hiç eşit olmaz. Sonuç StackOverflowError.

Taban durum n <= 0 yapılırsa g(5), g(-1)'de durur ve 5 + 3 + 1 + 0 = 9 döndürür. Metot artık her girdide durur; ama tek bir n için tek sayıları topladığı da görülür. Metodun amacına göre ya girdinin çift olması şart koşulur ya da tek girdide önce n − 1'e geçilir.

Bu üç hatadan çıkan kural şudur: her olası girdi, 0 ve negatif değerler dahil, sonlu sayıda adımda bir taban duruma ulaşmalıdır. Bir özyinelemeli metot yazıldıktan sonra iki soru sorulur. Hangi girdiler doğrudan taban durumda biter? Öteki her girdi, her çağrıda bunlardan birine yaklaşıyor mu? İki sorunun cevabı da “evet” değilse metot bazı girdilerde taşar.

Sık yapılan hata

Taban durumu yalnızca örnek girdiyle denemek. f(5) doğru çalıştı diye metodun her girdide çalıştığı söylenemez. 0, 1, negatif bir sayı ve tek/çift ayrımı önemliyse her iki türden birer girdi ayrıca denenmelidir.

4.3Doğrusal özyineleme

Şimdiye kadarki örneklerin hepsinde her çağrı en çok bir özyinelemeli çağrı yaptı. Bu, özyinelemenin en sade türüdür. Adını çağrıların oluşturduğu biçimden alır: çağrılar dallanmayan, düz bir zincir oluşturur. Kesimin konusu aynı türden üç problemdir: dizinin toplamı, dizinin tersine çevrilmesi ve üs alma. Son örnek, zincirin uzunluğunu kısaltmanın maliyeti nasıl değiştirdiğini gösterir.

4.3.1Tanım ve dizinin toplamı

Tanım 4.3

Doğrusal özyineleme (linear recursion), her çağrının en fazla bir özyinelemeli çağrı yaptığı özyinelemedir. n boyutlu problem, n − 1 (ya da n / 2) boyutlu tek bir probleme indirgenir; çağrılar dallanmayan bir zincir oluşturur.

faktoriyel, geriSay ve topla doğrusal özyinelemenin örnekleridir. Dizinin toplamı, diziyi tersine çevirme, üs alma ve bağlı listenin uzunluğu da bu türdendir.

Bir tamsayı dizisindeki öğelerin toplamını özyinelemeyle bulalım. Özyinelemeli düşünmenin üç sorusu (4.1.3) burada da yol gösterir. Ama önce problemi küçültülebilir bir biçimde yazmak gerekir. “Dizinin toplamı” yerine “dizinin ilk n öğesinin toplamı” diye düşünülürse n küçültülebilecek bir parametre olur.

  • Taban durum: n = 0 ise parça boştur; boş parçanın toplamı 0'dır.
  • Küçültme: İlk n öğenin toplamı, ilk n − 1 öğenin toplamına dayandırılır.
  • Birleştirme: İlk n − 1 öğenin toplamına son öğe a[n - 1] eklenir.

Dizi indisleri 0'dan başladığı için ilk n öğe a[0], …, a[n - 1]'dir; sondaki öğe a[n] değil a[n - 1]'dir. Kod şöyledir:

int toplam(int[] a, int n) {   // ilk n öğenin toplamı
  if (n == 0)
    return 0;                   // taban durum
  return toplam(a, n - 1) + a[n - 1];
}

Metodun ikinci parametresi, dizinin hangi parçasıyla ilgilenildiğini söyler. Dizinin kendisi hiçbir çağrıda değişmez ve kopyalanmaz. Java'da diziler referansla geçirilir: her çerçevede a, aynı dizinin adresini tutar. Böylece her çağrı yalnız bir referans ve bir tamsayı taşır. Bütün dizinin toplamı için ilk çağrı toplam(a, a.length) biçiminde yapılır.

40316223toplam(a, 3) = 13a[3] = 2toplam(a, 4) = 13 + 2 = 15
Şekil 4.6: a = {4, 3, 6, 2} için tek adım. İlk üç öğenin toplamı toplam(a, 3) = 13'tür; son öğe a[3] = 2 eklenince toplam(a, 4) = 13 + 2 = 15 bulunur.

Şekil 4.6 özyinelemeli düşünmenin özünü gösterir. toplam(a, 4)'ü yazan kişinin toplam(a, 3)'ün 13'ü nasıl bulduğunu bilmesi gerekmez. 13'ün doğru olduğunu kabul eder ve yalnızca son öğeyi ekler.

4.3.2Kod izleme: toplam(a, 4)

int toplam(int[] a, int n) {   // ilk n öğenin toplamı
  if (n == 0)
    return 0;                   // taban durum
  return toplam(a, n - 1) + a[n - 1];
}
// main içinde:
int[] a = {4, 3, 6, 2};
System.out.println(toplam(a, 4));

main, a = {4, 3, 6, 2} dizisi için toplam(a, 4)'ü çağırıyor. Her çağrı önce bir küçük parçanın toplamını ister; kendi öğesini toplamaya eklemeyi dönüşe bırakır.

Tablo 4.3: toplam(a, 4) izlemesi, a = {4, 3, 6, 2}.
AdımÇağrıOlayDönen
1toplam(a, 4)n ≠ 0: önce toplam(a, 3); a[3] = 2 bekler
2toplam(a, 3)önce toplam(a, 2); a[2] = 6 bekler
3toplam(a, 2)önce toplam(a, 1); a[1] = 3 bekler
4toplam(a, 1)önce toplam(a, 0); a[0] = 4 bekler
5toplam(a, 0)taban durum: boş parça0
6toplam(a, 1)0 + a[0] = 0 + 44
7toplam(a, 2)4 + a[1] = 4 + 37
8toplam(a, 3)7 + a[2] = 7 + 613
9toplam(a, 4)13 + a[3] = 13 + 215

5. adımda yığın en derindedir: main ve 5 toplam çerçevesi. Toplamalar dönüş yolunda, dizinin başından sonuna doğru yapılır: önce a[0], en son a[3]. Bunun nedeni, toplama işleminin özyinelemeli çağrıdan sonra yapılmasıdır; 4.1.5'teki ileriSay ile aynı durumdur.

40316223toplam(a, 0) = 0 (boş parça)dönen değerler: 0
40316223a[3]toplam(a, 4) = 13 + 2 = 15dönen değerler: 0 → 4 → 7 → 13 → 15
Şekil 4.7: toplam(a, 4) izlemesinden iki an. Üstte taban durum (Tablo 4.3, 5. adım): boş parça soluk gösterilmiştir ve toplam(a, 0) 0 döndürür. Altta son durum: dönen değerler sırasıyla 0, 4, 7, 13, 15'tir; en son a[3] = 2 eklenmiştir.

Maliyet: n öğeli parça için n + 1 çağrı yapılır (n, n − 1, …, 0). Her çağrı bir karşılaştırma, bir toplama ve bir dizi erişimi yapar; zaman O(n)'dir. Yığın en derin anda n + 1 çerçeve (ve main) içerir; bellek de O(n)'dir.

Sık yapılan hata

Birleştirme adımında a[n] yazmak. İlk n öğenin sonuncusu a[n - 1]'dir. a[n] yazılırsa ilk çağrıda (n = a.length) dizinin dışına çıkılır ve ArrayIndexOutOfBoundsException fırlatılır. Taban durum n == 0 olduğu için a[0] da hiç toplanmaz.

Aynı kalıp dizinin başka özelliklerine de uygulanır. Örneğin dizinin en büyük öğesi de ilk n öğe üzerinden düşünülebilir. Taban durum n = 1'dir: tek öğeli parçanın en büyüğü a[0]'dır (boş parçanın en büyüğü tanımsızdır). Özyinelemeli adım, ilk n − 1 öğenin en büyüğünü son öğeyle karşılaştırır: Math.max(enBuyuk(a, n - 1), a[n - 1]). Maliyet yine O(n) zaman ve O(n) yığındır.

4.3.3Diziyi tersine çevirme

Bir diziyi yerinde, yani ikinci bir dizi kullanmadan, tersine çevirmek isteyelim. Fikir şudur: dizinin iki ucundaki öğelerin yeri değiştirilir, sonra aradaki parça aynı yöntemle tersine çevrilir. Aradaki parça, iki öğe kısa bir dizidir; problem böylece küçülür.

Bu kez parça, başlangıç ve bitiş indisleriyle verilir: a[i..j], i'den j'ye kadar (ikisi dahil) öğeleri gösterir. İlk çağrı bütün dizi için yapılır: ters(a, 0, n - 1).

  • Taban durum: i >= j ise parçada 0 ya da 1 öğe kalmıştır. Tek öğeli ya da boş bir parça zaten kendi tersidir; yapılacak iş yoktur.
  • Küçültme ve birleştirme: a[i] ile a[j] yer değiştirir; ardından iç parça a[i+1..j-1] tersine çevrilir.
void ters(int[] a, int i, int j) {
  if (i >= j)
    return;                    // taban: 0 ya da 1 öğe
  int t = a[i]; a[i] = a[j]; a[j] = t;  // uçlar
  ters(a, i + 1, j - 1);       // içteki parça
}
// main içinde:
int[] a = {2, 7, 4, 9, 1, 6};
ters(a, 0, a.length - 1);

Bu metodun öncekilerden iki farkı vardır. Birincisi, bir değer döndürmez (void). İş, dizinin kendisi değiştirilerek yapılır. Bütün çağrılar aynı diziyi gördüğü için bir çağrının yaptığı değiştirme ötekilere de yansır. İkincisi, iş özyinelemeli çağrıdan önce yapılır; dönüş yolunda yapılacak bir şey kalmaz.

2071429314651.2.3.sonuç: 6 1 9 4 7 2
Şekil 4.8: {2, 7, 4, 9, 1, 6} dizisini tersine çevirmek. Önce 0. ile 5. öğe, sonra 1. ile 4. öğe, en son 2. ile 3. öğe yer değiştirir. Sonuç: 6 1 9 4 7 2.

Kaç değiştirme ve kaç çağrı yapılır? Her çağrı parçayı 2 öğe kısaltır. n öğeli dizide ⌊n/2⌋ değiştirme yapılır; buradaki ⌊ ⌋ işareti aşağı yuvarlamayı gösterir. Değiştirme yapan her çağrıya ek olarak, en sonda taban duruma düşen bir çağrı daha vardır. Toplam çağrı sayısı ⌊n/2⌋ + 1'dir. 6 öğeli dizi için 3 değiştirme ve 4 çağrı; 7 öğeli dizi için de 3 değiştirme ve 4 çağrı (ortadaki öğe yerinde kalır). Zaman O(n), yığın belleği O(n)'dir.

4.3.4Kod izleme: ters(a, 0, 5)

a = {2, 7, 4, 9, 1, 6} için ters(a, 0, 5) çağrısını izleyelim. Tabloda her çağrının i ve j değerleri ve çağrının sonunda dizinin durumu verilmiştir.

Tablo 4.4: ters(a, 0, 5) izlemesi.
Çağrıi, jOlayDizi
—başlangıç2 7 4 9 1 6
10, 50 < 5: a[0] ↔ a[5], sonra ters(a, 1, 4)6 7 4 9 1 2
21, 41 < 4: a[1] ↔ a[4], sonra ters(a, 2, 3)6 1 4 9 7 2
32, 32 < 3: a[2] ↔ a[3], sonra ters(a, 3, 2)6 1 9 4 7 2
43, 23 ≥ 2: taban durum, döner6 1 9 4 7 2

4. çağrıda yığında main ve 4 ters çerçevesi vardır. Bu çağrı döner; ardından 3., 2. ve 1. çağrılar sırayla döner. Dönüşte hiçbirinin yapacak işi yoktur, çünkü ters(a, i + 1, j - 1) satırı metodun son satırıdır. Sonuç: 6 1 9 4 7 2; 3 değiştirme, 4 çağrı.

607142931425ija[0] ↔ a[5] değişti
601192437425iji = 3 ≥ j = 2 → taban durum
Şekil 4.9: ters(a, 0, 5) izlemesinden iki an. Üstte ilk değiştirme: a[0] ile a[5] yer değiştirmiş, dizi 6 7 4 9 1 2 olmuştur. Altta taban durum: i = 3, j = 2; indisler birbirini geçmiş, bütün öğeler yerine oturmuştur (yeşil).
Sık yapılan hata

Taban durumu i == j diye yazmak. Tek sayıda öğeli dizide indisler ortada buluşur ve i == j olur; ama çift sayıda öğeli dizide hiç eşit olmazlar. 6 öğeli dizide 3. çağrıdan sonra i = 3, j = 2 olur; i == j yanlıştır, değiştirme yeniden yapılır ve dizi bozulmaya başlar. Çağrılar (4, 1), (5, 0) ve (6, −1) ile sürer; sonuncusunda a[6]'ya erişilir ve ArrayIndexOutOfBoundsException fırlatılır. Doğru koşul i >= j'dir.

4.3.5Üs alma: yavaş ve hızlı

xn, x'in kendisiyle n kez çarpımıdır. Bu tanım hemen bir özyineleme verir: xn = x · xn−1 ve x0 = 1.

long us(long x, int n) {
  if (n == 0) return 1;
  return x * us(x, n - 1);
}

Bu metot doğrudur, ama yavaştır. n her çağrıda yalnızca 1 azalır; us(x, n) n + 1 çağrı yapar (n, n − 1, …, 0). Zaman O(n), yığın belleği O(n)'dir. n = 1.000.000 için bir milyon çerçeve gerekir ve yığın taşar.

Daha hızlı bir yol için üslerin bir özelliğinden yararlanılır: x10 = x5 · x5 = (x5)2. x5 bir kez hesaplanıp karesi alınırsa x10 bulunur. Böylece üs her adımda yarıya iner. Üs tek sayıysa yarıya tam bölünmez; o zaman bir x dışarıda bırakılır: x5 = x · x4 = x · (x2)2. Genel kural şudur:

xn = (xn/2)2 (n çift)  ·  xn = x · (x(n−1)/2)2 (n tek)

Java'da tamsayı bölmesi aşağı yuvarladığı için tek n için n / 2, (n − 1)/2'ye eşittir. İki durumu da n / 2 ile yazmak mümkündür. Örneğin 210 şöyle açılır:

210 = (25)2,   25 = 2 · (22)2,   22 = (21)2,   21 = 2 · (20)2

long hizliUs(long x, int n) {
  if (n == 0) return 1;           // taban durum
  long y = hizliUs(x, n / 2);     // x^(n/2)
  if (n % 2 == 0) return y * y;   // n çift
  return x * y * y;               // n tek
}
// main içinde:
System.out.println(hizliUs(2, 10));

3. satırdaki y değişkeni bu metodun püf noktasıdır. xn/2 bir kez hesaplanır ve y'de saklanır; kare almak için y * y yazılır. hizliUs(x, n / 2) * hizliUs(x, n / 2) yazılsaydı aynı değer iki kez hesaplanır ve kazanç kaybolurdu (bu tuzak 4.4.3'te yeniden ele alınır). 4. satır çift üs için kareyi döndürür. 5. satıra yalnız tek üste gelinir; burada kareye bir x çarpanı daha eklenir.

Hızlı sürüm kaç çağrı yapar? Her çağrıda n yarıya iner. n'yi 1'e indirmek için gereken yarılama sayısı ⌊log2 n⌋'dir. Buna 1'in kendi çağrısı ve n = 0 taban durum çağrısı eklenir: toplam ⌊log2 n⌋ + 2 çağrı (n ≥ 1 için). Zaman ve yığın belleği O(log n)'dir.

Tablo 4.5: us ve hizliUs çağrı sayıları.
nus: çağrıhizliUs: çağrı
10115
1001018
1.0001.00111
1061.000.00121

Tablo 4.5 iki büyüme hızı arasındaki farkı somutlaştırır. n bin kat büyüyünce us'un çağrı sayısı da bin kat büyür; hizliUs'unki yalnız yaklaşık 10 artar. Bir milyonluk üs için us yığını taşırır, hizliUs ise 21 çerçeveyle işini bitirir. Bu fark, problemi her adımda sabit miktarda değil, sabit oranda küçültmenin sonucudur.

Sık yapılan hata

Hızlı üs almanın sonucunun taşmayacağını sanmak. Çağrı sayısı azdır, ama sonuç yine çok büyüktür. long en çok yaklaşık 9,2 · 1018 tutar; 263 ve daha büyük kuvvetler sessizce yanlış çıkar. Hızlı üs alma pratikte çoğunlukla bir modüle göre (örneğin her çarpmadan sonra % m alınarak) kullanılır.

4.3.6Kod izleme: hizliUs(2, 10)

main, hizliUs(2, 10)'u çağırıyor. İniş yolunda yalnız üs yarıya indirilir; çarpmaların hepsi dönüş yolunda yapılır.

Tablo 4.6: hizliUs(2, 10) izlemesi. İlk beş satır iniş, son beş satır dönüştür.
AdımÇağrıOlayDönen
1hizliUs(2, 10)n ≠ 0: önce y = hizliUs(2, 5) (10 / 2 = 5)
2hizliUs(2, 5)önce y = hizliUs(2, 2) (5 / 2 = 2)
3hizliUs(2, 2)önce y = hizliUs(2, 1) (2 / 2 = 1)
4hizliUs(2, 1)önce y = hizliUs(2, 0) (1 / 2 = 0)
5hizliUs(2, 0)taban durum: 20 = 11
6hizliUs(2, 1)y = 1; n = 1 tek: 2 · 1 · 12
7hizliUs(2, 2)y = 2; n = 2 çift: 2 · 24
8hizliUs(2, 5)y = 4; n = 5 tek: 2 · 4 · 432
9hizliUs(2, 10)y = 32; n = 10 çift: 32 · 321024

Sonuç 1024'tür ve yalnız 5 çağrıyla bulunmuştur; yavaş us(2, 10) 11 çağrı yapardı. Dönen değerler dikkatle incelenirse 1, 2, 4, 32, 1024 dizisi görülür. Bunlar sırasıyla 20, 21, 22, 25, 210'dur. Üsler 0, 1, 2, 5, 10'dur; bu sayılar 10'un ikili gösterimi olan 1010'un soldan büyüyen önekleridir (1, 10, 101, 1010). Tek üslü adımlar, ikili gösterimdeki 1 basamaklarına karşılık gelir.

hizliUs(2,10)y · yhizliUs(2,5)2 · y · yhizliUs(2,2)y · yhizliUs(2,1)2 · y · yhizliUs(2,0)1= 1→ çağrı · ← dönüş
hizliUs(2,10)y · y= 1024hizliUs(2,5)2 · y · y= 32hizliUs(2,2)y · y= 4hizliUs(2,1)2 · y · y= 2hizliUs(2,0)1= 1→ çağrı · ← dönüş
Şekil 4.10: hizliUs(2, 10) çağrı zinciri. Üstte en derin an (Tablo 4.6, 5. adım): dört çağrı bekliyor, her kutuda dönüşte yapılacak işlem yazılı (çift üste y · y, tek üste 2 · y · y). Altta son an: değerler 1, 2, 4, 32 diye geri dönmüş, ilk çağrı 32 · 32 = 1024'ü hesaplıyor.
Örnek 4.4

hizliUs(3, 6) kaç çağrı yapar ve ne döndürür?

  1. İniş: n değerleri 6, 3, 1, 0'dır (6 / 2 = 3, 3 / 2 = 1, 1 / 2 = 0). 4 çağrı yapılır; formül de ⌊log2 6⌋ + 2 = 2 + 2 = 4 verir.
  2. hizliUs(3, 0) 1 döndürür.
  3. hizliUs(3, 1): n tek, 3 · 1 · 1 = 3.
  4. hizliUs(3, 3): n tek, 3 · 3 · 3 = 27.
  5. hizliUs(3, 6): n çift, 27 · 27 = 729.

Sonuç 36 = 729. Yavaş sürüm 7 çağrı yapardı.

4.4İkili özyineleme

Bazı problemlerde bir çağrı, kendini iki kez çağırır. Çağrılar artık düz bir zincir oluşturmaz; her çağrıdan iki dal çıkar ve bir ağaç oluşur. Böyle yapıların en bilinen örneği Fibonacci sayılarıdır. Fibonacci ağacının maliyeti ve bu maliyetten kurtulma yolları burada işlenir. Kesimin sonundaki ikili aramanın kodunda da iki çağrı yazılıdır, ama çağrılar ağaç değil zincir oluşturur.

4.4.1Fibonacci sayıları

Fibonacci dizisi 0 ve 1 ile başlar. Sonraki her sayı, kendinden önceki iki sayının toplamıdır: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, … Dizi doğada (ayçiçeğindeki tohum dizilişi, bazı bitkilerde yaprakların dizilişi) ve bilgisayar biliminde (bazı veri yapılarının analizi) sık karşımıza çıkar.

0F(0)1F(1)1F(2)2F(3)3F(4)5F(5)8F(6)13F(7)21F(8)34F(9)
Şekil 4.11: Fibonacci dizisinin ilk on terimi, F(0)'dan F(9)'a: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34. Her terim, önceki iki terimin toplamıdır; örneğin F(7) = F(6) + F(5) = 8 + 5 = 13.

Dizinin tanımı kendiliğinden özyinelemelidir:

F(0) = 0,   F(1) = 1,   F(n) = F(n − 1) + F(n − 2) (n ≥ 2)

Bu tanımda iki taban durum vardır: n = 0 ve n = 1. İki taban durum gereklidir, çünkü özyinelemeli adım iki önceki terime başvurur. Yalnız F(0) verilseydi F(1) = F(0) + F(−1) tanımsız kalırdı. Tanım koda doğrudan dökülür:

int fib(int n) {
  if (n <= 1) return n;    // taban durum
  return fib(n - 1) + fib(n - 2);
}

2. satır iki taban durumu tek koşulla yakalar: n ≤ 1 iken F(n) = n'dir (F(0) = 0, F(1) = 1). 3. satır iki özyinelemeli çağrı yapar ve sonuçlarını toplar.

Tanım 4.4

İkili özyineleme (binary recursion), bir çağrının iki özyinelemeli çağrı yaptığı özyinelemedir. Bir çağrı ikiden çok özyinelemeli çağrı yapabiliyorsa çoklu özyineleme (multiple recursion) denir. Bu türlerde çağrılar bir çağrı ağacı oluşturur: her düğüm bir çağrıdır; düğümün çocukları, o çağrının yaptığı çağrılardır.

Çağrı ağacı, ikili özyinelemeyi çözümlemenin temel aracıdır. Ağaçtaki düğüm sayısı toplam çağrı sayısını, ağacın derinliği ise yığında aynı anda bulunan en çok çerçeve sayısını verir. Fibonacci ağacının dikkat çekici bir özelliği, aynı değerin ağaçta birden çok kez hesaplanmasıdır. fib'in maliyetini bu tekrar belirler.

4.4.2Kod izleme: fib(4) çağrı ağacı

int fib(int n) {
  if (n <= 1) return n;    // taban durum
  return fib(n - 1) + fib(n - 2);
}
// main içinde:
System.out.println(fib(4));

fib(4)'ü izlerken önemli kural şudur: Java bir ifadeyi soldan sağa değerlendirir. fib(n - 1) + fib(n - 2) ifadesinde önce fib(n - 1) çağrılır ve tamamen biter. Ancak ondan sonra fib(n - 2) çağrılır. Sol dal bütünüyle bitmeden sağ dala geçilmez.

Tablo 4.7: fib(4) izlemesi. Çağrılar başlama sırasıyla numaralanmıştır.
ÇağrıKim çağırdıOlayDönen
1: fib(4)mainn > 1: önce fib(3); fib(2) sırada bekler
2: fib(3)1, solönce fib(2); fib(1) sırada bekler
3: fib(2)2, solönce fib(1); fib(0) sırada bekler
4: fib(1)3, soltaban durum1
5: fib(0)3, sağtaban durum0
3: fib(2)1 + 01
6: fib(1)2, sağtaban durum1
2: fib(3)1 + 12
7: fib(2)1, sağikinci kez hesaplanıyor: önce fib(1)
8: fib(1)7, soltaban durum1
9: fib(0)7, sağtaban durum0
7: fib(2)1 + 01
1: fib(4)2 + 13

Sonuç F(4) = 3'tür ve 9 çağrıyla bulunmuştur. Bu 9 çağrının dağılımı şöyledir: fib(4) ve fib(3) birer kez, fib(2) iki kez, fib(1) üç kez, fib(0) iki kez. fib(2)'nin ikinci hesabı (7. çağrı) tamamen gereksizdir: aynı değer 3. çağrıda zaten bulunmuştu, ama hiçbir yerde saklanmadığı için yeniden hesaplanır.

f(4)f(3)f(2)f(1)=1f(0)f(1)f(2)f(1)f(0)şimdi çalışanyığında, bekliyordeğerini döndürdühenüz çağrılmadı
f(4)f(3)=2f(2)=1f(1)=1f(0)=0f(1)=1f(2)f(1)f(0)şimdi çalışanyığında, bekliyordeğerini döndürdühenüz çağrılmadı
f(4)=3f(3)=2f(2)=1f(1)=1f(0)=0f(1)=1f(2)=1f(1)=1f(0)=0şimdi çalışanyığında, bekliyordeğerini döndürdühenüz çağrılmadı
Şekil 4.12: fib(4) çağrı ağacının üç anı. Üstte ilk taban durum: fib(1) çalışıyor (kırmızı çerçeveli), fib(2), fib(3), fib(4) yığında bekliyor; kesikli düğümler henüz çağrılmadı. Ortada fib(3) = 2 döndükten sonra fib(4), sağ dal için fib(2)'yi ikinci kez hesaplamaya başlamış. Altta bütün ağaç: 9 çağrı da değerini döndürmüş (yeşil), fib(4) = 3.

Şekil 4.12'deki ağaçtan bellek maliyeti de okunur. Yığında aynı anda bulunan çerçeveler, kökten o an çalışan düğüme giden yoldur. En uzun yol fib(4) → fib(3) → fib(2) → fib(1)'dir: 4 çerçeve, main ile 5. Toplam çağrı 9 olduğu hâlde yığın hiçbir zaman 4 fib çerçevesini aşmaz.

Sık yapılan hata

İki özyinelemeli çağrının aynı anda çalıştığını sanmak. Java'da bir metot çağrısı bitmeden çağıran satır devam etmez. fib(n - 1) + fib(n - 2) ifadesinde ikinci çağrı, birincinin bütün alt ağacı bitince başlar. Çağrı ağacı bu yüzden soldan sağa, derinlemesine gezilir.

4.4.3Özyinelemenin maliyeti

Özyinelemeli bir metodun zaman maliyeti, yaptığı toplam iştir. Her çağrı sabit miktarda iş yapıyorsa (bir karşılaştırma, bir toplama, bir dönüş), toplam iş çağrı sayısıyla orantılıdır. Maliyeti bulmak, çağrı sayısını bulmaya indirgenir.

faktoriyel için bu kolaydır. faktoriyel(n) bir kez faktoriyel(n - 1)'i çağırır. T(n), faktoriyel(n)'in yaptığı toplam iş olsun ve c bir çağrının kendi içindeki sabit işi göstersin. O zaman T(n) = T(n − 1) + c yazılır. Her çağrı bir sonrakini yapar ve zincir n çağrı uzunluğundadır. Zaman O(n), yığın belleği O(n)'dir. Bu tür denklemlere yineleme bağıntısı denir; 4.6 bu konuya ayrılmıştır.

fib için durum çok farklıdır. Her çağrı iki çağrı yapar ve aynı alt problemler tekrar tekrar çözülür.

int fib(int n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}
f(4)f(3)f(2)f(1)f(0)f(1)f(2)f(1)f(0)
Şekil 4.13: fib(4) çağrı ağacı; 9 düğüm. Turuncu f(2) düğümleri aynı alt problemin iki ayrı hesabıdır. Her f(2) kendi altında f(1) ve f(0)'ı yeniden çağırır.

Çağrı sayısı n büyüdükçe çok hızlı artar. fib(n)'in toplam çağrı sayısına C(n) diyelim. n ≤ 1 için tek çağrı yapılır: C(0) = C(1) = 1. Daha büyük n için çağrının kendisi, sol alt ağaç ve sağ alt ağaç toplanır: C(n) = C(n − 1) + C(n − 2) + 1. Bu bağıntı Fibonacci bağıntısına çok benzer ve çözümü de Fibonacci sayılarıyla yazılır.

Teorem 4.1 (Fibonacci çağrı sayısı)

Özyinelemeli fib(n) toplam C(n) = 2 · F(n + 1) − 1 çağrı yapar.

Tümevarımla gösterelim. n = 0 için 2 · F(1) − 1 = 2 · 1 − 1 = 1; n = 1 için 2 · F(2) − 1 = 2 · 1 − 1 = 1. İkisi de doğrudur. n ≥ 2 için formülün n − 1 ve n − 2'de doğru olduğunu kabul edelim. Bağıntıya koyunca C(n) = (2F(n) − 1) + (2F(n − 1) − 1) + 1 = 2(F(n) + F(n − 1)) − 1 = 2F(n + 1) − 1 bulunur. □

Teorem, fib(4) için 2 · F(5) − 1 = 2 · 5 − 1 = 9 verir; izlemeyle aynı. Fibonacci sayıları her adımda yaklaşık 1,6 katına çıktığı için (oran, altın oran denen φ ≈ 1,618 sayısına yaklaşır) çağrı sayısı da yaklaşık 1,6n gibi büyür. Bu üstel bir büyümedir: n her 1 arttığında iş yaklaşık 1,6 katına çıkar.

Bellek ise hâlâ O(n)'dir. Ağaç ne kadar büyük olursa olsun, yığında aynı anda yalnız kökten bir düğüme giden tek bir yol bulunur ve en uzun yol n düğümlüdür. fib'in sorunu bellek değil, zamandır.

Sık yapılan hata

Aynı özyinelemeli çağrıyı bir ifadede iki kez yazmak. hizliUs'ta y * y yerine hizliUs(x, n / 2) * hizliUs(x, n / 2) yazılırsa doğrusal bir zincir ikili bir ağaca dönüşür. Ağacın derinliği log2 n olduğu için düğüm sayısı yaklaşık 2log n = n olur: O(log n)'lik metot O(n)'e düşer. Aynı değer birden çok kez gerekiyorsa bir kez hesaplanıp bir değişkende saklanır.

4.4.4Tekrarlanan hesaptan kaçınmak

Üstel büyümenin ne demek olduğu sayılara bakınca daha iyi anlaşılır. Tablo 4.8, fib(n)'in değerini ve yaptığı çağrı sayısını birkaç n için verir.

Tablo 4.8: Özyinelemeli fib'in çağrı sayısı.
nfib(n)çağrı sayısı
5515
1055177
206.76521.891
30832.0402.692.537
40102.334.155331.160.281

fib(40) 331 milyondan fazla çağrı yapar. Oysa farklı alt problem sayısı yalnızca 41'dir: fib(0)'dan fib(40)'a kadar. Çağrıların neredeyse hepsi daha önce çözülmüş bir problemi yeniden çözer. Bu israfı önlemenin iki yolu vardır.

1. Sonuçları saklamak. Bir değer ilk kez hesaplandığında bir dizide saklanır. Aynı değer yeniden istendiğinde hesaplanmaz, diziden okunur. Bu tekniğe sonuçları saklama ya da yaygın İngilizce adıyla memoization denir.

long[] bellek = new long[91];   // 0: hesaplanmadı
long fibBellek(int n) {
  if (n <= 1) return n;
  if (bellek[n] != 0) return bellek[n];
  bellek[n] = fibBellek(n - 1) + fibBellek(n - 2);
  return bellek[n];
}

bellek dizisi 91 öğelidir ve F(0)–F(90) değerlerini tutar. Bu değerlerin hepsi long'a sığar; long ancak F(92)'ye kadar yeter. Dizinin her hücresi başlangıçta 0'dır ve 0 “henüz hesaplanmadı” anlamına gelir. Bu seçim güvenlidir, çünkü n ≥ 2 için F(n) hiçbir zaman 0 değildir. Metot önce taban duruma bakar. Ardından değerin daha önce hesaplanıp hesaplanmadığını denetler; hesaplanmışsa hemen döndürür. Hesaplanmamışsa iki çağrıyı yapar, sonucu bellek[n]'e yazar ve döndürür.

fibBellek(40) 79 çağrı yapar. Bunun nedeni şudur. fibBellek(n)'in sol çağrısı fibBellek(n - 1), n − 1'e kadar her değeri hesaplar ve saklar. Sağ çağrı fibBellek(n - 2) geldiğinde değer zaten bellektedir; çağrı hemen döner. Her k ≥ 2 için bir “hesaplayan” çağrı ve onun sağ dalında değeri hazır bulan (ya da fibBellek(0) gibi taban duruma düşen) bir çağrı vardır; buna en alttaki fibBellek(1) eklenir. Toplam 2n − 1 çağrı yapılır: n = 40 için 79. Zaman O(n)'dir; yığın derinliği n ve bellek dizisi de O(n) yer kaplar.

2. Döngüyle hesaplamak. Fibonacci dizisi baştan sona doğru hesaplanırsa her adımda yalnız son iki terim gerekir.

long fibDongu(int n) {
  long a = 0, b = 1;              // F(0), F(1)
  for (int i = 0; i < n; i++) {
    long t = a + b;  a = b;  b = t;
  }
  return a;
}

a ve b her an ardışık iki terimi tutar. Döngünün başında a = F(0), b = F(1)'dir. Her turda t = a + b bir sonraki terimi hesaplar; sonra pencere bir adım kayar. n tur sonunda a = F(n) olur.

Tablo 4.9: fibDongu(6) izlemesi.
it = a + bab
başlangıç01
0111
1212
2323
3535
4858
513813

Altı turdan sonra a = 8 = F(6) döndürülür. Döngülü sürüm O(n) zaman ve O(1) bellek kullanır; çerçeve açmaz, yığın taşması riski yoktur. Fibonacci için en iyi seçim budur.

Sık yapılan hata

Sonuç saklama dizisini metodun içinde tanımlamak. long[] bellek = new long[91]; satırı fibBellek'in içine yazılırsa her çağrı kendi boş dizisini oluşturur; hiçbir değer çağrılar arasında paylaşılmaz ve metot yine üstel sürede çalışır. Dizi, bütün çağrıların gördüğü bir yerde (bir alan ya da parametre olarak) tutulmalıdır.

4.4.5Özyinelemeli ikili arama

Sıralı bir dizide bir x değerini aramak için ortadaki öğeye bakılır. x ortadaki öğeye eşitse aranan bulunmuştur. x daha küçükse, dizi sıralı olduğu için ancak sol yarıda olabilir; daha büyükse ancak sağ yarıda. Her iki durumda da aynı arama, yarı boyutlu bir parça üzerinde tekrarlanır. Bu, doğası gereği özyinelemeli bir algoritmadır.

Aranan parça lo ve hi indisleriyle verilir: a[lo..hi]. İki taban durum vardır:

  • Aralık boş (lo > hi): aranacak öğe kalmamıştır; x dizide yoktur. Metot −1 döndürür.
  • Ortadaki öğe x: aranan bulunmuştur; metot ortanın indisini döndürür.
int ikiliAra(int[] a, int x, int lo, int hi) {
  if (lo > hi) return -1;       // taban: boş aralık
  int mid = (lo + hi) / 2;
  if (a[mid] == x) return mid;  // taban: bulundu
  if (x < a[mid])
    return ikiliAra(a, x, lo, mid - 1); // sol yarı
  return ikiliAra(a, x, mid + 1, hi);   // sağ yarı
}
System.out.println(ikiliAra(a, 23, 0, 9));

Kodda iki özyinelemeli çağrı yazılıdır (6. ve 7. satırlar). Ama bir çağrı bunlardan yalnız birini yapar: if koşulu doğruysa sol yarıya, değilse sağ yarıya gidilir. Fibonacci'deki gibi bir ağaç oluşmaz; çağrılar yine bir zincirdir. İkili arama bu yüzden ikili değil, doğrusal özyinelemedir. Örnek, ağaç ile zincir arasındaki farkı göstermek için bu kesimde ele alınır.

fib: iki çağrı → ağaçikiliAra: biri seçilir → zincir
Şekil 4.14: Solda fib: her çağrı iki çağrı yapar, ağaç oluşur. Sağda ikiliAra: iki yarıdan yalnız birine girilir (kırmızı yol); öteki dala hiç gidilmez (kesikli). Sonuç bir zincirdir.

Her çağrıda aralık yarıya iner. Yineleme bağıntısı T(n) = T(n/2) + c'dir; çözümü O(log n) zaman ve O(log n) yığın belleğidir (4.6.3). n = 10 öğeli dizide en çok 5, n = 106 öğeli dizide en çok 21 çağrı yapılır; bu sayılar boş aralıkla biten son çağrıyı da içerir.

Sık yapılan hata

Özyinelemeli çağrının önündeki return'ü unutmak. 6. satır ikiliAra(a, x, lo, mid - 1); diye yazılırsa sol yarının sonucu atılır ve akış 7. satıra, sağ yarı çağrısına düşer. Derleyici bu hatayı her zaman yakalamaz (metodun sonunda bir return olduğu için kod derlenir), ama cevap yanlış çıkar. Bir değer döndüren özyinelemeli metotta, dönen değerin kullanıldığından ya da iletildiğinden emin olunmalıdır.

4.4.6Kod izleme: ikiliAra(a, 23, 0, 9)

Dizi a = {2, 5, 8, 12, 16, 23, 38, 56, 72, 91}, aranan x = 23'tür. main, ikiliAra(a, 23, 0, 9)'u çağırır.

Tablo 4.10: ikiliAra(a, 23, 0, 9) izlemesi.
Çağrılo, himida[mid]Karar
10, 9(0 + 9)/2 = 41623 > 16: sağ yarı, ikiliAra(a, 23, 5, 9)
25, 9(5 + 9)/2 = 75623 < 56: sol yarı, ikiliAra(a, 23, 5, 6)
35, 6(5 + 6)/2 = 523bulundu: 5 döner

3. çağrı taban durumda 5 döndürür. 2. çağrı aldığı 5'i return ile olduğu gibi iletir; 1. çağrı da aynısını yapar. Dönüş yolunda yeni bir hesap yapılmaz. Sonuç 5'tir ve 3 çağrıyla bulunmuştur; aralık 10 → 5 → 2 öğeye inmiştir.

205182123164235386567728919lomidhix = 23aralık: 10 öğe
205182123164235386567728919lo, midhix = 23aralık: 2 öğe
Şekil 4.15: ikiliAra(a, 23, 0, 9) izlemesinden iki an. Üstte 1. çağrı: aralık 10 öğe, mid = 4 ve a[4] = 16. Altta 3. çağrı: aralık lo = 5, hi = 6 ile 2 öğeye inmiş, a[5] = 23 bulunmuş. Aralık dışındaki öğeler soluk gösterilmiştir.
Örnek 4.5

Aynı dizide x = 50 aranırsa ne olur?

  1. (0, 9): mid = 4, a[4] = 16; 50 > 16, sağ yarı.
  2. (5, 9): mid = 7, a[7] = 56; 50 < 56, sol yarı.
  3. (5, 6): mid = 5, a[5] = 23; 50 > 23, sağ yarı.
  4. (6, 6): mid = 6, a[6] = 38; 50 > 38, sağ yarı.
  5. (7, 6): lo > hi, aralık boş: −1 döner.

5 çağrı yapılır; n = 10 için en kötü durum budur. Son çağrıdaki lo = 7, 50'nin sıralamayı bozmadan ekleneceği indistir; ama metot bu bilgiyi döndürmez, yalnız −1 döndürür.

Sık yapılan hata

Yeni aralığı mid ile sınırlamak: ikiliAra(a, x, lo, mid) ya da ikiliAra(a, x, mid, hi). a[mid] zaten denetlenmiştir; aralıkta bırakılırsa iki öğeli bir aralıkta mid = lo çıkar ve aynı aralıkla tekrar tekrar çağrı yapılır. Problem küçülmez ve yığın taşar. Doğrusu mid - 1 ve mid + 1'dir.

4.5Hanoi kuleleri ve geri izleme

Önceki kesimdeki Fibonacci ağacı, özyinelemenin kötü kullanımına bir örnekti: aynı iş tekrar tekrar yapılıyordu. Bu kesimdeki problemlerde de çağrı ağacı üstel büyür, ama bu kez ağacın her düğümü farklı ve gerekli bir iş yapar. Hanoi kulelerinde her düğüm bir disk hamlesidir. Geri izlemede her yaprak, üretilmesi istenen bir alt küme ya da permütasyondur. Bu problemlerde üstel maliyetten kaçış yoktur, çünkü çıktının kendisi üstel büyüklüktedir. Özyineleme ise bu çıktıyı üretmenin en açık yoludur.

4.5.1Hanoi kuleleri

Hanoi kuleleri (Towers of Hanoi), bilgisayar biliminde ve matematikte klasik bir problemdir. Üç çubuk vardır: A, B ve C. A çubuğunda, büyükten küçüğe doğru üst üste dizilmiş n disk bulunur; en büyük disk en alttadır. Amaç, bütün diskleri A'dan C'ye taşımaktır. B çubuğu yardımcı olarak kullanılabilir. Taşıma iki kurala uyar:

  • Her hamlede tek disk taşınır; bu, bir çubuğun en üstündeki disktir.
  • Disk yalnız kendinden büyük bir diskin üstüne ya da boş çubuğa konabilir.
ABC321
Şekil 4.16: Üç diskli Hanoi kuleleri. Başlangıçta üç disk A çubuğunda, büyükten küçüğe dizilidir (3 en büyük, 1 en küçük). Hedef, hepsini C'ye taşımaktır.

Problemi doğrudan, hamle hamle düşünmek zordur. Özyinelemeli düşünmek ise çözümü neredeyse kendiliğinden verir. En büyük diske odaklanalım. Bu disk bir noktada A'dan C'ye taşınmak zorundadır. O hamle yapılırken A'da başka disk olamaz (en büyük disk en üstte olmalıdır) ve C boş olmalıdır (en büyük disk hiçbir diskin üstüne konamaz). Öyleyse geri kalan n − 1 diskin hepsi o anda B'de olmalıdır. Bu gözlem üç adımlı bir plan verir:

  1. Üstteki n − 1 diski A'dan B'ye taşı (C'yi yardımcı kullanarak).
  2. En büyük diski A'dan C'ye taşı.
  3. n − 1 diski B'den C'ye taşı (A'yı yardımcı kullanarak).

1. ve 3. adımlar, n − 1 diskli aynı problemdir; yalnız çubukların rolleri değişmiştir. Bu iki adım sırasında en büyük disk hiç rahatsız edilmez ve her disk ondan küçük olduğu için onun üstüne serbestçe konabilir. En büyük disk, küçük problem için yok sayılabilir. Taban durum tek disktir: tek disk doğrudan kaynaktan hedefe taşınır.

4.5.2Üç diskle yedi hamle

Üç diskli problemde plan şöyle işler. Üstteki 2 disk A'dan B'ye taşınır; bu, 3 hamle tutar. Disk 3, A'dan C'ye gider. Sonra 2 disk B'den C'ye taşınır; bu da 3 hamledir. Toplam 7 hamle eder.

ABC321
ABC321
ABC213
ABC321
Şekil 4.17: Üç diskle yedi hamlenin dört anı, yukarıdan aşağı: başlangıç; 3. hamleden sonra (küçük iki disk B'de toplanmış); 4. hamleden sonra (disk 3 C'de); 7. hamleden sonra (bütün diskler C'de). Hamleler sırasıyla şunlardır: disk 1, A → C; disk 2, A → B; disk 1, C → B; disk 3, A → C; disk 1, B → A; disk 2, B → C; disk 1, A → C. Her durumda son hamlede taşınan disk turuncu gösterilmiştir.

Şekil 4.17, planın üç adımını açıkça gösterir. İlk üç hamle küçük iki diski B'de toplar (hamle 3'ten sonraki durum). Hamle 4 en büyük diski taşır. Son üç hamle küçük iki diski C'de en büyük diskin üstüne yerleştirir. İlk üç hamle ile son üç hamle aynı “2 diski taşı” işinin iki örneğidir; yalnız çubuklar farklıdır.

Hamle sayısı bu plandan hesaplanır. T(n), n diski taşımak için gereken hamle sayısı olsun. Tek disk için T(1) = 1'dir. Daha fazla disk için planın üç adımı toplanır: T(n) = T(n − 1) + 1 + T(n − 1) = 2T(n − 1) + 1. Birkaç değer hesaplayalım: T(2) = 3, T(3) = 7, T(4) = 15. Her değer bir ikinin kuvvetinin bir eksiğidir.

Teorem 4.2 (Hanoi hamle sayısı)

n diskli Hanoi kulelerini çözmek için 2n − 1 hamle yeter ve gerekir. Özyinelemeli plan tam 2n − 1 hamle yapar; daha az hamleli bir çözüm yoktur.

Yeterlik. Tümevarımla: T(1) = 1 = 21 − 1. T(n − 1) = 2n−1 − 1 ise T(n) = 2(2n−1 − 1) + 1 = 2n − 2 + 1 = 2n − 1.

Gereklik. Herhangi bir çözümde en büyük disk en az bir kez taşınır. İlk taşındığı anda, yukarıdaki akıl yürütmeye göre öteki n − 1 disk tek bir çubukta toplanmış olmalıdır; bunun için en az T(n − 1) hamle gerekir. En büyük diskin C'ye son taşınışından sonra da n − 1 disk bir çubuktan C'ye taşınmalıdır: yine en az T(n − 1) hamle. Arada en büyük disk için en az bir hamle vardır. Toplam en az 2T(n − 1) + 1 hamledir. □

2n − 1 çok hızlı büyür. 20 disk için 1.048.575 hamle gerekir. Bir efsaneye göre tapınaktaki rahipler 64 diskli kuleyi taşıyor; iş bitince dünyanın sonu gelecektir. 264 − 1, yaklaşık 1,8 · 1019 hamledir; saniyede bir hamleyle yaklaşık 585 milyar yıl sürer.

4.5.3Kod izleme: hanoi(3, 'A', 'B', 'C')

Plan Java'ya doğrudan çevrilir. Metot dört parametre alır: disk sayısı n, kaynak çubuk k, yardımcı çubuk y ve hedef çubuk h.

void hanoi(int n, char k, char y, char h) {
  if (n == 1) {
    System.out.println("1: " + k + "→" + h);
    return;
  }
  hanoi(n - 1, k, h, y);
  System.out.println(n + ": " + k + "→" + h);
  hanoi(n - 1, y, k, h);
}
// main içinde:
hanoi(3, 'A', 'B', 'C');

2–5. satırlar taban durumdur: tek disk kaynaktan hedefe taşınır ve hamle “1: A→C” biçiminde yazdırılır. Baştaki sayı, taşınan diskin numarasıdır. 6. satır planın 1. adımıdır: n − 1 disk kaynaktan yardımcıya taşınır. Bu çağrıda hedefin (h) yardımcı, yardımcının (y) hedef rolüne geçtiğine dikkat edilmelidir: hanoi(n - 1, k, h, y). 7. satır en büyük diski, yani n numaralı diski taşır. 8. satır 3. adımdır: n − 1 disk yardımcıdan hedefe taşınır; bu kez kaynak yardımcı kullanılır: hanoi(n - 1, y, k, h).

Tablo 4.11: hanoi(3, 'A', 'B', 'C') izlemesi. H(n,k,y,h), hanoi çağrısının kısaltmasıdır.
AdımÇalışan çağrıOlayYazılan
1H(3,A,B,C)n ≠ 1; satır 6: önce 2 disk A'dan B'ye
2H(2,A,C,B)n ≠ 1; satır 6: önce 1 disk A'dan C'ye
3H(1,A,B,C)taban durum1: A→C
4H(2,A,C,B)satır 7: disk 22: A→B
5H(1,C,A,B)satır 8'den çağrıldı; taban durum1: C→B
6H(3,A,B,C)H(2,A,C,B) bitti; satır 7: disk 33: A→C
7H(2,B,A,C)satır 8'den çağrıldı; satır 6: önce 1 disk B'den A'ya
8H(1,B,C,A)taban durum1: B→A
9H(2,B,A,C)satır 7: disk 22: B→C
10H(1,A,B,C)satır 8'den çağrıldı; taban durum1: A→C
11H(3,A,B,C)alt çağrılar bitti; main'e döner

Yazılan yedi satır, Şekil 4.17'deki yedi hamlenin aynısıdır. Çağrı ağacında 7 düğüm vardır: bir H(3,…), iki H(2,…) ve dört H(1,…). Her düğüm tam bir satır yazar; çağrı sayısı ile hamle sayısı eşittir: 23 − 1 = 7.

A32BC13:A→C2:A→B2:B→C1:A→C1:C→B1:B→A1:A→C
AB21C33:A→C2:A→B2:B→C1:A→C1:C→B1:B→A1:A→C
ABC3213:A→C2:A→B2:B→C1:A→C1:C→B1:B→A1:A→C
Şekil 4.18: hanoi(3, 'A', 'B', 'C') çağrı ağacı ve çubukların durumu. Ağaçtaki her kutu bir çağrıdır ve n:kaynak→hedef biçiminde, o çağrının yazdırdığı hamleyi gösterir. Üstte ilk hamle (1:A→C) yapılmış; kalın çerçeveli kutular yığındaki çağrılardır. Ortada sol alt ağaç bitmiş, küçük iki disk B'de toplanmış ve disk 3 A'dan C'ye taşınmıştır. Altta bütün çağrılar bitmiş, üç disk C'dedir.

Maliyet: zaman O(2n)'dir, çünkü 2n − 1 çağrının her biri sabit iş yapar. Bellek ise O(n)'dir. Yığında aynı anda en çok n hanoi çerçevesi bulunur (n, n − 1, …, 1 disklik çağrılar) ve main ile n + 1 çerçeve olur.

Sık yapılan hata

Özyinelemeli çağrılarda çubukların sırasını karıştırmak. 6. satırda hanoi(n - 1, k, y, h) yazılırsa küçük diskler hedefe taşınır ve en büyük disk hedefte kendinden küçük disklerin üstüne konmaya çalışılır; kurallar bozulur. Parametreleri doğru yazmanın bir yolu, her çağrı için “nereden, nereye, hangisi boş kalıyor?” sorusunu cevaplamaktır: 1. adımda n − 1 disk k'dan y'ye gider, h boştur.

Çıktıda bir düzen daha göze çarpar. Disk 1 her ikinci hamlede, yani tek numaralı hamlelerde oynar; n diskte toplam 2n−1 kez taşınır. Disk k ise 2n−k kez taşınır; en büyük disk yalnız bir kez. Bu, ağaçta her seviyedeki düğüm sayısıyla aynıdır: en alt seviyede (tek disklik çağrılar) 2n−1 düğüm vardır. Hanoi kuleleri simülatöründe bulmaca 1–6 diskle elle çözülebilir ya da en kısa çözüm adım adım izlenebilir. Simülatör yapılan hamle sayısını en az hamle sayısıyla (2n − 1; 6 disk için 63) karşılaştırır. Her kule bir yığın gibi davranır: yalnız en üstteki disk alınabilir.

4.5.4Geri izleme

Bazı problemlerde cevap tek bir değer değil, bir dizi seçimin sonucudur. Labirentte her kavşakta hangi yöne gidileceği, sudoku tablosunda her boş kareye hangi rakamın yazılacağı, kümeden hangi öğelerin seçileceği böyledir. Bu problemlerin genel çözüm yöntemine geri izleme denir.

Tanım 4.5

Geri izleme (backtracking), çözümü adım adım kuran bir yöntemdir. Her adımda olası seçeneklerden biri denenir ve özyinelemeli bir çağrıyla bir sonraki adıma geçilir. Çağrı döndüğünde yapılan seçim geri alınır ve sıradaki seçenek denenir. Bir seçimin çözüme götüremeyeceği anlaşılırsa o dal bırakılır, daha derine inilmez.

seçilerle (çağrı)geri alsıradakiçözüm tamamya da seçenekkalmadı → dön
Şekil 4.19: Geri izleme döngüsü. Bir seçenek seçilir, özyinelemeli çağrıyla ilerlenir, çağrı dönünce seçim geri alınır ve sıradaki seçeneğe geçilir. Çözüm tamamlandığında ya da seçenek kalmadığında çağrı döner.

Seçimi geri almak neden gereklidir? Geri izlemede kurulmakta olan çözüm (örneğin bir liste) genellikle bütün çağrılar arasında paylaşılan tek bir nesnedir. Bir dalda yapılan değişiklik geri alınmazsa sonraki dal bu değişikliği hazır bulur ve yanlış bir durumdan başlar. Seçim geri alınınca her dal, aynı başlangıç durumunu görür.

Hanoi'de olduğu gibi geri izlemede de her çağrı birden çok özyinelemeli çağrı yapar. Çağrı ağacı, denenen bütün seçimlerin ağacıdır; buna karar ağacı da denir. Kökten bir yaprağa giden her yol, bir seçimler dizisidir. Geri izlemenin bilinen örnekleri şunlardır: alt kümeleri ya da permütasyonları listelemek, labirentte yol bulmak, sudoku çözmek ve n vezir problemi. Son problemde bir satranç tahtasına birbirini tehdit etmeyen n vezir yerleştirilir.

4.5.5Alt kümeleri listeleme

Bir kümenin bütün alt kümelerini yazdırmak isteyelim. {1, 2, 3} kümesinin 8 alt kümesi vardır: {1, 2, 3}, {1, 2}, {1, 3}, {1}, {2, 3}, {2}, {3} ve boş küme. Bunları sistemli üretmenin yolu, her öğe için tek tek karar vermektir: bu öğe alt kümeye alınacak mı, alınmayacak mı? Her öğe için iki seçenek vardır ve n öğe için kararlar birbirinden bağımsızdır. Toplam 2 · 2 · … · 2 = 2n farklı karar dizisi, yani 2n alt küme vardır.

+1+2+3+3+2+3+3[][1][1,2][1,2,3][1,2][1][1,3][1][][2][2,3][2][][3][]
Şekil 4.20: {1, 2, 3} için karar ağacı. Her seviyede bir öğe için karar verilir: sol dal öğeyi alır (+1, +2, +3), sağ dal almaz. Düğümlerdeki listeler o ana kadar seçilen öğelerdir. 8 yaprak, 8 alt kümedir.

Karar ağacı doğrudan bir özyinelemeli metoda dönüşür. Metot üç parametre alır: dizi a, karar verilecek öğenin indisi i ve o ana kadar seçilen öğeleri tutan liste s.

void altKumeler(int[] a, int i, List<Integer> s) {
  if (i == a.length) {              // taban durum
    System.out.print(s + " "); return;
  }
  s.add(a[i]);                      // a[i]'yi al
  altKumeler(a, i + 1, s);
  s.remove(s.size() - 1);           // geri al
  altKumeler(a, i + 1, s);          // a[i]'siz dene
}
// main içinde:
altKumeler(new int[]{1, 2, 3}, 0, new ArrayList<>());

Metodun her parçası karar ağacındaki bir öğeye karşılık gelir:

  • Taban durum (2–4. satırlar): i == a.length ise bütün öğeler için karar verilmiştir; ağacın bir yaprağındayız. s bir alt kümedir ve yazdırılır.
  • Sol dal (5–6. satırlar): a[i] listeye eklenir ve bir sonraki öğe için karar verilir.
  • Geri alma (7. satır): sol daldan dönülünce a[i] listeden çıkarılır. Liste, bu çağrının başındaki hâline döner.
  • Sağ dal (8. satır): a[i] alınmadan bir sonraki öğeye geçilir.

s.remove(s.size() - 1) listenin son öğesini indisle siler. Son öğe her zaman bu çağrının eklediği a[i]'dir, çünkü alt çağrılar ekledikleri her şeyi kendileri geri almıştır. List<Integer> için s.remove(a[i]) yazmak tehlikelidir: a[i] bir int olduğundan Java bunu indis olarak yorumlar ve yanlış öğeyi siler.

Çağrı sayısı: karar ağacı tam bir ikili ağaçtır. Seviye k'de 2k düğüm vardır (k = 0, 1, …, n). Toplam 1 + 2 + … + 2n = 2n+1 − 1 çağrı yapılır. n = 3 için 8 alt küme ve 15 çağrı. Her yaprakta alt küme yazdırılırken O(n) iş yapıldığı için toplam zaman O(n · 2n)'dir. Yığın derinliği n + 1 çerçevedir; bellek O(n)'dir.

4.5.6Kod izleme: altKumeler ile {1, 2, 3}

main, altKumeler(new int[]{1, 2, 3}, 0, new ArrayList<>()) çağrısını yapıyor. Tablo 4.12, 15 çağrıyı başlama sırasıyla verir. Her satırda çağrının i değeri, çağrı başladığı andaki s ve çağrının ilk yaptığı iş yazılıdır.

Tablo 4.12: altKumeler izlemesi, a = {1, 2, 3}.
Çağrıigirişte sOlay
10[]1 alınır, sol dala inilir
21[1]2 alınır, sol dala inilir
32[1, 2]3 alınır, sol dala inilir
43[1, 2, 3]taban durum: [1, 2, 3] yazılır
53[1, 2](3. çağrı 3'ü geri aldı) taban durum: [1, 2]
62[1](2. çağrı 2'yi geri aldı) 3 alınır
73[1, 3]taban durum: [1, 3]
83[1](3 geri alındı) taban durum: [1]
91[](1. çağrı 1'i geri aldı) 2 alınır
102[2]3 alınır
113[2, 3]taban durum: [2, 3]
123[2](3 geri alındı) taban durum: [2]
132[](2 geri alındı) 3 alınır
143[3]taban durum: [3]
153[](3 geri alındı) taban durum: []

Çıktı sırası şöyledir: [1, 2, 3] [1, 2] [1, 3] [1] [2, 3] [2] [3] []. Sıra, karar ağacının yapraklarının soldan sağa sırasıdır; çünkü her çağrı önce sol dalı (“al”), sonra sağ dalı (“alma”) bütünüyle gezer. “Al” ve “alma” dallarının sırası değiştirilirse yapraklar ters sırayla gelir; alt kümeler aynı kalır.

+1+2+3+3+2+3+3[][1][1,2][1,2,3][1,2][1][1,3][1][][2][2,3][2][][3][]şimdi çalışanyığında, bekliyordeğerini döndürdühenüz çağrılmadı
+1+2+3+3+2+3+3[][1][1,2][1,2,3][1,2][1][1,3][1][][2][2,3][2][][3][]şimdi çalışanyığında, bekliyordeğerini döndürdühenüz çağrılmadı
+1+2+3+3+2+3+3[][1][1,2][1,2,3][1,2][1][1,3][1][][2][2,3][2][][3][]şimdi çalışanyığında, bekliyordeğerini döndürdühenüz çağrılmadı
Şekil 4.21: altKumeler karar ağacında üç an. Üstte ilk yaprak (Tablo 4.12, 4. çağrı): [1, 2, 3] yazılıyor; kökten bu yaprağa giden yol yığındadır. Ortada 8. çağrı: [1] yazılıyor; sol alt ağacın bütün yaprakları bitmiştir. Altta son durum: 15 çağrının hepsi döndü.

Yığında aynı anda en çok main ve 4 altKumeler çerçevesi vardır (kökten bir yaprağa giden yol: i = 0, 1, 2, 3).

Sık yapılan hata

Geri almayı unutmak. 7. satır silinirse sol dalda eklenen öğe listede kalır ve sağ dala taşar. {1, 2} için çıktı [1, 2] [1, 2] [1, 2, 2] [1, 2, 2] olur: hiçbir seçim geri alınmaz, 2 ikinci kez eklenir ve “1'siz” alt kümeler hiç görünmez. Paylaşılan bir nesneyi değiştiren her seçim, çağrı dönünce geri alınmalıdır.

4.5.7Permütasyonları listeleme

Bir dizi harfin bütün sıralanışlarını, yani permütasyonlarını yazdırmak isteyelim. A, B, C harflerinin 6 permütasyonu vardır: ABC, ACB, BAC, BCA, CAB, CBA. Geri izlemeyle çözüm şöyle kurulur: kelimenin ilk harfi için 3 seçenek vardır; ilk harf seçildikten sonra ikinci harf için kalan 2 harften biri seçilir; üçüncü harf için tek seçenek kalır. Toplam 3 · 2 · 1 = 3! = 6 permütasyon çıkar.

void perm(char[] h, boolean[] kul, StringBuilder s) {
  if (s.length() == h.length) {  // taban durum
    System.out.print(s + " "); return;
  }
  for (int k = 0; k < h.length; k++) {
    if (kul[k]) continue;        // harf kullanıldı
    kul[k] = true; s.append(h[k]);   // seç
    perm(h, kul, s);
    kul[k] = false;              // geri al
    s.deleteCharAt(s.length() - 1);
  }
}

Metodun üç parametresi vardır. h harfleri tutar. kul dizisi hangi harflerin o ana kadar kullanıldığını gösterir: kul[k] doğruysa h[k] kelimede vardır. s, kurulmakta olan kelimedir. Kelime harf sayısı uzunluğuna ulaşınca (2. satır) bir permütasyon tamamlanmıştır ve yazdırılır. Öteki durumda her kullanılmamış harf sırayla denenir: harf kullanıldı olarak işaretlenir ve kelimenin sonuna eklenir (7. satır); kalan harfler için özyinelemeli çağrı yapılır (8. satır); çağrı dönünce işaret kaldırılır ve harf kelimeden silinir (9–10. satırlar).

boşAABABCACACBBBABACBCBCACCACABCBCBA
Şekil 4.22: A, B, C için permütasyon ağacı. Kökte kelime boştur; birinci seviyede 3, ikincide 6, üçüncüde 6 düğüm vardır. Yapraklar (yeşil) soldan sağa ABC, ACB, BAC, BCA, CAB, CBA'dır. Toplam 1 + 3 + 6 + 6 = 16 çağrı.

Bu metotta bir çağrı, kalan harf sayısı kadar özyinelemeli çağrı yapar: kökteki çağrı 3, ikinci seviyedekiler 2'şer, üçüncü seviyedekiler 1'er. Çağrı sayısı sabit değil, değişkendir; bu tür özyinelemeye çoklu özyineleme denir. n harf için yaprak sayısı n!'dir. Ağaçtaki toplam düğüm sayısı n!'in en çok yaklaşık 2,72 katıdır (n = 3 için 16 düğüm, 6 yaprak). Her düğüm O(n) adımlık bir for döngüsü çalıştırır ve her yaprakta n harflik bir kelime yazdırılır. Zaman bu yüzden O(n · n!)'dir. Yığın derinliği n + 1'dir; bellek O(n).

Örnek 4.6

A, B, C için ilk iki permütasyonun nasıl üretildiğini izleyelim.

  1. Kök: s = "". k = 0: A kullanılmamış; işaretlenir, s = "A", çağrı.
  2. s = "A": k = 0 atlanır (A kullanıldı). k = 1: B seçilir, s = "AB", çağrı.
  3. s = "AB": yalnız C kullanılmamış; s = "ABC", çağrı.
  4. s = "ABC": uzunluk 3, ABC yazılır, dönülür.
  5. s = "AB" çağrısında C geri alınır (s = "AB"); başka harf yoktur, dönülür.
  6. s = "A" çağrısında B geri alınır (s = "A"). k = 2: C seçilir, s = "AC"; ardından B eklenir ve ACB yazılır.

Sonra kökte A geri alınır ve ilk harf olarak B denenir; BAC ve BCA üretilir. En son C ile CAB ve CBA gelir.

Sık yapılan hata

kul[k] = false; satırını unutmak. İşaret kaldırılmazsa bir harf, bir dalda kullanıldıktan sonra öteki dallarda da kullanılmış sayılır. A, B, C için yalnız ABC yazılır; sonraki denemelerde boş harf kalmadığı için hiçbir kelime tamamlanmaz. kul ve s bütün çağrılarca paylaşıldığı için her denemeden sonra iki değişiklik de geri alınmalıdır.

4.6Yineleme bağıntıları

Önceki kesimlerde her özyinelemeli metodun maliyetini çağrıları tek tek sayarak bulduk. Bu yöntem küçük girdilerde işe yarar, ama genel bir kural vermez. Daha genel bir yol, maliyeti bir denklemle yazıp bu denklemi çağrı ağacıyla çözmektir. Bölümde geçen bütün metotlar dört ya da beş denklem tipinden birine uyar.

4.6.1Yineleme bağıntısı nedir?

T(n), bir metodun n boyutlu girdide yaptığı toplam iş olsun. Özyinelemeli bir metotta bu iş iki parçadan oluşur: metodun kendi içinde yaptığı iş ve özyinelemeli çağrıların yaptığı iş. İkinci parça, aynı metodun daha küçük girdilerdeki maliyetidir. Böylece T(n), T'nin daha küçük değerleri cinsinden yazılır.

Tanım 4.6

Yineleme bağıntısı (recurrence relation), bir fonksiyonun değerini aynı fonksiyonun daha küçük girdilerdeki değerleriyle veren denklemdir. Özyinelemeli bir metodun maliyeti T(n), bir taban değer (örneğin T(0) = c) ve bir yineleme bağıntısıyla tanımlanır.

Bağıntıyı yazmak için metoda bakıp üç soru cevaplanır:

  1. Taban durumun maliyeti nedir? Çoğunlukla bir sabittir; c ile gösterilir.
  2. Kaç özyinelemeli çağrı yapılıyor ve girdileri ne kadar küçük?
  3. Çağrılar dışında metodun kendisi ne kadar iş yapıyor?

Örneğin hizliUs bir çağrı yapar, girdisi n / 2'dir ve çağrı dışında bir karşılaştırma ile en çok iki çarpma yapar (sabit iş). Bağıntısı T(n) = T(n/2) + c'dir. Bölümdeki metotların bağıntıları Tablo 4.13'te toplanmıştır.

Tablo 4.13: Bölümdeki metotların yineleme bağıntıları.
MetotÇağrılarYineleme bağıntısıÇözüm
faktoriyel, toplam, geriSaybir çağrı, n − 1T(n) = T(n − 1) + cO(n)
tersbir çağrı, n − 2T(n) = T(n − 2) + cO(n)
ikiliAra, hizliUsbir çağrı, n / 2T(n) = T(n/2) + cO(log n)
hanoiiki çağrı, n − 1T(n) = 2T(n − 1) + cO(2n)
fibiki çağrı, n − 1 ve n − 2T(n) = T(n − 1) + T(n − 2) + cüstel

Bağıntıyı çözmenin en kolay yolu çağrı ağacını çizmektir. Her düğüm bir çağrıdır ve kendi içinde c kadar iş yapar. Toplam iş, düğüm sayısı ile c'nin çarpımıdır: T(n) ≈ c · (çağrı sayısı). Bağıntıyı çözmek, çağrı ağacındaki düğümleri saymaya indirgenir.

Sık yapılan hata

Bağıntıyı koddaki çağrı yazılışlarını sayarak kurmak. ikiliAra'nın kodunda iki özyinelemeli çağrı yazılıdır, ama her çağrı bunlardan yalnız birini yapar. Bağıntı 2T(n/2) + c değil, T(n/2) + c'dir. Bağıntı, bir çağrıda gerçekten yapılan çağrıları sayar.

4.6.2T(n) = T(n − 1) + c

Bu bağıntı, girdiyi her adımda 1 azaltan doğrusal özyinelemeye aittir. Çözmek için bağıntı kendi içine tekrar tekrar yerleştirilir; buna bağıntıyı açmak denir:

T(n) = T(n − 1) + c
     = T(n − 2) + 2c
     = T(n − 3) + 3c
     …
     = T(0) + n · c
     = c + n · c   →  O(n)

Her açılışta bir c daha eklenir ve n 1 azalır. k açılıştan sonra T(n) = T(n − k) + k · c olur. k = n olunca taban duruma inilir: T(n) = T(0) + n · c = c + n · c. Bu O(n)'dir.

ncn − 1cn − 2c…1c0cn + 1 çağrı × c iş
Şekil 4.23: T(n) = T(n − 1) + c çağrı ağacı: dallanmayan bir zincir. n, n − 1, …, 1, 0 girdili n + 1 çağrı vardır ve her biri c iş yapar.

Çağrı ağacı burada düz bir zincirdir (Şekil 4.23). Zincirde n + 1 çağrı vardır ve zincirin tamamı aynı anda yığındadır: ilk çağrı, son çağrı dönmeden bitemez. Bellek de bu yüzden O(n)'dir. faktoriyel, toplam, geriSay, yavaş üs alma ve bağlı listenin uzunluğu bu bağıntıya uyar.

ters için girdi, yani parçanın uzunluğu, her adımda 2 azalır: T(n) = T(n − 2) + c. Açılınca T(n) = T(n − 2k) + k · c çıkar ve taban duruma yaklaşık k = n/2 adımda inilir. Çağrı sayısı yarıya iner, ama n/2 de n ile doğru orantılı olduğundan sonuç yine O(n)'dir. Girdiyi sabit bir miktar azaltmak, bu miktar ne olursa olsun, doğrusal bir zincir verir.

4.6.3T(n) = T(n/2) + c

Bu bağıntı, girdiyi her adımda yarıya indiren özyinelemeye aittir. Açalım:

T(n) = T(n/2) + c
     = T(n/4) + 2c
     = T(n/8) + 3c
     …
     = T(n/2ᵏ) + k · c

k açılıştan sonra girdi n/2k'dir. Taban duruma girdi 1 olunca ulaşılır: n/2k = 1, yani 2k = n, yani k = log2 n. Bu değer yerine konunca T(n) = T(1) + c · log2 n bulunur. Bu O(log n)'dir.

Logaritma burada tek bir soruya cevaptır: n'yi kaç kez yarıya bölersek 1'e ulaşırız? 16 → 8 → 4 → 2 → 1 dizisinde 4 bölme vardır ve log2 16 = 4'tür. n ikinin kuvveti değilse tamsayı bölmesi aşağı yuvarlar ve bölme sayısı ⌊log2 n⌋ olur.

16c8c4c2c1clog₂ 16 + 1 = 5 çağrı
Şekil 4.24: n = 16 için yarılama zinciri: 16, 8, 4, 2, 1. Her çağrı c iş yapar; log2 16 + 1 = 5 çağrı.
Tablo 4.14: Yarılama zincirinin uzunluğu (n'den 1'e, iki uç dahil).
n161.0241.000.000
çağrı51120

Girdi bin kat büyüyünce (1.024'ten yaklaşık bir milyona) zincir yalnızca yaklaşık 10 çağrı uzar. Logaritmik büyüme bu kadar yavaştır. Zincirin derinliği de log n olduğu için bellek O(log n)'dir. İkili arama ve hızlı üs alma bu bağıntıya uyar. (Gerçek metotlarda taban durum 0 ya da boş aralık olabilir; o zaman zincire bir çağrı daha eklenir. hizliUs'un ⌊log2 n⌋ + 2 çağrısı bu yüzdendir.)

Örnek 4.7

T(n) = T(n/2) + c, T(1) = c bağıntısını n = 100 için çağrı sayarak çözelim. Java'nın tamsayı bölmesi kullanılsın.

  1. Zincir: 100, 50, 25, 12, 6, 3, 1. 25 / 2 = 12 ve 3 / 2 = 1, aşağı yuvarlamanın sonucudur.
  2. Çağrı sayısı 7'dir. ⌊log2 100⌋ = 6 (26 = 64 ≤ 100 < 128 = 27), buna 1'in çağrısı eklenir: 6 + 1 = 7.
  3. T(100) = 7c.

4.6.4T(n) = 2T(n − 1) + c

Bu bağıntıda her çağrı, n − 1 boyutlu iki çağrı yapar. Çağrı ağacının her seviyesinde çağrı sayısı ikiye katlanır. Kökte (seviye 0) 1 çağrı, seviye 1'de 2 çağrı, seviye 2'de 4 çağrı vardır; seviye k'de 2k çağrı. Taban durum n = 1 ise seviyeler 0'dan n − 1'e kadar gider.

1 + 2 + 4 + … + 2n−1 = 2n − 1 çağrı → O(2n)

Bu toplamın değeri, ikinin kuvvetlerinin bilinen bir özelliğinden gelir: ikinin ardışık kuvvetlerinin toplamı, bir sonraki kuvvetin bir eksiğidir. Örneğin 1 + 2 + 4 + 8 = 15 = 16 − 1. İkili sayılarla düşünülünce bu açıktır: 1111 ikili sayısı 15'tir ve 1 eklenince 10000, yani 16 olur.

n = 4: 1 çağrın = 3: 2 çağrın = 2: 4 çağrın = 1: 8 çağrıtoplam 1 + 2 + 4 + 8 = 15 = 2⁴ − 1
Şekil 4.25: T(n) = 2T(n − 1) + c çağrı ağacı, n = 4. Seviyelerde 1, 2, 4 ve 8 çağrı vardır; toplam 1 + 2 + 4 + 8 = 15 = 24 − 1. En alttaki 8 düğüm (kırmızı) taban durumdur.

Hanoi kuleleri bu bağıntıya tam uyar: 2n − 1 hamle. n = 20 için 1.048.575, n = 64 için yaklaşık 1,8 · 1019 hamle.

fib'in bağıntısı T(n) = T(n − 1) + T(n − 2) + c'dir. T(n − 2) ≤ T(n − 1) olduğu için T(n) ≤ 2T(n − 1) + c yazılabilir. Buradan fib için üst sınır olarak O(2n) çıkar. Gerçek büyüme bundan biraz yavaştır: 4.4.3'te gösterildiği gibi yaklaşık 1,6n. Ama 1,6 tabanı da üsteldir; n = 40 için 331 milyon çağrı bunun sonucudur.

Bu ağaç ne kadar büyük olursa olsun derinliği yalnız n'dir. Yığında aynı anda en çok n çerçeve bulunur. Zaman üstel, bellek doğrusaldır.

Sık yapılan hata

2T(n − 1) ile 2T(n/2)'yi karıştırmak. İkisi de iki çağrı yapar, ama sonuçları çok farklıdır. T(n) = 2T(n/2) + c'de ağacın derinliği log2 n'dir ve son seviyede 2log n = n düğüm vardır; toplam yaklaşık 2n çağrı, yani O(n). T(n) = 2T(n − 1) + c'de ise derinlik n'dir ve toplam O(2n). Çağrı sayısı kadar, girdinin ne kadar küçüldüğü de önemlidir.

4.6.5Alan maliyeti: en derin çağrı zinciri

Zaman maliyeti çağrı ağacındaki bütün düğümlerden gelir. Alan maliyeti ise başka bir ölçüye bağlıdır. Bir çağrı, kardeşi başlamadan önce tamamen biter. Örneğin fib(4)'te fib(3)'ün bütün alt ağacı bitmeden fib(2) başlamaz. Bu yüzden yığında aynı anda yalnız kökten bir düğüme giden tek bir yol bulunur. Bitmiş çağrıların çerçeveleri silinmiştir, henüz başlamamış çağrıların çerçeveleri ise yoktur.

Bellek = en derin çağrı zinciri × çerçeve boyutu

Toplam çağrı sayısı değil, ağacın derinliği belirleyicidir. fib(4) 9 çağrı yapar, ama yığında aynı anda en çok 4 fib çerçevesi (ve main) bulunur.

f(4)f(3)f(2)f(1)f(0)f(1)f(2)f(1)f(0)
Şekil 4.26: fib(4) çağrı ağacında en derin zincir (kırmızı): fib(4) → fib(3) → fib(2) → fib(1). Yığın hiçbir anda bu yoldan daha uzun olmaz.
Tablo 4.15: Toplam çağrı sayısı ile en derin zincirin karşılaştırılması.
ÇağrıToplam çağrıEn derin zincirZamanAlan
faktoriyel(4)44O(n)O(n)
fib(40)331.160.28140üstelO(n)
hanoi(3, …)73O(2n)O(n)
altKumeler, n = 3154O(n · 2n)O(n)

Tablo 4.15 çarpıcı bir farkı gösterir. fib(40) üç yüz milyondan fazla çağrı yapar ve sonucu beklemek fark edilir bir süre alır, ama belleği hiç zorlamaz: yığın en çok 40 çerçeve derinliğe iner. Tersine, topla(1_000_000) yalnız bir milyon çağrı yapar ve bu zaman açısından hiç sorun değildir; ama bir milyon çerçevelik zincir yığını taşırır (4.2.3). Özyinelemede zaman sorunu ile bellek sorunu birbirinden bağımsızdır ve ayrı ayrı denetlenmelidir.

altKumeler satırındaki zaman, yalnız çağrı sayısından gelmez. Çağrı sayısı 2n+1 − 1'dir, ama 2n yaprağın her birinde n'ye kadar öğeli bir liste yazdırılır. Yazdırmanın maliyeti eklenince toplam O(n · 2n) olur.

4.7Kuyruk özyinelemesi

Özyinelemeli metotların büyük bir kısmı dönüş yolunda iş yapar: faktoriyel çarpmayı, toplam toplamayı, ileriSay yazdırmayı dönüşte yapar. Bu yüzden çerçeveler, alt çağrılar bitene kadar bellekte beklemek zorundadır. Bazı metotlarda ise dönüşte yapılacak hiçbir iş yoktur. Böyle metotlar özeldir: döngüye kolayca çevrilir.

4.7.1Kuyruk özyinelemesi nedir?

Tanım 4.7

Özyinelemeli çağrı metodun son işiyse, yani çağrı döndükten sonra yapılacak bir iş kalmıyorsa, metoda kuyruk özyinelemeli denir (tail recursion). Böyle bir çağrıya kuyruk çağrısı denir: metot, çağrının döndürdüğü değeri hiç değiştirmeden döndürür.

faktoriyel kuyruk özyinelemeli değildir:

int faktoriyel(int n) {
  if (n <= 1) return 1;
  return n * faktoriyel(n - 1);
  // çarpma, çağrı dönünce yapılır
}

return n * faktoriyel(n - 1); satırında çağrı son iş gibi görünür, ama değildir. Çağrı döndükten sonra sonucun n ile çarpılması gerekir. Son iş çarpmadır.

Faktöriyeli kuyruk özyinelemeli yazmak için çarpmayı çağrıdan önceye almak gerekir. Bunun yolu, ara sonucu bir parametreyle aşağı taşımaktır. Bu parametreye biriktirici (accumulator) denir ve geleneksel olarak acc diye adlandırılır.

int faktKuyruk(int n, int acc) {
  if (n <= 1) return acc;
  return faktKuyruk(n - 1, n * acc);
  // son iş: çağrı (çarpma önce)
}

acc, o ana kadar yapılmış çarpmaların sonucunu tutar. Her çağrı kendi n'sini acc ile çarpar ve sonucu bir sonraki çağrıya verir. Taban durumda bütün çarpmalar yapılmıştır; acc sonucun kendisidir ve döndürülür. İlk çağrı faktKuyruk(n, 1) biçiminde yapılır, çünkü henüz hiçbir çarpma yapılmamıştır ve 1 çarpmanın etkisiz öğesidir.

Bölümdeki metotlar bu açıdan ikiye ayrılır:

  • Kuyruk özyinelemeli: geriSay (yazdırma çağrıdan önce), ters (değiştirme çağrıdan önce), ikiliAra (dönen değer olduğu gibi iletilir), faktKuyruk.
  • Kuyruk özyinelemeli değil: faktoriyel ve toplam (çağrıdan sonra çarpma ya da toplama), fib (iki çağrının sonucu toplanır), ileriSay (çağrıdan sonra yazdırma).
Sık yapılan hata

Çağrı return satırında diye metodu kuyruk özyinelemeli saymak. Ölçüt, çağrının satırdaki yeri değil, çağrı döndükten sonra iş kalıp kalmadığıdır. return f(n - 1) + 1; satırında çağrı döndükten sonra 1 eklenir; metot kuyruk özyinelemeli değildir. return g(n - 1, acc + n); ise kuyruk çağrısıdır: toplama çağrıdan önce, parametre hesaplanırken yapılır.

4.7.2Kod izleme: faktKuyruk(4, 1)

int faktKuyruk(int n, int acc) {
  if (n <= 1) return acc;   // taban durum
  return faktKuyruk(n - 1, n * acc);  // son iş
}
// main içinde:
System.out.println(faktKuyruk(4, 1));
Tablo 4.16: faktKuyruk(4, 1) izlemesi.
AdımÇağrıOlayDönen
1faktKuyruk(4, 1)n = 4 > 1: önce çarpma 4 · 1 = 4, sonra faktKuyruk(3, 4)
2faktKuyruk(3, 4)3 · 4 = 12, sonra faktKuyruk(2, 12)
3faktKuyruk(2, 12)2 · 12 = 24, sonra faktKuyruk(1, 24)
4faktKuyruk(1, 24)taban durum: acc döner; sonuç burada hazırdır24
5faktKuyruk(2, 12)24'ü değiştirmeden iletir24
6faktKuyruk(3, 4)24'ü değiştirmeden iletir24
7faktKuyruk(4, 1)24'ü değiştirmeden iletir24

Tablo 4.2'deki faktoriyel(4) izlemesiyle karşılaştırınca fark açıkça görülür. Orada çarpmalar dönüş yolunda yapılıyordu: 2 · 1, 3 · 2, 4 · 6. Burada çarpmalar iniş yolunda yapılır: 4 · 1, 3 · 4, 2 · 12. Çarpma sırası farklıdır ama çarpma değişmeli olduğu için sonuç aynıdır. Asıl fark dönüş yolundadır. 5–7. adımlarda hiçbir çerçeve yeni bir hesap yapmaz; hepsi aldığı 24'ü olduğu gibi iletir.

faktKuyruk(4, 1)çağrı: (3, 4)faktKuyruk(3, 4)çağrı: (2, 12)faktKuyruk(2, 12)çağrı: (1, 24)faktKuyruk(1, 24)return acc= 24→ çağrı · ← dönüş
faktKuyruk(4, 1)çağrı: (3, 4)= 24faktKuyruk(3, 4)çağrı: (2, 12)= 24faktKuyruk(2, 12)çağrı: (1, 24)= 24faktKuyruk(1, 24)return acc= 24→ çağrı · ← dönüş
Şekil 4.27: faktKuyruk(4, 1) çağrı zinciri. Üstte taban durum (Tablo 4.16, 4. adım): her kutuda o çağrının yaptığı çağrının parametreleri yazılıdır; faktKuyruk(1, 24) sonucu, 24'ü, hazır döndürüyor. Altta bütün çerçeveler aynı 24'ü iletmiştir.

Dönüşte yapılacak iş yoksa bekleyen çerçevelere de gerek yoktur. Bir derleyici bunu fark edip kuyruk çağrısında yeni çerçeve açmak yerine mevcut çerçeveyi yeniden kullanabilir. Bu eniyilemeye kuyruk çağrısı eniyilemesi denir. Ama Java bunu yapmaz: faktKuyruk için de her çağrıda yeni bir çerçeve açılır. Yukarıdaki izlemede 4 çerçeve açılmıştır; dönüşte bu çerçevelere ihtiyaç yoktu, ama yine de yığında yer tuttular.

4.7.3Döngüye çevirme

Kuyruk özyinelemeli bir metot her zaman bir döngüye çevrilebilir ve bu çevirme tamamen mekaniktir. Bir kuyruk çağrısı, parametrelere yeni değerler verip metodun başına dönmek demektir. Döngü de tam olarak bunu yapar.

int faktDongu(int n) {
  int acc = 1;
  while (n > 1) {   // taban durum değilken
    acc = n * acc;  // çağrının yeni
    n = n - 1;      //   parametreleri
  }
  return acc;       // taban: return acc
}

Çevirmenin kuralları şunlardır:

  • Parametreler değişken olur. n zaten bir parametredir; acc, ilk çağrıdaki değeri olan 1 ile başlatılan yerel bir değişkene dönüşür.
  • Taban durumun tersi döngü koşulu olur. Özyineleme n <= 1 iken durur; döngü n > 1 iken sürer.
  • Kuyruk çağrısı, parametrelerin güncellenmesi olur. faktKuyruk(n - 1, n * acc) çağrısı, acc = n * acc; ve n = n - 1; atamalarına dönüşür. Atamaların sırası önemlidir: acc eski n ile hesaplanmalıdır. Önce n azaltılsaydı yanlış sayıyla çarpılırdı.
  • Taban durumdaki dönüş, döngüden sonraki dönüş olur: return acc;.
Tablo 4.17: faktDongu(4) izlemesi. Her satır, özyinelemeli sürümdeki bir çağrının parametrelerine karşılık gelir.
TurnaccKarşılığı
başlangıç41faktKuyruk(4, 1)
134faktKuyruk(3, 4)
2212faktKuyruk(2, 12)
3124faktKuyruk(1, 24): koşul yanlış, 24 döner

Döngü sabit bellekle çalışır: yalnız iki değişken vardır, çerçeve açılmaz. Bellek O(1), zaman O(n)'dir. Aynı çevirme ikiliAra'ya da uygulanabilir: lo ve hi değişken olur, döngü lo <= hi iken sürer, her turda lo ya da hi güncellenir. Bilinen döngülü ikili arama, özyinelemeli sürümün tam olarak bu biçimde çevrilmiş hâlidir.

Dil desteği konusunda bir not: Java derleyicisi ve JVM kuyruk çağrısını eniyilemez; çok derin bir kuyruk özyinelemesi de StackOverflowError verir. Scheme gibi bazı diller bu eniyilemeyi dilin kuralı olarak garanti eder. Scala ve Kotlin derleyicileri, bir metodun kendini çağıran kuyruk özyinelemesini döngüye çevirebilir. Java'da derinliği büyük olabilecek kuyruk özyinelemesi, bu yüzden elle döngüye çevrilir.

Örnek 4.8

Aşağıdaki kuyruk özyinelemeli metot bir sayının basamak sayısını bulur: int bas(int n, int acc) { if (n < 10) return acc + 1; return bas(n / 10, acc + 1); }. İlk çağrı bas(n, 0)'dır. Döngüye çevirelim ve bas(4096, 0)'ı izleyelim.

  1. Parametreler değişken olur: acc = 0 ile başlanır.
  2. Taban durum n < 10'dur; döngü n >= 10 iken sürer.
  3. Kuyruk çağrısı bas(n / 10, acc + 1), acc = acc + 1; n = n / 10; olur (burada sıra fark etmez, çünkü iki ifade birbirinin eski değerini kullanmaz).
  4. Döngüden sonra taban durumdaki ifade döndürülür: return acc + 1;.
  5. İzleme: (n, acc) = (4096, 0) → (409, 1) → (40, 2) → (4, 3). 4 < 10, döngü biter, 3 + 1 = 4 döner.

4.7.4Özyineleme mi, döngü mü?

Her döngü özyinelemeyle, her özyineleme de döngüyle yazılabilir. Seçim, problemin yapısına ve girdinin büyüklüğüne bağlıdır. Tablo 4.18 iki yaklaşımı dört ölçüte göre karşılaştırır.

Tablo 4.18: Özyineleme ile döngünün karşılaştırılması.
ÖlçütÖzyinelemeDöngü
Okunabilirliktanıma yakın, kısadurum değişkenleri elle yönetilir
BellekO(derinlik) çağrı yığınıçoğunlukla O(1)
Hızher çağrının ek maliyeti vargenellikle daha hızlı
Derinlik sınırıStackOverflowError riskiyok

Şu durumlarda özyineleme doğal seçimdir: veri iç içe bir yapıdaysa (ağaçlar, iç içe klasörler, iç içe ifadeler); problem geri izleme gerektiriyorsa (alt kümeler, permütasyonlar); problem her adımda yarıya bölünüyorsa; Hanoi gibi tanımı özyinelemeli olan problemlerde. Bu durumların ortak noktası, derinliğin küçük olmasıdır: log n kadar ya da n'nin kendisi küçük.

Şu durumlarda döngü daha iyidir: basit sayma ve toplama işlerinde; derinliği n'ye ulaşan doğrusal özyinelemede, özellikle n büyükse (uzun bir bağlı liste, büyük bir sayı); tekrarlanan alt problemli ikili özyinelemede (fib).

Daha genel bir sonuç da vardır: kuyruk özyinelemeli olmayanlar dahil, her özyinelemeli metot döngüye çevrilebilir. Bunun yolu, çağrı yığınının yaptığı işi açık bir yığın veri yapısıyla yapmaktır. Bekleyen işler (yarım kalmış çarpmalar, henüz çözülmemiş alt problemler) bir yığına konur ve döngü yığın boşalana kadar sürer. Hanoi kuleleri böyle bir “görev yığınıyla” özyinelemesiz çözülebilir. Bu çevirme kuyruk özyinelemesindeki kadar kısa değildir, ama yığın taşması riskini ortadan kaldırır: açık yığın, çağrı yığınının aksine, programın genel belleğinde büyür.

Sık yapılan hata

Özyinelemenin her zaman yavaş ve kötü olduğunu düşünmek. fib'in yavaşlığı özyinelemeden değil, aynı alt problemin tekrar tekrar çözülmesinden gelir. ikiliAra ya da hizliUs gibi O(log n) derinlikli özyinelemeler hem hızlıdır hem de yığını zorlamaz. Sorun, derinliğin ve tekrarın büyüklüğündedir; özyinelemenin kendisinde değil.

4.8Bağlı listede özyineleme

Özyineleme yalnızca sayılar ve diziler üzerinde değil, veri yapılarının kendisi üzerinde de doğal bir araçtır. En basit bağlantılı veri yapısı tek yönlü bağlı listedir ve özyinelemeli bir gözle bakılınca işlemleri çok kısa yazılır. Aynı bakış ağaçlar gibi daha karmaşık yapılarda da işe yarar.

4.8.1Liste özyinelemeli bir yapıdır

Bağlı liste (linked list), öğeleri düğümlerde (node) tutan bir veri yapısıdır. Her düğüm bir veri ve bir sonraki düğümün referansını taşır. Son düğümün “sonraki” referansı null'dır. Listenin ilk düğümünü gösteren referansa genellikle bas denir. Liste boşsa bas da null'dır.

Bu yapıya özyinelemeli bir tanım verilebilir: bağlı liste ya boştur (null) ya da bir düğüm ile onu izleyen daha kısa bir listedir. bas → 7 → 3 → 9 → null listesi, 7'yi taşıyan bir düğüm ile 3 → 9 → null listesinden oluşur. O liste de 3'ü taşıyan düğüm ile 9 → null listesinden oluşur. En sonda boş liste vardır.

bas739nullkalan liste: d.sonraki
Şekil 4.28: bas → 7 → 3 → 9 → null listesi. İlk düğümün sonraki referansından başlayan kısım (d.sonraki), kendisi de bir bağlı listedir: “kalan liste”.

Tanım özyinelemeli olduğu için liste üzerindeki işlemler de doğal olarak özyinelemeli yazılır. Kalıp hep aynıdır: düğüm işlenir, kalan liste (d.sonraki) aynı metoda verilir. Taban durum boş listedir: d == null. Listenin uzunluğu bu kalıbın en sade örneğidir:

class Dugum { int veri; Dugum sonraki; }

int uzunluk(Dugum d) {
  if (d == null) return 0;        // boş liste
  return 1 + uzunluk(d.sonraki);  // 1 + kalan
}

Boş listenin uzunluğu 0'dır. Boş olmayan listenin uzunluğu, ilk düğüm için 1 artı kalan listenin uzunluğudur. Özyinelemeli düşünmenin üç sorusu burada şöyle cevaplanır: taban durum d == null, küçültme d.sonraki'ye geçmek, birleştirme 1 eklemek. Listeyi baştan sona yazdıran metot da aynı kalıba uyar:

void yazdir(Dugum d) {
  if (d == null) return;
  System.out.print(d.veri + " ");
  yazdir(d.sonraki);
}
Sık yapılan hata

Taban durumu d.sonraki == null diye yazmak. Bu koşul tek düğümlü listeyi taban durum sayar, ama boş listeyi (d == null) yakalamaz: uzunluk(null) çağrılınca d.sonraki'ye erişilir ve NullPointerException fırlatılır. Taban durum, yapının en küçük hâli olan boş listedir.

4.8.2Kod izleme: uzunluk(bas)

int uzunluk(Dugum d) {
  if (d == null) return 0;        // boş liste
  return 1 + uzunluk(d.sonraki);  // 1 + kalan
}
// main içinde (bas → 7 → 3 → 9 → null):
System.out.println(uzunluk(bas));
Tablo 4.19: uzunluk(bas) izlemesi, liste 7 → 3 → 9 → null.
AdımdOlayDönen
17 düğümüd ≠ null: 1 + uzunluk(d.sonraki); kalan liste 3 ile başlar
23 düğümüd ≠ null: kalan liste 9 ile başlar
39 düğümüd ≠ null: d.sonraki null, boş liste için çağrı
4nulltaban durum: boş liste0
59 düğümü1 + 01
63 düğümü1 + 12
77 düğümü1 + 23

Sonuç 3'tür. 4 çağrı yapılmıştır: 3 düğüm ve sondaki null. n düğümlü listede n + 1 çağrı yapılır ve yığın en derin anda n + 1 çerçeve içerir. Zaman O(n), yığın belleği O(n)'dir.

bas739null↑ 0
bas7↑ 33↑ 29↑ 1null↑ 0
Şekil 4.29: uzunluk(bas) izlemesinden iki an. Üstte taban durum (Tablo 4.19, 4. adım): d, null'ı gösteriyor ve 0 dönüyor. Altta son durum: düğümlerin üstündeki sayılar, o düğümden başlayan listenin uzunluğudur (3, 2, 1, 0).

Bu bellek maliyeti, listeler için ciddi bir sorundur. Bağlı listeler milyonlarca düğüm içerebilir ve uzunluk, bir milyon düğümlü listede bir milyon çerçeve açmaya çalışır. 4.2.3'te görüldüğü gibi bu kadar çerçeve varsayılan yığına sığmaz. Döngülü sürüm aynı işi sabit bellekle yapar: int s = 0; for (Dugum d = bas; d != null; d = d.sonraki) s++;.

4.8.3Listeyi tersten yazdırma

Tek yönlü bağlı listede her düğüm yalnız bir sonrakini bilir; bir öncekine giden referans yoktur. Listeyi sondan başa yazdırmak bu yüzden döngüyle kolay değildir. Önce son düğüme gidilir, ama ondan sonra bir öncekine dönülemez. Özyineleme bu sorunu kendiliğinden çözer.

void tersYazdir(Dugum d) {
  if (d == null) return;
  tersYazdir(d.sonraki);  // önce kalan
  System.out.print(d.veri + " ");
}

4.8.1'deki yazdir ile bu metot arasındaki tek fark, yazdırma satırının özyinelemeli çağrıdan önce mi sonra mı geldiğidir. Bu, 4.1.5'teki geriSay2 ile ileriSay farkının aynısıdır. yazdir(bas) çıktısı 7 3 9'dur: yazdırma çağrıdan önce yapılır ve düğümler gidiş sırasıyla yazılır. tersYazdir(bas) çıktısı ise 9 3 7'dir.

bas739null
Şekil 4.30: bas → 7 → 3 → 9 → null listesi. yazdir düğümleri gidiş yönünde (7 3 9), tersYazdir çağrı yığını çözülürken dönüş yönünde (9 3 7) yazar.
Örnek 4.9

tersYazdir(bas) izlemesi:

  1. tersYazdir(7): d ≠ null; önce tersYazdir(3) çağrılır, 7'nin yazdırılması bekler.
  2. tersYazdir(3): önce tersYazdir(9); 3 bekler.
  3. tersYazdir(9): önce tersYazdir(null); 9 bekler.
  4. tersYazdir(null): taban durum, hemen döner.
  5. Yığın çözülür: tersYazdir(9) 9 yazar, sonra tersYazdir(3) 3 yazar, en son tersYazdir(7) 7 yazar.

Çıktı 9 3 7'dir. Düğümler, yığın çözülürken sondan başa yazdırılmıştır.

Tek yönlü listede geriye giden bağlantı yoktur; geri dönüşü çağrı yığını sağlar. Her çerçeve kendi düğümünü “aklında tutar” ve sırası gelince yazar. Bunun bedeli O(n) yığın belleğidir. Çok uzun listelerde aynı iş bir döngü ve açık bir yığın veri yapısıyla daha güvenli yapılır. Döngü düğümleri baştan sona dolaşır ve her birini yığına ekler. Sonra yığın boşaltılır; düğümler ters sırayla çıkar ve yazdırılır.

Sık yapılan hata

Listeyi tersten yazdırmak için listeyi tersine çevirmek. Liste yerinde tersine çevrilirse yapı değişir; başka bir yerde aynı listeye bakan kod bozulur. tersYazdir listeye hiç dokunmaz, yalnız okur. Yapıyı değiştirmeden ters sırada gezmek gerekiyorsa özyineleme ya da açık bir yığın kullanılır.

4.9Sık yapılan hatalar

Özyinelemedeki hataların çoğu birkaç tipe ayrılır. Not boyunca her konunun yanında verilen hatalar burada bir arada toplanmıştır; özyinelemeli bir metodu gözden geçirirken kontrol listesi olarak kullanılabilir.

Tablo 4.20: Özyinelemeli kodda sık görülen hatalar.
HataNe olur?Doğrusu
Taban durum eksik ya da ulaşılamaz0 ya da negatif girdi taban durumu atlar; çağrılar bitmez, StackOverflowError.n == 1 yerine n <= 1 gibi her girdiyi yakalayan bir koşul.
Problem küçülmüyorf(n) içinden yine f(n) ya da f(n + 1) çağrılır; taban duruma yaklaşılmaz.Her çağrıda girdi taban duruma doğru değişmeli.
Dönüş değerini kaybetmekikiliAra'da sol yarı çağrısının önündeki return unutulursa sonuç atılır, akış sağ yarıya düşer; derleyici uyarmaz.Özyinelemeli çağrının sonucu kullanılmalı ya da döndürülmeli.
Geri almayı unutmakGeri izlemede bir dalın seçimleri sonraki dallara taşar; alt kümeler ya da permütasyonlar yanlış çıkar.Paylaşılan nesneye yapılan her değişiklik, çağrı dönünce geri alınmalı.
Tekrarlanan hesapfib gibi ikili özyinelemede aynı alt problem üstel sayıda çözülür.Sonuçları saklamak ya da döngü: O(n).
Derin doğrusal özyinelemeMilyon düğümlü listede uzunluk yığını taşırır; Java kuyruk çağrısını eniyilemez.Döngü.

Bu hataların hepsi aynı iki soruya bağlanır. Birincisi doğrulukla ilgilidir: taban durum her girdiyi yakalıyor mu ve her adım ona yaklaşıyor mu? İlk iki hata bu sorunun cevabının “hayır” olduğu durumlardır. Üçüncü ve dördüncü hata da doğrulukla ilgilidir, ama tümevarım varsayımının bozulmasıyla: küçük problemin cevabı ya kaybolur ya da küçük problem yanlış bir durumdan başlar. İkinci soru maliyetle ilgilidir: çağrı ağacı kaç düğümlü ve ne kadar derin? Son iki hata bu sorunun cevabı göz ardı edildiğinde ortaya çıkar. Düğüm sayısı üstelse zaman, derinlik çok büyükse bellek sorun olur.

4.10Özet

  • Özyineleme, bir metodun kendini daha küçük bir girdiyle çağırmasıdır. Her özyinelemeli metot bir taban durum (doğrudan çözülen girdiler) ve taban duruma yaklaşan bir özyinelemeli adım içerir. Özyinelemeli düşünmede küçük problemin doğru çözüldüğü kabul edilir ve yalnız son adım düşünülür; bu, tümevarımın mantığıdır.
  • Bir iş özyinelemeli çağrıdan önce yapılırsa çağrılar inerken, sonra yapılırsa çağrılar dönerken ve ters sırayla yapılır (geriSay2 → 3 2 1, ileriSay → 1 2 3).
  • Her çağrı çağrı yığınında bir çerçeve açar: parametreler, yerel değişkenler, dönüş adresi. Çerçeveler LIFO düzeniyle eklenip çıkarılır; yalnız en üstteki çalışır. Yığın sınırlıdır: çok derin ya da sonsuz özyineleme StackOverflowError verir. Her girdi, 0 ve negatifler dahil, sonlu adımda bir taban duruma ulaşmalıdır.
  • Doğrusal özyinelemede her çağrı en çok bir çağrı yapar ve çağrılar bir zincir oluşturur: faktoriyel, toplam, ters, uzunluk. Girdi her adımda yarıya inerse zincir O(log n) uzunluğundadır: hizliUs ⌊log2 n⌋ + 2 çağrı yapar, ikiliAra O(log n)'dir.
  • İkili özyinelemede bir çağrı iki çağrı yapar ve bir çağrı ağacı oluşur. fib(n) 2F(n + 1) − 1 çağrı yapar ve yaklaşık 1,6n hızında büyür, çünkü aynı alt problemler tekrar tekrar çözülür. Sonuçları saklamak ya da döngü O(n) verir; döngülü sürüm O(1) bellek kullanır.
  • Hanoi kuleleri üç adımla çözülür: n − 1 disk yardımcıya, en büyük disk hedefe, n − 1 disk hedefe. Hamle sayısı 2n − 1'dir ve bu en azdır.
  • Geri izleme, çözümü adım adım kurar: bir seçenek seçilir, özyinelemeli çağrıyla ilerlenir, dönüşte seçim geri alınır. altKumeler 2n alt küme ve 2n+1 − 1 çağrı, perm n! permütasyon üretir.
  • Yineleme bağıntısı, maliyeti küçük girdilerin maliyetiyle yazar ve çağrı ağacıyla çözülür: T(n − 1) + c → O(n), T(n/2) + c → O(log n), 2T(n − 1) + c → O(2n). Zaman çağrı sayısından, alan en derin çağrı zincirinden gelir.
  • Kuyruk özyinelemesinde özyinelemeli çağrı metodun son işidir; ara sonuç bir biriktiriciyle taşınır. Böyle bir metot mekanik olarak döngüye çevrilir: parametreler değişken, taban durumun tersi döngü koşulu olur. Java kuyruk çağrısını eniyilemez; faktKuyruk da O(n) yığın kullanır.
  • Bağlı liste özyinelemeli bir yapıdır: ya boştur ya da bir düğüm ve daha kısa bir listedir. Taban durum d == null'dır. tersYazdir, çağrı yığınını kullanarak tek yönlü listeyi sondan başa yazar; bedeli O(n) yığın belleğidir.
Tablo 4.21: Bölümdeki özyinelemeli metotların özeti.
ÖrnekÖzyineleme türüZamanAlan (yığın)
faktoriyel, toplam, tersdoğrusalO(n)O(n)
hizliUs, ikiliAradoğrusal, yarılayanO(log n)O(log n)
fibikiliüstel (≈ 1,6n)O(n)
hanoiikiliO(2n)O(n)
altKumelergeri izlemeO(n · 2n)O(n)
permçoklu, geri izlemeO(n · n!)O(n)
faktKuyrukkuyruk özyinelemesiO(n)O(n) (Java'da)
uzunluk, tersYazdirdoğrusal (bağlı liste)O(n)O(n)

Bölümün tamamı tek bir cümleye sığar: özyineleme, taban durum ile taban duruma yaklaşan özyinelemeli adımdan oluşur; zaman maliyeti çağrı sayısından, alan maliyeti en derin çağrı zincirinden gelir.

4.11Kavram listesi

Terimlerin İngilizce karşılıkları ve öteki bölümlerdeki kullanımları terim sözlüğünde bulunur.

4.12Kendini Dene

  1. void yaz(int n) { if (n == 0) return; yaz(n / 10); System.out.print(n % 10 + " "); } için yaz(472) ne yazar? Kaç çağrı yapılır ve yığında (main dahil) en çok kaç çerçeve bulunur?

    Cevap

    Çağrılar 472, 47, 4, 0 girdileriyle yapılır: 4 çağrı. yaz(0) taban durumdur ve hiçbir şey yazmaz. Yazdırma çağrıdan sonra olduğu için dönüşte, ters sırayla yapılır: önce yaz(4) 4'ü, sonra yaz(47) 7'yi, en son yaz(472) 2'yi yazar. Çıktı 4 7 2'dir; basamaklar soldan sağa yazılmıştır. En derin anda 4 yaz çerçevesi ve main vardır: 5 çerçeve.

  2. hizliUs(3, 9) kaç çağrı yapar? Her çağrı ne döndürür?

    Cevap

    n değerleri 9, 4, 2, 1, 0'dır: 5 çağrı (⌊log2 9⌋ + 2 = 3 + 2). Dönüşte: hizliUs(3, 0) = 1; n = 1 tek, 3 · 1 · 1 = 3; n = 2 çift, 3 · 3 = 9; n = 4 çift, 9 · 9 = 81; n = 9 tek, 3 · 81 · 81 = 19.683. Sonuç 39 = 19.683.

  3. Özyinelemeli fib(7) kaç çağrı yapar? Yığında aynı anda en çok kaç fib çerçevesi bulunur?

    Cevap

    F(8) = 21 olduğundan çağrı sayısı 2 · 21 − 1 = 41'dir; fib(7) = 13. En derin zincir fib(7) → fib(6) → … → fib(1)'dir: 7 çerçeve (main ile 8). Zaman üstel, bellek O(n).

  4. a = {1, 4, 7, 10, 13, 16, 19} dizisinde ikiliAra(a, 19, 0, 6) ve ikiliAra(a, 5, 0, 6) kaçar çağrı yapar, ne döndürür?

    Cevap

    19 için: (0, 6) mid = 3, 19 > 10; (4, 6) mid = 5, 19 > 16; (6, 6) mid = 6, bulundu. 3 çağrı, 6 döner. 5 için: (0, 6) 5 < 10; (0, 2) mid = 1, 5 > 4; (2, 2) mid = 2, 5 < 7; (2, 1) aralık boş. 4 çağrı, −1 döner.

  5. 5 diskli Hanoi kulelerinde toplam kaç hamle yapılır? 2 numaralı disk kaç kez taşınır?

    Cevap

    25 − 1 = 31 hamle. Disk k, 2n−k kez taşınır: disk 2 için 23 = 8. Denetim: disk 1'den 5'e taşınma sayıları 16, 8, 4, 2, 1'dir ve toplamı 31'dir.

  6. altKumeler(new int[]{5, 6}, 0, new ArrayList<>()) ne yazar ve kaç çağrı yapar?

    Cevap

    Çıktı [5, 6] [5] [6] []'dir. Karar ağacında 1 + 2 + 4 = 7 düğüm vardır: 22+1 − 1 = 7 çağrı. Yığın en çok 3 altKumeler çerçevesi içerir.

  7. Bir metot n <= 0 iken sabit iş yapıp duruyor, öteki durumda sabit iş yapıp kendini n − 3 ile bir kez çağırıyor. Yineleme bağıntısı nedir? n = 12 için kaç çağrı yapılır?

    Cevap

    T(n) = T(n − 3) + c. Zincir 12, 9, 6, 3, 0'dır: 5 çağrı. Genel olarak yaklaşık n/3 + 1 çağrı yapılır; bu O(n)'dir, çünkü girdi her adımda sabit bir miktar azalır.

  8. int ik(int n, int acc) { if (n == 0) return acc; return ik(n - 1, acc * 2); } kuyruk özyinelemeli midir? ik(5, 1) hangi çağrı zinciriyle ne döndürür? Döngülü karşılığı nedir?

    Cevap

    Evet: çarpma parametre hesaplanırken, çağrıdan önce yapılır; dönen değer değiştirilmeden iletilir. Zincir (5, 1) → (4, 2) → (3, 4) → (2, 8) → (1, 16) → (0, 32); 6 çağrı, 32 döner (25). Döngülü karşılığı: int acc = 1; while (n != 0) { acc = acc * 2; n = n - 1; } return acc;. Java'da özyinelemeli sürüm O(n), döngü O(1) bellek kullanır.

  9. int say(Dugum d, int x) { if (d == null) return 0; return (d.veri == x ? 1 : 0) + say(d.sonraki, x); }. Liste 2 → 7 → 2 → 2 → null ise say(bas, 2) ne döndürür, kaç çağrı yapar? Metot kuyruk özyinelemeli midir?

    Cevap

    3 döndürür (2 değerli üç düğüm). Çağrı sayısı düğüm sayısı artı 1'dir: 5 (dört düğüm ve null). Kuyruk özyinelemeli değildir: çağrı döndükten sonra sonuca 0 ya da 1 eklenir. Biriktiricili bir sürüm (say(d.sonraki, x, acc + …)) kuyruk özyinelemeli olur.

  10. int t(int n) { if (n <= 1) return 1; return t(n - 1) + t(n - 1) + t(n - 1); } için t(3) ne döndürür, kaç çağrı yapar? Metot nasıl doğrusal yapılır?

    Cevap

    Çağrı ağacında seviyeler 1, 3, 9 düğümlüdür: 1 + 3 + 9 = 13 çağrı. 9 yaprağın her biri 1 döndürür: t(3) = 9 = 32. Genel olarak t(n) = 3n−1 ve çağrı sayısı (3n − 1)/2'dir: O(3n). Üç çağrı aynı değeri hesaplar; return 3 * t(n - 1); yazılırsa tek çağrı kalır ve maliyet O(n) olur.

4.13İleri okuma

  • M. T. Goodrich, R. Tamassia ve M. H. Goldwasser, Data Structures and Algorithms in Java (6. baskı, 2014), Bölüm 5: özyinelemeyi doğrusal, ikili ve çoklu özyineleme diye ayırarak anlatır ve bu nottakine yakın Java örnekleri (ikili arama, diziyi tersine çevirme, üs alma) verir.
  • R. Sedgewick ve K. Wayne, Computer Science: An Interdisciplinary Approach (2016), Kesim 2.3: Hanoi kuleleri, Fibonacci'deki tekrarlanan hesap ve sonuçları saklama konularını kısa programlar ve deneylerle işler.
  • E. Roberts, Thinking Recursively with Java (2006): kitabın tamamı özyinelemeli düşünmeye ayrılmıştır; tümevarımla bağlantıyı ve geri izleme örneklerini başlangıç düzeyindeki okura göre açıklar.
  • T. H. Cormen, C. E. Leiserson, R. L. Rivest ve C. Stein, Introduction to Algorithms (4. baskı, 2022), Bölüm 4: yineleme bağıntılarını özyineleme ağacı ve yerine koyma yöntemleriyle daha biçimsel bir dille çözer.

4.14Kaynaklar

  1. T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms, 4. baskı, MIT Press, 2022.
  2. M. T. Goodrich, R. Tamassia, M. H. Goldwasser, Data Structures and Algorithms in Java, 6. baskı, Wiley, 2014.
  3. E. Roberts, Thinking Recursively with Java, Wiley, 2006.
  4. R. Sedgewick, K. Wayne, Computer Science: An Interdisciplinary Approach, Addison-Wesley, 2016.

İlgili sayfalar: Bölüm 4 sunumu · Çalışma soruları · Hanoi kuleleri simülatörü · Terim sözlüğü · © Sercan Külcü. Tüm hakları saklıdır.