Опубликован: 06.10.2011 | Доступ: свободный | Студентов: 1615 / 78 | Оценка: 4.67 / 3.67 | Длительность: 18:18:00
Лекция 10:

Рекурсивные программы

< Лекция 9 || Лекция 10: 1234 || Лекция 11 >
Аннотация: В лекции обсуждается стратегия перебора с возвратами и стратегия "альфа-бета". Обсуждаются вопросы связи рекурсии и циклов, а также алгоритм реализации рекурсии.

9.1. От циклов к рекурсии

Вернемся назад, к общим проблемам рекурсии.

Мы уже видели, что некоторые рекурсивные алгоритмы – числа Фибоначчи, вставка и поиск в бинарных деревьях – имеют циклические эквиваленты. Что можно сказать в общем случае?

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

from Init until Exit loop Body end
    

Мы можем заменить его на

Init
loop_equiv
Здесь введена процедура:
loop_equiv
            — Используется условие выхода Exit и тело цикла Body.
        do
            if not Exit then
                Body
                Loop_equiv
            end
        end
    

В функциональных языках (таких как Lisp, Scheme, Haskell, ML) рекурсивный стиль является предпочитаемым, даже если доступны циклы. Мы могли бы также использовать рекурсию с первых шагов нашего курса, рассмотрев, например, анимацию линии метро, перемещающую красную точку, как рекурсивную процедуру:

Line8.start
animate_rest (Line8)
Вот как могла бы выглядеть сама процедура:
animate_rest (line: LINE)
        — Анимация станций линии метро, начиная от текущей позиции курсора
    do
        if not line.after then
            show_spot (line.item.location)
            line.forth
            animate_rest (line)
        end
    end
    

(более полная версия должна восстанавливать текущую позицию курсора).

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

Заключение могло бы быть другим, если бы мы основывались на функциональном языке программирования, где рекурсия является частью стиля, характерного для такого языка.

Но даже чисто теоретически важно понимать, что циклы не являются обязательной принадлежностью языка программирования и могут быть заменены рекурсией. Хорошим примером является программа paradox, демонстрирующая неразрешимость проблемы остановки. Рекурсия позволяет задать более выразительную версию:

recursive_paradox
        — Завершается, если и только если не завершается.
    do
        if terminates ("C:\your_project") then
            recursive_paradox
        end
    end
    

Знание того, что всякий цикл может быть заменен рекурсией, немедленно порождает вопрос, а верно ли обратное – можно ли рекурсивную процедуру заменить процедурой, использующей циклы?

С примерами замены мы уже встречались – те же числа Фибоначчи, has и put для бинарных деревьев. Другие рекурсивные процедуры – hanoi, height, print_all – не имели свободного от рекурсии эквивалента. Для понимания того, что точно может быть сделано, необходимо более глубоко познакомиться со свойствами и смыслом рекурсивных программ.

9.2. Понимание рекурсии

Приобретенный опыт построения рекурсивных программ позволяет нам более глубоко исследовать смысл рекурсивных определений.

Неправильные циклы?

Прежде всего, вернемся назад и зададим весьма невежливый вопрос: а не является ли рекурсия "голым королем"? Другими словами, стоит ли что-либо за рекурсивным определением? Примеры, особенно примеры рекурсивных программ, свидетельствуют в их пользу, но некоторая доля сомнений все же остается. Мы все же находимся в опасной близости к определениям, не имеющим смысла, – к каким-то неправильным циклам. Рекурсия позволяет определять понятие в терминах самого понятия. Но, когда говорится:

Информатика занимается изучением информатики

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

Информатика занимается изучением программирования, структур данных, алгоритмов, приложений, теоретическими вопросами и другими областями информатики

В определение добавлены полезные элементы, но оно все еще не является удовлетворительным определением. Подобным образом могут оказаться бесполезными и рекурсивные программы, такие как эта:

p (x: INTEGER)
        — Что в этом хорошего?
    do p (x) end
        

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

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

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

