Як саме працює хвоста рекурсія?


121

Я майже розумію, як працює хвостова рекурсія та різниця між нею і нормальною рекурсією. Я тільки не розумію, чому йому не потрібен стек, щоб запам'ятати його зворотну адресу.

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

Немає чого робити після виклику самої функції у функції хвостової рекурсії, але для мене це не має сенсу.


16
Рекусія хвоста - це «нормальна» рекурсія. Це означає лише, що рекурсія відбувається в кінці функції.
Піт Бекер

7
... Але це може бути реалізовано інакше на рівні IL, ніж звичайна рекурсія, зменшуючи глибину стека.
KeithS

2
BTW, gcc може виконувати усунення хвостової рекурсії на "звичайному" прикладі тут.
dmckee --- кошеня колишнього модератора

1
@Geek - я C # Dev, тому моєю "мовою асемблера" є MSIL або просто IL. Для C / C ++ замініть IL на ASM.
КітС

1
@ShannonSeverance Я виявив, що gcc робить це простим доцільним вивченням випромінюваного коду збірки з без -O3. Посилання для попередньої дискусії, яка охоплює дуже подібний підґрунтя та обговорює, що необхідно для здійснення цієї оптимізації.
dmckee --- кошеня колишнього модератора

Відповіді:


169

Компілятор просто може перетворити це

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

у щось подібне:

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

2
@ Mr.32 Я не розумію вашого запитання. Я перетворив функцію в еквівалентну, але без явної рекурсії (тобто без явних викликів функції). Якщо ви зміните логіку на щось нееквівалентне, ви можете дійсно зробити цикл функцій назавжди в деяких або всіх випадках.
Олексій Фрунзе

18
Тож рекурсія хвостів ефективна лише завдяки компілятору, який оптимізує її? Інакше це було б те саме, що і звичайна рекурсія в плані пам'яті стека.
Алан Коромано

34
Так. Якщо компілятор не може зменшити рекурсію до циклу, ви зупинилися на рекурсії. Все або нічого.
Олексій Фрунзе

3
@AlanDert: правильно. Ви також можете вважати рекурсію хвоста особливим випадком "оптимізації виклику хвоста", особливо тому, що хвостовий виклик відповідає одній і тій же функції. Загалом, будь-який хвостовий виклик (з тими ж вимогами, що стосуються "жодної роботи не залишається робити"), що стосується хвостової рекурсії, і де значення повернення хвостового виклику безпосередньо повертається) можна оптимізувати, якщо компілятор може здійснити виклик у спосіб, який встановлює зворотну адресу викличеної функції, бути зворотною адресою функції, що робить хвостовий виклик, а не адресою, з якої робився хвостовий виклик.
Стів Джессоп

1
@AlanDert в C це лише оптимізація, не застосовується жодним стандартом, тому портативний код не повинен залежати від нього. Але є мови (схема є одним із прикладів), де оптимізація хвостової рекурсії забезпечується стандартом, тому вам не потрібно турбуватися про те, що вона буде переповнюватися в деяких середовищах.
Ян Вробель

57

Ви запитуєте, чому "йому не потрібен стек, щоб запам'ятати свою зворотну адресу".

Я б хотів це перевернути. Це робить використання стека пам'ятати зворотну адресу. Трюк полягає в тому, що функція, в якій відбувається хвоста рекурсія, має свою власну зворотну адресу на стеку, і коли вона переходить до викличеної функції, вона буде трактувати це як власну зворотну адресу.

Конкретно, без оптимізації виклику:

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

У цьому випадку, коли gбуде викликано, стек буде мати вигляд:

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

З іншого боку, оптимізація хвостових викликів:

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

У цьому випадку, коли gбуде викликано, стек буде мати вигляд:

   SP ->  Return address of "f"

Очевидно, що коли gповернеться, він повернеться в те місце, звідки fбуло викликано.

EDIT : У наведеному вище прикладі використовується випадок, коли одна функція викликає іншу функцію. Механізм ідентичний, коли функція викликає себе.


8
Це набагато краща відповідь, ніж інші відповіді. У компіляторі, швидше за все, немає якогось магічного спеціального випадку для перетворення хвостового рекурсивного коду. Він просто виконує звичайну оптимізацію останнього дзвінка, яка трапляється на ту саму функцію.
Арт

