ФЕДЕРАЛЬНОЕ АГЕНТСТВО ПО ОБРАЗОВАНИЮ Федеральное государственное образовательное учреждение высшего профессионального об...
17 downloads
253 Views
543KB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
ФЕДЕРАЛЬНОЕ АГЕНТСТВО ПО ОБРАЗОВАНИЮ Федеральное государственное образовательное учреждение высшего профессионального образования «ЮЖНЫЙ ФЕДЕРАЛЬНЫЙ УНИВЕРСИТЕТ»
С.С. Михалкович
Основы программирования Файлы. Рекурсия
МЕТОДИЧЕСКИЕ УКАЗАНИЯ для студентов 1 курса факультета математики, механики и компьютерных наук
Ростов-на-Дону 2007 3
Аннотация Методические указания содержат лекции по темам «Файлы», «Рекурсия» курса «Основы программирования» для студентов направления «Информационные технологии» факультета математики, механики и компьютерных наук Методические указания разработаны кандидатом физико-математических наук, доцентом кафедры алгебры и дискретной математики Михалковичем С.С.
Печатается в соответствии с решением кафедры алгебры и дискретной математики факультета математики, механики и компьютерных наук ЮФУ, протокол № 3 от 13 ноября 2006 г. 4
1 Файлы Файл – это именованная область на диске, предназначенная для хранения информации. Основным достоинством файлов является возможность хранить данные между запусками программы. Кроме того, количество информации в файле может быть значительным, превышая объем оперативной памяти. Файлы подразделяются по двум признакам: по типу элементов и по способу доступа. По типу элементов различают текстовые и двоичные (бинарные) файлы. Текстовые файлы предназначены для хранения текста и состоят из строк разной длины, разделяемых специальными невидимыми символами перехода на новую строку. В операционной системе Windows разделителем строк в текстовых файлах служит пара символов с кодами 13 и 10, идущих подряд. В системах Unix и Linux разделителем строк является символ с кодом 10. Будем называть эти символы маркером конца строки и обозначать EOLN (от англ. End Of Line). Двоичные файлы предназначены для хранения произвольной информации. В языке Паскаль существует две разновидности двоичных файлов – типизированные и бестиповые. Типизированные файлы состоят из элементов одного типа, что позволяет работать с ними как с массивами, обращаясь к элементам по индексу. Бестиповые файлы предназначены для низкоуровневой работы с файлами, и в данной книге рассматриваться не будут. По способу доступа различают файлы с последовательным и произвольным доступом. В файлах с последовательным доступом мы имеем доступ только к текущему элементу. При совершении операции чтения или записи осуществляется переход к следующему элементу. Таким образом, нельзя получить доступ к элементу, не обратившись к предыдущим. Последовательный доступ отражает тот факт, что на диске данные файла хранятся последовательно и при обращении к ним головка жесткого диска обычно считывает или записывает порцию последовательно идущих данных. Заметим, что текстовые файлы имеют только последовательный доступ, поскольку их элементами являются строки, имеющие, вообще говоря, разную длину. В файлах с произвольным доступом данные считаются принадлежащими к одному типу, элементы которого имеют одинаковый размер. Поэтому мы можем обратиться к произвольному элементу по его номеру (как в массиве). Так, двоичные файлы в языке Паскаль имеют произвольный доступ. Перед началом работы с файлом его требуется открыть, а по окончании работы – закрыть. Если по окончании работы программы файл не закрыт, то часть записываемых в него данных может быть потеряна. С каждым открытым файлом связан так называемый файловый указатель – текущая позиция в файле, в которой будут производиться операции чтения/записи. При открытии файла файловый указатель обычно устанавливается на начало файла, а при каждой операции чтения/записи автоматически продвигается вперед на размер считанных/записанных данных. 5
В зависимости от типа файл может быть открыт только на чтение, только на запись или на чтение и запись одновременно. Поскольку текстовые файлы имеют только последовательный доступ, то они могут быть открыты либо только на чтение, либо только на запись. Двоичные же файлы имеют произвольный доступ и поэтому открываются на чтение и запись одновременно. Чтобы осуществлять доступ к файлу на диске, в программе описывается файловая переменная, которая связывается с конкретным файлом с помощью специальной процедуры. Затем файл открывается, и с ним производятся операции чтения/записи. По окончании работы файл следует закрыть. Файловые переменные представляют собой скрытые записи, хранящие различную информацию о файле. Эти переменные запрещено присваивать друг другу и передавать по значению как параметры подпрограмм. Файловая переменная, связанная с текстовым файлом, в языке Паскаль имеет тип text, для типизированного файла – тип file of тип компонент, для бестипового файла – тип file. Перечислим основные подпрограммы для работы с файлами, общие для типизированных и текстовых файлов. C каждой файловой переменной после открытия файла связывается некоторый буфер – область оперативной памяти, в которую данные из файла считываются опережающим образом. Наличие буфера ускоряет операции с файлом. Все операции чтения/записи осуществляются не с самим файлом, а с его буфером. Если мы считываем из буфера и данные в буфере заканчиваются, то осуществляется чтение в буфер следующей порции информации из файла. Если мы производим запись в буфер и он заканчивается или если мы перемещаемся в другое место файла с помощью операции произвольного доступа, то содержимое буфера записывается в файл, после чего в буфер считывается новое содержимое файла. Если файл был открыт на запись, то при закрытии файла содержимое буфера сбрасывается в этот файл. По этой причине если не закрыть файл, то последняя информация, записанная в него и содержащаяся в буфере, может не сохраниться на диске.
2.1 Основные процедуры и функции для работы с файлами Assign(f,name) – процедура, связывающая файловую переменную f с файлом на диске с именем name. Вызывается до открытия файла. Не требует наличия файла на диске. Reset(f) – процедура, открывающая существующий файл и устанавливающая файловый указатель на его начало. Если файла на диске нет, происходит ошибка времени выполнения. Для текстового файла открывает его на чтение. Rewrite(f) – процедура, создающая новый файл с именем, указанным в процедуре Assign, и открывающая его. Если файл уже есть, то он удаляется и создается пустой файл. Если файл по каким-либо причинам нельзя создать (например, имя файла содержит запрещенные символы), происходит ошибка времени выполнения. Для текстового файла открывает его на запись. 6
Close(f) – процедура, закрывающая открытый файл. Для неоткрытого файла ее вызов приводит к ошибке. Eof(f)– функция, возвращающая True, если достигнут конец файла, и False в противном случае. Если достигнут конец файла, то есть файловый указатель стоит непосредственно за последним элементом файла, считается, что файловый указатель стоит на специальном элементе EOF (Eof – end of file), называемом маркером конца файла. Для чтения/записи в текстовых и типизированных файлах используются стандартные процедуры Read и Write, при вызове которых в качестве первого параметра передается файловая переменная. Read(f,x1,x2,x3) – считывает данные из файла f в переменные x1, x2, x3. Для типизированных файлов тип переменных должен совпадать с типом элементов файла, для текстовых – переменные x1, x2, x3 могут иметь те же типы, что и в процедуре Read для ввода данных с клавиатуры (т.е. символьный, строковый или числовой). При этом данные, считываемые из текстового файла, должны храниться в том же виде, как при вводе с клавиатуры; в частности, при считывании числовых данных пропускаются лидирующие пробелы. Данные же, считываемые из типизированного файла, должны храниться в том же формате, в каком хранятся значения соответствующих типов в оперативной памяти. Write(f,x1,x2,x3) – записывает данные в файл f из переменных x1, x2, x3. Для типизированных файлов тип переменных должен совпадать с типом элементов файла, для текстовых вместо переменных можно использовать любые выражения символьного, строкового или числового типа. В текстовый файл данные записываются в текстовом виде, а в типизированный – в том виде, в котором хранятся значения этих типов в оперативной памяти. При выполнении Read и Write, файловый указатель передвигается вперед на количество обработанных компонент. Если файловый указатель стоит за концом файла, то вызов процедуры Write произведет запись в конец файла. Вызов же процедуры Read в этом случае для типизированных файлов приведет к ошибке (для текстовых – ошибки не происходит). Erase(f) – удалить файл (файл должен быть закрыт). Rename(f,newname) – переименовать файл (файл должен быть закрыт).
2.2 Подпрограммы для работы с файлами в Delphi Cледующие подпрограммы доступны в Delphi при подключении модуля SysUtils: FileExists(name) – функция, возвращающая True, если файл с именем name существует, и False в противном случае. DirectoryExists(name) – функция, возвращающая True, если каталог с именем name существует, и False в противном случае. 7
DeleteFile(name) – функция, удаляющая файл с именем name и возвращающая True, если файл удален, и False в противном случае. RemoveDir(name) – функция, удаляющая каталог с именем name и возвращающая True, если каталог удален, и False в противном случае. GetCurrentDir – функция, возвращающая имя текущего каталога. SetCurrentDir(name) – процедура, устанавливающая каталог с именем name текущим. CreateDir(name) – процедура, создающая каталог с именем name. Если в переменной s типа string хранится полное имя файла, то ExtractFilePath(s) – функция, возвращающая путь к файлу. ExtractFileName(s) – функция, возвращающая имя файла. ExtractFileExt(s) – функция, возвращающая расширение файла. Например, для s='D:\MyPrograms\a.pas' функция ExtractFilePath(s) возвращает 'D:\MyPrograms\', функция ExtractFileName(s) возвращает 'a.pas' и функция ExtractFileExt(s) возвращает '.pas'. Процедуры Assign, Rename и Close, а также тип text в Delphi имеют синонимы AssignFile, RenameFile, CloseFile и TextFile. Они были введены для устранения коллизии имен при совместном использовании с классами компонент, в которых имеются методы с именами Assign, Rename, Close, Text.
2.3 Простые примеры: чтение и запись Пример 1. Рассмотрим задачу записи всех целых чисел от 1 до 9 в файл. Приведем код для типизированных файлов: var f: file of integer; i: integer; begin Assign(f,'a.dat'); Rewrite(f); for i:=1 to 9 do write(f,i); Close(f); end; Вначале с файловой переменной f связывается файл на диске a.dat, затем файл создается и одновременно открывается, в цикле в него записываются числа, после чего файл закрывается. Для текстовых файлов (тип text) код аналогичен, однако для разделения чисел в тексте между ними выводятся пробелы: 8
Assign(f,'a.txt'); Rewrite(f); for i:=1 to 9 do if iy then begin Seek(f,j); write(f,y,x); end; end; end; Следует обратить внимание на уже отмеченный факт, что файловую переменную можно передавать в подпрограмму только по ссылке. Отметим также, что задачу можно решить и так: считать данные из файла в массив, отсортировать его, после чего записать полученный массив в файл. Очевидно, сортировка в оперативной памяти пройдет быстрее, и такой метод предпочтителен для небольших файлов. Однако если содержимое файла не помещается целиком в оперативную память, то сортировка непосредственно в файле – единственный путь.
2.6 Текстовые файлы Напомним, что текстовые файлы имеют тип text и состоят из строк разной длины, разделяемых маркером конца строки EOLN. Символ с кодом 26 воспринимается как признак конца текстового файла. Будем называть его маркером конца текстового файла и обозначать EOF. При обращении к файлу из программы считается, что в конце файла обязательно находится символ EOF, даже если он отсутствует в физическом файле. Например, файл, состоящий из четырех строк 14
var i: integer; begin end. следующим образом воспринимается Паскаль-программой: var i: integer;<EOLN>begin<EOLN><EOLN>end.<EOF> Подпрограммы работы с текстовыми файлами имеют ряд особенностей. Процедура Reset(f) открывает текстовый файл f только на чтение, а процедура Rewrite(f) – только на запись. Процедура Write в качестве параметров может содержать любые выражения целого, вещественного, символьного, строкового или логического типа; переменные тех же типов, кроме логического, могут присутствовать в операторе ввода Read. Вывод осуществляется в текстовом виде, при этом можно использовать форматы вывода вида :w (w – целое число, задающее ширину поля вывода) и для вывода вещественных значений – формат :w:d (d – целое число, задающее количество цифр после десятичной точки; если d=0, то десятичная точка не выводится). При вводе чисел данные должны быть отделены друг от друга пробелами, символами табуляции или символами перехода на новую строку. Если при вводе числа файловый указатель находится на таком символе-разделителе, он пропускает все символы-разделители до первого значащего символа (символа с кодом больше 32), после чего пытается прочитать число. При неудачной попытке и включенном контроле за ошибками ввода-вывода {$I+} в Delphi либо генерируется исключение (если подключен модуль SysUtils), либо происходит ошибка времени выполнения (если модуль SysUtils не подключен). В отличие от двоичных файлов, считывание за пределами текстового файла не приводит к ошибке (в частности, функция IOResult возвращает 0), файловый указатель при этом не перемещается. При чтении за концом файла в символьную переменную записывается символ конца файла (#26), в числовую – нулевое значение, в строковую – пустая строка. Как и при вводе с клавиатуры и выводе на экран, для текстовых файлов можно использовать процедуры Writeln и Readln. Процедура Writeln после вывода вставляет в текстовый файл маркер конца строки. В частности, Writeln(f) просто осуществляет переход на новую строку в файле f. Процедура Readln после ввода пропускает все символы до конца строки включительно (вместе с символом EOLN). В частности Readln(f), просто переставляет файловый указатель в начало новой строки. Далее перечислим стандартные подпрограммы, предназначенные для работы только с текстовыми файлами. 15
Append(f) – процедура, открывающая текстовый файл в режиме добавления. Файловый указатель при этом устанавливается на маркер конца файла. Если файл не существует, то происходит ошибка времени выполнения. Eoln(f) – функция, возвращающая True, если файловый указатель стоит на маркере конца строки. SeekEof(f) – функция, пропускающая все пробелы, символы табуляции и символы перехода на новую строку, после чего возвращающая то же, что и Eof(f). SeekEoln(f) – функция, пропускающая все пробелы и символы табуляции, после чего возвращающая то же, что и Eoln(f). Две последние функции обычно используются для ввода чисел. Для текстовых файлов подпрограммы Truncate, Seek, FileSize, FilePos не применяются: их использование приведет к ошибке компиляции. Рассмотрим несколько примеров, иллюстрирующих основные действия с текстовыми файлами. Пример 1. Дан текстовый файл a.txt, содержащий целые числа, разделенные любым количеством пробелов или символов перехода на новую строчку. Найти их количество и сумму. Приведем пример подобного файла: 5 3 <EOLN> 4<EOLN> 77 8 <EOF> Решение 1. Поэлементная обработка. Вместо Eof будем использовать функцию SeekEof, которая пропускает пробельные символы перед тем как проанализировать состояние конца файла. Это важно при считывании последнего числа: после считывания числа 8 файловый указатель будет находиться на пробеле после него; вызов SeekEof продвинет указатель на конец файла и вернет True. Для обработки возможных ошибок ввода окаймим также тело цикла блоком try с пустой секцией except: в случае ошибки в файле нечисловые символы будут пропущены. Далее приводится полный текст первого варианта решения. uses SysUtils; var f: text; s,c,x: integer; begin Assign(f,'a.txt'); Reset(f); s:=0; c:=0; while not SeekEof(f) do 16
try read(f,x); s:=s+x; c:=c+1; except end; Close(f); writeln(c,' ',s); end. Отметим, что если использовать функцию Eof вместо SeekEof, то при наличии пробелов в конце файла после считывания последнего числа Eof(f) вернет False и на следующей итерации цикла вызов read(f,x) запишет в переменную x число 0. В результате количество целых чисел c будет на 1 больше правильного. Решение 2. Посимвольная обработка. Вторая стратегия решения заключается в посимвольном считывании и накапливании в некоторой строковой переменной s текущей лексемы. Лексемой здесь мы будем называть любую последовательность символов, не являющихся разделителями. Если считанный символ является разделителем и лексема в строке s не пуста, то она считается сформированной и обрабатывается (преобразуется в число), после чего строка s обнуляется. Если же считанный символ не является разделителем, то он просто добавляется к строке s. Преобразование лексемы в число можно осуществить процедурой Val, что позволяет обойти использование механизма исключений. Далее приводится полный текст второго варианта решения. var f: text; ch: char; i: integer; begin Assign(f,'a.txt'); Reset(f); sum:=0; s:=''; repeat read(f,ch); if not (ch in [#0..#32]) then s:=s+ch else if s'' then begin Val(s,i,errcode); if errcode=0 then sum:=sum+i; 17
s:=''; end until Eof(f); Close(f); end. Заметим, что в данной задаче условие Eof(f) эквивалентно условию ch=#26. Заметим также, что разбиение на лексемы в данном варианте решения можно использовать в ряде других задач (связанных, например, с разбиением текста на слова). Пример 2. Преобразовать строки текстового файла, воспользовавшись для преобразования каждой строки пользовательской функцией Convert. Решение. В отличие от предыдущего примера, где мы пользовались поэлементным или посимвольным считыванием, будем считывать строку целиком, после чего ее обрабатывать. Такое решение естественно назвать построчной обработкой. Результат преобразования будем записывать во вспомогательный файл (имя ему дадим произвольно). В конце программы решение содержится во вспомогательном файле, поэтому удалим основной файл и дадим вспомогательному файлу имя основного: var f,f1: text; // f1-вспомогательный файл s: string; begin Assign(f,'a.txt'); Reset(f); Assign(f1,'$tmp.dat'); Rewrite(f1); while not Eof(f) do begin readln(f,s); s:=Convert(s); writeln(f1,s) end Close(f); Close(f1); Erase(f); Rename(f1,'a.txt'); end. Обратим внимание на ошибку, которую часто допускают начинающие в подобной ситуации. Если вместо процедуры readln в данной программе использовать процедуру read, то программа зациклится. Причина состоит в том, что оператор read считывает строку до символа-разделителя строк, устанавливая файловый указатель непосредственно на нем. Поэтому все вызовы read, начиная со 18
второго, будут возвращать пустую строку, а файловый указатель продвигаться не будет. Кроме того, после прерывания зациклившейся программы на диске останется большой временный файл $tmp.dat. В заключение заметим, что данный каркас решения можно использовать и для родственных задач. Например, если требуется вычислить количество и сумму чисел в файле (как в примере 1), то в данном решении достаточно заменить функцию Convert процедурой подсчета количества и суммы чисел в строке и изъять все строки, связанные со вспомогательным файлом.
2.7 Алгоритмы внешней сортировки Алгоритмы внешней сортировки – это алгоритмы, позволяющие сортировать данные во внешней памяти. Они ориентированы на последовательный перебор элементов и поэтому ранее широко применялись именно для сортировки файлов. Однако эти алгоритмы могут быть использованы для любых последовательно организованных данных: например, для массивов и списков. Кроме того, данные алгоритмы по скорости работы сравниваются с алгоритмом быстрой сортировки и потому являются одними из лучших. Реализация этих алгоритмов, однако, является достаточно громоздкой, поэтому в настоящем пункте приведем лишь сам алгоритм. Далее будем предполагать, что используемые файлы имеют только последовательный доступ. Сами файлы будем называть также лентами (по аналогии с лентой магнитофонной кассеты, доступ к которой может быть только последовательным). Трехленточная двухфазовая сортировка слиянием
Пусть исходные данные хранятся в файле C, и количество данных есть степень двойки. Приводимый ниже алгоритм использует на каждом шаге две фазы (разделение и слияние) и три ленты (файла). Отсюда его название – трехленточная двухфазовая сортировка слиянием. Разобьем данные на серии. Серией будем называть группу подряд идущих отсортированных элементов. I шаг. На первом шаге в каждой серии будет содержаться один элемент. C: 23 │ 12 │ 49 │ 96 │ 14 │ 69 │ 73 │ 28 I фаза. Разделение. Файлы A и B очищаются. Серии в файле C с нечетными номерами записываются в файл A, а с четными – в файл B. A: 23 │ 49 │ 14 │ 73 B: 12 │ 96 │ 69 │ 28
19
II фаза. Слияние. Файл C очищается. Серии элементов с одинаковыми номерами в файлах A и B сливаются в одну серию с помощью алгоритма слияния двух упорядоченных последовательностей в одну и дописываются в конец файла C. C: 12 23 │ 49 96 │ 14 69 │ 28 73 II шаг. В каждой серии – два элемента. I фаза. Разделение. A: 12 23 │ 14 69 B: 49 96 │ 28 73 II фаза. Слияние. C: 12 23 49 96 │ 14 28 69 73 III шаг. В каждой серии – четыре элемента. I фаза. Разделение. A: 12 23 49 96 B: 14 28 69 73 II фаза. Слияние. C: 12 14 23 28 49 69 73 96 Заметим, что в каждой фазе совершается один последовательный проход по каждому файлу. Отметим также, что если количество элементов в файле C равно n, то количество шагов алгоритма равно log 2 n . Поскольку на каждом шаге совершается два прохода по файлу C с n элементами, то количество операций при сортировке слиянием имеет временную сложность n ⋅ log 2 n (как и для быстрой сортировки). Четырехленточная однофазовая сортировка слиянием
В предыдущем алгоритме вторая фаза неэффективна – данные просто переписываются из одного файла в другой. От этой фазы можно избавиться, введя дополнительную ленту (файл). Для этого на фазе слияния следует записывать данные не в один файл, а попеременно в два, а затем использовать эти файлы в качестве исходных на следующем шаге. Пусть первоначально файл C содержит те же данные, что и в предыдущем пункте. C: 23 12 49 96 14 69 73 28 На первом шаге данные из файла C следует разбить на два файла, попеременно записывая их в файл A и файл B: A: 23 │ 49 │ 14 │ 73 B: 12 │ 96 │ 69 │ 28 Произведем слияние серий. Серии с нечетными номерами будем сливать в первый файл, с четными – во второй. 20
C: 12 23 │ 14 69 D: 49 96 │ 28 73 Повторим слияние серий, используя полученные файлы в качестве исходных. A: 12 23 49 96 B: 14 28 69 73 На последнем шаге полученные два файла следует слить в один, записав элементы второго файла в конец первого. C: 12 14 23 28 49 69 73 96 Отметим, что на каждом шаге по каждому файлу мы совершаем один последовательный проход. Естественное слияние
В двух предыдущих алгоритмах не учитывается упорядоченность элементов. Эти алгоритмы работают одинаковое время для любых последовательностей данных, в частности, если данные уже отсортированы. Предложим более совершенный алгоритм, в котором учитывается уже имеющаяся упорядоченность элементов. Такой алгоритм называется естественным слиянием. Пусть данные находятся в файле C. Разобьем их на серии упорядоченных элементов. C: 17 19 21 │ 13 57 67 │ 23 29 │ 11 59 61 │ 7 31 37 │ 2 43 67 │ 5 Применим алгоритм трехленточной двухфазовой сортировки, но в конце каждой фазы будем разбивать элементы на серии заново. I шаг. I фаза. Разделение. A: 17 19 21 │ 23 29 │ 7 31 37 │ 5 B: 13 57 67 │ 11 59 61 │ 2 43 67 Обратим внимание, что две первых серии в массиве A могут быть объединены в одну: A: 17 19 21 23 29 │ 7 31 37 │ 5 II фаза. Слияние. C: 13 17 19 21 23 29 57 67 │ 7 11 31 37 59 61 │ 2 5 43 67 II шаг. I фаза. A: 13 17 19 21 23 29 57 67 │ 2 5 43 67 B: 7 11 31 37 59 61 II фаза. C: 7 11 13 17 19 21 23 29 31 37 57 59 61 67 │ 2 5 43 67 21
III шаг. I фаза. A: 7 11 13 17 19 21 23 29 31 37 57 59 61 67 B: 2 5 43 67 II фаза. C: 2 5 7 11 13 17 19 21 23 29 31 37 43 57 59 61 67 67 Разбиение на серии не обязательно проводить специально – это можно делать в процессе слияния. Очевидно, серия заканчивается, если следующий элемент либо отсутствует, либо меньше предыдущего. Заметим также, что для сортировки нам потребовалось всего 3 шага, несмотря на то, что исходная последовательность состоит из 18 элементов, а не из 8, как в предыдущем пункте.
2 Рекурсия 2.1 Основные определения Рекурсия – это способ определения объекта, при котором он частично определяется через такой же объект. Определение, содержащее рекурсию, называется рекурсивным определением. Например, натуральное число – это либо 1, либо целое число, следующее за натуральным. Рекурсивные определения удобно записывать с помощью форм БэкусаНаура: Массив ::= | элемент Массив СписокПараметров ::= параметр | СписокПараметров ’,’ параметр Обратим внимание, что во всех предыдущих примерах объект при некотором условии определяется нерекурсивно. Это условие является признаком окончания рекурсии. Обратим также внимание, что рекурсивный объект может стоять как в начале рекурсивного определения (леворекурсивное определение), так и в конце (праворекурсивное определение). Рекурсивно можно определять не только объекты, но и математические функции. Функция называется рекурсивной, если ее значение при некоторых значениях аргументов определяется через значение этой функции при других значениях аргументов. Например: ⎧n ⋅ ( n − 1)! , åñëè n > 0, n! = ⎨ ⎩1, åñëè n = 0. ⎧a ⋅ a n −1 , åñëè n > 0, n a =⎨ ⎩1, åñëè n = 0.
22
В программировании рекурсия – это описание подпрограммы, содержащее прямой или косвенный вызов этой подпрограммой самой себя. Если подпрограмма р вызывает себя в своем теле, то такая рекурсия называется прямой, если же подпрограмма р вызывает подпрограмму q, которая прямо или косвенно вызывает р, то такая рекурсия называется косвенной. Рекурсией также называют процесс выполнения рекурсивной подпрограммы.
2.2 Простые примеры использования рекурсии Рассмотрим несколько примеров рекурсивных подпрограмм. Пример 1. procedure p; begin write(1); p; end; При вызове процедуры p произойдёт рекурсивное зацикливание: будет выводиться 1, после чего процедура снова будет вызывать себя и т.д. до бесконечности. Поскольку при каждом вызове процедуры на программный стек помещается ее запись активации, то в конце концов программный стек переполнится, и программа завершится с ошибкой. Таким образом, чтобы рекурсия завершалась, необходимо, чтобы рекурсивный вызов происходил не всегда, а лишь при выполнении некоторого условия. Пример 2. procedure p(n: integer); begin write(n,' '); if n>0 then p(n-1); end; При вызове p(5) вначале выведется 5, после чего вызовется p(4), выведется 4 и вызовется p(3) и т.д. до вызова p(0), который выведет 0 и, поскольку условие n>0 станет ложным, рекурсия завершится. Итак, в результате вызова p(5) на экран будет выведено 5 4 3 2 1 0 Таким образом, использование рекурсии позволяет заменить цикл. Отметим, что в простых примерах такая замена неэффективна, так как накладные расходы на рекурсивный вызов процедур (копирование параметров на программный стек, запоминание адреса возврата и т.д.) намного превосходят затраты на организацию цикла.
23
Пример 3. procedure p(n: integer); begin if n>0 then p(n-1); write(n,' '); end; При вызове p(5) вначале проверится условие n>0 и, поскольку оно истинно, вызовется p(4), затем p(3) и т.д. до p(0). Так как при вызове p(0) условие n>0 уже не выполняется, то осуществится вывод 0 и произойдет выход из вызова p(0) в вызов p(1) сразу после условного оператора. Далее осуществится вывод 1 и выход из вызова p(1). Процесс возврата из уже сделанных рекурсивных вызовов продолжится, пока не будет осуществлен выход из вызова p(5). В результате на экран будет выведено 0 1 2 3 4 5 Схема рекурсивных вызовов в примерах 2 и 3 изображена на рисунке:
Процесс рекурсивных вызовов называется рекурсивным спуском, а процесс возврата из них – рекурсивным возвратом. Глубиной рекурсии называется максимальное число вложенных рекурсивных вызовов (в примерах 2 и 3 глубина рекурсии равна 5). Число вложенных рекурсивных вызовов в данный момент выполнения программы называется текущим уровнем рекурсии. Отметим, что в примере 2 действия осуществляются на рекурсивном спуске, поэтому порядок действий – прямой (в порядке вызова рекурсивных процедур). В примере 3, напротив, действия осуществляются на рекурсивном возврате, т.е. откладываются до достижения наивысшей глубины рекурсии, после чего начинают выполняться в порядке, обратном порядку вызова. Реализуем рекурсивные функции из примеров в начале пункта. Пример 4. Вычисление n! Реализация повторяет рекурсивное определение n!, данное в начале главы: function Nfact(n: integer): integer; begin if n=0 then Result:=1 else Result:=n*Nfact(n-1); end; 24
Пример 5. Вычисление a n . Рассмотрим рекурсивное определение a n , которое является более эффективным, чем приведенное в начале главы, и, кроме того, учитывает случай n < 0 : ⎧1, если n = 0, ⎪ n/2 2 ⎪( a ) , если n > 0, n − четное, n a =⎨ n −1 ⎪a ⋅ a , если n > 0, n − нечетное, ⎪1 a n , если n < 0. ⎩
Реализация также не вызывает затруднений: function Power(a: real; n: integer): real; begin if n=0 then Result:=1 else if n 1.
Реализация повторяет рекурсивное определение: function MinA(const A: RArr; n: integer): real; begin if n=1 then Result:=A[n] else begin Result:=MinA(A,n-1); if A[n] 0, m = 0, ⎪ A( n − 1,A( n, m − 1)), если n > 0, m > 0. ⎩
В последних двух видах рекурсии рекурсивные вызовы образуют древовидную структуру.
2.5 Пример плохого использования рекурсии Пример 7. Пример плохого использования рекурсии. Числа Фибоначчи. Как известно, определение чисел Фибоначчи рекурсивно: ⎧1, n = 1,2, fn = ⎨ ⎩ f n −1 + f n −2 , n > 2.
Данное определение легко переводится в рекурсивную функцию: function Fib(n: integer): integer; begin if (n=1) or (n=2) then Result:=1 else Result:=Fib(n-1)+Fib(n-2); end;
28
Однако, такое «лобовое» решение крайне неэффективно, поскольку содержит большое количество повторяющихся вычислений. Изобразим дерево рекурсивных вызовов для вызова Fib(7):
Из рисунка видно, что, например, Fib(5) вычисляется дважды, Fib(4) – трижды, Fib(3) – 5 раз и т.д., то есть количество повторных вызовов представляет собой, по иронии судьбы, последовательность чисел Фибоначчи!
2.6 Более сложные примеры использования рекурсии Пример 8. Ханойские башни. Это – классический пример задачи, у которой имеется простое рекурсивное решение, а нерекурсивное решение является существенно более громоздким и дает лишь незначительный выигрыш в эффективности. Задача состоит в следующем. Имеется три штыря, на одном из которых лежит пирамида из n дисков, причем, меньшие диски лежат на больших (см. рисунок).
Требуется, перекладывая по одному диску на соседние штыри, переложить всю пирамиду с одного штыря на другой за наименьшее количество ходов. При этом запрещается класть больший диск на меньший. Сведем задачу с n дисками к задаче с n – 1 дисками (фактически, применим метод математической индукции по числу дисков). Пусть нам требуется переложить пирамиду из дисков со штыря с номером f (from) на штырь с номером t (to), используя штырь w (work) в качестве вспомогательного. Для этого необходимо вначале переложить пирамиду из n – 1 диска со штыря f на штырь w, используя штырь t в качестве вспомогательного, затем переместить оставшийся диск со штыря f на штырь t и, наконец, переложить пирамиду из n – 1 диска со штыря w на штырь t, используя штырь f в качестве вспомогательного. 29
procedure MoveTown(n,f,t,w: integer); begin if n=0 then exit; MoveTown(n-1,f,w,t); MoveDisk(f,t); MoveTown(n-1,w,t,f); end; Процедура MoveDisk, обеспечивающая перемещение одного диска, либо выдает текстовую информацию о том, с какого на какой диск было осуществлено перемещение, либо перемещает диск графически, меняя рисунок с изображениями штырей и дисков, подобный приведенному в начале данного примера. Нетрудно показать, что глубина рекурсии равна n, а количество перемещений дисков составляет 2 n − 1 . Пример 2. Сгенерировать все перестановки длины n. Заполним массив тождественной перестановкой. Будем менять первый элемент с каждым последующим, включая себя, после этого совершать те же действия с оставшейся частью массива и менять элементы обратно: for i:=1 to n do begin Swap(A[i],A[1]); ... Swap(A[i],A[1]); end; В итоге каждый элемент побывает на первом месте. Вместо многоточия вызовем аналогичную процедуру для элементов со второго по последний. Таким образом, мы реализовали следующую идею: получить все перестановки n элементов можно, поставив на первое место каждый элемент и дописав к нему все перестановки из оставшихся элементов. Очень важным шагом здесь является последний оператор цикла, возвращающий обратно элемент, переставленный на первом шаге цикла. Это гарантирует, что в начале каждой итерации цикла мы имеем дело с одной и той же перестановкой. Поскольку рекурсивные вызовы необходимо совершать для элементов с индексами от 2 по n, затем от 3 по n и т.д., в качестве параметра рекурсивной процедуры будем передавать k – начальный индекс переставляемого элемента (таким образом, перестановка будет совершаться для элементов с номерами от k до n). В результате наша рекурсивная процедура примет вид: procedure Permutation0(k: integer); var i: integer; begin for i:=k to n do begin 30
Swap(A[i],A[k]); ... Permutation0(k+1); Swap(A[i],A[k]); end; end; Очевидно, при k=n рекурсию надо завершать и выдавать полученную перестановку. Приведем итоговую процедуру, считая, что для вывода массива A длины n составлена процедура Print(A,n). procedure Permutation(n: integer); var A: IArr; procedure Permutation0(k: integer); var i: integer; begin for i:=k to n do begin Swap(A[i],A[k]); if k=n then Print(A,n) else Permutation0(k+1); Swap(A[i],A[k]); end; end; var i: integer; begin for i:=1 to n do A[i]:=i; Permutation0(1); end;
2.7 Быстрая сортировка Алгоритм быстрой сортировки – один из самых производительных и часто используемых алгоритмов сортировки. Основная идея алгоритма быстрой сортировки состоит в следующем. На первом шаге выбирается некоторый опорный элемент x, относительно которого переупорядочиваются остальные элементы массива. Переупорядочение осуществляется следующим образом: все элементы, меньшие x, переставляются перед x, а больше или равные x – после x. В итоге массив оказывается разбит на две части: элементы, меньшие x, и элементы, большие или равные x. Затем к первой и второй частям рекурсивно применяется алгоритм быстрой сортировки до тех пор, пока в каждой части не останется по одному элементу. Желательно выбрать элемент x таким, чтобы количество элементов в 31
первой и второй части было примерно одинаковым (в идеале отличалось бы на 1 – в этом случае элемент x называется медианой массива). Однако, поиск медианы массива – достаточно долгий алгоритм, поэтому обычно в качестве элемента x выбирают любой элемент массива, например, средний. Рассмотрим алгоритм подробнее. После выбора опорного элемента x введем два индекса i и j, указывающие соответственно на первый и последний элементы массива A. Увеличивая i, найдем элемент A[i], не меньший x. Затем, уменьшая j, найдем элемент A[j], не больший x. Такие элементы всегда найдутся: в крайнем случае, таким элементом будет сам элемент х. Поменяем элементы A[i] и A[j] местами, после чего увеличим i на 1 и уменьшим j на 1. Продолжим эти действия до тех пор, пока i не окажется больше j. В этот момент все элементы A[1]..A[j] меньше или равны x, а все элементы A[i]..A[n] - больше или равны x. Теперь применим наш алгоритм рекурсивно к подмассивам A[1]..A[j] и A[i]..A[n]. Процесс рекурсивных вызовов прекращается, если подмассив состоит из одного элемента. Рассмотрим конкретный пример. Пусть массив A размера n=9 имеет вид: 2 7 6 9 4 5 8 3 1 i j Выберем в качестве x средний элемент A[5]=4 и положим i=1, j=n. Увеличивая i, найдем элемент A[i]>=x (это элемент A[2]=7) и уменьшая j найдем элемент A[j]x do Dec(j); if ij; if l<j then QuickSort0(l,j); if i