Почувствуйте методологию
Правильно построенное рекурсивное определение

Полезное рекурсивное определение должно удовлетворять следующим требованиям:

R1 должна присутствовать хотя бы одна нерекурсивная ветвь;
R2 каждый вызов рекурсивной ветви должен иметь контекст, отличающийся от контекста, в котором эта ветвь была вызвана;
R3 для каждой рекурсивной ветви изменение контекста (R2) приближает к одному из нерекурсивных случаев (R1).

Для рекурсивных программ изменение контекста (R2) может заключаться в том, что вызов использует различное значение аргумента, как в вызове r(n -1) в программе r(n:INTEGER). Этот вызов применим к различным целям – x.r(n), где x не является текущим объектом. Изменение контекста может также означать, что вызов встречается после того, как программа изменила по меньшей мере одно поле по меньшей мере одного объекта.

Все рекурсивные программы, рассмотренные нами ранее, удовлетворяют этим требованиям.

  • Тело Hanoi(n, …) включает условный оператор if n > 0 then … end, где все рекурсивные вызовы сосредоточены в then-ветви оператора, но поскольку else-ветвь отсутствует, то эта "пустая" ветвь при n = 0 определяет нерекурсивный вариант (R1). Рекурсивные вызовы имеют форму Hanoi(n - 1, …), изменяя первый аргумент и порядок других аргументов (R2). Замена n на n – 1 приближает контекст к нерекурсивному случаю n = 0 (R3).
  • Рекурсивный метод has для бинарных деревьев поиска имеет нерекурсивные варианты для x = item, для x < item, если нет левого поддерева, и для x > item, если нет правого поддерева (R1). Рекурсивные вызовы имеют другую цель – left или right, отличающуюся от текущего объекта (R2). Каждый такой вызов приближается к листьям дерева, где рекурсия заканчивается (R3). Все эти утверждения справедливы и для других методов, работающих с деревьями поиска, например, height.
  • В методе animate_rest – рекурсивной версии обхода линии метро, – когда курсор находится в положении after, срабатывает нерекурсивная ветвь (R1), ничего не делающая. Рекурсивные вызовы не изменяют аргумент, но в процессе работы вызывается метод line.forth, изменяющий состояние линии (R2); при этом курсор передвигается ближе к состоянию after, где рекурсия заканчивается (R3).

Для рекурсивных понятий, не связанных с программами, условия R1, R2, R3 также должны выполняться.

  • Мини-грамматика, определяющая понятие "Операторы", имеет нерекурсивный вариант – "Присваивание";
  • Все наши рекурсивно определенные структуры данных, такие как STOP, являются рекурсивными благодаря ссылкам, которые могут иметь значение void. В связных структурах значения void служат в качестве терминаторов, завершающих структуру.
  • В случае рекурсивных программ комбинирование трех вышеприведенных правил предполагает понятие варианта, подобное варианту цикла, гарантирующего завершение цикла.
Почувствуй методологию
Вариант в рекурсии

Каждая рекурсивная программа должна быть объявлена с ассоциированным с рекурсией вариантом, целочисленной величиной, связанной с каждым вызовом, такой, что:

  • предусловие программы гарантирует неотрицательность варианта;
  • если выполнение программы начинается со значения v для варианта, то значение варианта v1 для любого рекурсивного вызова удовлетворяет условию 0 <= v1 < v.

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

  • Для Hanoi(n, …) вариантом является n.
  • Для has, height, print_all и других рекурсивных методов, связанных с обходом бинарных деревьев, вариантом является node_height – наибольшая длина пути от текущего узла до одного из листьев дерева.
  • Для animate_rest вариантом является, как и для соответствующего цикла, Line8.count – Line8.index +1.

Специального синтаксиса для вариантов рекурсивных методов нет, но мы будем использовать комментарий в следующей форме, показанной для процедуры Hanoi(n, …):

— variant n
        
< Лекция 9 || Лекция 10: 1234 || Лекция 11 >