Инструменты, алгоритмы и структуры данных

Рекурсия и деревья

Разбить на страницы
Показывать лекцию целиком
(рис 8.1)

Смеющаяся корова, изображенная на фирменном жетоне "Смеющаяся Корова", носит в качестве сережек фирменные жетоны, на которых, я подозреваю, но зрение не позволяет убедиться в правильности моего предположения, изображена корова с фирменными жетонами, на которых изображена … (надеюсь, идея понятна)У нас известен рекурсивный стишок, вошедший в поговорку: "У попа была собака, он ее любил. Она съела кусок мяса, он ее убил, и в землю закопал, и на могиле написал, что у попа была собака …" .

Эта реклама, появившаяся в 1921 году, все еще хорошо работает, являясь примером структуры, определенной рекурсивно в следующем смысле:

Рекурсивное определение

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

"Рекурсия" – использование рекурсивного определения – широко применяется в программировании: она позволяет элегантно определять синтаксические структуры; мы также познакомимся с рекурсивно определенными структурами данных и рекурсивными процедурами.

Мы будем использовать термин "рекурсивный" как сокращение "рекурсивно определенный" – рекурсивная грамматика, рекурсивная структура данных. Но это только соглашение, поскольку нельзя сказать, что понятие или структура сами по себе рекурсивны. Все, что мы знаем, – это то, что их можно рекурсивно описать в соответствии с вышеприведенным определением. Любое частичное понятие – в том числе структура, задающая Смеющуюся корову, – может быть определено как рекурсивно, так и без использования рекурсии.

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

Рекурсия является прямой, если определение $$А$$ ссылается на экземпляр $$А$$, и косвенной, если для $$1 <= i < n$$ (для некоторого $$n >=2$$) определение каждого $$A_i$$ ссылается на $$A_{i+1}$$, а определение $$A_n$$ ссылается на $$A_1$$.

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

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

8.1. Основные примеры

В этот момент могут возникнуть вполне обоснованные сомнения – а есть ли смысл в рекурсивных определениях, как можно определить понятие через само понятие, "масло масляное"?

Вы вправе сомневаться. Не все рекурсивные определения хороши для определения чего-либо. Когда вас просят дать характеристику кому-нибудь, а вы отвечаете: "Света? Ну, это просто Света, что еще можно сказать!" – то вы не много нового сказали. Так что следует позаботиться о критериях, гарантирующих полезность определения, даже если оно рекурсивно.

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

Рекурсивные определения

С введением универсальности мы получаем возможность определять тип как:

Т1 класс, не являющийся универсальным, такой как INTEGER или STATION;
Т2 родовое порождение в форме C[T], где Суниверсальный класс, а Т – тип.