12

Хвостова рекурсія зазвичай може бути перетворена в цикл компілятором, особливо коли використовуються акумулятори.

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

складе щось подібне

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

3
Не такий розумний, як реалізація Олексія ... і так, це комплімент.
Матьє М.

1
Насправді, результат виглядає простішим, але я думаю, що код для здійснення цієї трансформації був би ДУШЕ "розумнішим", ніж або мітка / goto, або просто усунення хвостових викликів (див. Відповідь Ліндиданера).
Phob

Якщо це все рецидиви хвоста, то чому люди так хвилюються від цього? Я не бачу, щоб хтось хвилювався, поки циклі.
Бух Бух

@BuhBuh: У цьому немає потоку stackoverflow і уникає натискання / вискакування параметрів стека. Для такої щільної петлі це може змінити світ. Крім того, люди не повинні хвилюватися.
Mooing Duck

11

У рекурсивній функції повинні бути присутні два елементи:

  1. Рекурсивний дзвінок
  2. Місце для обліку повернених значень.

"Регулярна" рекурсивна функція зберігає (2) у кадрі стека.

Повернені значення в регулярній рекурсивній функції складаються з двох типів значень:

  • Інші повернені значення
  • Результат обчислення функції власника

Давайте розглянемо ваш приклад:

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

Кадр f (5) "зберігає" результат власних обчислень (5) і, наприклад, значення f (4). Якщо я зателефоную на факториал (5), перед тим, як виклики стека почнуть руйнуватися, у мене є:

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

Зауважте, що кожен стек зберігає, окрім згаданих я значень, і всю сферу функції. Отже, використання пам'яті для рекурсивної функції f дорівнює O (x), де x - кількість рекурсивних дзвінків, які я повинен здійснити. Отже, якщо мені потрібно 1кб оперативної пам’яті для обчислення факторної (1) або факторної (2), мені потрібно ~ 100 к для обчислення факторної (100) тощо.

Рекурсивна функція Tail, поставлена ​​в аргументах (2).

У рекурсії хвоста я передаю результат часткових обчислень у кожному рекурсивному кадрі до наступного за допомогою параметрів. Подивимося наш фактичний приклад Tail Recursive:

int factorial (int n) {int helper (int num, int akumulirano) {якщо num == 0 повернути накопичено інше повернути помічник (num - 1, накопичено * num)} повернути помічник (n, 1)
}

Давайте подивимось, що це кадри факторно (4):

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

Бачите відмінності? У "регулярних" рекурсивних викликах функції повернення рекурсивно складають кінцеве значення. У хвостовій рекурсії вони посилаються лише на базовий випадок (останній оцінювали) . Ми називаємо акумулятор аргументом, який відслідковує старі значення.

Шаблони рекурсії

Регулярна рекурсивна функція полягає в наступному:

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

Щоб перетворити його в Хвостову рекурсію, ми:

  • Введіть допоміжну функцію, яка несе акумулятор
  • запустіть функцію помічника всередині основної функції, при цьому акумулятор встановлений в базовий корпус.

Подивіться:

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)

Бачите різницю?

Оптимізація Tail Call

Оскільки жоден стан не зберігається у безмежному випадку стеків Tail Call, вони не так важливі. Деякі мови / перекладачі замінюють старий стек новим. Отже, не маючи стекових кадрів, що обмежують кількість викликів, в цих випадках Tail Calls поводиться так само, як і цикл for .

Ваша компілятор залежить від оптимізації, чи ні.


6

Ось простий приклад, який показує, як працюють рекурсивні функції:

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

}

Рекурсія хвоста - це проста рекурсивна функція, де рецидиви проводяться в кінці функції, таким чином, жоден код не робиться за зростанням, що допомагає більшості компіляторів мов програмування високого рівня робити те, що відомо як оптимізація рекурсії Tail , також має більш складна оптимізація, відома як модуль рекурсії хвоста


1

Рекурсивна функція - це функція, яка викликає сама по собі

Це дозволяє програмістам писати ефективні програми, використовуючи мінімальну кількість коду .

