Kuyruk özyineleme tam olarak nasıl çalışır?


121

Neredeyse kuyruk özyinelemesinin nasıl çalıştığını ve bununla normal özyineleme arasındaki farkı anlıyorum. Ben sadece o niye anlamıyorum gelmez onun dönüş adresini hatırlamak yığını gerektirir.

// tail recursion
int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times (n, 1);
}

// normal recursion
int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

Kuyruk özyineleme fonksiyonunda bir fonksiyonun kendisini çağırdıktan sonra yapacak bir şey yok ama bu bana mantıklı gelmiyor.


16
Kuyruk özyineleme , "normal" özyinelemedir. Bu sadece özyinelemenin fonksiyonun sonunda gerçekleştiği anlamına gelir.
Pete Becker

7
... Ancak IL düzeyinde normal özyinelemeden farklı bir şekilde uygulanabilir ve yığın derinliğini azaltır.
KeithS

2
BTW, gcc buradaki "normal" örnekte kuyruk özyinelemesini ortadan kaldırabilir.
dmckee --- eski moderatör yavru kedi

1
@Geek - Ben bir C # dev'im, bu yüzden "assembly dili" MSIL veya sadece IL'dir. C / C ++ için IL'yi ASM ile değiştirin.
KeithS

1
@ShannonSeverance gcc'nin bunu, yayılan montaj kodunu olmadan basit bir şekilde inceleyerek yaptığını buldum -O3. Bağlantı, çok benzer bir zemini kapsayan ve bu optimizasyonu uygulamak için neyin gerekli olduğunu tartışan daha önceki bir tartışma içindir.
dmckee --- eski moderatör yavru kedi

Yanıtlar:


169

Derleyici bunu basitçe dönüştürebilir

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

bunun gibi bir şeye:

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}

2
@ Bay.32 Sorunuzu anlamıyorum. Fonksiyonu eşdeğer bir fonksiyona dönüştürdüm, ancak açık özyineleme olmadan (yani, açık fonksiyon çağrıları olmadan). Mantığı eşdeğer olmayan bir şeye değiştirirseniz, gerçekten de bazı durumlarda veya tüm durumlarda işlev döngüsünü sonsuza kadar yapabilirsiniz.
Alexey Frunze

18
Yani kuyruk özyinelemesi yalnızca derleyicinin onu optimize etmesi nedeniyle mi etkilidir ? Aksi takdirde yığın bellek açısından normal bir özyineleme ile aynı mı olur?
Alan Coromano

34
Evet. Derleyici özyinelemeyi bir döngüye indirgeyemezse, özyinelemeye takılı kalırsınız. Ya hep ya hiç.
Alexey Frunze

