Рекурсия против итерации в отношении использования памяти

#algorithm #recursion #iteration

#алгоритм #рекурсия #итерация

Вопрос:

Предположим, у меня есть рекурсивное, а также итеративное решение (с использованием стека) для некоторой проблемы, например, обход предварительного порядка двоичного дерева. С современными компьютерами, с точки зрения памяти, есть ли преимущество в использовании рекурсивного решения по сравнению с итеративной версией или наоборот для очень больших деревьев?

Я знаю, что для определенных рекурсивных решений, в которых повторяются подзадачи, при использовании рекурсии требуются дополнительные затраты времени и памяти. Предположим, что здесь это не так. Например,

 preOrder(Node n){
    if (n == null) return;
    print(n);
    preOrder(n.left);
    preOrder(n.right);
}
  

против

 preOrder(Node n){
    stack s;
    s.push(n);
    while(!s.empty()){
        Node node = s.pop();
        print(node);
        s.push(node.right);
        s.push(node.left);
    }
}
  

Комментарии:

1. visit Должно быть preOrder ?

2. Спасибо, что отметили это.

3. Одна мысль может заключаться в том, что при использовании рекурсии память всегда будет находиться в стеке. В то время как с итеративным вы можете решить выделить его в куче.

4. Поскольку вы не можете выполнить итерацию дерева без использования рекурсивного процесса, оба ваших примера являются рекурсивными процессами. Использование памяти равно O (log n) в обоих случаях. Рекурсивная версия может взорвать стек на большинстве языков, если глубина, умноженная на размер кадра, больше, чем пространство стека.

5. Перефразируя, я имел в виду «рекурсивную функцию против итеративной функции». Итак, помимо ограничений из-за размера стека, одно не «превосходит» другое? Или, это достаточная причина для меня, чтобы использовать итеративную версию?

Ответ №1:

Если существует риск переполнения стека (в данном случае, потому что деревья не гарантированно будут даже полусбалансированными), то надежная программа будет избегать рекурсии и использовать явный стек.

Явный стек может использовать меньше памяти, поскольку фреймы стека имеют тенденцию быть больше, чем строго необходимо для поддержания контекста рекурсивных вызовов. (Например, фрейм стека будет содержать по крайней мере обратный адрес, а также локальные переменные.)

Однако, если известно, что глубина рекурсии ограничена, то отсутствие необходимости динамического выделения может сэкономить пространство и время, а также время программиста. Например, для обхода сбалансированного двоичного дерева требуется только рекурсия в глубину дерева, которая составляет log 2 от числа узлов; это не может быть очень большим числом.

Как предположил комментатор, одним из возможных сценариев является то, что дерево, как известно, имеет правый перекос. В этом случае вы можете выполнить рекурсию вниз по левым ветвям, не беспокоясь о переполнении стека (если вы абсолютно уверены, что дерево имеет правый перекос). Поскольку второй рекурсивный вызов находится в хвостовой позиции, его можно просто переписать как цикл:

 void preOrder(Node n) {
    for (; n; n = n.right) {
        print(n);
        preOrder(n.left);
        n = n.right;
}
  

Аналогичный метод часто (и всегда должен быть) применяется к быстрой сортировке: после разделения функция выполняет рекурсию на меньшем разделе, а затем выполняет цикл для обработки большего раздела. Поскольку меньший раздел должен быть меньше половины размера исходного массива, это гарантирует, что глубина рекурсии будет меньше log 2 от исходного размера массива, что, безусловно, меньше 50 кадров стека и, вероятно, намного меньше.

Комментарии:

1. Хороший ответ, но вы также могли бы указать, что в примере OP хороший компилятор оптимизирует второй рекурсивный (хвостовой) вызов цикла, поэтому только для оставшихся дочерних элементов требуются кадры стека. В этом случае рекурсивный код будет искать деревья с наклоном вправо в постоянном пространстве, а линейное пространство — в явном коде стека. Мораль заключается в том, что детали реализации и системы компиляции имеют значение.

2. @gene: Изначально у меня была дискуссия о правильном алгоритме быстрой сортировки, оптимизированном для конечных вызовов, но я удалил его, потому что он отклонялся от темы. Сложно выполнить TCO с C , и хотя я думаю, что gcc найдет оптимизацию в этом простом случае, гарантий нет. Поскольку вы не можете знать, что компилятор будет использовать данную функцию, надежный код не должен полагаться на нее. Вы могли бы записать хвостовой вызов в виде цикла и оставить рекурсию, отличную от TC, как есть, что будет хорошо работать для перекошенных деревьев или q-s; если я стану амбициозным, я добавлю гибридный вариант.

3. Не могли бы вы показать мне, как выглядит версия, оптимизированная для конечных вызовов?

4. @frank: компилятор будет выполнять TCO (или нет, в зависимости от его настроения), поэтому нет версии, оптимизированной для конечных вызовов. Чтобы оптимизировать ваше итеративное решение, обратите внимание, что вы нажимаете и завершаете цикл, а затем сразу же всплываете, чтобы вы могли просто установить n = n.left вместо этого.