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


44

Питання

Які можливі способи вирішити переповнення стека, викликане рекурсивним алгоритмом?

Приклад

Я намагаюся вирішити проблему Project Euler 14 і вирішив спробувати її за допомогою рекурсивного алгоритму. Однак програма зупиняється з java.lang.StackOverflowError. Зрозуміло. Алгоритм справді переповнював стек, тому що я намагався генерувати послідовність Collatz для дуже великої кількості.

Рішення

Тож мені було цікаво: які існують стандартні способи вирішення переповнення стека, якщо припустимо, що ваш рекурсивний алгоритм написаний правильно і завжди закінчується переповненням стека? Дві концепції, які прийшли до тями:

  1. хвоста рекурсія
  2. ітерація

Чи правильні ідеї (1) та (2)? Чи є інші варіанти?

Редагувати

Це допоможе побачити якийсь код, бажано на Java, C #, Groovy або Scala.

Можливо, не використовуйте згадану вище проблему Project Euler, щоб вона не була зіпсована для інших, але візьміть якийсь інший алгоритм. Факторний, можливо, чи щось подібне.


3
Ітерація. Спогад
Джеймс

2
Очевидно, що пам'ять працює лише тоді, коли насправді є повторний обчислення.
Йорг W Міттаг

2
також варто зазначити, що не всі мовні реалізації в будь-якому випадку можуть робити оптимізацію хвостових рекурсій
jk.

2
Це, мабуть, було б краще вирішити з основним процесом, ніж рекурсією.
Jörg W Mittag

3
Якщо ви працюєте з числом менше 1 000 000 і збираєтесь на 1, відповідь на це запитання передбачає приблизно 500 кроків, щоб досягти 1. Це не повинно оподатковувати рекурсії, враховуючи невелику рамку стека. --- Якщо ви намагаєтеся вирішити, починаючи з 1, а слідуючи за ним до 2, 4, 8, 16, {5,32} і піднімаючись звідти, ви робите це неправильно.

Відповіді:


35

Оптимізація виклику хвоста існує у багатьох мовах та компіляторах. У цій ситуації компілятор розпізнає функцію форми:

int foo(n) {
  ...
  return bar(n);
}

Тут мова здатна визнати, що результат, що повертається, є результатом іншої функції та змінить виклик функції з нового кадру стека в стрибок.

Зрозумійте, що класичний факторіальний метод:

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

це НЕ хвіст виклик optimizatable з інспекції , необхідної на повернення. ( Приклад вихідного коду та компільований вихід )

Щоб зробити цей хвостовий дзвінок оптимізованим,

int _fact(int n, int acc) {
    if(n == 1) return acc;
    return _fact(n - 1, acc * n);
}

int factorial(int n) {
    if(n == 0) return 1;
    return _fact(n, 1);
}

Компіляція цього коду з gcc -O2 -S fact.c(-O2 необхідна для включення оптимізації в компіляторі, але при більшій оптимізації -O3 людині важко читати ...)

_fact(int, int):
    cmpl    $1, %edi
    movl    %esi, %eax
    je  .L2
.L3:
    imull   %edi, %eax
    subl    $1, %edi
    cmpl    $1, %edi
    jne .L3
.L2:
    rep ret

( Приклад вихідного коду та компільований вихід )

Ви можете бачити в сегменті .L3, jneа не call(який виконує виклик підпрограми з новим фреймом стека).

Зверніть увагу: це було зроблено за допомогою C. Оптимізація виклику хвостів у Java є важкою і залежить від впровадження JVM (що говорив, я не бачив жодного, хто це зробив, тому що це важко і наслідки необхідної моделі безпеки Java, що вимагає кадри стека - чого уникає TCO) - хвіст-рекурсія + java та реверсія хвоста + оптимізація - це гарні набори тегів для перегляду. Ви можете знайти інші мови JVM здатні оптимізувати хвостову рекурсію краще (спроба Clojure (який вимагає повторювався для оптимізації хвостового виклику), або Скелі).

Це сказав:

Існує певна радість, коли ти знаєш, що ти написав щось правильно - ідеально, як це можна зробити.
А тепер я збираюсь взяти трохи скотча і надіти якусь німецьку електроніку ...


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

Інший підхід - включити лічильник рекурсії. Це більше для виявлення нескінченних циклів, спричинених ситуаціями, що перебувають поза контролем (і поганим кодуванням).

Лічильник рекурсії має форму

int foo(arg, counter) {
  if(counter > RECURSION_MAX) { return -1; }
  ...
  return foo(arg, counter + 1);
}

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

Теоретично вам це не потрібно. На практиці я бачив погано написаний код, який вплинув на це через безліч дрібних помилок та поганих практик кодування (багатопотокові проблеми одночасності, коли щось змінюється поза методом, що змушує інший потік переходити у нескінченний цикл рекурсивних викликів).