3
@AlanDert: doğru. Ayrıca, kuyruk özyinelemesini "kuyruk çağrısı optimizasyonunun" özel bir durumu olarak da düşünebilirsiniz, çünkü kuyruk çağrısı aynı işleve sahiptir. Genel olarak, herhangi bir kuyruk çağrısı (kuyruk özyinelemeye uygulandığı gibi "yapılacak iş kalmadığına ilişkin aynı gereksinimlerle ve kuyruk çağrısının dönüş değerinin doğrudan döndürüldüğü durumlarda), derleyici çağrıyı bir içinde yapabilirse optimize edilebilir. aranan fonksiyonun dönüş adresini, kuyruk çağrısının yapıldığı adres yerine, kuyruk çağrısını yapan fonksiyonun dönüş adresi olacak şekilde ayarlayan yol.
Steve Jessop

1
@AlanDert in C bu sadece herhangi bir standart tarafından uygulanmayan bir optimizasyondur, bu nedenle taşınabilir kod buna bağlı olmamalıdır. Ancak, kuyruk özyineleme optimizasyonunun standart tarafından uygulandığı diller de vardır (Şema bir örnektir), bu nedenle bazı ortamlarda taşmanın yığılacağından endişelenmenize gerek yoktur.
Jan Wrobel

57

Neden "dönüş adresini hatırlamak için yığın gerektirmediğini" soruyorsunuz.

Bunu tersine çevirmek isterim. Bu does iade adresi hatırlamak yığın kullanın. İşin püf noktası, kuyruk özyinelemesinin gerçekleştiği işlevin yığın üzerinde kendi dönüş adresine sahip olması ve çağrılan işleve atladığında, bunu kendi dönüş adresi olarak ele almasıdır.

Somut olarak, kuyruk arama optimizasyonu olmadan:

f: ...
   CALL g
   RET
g:
   ...
   RET

Bu durumda, gçağrıldığında yığın şöyle görünecektir:

   SP ->  Return address of "g"
          Return address of "f"

Öte yandan, kuyruk arama optimizasyonu ile:

f: ...
   JUMP g
g:
   ...
   RET

Bu durumda, gçağrıldığında yığın şöyle görünecektir:

   SP ->  Return address of "f"

Açıkça, ggeri döndüğünde, fçağrıldığı yere geri dönecektir .

DÜZENLE : Yukarıdaki örnek, bir işlevin başka bir işlevi çağırdığı durumu kullanır. Mekanizma, işlev kendisini çağırdığında aynıdır.


8
Bu diğer cevaplardan çok daha iyi bir cevap. Derleyici, büyük olasılıkla tail yinelemeli kodu dönüştürmek için bazı sihirli özel durumlara sahip değildir. Sadece aynı işleve giden normal bir son arama optimizasyonu gerçekleştirir.
Sanat

12

Kuyruk özyineleme, özellikle akümülatörler kullanıldığında, derleyici tarafından genellikle bir döngüye dönüştürülebilir.

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

gibi bir şeye derlerdi

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}

3
Alexey'nin uygulaması kadar akıllıca değil ... ve evet bu bir iltifat.
Matthieu M.

