Bagaimana tepatnya cara kerja rekursi ekor?


121

Saya hampir mengerti cara kerja rekursi ekor dan perbedaan antara rekursi itu dan rekursi normal. Saya hanya tidak mengerti mengapa tidak memerlukan tumpukan untuk mengingat alamat pengirimnya.

// 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);
}

Tidak ada yang bisa dilakukan setelah memanggil fungsi itu sendiri dalam fungsi rekursi ekor tapi itu tidak masuk akal bagi saya.


16
Rekursi ekor adalah rekursi "normal". Ini hanya berarti bahwa rekursi terjadi di akhir fungsi.
Pete Becker

7
... Tapi itu dapat diimplementasikan dengan cara yang berbeda pada tingkat IL dari rekursi normal, mengurangi kedalaman tumpukan.
KeithS

2
BTW, gcc dapat melakukan eliminasi rekursi ekor pada contoh "normal" di sini.
dmckee --- mantan moderator anak kucing

1
@Geek - Saya seorang C # dev, jadi "bahasa assembly" saya adalah MSIL atau hanya IL. Untuk C / C ++, ganti IL dengan ASM.
KeithS

1
@ShannonSeverance Saya menemukan bahwa gcc melakukannya dengan cara sederhana yang memeriksa kode assembly yang dipancarkan tanpa -O3. Tautan ini untuk diskusi sebelumnya yang mencakup dasar yang sangat mirip dan membahas apa yang diperlukan untuk menerapkan pengoptimalan ini.
dmckee --- mantan moderator anak kucing

Jawaban:


169

Kompiler hanya dapat mengubah ini

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

menjadi seperti ini:

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

2
@ Mr. 32 Saya tidak mengerti pertanyaan Anda. Saya mengubah fungsi menjadi fungsi yang setara tetapi tanpa rekursi eksplisit (yaitu, tanpa pemanggilan fungsi eksplisit). Jika Anda mengubah logika menjadi sesuatu yang tidak setara, Anda mungkin memang membuat loop fungsi selamanya dalam beberapa atau semua kasus.
Alexey Frunze

18
Jadi rekursi tails hanya efektif karena compiler mengoptimalkannya? Dan jika tidak, itu akan sama dengan rekursi normal dalam hal memori tumpukan?
Alan Coromano

34
Ya. Jika kompilator tidak dapat mengurangi rekursi menjadi sebuah loop, Anda akan terjebak dengan rekursi. Semua atau tidak.
Alexey Frunze

3
@AlanDert: benar. Anda juga dapat menganggap rekursi ekor sebagai kasus khusus dari "pengoptimalan panggilan ekor", khusus karena panggilan ekor kebetulan memiliki fungsi yang sama. Secara umum, tail call apa pun (dengan persyaratan yang sama tentang "tidak ada pekerjaan tersisa untuk dilakukan" seperti yang berlaku untuk rekursi tail, dan di mana nilai kembalian dari panggilan tail langsung dikembalikan) dapat dioptimalkan jika compiler dapat melakukan panggilan dalam cara yang mengatur alamat pengirim dari fungsi yang dipanggil menjadi alamat kembali dari fungsi yang membuat panggilan ekor, bukan alamat dari mana panggilan ekor dibuat.
Steve Jessop

1
@AlanDert di C ini hanyalah pengoptimalan yang tidak diberlakukan oleh standar apa pun, jadi kode portabel tidak boleh bergantung padanya. Tetapi ada beberapa bahasa (Skema adalah salah satu contohnya), di mana pengoptimalan rekursi ekor diberlakukan oleh standar, jadi Anda tidak perlu khawatir bahwa itu akan menumpuk overflow di beberapa lingkungan.
Jan Wrobel

57

Anda bertanya mengapa "tidak memerlukan tumpukan untuk mengingat alamat pengirimnya".

Saya ingin membalikkan ini. Ini tidak menggunakan stack untuk mengingat alamat pengirim. Triknya adalah bahwa fungsi di mana rekursi tail terjadi memiliki alamat pengembaliannya sendiri di tumpukan, dan ketika ia melompat ke fungsi yang dipanggil, ia akan memperlakukan ini sebagai alamat pengembaliannya sendiri.

Secara konkret, tanpa pengoptimalan panggilan ekor:

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

Dalam hal ini, ketika gdipanggil, tumpukan akan terlihat seperti:

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

Di sisi lain, dengan pengoptimalan panggilan ekor:

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

Dalam hal ini, ketika gdipanggil, tumpukan akan terlihat seperti:

   SP ->  Return address of "f"

Jelas, ketika gkembali, itu akan kembali ke lokasi asal fpanggilan.

EDIT : Contoh di atas menggunakan kasus di mana satu fungsi memanggil fungsi lain. Mekanismenya identik saat fungsi memanggil dirinya sendiri.


