В чем преимущество использования хвостовой рекурсии здесь?

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

QUICKSORT(A, p, r)
       q = PARTITION(A, p, r)
       QUICKSORT(A, p, q-1)
       QUICKSORT(A, q+1, r)


TAIL-RECURSIVE-QUICKSORT(A, p, r)
   while p < r
      q = PARTITION(A, p, r)
      TAIL-RECURSIVE-QUICKSORT(A, p, q-1)
      p = q+1

(Источник - http://mypathtothe4.blogspot.com/2013/02/lesson-2-variations-on-quicksort-tail.html)

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

Псевдо-код выше взят из статьи - http://mypathtothe4.blogspot.com/2013/02/lesson-2-variations-on-quicksort-tail.html Объяснение, приведенное в статье, меня еще больше смущает -

Quicksort разделяет данный подматрица и переходит к повторной обработке дважды; один в левой части и один справа. Каждый из них рекурсивным вызовам потребуется отдельный поток стека. Это пространство используется для хранения индексирующих переменных для массива в некоторый уровень рекурсии. Если представить, что это происходит с самого начала к завершению выполнения, мы видим, что пространство стека удваивается на каждом слой.

Итак, как Tail-Recursive-Quicksort исправить все это?

Ну, вместо того, чтобы рекурсировать на двух под-массивах, теперь мы только рекурсируем один. Это устраняет необходимость удвоения пространства стека на каждом уровне исполнения. Мы оборачиваем эту проблему, используя цикл while как итеративный элемент управления, выполняющий ту же задачу. Вместо того, чтобы стек для сохранения наборов переменных для двух рекурсивных вызовов, мы просто изменить один и тот же набор переменных и использовать один рекурсивный вызов на новые переменные.

Я не вижу, как пространство стека удваивается на каждом уровне выполнения в случае регулярной быстрой сортировки.

Примечание. - В статье не упоминается оптимизация компилятора.

Ответ 1

Вызов хвостовой рекурсивной функции позволяет компилятору выполнять специальную оптимизацию, которая обычно не может быть выполнена с регулярной рекурсией. В хвостовой рекурсивной функции рекурсивный вызов - это последняя вещь, которую нужно выполнить. В этом случае вместо выделения фрейма стека для каждого вызова компилятор может переработать код, чтобы просто повторно использовать текущий стек стека, что означает, что функция tail-recursive будет использовать только один стек стека, а не сотни или даже тысячи.

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

Здесь страница wiki, если вы хотите получить дополнительную информацию о том, как это "экономия пространства" и повторное использование стека действительно работают вместе с примерами: Tail Call

Изменить: я не объяснил, как это относится к quicksort, не так ли? Ну, некоторые термины брошены вокруг в этой статье, которые делают все запутанным (и некоторые из них просто неверны). Первая функция (QUICKSORT) делает рекурсивный вызов слева, рекурсивный вызов справа, а затем завершается. Обратите внимание, что рекурсивный вызов справа - это последнее, что происходит в функции. Если компилятор поддерживает рекурсивную оптимизацию хвоста (объяснено выше), только левые вызовы создают новые фреймы стека; все правильные вызовы просто повторно используют текущий кадр. Это может сэкономить несколько кадров стека, но может по-прежнему страдать от случая, когда секционирование создает последовательность вызовов, где оптимизация хвостовой рекурсии не имеет значения. Плюс, хотя правосторонние вызовы используют один и тот же фрейм, вызовы с левой стороны, вызываемые в правом порядке, по-прежнему используют стек. В худшем случае глубина стека равна N.

Вторая описанная версия - это не хвостовая рекурсивная quicksort, а скорее быстрая сортировка, где рекурсивно выполняется только левая сортировка, а правильная сортировка выполняется с использованием цикла. Фактически, этот quicksort (как ранее описано другим пользователем) не может использовать оптимизацию рекурсии хвоста, поскольку рекурсивный вызов не является последним, что нужно выполнить. Как это работает? При правильной реализации первый вызов quicksort совпадает с левым вызовом в исходном алгоритме. Однако рекурсивные вызовы с правой стороны даже не называются. Как это работает? Ну, цикл позаботится об этом: вместо сортировки "влево-вправо" он сортирует левый вызов, а затем сортирует справа, постоянно сортируя только левые справа. Это действительно смешное звучание, но в основном просто сортировка так много левых, что права становятся едиными элементами и не нуждаются в сортировке. Это эффективно удаляет правильную рекурсию, делая функцию менее рекурсивной (псевдорекурсивной, если хотите). Однако реальная реализация не выбирает только левую сторону каждый раз; он выбирает самую маленькую сторону. Идея все та же; в основном это рекурсивный вызов только с одной стороны, а не с обеих сторон. Выбор более короткой стороны гарантирует, что глубина стека никогда не может быть больше log2 (N), что является глубиной правильного бинарного дерева. Это связано с тем, что более короткая сторона всегда будет составлять не более половины нашего текущего массива. Однако реализация данной статьи не гарантирует этого, поскольку она может страдать от одного и того же наихудшего сценария "левое - это все дерево". Эта статья действительно дает довольно хорошее объяснение этому, если вы готовы сделать больше чтения: Эффективный выбор и частичная сортировка на основе quicksort

Ответ 2

Преимущество: вся точка "смешанной рекурсивной/итеративной" версии, то есть версия, которая обрабатывает один субдиапазон рекурсией и другим поддиапазоном путем итерации, заключается в том, что, выбрав, какой из двух поддиапазонов обрабатывать рекурсивно вы можете гарантировать, что глубина рекурсии никогда не будет превышать log2 N, независимо от того, насколько плох выбор поворота.

Для псевдокода TAIL-RECURSIVE-QUICKSORT, предоставленного в вопросе, где рекурсивная обработка выполняется сначала буквально-рекурсивным вызовом, этому рекурсивному вызову должен быть предоставлен более короткий субдиапазон. Это само по себе будет гарантировать, что глубина рекурсии будет ограничена на log2 N. Таким образом, для достижения гарантии глубины рекурсии, код абсолютно должен сравнивать длины поддиапазонов, прежде чем решать, какой субдиапазон обрабатывается рекурсивным вызовом.

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

HALF-RECURSIVE-QUICKSORT(A, p, r)
   while p < r
      q = PARTITION(A, p, r)
      if (q - p < r - q)
        HALF-RECURSIVE-QUICKSORT(A, p, q-1)
        p = q+1
      else
        HALF-RECURSIVE-QUICKSORT(A, q+1, r)
        r = q-1

Предоставленный псевдокод TAIL-RECURSIVE-QUICKSORT не производит попыток сравнить длины поддиапазонов. В этом случае он не дает никакой пользы. И нет, это не совсем "хвост рекурсивный". QuickSort нельзя свести к рекурсивному алгоритму.

Если вы выполняете поиск Google на условиях "qsort loguy higuy", вы легко найдете множество примеров другой популярной реализации QuickSort (стандартный стиль библиотеки C), основанный на той же идее использования рекурсии только для одного из двух поддиапазоны. Эта реализация для 32-разрядных платформ использует явный стек максимальной глубины ~ 32, потому что он может гарантировать, что глубина рекурсии никогда не будет выше. (Аналогично, для 64-битных платформ потребуется только глубина стека ~ 64.)

QUICKSORT версия, что делает два буквальных рекурсивных вызовов значительно хуже в этом отношении, так как повторяющиеся плохой выбор поворота может сделать это, чтобы достигнуть очень высокой глубины рекурсии, до N в самом худшем случае. С двумя рекурсивными вызовами вы не можете гарантировать, что глубина рекурсии будет ограничена log2 N. Умный компилятор может заменить трейлинг-вызов на QUICKSORT на итерацию, то есть превратить ваш QUICKSORT в ваш TAIL-RECURSIVE-QUICKSORT, но он не будет достаточно умен, чтобы выполнить сравнение длины поддиапазона.

Ответ 3

Преимущество использования tail-recursion: = чтобы компилятор оптимизировал код и преобразовал его в нерекурсивный код.

Преимущество нерекурсивного кода над рекурсивным: = нерекурсивный код требует меньше памяти для выполнения, чем рекурсивный. Это происходит из-за неиспользуемых кадров стека, которые потребляет рекурсия.

Вот интересная часть: - хотя составители могут теоретически выполнять эту оптимизацию, они на практике не делают. даже широко распространенные компиляторы, такие как dot-net и java, не выполняют эту оптимизацию.

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

смотрите:

Ответ 4

Кажется, здесь есть некоторая лексика.

Первая версия является хвостовой рекурсивной, поскольку последний оператор является рекурсивным:

QUICKSORT(A, p, r)
  q = PARTITION(A, p, r)
  QUICKSORT(A, p, q-1)
  QUICKSORT(A, q+1, r)

Если вы применяете оптимизацию хвостовой рекурсии, которая предназначена для преобразования рекурсии в цикл, вы получаете вторую, которая больше не является хвостовой рекурсией:

TAIL-RECURSIVE-QUICKSORT(A, p, r)
  while p < r
    q = PARTITION(A, p, r)
    TAIL-RECURSIVE-QUICKSORT(A, p, q-1)
    p = q+1

Преимущество этого заключается в том, что обычно вам потребуется меньше памяти стека. Почему это? Чтобы понять, представьте, что вы хотите отсортировать массив с 31 элементом. В очень маловероятном случае, когда все разделы идеальны, т.е. Они разделяют массив прямо посередине, ваша глубина рекурсии будет равна 4. Действительно, первый раскол привел бы к двум разделам из 15 элементов, второй - к двум разделам из 7 элементов, третий из двух из трех предметов, а после четвертого все сортируется.

Но перегородки редко бывают идеальными. В результате не все рекурсии становятся одинаково глубокими. В нашем примере у вас могут быть некоторые, которые имеют только три уровня глубины, а некоторые из них 7 или более (наихудший случай - 30). Исключив половину рекурсий, у вас есть справедливая вероятность, что ваша максимальная глубина рекурсии будет меньше.

Как указывал AndreyT, часто диапазоны сравниваются, чтобы убедиться, что самый большой раздел всегда обрабатывается итеративно, а наименьший - рекурсивно. Это гарантирует наименьшую возможную глубину рекурсии для заданной стратегии выбора входа и поворота.

Но это не всегда так. Иногда люди хотят дать результаты как можно скорее или хотят найти и отсортировать только первые n элементов. В этих случаях они всегда хотят отсортировать первый раздел перед вторым. Даже в этой ситуации устранение хвостовой рекурсии обычно улучшает использование памяти и никогда не делает ее хуже.

Ответ 5

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

void print(int n) {
  if (n < 0) return;
  cout << " " << n;
// The last executed statement is recursive call
  print(n-1);
  print(n-1);
}

код > Является ли этот хвост рекурсивным?