1
Aslında, sonuç daha basit görünüyor ama bence bu dönüşümü gerçekleştirecek kod, etiket / goto ya da sadece kuyruk çağrısının ortadan kaldırılmasından FAR daha "akıllı" olacaktır (Lindydancer'ın cevabına bakınız).
Phob

Eğer bunların hepsi kuyruk özyineleme ise, o zaman insanlar neden bu kadar heyecanlanıyor? Döngüler sırasında kimsenin heyecanlandığını görmüyorum.
Buh Buh

@BuhBuh: Bu, yığın taşmasına sahip değildir ve parametrelerin yığın itilmesini / atılmasını önler. Böyle sıkı bir döngü için bir dünya fark yaratabilir. Bunun dışında insanlar heyecanlanmamalı.
Mooing Duck

11

Özyinelemeli bir işlevde bulunması gereken iki öğe vardır:

  1. Özyinelemeli çağrı
  2. Dönüş değerlerinin sayılacağı bir yer.

"Normal" özyinelemeli bir fonksiyon (2) yi yığın çerçevesinde tutar.

Normal özyinelemeli fonksiyondaki dönüş değerleri iki tür değerden oluşur:

  • Diğer dönüş değerleri
  • Sahiplik işlevi hesaplamasının sonucu

Örneğinize bakalım:

int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

Örneğin f (5) çerçevesi kendi hesaplamasının sonucunu (5) ve f (4) değerini "depolar". Factorial (5) 'i çağırırsam, yığın çağrıları çökmeye başlamadan hemen önce, elimde:

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

Her bir yığının, bahsettiğim değerlerin yanı sıra, işlevin tüm kapsamını sakladığına dikkat edin. Dolayısıyla, f özyinelemeli bir işlev için bellek kullanımı O (x) 'dir, burada x, yapmak zorunda olduğum özyinelemeli çağrıların sayısıdır. Öyleyse, faktöryel (1) veya faktöryel (2) hesaplamak için 1kb RAM'e ihtiyacım olursa, faktöriyel (100) hesaplamak için ~ 100k gerekir, vb.

Bir Kuyruk Özyinelemeli işlevi, argümanlarına (2) koyar.

Bir Kuyruk Özyinelemesinde, her özyinelemeli çerçevedeki kısmi hesaplamaların sonucunu parametreleri kullanarak bir sonrakine geçiriyorum. Faktöriyel örneğimiz olan Tail Recursive'e bakalım:

int factorial (int n) {int helper (int num, int biriktirilmiş) {if num == 0 return birikimli, aksi takdirde return helper (num - 1, birikmiş * num)} dönüş yardımcısı (n, 1)
}

Faktöriyel (4) çerçevelerine bakalım:

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

Farkları görüyor musun? "Normal" özyinelemeli çağrılarda, dönüş işlevleri özyinelemeli olarak nihai değeri oluşturur. Kuyruk Özyinelemede yalnızca temel duruma başvururlar (sonuncusu değerlendirilir) . Akümülatöre eski değerlerin kaydını tutan argüman diyoruz .

Özyineleme Şablonları

Düzenli özyinelemeli işlev aşağıdaki gibidir:

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

Bir Tail özyinelemesine dönüştürmek için biz:

  • Akümülatörü taşıyan yardımcı bir işlevi tanıtın
  • akümülatör temel kasaya ayarlıyken yardımcı işlevi ana işlevin içinde çalıştırın.

Bak:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

Farkı gör?

Kuyruk Çağrısı optimizasyonu

Kuyruk Çağrısının Sınır Dışı Durumları yığınlarında hiçbir durum depolanmadığından, bunlar çok önemli değil. Bazı diller / tercümanlar daha sonra eski yığını yenisiyle değiştirir. Bu nedenle, çağrı sayısını kısıtlayan yığın çerçevesi olmadığından , Kuyruk Çağrıları bu durumlarda bir for-döngü gibi davranır .

Optimize etmek veya hayır yapmak derleyicinize bağlıdır.


6

İşte özyinelemeli fonksiyonların nasıl çalıştığını gösteren basit bir örnek:

long f (long n)
{

    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence

}

Kuyruk özyineleme olarak bilinen yapmak yüksek seviyeli programlama dillerinin çoğu derleyiciler yardımcı olur ve böylece hiçbir kod Ascendence yapılır nüks fonksiyonunun sonunda yapılır basit özyinelemeli fonksiyon, olduğu Kuyruk Özyinelemeye Optimizasyonu de vardır, Kuyruk yineleme modülü olarak bilinen daha karmaşık optimizasyon


1

Özyinelemeli işlev, kendi kendine çağıran bir işlevdir

Programcıların minimum miktarda kod kullanarak verimli programlar yazmasına olanak tanır .

Dezavantajı, düzgün yazılmazsa sonsuz döngülere ve diğer beklenmedik sonuçlara neden olabilmeleridir. .

Hem Basit Özyinelemeli işlevi hem de Kuyruk Özyinelemeli işlevi açıklayacağım

Basit bir özyinelemeli fonksiyon yazmak için

  1. Dikkate alınması gereken ilk nokta, if döngüsü olan döngüden çıkmaya ne zaman karar vermeniz gerektiğidir.
  2. İkincisi, kendi işlevimizsek ne yapacağımızdır

Verilen örnekten:

public static int fact(int n){
  if(n <=1)
     return 1;
  else 
     return n * fact(n-1);
}

Yukarıdaki örnekten

if(n <=1)
     return 1;

Döngüden ne zaman çıkılacağına karar veren faktör

else 
     return n * fact(n-1);

Gerçek işlem yapılacak mı

Kolay anlaşılması için görevi tek tek kırmama izin verin.

Koşarsam içeride ne olacağını görelim fact(4)

  1. N = 4 yerine
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifdöngü başarısız olduğundan döngüye gider, elseböylece geri döner4 * fact(3)

  1. Yığın hafızada, 4 * fact(3)

    N = 3 yerine

public static int fact(3){
  if(3 <=1)
     return 1;
  else 
     return 3 * fact(3-1);
}

If döngü başarısız olduğu için gider elsedöngüye

bu yüzden geri döner 3 * fact(2)

`` 4 * fact (3) '' dediğimizi hatırlayın

İçin çıktı fact(3) = 3 * fact(2)

Şimdiye kadar yığın var 4 * fact(3) = 4 * 3 * fact(2)

  1. Yığın hafızada, 4 * 3 * fact(2)

    N = 2 yerine

public static int fact(2){
  if(2 <=1)
     return 1;
  else 
     return 2 * fact(2-1);
}

Ifdöngü başarısız olduğu için elsedöngüye gider

bu yüzden geri döner 2 * fact(1)

Hatırla aradık 4 * 3 * fact(2)

İçin çıktı fact(2) = 2 * fact(1)

Şimdiye kadar yığın var 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. Yığın hafızada, 4 * 3 * 2 * fact(1)

    N = 1 yerine

public static int fact(1){
  if(1 <=1)
     return 1;
  else 
     return 1 * fact(1-1);
}

If döngü doğrudur

bu yüzden geri döner 1

Hatırla aradık 4 * 3 * 2 * fact(1)

İçin çıktı fact(1) = 1

Şimdiye kadar yığın var 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

Son olarak, olgunun sonucu (4) = 4 * 3 * 2 * 1 = 24

görüntü açıklamasını buraya girin

Kuyruk Özyineleme olurdu

public static int fact(x, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(x-1, running_total*x);
    }
}
  1. N = 4 yerine
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifdöngü başarısız olduğundan döngüye gider, elseböylece geri dönerfact(3, 4)

  1. Yığın hafızada, fact(3, 4)

    N = 3 yerine