Використовуйте правильний алгоритм і вирішіть правильну задачу. Спеціально для Collatz Conjecture, здається , ви намагаєтеся вирішити це xkcd способом:

XKCD # 710

Ви починаєте з числа і робите обхід дерева. Це швидко призводить до дуже великого простору пошуку. Швидкий пробіг для обчислення кількості повторень для правильної відповіді призводить до приблизно 500 кроків. Це не повинно бути проблемою для рекурсії з невеликим фреймом стека.

Хоча знання рекурсивного рішення не є поганою справою, слід також усвідомити, що багато разів ітераційне рішення краще . Ряд способів наближення перетворюючого рекурсивного алгоритму до ітеративного можна побачити на Stack Overflow at Way, щоб перейти від рекурсії до ітерації .


1
Я сьогодні натрапив на цей мультиплікаційний xkcd, коли переглядав Інтернет. :-) Мультфільми Рендалла Манро - це захоплення.
Лернкурве

@Lernkurve Я помітив додаток редагування коду після того, як я почав писати це (і розміщував). Вам потрібні інші зразки коду для цього?

Ні, зовсім ні. Це прекрасно. Дякую купу за запитання!
Лернкурве

Можна запропонувати також додати цей мультфільм: imgs.xkcd.com/comics/functional.png
Еллен Спертус

@espertus дякую Я додав його (очистив деяке покоління джерела і додав трохи більше)

17

Майте на увазі, що мовна реалізація повинна підтримувати оптимізацію хвостової рекурсії. Я не думаю, що це роблять основні компілятори Java.

Пам'ять означає, що ви пам’ятаєте результат обчислення, а не перераховувати його кожен раз, наприклад:

collatz(i):
    if i in memoized:
        return memoized[i]

    if i == 1:
        memoized[i] = 1
    else if odd(i):
        memoized[i] = 1 + collatz(3*i + 1)
    else
        memoized[i] = 1 + collatz(i / 2)

    return memoized[i]

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


1
Дуже зрозуміле пояснення запам'ятовування. Перш за все, дякую, що проілюстрували його фрагментом коду. Крім того, "там буде багато повторень в кінці послідовностей" ясні речі для мене. Дякую.
Лернкурве

10

Я здивований, що ще ніхто не згадував батут . Батут (у цьому сенсі) - це цикл, який ітеративно викликає функції зворотного повернення (стиль проходження продовження) і може використовуватися для реалізації хвостово-рекурсивних викликів функцій у орієнтованому на стек мові програмування.

У цьому питанні StackOverflow розглядається дещо детальніше про різні впровадження батутного навчання на Java : Обробка StackOverflow в Java для Trampoline


Я теж подумав про це відразу. Батути - це метод для оптимізації хвостових викликів, тому люди (це майже - можливо) говорять про це. +1 Для конкретної довідки.
Стівен Еверс

6

Якщо ви використовуєте мову та компілятор, які розпізнають хвостові рекурсивні функції та належним чином обробляють їх (тобто "замінює абонента на місці викликом"), то так, стек не повинен переростати з-під контролю. Ця оптимізація по суті зводить рекурсивний метод до ітеративного. Я не думаю, що Java це робить, але я знаю, що це робить Ракетка.

Якщо ви йдете з ітеративним підходом, а не з рекурсивним підходом, ви усуваєте велику частину необхідності пам’ятати, звідки надходять дзвінки, і практично виключаєте шанс переповнення стека (від рекурсивних дзвінків у будь-якому разі).

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


1
+1 для вказівки на запам'ятовування також корисний при ітеративних підходах.
Карл Білефельдт

Усі мови функціонального програмування мають оптимізацію хвостових викликів.

3

ви можете створити перерахунок, який замінить рекурсію ... ось приклад для обчислення викладачів, що роблять це ... (звичайно не працюватимуть для великих чисел, як я використовуюсь лише довго в прикладі :-))

public class Faculty
{

    public static IEnumerable<long> Faculties(long n)
    {
        long stopat = n;

        long x = 1;
        long result = 1;

        while (x <= n)
        {
            result = result * x;
            yield return result;
            x++;
        }
    }
}

навіть якщо це не запам'ятовування, таким чином ви скасуєте переповнення стека


EDIT


Мені шкода, якщо я когось із вас засмутила. Єдиним моїм наміром було показати спосіб, як уникнути переповнення стека. Я, мабуть, повинен був написати повний приклад коду, а не лише невеликий фрагмент швидко написаного та грубого уривку коду.

Наступний код

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

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

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Text;
using System.Threading.Tasks;
using System.Timers;

