Опубликован: 05.01.2015 | Доступ: свободный | Студентов: 1996 / 0 | Длительность: 63:16:00
Лекция 5:

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

Аннотация: Рассмотрены рекурсивные программы и деревья, а также алгоритмы для манипулирования ими.

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

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

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

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

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

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

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

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

N!=N\cdot(N -1)!, для }N\geq1, причём 0!=1

Это определение непосредственно соответствует рекурсивной функции C++ в программе 5.1.

Программа 5.1 эквивалентна простому циклу. Например, такое же вычисление выполняет следующий цикл for:

for ( t = 1, i = 1; i <= N; i++) t *= i;
        

Программа 5.1. Функция вычисления факториала (рекурсивная реализация)

Эта рекурсивная функция вычисляет функцию N!, используя стандартное рекурсивное определение. Она возвращает правильное значение, когда вызывается с неотрицательным и достаточно малым аргументом N, чтобы N! можно было представить типом int.

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

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

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

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

  • Программа вычисляет 0! (база индукции)
  • Если допустить, что программа вычисляет к! для к < N (индуктивный переход), то она вычисляет и N! .

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

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

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

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

Программа 5.2 - занятный пример, иллюстрирующий необходимость наличия индуктивного аргумента. Она представляет собой рекурсивную функцию, нарушающую правило, в соответствии с которым каждый рекурсивный вызов должен использовать меньшие значения аргументов, и поэтому для ее проверки нельзя использовать метод математической индукции. Действительно, неизвестно, завершается ли это вычисление для каждого значения N, поскольку значение N не имеет никаких пределов. Для меньших целочисленных значений, которые могут быть представлены значениями типа int, можно проверить, что программа прерывается (см. рис. 5.1 и упражнение 5.4), но для больших целочисленных значений (скажем, для 64 -разрядных слов), неизвестно, уходит ли эта программа в бесконечный цикл.

Программа 5.2. Сомнительная рекурсивная программа

Если аргумент N является нечетным, эта функция вызывает саму себя с аргументом, равным 3N+1. Если N является четным, она вызывает себя с аргументом, равным N/2. Невозможно доказать по индукции, что программа гарантированно завершится, поскольку не каждый рекурсивный вызов использует аргумент, меньший предыдущего.

int puzzle(int N)
  {
    if (N == 1) return 1;
    if (N % 2 == 0)
      return puzzle(N/2);
    else
    return puzzle(3*N+1);
  }
        

Программа 5.3 - компактная реализация алгоритма Евклида для отыскания наибольшего общего делителя для двух целых чисел. Алгоритм основывается на наблюдении, что наибольший общий делитель двух целых чисел х и у (х > у) совпадает с наибольшим общим делителем чисел у и х mod у (остатка от деления х на у). Число t делит и х и у тогда, и только тогда, когда оно делит и у, и х mod у (х по модулю у), поскольку х равно х mod у плюс число, кратное у. Рекурсивные вызовы, выполненные в примере выполнения этой программы, показаны на рис. 5.2. Для алгоритма Евклида глубина рекурсии зависит от арифметических свойств аргументов (она связана с ними логарифмической зависимостью).

Пример цепочки рекурсивных вызовов

Рис. 5.1. Пример цепочки рекурсивных вызовов

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

Пример применения алгоритма Эвклида

Рис. 5.2. Пример применения алгоритма Эвклида

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

Программа 5.3. Алгоритм Евклида

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

int gcd(int m, int n)
  {
    if (n == 0) return m;
    return gcd(n, m % n);
  }
        

Программа 5.4 - пример с несколькими рекурсивными вызовами. Это еще один способ вычисления выражения, который работает примерно так же, как и программа 4.2, но с префиксными (а не постфиксными) выражениями и с заменой явного стека рекурсией. В этой главе будут встречаться множество других примеров использования рекурсивных программ и эквивалентных программ, в которых задействуются стеки. И мы подробно изучим взаимосвязь между несколькими парами таких программ.

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

Конечно, строгое индуктивное доказательство правильности вычисления выражения программой 5.4 - гораздо более сложная задача, нежели доказательства для рассмотренных выше функций с целочисленными аргументами, и в этой книге встретятся еще более сложные рекурсивные программы и структуры данных. Поэтому мы не ставим перед собой идеалистичной задачи предоставления полных индуктивных доказательств правильности каждой создаваемой рекурсивной программы. В данном случае "знание " программы, как следует разделять операнды в соответствии с заданной операцией, вначале кажется непостижимым (возможно, потому, что не сразу понятно, как выполнить это разделение на верхнем уровне). Однако в действительности вычисление достаточно понятно (поскольку нужная ветвь программы при каждом вызове функции однозначно определяется первым символом выражения).

Дмитрий Уколов
Дмитрий Уколов
Михаил Новопашин
Михаил Новопашин
Владимир Хаванских
Владимир Хаванских
Россия, Москва, Высшая школа экономики
Вадим Рычков
Вадим Рычков
Россия, Москва, МГТУ Станкин