public static int fact(3, running_total=4) {
    if (x==1) {
        return running_total;
    } else {
        return fact(3-1, 4*3);
    }
}

Ifdöngü başarısız olduğu için elsedöngüye gider

bu yüzden geri döner fact(2, 12)

  1. Yığın hafızada, fact(2, 12)

    N = 2 yerine

public static int fact(2, running_total=12) {
    if (x==1) {
        return running_total;
    } else {
        return fact(2-1, 12*2);
    }
}

Ifdöngü başarısız olduğu için elsedöngüye gider

bu yüzden geri döner fact(1, 24)

  1. Yığın hafızada, fact(1, 24)

    N = 1 yerine

public static int fact(1, running_total=24) {
    if (x==1) {
        return running_total;
    } else {
        return fact(1-1, 24*1);
    }
}

If döngü doğrudur

bu yüzden geri döner running_total

İçin çıktı running_total = 24

Son olarak, olgunun sonucu (4,1) = 24

görüntü açıklamasını buraya girin


0

Cevabım daha çok bir tahmindir, çünkü özyineleme, iç uygulama ile ilgili bir şeydir.

Kuyruk özyinelemede, özyinelemeli işlev aynı işlevin sonunda çağrılır. Muhtemelen derleyici aşağıdaki şekilde optimize edebilir:

  1. Devam eden işlevin sona ermesine izin verin (yani kullanılmış yığın geri çağrılır)
  2. İşleve argüman olarak kullanılacak değişkenleri geçici bir depoda saklayın
  3. Bundan sonra, geçici olarak depolanan bağımsız değişkenle işlevi tekrar çağırın

Gördüğünüz gibi, orijinal işlevi aynı işlevin bir sonraki yinelemesinden önce sarıyoruz, bu nedenle aslında yığını "kullanmıyoruz".

Ancak, işlevin içinde çağrılacak yıkıcılar varsa, bu optimizasyonun uygulanamayacağına inanıyorum.


0

Derleyici, kuyruk özyinelemesini anlamak için yeterince akıllıdır. Yinelemeli bir aramadan geri dönerken, bekleyen bir işlem yoktur ve özyinelemeli arama son deyimdir, kuyruk özyineleme kategorisine girer. Derleyici temelde kuyruk yineleme optimizasyonunu gerçekleştirerek yığın uygulamasını kaldırır.

void tail(int i) {
    if(i<=0) return;
    else {
     system.out.print(i+"");
     tail(i-1);
    }
   }

Optimizasyon yapıldıktan sonra yukarıdaki kod aşağıya dönüştürülür.

void tail(int i) {
    blockToJump:{
    if(i<=0) return;
    else {
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    }
    }
   }

Derleyici, Tail Recursion Optimization'ı bu şekilde yapar.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.