Мінус полягає в тому, що вони можуть спричинити нескінченні петлі та інші несподівані результати, якщо записані неправильно .

Я поясню як просту рекурсивну функцію, так і хвостову рекурсивну функцію

Для того, щоб написати просту рекурсивну функцію

  1. Перший момент, який слід врахувати, - це коли ви вирішите вийти з циклу, який є циклом if
  2. Друге - що робити, якщо ми є власною функцією

З наведеного прикладу:

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

З наведеного прикладу

if(n <=1)
     return 1;

Чи є вирішальним фактором, коли потрібно вийти з циклу

else 
     return n * fact(n-1);

Чи має бути проведена фактична обробка

Дозвольте мені розбити завдання по черзі для легкого розуміння.

Давайте подивимось, що відбувається всередині, якщо я біжу fact(4)

  1. Підстановка n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifцикл виходить з ладу, тому він переходить до elseциклу, тому він повертається4 * fact(3)

  1. У пам'яті стека у нас є 4 * fact(3)

    Підстановка n = 3

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

Ifцикл виходить з ладу, тому він переходить до elseциклу

тому воно повертається 3 * fact(2)

Пам'ятайте, ми називали `` 4 * факт (3) ``

Вихід для fact(3) = 3 * fact(2)

Поки що стек є 4 * fact(3) = 4 * 3 * fact(2)

  1. У пам'яті стека у нас є 4 * 3 * fact(2)

    Підстановка n = 2

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

Ifцикл виходить з ладу, тому він переходить до elseциклу

тому воно повертається 2 * fact(1)

Пам'ятайте, ми зателефонували 4 * 3 * fact(2)

Вихід для fact(2) = 2 * fact(1)

Поки що стек є 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. У пам'яті стека у нас є 4 * 3 * 2 * fact(1)

    Підстановка n = 1

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

If петля справжня

тому воно повертається 1

Пам'ятайте, ми зателефонували 4 * 3 * 2 * fact(1)

Вихід для fact(1) = 1

Поки що стек є 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

Нарешті, результат факту (4) = 4 * 3 * 2 * 1 = 24

введіть тут опис зображення

Хвостова рекурсія буде

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
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifцикл виходить з ладу, тому він переходить до elseциклу, тому він повертаєтьсяfact(3, 4)

  1. У пам'яті стека у нас є fact(3, 4)

    Підстановка n = 3

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

Ifцикл виходить з ладу, тому він переходить до elseциклу

тому воно повертається fact(2, 12)

  1. У пам'яті стека у нас є fact(2, 12)

    Підстановка n = 2

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

Ifцикл виходить з ладу, тому він переходить до elseциклу

тому воно повертається fact(1, 24)

  1. У пам'яті стека у нас є fact(1, 24)

    Підстановка n = 1

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

If петля справжня

тому воно повертається running_total

Вихід для running_total = 24

Нарешті, результат факту (4,1) = 24

введіть тут опис зображення


0

Моя відповідь - це більше здогадка, адже рекурсія - це щось, що стосується внутрішньої реалізації.

У хвостовій рекурсії рекурсивна функція називається в кінці тієї ж функції. Можливо, компілятор може оптимізувати нижче:

  1. Нехай поточна функція завершується (тобто згадується використаний стек)
  2. Зберігайте змінні, які будуть використовуватися як аргументи функції, у тимчасовому сховищі
  3. Після цього знову викликайте функцію тимчасово збереженим аргументом

Як бачимо, ми завершуємо початкову функцію перед наступною ітерацією тієї самої функції, тому насправді ми не використовуємо стек.

Але я вважаю, що всередині функції є деструктори, які потрібно викликати, то ця оптимізація може не застосовуватися.


0

Компілятор є досить розумним, щоб зрозуміти хвостову рекурсію. У випадку, коли повертається назад з рекурсивного дзвінка, немає очікуваної операції, а рекурсивний виклик є останньою заявою, підпадаючи під категорію хвостової рекурсії. Компілятор в основному виконує оптимізацію хвостової рекурсії, видаляючи виконання стека. Подивіться нижче коду.

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

Після проведення оптимізації вищевказаний код перетворюється на нижчий.

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

Ось як компілятор робить оптимізацію рекурсії хвоста.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.