Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Богоутдинов Дмитрий Гилманович, преподаватель, кафедра информатики и информационных технологий ДВГГУ ОЛИМПИАДНЫЕ ЗАДАЧИ ПО ПРОГРАММИРОВАНИЮ Пояснительная записка ................................................................................................................................................... 1 Тематическое планирование ........................................................................................................................................... 1 Текст пособия ................................................................................................................................................................... 2 Файлы и работа с ними ................................................................................................................................................. 2 Построение динамических структур данных. ........................................................................................................... 7 Понятие указателя, связанного списка, дерева, графа ........................................................................................... 7 Указатели. Работа с указателями ................................................................................................................................ 7 Выделение и освобождение динамической памяти .................................................................................................. 8 Реализация структур данных на ЭВМ ....................................................................................................................... 9 Линейные структуры..................................................................................................................................................... 9 Нелинейные структуры (деревья) ............................................................................................................................. 12 Метод динамического программирования .............................................................................................................. 14 Сортировка и ее виды. Применимость алгоритмов к различным входным данным ..................................... 15 Иерархические структуры данных. Деревья .......................................................................................................... 16 Пояснительная записка Программа данного курса рассчитана на учащихся, имеющих навыки работы с каким-либо языком программирования и желающих расширить свои знания в области решения задач по программированию. В данном курсе рассматриваются вопросы, связанные с проведением современных олимпиад по программированию. Здесь будут представлены задачи, ранее предлагавшиеся на олимпиадах по информатике разных уровней (от районных туров до всероссийских). Для успешного изучения курса необходимо, чтобы учащийся имел представление об алгоритмизации, процедурном программировании, знал основные операторы и управляющие конструкции языков программирования. По окончании курса слушатель должен уметь: определять тип предлагаемой задачи; выделять основные алгоритмические конструкции, необходимые для решения задачи; строить структуры данных, соответствующие условию. Программа рассчитана на подготовленного слушателя 9 – 11 классов общеобразовательных учреждений. Тематическое планирование № п/ Тема п 1. Обзор требований современных олимпиад по информатике. 2. Файлы и работа с ними 3. Построение динамических структур данных. Понятие указателя, связанного списка, дерева, графа 4. Метод динамического программирования. Обзор задач Хабаровск, 2007 Кол–во часов (теория) 1 Кол-во часов (практика) 1 2 1 2 1 2 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 5. 6. 7. Сортировка и ее виды. Применимость алгоритмов к различным входным данным Структуры данных и алгоритмы поиска для них. Поиск на деревьях Решение задач. Всего часов: 1 2 1 2 7 4 13 Текст пособия Несмотря на то, что на олимпиадах по информатике предлагаются самые разнообразные задачи, тем не менее, можно выделить наиболее часто встречающиеся разделы информатики, которые необходимо знать, чтобы успешно выступать на этих олимпиадах. К таким основным разделам можно отнести: динамическое программирование; алгоритмы перебора с возвратом; алгоритмы на графах; вычислительная геометрия; комбинаторные алгоритмы; моделирование. Далее мы рассмотрим некоторые вопросы, связанные с перечисленными разделами. Файлы и работа с ними Большинство олимпиадных задач по информатике предполагают, что исходные данные и результаты работы программы хранятся в некоторых файлах. Поэтому важно научиться правильно обращаться к файлам. Любой файл имеет три характерные особенности. Во-первых, у него есть имя, что дает возможность программе работать одновременно с несколькими файлами. Во-вторых, он содержит компоненты одного типа. Типом компонентов может быть любой тип Паскаля, кроме файлов. Иными словами, нельзя создать «файл файлов». В-третьих, длина вновь создаваемого файла никак не оговаривается при его объявлении и ограничивается только емкостью устройств внешней памяти. Файловый тип или переменную файлового типа можно задать одним из трех способов: Type <имя_ф_типа>= file of <тип_элементов>; <имя_ф_типа>= text; <имя_ф_типа>= file; Здесь <имя_ф_типа> – имя файлового типа (правильный идентификатор); File , of – зарезервированные слова (файл, из); <тип_элементов> – любой тип Паскаля, кроме файлов. Например: Type Product= record Name: string; Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Code: word; End; Text80= file of string[80]; Var F1: file of char; F2: text; F3: file; F4: Text80; F5: file of Product; В зависимости от способа объявления можно выделить три вида файлов: типизированные файлы (задаются предложением file of..); текстовые файлы (определяются типом text); нетипизированные файлы (определяются типом file). Следует помнить, что физические файлы на магнитных дисках и переменные файлового типа в программе на Паскале – объекты различные. Переменные файлового типа в Паскале могут соответствовать не только физическим файлам, но и логическим устройствам, связанным с вводом/выводом информации. Например, клавиатуре и экрану соответствуют файлы со стандартными именами Input, Output. Как известно, каждый тип данных, вообще говоря, определяет множество значений и множество операций над значениями этого типа. Однако над значениями файлового типа не определены какие-либо операции, в том числе операции отношения и присваивания, так что даже такое простое действие, как присваивание значения одной файловой переменной другой файловой переменной, имеющей тот же самый тип, запрещено. Все операции могут производиться лишь с элементами (компонентами) файлов. Естественно, что множество операций над компонентами файла определяется типом компонент. Переменные файлового типа используются в программе только в качестве параметров собственных и стандартных процедур и функций. Основные процедуры и функции для работы с файлами 1. До начала работы с файлами необходимо установить связь между файловой переменной и именем физического дискового файла: Assign (<файловая_переменная>, <имя_дискового_файла>) Следует помнить, что имя дискового файла при необходимости должно содержать путь доступа к этому файлу, включая имя дисковода. При этом имя дискового файла – строковая величина, т.е. должна быть заключена в апострофы. Например: Assign (chf, ‘G:\Home\ Student\ Lang\ Pascal\ primer.dat ‘); Если путь не указан, то программа будет искать файл в своем рабочем каталоге и по указанным путям в autoexec.bat. Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Вместо имени дискового файла можно указать имя логического устройства, каждое из которых имеет стандартное имя: CON – консоль, т.е. клавиатура-дисплей; PRN – принтер. Если к компьютеру подключено несколько принтеров, доступ к ним осуществляется по именам LPT1, LPT2, LPT3. Не разрешается связывать с одним физическим файлом более одной файловой переменной. 2. После окончания работы с файлами, они должны быть закрыты. Close (<список файловых переменных>); При выполнении этой процедуры закрываются соответствующие физические файлы и фиксируются сделанные изменения. Следует иметь в виду, что при выполнении процедуры close связь файловой переменной с именем дискового файла, установленная ранее процедурой assign, сохраняется, следовательно, файл можно повторно открыть без дополнительного использования процедуры assign. Работа с файлами заключается, в основном, в записи элементов в файл и считывании их из файла. Для удобства описания этих процедур введем понятие указателя, который определяет позицию доступа, т.е. ту позицию файла, которая доступна для чтения (в режиме чтения), либо для записи (в режиме записи). Позиция файла, следующая за последней компонентой файла (или первая позиция пустого файла) помечается специальным маркером, который отличается от любых компонент файла. Благодаря этому маркеру определяется конец файла. 3. Подготовка к записи в файл Rewrite (<имя_ф_переменной>); Процедура Rewrite (f) (где f – имя файловой переменной) устанавливает файл с именем f в начальное состояние режима записи, в результате чего указатель устанавливается на первую позицию файла. Если ранее в этот файл были записаны какие-либо элементы, то они становятся недоступными. Результат выполнения процедуры rewrite(f) выглядит следующим образом: F указатель Запись в файл Write (<имя_ф_переменной>, <список записи>); При выполнении процедуры write (f, x) в ту позицию, на которую показывает указатель, записывается очередная компонента, после чего указатель смещается на следующую позицию. Естественно, тип выражения х должен совпадать с типом компонент файла. Результат действия процедуры write(f, x) можно изобразить так: Состояние файла f до выполнения процедуры Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 F F1 F2 .... F3 указатель Состояние файла f после выполнения процедуры F F1 F2 F3 .... X указатель Для типизированных файлов выполняется следующее утверждение: если в списке записи перечислено несколько выражений, то они записываются в файл, начиная с первой доступной позиции, а указатель смещается на число позиций, равное числу записываемых выражений. 4. Подготовка файла к чтению Reset(<имя_ф_переменной>); Эта процедура ищет на диске уже существующий файл и переводит его в режим чтения, устанавливая указатель на первую позицию файла. Результат выполнения этой процедуры можно изобразить следующим образом: F F1 F2 … Fk указатель Если происходит попытка открыть для чтения не существующий еще на диске файл, то возникает ошибка ввода/вывода, и выполнение программы будет прервано. 5. Чтение из файла Read(<имя_ф_переменной>,<список переменных>); Рассмотрим результат действия процедуры read (f, v): Состояние файла f и переменной v до выполнения процедуры Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 F F1 F2 F3 X Fi . . . . Fk Fi . . . . Fk указатель V F ~ F1 F2 F3 X указатель V X Состояние файла f и переменной v после выполнения процедуры Для типизированных файлов при выполнении процедуры read() последовательно считывается, начиная с текущей позиции указателя, число компонент файла, соответствующее числу переменных в списке, а указатель смещается на это число позиций. В большинстве задач, в которых используются файлы, необходимо последовательно перебрать компоненты и произвести их обработку. В таком случае необходимо иметь возможность определять, указывает ли указатель на какую-то компоненту файла, или он уже вышел за пределы файла и указывает на маркер конца файла. 6. Функция определения достижения конца файла Eof (<имя_ф_переменной>); Название этой функции является сложносокращенным словом от end of file. Значение этой функции имеет значение true, если конец файла уже достигнут, т.е. указатель стоит на позиции, следующей за последней компонентой файла. В противном случае значение функции – false. 7. Изменение имени файла Rename(<имя_ф_переменной>, <новое_имя_файла>); Здесь новое_ имя_ файла – строковое выражение, содержащее новое имя файла, возможно с указанием пути доступа к нему. Перед выполнением этой процедуры необходимо закрыть файл, если он ранее был открыт. 8. Уничтожение файла Erase(<имя_ф_переменной>); Перед выполнением этой процедуры необходимо закрыть файл, если он ранее был открыт. 9. Уничтожение части файла от текущей позиции указателя до конца Truncate(<имя_ф_переменной>); 11.Файл может быть открыт для добавления записей в конец файла Append (<имя_ф_переменной>); Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Построение динамических структур данных. Понятие указателя, связанного списка, дерева, графа От выбора структуры данных зависит эффективность того или иного алгоритма, поэтому принципиальным вопросом становится вопрос о представлении структур данных в памяти ЭВМ. (В дальнейшем все описания структур данных и реализации алгоритмов приводятся на языке Pascal). Все динамические структуры данных можно представлять в памяти ЭВМ двумя способами: без использования динамического распределения памяти и с его использованием. Нам представляется, что человек знакомый с языком программирования Pascal без труда сможет описать все структуры без использования динамической памяти. Поэтому остановимся на варианте с использованием указателей. В языке Pascal есть возможность по ходу выполнения программы выделять и освобождать необходимую память для размещения в ней различных данных. Оперативная память при этом используется наиболее эффективно. Такая возможность связана с наличием в языке особых типов данных – указателей. Указатели. Работа с указателями Указатель представляет собой переменную целого типа, которая интерпретируется как адрес байта памяти, содержащий некоторый элемент данных. Этим элементом может быть переменная, константа, адрес другой переменной. Обычно, работая с программой, мы не интересуемся расположением в памяти переменных и констант. Мы просто можем обращаться к ним по именам, при этом Турбо Паскаль точно знает, где нужно искать эти переменные и константы. Часто в процессе программирования возникают ситуации, когда без применения указателей нельзя обойтись. Например: программа должна использовать данные, объем памяти, для хранения которых заранее неизвестен; программа использует динамические структуры данных. В Турбо Паскале указатели могут связываться с некоторым типом данных (типизированные указатели) или не связываться (нетипизированные указатели). Для описания динамических структур данных будем использовать типизированные указатели. Для объявления типизированного указателя обычно используется символ ^, который размещается непосредственно перед соответствующим типом данных, например: Type Point = ^Integer; Var P1:^Integer; Как известно, в Турбо Паскале существует правило, согласно которому любой идентификатор должен быть описан в программе до того, как впервые используется. Для указателей в данном случае сделано исключение, они могут ссылаться на еще не объявленный тип данных. (Это дает возможность организовать хранение данных в виде списка.) Для указателей допустимы только операции присваивания и сравнения. Указателю можно присваивать содержимое другого указателя того же типа, константу NIL (пустой указатель) или адрес объекта, определенный с помощью функции Addr (или оператора @), а также адрес, возвращаемый функцией Ptr. Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Выделение и освобождение динамической памяти В Турбо Паскале существует два основных метода работы с динамически распределяемой областью памяти: с помощью процедур New и Dispose; с помощью процедур GetMem и FreeMem. В данной работе не рассматриваются процедуры второй группы, поэтому останавливаться на них мы не будем. Процедура New. Процедура New (P), где P – переменная типа Pointer (указатель), создает новую динамическую переменную того типа, на который ссылается указатель, и устанавливает значение переменной P таким образом, чтобы оно указывало на эту новую динамическую переменную. Var P1: ^Integer; Begin New (P1); ... End. Процедура Dispose. Процедура Dispose (P), где P – переменная типа Pointer (указатель) уничтожает динамическую переменную на которую указывает указатель P. Следует отметить, что процедура Dispose (P) не изменяет значения указателя P, а только возвращает память в кучу. Однако следует помнить, повторное применение процедуры к свободному указателю приводит к сообщению об ошибке во время исполнения программы. Во избежание такой ошибки освободившийся указатель можно пометить зарезервированным словом Nil. ... Dispose (P); New (P); ... Кроме рассмотренных. Существует еще целый ряд функций, работающих с указателями (подробнее работа с указателями описана, например, в книге [1]), но для рассмотрения темы данной работы достаточно перечисленных функций. Представление структур данных в памяти ЭВМ можно разбить на два больших класса: 1. Линейные структуры, такие как связанные списки, стеки, очереди, деки. 2. Нелинейные структуры: графы и деревья. Все рассматриваемые структуры связные, т.е. для каждого элемента можно определить последующий и предыдущий. В связных структурах обычно используются однотипные элементы. Каждый элемент имеет две (а может и больше) различные части: информационную часть – та часть, которая содержит всю информацию о том или ином объекте; ссылку на соседний элемент (элементы) в конкретной иерархии элементов. Наиболее удобно для фиксации такой информации использовать тип-запись. Однако при задании такого типа элемента возникает определенная трудность, связанная с тем, что этот тип должен содержать тип-указатель на элемент структуры, который, в свою очередь, нельзя определить пока не определен тип элемента. При этом в какой бы последовательности ни определялись эти типы, Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 будет нарушено правило, гласящее, что использовать можно только те элементы, которые определены ранее. Для устранения этого препятствия в языке Pascal сделано исключение из данного правила по отношению к двум типам данных, один из которых является типом - указатель на объект второго типа. При работе с динамическими структурами данных выполняются следующие основные операции: добавление элемента структуры; исключение элемента структуры; поиск элементов структуры по какому-то признаку; причем в разных структурах эти операции выполняются по-разному. Реализация структур данных на ЭВМ В данной главе рассматриваются вопросы, связанные с выбором базового типа для представления конкретных динамических структур данных, а также приводятся описания этих структур на языке Pascal. Отдельно рассматриваются вопросы добавления и исключения элементов структур. Линейные структуры Представление любой структуры данных начинается с определения ее базового типа. Базовый тип выбирается таким образом, чтобы можно было организовать хранение данных и хранение ссылок на последующий и/или предыдущий элемент. При реализации списковых структур на языке программирования Паскаль за базовый тип принято брать тип-запись. Описание простейшего связанного списка будет выглядеть следующим образом: Листинг 1. Type Point = ^Node; {указатель на элемент Node} Node = Record {тип элемента структуры (Node)} Data: Char; Link: Point; End; Здесь Point является указателем на запись Node, состоящую из двух полей Data и Link, причем Data – это данные, которые будут храниться в данном узле списка символьного типа, а Link – это указатель на следующий элемент из списка (его тип – указатель). Нужно отметить, что данное описание подходит не только для связанных списков, но и для тех списковых структур, у которых есть одна ссылка на следующий элемент, т.е. стек, очередь, дек. Для таких структур как двухсвязный список в это описание базового типа следует добавить ссылку на предыдущий элемент, т.е.: Листинг 2. Type Point = ^Node; Node = Record Data: Char; Prev, Next: Point; {Prev –предыдущий, Next – следующий элемент} End; Теперь, когда мы описали структуру узлов, необходимо (в соответствии с определением структуры данных) задать операции над этими узлами. Рассмотрим операции добавления и исключения узлов на примере односвязного списка. Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Как и договорились, создадим связанный список при помощи описания, приведенного на листинге 1, и опишем операцию включения нового узла. Для того, чтобы присоединить новый узел, нам необходимо зарезервировать участок памяти для размещения новой динамической переменной. А затем установить соответствующие связи. Это можно сделать следующим образом (листинг 3). Листинг 3. В процедурах вставки и удаления узлов используются следующие параметры: x – данные, которые хранятся в поле data, PoE – указатель на конец списка. procedure Ins_Node(x: char; var PoE: point); var r: Point; begin new (r); {выделить память для нового узла} r^.data:=x; {присваивается значение полю data} r^.link:=PoE; {в поле связи заносится ссылка на следующий узел} PoE:=r; {указатель конца списка устанавливается на новый элемент} end; procedure Del_Node(var x: char; var PoE: point); var r:Point; begin if PoE^.Link<>Nil then begin r:=PoE^.link; x:=PoE^.data; dispose (PoE); PoE:=r; end else writeln (' Список пуст '); end; Нужно отметить, что процедура Ins_Node позволяет нам вставлять новый узел только последним в списке. Чаще встречается задача, в которой требуется вставить узел не нарушая упорядоченности списка. В этом случае данная процедура становится не пригодной для использования. Рассмотрим пример, в котором данный недостаток устраняется. Пример. Дана последовательность натуральных чисел, расположенных в порядке возрастания. Требуется вставить новый элемент таким образом, чтобы он не нарушал порядка в списке. (Приведем полное решение этой задачи). Листинг 4. Program Spisok; Type Point=^Node; Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Node=Record Data: Integer; Link: Point; End; Var procedure Ins_Node(x: char; var PoE: point); procedure Del_Node(var x: char; var PoE: point); Begin End. Реализация динамических линейных структур, отличных от списков, аналогична. В качестве примера приведем листинг модуля, содержащего процедуры и функции, реализующие основные операции со стеками (Push – добавить, Pop – выдать элемент из стека, Empty – функция проверки пустоты стека). Листинг 5. unit Stack; interface type Point=^noid; noid=record data: char; link: point; end; procedure Push(x: char; var PoE: point); procedure Pop(var x: char; var PoE: point); function Empty (PoE: point): boolean; implementation procedure Push; var r: Point; begin new (r); r^.data:=x; r^.link:=PoE; PoE:=r; end; procedure Pop; var r:Point; begin if not Empty (PoE) then begin r:=PoE^.link; x:=PoE^.data; dispose (PoE); PoE:=r; end Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 else writeln (' Стек пуст '); end; function Empty; begin if PoE=nil then Empty:=true else Empty:=false; end; end. Нелинейные структуры (деревья) Особенно интересными динамическими структурами данных являются упорядоченные двоичные деревья. Дерево состоит из узлов и ветвей, за которыми вновь следуют узлы. Узлы, в которые не входит ни каких ветвей, называются корневыми. Узлы, из которых не выходит ветвей, называют листьями. Тем самым можно строить иерархические отношения. Каждый узел состоит из части, содержащей данные (например, элемент Key, позволяющий однозначно идентифицировать узел), и указателя, показывающего на следующий узел. Мы рассмотрим наиболее часто используемые – бинарные деревья. Подобное дерево можно описать, например, таким образом: type BTree=^Node; Node= record Left: BTree; Key: integer; Right: BTree; end; Если для каждого узла выполняется правило, что все левые примыкающие к этому узлу узлы меньше (то есть имеют меньший ключ), а все правые узлы больше, то такое бинарное дерево называют упорядоченным. В силу своего свойства упорядоченности применение подобных деревьев для поиска исключительно удобно. Их также называют поисковыми деревьями или деревьями поиска. Как было описано выше, существует три способа просмотра всех узлов дерева: Preorder, Inorder, Endorder. Программирование всех трех способов требует знания рекурсии, так, например, для того чтобы просмотреть дерево в обратном порядке (Inorder), нам понадобится следующая процедура (листинг 6.). Листинг 6. procedure Inorder (b: BTree; t: integer); begin if b=Nil then writeln (' Дерево пусто ') else with b^ do begin t:=t+1; if left<>Nil then Inorder (left, t); writeln (' ':4*t, key:4); if right<>Nil then Inorder (right, t); t:=t-1; end; end; Данная процедура выводит на экран дерево в заданном порядке. Остальные порядки обхода строятся аналогично, но вывод их на экран в виде дерева не так легок, как только что приведенный способ. Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 В заключение рассказа о реализации деревьев, приведем листинг исходного кода модуля TREES.TPU, в котором собраны несколько функций для работы с деревьями (листинг 7.). Листинг 7. unit Trees; interface type BTree=^Node; Node= record Left: BTree; Key: integer; b: integer; Right: BTree; end; Procedure Inorder (b: BTree; t: integer); Procedure Del_tree (var b: Btree); Procedure Print_tree(b: BTree; t: integer); procedure Zapoln(var b: BTree; k: integer); implementation procedure Inorder; begin if b=Nil then writeln (' Tree is empty ') else with b^ do begin t:=t+1; if left<>Nil then Inorder (left, t); writeln (' ':4*t, key:4); if right<>Nil then Inorder (right, t); t:=t-1; end; end; procedure Del_tree; begin if b<>Nil then begin if b^.right<>Nil then Del_tree(b^.right); if b^.left<>Nil then Del_tree(b^.left); dispose (b); end; end; procedure Print_tree; var i: integer; begin if b<>Nil then with b^ do begin Print_tree (left, t+1); writeln (' ':t*4, key:4); Print_tree (right, t+1); Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 end; end; procedure Zapoln; begin if b=Nil then begin new (b); with b^ do begin left:=Nil; key:=k; right:=Nil; end; end else with b^ do begin if key<k then Zapoln(left, k); if key>k then Zapoln(right, k); end; end; end. Двоичные деревья являются хорошими структурами данных для получения упорядоченных множеств данных, поскольку позволяют хранить их отсортированными, а также для проведения поиска. В данной работе не будут описываться реализация алгоритмов поиска на языке программирования Pascal, т.к. их реализация представляется очень простой и легкодоступной даже для начинающего программиста. Метод динамического программирования Метод динамического программирования часто помогает эффективно решить задачу, переборный алгоритм для которой потребовал бы экспоненциального времени. Идея этого метода состоит в сведении исходной задачи к решению некоторых ее подзадач с меньшей размерностью и использовании табличной техники для сохранения уже найденных ответов. Решение подзадач при этом происходит в порядке возрастания их размерности — от меньшей к большей. Преимущество динамического программирования заключается в том, что любая подзадача решается один раз, ее решение сохраняется и никогда не вычисляется заново. В том случае, когда исходная задача определяется одним параметром N, определяющим размерность задачи, идея метода динамического программирования очень похожа на идею метода математической индукции. А именно, предположим, что мы уже знаем решение Fk задачи размерности k для всех k, меньших N, и хотим получить решение для k, равного N. Если нам удастся выразить это решение через уже известные, то тем самым будет получен алгоритм решения задачи для произвольного N. В частности, зная решения задач F0, F1, …, Fs, вычисляем в цикле решения Fs+1, Fs+2 и т.д. до искомого решения FN. Задачи, решение которых основано на использовании метода динамического программирования, зачастую требуют только правильного применения основной идеи этого метода. После этого нужна только аккуратная реализация алгоритма. Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Сложно выделить какие-то общие ошибки или проблемы, возникающие в таких задачах. Все же отметим, что часто «лобовое» применение принципа динамического программирования не укладывается в ограничения, например, по памяти (такие задачи требовательны к памяти, ведь мы экономим время работы, в том числе за счет хранения промежуточных результатов). Поэтому, возможно, потребуется аккуратная реализация хранения большого количества данных (динамическая память, структуры данных). Сортировка и ее виды. Применимость алгоритмов к различным входным данным Сортировка является одной из вспомогательных процедур, которая может привести к упрощению реализации решения. Рассмотрим некоторые виды сортировки массивов. Сортировка выбором. Дана последовательность чисел a1, a2, ..an. Требуется переставить элементы так, чтобы они были расположены по убыванию. Для этого в массиве, начиная с k-го, выбирается наибольший элемент и меняется местами с kм. Эта процедура выполняется для k= 1, 2, 3... n-1. При k=1 из всех элементов массива выбирается наибольший и меняется местами с k-м, т.е. с 1-м, таким образом, он занимает свое место. При k=2 из оставшихся (всех, кроме первого) выбирается максимальный элемент и меняется местами с k-м, т.е. со 2-м. Процедура эта повторяется, пока k не станет равно n-1. Тогда на последнем шаге из двух последних элементов выбирается максимальный, меняется местами с k-м, т.е. с (n1)-м. После этого массив является отсортированным. Сортировка методом «пузырька». Дана последовательность чисел a1, a2, ..an. Требуется переставить элементы так, чтобы они были расположены по возрастанию. Для этого из k элементов массива сравнивают каждую пару соседних элементов ai и ai+1. Если ai>ai+1, то их меняют местами. При первом прохождении массива самый большой элемент, как «пузырек», займет последнее место. Эта процедура выполняется для всех k = n, n-1, n-2, ...2. Шейкер-сортировка. Эта сортировка является усовершенствованной сортировкой методом «пузырька» (внимательно прочитайте предыдущее описание). Просмотр массива осуществляется последовательно в двух направлениях. Сначала массив проходят в «прямом» направлении, сравнивают каждую пару соседних элементов и, если ai>ai+1, то их меняют местами. При этом элемент с самым большим значением перемещается вверх и занимает последнее место. Затем массив просматривается в обратном направлении, начиная уже с (n1)-го элемента. Сравнивают каждую пару соседних элементов и, если ai<ai-1, то их меняют местами. При этом элемент с самым маленьким значением перемещается вниз и занимает первое место. Затем этот процесс повторяется, начиная со второго элемента и т.д. Сортировка вставками. Дана последовательность чисел a1, a2, ..an. Требуется переставить элементы так, чтобы массив стал упорядоченным. Пусть k элементов массива уже являются упорядоченными. Берется следующее число ak+1 и вставляется в последовательность так, чтобы оказались упорядоченными уже k+1 элемент. Выполняем эту процедуру для k=1, 2, 3, ..., n-1. Иными словами, при k=1 считаем, что первый элемент стоит на своем месте. Берем (k+1)-й, т.е. 2-й элемент, и переставляем его так, чтобы по порядку стояли уже первые два элемента. При Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 k=2 считаем, что два элемента стоят на своем месте. Берем (k+1)-й, т.е. 3-й элемент, и переставляем его так, чтобы первые три элемента были упорядочены. Повторяем эту процедуру, пока k не станет равно n-1. Тогда берем (k+1)-й, т.е. последний элемент, и переставляем его так, чтобы он занял свое место среди предыдущих уже упорядоченных элементов. После этого массив отсортирован. Сортировка Шелла. Дана последовательность чисел a1, a2, ..an. Требуется переставить элементы так, чтобы они были расположены по неубыванию. Делается это следующим образом: сравниваются два соседних элемента ai и ai+1. Если aiai+1, то продвигаются на один элемент вперед, а если ai>ai+1, то их меняют местами и сдвигаются на один элемент назад. Сортировка подсчетом. Дана последовательность чисел a1, a2, ..an. Требуется переставить элементы так, чтобы они были расположены по возрастанию. Выходной массив заполняется фиксированными значениями, заведомо отличными от элементов исходного массива, например, равными –1. Затем для каждого элемента исходного массива определяется его место в выходном массиве путем подсчета количества элементов строго меньших данного. Естественно, что все равные элементы попадают на одну позицию в выходном массиве, за которой будет следовать ряд значений –1. После того, как позиции всех элементов исходного массива определены, и они размещены в выходном массиве, все оставшиеся в выходном массиве элементы равные –1 заполняются копией предыдущего значения. Иерархические структуры данных. Деревья Деревья представляют собой иерархическую структуру некой совокупности элементов. Деревья – это одна из наиболее важных нелинейных структур, которые встречаются при работе с компьютерными алгоритмами, их используют при анализе электрических цепей, математических формул, для организации информации в системах управления базами данных и для представления синтаксических структур в компиляторах. Дерево – это совокупность элементов, называемых узлами (один из которых определен как корень), и отношений («родительских»), образующих иерархическую структуру узлов. Вообще говоря, древовидная структура задает для элементов дерева (узлов) отношение «ветвления», которое во многом напоминает строение обычного дерева. Формально дерево (tree) определяется как конечное множество T одного или более узлов со следующими свойствами: Существует один выделенный узел, а именно – корень (root) данного дерева; Остальные узлы (за исключением корня) распределены среди m≥0 непересекающихся множеств T1, T2, …. Tm , и каждое из этих множеств, в свою очередь, является деревом; деревья T1, T2, ... Tm называются поддеревьями данного корня. Как видите, это определение является рекурсивным: дерево определено на основе понятия дерево. Рекурсивный характер деревьев можно наблюдать и в природе, например, почки молодых деревьев растут и со временем превращаются в ветви (поддеревья), на которых снова появляются почки, которые также растут и со временем превращаются в ветви (поддеревья) и т.д. Можно привести еще одно формальное определение дерева: Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Один узел является деревом. Этот же узел также является корнем этого дерева. Пусть n – это узел, а T1, T2, ... Tm – деревья с корнями n1, n2, … nm соответственно. Можно построить новое дерево, сделав n родителем узлов n1, n2, … nm . В этом дереве n будет корнем, а T1, T2, ... Tm – поддеревьями этого корня. Узлы n1, n2, … nm называются сыновьями узла n. Из приведенных выше определений следует, что каждый узел дерева является корнем некоторого поддерева данного дерева. Количество поддеревьев узла называется степенью этого узла. Узел с нулевой степенью называется концевым узлом или листом. Неконцевой узел называется узлом ветвления. Каждый узел имеет уровень, который определяется следующим образом: уровень корня дерева равен нулю, а уровень любого другого узла на единицу выше, чем уровень корня ближайшего поддерева, содержащего данный узел. Рассмотрим эти понятия на примере дерева с семью узлами (см. рисунок). Узлы часто изображаются буквами, они так же, как и элементы списков могут быть элементами любого типа. D E B C A G Уровень 3 F Уровень 2 Уровень 1 Уровень 0 G D F E C B A Узел A является корнем, который имеет два поддерева {B} и {C, D, E, F, G}. Корнем дерева{C, D, E, F, G} является узел C. Уровень узла C равен 1 по отношению ко всему дереву. Он имеет три поддерева {D}, {E}и {F, G}, поэтому степень узла C равна 3. Концевыми узлами (листьями) являются узлы B, D, E, G. Путем из узла n1 в узел nk называется последовательность узлов n1, n2, … nk, где для всех i, 1≤i≤k, узел ni является родителем узла ni+1. Длиной пути называется число, на единицу меньшее числа узлов, составляющего этот путь. Таким образом, путем нулевой длины будет путь из любого узла к самому себе. Например, на рисунке путем длины 2 будет путь от узла A к узлу F или от узла C к узлу G. Если существует путь из узла a в узел b, то в этом случае узел a называется предком узла b, а узел b – потомком узла a. Отметим, что любой узел одновременно является предком и потомком самого себя. Например, на рисунке предками узла G будут сам узел G и узлы F, C и A. Потомками узла C будут являться сам узел C и узлы D, T, F, G. В дереве только корень не имеет предков, а листья не имеют потомков. Предок узла, имеющий уровень на единицу меньше уровня самого узла, называется родителем. Потомки узла, уровень которых на единицу больше относительно самого узла, называются сыновьями или детьми. Узлы, являющиеся сыновьями одного родителя, принято называть братьями. Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Высотой узла дерева называется длина самого длинного пути от этого узла до какого-либо листа. Глубина узла определяется как длина пути от корня до этого узла. Лес – это множество (обычно упорядоченное), содержащее несколько непересекающихся деревьев. Узлы дерева при условии исключения корня образуют лес. Порядок узлов Если в определении дерева имеет значение порядок поддеревьев T1, T2, ... Tm, то дерево является упорядоченным. Сыновья узла обычно упорядочиваются слева A A направо. Поэтому деревья, приведенные на рисунке, являются различными. B C C B Если порядок сыновей игнорируется, то такое дерево называется неупорядоченным. Далее будем неявно предполагать, что все рассматриваемые деревья являются упорядоченными, если явно не указано обратное. Обходы дерева Существует несколько способов обхода всех узлов дерева. Три наиболее часто используемых способа обхода называются прямой, обратный и симметричный обходы. Все три способа можно рекурсивно определить следующим образом: Если дерево T является нулевым деревом, то в список обхода записывается пустая строка; Если дерево T состоит из одного узла, то в список обхода записывается этот узел; Пусть дерево T имеет корень n и поддеревья T1, T2, ... Tm, как показано на рисунке n T1 T2 ….. Tm Тогда для различных способов обхода имеем следующее: 1. Прямой обход. Сначала посещается корень n, затем в прямом порядке узлы поддерева T1, далее все узлы поддерева T2 и т.д. Последними посещаются в прямом порядке узлы поддерева Tm. 2. Обратный обход. Сначала посещаются в обратном порядке все узлы поддерева T1, затем в обратном порядке узлы поддеревьев T2 … Tm, последним посещается корень n. 3. Симметричный обход. Сначала в симметричном порядке посещаются все узлы поддерева T1, затем корень n, после чего в симметричном порядке все узлы поддеревьев T2 … Tm. Рассмотрим пример всех способов обхода дерева, изображенного на рисунке: Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Порядок узлов данного дерева в случае прямого обхода будет следующим: 1 2 3 5 8 9 6 10 4 7. Обратный обход этого же дерева даст нам следующий порядок узлов: 2 8 9 5 10 6 3 7 4 1. При симметричном обходе мы получим следующую последовательность узлов: 2 1 8 5 9 3 10 6 7 4. 1 2 3 4 5 6 8 9 7 10 Помеченные деревья и деревья выражений Часто бывает полезным сопоставить каждому узлу дерева метку или значение. Дерево, у которого узлам сопоставлены метки, называется помеченным деревом. Метка узла – это значение, которое «хранится» в узле. Полезна следующая аналогия: дерево – список, узел – позиция, метка – элемент. Рассмотрим пример дерева с метками, представляющее арифметическое выражение (a+b)*(a+c), где n1, n2, …, n7 – имена узлов, а метки проставлены рядом с соответствующими узлами. Правила соответствия меток деревьев элементам выражений следующие: n1 * Метка каждого листа соответствует операнду и содержит его значение; Метка каждого внутреннего (родительского) узла соответствует оператору. Часто при обходе деревьев составляется список не имен узлов, а их n4 a n5 b n6 a n7 c меток. В случае дерева выражений при прямом обходе получим известную префиксную форму записи выражения, где оператор предшествует обоим операндам. В нашем примере мы получим префиксное выражение вида: *+ab+ac. Обратный обход меток дерева дает постфиксное представление выражения (польскую запись). Обратный обход нашего дерева даст нам следующую запись выражения: ab+ac+*. Следует учесть, что префиксная и постфиксная запись выражения не требует скобок. При симметричном обходе мы получим обычную инфиксную запись выражения: a+b * a+c. Правда для инфиксной записи выражений характерно заключение в скобки: (a+b)*(a+c). + n2 n3 + 1 2 3 5 4 6 7 Хабаровск, 2007 8 9 Реализация деревьев Пусть дерево T имеет узлы 1, 2, …., n. Возможно, самым простым представлением дерева T 10 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 будет линейный массив A, где каждый элемент A[i] содержит номер родительского узла (является курсором на родителя). Поскольку корень дерева не имеет родителя, то его курсор будет равен 0. Рассмотрим пример. Для приведенного на рисунке дерева построим линейный массив по следующему правилу: A[i]=j, если узел j является родителем узла i, A[i]=0, если узел i является корнем. Тогда массив будет выглядеть следующим образом: 1 2 3 4 5 6 7 8 9 10 Индексы 0 1 1 1 3 3 4 5 5 6 Курсоры на родителей Другой важный и полезный способ представления деревьев состоит в формировании для каждого узла списка его сыновей. Рассмотрим этот способ для приведенного выше примера. 1 2 ● 2 3 4 5 5 7 6 8 7 ● 8 ● 9 ● 3 4 6 9 10 10 ● Двоичные деревья Двоичное или бинарное дерево – это наиболее важный тип деревьев. Каждый узел бинарного дерева имеет не более двух поддеревьев, причем в случае только одного поддерева следует различать левое или правое. Строго говоря, бинарное дерево – это конечное множество узлов, которое является либо пустым, либо состоит из корня и двух непересекающихся бинарных деревьев, которые называются левым и правым поддеревьями данного корня. Тот факт, что каждый сын любого узла определен как левый или как правый сын, существенно отличает двоичное дерево от обычного упорядоченного ориентированного дерева. Если мы примем соглашение, что на схемах двоичных деревьев левый сын всегда соединяется с родителем линей, направленной влево и вниз от родителя, а правый сын – линией, направленной вправо и вниз, тогда деревья, изображенные на рисунке а) и б) ниже, – это различные деревья, хотя они оба похожи на обычное дерево (рис. в)). Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 1 1 2 3 1 2 2 4 3 5 4 3 5 4 5 а) б) в) Двоичные деревья нельзя непосредственно сопоставить с обычным деревом. Обход двоичных деревьев в прямом и обратном порядке в точности соответствует таким же обходам обычных деревьев. При симметричном обходе двоичного дерева с корнем n левым поддеревом T1 и правым поддеревом T2 сначала проходится поддерево T1, затем корень n и далее поддерево T2. Представление двоичных деревьев Бинарное дерево можно представить в виде динамической структуры данных, состоящей из узлов, каждый из которых содержит кроме данных не более двух ссылок на правое и левое бинарное дерево. На каждый узел имеется одна ссылка. Начальный узел называется корнем. По аналогии с элементами списков, узлы дерева удобно представить в виде записей, хранящих информацию и два указателя: Type Tree=^s; S=record Inf: <тип хранимой информации>; Left, right: tree; End; Изобразим схематично пример дерева, организованного в виде динамической структуры данных: 10 6 ● 1 ● 25 ● 8 ● ● ● 20 ● 21 30 ● ● Дерево поиска Обратите внимание на рисунок, приведенный выше. Данное дерево организовано таким образом, что для каждого узла все ключи (значения узлов) его левого поддерева меньше ключа этого узла, а все ключи его правого поддерева Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 больше. Такой способ построения дерева называется деревом поиска или двоичным упорядоченным деревом. С помощью дерева поиска можно организовать эффективный способ поиска, который значительно эффективнее поиска по списку. Поиск в упорядоченном дереве выполняется по следующему рекурсивному алгоритму: Если дерево не пусто, то нужно сравнить искомый ключ с ключом в корне дерева: - если ключи совпадают, поиск завершен; - если ключ в корне больше искомого, выполнить поиск в левом поддереве; - если ключ в корне меньше искомого, выполнить поиск в правом поддереве. Если дерево пусто, то искомый элемент не найден. Дерево поиска может быть использовано для построения упорядоченной последовательности ключей узлов. Например, если мы используем симметричный порядок обхода такого дерева, то получим упорядоченную по возрастанию последовательность: 1 6 8 10 20 21 25 30. Можно организовать «зеркально симметричный» обход, начиная с правого поддерева, тогда получим упорядоченную по убыванию последовательность: 30 25 20 10 8 6 1. Таким образом, деревья поиска можно применять для сортировки значений. Операции с двоичными деревьями Для работы с двоичными деревьями важно уметь выполнять следующие операции: Поиск по дереву; Обход дерева; Включение узла в дерево; Удаление узла из дерева. 1. Алгоритм поиска по дереву мы рассмотрели выше. Функция поиска: function find(root:tree; key:integer; var p, parent:tree):Boolean; begin p:=root; while p<>nil do begin if key=p^.inf then {узел с таким ключом есть} begin find:=true; exit; end; parent:=p {запомнить указатель на предка} if key<p^.inf then p:=p^.left {спуститься влево} else p:=p^.right; {спуститься вправо} end; find:=false; end; 2. Мы рассмотрели несколько способов обхода дерева. Наибольший интерес для двоичного дерева поиска представляет симметричный обход, т.к. он дает нам упорядоченную последовательность ключей. Логично реализовать обход дерева в виде рекурсивной процедуры. Procedure obhod(p:tree); Begin if p<>nil then Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 begin obhod(p^.left); writeln(p^.inf); obhod(p^.right); end; end; 3. Вставка узла в двоичное дерево поиска не представляет сложности. Для того чтобы вставить узел, необходимо найти его место. Для этого мы сравниваем вставляемый ключ с корнем, если ключ больше, чем ключ корня, уходим в правое поддерево, а иначе – в левое. Тем же образом продвигаемся дальше, пока не дойдем до конечного узла (листа). Сравниваем вставляемый ключ с ключом листа. Если ключ меньше ключа листа, то добавляем листу левого сына, а иначе – правого сына. Например, необходимо вставить в дерево, изображенное на рисунке, узел с ключом 5. 10 6 ● 25 ● 1 ● 5 8 ● ● ● 20 ● ● 21 30 ● ● Сравниваем 5 с ключом корня; 5<10, следовательно, уходим в левое поддерево. Сравниваем 5 и 6; 5<6, спускаемся влево. Следующий узел является конечным (листом). Сравниваем 5 и 1; 5>1, следовательно, вставляем правого сына. Получим дерево с новым узлом, которое сохранило все свойства дерева поиска. 4. Удаление узла дерева происходит по-разному в зависимости от его расположения в дереве. … … Если узел является конечным (то есть не имеет потомков), то его удаление не вызывает трудностей, достаточно обнулить соответствующий указатель узлародителя. Если узел имеет только одного потомка, этот потомок ставится на место удаляемого узла, а в остальном дерево не изменяется. … … … Хабаровск, 2007 … Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 Сложнее всего случай, когда у удаляемого узла есть оба потомка. Есть простой особый случай: если у правого потомка удаляемого узла нет левого потомка, удаляемый узел заменяется на своего правого потомка, а его левый потомок подключается вместо отсутствующего левого потомка к замещающему узлу. Рассмотрите этот случай на рисунке, должно стать понятней. В общем же случае на место удаляемого узла ставится самый левый лист его правого поддерева (или наоборот – самый правый лист его левого поддерева). Это не нарушает свойств дерева поиска. … … Корень дерева удаляется по общему правилу за исключением того, что заменяющий его узел не требуется присоединять к узлу-родителю. Рассмотрим реализацию алгоритма удаления. procedure del(var root:tree; key:integer); var p:tree; {удаляемый узел} parent:tree; {предок удаляемого узла} y:tree; {узел, заменяющий удаляемый} function spusk(p:tree):tree; var y:tree; {узел, заменяющий удаляемый} pred:tree; {предок узла “y”} begin y:=p^.right; if y^.left=nil then y^.left:=p^.left {1} else {2} begin repeat pred:=y; y:=y^.left; until y^.left=nil; y^.left:=p^.left; {3} pred^.left:=y^.right; {4} y^.right:=p^.right; {5} end; spusk:=y; end; Хабаровск, 2007 Учебные материалы к элективным курсам по информатике для 10 - 11 классов, выпуск 4 begin if not find(root, key, p, parent) then {6} begin writeln(‘такого элемента нет’); exit; end; if p^.left=nil then y:=p^.right {7} else if p^.right=nil then y:=p^.left {8} else y:=spusk(p); {9} if p=root then root:=y {10} else {11} if key<parent^.inf then parent^.left:=y else parent^.right:=y; dispose(p); {12} end; В функцию del передаются указатель root на корень дерева и ключ key удаляемого элемента. С помощью функции find определяются указатели на удаляемый элемент p и его предка parent. Если искомого элемента в дереве нет, то выдается сообщение ({6}). В операторах {7}-{9} определяется указатель на узел y, который должен заменить удаляемый. Если у узла p нет левого поддерева, на его место будет поставлена вершина (возможно пустая) его правого поддерева ({7}). Иначе, если у узла p нет правого поддерева, на его место будет поставлена вершина его левого поддерева ({8}). В противном случае, когда оба поддерева существуют, для определения замещающего узла вызывается функция spusk, выполняющая спуск по дереву ({9}). В этой функции первым делом проверяется особый случай, описанный выше ({1}). Если же этот случай (отсутствие левого потомка у правого потомка удаляемого узла) не выполняется, организуется цикл ({2}), на каждой итерации которого указатель на текущий элемент запоминается в переменной pred, а указатель y смещается вниз и влево до того момента, пока не станет ссылаться на узел, не имеющий левого потомка (он-то нам и нужен). В операторе {3} к этой пустующей ссылке присоединяется левое поддерево удаляемого узла. Перед тем как присоединять к этому узлу правое поддерево удаляемого узла ({5}), требуется «пристроить» его собственное правое поддерево. Мы присоединяем его к левому поддереву предка узла y, заменяющего удаляемый ({4}), поскольку этот узел перейдет на новое место. Функция spusk возвращает указатель на узел, заменяющий удаляемый. Если мы удаляем корень дерева, надо обновить указатель на корень ({10}), иначе – присоединить этот указатель к соответствующему поддереву предка удаляемого узла ({11}). После того как узел удален из дерева, освобождается занимаемая им память ({12}). Хабаровск, 2007