namespace ConsoleApplication1
{
    class Program
    {
        static Stopwatch w = new Stopwatch();
        static Faculty f = Faculty.GetInstance();

        static void Main(string[] args)
        {
            Out(5);
            Out(10);
            Out(-5);
            Out(0);
            Out(1);
            Out(4);
            Out(29);
            Out(30);
            Out(20);
            Out(10000);
            Out(20000);
            Out(19999);
            Console.ReadKey();
        }

        static void Out(BigInteger n)
        {
             try
            {
                w.Reset();
                w.Start();
                var x = f.Calculate(n);
                w.Stop();
                var time = w.ElapsedMilliseconds;
                Console.WriteLine(String.Format("{0} ({2}ms): {1}", n, x, time));
            }
            catch (ArgumentException e)
            {
                Console.WriteLine(e.Message);
            }

            Console.WriteLine("\n\n");
       }
    }

Я оголошую * 1 статичну змінну "екземпляр" в класі факультету для зберігання синглтона. Таким чином, доки ваша програма працює, кожен раз, коли ви "GetInstance ()" класу, ви отримуєте екземпляр, який зберігав усі вже обчислені значення. * 1 статичний SortedList, який буде містити всі вже обчислені значення

У конструктор я також додаю 2 спеціальних значення списку 1 для входів 0 і 1.

    public class Faculty
    {
        private static SortedList<BigInteger, BigInteger> _values; 
        private static Faculty _faculty {get; set;}

        private Faculty ()
        {
            _values = new SortedList<BigInteger, BigInteger>();
            _values.Add(0, 1);
            _values.Add(1, 1);
        }

        public static Faculty GetInstance() {
            _faculty = _faculty ?? new Faculty();
            return _faculty;
        }

        public BigInteger Calculate(BigInteger n) 
        {
            // check if input is smaller 0
            if (n < 0)
                throw new ArgumentException(" !!! Faculty is not defined for values < 0 !!!");

            // if value is not already calculated => do so
            if(!_values.ContainsKey(n))
                Faculties(n);

            // retrieve n! from Sorted List
            return _values[n];
        }

        private static void Faculties(BigInteger n)
        {
            // get the last calculated values and continue calculating if the calculation for a bigger n is required
            BigInteger i = _values.Max(x => x.Key),
                           result = _values[i];

            while (++i <= n)
            {
                CalculateNext(ref result, i);
                // add value to the SortedList if not already done
                if (!_values.ContainsKey(i))
                    _values.Add(i, result);
            }
        }

        private static void CalculateNext(ref BigInteger lastresult, BigInteger i) {

            // put in whatever iterative calculation step you want to do
            lastresult = lastresult * i;

        }
    }
}

5
Технічно це ітерація, оскільки ви повністю видалили будь-яку рекурсію
грохот урод

що це :-), і він запам'ятовує результати в межах змінних методів між кожним етапом обчислення
Ingo

2
Я думаю, ви неправильно розумієте спогад, який коли викладачі (100) називають перший раз, коли він обчислює результат, і зберігає його в хеш і повертається, потім, коли він знову викликається, зберігається результат повертається
храповик виродка

@jk. На його честь, він ніколи насправді не каже, що це рекурсивно.
Ніл

навіть якщо це не запам'ятовування, таким чином ви скасуєте переповнення стека
Ingo

2

Що стосується Scala, то ви можете додати @tailrecанотацію до рекурсивного методу. Таким чином компілятор забезпечує фактичну оптимізацію хвостових викликів:

Отже, це не компілюється (факторіально):

@tailrec
def fak1(n: Int): Int = {
  n match {
    case 0 => 1
    case _ => n * fak1(n - 1)
  }
}

повідомлення про помилку:

scala: не вдалося оптимізувати @tailrec анотований метод fak1: він містить рекурсивний виклик не в положенні хвоста

З іншого боку:

def fak3(n: Int): Int = {
  @tailrec
  def fak3(n: Int, result: Int): Int = {
    n match {
      case 0 => result
      case _ => fak3(n - 1, n * result)
    }
  }

  fak3(n, 1)
}

відбулася компіляція та оптимізація хвостових дзвінків


1

Однією з можливостей, про яку ще не було сказано, є рекурсія, але без використання системного стеку. Звичайно, ви можете переповнювати свою групу, але якщо алгоритм дійсно потребує зворотного відстеження в тій чи іншій формі (навіщо взагалі використовувати рекурсію?), У вас немає вибору.

Існує безладна реалізація деяких мов, наприклад, Stackless Python .


0

Іншим рішенням буде імітувати власний стек, а не покладатися на реалізацію компілятора + час виконання. Це не просте рішення, ані швидке, але теоретично ви отримаєте StackOverflow лише тоді, коли не будете пам'яті.

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