Это определение рекурсивно, оно просто означает, что, при наличии универсальных классов ARRAY и LIST, правильными классами также будут:

  • INTEGER, STATION и им подобные в соответствии с определением Т1;
  • согласно случаю Т2, прямые родовые порождения: ARRAY[INTEGER], LIST[STATION] и так далее.
  • Снова рекурсивно применяя Т2: ARRAY[LIST[INTEGER]], ARRAY[ARRAY [LIST[STATION]]] и так далее – родовые порождения любого уровня вложенности.

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

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

    Рассмотрим подмножество Eiffel с двумя видами операторов.

  • Присваивание, в его обычной форме variable:= expression, рассматриваемое здесь как терминальное и далее не уточняемое.
  • Условный оператор, имеющий только часть then (без else) для простоты.
  • Грамматика, определяющая язык, такова:

    $$\text{Оператор }\triangleq\text{ Присваивание | Условный}\\\text{Условный }\triangleq}\text{ if Условие then Оператор end }$$

    Для наших непосредственных целей будем полагать, что "Условие" является терминальным понятием. Это определение грамматики очевидно рекурсивно, поскольку определение "Оператор" включает "Условный", а его определение, в свою очередь, включает "Оператор". Но так как здесь присутствует нерекурсивная часть определения – "Присваивание", то в целом грамматика четко определяет правильные конструкции языка:

  • просто Присваивание;
  • Условный, содержащий Присваивание: if c then a end;
  • то же самое с произвольной степенью вложенности: if c1 then if c2 then a end end, if c1 then if c2 then if c3 then a end end end и так далее.
  • Рекурсивные грамматики на самом деле являются незаменимым средством для описания любого языка, который, как все распространенные языки, поддерживает вложенные структуры.

    Рекурсивно определенные структуры данных

    Класс STOP представляет понятие остановки для линии метро:

    class STOP create
    …
    feature
        next: STOP
                — Следующая остановка на той же линии.
            …Другие компоненты, здесь опущенные (см.6.5)
    end
            

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

    (рис 8.2) Вложенные поля (интерпретация не корректна)

    Такое попросту невозможно. Но STOP в любом случае является ссылочным типом, подобно любому классу, определенному как class X… без всяких других квалификаций, так что реальная картина выглядит так:

    (рис 8.3) Линия, связанная ссылками

    Рекурсия в таком определении структуры данных просто указывает, что каждый экземпляр класса потенциально содержит ссылку на экземпляр того же класса, "потенциально" – поскольку ссылка может иметь значение void, и тогда действие рекурсии прекращается, как для последней остановки на рисунке.

    В той же 6-й главе, где рассматривались линии метро, шла речь и о классе PERSON c атрибутом spouse типа PERSON.

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

    Рекурсивно определяемые алгоритмы и программы

    Известная последовательность чисел Фибоначчи обладает многими прекрасными свойствами и появляется во многих приложениях математики и естественных наук. Она имеет следующее определение:

    $$F_0=0\\F_1=1\\F_i=F_{i-1}+F_{i-2}\qquad \text{- Для}\;i>1$$

    Почувствуй историю

    Кролики Фибоначчи

    Леонардо Фибоначчи из Пизы (1170 – 1250) сыграл ключевую роль в знакомстве Запада с трудами индийских и арабских математиков. Он известен также и собственными исследованиями, лежащими в основании современной математики. Он сформулировал задачу, приводящую к его знаменитой последовательности (которая была известна еще индийским математикам):

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

    (рис 8.4) Фибоначчи

    Решение дает следующее рассуждение. Пары кроликов в месяце $$i$$ включают пары кроликов, уже существующие в предыдущем месяце (кролики не умирают); обозначим их число как $$F_{i-1}$$, плюс потомство, принесенное кроликами, жившими в месяце $$i-2$$ (кролики, появившиеся в месяце $$i-1$$, потомства не приносят). Это и дает приведенную выше формулу, создающую последовательность целых чисел 0, 1, 1, 2, 3, 5, 8 и так далее. Формула приводит к рекурсивной программе, вычисляющей $$F_n$$ для любого $$n$$:

    fibonacci (n: INTEGER): INTEGER
            — Элемент с индексом n в последовательности Фибоначчи.
        require
            non_negative: n >= 0
        do
            if n = 0 then
                Result := 0
            elseif n = 1 then
                Result := 1
            else
                Result := fibonacci(n – 1)+ fibonacci(n – 2)
            end
        end
            

    Время программирования!

    Рекурсивная версия Фибоначчи

    Напишите небольшую программную систему, включающую вышеприведенную рекурсивную функцию и печатающую ее результат. Протестируйте ее для небольших значений n, включая 12, как в оригинальном тексте Фибоначчи.

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

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

    Время программирования!

    Нерекурсивная версия Фибоначчи

    Можете ли вы, не заглядывая в дальнейший текст, написать функцию, вычисляющую N-е число Фибоначчи, используя цикл, а не рекурсию?

    Следующая функция дает тот же результат, что и рекурсивная версия. Проверьте это на нескольких значениях.

    fibonacci1 (n: INTEGER): INTEGER
                — Элемент с индексом n в последовательности Фибоначчи.
                — (Нерекурсивная версия.)
        require
            positive: n >=1
        local
            i, previous, second_previous: INTEGER
        do
            from
                i := 1 ; Result := 1
            invariant
                Result = fibonacci(i )
                previous = fibonacci (i – 1)
            until i = n loop
                i := i + 1
                second_previous := previous
                previous := Result
                Result := previous + second_previous
            variant
                n – i
            end
        end
            
    Для удобства в этой версии предполагается, что n >= 1, а не n >= 0. Благодаря правилам инициализации previous начинается с 0, что гарантирует начальное выполнение инварианта, так как $$F_0 = 0$$. Переменная second_previous обновляется на каждом шаге цикла и ей не нужно специальной инициализации.

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

    Оставим в стороне вкус и эффективность. Если бы мы рассматривали только такие примеры, то необходимость рекурсивных программ была бы далеко не очевидной. Нам необходимы примеры, в которых рекурсия обеспечила бесспорные преимущества, например, за счет того, что ее нерекурсивный аналог был бы значительно сложнее и труднее в понимании. Такие примеры существуют в большом количестве. Одним из них является очаровательная головоломка – "Ханойская башня". В этом примере сконцентрировано много полезных свойств рекурсии, и практически нет не относящихся к делу деталей.

    8.2. Ханойская башня

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

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

    (рис 8.5) Башня Ханоя (должна быть башней Бенареса?) из 9 дисков в начальном состоянии

    Несмотря на восточный орнамент, эта история является созданием французского математика Эдуарда Лукаса (подписывающегося как "N. Claus de Siam" – анаграмма "Lucas d'Amiens", с добавлением названия его родного города). На рынке в Таиланде (Сиам) я купил подобную башню, показанную на рисунке. Метки А, В, С – это мое добавление. Не буду распространяться на тему, почему я выбрал модель, сделанную из дерева, а не из бриллиантов, золота и меди. Но вполне законно спросить, почему на ней только 9 дисков, хотя портфель у меня был большой и мог бы вместить башню из 64 дисков.

    Время теста!

    Размер Ханойской башни

    Почему коммерчески доступные модели Ханойской башни имеют размеры много меньшие, чем 64 диска?

    (Подсказка: игра сопровождается бумажным свертком, на котором дается решение головоломки в форме последовательности ходов; А => C, A => B и т. д.)

    Чтобы ответить на этот вопрос, давайте оценим минимальное число ходов $$H_n$$ (ход – это перемещение диска с одного стержня на другой), требуемое для решения задачи. Если задача имеет решение, то нужно перенести n дисков со стержня А на стержень В, используя стержень С как промежуточный. При этом нужно соблюдать правило Будды, запрещающее класть больший диск на меньший. В оригинальной версии n = 64, для небольшой модели n = 9.

    Заметим, что для любой стратегии перемещения в некоторый момент необходимо перенести самый большой диск со стержня А на стержень В, а это возможно лишь при условии, что все остальные n -1 дисков в этом момент находятся на диске С в требуемом порядке:

    (рис 8.6) Промежуточное состояние

    Каково минимальное число ходов, необходимое для достижения этого промежуточного состояния? Необходимо перенести n -1 диск со стержня А на стержень С, не перемещая самый большой диск и используя стержень В как промежуточный. Ввиду симметричности задачи для этого потребуется $$H_{n-1}$$ ходов.

    Сразу же по достижении этого состояния можно перенести за один ход самый большой диск с А на В, после чего перенести n – 1 диск с С на В, используя А как промежуточный. Общее количество ходов:

    $$H_n=2*H_{n-1}+1$$

    Учитывая, что $$H_0$$ равно 0, получим:

    $$H_n=2^n-1$$

    Как следствие, нетрудно получить ответ на наш тест. Вспомним, что $$2^{10} = 1024$$, или примерно $$10^3$$, и получим, что число ходов $$2^{64}$$ примерно равно $$1,5 * 10^{19}$$.

    Год – это примерно 30 миллионов секунд. Если предположить, что священники Бенареса за секунду выполняют один ход – весьма неплохая скорость для переноса золотого диска, – всю работу они закончат за 500 миллиардов лет, что примерно в 30 раз превосходит оценочный возраст существования нашей вселенной. Даже компьютеру, выполняющему 100 миллионов ходов за секунду, при моделировании этой задачи для переноса дисков потребуется не одна тысяча лет.

    Вывод оценки $$H_n$$ был конструктивным, в том смысле, что он дает практическую стратегию для перемещения дисков.

  • Переместить $$n – 1$$ диск с А на С, используя В как промежуточное хранилище и руководствуясь правилами игры.
  • После этого стержень В будет пуст, а на А будет находиться только один самый большой диск, который и переносится за один ход с А на В. Этот ход соответствует всем правилам игры – перенос одного диска с вершины одного стержня на другой стержень, в вершине которого нет диска, размер коего меньше размера переносимого диска.
  • После этого переместить $$n – 1$$ диск с С на В, используя А как промежуточное хранилище, руководствуясь правилами игры. Самый большой диск, находящийся на В, не мешает переносу дисков меньшего размера.
  • Эта стратегия превращает число ходов $$H_n = 2^n – 1$$ из теоретического минимума в практически достижимую цель. Мы можем записать алгоритм в виде рекурсивной процедуры, входящей в класс NEEDLES:

    hanoi (n: INTEGER; source, target, other: CHARACTER)
            — Перенос n дисков из source на target,
            — используя other как промежуточное хранилище,
            — в соответствии с правилами игры "Ханойская башня"
        require
            non_negative: n >= 0
            different1: source /= target
            different2: target /= other
            different3: source /= other
        do
            if n > 0 then
                hanoi (n–1, source, other, target)
                move (source, target)
                hanoi (n–1, other, target, source)
            end
        end
            
    Обсуждение контрактов для рекурсивных методов добавит дополнительные предложения в предусловие и постусловие.

    По соглашению стержни представляются символами – 'A', 'B', 'C'. Другое соглашение, принятое в этой лекции (уже использованное в предыдущих примерах), состоит в подсветке рекурсивных ветвей кода, – процедура hanoi содержит два таких участка.

    Базисная операция move(source, target) перемещает один диск с вершины стержня source на вершину target. Предусловие устанавливает, что на source должен находиться, по крайней мере, один диск, а на target диска либо нет, либо диск в вершине имеет больший размер, чем перемещаемый диск. Запишем move как процедуру, выводящую на консоль инструкцию по перемещению диска:

    move (source, target: CHARACTER)
            — Инструкция по перемещению диска с source на target.
        do
            io.put_character (source)
            io.put_string (" to ")
            io.put_character (target)
            io.put_new_line
        end
            

    Время программирования!

    Ханойская башня

    Напишите систему с корневым классом NEEDLES, включающим процедуры hanoi и move. Проверьте их работоспособность на примерах.

    Например, выполните вызов

    hanoi (4, 'A', 'B', 'C')
                

    В результате должна быть напечатана последовательность из пятнадцати ($$2^4 – 1$$) ходов:

    A на C B на C B на A
    A на B A на C C на B
    C на B A на B A на C
    A на C C на B A на B
    B на A C на A C на B

    Эта последовательность ходов успешно переносит диски с А на B в полном соответствии с правилами игры.

    Один из способов анализа рекурсивного решения – процедуры hanoi – состоит в том, чтобы рассматривать перемещение n – 1 дисков, как один обобщенный ход. В этом случае мы могли бы начать перемещение с этого хода (с А на С):

    (рис 8.7) Начальный глобальный ход Фибоначчи

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

    Конечно, это фиктивное рассмотрение, поскольку правилами разрешается перенос только одного диска за один ход, но перемещая n – 1 диск, можно применять ту же технику, зная, что целевой стержень либо пуст, либо содержит диски большего размера. Так можно рекурсивно выполнять перемещения, уменьшая каждый раз значение n. Когда же n становится равным нулю, то делать ничего не нужно.

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

    8.3. Рекурсия как стратегия решения задач

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

    (рис 8.8)
  • Составной оператор (последовательность). Семантику последовательности можно выразить так: "Я знаю кого-то, кто может провести меня от текущей точки до В, и знаю того, кто может провести от В до С, так что можно просить их, работая последовательно, провести меня до С".
  • Условный оператор означает: "Я знаю кого-то, кто может решить задачу в одном случае, и знаю того, кто может решить ее для всех других возможных случаев, что позволяет мне просить их работать в зависимости от возникающей ситуации".
  • Решение, использующее цикл, можно выразить так: "Я не знаю, как добраться до С, но я знаю область I (инвариант), содержащую С, знаю кого-то, кто может привести меня в эту область (инициализация), и знаю того (тело цикла), кто может приблизить меня к С, при условии, что я нахожусь в области I. Расстояние до цели будет уменьшаться (вариант) таким образом, что за конечное число шагов я достигну требуемой мне окрестности С. Мне остается попросить моего первого друга привести меня в область I, а затем просить второго друга приближать меня к С, пока я не достигну цели".
  • Процедура, как способ решения задачи, означает: "Я знаю кого-то, кто может решать эту задачу в общем случае, так что мне нужно лишь сформулировать мою специальную задачу в его терминах и попросить решить задачу для меня".
  • Что можно сказать о рекурсивном решении? К кому нужно обращаться? Ответ – к себе! Возможно – несколько раз (как в случае с Ханойской башней и многих других)!

    Зачем обращаться к другим, если я доверяю себе (по крайней мере, я так думаю)?

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

    Такова идея рекурсии, рассматриваемая как стратегия решения сложной задачи. Она связана с некоторыми предыдущими стратегиями.

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

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

    Определение: бинарное дерево

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

  • единственный узел, называемый корнем бинарного дерева;
  • (рекурсивно) два бинарных дерева над G, называемых левым поддеревом и правым поддеревом.
  • Все это просто выразить в каркасе класса, не включающем методов:

    class BINARY_TREE [G] feature
    item: G
    left, right: BINARY_TREE[G]
    end
        

    Ссылка void указывает на пустое дерево. Проиллюстрируем бинарное дерево над целыми (рис 8.9).

    Форма "Ветвления" – это наиболее общий стиль представления бинарных деревьев, но не единственный: возможно представление в форме вложенности, которое для данного примера выглядит так (рис 8.10).

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

    (рис 8.9) Соглашение: Бинарное дерево (представленное "ветвлением") (рис 8.10) Бинарное дерево (вложенное представление)

    Если бинарное дерево не пусто, то оно всегда имеет корень и может не иметь поддеревьев, иметь только левое или только правое поддерево или иметь оба поддерева.

    Любой узел бинарного дерева сам рассматривается как бинарное дерево. Достаточно взглянуть на два последних рисунка. Узел, помеченный как 35, задает полное дерево, 23 – его левое поддерево, 54 – правое. Узел 78 задает корень дерева, которое является правым поддеревом правого поддерева полного дерева. Это позволяет говорить о правом и левом поддереве каждого узла. Эту ассоциацию можно сделать формальной, дав другой пример рекурсивного определения.

    Определение: дерево, ассоциированное с узлом

    Любой узел $$n$$ бинарного дерева $$B$$ определяет бинарное дерево $$B_n$$ следующим образом:
  • если $$n$$ – корень $$B$$, то $$B_n$$ – это просто $$B$$;
  • в противном случае из предыдущего определения следует, что $$n$$ – это одно из поддеревьев $$B$$. Если $$B^\prime$$ – это поддерево, то определим $$B_n$$ как $$B^\prime_n$$ (узел, связанный с $$n$$, рекурсивно, в соответствующем поддереве).
  • Рекурсивные процедуры над рекурсивными структурами данных

    Большинство методов класса, определяющего рекурсивную структуру класса, будут строиться рекурсивно с учетом рекурсивного определения данных. Простым примером является метод, подсчитывающий число узлов бинарного дерева. В пустом дереве число узлов равно нулю, для непустого дерева число узлов равно сумме трех значений: для корня и числа узлов соответственно левого и правого поддеревьев. Нетрудно записать это наблюдение в виде рекурсивной функции, входящей в класс BINARY_TREE.

    count: INTEGER
            — Число узлов.
        do
            Result := 1
            if left /= Void then Result := Result + left.count end
            if right /= Void then Result := Result + right.count end
        end
            

    Заметьте схожесть этой функции с процедурой Hanoi.

    Дети и родители

    Дети (непосредственные потомки) узла – сами узлы – являются корневыми узлами левого и правого поддеревьев:

    (рис 8.11) Бинарное дерево (представление "ветвлением")

    Если С – сыновний (дочерний) узел В, то В – родитель С. Более точно мы можем сказать, что В является "родителем" С, благодаря следующему результату:

    Теорема: "Единственный родитель"

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

    Теорема кажется очевидной, но мы докажем ее, что даст нам возможность познакомиться с рекурсивными доказательствами.

    Рекурсивные доказательства

    Рекурсивное доказательство теоремы о единственном родителе в значительной степени отражает рекурсивное определение бинарного дерева.

    Если бинарное дерево BT пусто, то теорема выполняется. В противном случае бинарное дерево имеет корень и два непересекающихся бинарных дерева, о которых мы можем предположить – "рекурсивная предпосылка", – что они оба удовлетворяют теореме. Это следует из определений "бинарного дерева", "ребенка" и "родителя", так что узел С может иметь родителя Р в ВТ только одним из трех возможных случаев:

    Р1 Р является корнем ВТ, а С является корнем либо левого, либо правого поддерева;
    Р2 они оба принадлежат левому поддереву, и Р является родителем С в этом поддереве;
    Р3 они оба принадлежат правому поддереву, и Р является родителем С в этом поддереве.

    В случае Р1 узел С по гипотезе рекурсивности, являясь корнем, не имеет родителей в своем поддереве, так что у него есть единственный родитель – корень всего дерева ВТ. В случаях Р2 и Р3, опять-таки по гипотезе рекурсивности, Р был единственным родителем С в соответствующем поддереве, и это остается верным и во всем дереве.

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

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

  • Для любой нерекурсивной ветви определения необходимо доказать свойство непосредственно (в примере нерекурсивной ветвью является пустое дерево).
  • Для рекурсивной ветви определяется новый экземпляр понятия в терминах существующих экземпляров. Для них можно предположить, что свойство выполняется (это и есть "гипотеза рекурсивности"), после чего требуется доказать, что при этих предположениях свойство выполняется.
  • Эта схема применима в целом для всех рекурсивно определяемых понятий. Мы увидим ее применение к рекурсивно определенной процедуре hanoi.

    Бинарные деревья выполнения

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

    (рис 8.12) Выполнение Hanoi, рассматриваемое как бинарное дерево

    Добавление операции move позволило бы реконструировать последовательность операций. Формально мы выполним это позднее.

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

    Еще о свойствах бинарных деревьях и о терминологии

    Как отмечалось, узел бинарного дерева может иметь:

  • как левого, так и правого сына, подобно узлу 35 из нашего примера;
  • только левого сына, подобно всем узлам левого поддерева, помеченным значениями 23, 18, 12;
  • только правого сына, подобно узлу 60;
  • не иметь сыновних узлов. Такие узлы называются листьями дерева; в примере листьями являются узлы с пометками 12, 41, 67 и 90.
  • (рис 8.13) Копия ранее приведенного дерева

    Определим восходящий путь в бинарном дереве как последовательность из нуля или более узлов, где любой узел последовательности является родителем предыдущего узла, если таковой имеется. В нашем примере узлы с метками 60, 78, 54 формируют восходящий путь. Справедливо следующее свойство, являющееся следствием теоремы о единственном родителе.

    Теорема: "Путь к корню"

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

    Доказательство. Рассмотрим произвольный узел С бинарного дерева. Построим восходящий путь, начинающийся в С. В соответствии с теоремой о единственном родителе такой путь определяется единственным образом. Если путь конечен, то заканчиваться он должен в корне дерева, поскольку любой другой узел имеет родителя, и следовательно, построение восходящего пути могло бы быть продолжено. Для завершения доказательства необходимо показать, что все пути конечны. Единственный способ построения бесконечного пути (учитывая, что число узлов бинарного дерева по определению конечно) состоит в том, что путь включает цикл. Если некоторый узел n встретится дважды, то он встретится сколь угодно много раз, так что бесконечный путь должен содержать подпоследовательность в форме n... n. Но это означает, что n появляется в своем собственном левом или правом поддереве, что невозможно по определению бинарных деревьев.

    Рассмотрение нисходящих путей позволяет установить следующий факт, как следствие предыдущей теоремы.

    Теорема: "Нисходящий путь"

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

    Весом бинарного дерева является максимальное число узлов среди всех нисходящих путей от корня к листьям дерева. В примере вес бинарного дерева равен 5, он достигается на пути, который ведет от корня к листу, помеченному как 67.

    Это понятие можно определить рекурсивно, следуя снова рекурсивной структуре определения. Вес пустого дерева равен нулю. Вес непустого дерева равен 1 плюс максимум (рекурсивно) из весов левого и правого поддеревьев. Мы можем добавить соответствующую функцию в класс BINARY_TREE:

    height: INTEGER
            — Максимальное число узлов нисходящего пути.
        local
            lh, rh: INTEGER
        do
            if left /= Void then lh := left.height end
            if right /= Void then rh := right.height end
            Result := 1 + lh.max (rh)
        end
            

    Здесь рекурсивное определение адаптируется к соглашению, принятому для класса, который рассматривает только непустые поддеревья. Отметьте опять-таки схожесть с hanoi.

    Операции над бинарными деревьями

    В классе BINARY_TREE пока определены только три компонента, все они являются запросами: item, left и right. Мы можем добавить процедуру создания:

        make (x: G)
                — Инициализация item значением x.
            do
                item := x
            ensure
                set: item = x
            end
    Добавим в класс команды, позволяющие изменять поддеревья, и значение в корне:
        add_left (x: G)
                — Создать левого сына со значением x..
            require
                no_left_child_behind: left = Void
            do
                create left.make (x)
            end
        add_right … Аналогично add_left…
        replace (x: G)
                — Установить значение корня равным x.
            do item := x end
            

    На практике удобно специфицировать replace как команду-присваиватель для соответствующего запроса, изменив объявление запроса следующим образом:

    item: G assign replace
            

    Это позволяет писать bt.item:= x вместо bt.item.replace(x).

    Обходы бинарного дерева

    Нет ничего удивительного в том, что, благодаря рекурсивной определенности, с бинарным деревом связываются многие рекурсивные методы. Функция height является одним из таких примеров. Приведем сейчас и другие. Предположим, что требуется напечатать все значения элементов, связанных с узлами дерева. Следующая процедура, добавленная в класс, выполняет эту работу:

    print_all
            — Печать значений всех узлов.
        do
            if left /= Void then print_all (left)end
            print (item)
            if right /= Void then print_all (right) end
        end
            
    Здесь используется процедура print (доступная всем класса через общего родителя ANY), которая печатает подходящее представление значения типа G – родового параметра в классе BINARY_TREE[G].

    Заметьте, структура print_all идентична структуре hanoi.

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

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

    Порядки обхода бинарного дерева

  • Inorder: обход левого поддерева, посетить корень, обход правого поддерева.
  • Preorder: посетить корень, обход левого поддерева, обход правого поддерева.
  • Postorder: обход левого поддерева, обход правого поддерева, посетить корень.
  • В этих определениях "посетить" означает выполнение операции над отдельным узлом, такой как print в процедуре print_all; "обход" означает либо рекурсивное применение алгоритма для поддерева, либо отсутствие каких-либо действий, если поддерево пусто.

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

    Процедура print_all является иллюстрацией инфиксного способа обхода дерева. Достаточно просто записываются и другие способы обхода, например, для постфиксного обхода тело процедуры post имеет вид:

    if left /= Void then post (left) end
    if right /= Void then post (right) end
    visit (item)
            

    Здесь visit является операцией над узлом, такой как print.

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

    В качестве еще одной иллюстрации инфиксного обхода рассмотрим снова бинарное дерево выполнения hanoi для n = 3, где узлы уровня 0 опущены, поскольку не представляют интересной информации в данном случае.

    (рис 8.14) Выполнение Hanoi в инфиксном порядке обхода

    Процедура hanoi является матерью всех инфиксных обходов: обход левого дерева, если оно есть, посещение корня, обход правого поддерева, если оно есть. При посещении каждого узла выполняется операция move(source, target). Инфиксный порядок дает нужную последовательность ходов: A B, A C, B C, A B, C A, C B, A B.

    Бинарные деревья поиска

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

    Множество G, над которым определено общее бинарное дерево, может быть любым множеством. Для бинарных деревьев поиска предполагается, что на множестве G задано полное отношение порядка, позволяющее сравнивать любые два элемента G, задавая булевское выражение a < b, такое, что истинно одно из трех отношений: a < b, b < a, a ∼ b. Примерами таких множеств могут служить INTEGER и REAL с отношением порядка <, но G может быть любым вполне упорядоченным множеством.

    Как обычно, мы пишем a < = b, когда либо a < b, либо a ∼ b. Аналогично, b > a, если a < b. Над вполне упорядоченным множеством можно определить бинарное дерево поиска.

    Определение: бинарное дерево поиска

    Бинарным деревом поиска над вполне упорядоченным множеством G называется бинарное дерево, такое, что для любого поддерева с корнем root со значением item, равным r, выполняются условия:
  • если у корня root есть левое поддерево, то для любого его узла со значением item, равным le, справедливо: le < r;
  • если у корня root есть правое поддерево, то для любого его узла со значением item, равным ri, справедливо: ri > r.
  • Значения в узлах левого поддерева меньше значения в корне, а в правом поддереве – больше. Это свойство применимо не только ко всему дереву в целом, но, рекурсивно, к любому поддереву, прямому или непрямому потомку корня. Это свойство будем называть инвариантом бинарного дерева поиска.

    (рис 8.15) Упражнение 5.11.3 Из этого определения следует, что все значения в узлах дерева должны быть различны. Мы для простоты будем использовать такое соглашение. Вполне возможно разрешать появление узлов с одинаковыми значениями; тогда отношение < и > в наших определениях пришлось бы заменить на <= и >=. В одном из упражнений придется адаптировать рассматриваемые алгоритмы на такой случай деревьев поиска.

    Дерево, показанное на рисунке, является бинарным деревом поиска.

    Процедура print_all, примененная к этому дереву, напечатает значения, упорядоченные по возрастанию, начиная с наименьшего значения 12.

    Время программирования!

    Печать упорядоченных значений

    Используя приведенные на данный момент процедуры, постройте дерево, показанное на рисунке, затем напечатайте значения в узлах, вызвав print_all. Убедитесь, что значения упорядочены.

    Производительность

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

  • O(1) вставку (если элементы сохраняются в порядке вставки);
  • O(n) для поиска.
  • Для бинарного дерева поиска обе операции имеют сложность O(log n), что намного лучше, чем O(n) для больших n (вспомните, что для нотации "О-большое" не имеет значения основание алгоритма). Проанализируем эффективность работы полного бинарного дерева, которое определяется тем, что для любого его узла оба поддерева, выходящие из узла, имеют одну и ту же высоту h:

    (рис 8.16) Полное бинарное дерево

    Нетрудно видеть, при помощи индукции по высоте h, что число узлов n в полном дереве высоты h равно $$2^h – 1$$. Как следствие, $$h = \log_2(n+1)$$. В полном дереве как поиск, так и вставка, используя приведенные ниже алгоритмы, выполняются за время O(log n), начиная работу в корне и следуя нисходящим путем к листьям дерева. В этом и состоит главная привлекательность бинарных деревьев поиска.

    Конечно, большинство практических бинарных деревьев не являются полными. Если не повезет при вставке, то производительность может быть столь же плоха, как и для последовательного списка – O(n). К этому добавляются потери памяти, связанные с необходимостью хранения двух ссылок для каждого узла, в то время как для списка достаточно одной ссылки. На следующем рисунке показаны варианты "плохих" деревьев поиска:

    (рис 8.17) Схемы бинарных деревьев поиска, являющиеся причиной поведения O(N)

    При случайном порядке вставки бинарные деревья поиска остаются достаточно близкими к полным деревьям с поведением близким к O(log n). Можно гарантировать выполнение операций поиска, вставки и удаления за время O(log n), если пользоваться специальными вариантами таких деревьев – АВЛ-деревьями или черно-красными деревьями, которые являются почти полными деревьями.

    Вставка, поиск, удаление

    Приведем рекурсивную функцию поиска в бинарном дереве поиска (эта и следующая функция добавлены в класс, реализующий бинарные деревья поиска):

    has (x: G): BOOLEAN
            — Есть ли x в каком-либо узле?
        require
            argument_exists: x /= Void
        do
            if x ~ item then
                Result := True
            elseif x < item then
                Result := (left /=Void) and then left.has (x)
            else — x > item
                Result := (right /= Void) and then right.has (x)
            end
        end
            

    Алгоритм имеет сложность O(h), где h – высота дерева, что означает O(log n) для полного или почти полного дерева.

    В этом варианте приводится простая нерекурсивная версия алгоритма:

    has1 (x: G): BOOLEAN
            — Есть ли x в каком-либо узле?
        require
            argument_exists: x /= Void
        local
            node: BINARY_TREE [G]
        do
            from
                node := Current
            until
                Result or node = Void
            invariant
        — x не находится в вышерасположенных узлах на нисходящем пути от корня
            loop
                if x < item then
                    node := left
                elseif x > item then
                    node := right
                else
                    Result := True
                end
            variant
                — (Высота дерева) – (Длина пути от корня к узлу)
            end
        end
            

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

    put (x: G)
            — Вставка x, если он не присутствует в дереве.
        require
            argument_exists: x /= Void
        do
            if x < item then
                if left = Void then
                    add_left (x)
                else
                    left.put (x)
                end
            elseif x > item then
                if right = Void then
                    add_right (x)
                else
                    right.put (x)
                end
            end
        end
            

    Отсутствие ветви else во внешнем if отражает наше решение не размещать дублирующую информацию. Как следствие, вызов put с уже присутствующим значением не имеет эффекта. Это корректное поведение ("не жучок, а свойство"), о чем и уведомляет заголовочный комментарий. Некоторые пользователи могут, однако, предпочитать другой API с предусловием, устанавливающим not has(x).

    Нерекурсивная версия остается в качестве упражнения.

    Возникает естественный вопрос: как написать процедуру удаления – remove(x:G)? Это не так просто, поскольку нельзя просто удалить узел, содержащий x, если только это не лист дерева. Удаление листа сводится к обнулению одной из ссылок – left или right. Удаление произвольного узла приводило бы к нарушению инварианта бинарного дерева поиска.

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

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

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

  • в левом поддереве – это элемент с максимальным значением (такой элемент является листом дерева или имеет только одну связь);
  • в правом поддереве – это элемент с минимальным значением.
  • Если перемещаемый элемент является листом дерева, то после перемещения соответствующий узел удаляется. Если элемент имеет одну ссылку, то ссылка родителя перемещаемого элемента связывается с потомком перемещаемого элемента, тем самым перемещаемый узел исключается из дерева. Инвариант при этих операциях сохраняется.

    (рис 8.18) Ранее построенное дерево

    Предположим, что удаляется корень дереваremove (35). Тогда в корень можно поместить либо элемент 23 (из левого поддерева), либо элемент 41 из правого поддерева.

    Подобно поиску и вставке процесс удаления должен выполняться за время O(h), где h – это высота дерева. Процедура удаления остается в качестве упражнения.

    Время программирования!

    Удаление в бинарном дереве поиска

    Напишите процедуру remove(x:G), которая удаляет элемент x, если он присутствует в дереве, сохраняя инвариант.

    8.5. Перебор с возвратами и альфа-бета

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

    Перебор с возвратами применим к задаче, если каждое ее потенциальное решение может рассматриваться как последовательность выборов.

    Бедственное положение застенчивого туриста

    При необходимости достижения некоторой цели путешествия в незнакомом городе как к последней надежде можно прибегнуть к перебору с возвратом. Скажем, Вы находитесь в точке А (главная станция Цюриха) и хотите добраться до точки В (между главными зданиями ЕТН и Университета Цюриха):

    (рис 8.19) Испытания и перебор с возвратами

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

    На каждом этапе вы проверяете отрезки улиц, начиная с севера и двигаясь по часовой стрелке. Первая попытка приводит в точку 1. Здесь вы понимаете, что это не отвечает вашей цели, так как далее все возможные пути ведут на запад, то есть это тупик и нужно возвращаться назад в исходную точку А. Далее вы испытываете следующий выбор, приводящий в 2. Здесь есть несколько возможностей, и вначале вы проверяете путь, ведущий в 3, который оказывается тупиком, так что приходится вернуться в точку 2.

    Если все "правильные" (не ведущие на запад) пути в 2 были исследованы и приводили к тупикам, то из 2 пришлось бы вернуться в точку А. Но в данной ситуации из 2 есть еще не исследованный выбор, ведущий в точку 4.

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

    find ( p: PAT H): PATH
                — Решение, если оно существует, начинающееся в P.
        require
                meaningful: p /= Void
        local
                c: LIST [CHOICE]
        do
            if p.is_solution then
                Result := p
            else
                c := p.choices
                from c.start until
                    (Result /= Void) or c.after
                loop
                    Result := find ( p + c)
                    c.forth
                end
            end
        end
            

    Здесь применяются следующие соглашения. Выборы на каждом шаге описываются типом CHOICE (во многих ситуациях для этой цели можно использовать тип INTEGER). Есть также тип PATH, но каждый путь path – это просто последовательность выборов, и p + c – это путь, полученный присоединением c к p. Мы идентифицируем решение с путем, приводящим к цели, так что find возвращает PATH. По соглашению, если find не находит решения, то возвращается void. Запрос is_solution позволяет выяснить, найдено ли решение. Список выборов – p.choices, доступных из p, является пустым списком, если p – это тупик.

    Для получения решения достаточно использовать find(p0), где p0 – начальный пустой путь.

    Как обычно, Result инициализируется значением Void. Если в вызове find(p) ни один из рекурсивных вызовов на возможных расширениях p + c не вырабатывает решения (в частности, если таких расширений вообще нет, поскольку p.choices пусто), то цикл завершится со значением c.after, и тогда find(p) вернет значение Void. Если это был начальный вызов find(p0), то процесс завершается, не достигнув положительного результата, в противном случае рекурсивно включается следующий вызов, пока не будет получен результат или не будут перебраны все возможности.

    Если один из вызовов находит, что p является решением, то p возвращается в качестве результата, и, подымаясь вверх по цепочке вызовов, результат возвращается, поскольку Result /= Void является условием выхода.

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

    Дальнейшее обсуждение также показывает, как улучшить эффективность данного алгоритма, удаляя излишние записи. Например, фактически нет необходимости передавать путь как явно заданный аргумент, освобождая пространство в стеке вызовов. Вместо этого p может быть атрибутом, если мы добавим p:= p+x перед рекурсивным вызовом и p:= p.head после него (где head вырабатывает копию последовательности с удаленным последним элементом). Мы разработаем общий каркас, позволяющий безопасно выполнять такую оптимизацию.

    Правильная организация перебора с возвратом

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

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

    (рис 8.20) Путь с циклом

    Пути [1, 2, 3, 4], [1, 2, 3, 4, 2, 3, 4], [1, 2, 3, 4, 2, 3, 4, 2, 3, 4] и так далее являются эквивалентными. Пример, демонстрирующий нахождение маршрута без циклов, состоял в важной рекомендации – "никогда не уезжайте на запад, молодой человек". Конечно, этот совет не может служить общей рекомендацией и связан лишь с конкретной проблемной ситуацией. Чтобы справиться с циклом, необходимо сохранять информацию о ранее посещенных точках и игнорировать любой путь, ведущий к такой точке.

    Перебор с возвратами и деревья

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

    (рис 8.21) Дерево путей при переборе с возвратами

    Мы можем представлять всю карту города подобным образом: узлы отражают местоположение указанных на карте точек, дуги представляют отрезки улиц, соединяющих точки. В результате получается граф. Граф может быть деревом только в том случае, если в нем нет циклов. Если граф не является деревом, то на его основе можно построить дерево, называемое остовным деревом графа, которое содержит все узлы графа и некоторые из его дуг. Для этого используются методы, упомянутые ранее, а также существует соглашение, позволяющее избежать цикла (никогда не ходите на запад) или построения путей из корня и исключающее любую дугу, которая ведет к ранее встречающемуся узлу. Вышеприведенное дерево является остовным деревом для части нашего примера, включающего узлы А, 1, 2, 3 и 4.

    Для такого представления нашей задачи в виде дерева:

  • решением является узел, удовлетворяющий заданному критерию (свойство, ранее названное is_solution, адаптированное для применения к узлам);
  • выполнение алгоритма сводится к префиксному обходу дерева (самый левый в глубину).
  • В нашем примере этот порядок приведет к посещению узлов А, 1, 2, 3, 4.

    Это соответствие указывает, что "Префиксный обход" и "Перебор с возвратами" основаны на одной и той же идее: всякий раз, когда мы рассматриваем возможный путь, мы разматываем все его возможные продолжения – все поддеревья узла, – прежде чем обращаемся к любому альтернативному выбору на уровне, представляющем непосредственных потомков узла. Например, если А на предыдущем рисунке будет иметь третьего потомка, то обход не будет его рассматривать, прежде чем не обойдет все поддеревья 2.

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

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

    Минимакс

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

  • в игре два игрока. Будем называть их Минни и Макс;
  • для оценки ситуации, сложившейся в игре, будем использовать оценочную функцию с числовыми значениями, спроектированную так, что отрицательные значения хороши для Минни, а положительные – для Макса.
  • Примитивной оценочной функцией на примере игры в шашки может служить следующая функция, построенная в предположении, что Макс играет шашками черного цвета (black): (mb – mw) + 3 * (kb – kw), где mb и mw – число простых шашек соответственно у Макса и Минни, а kb и kw – число дамок, каждая из которых оценивается в три раза выше, чем простая шашка. Конечно, в хорошей игровой программе оценочная функция является более сложной.

    Минни старается найти позицию, минимизирующую оценочную функцию, а Макс старается ее максимизировать.

    (рис 8.22) Дерево игры

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

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

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

    М1 Значение листа является результатом применения оценочной функции к соответствующей позиции игры.
    М2 Значение внутреннего узла, задающего ход Макса, является максимумом из значений детей этого узла.
    М3 Значение внутреннего узла, задающего ход Минни, является минимумом из значений детей этого узла.

    Значением игры в целом является значение, ассоциированное с корнем дерева. Для формирования стратегии следует в случаях М2 и М3 сохранять для каждого внутреннего узла не только значение, но и тот сыновний узел, обеспечивший оптимальную оценку. Вот иллюстрация стратегии, полученной в предположении, что значения в листьях вычислены с помощью некоторой оценочной функции:

    Можно видеть, что значение в каждом узле является минимумом (для уровней 1 и 3) или максимумом (на уровне 2) значений сыновей узла. Оптимальный ход для Минни, обеспечивающий значение -7, состоит в выборе хода С.

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

    (рис 8.23) Дерево игры с оценками

    Следующий алгоритм, являясь вариацией ранее приведенной общей схемы перебора, реализует эту идею. Он представлен функцией minimax, возвращающей пару целых: value – гарантированное значение из начальной позиции p, и choice – начальный выбор, приводящий к этому значению. Аргумент l задает уровень, на котором появляется позиция p в ходе игры. Первый ход из этой позиции, возвращаемый как часть результата, является ходом Минни (как на рисунке), если l нечетно, и ходом Макса, если l четно.

    minimax ( p: POSITION; l: INTEGER): TUPLE [value, choice: INTEGER]
        — Оптимальная стратегия(value + choice) на уровне l, начиная с позиции p.
        local
            next: TUPLE [value, choice: INTEGER]
        do
            if p.is_terminal (l ) then
                Result := [value: p.value; choice: 0]
            else
                c := p.choices
                from
                    Result := worst (l )
                    c.start
                until c.after loop
                    next := minimax (p.moved (c.item), l + 1)
                    Result := better (next, Result, l )
                end
            end
        end
            

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

    Дополнительные функции worst и better дают возможность переключаться между Максом и Минни – игрок минимизирует на каждом нечетном ходе и максимизирует на четном.

    worst (l: INTEGER): INTEGER
            — Худшее возможное значение для игрока на уровне l.
        do
            if l \\ 2 = 1 then Result := Max else Result := Min end
        end
    better (a, b: TUPLE [value, choice: INTEGER]; l: INTEGER):
                                                     TUPLE [value, choice: INTEGER]
            - Лучшее из a и b, в соответствии с их значениями для игрока на уровне l.
        do
            if l \\ 2 = 1 then
                Result := (a.value < b.value)
            else
                Result := (a.value > b.value)
            end
        end
            

    Для определения худшего значения для каждого игрока вводятся две константы Max и Min (самое большое и самое малое значения).

    Функция minimax предполагает существование следующих методов в классе POSITION:

  • is-terminal указывает, что в данной позиции нет ходов, подлежащих исследованию;
  • в этом случае value дает значение оценочной функции (запрос value может иметь предусловие is-terminal);
  • для нетерминальной позиции пополняется список выборов choices, где каждый выбор представлен целым, задающим допустимый ход;
  • если i является таким выбором, moved(i) дает позицию, полученную в результате применения соответствующего хода к текущей позиции.
  • Простейший способ убедиться, что алгоритм завершается, состоит в ограничении глубины перебора, задав Limit – предельное число уровней. Вот почему is-terminal в том виде, как она задана, включает уровень l как аргумент. Она может быть записана в виде:

    is_terminal (l: INTEGER): BOOLEAN
            — Следует ли исследовать уровень l или остановиться на текущей позиции?
        do
            Result := (l = Limit) or choices.is_empty
        end
            

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

    Для выполнения мы вызываем minimax(initial, l), где initial задает начальную позицию игры. Уровень 1 указывает на то, что ход принадлежит Минни.

    Альфа-бета

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

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

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

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

    (рис 8.24) Пропуск полезных поддеревьев

    Мы находимся в процессе вычисления значения (максимума) для узла Ма1 и, как часть этой цели, вычисления значения (минимум) для узла Мi2. Анализ первого поддерева для Ма1 с корнем в Мi1 позволил определить частичный максимум для Ма1, равный 8 (на рисунке он отмечен знаком вопроса, сигнализирующим, что вычисления еще не завершены). Это означает для Макса, что 8 он себе обеспечил и на меньшее не согласится. При анализе поддерева с корнем Мi2 мы пришли к поддереву Ма2 (к листу в данном конкретном случае, но вывод применим к любому поддереву), где значение равно 6, так что в любом случае Минни не выберет в Мi2 значение, большее 6, а тогда ясно, что дальнейший анализ бесполезен, поскольку не окажет влияния на значение в вышестоящем узле Ма1. Так что, как только найдено значение 6 для Ма2, альфа-бета-стратегия прекратит анализ поддерева с корнем в Мi2.

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

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

    Время программирования!

    Добавление альфа-бета в минимаксный алгоритм

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

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

      alpha_beta ( p: POSITION; l: INTEGER; guarantee: INTEGER):
                                                               TUPLE [value, choice:
    INTEGER]
          — Оптимальная стратегия(value + choice) на уровне l, начиная с позиции p.
              — Нечетный уровень минимизирует, четный – максимизирует.
          local
              next: TUPLE [value, choice: INTEGER]
          do
              if p.is_terminal (l ) then
                  Result := [value: p.value; choice: 0]
              else
                  c := p.choices
                  from
                      Result := worst (l )
                      c.start
                  until c.after or better (guarantee, Result, l – 1) loop
                      next := minimax ( p.moved (c.item), l + 1), Result)
                      Result := better (next, Result, l )
                  end
              end
          end
            

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

    Так как better определена без предусловия, то это означает, что она может принимать и уровень 0, так что допустимо передавать ей l - 1. Мы могли бы передавать и l + 1, поскольку вариантом better(guarantee, Result, l-1) является better(Result, guarantee, l), благодаря симметрической природе стратегии.

    Рекурсивный вызов передает как "guarantee" на следующий уровень лучший Result, полученный на текущем уровне. Как следствие, альфа-бета останавливает обход узлов детей, когда срабатывает новый переключатель выхода better(guarantee, Result, l-1). Такая ситуация никогда не встретится для первого сыновнего узла, поскольку Result инициализирован значением worst.

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

    Страницы:
    (рис 8.1)

    Смеющаяся корова, изображенная на фирменном жетоне "Смеющаяся Корова", носит в качестве сережек фирменные жетоны, на которых, я подозреваю, но зрение не позволяет убедиться в правильности моего предположения, изображена корова с фирменными жетонами, на которых изображена … (надеюсь, идея понятна)У нас известен рекурсивный стишок, вошедший в поговорку: "У попа была собака, он ее любил. Она съела кусок мяса, он ее убил, и в землю закопал, и на могиле написал, что у попа была собака …" .

    Эта реклама, появившаяся в 1921 году, все еще хорошо работает, являясь примером структуры, определенной рекурсивно в следующем смысле:

    Рекурсивное определение

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

    "Рекурсия" – использование рекурсивного определения – широко применяется в программировании: она позволяет элегантно определять синтаксические структуры; мы также познакомимся с рекурсивно определенными структурами данных и рекурсивными процедурами.

    Мы будем использовать термин "рекурсивный" как сокращение "рекурсивно определенный" – рекурсивная грамматика, рекурсивная структура данных. Но это только соглашение, поскольку нельзя сказать, что понятие или структура сами по себе рекурсивны. Все, что мы знаем, – это то, что их можно рекурсивно описать в соответствии с вышеприведенным определением. Любое частичное понятие – в том числе структура, задающая Смеющуюся корову, – может быть определено как рекурсивно, так и без использования рекурсии.

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

    Рекурсия является прямой, если определение $$А$$ ссылается на экземпляр $$А$$, и косвенной, если для $$1 <= i < n$$ (для некоторого $$n >=2$$) определение каждого $$A_i$$ ссылается на $$A_{i+1}$$, а определение $$A_n$$ ссылается на $$A_1$$.

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

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

    8.1. Основные примеры

    В этот момент могут возникнуть вполне обоснованные сомнения – а есть ли смысл в рекурсивных определениях, как можно определить понятие через само понятие, "масло масляное"?

    Вы вправе сомневаться. Не все рекурсивные определения хороши для определения чего-либо. Когда вас просят дать характеристику кому-нибудь, а вы отвечаете: "Света? Ну, это просто Света, что еще можно сказать!" – то вы не много нового сказали. Так что следует позаботиться о критериях, гарантирующих полезность определения, даже если оно рекурсивно.

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

    Рекурсивные определения

    С введением универсальности мы получаем возможность определять тип как:

    Т1 класс, не являющийся универсальным, такой как INTEGER или STATION;
    Т2 родовое порождение в форме C[T], где Суниверсальный класс, а Т – тип.

    Это определение рекурсивно, оно просто означает, что, при наличии универсальных классов ARRAY и LIST, правильными классами также будут:

  • INTEGER, STATION и им подобные в соответствии с определением Т1;
  • согласно случаю Т2, прямые родовые порождения: ARRAY[INTEGER], LIST[STATION] и так далее.
  • Снова рекурсивно применяя Т2: ARRAY[LIST[INTEGER]], ARRAY[ARRAY [LIST[STATION]]] и так далее – родовые порождения любого уровня вложенности.

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

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

    Рассмотрим подмножество Eiffel с двумя видами операторов.

  • Присваивание, в его обычной форме variable:= expression, рассматриваемое здесь как терминальное и далее не уточняемое.
  • Условный оператор, имеющий только часть then (без else) для простоты.
  • Грамматика, определяющая язык, такова:

    $$\text{Оператор }\triangleq\text{ Присваивание | Условный}\\\text{Условный }\triangleq}\text{ if Условие then Оператор end }$$

    Для наших непосредственных целей будем полагать, что "Условие" является терминальным понятием. Это определение грамматики очевидно рекурсивно, поскольку определение "Оператор" включает "Условный", а его определение, в свою очередь, включает "Оператор". Но так как здесь присутствует нерекурсивная часть определения – "Присваивание", то в целом грамматика четко определяет правильные конструкции языка:

  • просто Присваивание;
  • Условный, содержащий Присваивание: if c then a end;
  • то же самое с произвольной степенью вложенности: if c1 then if c2 then a end end, if c1 then if c2 then if c3 then a end end end и так далее.
  • Рекурсивные грамматики на самом деле являются незаменимым средством для описания любого языка, который, как все распространенные языки, поддерживает вложенные структуры.

    Рекурсивно определенные структуры данных

    Класс STOP представляет понятие остановки для линии метро:

    class STOP create
    …
    feature
        next: STOP
                — Следующая остановка на той же линии.
            …Другие компоненты, здесь опущенные (см.6.5)
    end
            

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

    (рис 8.2) Вложенные поля (интерпретация не корректна)

    Такое попросту невозможно. Но STOP в любом случае является ссылочным типом, подобно любому классу, определенному как class X… без всяких других квалификаций, так что реальная картина выглядит так:

    (рис 8.3) Линия, связанная ссылками

    Рекурсия в таком определении структуры данных просто указывает, что каждый экземпляр класса потенциально содержит ссылку на экземпляр того же класса, "потенциально" – поскольку ссылка может иметь значение void, и тогда действие рекурсии прекращается, как для последней остановки на рисунке.

    В той же 6-й главе, где рассматривались линии метро, шла речь и о классе PERSON c атрибутом spouse типа PERSON.

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

    Рекурсивно определяемые алгоритмы и программы

    Известная последовательность чисел Фибоначчи обладает многими прекрасными свойствами и появляется во многих приложениях математики и естественных наук. Она имеет следующее определение:

    $$F_0=0\\F_1=1\\F_i=F_{i-1}+F_{i-2}\qquad \text{- Для}\;i>1$$

    Почувствуй историю

    Кролики Фибоначчи

    Леонардо Фибоначчи из Пизы (1170 – 1250) сыграл ключевую роль в знакомстве Запада с трудами индийских и арабских математиков. Он известен также и собственными исследованиями, лежащими в основании современной математики. Он сформулировал задачу, приводящую к его знаменитой последовательности (которая была известна еще индийским математикам):

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

    (рис 8.4) Фибоначчи

    Решение дает следующее рассуждение. Пары кроликов в месяце $$i$$ включают пары кроликов, уже существующие в предыдущем месяце (кролики не умирают); обозначим их число как $$F_{i-1}$$, плюс потомство, принесенное кроликами, жившими в месяце $$i-2$$ (кролики, появившиеся в месяце $$i-1$$, потомства не приносят). Это и дает приведенную выше формулу, создающую последовательность целых чисел 0, 1, 1, 2, 3, 5, 8 и так далее. Формула приводит к рекурсивной программе, вычисляющей $$F_n$$ для любого $$n$$:

    fibonacci (n: INTEGER): INTEGER
            — Элемент с индексом n в последовательности Фибоначчи.
        require
            non_negative: n >= 0
        do
            if n = 0 then
                Result := 0
            elseif n = 1 then
                Result := 1
            else
                Result := fibonacci(n – 1)+ fibonacci(n – 2)
            end
        end
            

    Время программирования!

    Рекурсивная версия Фибоначчи

    Напишите небольшую программную систему, включающую вышеприведенную рекурсивную функцию и печатающую ее результат. Протестируйте ее для небольших значений n, включая 12, как в оригинальном тексте Фибоначчи.

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

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

    Время программирования!

    Нерекурсивная версия Фибоначчи

    Можете ли вы, не заглядывая в дальнейший текст, написать функцию, вычисляющую N-е число Фибоначчи, используя цикл, а не рекурсию?

    Следующая функция дает тот же результат, что и рекурсивная версия. Проверьте это на нескольких значениях.

    fibonacci1 (n: INTEGER): INTEGER
                — Элемент с индексом n в последовательности Фибоначчи.
                — (Нерекурсивная версия.)
        require
            positive: n >=1
        local
            i, previous, second_previous: INTEGER
        do
            from
                i := 1 ; Result := 1
            invariant
                Result = fibonacci(i )
                previous = fibonacci (i – 1)
            until i = n loop
                i := i + 1
                second_previous := previous
                previous := Result
                Result := previous + second_previous
            variant
                n – i
            end
        end
            
    Для удобства в этой версии предполагается, что n >= 1, а не n >= 0. Благодаря правилам инициализации previous начинается с 0, что гарантирует начальное выполнение инварианта, так как $$F_0 = 0$$. Переменная second_previous обновляется на каждом шаге цикла и ей не нужно специальной инициализации.

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

    Оставим в стороне вкус и эффективность. Если бы мы рассматривали только такие примеры, то необходимость рекурсивных программ была бы далеко не очевидной. Нам необходимы примеры, в которых рекурсия обеспечила бесспорные преимущества, например, за счет того, что ее нерекурсивный аналог был бы значительно сложнее и труднее в понимании. Такие примеры существуют в большом количестве. Одним из них является очаровательная головоломка – "Ханойская башня". В этом примере сконцентрировано много полезных свойств рекурсии, и практически нет не относящихся к делу деталей.

    8.2. Ханойская башня

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

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

    (рис 8.5) Башня Ханоя (должна быть башней Бенареса?) из 9 дисков в начальном состоянии

    Несмотря на восточный орнамент, эта история является созданием французского математика Эдуарда Лукаса (подписывающегося как "N. Claus de Siam" – анаграмма "Lucas d'Amiens", с добавлением названия его родного города). На рынке в Таиланде (Сиам) я купил подобную башню, показанную на рисунке. Метки А, В, С – это мое добавление. Не буду распространяться на тему, почему я выбрал модель, сделанную из дерева, а не из бриллиантов, золота и меди. Но вполне законно спросить, почему на ней только 9 дисков, хотя портфель у меня был большой и мог бы вместить башню из 64 дисков.

    Время теста!

    Размер Ханойской башни

    Почему коммерчески доступные модели Ханойской башни имеют размеры много меньшие, чем 64 диска?

    (Подсказка: игра сопровождается бумажным свертком, на котором дается решение головоломки в форме последовательности ходов; А => C, A => B и т. д.)

    Чтобы ответить на этот вопрос, давайте оценим минимальное число ходов $$H_n$$ (ход – это перемещение диска с одного стержня на другой), требуемое для решения задачи. Если задача имеет решение, то нужно перенести n дисков со стержня А на стержень В, используя стержень С как промежуточный. При этом нужно соблюдать правило Будды, запрещающее класть больший диск на меньший. В оригинальной версии n = 64, для небольшой модели n = 9.

    Заметим, что для любой стратегии перемещения в некоторый момент необходимо перенести самый большой диск со стержня А на стержень В, а это возможно лишь при условии, что все остальные n -1 дисков в этом момент находятся на диске С в требуемом порядке:

    (рис 8.6) Промежуточное состояние

    Каково минимальное число ходов, необходимое для достижения этого промежуточного состояния? Необходимо перенести n -1 диск со стержня А на стержень С, не перемещая самый большой диск и используя стержень В как промежуточный. Ввиду симметричности задачи для этого потребуется $$H_{n-1}$$ ходов.

    Сразу же по достижении этого состояния можно перенести за один ход самый большой диск с А на В, после чего перенести n – 1 диск с С на В, используя А как промежуточный. Общее количество ходов:

    $$H_n=2*H_{n-1}+1$$

    Учитывая, что $$H_0$$ равно 0, получим:

    $$H_n=2^n-1$$

    Как следствие, нетрудно получить ответ на наш тест. Вспомним, что $$2^{10} = 1024$$, или примерно $$10^3$$, и получим, что число ходов $$2^{64}$$ примерно равно $$1,5 * 10^{19}$$.

    Год – это примерно 30 миллионов секунд. Если предположить, что священники Бенареса за секунду выполняют один ход – весьма неплохая скорость для переноса золотого диска, – всю работу они закончат за 500 миллиардов лет, что примерно в 30 раз превосходит оценочный возраст существования нашей вселенной. Даже компьютеру, выполняющему 100 миллионов ходов за секунду, при моделировании этой задачи для переноса дисков потребуется не одна тысяча лет.

    Вывод оценки $$H_n$$ был конструктивным, в том смысле, что он дает практическую стратегию для перемещения дисков.

  • Переместить $$n – 1$$ диск с А на С, используя В как промежуточное хранилище и руководствуясь правилами игры.
  • После этого стержень В будет пуст, а на А будет находиться только один самый большой диск, который и переносится за один ход с А на В. Этот ход соответствует всем правилам игры – перенос одного диска с вершины одного стержня на другой стержень, в вершине которого нет диска, размер коего меньше размера переносимого диска.
  • После этого переместить $$n – 1$$ диск с С на В, используя А как промежуточное хранилище, руководствуясь правилами игры. Самый большой диск, находящийся на В, не мешает переносу дисков меньшего размера.
  • Эта стратегия превращает число ходов $$H_n = 2^n – 1$$ из теоретического минимума в практически достижимую цель. Мы можем записать алгоритм в виде рекурсивной процедуры, входящей в класс NEEDLES:

    hanoi (n: INTEGER; source, target, other: CHARACTER)
            — Перенос n дисков из source на target,
            — используя other как промежуточное хранилище,
            — в соответствии с правилами игры "Ханойская башня"
        require
            non_negative: n >= 0
            different1: source /= target
            different2: target /= other
            different3: source /= other
        do
            if n > 0 then
                hanoi (n–1, source, other, target)
                move (source, target)
                hanoi (n–1, other, target, source)
            end
        end
            
    Обсуждение контрактов для рекурсивных методов добавит дополнительные предложения в предусловие и постусловие.

    По соглашению стержни представляются символами – 'A', 'B', 'C'. Другое соглашение, принятое в этой лекции (уже использованное в предыдущих примерах), состоит в подсветке рекурсивных ветвей кода, – процедура hanoi содержит два таких участка.

    Базисная операция move(source, target) перемещает один диск с вершины стержня source на вершину target. Предусловие устанавливает, что на source должен находиться, по крайней мере, один диск, а на target диска либо нет, либо диск в вершине имеет больший размер, чем перемещаемый диск. Запишем move как процедуру, выводящую на консоль инструкцию по перемещению диска:

    move (source, target: CHARACTER)
            — Инструкция по перемещению диска с source на target.
        do
            io.put_character (source)
            io.put_string (" to ")
            io.put_character (target)
            io.put_new_line
        end
            

    Время программирования!

    Ханойская башня

    Напишите систему с корневым классом NEEDLES, включающим процедуры hanoi и move. Проверьте их работоспособность на примерах.

    Например, выполните вызов

    hanoi (4, 'A', 'B', 'C')
                

    В результате должна быть напечатана последовательность из пятнадцати ($$2^4 – 1$$) ходов:

    A на C B на C B на A
    A на B A на C C на B
    C на B A на B A на C
    A на C C на B A на B
    B на A C на A C на B

    Эта последовательность ходов успешно переносит диски с А на B в полном соответствии с правилами игры.

    Один из способов анализа рекурсивного решения – процедуры hanoi – состоит в том, чтобы рассматривать перемещение n – 1 дисков, как один обобщенный ход. В этом случае мы могли бы начать перемещение с этого хода (с А на С):

    (рис 8.7) Начальный глобальный ход Фибоначчи

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

    Конечно, это фиктивное рассмотрение, поскольку правилами разрешается перенос только одного диска за один ход, но перемещая n – 1 диск, можно применять ту же технику, зная, что целевой стержень либо пуст, либо содержит диски большего размера. Так можно рекурсивно выполнять перемещения, уменьшая каждый раз значение n. Когда же n становится равным нулю, то делать ничего не нужно.

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

    8.3. Рекурсия как стратегия решения задач

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

    (рис 8.8)
  • Составной оператор (последовательность). Семантику последовательности можно выразить так: "Я знаю кого-то, кто может провести меня от текущей точки до В, и знаю того, кто может провести от В до С, так что можно просить их, работая последовательно, провести меня до С".
  • Условный оператор означает: "Я знаю кого-то, кто может решить задачу в одном случае, и знаю того, кто может решить ее для всех других возможных случаев, что позволяет мне просить их работать в зависимости от возникающей ситуации".
  • Решение, использующее цикл, можно выразить так: "Я не знаю, как добраться до С, но я знаю область I (инвариант), содержащую С, знаю кого-то, кто может привести меня в эту область (инициализация), и знаю того (тело цикла), кто может приблизить меня к С, при условии, что я нахожусь в области I. Расстояние до цели будет уменьшаться (вариант) таким образом, что за конечное число шагов я достигну требуемой мне окрестности С. Мне остается попросить моего первого друга привести меня в область I, а затем просить второго друга приближать меня к С, пока я не достигну цели".
  • Процедура, как способ решения задачи, означает: "Я знаю кого-то, кто может решать эту задачу в общем случае, так что мне нужно лишь сформулировать мою специальную задачу в его терминах и попросить решить задачу для меня".
  • Что можно сказать о рекурсивном решении? К кому нужно обращаться? Ответ – к себе! Возможно – несколько раз (как в случае с Ханойской башней и многих других)!

    Зачем обращаться к другим, если я доверяю себе (по крайней мере, я так думаю)?

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

    Такова идея рекурсии, рассматриваемая как стратегия решения сложной задачи. Она связана с некоторыми предыдущими стратегиями.

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

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

    Определение: бинарное дерево

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

  • единственный узел, называемый корнем бинарного дерева;
  • (рекурсивно) два бинарных дерева над G, называемых левым поддеревом и правым поддеревом.
  • Все это просто выразить в каркасе класса, не включающем методов:

    class BINARY_TREE [G] feature
    item: G
    left, right: BINARY_TREE[G]
    end
        

    Ссылка void указывает на пустое дерево. Проиллюстрируем бинарное дерево над целыми (рис 8.9).

    Форма "Ветвления" – это наиболее общий стиль представления бинарных деревьев, но не единственный: возможно представление в форме вложенности, которое для данного примера выглядит так (рис 8.10).

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

    (рис 8.9) Соглашение: Бинарное дерево (представленное "ветвлением") (рис 8.10) Бинарное дерево (вложенное представление)

    Если бинарное дерево не пусто, то оно всегда имеет корень и может не иметь поддеревьев, иметь только левое или только правое поддерево или иметь оба поддерева.

    Любой узел бинарного дерева сам рассматривается как бинарное дерево. Достаточно взглянуть на два последних рисунка. Узел, помеченный как 35, задает полное дерево, 23 – его левое поддерево, 54 – правое. Узел 78 задает корень дерева, которое является правым поддеревом правого поддерева полного дерева. Это позволяет говорить о правом и левом поддереве каждого узла. Эту ассоциацию можно сделать формальной, дав другой пример рекурсивного определения.

    Определение: дерево, ассоциированное с узлом

    Любой узел $$n$$ бинарного дерева $$B$$ определяет бинарное дерево $$B_n$$ следующим образом:
  • если $$n$$ – корень $$B$$, то $$B_n$$ – это просто $$B$$;
  • в противном случае из предыдущего определения следует, что $$n$$ – это одно из поддеревьев $$B$$. Если $$B^\prime$$ – это поддерево, то определим $$B_n$$ как $$B^\prime_n$$ (узел, связанный с $$n$$, рекурсивно, в соответствующем поддереве).
  • Рекурсивные процедуры над рекурсивными структурами данных

    Большинство методов класса, определяющего рекурсивную структуру класса, будут строиться рекурсивно с учетом рекурсивного определения данных. Простым примером является метод, подсчитывающий число узлов бинарного дерева. В пустом дереве число узлов равно нулю, для непустого дерева число узлов равно сумме трех значений: для корня и числа узлов соответственно левого и правого поддеревьев. Нетрудно записать это наблюдение в виде рекурсивной функции, входящей в класс BINARY_TREE.

    count: INTEGER
            — Число узлов.
        do
            Result := 1
            if left /= Void then Result := Result + left.count end
            if right /= Void then Result := Result + right.count end
        end
            

    Заметьте схожесть этой функции с процедурой Hanoi.

    Дети и родители

    Дети (непосредственные потомки) узла – сами узлы – являются корневыми узлами левого и правого поддеревьев:

    (рис 8.11) Бинарное дерево (представление "ветвлением")

    Если С – сыновний (дочерний) узел В, то В – родитель С. Более точно мы можем сказать, что В является "родителем" С, благодаря следующему результату:

    Теорема: "Единственный родитель"

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

    Теорема кажется очевидной, но мы докажем ее, что даст нам возможность познакомиться с рекурсивными доказательствами.

    Рекурсивные доказательства

    Рекурсивное доказательство теоремы о единственном родителе в значительной степени отражает рекурсивное определение бинарного дерева.

    Если бинарное дерево BT пусто, то теорема выполняется. В противном случае бинарное дерево имеет корень и два непересекающихся бинарных дерева, о которых мы можем предположить – "рекурсивная предпосылка", – что они оба удовлетворяют теореме. Это следует из определений "бинарного дерева", "ребенка" и "родителя", так что узел С может иметь родителя Р в ВТ только одним из трех возможных случаев:

    Р1 Р является корнем ВТ, а С является корнем либо левого, либо правого поддерева;
    Р2 они оба принадлежат левому поддереву, и Р является родителем С в этом поддереве;
    Р3 они оба принадлежат правому поддереву, и Р является родителем С в этом поддереве.

    В случае Р1 узел С по гипотезе рекурсивности, являясь корнем, не имеет родителей в своем поддереве, так что у него есть единственный родитель – корень всего дерева ВТ. В случаях Р2 и Р3, опять-таки по гипотезе рекурсивности, Р был единственным родителем С в соответствующем поддереве, и это остается верным и во всем дереве.

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

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

  • Для любой нерекурсивной ветви определения необходимо доказать свойство непосредственно (в примере нерекурсивной ветвью является пустое дерево).
  • Для рекурсивной ветви определяется новый экземпляр понятия в терминах существующих экземпляров. Для них можно предположить, что свойство выполняется (это и есть "гипотеза рекурсивности"), после чего требуется доказать, что при этих предположениях свойство выполняется.
  • Эта схема применима в целом для всех рекурсивно определяемых понятий. Мы увидим ее применение к рекурсивно определенной процедуре hanoi.

    Бинарные деревья выполнения

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

    (рис 8.12) Выполнение Hanoi, рассматриваемое как бинарное дерево

    Добавление операции move позволило бы реконструировать последовательность операций. Формально мы выполним это позднее.

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

    Еще о свойствах бинарных деревьях и о терминологии

    Как отмечалось, узел бинарного дерева может иметь:

  • как левого, так и правого сына, подобно узлу 35 из нашего примера;
  • только левого сына, подобно всем узлам левого поддерева, помеченным значениями 23, 18, 12;
  • только правого сына, подобно узлу 60;
  • не иметь сыновних узлов. Такие узлы называются листьями дерева; в примере листьями являются узлы с пометками 12, 41, 67 и 90.
  • (рис 8.13) Копия ранее приведенного дерева

    Определим восходящий путь в бинарном дереве как последовательность из нуля или более узлов, где любой узел последовательности является родителем предыдущего узла, если таковой имеется. В нашем примере узлы с метками 60, 78, 54 формируют восходящий путь. Справедливо следующее свойство, являющееся следствием теоремы о единственном родителе.

    Теорема: "Путь к корню"

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

    Доказательство. Рассмотрим произвольный узел С бинарного дерева. Построим восходящий путь, начинающийся в С. В соответствии с теоремой о единственном родителе такой путь определяется единственным образом. Если путь конечен, то заканчиваться он должен в корне дерева, поскольку любой другой узел имеет родителя, и следовательно, построение восходящего пути могло бы быть продолжено. Для завершения доказательства необходимо показать, что все пути конечны. Единственный способ построения бесконечного пути (учитывая, что число узлов бинарного дерева по определению конечно) состоит в том, что путь включает цикл. Если некоторый узел n встретится дважды, то он встретится сколь угодно много раз, так что бесконечный путь должен содержать подпоследовательность в форме n... n. Но это означает, что n появляется в своем собственном левом или правом поддереве, что невозможно по определению бинарных деревьев.

    Рассмотрение нисходящих путей позволяет установить следующий факт, как следствие предыдущей теоремы.

    Теорема: "Нисходящий путь"

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

    Весом бинарного дерева является максимальное число узлов среди всех нисходящих путей от корня к листьям дерева. В примере вес бинарного дерева равен 5, он достигается на пути, который ведет от корня к листу, помеченному как 67.

    Это понятие можно определить рекурсивно, следуя снова рекурсивной структуре определения. Вес пустого дерева равен нулю. Вес непустого дерева равен 1 плюс максимум (рекурсивно) из весов левого и правого поддеревьев. Мы можем добавить соответствующую функцию в класс BINARY_TREE:

    height: INTEGER
            — Максимальное число узлов нисходящего пути.
        local
            lh, rh: INTEGER
        do
            if left /= Void then lh := left.height end
            if right /= Void then rh := right.height end
            Result := 1 + lh.max (rh)
        end
            

    Здесь рекурсивное определение адаптируется к соглашению, принятому для класса, который рассматривает только непустые поддеревья. Отметьте опять-таки схожесть с hanoi.

    Операции над бинарными деревьями

    В классе BINARY_TREE пока определены только три компонента, все они являются запросами: item, left и right. Мы можем добавить процедуру создания:

        make (x: G)
                — Инициализация item значением x.
            do
                item := x
            ensure
                set: item = x
            end
    Добавим в класс команды, позволяющие изменять поддеревья, и значение в корне:
        add_left (x: G)
                — Создать левого сына со значением x..
            require
                no_left_child_behind: left = Void
            do
                create left.make (x)
            end
        add_right … Аналогично add_left…
        replace (x: G)
                — Установить значение корня равным x.
            do item := x end
            

    На практике удобно специфицировать replace как команду-присваиватель для соответствующего запроса, изменив объявление запроса следующим образом:

    item: G assign replace
            

    Это позволяет писать bt.item:= x вместо bt.item.replace(x).

    Обходы бинарного дерева

    Нет ничего удивительного в том, что, благодаря рекурсивной определенности, с бинарным деревом связываются многие рекурсивные методы. Функция height является одним из таких примеров. Приведем сейчас и другие. Предположим, что требуется напечатать все значения элементов, связанных с узлами дерева. Следующая процедура, добавленная в класс, выполняет эту работу:

    print_all
            — Печать значений всех узлов.
        do
            if left /= Void then print_all (left)end
            print (item)
            if right /= Void then print_all (right) end
        end
            
    Здесь используется процедура print (доступная всем класса через общего родителя ANY), которая печатает подходящее представление значения типа G – родового параметра в классе BINARY_TREE[G].

    Заметьте, структура print_all идентична структуре hanoi.

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

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

    Порядки обхода бинарного дерева

  • Inorder: обход левого поддерева, посетить корень, обход правого поддерева.
  • Preorder: посетить корень, обход левого поддерева, обход правого поддерева.
  • Postorder: обход левого поддерева, обход правого поддерева, посетить корень.
  • В этих определениях "посетить" означает выполнение операции над отдельным узлом, такой как print в процедуре print_all; "обход" означает либо рекурсивное применение алгоритма для поддерева, либо отсутствие каких-либо действий, если поддерево пусто.

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

    Процедура print_all является иллюстрацией инфиксного способа обхода дерева. Достаточно просто записываются и другие способы обхода, например, для постфиксного обхода тело процедуры post имеет вид:

    if left /= Void then post (left) end
    if right /= Void then post (right) end
    visit (item)
            

    Здесь visit является операцией над узлом, такой как print.

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

    В качестве еще одной иллюстрации инфиксного обхода рассмотрим снова бинарное дерево выполнения hanoi для n = 3, где узлы уровня 0 опущены, поскольку не представляют интересной информации в данном случае.

    (рис 8.14) Выполнение Hanoi в инфиксном порядке обхода

    Процедура hanoi является матерью всех инфиксных обходов: обход левого дерева, если оно есть, посещение корня, обход правого поддерева, если оно есть. При посещении каждого узла выполняется операция move(source, target). Инфиксный порядок дает нужную последовательность ходов: A B, A C, B C, A B, C A, C B, A B.

    Бинарные деревья поиска

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

    Множество G, над которым определено общее бинарное дерево, может быть любым множеством. Для бинарных деревьев поиска предполагается, что на множестве G задано полное отношение порядка, позволяющее сравнивать любые два элемента G, задавая булевское выражение a < b, такое, что истинно одно из трех отношений: a < b, b < a, a ∼ b. Примерами таких множеств могут служить INTEGER и REAL с отношением порядка <, но G может быть любым вполне упорядоченным множеством.

    Как обычно, мы пишем a < = b, когда либо a < b, либо a ∼ b. Аналогично, b > a, если a < b. Над вполне упорядоченным множеством можно определить бинарное дерево поиска.

    Определение: бинарное дерево поиска

    Бинарным деревом поиска над вполне упорядоченным множеством G называется бинарное дерево, такое, что для любого поддерева с корнем root со значением item, равным r, выполняются условия:
  • если у корня root есть левое поддерево, то для любого его узла со значением item, равным le, справедливо: le < r;
  • если у корня root есть правое поддерево, то для любого его узла со значением item, равным ri, справедливо: ri > r.
  • Значения в узлах левого поддерева меньше значения в корне, а в правом поддереве – больше. Это свойство применимо не только ко всему дереву в целом, но, рекурсивно, к любому поддереву, прямому или непрямому потомку корня. Это свойство будем называть инвариантом бинарного дерева поиска.

    (рис 8.15) Упражнение 5.11.3 Из этого определения следует, что все значения в узлах дерева должны быть различны. Мы для простоты будем использовать такое соглашение. Вполне возможно разрешать появление узлов с одинаковыми значениями; тогда отношение < и > в наших определениях пришлось бы заменить на <= и >=. В одном из упражнений придется адаптировать рассматриваемые алгоритмы на такой случай деревьев поиска.

    Дерево, показанное на рисунке, является бинарным деревом поиска.

    Процедура print_all, примененная к этому дереву, напечатает значения, упорядоченные по возрастанию, начиная с наименьшего значения 12.

    Время программирования!

    Печать упорядоченных значений

    Используя приведенные на данный момент процедуры, постройте дерево, показанное на рисунке, затем напечатайте значения в узлах, вызвав print_all. Убедитесь, что значения упорядочены.

    Производительность

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

  • O(1) вставку (если элементы сохраняются в порядке вставки);
  • O(n) для поиска.
  • Для бинарного дерева поиска обе операции имеют сложность O(log n), что намного лучше, чем O(n) для больших n (вспомните, что для нотации "О-большое" не имеет значения основание алгоритма). Проанализируем эффективность работы полного бинарного дерева, которое определяется тем, что для любого его узла оба поддерева, выходящие из узла, имеют одну и ту же высоту h:

    (рис 8.16) Полное бинарное дерево

    Нетрудно видеть, при помощи индукции по высоте h, что число узлов n в полном дереве высоты h равно $$2^h – 1$$. Как следствие, $$h = \log_2(n+1)$$. В полном дереве как поиск, так и вставка, используя приведенные ниже алгоритмы, выполняются за время O(log n), начиная работу в корне и следуя нисходящим путем к листьям дерева. В этом и состоит главная привлекательность бинарных деревьев поиска.

    Конечно, большинство практических бинарных деревьев не являются полными. Если не повезет при вставке, то производительность может быть столь же плоха, как и для последовательного списка – O(n). К этому добавляются потери памяти, связанные с необходимостью хранения двух ссылок для каждого узла, в то время как для списка достаточно одной ссылки. На следующем рисунке показаны варианты "плохих" деревьев поиска:

    (рис 8.17) Схемы бинарных деревьев поиска, являющиеся причиной поведения O(N)

    При случайном порядке вставки бинарные деревья поиска остаются достаточно близкими к полным деревьям с поведением близким к O(log n). Можно гарантировать выполнение операций поиска, вставки и удаления за время O(log n), если пользоваться специальными вариантами таких деревьев – АВЛ-деревьями или черно-красными деревьями, которые являются почти полными деревьями.

    Вставка, поиск, удаление

    Приведем рекурсивную функцию поиска в бинарном дереве поиска (эта и следующая функция добавлены в класс, реализующий бинарные деревья поиска):

    has (x: G): BOOLEAN
            — Есть ли x в каком-либо узле?
        require
            argument_exists: x /= Void
        do
            if x ~ item then
                Result := True
            elseif x < item then
                Result := (left /=Void) and then left.has (x)
            else — x > item
                Result := (right /= Void) and then right.has (x)
            end
        end
            

    Алгоритм имеет сложность O(h), где h – высота дерева, что означает O(log n) для полного или почти полного дерева.

    В этом варианте приводится простая нерекурсивная версия алгоритма:

    has1 (x: G): BOOLEAN
            — Есть ли x в каком-либо узле?
        require
            argument_exists: x /= Void
        local
            node: BINARY_TREE [G]
        do
            from
                node := Current
            until
                Result or node = Void
            invariant
        — x не находится в вышерасположенных узлах на нисходящем пути от корня
            loop
                if x < item then
                    node := left
                elseif x > item then
                    node := right
                else
                    Result := True
                end
            variant
                — (Высота дерева) – (Длина пути от корня к узлу)
            end
        end
            

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

    put (x: G)
            — Вставка x, если он не присутствует в дереве.
        require
            argument_exists: x /= Void
        do
            if x < item then
                if left = Void then
                    add_left (x)
                else
                    left.put (x)
                end
            elseif x > item then
                if right = Void then
                    add_right (x)
                else
                    right.put (x)
                end
            end
        end
            

    Отсутствие ветви else во внешнем if отражает наше решение не размещать дублирующую информацию. Как следствие, вызов put с уже присутствующим значением не имеет эффекта. Это корректное поведение ("не жучок, а свойство"), о чем и уведомляет заголовочный комментарий. Некоторые пользователи могут, однако, предпочитать другой API с предусловием, устанавливающим not has(x).

    Нерекурсивная версия остается в качестве упражнения.

    Возникает естественный вопрос: как написать процедуру удаления – remove(x:G)? Это не так просто, поскольку нельзя просто удалить узел, содержащий x, если только это не лист дерева. Удаление листа сводится к обнулению одной из ссылок – left или right. Удаление произвольного узла приводило бы к нарушению инварианта бинарного дерева поиска.

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

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

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

  • в левом поддереве – это элемент с максимальным значением (такой элемент является листом дерева или имеет только одну связь);
  • в правом поддереве – это элемент с минимальным значением.
  • Если перемещаемый элемент является листом дерева, то после перемещения соответствующий узел удаляется. Если элемент имеет одну ссылку, то ссылка родителя перемещаемого элемента связывается с потомком перемещаемого элемента, тем самым перемещаемый узел исключается из дерева. Инвариант при этих операциях сохраняется.

    (рис 8.18) Ранее построенное дерево

    Предположим, что удаляется корень дереваremove (35). Тогда в корень можно поместить либо элемент 23 (из левого поддерева), либо элемент 41 из правого поддерева.

    Подобно поиску и вставке процесс удаления должен выполняться за время O(h), где h – это высота дерева. Процедура удаления остается в качестве упражнения.

    Время программирования!

    Удаление в бинарном дереве поиска

    Напишите процедуру remove(x:G), которая удаляет элемент x, если он присутствует в дереве, сохраняя инвариант.

    8.5. Перебор с возвратами и альфа-бета

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

    Перебор с возвратами применим к задаче, если каждое ее потенциальное решение может рассматриваться как последовательность выборов.

    Бедственное положение застенчивого туриста

    При необходимости достижения некоторой цели путешествия в незнакомом городе как к последней надежде можно прибегнуть к перебору с возвратом. Скажем, Вы находитесь в точке А (главная станция Цюриха) и хотите добраться до точки В (между главными зданиями ЕТН и Университета Цюриха):

    (рис 8.19) Испытания и перебор с возвратами

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

    На каждом этапе вы проверяете отрезки улиц, начиная с севера и двигаясь по часовой стрелке. Первая попытка приводит в точку 1. Здесь вы понимаете, что это не отвечает вашей цели, так как далее все возможные пути ведут на запад, то есть это тупик и нужно возвращаться назад в исходную точку А. Далее вы испытываете следующий выбор, приводящий в 2. Здесь есть несколько возможностей, и вначале вы проверяете путь, ведущий в 3, который оказывается тупиком, так что приходится вернуться в точку 2.

    Если все "правильные" (не ведущие на запад) пути в 2 были исследованы и приводили к тупикам, то из 2 пришлось бы вернуться в точку А. Но в данной ситуации из 2 есть еще не исследованный выбор, ведущий в точку 4.

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

    find ( p: PAT H): PATH
                — Решение, если оно существует, начинающееся в P.
        require
                meaningful: p /= Void
        local
                c: LIST [CHOICE]
        do
            if p.is_solution then
                Result := p
            else
                c := p.choices
                from c.start until
                    (Result /= Void) or c.after
                loop
                    Result := find ( p + c)
                    c.forth
                end
            end
        end
            

    Здесь применяются следующие соглашения. Выборы на каждом шаге описываются типом CHOICE (во многих ситуациях для этой цели можно использовать тип INTEGER). Есть также тип PATH, но каждый путь path – это просто последовательность выборов, и p + c – это путь, полученный присоединением c к p. Мы идентифицируем решение с путем, приводящим к цели, так что find возвращает PATH. По соглашению, если find не находит решения, то возвращается void. Запрос is_solution позволяет выяснить, найдено ли решение. Список выборов – p.choices, доступных из p, является пустым списком, если p – это тупик.

    Для получения решения достаточно использовать find(p0), где p0 – начальный пустой путь.

    Как обычно, Result инициализируется значением Void. Если в вызове find(p) ни один из рекурсивных вызовов на возможных расширениях p + c не вырабатывает решения (в частности, если таких расширений вообще нет, поскольку p.choices пусто), то цикл завершится со значением c.after, и тогда find(p) вернет значение Void. Если это был начальный вызов find(p0), то процесс завершается, не достигнув положительного результата, в противном случае рекурсивно включается следующий вызов, пока не будет получен результат или не будут перебраны все возможности.

    Если один из вызовов находит, что p является решением, то p возвращается в качестве результата, и, подымаясь вверх по цепочке вызовов, результат возвращается, поскольку Result /= Void является условием выхода.

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

    Дальнейшее обсуждение также показывает, как улучшить эффективность данного алгоритма, удаляя излишние записи. Например, фактически нет необходимости передавать путь как явно заданный аргумент, освобождая пространство в стеке вызовов. Вместо этого p может быть атрибутом, если мы добавим p:= p+x перед рекурсивным вызовом и p:= p.head после него (где head вырабатывает копию последовательности с удаленным последним элементом). Мы разработаем общий каркас, позволяющий безопасно выполнять такую оптимизацию.

    Правильная организация перебора с возвратом

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

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

    (рис 8.20) Путь с циклом

    Пути [1, 2, 3, 4], [1, 2, 3, 4, 2, 3, 4], [1, 2, 3, 4, 2, 3, 4, 2, 3, 4] и так далее являются эквивалентными. Пример, демонстрирующий нахождение маршрута без циклов, состоял в важной рекомендации – "никогда не уезжайте на запад, молодой человек". Конечно, этот совет не может служить общей рекомендацией и связан лишь с конкретной проблемной ситуацией. Чтобы справиться с циклом, необходимо сохранять информацию о ранее посещенных точках и игнорировать любой путь, ведущий к такой точке.

    Перебор с возвратами и деревья

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

    (рис 8.21) Дерево путей при переборе с возвратами

    Мы можем представлять всю карту города подобным образом: узлы отражают местоположение указанных на карте точек, дуги представляют отрезки улиц, соединяющих точки. В результате получается граф. Граф может быть деревом только в том случае, если в нем нет циклов. Если граф не является деревом, то на его основе можно построить дерево, называемое остовным деревом графа, которое содержит все узлы графа и некоторые из его дуг. Для этого используются методы, упомянутые ранее, а также существует соглашение, позволяющее избежать цикла (никогда не ходите на запад) или построения путей из корня и исключающее любую дугу, которая ведет к ранее встречающемуся узлу. Вышеприведенное дерево является остовным деревом для части нашего примера, включающего узлы А, 1, 2, 3 и 4.

    Для такого представления нашей задачи в виде дерева:

  • решением является узел, удовлетворяющий заданному критерию (свойство, ранее названное is_solution, адаптированное для применения к узлам);
  • выполнение алгоритма сводится к префиксному обходу дерева (самый левый в глубину).
  • В нашем примере этот порядок приведет к посещению узлов А, 1, 2, 3, 4.

    Это соответствие указывает, что "Префиксный обход" и "Перебор с возвратами" основаны на одной и той же идее: всякий раз, когда мы рассматриваем возможный путь, мы разматываем все его возможные продолжения – все поддеревья узла, – прежде чем обращаемся к любому альтернативному выбору на уровне, представляющем непосредственных потомков узла. Например, если А на предыдущем рисунке будет иметь третьего потомка, то обход не будет его рассматривать, прежде чем не обойдет все поддеревья 2.

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

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

    Минимакс

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

  • в игре два игрока. Будем называть их Минни и Макс;
  • для оценки ситуации, сложившейся в игре, будем использовать оценочную функцию с числовыми значениями, спроектированную так, что отрицательные значения хороши для Минни, а положительные – для Макса.
  • Примитивной оценочной функцией на примере игры в шашки может служить следующая функция, построенная в предположении, что Макс играет шашками черного цвета (black): (mb – mw) + 3 * (kb – kw), где mb и mw – число простых шашек соответственно у Макса и Минни, а kb и kw – число дамок, каждая из которых оценивается в три раза выше, чем простая шашка. Конечно, в хорошей игровой программе оценочная функция является более сложной.

    Минни старается найти позицию, минимизирующую оценочную функцию, а Макс старается ее максимизировать.

    (рис 8.22) Дерево игры

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

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

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

    М1 Значение листа является результатом применения оценочной функции к соответствующей позиции игры.
    М2 Значение внутреннего узла, задающего ход Макса, является максимумом из значений детей этого узла.
    М3 Значение внутреннего узла, задающего ход Минни, является минимумом из значений детей этого узла.

    Значением игры в целом является значение, ассоциированное с корнем дерева. Для формирования стратегии следует в случаях М2 и М3 сохранять для каждого внутреннего узла не только значение, но и тот сыновний узел, обеспечивший оптимальную оценку. Вот иллюстрация стратегии, полученной в предположении, что значения в листьях вычислены с помощью некоторой оценочной функции:

    Можно видеть, что значение в каждом узле является минимумом (для уровней 1 и 3) или максимумом (на уровне 2) значений сыновей узла. Оптимальный ход для Минни, обеспечивающий значение -7, состоит в выборе хода С.

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

    (рис 8.23) Дерево игры с оценками

    Следующий алгоритм, являясь вариацией ранее приведенной общей схемы перебора, реализует эту идею. Он представлен функцией minimax, возвращающей пару целых: value – гарантированное значение из начальной позиции p, и choice – начальный выбор, приводящий к этому значению. Аргумент l задает уровень, на котором появляется позиция p в ходе игры. Первый ход из этой позиции, возвращаемый как часть результата, является ходом Минни (как на рисунке), если l нечетно, и ходом Макса, если l четно.

    minimax ( p: POSITION; l: INTEGER): TUPLE [value, choice: INTEGER]
        — Оптимальная стратегия(value + choice) на уровне l, начиная с позиции p.
        local
            next: TUPLE [value, choice: INTEGER]
        do
            if p.is_terminal (l ) then
                Result := [value: p.value; choice: 0]
            else
                c := p.choices
                from
                    Result := worst (l )
                    c.start
                until c.after loop
                    next := minimax (p.moved (c.item), l + 1)
                    Result := better (next, Result, l )
                end
            end
        end
            

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

    Дополнительные функции worst и better дают возможность переключаться между Максом и Минни – игрок минимизирует на каждом нечетном ходе и максимизирует на четном.

    worst (l: INTEGER): INTEGER
            — Худшее возможное значение для игрока на уровне l.
        do
            if l \\ 2 = 1 then Result := Max else Result := Min end
        end
    better (a, b: TUPLE [value, choice: INTEGER]; l: INTEGER):
                                                     TUPLE [value, choice: INTEGER]
            - Лучшее из a и b, в соответствии с их значениями для игрока на уровне l.
        do
            if l \\ 2 = 1 then
                Result := (a.value < b.value)
            else
                Result := (a.value > b.value)
            end
        end
            

    Для определения худшего значения для каждого игрока вводятся две константы Max и Min (самое большое и самое малое значения).

    Функция minimax предполагает существование следующих методов в классе POSITION:

  • is-terminal указывает, что в данной позиции нет ходов, подлежащих исследованию;
  • в этом случае value дает значение оценочной функции (запрос value может иметь предусловие is-terminal);
  • для нетерминальной позиции пополняется список выборов choices, где каждый выбор представлен целым, задающим допустимый ход;
  • если i является таким выбором, moved(i) дает позицию, полученную в результате применения соответствующего хода к текущей позиции.
  • Простейший способ убедиться, что алгоритм завершается, состоит в ограничении глубины перебора, задав Limit – предельное число уровней. Вот почему is-terminal в том виде, как она задана, включает уровень l как аргумент. Она может быть записана в виде:

    is_terminal (l: INTEGER): BOOLEAN
            — Следует ли исследовать уровень l или остановиться на текущей позиции?
        do
            Result := (l = Limit) or choices.is_empty
        end
            

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

    Для выполнения мы вызываем minimax(initial, l), где initial задает начальную позицию игры. Уровень 1 указывает на то, что ход принадлежит Минни.

    Альфа-бета

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

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

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

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

    (рис 8.24) Пропуск полезных поддеревьев

    Мы находимся в процессе вычисления значения (максимума) для узла Ма1 и, как часть этой цели, вычисления значения (минимум) для узла Мi2. Анализ первого поддерева для Ма1 с корнем в Мi1 позволил определить частичный максимум для Ма1, равный 8 (на рисунке он отмечен знаком вопроса, сигнализирующим, что вычисления еще не завершены). Это означает для Макса, что 8 он себе обеспечил и на меньшее не согласится. При анализе поддерева с корнем Мi2 мы пришли к поддереву Ма2 (к листу в данном конкретном случае, но вывод применим к любому поддереву), где значение равно 6, так что в любом случае Минни не выберет в Мi2 значение, большее 6, а тогда ясно, что дальнейший анализ бесполезен, поскольку не окажет влияния на значение в вышестоящем узле Ма1. Так что, как только найдено значение 6 для Ма2, альфа-бета-стратегия прекратит анализ поддерева с корнем в Мi2.

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

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

    Время программирования!

    Добавление альфа-бета в минимаксный алгоритм

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

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

      alpha_beta ( p: POSITION; l: INTEGER; guarantee: INTEGER):
                                                               TUPLE [value, choice:
    INTEGER]
          — Оптимальная стратегия(value + choice) на уровне l, начиная с позиции p.
              — Нечетный уровень минимизирует, четный – максимизирует.
          local
              next: TUPLE [value, choice: INTEGER]
          do
              if p.is_terminal (l ) then
                  Result := [value: p.value; choice: 0]
              else
                  c := p.choices
                  from
                      Result := worst (l )
                      c.start
                  until c.after or better (guarantee, Result, l – 1) loop
                      next := minimax ( p.moved (c.item), l + 1), Result)
                      Result := better (next, Result, l )
                  end
              end
          end
            

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

    Так как better определена без предусловия, то это означает, что она может принимать и уровень 0, так что допустимо передавать ей l - 1. Мы могли бы передавать и l + 1, поскольку вариантом better(guarantee, Result, l-1) является better(Result, guarantee, l), благодаря симметрической природе стратегии.

    Рекурсивный вызов передает как "guarantee" на следующий уровень лучший Result, полученный на текущем уровне. Как следствие, альфа-бета останавливает обход узлов детей, когда срабатывает новый переключатель выхода better(guarantee, Result, l-1). Такая ситуация никогда не встретится для первого сыновнего узла, поскольку Result инициализирован значением worst.

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

    Вернуться к учебному плану