8
Ini adalah jawaban yang jauh lebih baik daripada jawaban lainnya. Kompiler kemungkinan besar tidak memiliki kasus khusus magis untuk mengubah kode rekursif ekor. Itu hanya melakukan optimasi panggilan terakhir normal yang kebetulan pergi ke fungsi yang sama.
Seni

12

Rekursi tail biasanya dapat diubah menjadi loop oleh compiler, terutama jika akumulator digunakan.

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

akan dikompilasi menjadi sesuatu seperti

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

3
Tidak secerdas implementasi Alexey ... dan ya itu pujian.
Matthieu M.

1
Sebenarnya, hasilnya terlihat lebih sederhana tetapi saya pikir kode untuk menerapkan transformasi ini akan JAUH lebih "pintar" daripada label / goto atau hanya eliminasi panggilan ekor (lihat jawaban Lindydancer).
Phob

Jika ini semua adalah rekursi ekor, lalu mengapa orang begitu bersemangat tentang itu? Saya tidak melihat ada yang bersemangat saat loop.
Buh Buh

@BuhBuh: Ini tidak memiliki stackoverflow, dan menghindari tumpukan parameter yang mendorong / muncul. Untuk putaran ketat seperti ini, hal itu dapat membuat perbedaan besar. Selain itu orang tidak boleh bersemangat.
Mooing Duck

11

Ada dua elemen yang harus ada dalam fungsi rekursif:

  1. Panggilan rekursif
  2. Tempat untuk menghitung nilai yang dikembalikan.

Fungsi rekursif "biasa" menyimpan (2) di bingkai tumpukan.

Nilai yang dikembalikan dalam fungsi rekursif reguler terdiri dari dua jenis nilai:

  • Nilai kembali lainnya
  • Hasil perhitungan fungsi milik sendiri

Mari kita lihat contoh Anda:

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

Bingkai f (5) "menyimpan" hasil perhitungannya sendiri (5) dan nilai f (4), misalnya. Jika saya memanggil faktorial (5), tepat sebelum panggilan tumpukan mulai runtuh, saya memiliki:

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

Perhatikan bahwa setiap tumpukan menyimpan, selain nilai yang saya sebutkan, seluruh cakupan fungsi. Jadi, penggunaan memori untuk fungsi rekursif f adalah O (x), di mana x adalah jumlah panggilan rekursif yang harus saya lakukan. Jadi, jika saya membutuhkan RAM 1kb untuk menghitung faktorial (1) atau faktorial (2), saya perlu ~ 100k untuk menghitung faktorial (100), dan seterusnya.

Fungsi Tail Recursive menempatkan (2) di argumennya.

Dalam Rekursi Ekor, saya meneruskan hasil penghitungan parsial di setiap bingkai rekursif ke yang berikutnya menggunakan parameter. Mari kita lihat contoh faktorial kita, Rekursif Ekor:

int factorial (int n) {int helper (int num, int akumulasi) {if num == 0 return akumulasi else return helper (num - 1, akumulasi * num)} return helper (n, 1)
}

Mari kita lihat bingkainya secara faktorial (4):

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

Lihat perbedaannya? Dalam panggilan rekursif "biasa", fungsi kembalian secara rekursif menyusun nilai akhir. Dalam Tail Recursion, mereka hanya mereferensikan kasus dasar (yang terakhir dievaluasi) . Kami menyebut akumulator sebagai argumen yang melacak nilai yang lebih lama.

Template Rekursi

Fungsi rekursif reguler adalah sebagai berikut:

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

Untuk mengubahnya menjadi rekursi Tail, kami:

  • Perkenalkan fungsi pembantu yang membawa akumulator
  • jalankan fungsi helper di dalam fungsi utama, dengan akumulator yang disetel ke kasus dasar.

Lihat:

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)

Lihat perbedaannya?

Pengoptimalan Tail Call

Karena tidak ada status yang disimpan di tumpukan Non-Border-Case dari Tail Call, mereka tidak begitu penting. Beberapa bahasa / interpreter kemudian mengganti tumpukan lama dengan yang baru. Jadi, tanpa frame stack yang membatasi jumlah panggilan, Tail Calls berperilaku seperti loop-ke dalam kasus ini.

Terserah kompiler Anda untuk mengoptimalkannya, atau tidak.


6

Berikut adalah contoh sederhana yang menunjukkan cara kerja fungsi rekursif:

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

}

Rekursi tail adalah fungsi rekursif sederhana, di mana pengulangan dilakukan di akhir fungsi, sehingga tidak ada kode yang dilakukan secara berlebihan, yang membantu sebagian besar kompiler bahasa pemrograman tingkat tinggi untuk melakukan apa yang dikenal sebagai Pengoptimalan Rekursi Ekor , juga memiliki pengoptimalan yang lebih kompleks yang dikenal sebagai modulo rekursi Tail


1

