« Структуры данных и алгоритмы. Теория. Усовершенствованные алгоритмы сортировки | Структуры данных и алгоритмы. Теория. Динамические структуры данных » |
Структуры данных и алгоритмы. Теория. Рекурсия.
Когда мы разрабатываем программы, то выделяем составные части, по возможности максимально автономные и слабосвязанные между собой, даем им имена, говорим, что эти подпрограммы получают некоторый набор входных параметров (исходных данных) и говорим, что они могут возвращать некоторый результат работы. Мы даем таким фрагментам кода имена и называем их процедуры, функции. Любая подпрограмма может вызывать другую подпрограмму. Возникает вопрос: а может ли некоторая подпрограмма вызвать саму себя и что из этого получится. Подобный прием называется рекурсией, практически все языки программирования поддерживают данную концепцию. Так как существует класс задач, которые не имеют решения без использования рекурсии.Представьте, что перед человеком и позади него поставили зеркало. Если человек посмотрит в переднее зеркало то увидит в нем себя и разумеется зеркало сзади, в котором он увидит отражение снова себя и переднего зеркала, в котором он увидит снова отражение себя и отражение заднего зеркала в котором… И так далее. Часто для демонстрации возможностей рекурсии используют такие математические объекты как факториал, цепные дроби. В качестве примера далее приводится пример кода рекурсивного и итеративного вычисления факториала:
Как известно из математики факториал числа
N! = 1*2*3 … (N-1)*N = (N-1)!*N.При этом полагается значение 0! = 1.
Рекурсия | Итерация |
|
|
Уровень | Описание и состояние переменных |
0 | Пока функция не вызвана |
1 | Значение N=5, согласно заданному условию результатом значения 5!=5*4! Следовательно, нам необходимо сделать рекурсивный вызов с N=N-1. |
2 | Значение N=4, мы еще не дошли до ситуации когда не требуется применение рекурсии и следовательно должны снова вызвать себя уже со значение N=3 |
3 | N=3 |
4 | N=2 |
5 | N=1 |
6 | N=0, ну наконец-то, рекурсивный спуск был завершен мы знаем значение факториала при N=0, N!=1 и теперь начинаем подъем. |
5 | При подъеме уровни рекурсии постепенно уменьшаются пока мы не дойдем до 0. т.е. не вернемся в ту точку откуда был сделан первый рекурсивный вызов. Мы возвращаемся из функции и в качестве результата будет значение factorial = N*1. Здесь 1 – это результат который мы получили с уровня 5. значение N=1. Следовательно, возвращается из функции значение 1. |
4 | Вычисляем текущее значение factorial = 2*1 |
3 | Вычисляем текущее значение factorial = 3*2 |
2 | Вычисляем текущее значение factorial = 4*6 |
1 | Итак, мы вернулись к первому вызову рекурсивной функции таким образом мы завершаем подъем и как результат получаем factorial = 5 * 24 = 120 |
0 | Работа рекурсивной функции завершена: результатом 5!= 120 |
Когда стоит применять рекурсию? На этот вопрос есть очень простой ответ: по возможности никогда. Дело в том, что рекурсия всегда является с точки зрения вычислительных ресурсов слишком дорогой и в ряде ситуаций из-за этого просто не работает. В качестве демонстрации попробуйте запустить приведенные выше фрагменты кода для N = 1, 5 , 10 , 100 , 1000, 1000000. В то время как итеративный алгоритм работает и продолжает работать (правда, начиная с некоторого шага выдает неверный результат из-за переполнения переменной F, дело в том, что факториал – это очень быстрорастущая функция и вскоре ее значение превышает предельные 2 миллиарда для целых чисел в C++), а рекурсивная версия начинает завершаться аварийно с сообщением “stack overflow” – переполнение стека или сообщением о попытке доступа к чужой памяти. И это, не говоря о том, что время вычисления в рекурсивной версии заметно больше чем в итеративной. Тогда у вас должен возникнуть вопрос: а зачем нам нужен подобный инструмент? Да потому что рекурсия гораздо гибче, чем итерация, и я снова вам повторю, что есть такой класс задач, которые не имеют решения без использования рекурсии, или их решение слишком неэффективно.
Как проектировать рекурсивные алгоритмы? Да, это действительно сложный вопрос. Прежде всего, определитесь с самой задачей – какова ее природа. Если вам необходимо найти, например, максимальный элемент в массиве, то это обычный цикл с перебором всех элементов и отбором среди них (разумеется, что всякий итеративный алгоритм можно переписать с помощью рекурсии, и честно говоря, мы будем поступать так достаточно часто, но только ради тренировки, пока не набьем руку, пожалуйста, поступайте в будущем более эффективно). Для демонстрации я приведу далее примеры, той же задачи с поиском максимального элемента.
* | Рекурсивное решение | Итеративное решение |
Концепция | Что есть максимальный элемент в массиве? Пусть нам будет как-будто очень сложно ответить на этот вопрос и поэтому мы ищем упрощение. Да тяжело найти максимальный среди N элементов, гораздо проще найти его среди (N-1) элементов, а затем сравнить это значение и первый элемент и определить кто-же на самом деле наибольший. 1) Максимальный элемент в массиве из N элементов есть наибольший из первого и максимального элемента в хвосте массива (2..N) 2) (Это терминальная ветвь, она должна обязательно присутствовать). Очевидно что в массиве состоящем из одного элемента, этот элемент и будет максимальным. | 1) Предположим, что MAX – максимальный элемент массива – пусть это будет его первый элемент (или второй, в общем любой). 2) Переберем все оставшиеся элементы и для каждого из них проверим истинность первого предположения: может быть мы ошибались и на самом деле некоторый текущий элемент A[i] более подходит на роль максимального: A[i]> MAX, если данное условие выполняется то исправим нашу “ошибку”, положив: MAX = A [i] |
Реализация |
|
|
Замечания | Когда мы формулируем рекурсивный алгоритм то больше внимания мы уделяем описанию характеристик того что должно получится или как оно выглядит. Рекурсивный алгоритм сводится к самому себе, но всегда чуть более простому и близкому к моменту, когда для решения поставленной задачи рекурсия будет не нужна, решение будет тривиально и достижимо с помощью итерации или иного не рекурсивного приема (терминальная ветвь). | Здесь основной упор на определение четкой последовательности действий. Пройдя их от начала до конца, мы достигнем поставленной цели. |
Попробуйте в терминах рекурсии описать алгоритм, который служит для построения данных изображений. Если в ходе изучения C, Pascal вы не прошли графические примитивы то воспользуйтесь псевдо-функцией: линия (Хнач, Yнач, Xконечное, Yконечное).
А теперь я покажу ситуацию, в которое рекурсивное решение выглядит более предпочтительно (с точки зрения понятности и простоты формулировки решения).
Задача о ханойских башнях (алмазные башни, башни Брахмы) известна давно. Мне стоит сначала рассказать древнюю легенду о том, когда произойдет конец света: В далеком Индокитае бог Брахма построил храм, в котором поставил три алмазных стержня, на один из этих стержней он поместил 64 золотых диска, различного диаметра каждый последующий меньше чем предыдущий. Монахи этого храма уже несколько тысячелетий перекладывают эти диски так чтобы переместить их с первого стержня на второй. Считается, что когда все диски займут свое место, то произойдет конец света. К счастью, для этого потребуется очень много времени. При перекладывании дисков существуют два ограничения:
- за один раз можно переместить только один диск;
- нельзя поместить диск большего размера на диск меньшего размер.
В терминах рекурсии решение формулируется очень просто:
Что означает переложить пирамидку размера N со стержня A на стержень B используя в качестве вспомогательного стержень C? Согласитесь что упрощение этой задачи – выполнять перемещение не пирамиды размером N, а размером поменьше, например N-1. Тогда сам алгоритм можно сформулировать, например, так:
Алгоритм перекладывания дисков со стержня ”Источник” на стержень ”Приемник” используя “вспомогательный” стержень.
Начальное состояние: Стержень источник – n дисков расположенных согласно правилу “меньший на больший”. Стержень приемник и вспомогательный стержень пусты.
Алгоритм состоит из трех шагов:
1) Переложить со стержня “источника” пирамиду размером (n-1) дисков на “вспомогательный” стержень. Разумеется, что мы, помня первое ограничение, не имеем права взять за один раз все (n-1) дисков. Но ведь помните основное свойство рекурсии: сводим задачу к самой себе, но чуть более простой: когда мы поставили перед собой задачу перемещения пирамиды из (n-1) диска то у нас есть решение данной задачи, описываемое теми тремя пунктами, которые вы сейчас читаете.
(после этого состояние стержней: на стержне источнике у нас будет 1 диск - самый большой (размер n), на стержне вспомогательном пирамида из (n-1) дисков, разумеется правила не должны быть нарушены – меньший на большем. И на целевом стержне ничего не будет.
2) Перекладываем один диск с “источника” на “целевой” стержень – эта операция примитивна и не требует применения рекурсии как для первого шага.
3) Последний шаг – перемещение пирамиды состоящей из (n-1) диска со “вспомогательного” стержня (куда мы ее поместили на шаге 1) на стержень “приемник”. Здесь также мы выполним обращение алгоритма к самому себе, что значит переложить пирамиду размера (n-1), что является более простым, разумеется, работа с пирамидой размера (n-2).
Не забудьте, основное свойство рекурсии – терминальность. В ситуации, когда нам нужно выполнить перемещение пирамиды состоящей всего из одного диска – нем требуется только пункт # 2, и без всякой рекурсии
Согласитесь, что решение очень простое. А теперь маленькое задание:
Задание: согласно приведенной выше схеме необходимо реализовать программный алгоритм, результатом его работы должен быть список операций (текстовых сообщений с пояснением, что, откуда и куда необходимо перемещать) . В качестве исходных данных должно быть число N – количество дисков.
« Структуры данных и алгоритмы. Теория. Усовершенствованные алгоритмы сортировки | Структуры данных и алгоритмы. Теория. Динамические структуры данных » |