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


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.