Fungsi rekursif adalah fungsi yang memanggil dengan sendirinya

Ini memungkinkan pemrogram untuk menulis program yang efisien menggunakan jumlah kode minimal .

Kelemahannya adalah bahwa mereka dapat menyebabkan loop tak terbatas dan hasil tak terduga lainnya jika tidak ditulis dengan benar .

Saya akan menjelaskan fungsi Rekursif Sederhana dan fungsi Rekursif Ekor

Untuk menulis fungsi rekursif sederhana

  1. Hal pertama yang perlu dipertimbangkan adalah kapan Anda harus memutuskan keluar dari loop yang merupakan loop if
  2. Yang kedua adalah proses apa yang harus dilakukan jika kita adalah fungsi kita sendiri

Dari contoh yang diberikan:

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

Dari contoh di atas

if(n <=1)
     return 1;

Apakah faktor penentu kapan harus keluar dari loop

else 
     return n * fact(n-1);

Apakah pemrosesan sebenarnya harus dilakukan

Biarkan saya memecahkan tugas satu per satu agar mudah dipahami.

Mari kita lihat apa yang terjadi secara internal jika saya lari fact(4)

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

Ifloop gagal sehingga beralih ke elseloop sehingga kembali4 * fact(3)

  1. Dalam memori tumpukan, kami punya 4 * fact(3)

    Mengganti n = 3

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

Ifloop gagal sehingga beralih ke elseloop

jadi itu kembali 3 * fact(2)

Ingat kami menyebut `` 4 * fakta (3) ''

Output untuk fact(3) = 3 * fact(2)

Sejauh ini tumpukan itu 4 * fact(3) = 4 * 3 * fact(2)

  1. Dalam memori tumpukan, kami punya 4 * 3 * fact(2)

    Mengganti n = 2

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

Ifloop gagal sehingga beralih ke elseloop

jadi itu kembali 2 * fact(1)

Ingat kami menelepon 4 * 3 * fact(2)

Output untuk fact(2) = 2 * fact(1)

Sejauh ini tumpukan itu 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. Dalam memori tumpukan, kami punya 4 * 3 * 2 * fact(1)

    Mengganti n = 1

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

If loop benar

jadi itu kembali 1

Ingat kami menelepon 4 * 3 * 2 * fact(1)

Output untuk fact(1) = 1

Sejauh ini tumpukan itu 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

Akhirnya, hasil dari fakta (4) = 4 * 3 * 2 * 1 = 24

masukkan deskripsi gambar di sini

The Tail Rekursi akan

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

Ifloop gagal sehingga beralih ke elseloop sehingga kembalifact(3, 4)

  1. Dalam memori tumpukan, kami punya fact(3, 4)

    Mengganti n = 3

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

Ifloop gagal sehingga beralih ke elseloop

jadi itu kembali fact(2, 12)

  1. Dalam memori tumpukan, kami punya fact(2, 12)

    Mengganti n = 2

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

Ifloop gagal sehingga beralih ke elseloop

jadi itu kembali fact(1, 24)

  1. Dalam memori tumpukan, kami punya fact(1, 24)

    Mengganti n = 1

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

If loop benar

jadi itu kembali running_total

Output untuk running_total = 24

Akhirnya, hasil dari fakta (4,1) = 24

masukkan deskripsi gambar di sini


0

Jawaban saya lebih merupakan tebakan, karena rekursi adalah sesuatu yang berkaitan dengan implementasi internal.

Dalam rekursi ekor, fungsi rekursif dipanggil di akhir fungsi yang sama. Mungkin kompiler dapat mengoptimalkan dengan cara di bawah ini:

  1. Biarkan fungsi yang sedang berlangsung berakhir (yaitu tumpukan yang digunakan dipanggil kembali)
  2. Simpan variabel yang akan digunakan sebagai argumen untuk fungsi di penyimpanan sementara
  3. Setelah ini, panggil kembali fungsi tersebut dengan argumen yang disimpan sementara

Seperti yang Anda lihat, kami menutup fungsi asli sebelum iterasi berikutnya dari fungsi yang sama, jadi kami sebenarnya tidak "menggunakan" tumpukan.

Tetapi saya yakin jika ada destruktor yang dipanggil di dalam fungsi, maka pengoptimalan ini mungkin tidak berlaku.


0

Compiler cukup cerdas untuk memahami rekursi tail. Dalam kasus, ketika kembali dari panggilan rekursif, tidak ada operasi yang tertunda dan panggilan rekursif adalah pernyataan terakhir, termasuk dalam kategori rekursi ekor. Compiler pada dasarnya melakukan optimasi rekursi ekor, menghapus implementasi stack. Pertimbangkan kode di bawah ini.

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

Setelah melakukan optimasi, kode di atas diubah menjadi kode di bawah ini.

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

Ini adalah cara compiler melakukan Tail Recursion Optimization.

Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.