4.2. Вычислительная схема перебора с возвратом

реклама
Г. В. Ваныкина, Т. О. Сундукова
АЛГОРИТМЫ
КОМПЬЮТЕРНОЙ
ОБРАБОТКИ ДАННЫХ
Учебное пособие
Тула
2012
УДК 004.421
ББК 32.973.26-018.2
В17
Рецензенты:
доктор педагогических наук, профессор А. Р. Есаян
(кафедра информатики и методики обучения информатике
ТГПУ им. Л. Н. Толстого);
кандидат физико-математических наук, доцент И. Ю. Баженова
(факультет ВМК МГУ им. М. В. Ломоносова)
В17
Ваныкина, Г. В., Сундукова, Т. О.
Алгоритмы компьютерной обработки данных: Учеб. пособие /
Г. В. Ваныкина, Т. О. Сундукова.– Тула, 2012.– 217 с.
Пособие представляет собой подробное изложение алгоритмов компьютерной обработки структурированных типов данных. Для изучения и реализации алгоритмов решения прикладных задач на языке С++, учета технологических особенностей конкретной среды исполнения предложен комплекс тематических разделов. Каждый раздел содержит необходимый теоретический и
справочный материал, большое количество примеров программных кодов с
комментариями, задания для аудиторной и/или самостоятельной работы.
Данное пособие обобщает и систематизирует различные абстракции данных, поддерживаемые в С++, в контексте применяемых для их обработки алгоритмов.
Предназначено для студентов специальности 351500 «Математическое
обеспечение и администрирование информационных систем», направлений
подготовки 010500.62 «Математическое обеспечение и администрирование
информационных систем», 010300.62 «Фундаментальная информатика и информационные технологии». Отдельные разделы могут быть использованы
при обучении программированию студентов специальности 030100 «Информатика» и направлений подготовки 540200 «Физико-математическое образование» (профиль 540203 «Информатика»), 050100.62 «Педагогическое образование» (профиль «Информатика»). Может быть использовано студентами и
преподавателями вузов, средних профессиональных и средних общеобразовательных учреждений.
ББК 32.973.26-018.2
УДК 004.421
© Г. В. Ваныкина, Т. О. Сундукова, 2012
2
СОДЕРЖАНИЕ
Предисловие ...................................................................................................... 7
1. Алгоритмы обработки данных .................................................................. 9
1.1. Понятие «алгоритм обработки данных» ............................................... 9
1.2. Ресурсная эффективность алгоритмов ................................................ 10
1.3. Методы оценки ресурсной эффективности алгоритмов ................... 13
1.4. Базовые алгоритмы обработки данных ............................................... 16
Ключевые термины ...................................................................................... 17
Краткие итоги ............................................................................................... 18
Материалы для практики ............................................................................. 18
Литература .................................................................................................... 19
2. Рекурсия и рекурсивные алгоритмы ..................................................... 20
2.1. Основные понятия рекурсии ................................................................ 20
2.2. Анализ трудоемкости рекурсивных алгоритмов методом подсчета
вершин дерева рекурсии ....................................................................... 22
2.3. Примеры разработки рекурсивной триады ........................................ 23
Ключевые термины ...................................................................................... 31
Краткие итоги ............................................................................................... 32
Материалы для практики ............................................................................. 32
Литература .................................................................................................... 33
3. Решение задач на использование рекурсивных алгоритмов ............ 34
3.1. Основные методы решения задач с помощью рекурсии .................. 34
3.2. Опорная схема «Увидеть» .................................................................... 35
3.3. Опорная схема «Переформулировать» ............................................... 35
3.4. Опорная схема «Обобщить» ................................................................ 36
3.5. Опорная схема «Характеристические свойства» ............................... 37
3.6. Опорная схема «Перенести часть условий в проверку» ................... 39
3.7. Опорная схема «Обратить функцию» ................................................. 39
3.8. Опорная схема «Найти родственника» ............................................... 40
Ключевые термины ...................................................................................... 41
Краткие итоги ............................................................................................... 42
Материалы для практики ............................................................................. 43
Литература .................................................................................................... 44
4. Алгоритм перебора с возвратом .............................................................. 45
4.1. Перебор с возвратом ............................................................................. 45
4.2. Вычислительная схема перебора с возвратом.................................... 46
4.3. Использование перебора с возвратом при решении задач ............... 47
Ключевые термины ...................................................................................... 53
Краткие итоги ............................................................................................... 54
Материалы для практики ............................................................................. 54
Литература .................................................................................................... 55
3
5. Алгоритмы поиска в линейных структурах ......................................... 56
5.1. Основные понятия и общий алгоритм поиска данных ..................... 56
5.2. Последовательный (линейный) поиск ................................................ 57
5.3. Бинарный (двоичный) поиск ................................................................ 58
Ключевые термины ...................................................................................... 60
Краткие итоги ............................................................................................... 61
Материалы для практики ............................................................................. 61
Литература .................................................................................................... 64
6. Алгоритмы хеширования данных .......................................................... 65
6.1. Основные понятия хеширования данных ........................................... 65
6.2. Методы разрешения коллизий ............................................................. 67
6.3. Алгоритмы хеширования ..................................................................... 69
6.3.1. Таблица прямого доступа ........................................................... 69
6.3.2. Метод остатков от деления ......................................................... 70
6.3.3. Метод функции середины квадрата ........................................... 70
6.3.4. Метод свертки .............................................................................. 70
6.3.5. Открытое хеширование ............................................................... 71
6.3.6. Закрытое хеширование................................................................ 74
Ключевые термины ...................................................................................... 79
Краткие итоги ............................................................................................... 80
Материалы для практики ............................................................................. 80
Литература .................................................................................................... 81
7. Алгоритмы поиска в тексте ..................................................................... 82
7.1. Основные понятия задач поиска в тексте ........................................... 82
7.2. Прямой поиск ........................................................................................ 83
7.3. Алгоритм Кнута, Морриса и Пратта ................................................... 84
7.4. Алгоритм Бойера и Мура ..................................................................... 86
Ключевые термины ...................................................................................... 89
Краткие итоги ............................................................................................... 89
Материалы для практики ............................................................................. 90
Литература .................................................................................................... 91
8. Алгоритмы поиска на основе деревьев ................................................. 92
8.1. Основные понятия задач поиска на основе деревьев ........................ 92
8.2. Двоичные (бинарные) деревья ............................................................. 92
8.3. Двоичные упорядоченные деревья ...................................................... 93
8.4. Случайные деревья ............................................................................... 97
8.5. Оптимальные деревья ........................................................................... 98
8.6. Сбалансированные по высоте деревья ................................................ 98
8.7. Деревья цифрового (поразрядного) поиска ...................................... 105
Ключевые термины .................................................................................... 105
Краткие итоги ............................................................................................. 106
Материалы для практики ........................................................................... 107
Литература .................................................................................................. 107
4
9. Алгоритмы сжатия данных .................................................................... 109
9.1. Основные понятия и методы сжатия данных ................................... 109
9.2. Метод Хаффмана ................................................................................. 111
9.3. Кодовые деревья .................................................................................. 115
Ключевые термины .................................................................................... 120
Краткие итоги ............................................................................................. 121
Материалы для практики ........................................................................... 122
Литература .................................................................................................. 123
10. Алгоритмы сортировки массивов. Внутренняя сортировка ........ 124
10.1. Основные понятия сортировки ........................................................ 124
10.2. Оценка алгоритмов сортировки ....................................................... 125
10.3. Классификация алгоритмов сортировок ......................................... 125
10.4. Бинарная пирамидальная сортировка ............................................. 126
10.5. Сортировка методом Шелла ............................................................ 130
10.6. Быстрая сортировка Хоара ............................................................... 132
10.7. Сортировка слиянием ....................................................................... 135
Ключевые термины .................................................................................... 137
Краткие итоги ............................................................................................. 138
Материалы для практики ........................................................................... 139
Литература .................................................................................................. 140
11. Алгоритмы сортировки массивов. Внешняя сортировка ............. 142
11.1. Основные понятия алгоритмов внешних сортировок ................... 142
11.2. Сортировка простым слиянием ....................................................... 144
11.3. Сортировка естественным слиянием .............................................. 147
Ключевые термины .................................................................................... 150
Краткие итоги ............................................................................................. 151
Материалы для практики ........................................................................... 151
Литература .................................................................................................. 152
12. Алгоритмы на графах. Алгоритмы обхода графа ........................... 153
12.1. Основные понятия теории графов ................................................... 153
12.2. Поиск в глубину ................................................................................ 156
12.4. Поиск в ширину ................................................................................. 157
Ключевые термины .................................................................................... 158
Краткие итоги ............................................................................................. 160
Материалы для практики ........................................................................... 160
Литература .................................................................................................. 161
13. Алгоритмы на графах. Алгоритмы нахождения кратчайшего
пути ............................................................................................................. 162
13.1. Алгоритмы поиска на графах ........................................................... 162
13.2. Алгоритм Дейкстры .......................................................................... 162
13.3. Алгоритм Флойда .............................................................................. 165
13.4. Переборные алгоритмы .................................................................... 167
5
13.4.1. Перебор с возвратом................................................................ 167
13.4.2. Волновой алгоритм.................................................................. 170
Ключевые термины .................................................................................... 171
Краткие итоги ............................................................................................. 171
Материалы для практики ........................................................................... 172
Литература .................................................................................................. 173
14. Решение задач на использование алгоритмов обработки
данных ........................................................................................................ 174
14.1. Этапы решения задач на обработку данных .................................. 174
14.2. Алгоритмы сортировки данных ....................................................... 175
14.3. Алгоритмы на графах ....................................................................... 179
14.4. Алгоритмы сжатия данных .............................................................. 187
Ключевые термины .................................................................................... 194
Краткие итоги ............................................................................................. 194
Материалы для практики ........................................................................... 194
Литература .................................................................................................. 196
15. Тестовые задания ................................................................................... 197
15.1. Тест по теме «Трудоемкость алгоритмов и рекурсия» ................. 197
Вариант 1 .............................................................................................. 197
Вариант 2 .............................................................................................. 199
Вариант 3 .............................................................................................. 201
15.2. Тест по теме «Алгоритмы поиска, хеширования и сжатия
данных» ................................................................................................. 203
Вариант 1 .............................................................................................. 203
Вариант 2 .............................................................................................. 205
Вариант 3 .............................................................................................. 208
15.3. Тест по теме «Алгоритмы сортировки массивов. Алгоритмы на
графах» .................................................................................................. 211
Вариант 1 .............................................................................................. 211
Вариант 2 .............................................................................................. 214
Вариант 3 .............................................................................................. 217
6
Предисловие
Язык программирования С++ был разработан датским ученым Бьёрном Страуструпом в начале 80-х годов, первоначально как объектноориентированное расширение языка С. В настоящее время C++ является
одним из наиболее мощных и широко распространенных языков программирования. Язык характеризуется своей универсальностью, он успешно
применяется для решения разнообразных задач прикладного и системного
программирования с использованием различных парадигм программирования – процедурной, объектно-ориентированной, модульной.
Настоящее пособие «Алгоритмы компьютерной обработки данных»
обобщает и систематизирует различные абстракции данных, поддерживаемые в С++, в контексте применяемых для их обработки алгоритмов. Основным содержанием курса являются лекционно-практические тематические разделы, которые следуют в порядке, соответствующем последовательности изучения алгоритмов компьютерной обработки данных при подготовке студентов. В качестве программной реализации выбран язык С++
и использована среда MS Visual Studio 2010.
Представленный в курсе теоретический материал и набор упражнений покрывает основные синтаксические и семантические аспекты языка
С++ в объеме вузовских программ по структурам и алгоритмам компьютерной обработки данных для специальности 351500 Математическое
обеспечение и администрирование информационных систем и направлений подготовки 010500.62 Математическое обеспечение и администрирование информационных систем, 010300.62 Фундаментальная информатика
и информационные технологии. Отдельные разделы могут быть использованы при обучении программированию студентов специальности 030100
Информатика и направлений подготовки 540200 Физико-математическое
образование (профиль 540203 Информатика), 050100.62 Педагогическое
образование (профиль Информатика).
Теоретический и практический материал опирается на сформированные ранее базовые знания обучающихся по основам алгоритмизации, на
умения работать со структурированными типами данных языка С++ и владение базовыми алгоритмами обработки простых и структурированных
данных. В пособии продолжается реализация идеи процедурной парадигмы программирования, так как обучение знанию алгоритмов обработки
структурированных данных и построение на их основе решения различных
классов задач способствует формированию определенного стиля мышления и культуры. Формируется база для перехода к изучению альтернативных парадигм современного программирования.
Курс представлен следующими тематическими разделами, включающими теоретический материал и задания для практической работы:
 трудоемкость алгоритмов;
 рекурсия и рекурсивные алгоритмы;
7
 алгоритмы поиска, хеширования и сжатия данных;
 алгоритмы сортировки массивов;
 алгоритмы на графах.
Каждая тема начинается с краткой аннотации и изложения теоретического материала, на основе которого построено объяснение рассматриваемого способа обработки структурированных данных, изложение сути
методов и применяемых алгоритмов, технологических особенностей программирования. При необходимости в тексте приводится справочный материал. В материале широко представлены многочисленные примеры программных кодов с комментариями, в которых раскрываются алгоритмические подходы к решению задач. Для закрепления изученного материала и
приобретения навыков программирования предусмотрена система практических заданий, которые можно использовать на аудиторных занятиях, а
также для самостоятельной работы в соответствии с рассматриваемой тематикой.
Пособие написано на основе курса лекций и лабораторнопрактических занятий по дисциплине «Структуры и алгоритмы компьютерной обработки данных» со студентами факультета математики, физики
и информатики ТГПУ им. Л.Н. Толстого. Для базовой подготовки студентов, обучающихся на основе материалов пособия, необходимо знание основ программирования, умение работать с массивами, строками и реализовывать метод процедурной абстракции средствами языка С++.
8
1. Алгоритмы обработки данных
Краткая аннотация
В данной теме рассматриваются понятие ресурсной эффективности алгоритмов посредством анализа асимптотических функций временной и емкостной сложности, приводится классификация алгоритмов на основе
функции временной сложности, рассматриваются общие методы оценки
трудоемкости алгоритмов.
Цель изучения темы
Изучить понятие и классификацию алгоритмов обработки данных, трудоемкость алгоритмов и методов ее оценки, научиться выработке критериев
и оценке трудоемкости алгоритмов с учетом критериев на примере реализаций и задач на языке C++.
1.1. Понятие «алгоритм обработки данных»
Использование вычислительной техники при решении задач в течение многих десятилетий позволило выстроить общую схему подхода к работе над сформулированной проблемой. Решение поставленных задач
укладывается в так называемые этапы решения задач, которые начинаются
с информационной модели (работа над условием) и завершаются построением компьютерной модели (реализация алгоритма средствами языков
программирования).
Понятие «алгоритм обработки данных» в компьютерных науках используется для описания метода решения задачи, который в дальнейшем
возможно реализовать в выбранной среде программирования. Тщательная
разработка алгоритма является весьма эффективной частью процесса решения задачи в любой области применения. При разработке алгоритма для
реальной задачи значительные усилия должны быть потрачены на осознание степени ее сложности, выяснение ограничений на входные данные,
разбиение задачи на менее трудоемкие подзадачи.
Алгоритм не должен быть привязан к конкретной реализации. В силу
разнообразия используемых средств программирования, их требований к
аппаратным ресурсам и платформенной зависимости сходные по структуре, но различные в реализации, алгоритмы могут выдавать отличающиеся
по эффективности результаты. При этом некоторые среды программирования содержат встроенные библиотечные функции, реализующие базовые
алгоритмы обработки данных (например, в MS Visual Studio 2010 в библиотеки С++ входит функция быстрой сортировки массивов данных). Чтобы решения были переносимыми и оставались актуальными, не рекомендуется их ориентировать на процедурную реализацию среды. Поэтому
главным в рассматриваемом подходе является выбор метода решения с
учетом специфики задачи. Адаптация к среде осуществляется позднее.
9
Выбор того или иного метода обработки данных определяется не
только сложностью задачи. Учитывать необходимо и массовость применения разработанного кода: при однократном или редком обращении к реализации предпочтительнее бывают простые алгоритмы, которые несложны
в разработке. При этом, однако, допускается возможным увеличение времени работы программы.
Массовое использование алгоритмов обработки данных требует поиска наилучшего алгоритма решения. Такой процесс бывает весьма сложен, так как требует выработки критериев оценки и применения математических методов для получения количественных характеристик. Направление компьютерных наук, занимающееся изучением оценки эффективности
алгоритмов, называется анализом алгоритмов.
1.2. Ресурсная эффективность алгоритмов
Определение ресурсной эффективности алгоритмов – необходимая
составляющая этапа анализа разработанного программного обеспечения.
Повышение ресурсной эффективности вычислительных алгоритмов актуально при обработке больших объемов данных, когда аппаратных и/или
программных ресурсов может быть недостаточно для корректного завершения работы программного кода.
Наиболее значимыми характеристиками ресурсной эффективности
алгоритмов являются оценки временной и емкостной сложности, отражающие ресурсы процессора, оперативной памяти, а также внешних носителей данных (при использовании).
Под трудоемкостью алгоритма А на входе D будем понимать количество элементарных операций, которые учитываются при анализе алгоритма. Под худшим случаем трудоемкости понимают наибольшее количество операций, задаваемых алгоритмом А на всех входах D определенной
размерности n. Определим лучший случай трудоемкости, как наименьшее
количество операций в аналогичном алгоритме и при той же размерности
входа. Средний случай трудоемкости определяется средним количеством
операций рассматриваемого алгоритма и входных данных. Зависимость
трудоемкости алгоритма А от значения параметров на входе D определяет
функцию трудоемкости алгоритма А для входа D.
Классический анализ алгоритмов в данном контексте связан, прежде
всего, с оценкой временной сложности. Большинство алгоритмов имеют
основной параметр, который в значительной степени влияет на время выполнения операций. Если же определяющих параметров несколько, то, как
правило, один их них выражается как функция от остальных. Иногда используют и такой подход: рассматривают только один параметр, считая
остальные константами.
Результатом анализа является асимптотическая оценка выполняемых алгоритмом операций в зависимости от длины входа, которая указы10
вает порядок роста функции и результаты сравнения работы алгоритмов
для больших данных. При этом оценка на реальных данных отличается от
асимптотической тем, что она ориентирована на конкретные длины входов
и число выполняемых алгоритмом операций.
Временная сложность алгоритма определяется асимптотической
оценкой функции трудоемкости алгоритма для худшего случая, обозначается O( f (n)) и читается как «О большое» или «О-нотация». Асимптотический класс функций О включает в себя как средний, так и лучший случай,
потому что запись O( f (n)) обозначает класс функций, скорость роста которых не более, чем f (n) с точностью до некоторой положительной константы. В зависимости от вида функции f (n) выделяют следующие классы сложности алгоритмов.
Классы сложности алгоритмов
в зависимости от функции трудоемкости
Вид f ( n)
Характеристика класса алгоритмов
1
Большинство инструкций большинства функций запускается
один или несколько раз. Если все инструкции программы
обладают таким свойством, то время выполнения программы постоянно.
log N
Когда время выполнения программы является логарифмическим, программа становится медленнее с ростом N. Такое
время выполнения обычно присуще программам, которые
сводят большую задачу к набору меньших подзадач, уменьшая на каждом шаге размер задачи на некоторый постоянный фактор. Будем рассматривать время выполнения, являющееся небольшой по величине константой. Изменение основания не сильно сказывается на изменении значения логарифма: при N=1 000, log N  3 , если основание равно 10, или
порядка 10, если основание равно 2; когда N=1 000 000, значения log N увеличивается в два раза. При удвоении значения параметра log N растет на постоянную величину, а
удваивается лишь тогда, когда N достигает N 2 .
N
Когда время выполнения программы является линейным, это
обычно значит, что каждый входной элемент подвергается
небольшой обработке. Когда N равно миллиону, таким же и
является время выполнения. Когда N удваивается, то же
происходит и со временем выполнения. Эта ситуация оптимальна для алгоритма, который должен обработать N вводов
(или произвести N выводов).
11
N log N
N2
N3
2N
Время выполнения, пропорциональное N log N , возникает
тогда, когда алгоритм решает задачу, разбивая ее на меньшие подзадачи, решая их независимо и затем объединяя решения. Время выполнения такого алгоритма равно N log N .
Когда N=1 000 000, N log N  20 000 000 . Когда N удваивается, тогда время выполнения более чем удваивается.
Когда время выполнения алгоритма является квадратичным, он полезен для практического использования при решении относительно небольших задач. Квадратичное время
выполнения обычно появляется в алгоритмах, которые обрабатывают все пары элементов данных (возможно, в цикле
двойного уровня вложенности). Когда N=1 000, время выполнения равно одному миллиону. Когда N удваивается,
время выполнения увеличивается вчетверо.
Похожий алгоритм, который обрабатывает тройки элементов данных (возможно, в цикле тройного уровня вложенности), имеет кубическое время выполнения и практически
применим лишь для малых задач. Когда N =100, время выполнения равно одному миллиону. Когда N удваивается,
время выполнения увеличивается в восемь раз.
Лишь несколько алгоритмов с экспоненциальным временем
выполнения имеет практическое применение, хотя такие алгоритмы возникают естественным образом при попытках
прямого решения задачи, например полного перебора. Когда
N=20, время выполнения имеет порядок одного миллиона.
Когда N удваивается, время выполнения увеличивается экспоненциально.
На основании математических методов исследования асимптотических функций трудоемкости на бесконечности выделены пять классов алгоритмов.
Класс π0 – это класс быстрых алгоритмов с постоянным временем выполнения, их функция трудоемкости O(1) . Промежуточное состояние занимают алгоритмы со сложностью O(log N ) , которые
также относят к данному классу.
Класс πР – это класс рациональных или полиномиальных алгоритмов,
функция трудоемкости которых определяется полиномиально
от входных параметров. Например, O(N ) , O( N 2 ) , O ( N 3 ) .
Класс πL – это класс субэкспоненциальных алгоритмов со степенью трудоемкости O( N log N ) .
Класс πE – это класс собственно экспоненциальных алгоритмов со степенью трудоемкости O(2 N ) .
12
Класс πF – это класс собственно надэкспоненциальных алгоритмов. Существуют алгоритмы с факториальной трудоемкостью, но они в
основном не имеют практического применения.
Состояние памяти при выполнении алгоритма определяется значениями, требующими для размещения определенных участков. При этом в ходе решения задачи может быть задействовано дополнительное количество
ячеек. Под объемом памяти, требуемым алгоритмом А для входа D, понимаем максимальное количество ячеек памяти, задействованных в ходе выполнения алгоритма. Емкостная сложность алгоритма определяется как
асимптотическая оценка функции объема памяти алгоритма для худшего
случая.
Таким образом, ресурсная сложность алгоритма в худшем, среднем
и лучшем случаях определяется как упорядоченная пара классов функций
временной и емкостной сложности, заданных асимптотическими обозначениями и соответствующих рассматриваемому случаю.
1.3. Методы оценки ресурсной эффективности алгоритмов
Основными алгоритмическими конструкциями в процедурном программировании являются следование, ветвление и цикл. Для получения
функций трудоемкости для лучшего, среднего и худшего случаев при фиксированной размерности входа необходимо учесть различия в оценке основных алгоритмических конструкций.
 Трудоемкость конструкции «Следование» есть сума трудоемкостей
блоков, следующих друг за другом:
f  f1  f 2  ...  f n .
 Трудоемкость конструкции «Ветвление» определяется через вероятность перехода к каждой из инструкций, определяемой условием.
При этом проверка условия также имеет определенную трудоемкость. Для вычисления трудоемкости худшего случая может быть
выбран тот блок ветвления, который имеет большую трудоемкость,
для лучшего случая – блок с меньшей трудоемкостью.
f if  f1  f then  pthen  f else  (1  pthen )
 Трудоемкость конструкции «Цикл» зависит от вида цикла. Для цикла
с параметрами будет справедливой формула:
f for  1  3n  nf , где n – количество повторений тела цикла, f – трудоемкость тела цикла.
Реализация цикла с предусловием и с постусловием не меняет методики оценки его трудоемкости. На каждом проходе выполняется
оценка трудоемкости условия, изменения параметров (при наличии)
и тела цикла. Общие рекомендации для оценки циклов с условиями
затруднительны. Так как в значительной степени зависят от исходных данных.
13
 В случае использования вложенных циклов их трудоемкости перемножаются.
Таким образом, для оценки трудоемкости алгоритма может быть
сформулирован общий метод получения функции трудоемкости.
1. Декомпозиция алгоритма предполагает выделение в алгоритме базовых конструкций и оценку и трудоемкости. При этом рассматривается следование основных алгоритмических конструкций.
2. Построчный анализ трудоемкости по базовым операциям языка подразумевает либо совокупный анализ (учет всех операций), либо пооперационный анализ (учет трудоемкости каждой операции).
3. Обратная композиция функции трудоемкости на основе методики
анализа базовых алгоритмических конструкций для лучшего, среднего и худшего случаев.
Особенностью оценки ресурсной эффективности рекурсивных алгоритмов является необходимость учета дополнительных затрат памяти и
механизма организации рекурсии. Поэтому трудоемкость рекурсивных реализаций алгоритмов связана с количеством операций, выполняемых при
оном рекурсивном вызове, а также с количеством таких вызовов. Учитываются также затраты на возвращения значений и передачу управления в
точку вызова. Для анализа трудоемкости механизма рекурсивного вызовавозврата будем учитывать следующие параметры: p – количество передаваемых фактических параметров, r – количество сохраняемых в стеке регистров, k – количество возвращаемых по адресной ссылке значений, l –
количество локальных ячеек функции. Тогда функция трудоемкости на одни вызов-возврат примет вид:
f  2( p  k  r  l  1) ,
где дополнительная единица учитывает операции с адресом возврата.
Оценка требуемой памяти стека может быть получена следующим
образом: так как рекурсивные вызовы обрабатываются последовательно,
то в конкретный момент времени в стеке хранится не фрагмент дерева рекурсии, а цепочка рекурсивных вызовов – унарный фрагмент дерева. Поэтому объем стека определяется максимально возможным числом одновременно полученных рекурсивных вызовов.
Анализ совокупной трудоемкости рекурсивного алгоритма можно
выполнять разными способами в зависимости от формирования итоговой
суммы базовых операций: по цепочкам рекурсивных вызовов и возвратов,
по вершинам рекурсивного дерева.
Пример 1. Оценка временной сложности функции пузырьковой сортировки.
//Описание функции сортировки методом "пузырька"
void BubbleSort (int k,int x[max]) {
int i,j,buf;
for (i=k-1;i>0;i--)
for (j=0;j<i;j++)
14
if (x[j]>x[j+1]) {
buf=x[j];
x[j]=x[j+1];
x[j+1]=buf;
}
}
Оценим временную сложность функции пузырьковой сортировки в
худшем случае, т.е. когда исходные данные отсортированы в обратном порядке. В этом случае внутренний цикл для каждого i выполнится i раз и
k (k  1)
произойдет
обменов. Соответственно сложность алгоритма в
2
худшем случае составит O ( k 2 ) обменов.
Оценим временную сложность алгоритма пузырьковой сортировки в
среднем случае, т.е. когда исходные данные имеют произвольный порядок. В
этом случае условие во внутреннем цикле может выполниться 1, 2, ..., i  1
i(i  1)
раз. Складывая, получим
и, соответственно, условие во внутреннем
2
i
k (k  1)
цикле для каждого i выполнится в среднем
раз и произойдет
об4
2
менов. Соответственно сложность алгоритма в среднем случае составит
O(k 2 ) .
Пример 2. Оценка временной сложности функции вычисления биномиальn!
ного коэффициента Cnm 
( n  m) .
m!(n  m)!
//Описание функции вычисления биномиального коэффициента
int Binom (int n,int m) {
if (m==0) return 1; //база рекурсии
return Binom(n-1,m-1)*n/m; //декомпозиция
}
Оценим временную сложность функции в худшем случае, т.е. когда
m=n. Будет выполнено (n+1) обращений к функции, которая выполнит в n
случаях три операции, а в одном возвратит значение. Функция при каждом
обращении передает два параметра, не использует локальных переменных,
а при возвращении (n+1) раз передает управление в точку вызова. Соответственно сложность алгоритма в худшем случае составит O(n) или O(m) .
Оценим временную сложность функции в среднем случае, т.е. когда
m<n. При этом выполняются рассуждения, аналогичные худшему случаю,
только количество рекурсивных вызовов составит (m+1). Соответственно
сложность алгоритма в среднем случае составит O(m) .
Лучший случай достигается при m=0, когда выполняется единственный вызов функции, передача двух параметров и возвращение в точку вызова, то есть оценка лучшего случая O(1) .
15
1.4. Базовые алгоритмы обработки данных
Базовые алгоритмы обработки данных являются результатом исследований и разработок, проводившихся на протяжении десятков лет. Но
они, как и прежде, продолжают играть важную роль во все расширяющемся применении вычислительных процессов.
К базовым алгоритмам процедурного программирования можно отнести:
 Алгоритмы работы со структурами данных. Они определяют базовые принципы и методологию, используемые для реализации, анализа и сравнения алгоритмов. Позволяют получить представление о
методах представления данных. К таким структурам относятся связные списки и строки, деревья, абстрактные типы данных, такие как
стеки и очереди.
 Алгоритмы сортировки, предназначенные для упорядочения массивов и файлов, имеют особую важность. С алгоритмами сортировки
связаны, в частности, очереди по приоритету, задачи выбора и слияния.
 Алгоритмы поиска, предназначенные для поиска конкретных элементов в больших коллекциях элементов. К ним относятся основные
и расширенные методы поиска с использованием деревьев и преобразований цифровых ключей, в том числе деревья цифрового поиска,
сбалансированные деревья, хеширование, а также методы, которые
подходят для работы с очень крупными файлами.
 Алгоритмы на графах полезны при решении ряда сложных и важных
задач. Общая стратегия поиска на графах разрабатывается и применяется к фундаментальным задачам связности, в том числе к задаче
отыскания кратчайшего пути, построения минимального остовного
дерева, к задаче о потоках в сетях и задаче о паросочетаниях. Унифицированный подход к этим алгоритмам показывает, что в их основе лежит одна и та же функция, и что эта функция базируется на основном абстрактном типе данных очереди по приоритету.
 Алгоритмы обработки строк включают ряд методов обработки последователей символов. Поиск в строке приводит к сопоставлению с
эталоном, что, в свою очередь, ведет к синтаксическому анализу. К
этому же классу задач можно отнести и технологии сжатия файлов.
 Геометрические алгоритмы – это методы решения задач с использованием точек и линий (и других простых геометрических объектов),
которые вошли в употребление достаточно недавно. К ним относятся
алгоритмы построения выпуклых оболочек, заданных набором точек,
определения пересечений геометрических объектов, решения задач
отыскания ближайших точек и алгоритма многомерного поиска.
Многие из этих методов дополняют простые методы сортировки и
поиска.
16
Ключевые термины
Алгоритм обработки данных – это описание метода решения задачи в
компьютерных науках, который в дальнейшем возможно реализовать
в выбранной среде программирования.
Алгоритмы на графах – это алгоритмы, предназначенные для реализации стратегий обходов и поиска на графах.
Алгоритмы обработки строк – это алгоритмы, которые включают ряд
методов обработки последователей символов.
Алгоритмы поиска – это алгоритмы, предназначенные для поиска конкретных элементов в больших коллекциях данных.
Алгоритмы работы со структурами данных – это алгоритмы, которые определяют базовые принципы и методологию, используемые
для получения представление о методах обработки данных.
Алгоритмы сортировки – это алгоритмы, предназначенные для упорядочения массивов и файлов.
Анализ алгоритмов – это направление компьютерных наук, занимающееся изучением оценки эффективности алгоритмов.
Временная сложность алгоритма – это асимптотическая оценка
функции трудоемкости алгоритма для худшего случая.
Геометрические алгоритмы – это алгоритмы решения задач с использованием геометрических объектов.
Емкостная сложность алгоритма – это асимптотическая оценка
функции объема памяти алгоритма для худшего случая.
Лучший случай трудоемкости – это наименьшее количество операций
в алгоритме А на всех входах D определенной размерности n.
Объем памяти – это максимальное количество ячеек памяти, задействованных в ходе выполнения алгоритма А для входа D.
Ресурсная сложность алгоритма в худшем, среднем и лучшем случаях –
это упорядоченная пара классов функций временной и емкостной
сложности, заданных асимптотическими обозначениями и соответствующих рассматриваемому случаю.
Средний случай трудоемкости – это среднее количество операций в
алгоритме А на всех входах D определенной размерности n.
Трудоемкость алгоритма – это количество элементарных операций,
которые учитываются при анализе алгоритма.
Функция трудоемкости алгоритма – это зависимость трудоемкости
алгоритма А от значения параметров на входе D.
Худший случай трудоемкости – это наибольшее количество операций,
задаваемых алгоритмом А на всех входах D определенной размерности n.
17
Краткие итоги
1. Определение ресурсной эффективности алгоритмов – необходимая
составляющая этапа анализа разработанного программного обеспечения.
2. Наиболее значимыми характеристиками ресурсной эффективности
алгоритмов являются оценки их временной и емкостной сложности.
3. Временная сложность алгоритма определяется асимптотической
оценкой функции трудоемкости алгоритма для худшего случая.
4. В зависимости от вида функции временной сложности алгоритма
выделяют пять основных классов сложности алгоритмов.
5. Емкостная сложность алгоритма определяется как асимптотическая
оценка функции объема памяти алгоритма для худшего случая.
6. Ресурсная сложность алгоритма в худшем, среднем и лучшем случаях определяется как упорядоченная пара классов функций временной
и емкостной сложности, заданных асимптотическими обозначениями
и соответствующих рассматриваемому случаю.
7. Для получения функций трудоемкости для лучшего, среднего и худшего случаев при фиксированной размерности входа необходимо
учесть особенности в оценке основных алгоритмических конструкций.
8. Особенностью оценки ресурсной эффективности рекурсивных алгоритмов является необходимость учета дополнительных затрат памяти и механизма организации рекурсии.
9. В зависимости от структур обработки данных в процедурном программировании выделяются классы базовых алгоритмов.
Материалы для практики
Вопросы
1. С какой целью проводится оценка ресурсной эффективности алгоритмов?
2. Почему в теории анализа алгоритмов нет привязки к конкретной реализации?
3. Могут ли оценки временной сложности для худшего и лучшего случаев совпадать? Подтвердите вывод примерами.
4. Охарактеризуйте область применения алгоритмов классов сложности
πE и πF.
5. В чем особенность оценки трудоемкости рекурсивных алгоритмов?
6. Оцените временную трудоемкость рекурсивного и итерационного
способов вычисления факториала целого неотрицательного числа.
Определите класс сложности алгоритмов в каждом случае. Объясните результат.
18
Упражнения
1. Выполните анализ временной сложности алгоритмов простых сортировок. Проведите сравнительный анализ полученных результатов.
Определите классы этих алгоритмов в зависимости от функции трудоемкости.
2. Выполните анализ временной трудоемкости алгоритма решения задачи о Ханойских башнях. Определите класс этого алгоритма в зависимости от функции трудоемкости.
3. Выполните анализ трудоемкости конструкций вложенных циклов для
n=102, n=106, n=109. Составьте функцию временной трудоемкости алгоритма и определите его класс сложности. Считать, что все указанные
операции корректны. Возможное переполнение разрядов не учитывать.
k=0;
for (a=0; a<n; a++)
for (b=0; b<n; b++)
for (c=0; c<n; c++)
k++;
4. Составьте функцию нахождения наибольшего общего делителя двух
натуральных чисел по алгоритму Евклида. Выполните анализ временной трудоемкости алгоритма. Определите класс этого алгоритма
в зависимости от функции трудоемкости.
5. Составьте функцию нахождения наибольшего общего делителя n
натуральных чисел, используя алгоритм Евклида для двух чисел.
Выполните анализ временной трудоемкости алгоритма. Определите
класс этого алгоритма в зависимости от функции трудоемкости.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Головешкин В.В., Ульянов М.В. Теория рекурсии для программистов
/ В.А. Головешкин, М.В. Ульянов. – М.: ФИЗМАТЛИТ, 2006. – 296 с.
3. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 1: Корзина разнообразных задач / А.Р. Есаян. – Тула:
Изд-во ТГПУ им. Л. Н. Толстого, 2000. – 90 с.
4. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
19
2. Рекурсия и рекурсивные алгоритмы
Краткая аннотация
В данной теме рассматриваются основные понятия рекурсии в контексте
разработки алгоритмов с помощью рекурсивной триады, дается представление о ресурсной эффективности и о методе оценки рекурсивных алгоритмов через подсчет вершин рекурсивного дерева.
Цель изучения темы
Изучить понятие, виды рекурсии и рекурсивную триаду, научиться разрабатывать рекурсивную триаду при решении задач на языке C++.
2.1. Основные понятия рекурсии
Одной из идей процедурного программирования, которая оформилась в начале шестидесятых годов ХХ века, стало активное применение в
практике программирования некоторого метода, основанного на организации серий взаимных обращений программ (функций) друг к другу. Вопросы об эффективности использования данного метода при разработке алгоритмических моделей актуальны и в настоящее время, несмотря на существование различных парадигм программирования, создание новых и совершенствование существующих языков программирования. Речь идет о
рекурсивном методе в программировании, который рассматривается альтернативным по отношению к итерационному.
Рекурсия – это определение объекта через обращение к самому себе.
Рекурсивный алгоритм – это алгоритм, в описании которого прямо
или косвенно содержится обращение к самому себе. В технике процедурного программирования данное понятие распространяется на функцию,
которая реализует решение отдельного блока задачи посредством вызова
из своего тела других функций, в том числе и себя самой. Если при этом на
очередном этапе работы функция организует обращение к самой себе, то
такая функция является рекурсивной.
Прямое обращение функции к самой себе предполагает, что в теле
функции содержится вызов этой же функции, но с другим набором фактических параметров. Такой способ организации работы называется прямой
рекурсией. Например, чтобы найти сумму первых n натуральных чисел,
надо сумму первых (n  1) чисел сложить с числом n, то есть имеет место
зависимость: S n  S n1  n . Вычисление S n1 происходит с помощью аналогичных рассуждений. Такая цепочка взаимных обращений в конечном
итоге сведется к вычислению суммы одного первого элемента, которая
равна самому элементу.
При косвенном обращении функция содержит вызовы других функций из своего тела. При этом одна или несколько из вызываемых функций
на определенном этапе обращаются к исходной функции с измененным
20
набором входных параметров. Такая организация обращений называется
косвенной рекурсией. Например, поиск максимального элемента в массиве
размера n можно осуществлять как поиск максимума из двух чисел: одно
их них – это последний элемент массива, а другое является максимальным
элементом в массиве размера (n  1) . Для нахождения максимального элемента массива размера (n  1) применяются аналогичные рассуждения. В
итоге решение сводится к поиску максимального из первых двух элементов массива.
Рекурсивный метод в программировании предполагает разработку
решения задачи, основываясь на свойствах рекурсивности отдельных объектов или закономерностей. При этом исходная задача сводится к решению аналогичных подзадач, которые являются более простыми и отличаются другим набором параметров.
Разработке рекурсивных алгоритмов предшествует рекурсивная
триада – этапы моделирования задачи, на которых определяется набор
параметров и соотношений между ними. Рекурсивную триаду составляют
параметризация, выделение базы и декомпозиция.
На этапе параметризации из постановки задачи выделяются параметры, которые описывают исходные данные. При этом некоторые дальнейшие разработки решения могут требовать введения дополнительных
параметров, которые не оговорены в условии, но используются при составлении зависимостей. Необходимость в дополнительных параметрах
часто возникает также при решении задач оптимизации рекурсивных алгоритмов, в ходе которых сокращается их временная сложность.
Выделение базы рекурсии предполагает нахождение в решаемой задаче тривиальных случаев, результат для которых очевиден и не требует
проведения расчетов. Верно найденная база рекурсии обеспечивает завершенность рекурсивных обращений, которые в конечном итоге сводятся к
базовому случаю. Переопределение базы или ее динамическое расширение
в ходе решения задачи часто позволяют оптимизировать рекурсивный алгоритм за счет достижения базового случая за более короткий путь обращений.
Декомпозиция представляет собой сведение общего случая к более
простым подзадачам, которые отличаются от исходной задачи набором
входных данных. Декомпозиционные зависимости описывают не только
связь между задачей и подзадачами, но и характер изменения значений параметров на очередном шаге. От выбранных отношений зависит трудоемкость алгоритма, так как для одной и той же задачи могут быть составлены
различные зависимости. Пересмотр отношений декомпозиции целесообразно проводить комплексно, то есть параллельно с корректировкой параметров и анализом базовых случаев.
21
2.2. Анализ трудоемкости рекурсивных алгоритмов методом
подсчета вершин дерева рекурсии
Рекурсивные алгоритмы относятся к классу алгоритмов с высокой
ресурсоемкостью, так как при большом количестве самовызовов рекурсивных функций происходит быстрое заполнение стековой области. Кроме того, организация хранения и закрытия очередного слоя рекурсивного стека
являются дополнительными операциями, требующими временных затрат.
На трудоемкость рекурсивных алгоритмов влияет и количество передаваемых функцией параметров.
Рассмотрим один из методов анализа трудоемкости рекурсивного
алгоритма, который строится на основе подсчета вершин рекурсивного
дерева. Для оценки трудоемкости рекурсивных алгоритмов строится полное дерево рекурсии. Оно представляет собой граф, вершинами которого
являются наборы фактических параметров при всех вызовах функции,
начиная с первого обращения к ней, а ребрами – пары таких наборов, соответствующих взаимным вызовам. При этом вершины дерева рекурсии
соответствуют фактическим вызовам рекурсивных функций. Следует заметить, что одни и те же наборы параметров могут соответствовать разным вершинам дерева. Корень полного дерева рекурсивных вызовов – это
вершина полного дерева рекурсии, соответствующая начальному обращению к функции.
Важной характеристикой рекурсивного алгоритма является глубина
рекурсивных вызовов – наибольшее одновременное количество рекурсивных обращений функции, определяющее максимальное количество слоев
рекурсивного стека, в котором осуществляется хранение отложенных вычислений. Количество элементов полных рекурсивных обращений всегда
не меньше глубины рекурсивных вызовов. При разработке рекурсивных
программ необходимо учитывать, что глубина рекурсивных вызовов не
должна превосходить максимального размера стека используемой вычислительной среды.
При этом объем рекурсии  это одна из характеристик сложности
рекурсивных вычислений для конкретного набора параметров, представляющая собой количество вершин полного рекурсивного дерева без единицы.
Будем использовать следующие обозначения для конкретного входного параметра D:
R(D) – общее число вершин дерева рекурсии,
RV (D ) – объем рекурсии без листьев (внутренние вершины),
RL (D ) – количество листьев дерева рекурсии,
H R (D ) – глубина рекурсии.
Например, для вычисления n-го члена последовательности Фибоначчи раз22
работана следующая рекурсивная функция:
int Fib(int n){ //n – номер члена последовательности
if(n<3) return 1; //база рекурсии
return Fib(n-1)+Fib(n-2); //декомпозиция
}
Тогда полное дерево рекурсии для вычисления пятого члена последовательности Фибоначчи будет иметь вид (рис. 1):
f5
f4
f3
f2
f3
f2
f2
f1
f1
Рис. 1. Полное дерево рекурсии для пятого члена последовательности Фибоначчи
Характеристиками рассматриваемого метода оценки алгоритма будут следующие величины.
D=5
R ( D)  9
D=n
R( D)  2 fn  1
RV ( D )  4
RV ( D)  fn  1
RL ( D )  5
RL ( D )  fn
H R ( D)  4
H R ( D)  n  1
2.3. Примеры разработки рекурсивной триады
Пример 1. Задача о разрезании прямоугольника на квадраты.
Дан прямоугольник, стороны которого выражены натуральными числами.
Разрежьте его на минимальное число квадратов с натуральными сторонами. Найдите число получившихся квадратов.
Разработаем рекурсивную триаду.
Параметризация: m, n – натуральные числа, соответствующие размерам прямоугольника.
База рекурсии: для m=n число получившихся квадратов равно 1, так
как данный прямоугольник уже является квадратом.
Декомпозиция: если m  n, то возможны два случая m < n или m > n.
Отрежем от прямоугольника наибольший по площади квадрат с натураль23
ными сторонами. Длина стороны такого квадрата равна наименьшей из
сторон прямоугольника. После того, как квадрат будет отрезан, размеры
прямоугольника станут следующие: большая сторона уменьшится на длину стороны квадрата, а меньшая не изменится. Число искомых квадратов
будет вычисляться как число квадратов, на которые будет разрезан полученный прямоугольник, плюс один (отрезанный квадрат). К получившемуся прямоугольнику применим аналогичные рассуждения: проверим на соответствие базе или перейдем к декомпозиции (рис. 2).
Рис. 2. Пример разрезания прямоугольника 135 на квадраты
#include "stdafx.h"
#include <iostream>
using namespace std;
int kv(int m,int n);
int _tmain(int argc, _TCHAR* argv[]) {
int a,b,k;
printf("Введите стороны прямоугольника->");
scanf("%d%d",&a,&b);
k = kv(a,b);
printf("Прямоугольник со сторонами %d и %d можно
разрезать на %d квадратов",a,b,k);
system("pause");
return 0;
}
int kv(int m,int n){ //m,n – стороны прямоугольника
if(m==n) return 1; //база рекурсии
if(m>n) return 1+kv(m-n,n); //декомпозиция для m>n
return 1+kv(m,n-m); //декомпозиция для m<n
}
Оптимальнее декомпозицию можно оформить так:
if(m>n) return m/n+kv(n,m%n); //декомпозиция для m>n
return n/m+kv(m,n%m); //декомпозиция для m<n
Характеристиками рассматриваемого метода оценки алгоритма будут следующие величины (рис. 3).
24
D = (13, 5)
R ( D)  6
D = (m, n), m ≥ n, худший случай
R( D)  m
RV ( D )  4
RV ( D)  m  2
RL ( D )  1
RL ( D )  1
H R ( D)  6
H R ( D)  m
13,5
8,5
5,3
3,2
2,1
1,1
Рис. 3. Пример полного дерева рекурсии для разрезания прямоугольника 135
на квадраты
Пример 2. Задача о нахождении центра тяжести выпуклого многоугольника.
Выпуклый многоугольник задан на плоскости координатами своих вершин. Найдите его центр тяжести.
Разработаем рекурсивную триаду.
Параметризация: x, y – вещественные массивы, в которых хранятся
координаты вершин многоугольника; n – это число вершин многоугольника, по условию задачи, n  1 так как минимальное число вершин имеет
двуугольник (отрезок).
База рекурсии: для n  2 в качестве многоугольника рассматривается
отрезок, центром тяжести которого является его середина (рис. 4А). При
этом середина делит отрезок в отношении 1 : 1. Если координаты концов
отрезка заданы как ( x0 , y0 ) и ( x1 , y1 ) , то координаты середины вычисляются по формуле:
x  x1
y  y1
, cy  0
.
cx  0
2
2
Декомпозиция: если n  2 , то рассмотрим последовательное нахождение центров тяжести треугольника, четырехугольника и т.д.
Для n  3 центром тяжести треугольника является точка пересечения
его медиан, которая делит каждую медиану в отношении 2 : 1, считая от
вершины. Но основание медианы – это середина отрезка, являющегося
25
стороной треугольника. Таким образом, для нахождения центра тяжести
треугольника необходимо: найти центр тяжести стороны треугольника
(отрезка), затем разделить в отношении 2 : 1, считая от вершины, отрезок,
образованный основанием медианы и третьей вершиной (рис. 4B).
Для n  4 центром тяжести четырехугольника является точка, делящая в отношении 3 : 1, считая от вершины, отрезок: он образован центром
тяжести треугольника, построенного на трех вершинах, и четвертой вершиной (рис. 4C).
A2
A2
A1
A3
C0
A1
A1
C1
C1
C2
1
1
A0
C0
C0
A0
A0
А
В
С
Рис. 4. Примеры построения центров тяжести многоугольников
Таким образом, для нахождения центра тяжести n-угольника необходимо разделить в отношении (n  1) : 1, считая от вершины, отрезок: он
образован центром тяжести (n  1) -угольника и n-ой вершиной рассматриваемого многоугольника. Если концы отрезка заданы координатами вершины ( xn , y n ) и центра тяжести (n  1) -угольника (cxn1 , cy n1 ) , то при делении отрезка в данном отношении получаем координаты:
x  (n  1) cxn1
y  (n  1) cy n1
, cy n  n
.
cxn  n
n
n
#include "stdafx.h"
#include <iostream>
using namespace std;
#define max 20
void centr(int n,float *x, float *y, float *c);
int _tmain(int argc, _TCHAR* argv[]){
int m, i=0;
FILE *f;
if ( ( f = fopen("in.txt", "r") ) == NULL )
perror("in.txt");
26
else {
fscanf(f, "%d",&m);
printf("\n%d",m);
if ( m < 2 || m > max ) //вырожденный многоугольник
printf ("Вырожденный многоугольник");
else {
float *px,*py,*pc;
px = new float[m];
py = new float[m];
pc = new float[2];
pc[0] = pc[1] = 0;
while(i<m) {
fscanf(f, "%f %f",&px[i], &py[i]);
printf("\n%f %f",px[i], py[i]);
i++;
}
centr(m,px,py,pc);
printf ("\nЦентр тяжести имеет координаты:
(%.4f, %.4f)",pc[0],pc[1]);
delete [] pc;
delete [] py;
delete [] px;
}
fclose(f);
}
system("pause");
return 0;
}
void centr(int n,float *x, float *y, float *c){
//n - количество вершин,
//x,y - координаты вершин,
//c - координаты центра тяжести
if(n==2){ //база рекурсии
c[0]=(x[0]+x[1])/2;
c[1]=(y[0]+y[1])/2;
}
if(n>2) { //декомпозиция
centr(n-1,x,y,c);
c[0]= (x[n-1] + (n-1)*c[0])/n;
c[1]= (y[n-1] + (n-1)*c[1])/n;
}
}
Характеристиками рассматриваемого метода оценки алгоритма будут следующие величины.
27
D=4
R ( D)  3
D=n
R( D)  n  1
RV ( D )  1
RV ( D)  n  3
RL ( D )  1
RL ( D )  1
H R ( D)  3
H R ( D)  n  1
Однако в данном случае для более достоверной оценки необходимо учитывать емкостные характеристики алгоритма.
Пример 3. Задача о разбиении целого на части.
Найдите количество разбиений натурального числа на сумму натуральных
слагаемых.
Разбиение подразумевает представление натурального числа в виде
суммы натуральных слагаемых, при этом суммы должны отличаться набором чисел, а не их последовательностью. В разбиение также может входить одно число.
Например, разбиение числа 6 будет представлено 11 комбинациями:
6
5+1
4+2, 4+1+1
3+3, 3+2+1, 3+1+1+1
2+2+2, 2+2+1+1, 2+1+1+1+1
1+1+1+1+1+1
Рассмотрим решение в общем виде. Пусть зависимость R(n, k ) вычисляет количество разбиений числа n на сумму слагаемых, не превосходящих k. Опишем свойства R(n, k ) .
Если в сумме все слагаемые не превосходят 1, то такое представление единственно, то есть R(n, k )  1.
Если рассматриваемое число равно 1, то при любом натуральном
значении второго параметра разбиение также единственно: R(1, k )  1 .
Если второй параметр превосходи значение первого (n  k ) , то имеет
место равенство R(n, k )  R(n, n) , так как для представления натурального
числа в сумму не могут входить числа, превосходящие его.
Если в сумму входит слагаемое, равное первому параметру, то такое
представление также единственно (содержит только это слагаемое), поэтому имеет место равенство: R(n, n)  R(n, n  1)  1.
Осталось рассмотреть случай (n  k ) . Разобьем все представления
числа n на непересекающиеся разложения: в одни обязательно будет входить слагаемое k, а другие суммы не содержат k. Первая группа сумм, со28
держащая k, эквивалентна зависимости R(n  k , k ) , что следует после вычитания числа k из каждой суммы. Вторая группа сумм содержит разбиение числа n на слагаемые, каждое из которых не превосходит k  1 , то есть
число таких представлений равно R(n, k  1) . Так как обе группы сумм не
пересекаются, то R(n, k )  R(n  k , k )  R(n, k  1) .
Разработаем рекурсивную триаду.
Параметризация: Рассмотрим разбиение натурального числа n на
сумму таких слагаемых, которые не превосходят натурального числа k.
База рекурсии: исходя из свойств рассмотренной зависимости, выделяются два базовых случая:
при n  1 R(n, k )  1,
при k  1 R(n, k )  1.
Декомпозиция: общий случай задачи сводится к трем случаям, которые и составляют декомпозицонные отношения.
при n  k R(n, k )  R(n, n  1)  1 ,
при n  k R(n, k )  R(n, n) ,
при n  k R(n, k )  R(n  k , k )  R(n, k  1) .
#include "stdafx.h"
#include <iostream>
using namespace std;
unsigned long int Razbienie(unsigned long int n,
unsigned long int k);
int _tmain(int argc, _TCHAR* argv[]){
unsigned long int number, max,num;
printf ("\nВведите натуральное число: ");
scanf ("%d", &number);
printf ("Введите максимальное натуральное слагаемое в
сумме: ");
scanf ("%d", &max);
num=Razbienie(number,max);
printf ("Число %d можно представить в виде суммы с
максимальным слагаемым %d.", number, max);
printf ("\nКоличество разбиений равно %d",num);
system("pause");
return 0;
}
unsigned long int Razbienie(unsigned long int n,
unsigned long int k){
if(n==1 || k==1) return 1;
if(n<=k) return Razbienie(n,n-1)+1;
return Razbienie(n,k-1)+Razbienie(n-k,k);
}
29
Пример 4. Задача о переводе натурального числа в шестнадцатеричную
систему счисления.
Дано натуральное число, не выходящее за пределы типа unsigned long.
Число представлено в десятичной системе счисления. Переведите его в систему счисления с основанием 16.
Пусть требуется перевести целое число n из десятичной в р-ичную
систему счисления (по условию задачи, р = 16), то есть найти такое k, чтобы выполнялось равенство n10  k p .
Параметризация: n – данное натуральное число, р – основание системы счисления.
База рекурсии: на основании правил перевода чисел из десятичной
системы в систему счисления с основанием р, деление нацело на основание системы выполняется до тех пор, пока неполное частное не станет
равным нулю, то есть: если целая часть частного n и р равна нулю, то k = n.
Данное условие можно реализовать иначе, сравнив n и р: целая часть частного равна нулю, если n < р.
Декомпозиция: в общем случае k формируется из цифр целой части
частного n и р, представленной в системе счисления с основанием р, и
остатка от деления n на p.
#include "stdafx.h"
#include <iostream>
using namespace std;
#define maxline 50
void perevod( unsigned long n, unsigned int p,FILE *pf);
int _tmain(int argc, _TCHAR* argv[]){
unsigned long number10;
unsigned int osn=16;
char number16[maxline];
FILE *f;
if ((f=fopen("out.txt", "w"))==NULL)
perror("out.txt");
else {
printf ("\nВведите число в десятичной системе: ");
scanf("%ld", &number10);
perevod(number10, osn, f);
fclose(f);
}
if ((f=fopen("out.txt", "r"))==NULL)
perror("out.txt");
else {
fscanf(f,"%s",number16);
printf("\n %ld(10)=%s(16)", number10, number16);
fclose(f);
}
30
system("pause");
return 0;
}
void perevod(unsigned long n, unsigned int p, FILE *pf){
char c;
unsigned int r;
if(n >= p) perevod (n/p, p, pf);//декомпозиция
r=n%p;
c=r < 10 ? char (r+48) : char (r+55);
putc(c, pf);
}
Ключевые термины
База рекурсии – это тривиальный случай, при котором решение задачи
очевидно, то есть не требуется обращение функции к себе.
Глубина рекурсивных вызовов – это наибольшее одновременное количество рекурсивных обращений функции, определяющее максимальное количество слоев рекурсивного стека.
Декомпозиция – это выражение общего случая через более простые
подзадачи с измененными параметрами.
Корень полного дерева рекурсивных вызовов – это вершина полного
дерева рекурсии, соответствующая начальному обращению к функции.
Косвенная (взаимная) рекурсия – это последовательность взаимных
вызовов нескольких функций, организованная в виде циклического
замыкания на тело первоначальной функции, но с иным набором параметров.
Объем рекурсии  это характеристика сложности рекурсивных вычислений для конкретного набора параметров, представляющая собой
количество вершин полного рекурсивного дерева без единицы.
Параметризация – это выделение из постановки задачи параметров, которые используются для описания условия задачи и решения.
Полное дерево рекурсии – это граф, вершинами которого являются наборы фактических параметров при всех вызовах функции, начиная с первого обращения к ней, а ребрами – пары таких наборов, соответствующих взаимным вызовам.
Прямая рекурсия – это непосредственное обращение рекурсивной
функции к себе, но с иным набором входных данных.
Рекурсивная триада – это этапы решения задач рекурсивным методом.
Рекурсивная функция – это функция, которая в своем теле содержит
обращение к самой себе с измененным набором параметров.
Рекурсивный алгоритм – это алгоритм, в определении которого содержится прямой или косвенный вызов этого же алгоритма.
Рекурсия – это определение объекта посредством ссылки на себя.
31
Краткие итоги
1. Рекурсия характеризуется определением объекта посредством ссылки на себя.
2. Рекурсивные алгоритмы содержат в своем теле прямое или опосредованное обращение с самим себе.
3. Рекурсивные функции содержат в своем теле обращение к самим себе с измененным набором параметров в виде прямой рекурсии. При
этом обращение к себе может быть организовано посредством косвенной рекурсии – через цепочку взаимных обращений функций, замыкающихся в итоге на первоначальную функцию.
4. Решение задач рекурсивными способами проводится посредством
разработки рекурсивной триады.
5. Целесообразность применения рекурсии в программировании обусловлена спецификой задач, в постановке которых явно или опосредовано указывается на возможность сведения задачи к подзадачам,
аналогичным самой задаче.
6. Рекурсивные методы решения задач широко используются при моделировании задач из различных предметных областей.
7. Рекурсивные алгоритмы относятся к ресурсоемким алгоритмам. Для
оценки сложности рекурсивных алгоритмов учитывается число вершин полного рекурсивного дерева, количество передаваемых параметров, временные затраты на организацию стековых слоев.
Материалы для практики
Вопросы
1. Можно ли случай косвенной рекурсии свести к прямой рекурсии?
Ответ обоснуйте.
2. Может ли рекурсивная база содержать несколько тривиальных случаев? Ответ обоснуйте.
3. Являются ли параметры, база и декомпозиция единственными для
конкретной задачи? Ответ обоснуйте.
4. С какой целью в задачах происходит пересмотр или корректировка
выбранных параметров, выделенной базы или случая декомпозиции?
5. Является ли рекурсия универсальным способом решения задач? Ответ обоснуйте.
6. Почему для оценки трудоемкости рекурсивного алгоритма недостаточно одного метода подсчета вершин рекурсивного дерева?
7. Выполните оценку алгоритма из Примера 3 методом подсчета вершин рекурсивного дерева для случая n = 6, k = 6.
32
Упражнения
1. Наберите коды программ из Примеров 1-4. Выполните компиляцию
и запуск программ.
2. Разработайте рекурсивную функцию, подсчитывающую количество
способов разбиения выпуклого многоугольника на треугольники непересекающимися диагоналями.
3. В Фибоначчиевой системе счисления числа формируются по правилам.
 Используются только символы 0 и 1;
 Каждый разряд соответствует элементу последовательности
Фибоначчи 1, 2, 3, 5, 8, …, то есть указывает на наличие или отсутствие такового;
 В соседних разрядах не могут стоять символы 1, так как это автоматически означает формирование следующего за ними разряда.
Например, 1710 = 1310 + 310 + 110 = 100101ф.
Составьте программу перевода числа из десятичной системы в
Фибоначчиевую. Считать входные данные введенными корректно.
4. Найдите походящие дроби рационального числа x / y (х –
5
1
неотрицательно, у – положительно). Например,  0 
, то есть
1
6
1
5
для х = 5, у = 6 ответом будет последовательность [0; 1, 5].
5. Вычислите определитель квадратной матрицы размера n  n .
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Головешкин В.В., Ульянов М.В. Теория рекурсии для программистов
/ В.А. Головешкин, М.В. Ульянов. – М.: ФИЗМАТЛИТ, 2006. – 296 с.
3. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 1: Корзина разнообразных задач / А.Р. Есаян. – Тула:
Изд-во ТГПУ им. Л. Н. Толстого, 2000. – 90 с.
4. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 2: Матрицы / А.Р. Есаян. – Тула: Изд-во ТГПУ им. Л.
Н. Толстого, 2000. – 95 с.
5. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
6. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
33
3. Решение задач на использование
рекурсивных алгоритмов
Краткая аннотация
В данной теме рассматриваются опорные схемы решения задач рекурсивными способами, приводятся примеры разработки рекурсивных функций с
помощью опорных схем.
Цель изучения темы
Изучить рекурсивные алгоритмы и основные схемы решения задач рекурсивными способами, научиться применять рекурсивные алгоритмы при
решении задач на языке C++.
3.1. Основные методы решения задач с помощью рекурсии
В построении алгоритмов решения задачи важным является формирование общих подходов к выбору способа решения. Универсального метода, гарантирующего верный и оптимальный алгоритм решения для любой задачи, не существует. В частности, сведение решения к выбору итерационного или рекурсивного способа построения алгоритма зависит от
результатов анализа постановки задачи. Поэтому необходимо иметь представление о возможных направлениях анализа решаемой задачи.
Разбиение задачи на подзадачи. Метод процедурной абстракции, положенный в основу процедурного программирования, предполагает выделение в задаче отдельных модулей, в дальнейшем реализуемых посредством функций. В процессе анализа задачи возможны случаи:
 разбиение условий задачи на части;
 разбиение требований задачи на части;
 разбиение области определения задачи на части.
Преобразования задачи. Последовательные преобразования решаемой задачи в цепочку эквивалентных задач сводятся к получению задачи,
решение которой может быть получено более простым способом или уже
известно. При этом эквивалентность задач понимается как совпадение их
множеств решений, а преобразование должно не менять языка, ее записи.
В противном случае это уже будет не преобразование, а моделирование.
Моделирование. В процессе работы над условием происходит замена
исходной задачи ее моделью: текстовая задача переводится в уравнение,
систему уравнений или неравенств. При этом проводится детальное исследование возможных ошибок или погрешностей метода, учет которых входит в алгоритмизацию задачи.
Введение вспомогательных элементов. В постановке задачи не всегда явно указывается набор данных, которые оказывают влияние на получение результата. Например, решение квадратного уравнения на множе34
стве действительных чисел сводится к вычислению и анализу значения
дискриминанта, о котором в постановке задачи ничего не сказано. Выделим следующие случаи:
 введение недостающих по смыслу задачи элементов между данными и искомыми элементами (дополнительное построение на
чертеже, новые переменные для составления уравнений и т.п.);
 преднамеренное погружение задачи в большую размерность, то
есть введение дополнительных параметров, не связанных с существом задачи.
С учетом вышеизложенного рассмотрим существующие подходы к
выбору рекурсии как метода решения задач, то есть выделим основные
опорные схемы рекурсивных вычислений:
 «Увидеть»;
 «Переформулировать»;
 «Обобщить»;
 «Использовать характеристическое свойство»;
 «Перенести часть условий в проверку»;
 «Обратить функцию»;
 «Найти родственника».
Опорные схемы по своей сути не являются реальной классификацией
методов решения задач с использованием рекурсии. Одна и та же задача,
исследуемая с опорой на разные схемы, может приводить к одному и тому
же рекурсивному алгоритму. Более того, иногда достаточно трудно однозначно утверждать, что при решении задачи применялась именно конкретная схема. Однако опорные схемы определяют подходы к анализу условия
задачи, опираясь на которые можно выработать метод ее решения.
3.2. Опорная схема «Увидеть»
Данная опорная схема является наиболее естественной, так как содержится в постановке задачи. Для разработки триады достаточно использовать параметры, тривиальный случай и соотношения, непосредственно
вытекающие из условия.
3.3. Опорная схема «Переформулировать»
Часто в условии задачи не только не обозначена рекурсия, но и сама
задача не является алгоритмически сформулированной. Иногда ее простая
перефразировка, а чаще построение математической модели позволяют
обнаружить первоначально скрытую рекурсию.
Рассмотрим задачу о динамике вклада. Большой выбор простых содержательных задач, допускающих рекурсивное решение, можно встретить в сфере банковской деятельности. Рассмотрим несколько различных
рекурсивных вариантов решения задачи о динамике вклада.
35
Вкладчик положил в банк сумму в sum денежных единиц под p процентов за один период времени. Составим функцию, возвращающую величину вклада по истечении n периодов времени.
Вычисление значения величины вклада можно проводить по известn
p 

ной формуле сложных процентов: sum 1 
 . Но рассмотрим рекур 100 
сивный вариант алгоритма решения задачи.
Параметризация: выбор параметров следует непосредственно из
условия задачи, то есть sum – первоначальный размер положенной суммы,
p – процент вклада, n – количество периодов хранения вклада.
База рекурсии: для n  0 размер суммы не изменится, то есть останется sum.
Декомпозиция: если n  0 , то размер вклада вычисляется как сумма
за (n  1) периодов, увеличенная на процент p.
float Deposit(float sum, float p, int n){
if(n==0) return sum; //база рекурсии
return Deposit(sum,p,n-1)*(1+p/100); //декомпозиция
}
Общее
количество рекурсивных вызовов при вычислении
Deposit(sum, p, n) равно n. Можно уменьшить это значение до величины порядка O(log 2 n  1) исходя из следующих двух декомпозиционных
посылок, описывающих случаи четного и нечетного n.
float DepositNew(float sum, float p, int n){
if (n==0) return sum ;// база рекурсии
if (n%2==0) //декомпозиция для четного n
return sum*pow(DepositNew(1.0,p,n/2),2);
//декомпозиция для нечетного n
return sum*(1+p/100)*DepositNew(1.0,p,n-1);
}
3.4. Опорная схема «Обобщить»
Если из постановки задачи рекурсию извлечь не удается, то за счет
перехода к ее некоторому обобщению иногда это сделать возможно. Как
правило, это обобщение протекает за счет введения дополнительных параметров, то есть намеренного погружения исходной задачи в пространство
большей размерности, чем это обусловлено ее основными параметрами.
Поэтому данную опорную схему иногда называют «Погрузить» или «Вложить». Использование рассматриваемой схемы предполагает, что из решения обобщенной задачи может быть получено решение исходной задачи. В
некоторых случаях данная схема может быть использована для улучшения
быстродействия алгоритма или для перехода от одного типа рекурсии к
другому. При этом «Обобщение» является наиболее общей и часто исполь36
зуемой схемой при решении многих задач рекурсивными алгоритмами.
Стоит отметить еще одно обстоятельство, связанное с данной схемой. Имея свободу выбора обобщения исходной задачи, мы, тем не менее,
ограничены жесткими рамками, регламентирующими этот выбор: решение
(доказательство) обобщения должно быть по возможности простым и из
него должно легко выделяться решение исходной задачи.
Рассмотрим задачу под названием «Абракадабра». Последовательность из латинских букв строится следующим образом. На нулевом шаге
она пуста. На каждом последующем шаге последовательность удваивается,
то есть приписывается сама к себе, и к ней слева добавляется очередная
буква алфавита (a, b, c, …). По заданному числу n определить символ, который стоит на n-м месте последовательности, получившейся после шага
26.
Приведем первые шаги формирования последовательности: 0  пустая последовательность, 1  «a», 2  «baa», 3  «cbaabaa», 4 
«dcbaabaacbaabaa» и так далее по закономерности. Данный процесс носит
рекурсивный характер.
Параметризация. Построим более общую функцию, чем это требуется по условиям задачи. Пусть значение функции Abra(k,n)  n-я буква
в последовательности, полученной на шаге k (k = 1, …, 26). Будем возвращать значение функции в виде целочисленного кода, соответствующего
требуемому символу.
База рекурсии. Значение Abra(k,1) равно k-й букве латинского алфавита. Этот факт можно взять в качестве базы рекурсии.
Декомпозицию удобно организовать по k, проводя «раскрутку» последовательности по шагам в обратном направлении. Это приводит к следующей зависимости:
k 1
 если n  2 , то искомый символ находится на (n  1) месте в латинском алфавите;
k 1
k 1
 если n  2 , то искомый символ находится на ( 2 ) месте в латинском алфавите.
int Abra(int k, int n){
if (n > pow(2, k-1)-1 || k > 26) return 0;
//корректность входных данных
if (n == 1) return k+96; //база рекурсии
return Abra(k-1, n-(n <= pow(2, k-1) ? 1 : pow(2, k-1)));
//декомпозиция
}
3.5. Опорная схема «Характеристические свойства»
Совокупность всех или части условий любой задачи, оформленная в
виде некоторого предиката над наборами входных данных и возможных
результатов, назовем характеристическим свойством задачи. Если в пре37
дикате задействованы все условия задачи, то характеристическое свойство
и соответствующий предикат назовем полным, если нет,  частичным.
Формальная запись полного или частичного предиката, с одной стороны,
позволяет проводить независимую проверку правильности работы ранее
разработанных алгоритмов решения данной задачи, а с другой стороны,
может оказать существенную помощь для отыскания новых рекурсивных
алгоритмов ее решения. Остановимся на примерах, иллюстрирующих второй вариант использования характеристических свойств задачи.
Рассмотрим задачу о «Допустимых последовательностях». Последовательность Q(N ) длины N, составленная из символов 0 и 1, называется
допустимой, если в ней нет двух подряд идущих символов 1. В противном
случае Q(N ) называется недопустимой. Определим K (N )  общее количество допустимых последовательностей для натурального значения N.
Методом полного перебора эту задачу можно решить лишь при небольших значениях N, так как количество всевозможных последовательностей равно 2 N .
Пусть набор представлен в виде вектора v с компонентами 0 и 1 (с
нумерацией их от 0 до N  1). Определим предикат P(v) , истинный только
на допустимых наборах v. Формализованная запись этого предиката выглядит следующим образом:
P(v)  ((0  i  N  2))(((vi  1)(vi 1  1))) 
 ((0  i  N  2))((vi  1)  (vi 1  0)  (vi  0)  (vi 1  0)  (vi  0)  (vi 1  1)) 
 ((0  i  N  2))((vi 1  0)  ((vi 1  1)  (vi  0))) 
 (((0  i  N  3))((vi 1  0)  ((vi 1  1)  (vi  0)))) 
 ((v N 1  0)  ((v N 1  1)  (v N 2  0))) 
 ((((0  i  N  3))((vi 1  0)  ((vi 1  1)  (vi  0))))  (v N 1  0)) 
 ((((0  i  N  3))((vi 1  0)  ((vi 1  1)  (vi  0))))  ((v N 2  0)  (v N 1  1)))
Фактически формальными преобразованиями предиката мы получили его декомпозицию, то есть множество M (N ) допустимых векторов
длины N (N  3) можно представить в виде объединения двух непересекающихся подмножеств:

 0  
M ( N )  M  N  1  0   M N  2     .
 1  

декартовыми произведениями
M N  1  0 и
T
M N  2  0,1 понимаются множества векторов длины N. В первом
случае последняя компонента векторов равна 0, а первые N  1 компонентов составляют допустимые векторы длиной N  1. Во втором случае последние две компоненты равны соответственно 0 и 1, а первые N  2 компоненты составляют допустимые векторы длины N  2 . Кроме того:
Здесь

под

38


M (1)  (0), (1) ; M (2)  (0, 0)T , (0, 1)T , (1, 0)T . Отсюда вытекает справедливость следующего рекуррентного соотношения:
K (1)  2, K (2)  3, , K ( N )  K ( N  1)  K ( N  2) (N  3).
Таким образом, K ( N )  F ( N  2) , то есть искомая последовательность K (N ) есть сдвиг последовательности Фибоначчи F (N ) на два элемента влево. Для вычисления K (N ) можно написать рекурсивную функцию, аналогичную функции вычисления членов последовательности
Фибоначчи.
3.6. Опорная схема «Перенести часть условий в проверку»
Во многих задачах, сводящихся к рекурсивным алгоритмам, рекурсия в явном виде сразу не обнаруживается или достаточно сложна для алгоритмической реализации. Однако удаление части условий из задачи приводит к новой вспомогательной задаче, рекурсивный алгоритм решения
которой строится достаточно несложно. В этом случае чтобы узнать, является ли полученный для новой задачи ответ (ответы) решением исходной
задачи, необходимо проверить, выполняются ли для него ранее удаленные
условия или нет.
Если решение задачи сводится к вычислению значения истинности
некоторого предиката, непосредственно построенного из конъюнкции
условий задачи на наборах входных данных, то описанная схема допускает
возможность проверки выполнимости удаляемых условий как до использования рекурсивного алгоритма решения вспомогательной задачи, так и
после этого. При этом рассматриваемая опорная схема может использоваться как конкретная разновидность схемы «Обобщить». Перенося одно
или несколько условий исходной задачи в проверку, то есть, фактически
временно отбрасывая их, мы получаем новую задачу, являющуюся естественных обобщением решаемой задачи. Иногда бывает удобно или более
естественно схему «Перенести часть условий в проверку» интерпретировать как разновидность схемы «Переформулировать».
3.7. Опорная схема «Обратить функцию»
Задачи на обращение функций являются достаточно распространенными. Иногда возникает вопрос об обращении функций, заданных посредством алгоритмов. Пусть, например, относительно параметра x  X решается уравнение вида f ( x)  a при некотором известном рекурсивном алгоритме f и заданной величине a  Y , где X и Y  множества. Тогда знание
обратной для f рекурсивной функции g ( y) ( y Y ) сразу же позволило бы
решить исходное уравнение: f ( x)  a , g ( f ( x))  g (a) , x  g (a) .
Поэтому умение «обратить» алгоритм является хотя и непростым, но
достаточно полезным и эффективным подспорьем в решении задач рекур39
сивными методами. Реальное использование данной опорной схемы проводится так. Алгоритм решения исходной задачи нам неизвестен. Возможно, он не подходит по причине сложности или плохой эффективности. Однако для какой-либо из обратных для решаемой задачи удается построить
алгоритм решения. В некоторых случаях простые рассуждения, приводящие к незначительным изменениям обратной задачи, позволяют получить
конкретный искомый алгоритм.
Для рассуждений и изменений сложно дать какие-либо общие рекомендации, так как они жестко связаны с содержанием рассматриваемой задачи, выбором для нее обратной задачи и алгоритма решения. Многое
здесь зависит от того, какую из обратных задач мы выбрали.
3.8. Опорная схема «Найти родственника»
Иногда исходная задача естественным образом распадается на две
или более вспомогательные родственные задачи так, что в совокупности,
взаимно дополняя друг друга, они уже будут определять вполне просматриваемую косвенную рекурсию.
Рассмотрим пример, связанный с экзотическими средними. Пусть a0
и b0  два положительных числа ( a0  b0 ). Составим их среднее арифметическое и среднее геометрическое. Продолжим этот процесс рекурсивно.
Если числа an и bn уже построены, то определим a n1 и bn 1 следующим
образом:
a  bn
an1  n
,
bn1  an  bn
(n  0,1, ...).
2
Можно показать, что a0  an  an1  bn1  bn b 0 (n  1, 2, ...) . Откуда
вытекает, что обе последовательности an  и bn  с двух разных сторон
монотонно стремятся к общему пределу, который называют средним
арифметико-геометрическим или экзотическим средним исходных чисел
a0 и b0 . Таким образом, при любом заданном n (n  0, 1, 2, ...) числа an и
bn служат приближениями сверху и снизу для среднего арифметикогеометрического a0 и b0 . Для поиска экзотического среднего можно составить функцию, реализующую косвенную рекурсию. При этом параметризация, база и декомпозиция в явном виде приведены в задаче.
#include "stdafx.h"
#include <iostream>
using namespace std;
float Arifm(int n, float a, float b);
float Geom(int n, float a, float b);
int _tmain(int argc, _TCHAR* argv[]){
int n;
float a,b;
40
do {
printf ("a>0, a=");
scanf("%f",&a);
printf ("b>0, b=");
scanf("%f",&b);
printf ("n>0, n=");
scanf("%d",&n); }
while (a<=0 || b<=0 || n<=0);
printf("Exotic: between %f and %f",
Arifm(n,a,b),Geom(n,a,b));
system("pause");
return 0;
}
float Arifm(int n, float a, float b){
if (n==0) return a;
return (Arifm (n-1,a,b)+Geom(n-1,a,b))/2;
}
float Geom(int n, float a, float b){
if (n==0) return b;
return pow(double(Arifm (n-1,a,b)*Geom(n-1,a,b)),0.5);
}
Ключевые термины
«Использовать характеристическое свойство» – это опорная схема
решения задачи рекурсивными способами, которая предполагает
строить решение на общем свойстве, которым обладают представленные в задаче объекты.
«Найти родственника» – это опорная схема решения задачи рекурсивными способами, которая предполагает разделение задачи естественным образом на две или более вспомогательные родственные
задачи так, что в совокупности, взаимно дополняя друг друга, они
уже будут определять рекурсию.
«Обобщить» – это опорная схема решения задачи рекурсивными способами, которая предполагает решение задачи в общем виде с целью
нахождения частного решения.
«Обратить функцию» – это опорная схема решения задачи рекурсивными способами, которая предполагает перейти от задачи к решению
обратной для нее.
«Перенести часть условий в проверку» – это опорная схема решения
задачи рекурсивными способами, которая предполагает упрощение
рекурсивных отношений за счет сведения задачи к эквивалентной
подзадаче, отличающейся от исходной рядом условий.
41
«Переформулировать» – это опорная схема решения задачи рекурсивными способами, которая предполагает перефразировать условие
или построить математическую модель с целью обнаружить первоначально скрытую рекурсию.
«Увидеть» – это опорная схема решения задачи рекурсивными способами, которая предполагает использовать рекурсию, заданную условии в явном виде.
Введение вспомогательных элементов – это прием использования при
решении задачи дополнительных параметров, явно не указанных в
постановке задачи.
Моделирование – это замена исходной задачи ее моделью в виде математических описаний.
Опорные схемы рекурсивных вычислений – это подходы к выбору рекурсии как метода решения задач.
Преобразования задачи – это последовательные модификации решаемой задачи в цепочку эквивалентных задач, решение которых может
быть получено более простым способом или уже известно.
Разбиение задачи на подзадачи – это выделение в задаче отдельных
модулей, в дальнейшем реализуемых посредством функций.
Краткие итоги
1. Решение задач рекурсивными способами не всегда явно следует из
постановки задачи.
2. Рекурсия не является универсальным методом построения алгоритмов. Ее следует рассматривать как альтернативный итерационному
метод.
3. Опорные схемы решения задач рекурсивными способами являются
направлениями, задающими ход рассуждений при разработке триады.
4. «Использовать характеристическое свойство» является опорной схемой решения задачи рекурсивными способами, которая предполагает строить решение на общем свойстве, которым обладают представленные в задаче объекты.
5. «Найти родственника» является опорной схемой решения задачи рекурсивными способами, которая предполагает разделение задачи
естественным образом на две или более вспомогательные родственные задачи так, что в совокупности, взаимно дополняя друг друга,
они уже будут определять рекурсию.
6. «Обобщить» является опорной схемой решения задачи рекурсивными способами, которая предполагает решение задачи в общем виде с
целью нахождения частного решения.
7. «Обратить функцию» является опорной схемой решения задачи рекурсивными способами, которая предполагает перейти от задачи к
решению обратной для нее.
42
8. «Перенести часть условий в проверку» является опорной схемой решения задачи рекурсивными способами, которая предполагает
упрощение рекурсивных отношений за счет сведения задачи к эквивалентной подзадаче, отличающейся от исходной рядом условий.
9. «Переформулировать» является опорной схемой решения задачи рекурсивными способами, которая предполагает перефразировать
условие или построить математическую модель с целью обнаружить
первоначально скрытую рекурсию.
10. «Увидеть» является опорной схемой решения задачи рекурсивными
способами, которая предполагает использовать рекурсию, заданную
условии в явном виде.
Материалы для практики
Вопросы
1. Почему рекурсию нельзя рассматривать как универсальный метод
решения задач?
2. Почему опорные схемы решения задач рекурсивными способами не
являются жестко привязанными к отдельным классам задач?
3. Каким образом анализ решения обратной задачи может привести к
решению поставленной задачи?
4. По каким признакам «находится родственник» в одноименной опорной схеме?
5. Всегда ли отбрасывание условий и переход к подзадаче могут привести к эквивалентной задаче? Обоснуйте ответ примерами.
6. Какие свойства объектов выступают в роли характеристических в
соответствующей опорной схеме?
Упражнения
1. Два многочлена заданы своими степенями и коэффициентами. Выполните умножение данных многочленов. Выведите в файл коэффициенты результата в порядке убывания степеней его одночленов.
2. Найдите сумму факториалов первых n натуральных чисел. Решите
двумя способами: через непосредственное вычисление факториалов
и с помощью преобразования декомпозиционных отношений. Оцените трудоемкость функции в каждом случае.
3. Для данных натуральных n и m найдите цепную дробь, соответствующую отношению n/m.
4. Вычислите значение функции Аккермана двумя способами: непосредственно из определения и снизив трудоемкость алгоритма.
Найдите каждым способом Akkerman(3, 7) и Akkerman(8, 20).
Функция Аккермана определяется рекурсивно для неотрицательных
целых чисел m и n следующим образом:
43
n  1, при m  0;

A(m, n)   A(m  1, 1), при m  0, n  0;
 A(m  1, A(m, n  1)), при m  0, n  0.

5. Первый член последовательности равен натуральному числу, сравнимому с 2 по модулю 3. Каждый следующий член последовательности равен сумме кубов цифр предыдущего члена. Исследуйте последовательности на сходимость для конкретного первого члена.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Головешкин В.В., Ульянов М.В. Теория рекурсии для программистов
/ В.А. Головешкин, М.В. Ульянов. – М.: ФИЗМАТЛИТ, 2006. – 296 с.
3. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 1: Корзина разнообразных задач / А.Р. Есаян. – Тула:
Изд-во ТГПУ им. Л. Н. Толстого, 2000. – 90 с.
4. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 2: Матрицы / А.Р. Есаян. – Тула: Изд-во ТГПУ им. Л.
Н. Толстого, 2000. – 95 с.
5. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
6. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
44
4. Алгоритм перебора с возвратом
Краткая аннотация
В данной теме рассматриваются общее и частное решения переборных задач, организация возвратной рекурсии, трудоемкость алгоритмов возвратной рекурсии, приводится пример решения задачи о расстановке ферзей на
шахматной доске методом рекурсии с возвратом.
Цель изучения темы
Изучить рекурсивный алгоритм перебора с возвратом, научиться разрабатывать рекурсивную триаду и алгоритм перебора с возвратом при решении
задач на языке C++.
4.1. Перебор с возвратом
Во многих практических задачах из различных предметных областей
требуется найти общее количество вариантов решения, число элементов в
полном наборе решений. Иногда, исходя из постановки задачи, достаточно
найти один из вариантов, соответствующих условию задачи. В некоторых
задачах изучается вопрос о существовании решения как такового.
Ответы на поставленные вопросы, как правило, требуют проведения
исчерпывающего поиска в некотором множестве всех возможных вариантов, среди которых находятся решения конкретной задачи. Существуют
два общих метода организации исчерпывающего поиска: перебор с возвратом (backtracking) и его естественное логическое дополнение  метод
решета.
Решение задачи методом перебора с возвратом строится конструктивно последовательным расширением частичного решения. Если на конкретном шаге такое расширение провести не удается, то происходит возврат к более короткому частичному решению, и попытки его расширить
продолжаются. Для ускорения перебора с возвратом вычисления всегда
стараются организовать так, чтобы была возможность отказаться как можно раньше от как можно большего числа заведомо неподходящих вариантов. Незначительные модификации метода перебора с возвратом, связанные с представлением данных или особенностями реализации, имеют и
иные названия: метод ветвей и границ (branch and bound), поиск в глубину
(depth first search), метод проб и ошибок и т. д. Перебор с возвратом практически одновременно и независимо был изобретен многими исследователями еще до его формального описания. Он находит применение при решении различных комбинаторных задач в области искусственного интеллекта.
При использовании метода решета вместо конструктивного построения решений задачи из множества возможных вариантов исключаются все
элементы, не являющиеся решениями. Методы решета нашли широкое
45
применение в теоретико-числовых задачах.
Метод перебора с возвратом и метод решета, строго говоря, не являются ни методами, ни алгоритмами решения задач. Их следует воспринимать как на некоторые общие схемы, которые применяются для решения
той или иной задачи. Реализация этих схем в виде конкретных алгоритмов
часто требует значительных дополнительных усилий в представлении данных и описании зависимостей между ними.
Соединение метода перебора с возвратом и рекурсии определяет
специфический способ реализации рекурсивных вычислений и называется
возвратной рекурсией. Это соединение двух эффективных методов реализации переборных алгоритмов.
При использовании возвратной рекурсии отпадает необходимость
непосредственно организовывать возвраты и отслеживать правильность их
осуществления. Они, как правило, становятся встроенной частью механизма выполнения рекурсивных вызовов.
4.2. Вычислительная схема перебора с возвратом
Опишем общую постановку класса задач, к которым заведомо применим алгоритм перебора с возвратом.
Пусть M 0 , M 1 , ... M n1  n конечных линейно упорядоченных множеств и G  совокупность ограничений (условий), ставящих в соответствие
T
векторам вида v  v0 , v1 , ..., v k  v j  M j ; j  0,1,..., k ; k  n  1, булево значение Gv  истина , ложь . Векторы v  v0 , v1 ,..., v k  , для которых
Gv   истина , назовем частичными решениями. Пусть, далее, существует
конкретное правило P, в соответствии с которым некоторые из частичных
решений могут объявляться полными решениями. Тогда возможна постановка следующих поисковых задач.
 Найти все полные решения или установить отсутствие таковых.
 Найти хотя бы одно полное решение или установить его отсутствие.
Общий метод решения приведенных задач состоит в последовательном покомпонентном наращивании вектора v слева направо, начиная с v 0 ,
и последующих проверках его ограничениями G и правилом P.
В общем случае этот метод приводит к алгоритмам с экспоненциальной временной сложностью, а применяется он в основном к классу так
называемых Np-полных задач (задача коммивояжера, задача о рюкзаке и
т. д.). Задачи этого класса эквивалентны друг другу в том смысле, что все
они разрешимы недетерминированными алгоритмами полиномиальной
сложности. Для них известно, что либо все они разрешимы, либо ни одна
из них не разрешима детерминированными алгоритмами полиномиальной
сложности. Иными словами, если хотя бы для одной из этих задач не существует детерминированного алгоритма, имеющего в худшем случае поT
46
линомиальную трудоемкость, то такие алгоритмы не должны существовать
и для остальных задач этого класса. Наоборот, если хотя бы для одной из
этих задач удалось найти детерминированный алгоритм, имеющий в худшем случае полиномиальную трудоемкость, то подобные алгоритмы существовали бы и для остальных задач этого класса и, более того, их можно
было бы построить.
Опишем схему выполнения недетерминированного алгоритма. Пусть
алгоритм выполняется до тех пор, пока не доходит до места, с которого
должен быть сделан выбор из нескольких альтернатив. Детерминированный алгоритм однозначно осуществит выбор конкретной альтернативы и
продолжит работать в соответствии с эти выбором. Недетерминированный
алгоритм исследует все возможности одновременно, как бы копируя себя
для реализации вычислений по всем альтернативам одновременно. Далее
все копии работают независимо друг от друга и по мере необходимости
продолжают создавать новые копии. Копия, сделавшая неправильный или
безрезультатный выбор прекращает свою работу. Копия, нашедшая решение задачи, объявляет об этом, давая тем самым сигнал другим копиям о
прекращении вычислений. Недетерминированные алгоритмы, являясь
весьма полезной и продуктивной абстракцией, рекурсивны по сути, ибо
при реализации оператора выбора фактически обращаются сами к себе.
Возможно, что многие задачи, решаемые методом перебора с возвратом, могут быть решены более эффективно другими способами. Однако
ценность метода перебора с возвратом в соединении с рекурсией неоспорима. Во-первых, программы решения многих задач строятся по единой
схеме, а во-вторых, они компактны и тем самым просты для понимания и
усвоения соответствующих идей.
4.3. Использование перебора с возвратом при решении задач
Пример 1. Задача о расстановке ферзей на шахматной доске.
Составьте рекурсивную функцию, находящую возможную расстановку n
ферзей на шахматной доске размером nn так, чтобы они не били друг
друга (n – натуральное число).
В соответствии с общей схемой алгоритма перебора с возвратом
предложенную задачу можно решать по алгоритму «Все расстановки», но
завершить выполнение алгоритма при нахождении первой требуемой расстановки или при получении вывода о невозможности получить нужную
комбинацию. Согласно алгоритму ферзи расставляются последовательно
на вертикалях с номерами от нуля и далее. В процессе выполнения предписания возможны снятия ферзей с доски (возвраты).
Алгоритм «Все расстановки»
Шаг 1. Полагаем D =  , j = 0 (D  множество решений, j  текущий
столбец для очередного ферзя).
Шаг 2. Пытаемся в столбце j продвинуть вниз по вертикали или но47
вый (если столбец j пустой), или уже имеющийся там ферзь на ближайшую
допустимую строку. Если это сделать не удалось, то переходим к шагу 4.
Шаг 3. Увеличиваем j на 1, то есть переходим к следующему столбцу. Если j  n  1, то переходим к шагу 2. В противном случае j  n  1 , то
есть все вертикали уже заняты. Найденное частичное решение запоминаем
в множестве D и переходим к шагу 2.
Шаг 4. Уменьшаем j на 1, то есть снимаем ферзь со столбца j и переходим к предыдущему столбцу. Если j  0 , то выполняем шаг 2. Иначе
вычисления прекращаем. Решения задачи находятся в множестве D, которое, может быть и пустым.
Решение задачи для небольших размеров доски можно найти «вручную», но для разработки алгоритма необходимо провести моделирование
условия и остановиться на каком-либо представлении данных.
Проведем параметризацию задачи. Введем четыре вспомогательных
вектора: pos, ho, dd и du c длинами n, n, 2n  1 и 2n  1 соответственно. Использовать их будем следующим образом (рис. 1):
 hoi  1 , если на горизонтали с номером i (i  0, 1, ... n  1) имеется
ферзь, и hoi  0  в противном случае;
 du s  1 , если на диагонали с номером s ( s  0, 1, ... 2n  2) , идущей
слева направо и снизу вверх, имеется ферзь, и du s  0  в противном случае;
 dd s  1 , если на диагонали с номером s ( s  0, 1, ... 2n  1) , идущей
слева направо и сверху вниз, имеется ферзь, и dd s 1  0  в противном случае;
 pos j  i , если в позиции (i, j ) (i, j  0, 1, ... n  1) стоит ферзь.
Использование этих соглашений позволяет получить такие утверждения:
(i, j )
 В
позицию
можно
поставить
ферзь,
если
hoi  dui  j  ddn i  j  0 .
 Поставить ферзь в позицию (i, j ) равносильно присваиваниям:
hoi  1 , dui  j  1, ddni  j  1 .
 Убрать ферзь из позиции (i, j ) равносильно присваиваниям:
hoi  0 , dui  j  0 , dd ni  j  0 .
48
0
1
2
3
4
5
6
7
8
0
0
1
1
2
2
3
3
4
4
5
5
6
6
7
7
9 10 11 12 13 14 15
i+j
8
9
0
1
2
3
4 5
6
7
10
11
12
n+i-j
13
14
ho (i)
pos (j)
dd
15
du
Рис. 1. Схема использования вспомогательных массивов в задаче
Данное описание алгоритма является моделью решения общей задачи о нахождении всех вариантов расстановок. Рекурсия здесь осуществляется по не совсем стандартной схеме. В каждом рекурсивном вызове глубины j делается попытка поместить ферзь в некоторую позицию i столбца j
(i, j  0, 1, ... n  1) , а сам вызов соответствует переходу от работы с текущим столбцом к работе со следующим столбцом. При этом в начале вычислений и при переходах к любому последующему рекурсивному вызову
параметр i меняется от нуля и далее с шагом, равным единице, пытаясь
принять значение наименьшего номера поля, допустимого для установки
ферзя. При переходах к любому предыдущему рекурсивному вызову параметр i продолжает изменяться от своего текущего на данном уровне значения с шагом, равным единице, также пытаясь принять значение наименьшего номера поля, допустимого для установки ферзя. Если в текущем
столбце ферзь установить уже не удается, то создавшуюся ситуацию назовем тупиком. Попадание в тупик приводит к завершению текущего рекурсивного вызова, то есть к возврату к предыдущему столбцу и продолжению работы с ним. Иных случаев завершения рекурсивных вызовов не существует. Поэтому базой рекурсии мы должны считать совокупность всех
тупиков. Заметим, что в данном случае элементы базы заранее до вычислений неизвестны.
49
После установки ферзя в одну из строк i последнего столбца j  n  1
формируется одно из решений задачи – при поиске одного варианта расстановки на этом этапе следует завершить выполнение алгоритма. При поиске всех расстановок вычисления прекращаются, когда мы попадаем в
тупик при работе со столбцом 0. Полученные решения задачи, если они
есть, возвращаются в виде столбцов матрицы otv, начиная от первого и далее.
Рассмотрим, как реализуется декомпозиция. Для этого вместо исходной задачи удобно решать ее следующее обобщение.
На доске размера nm (m  n, n  1, ... ,0) требуется установить m
ферзей так, чтобы они не били друг друга. При этом имеются некоторые
клетки доски, на которые ферзь заведомо ставить нельзя. Множество этих
«запретных» клеток обозначим через .
Исходная задача есть E (n, n,  ). Проведем ее декомпозицию. Представим доску в виде двух частей: нулевого столбца (A) и оставшейся части
(B). Соответственно этому разбиению будем решать задачи E (n, 1,  ) и
E (n, n1, ). Каждое из n возможных решений i (ферзь установлен в строке i  0, 1, ... n  1) первой задачи однозначно определяет множество
   (i) запретных клеток для второй задачи. При этом в  (i) попадают
те клетки B, которые в «объединенной» доске бьет ферзь, установленный в
строке i доски A. Пусть i зафиксировано и найдено множество d (i ) решений второй задачи E (n, n1, (i)) для доски B. Тогда расположение ферзя
в строке i доски A и любое из полученных решений x  d (i) на B в совокупности дают различные решения otv(i) исходной задачи E (n, n,  ). Для
получения всех решений этой задачи остается лишь взять объединение по
i множеств otv(i).
При анализе трудоемкости алгоритма получаем, что глубина рекурсии равна n 2  при каждом рекурсивном вызове по j ( j  0, 1, ... n  1 ) происходит n рекурсивных вызовов по i ( i  0, 1, ... n  1).
//нахождение одного варианта расстановки
#include "stdafx.h"
#include <iostream>
using namespace std;
void Initialization(int n, int ***x);
void Destruction(int n, int **x);
bool Queen(int n, int **x);
void Placement(int n, int **a, int *p, int *h, int *du,
int *dd, int i=0, int j=0);
int _tmain(int argc, _TCHAR* argv[]){
int n, i, j;
bool otv;
50
int **mas;
printf("Введите размер шахматной доски n : ");
scanf ("%d",&n);
Initialization(n,&mas);
otv = Queen(n,mas);
if ( otv ) printf("Правильное размещение найдено\n");
else printf("Правильного размещения не найдено\n");
Destruction(n,mas);
system("pause");
return 0;
}
//инициализация поля шахматной доски
void Initialization(int n, int ***x){
*x = new int*[n];
for (int i = 0 ; i < n; i++ ){
(*x)[i] = new int[n];
for (int j = 0 ; j < n ; j++ )
(*x)[i][j] = 0;
}
}
//вывод найденной расстановки в файл
void Destruction(int n, int **x){
FILE *f;
if( ( f = fopen("out.txt","w") ) == NULL ){
printf("Файл out.txt не может быть открыт для записи");
}
else{
fprintf(f,"%d\n",n);
for (int i = 0 ; i < n; i++ ){
for (int j = 0 ; j < n ; j++ )
fprintf(f,"%2d",x[i][j]);
fprintf(f,"\n");
}
}
for (int i = 0 ; i < n; i++ )
delete [] x[i];
delete [] x;
}
//проверка возможности постановки ферзя
bool Queen(int n, int **x){
bool rez = false;
int *p, *h, *du, *dd;
p = new int[n];
h = new int[n];
51
du = new int[2 * n - 1];
dd = new int[2 * n - 1];
for ( int i = 0 ; i < n ; i++ )
p[i] = h[i] = 0;
for ( int i = 0 ; i < (2 * n - 1) ; i++ )
du[i] = dd[i] = 0;
Placement(n,x,p,h,du,dd);
if ( h[0] != 0 ) rez = true;
delete [] dd, du, h, p;
return rez;
}
//описание функции расстановки ферзей
void Placement(int n, int **a, int *p, int *h, int *du,
int *dd, int i, int j){
if ( j >= 0 && j < n )
if ( i < n )
if (h[i]==0 && du[i+j] == 0 && dd[n+i-j-1] == 0 ) {
h[i] = 1;
du[i+j] = 1;
dd[n+i-j-1] = 1;
p[j] = i;
a[i][j] = 1;
Placement(n,a,p,h,du,dd,0,j+1);
}
else
Placement(n,a,p,h,du,dd,i+1,j);
else
if ( j > 0 ) {
h[p[j-1]] = 0;
du[p[j-1]+j-1] = 0;
dd[n+p[j-1]-(j-1)-1] = 0;
a[p[j-1]][j-1] = 0;
Placement(n,a,p,h,du,dd,p[j-1]+1,j-1);
}
}
Пример 2. Задача о количестве расстановок ферзей на шахматной доске.
Составить рекурсивную функцию, находящую количество возможных расстановок n ферзей на шахматной доске размером nn так, чтобы они не били друг друга.
Предложенную задачу можно решать по приведенным в Примере 1
функциям, упростив их следующим образом. Вместо запоминания найденных решений будем подсчитывать в переменной otv их количество.
Пример 3. Задача об основных расстановках.
Для формулировки следующей задачи введем понятие. Среди всех
52
расстановок n ферзей на доске nn выделим отдельные непересекающиеся
классы H s ( s  0, 1, ..., q) расстановок так, что все элементы данного класса можно получить из любого его представителя какими-либо элементарными преобразованиями типа:
 поворот доски в ее плоскости вокруг центра на 900, 1800 и 2700;
 преобразования симметрии относительно диагоналей;
 преобразования симметрии относительно прямых, проходящих
через центр доски по границам клеток.
Взяв по одному представителю из каждого класса H s ( s  0, 1, ..., q) , получим некоторое множество, называемое основными расстановками. Составить рекурсивную функцию, находящую какое-либо множество основных
расстановок n ферзей на шахматной доске размера nn.
Эта задача решается с помощью рекурсивных функций практически
аналогично задаче из Примера 1. Отличия здесь такие. Рекурсивная функция последовательно формирует каждую из возможных расстановок ферзей на доске, но не все из них запоминаются в матрице ответа otv. Очередная полученная расстановка подвергается проверке  включать или не
включать ее в матрицу otv. Делается это следующим образом. Из вектора
pos описанными выше элементарными преобразованиями формируются
еще семь расстановок (некоторые из них могут оказаться совпадающими).
Если ни одна из них не входит в текущую матрицу ответов otv, то otv дополняется новым решением и так далее. Следовательно, по завершении
вычислений otv будет содержать некоторое множество основных расстановок.
Ключевые термины
Возвратная рекурсия – это соединение метода перебора с возвратом и
рекурсии.
Детерминированный алгоритм – это алгоритм, который однозначно
осуществит выбор конкретной альтернативы и продолжит работать в
соответствии с эти выбором.
Исчерпывающий поиск – это процесс нахождения в некотором множестве всех возможных вариантов, среди которых имеется решение
конкретной задачи.
Метод решета – это один из методов организации исчерпывающего
поиска, при котором из множества возможных вариантов исключаются все элементы, не являющиеся решениями.
Недетерминированный алгоритм – это алгоритм, который исследует
все возможности одновременно, как бы копируя себя для реализации
вычислений по всем альтернативам одновременно.
Перебор с возвратом (backtracking)– это один из методов организации
исчерпывающего поиска, который строится конструктивно последовательным расширением частичного решения.
53
Полное решение – это набор вариантов, образующий хотя бы одно решение в целом.
Частичное решение – это неполный набор вариантов, который входит
в одно или несколько полных решений.
Краткие итоги
1. Решение переборных задач сводится к определению наличия решения, всех различных вариантов решения или к нахождению одного
решения.
2. В зависимости от постановки задачи переборные алгоритмы реализуются методами перебора с возвратом или методом решета.
3. Возвратная рекурсия – это соединение метода перебора с возвратом
и рекурсии.
4. Детерминированный алгоритм однозначно осуществит выбор конкретной альтернативы и продолжит работать в соответствии с эти
выбором. Недетерминированный алгоритм исследует все возможности одновременно, как бы копируя себя для реализации вычислений
по всем альтернативам одновременно.
5. Для решения задачи о расстановке ферзей на шахматной доске как
альтернативный используется метод возвратной рекурсии.
6. При разработке триады для решения задачи о расстановке ферзей
вводятся дополнительные параметры при описании процесса расстановки.
7. Базой в данной задаче считается совокупность всех тупиковых ситуаций.
8. Декомпозиция проводится от частичного к полному решению или к
снятию ферзя с вертикали (возврат).
Материалы для практики
Вопросы
1. В чем проявляется рекурсивность метода перебора с возвратом?
2. Почему полный метод перебора с возвратом гарантирует отыскание
всех решений задачи?
3. Как формируется рекурсивная база метода возвратной рекурсии?
4. Какие классы задач сводятся к разработке детерминированных алгоритмов, а какие – к недетерминированным? Поясните примерами.
5. Поясните, почему данные описания характеризуют описание действий над ферзем в контексте модели шахматной доски:
а) В позицию (i, j ) можно поставить ферзь, если
hoi  dui  j  dd ni  j  0 .
б) Поставить ферзь в позицию (i, j ) равносильно присваиваниям:
hoi  1 , dui  j  1, ddni  j  1 .
54
в) Убрать ферзь из позиции (i, j ) равносильно присваиваниям:
hoi  0 , dui  j  0 , dd ni  j  0 .
Упражнения
1. Наберите код программы из Примера 1. Откомпилируйте и протестируйте полученный код.
2. На основе описания из Примера 1 решите задачу о нахождении всех
расстановок ферзей на шахматной доске размером nn.
3. Найдите количество всех расстановок ферзей на шахматной доске
размером nn.
4. Найдите все основные расстановки ферзей на шахматной доске размером nn.
5. Решите задачу о рюкзаке. Имеется n предметов, пронумерованных
числами от 0 до n  1 , для каждого из которых известен набор масс
m0 , ..., mn1 и набор стоимостей s0 , ..., sn1 . Определите, какой набор
предметов необходимо положить в рюкзак, чтобы его масса не превышала Q, а стоимость предметов была бы наибольшей.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Головешкин В.В., Ульянов М.В. Теория рекурсии для программистов
/ В.А. Головешкин, М.В. Ульянов. – М.: ФИЗМАТЛИТ, 2006. – 296 с.
3. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 1: Корзина разнообразных задач / А.Р. Есаян. – Тула:
Изд-во ТГПУ им. Л. Н. Толстого, 2000. – 90 с.
4. Есаян, А. Р. Рекурсия в информатике: Учеб. пособие для студентов
пед. вузов: Ч. 2: Матрицы / А.Р. Есаян. – Тула: Изд-во ТГПУ им. Л.
Н. Толстого, 2000. – 95 с.
5. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
6. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
55
5. Алгоритмы поиска в линейных структурах
Краткая аннотация
В данной теме рассматриваются определение и классификация алгоритмов
поиска в линейных структурах данных, описания и примеры реализаций алгоритмов последовательного поиска, поиска с барьером, бинарного поиска,
приводится оценка трудоемкости алгоритмов поиска в линейных структурах.
Цель изучения темы
Изучить основные алгоритмы поиска в линейных структурах и научиться
решать задачи поиска в линейных структурах на основе алгоритмов последовательного и бинарного поиска.
5.1. Основные понятия и общий алгоритм поиска данных
Одним из важнейших действий со структурированной информацией
является поиск. Поиск – процесс нахождения конкретной информации в
ранее созданном множестве данных. Обычно данные представляют собой
записи, каждая из которых имеет хотя бы один ключ. Ключ поиска – это
поле записи, по значению которого происходит поиск. Ключи используются для отличия одних записей от других. Целью поиска является нахождение всех записей (если они есть) с данным значением ключа.
Структуру данных, в которой проводится поиск, можно рассматривать как таблицу символов (таблицу имен или таблицу идентификаторов) –
структуру, содержащую ключи и данные, и допускающую две операции –
вставку нового элемента и возврат элемента с заданным ключом. Иногда
таблицы символов называют словарями по аналогии с хорошо известной
системой упорядочивания слов в алфавитном порядке: слово – ключ, его
толкование – данные.
Поиск является одним из наиболее часто встречаемых действий в
программировании. Существует множество различных алгоритмов поиска,
которые принципиально зависят от способа организации данных. У каждого алгоритма поиска есть свои преимущества и недостатки. Поэтому важно
выбрать тот алгоритм, который лучше всего подходит для решения конкретной задачи.
Поставим задачу поиска в линейных структурах. Пусть задано множество данных, которое описывается как массив, состоящий из некоторого
количества элементов. Проверим, входит ли заданный ключ в данный массив. Если входит, то найдем номер этого элемента массива, то есть, определим первое вхождение заданного ключа (элемента) в исходном массиве.
Таким образом, определим общий алгоритм поиска данных:
Шаг 1. Вычисление элемента, что часто предполагает получение значения элемента, ключа элемента и т.д.
Шаг 2. Сравнение элемента с эталоном или сравнение двух элемен56
тов (в зависимости от постановки задачи).
Шаг 3. Перебор элементов множества, то есть прохождение по элементам массива.
Основные идеи различных алгоритмов поиска сосредоточены в методах перебора и стратегии поиска.
Рассмотрим основные алгоритмы поиска в линейных структурах более подробно.
5.2. Последовательный (линейный) поиск
Последовательный (линейный) поиск – это простейший вид поиска
заданного элемента на некотором множестве, осуществляемый путем последовательного сравнения очередного рассматриваемого значения с искомым до тех пор, пока эти значения не совпадут.
Идея этого метода заключается в следующем. Множество элементов
просматривается последовательно в некотором порядке, гарантирующем,
что будут просмотрены все элементы множества (например, слева направо). Если в ходе просмотра множества будет найден искомый элемент,
просмотр прекращается с положительным результатом; если же будет просмотрено все множество, а элемент не будет найден, алгоритм должен выдать отрицательный результат.
Алгоритм последовательного поиска
Шаг 1. Полагаем, что значение переменной цикла i  0 .
Шаг 2. Если значение элемента массива x[i ] равно значению ключа
key , то возвращаем значение, равное номеру искомого элемента, и алгоритм завершает работу. В противном случае значение переменной цикла
увеличивается на единицу i  i  1.
Шаг 3. Если i  k , где k – число элементов массива x, то выполняется
Шаг 2, в противном случае – работа алгоритма завершена и возвращается
значение равное -1.
При наличии в массиве нескольких элементов со значением key данный алгоритм находит только первый из них (с наименьшим индексом).
//описание функции последовательного поиска
int LinearSearch(int *x, int k, int key){
int i = 0;
for ( i = 0 ; i < k ; i++ )
if ( x[i] == key )
break;
return i < k ? i : -1;
}
Время выполнения данного алгоритма поиска для вещественных чисел n /  , где n – количество элементов множества, а  – точность. Поиск на
дискретном множестве из n элементов осуществляется в худшем случае за
n итераций, а в среднем этот алгоритм требует n / 2 итераций цикла. Следовательно, временная сложность последовательного поиска пропорцио57
нальна O(n) . Никаких ограничений на порядок элементов в массиве данный алгоритм не накладывает.
Недостатком рассматриваемого алгоритма поиска является то, что в
худшем случае осуществляется просмотр всего массива. Поэтому данный
алгоритм используется, если множество содержит небольшое количество
элементов.
Достоинства последовательного поиска заключаются в том, что он
прост в реализации, не требует сортировки значений множества, дополнительной памяти и дополнительного анализа функций. Следовательно, может работать в потоковом режиме при непосредственном получении данных из любого источника.
Существует модификация алгоритма последовательного поиска, которая ускоряет поиск. Эта модификация является небольшим усовершенствованием рассмотренного алгоритма поиска.
Идея поиска с барьером состоит в том, чтобы не проверять каждый
раз в цикле условие, связанное с границами множества. Это можно обеспечить, установив в данном множестве так называемый барьер. Под барьером понимается любой элемент, который удовлетворяет условию поиска.
Тем самым будет ограничено изменение индекса.
Выход из цикла, в котором теперь остается только условие поиска,
может произойти либо на найденном элементе, либо на барьере. Существует два способа установки барьера: дополнительным элементом или
вместо крайнего элемента массива.
//описание функции последовательного поиска с барьером
int LinearSearchWithBarrier(int *x, int k, int key){
x = (int *)realloc(x,(k+1)*sizeof(int));
x[k] = key;
int i = 0;
while ( x[i] != key )
i++;
return i < k ? i : -1;
}
Заметим, что поиск с барьером работает быстрее, но временная сложность алгоритма остается такой же O(n) , где n – количество элементов
множества. Гораздо больший интерес представляют методы, не только работающие быстро, но и реализующие алгоритмы с меньшей сложностью.
5.3. Бинарный (двоичный) поиск
Бинарный (двоичный, дихотомический) поиск – это поиск заданного элемента на упорядоченном множестве, осуществляемый путем неоднократного деления этого множества на две части таким образом, что искомый элемент попадает в одну из этих частей. Поиск заканчивается при
совпадении искомого элемента с элементом, который является границей
между частями множества или при отсутствии искомого элемента.
58
Бинарный поиск применяется к отсортированным множествам и заключается в последовательном разбиении множества пополам и поиска
элемента только в одной половине на каждой итерации.
Таким образом, идея этого метода заключается в следующем. Поиск
нужного значения среди элементов упорядоченного массива (по возрастанию или по убыванию) начинается с определения значения центрального
элемента этого массива. Значение данного элемента сравнивается с искомым значением и в зависимости от результатов сравнения предпринимаются определенные действия. Если искомое и центральное значения оказываются равны, то поиск завершается успешно. Если искомое значение
меньше центрального или больше, то формируется массив, состоящий из
элементов, находящихся слева или справа от центрального соответственно.
Затем поиск повторяется в новом массиве (рис. 1).
Алгоритм бинарного поиска
Шаг 1. Определить номер среднего элемента массива
middle  (high  low) / 2 .
Шаг 2. Если значение среднего элемента массива равно искомому, то
возвращаем значение, равное номеру искомого элемента, и алгоритм завершает работу.
Шаг 3. Если искомое значение больше значения среднего элемента,
то возьмем в качестве массива все элементы справа от среднего, иначе
возьмем в качестве массива все элементы слева от среднего (в зависимости
от характера упорядоченности). Перейдем к Шагу 1.
В массиве может встречаться несколько элементов со значениями,
равными ключу. Данный алгоритм находит первый совпавший с ключом
элемент, который в порядке следования в массиве может быть ни первым,
ни последним среди равных ключу. Например, в массиве чисел 1, 5, 5, 5, 5,
5, 5, 7, 8 с ключом key=5 совпадет элемент с порядковым номером 4, который не относится ни к первому, ни к последнему.
Существуют две модификации рассматриваемого алгоритма для поиска первого и последнего вхождения. Все зависит от того, как выбирается
средний элемент: округлением в меньшую или большую сторону. В первом случае средний элемент относится к левой части массива, а во втором
– к правой.
A
1
21
22
3
5
key = 3
low
middle
high
A
1
21
22
3
5
low,
middle
high
key = 3
A
1
21
22
3
5
low,
middle,
high
key = 3
Рис.1. Демонстрация алгоритма бинарного поиска
59
A
1
21
22
3
5
key = 3
//описание функции бинарного поиска
int BinarySearch(int *x, int k, int key){
bool found = false;
int high = k - 1, low = 0;
int middle = (high + low) / 2;
while ( !found && high >= low ){
if (key == x[middle])
found = true;
else if (key < x[middle])
high = middle - 1;
else
low = middle + 1;
middle = (high + low) / 2;
}
return found ? middle : -1 ;
}
В процессе работы алгоритма бинарного поиска размер фрагмента,
где этот поиск должен продолжаться, каждый раз уменьшается примерно в
два раза. Это обеспечивает сложность алгоритма пропорциональную
O(log n) , где n – количество элементов множества.
Время выполнения алгоритма бинарного поиска: если функция имеет
вещественный аргумент, найти решение с точностью до  можно за время
1
log , а если аргумент дискретен, то поиск решения займет (1  log n) време-

ни.
Достоинством данного алгоритма является относительная быстрота
выполнения поиска, по сравнению с алгоритмом последовательного поиска. Недостаток заключается в том, что бинарный поиск может применяться
только на упорядоченном множестве.
Ключевые термины
Бинарный (двоичный, дихотомический) поиск – это поиск заданного
элемента на упорядоченном множестве, осуществляемый путем неоднократного деления этого множества на две части таким образом,
что искомый элемент попадает в одну из этих частей.
Ключ поиска – это поле записи, по значению которого происходит поиск
Поиск – это процесс нахождения конкретной информации в ранее созданном множестве данных.
Поиск с барьером – это модификация алгоритма последовательного поиска, ускоряющая процесс путем определения граничного элемента.
Последовательный (линейный) поиск – это простейший вид поиска
заданного элемента на некотором множестве, осуществляемый путем
60
последовательного сравнения очередного рассматриваемого значения с искомым до тех пор, пока эти значения не совпадут.
Краткие итоги
1. Одним из важнейших действий со структурированной информацией
является поиск.
2. Существует множество различных алгоритмов поиска, которые
принципиально зависят от способа организации данных. У каждого
алгоритма поиска есть свои преимущества и недостатки.
3. Последовательный (линейный) поиск является простейшим видом
поиска заданного элемента на некотором множестве, осуществляемым путем последовательного сравнения очередного рассматриваемого значения с искомым до тех пор, пока эти значения не совпадут.
4. Существует модификация алгоритма последовательного поиска, которая ускоряет поиск путем установки в рассматриваемом множестве барьера.
5. Бинарный (двоичный, дихотомический) поиск является поиском заданного элемента на упорядоченном множестве, осуществляемым
путем неоднократного деления этого множества на две части таким
образом, что искомый элемент попадает в одну из этих частей. Бинарный поиск применяется к отсортированным множествам.
6. Преимуществом бинарного поиска является более низкая трудоемкость по сравнению с бинарным поиском. Недостаток бинарного поиска состоит в том, что он применим только на отсортированных
множествах.
Материалы для практики
Вопросы
1. Чем можно объяснить многообразие алгоритмов поиска в линейных
структурах?
2. В чем преимущества поиска с барьером по сравнению с последовательным поиском?
3. Нахождение какого по порядку элемента в линейном множестве
(первого, последнего) гарантирует алгоритм прямого поиска? Как в
этом случае должен быть выполнен просмотр?
4. Нахождение какого по порядку элемента в линейном множестве
(первого, последнего) гарантирует алгоритм бинарного поиска? Ответ обоснуйте.
5. Как трудоемкость алгоритма бинарного поиска на дискретном множестве зависит от мощности множества?
6. Почему время выполнения алгоритма бинарного поиска на вещественном множестве не зависит от количества элементов?
61
Упражнения
1. На основании приведенных в лекции функций реализуйте алгоритмы
последовательного и бинарного поиска.
2. В связи с визитом Императора Палпатина было решено обновить состав дроидов в ангаре 32. Из-за кризиса было решено новых дроидов
не закупать, но выкинуть пару старых. Как известно, Палпатин не
переносит дроидов с маленькими серийными номерами, так что все,
что требуется – найти среди них двух, у которых серийные номера
наименьшие.
Формат входного файла
Первая строка входного файла содержит целое число N – количество
дроидов. (2 ≤ N ≤ 1000), вторая строка – N целых чисел, по модулю
не превышающих 2  10 9 – номера дроидов.
Формат выходного файла
Выведите два числа: первым – последний по величине из номеров
дроидов (такого следует утилизировать в первую очередь), а вторым – предпоследний.
Пример входного файла
5
49 100 23 -100 157
Пример выходного файла
-100 23
Пример входного файла
4
99 1 5 1
Пример выходного файла
1 1
3. Некто загадал число от 1 до N. За какое наименьшее количество вопросов (на которые он отвечает «да» или «нет») можно угадать задуманное число?
Формат входных данных
Вводится одно число N (1 < N < 10001).
Формат выходных данных
Выведите наименьшее количество вопросов, которого гарантированно хватит, чтобы угадать задуманное число.
Пример входного файла
6
Пример выходного файла
3
4. Задана матрица K, содержащая n строк и m столбцов. Седловой точкой этой матрицы назовем элемент, который одновременно является
62
минимумом в своей строке и максимумом в своем столбце. Найдите
количество седловых точек заданной матрицы.
Формат входного файла
Первая строка входного файла содержит целые числа n и m (1 ≤ n,
m ≤ 750). Далее следуют n строк по m чисел в каждой. j-ое число i-ой
строки равно kij . Все kij по модулю не превосходят 1000.
Формат выходного файла
В выходной файл выведите ответ на задачу.
Пример входного файла
2 2
0 0
0 0
Пример выходного файла
4
Пример входного файла
2 2
1 2
3 4
Пример выходного файла
1
5. Спортсмен Василий участвовал в соревнованиях по хоккейболу и
получил в личном зачете серебряную медаль. Известно, что участники, получившие одинаковое количество очков, награждаются одинаковыми наградами. Известно, что были разыграны золотые серебряные и бронзовые медали. В задаче не спрашиваются правила хоккейбола. Необходимо только определить сколько очков набрал Василий. Для решения данной задачи массив лучше не использовать.
Формат входного файла
На первой строке дано число N (2 ≤ N ≤ 1000) количество спортсменов, участвовавших в соревнованиях, на второй N целых чисел – результаты через пробел.
Формат выходного файла
Требуется вывести одно число – результат Василия.
Пример входного файла
5
4 3 3 1 2
Пример выходного файла
3
Пример входного файла
8
1 2 5 3 5 1 1 6
Пример выходного файла
5
63
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
64
6. Алгоритмы хеширования данных
Краткая аннотация
В данной теме рассматриваются определение и виды хеширования, методы
разрешения коллизий в хеш-таблицах, основные алгоритмы хеширования,
приводятся примеры программной реализации открытого и закрытого хеширования.
Цель изучения темы
Изучить построение функции хеширования и алгоритмов хеширования
данных и научиться разрабатывать алгоритмы открытого и закрытого хеширования при решении задач на языке C++.
6.1. Основные понятия хеширования данных
Процесс поиска данных в больших объемах информации сопряжен с
временными затратами, которые обусловлены необходимостью просмотра и
сравнения с ключом поиска значительного числа элементов. Сокращение
поиска возможно осуществить путем локализации области просмотра.
Например, отсортировать данные по ключу поиска, разбить на непересекающиеся блоки по некоторому групповому признаку или поставить в соответствие реальным данным некий код, который упростит процедуру поиска.
В настоящее время используется широко распространенный метод
обеспечения быстрого доступа к информации, хранящейся во внешней памяти – хеширование.
Хеширование (или хэширование, англ. hashing) – это преобразование
входного массива данных определенного типа и произвольной длины в
выходную битовую строку фиксированной длины. Такие преобразования
также называются хеш-функциями или функциями свертки, а их результаты называют хешем, хеш-кодом, хеш-таблицей или дайджестом сообщения (англ. message digest).
Хеш-таблица – это структура данных, реализующая интерфейс ассоциативного массива, то есть она позволяет хранить пары вида «ключзначение» и выполнять три операции: операцию добавления новой пары,
операцию поиска и операцию удаления пары по ключу. Хеш-таблица является массивов, формируемым в определенном порядке хеш-функцией.
Принято считать, что хорошей, с точки зрения практического применения, является такая хеш-функция, которая удовлетворяет следующим
условиям:
 функция должна быть простой с вычислительной точки зрения;
 функция должна распределять ключи в хеш-таблице наиболее равномерно;
 функция не должна отображать какую-либо связь между значениями
ключей в связь между значениями адресов;
65
 функция должна минимизировать число коллизий – то есть ситуаций,
когда разным ключам соответствует одно значение хеш-функции (ключи в этом случае называются синонимами).
При этом первое свойство хорошей хеш-функции зависит от характеристик компьютера, а второе – от значений данных.
Если бы все данные были случайными, то хеш-функции были бы
очень простые (например, несколько битов ключа). Однако на практике
случайные данные встречаются достаточно редко, и приходится создавать
функцию, которая зависела бы от всего ключа. Если хеш-функция распределяет совокупность возможных ключей равномерно по множеству индексов, то хеширование эффективно разбивает множество ключей. Наихудший случай – когда все ключи хешируются в один индекс.
При возникновении коллизий необходимо найти новое место для
хранения ключей, претендующих на одну и ту же ячейку хеш-таблицы.
Причем, если коллизии допускаются, то их количество необходимо минимизировать. В некоторых специальных случаях удается избежать коллизий
вообще. Например, если все ключи элементов известны заранее (или очень
редко меняются), то для них можно найти некоторую инъективную хешфункцию, которая распределит их по ячейкам хеш-таблицы без коллизий.
Хеш-таблицы, использующие подобные хеш-функции, не нуждаются в механизме разрешения коллизий, и называются хеш-таблицами с прямой адресацией.
Хеш-таблицы должны соответствовать следующим свойствам.
 Выполнение операции в хеш-таблице начинается с вычисления хешфункции от ключа. Получающееся хеш-значение является индексом в
исходном массиве.
 Количество хранимых элементов массива, деленное на число возможных значений хеш-функции, называется коэффициентом заполнения
хеш-таблицы (load factor) и является важным параметром, от которого
зависит среднее время выполнения операций.
 Операции поиска, вставки и удаления должны выполняться в среднем
за время O1 . Однако при такой оценке не учитываются возможные
аппаратные затраты на перестройку индекса хеш-таблицы, связанную с
увеличением значения размера массива и добавлением в хеш-таблицу
новой пары.
 Механизм разрешения коллизий является важной составляющей любой
хеш-таблицы.
Хеширование полезно, когда широкий диапазон возможных значений должен быть сохранен в малом объеме памяти, и нужен способ быстрого, практически произвольного доступа. Хэш-таблицы часто применяются в базах данных, и, особенно, в языковых процессорах типа компиляторов и ассемблеров, где они повышают скорость обработки таблицы
идентификаторов. В качестве использования хеширования в повседневной
66
жизни можно привести примеры распределение книг в библиотеке по тематическим каталогам, упорядочивание в словарях по первым буквам
слов, шифрование специальностей в вузах и т.д.
6.2. Методы разрешения коллизий
Коллизии осложняют использование хеш-таблиц, так как нарушают
однозначность соответствия между хеш-кодами и данными. Тем не менее,
существуют способы преодоления возникающих сложностей:
 метод цепочек (внешнее или открытое хеширование);
 метод открытой адресации (закрытое хеширование).
Метод цепочек. Технология сцепления элементов состоит в том, что
элементы множества, которым соответствует одно и то же хеш-значение,
связываются в цепочку-список. В позиции номер i хранится указатель на
голову списка тех элементов, у которых хеш-значение ключа равно i; если
таких элементов в множестве нет, в позиции i записан NULL. На рисунке 1
демонстрируется реализация метода цепочек при разрешении коллизий. На
ключ 002 претендуют два значения, которые организуются в линейный
список.
Андреев Иван
000
001
Алексеев Сергей
002
Володин Михаил
003
...
Андреев Иван
×
×
...
×
Алексеев Сергей
×
Володин Михаил
Семенов Сергей
150
×
Семенов Сергей
Томский Павел
151
×
Томский Павел
...
...
Рис. 1. Разрешение коллизий при помощи цепочек
Каждая ячейка массива является указателем на связный список (цепочку) пар ключ-значение, соответствующих одному и тому же хешзначению ключа. Коллизии просто приводят к тому, что появляются цепочки длиной более одного элемента.
Операции поиска или удаления данных требуют просмотра всех элементов соответствующей ему цепочки, чтобы найти в ней элемент с заданным ключом. Для добавления данных нужно добавить элемент в конец или
начало соответствующего списка, и, в случае если коэффициент заполнения станет слишком велик, увеличить размер массива и перестроить таблицу.
При предположении, что каждый элемент может попасть в любую
67
позицию таблицы с равной вероятностью и независимо от того, куда попал
любой другой элемент, среднее время работы операции поиска элемента
составляет O1  k  , где k – коэффициент заполнения таблицы.
Метод открытой адресации. В отличие от хеширования с цепочками, при открытой адресации никаких списков нет, а все записи хранятся в
самой хеш-таблице. Каждая ячейка таблицы содержит либо элемент динамического множества, либо NULL.
В этом случае, если ячейка с вычисленным индексом занята, то можно просто просматривать следующие записи таблицы по порядку до тех
пор, пока не будет найден ключ K или пустая позиция в таблице. Для вычисления шага можно также применить формулу, которая и определит
способ изменения шага. На рисунке 2 разрешение коллизий осуществляется методом открытой адресации. Два значения претендуют на ключ 002,
для одного из них находится первое свободное (еще незанятое) место в
таблице.
Андреев Иван
000
001
Алексеев Сергей
002
Андреев Иван
003
Алексеев Сергей
004
Володин Михаил
...
...
Семенов Сергей
150
Семенов Сергей
Томский Павел
151
Томский Павел
...
...
Володин Михаил
Рис. 2. Разрешение коллизий при помощи открытой адресации
При любом методе разрешения коллизий необходимо ограничить
длину поиска элемента. Если для поиска элемента необходимо более 3 – 4
сравнений, то эффективность использования такой хеш-таблицы пропадает
и ее следует реструктуризировать (т.е. найти другую хеш-функцию), чтобы
минимизировать количество сравнений для поиска элемента
Для успешной работы алгоритмов поиска, последовательность проб
должна быть такой, чтобы все ячейки хеш-таблицы оказались просмотренными ровно по одному разу.
Удаление элементов в такой схеме несколько затруднено. Обычно
поступают так: заводят логический флаг для каждой ячейки, помечающий,
удален ли элемент в ней или нет. Тогда удаление элемента состоит в установке этого флага для соответствующей ячейки хеш-таблицы, но при этом
необходимо модифицировать процедуру поиска существующего элемента
так, чтобы она считала удаленные ячейки занятыми, а процедуру добавле68
ния – чтобы она их считала свободными и сбрасывала значение флага при
добавлении.
6.3. Алгоритмы хеширования
Существует несколько типов функций хеширования, каждая из которых имеет свои преимущества и недостатки и основана на представлении
данных. Приведем обзор и анализ некоторых наиболее простых из применяемых на практике хеш-функций.
6.3.1. Таблица прямого доступа
Простейшей организацией таблицы, обеспечивающей идеально
быстрый поиск, является таблица прямого доступа. В такой таблице ключ
является адресом записи в таблице или может быть преобразован в адрес,
причем таким образом, что никакие два разных ключа не преобразуются в
один и тот же адрес. При создании таблицы выделяется память для хранения всей таблицы и заполняется пустыми записями. Затем записи вносятся
в таблицу – каждая на свое место, определяемое ее ключом. При поиске
ключ используется как адрес и по этому адресу выбирается запись. Если
выбранная запись пустая, то записи с таким ключом вообще нет в таблице.
Таблицы прямого доступа очень эффективны в использовании, но, к сожалению, область их применения весьма ограничена.
Назовем пространством ключей множество всех теоретически возможных значений ключей записи. Назовем пространством записей множество тех ячеек памяти, которые выделяются для хранения таблицы. Таблицы прямого доступа применимы только для таких задач, в которых размер пространства записей может быть равен размеру пространства ключей.
В большинстве реальных задач размер пространства записей много меньше, чем пространства ключей. Так, если в качестве ключа используется
фамилия, то, даже ограничив длину ключа десятью символами кириллицы,
получаем 3310 возможных значений ключей. Даже если ресурсы вычислительной системы и позволят выделить пространство записей такого размера, то значительная часть этого пространства будет заполнена пустыми записями, так как в каждом конкретном заполнении таблицы фактическое
множество ключей не будет полностью покрывать пространство ключей.
В целях экономии памяти можно назначать размер пространства записей равным размеру фактического множества записей или превосходящим его незначительно. В этом случае необходимо иметь некоторую
функцию, обеспечивающую отображение точки из пространства ключей в
точку в пространстве записей, то есть, преобразование ключа в адрес записи: a  hk  , где a – адрес, k – ключ.
Идеальной хеш-функцией является инъективная функция, которая
для любых двух неодинаковых ключей дает неодинаковые адреса.
69
6.3.2. Метод остатков от деления
Простейшей хеш-функцией является деление по модулю числового
значения ключа Key на размер пространства записи HashTableSize. Результат интерпретируется как адрес записи. Следует иметь в виду, что такая функция хорошо соответствует первому, но плохо – последним трем
требованиям к хеш-функции и сама по себе может быть применена лишь в
очень ограниченном диапазоне реальных задач. Однако операция деления
по модулю обычно применяется как последний шаг в более сложных
функциях хеширования, обеспечивая приведение результата к размеру
пространства записей.
Если ключей меньше, чем элементов массива, то в качестве хешфункции можно использовать деление по модулю, то есть остаток от деления целочисленного ключа Key на размерность массива HashTableSize,
то есть:
Key % HashTableSize
Данная функция очень проста, хотя и не относится к хорошим. Вообще, можно использовать любую размерность массива, но она должна
быть такой, чтобы минимизировать число коллизий. Для этого в качестве
размерности лучше использовать простое число. В большинстве случаев
подобный выбор вполне удовлетворителен. Для символьной строки ключом может являться остаток от деления, например, суммы кодов символов
строки на HashTableSize.
На практике, метод деления – самый распространенный.
//функция создания хеш-таблицы метод деления по модулю
int Hash(int Key, int HashTableSize) {
//HashTableSize
return Key % HashTableSize;
}
6.3.3. Метод функции середины квадрата
Следующей хеш-функцией является функция середины квадрата.
Значение ключа преобразуется в число, это число затем возводится в квадрат, из него выбираются несколько средних цифр и интерпретируются как
адрес записи.
6.3.4. Метод свертки
Еще одной хеш-функцией можно назвать функцию свертки. Цифровое представление ключа разбивается на части, каждая из которых имеет
длину, равную длине требуемого адреса. Над частями производятся определенные арифметические или поразрядные логические операции, результат которых интерпретируется как адрес. Например, для сравнительно небольших таблиц с ключами – символьными строками неплохие результаты
дает функция хеширования, в которой адрес записи получается в результате сложения кодов символов, составляющих строку-ключ.
70
В качестве хеш-функции также применяют функцию преобразования
системы счисления. Ключ, записанный как число в некоторой системе
счисления P, интерпретируется как число в системе счисления Q  P .
Обычно выбирают Q  P  1 . Это число переводится из системы Q обратно
в систему P, приводится к размеру пространства записей и интерпретируется как адрес.
6.3.5. Открытое хеширование
Основная идея базовой структуры при открытом (внешнем) хешировании заключается в том, что потенциальное множество (возможно, бесконечное) разбивается на конечное число классов. Для В классов, пронумерованных от 0 до B  1, строится хеш-функция hx  такая, что для любого
элемента х исходного множества функция hx  принимает целочисленное
значение из интервала 0, 1, ..., B  1, соответствующее, классу, которому
принадлежит элемент х. Часто классы называют сегментами, поэтому будем говорить, что элемент х принадлежит сегменту hx  . Массив, называемый таблицей сегментов и проиндексированный номерами сегментов
0, 1, ..., B  1, содержит заголовки для B списков. Элемент х, относящийся
к i-му списку – это элемент исходного множества, для которого h x   i .
Если сегменты примерно одинаковы по размеру, то в этом случае
списки всех сегментов должны быть наиболее короткими при данном числе сегментов. Если исходное множество состоит из N элементов, тогда
средняя длина списков будет N / B элементов. Если можно оценить величину N и выбрать В как можно ближе к этой величине, то в каждом списке
будет один или два элемента. Тогда время выполнения операторов словарей будет малой постоянной величиной, не зависящей от N.
Пример 1. Программная реализация открытого хеширования.
#include "stdafx.h"
#include <iostream>
#include <fstream>
using namespace std;
typedef
typedef
#define
typedef
int T; // тип элементов
int hashTableIndex; // индекс в хеш-таблице
compEQ(a,b) (a == b)
struct Node_ {
T data;// данные, хранящиеся в вершине
struct Node_ *next; // следующая вершина
} Node;
Node **hashTable;
int hashTableSize;
hashTableIndex myhash(T data);
Node *insertNode(T data);
void deleteNode(T data);
Node *findNode (T data);
71
int _tmain(int argc, _TCHAR* argv[]){
int i, *a, maxnum;
cout << "Введите количество элементов maxnum : ";
cin >> maxnum;
cout << "Введите размер хеш-таблицы HashTableSize : ";
cin >> hashTableSize;
a = new int[maxnum];
hashTable = new Node*[hashTableSize];
for (i = 0; i < hashTableSize; i++)
hashTable[i] = NULL;
// генерация массива
for (i = 0; i < maxnum; i++)
a[i] = rand();
// заполнение хеш-таблицы элементами массива
for (i = 0; i < maxnum; i++) {
insertNode(a[i]);
}
// поиск элементов массива по хеш-таблице
for (i = maxnum-1; i >= 0; i--) {
findNode(a[i]);
}
// вывод элементов массива в файл List.txt
ofstream out("List.txt");
for (i = 0; i < maxnum; i++){
out << a[i];
if ( i < maxnum - 1 ) out << "\t";
}
out.close();
// сохранение хеш-таблицы в файл HashTable.txt
out.open("HashTable.txt");
for (i = 0; i < hashTableSize; i++){
out << i << " : ";
Node *Temp = hashTable[i];
while ( Temp ){
out << Temp->data << " -> ";
Temp = Temp->next;
}
out << endl;
}
out.close();
// очистка хеш-таблицы
for (i = maxnum-1; i >= 0; i--) {
deleteNode(a[i]);
}
system("pause");
return 0;
}
72
// хеш-функция размещения вершины
hashTableIndex myhash(T data) {
return (data % hashTableSize);
}
// функция поиска местоположения и вставки вершины в таблицу
Node *insertNode(T data) {
Node *p, *p0;
hashTableIndex bucket;
// вставка вершины в начало списка
bucket = myhash(data);
if ((p = new Node) == 0) {
fprintf (stderr, "Нехватка памяти (insertNode)\n");
exit(1);
}
p0 = hashTable[bucket];
hashTable[bucket] = p;
p->next = p0;
p->data = data;
return p;
}
// функция удаления вершины из таблицы
void deleteNode(T data) {
Node *p0, *p;
hashTableIndex bucket;
p0 = 0;
bucket = myhash(data);
p = hashTable[bucket];
while (p && !compEQ(p->data, data)) {
p0 = p;
p = p->next;
}
if (!p) return;
if (p0)
p0->next = p->next;
else
hashTable[bucket] = p->next;
free (p);
}
// функция поиска вершины со значением data
Node *findNode (T data) {
Node *p;
p = hashTable[myhash(data)];
while (p && !compEQ(p->data, data))
p = p->next;
return p;
}
73
6.3.6. Закрытое хеширование
При закрытом (внутреннем) хешировании в хеш-таблице хранятся
непосредственно сами элементы, а не заголовки списков элементов. Поэтому в каждой записи (сегменте) может храниться только один элемент.
При закрытом хешировании применяется методика повторного хеширования. Если осуществляется попытка поместить элемент х в сегмент с номером hx  , который уже занят другим элементом (коллизия), то в соответствии с методикой повторного хеширования выбирается последовательность других номеров сегментов h1x , h2x , ... , куда можно поместить
элемент х. Каждое из этих местоположений последовательно проверяется,
пока не будет найдено свободное. Если свободных сегментов нет, то, следовательно, таблица заполнена, и элемент х добавить нельзя.
При поиске элемента х необходимо просмотреть все местоположения
hx , h1x , h2x , ... , пока не будет найден х или пока не встретится пустой
сегмент. Чтобы объяснить, почему можно остановить поиск при достижении пустого сегмента, предположим, что в хеш-таблице не допускается
удаление элементов. Пусть h3x  – первый пустой сегмент. В такой ситуации невозможно нахождение элемента х в сегментах h4x , h5x  и далее,
так как при вставке элемент х вставляется в первый пустой сегмент, следовательно, он находится где-то до сегмента h3x  . Но если в хеш-таблице
допускается удаление элементов, то при достижении пустого сегмента, не
найдя элемента х, нельзя быть уверенным в том, что его вообще нет в таблице, так как сегмент может стать пустым уже после вставки элемента х.
Поэтому, чтобы увеличить эффективность данной реализации, необходимо
в сегмент, который освободился после операции удаления элемента, поместить специальную константу, которую назовем, например, DEL. В качестве альтернативы специальной константе можно использовать дополнительное поле таблицы, которое показывает состояние элемента. Важно
различать константы DEL и NULL – последняя находится в сегментах, которые никогда не содержали элементов. При таком подходе выполнение поиска элемента не требует просмотра всей хеш-таблицы. Кроме того, при
вставке элементов сегменты, помеченные константой DEL, можно трактовать как свободные, таким образом, пространство, освобожденное после
удаления элементов, можно рано или поздно использовать повторно. Но
если невозможно непосредственно сразу после удаления элементов пометить освободившиеся сегменты, то следует предпочесть закрытому хешированию схему открытого хеширования.
Существует несколько методов повторного хеширования, то есть
определения местоположений hx , h1x , h2x , ... :
 линейное опробование;
 квадратичное опробование;
 двойное хеширование.
74
Линейное опробование сводится к последовательному перебору сегментов таблицы с некоторым фиксированным шагом:
адрес  h( x)  ci ,
где i – номер попытки разрешить коллизию;
c – константа, определяющая шаг перебора.
При шаге, равном единице, происходит последовательный перебор
всех сегментов после текущего. Квадратичное опробование отличается от
линейного тем, что шаг перебора сегментов нелинейно зависит от номера
попытки найти свободный сегмент:
адрес  h( x)  ci  di 2 ,
где i – номер попытки разрешить коллизию,
c и d – константы.
Благодаря нелинейности такой адресации уменьшается число проб
при большом числе ключей-синонимов. Однако даже относительно небольшое число проб может быстро привести к выходу за адресное пространство небольшой таблицы вследствие квадратичной зависимости адреса от номера попытки.
Еще одна разновидность метода открытой адресации, которая называется двойным хешированием, основана на нелинейной адресации, достигаемой за счет суммирования значений основной и дополнительной хешфункций:
адрес  h( x)  ih2( x) .
Очевидно, что по мере заполнения хеш-таблицы будут происходить
коллизии, и в результате их разрешения очередной адрес может выйти за
пределы адресного пространства таблицы. Чтобы это явление происходило
реже, можно пойти на увеличение длины таблицы по сравнению с диапазоном адресов, выдаваемым хеш-функцией. С одной стороны, это приведет
к сокращению числа коллизий и ускорению работы с хеш-таблицей, а с
другой – к нерациональному расходованию памяти. Даже при увеличении
длины таблицы в два раза по сравнению с областью значений хешфункции нет гарантии того, что в результате коллизий адрес не превысит
длину таблицы. При этом в начальной части таблицы может оставаться достаточно свободных сегментов. Поэтому на практике используют циклический переход к началу таблицы.
Однако в случае многократного превышения адресного пространства
и, соответственно, многократного циклического перехода к началу будет
происходить просмотр одних и тех же ранее занятых сегментов, тогда как
между ними могут быть еще свободные сегменты. Более корректным будет
использование сдвига адреса на 1 в случае каждого циклического перехода
к началу таблицы. Это повышает вероятность нахождения свободных сегментов.
В случае применения схемы закрытого хеширования скорость выполнения вставки и других операций зависит не только от равномерности
75
распределения элементов по сегментам хеш-функцией, но и от выбранной
методики повторного хеширования (опробования) для разрешения коллизий, связанных с попытками вставки элементов в уже заполненные сегменты. Например, методика линейного опробования для разрешения коллизий
– не самый лучший выбор.
Как только несколько последовательных сегментов будут заполнены,
образуя группу, любой новый элемент при попытке вставки в эти сегменты
будет вставлен в конец этой группы, увеличивая тем самым длину группы
последовательно заполненных сегментов. Другими словами, для поиска
пустого сегмента в случае непрерывного расположения заполненных сегментов необходимо просмотреть больше сегментов, чем при случайном
распределении заполненных сегментов. Отсюда также следует очевидный
вывод, что при непрерывном расположении заполненных сегментов увеличивается время выполнения вставки нового элемента и других операций.
Пример 2. Программная реализация закрытого хеширования.
#include "stdafx.h"
#include <iostream>
#include <fstream>
using namespace std;
typedef int T; // тип элементов
typedef int hashTableIndex;// индекс в хеш-таблице
int hashTableSize;
T *hashTable;
bool *used;
hashTableIndex myhash(T data);
void insertData(T data);
void deleteData(T data);
bool findData (T data);
int dist (hashTableIndex a,hashTableIndex b);
int _tmain(int argc, _TCHAR* argv[]){
int i, *a, maxnum;
cout << "Введите количество элементов maxnum : ";
cin >> maxnum;
cout << "Введите размер хеш-таблицы hashTableSize : ";
cin >> hashTableSize;
a = new int[maxnum];
hashTable = new T[hashTableSize];
used = new bool[hashTableSize];
for (i = 0; i < hashTableSize; i++){
hashTable[i] = 0;
used[i] = false;
}
// генерация массива
for (i = 0; i < maxnum; i++)
76
a[i] = rand();
// заполнение хеш-таблицы элементами массива
for (i = 0; i < maxnum; i++)
insertData(a[i]);
// поиск элементов массива по хеш-таблице
for (i = maxnum-1; i >= 0; i--)
findData(a[i]);
// вывод элементов массива в файл List.txt
ofstream out("List.txt");
for (i = 0; i < maxnum; i++){
out << a[i];
if ( i < maxnum - 1 ) out << "\t";
}
out.close();
// сохранение хеш-таблицы в файл HashTable.txt
out.open("HashTable.txt");
for (i = 0; i < hashTableSize; i++){
out << i << " : " << used[i] << " : " <<
hashTable[i] << endl;
}
out.close();
// очистка хеш-таблицы
for (i = maxnum-1; i >= 0; i--) {
deleteData(a[i]);
}
system("pause");
return 0;
}
// хеш-функция размещения величины
hashTableIndex myhash(T data) {
return (data % hashTableSize);
}
//функция поиска местоположения и вставки величины в таблицу
void insertData(T data) {
hashTableIndex bucket;
bucket = myhash(data);
while ( used[bucket] && hashTable[bucket] != data)
bucket = (bucket + 1) % hashTableSize;
if ( !used[bucket] ) {
used[bucket] = true;
hashTable[bucket] = data;
}
}
// функция поиска величины, равной data
bool findData (T data) {
77
hashTableIndex bucket;
bucket = myhash(data);
while ( used[bucket] && hashTable[bucket] != data )
bucket = (bucket + 1) % hashTableSize;
return used[bucket] && hashTable[bucket] == data;
}
// функция удаления величины из таблицы
void deleteData(T data){
int bucket, gap;
bucket = myhash(data);
while ( used[bucket] && hashTable[bucket] != data )
bucket = (bucket + 1) % hashTableSize;
if ( used[bucket] && hashTable[bucket] == data ){
used[bucket] = false;
gap = bucket;
bucket = (bucket + 1) % hashTableSize;
while ( used[bucket] ){
if ( bucket == myhash(hashTable[bucket]) )
bucket = (bucket + 1) % hashTableSize;
else if ( dist(myhash(hashTable[bucket]),bucket) <
dist(gap,bucket) )
bucket = (bucket + 1) % hashTableSize;
else {
used[gap] = true;
hashTable[gap] = hashTable[bucket];
used[bucket] = false;
gap = bucket;
bucket++;
}
}
}
}
// функция вычисления расстояние от a до b
//(по часовой стрелке, слева направо)
int dist (hashTableIndex a,hashTableIndex b){
return (b - a + hashTableSize) % hashTableSize;
}
До сих пор рассматривались способы поиска в таблице по ключам,
позволяющим однозначно идентифицировать запись. Такие ключи называются первичными. Возможен вариант организации таблицы, при котором
отдельный ключ не позволяет однозначно идентифицировать запись. Такая
ситуация часто встречается в базах данных. Идентификация записи осуществляется по некоторой совокупности ключей. Ключи, не позволяющие
однозначно идентифицировать запись в таблице, называются вторичными
ключами. Даже при наличии первичного ключа, для поиска записи могут
78
быть использованы вторичные.
Идея хеширования впервые была высказана Г.П. Ланом при создании внутреннего меморандума IBM в январе 1953 г. с предложением использовать для разрешения коллизий метод цепочек. Примерно в это же
время другой сотрудник IBM, Жини Амдал, высказала идею использования открытой линейной адресации. В открытой печати хеширование впервые было описано Арнольдом Думи (1956 год), указавшим, что в качестве
хеш-адреса удобно использовать остаток от деления на простое число. А.
Думи описывал метод цепочек для разрешения коллизий, но не говорил об
открытой адресации. Подход к хешированию, отличный от метода цепочек, был предложен А.П. Ершовым (1957 год), который разработал и описал метод линейной открытой адресации.
Ключевые термины
Вторичные ключи – это ключи, не позволяющие однозначно идентифицировать запись в таблице.
Закрытое хеширование или Метод открытой адресации – это технология разрешения коллизий, которая предполагает хранение записей в самой хеш-таблице.
Коллизия – это ситуация, когда разным ключам соответствует одно
значение хеш-функции.
Коэффициент заполнения хеш-таблицы – это количество хранимых
элементов массива, деленное на число возможных значений хешфункции.
Открытое хеширование или Метод цепочек – это технология разрешения коллизий, которая состоит в том, что элементы множества с
равными хеш-значениями связываются в цепочку-список.
Первичные ключи – это ключи, позволяющие однозначно идентифицировать запись.
Повторное хеширование – это поиск местоположения для очередного
элемента таблицы с учетом шага перемещения.
Пространство записей – это множество тех ячеек памяти, которые
выделяются для хранения таблицы.
Пространство ключей – это множество всех теоретически возможных
значений ключей записи.
Синонимы – это совпадающие ключи в хеш-таблице.
Хеширование – это преобразование входного массива данных определенного типа и произвольной длины в выходную битовую строку
фиксированной длины.
Хеш-таблица – это структура данных, реализующая интерфейс ассоциативного массива, то есть она позволяет хранить пары вида «ключзначение» и выполнять три операции: операцию добавления новой
пары, операцию поиска и операцию удаления пары по ключу.
79
Хеш-таблицы с прямой адресацией – это хеш-таблицы, использующие
инъективные хеш-функции и не нуждающиеся в механизме разрешения коллизий.
Краткие итоги
1. В настоящее время используется широко распространенный метод
обеспечения быстрого доступа к большим объемам информации –
хеширование.
2. Для установления соответствия ключей и данных строится хештаблица.
3. Хеш-таблица строится при помощи хеш-функций. Практическое
применение получили функции прямого доступа, остатков от деления, середины квадрата, свертки.
4. При построении хеш-таблиц могут возникать коллизии, то есть ситуации неоднозначного соответствия данных ключу.
5. Разрешение коллизий проводится методом цепочек (открытое или
внешнее хеширование) или методом открытой адресации (закрытое
хеширование).
6. Поиск свободных ключей в методе открытой адресации может проводиться методом повторного хеширования с помощью линейного
опробования, квадратичного опробования или двойного хеширования.
7. Идентификация данных в таблицах может осуществляться как по
первичному, так и по вторичному ключу.
8. Хеширование имеет широкое практическое применение в теории баз
данных, кодировании, банковском деле, криптографии и других областях.
Материалы для практики
Вопросы
1. Каков принцип построения хеш-таблиц?
2. Существуют ли универсальные методы построения хеш-таблиц? Ответ обоснуйте.
3. Почему возможно возникновение коллизий?
4. Каковы методы устранения коллизий? Охарактеризуйте их эффективность в различных ситуациях.
5. Назовите преимущества открытого и закрытого хеширования.
6. В каком случае поиск в хеш-таблицах становится неэффективен?
7. Как выбирается метод изменения адреса при повторном хешировании?
80
Упражнения
1. Наберите коды программ из Примеров 1-2. Выполните компиляцию
и запуск программ.
2. Составьте хеш-таблицу, содержащую буквы и количество их вхождений во введенной строке. Вывести таблицу на экран. Осуществить
поиск введенной буквы в хеш-таблице.
3. Постройте хеш-таблицу из слов произвольного текстового файла, задав ее размерность с экрана. Выведите построенную таблицу слов на
экран. Осуществите поиск введенного слова. Выполните программу
для различных размерностей таблицы и сравните количество сравнений. Удалите все слова, начинающиеся на указанную букву, выведите таблицу.
4. Постройте хеш-таблицу для зарезервированных слов, используемого
языка программирования (не менее 20 слов), содержащую HELP для
каждого слова. Выдайте на экран подсказку по введенному слову.
Добавьте подсказку по вновь введенному слову, используя при
необходимости реструктуризацию таблицы. Сравните эффективность добавления ключа в таблицу или ее реструктуризацию для
различной степени заполненности таблицы.
5. В текстовом файле содержатся целые числа. Постройте хеш-таблицу
из чисел файла. Осуществите поиск введенного целого числа в хештаблице. Сравните результаты количества сравнений при различном
наборе данных в файле.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
81
7. Алгоритмы поиска в тексте
Краткая аннотация
В данной теме рассматриваются основные понятия и алгоритмы, используемые в задачах поиска в тексте и приводятся примеры реализации основных алгоритмов поиска в тексте.
Цель изучения темы
Изучить основные алгоритмы поиска в тексте и научиться решать задачи
поиска в тексте на основе алгоритмов прямого поиска; Кнута, Морриса и
Пратта; Боуера и Мура.
7.1. Основные понятия задач поиска в тексте
Работа в текстовом редакторе, поисковые запросы в базе данных, задачи в биоинформатике, лексический анализ программ требуют эффективных алгоритмов работы с текстом. Задачи поиска слова в тексте используются в криптографии, различных разделах физики, сжатии данных, распознавании речи и других сферах человеческой деятельности.
Введем ряд определений, которые будут использоваться далее в изложении материала.
Алфавит – конечное множество символов.
Строка (слово) – это последовательность символов из некоторого
алфавита. Длина строки – количество символов в строке.
Строку будем обозначать символами алфавита, например
X  x[1]x[2] ... x[n] – строка длинной n, где x[i ] – i-ый символ строки Х,
принадлежащий алфавиту. Строка, не содержащая ни одного символа,
называется пустой.
Строка X называется подстрокой строки Y, если найдутся такие
строки Z1 и Z 2 , что Y  Z1 XZ 2 . При этом Z1 называют левым, а Z 2 – правым крылом подстроки. Подстрокой может быть и сама строка. Иногда
при этом строку X называют вхождением в строку Y. Например, строки
hrf и fhr является подстроками строки abhrfhr.
Подстрока X называется префиксом строки Y, если есть такая подстрока Z, что Y  XZ . Причем сама строка является префиксом для себя
самой (так как найдется нулевая строка L, что X  XL ). Например, подстрока ab является префиксом строки abcfa.
Подстрока X называется суффиксом строки Y, если есть такая подстрока Z, что Y  ZX . Аналогично, строка является суффиксом себя самой.
Например, подстрока bfg является суффиксом строки vsenfbfg.
Поставим задачу поиска подстроки в строке. Пусть задана строка,
состоящая из некоторого количества символов. Проверим, входит ли заданная подстрока в данную строку. Если входит, то найдем номер, начиная
с какого символа строки, то есть, определим первое вхождение заданной
82
подстроки в исходной строке.
Рассмотрим несколько известных алгоритмов поиска подстроки в
строке более подробно.
7.2. Прямой поиск
Данный алгоритм еще называется алгоритмом последовательного
поиска, он является самым простым и очевидным.
Основная идея алгоритма прямым поиском заключается в посимвольном сравнении строки с подстрокой. В начальный момент происходит сравнение первого символа строки с первым символом подстроки, второго символа строки со вторым символом подстроки и т.д. Если произошло совпадение всех символов, то фиксируется факт нахождения подстроки. В противном случае производится сдвиг подстроки на одну позицию вправо и повторяется посимвольное сравнение, то есть сравнивается второй символ строки
с первым символом подстроки, третий символ строки со вторым символом
подстроки и т.д. (рис. 1) Символы, которые сравниваются, на рисунке выделены жирным. Рассматриваемые сдвиги подстроки повторяются до тех пор,
пока конец подстроки не достиг конца строки или не произошло полное
совпадение символов подстроки со строкой, то есть найдется подстрока.
Подстрока
Строка
i
i
i
i
i
i
i
i
A
B
C
A
B
C
A
A
A
B
C
A
B
D
A
B
C
A
B
D
A
B
C
A
B
D
A
B
C
A
B
D
A
B
C
A
B
D
A
B
C
A
B
D
A
B
C
A
B
D
A
B
C
A
B
B
C
Рис.1. Демонстрация алгоритма прямого поиска
83
A
B
D
D
//Описание функции прямого поиска подстроки в строке
int DirectSearch(char *string, char *substring){
int sl, ssl;
int res = -1;
sl = strlen(string);
ssl = strlen(substring);
if ( sl == 0 )
cout << "Неверно задана строка\n";
else if ( ssl == 0 )
cout << "Неверно задана подстрока\n";
else
for (int i = 0; i < sl - ssl + 1; i++)
for (int j = 0; j < ssl; j++)
if ( substring[j] != string[i+j] )
break;
else if ( j == ssl - 1 ){
res = i;
break;
}
return res;
}
Данный алгоритм является малозатратным и не нуждается в предварительной обработке и в дополнительном пространстве. Большинство
сравнений алгоритма прямого поиска являются лишними. Поэтому в худшем случае алгоритм будет малоэффективен, так как его сложность будет
пропорциональна O((n  m  1)  m) , где n и m – длины строки и подстроки
соответственно.
7.3. Алгоритм Кнута, Морриса и Пратта
Алгоритм был открыт Д. Кнутом и В. Праттом и, независимо от них,
Д. Моррисом. Результаты своей работы они совместно опубликовали в
1977 году. Алгоритм Кнута, Морриса и Пратта (КМП-алгоритм) является
алгоритмом, который фактически требуюет только O(n) сравнений даже в
самом худшем случае. Рассматриваемый алгоритм основывается на том,
что после частичного совпадения начальной части подстроки с соответствующими символами строки фактически известна пройденная часть
строки и можно, вычислить некоторые сведения, с помощью которых затем быстро продвинуться по строке.
Основным отличием алгоритма Кнута, Морриса и Пратта от алгоритма прямого поиска заключается в том, что сдвиг подстроки выполняется не на один символ на каждом шаге алгоритма, а на некоторое переменное количество символов. Следовательно, перед тем как осуществлять
очередной сдвиг, необходимо определить величину сдвига. Для повышения эффективности алгоритма необходимо, чтобы сдвиг на каждом шаге
был бы как можно большим (рис. 2). На рисунке символы, подвергшиеся
84
сравнению, выделены жирным шрифтом.
Если для произвольной подстроки определить все ее начала, одновременно являющиеся ее концами, и выбрать из них самую длинную (не
считая, конечно, саму строку), то такую процедуру принято называть префикс-функцией. В реализации алгоритма Кнута, Морриса и Пратта используется предобработка искомой подстроки, которая заключается в создании
префикс-функции на ее основе. При этом используется следующая идея:
если префикс (он же суффикс) строки длинной i длиннее одного символа,
то он одновременно и префикс подстроки длинной i  1 . Таким образом,
проверяем префикс предыдущей подстроки, если же тот не подходит, то
префикс ее префикса, и т.д. Действуя так, находим наибольший искомый
префикс.
i
Подстрока
Строка
i
A
B
C
A
B
C
A
B
C
A
B
D
A
B
C
i
i
A
A
B
A
B
D
A
B
A
C
A
B
C
A
B
D
B
C
A
B
Рис.2. Демонстрация алгоритма Кнута, Морриса и Пратта
//описание функции алгоритма Кнута, Морриса и Пратта
int KMPSearch(char *string, char *substring){
int sl, ssl;
int res = -1;
sl = strlen(string);
ssl = strlen(substring);
if ( sl == 0 )
cout << "Неверно задана строка\n";
else if ( ssl == 0 )
cout << "Неверно задана подстрока\n";
else {
int i, j = 0, k = -1;
int *d;
d = new int[1000];
d[0] = -1;
while ( j < ssl - 1 ) {
85
D
D
while ( k >= 0 && substring[j] != substring[k] )
k = d[k];
j++;
k++;
if ( substring[j] == substring[k] )
d[j] = d[k];
else
d[j] = k;
}
i = 0;
j = 0;
while ( j < ssl && i < sl ){
while ( j >= 0 && string[i] != substring[j] )
j = d[j];
i++;
j++;
}
delete [] d;
res = j == ssl ? i - ssl : -1;
}
return res;
}
Точный анализ рассматриваемого алгоритма весьма сложен. Д. Кнут,
Д. Моррис и В. Пратт доказывают, что для данного алгоритма требуется
порядка O(m  n) сравнений символов (где n и m – длины строки и подстроки соответственно), что значительно лучше, чем при прямом поиске.
7.4. Алгоритм Бойера и Мура
Алгоритм Бойера и Мура считается наиболее быстрым среди алгоритмов, предназначенных для поиска подстроки в строке. Он был разработан Р. Бойером и Д. Муром в 1977 году. Преимущество этого алгоритма в
том, что необходимо сделать некоторые предварительные вычисления над
подстрокой, чтобы сравнение подстроки с исходной строкой осуществлять
не во всех позициях – часть проверок пропускаются как заведомо не дающие результата.
Существует множество вариаций алгоритма Бойера и Мура, рассмотрим простейший из них, который состоит из следующих шагов. Первоначально строится таблица смещений для искомой подстроки. Далее
идет совмещение начала строки и подстроки и начинается проверка с последнего символа подстроки. Если последний символ подстроки и соответствующий ему при наложении символ строки не совпадают, подстрока
сдвигается относительно строки на величину, полученную из таблицы
смещений, и снова проводится сравнение, начиная с последнего символа
подстроки. Если же символы совпадают, производится сравнение предпоследнего символа подстроки и т.д. Если все символы подстроки совпали с
86
наложенными символами строки, значит, найдена подстрока и поиск окончен. Если же какой-то (не последний) символ подстроки не совпадает с соответствующим символом строки, далее производим сдвиг подстроки на
один символ вправо и снова начинаем проверку с последнего символа.
Весь алгоритм выполняется до тех пор, пока либо не будет найдено вхождение искомой подстроки, либо не будет достигнут конец строки (рис. 3).
На рисунке символы, подвергшиеся сравнению, выделены жирным шрифтом.
Величина сдвига в случае несовпадения последнего символа вычисляется, исходя из следующего: сдвиг подстроки должен быть минимальным, таким, чтобы не пропустить вхождение подстроки в строке. Если
данный символ строки встречается в подстроке, то смещаем подстроку таким образом, чтобы символ строки совпал с самым правым вхождением
этого символа в подстроке. Если же подстрока вообще не содержит этого
символа, то сдвигаем подстроку на величину, равную ее длине, так что
первый символ подстроки накладывается на следующий за проверявшимся
символом строки.
Величина смещения для каждого символа подстроки зависит только
от порядка символов в подстроке, поэтому смещения удобно вычислить
заранее и хранить в виде одномерного массива, где каждому символу алфавита соответствует смещение относительно последнего символа подстроки.
i
Подстрока
Строка
i
i
A
B
C
A
F
D
A
B
C
A
B
D
A
F
A
B
C
A
B
C
A
B
D
A
B
C
A
Рис.3. Демонстрация алгоритма Бойера и Мура
//описание функции алгоритма Бойера и Мура
int BMSearch(char *string, char *substring){
int sl, ssl;
int res = -1;
sl = strlen(string);
ssl = strlen(substring);
if ( sl == 0 )
cout << "Неверно задана строка\n";
87
B
D
B
D
else if ( ssl == 0 )
cout << "Неверно задана подстрока\n";
else {
int i, Pos;
int BMT[256];
for ( i = 0; i < 256; i ++ )
BMT[i] = ssl;
for ( i = ssl-1; i >= 0; i-- )
if ( BMT[(short)(substring[i])] == ssl )
BMT[(short)(substring[i])] = ssl - i - 1;
Pos = ssl - 1;
while ( Pos < sl )
if ( substring[ssl - 1] != string[Pos] )
Pos = Pos + BMT[(short)(string[Pos])];
else
for ( i = ssl - 2; i >= 0; i-- ){
if ( substring[i] != string[Pos - ssl + i + 1] ) {
Pos += BMT[(short)(string[Pos - ssl + i + 1])] - 1;
break;
}
else
if ( i == 0 )
return Pos - ssl + 1;
cout << "\t" << i << endl;
}
}
return res;
}
Алгоритм Бойера и Мура на хороших данных очень быстр, а вероятность появления плохих данных крайне мала. Поэтому он оптимален в
большинстве случаев, когда нет возможности провести предварительную
обработку текста, в котором проводится поиск. Таким образом, данный алгоритм является наиболее эффективным в обычных ситуациях, а его быстродействие повышается при увеличении подстроки или алфавита. В
наихудшем случае трудоемкость рассматриваемого алгоритма O(m  n) .
Существуют попытки совместить присущую алгоритму Кнута, Морриса и Пратта эффективность в «плохих» случаях и скорость алгоритма
Бойера и Мура в «хороших» – например, турбо-алгоритм, обратный алгоритм Колусси и другие.
Каждый алгоритм поиска позволяет эффективно действовать лишь
для своего класса задач, об этом еще говорят различные узконаправленные
улучшения. Алгоритм поиска подстроки в строке следует выбирать только
после точной постановки задачи, которые должна выполнять программа.
88
Ключевые термины
Алгоритм Бойера и Мура – это алгоритм поиска подстроки в строке,
при котором первоначально строится таблица смещений для искомой
подстроки, проверка начинается с последнего символа подстроки после совмещения начала строки и подстроки.
Алгоритм Кнута, Морриса и Пратта – это алгоритм поиска подстроки в строке, при котором сдвиг подстроки выполняется на некоторое
переменное количество символов.
Алгоритм прямого поиска – это алгоритм поиска подстроки в строке,
при котором происходит посимвольное сравнение строки с подстрокой.
Алфавит – конечное множество символов
Длина строки – количество символов в строке
Подстрока – это последовательность подряд идущих символов в строке.
Префикс – это подстрока, начинающаяся с первого символа строки.
Строка – это последовательность символов.
Суффикс – это подстрока, заканчивающаяся на последний символ строки.
Краткие итоги
1. Задачи поиска слова в тексте используются в криптографии, различных разделах физики, сжатии данных, распознавании речи и других
сферах человеческой деятельности.
2. Основная идея алгоритма прямым поиском заключается в посимвольном сравнении строки с подстрокой.
3. Алгоритм прямого поиска является малозатратным и не нуждается в
предварительной обработке и в дополнительном пространстве.
4. Алгоритм Кнута, Морриса и Пратта основывается на том, что после
частичного совпадения начальной части подстроки с соответствующими символами строки можно, вычислить сведения, с помощью
которых быстро продвинуться по строке.
5. Трудоемкость алгоритма Кнута, Морриса и Пратта лучше, чем трудоемкость алгоритма прямого поиска.
6. Особенность алгоритма Бойера и Мура заключается в предварительных вычислениях над подстрокой с целью сравнения подстроки с
исходной строкой, осуществляемой не во всех позициях.
7. Алгоритм Бойера и Мура оптимален в большинстве случаев, когда
нет возможности провести предварительную обработку текста, в котором проводится поиск.
89
Материалы для практики
Вопросы
1. Приведите пример входных данных для реализации эффективного
метода прямого поиска подстроки в строке.
2. Приведите пример строки, для которой поиск подстроки «aaabaaa»
будет более эффективным, если делать его методом Кнута, Морриса
и Пратта, чем, если делать его методом Бойера и Мура. И наоборот.
3. Объясните, как влияет размер таблицы кодов в алгоритме Бойера и
Мура на скорость поиска.
4. За счет чего в алгоритме Бойера и Мура поиск оптимален в большинстве случаев?
5. Поясните влияние префикс-функции в алгоритме Кнута, Морриса и
Пратта на организацию поиска подстроки в строке.
Упражнения
1. На основании приведенных в лекции функций реализуйте алгоритмы
поиска подстроки в строке.
2. Строка S была записана много раз подряд, после чего из получившейся строки взяли подстроку и передали как входные данные.
Необходимо определить минимально возможную длину исходной
строки S.
Формат входных данных
В первой и единственной строке входного файла записана строка,
которая содержит только латинские буквы, длина строки не превышает 50000 символов.
Формат выходных данных
В выходной файл нужно вывести одно число – ответ на задачу.
Пример входного файла input.txt
abababa
Пример выходного файла output.txt
2
3. Даны две строки a и b. Требуется найти максимальную длину префикса строки a, который входит как подстрока в строку b. При этом
считать, что пустая строка является подстрокой любой строки.
Формат входных данных
В первой строке входного файла содержится строка a, во второй –
строка b. Элементами строк a и b являются произвольные символы с
кодами ASCII больше 32. Длина каждой строки от 1 до 30000.
Формат выходных данных
В выходной файл вывести искомую длину префикса строки a, т.е.
целое число от 0 до длины строки a.
Пример входного файла input.txt
90
abcdefghijklmnopqrstuvwxyz
Abcd?aBcd!abCd.abcD!?
Пример выходного файла output.txt
3
4. Назовем строку палиндромом, если она одинаково читается слева
направо и справа налево. Примеры палиндромов: «abcba», «55», «q»,
«xyzzyx». Требуется для заданной строки найти максимальную по
длине ее подстроку, являющуюся палиндромом.
Формат входных данных
Во входном файле содержится единственная строка, состоящая из
строчных букв латинского алфавита и цифр. Длина строки не превосходит 2000.
Формат выходных данных
В выходной файл выведите одно целое число - максимальную длину
подстроки, являющейся палиндромом.
Пример входного файла input.txt
a123bc9e9c321
Пример выходного файла output.txt
5
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
91
8. Алгоритмы поиска на основе деревьев
Краткая аннотация
В данной теме рассматриваются определение и виды деревьев поиска, приемы снижения трудоемкости поиска в древовидных структурах, приводятся описания алгоритмов поиска в двоичных упорядоченных, случайных и
сбалансированных в высоту (АВЛ) деревьях, приводятся примеры программной реализации бинарного дерева поиска и АВЛ-дерева.
Цель изучения темы
Изучить алгоритмы поиска на основе деревьев, научиться решать задачи
поиска через построение упорядоченного, случайного, оптимального или
сбалансированного в высоту деревьев на языке C++.
8.1. Основные понятия задач поиска на основе деревьев
Поиск данных, являясь одним из приоритетных направлений работы
с данными, предполагает использование соответствующих алгоритмов в
зависимости от ряда факторов: способ представления данных, упорядоченность множества поиска, объем данных, расположение их во внешней
или во внутренней памяти. Поиск – процесс нахождения конкретной информации в ранее созданном множестве данных. Как правило, данные
представляют собой структуры, каждая из которых имеет хотя бы один
ключ – значение определенного поля конкретной структуры. Ключ поиска
– это поле, по значению которого происходит поиск.
Рассмотрим организацию поиска данных, имеющих древовидную
структуру. Анализируя дерево только с точки зрения представления данных в виде иерархической структуры, заметим, что выигрыша при организации поиска не получится. Сравнение ключа поиска с эталоном необходимо провести для всех элементов дерева.
Уменьшить число сравнений ключей с эталоном возможно, если выполнить организацию дерева особым образом, то есть расположить его
элементы по определенным правилам. При этом в процессе поиска будет
просмотрено не все дерево, а отдельное поддерево. Такой подход позволяет классифицировать деревья в зависимости от правил построения. Выделим некоторые популярные виды деревьев, на основе которых рассмотрим
организацию поиска.
8.2. Двоичные (бинарные) деревья
Двоичные деревья представляют собой иерархическую структуру, в
которой каждый узел имеет не более двух потомков. То есть двоичное дерево либо является пустым, либо состоит из данных и двух поддеревьев
(каждое из которых может быть пустым). При этом каждое поддерево в
свою очередь тоже является деревом. Поиск на таких структурах не дает
92
выигрыша по выполнению по сравнению с линейными структурами того
же размера, так как необходимо в худшем случае выполнить обход всего
дерева. Поэтому интерес представляют двоичные упорядоченные деревья.
8.3. Двоичные упорядоченные деревья
Двоичное дерево упорядоченно, если для любой его вершины x
справедливы такие свойства (рис. 1):
 все элементы в левом поддереве меньше элемента, хранимого в x;
 все элементы в правом поддереве больше элемента, хранимого в x;
 все элементы дерева различны.
7
4
2
8
6
9
5
Рис. 1. Двоичное упорядоченное дерево
Если в дереве выполняются первые два свойства, но встречаются
одинаковые элементы, то такое дерево является частично упорядоченным.
В дальнейшем будет идти речь только о двоичных упорядоченных деревьях. Основными операциями, производимыми с упорядоченным деревом,
являются:
 поиск вершины;
 добавление вершины;
 удаление вершины;
 вывод (печать) дерева;
 очистка дерева.
Пример 1. Программная реализация основных операций бинарного дерева
поиска.
#include "stdafx.h"
#include <iostream>
#include <time.h>
using namespace std;
typedef int T; // тип элемента
#define compLT(a,b) (a < b)
#define compEQ(a,b) (a == b)
typedef struct Node_ {
T data; // значение узла
struct Node_ *left;// левый потомок
93
struct Node_ *right;// правый потомок
struct Node_ *parent;// родитель
} Node;
Node *root = NULL; //корень бинарного дерева поиска
Node* insertNode(T data);
void deleteNode(Node *z);
Node* findNode(T data);
void printTree(Node *node, int l = 0);
int _tmain(int argc, _TCHAR* argv[]){
int i, *a, maxnum;
cout << "Введите количество элементов maxnum : ";
cin >> maxnum;
cout << endl;
a = new int[maxnum];
srand(time(NULL)*1000);
// генерация массива
for (i = 0; i < maxnum; i++)
a[i] = rand();
cout << "Вывод сгенерированной последовательности" << endl;
for (i = 0; i < maxnum; i++)
cout << a[i] << " ";
cout << endl;
cout << endl;
// добавление элементов в бинарное дерево поиска
for (i = 0; i < maxnum; i++) {
insertNode(a[i]);
}
cout << "Вывод бинарного дерева поиска" << endl;
printTree(root);
cout << endl;
// поиск элементов по бинарному дереву поиска
for (i = maxnum-1; i >= 0; i--) {
findNode(a[i]);
}
// очистка бинарного дерева поиска
for (i = 0; i < maxnum; i++) {
deleteNode(findNode(a[i]));
}
system("pause");
return 0;
}
//функция выделения памяти для нового узла и вставка в дерево
Node* insertNode(T data) {
Node *x, *current, *parent;
current = root;
parent = 0;
94
while (current) {
if ( data == current->data ) return (current);
parent = current;
current = data < current->data ?
current->left : current->right;
}
x = new Node;
x->data = data;
x->parent = parent;
x->left = NULL;
x->right = NULL;
if(parent)
if( x->data < parent->data )
parent->left = x;
else
parent->right = x;
else
root = x;
return(x);
}
//функция удаления узла из дерева
void deleteNode(Node *z) {
Node *x, *y;
if (!z || z == NULL) return;
if (z->left == NULL || z->right == NULL)
y = z;
else {
y = z->right;
while (y->left != NULL) y = y->left;
}
if (y->left != NULL)
x = y->left;
else
x = y->right;
if (x) x->parent = y->parent;
if (y->parent)
if (y == y->parent->left)
y->parent->left = x;
else
y->parent->right = x;
else
root = x;
if (y != z) {
y->left = z->left;
if (y->left) y->left->parent = y;
y->right = z->right;
if (y->right) y->right->parent = y;
95
y->parent = z->parent;
if (z->parent)
if (z == z->parent->left)
z->parent->left = y;
else
z->parent->right = y;
else
root = y;
free (z);
}
else {
free (y);
}
}
//функция поиска узла, содержащего data
Node* findNode(T data) {
Node *current = root;
while(current != NULL)
if(compEQ(data, current->data))
return (current);
else
current = compLT(data, current->data) ?
current->left : current->right;
return(0);
}
//функция вывода бинарного дерева поиска
void printTree(Node *node, int l){
int i;
if (node != NULL) {
printTree(node->right, l+1);
for (i=0; i < l; i++) cout << "
";
printf ("%4ld", node->data);
printTree(node->left, l+1);
}
else cout << endl;
}
Алгоритм удаления элемента более трудоемкий, так как надо соблюдать упорядоченность дерева. При удалении может случиться, что удаляемый элемент находится не в листе, то есть вершина имеет ссылки на реально существующие поддеревья. Эти поддеревья терять нельзя, а присоединить два поддерева на одно освободившееся после удаления место невозможно. Поэтому необходимо поместить на освободившееся место либо
самый правый элемент из левого поддерева, либо самый левый из правого
поддерева. Упорядоченность дерева при этом не нарушится. Удобно придерживаться одной стратегии, например, заменять самый левый элемент из
96
правого поддерева. Нельзя забывать, что при замене вершина, на которую
производится замена, может иметь правое поддерево. Это поддерево необходимо поставить вместо перемещаемой вершины.
Временная сложность этих алгоритмов (она одинакова для этих алгоритмов, так как в их основе лежит поиск) оценим для наилучшего и
наихудшего случая. В лучшем случае, то есть случае полного двоичного
дерева, получаем сложность Omin (log n) . В худшем случае дерево может
выродиться в список. Такое может произойти, например, при добавлении
элементов в порядке возрастания. При работе со списком в среднем придется просмотреть половину списка. Это даст сложность Omax (n) .
8.4. Случайные деревья
Случайные деревья поиска представляют собой упорядоченные бинарные деревья поиска, при создании которых элементы (их ключи) вставляются в случайном порядке.
При создании таких деревьев используется тот же алгоритм, что и
при добавлении вершины в бинарное дерево поиска. Будет ли созданное
дерево случайным или нет, зависит от того, в каком порядке поступают
элементы для добавления. Примеры различных деревьев, создаваемых при
различном порядке поступления элементов, приведены ниже (рис. 2).
При поступлении элементов в случайном порядке получаем дерево с
минимальной высотой h (рис. 2 А), при этом минимизируется время поиска элемента в дереве, которое пропорционально O(log n) . При поступлении
элементов в упорядоченном виде (рис. 2 В) или в порядке с единичными
сериями монотонности (рис. 2 С) происходит построение вырожденных
деревьев поиска (оно вырождено в линейный список), что нисколько не
сокращает время поиска, которое составляет O(n) .
1 2 3 4 5
3 1 2 4 5
A
B
3
4
1
2
1 5 2 4 3
С
1
1
2
5
5
3
2
4
4
5
Рис. 2. Случайные деревья поиска
97
3
8.5. Оптимальные деревья
В двоичном дереве поиск одних элементов может происходить чаще,
чем других, то есть существуют вероятности p k поиска k-го элемента и
для различных элементов эти вероятности неодинаковы. Можно предположить, что поиск в дереве в среднем будет более быстрым, если те элементы, которые ищут чаще, будут находиться ближе к корню дерева.
Пусть даны 2n  1 вероятностей p1 , p2 , ... , pn , q0 , q1 , ... , qn , где pi –
вероятность того, что аргументом поиска является K i элемент; q i – вероятность того, что аргумент поиска лежит между вершинами K i и K i 1 ; q0 –
вероятность того, что аргумент поиска меньше, чем значение элемента K1 ;
qn – вероятность того, что аргумент поиска больше, чем K n . Тогда цена
дерева поиска C будет определяться следующим образом:
n
n
j 1
k 1
C   p j (levelroot j  1)   qk (levellist k ) ,
где levelroot j – уровень узла j, а levellist k – уровень листа K.
Дерево поиска называется оптимальным, если его цена минимальна.
То есть оптимальное бинарное дерево поиска – это бинарное дерево поиска, построенное в расчете на обеспечение максимальной производительности при заданном распределении вероятностей поиска требуемых данных.
Существует подход построения оптимальных деревьев поиска, при
котором элементы вставляются в порядке уменьшения частот, что дает в
среднем неплохие деревья поиска. Однако этот подход может дать вырожденное дерево поиска, которое будет далеко от оптимального. Еще один
подход состоит в выборе корня k таким образом, чтобы максимальная
сумма вероятностей для вершин левого поддерева или правого поддерева
была настолько мала, насколько это возможно. Такой подход также может
оказаться плохим в случае выбора в качестве корня элемента с малым значением p k .
Существуют алгоритмы, которые позволяют построить оптимальное
дерево поиска. К ним относится, например, алгоритм Гарсия-Воча. Однако
такие алгоритмы имеют временную сложность порядка O ( n 2 ) . Таким образом, создание оптимальных деревьев поиска требует больших накладных
затрат, что не всегда оправдывает выигрыш при быстром поиске.
8.6. Сбалансированные по высоте деревья
В худшем случае, когда дерево вырождено в линейный список, хранение данных в упорядоченном бинарном дереве никакого выигрыша в
сложности операций по сравнению с массивом или линейным списком не
дает. В лучшем случае, когда дерево сбалансировано, для всех операций
98
получается логарифмическая сложность, что гораздо лучше. Идеально
сбалансированным называется дерево, у которого для каждой вершины
выполняется требование: число вершин в левом и правом поддеревьях различается не более чем на 1.
Однако идеальную сбалансированность довольно трудно поддерживать. В некоторых случаях при добавлении или удалении элементов может
потребоваться значительная перестройка дерева, не гарантирующая логарифмической сложности. В 1962 году два советских математика:
Г.М. Адельсон-Вельский и Е.М. Ландис – ввели менее строгое определение сбалансированности и доказали, что при таком определении можно
написать программы добавления и/или удаления, имеющие логарифмическую сложность и сохраняющие дерево сбалансированным. Дерево считается сбалансированным по АВЛ (сокращения от фамилий Г.М. АдельсонВельский и Е.М. Ландис), если для каждой вершины выполняется требование: высота левого и правого поддеревьев различаются не более, чем на 1.
Не всякое сбалансированное по АВЛ дерево идеально сбалансировано, но
всякое идеально сбалансированное дерево сбалансировано по АВЛ.
При операциях добавления и удаления может произойти нарушение
сбалансированности дерева. В этом случае потребуются некоторые преобразования, не нарушающие упорядоченности дерева и способствующие
лучшей сбалансированности.
Рассмотрим такие преобразования. Пусть вершина a имеет правый
потомок b. Обозначим через P левое поддерево вершины a, через Q и R –
левое и правое поддеревья вершины b соответственно. Упорядоченность
дерева требует, чтобы P  a  Q  b  R . Точно того же требует упорядоченность дерева с корнем b, его левым потомком a, в котором P и Q – левое и правое поддеревья вершины a, R – правое поддерево вершины b. Поэтому первое дерево можно преобразовать во второе, не нарушая упорядоченности. Такое преобразование называется малым правым вращением
(рис. 3). Аналогично определяется симметричное ему малое левое вращение.
b
a
a
b
R
P
Q
R
P
Q
Рис. 3. Малое правое вращение АВЛ-дерева
Пусть b – правый потомок вершины a, c – левый потомок вершины b,
P – левое поддерево вершины a, Q и R – соответственно левое и правое
поддеревья вершины c, S – правое поддерево b. Тогда
99
P  a  Q  c  R  b  S . Такой же порядок соответствует дереву с корнем
c, имеющим левый потомок a и правый потомок b, для которых P и Q –
поддеревья вершины a, а R и S – поддеревья вершины b. Соответствующее
преобразование будем называть большим правым вращением (рис. 4). Аналогично определяется симметричное ему большое левое вращение.
a
c
b
a
b
P
c
P
S
Q
Q
R
S
R
Рис. 4. Большое правое вращение АВЛ-дерева
Схематично алгоритм добавления нового элемента в сбалансированное по АВЛ дерево будет состоять из следующих трех основных шагов.
Шаг 1. Поиск по дереву.
Шаг 2. Вставка элемента в место, где закончился поиск, если элемент
отсутствует.
Шаг 3. Восстановление сбалансированности.
Первый шаг необходим для того, чтобы убедиться в отсутствии элемента в дереве, а также найти такое место вставки, чтобы после вставки
дерево осталось упорядоченным. Третий шаг представляет собой обратный
проход по пути поиска: от места добавления к корню дерева. По мере продвижения по этому пути корректируются показатели сбалансированности
проходимых вершин, и производится балансировка там, где это необходимо. Добавление элемента в дерево никогда не требует более одного поворота.
Пример 2. Программная реализация основных операций АВЛ-дерева.
#include "stdafx.h"
#include <iostream>
#include <time.h>
using namespace std;
typedef int ElementType;
typedef struct AvlNode *Position;
typedef struct AvlNode *AvlTree;
struct AvlNode {
ElementType Element;
AvlTree Left;
100
AvlTree Right;
int Height;
};
AvlTree MakeEmpty( AvlTree T );
Position Find( ElementType X, AvlTree T );
Position FindMin( AvlTree T );
Position FindMax( AvlTree T );
AvlTree Insert( ElementType X, AvlTree T );
ElementType Retrieve( Position P );
void printTree(AvlTree T, int l = 0);
int _tmain(int argc, _TCHAR* argv[]){
int i, *a, maxnum;
AvlTree T;
Position P;
int j = 0;
cout << "Введите количество элементов maxnum : ";
cin >> maxnum;
cout << endl;
a = new int[maxnum];
srand(time(NULL)*1000);
// генерация массива
for (i = 0; i < maxnum; i++)
a[i] = rand()%100;
cout << "Вывод сгенерированной последовательности" << endl;
for (i = 0; i < maxnum; i++)
cout << a[i] << " ";
cout << endl;
cout << endl;
// добавление элементов в АВЛ-дерево
T = MakeEmpty( NULL );
for( i = 0; i < maxnum; i++ )
T = Insert( a[i], T );
cout << "Вывод АВЛ-дерева" << endl;
printTree(T);
cout << endl;
cout << "Min = " << Retrieve( FindMin( T ) ) << ", Max = "
<< Retrieve( FindMax( T ) ) << endl;
// удаление АВЛ-дерева
T = MakeEmpty(T);
delete [] a;
system("pause");
return 0;
}
101
//функция удаления вершины и его поддеревьев
AvlTree MakeEmpty( AvlTree T ) {
if( T != NULL ){
MakeEmpty( T->Left );
MakeEmpty( T->Right );
free( T );
}
return NULL;
}
// поиск вершины со значением X
Position Find( ElementType X, AvlTree T ) {
if( T == NULL )
return NULL;
if( X < T->Element )
return Find( X, T->Left );
else
if( X > T->Element )
return Find( X, T->Right );
else
return T;
}
//функция поиска вершины с минимальным значением
Position FindMin( AvlTree T ) {
if( T == NULL )
return NULL;
else
if( T->Left == NULL )
return T;
else
return FindMin( T->Left );
}
//функция поиска вершины с максимальным значением
Position FindMax( AvlTree T ) {
if( T != NULL )
while( T->Right != NULL )
T = T->Right;
return T;
}
//функция возвращает вес вершины
static int Height( Position P ) {
if( P == NULL )
return -1;
else
return P->Height;
}
102
//функция возвращает максимальное из двух чисел
static int Max( int Lhs, int Rhs ) {
return Lhs > Rhs ? Lhs : Rhs;
}
/*функция выполняет поворот между вершинами K2 и его левым
потомком*/
static Position SingleRotateWithLeft( Position K2 ) {
Position K1;
K1 = K2->Left;
K2->Left = K1->Right;
K1->Right = K2;
K2->Height = Max(Height(K2->Left), Height(K2->Right)) + 1;
K1->Height = Max(Height( K1->Left ), K2->Height) + 1;
return K1; //Новый корень
}
/*функция выполняет поворот между вершинами K1 и его
правым потомком*/
static Position SingleRotateWithRight( Position K1 ) {
Position K2;
K2 = K1->Right;
K1->Right = K2->Left;
K2->Left = K1;
K1->Height = Max(Height(K1->Left), Height(K1->Right)) + 1;
K2->Height = Max(Height( K2->Right ), K1->Height) + 1;
return K2; //новый корень
}
//функция выполняет двойной левый-правый поворот
static Position DoubleRotateWithLeft( Position K3 ) {
// поворот между K1 и K2/
K3->Left = SingleRotateWithRight( K3->Left );
// поворот между K3 и K2
return SingleRotateWithLeft( K3 );
}
//функция выполняет двойной правый-левый поворот
static Position DoubleRotateWithRight( Position K1 ) {
// поворот между K3 и K2
K1->Right = SingleRotateWithLeft( K1->Right );
// поворот между K1 и K2
return SingleRotateWithRight( K1 );
}
//функция вставки вершины в АВЛ-дерево
AvlTree Insert( ElementType X, AvlTree T ){
103
if( T == NULL ){
T = new AvlNode();
if( T == NULL )
fprintf( stderr, "Недостаточно памяти!!!\n" );
else {
T->Element = X; T->Height = 0;
T->Left = T->Right = NULL;
}
}
else if( X < T->Element ) {
T->Left = Insert( X, T->Left );
if( Height( T->Left ) - Height( T->Right ) == 2 )
if( X < T->Left->Element )
T = SingleRotateWithLeft( T );
else
T = DoubleRotateWithLeft( T );
}
else if( X > T->Element ) {
T->Right = Insert( X, T->Right );
if( Height( T->Right ) - Height( T->Left ) == 2 )
if( X > T->Right->Element )
T = SingleRotateWithRight( T );
else
T = DoubleRotateWithRight( T );
}
T->Height = Max(Height(T->Left), Height(T->Right)) + 1;
return T;
}
//функция возвращает значение, хранящееся в вершине
ElementType Retrieve( Position P ) {
return P->Element;
}
//функция вывода АВЛ-дерева на печать
void printTree(AvlTree T, int l){
int i;
if ( T != NULL ) {
printTree(T->Right, l+1);
for (i=0; i < l; i++) cout << "
printf ("%4ld", Retrieve ( T ));
printTree(T->Left, l+1);
}
else cout << endl;
}
";
Алгоритм удаления элемента из сбалансированного дерева будет выглядеть так:
104
Шаг 1. Поиск по дереву.
Шаг 2. Удаление элемента из дерева.
Шаг 3. Восстановление сбалансированности дерева (обратный проход).
Первый шаг необходим, чтобы найти в дереве вершину, которая
должна быть удалена. Третий шаг представляет собой обратный проход от
места, из которого взят элемент для замены удаляемого, или от места, из
которого удален элемент, если в замене не было необходимости. Операция
удаления может потребовать перебалансировки всех вершин вдоль обратного пути к корню дерева, т.е. порядка log n вершин. Таким образом, алгоритмы поиска, добавления и удаления элементов в сбалансированном по
АВЛ дереве имеют сложность, пропорциональную O(log n) .
8.7. Деревья цифрового (поразрядного) поиска
Методы цифрового поиска достаточно громоздки и плохо иллюстрируются. Рассмотрим бинарное дерево цифрового поиска. Как и в деревьях,
рассмотренных выше, в каждой вершине такого дерева хранится полный
ключ, но переход по левой или правой ветви происходит не путем сравнения ключа-эталона со значением ключа, хранящегося в вершине, а на основе значения очередного бита аргумента. Реализация цифрового поиска
происходит поразрядно (побитово).
Поиск начинается от корня дерева. Если содержащийся в корневой
вершине ключ не совпадает с аргументом поиска, то анализируется самый
левый бит аргумента. Если он равен 0, происходит переход по левой ветви,
если 1 – по правой. Если не обнаруживается совпадение ключа с аргументом поиска, то анализируется следующий бит аргумента и т.д. Поиск завершается, когда будут проверены все биты аргумента либо встретится
вершина с отсутствующей левой или правой ссылкой.
Ключевые термины
Бинарное дерево цифрового поиска – это дерево, в каждой вершине которого хранится полный ключ, а переход по ветвям происходит на
основе значения очередного бита аргумента.
Двоичное (бинарное) дерево – это иерархическая структура, в которой
каждый узел имеет не более двух потомков.
Идеально сбалансированное дерево – это дерево, у которого для каждой вершины выполняется требование: число вершин в левом и правом поддеревьях различается не более чем на 1.
Ключ поиска – это поле, по значению которого происходит поиск.
Оптимальное бинарное дерево поиска – это бинарное дерево поиска,
построенное в расчете на обеспечение максимальной производительности при заданном распределении вероятностей поиска требуемых данных.
105
Поиск – это процесс нахождения конкретной информации в ранее созданном множестве данных.
Сбалансированное по АВЛ дерево – это дерево, для каждой вершины
которого выполняется требование: высота левого и правого поддеревьев различаются не более, чем на 1.
Случайные деревья поиска – это упорядоченные бинарные деревья поиска, при создании которых элементы вставляются в случайном порядке.
Упорядоченное двоичное дерево – это двоичное дерево, в котором для
любой его вершины x справедливы свойства: все элементы в левом
поддереве меньше элемента, хранимого в x; все элементы в правом
поддереве больше элемента, хранимого в x; все элементы дерева различны.
Частично упорядоченное бинарное дерево – это упорядоченное бинарное дерево, в котором встречаются одинаковые элементы.
Краткие итоги
1. Поиск данных предполагает использование соответствующих алгоритмов в зависимости от ряда факторов: способ представления данных, упорядоченность множества поиска, объем данных, расположение их во внешней или во внутренней памяти.
2. Двоичные деревья представляют собой иерархическую структуру, в
которой каждый узел имеет не более двух потомков. Поиск на двоичных деревьях не дает выигрыша по времени по сравнению с линейными структурами.
3. Упорядоченное двоичное дерево – это двоичное дерево, в котором
для любой его вершины x справедливы свойства: все элементы в левом поддереве меньше элемента, хранимого в x; все элементы в правом поддереве больше элемента, хранимого в x; все элементы дерева
различны. Поиск в худшем случае на таких деревьях имеет сложность O(n) .
4. Случайные деревья поиска представляют собой упорядоченные бинарные деревья поиска, при создании которых элементы (их ключи)
вставляются в случайном порядке. Высота дерева зависит от случайного поступления элементов, поэтому трудоемкость определяется
построением дерева.
5. Оптимальное бинарное дерево поиска – это бинарное дерево поиска,
построенное в расчете на обеспечение максимальной производительности при заданном распределении вероятностей поиска требуемых данных. Поиск на таких деревьях имеет сложность порядка
O(n 2 ) .
6. Дерево считается сбалансированным по АВЛ, если для каждой вершины выполняется требование: высота левого и правого поддеревь106
ев различаются не более, чем на 1. Алгоритмы поиска, добавления и
удаления элементов в таком дереве имеют сложность, пропорциональную O(log n) .
7. В деревьях цифрового поиска осуществляется поразрядное сравнение ключей.
Материалы для практики
Вопросы
1. Почему поиск на бинарных деревьях не дает выигрыша по сложности по сравнению с линейными структурами?
2. С какой целью производится балансировка деревьев?
3. Какое из деревьев: упорядоченное, случайное, оптимальное или сбалансированное по АВЛ – дает наибольший выигрыш по трудоемкости? Рассмотрите различные случаи.
4. Выполните левое малое вращение дерева, приведенного на рис 3.
5. Выполните левое большое вращение дерева, приведенного на рис 4.
6. Как выполняется балансировка элементов в упорядоченных после
вставки или удаления элемента?
7. Всегда ли возможна балансировка упорядоченных деревьев? Ответ
обоснуйте.
8. Как выполняется балансировка элементов в АВЛ-деревьях после
вставки или удаления элемента?
Упражнения
1. На основании приведенных в лекции кодов реализуйте основные
операции, производимые в бинарном дереве поиска и АВЛ-дереве.
2. Реализуйте алгоритм удаления элемента из АВЛ-дерева.
3. В упорядоченном двоичном дереве с целочисленными ключами возведите в квадрат корневой элемент. Выполните балансировку дерева.
4. Найдите в АВЛ-дереве такое поддерево, которое является упорядоченным бинарным деревом.
5. Найдите в АВЛ-дереве такое поддерево максимальной высоты, которое является упорядоченным бинарным деревом.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
107
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
108
9. Алгоритмы сжатия данных
Краткая аннотация
В данной теме рассматриваются основные понятия и алгоритмы сжатия
данных, приводятся примеры программной реализации алгоритма Хаффмана через префиксные коды и на основе кодовых деревьев.
Цель изучения темы
Изучить основные виды и алгоритмы сжатия данных и научиться решать
задачи сжатия данных по методу Хаффмана и с помощью кодовых деревьев.
9.1. Основные понятия и методы сжатия данных
Основоположником науки о сжатии информации принято считать
Клода Шеннона. Его теорема об оптимальном кодировании показывает, к
чему нужно стремиться при кодировании информации и насколько та или
иная информация при этом сожмется. Кроме того, им были проведены
опыты по эмпирической оценке избыточности английского текста. Шенон
предлагал людям угадывать следующую букву и оценивал вероятность
правильного угадывания. На основе ряда опытов он пришел к выводу, что
количество информации в английском тексте колеблется в пределах 0,6 –
1,3 бита на символ. Несмотря на то, что результаты исследований Шеннона были по-настоящему востребованы лишь десятилетия спустя, трудно
переоценить их значение.
Сжатие данных – это процесс, обеспечивающий уменьшение объема данных путем сокращения их избыточности. Сжатие данных связано с
компактным расположением порций данных стандартного размера. Сжатие данных можно разделить на два основных типа:
 Сжатие без потерь (полностью обратимое) – это метод сжатия данных, при котором ранее закодированная порция данных восстанавливается после их распаковки полностью без внесения изменений. Для каждого типа данных, как правило, существуют свои оптимальные алгоритмы сжатия без потерь.
 Сжатие с потерями – это метод сжатия данных, при котором для обеспечения максимальной степени сжатия исходного массива данных часть
содержащихся в нем данных отбрасывается. Для текстовых, числовых и
табличных данных использование программ, реализующих подобные
методы сжатия, является неприемлемыми. В основном такие алгоритмы
применяются для сжатия аудио- и видеоданных, статических изображений.
Алгоритм сжатия данных (алгоритм архивации) – это алгоритм,
который устраняет избыточность записи данных.
Введем ряд определений, которые будут использоваться далее в изложении материала.
109
Алфавит кода – множество всех символов входного потока. При
сжатии англоязычных текстов обычно используют множество из 128
ASCII кодов. При сжатии изображений множество значений пиксела может содержать 2, 16, 256 или другое количество элементов.
Кодовый символ – наименьшая единица данных, подлежащая сжатию. Обычно символ – это 1 байт, но он может быть битом, тритом {0,1,2},
или чем-либо еще.
Кодовое слово – это последовательность кодовых символов из алфавита кода. Если все слова имеют одинаковую длину (число символов), то
такой код называется равномерным (фиксированной длины), а если же допускаются слова разной длины, то – неравномерным (переменной длины).
Код – полное множество слов.
Токен – единица данных, записываемая в сжатый поток некоторым
алгоритмом сжатия. Токен состоит из нескольких полей фиксированной
или переменной длины.
Фраза – фрагмент данных, помещаемый в словарь для дальнейшего
использования в сжатии.
Кодирование – процесс сжатия данных.
Декодирование – обратный кодированию процесс, при котором осуществляется восстановление данных.
Отношение сжатия – одна из наиболее часто используемых величин для обозначения эффективности метода сжатия.
размер выходного потока
Отношение сжатия 
размер входного потока
Значение 0,6 означает, что данные занимают 60% от первоначального объема. Значения больше 1 означают, что выходной поток больше входного
(отрицательное сжатие, или расширение).
Коэффициент сжатия – величина, обратная отношению сжатия.
размер входного потока
Коэффициент сжатия 
размер выходного потока
Значения больше 1 обозначают сжатие, а значения меньше 1 – расширение.
Средняя длина кодового слова – это величина, которая вычисляется
как взвешенная вероятностями сумма длин всех кодовых слов.
Lср  p1 L1  p2 L2  ...  pn Ln ,
где p1 , p2 ,..., pn – вероятности кодовых слов;
L1 , L2 ,..., Ln – длины кодовых слов.
Существуют два основных способа проведения сжатия.
 Статистические методы – методы сжатия, присваивающие коды переменной длины символам входного потока, причем более короткие коды присваиваются символам или группам символам, имеющим большую вероятность появления во входном потоке. Лучшие статистические методы применяют кодирование Хаффмана.
110
Словарное сжатие – это методы сжатия, хранящие фрагменты данных
в «словаре» (некоторая структура данных). Если строка новых данных,
поступающих на вход, идентична какому-либо фрагменту, уже находящемуся в словаре, в выходной поток помещается указатель на этот
фрагмент. Лучшие словарные методы применяют метод Зива-Лемпела.
Рассмотрим несколько известных алгоритмов сжатия данных более
подробно.

9.2. Метод Хаффмана
Этот алгоритм кодирования информации был предложен
Д.А. Хаффманом в 1952 году. Хаффмановское кодирование (сжатие) –
это широко используемый метод сжатия, присваивающий символам алфавита коды переменной длины основываясь на вероятностях появления этих
символов.
Идея алгоритма состоит в следующем: зная вероятности вхождения
символов в исходный текст, можно описать процедуру построения кодов
переменной длины, состоящих из целого количества битов. Символам с
большей вероятностью присваиваются более короткие коды. Таким образом, в этом методе при сжатии данных каждому символу присваивается
оптимальный префиксный код, основанный на вероятности его появления
в тексте.
Префиксный код – это код, в котором никакое кодовое слово не является префиксом любого другого кодового слова. Эти коды имеют переменную длину.
Оптимальный префиксный код – это префиксный код, имеющий
минимальную среднюю длину.
Алгоритм Хаффмана можно разделить на два этапа.
1) Определение вероятности появления символов в исходном тексте.
Первоначально необходимо прочитать исходный текст полностью и подсчитать вероятности появления символов в нем (иногда подсчитывают,
сколько раз встречается каждый символ). Если при этом учитываются все
256 символов, то не будет разницы в сжатии текстового или файла иного
формата.
2) Нахождение оптимального префиксного кода.
Далее находятся два символа a и b с наименьшими вероятностями появления и заменяются одним фиктивным символом x, который имеет вероятность появления, равную сумме вероятностей появления символов a и b.
Затем, используя эту процедуру рекурсивно, находится оптимальный префиксный код для меньшего множества символов (где символы a и b заменены одним символом x). Код для исходного множества символов получается из кодов замещающих символов путем добавления 0 или 1 перед кодом замещающего символа, и эти два новых кода принимаются как коды
заменяемых символов. Например, код символа a будет соответствовать ко111
ду x с добавленным нулем перед этим кодом, а для символа b перед кодом
символа x будет добавлена единица.
Коды Хаффмана имеют уникальный префикс, что и позволяет однозначно их декодировать, несмотря на их переменную длину.
Пример 1. Программная реализация метода Хаффмана.
#include "stdafx.h"
#include <iostream>
using namespace std;
void
long
void
void
void
void
Expectancy();
MinK();
SumUp();
BuildBits();
OutputResult(char **Result);
Clear();
const int MaxK = 1000;
long k[MaxK + 1], a[MaxK + 1], b[MaxK + 1];
char bits[MaxK + 1][40];
char sk[MaxK + 1];
bool Free[MaxK + 1];
char *res[256];
long i, j, n, m, kj, kk1, kk2;
char str[256];
int _tmain(int argc, _TCHAR* argv[]){
char *BinaryCode;
Clear();
cout << "Введите строку для кодирования : ";
cin >> str;
Expectancy();
SumUp();
BuildBits();
OutputResult(&BinaryCode);
cout << "Закодированная строка : " << endl;
cout << BinaryCode << endl;
system("pause");
return 0;
}
//описание функции обнуления данных в массивах
void Clear(){
for (i = 0; i < MaxK + 1; i++){
k[i] = a[i] = b[i] = 0;
sk[i] = 0;
Free[i] = true;
for (j = 0; j < 40; j++)
bits[i][j] = 0;
112
}
}
/*описание функции вычисления вероятности вхождения каждого символа в тексте*/
void Expectancy(){
long *s = new long[256];
for ( i = 0; i < 256; i++)
s[i] = 0;
for ( n = 0; n < strlen(str); n++ )
s[str[n]]++;
j = 0;
for ( i = 0; i < 256; i++)
if ( s[i] != 0 ){
j++;
k[j] = s[i];
sk[j] = i;
}
kj = j;
}
/*описание функции нахождения минимальной частоты символа
в исходном тексте*/
long MinK(){
long min;
i = 1;
while ( !Free[i] && i < MaxK) i++;
min = k[i];
m = i;
for ( i = m + 1; i <= kk2; i++ )
if ( Free[i] && k[i] < min ){
min = k[i];
m = i;
}
Free[m] = false;
return min;
}
//описание функции подсчета суммарной частоты символов
void SumUp(){
long s1, s2, m1, m2;
for ( i = 1; i <= kj; i++ ){
Free[i] = true;
a[i] = 0;
b[i] = 0;
}
kk1 = kk2 = kj;
while (kk1 > 2){
s1 = MinK();
m1 = m;
s2 = MinK();
113
m2 = m;
kk2++;
k[kk2] = s1 + s2;
a[kk2] = m1;
b[kk2] = m2;
Free[kk2] = true;
kk1--;
}
}
//описание функции формирования префиксных кодов
void BuildBits(){
strcpy(bits[kk2],"1");
Free[kk2] = false;
strcpy(bits[a[kk2]],bits[kk2]);
strcat( bits[a[kk2]] , "0");
strcpy(bits[b[kk2]],bits[kk2]);
strcat( bits[b[kk2]] , "1");
i = MinK();
strcpy(bits[m],"0");
Free[m] = true;
strcpy(bits[a[m]],bits[m]);
strcat( bits[a[m]] , "0");
strcpy(bits[b[m]],bits[m]);
strcat( bits[b[m]] , "1");
for ( i = kk2 - 1; i > 0; i-- )
if ( !Free[i] ) {
strcpy(bits[a[i]],bits[i]);
strcat( bits[a[i]] , "0");
strcpy(bits[b[i]],bits[i]);
strcat( bits[b[i]] , "1");
}
}
//описание функции вывода данных
void OutputResult(char **Result){
(*Result) = new char[1000];
for (int t = 0; i < 1000 ;i++)
(*Result)[t] = 0;
for ( i = 1; i <= kj; i++ )
res[sk[i]] = bits[i];
for (i = 0; i < strlen(str); i++)
strcat( (*Result) , res[str[i]]);
}
Алгоритм Хаффмана универсальный, его можно применять для сжатия данных любых типов, но он малоэффективен для файлов маленьких
размеров (за счет необходимости сохранения словаря). В настоящее время
данный метод практически не применяется в чистом виде, обычно используется как один из этапов сжатия в более сложных схемах. Это единствен114
ный алгоритм, который не увеличивает размер исходных данных в худшем
случае (если не считать необходимости хранить таблицу перекодировки
вместе с файлом).
9.3. Кодовые деревья
Рассмотрим реализацию алгоритма Хаффмана с использованием кодовых деревьев.
Кодовое дерево (дерево кодирования Хаффмана, Н-дерево) – это
бинарное дерево, у которого:
 листья помечены символами, для которых разрабатывается кодировка;
 узлы (в том числе корень) помечены суммой вероятностей появления всех символов, соответствующих листьям поддерева, корнем которого является соответствующий узел.
Метод Хаффмана на входе получает таблицу частот встречаемости
символов в исходном тексте. Далее на основании этой таблицы строится
дерево кодирования Хаффмана.
Алгоритм построения дерева Хаффмана.
Шаг 1. Символы входного алфавита образуют список свободных узлов. Каждый лист имеет вес, который может быть равен либо вероятности,
либо количеству вхождений символа в сжимаемый текст.
Шаг 2. Выбираются два свободных узла дерева с наименьшими весами.
Шаг 3. Создается их родитель с весом, равным их суммарному весу.
Шаг 4. Родитель добавляется в список свободных узлов, а двое его
детей удаляются из этого списка.
Шаг 5. Одной дуге, выходящей из родителя, ставится в соответствие
бит 1, другой – бит 0.
Шаг 6. Повторяем шаги, начиная со второго, до тех пор, пока в
списке свободных узлов не останется только один свободный узел. Он и
будет считаться корнем дерева.
Существует два подхода к построению кодового дерева: от корня к
листьям и от листьев к корню.
Пример построения кодового дерева. Пусть задана исходная последовательность символов:
aabbbbbbbbccсcdeeeee.
Ее исходный объем равен 20 байт (160 бит). В соответствии с приведенными на рисунке 1 данными (таблица вероятности появления символов, кодовое дерево и таблица оптимальных префиксных кодов) закодированная исходная последовательность символов будет выглядеть следующим образом:
110111010000000011111111111111001010101010.
Следовательно, ее объем будет равен 42 бита. Коэффициент сжатия приближенно равен 3,8.
115
Вероятности появления
символов
Символ Вероятность
0,1
a
0,4
b
0,2
c
0,05
d
0,25
e
Оптимальные префиксные коды
Кодовое дерево
b
e
c
d
Символ
a
b
c
d
e
Код
1101
0
111
1100
10
a
Рис.1. Создание оптимальных префиксных кодов
Классический алгоритм Хаффмана имеет один существенный недостаток. Для восстановления содержимого сжатого текста при декодировании необходимо знать таблицу частот, которую использовали при кодировании. Следовательно, длина сжатого текста увеличивается на длину таблицы частот, которая должна посылаться впереди данных, что может свести на нет все усилия по сжатию данных. Кроме того, необходимость
наличия полной частотной статистики перед началом собственно кодирования требует двух проходов по тексту: одного для построения модели
текста (таблицы частот и дерева Хаффмана), другого для собственно кодирования.
Пример 2. Программная реализация алгоритма Хаффмана с помощью кодового дерева.
#include "stdafx.h"
#include <iostream>
using namespace std;
struct sym {
unsigned char ch;
float freq;
char code[255];
sym *left;
sym *right;
};
void Statistics(char *String);
sym *makeTree(sym *psym[],int k);
void makeCodes(sym *root);
void CodeHuffman(char *String,char *BinaryCode, sym
*root);
void DecodeHuffman(char *BinaryCode,char *ReducedString,
sym *root);
116
int chh;//переменная для подсчета информация из строки
int k=0;
//счётчик количества различных букв, уникальных символов
int kk=0;//счетчик количества всех знаков в файле
int kolvo[256]={0};
//инициализируем массив количества уникальных символов
sym simbols[256]={0};//инициализируем массив записей
sym *psym[256];//инициализируем массив указателей на записи
float summir=0;//сумма частот встречаемости
int _tmain(int argc, _TCHAR* argv[]){
char *String = new char[1000];
char *BinaryCode = new char[1000];
char *ReducedString = new char[1000];
String[0] = BinaryCode[0] = ReducedString[0] = 0;
cout << "Введите строку для кодирования : ";
cin >> String;
sym *symbols = new sym[k];
//создание динамического массива структур simbols
sym **psum = new sym*[k];
//создание динамического массива указателей на simbols
Statistics(String);
sym *root = makeTree(psym,k);
//вызов функции создания дерева Хаффмана
makeCodes(root);//вызов функции получения кода
CodeHuffman(String,BinaryCode,root);
cout << "Закодированная строка : " << endl;
cout << BinaryCode << endl;
DecodeHuffman(BinaryCode,ReducedString, root);
cout << "Раскодированная строка : " << endl;
cout << ReducedString << endl;
delete psum;
delete String;
delete BinaryCode;
delete ReducedString;
system("pause");
return 0;
}
//рeкурсивная функция создания дерева Хаффмана
sym *makeTree(sym *psym[],int k) {
int i, j;
sym *temp;
temp = new sym;
temp->freq = psym[k-1]->freq+psym[k-2]->freq;
temp->code[0] = 0;
temp->left = psym[k-1];
temp->right = psym[k-2];
117
if ( k == 2 )
return temp;
else {
//внесение в нужное место массива элемента дерева Хаффмана
for ( i = 0; i < k; i++)
if ( temp->freq > psym[i]->freq ) {
for( j = k - 1; j > i; j--)
psym[j] = psym[j-1];
psym[i] = temp;
break;
}
}
return makeTree(psym,k-1);
}
//рекурсивная функция кодирования дерева
void makeCodes(sym *root) {
if ( root->left ) {
strcpy(root->left->code,root->code);
strcat(root->left->code,"0");
makeCodes(root->left);
}
if ( root->right ) {
strcpy(root->right->code,root->code);
strcat(root->right->code,"1");
makeCodes(root->right);
}
}
/*функция подсчета количества каждого символа и его вероятности*/
void Statistics(char *String){
int i, j;
//побайтно считываем строку и составляем таблицу встречаемости
for ( i = 0; i < strlen(String); i++){
chh = String[i];
for ( j = 0; j < 256; j++){
if (chh==simbols[j].ch) {
kolvo[j]++;
kk++;
break;
}
if (simbols[j].ch==0){
simbols[j].ch=(unsigned char)chh;
kolvo[j]=1;
k++; kk++;
break;
}
}
}
118
// расчет частоты встречаемости
for ( i = 0; i < k; i++)
simbols[i].freq = (float)kolvo[i] / kk;
// в массив указателей заносим адреса записей
for ( i = 0; i < k; i++)
psym[i] = &simbols[i];
//сортировка по убыванию
sym tempp;
for ( i = 1; i < k; i++)
for ( j = 0; j < k - 1; j++)
if ( simbols[j].freq < simbols[j+1].freq ){
tempp = simbols[j];
simbols[j] = simbols[j+1];
simbols[j+1] = tempp;
}
for( i=0;i<k;i++) {
summir+=simbols[i].freq;
printf("Ch= %d\tFreq= %f\tPPP= %c\t\n",simbols[i].ch,
simbols[i].freq,psym[i]->ch,i);
}
printf("\n Slova = %d\tSummir=%f\n",kk,summir);
}
//функция кодирования строки
void CodeHuffman(char *String,char *BinaryCode, sym
*root){
for (int i = 0; i < strlen(String); i++){
chh = String[i];
for (int j = 0; j < k; j++)
if ( chh == simbols[j].ch ){
strcat(BinaryCode,simbols[j].code);
}
}
}
//функция декодирования строки
void DecodeHuffman(char *BinaryCode,char *ReducedString,
sym *root){
sym *Current;// указатель в дереве
char CurrentBit;// значение текущего бита кода
int BitNumber;
int CurrentSimbol;//индекс распаковываемого символа
bool FlagOfEnd; //флаг конца битовой последовательности
FlagOfEnd = false;
CurrentSimbol = 0;
BitNumber = 0;
Current = root;
//пока не закончилась битовая последовательность
while ( BitNumber != strlen(BinaryCode) ) {
//пока не пришли в лист дерева
119
while (Current->left != NULL && Current->right != NULL &&
BitNumber != strlen(BinaryCode) ) {
//читаем значение очередного бита
CurrentBit = BinaryCode[BitNumber++];
//бит – 0, то идем налево, бит – 1, то направо
if ( CurrentBit == '0' )
Current = Current->left;
else
Current = Current->right;
}
//пришли в лист и формируем очередной символ
ReducedString[CurrentSimbol++] = Current->ch;
Current = root;
}
ReducedString[CurrentSimbol] = 0;
}
Для осуществления декодирования необходимо иметь кодовое дерево, которое приходится хранить вместе со сжатыми данными. Это приводит к некоторому незначительному увеличению объема сжатых данных.
Используются самые различные форматы, в которых хранят это дерево.
Обратим внимание на то, что узлы кодового дерева являются пустыми.
Иногда хранят не само дерево, а исходные данные для его формирования,
то есть сведения о вероятностях появления символов или их количествах.
При этом процесс декодирования предваряется построением нового кодового дерева, которое будет таким же, как и при кодировании.
Ключевые термины
Алгоритм сжатия данных (алгоритм архивации) – это алгоритм, который устраняет избыточность записи данных.
Алфавит кода – это множество всех символов входного потока.
Декодирование – это обратный кодированию процесс, при котором
осуществляется восстановление данных.
Кодирование – это процесс сжатия данных.
Кодовое дерево (дерево кодирования Хаффмана, Н-дерево) – это бинарное дерево, у которого: листья помечены символами, для которых
разрабатывается кодировка; узлы (в том числе корень) помечены
суммой вероятностей появления всех символов, соответствующих
листьям поддерева, корнем которого является соответствующий
узел.
Кодовое слово – это последовательность кодовых символов из алфавита
кода.
Кодовый символ – это наименьшая единица данных, подлежащая сжатию.
Коэффициент сжатия – это величина, обратная отношению сжатия.
Оптимальный префиксный код – это префиксный код, имеющий ми120
нимальную среднюю длину.
Отношение сжатия – это величина для обозначения эффективности
метода сжатия, равная отношению размера выходного потока к размеру входного потока.
Префиксный код – это код, в котором никакое кодовое слово не является префиксом любого другого кодового слова.
Сжатие без потерь (полностью обратимое) – это метод сжатия данных, при котором ранее закодированная порция данных восстанавливается после их распаковки полностью без внесения изменений.
Сжатие данных – это процесс, обеспечивающий уменьшение объема
данных путем сокращения их избыточности.
Сжатие с потерями – это метод сжатия данных, при котором для
обеспечения максимальной степени сжатия исходного массива данных часть содержащихся в нем данных отбрасывается.
Словарное сжатие – это методы сжатия, хранящие фрагменты данных
в некоторой структуре данных, называемой словарем.
Средняя длина кодового слова – это величина, которая вычисляется как
взвешенная вероятностями сумма длин всех кодовых слов.
Статистические методы – это методы сжатия, присваивающие коды
переменной длины символам входного потока, причем более короткие коды присваиваются символам или группам символам, имеющим
большую вероятность появления во входном потоке.
Токен – это единица данных, записываемая в сжатый поток некоторым
алгоритмом сжатия.
Фраза – это фрагмент данных, помещаемый в словарь для дальнейшего
использования в сжатии.
Хаффмановское кодирование (сжатие) – это метод сжатия, присваивающий символам алфавита коды переменной длины основываясь на
вероятностях появления этих символов.
Краткие итоги
1. Сжатие данных является процессом, обеспечивающим уменьшение
объема данных путем сокращения их избыточности.
2. Сжатие данных может происходить с потерями и без потерь.
3. Отношение сжатия характеризует степень сжатия данных.
4. Существуют два основных способа проведения сжатия: статистические методы и словарное сжатие.
5. Алгоритм Хаффмана относится к статистическим методам сжатия
данных.
6. Идея алгоритма Хаффмана состоит в следующем: зная вероятности
вхождения символов в исходный текст, можно описать процедуру
построения кодов переменной длины, состоящих из целого количества битов.
121
7. Коды Хаффмана имеют уникальный префикс, что и позволяет однозначно их декодировать, несмотря на их переменную длину.
8. Алгоритм Хаффмана универсальный, его можно применять для сжатия данных любых типов, но он малоэффективен для файлов маленьких размеров.
9. Классический алгоритм Хаффмана на основе кодового дерева требует хранения кодового дерева, что увеличивает его трудоемкость.
Материалы для практики
Вопросы
1. При кодировании каких данных можно использовать сжатие данных
с потерями? Ответ обоснуйте.
2. В чем преимущества и недостатки статических методов и словарного
сжатия?
3. Каким образом кодирование по алгоритму Хаффмана через префиксный код гарантирует минимальную длину кода?
4. За счет чего в методе Хаффмана поддерживается однозначность соответствия кода кодируемому символу?
5. Почему алгоритм Хаффмана малоэффективен для файлов маленьких
размеров?
6. Выполните кодирование по методу Хаффмана через префиксный код
символов, которые встречаются с вероятностями 0,3; 0,2; 0,1; 0,1;
0,1; 0,05; 0,05; 0,04; 0,03; 0,03. Сравните полученный результат с
данными программной реализации.
7. Докажите, что метод Хаффмана кодирует информацию без потерь.
Упражнения
1. На основании приведенных в лекции кодов реализуйте алгоритмы
сжатия по методу Хаффмана через префиксные коды и на основе кодовых деревьев.
2. Алфавит содержит 7 букв, которые встречаются с вероятностями 0,4;
0,2; 0,1; 0,1; 0,1; 0,05; 0,05. Осуществите кодирование по методу
Хаффмана.
3. Закодируйте по алгоритму Хаффмана строку с вашим именем, отчеством, фамилией, датой и местом рождения (например, «Иванова
Наталья Николаевна, 1 января 1990 года, город Тверь»). При кодировании не округляйте частоты менее, чем четыре знака после запятой – сокращение точности понижает эффективность кодирования.
Подсчитайте коэффициент сжатия.
4. При кодировании по методу Фано все сообщения записываются в
таблицу по степени убывания вероятности и разбиваются на две
группы примерно (насколько это возможно) равной вероятности.
Соответственно этой процедуре из корня кодового дерева исходят
122
два ребра, которым в качестве весов присваиваются полученные вероятности. Двум образовавшимся вершинам приписывают кодовые
символы 0 и 1. Затем каждая из групп вероятностей вновь делится на
две подгруппы примерно равной вероятности. В соответствии с этим
из каждой вершины 0 и 1 исходят по два ребра с весами, равными
вероятностям подгрупп, а вновь образованным вершинам приписывают символы 00 и 01, 10 и 11. В результате многократного повторения процедуры разделения вероятностей и образования вершин приходим к ситуации, когда в качестве веса, приписанного ребру бинарного дерева, выступает вероятность одного из данных сообщений. В
этом случае вновь образованная вершина оказывается листом дерева, т.к. процесс деления вероятностей для нее завершен. Задача кодирования считается решенной, когда на всех ветвях кодового бинарного дерева образуются листья. Закодируйте по алгоритму Фано
данные текстового файла.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
123
10. Алгоритмы сортировки массивов.
Внутренняя сортировка
Краткая аннотация
В данной теме рассматриваются определение и классификация алгоритмов
сортировок массивов, в частности, быстрых сортировок, изучаются параметры, характеризующие трудоемкость алгоритмов сортировок, рассматриваются описания и примеры программных кодов следующих алгоритмов
быстрых сортировок: бинарная пирамидальная сортировка, сортировка
слиянием, сортировка Шелла и сортировка Хоара.
Цель изучения темы
Изучить основные алгоритмы внутренних сортировок и научиться решать
задачи сортировок массивов различными методами (бинарная пирамидальная сортировка, метод Шелла, быстрая сортировка Хоара, сортировка
слиянием).
10.1. Основные понятия сортировки
Сортировка является одной из фундаментальных алгоритмических
задач программирования. Решению проблем, связанных с сортировкой, посвящено множество научных исследований, разработано множество алгоритмов.
В общем случае сортировку следует понимать как процесс перегруппировки, заданного множества объектов в определенном порядке. Сортировка применяется во всех без исключения областях программирования,
будь то базы данных или математические программы.
Алгоритмом сортировки называется алгоритм для упорядочения
некоторого множества элементов. Обычно под алгоритмом сортировки
подразумевают алгоритм упорядочивания множества элементов по возрастанию или убыванию.
В случае наличия элементов с одинаковыми значениями, в упорядоченной последовательности они располагаются рядом друг за другом в
любом порядке. Однако иногда бывает полезно сохранять первоначальный
порядок элементов с одинаковыми значениями.
В алгоритмах сортировки лишь часть данных используется в качестве ключа сортировки. Ключом сортировки называется атрибут (или несколько атрибутов), по значению которого определяется порядок элементов. Таким образом, при написании алгоритмов сортировок массивов следует учесть, что ключ полностью или частично совпадает с данными.
Практически каждый алгоритм сортировки можно разбить на 3 части:
 сравнение, определяющее упорядоченность пары элементов;
 перестановку, меняющую местами пару элементов;
124
собственно сортирующий алгоритм, который осуществляет сравнение и
перестановку элементов до тех пор, пока все элементы множества не
будут упорядочены.
Алгоритмы сортировки имеют большое практическое применение.
Их можно встретить там, где речь идет об обработке и хранении больших
объемов информации. Некоторые задачи обработки данных решаются
проще, если данные заранее упорядочить.

10.2. Оценка алгоритмов сортировки
Ни одна другая проблема не породила такого количества разнообразнейших решений, как задача сортировки. Универсального, наилучшего
алгоритма сортировки на данный момент не существует. Однако, имея
приблизительные характеристики входных данных, можно подобрать метод, работающий оптимальным образом. Для этого необходимо знать параметры, по которым будет производиться оценка алгоритмов.
 Время сортировки – основной параметр, характеризующий быстродействие алгоритма.
 Память – один из параметров, который характеризуется тем, что
ряд алгоритмов сортировки требуют выделения дополнительной
памяти под временное хранение данных. При оценке используемой памяти не будет учитываться место, которое занимает исходный массив данных и независящие от входной последовательности затраты, например, на хранение кода программы.
 Устойчивость – это параметр, который отвечает за то, что сортировка не меняет взаимного расположения равных элементов.
 Естественность поведения – параметр, которой указывает на
эффективность метода при обработке уже отсортированных, или
частично отсортированных данных. Алгоритм ведет себя естественно, если учитывает эту характеристику входной последовательности и работает лучше.
10.3. Классификация алгоритмов сортировок
Все разнообразие и многообразие алгоритмов сортировок можно
классифицировать по различным признакам, например, по устойчивости,
по поведению, по использованию операций сравнения, по потребности в
дополнительной памяти, по потребности в знаниях о структуре данных,
выходящих за рамки операции сравнения, и другие.
Наиболее подробно рассмотрим классификацию алгоритмов сортировки по сфере применения. В данном случае основные типы упорядочивания делятся следующим образом.
 Внутренняя сортировка – это алгоритм сортировки, который в процессе упорядочивания данных использует только оперативную память
(ОЗУ) компьютера. То есть оперативной памяти достаточно для поме125
щения в нее сортируемого массива данных с произвольным доступом к
любой ячейке и собственно для выполнения алгоритма. Внутренняя
сортировка применяется во всех случаях, за исключением однопроходного считывания данных и однопроходной записи отсортированных
данных. В зависимости от конкретного алгоритма и его реализации
данные могут сортироваться в той же области памяти, либо использовать дополнительную оперативную память.
 Внешняя сортировка – это алгоритм сортировки, который при проведении упорядочивания данных использует внешнюю память, как правило,
жесткие диски. Внешняя сортировка разработана для обработки больших списков данных, которые не помещаются в оперативную память.
Обращение к различным носителям накладывает некоторые дополнительные ограничения на данный алгоритм: доступ к носителю осуществляется последовательным образом, то есть в каждый момент времени можно считать или записать только элемент, следующий за текущим; объем данных не позволяет им разместиться в ОЗУ.
Внутренняя сортировка является базовой для любого алгоритма
внешней сортировки – отдельные части массива данных сортируются в
оперативной памяти и с помощью специального алгоритма сцепляются в
один массив, упорядоченный по ключу.
Следует отметить, что внутренняя сортировка значительно эффективней внешней, так как на обращение к оперативной памяти затрачивается намного меньше времени, чем к носителям.
Рассмотрим основные алгоритмы внутренних сортировок, которые
называются усовершенствованными (логарифмическими).
10.4. Бинарная пирамидальная сортировка
Данный метод сортировки был предложен Дж. У. Дж. Уильямсом и
Р.У. Флойдом в 1964 году. Пирамидальная сортировка в некотором роде
является модификацией такого подхода, как сортировка выбором, с тем
лишь отличием, что минимальный (или максимальный) элемент из неотсортированной последовательности выбирается за меньшее количество
операций. Для такого быстрого выбора из этой неотсортированной последовательности строится некоторая структура. Именно суть данного метода
и состоит в построении такой структуры, которая называется пирамидой.
Пирамида (сортирующее дерево, двоичная куча) – двоичное дерево
с упорядоченными листьями (корень дерева – наименьший или наибольший элемент). Пирамиду можно представить в виде массива. Первый элемент пирамиды является наименьшим или наибольшим, что зависит от
ключа сортировки.
Просеивание – это построение новой пирамиды по следующему алгоритму: новый элемент помещается в вершину дерева, далее он перемещается («просеивается») по пути вниз на основе сравнения с дочерними
126
элементами. Спуск завершается, если результат сравнения с дочерними
элементами соответствует ключу сортировки.
Последовательность чисел xi , xi 1 , ..., xi формирует пирамиду, если
для всех k  i, i  1, ..., n / 2 выполняются неравенства xk  x2 k , xk  xi
(или xk  x2 k , xk  x2 k 1 ). Элементы x2i и x2i 1 называются потомками
элемента x i .
Массив чисел 12 10 7 5 8 7 3 является пирамидой. Такой массив
удобно изображать в виде дерева. Первый элемент массива, элементы которого образуют собой пирамиду, является наибольшим (или наименьшим). Если массив представлен в виде пирамиды, то массив легко отсортировать.
Алгоритм пирамидальной сортировки.
Шаг 1. Преобразовать массив в пирамиду (рис. 1. А).
Шаг 2. Использовать алгоритм сортировки пирамиды (рис. 1. В – H).
Алгоритм преобразования массива в пирамиду (построение пирамиды).
Пусть дан массив x[1], x[2], ... , x[n] .
Шаг 1. Устанавливаем k  n / 2 .
Шаг 2. Перебираем элементы массива в цикле справа налево для
i  k , k  1, ... , 1. Если неравенства xi  x2i , xi  x2i 1 не выполняются, то
повторяем перестановки x i с наибольшим из потомков. Перестановки завершаются при выполнении неравенств xi  x2i , xi  x2i 1 .
Алгоритм сортировки пирамиды.
Рассмотрим массив размерности n, который представляет пирамиду
x[1], x[2], ... , x[n] .
Шаг 1. Переставляем элементы x[1] и x[n] .
Шаг 2. Определяем n  n  1 . Это эквивалентно тому, что в массиве
из дальнейшего рассмотрения исключается элемент x[n] .
Шаг 3. Рассматриваем массив x[1], x[2], ... , x[n  1] , который получается из исходного за счет исключения последнего элемента. Данный
массив из-за перестановки элементов уже не является пирамидой. Но такой
массив легко преобразовать в пирамиду. Это достигается повторением перестановки значения элемента из x[1] с наибольшим из потомков. Такая
перестановка продолжается до тех пор, пока элемент из x[1] не окажется
на месте элемента x[i] и при этом будут выполняться неравенства
x[i]  x[2i] , x[i]  x[2i  1] . Тем самым определяется новое место для значения первого элемента из x[1] (рис. 1. С).
Шаг 4. Повторяем шаги 2, 3, 4 до тех пор, пока не получим n  1 .
Произвольный массив можно преобразовать в пирамиду (рис. 1. D – H).
127
12
3
10
A
5
7
3
7
8
10
B
5
12 10 7 5 8 7 3
7
3 10 7 5 8 7 12
10
8
8
C
5
7
7
D
12
7
3
5
10 8 7 5 3 7 12
7
8 7 7 5 3 10 12
7
5
3
7
5
F
12
10
8
7
7 5 7 3 8 10 12
3
7 5 3 7 8 10 12
3
3
7
7
8
12
10
8
5
G
12
10
3
7
E
12
7
8
10
5
H
12
7
5 3 7 7 8 10 12
7
8
10
3 5 7 7 8 10 12
Рис.1. Демонстрация бинарной пирамидальной сортировки по неубыванию
128
12
Построение пирамиды, ее сортировка и «просеивание» элементов реализуются с помощью рекурсии. Базой рекурсии при этом выступает пирамида из одного элемента, а сортировка и просеивание n элементов сводятся посредством декомпозиции к аналогичным действиям с пирамидой
из n  1 элемента.
//Описание функции бинарной пирамидальной сортировки
void Binary_Pyramidal_Sort (int k,int *x){
Build_Pyramid(k/2+1,k-1,x);
Sort_Piramid(k-1,x);
}
//Построение пирамиды
void Build_Pyramid (int k, int r, int *x){
Sifting(k,r,x);
if (k > 0)
Build_Pyramid(k-1,r,x);
}
//Сортировка пирамиды
void Sort_Piramid (int k, int *x){
Exchange (0,k,x);
Sifting(0,k-1,x);
if (k > 1)
Sort_Piramid(k-1,x);
}
//"Просеивание" элементов
void Sifting (int left, int right, int *x){
int q, p, h;
q=2*left+1;
p=q+1;
if (q <= right){
if (p <= right && x[p] > x[q])
q = p;
if (x[left] < x[q]){
Exchange (left, q, x);
Sifting(q, right, x);
}
}
}
//процедура обмена двух элементов
void Exchange (int i, int j, int *x){
int tmp;
tmp = x[i];
x[i] = x[j];
x[j] = tmp;
}
129
Теоретическое время работы этого алгоритма можно оценить, учитывая, что пирамидальная сортировка аналогична построению пирамиды
методом просеивания (при этом не учитывается начальное построение пирамиды). Поэтому время работы алгоритма пирамидальной сортировки без
учета времени построения пирамиды будет определяться по формуле
T1 (n)  O(n  log n) .
Построение пирамиды занимает T2 (n)  O(n) операций за счет того,
что реальное время выполнения функции построения зависит от высоты
уже созданной части пирамиды.
Тогда общее время сортировки (с учетом построения пирамиды) будет равно: T (n)  T1 (n)  T2 (n)  O(n)  O(n  log n)  O(n  log n) .
Пирамидальная сортировка не использует дополнительной памяти.
Метод не является устойчивым: по ходу работы массив так «перетряхивается», что исходный порядок элементов может измениться случайным образом. Поведение неестественно: частичная упорядоченность массива никак не учитывается. Данная сортировка на почти отсортированных массивах работает также долго, выигрыш ее получается только на больших n.
10.5. Сортировка методом Шелла
Сортировка Шелла была названа в честь ее изобретателя – Дональда
Шелла, который опубликовал этот алгоритм в 1959 году. Общая идея сортировки Шелла состоит в сравнении на начальных стадиях сортировки пар
значений, расположенных достаточно далеко друг от друга в упорядочиваемом наборе данных. Такая модификация метода сортировки позволяет
быстро переставлять далекие неупорядоченные пары значений (сортировка
таких пар обычно требует большого количества перестановок, если используется сравнение только соседних элементов). Быстродействие метода
во многом зависит от расстояния h, на котором отстоят друг от друга переставляемые элементы (при этом расстояние уменьшается на каждом следующем шаге). Первоначально используемая Шеллом последовательность
шагов вычисляется как результат целочисленного половинного деления
предыдущего шага до достижения 1, при этом за начальное значение длины шага принимается  n / 2 для массива из n элементов.
Общая первоначальная схема метода состоит в следующем.
Шаг 1. Происходит упорядочивание элементов  n / 2 пар ( xi , xn/2i )
для 1  i  n / 2 .
Шаг 2. Упорядочиваются элементы в  n / 4 группах из четырех элементов ( xi , xn/4i , xn/2i , x3n/4i ) для 1  i  n / 4 .
Шаг 3. Упорядочиваются элементы уже в  n / 4 группах из восьми
элементов и т.д.
На последнем шаге упорядочиваются элементы сразу во всем массиве ( x1 , x2 , ..., xn ) . На каждом шаге для упорядочивания элементов в группах
130
используется метод сортировки вставками (рис. 2).
Другие значения последовательностей шагов были реализованы,
например, в алгоритмах сортировки Хиббардом, Седжвиком, Праттом, что
позволило получать решения меньшей трудоемкости. В настоящее время
неизвестна последовательность hi , hi 1 , hi 2 , ..., h1 , оптимальность которой
доказана. Для достаточно больших массивов рекомендуемой считается такая последовательность, что hi 1  3hi  1 , а h1  1 . Начинается процесс с
hm , что
hm  [n / 9] . Иногда значение h вычисляют проще:
hi 1   hi / 2, h1  1, hm   n / 2 . Это упрощенное вычисление h и будем использовать далее.
1 проход
2
8
7
31
9
10
32
5
0
0
8
7
31
2
10
32
5
9
0
8
7
31
2
10
32
5
9
0
8
32
31
2
10
7
5
9
0
8
32
31
2
10
7
5
9
0
8
32
31
2
10
7
5
9
0
8
2
31
32
10
7
5
9
0
31
2
5
32
8
7
10
9
0
31
2
5
32
8
7
10
9
0
2
31
32
5
7
8
9
10
h1=4
2 проход
h2=2
3 проход
h3=1
2
Рис.2. Демонстрация сортировки по неубыванию методом Шелла
131
//Описание функции сортировки Шелла
void Shell_Sort (int n, int *x){
int h, i, j;
for (h = n/2 ; h > 0 ; h = h/2)
for (i = 0 ; i < n-h ; i++)
for (j = i ; j >= 0 ; j = j - h)
if (x[j] > x[j+h])
Exchange (j, j+h, x);
else j = 0;
}
//процедура обмена двух элементов
void Exchange (int i, int j, int *x){
int tmp;
tmp = x[i];
x[i] = x[j];
x[j] = tmp;
}
Метод, предложенный Дональдом Л. Шеллом, является неустойчивой сортировкой по месту.
Эффективность метода Шелла объясняется тем, что сдвигаемые элементы быстро попадают на нужные места. Среднее время для сортировки
Шелла равняется O (n1.25 ) , для худшего случая оценкой является O(n1.5 ) .
10.6. Быстрая сортировка Хоара
Метод быстрой сортировки был впервые описан Ч.А.Р. Хоаром в
1962 году. Быстрая сортировка – это общее название ряда алгоритмов,
которые отражают различные подходы к получению критичного параметра, влияющего на производительность метода.
При общем рассмотрении алгоритма быстрой сортировки, отметим,
что этот метод основывается на последовательном разделении сортируемого набора данных на блоки меньшего размера таким образом, что между
значениями разных блоков обеспечивается отношение упорядоченности
(для любой пары блоков все значения одного из этих блоков не превышают значений другого блока).
Опорным (ведущим) элементом называется некоторый элемент массива, который выбирается определенный образом. С точки зрения корректности алгоритма выбор опорного элемента безразличен. С точки зрения повышения эффективности алгоритма выбираться должна медиана, но
без дополнительных сведений о сортируемых данных ее обычно невозможно получить. Необходимо выбирать постоянно один и тот же элемент
(например, средний или последний по положению) или выбирать элемент
со случайно выбранным индексом.
132
Алгоритм быстрой сортировки Хоара
Пусть дан массив x[n] размерности n.
Шаг 1. Выбирается опорный элемент массива.
Шаг 2. Массив разбивается на два – левый и правый – относительно
опорного элемента. Реорганизуем массив таким образом, чтобы все элементы, меньшие опорного элемента, оказались слева от него, а все элементы, большие опорного – справа от него.
Шаг 3. Далее повторяется шаг 2 для каждого из двух вновь образованных массивов. Каждый раз при повторении преобразования очередная
часть массива разбивается на два меньших и т. д., пока не получится массив из двух элементов (рис. 3).
Быстрая сортировка стала популярной прежде всего потому, что ее
нетрудно реализовать, она хорошо работает на различных видах входных
данных и во многих случаях требует меньше затрат ресурсов по сравнению
с другими методами сортировки.
Выберем в качестве опорного элемент, расположенный на средней
позиции.
направление
направление
1 проход
61
2
8
4
3
1
62
9
0
8
62
9
61
m=4
2 проход
0
2
1
3
4
m=2
3 проход
0
1
m=6
2
3
4
61
62
m=1
0
1
9
8
m=7
2
3
4
61
62
8
Рис.3. Демонстрация быстрой сортировки Хоара по неубыванию
133
9
//Описание функции сортировки Хоара
void Hoar_Sort (int k, int *x){
Quick_Sort (0, k-1, x);
}
void Quick_Sort(int left, int right, int *x){
int i, j, m, h;
i = left;
j = right;
m = x[(i+j+1)/2];
do {
while (x[i] < m) i++;
while (x[j] > m) j--;
if (i <= j) {
Exchange(i,j,x);
i++;
j--;
}
} while(i <= j);
if (left < j)
Quick_Sort (left, j, x);
if (i < right)
Quick_Sort (i, right, x);
}
//процедура обмена двух элементов
void Exchange (int i, int j, int *x){
int tmp;
tmp = x[i];
x[i] = x[j];
x[j] = tmp;
}
Эффективность быстрой сортировки в значительной степени определяется правильностью выбора опорных (ведущих) элементов при формировании блоков. В худшем случае трудоемкость метода имеет ту же сложность, что и пузырьковая сортировка, то есть порядка O ( n 2 ) . При оптимальном выборе ведущих элементов, когда разделение каждого блока происходит на равные по размеру части, трудоемкость алгоритма совпадает с
быстродействием наиболее эффективных способов сортировки, то есть порядка O(n log n) . В среднем случае количество операций, выполняемых алгоритмом
быстрой
сортировки,
определяется
выражением
T (n)  O(1.4 n log n)
Быстрая сортировка является наиболее эффективным алгоритмом из
всех известных методов сортировки, но все усовершенствованные методы
имеют один общий недостаток – невысокую скорость работы при малых
значениях n.
134
Рекурсивная реализация быстрой сортировки позволяет устранить
этот недостаток путем включения прямого метода сортировки для частей
массива с небольшим количеством элементов. Анализ вычислительной
сложности таких алгоритмов показывает, что если подмассив имеет девять
или менее элементов, то целесообразно использовать прямой метод (сортировку простыми вставками).
10.7. Сортировка слиянием
Алгоритм сортировки слиянием был изобретен Джоном фон Нейманом в 1945 году. Он является одним из самых быстрых способов сортировки.
Слияние – это объединение двух или более упорядоченных массивов
в один упорядоченный.
Сортировка слиянием является одним из самых простых алгоритмов
сортировки (среди быстрых алгоритмов). Особенностью этого алгоритма
является то, что он работает с элементами массива преимущественно последовательно, благодаря чему именно этот алгоритм используется при
сортировке в системах с различными аппаратными ограничениями
(например, при сортировке данных на жестком диске). Кроме того, сортировка слиянием является алгоритмом, который может быть эффективно
использован для сортировки таких структур данных, как связанные списки.
Данный алгоритм применяется тогда, когда есть возможность использовать для хранения промежуточных результатов память, сравнимую с
размером исходного массива. Он построен на принципе «разделяй и властвуй». Сначала задача разбивается на несколько подзадач меньшего размера. Затем эти задачи решаются с помощью рекурсивного вызова или непосредственно, если их размер достаточно мал. Далее их решения комбинируются, и получается решение исходной задачи (рис. 4).
Процедура слияния требует два отсортированных массива. Заметим,
что массив из одного элемента по определению является отсортированным.
Алгоритм сортировки слиянием
Шаг 1. Разбить имеющиеся элементы массива на пары и осуществить
слияние элементов каждой пары, получив отсортированные цепочки длины 2 (кроме, быть может, одного элемента, для которого не нашлось пары).
Шаг 2. Разбить имеющиеся отсортированные цепочки на пары, и
осуществить слияние цепочек каждой пары.
Шаг 3. Если число отсортированных цепочек больше единицы, перейти к шагу 2.
135
1 проход
3
0
1
21
7
5
22
9
6
0
3
1
21
5
7
22
9
6
0
1
21
3
22
5
7
9
6
0
1
21
22
3
5
7
9
6
0
1
21
22
3
5
6
7
9
l=1
2 проход
l=2
3 проход
l=4
4 проход
l=8
5 проход
l=9
(весь массив)
Рис.4. Демонстрация сортировки слиянием по неубыванию
//Описание функции сортировки слиянием
void Merging_Sort (int n, int *x){
int i, j, k, t, s, Fin1, Fin2;
int* tmp = new int[n];
k = 1;
while (k < n){
t = 0;
s = 0;
while (t+k < n){
Fin1 = t+k;
Fin2 = (t+2*k < n ? t+2*k : n);
i = t;
j = Fin1;
for ( ; i < Fin1 && j < Fin2 ; s++){
if (x[i] < x[j]) {
tmp[s] = x[i];
i++;
}
else {
tmp[s] = x[j];
j++;
}
}
136
for ( ; i < Fin1; i++, s++)
tmp[s] = x[i];
for ( ; j < Fin2; j++, s++)
tmp[s] = x[j];
t = Fin2;
}
k *= 2;
for (s = 0; s < t; s++)
x[s] = tmp[s];
}
delete(tmp);
}
Недостаток алгоритма заключается в том, что он требует дополнительную память размером порядка n (для хранения вспомогательного массива). Кроме того, он не гарантирует сохранение порядка элементов с одинаковыми значениями. Но его временная сложность всегда пропорциональна O(n log n) .
Ключевые термины
Алгоритм сортировки – это алгоритм для упорядочения некоторого
множества элементов.
Бинарная пирамидальная сортировка – это алгоритм внутренней сортировки, основанный на построении пирамиды и просеивании элементов из ее вершины методом спуска вниз в соответствии с ключом
сортировки.
Быстрая сортировка – это общее название ряда алгоритмов, которые
отражают различные подходы к получению критичного параметра,
влияющего на производительность метода.
Внешняя сортировка – это алгоритм сортировки, который при проведении упорядочивания данных использует внешнюю память, как
правило, жесткие диски.
Внутренняя сортировка – это алгоритм сортировки, который в процессе упорядочивания данных использует только оперативную память (ОЗУ) компьютера.
Время сортировки – основной параметр трудоемкости алгоритма, характеризующий быстродействие алгоритма сортировки.
Естественность поведения – это один из параметров трудоемкости
алгоритма, которой указывает на эффективность метода при обработке уже отсортированных, или частично отсортированных данных.
Ключ сортировки – это атрибут (или несколько атрибутов), по значению которого определяется порядок элементов во множестве.
Опорный (ведущий) элемент – это некоторый элемент массива, который выбирается определенный образом, и относительно которого
137
происходит сравнение и перемещение элементов между подмножествами массива.
Память – один из параметров трудоемкости алгоритма, который характеризует размер выделяемой дополнительной памяти под временное хранение данных.
Пирамида (сортирующее дерево, двоичная куча) – это двоичное дерево с упорядоченными листьями, в корне которого расположен максимальный или минимальный элемент.
Просеивание – это построение новой пирамиды посредством спуска
вниз элемента из вершины дерева в соответствии с ключом сортировки.
Слияние – это объединение двух или более упорядоченных массивов в
один упорядоченный.
Сортировка слиянием – это одна из разновидностей алгоритмов быстрых сортировок, основанная на слиянии подмножеств массива.
Сортировка Хоара – это одна из разновидностей быстрых сортировок,
основанная на упорядочивании подмножеств массива относительно
опорных элементов.
Сортировка Шелла – это алгоритм внутренней сортировки, основанный на сравнении и перемещении пар значений, расположенных
сначала достаточно далеко друг от друга в упорядочиваемом наборе
данных, с дальнейшим сокращением расстояний между ними.
Устойчивость – это один из параметров трудоемкости алгоритма, который характеризует то, что сортировка не меняет взаимного расположения равных элементов.
Краткие итоги
1. Сортировка является одной из фундаментальных алгоритмических
задач программирования.
2. Практически каждый алгоритм сортировки можно разбить на 3 части:
сравнение, определяющее упорядоченность пары элементов; перестановку, меняющую местами пару элементов; собственно сортирующий
алгоритм, который осуществляет сравнение и перестановку элементов
до тех пор, пока все элементы множества не будут упорядочены.
3. Для оценки трудоемкости алгоритмов сортировки используются параметры: время сортировки, дополнительная память, устойчивость и
естественность поведения
4. По сфере применения алгоритмы сортировок классифицируются на
алгоритмы внутренних и внешних сортировок.
5. Бинарная пирамидальная сортировка является алгоритмом внутренней сортировки, основанный на построении пирамиды и просеивании элементов из ее вершины методом спуска вниз в соответствии с
ключом сортировки
138
6. Пирамидальная сортировка не использует дополнительной памяти.
Метод не является устойчивым. Поведение неестественно. Данная
сортировка на почти отсортированных массивах работает также долго, выигрыш ее получается только на больших n.
7. Сортировка Шелла является алгоритмом внутренней сортировки,
основанный на сравнении и перемещении пар значений, расположенных сначала достаточно далеко друг от друга в упорядочиваемом
наборе данных, с дальнейшим сокращением расстояний между ними.
8. Сортировка Шелла является неустойчивой сортировкой по месту.
Эффективность метода Шелла объясняется тем, что сдвигаемые элементы быстро попадают на нужные места.
9. Сортировка Хоара является одной из разновидностей быстрых сортировок, основанная на упорядочивании подмножеств массива относительно опорных элементов.
10. Эффективность быстрой сортировки в значительной степени определяется правильностью выбора опорных элементов при формировании блоков.
11. Сортировка слиянием является одним из самых простых алгоритмов
сортировки среди быстрых алгоритмов, который может быть эффективно использован для сортировки связанных списков.
12. Недостаток алгоритма сортировки слиянием заключается в том, что
он требует дополнительную память размером порядка n, не гарантирует сохранение порядка элементов с одинаковыми значениями. Его
временная сложность всегда пропорциональна O(n log n) .
13. Быстрая сортировка является наиболее эффективным алгоритмом из
всех известных методов сортировки, но все усовершенствованные
методы имеют один общий недостаток – невысокую скорость работы при малых значениях n.
Материалы для практики
Вопросы
1. Чем можно объяснить многообразие алгоритмов сортировок?
2. Почему на данный момент не существует универсального алгоритма
сортировки?
3. Как соблюдение свойств устойчивости и естественности влияет на
трудоемкость алгоритма сортировки?
4. За счет чего в алгоритмах быстрых сортировок происходит выигрыш
при выполнении операций сравнения и перестановок?
5. Какие из перечисленных алгоритмов наиболее эффективны на почти
отсортированных массивах: бинарная пирамидальная сортировка,
сортировка слиянием, сортировка Шелла и сортировка Хоара? За
счет чего происходит выигрыш?
139
6. Почему алгоритмы быстрых сортировок не дают большого выигрыша при малых размерах массивов?
7. В чем преимущества и недостатки по отношению друг к другу следующих алгоритмов сортировок: бинарная пирамидальная сортировка, сортировка слиянием, сортировка Шелла и сортировка Хоара?
8. Как определить, какому алгоритму сортировки отдать предпочтение
при решении задачи?
Упражнения
1. На основании приведенных в лекции функций реализуйте алгоритмы
внутренних сортировок.
2. Даны два целочисленных файла, упорядоченных по возрастанию.
Сформировать третий файл на основе данных, который также упорядочен и представляет операцию с элементами исходных файлов:
а) объединение (содержит числа, принадлежащие хотя бы одному из
множеств);
б) перечисление (числа, принадлежащие обоим множествам);
в) разность (числа, принадлежащие первому множеству, но не второму);
г) симметричную разность (объединение разностей множеств).
3. Заданы N (N≤5000) попарно различных длин отрезков. Вычислить
количество способов, которыми из отрезков можно сложить треугольник.
4. Дана целочисленная квадратная матрица размером n. Упорядочить
значения так, чтобы
a11  a12  a1n  a21  a22  a2 n  an1  an 2  ann .
5. Дан целочисленный массив. Выполните проверку уникальности.
Удалите из массива повторные вхождения чисел.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
140
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
141
11. Алгоритмы сортировки массивов.
Внешняя сортировка
Краткая аннотация
В данной теме рассматриваются определение и классификация алгоритмов
внешних сортировок, понятия фаз и путей в алгоритмах внешних сортировок, приводятся описания и реализации алгоритмов внешней сортировки
слиянием и естественной сортировки.
Цель изучения темы
Изучить основные алгоритмы внешних сортировок, научиться решать задачи сортировок массивов различными методами и выполнять оценку эффективности алгоритмов внешней сортировки.
11.1. Основные понятия алгоритмов внешних сортировок
Внешние сортировки применяются к данным, которые хранятся во
внешней памяти. При выполнении таких сортировок требуется работать с
данными, расположенными на внешних устройствах последовательного
доступа. Для файлов, расположенных на таких устройствах в каждый момент времени доступен только один компонент последовательности данных, что является существенным ограничением по сравнению с сортировкой массивов, где всегда доступен каждый элемент.
Внешняя сортировка – это сортировка данных, которые расположены на внешних устройствах и не вмещающихся в оперативную память.
Данные, хранящиеся на внешних устройствах, имеют большой объем, что не позволяет их целиком переместить в оперативную память, отсортировать с использованием одного из алгоритмов внутренней сортировки, а затем вернуть их на внешнее устройство. В этом случае осуществлялось бы минимальное количество проходов через файл, то есть было бы
однократное чтение и однократная запись данных. Однако на практике
приходится осуществлять чтение, обработку и запись данных в файл по
блокам, размер которых зависит от операционной системы и имеющегося
объема оперативной памяти, что приводит к увеличению числа проходов
через файл и заметному снижению скорости сортировки.
К наиболее известным алгоритмам внешних сортировок относятся:
 сортировки слиянием (простое слияние и естественное слияние);
 улучшенные сортировки (многофазная сортировка и каскадная
сортировка).
Из представленных внешних сортировок наиболее важным является
метод сортировки с помощью слияния. Прежде чем описывать алгоритм
сортировки слиянием введем несколько определений.
142
Основным понятием при использовании внешней сортировки является понятие серии. Серия (упорядоченный отрезок) – это последовательность элементов, которая упорядочена по ключу.
Количество элементов в серии называется длиной серии. Серия, состоящая из одного элемента, упорядочена всегда. Последняя серия может
иметь длину меньшую, чем остальные серии файлов. Максимальное количество серий в файле N (все элементы не упорядочены). Минимальное количество серий одна (все элементы упорядочены).
В основе большинства методов внешних сортировок лежит процедура слияния и процедура распределения. Слияние – это процесс объединения двух (или более) упорядоченных серий в одну упорядоченную последовательность при помощи циклического выбора элементов доступных в
данный момент. Распределение – это процесс разделения упорядоченных
серий на два и несколько вспомогательных файла.
Фаза – это действия по однократной обработке всей последовательности элементов. Двухфазная сортировка – это сортировка, в которой отдельно реализуется две фазы: распределение и слияние. Однофазная сортировка – это сортировка, в которой объединены фазы распределения и
слияния в одну.
Двухпутевым слиянием называется сортировка, в которой данные
распределяются на два вспомогательных файла. Многопутевым слиянием
называется сортировка, в которой данные распределяются на N (N > 2)
вспомогательных файлов.
Общий алгоритм сортировки слиянием
Сначала серии распределяются на два или более вспомогательных
файлов. Данное распределение идет поочередно: первая серия записывается в первый вспомогательный файл, вторая – во второй и так далее до последнего вспомогательного файла. Затем опять запись серии начинается в
первый вспомогательный файл. После распределения всех серий, они объединяются в более длинные упорядоченные отрезки, то есть из каждого
вспомогательного файла берется по одной серии, которые сливаются. Если
в каком-то файле серия заканчивается, то переход к следующей серии не
осуществляется. В зависимости от вида сортировки сформированная более
длинная упорядоченная серия записывается либо в исходный файл, либо в
один из вспомогательных файлов. После того как все серии из всех вспомогательных файлов объединены в новые серии, потом опять начинается
их распределение. И так до тех пор, пока все данные не будут отсортированы.
Выделим основные характеристики сортировки слиянием:
 количество фаз в реализации сортировки;
 количество вспомогательных файлов, на которые распределяются
серии.
Рассмотрим основные и наиболее важные алгоритмы внешних сортировок более подробно.
143
11.2. Сортировка простым слиянием
Одна из сортировок на основе слияния называется простым слиянием.
Алгоритм сортировки простым слияния является простейшим алгоритмом внешней сортировки, основанный на процедуре слияния серией.
В данном алгоритме длина серий фиксируется на каждом шаге. В исходном файле все серии имеют длину 1, после первого шага она равна 2,
после второго – 4, после третьего – 8, после k-го шага – 2 k .
Алгоритм сортировки простым слиянием
Шаг 1. Исходный файл f разбивается на два вспомогательных файла
f1 и f2.
Шаг 2. Вспомогательные файлы f1 и f2 сливаются в файл f, при этом
одиночные элементы образуют упорядоченные пары.
Шаг 3. Полученный файл f вновь обрабатывается, как указано в шагах 1 и 2. При этом упорядоченные пары переходят в упорядоченные четверки.
Шаг 4. Повторяя шаги, сливаем четверки в восьмерки и т.д., каждый
раз удваивая длину слитых последовательностей до тех пор, пока не будет
упорядочен целиком весь файл (рис. 1).
После выполнения i проходов получаем два файла, состоящих из серий длины 2 i . Окончание процесса происходит при выполнении условия
2 i  n . Следовательно, процесс сортировки простым слиянием требует порядка O(log n) проходов по данным.
Исходный файл f: 5 7 3 2 8 4 1
1 проход
2 проход
3 проход
Распределение
Слияние
f1: 5 3 8 1
f: 5 7 2 3 4 8 1
f2: 7 2 4
f1: 5 7 4 8
f: 2 3 5 7 1 4 8
f2: 2 3 1
f1: 2 3 5 7
f: 1 2 3 4 5 7 8
f2: 1 4 8
Рис.1. Демонстрация сортировки двухпутевым двухфазным простым слиянием
Признаками конца сортировки простым слиянием являются следующие условия:
144
длина серии не меньше количества элементов в файле (определяется после фазы слияния);
 количество серий равно 1 (определяется на фазе слияния).
 при однофазной сортировке второй по счету вспомогательный
файл после распределения серий остался пустым.

//Описание функции сортировки простым слиянием
void Simple_Merging_Sort (char *name){
int a1, a2, k, i, j, kol, tmp;
FILE *f, *f1, *f2;
kol = 0;
if ( (f = fopen(name,"r")) == NULL )
printf("\nИсходный файл не может быть прочитан...");
else {
while ( !feof(f) ) {
fscanf(f,"%d",&a1);
kol++;
}
fclose(f);
}
k = 1;
while ( k < kol ){
f = fopen(name,"r");
f1 = fopen("smsort_1","w");
f2 = fopen("smsort_2","w");
if ( !feof(f) ) fscanf(f,"%d",&a1);
while ( !feof(f) ){
for ( i = 0; i < k && !feof(f) ; i++ ){
fprintf(f1,"%d ",a1);
fscanf(f,"%d",&a1);
}
for ( j = 0; j < k && !feof(f) ; j++ ){
fprintf(f2,"%d ",a1);
fscanf(f,"%d",&a1);
}
}
fclose(f2);
fclose(f1);
fclose(f);
f = fopen(name,"w");
f1 = fopen("smsort_1","r");
f2 = fopen("smsort_2","r");
if ( !feof(f1) ) fscanf(f1,"%d",&a1);
if ( !feof(f2) ) fscanf(f2,"%d",&a2);
while ( !feof(f1) && !feof(f2) ){
i = 0;
j = 0;
145
while ( i < k && j < k && !feof(f1) && !feof(f2) ) {
if ( a1 < a2 ) {
fprintf(f,"%d ",a1);
fscanf(f1,"%d",&a1);
i++;
}
else {
fprintf(f,"%d ",a2);
fscanf(f2,"%d",&a2);
j++;
}
}
while ( i < k && !feof(f1) ) {
fprintf(f,"%d ",a1);
fscanf(f1,"%d",&a1);
i++;
}
while ( j < k && !feof(f2) ) {
fprintf(f,"%d ",a2);
fscanf(f2,"%d",&a2);
j++;
}
}
while ( !feof(f1) ) {
fprintf(f,"%d ",a1);
fscanf(f1,"%d",&a1);
}
while ( !feof(f2) ) {
fprintf(f,"%d ",a2);
fscanf(f2,"%d",&a2);
}
fclose(f2);
fclose(f1);
fclose(f);
k *= 2;
}
remove("smsort_1");
remove("smsort_2");
}
Заметим, что для выполнения внешней сортировки методом простого
слияния в оперативной памяти требуется расположить всего лишь две переменные – для размещения очередных элементов (записей) из вспомогательных файлов. Исходный и вспомогательные файлы будут O(log n) раз
прочитаны и столько же раз записаны.
146
11.3. Сортировка естественным слиянием
В случае простого слияния частичная упорядоченность сортируемых
данных не дает никакого преимущества. Это объясняется тем, что на каждом проходе сливаются серии фиксированной длины. При естественном
слиянии длина серий не ограничивается, а определяется количеством элементов в уже упорядоченных подпоследовательностях, выделяемых на
каждом проходе.
Сортировка, при которой всегда сливаются две самые длинные из
возможных последовательностей, является естественным слиянием. В данной сортировке объединяются серии максимальной длины.
Алгоритм сортировки естественным слиянием
Шаг 1. Исходный файл f разбивается на два вспомогательных файла
f1 и f2. Распределение происходит следующим образом: поочередно считываются записи a i исходной последовательности (неупорядоченной) таким образом, что если значения ключей соседних записей удовлетворяют
условию f (ai )  f (ai 1 ) , то они записываются в первый вспомогательный
файл f1. Как только встречаются f (ai )  f (ai 1 ) , то записи ai 1 копируются
во второй вспомогательный файл f2. Процедура повторяется до тех пор,
пока все записи исходной последовательности не будут распределены по
файлам.
Шаг 2. Вспомогательные файлы f1 и f2 сливаются в файл f, при этом
серии образуют упорядоченные последовательности.
Шаг 3. Полученный файл f вновь обрабатывается, как указано в шагах 1 и 2.
Шаг 4. Повторяя шаги, сливаем упорядоченные серии до тех пор, пока не будет упорядочен целиком весь файл (рис. 2).
Символ «`» обозначает признак конца серии.
Исходный файл f: 2 3 17 7 8 9 1 4 6 9 2 3 1 18
1 проход
2 проход
3 проход
Распределение
Слияние
f1: 2 3 17 ` 1 4 6 9 ` 1 18
f: 2 3 7 8 9 17 1 2 3 4 6 9 1 18
f2: 7 8 9 ` 2 3
f1: 2 3 7 8 9 17 ` 1 18
f: 1 2 2 3 3 4 6 7 8 9 9 17 1 18
f2: 1 2 3 4 6 9 `
f1: 1 2 2 3 3 4 6 7 8 9 9 17
f: 1 1 2 2 3 3 4 6 7 8 9 9 17 18
f2: 1 18
Рис.2. Демонстрация сортировки двухпутевым двухфазным естественным слиянием
147
Признаками конца сортировки естественным слиянием являются
следующие условия:
 количество серий равно 1 (определяется на фазе слияния).
 при однофазной сортировке второй по счету вспомогательный
файл после распределения серий остался пустым.
Естественное слияние, у которого после фазы распределения количество серий во вспомогательных файлах отличается друг от друга не более
чем на единицу, называется сбалансированным слиянием, в противном
случае – несбалансированное слияние.
//Описание функции сортировки естественным слиянием
void Natural_Merging_Sort (char *name){
int s1, s2, a1, a2, mark;
FILE *f, *f1, *f2;
s1 = s2 = 1;
while ( s1 > 0 && s2 > 0 ){
mark = 1;
s1 = 0;
s2 = 0;
f = fopen(name,"r");
f1 = fopen("nmsort_1","w");
f2 = fopen("nmsort_2","w");
fscanf(f,"%d",&a1);
if ( !feof(f) ) {
fprintf(f1,"%d ",a1);
}
if ( !feof(f) ) fscanf(f,"%d",&a2);
while ( !feof(f) ){
if ( a2 < a1 ) {
switch (mark) {
case 1:{fprintf(f1,"' "); mark = 2; s1++; break;}
case 2:{fprintf(f2,"' "); mark = 1; s2++; break;}
}
}
if ( mark == 1 ) { fprintf(f1,"%d ",a2); s1++; }
else { fprintf(f2,"%d ",a2); s2++;}
a1 = a2;
fscanf(f,"%d",&a2);
}
if ( s2 > 0 && mark == 2 ) { fprintf(f2,"'");}
if ( s1 > 0 && mark == 1 ) { fprintf(f1,"'");}
fclose(f2);
fclose(f1);
fclose(f);
cout << endl;
Print_File(name);
148
Print_File("nmsort_1");
Print_File("nmsort_2");
cout << endl;
f = fopen(name,"w");
f1 = fopen("nmsort_1","r");
f2 = fopen("nmsort_2","r");
if ( !feof(f1) ) fscanf(f1,"%d",&a1);
if ( !feof(f2) ) fscanf(f2,"%d",&a2);
bool file1, file2;
while ( !feof(f1) && !feof(f2) ){
file1 = file2 = false;
while ( !file1 && !file2 ) {
if ( a1 <= a2 ) {
fprintf(f,"%d ",a1);
file1 = End_Range(f1);
fscanf(f1,"%d",&a1);
}
else {
fprintf(f,"%d ",a2);
file2 = End_Range(f2);
fscanf(f2,"%d",&a2);
}
}
while ( !file1 ) {
fprintf(f,"%d ",a1);
file1 = End_Range(f1);
fscanf(f1,"%d",&a1);
}
while ( !file2 ) {
fprintf(f,"%d ",a2);
file2 = End_Range(f2);
fscanf(f2,"%d",&a2);
}
}
file1 = file2 = false;
while ( !file1 && !feof(f1) ) {
fprintf(f,"%d ",a1);
file1 = End_Range(f1);
fscanf(f1,"%d",&a1);
}
while ( !file2 && !feof(f2) ) {
fprintf(f,"%d ",a2);
file2 = End_Range(f2);
fscanf(f2,"%d",&a2);
}
fclose(f2);
fclose(f1);
149
fclose(f);
}
remove("nmsort_1");
remove("nmsort_2");
}
//определение конца блока
bool End_Range (FILE * f){
int tmp;
tmp = fgetc(f);
tmp = fgetc(f);
if (tmp != '\'') fseek(f,-2,1);
else fseek(f,1,1);
return tmp == '\'' ? true : false;
}
Таким образом, число чтений или перезаписей файлов при использовании метода естественного слияния будет не хуже, чем при применении
метода простого слияния, а в среднем – даже лучше. Но в этом методе увеличивается число сравнений за счет тех, которые требуются для распознавания концов серий. Помимо этого, максимальный размер вспомогательных файлов может быть близок к размеру исходного файла, так как длина
серий может быть произвольной.
Ключевые термины
Внешняя сортировка – это сортировка данных, которые расположены
на внешних устройствах и не вмещающихся в оперативную память.
Двухпутевое слияние – это сортировка, в которой данные распределяются на два вспомогательных файла.
Двухфазная сортировка – это сортировка, в которой отдельно реализуется две фазы: распределение и слияние.
Длина серии – это количество элементов в серии.
Естественное слияние – это сортировка, при которой всегда сливаются две самые длинные из возможных серий.
Многопутевое слияние – это сортировка, в которой данные распределяются на N (N > 2) вспомогательных файлов.
Несбалансированное слияние – это естественное слияние, у которого
после фазы распределения количество серий во вспомогательных
файлах отличается друг от друга более чем на единицу.
Однофазная сортировка – это сортировка, в которой объединены фазы
распределения и слияния в одну.
Простое слияние – это одна из сортировок на основе слияния называется, в которой длина серий фиксируется на каждом шаге.
Распределение – это процесс разделения упорядоченных серий на два и
несколько вспомогательных файла.
Сбалансированное слияние – это естественное слияние, у которого по150
сле фазы распределения количество серий во вспомогательных файлах отличается друг от друга не более чем на единицу.
Серия (упорядоченный отрезок) – это последовательность элементов,
которая упорядочена по ключу.
Слияние – это процесс объединения двух (или более) упорядоченных
серий в одну упорядоченную последовательность при помощи циклического выбора элементов доступных в данный момент.
Фаза – это действия по однократной обработке всей последовательности элементов.
Краткие итоги
1. Внешние сортировки применяются к данным, которые хранятся во
внешней памяти. Внешние сортировки применяются, если объем
сортируемых данных превосходит допустимое место в ОЗУ.
2. Внешние сортировки, по сравнению с внутренними, характеризуются проигрышем по времени за счет обращения к внешним носителям.
3. К наиболее известным алгоритмам внешних сортировок относятся:
сортировки слиянием (простое слияние и естественное слияние);
улучшенные сортировки (многофазная сортировка и каскадная сортировка).
4. Алгоритмы внешних сортировок отличаются по реализации числом
фаз и путей.
5. Простое слияние является одной из сортировок на основе слияния, в
которой длина серий фиксируется на каждом шаге.
6. Естественное слияние является сортировкой, при которой всегда
сливаются две самые длинные из возможных серий.
7. Число чтений или перезаписей файлов при использовании метода
естественного слияния будет не хуже, чем при применении метода
простого слияния, а в среднем – даже лучше. Однако в данном методе
увеличивается число сравнений за счет распознавания концов серий.
Материалы для практики
Вопросы
1. Чем обусловлено использование алгоритмов внешних сортировок?
2. Как расходуется ОЗУ при использовании различных алгоритмов
внешних сортировок?
3. Каким слиянием, простым или естественным, эффективнее объединять два упорядоченных по общему ключу файла? Ответ обоснуйте.
4. Какие еще факторы, кроме числа фаз и путей, следует учитывать при
анализе эффективности алгоритмов внешних сортировок?
5. Как определить, какому алгоритму внешних сортировок отдать
предпочтение при решении задачи?
151
Упражнения
1. На основании приведенной в лекции функции реализуйте программу, в которой выполняется алгоритм внешней сортировки двухпутевым двухфазным простым слиянием.
2. На основании приведенной в лекции функции реализуйте программу, в которой выполняется алгоритм внешней сортировки двухпутевым двухфазным естественным слиянием.
3. Дан полный перечень всех стран, который включает в себя: название, континент, столицу, площадь, численность населения. Указать
сведения о государствах заданного континента в порядке возрастания численности населения. Использовать двухпутевое однофазное
простое слияние.
4. Даны сведения о химических веществах, которые включает в себя:
класс вещества, название вещества, молекулярная масса вещества.
Упорядочить по возрастанию молекулярных масс все вещества указанного класса. Использовать двухпутевое двухфазное естественное
сбалансированное слияние.
5. В файле хранится последовательность русских слов. Упорядочить ее
в алфавитном порядке. Использовать внешнюю сортировку. Учесть,
что порядок кодов букв русского алфавита не соответствует порядку
букв в алфавите.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
152
12. Алгоритмы на графах.
Алгоритмы обхода графа
Краткая аннотация
В данной теме рассматриваются основные понятия из теории графов, модели представления графов, на основе которых приводятся описания и реализации алгоритмов поиска в глубину и в ширину.
Цель изучения темы
Изучить основные алгоритмы обхода графа и научиться решать задачи обхода графа на основе поиска в ширину и поиска в глубину.
12.1. Основные понятия теории графов
Теория графов в последнее время широко используется в различных
отраслях науки и техники. Быстрое развитие данная теория получила с созданием электронно-вычислительной техники, которая позволяла решить
многие задачи алгоритмизации.
Граф – это совокупность двух конечных множеств: множества точек
и множества линий, попарно соединяющих некоторые из этих точек. Множество точек называется вершинами (узлами) графа. Множество линий,
соединяющих вершины графа, называются ребрами (дугами) графа.
Ориентированный граф (орграф) – граф, у которого все ребра ориентированы, т.е. ребрам которого присвоено направление.
Неориентированный граф (неорграф) – граф, у которого все ребра
неориентированы, т.е. ребрам которого не задано направление.
Смешанный граф – граф, содержащий как ориентированные, так и
неориентированные ребра.
Петлей называется ребро, соединяющее вершину саму с собой. Две
вершины называются смежными, если существует соединяющее их ребро.
Ребра, соединяющие одну и ту же пару вершин, называются кратными.
Простой граф – это граф, в котором нет ни петель, ни кратных ребер.
Мультиграф – это граф, у которого любые две вершины соединены
более чем одним ребром.
Маршрутом в графе называется конечная чередующаяся последовательность смежных вершин и ребер, соединяющих эти вершины.
Маршрут называется открытым, если его начальная и конечная
вершины различны, в противном случае он называется замкнутым.
Маршрут называется цепью, если все его ребра различны. Открытая
цепь называется путем, если все ее вершины различны.
Замкнутая цепь называется циклом, если различны все ее вершины,
за исключением концевых.
153
Граф называется связным, если для любой пары вершин существует
соединяющий их путь.
Вес вершины – число (действительное, целое или рациональное), поставленное в соответствие данной вершине (интерпретируется как стоимость, пропускная способность и т. д.). Вес (длина) ребра – число или несколько чисел, которые интерпретируются по отношению к ребру как длина, пропускная способность и т. д.
Взвешенный граф – граф, каждому ребру которого поставлено в соответствие некое значение (вес ребра).
Выбор структуры данных для хранения графа в памяти компьютера
имеет принципиальное значение при разработке эффективных алгоритмов.
Рассмотрим несколько способов представления графа.
Пусть задан граф (например, рис. 1), у которых количество вершин
равно n, а количество ребер – m. Каждое ребро и каждая вершина имеют
вес – целое положительное число. Если граф не является помеченным, то
считается, что вес равен единице.
Рис. 1. Граф
1. Список ребер – это множество, образованное парами смежных вершин
(рис. 2). Для его хранения обычно используют одномерный массив размером m, содержащий список пар вершин, смежных с одним ребром
графа. Список ребер более удобен для реализации различных алгоритмов на графах по сравнению с другими способами.
a
a
2
a
b
5
a
d
8
b
c
7
b
d
9
c
d
4
d
c
3
Рис. 2. Список ребер графа
2. Матрица смежности – это двумерный массив размерности n × n, значения элементов которого характеризуются смежностью вершин графа
(рис. 3). При этом значению элемента матрицы присваивается количество ребер, которые соединяют соответствующие вершины. Данный
способ действенен, когда надо проверять смежность или находить вес
ребра по двум заданным вершинам.
154
a
b
c
d
a
2
0
0
0
b
5
0
0
0
c
0
7
0
3
d
8
9
4
0
Рис. 3. Матрица смежности графа
3. Матрица инцидентности – это двумерный массив размерности n × m, в
котором указываются связи между инцидентными элементами графа
(ребро и вершина). Столбцы матрицы соответствуют ребрам, строки –
вершинам (рис. 4). Ненулевое значение в ячейке матрицы указывает
связь между вершиной и ребром. Данный способ является самым емким
для хранения, но облегчает нахождение циклов в графе.
(a, a)
(a, b)
(a, d)
(b, c)
(b, d)
(c, d)
(d, c)
a
2
0
0
0
0
0
0
b
0
5
0
0
0
0
0
c
0
0
0
7
0
0
3
d
0
0
8
0
9
4
0
Рис. 4. Матрица инцидентности графа
Существует много алгоритмов на графах, в основе которых лежит
систематический перебор вершин графа, такой что каждая вершина просматривается (посещается) в точности один раз. Поэтому важной задачей
является нахождение хороших методов поиска в графе.
Под обходом графов (поиском на графах) понимается процесс систематического просмотра всех ребер или вершин графа с целью отыскания ребер или вершин, удовлетворяющих некоторому условию.
При решении многих задач, использующих графы, необходимы эффективные методы регулярного обхода вершин и ребер графов. К стандартным и наиболее распространенным методам относятся:
 поиск в глубину (Depth First Search, DFS);
 поиск в ширину (Breadth First Search, BFS).
Эти методы чаще всего рассматриваются на ориентированных графах, но они применимы и для неориентированных, ребра которых считаются двунаправленными. Алгоритмы обхода в глубину и в ширину лежат в
основе решения различных задач обработки графов, например, построения
остовного леса, проверки связности, ацикличности, вычисления расстояний между вершинами и других.
155
12.2. Поиск в глубину
При поиске в глубину посещается первая вершина, затем необходимо идти вдоль ребер графа, до попадания в тупик. Вершина графа является
тупиком, если все смежные с ней вершины уже посещены. После попадания в тупик нужно возвращаться назад вдоль пройденного пути, пока не
будет обнаружена вершина, у которой есть еще не посещенная вершина, а
затем необходимо двигаться в этом новом направлении. Процесс оказывается завершенным при возвращении в начальную вершину, причем все
смежные с ней вершины уже должны быть посещены.
Таким образом, основная идея поиска в глубину – когда возможные
пути по ребрам, выходящим из вершин, разветвляются, нужно сначала
полностью исследовать одну ветку и только потом переходить к другим
веткам (если они останутся нерассмотренными).
Алгоритм поиска в глубину
Шаг 1. Всем вершинам графа присваивается значение не посещенная. Выбирается первая вершина и помечается как посещенная.
Шаг 2. Для последней помеченной как посещенная вершины выбирается смежная вершина, являющаяся первой помеченной как не посещенная, и ей присваивается значение посещенная. Если таких вершин нет, то
берется предыдущая помеченная вершина.
Шаг 3. Повторить шаг 2 до тех пор, пока все вершины не будут помечены как посещенные (рис. 5).
Рис.5. Демонстрация алгоритма поиска в глубину
//Описание функции алгоритма поиска в глубину
void Depth_First_Search(int n, int **Graph, bool *Visited,
int Node){
Visited[Node] = true;
cout << Node + 1 << endl;
156
for (int i = 0 ; i < n ; i++)
if (Graph[Node][i] && !Visited[i])
Depth_First_Search(n,Graph,Visited,i);
}
Также часто используется нерекурсивный алгоритм поиска в глубину. В этом случае рекурсия заменяется на стек. Как только вершина просмотрена, она помещается в стек, а использованной она становится, когда
больше нет новых вершин, смежных с ней.
Временная сложность зависит от представления графа. Если применена матрица смежности, то временная сложность равна O ( n 2 ) , а если нематричное представление – O(n  m) : рассматриваются все вершины и все
ребра.
12.4. Поиск в ширину
При поиске в ширину, после посещения первой вершины, посещаются все соседние с ней вершины. Потом посещаются все вершины, находящиеся на расстоянии двух ребер от начальной. При каждом новом шаге
посещаются вершины, расстояние от которых до начальной на единицу
больше предыдущего. Чтобы предотвратить повторное посещение вершин,
необходимо вести список посещенных вершин. Для хранения временных
данных, необходимых для работы алгоритма, используется очередь – упорядоченная последовательность элементов, в которой новые элементы добавляются в конец, а старые удаляются из начала.
Таким образом, основная идея поиска в ширину заключается в том,
что сначала исследуется все вершины, смежные с начальной вершиной
(вершина с которой начинается обход). Эти вершины находятся на расстоянии 1 от начальной. Затем исследуется все вершины на расстоянии 2 от
начальной, затем все на расстоянии 3 и т.д. Обратим внимание, что при
этом для каждой вершины сразу находятся длина кратчайшего маршрута
от начальной вершины.
Алгоритм поиска в ширину
Шаг 1. Всем вершинам графа присваивается значение не посещенная. Выбирается первая вершина и помечается как посещенная (и заносится в очередь).
Шаг 2. Посещается первая вершина из очереди (если она не помечена как посещенная). Все ее соседние вершины заносятся в очередь. После
этого она удаляется из очереди.
Шаг 3. Повторяется шаг 2 до тех пор, пока очередь не пуста (рис. 6).
157
Рис.6. Демонстрация алгоритма поиска в ширину
//Описание функции алгоритма поиска в ширину
void Breadth_First_Search(int n, int **Graph,
bool *Visited, int Node){
int *List = new int[n]; //очередь
int Count, Head;
// указатели очереди
int i;
// начальная инициализация
for (i = 0; i < n ; i++)
List[i] = 0;
Count = Head = 0;
// помещение в очередь вершины Node
List[Count++] = Node;
Visited[Node] = true;
while ( Head < Count ) {
//взятие вершины из очереди
Node = List[Head++];
cout << Node + 1 << endl;
// просмотр всех вершин, связанных с вершиной Node
for (i = 0 ; i < n ; i++)
// если вершина ранее не просмотрена
if (Graph[Node][i] && !Visited[i]){
// заносим ее в очередь
List[Count++] = i;
Visited[i] = true;
}
}
}
Сложность поиска в ширину при нематричном представлении графа
равна O(n  m) , ибо рассматриваются все n вершин и m ребер. Использование матрицы смежности приводит к оценке O ( n 2 )
Ключевые термины
Вершины (узлы) графа – это множество точек, составляющих граф.
Вес (длина) ребра – это число или несколько чисел, которые интерпретируются по отношению к ребру как длина, пропускная способность.
Вес вершины – это число (действительное, целое или рациональное),
поставленное в соответствие данной вершине.
158
Взвешенный граф – это граф, каждому ребру которого поставлено в соответствие его вес.
Граф – это совокупность двух конечных множеств: множества точек и
множества линий, попарно соединяющих некоторые из этих точек.
Замкнутый маршрут – это маршрут в графе, у которого начальная и
конечная вершины совпадают.
Кратные ребра – это ребра, соединяющие одну и ту же пару вершин.
Маршрут в графе – это конечная чередующаяся последовательность
смежных вершин и ребер, соединяющих эти вершины.
Матрица инцидентности – это двумерный массив, в котором указываются связи между инцидентными элементами графа (ребро и вершина).
Матрица смежности – это двумерный массив, значения элементов
которого характеризуются смежностью вершин графа
Мультиграф – это граф, у которого любые две вершины соединены более чем одним ребром.
Неориентированный граф (неорграф) – это граф, у которого все ребра
неориентированы, то есть ребрам которого не задано направление.
Обход графа (поиск на графе) – это процесс систематического просмотра всех ребер или вершин графа с целью отыскания ребер или
вершин, удовлетворяющих некоторому условию.
Ориентированный граф (орграф) – это граф, у которого все ребра ориентированы, то есть ребрам которого присвоено направление.
Открытый маршрут – это маршрут в графе, у которого начальная и
конечная вершины различны.
Петля – это ребро, соединяющее вершину саму с собой.
Поиск в глубину – это обход графа по возможным путям, когда нужно
сначала полностью исследовать одну ветку и только потом переходить к другим веткам (если они останутся нерассмотренными).
Поиск в ширину – это графа по возможным путям, когда после посещения вершины, посещаются все соседние с ней вершины.
Простой граф – это граф, в котором нет ни петель, ни кратных ребер.
Путь – это открытая цепь, у которой все вершины различны.
Ребра (дуги) графа – это множество линий, соединяющих вершины
графа.
Связный граф – это граф, у которого для любой пары вершин существует соединяющий их путь.
Смежные вершины – это вершины, соединенные общим ребром.
Смешанный граф – это граф, содержащий как ориентированные, так и
неориентированные ребра.
Список ребер – это множество, образованное парами смежных вершин
Тупик – это вершина графа, для которой все смежные с ней вершины
уже посещены
Цепь – это маршрут в графе, у которого все ребра различны.
159
Цикл – это замкнутая цепь, у которой различны все ее вершины, за исключением концевых.
Краткие итоги
1. Графы являются моделью представления данных, основанных на отношениях между элементами множеств.
2. Для представления графов используется несколько способов: список
ребер, матрица смежности, матрица инцидентности.
3. Для организации поиска на графах используются обходы в глубину
и в ширину.
4. Реализацию обходов можно осуществлять рекурсивными и нерекурсивными алгоритмами.
5. От вида графа и способа его представления зависит временная сложность выполнения алгоритма.
Материалы для практики
Вопросы
1. Как связаны между собой различные способы представления графов?
2. Как от вида или представления графа зависит временная сложность
алгоритмов поиска в глубину и в ширину?
3. Как при реализации в коде выполняется возвращение из тупиковых
вершин при обходе графа?
4. Как выполняется обход в несвязном графе?
5. Распространяются ли понятия «поиск в глубину» и «поиск в ширину» на несвязный граф? Ответ обоснуйте.
6. Охарактеризуйте трудоемкость рекурсивного и нерекурсивного алгоритмов обхода графа.
Упражнения
1. На основании приведенной в лекции функции реализуйте программу, в которой выполняется алгоритм обхода графа на основе поиска
в глубину.
2. На основании приведенной в лекции функции реализуйте программу, в которой выполняется алгоритм обхода графа на основе поиска
в ширину.
3. Используйте обход графа в ширину для определения всех вершин
графа, находящихся на фиксированном расстоянии d от данной вершины.
4. Перенумеруйте вершины графа в порядке обхода в глубину и вычислите среднюю плотность графа как частное от деления количества
его ребер на число вершин. Можно ли оба эти действия выполнить
за один обход графа?
160
5. В вершинах неориентированного графа хранятся положительные целые числа. Подсчитайте количество пар дружественных чисел в
вершинах графа, которые соединены ребрами.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Часть 5. Алгоритмы на графах / Р. Сэджвик. – М.: Диасофт, 2002. – 496 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
161
13. Алгоритмы на графах.
Алгоритмы нахождения кратчайшего пути
Краткая аннотация
В данной теме рассматриваются постановка задачи и описание алгоритмов
нахождения кратчайшего пути в графах, приводятся программные реализации алгоритмов Дейкстры, Флойда и переборного алгоритма.
Цель изучения темы
Изучить основные алгоритмы поиска кратчайшего пути и научиться решать задачи поиска кратчайшего пути на основе алгоритмов Дейкстры,
Флойда и переборных алгоритмов.
13.1. Алгоритмы поиска на графах
Нахождение кратчайшего пути на сегодняшний день является жизненно необходимой задачей и используется практически везде, начиная от
нахождения оптимального маршрута между двумя объектами на местности
(например, кратчайший путь от дома до университета), в системах автопилота, для нахождения оптимального маршрута при перевозках, коммутации информационного пакета в сетях и т.п.
Кратчайший путь рассматривается при помощи некоторого математического объекта, называемого графом. Поиск кратчайшего пути ведется
между двумя заданными вершинами в графе. Результатом является путь,
то есть последовательность вершин и ребер, инцидентных двум соседним
вершинам, и его длина.
Рассмотрим три наиболее эффективных алгоритма нахождения кратчайшего пути:
 алгоритм Дейкстры;
 алгоритм Флойда;
 переборные алгоритмы.
Указанные алгоритмы легко выполняются при малом количестве
вершин в графе. При увеличении их количества задача поиска кратчайшего
пути усложняется.
13.2. Алгоритм Дейкстры
Данный алгоритм является алгоритмом на графах, который изобретен нидерландским ученым Э. Дейкстрой в 1959 году. Алгоритм находит
кратчайшее расстояние от одной из вершин графа до всех остальных и работает только для графов без ребер отрицательного веса.
Каждой вершине приписывается вес – это вес пути от начальной
вершины до данной. Также каждая вершина может быть выделена. Если
вершина выделена, то путь от нее до начальной вершины кратчайший, ес162
ли нет – то временный. Обходя граф, алгоритм считает для каждой вершины маршрут, и, если он оказывается кратчайшим, выделяет вершину. Весом данной вершины становится вес пути. Для всех соседей данной вершины алгоритм также рассчитывает вес, при этом ни при каких условиях
не выделяя их. Алгоритм заканчивает свою работу, дойдя до конечной
вершины, и весом кратчайшего пути становится вес конечной вершины.
Алгоритм Дейкстры
Шаг 1. Всем вершинам, за исключением первой, присваивается вес
равный бесконечности, а первой вершине – 0.
Шаг 2. Все вершины не выделены.
Шаг 3. Первая вершина объявляется текущей.
Шаг 4. Вес всех невыделенных вершин пересчитывается по формуле:
вес невыделенной вершины есть минимальное число из старого веса данной вершины, суммы веса текущей вершины и веса ребра, соединяющего
текущую вершину с невыделенной.
Шаг 5. Среди невыделенных вершин ищется вершина с минимальным весом. Если таковая не найдена, то есть вес всех вершин равен бесконечности, то маршрут не существует. Следовательно, выход. Иначе, текущей становится найденная вершина. Она же выделяется.
Шаг 6. Если текущей вершиной оказывается конечная, то путь
найден, и его вес есть вес конечной вершины.
Шаг 7. Переход на шаг 4.
В программной реализации алгоритма Дейкстры построим множество S вершин, для которых кратчайшие пути от начальной вершины уже
известны. На каждом шаге к множеству S добавляется та из оставшихся
вершин, расстояние до которой от начальной вершины меньше, чем для
других оставшихся вершин. При этом будем использовать массив D, в который записываются длины кратчайших путей для каждой вершины. Когда
множество S будет содержать все вершины графа, тогда массив D будет
содержать длины кратчайших путей от начальной вершины к каждой вершине.
Помимо указанных массивов будем использовать матрицу длин C,
где элемент C[i, j] –длина ребра (i, j), если ребра нет, то ее длина полагается равной бесконечности, то есть больше любой фактической длины ребер.
Фактически матрица C представляет собой матрицу смежности, в которой
все нулевые элементы заменены на бесконечность.
Для определения самого кратчайшего пути введем массив P вершин,
где P[v] будет содержать вершину, непосредственно предшествующую
вершине v в кратчайшем пути (рис. 1).
163
Рис.1. Демонстрация алгоритма Дейкстры
//Описание функции алгоритма Дейкстры
void Dijkstra(int n, int **Graph, int Node){
bool *S = new bool[n];
int *D = new int[n];
int *P = new int[n];
int i, j;
int Max_Sum = 0;
for (i = 0 ; i < n ; i++)
for (j = 0 ; j < n ; j++)
Max_Sum += Graph[i][j];
for (i = 0 ; i < n ; i++)
for (j = 0 ; j < n ; j++)
if (Graph[i][j] == 0)
Graph[i][j] = Max_Sum;
for (i = 0 ; i < n ; i++){
S[i] = false;
P[i] = Node;
D[i] = Graph[Node][i];
}
S[Node] = true;
P[Node] = -1;
for ( i = 0 ; i < n - 1 ; i++ ){
int w = 0;
for ( j = 1 ; j < n ; j++ ){
if (!S[w]){
if (!S[j] && D[j] <= D[w])
w = j;
}
else w++;
}
S[w] = true;
for ( j = 1 ; j < n ; j++ )
if (!S[j])
if (D[w] + Graph[w][j] < D[j]){
D[j] = D[w] + Graph[w][j];
164
P[j] = w;
}
}
for ( i = 0 ; i < n ; i++ )
printf("%5d",D[i]);
cout << endl;
for ( i = 0 ; i < n ; i++ )
printf("%5d",P[i]+1);
cout << endl;
delete [] P;
delete [] D;
delete [] S;
}
Сложность алгоритма Дейкстры зависит от способа нахождения
вершины, а также способа хранения множества непосещенных вершин и
способа обновления длин.
Если для представления графа использовать матрицу смежности, то
время выполнения этого алгоритма имеет порядок O(n 2 ) , где n – количество вершин графа.
13.3. Алгоритм Флойда
Рассматриваемый алгоритм иногда называют алгоритмом ФлойдаУоршелла. Алгоритм Флойда-Уоршелла является алгоритмом на графах,
который разработан в 1962 году Робертом Флойдом и Стивеном Уоршеллом. Он служит для нахождения кратчайших путей между всеми парами
вершин графа.
Метод Флойда непосредственно основывается на том факте, что в
графе с положительными весами ребер всякий неэлементарный (содержащий более 1 ребра) кратчайший путь состоит из других кратчайших путей.
Этот алгоритм более общий по сравнению с алгоритмом Дейкстры,
так как он находит кратчайшие пути между любыми двумя вершинами
графа.
В алгоритме Флойда используется матрица A размером n  n , в которой вычисляются длины кратчайших путей. Элемент A[i, j ] равен расстоянию от вершины i к вершине j, которое имеет конечное значение, если существует ребро (i, j), и равен бесконечности в противном случае.
Алгоритм Флойда
Основная идея алгоритма. Пусть есть три вершины i, j, k и заданы
расстояния
между
ними.
Если
выполняется
неравенство
A[i, k ]  A[k , j ]  A[i, j ] , то целесообразно заменить путь i  j путем
i  k  j . Такая замена выполняется систематически в процессе выполнения данного алгоритма.
Шаг 0. Определяем начальную матрицу расстояния A0 и матрицу
165
последовательности вершин S 0 . Каждый диагональный элемент обеих
матриц равен 0, таким образом, показывая, что эти элементы в вычислениях не участвуют. Полагаем k = 1.
Основной шаг k. Задаем строку k и столбец k как ведущую строку и
ведущий столбец. Рассматриваем возможность применения замены описанной выше, ко всем элементам A[i, j ] матрицы Ak 1 . Если выполняется
неравенство A[i, k ]  A[k , j ]  A[i, j ] , ( i  k , j  k , i  j ), тогда выполняем
следующие действия:
1) создаем матрицу Ak путем замены в матрице Ak 1 элемента A[i, j ] на
сумму A[i, k ]  A[k , j ] ;
2) создаем матрицу S k путем замены в матрице S k 1 элемента S[i, j ] на
k. Полагаем k = k + 1 и повторяем шаг k.
Таким образом, алгоритм Флойда делает n итераций, после i-й итерации матрица А будет содержать длины кратчайших путей между любыми двумя парами вершин при условии, что эти пути проходят через вершины от первой до i-й. На каждой итерации перебираются все пары вершин и путь между ними сокращается при помощи i-й вершины (рис. 2).
Рис.2. Демонстрация алгоритма Флойда
//Описание функции алгоритма Флойда
void Floyd(int n, int **Graph, int **ShortestPath){
int i, j, k;
int Max_Sum = 0;
for ( i = 0 ; i < n ; i++ )
for ( j = 0 ; j < n ; j++ )
Max_Sum += ShortestPath[i][j];
for ( i = 0 ; i < n ; i++ )
for ( j = 0 ; j < n ; j++ )
if ( ShortestPath[i][j] == 0 && i != j )
ShortestPath[i][j] = Max_Sum;
for ( k = 0 ; k < n; k++ )
for ( i = 0 ; i < n; i++ )
166
for ( j = 0 ; j < n ; j++ )
if ((ShortestPath[i][k] + ShortestPath[k][j]) <
ShortestPath[i][j])
ShortestPath[i][j] = ShortestPath[i][k] +
ShortestPath[k][j];
}
Заметим, что если граф неориентированный, то все матрицы, получаемые в результате преобразований симметричны и, следовательно, достаточно вычислять только элементы, расположенные выше главной диагонали.
Если граф представлен матрицей смежности, то время выполнения
этого алгоритма имеет порядок O(n 3 ) , поскольку в нем присутствуют вложенные друг в друга три цикла.
13.4. Переборные алгоритмы
Переборные алгоритмы по сути своей являются алгоритмами поиска,
как правило, поиска оптимального решения. При этом решение конструируется постепенно. В этом случае обычно говорят о переборе вершин дерева вариантов. Вершинами такого графа будут промежуточные или конечные варианты, а ребра будут указывать пути конструирования вариантов.
Рассмотрим переборные алгоритмы, основанные на методах поиска в
графе, на примере задачи нахождения кратчайшего пути в лабиринте.
Постановка задачи.
Лабиринт, состоящий из проходимых и непроходимых клеток, задан
матрицей A размером m  n . Элемент матрицы A[i, j ]  0 , если клетка (i, j )
проходима. В противном случае A[i, j ]   .
Требуется найти длину кратчайшего пути из клетки (1, 1) в клетку
(m, n) .
Фактически дана матрица смежности (только в ней нули заменены
бесконечностями, а единицы – нулями). Лабиринт представляет собой граф.
Вершинами дерева вариантов в данной задаче являются пути, начинающиеся в клетке (1, 1). Ребра – показывают ход конструирования этих
путей и соединяют два пути длины k и k+1, где второй путь получается из
первого добавлением к пути еще одного хода.
13.4.1. Перебор с возвратом
Данный метод основан на методе поиска в глубину. Перебор с возвратом считают методом проб и ошибок («попробуем сходить в эту сторону: не получится – вернемся и попробуем в другую»). Так как перебор вариантов осуществляется методом поиска в глубину, то целесообразно во
время работы алгоритма хранить текущий путь в дереве. Этот путь представляет собой стек Way.
167
Также необходим массив Dist, размерность которого соответствует
количеству вершин графа, хранящий для каждой вершины расстояние от
нее до исходной вершины.
Пусть текущей является некоторая клетка (в начале работы алгоритма – клетка (1, 1)). Если для текущей клетки есть клетка-сосед Neighbor,
отсутствующая в Way, в которую на этом пути еще не ходили, то добавляем Neighbor в Way и текущей клетке присваиваем Neighbor, иначе извлечь из Way.
Приведенное выше описание дает четко понять, почему этот метод
называется перебором с возвратом. Возврату здесь соответствует операция
«извлечь из Way», которая уменьшает длину Way на 1.
Перебор заканчивается, когда Way пуст и делается попытка возврата
назад. В этой ситуации возвращаться уже некуда (рис. 3).
Way является текущим путем, но в процессе работы необходимо хранить и оптимальный путь OptimalWay.
Усовершенствование алгоритма можно произвести следующим образом: не позволять, чтобы длина Way была больше или равна длине
OptimalWay. В этом случае, если и будет найден какой-то вариант, он заведомо не будет оптимальным. Такое усовершенствование в общем случае
означает, что как только текущий путь станет заведомо неоптимальным,
надо вернуться назад. Данное улучшение алгоритма позволяет во многих
случаях сильно сократить перебор.
Рис.3. Демонстрация алгоритма перебора с возвратом
/*Описание функции переборного алгоритма методом поиска в
глубину */
void Backtracking(int n, int m, int **Maze){
168
int Begin, End, Current;
Begin = (n - 1) * m;
End = m - 1;
int *Way, *OptimalWay;
int LengthWay, LengthOptimalWay;
Way = new int[n*m];
OptimalWay = new int[n*m];
LengthWay = 0;
LengthOptimalWay = m*n;
for (int i = 0 ; i < n*m ; i++ )
Way[i] = OptimalWay[i] = -1;
int *Dist;
Dist = new int[n*m];
for (int i = 0 ; i < n ; i++ )
for (int j = 0 ; j < m ; j++ )
Dist[i * m + j] = ( Maze[i][j] == 0 ? 0 : -1 );
Way[LengthWay++] = Current = Begin;
while ( LengthWay > 0 ){
if(Current == End){
if (LengthWay < LengthOptimalWay){
for (int i = 0 ; i < LengthWay ; i++ )
OptimalWay[i] = Way[i];
LengthOptimalWay = LengthWay;
}
if (LengthWay > 0) Way[--LengthWay] = -1;
Current = Way[LengthWay-1];
}
else{
int Neighbor = -1;
if ((Current/m - 1) >= 0 && !Insert(Way, Current - m) &&
(Dist[Current - m] == 0 || Dist[Current - m] > LengthWay)
&& Dist[Current] < LengthOptimalWay)
Neighbor = Current - m;
else
if ((Current%m - 1) >= 0 && !Insert(Way,Current - 1)&&
(Dist[Current - 1]== 0 || Dist[Current - 1] > LengthWay)
&& Dist[Current] < LengthOptimalWay )
Neighbor = Current - 1;
else
if ((Current%m + 1) < m && !Insert(Way,Current + 1) &&
(Dist[Current + 1]== 0 || Dist[Current + 1] > LengthWay)
&& Dist[Current] < LengthOptimalWay )
Neighbor = Current + 1;
else
if ((Current/m + 1) < n && !Insert(Way,Current + m) &&
(Dist[Current + m]== 0 || Dist[Current + m] > LengthWay)
&& Dist[Current] < LengthOptimalWay )
Neighbor = Current + m;
169
if ( Neighbor != -1 ){
Way[LengthWay++] = Neighbor;
Dist[Neighbor] = Dist[Current] + 1;
Current = Neighbor;
}
else {
if (LengthWay > 0) Way[--LengthWay] = -1;
Current = Way[LengthWay-1];
}
}
}
if ( LengthOptimalWay < n*m )
cout << endl << "Yes. Length way=" << LengthOptimalWay<< endl;
else cout << endl << "No" << endl;
}
13.4.2. Волновой алгоритм
Этот переборный алгоритм, который основан на поиске в ширину,
состоит из двух этапов:
1. распространение волны;
2. обратный ход.
Распространение волны и есть собственно поиск в ширину, при котором клетки помечаются номером шага метода, на котором клетка посещается. При обратном ходе, начиная с конечной вершины, идет восстановление пути, по которому в нее попали путем включения в него клеток с
минимальной пометкой (рис. 4). Важной особенностью является то, что
восстановление начинается с конца (с начала оно зачастую невозможно).
Рис.4. Демонстрация волнового алгоритма
170
Заметим, что перебор методом поиска в ширину по сравнению с перебором с возвратом, как правило, требует больше вспомогательной памяти, которая необходима для хранения информации, чтобы построить путь
при обратном ходе и пометить посещенные вершины. Однако он работает
быстрее, так как совершенно исключается посещение одной и той же клетки более чем один раз.
Ключевые термины
Алгоритм Дейкстры – это алгоритм нахождения кратчайшего пути от
одной из вершин графа до всех остальных, который работает только
для графов без ребер отрицательного веса.
Алгоритм Флойда – это алгоритм поиска кратчайшего пути между любыми двумя вершинами графа.
Волновой алгоритм – это переборный алгоритм, который основан на
поиске в ширину и состоит из двух этапов: распространение волны и
обратный ход.
Кратчайший путь – это путь в графе, то есть последовательность
вершин и ребер, инцидентных двум соседним вершинам, и его длина.
Переборный алгоритм – это алгоритм обхода графа, основанный на
последовательном переборе возможных путей.
Краткие итоги
1. Нахождение кратчайшего пути на сегодняшний день является актуальной задачей
2. К наиболее эффективным алгоритмам нахождения кратчайшего пути
в графах относятся алгоритм Дейкстры, алгоритм Флойда и переборные алгоритмы. Эти алгоритмы эффективны при достаточно небольших количествах вершин.
3. В реализации алгоритма Дейкстры строится множество вершин, для
которых кратчайшие пути от начальной вершины уже известны.
Следующие шаги основаны на добавлении к имеющемуся множеству по одной вершине с сохранением длин оптимальных путей.
4. Сложность алгоритма Дейкстры зависит от способа нахождения
вершины, а также способа хранения множества непосещенных вершин и способа обновления длин.
5. Метод Флойда основывается на факте, что в графе с положительными весами ребер всякий неэлементарный кратчайший путь состоит
из других кратчайших путей.
6. Если граф представлен матрицей смежности, то время выполнения
алгоритма Флойда имеет порядок O(n 3 ) .
171
7. Переборные алгоритмы являются алгоритмами поиска оптимального
решения.
8. Волновой алгоритм является переборным алгоритмом, который основан на поиске в ширину и состоит из двух этапов: распространение волны и обратный ход.
9. Перебор методом поиска в ширину, по сравнению с перебором с
возвратом, требует больше вспомогательной памяти для хранения
информации, однако, он работает быстрее, так как исключается посещение одной и той же вершины более чем один раз.
Материалы для практики
Вопросы
1. С какими видами графов работают алгоритмы Дейкстры, Флойда и
переборные алгоритмы?
2. Как от представления графа зависит эффективность алгоритма его
обхода?
3. За счет чего поиск в ширину является достаточно ресурсоемким алгоритмом?
4. В чем преимущества алгоритмов обхода графа в ширину?
5. Каким образом в алгоритме перебора с возвратом при обходе графа
обрабатывается посещение тупиковых вершин?
6. Поясните на примере обхода графа этап обратного хода в волновом
алгоритме. Почему его удобно выполнять с конца?
7. При программной реализации алгоритмов обхода графа с помощью
рекурсии что выделяется в качестве базы и как организована декомпозиция?
Упражнения
1. На основании приведенной в лекции функций реализуйте программы, в которых выполняются алгоритм Дейкстры и алгоритм Флойда.
2. На основании приведенной в лекции функции реализуйте программу, в которой выполняется переборный алгоритм методом поиска в
ширину.
3. Оля (A), Маша (B), Витя (C), Дима (D), Ваня (E) и Катя (F) живут в
разных городах. Стоимость билетов из разных городов известна
(рис.). Добраться до городов можно разными способами. Определить
наименьшую сумму, которую нужно потратить, чтобы Оля могла
навестить каждого из своих друзей.
172
E
5
B
5
6
8
F
9
А
7
8
C
5
D
4
4
4. Квадратное озеро задается матрицей M×N и покрыто мелкими островками. В левом верхнем углу находится плот размером m×m. За
один шаг плот может передвигаться на одну клетку по вертикали
или горизонтали. Требуется определить кратчайший путь плота до
правого нижнего угла.
5. Напишите алгоритм, находящий строку длиной 100 символов, состоящую только из букв «A», «B», «C», такую, что в ней никакие две соседние подстроки не равны друг другу. Воспользуйтесь перебором с
возвратом.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
3. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
4. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
5. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
6. Сэджвик Р. Фундаментальные алгоритмы на С++. Часть 5. Алгоритмы на графах / Р. Сэджвик. – М.: Диасофт, 2002. – 496 с.
7. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
173
14. Решение задач на использование
алгоритмов обработки данных
Краткая аннотация
В данной теме даются общие рекомендации по решению задач повышенной сложности, рассматривается примеры программной реализации задач
обработки данных, которые решаются с помощью алгоритмов сортировок,
обходов графа и сжатия данных.
Цель изучения темы
Изучить основные приемы разработки алгоритмов обработки данных,
научиться применять их при решении задач и учитывать трудоемкость и
эффективность используемых алгоритмов.
14.1. Этапы решения задач на обработку данных
Рассмотренные в предыдущих лекциях алгоритмы в основном относятся к базовым алгоритмам обработки данных и являются результатом
исследований и разработок, проводившихся на протяжении десятков лет.
Они, как и прежде, продолжают играть важную роль во все расширяющемся использовании в вычислительных процессах. На этих алгоритмах строится большинство задач повышенной сложности и задач олимпиадного
уровня.
Приведем общую схему решения задач по программированию.
1. Чтение условия. Необходимо внимательно прочесть условие задачи, не
пропуская ни одной фразы.
2. Построение математической модели. Необходимо понять, в чем заключается задача – построить ее математическую модель (на листке
бумаги или образно), то есть достаточно формально и математически
строго понять условие.
3. Построение общей схемы решения. Теперь следует перейти от понимания того, что необходимо сделать, к пониманию того, как это сделать,
то есть наметить эффективный алгоритм решения задачи и пути его реализации.
4. Стыковка. Под стыковкой понимается уточнение решений, принятых
на предыдущем этапе. Необходимо достаточно медленно и тщательно
продумать, из каких частей будет состоять программа, какие массивы и
структуры будут выделены и т.д.
5. Реализация. На этом этапе собственно пишется сама программа. Иногда
предпочтительнее программирование «сверху вниз», иногда – «снизу
вверх» или их комбинация.
6. Тестирование и отладка. Добившись того, чтобы программа компилировалась, необходимо убедиться в ее правильности. Проблемы могут
174
быть в мелких ошибках, допущенных в процессе написания: перепутанные имена переменных, неверный знак в формуле и т.д. Решение
может быть принципиально неправильным или неэффективным. Размер
массивов может быть недостаточным или, напротив, чрезмерным, что
будет вызывать ошибку «превышен предел памяти».
14.2. Алгоритмы сортировки данных
Данную тему следует рассматривать в двух аспектах. Во-первых, при
решении различных задач повышенной сложности данные довольно часто
требуется упорядочить по некоторому признаку (то есть отсортировать).
При этом, если специально не оговорено иное, считается, что массив требуется отсортировать в порядке неубывания значений его элементов (для
различных элементов – в порядке возрастания). Во-вторых, задача сама по
себе может требовать построения оптимального в смысле определенных
требований или нестандартного алгоритма сортировки. Помимо этого,
специфика задач повышенной сложности может состоять в формализации
критерия, по которому следует сортировать данные.
Особую роль при выборе метода сортировки играет его трудоемкость и эффективность. Приведем таблицу, в которой для известных универсальных алгоритмов сортировки приведены порядки для количества
выполняемых тем или иным алгоритмом в худшем случае операций сравнения и присваивания.
Таблица.
Трудоемкость операций сравнения и присваивания
в алгоритмах сортировки
Название
сортировки
Простой обмен
(пузырьковая)
Прямой выбор
Простая вставка
Быстрая
Слияниями
Пирамидальная
Количество
сравнений
Количество
присваиваний
O(N2)
O(N2)
O(N2)
O(N2)
O(N2)
(на практике O(N logN))
O(N logN)
O(N logN)
O(N2)
O(N2)
O(N2)
(на практике O(N logN))
O(N logN)
O(N logN)
Таким образом, наилучшую теоретическую оценку имеют два последних из перечисленных в таблице алгоритмов, однако, в практическом
программировании для упорядочивания данных обычно используют быструю сортировку, как в силу высокой производительности (особенно выигрывает данный алгоритм по числу реально выполняемых присваиваний),
так и в силу простой реализации. Тем не менее, в задачах повышенной
сложности данные могут быть представлены так, что отсортировать за от175
веденное время их можно будет лишь с помощью пирамидальной сортировки или какой-либо другой.
Пример 1. Задача «Поразрядная сортировка»
Поразрядная сортировка была изобретена в 1920-х годах как побочный результат использования сортирующих машин. Такая машина обрабатывала перфокарты, имевшие по 80 колонок. Каждая колонка представляла
отдельный символ. В колонке было 12 позиций, и в них для представления
того или иного символа пробивались отверстия. Цифру от 0 до 9 кодировали одним отверстием в соответствующей позиции (еще две позиции в колонке использовали для кодировки букв).
Запуская машину, оператор закладывал в ее приемное устройство
стопку перфокарт и задавал номер колонки на перфокартах. Машина «просматривала» эту колонку на картах и по цифровому значению 0, 1, ..., 9 в
ней распределяла («сортировала») карты на 10 стопок.
Несколько колонок (разрядов) с закодированными цифрами представляли натуральное число, т.е. номер. Чтобы получить стопку карт, упорядоченных по номерам, оператор действовал так. Вначале он распределял
карты на 10 стопок по значению младшем разряде. Эти стопки в порядке
возрастания значений в младшем разряде он складывал в одну и повторял
процесс, но со следующим разрядом, и т.д. Получив стопки карт, распределенных по значениям в старшем разряде, оператор складывал их по возрастанию этих значений и получал то, что нужно.
Значения в разрядах номеров заданы цифрами, поэтому поразрядную
сортировку еще называют цифровой. Заметим, что цифры от 0 до 9 упорядочены по возрастанию, поэтому цифровая сортировка располагает числа в
лексикографическом порядке.
Пример.
Входные данные
Выходные данные
733 877 323 231 777 721 123
123 231 323 721 733 777 877
Описание решения.
Принцип решения разберем на конкретном примере. Пусть задана
последовательность трехзначных номеров:
733 877 323 231 777 721 123
Распределим данную последовательность по младшей цифре на
стопки:
231 721
733 323 123
877 777
Далее сложим получившиеся стопки в одну в порядке возрастания последней цифры.
231 721 733 323 123 877 777
На следующем шаге номера, которые обрабатываются именно в этой
176
последовательности, распределяются по второй цифре на следующие
стопки.
721 323 123
231 733
877 777
Затем из них также образуется одна последовательность.
721 323 123 231 733 877 777
Обратим внимание, что перед последним шагом все номера с числом
сотен 7, благодаря предыдущим шагам, расположены один относительно
другого по возрастанию.
На последнем шаге номера распределяются по старшей цифре на
стопки:
123
231
323
721 733 777
877
и образуется окончательная последовательность:
123 231 323 721 733 777 877.
Далее приведем код программы.
#include "stdafx.h"
#include <iostream>
using namespace std;
const int D = 3;
const int B = 10;
typedef int T[D];
typedef T *List;
void SortD(int k);
void Done();
void outDigs(int i);
List Data;
int PFirst[B], PLast[B], *PQNext;
int first, n, newL, tempL, i, nextI;
int _tmain(int argc, _TCHAR* argv[]){
int k;
cout << "Введите количество элементов массива n
cin >> n;
Data = new T[n];
PQNext = new int[n];
for ( k = 0 ; k < n ; k++ ){
PQNext[k] = k + 1;
for ( int r = 0 ; r < D ; r++ )
177
";
Data[k][r] = 0;
}
for ( k = 0 ; k < n ; k++ )
for ( int r = 0 ; r < D ; r++ )
Data[k][r] = rand()%B;
first = 0;
Done();
cout << endl;
for ( k = D - 1 ; k >= 0 ; k-- )
SortD(k);
Done();
cout << endl;
delete [] PQNext;
delete [] Data;
system("pause");
return 0;
}
// описание функции поразрядной сортировки
void SortD(int k){
for ( tempL = 0 ; tempL < B ; tempL++ ){
PFirst[tempL] = n;
PLast[tempL] = n;
}
i = first;
while (i != n){
tempL = Data[i][k];
nextI = PQNext[i];
PQNext[i] = n;
if ( PFirst[tempL] == n )
PFirst[tempL] = i;
else PQNext[PLast[tempL]] = i;
PLast[tempL] = i;
i = nextI;
}
tempL = 0;
while ( tempL < B && PFirst[tempL] == n )
tempL++;
first = PFirst[tempL];
while ( tempL < B - 1 ){
newL = tempL + 1;
while ( newL < B && PFirst[newL] == n )
newL++;
if ( newL < B )
PQNext[PLast[tempL]] = PFirst[newL];
tempL = newL;
}
}
178
/*описание функции вывода элементов в соответсвии со
списком индесов в массиве PQNext*/
void Done(){
int i = first;
while ( i != n ){
outDigs(i);
i = PQNext[i];
}
}
/*описание функции вывода элементов из массива Data,
индекс которого задан ее аргументом*/
void outDigs(int i){
int j = 0;
while ( Data[i][j] == 0 && j < D )
j++;
if ( j == D )
cout << 0;
else
while ( j < D )
cout << Data[i][j++];
cout << " ";
}
14.3. Алгоритмы на графах
Многие прикладные задачи и задачи повышенной сложности легко
сформулировать в терминах такой структуры данных как граф. Для ряда
подобных задач хорошо изучены эффективные (полиномиальные) алгоритмы их решения.
Для хранения графа в программе можно применить различные методы. Самым простым является хранение матрицы смежности, с помощью
которой легко проверить, существует ли в графе ребро, соединяющее вершину одну с вершиной с другой. Основной же ее недостаток заключается в
том, что матрица смежности требует, чтобы объем памяти был достаточен
для хранения N 2 значений, даже если ребер в графе существенно меньше,
чем N 2 . Это не позволяет построить алгоритм со временем порядка O(N )
для графов, имеющих O(N ) ребер.
Данного недостатка лишены такие способы хранения графа, как одномерный массив длины N списков или множеств вершин. В таком массиве каждый элемент соответствует одной из вершин и содержит список или
множество вершин, смежных ей.
Для реализации некоторых алгоритмов более удобным является описание графа путем перечисления его ребер. В этом случае хранить его
можно в одномерном массиве длиной M, каждый элемент которого содер179
жит запись о номерах начальной и конечной вершин ребра, а также его весе в случае взвешенного графа.
При решении многих задач, как для ориентированных, так и для неориентированных графов, необходим эффективный метод систематического обхода вершин графа. На практике применяется два принципиально
различных порядка обхода, основанных на поиске в глубину и поиске в
ширину соответственно.
Для определения и нахождения длины кратчайшего пути в графе в
основном используют известные алгоритмы, такие как алгоритм Дейкстры,
Флойда и переборные алгоритмы.
Графы широко используются в различных областях науки и техники
для моделирования отношений между объектами. Объекты соответствуют
вершинам графа, а ребра – отношениям между объектами.
Пример 2. Задача «Тетраэдр»
Дано треугольное поле в виде равностороннего треугольника. Оно
разбито на одинаковые равносторонние треугольники со сторонами в М
раз меньшими, чем сторона большого треугольника (рис. 1).
0
2
4
1
3
5
7
6
8
В
А
Рис. 1 Общий вид треугольного поля
Маленькие треугольники пронумерованы подряд с верхнего ряда
вниз по рядам, начиная с 0. Числами показаны номера треугольников. I-му
треугольнику приписана пометка Pi .
Имеется также тетраэдр (правильная треугольная пирамида) с ребром, равным длине стороны маленького треугольника. Тетраэдр установлен на S-м треугольнике. Все грани тетраэдра пронумерованы следующим
образом:
1) основание тетраэдра;
2) правая грань тетраэдра, если смотреть сверху тетраэдра в направлении стороны АВ перпендикулярно ей;
3) левая грань тетраэдра, если смотреть сверху тетраэдра в направлении стороны АВ перпендикулярно ей;
4) оставшаяся грань.
180
Например, при S  2 жирной линией выделено нижнее ребро третьей грани, а при S  3 жирной линией выделено нижнее ребро второй грани. J-я
грань тетраэдра имеет пометку R j .
Имеется возможность перекатывать тетраэдр через ребро, но при
каждом перекатывании взимается штраф, равный квадрату разности между
пометками совмещаемой грани тетраэдра и треугольника. Требуется перекатить тетраэдр с треугольника S на D с наименьшим суммарным штрафом
( S  D ).
Входные данные находятся в текстовом файле INPUT.TXT. Первая
строка содержит целые числа S, D и М ( M  90 ). Каждая из следующих
M 2 строк содержит пометку соответствующего треугольника. В последней строке записаны пометки граней тетраэдра. Пометки (как граней, так и
треугольников) – целые неотрицательные числа, не превосходящие 300.
Числа в одной строке разделены пробелами.
В выходной файл OUTPUT.TXT должно быть записано одно число –
минимально возможный штраф.
Пример.
Входные данные
Выходные данные
0 4 3
9446
4
3
8
100
7
3
2
49
9
7 50 100 8
Описание решения.
Перейдем к графу следующим образом: вершина – маленький треугольник. Ребро – наличие возможности перекатить тетраэдр через ребро
из одного треугольника в другой. Тогда, например, поле, изображенное на
рис. 2, превратится в граф на рис. 3.
181
0
2
3
1
5
7
6
4
8
Рис. 2. Пример треугольного поля
0
4
1
2
3
5
6
7
8
Рис. 3 Начальное положение развертки тетраэдра
На этом графе требуется найти путь минимальной стоимости из одной вершины в другую. Поскольку веса ребер в этом графе зависят от того,
какой именно гранью тетраэдр придет на соответствующий треугольник,
то воспользуемся поиском в ширину. Но прежде проясним процесс перекатывания тетраэдра. В соответствии с условиями задачи начальное положение развертки тетраэдра показано на рис. 4.
3
2
1
4
Рис. 4. Перекатывание тетраэдра
Обозначим его 1u (в основании грань номер 1, повернутая вверх).
Очевидно, что всего существует 8 возможных различных состояний тетраэдра: 1u, 2u, Зu, 4u, 1d, 2d, 3d, 4d. На рис. 5 и рис. 6 приведены соответствующие развертки:
1u
2u
3u
4u
3
2
3
1
1
4
2
3
1
2
3
4
4
4
2
1
Рис. 5. Развертки перекатывания тетраэдра вверх
182
3
1d
2d
3d
4d
4
3
2
1
1
2
3
4
2
1
4
1
4
2
3
Рис. 6. Развертки перекатывания тетраэдра вниз
Составим теперь таблицу, отображающую, в какое из состояний переходит тетраэдр при перекатывании его вниз, вверх, вправо, влево из текущего состояния.
1u
1d
2и
2d
3и
3d
4и
4d
Вниз
4d
x
3d
х
2d
х
1d
х
Вверх
x
4и
х
3и
х
2и
х
1u
Вправо
3d
2и
4d
1и
1d
4и
2d
3и
Влево
2d
3u
1d
4u
4d
1u
3d
2u
Для удобства использования этой информации введем следующее кодирование:
x 1u 1d 2u 2d 3u 3d 4u 4d
0
1
2
3
4
5
6
7
8
Вниз Вверх Вправо Влево
1
2
3
4
Получаем двумерный массив Т (8 строк, 4 столбца), который описывает
все возможные перекатывания тетраэдра.
Т
1 2 3 4
1 8 0 6 4
2 0 7 3 5
3 6 0 8 2
4 0 5 1 7
5 4 0 2 8
6 0 3 7 1
7 2 0 4 6
8 0 1 5 3
183
Основная идея решения заключается в следующем:
1) заносим в очередь стартовую позицию S;
2) пока очередь не пуста, берем из очереди очередную позицию,
ставим в очередь позиции, в которые тетраэдр может попасть за
одно перекатывание.
При установке в очередь очередной элемент включает номер вершины на графе, тип прихода (1u…4d), текущий штраф после перехода в эту
вершину. Элемент не нужно ставить в очередь, если текущий штраф больше ранее запомненного для этой вершины графа.
Далее приведем код программы.
#include "stdafx.h"
#include <iostream>
#include <cmath>
using namespace std;
void InputData();
void OutResult();
void InitGraph();
void Put(long long v, long long tv, long long cv);
void Get(long long *v, long long *tv, long long *cv);
void PutAll(long long v, long long tv, long long cv);
long long SQR(long long a);
int MaxM = 10;
int Table[8][4] = {
8, 0, 6, 4,
0, 7, 3, 5,
6, 0, 8, 2,
0, 5, 1, 7,
4, 0, 2, 8,
0, 3, 7, 1,
2, 0, 4, 6,
0, 1, 5, 3
};
int MaxQ = MaxM * MaxM * MaxM;
int *p, *cp, *Pw, **g, **Q;
long long *R;
long long i, S, D, M, j, a, TS, QBegin, QEnd, V, TV, CV,
Last;
//V – номер вершины
//TV – тип вершины
//CV – текущее значение штрафа
int _tmain(int argc, _TCHAR* argv[]){
p = new int[MaxM * MaxM];
cp = new int[MaxM * MaxM];
Pw = new int[MaxM * MaxM];
for (i = 0; i < MaxM * MaxM; i++)
p[i] = cp[i] = Pw[i] = 0;
g = new int*[MaxM * MaxM];
184
for (i = 0; i < MaxM * MaxM; i++ ){
g[i] = new int[4];
g[i][0] = g[i][1] = g[i][2] = g[i][3] = 0;
}
Q = new int*[MaxQ + 1];
for (i = 0; i < MaxQ + 1; i++ ){
Q[i] = new int[4];
Q[i][0] = Q[i][1] = Q[i][2] = Q[i][3] = Q[i][4] = 0;
}
R = new long long[5];
R[0] = R[1] = R[2] = R[3] = R[4] = 0;
InputData();
InitGraph();
QEnd = 0;
QBegin = 1;
Put(S,TS,0);
while (QBegin <= QEnd){
Get(&V,&TV,&CV);
PutAll(V,TV,CV);
}
OutResult();
system("pause");
return 0;
}
//описание функции ввода исходных данных
void InputData(){
FILE *f;
f = fopen("input.txt","r");
fscanf(f,"%d %d %d",&S,&D,&M);
for ( i = 0; i < M * M; i++ )
fscanf(f,"%d",p + i);
for ( i = 1; i < 5; i++ )
fscanf(f,"%d",R + i);
fclose(f);
}
//описание функции вывода результата
void OutResult(){
FILE *f;
f = fopen("output.txt","w");
fprintf(f,"%d",cp[D]);
fclose(f);
}
//описание функции создания графа по исходным данным
void InitGraph(){
Pw[0] = 1;
g[0][1] = 2;
for ( i = 1; i < M; i++)
for ( j = i * i; j < (i + 1) * (i + 1) - 1; j++){
185
g[j][++Pw[j]] = j + 1;
g[j + 1][++Pw[j + 1]] = j;
}
a = 4;
TS = 1;
for ( i = 1; i < M - 1; i++){
for ( j = i * i; j < (i + 1) * (i + 1); j += 2){
g[j][++Pw[j]] = j + a;
g[j + a][++Pw[j + a]] = j;
if ( S == j ) TS = 2;
}
a += 2;
}
for ( i = 0; i < M * M; i++)
cp[i] = INT_MAX;
}
//описание функции постановки в очередь одной вершины графа
void Put(long long v, long long tv, long long cv){
QEnd++;
Q[QEnd][1] = v;
Q[QEnd][2] = tv;
Q[QEnd][3] = cv;
cp[v] = cv;
}
//описание функции взятия из очереди очередной вершины графа
void Get(long long *v, long long *tv, long long *cv){
*v = Q[QBegin][1];
*tv = Q[QBegin][2];
*cv = Q[QBegin][3];
QBegin++;
}
/*описание функции постановки в очередь всех вершин, смежных с текущей*/
void PutAll(long long v, long long tv, long long cv){
long nv, ntv, ncv, Dir, Base;
for ( i = 1 ; i <= Pw[v]; i++ ){
nv = g[v][i];
if ( nv == v + 1 )
Dir = 2;
else if ( nv == v - 1 )
Dir = 3;
else if ( nv > v )
Dir = 0;
else Dir = 1;
ntv = Table[tv-1][Dir];
Base = (ntv + 1) / 2;
if ( Base > 0 ) {
186
ncv = cv + SQR(p[nv] - R[Base]);
if ( ncv < cp[nv] )
Put(nv,ntv,ncv);
}
}
}
//описание функции возведения в квадрат
long long SQR(long long a){
return a*a;
}
14.4. Алгоритмы сжатия данных
Характерной особенностью большинства типов данных является их
избыточность. Степень избыточности данных зависит от типа данных.
Например, для видеоданных степень избыточности в несколько раз больше, чем для графических данных, а степень избыточности графических
данных, в свою очередь, больше чем степень избыточности текстовых
данных. Другим фактором, влияющим на степень избыточности, является
принятая система кодирования.
Существует много разных практических методов сжатия без потери
информации, которые, как правило, имеют разную эффективность для разных типов данных и разных объемов. Однако в основе этих методов лежат
три теоретических алгоритма:
 алгоритм RLE (Run Length Encoding);
 алгоритмы группы KWE (Key Word Encoding);
 алгоритм Хаффмана.
Пример 3. Задача «Энтропийное кодирование»
Энтропийное кодирование – это метод кодирования данных, который обеспечивает компрессию данных за счет удаления избыточной информации. Например, английский текст, закодированный с помощью таблицы ASCII, является примером сообщения с высокой энтропией. В тоже
время сжатые сообщения, например zip-архивы, имеют очень маленькую
энтропию, и потому попытки их энтропийного кодирования не принесут
пользы.
Английский текст, закодированный с помощью ASCII, имеет высокую
степень энтропии, потому что для кодирования всех символов используется
одно и тоже количество битов – восемь. В то же время известный факт состоит в том, что буквы E, L, N, R, S и T встречаются со значительно более
высокой частотой, чем другие буквы английского алфавита. Если найдется
способ закодировать только эти буквы четырьмя битами, то закодированный текст станет существенно меньше и при этом будет содержать всю исходную информацию и иметь меньшую энтропию. Однако, как различить
при декодировании, четырьмя или восемью битами закодирован очередной
символ? Эта проблема решается с помощью префиксного кодирования.
187
В такой схеме кодирования любое количество битов может быть использовано для конкретного символа. Однако для того, чтобы иметь возможность восстановить информацию, запрещено, чтобы последовательность битов, кодирующая некоторый символ, была префиксом битовой последовательности, используемой для кодирования любого другого символа. Это позволяет читать входную последовательность бит за битом, и как
только встречено обозначение символа – его декодировать.
Рассмотрим текст AAAAABCD. Кодирование, использующее ASCII,
требует 64 бита. Если же символ А будет кодироваться битовой последовательностью 00, символ В – последовательностью 01, символ С – последовательностью 10, a D – последовательностью 11, то для кодирования потребуется всего 16 битов. Результирующий поток битов будет такой:
0000000000011011.
Но это все еще кодирование с фиксированной длиной, здесь просто
использовались для каждого символа два бита вместо восьми.
Символ А встречается чаще, тогда будем его кодировать с помощью
меньшего количества битов. Следовательно, закодируем символы такими
последовательностями битов:
А–0
В –10
С – 110
D – 111
Используя такое кодирование, получим только 13 битов в закодированном сообщении: 0000010110111. Коэффициент сжатия в этом случае
равен 4,9 к 1. Это означает, что каждый бит в последнем закодированном
сообщении содержит столько же информации, сколько и 4,9 бит в первом
закодированном сообщении (с помощью ASCII).
Попробуйте читать сообщение 0000010110111 слева направо – и
убедитесь, что «префиксное» кодирование обеспечивает простое декодирование текста, даже несмотря на то, что символы кодируются различным
количеством битов.
В качестве другого примера рассмотрим текст THE CAT IN THE HAT.
В этом тексте символы Т и пробел встречаются чаще других. Поэтому их нужно кодировать меньшим количеством битов. А символы C, I и N
встречаются только по одному разу, потому будут кодироваться самыми
длинными кодами. Например, так:
пробел – 00
А – 100
С – 1110
Е – 1111
Н – 110
I – 1010
N – 1011
Т – 01
188
При таком кодировании исходного предложения потребуется только
51 бит против 144, которые необходимы, чтобы закодировать исходное сообщение с помощью 8-битного ASCII-кодирования. Коэффициент сжатия
равен 2,8 к 1.
Входной файл будет содержать список текстовых сообщений, по одному в строке. Сообщения будут состоять только из больших английских
букв, цифр и символов подчеркивания (вместо пробелов). Конец файла
обозначается строкой END. Эту строку не нужно обрабатывать.
В выходном файле будет содержаться для каждого входного сообщения количество битов в восьмибитовом ASCII-кодировании, количество
битов при оптимальном префиксном кодировании и коэффициент сжатия с
точность до одного знака после десятичной точки.
Пример.
Входные данные
Выходные данные
AAAAABCD
64 13 4.9
THE_CAT_IN_THE_HAT
144 51 2.8
END
Описание решения.
В данной задаче проведем кодирование текста алгоритмом Хаффмана. Отличиями являются представление входных и выходных данных.
Каждую входную строку нужно кодировать по отдельности. Строка
«END» обозначает конец ввода, ее кодировать не нужно.
На выходе нужно указать три числа:
1. длину сообщения в битах при стандартном восьмибитовом кодировании;
2. длину сообщения в битах при выполненном оптимальном кодировании;
3. коэффициент сжатия.
Приведем программную реализацию данной задачи.
#include "stdafx.h"
#include <iostream>
using namespace std;
void InputData(FILE *f);
long MinK();
void SumUp(FILE *f);
void BuildBits();
void OutputData(FILE *f);
void Create();
void Clear();
void Destroy();
int MaxK = 1000;
long *k, *a, *b;
char **bits;
189
char *sk;
bool *Free;
char **res;
long i, j, n, m, kj, kk1, kk2;
char str[256];
int _tmain(int argc, _TCHAR* argv[]){
FILE *in, *out;
in = fopen("input.txt","r");
out = fopen("output.txt","w");
while ( !feof(in) ) {
Create();
Clear();
InputData(in);
cout << str << endl;
SumUp(out);
if (kj != 1) BuildBits();
if (kj != 1) OutputData(out);
Destroy();
}
fclose(out);
fclose(in);
return 0;
}
//описание функции выделения памяти
void Create(){
if ( (k = new long[MaxK + 1]) == NULL ){
printf ("Memory for k no!\n");
system("pause");
exit(0);
}
if ( (a = new long[MaxK + 1]) == NULL ){
printf ("Memory for a no!\n");
system("pause");
exit(0);
}
if ( (b = new long[MaxK + 1]) == NULL ){
printf ("Memory for b no!\n");
system("pause");
exit(0);
}
if ( (bits = new char*[MaxK + 1]) == NULL ){
printf ("Memory for bits no!\n");
system("pause");
exit(0);
}
for (i = 0; i < MaxK + 1 ; i++)
if ( (bits[i] = new char[40]) == NULL ){
190
printf ("Memory for bits[%d] no!\n",i);
system("pause");
exit(0);
}
if ( (sk = new char[MaxK + 1]) == NULL ){
printf ("Memory for sk no!\n");
system("pause");
exit(0);
}
if ( (Free = new bool[MaxK + 1]) == NULL ){
printf ("Memory for Free no!\n");
system("pause");
exit(0);
}
if ( (res = new char*[256]) == NULL ){
printf ("Memory for res no!\n");
system("pause");
exit(0);
}
for (int i = 0; i < 256 ; i++)
if ( (res[i] = new char[40]) == NULL ){
printf ("Memory for res[%d] no!\n",i);
system("pause");
exit(0);
}
}
//описание функции обнуления данных в массивах
void Clear(){
for (i = 0; i < MaxK + 1; i++){
k[i] = a[i] = b[i] = 0;
sk[i] = 0;
Free[i] = true;
for (j = 0; j < 40; j++)
bits[i][j] = 0;
}
for (i = 0; i < 256 ; i++)
for (j = 0; j < 40; j++)
res[i][j] = 0;
}
//описание функции освобождения памяти
void Destroy(){
delete [] res;
delete [] Free;
delete [] sk;
delete [] bits;
delete [] b;
delete [] a;
delete [] k;
191
}
//описание функции ввода данных
void InputData(FILE *f){
char c;
long *s = new long[256];
for ( i = 0; i < 256; i++)
s[i] = 0;
fscanf(f,"%s", str);
if (strcmp(str,"END") == 0) {
system("pause");
exit(0);
}
for ( n = 0; n < strlen(str); n++ ){
c = str[n];
s[c]++;
}
j = 0;
for ( i = 0; i < 256; i++)
if ( s[i] != 0 ){
j++;
k[j] = s[i];
sk[j] = i;
}
kj = j;
}
/*описание функции нахождения минимальной частоты символа
в исходном тексте*/
long MinK(){
long min;
i = 1;
while ( !Free[i] && i < MaxK) i++;
min = k[i];
m = i;
for ( i = m + 1; i <= kk2; i++ )
if ( Free[i] && k[i] < min ){
min = k[i];
m = i;
}
Free[m] = false;
return min;
}
//описание функции посчета суммарной частоты символов
void SumUp(FILE *f){
long s1, s2, m1, m2;
if ( kj == 1 ){
fprintf(f,"%d %d %.1f\n",8*strlen(str),strlen(str),8);
return;
}
192
for ( i = 1; i <= kj; i++ ){
Free[i] = true;
a[i] = 0;
b[i] = 0;
}
kk1 = kk2 = kj;
while (kk1 > 2){
s1 = MinK();
m1 = m;
s2 = MinK();
m2 = m;
kk2++;
k[kk2] = s1 + s2;
a[kk2] = m1;
b[kk2] = m2;
Free[kk2] = true;
kk1--;
}
}
//описание функции формирования префиксных кодов
void BuildBits(){
bits[kk2] = "1";
Free[kk2] = false;
strcpy(bits[a[kk2]],bits[kk2]);
strcat( bits[a[kk2]] , "0");
strcpy(bits[b[kk2]],bits[kk2]);
strcat( bits[b[kk2]] , "1");
i = MinK();
bits[m] = "0";
Free[m] = true;
strcpy(bits[a[m]],bits[m]);
strcat( bits[a[m]] , "0");
strcpy(bits[b[m]],bits[m]);
strcat( bits[b[m]] , "1");
for ( i = kk2 - 1; i > 0; i-- )
if ( !Free[i] ) {
strcpy(bits[a[i]],bits[i]);
strcat( bits[a[i]] , "0");
strcpy(bits[b[i]],bits[i]);
strcat( bits[b[i]] , "1");
}
}
//описание функции вывода данных
void OutputData(FILE *f){
long b8, bh;
for ( i = 1; i <= kj; i++ )
res[sk[i]] = bits[i];
b8 = 8 * strlen(str);
193
bh = 0;
for (i = 0; i < strlen(str); i++)
bh += strlen(res[str[i]]);
double k = b8 * 1.0 / bh;
fprintf(f,"%d %d %.1f\n",b8,bh,k);
}
Ключевые термины
Цифровая (поразрядная) сортировка – это упорядочивание данных по
ключу, которое выполняется отдельно с каждым разрядом с последующим объединением результатов.
Энтропийное кодирование – это метод кодирования данных, который
обеспечивает компрессию данных за счет удаления избыточной информации.
Краткие итоги
1. При решении задач повышенной сложности рекомендовано придерживаться общей схемы решения задач по программированию.
2. При решении различных задач повышенной сложности данные часто
требуется упорядочить по некоторому признаку, а задача может требовать построения оптимального в смысле определенных требований или нестандартного алгоритма сортировки.
3. Многие прикладные задачи и задачи повышенной сложности удобно
сформулировать в терминах такой структуры данных как граф.
4. Существует много разных практических методов сжатия без потери
информации, которые имеют разную эффективность для разных типов данных и разных объемов.
Материалы для практики
Вопросы
1. На каком этапе общей схемы решения задач по программированию
следует определиться с моделью представления данных?
2. В чем заключается преимущества поразрядной сортировки по отношению к быстрым сортировкам?
3. Приведите пример данных, при сортировке которых поразрядная
сортировка трудоемка по времени. Ответ обоснуйте.
4. Приведите другую модель представления данных в задаче о перекатывании тетраэдра.
5. На чем основана однозначность представления данных при шифровании в энтропийном кодировании?
194
Упражнения
1. Наберите коды программ из Примеров 1-3. Откомпилируйте и протестируйте полученные коды.
2. Латинским квадратом порядка n называют квадратную матрицу
размером nn, элементы которой принадлежат множеству
M  1, 2, ..., n, причем каждое число из M встречается ровно один раз
в каждой строке и в каждом столбце. Напишите рекурсивную функцию, которая при заданном натуральном n методом перебора с возвратом подсчитывает количество латинских квадратов
3. Имеется N кубиков разной массы, у которых грани раскрашены в
разные цвета. Необходимо построить максимально высокую башню
из таких кубиков, чтобы выполнялись требования:
 нельзя класть тяжелый кубик на более легкий;
 цвета соприкасающихся граней кубиков должны быть одного цвета.
Входные данные: первая строка файла содержит число N (1< N<500).
Следующие i строк содержат информацию о цветах граней каждого
кубика в таком порядке: передняя, задняя, левая, правая, верхняя,
нижняя (цвета описываются целыми числами от 1 до 100). Считается, что кубики вводятся в порядке увеличения масс.
Выходные данные: высота башни, перечень куликов с их порядковым номерами из входных данных с указанием вида верхней грани в
башне.
Пример входного файла:
10
1 5 10 3 6 5
2 6 7 3 6 9
5 7 3 2 1 9
1 3 3 5 8 10
6 6 2 2 4 4
1 2 3 4 5 6
10 9 8 7 6 5
6 1 2 3 4 7
1 2 3 3 2 1
3 2 1 1 2 3
Пример выходного файла:
8
1 bottom
2 back
3 right
4 left
6 top
8 front
9 front
10 top
195
Литература
1. Ахо А., Хопкрофт Дж., Ульман Д. Структуры данных и алгоритмы.
Уч. пособие / А. Ахо, Дж. Хопкрофт, Д. Ульман. – М.: Вильямс,
2000. – 384 с.
2. Долинский М.С. Решение сложных и олимпиадных задач по программированию: Уч. пос. / М.С. Долинский. – СПб.: Питер, 2006. –
366 с.
3. Кнут Д.Э. Искусство программирования. Том 3. Сортировка и поиск,
3-е изд.: Пер. с англ.: Уч. пос. / Д.Э. Кнут. – М.: Вильямс, 2000. – 832 с.
4. Кормен Т., Леверсон Ч., Ривест Р. Алгоритмы. Построение и анализ /
Т. Кормен, Ч. Леверсон, Р. Ривест. – М.: МЦНМО, 1999. – 960 с.
5. Подбельский, В.В. Программирование на языке Си: учеб. пособие /
В.В. Подбельский, С.С. Фомин. – М.: Финансы и статистика, 2004. –
600 с.
6. Подбельский, В.В. Язык Си++: учеб. пособие / В.В. Подбельский. –
М.: Финансы и статистика, 2005. – 560 с.
7. Порублев И.Н., Ставровский А.Б. Алгоритмы и программы. Решение
олимпиадных задач / И.Н. Порублев, А.Б. Ставровский. – М.: Вильямс, 2007. – 480 c.
8. Сэджвик Р. Фундаментальные алгоритмы на С++. Части 1-4. Анализ,
структуры данных, сортировка, поиск / Р. Сэджвик. – М.: Диасофт,
2001. – 687 с.
9. Сэджвик Р. Фундаментальные алгоритмы на С++. Часть 5. Алгоритмы на графах / Р. Сэджвик. – М.: Диасофт, 2002. – 496 с.
10. Хусаинов Б.С. Структуры и алгоритмы обработки данных. Примеры
на языке Си. Учебное пособие / Б.С. Хусаинов. – М.: Финансы и статистика, 2004. – 464 с.
196
15. Тестовые задания
15.1. Тест по теме «Трудоемкость алгоритмов и рекурсия»
Вариант 1
1. Укажите вид функции временной трудоемкости для следующей
функции в зависимости от размера массива.
void out (int str,int slb, int m[max_x][max_y]){
int i,j;
for (i=0;i<str;i++) {
for (j=0;j<slb;j++)
printf("%4d",m[i][j]);
printf("\n");
}
}
O(n)
O(n 2 )
O(log n)
O(n log n)
2. Разработана рекурсивная функция F(n,k). Определите глубину
рекурсии при вызове F(4,7).
int F(int n, int k){
if(n==1 || k==1) return 1;
if(n<=k) return F (n,n-1)+1;
return F(n,k-1)+ F(n-k,k);
}
3
4
5
7
3. Значение какого выражения возвращает функция Rec(a,x,n),
код которой приведен ниже?
float Rec(float *a, float x, int n){
if(n==0) return a[0];
return a[n]+x*Rec(a,x,n-1);
}
a0xn+a1xn-1+…an-1x+an
anxn+an-1xn-1+…a1x+a0
(a0+a1+…an-1+an)x
an+an-1x+…a1x+a0 x
197
4. Функция Аккермана задана формулой:
n  1, при m  0;

A(m, n)   A(m  1, 1), при m  0, n  0;
 A(m  1, A(m, n  1)), при m  0, n  0.

Найдите А(3, 2).
3
5
29
38
5. Укажите верные высказывания.
количество элементов полных рекурсивных обращений всегда
не меньше глубины рекурсивных вызовов
одни и те же наборы параметров однозначно соответствуют одной вершине дерева рекурсии
у дерева рекурсии может быть пустое множество листьев
объем рекурсии равен количеству вершин полного рекурсивного
дерева без единицы
6. Сколькими способами можно расставить 4 ферзей на доске размера 4×4?
расстановок не существует
1
2
4
7. Укажите методы организации исчерпывающего поиска.
метод пошаговой детализации
перебор с возвратом
метод решета
метод ветвей и границ
8. Укажите опорную схему рекурсивных вычислений, которая способствует уменьшению трудоемкости алгоритма за счет исклюючения несущественных случаев.
увидеть
характеристическое свойство
перенести часть условий в проверку
найти родственника
198
Вариант 2
1. Укажите вид функции временной трудоемкости для следующей
функции в зависимости от параметра n.
float Step(float p, int n){
if (n==0) return 1;
if (n%2==0) return pow(Step(p,n/2),2);
return p*Step(p,n-1);
}
O(n)
O(n 2 )
O(log n)
O(n log n)
2. Разработана рекурсивная функция F(n,k). Определите объем
рекурсии без листьев при вызове F(5,9).
int F(int n, int k){
if(n==1 || k==1) return 1;
if(n<=k) return F (n,n-1)+1;
return F(n,k-1)+ F(n-k,k);
}
4
5
6
7
3. Значение какого выражения возвращает функция Rec(1, n),
код которой приведен ниже?
int Rec(int s,int k){
if(k==0) return s;
return Rec(1+s*k,k-1);
}
1+2+3+…+n
n!
1!+2!+3!+…+n!
s+2s+3s+…+ks
4. Функция Аккермана задана формулой:
n  1, при m  0;

A(m, n)   A(m  1, 1), при m  0, n  0;
 A(m  1, A(m, n  1)), при m  0, n  0.

199
Найдите общее число вершин рекурсивного дерева при вызове
А(2, 1).
2
3
13
14
5. Укажите верные высказывания.
глубина рекурсивных вызовов может превосходить максимальный размер стека
все рекурсивные слои сохраняются в памяти до полного завершения программы
операции открытия и закрытия рекурсивного слоя требуют дополнительных временных затрат
одноименные переменные различных рекурсивных обращений
не накладываются друг на друга
6. Сколько существует основных расстановок 4 ферзей на доске
размером 4×4?
расстановок не существует
1
2
4
7. Какое решение задачи называется частичным?
один из вариантов, соответствующий условию задачи
часть одного из вариантов, соответствующего условию задачи
один из вариантов решения, полностью отличный от остальных
краевые решения задачи
8. Укажите опорную схему рекурсивных вычислений, в которой
возможен переход к задаче большей размерности.
обобщить
увидеть
найти родственника
переформулировать
200
Вариант 3
1. Укажите вид функции временной трудоемкости для следующей
функции в зависимости от параметра n.
float G(float p, int n){
if(n==0) return 1;
return G(p,n-1)*p;
}
O(n)
O(n 2 )
O(log n)
O(n log n)
2. Разработана рекурсивная функция F(n,k). Определите число
листьев рекурсии при вызове F(7,5).
int F(int n, int k){
if(n==1 || k==1) return 1;
if(n<=k) return F (n,n-1)+1;
return F(n,k-1)+ F(n-k,k);
}
8
10
12
16
3. Значение какого выражения возвращает функция Rec(1, 1, n),
код которой приведен ниже?
int Rec(int a,int b,int k){
if(k<2) return b;
return Rec(b,a+b,k-1);
}
1+1+2+3+…+n
1+1+2+2+3+3+…+n+n
сумму n первых чисел последовательности Фибоначчи
n-ый член последовательности Фибоначчи
4. Функция Аккермана задана формулой:
n  1, при m  0;

A(m, n)   A(m  1, 1), при m  0, n  0;
 A(m  1, A(m, n  1)), при m  0, n  0.

201
Найдите объем рекурсии при вызове А(2, 2).
4
18
26
27
5. Укажите верные высказывания.
уменьшить трудоемкость рекурсивного алгоритма невозможно
глубина рекурсивных вызовов ограничена максимальным размером стековой области
база рекурсии содержит тривиальный случай для задачи
трудоемкость рекурсивного алгоритма не зависит от количества
параметров функции
6. Сколько существует расстановок 5 ферзей на доске размера 5×5,
при которой один из ферзей занимает центр доски?
расстановок не существует
1
2
5
7. Какой способ реализации рекурсивных вычислений относится к
возвратной рекурсии?
соединение рекурсии с динамической базой
организация косвенной рекурсии
соединение метода перебора с возвратом и рекурсии
организация отслеживания рекурсивных возвращений
8. Укажите опорную схему рекурсивных вычислений, в которой совокупность всех или части условий любой задачи оформлена в виде
некоторого предиката.
увидеть
характеристическое свойство
перенести часть условий в проверку
найти родственника
202
15.2. Тест по теме «Алгоритмы поиска, хеширования и сжатия данных»
Вариант 1
1. Дана последовательность чисел: 2, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 8. Нумерация элементов начинается с нуля. Элемент с каким номером
будет найден методом бинарного поиска по ключу key=5?
7
8
9
11
2. Дан программный код. Какое значение возвращает функция
Search?
int Search(int *x, int k, int key){
int i;
for (i = k-1; i >=0 ; i--)
if ( x[i] == key )
break;
return i > 0 ? i : -1;
}
значение минимального элемента массива
номер последнего минимального элемента массива
номер последнего элемента, совпадающего с ключом поиска
номер первого элемента, совпадающего с ключом поиска
3. Размер хеш-таблицы HashTableSize =7. Определите хеш-коды
для первых пяти простых чисел, сформированные функцией Hash.
int Hash(int Key, int HashTableSize) {
return Key % HashTableSize;
}
1, 2, 3, 5, 0
2, 3, 5, 0, 4
0, 1, 2, 3, 4
2, 3, 5, 7, 4
4. Технология данного метода хеширования состоит в том, что элементы множества, которым соответствует одно и то же хешзначение, связываются в цепочку-список. О каком методе хеширования идет речь?
203
открытое хеширование
закрытое хеширование
таблица прямого доступа
повторное хеширование
5. Укажите, на какую позицию произойдет второе смещение начала
подстроки при поиске в тексте по алгоритму Кнута, Морриса и
Пратта. Строка: АВСКВАВСМКВ, подстрока: ВСМ. Нумерация в
строке начинается с нуля.
6
4
1
0
6. Дано случайное дерево поиска. Укажите примеры входных последовательностей, которые могли бы сформировать данное дерево.
1, 2, 6, 8, 3, 4
1, 2, 3, 6, 8, 4
1, 2, 6, 3, 8, 4
1, 2, 6, 8, 4, 3
7. Дана частотность появления символов в тексте. Выполните кодирование символов методом Хаффмана. Укажите код символа ‘е’.
Считать, что очередной бит кода начинает формироваться с единицы.
a
b
c
d
e
0,4
0,15 0,22 0,05 0,18
10
110
111
1110
8. Определите коэффициент сжатия текста «abcaabbaac», к которому применено сжатие по методу Хаффмана. Размер входной последовательности на 1 байт больше ее длины.
88/15
88/3
4
8
204
Вариант 2
1. Дана последовательность чисел: 2, 3, 4, 4, 4, 4, 4, 5, 5, 5, 5, 6, 7, 7, 8,
8, 8, 8. Нумерация элементов начинается с нуля. Элемент с каким
номером будет найден методом бинарного поиска по ключу key=8?
14
15
16
17
2. Дан программный код. Какое значение возвращает функция
Search?
int Search(int *x, int k, int key){
x = (int *)realloc(x,(k+1)*sizeof(int));
x[k] = key;
int i = 0;
while ( x[i] != key )
i++;
return i < k ? i : -1;
}
значение максимального элемента массива
номер последнего максимального элемента массива
номер последнего элемента, совпадающего с ключом поиска
номер первого элемента, совпадающего с ключом поиска
3. Хеш-таблица формируется методом середин квадратов. Определите хеш-коды для первых пяти двузначных простых чисел, сформированные функцией Hash.
int Hash(int Key) {
return ((Key*Key)/10)%10 ;
}
2, 6, 8, 6, 2
1, 1, 2, 3, 5
12, 16, 28, 36, 52
1, 9, 9, 1, 9
4. При данном методе хешировании в хеш-таблице хранятся непосредственно сами элементы, а не заголовки списков элементов. Поэтому в каждой записи (сегменте) может храниться только один
элемент. О каком методе хеширования идет речь?
открытое хеширование
закрытое хеширование
205
таблица прямого доступа
повторное хеширование
5. Составьте таблицу смещений при поиске подстроки в строке по
алгоритму Бойера и Мура. Строка: АВВОМРАВАВМАВ, подстрока: ВАВ. Нумерация в строке начинается с нуля.
1, 2, 7
0, 1, 2, 7
0, 1, 4, 5, 6, 7
0, 1, 2, 3, 4, 5, 6, 7
6. На схеме показано вращение АВЛ-дерева. Определите вид вращения.
a
c
b
b
a
P
c
S
S
Q
Q
R
P
R
малое правое
большое правое
малое левое
большое левое
7. Дана частотность появления символов в тексте. Выполните кодирование символов методом Хаффмана. Укажите среднюю длину
кодового слова, которая равна сумме произведений вероятности на
длину кода каждого символа соответственно. Считать, что очередной бит кода начинает формироваться с единицы.
a
b
c
d
e
0,4
0,15 0,22 0,05 0,18
4
3
2,8
2,14
206
8. Выполните кодирование текста «abcaabbaac», к которому применено сжатие по методу Хаффмана. Считать, что очередной бит кода
начинает формироваться с единицы.
00011000000101000010
101001101011100
100111010010010
101111001111
207
Вариант 3
1. Дана последовательность n вещественных чисел. Необходимо
найти число по ключу key с точностью e алгоритмом бинарного поиска. Оцените время выполнения алгоритма.
O(n)
O(1  log n)
O ( n / e)
O(log1/ e)
2. Дан программный код. Какое значение возвращает функция
Search?
int Search(int *x, int k, int key){
bool found = false;
int high = k - 1, low = 0;
int middle = (high + low) / 2;
while ( !found && high >= low ){
if (key == x[middle])
found = true;
else if (key < x[middle])
high = middle - 1;
else
low = middle + 1;
middle = (high + low) / 2;
}
return found ? middle : -1 ;
}
значение среднего элемента массива
номер последнего элемента, совпадающего с ключом поиска
номер первого элемента, совпадающего с ключом поиска
номер элемента, совпадающего с ключом поиска
3. Хеш-таблица формируется методом поразрядного сложения двузначных представлений цифр числа с последующим переводом результата в десятичное число. Определите хеш-коды для первых пяти двузначных составных чисел, сформированные функцией хеширования.
1, 1, 1, 1, 1
1, 3, 5, 6, 7
0, 2, 4, 5, 6
1, 3, 5, 5, 7
208
4. Если осуществляется попытка поместить элемент х в сегмент с
номером hx  , который уже занят другим элементом, то в соответствии с данной методикой выбирается последовательность других
номеров сегментов h1x , h2x , ... , куда можно поместить элемент х.
Каждое из этих местоположений последовательно проверяется, пока не будет найдено свободное. О какой методике хеширования
идет речь?
открытое хеширование
метод остатков от деления
таблица прямого доступа
повторное хеширование
5. Укажите, на какую позицию произойдет пятое смещение начала
подстроки при поиске в тексте по алгоритму Бойера и Мура. Строка: АВСССКВАВСМВВВК, подстрока: ВСМ. Нумерация в строке
начинается с нуля.
4
6
11
подстрока будет найдена меньше, чем за 5 смещений
6. Дано упорядоченное бинарное дерево. Укажите позицию вставки
элемента с ключом 3 в это дерево, чтобы соблюдалась балансировка дерева.
7
4
2
8
6
9
5
левый потомок 8
правый потомок 6
левый потомок 2
правый потомок 2
209
7. Дана частотность появления символов в тексте. Выполните кодирование символов методом Хаффмана. Укажите длину кода символа ‘b’. Считать, что очередной бит кода начинает формироваться
с единицы.
a
b
c
d
e
0,4
0,15 0,22 0,05 0,18
1
2
3
4
8. Дано кодовое дерево. Каким из представленных строк оно соответствует?
c
a
w
aaccwwcc
aaaaccwwss
aaaaccccccwwssss
caws
210
s
15.3. Тест по теме «Алгоритмы сортировки массивов. Алгоритмы на графах»
Вариант 1
1. Укажите последовательности, которые являются бинарными пирамидами.
8, 4, 7, 3, 1, 5, 2, 2, 0
8, 7, 4, 3, 1, 5, 2, 2, 0
8, 5, 7, 4, 3, 3, 2, 2, 3
8, 5, 7, 4, 6, 3, 2, 2, 3
2. В вершину пирамиды помещен элемент. На какой позиции он
остановится в результате спуска вниз? Нумерация элементов начинается с нуля.
1
9
2
8
1
3
0
2
1
3
4
7
3. Дан массив элементов: 4, 7, 3, 8, 5, 6, 3, 7, 2, 6, 8. Укажите порядок
элементов этого массива после выполнения первого прохода сортировки Хоара по невозрастанию. Опорный элемент расположен на
средней позиции.
8, 8, 7, 7, 6, 6, 5, 4, 3, 3, 2
8, 7, 5, 4, 3, 6, 8, 7, 6, 3, 2
8, 7, 7, 8, 6, 5, 3, 3, 2, 6, 4
7, 4, 8, 3, 6, 5, 7, 3, 6, 2, 8
211
4. Дан массив элементов: 4, 7, 9, 0, 3, 2, 6, 8, 7. Укажите порядок
элементов этого массива после выполнения одного прохода сортировки Шелла по неубыванию с шагом h=4.
4, 7, 0, 9, 2, 3, 6, 8, 7
3, 2, 6, 0, 4, 7, 9, 8, 7
0, 2, 3, 4, 6, 7, 7, 8, 9
0, 4, 7, 9, 2, 3, 6, 8, 7
5. В алгоритме внешней сортировки используется три вспомогательных файла и отдельно реализуются распределение и слияние.
Определите характеристики такой сортировки.
однофазная
двухфазная
двухпутевая
многопутевая
6. Во входном файле дан массив чисел:
5 6 9 3 2 3 4 5 4 7 8 6 0
Выполните первое распределение входных данных по двум вспомогательным файлам f1 и f2, используя сортировку по неубыванию
естественным слиянием.
f1: 5 6 9 3 4 7 8 6
f2: 2 3 4 5 0
f1: 5 6 9 2 3 4 5 6
f2: 3 4 7 8 0
f1: 5 6 2 3 4 7 0
f2: 9 3 4 5 8 6
f1: 5 6 9 4, 5 4 0
f2: 3 2 3 7 8 6
7. Укажите порядок вершин при обходе графа в ширину, начиная с
вершины 1.
1
7
2
5
8
3
4
12573468
12345678
12735846
12457368
212
6
8. Дано описание алгоритма поиска кратчайшего пути на графе.
«Алгоритм находит кратчайший путь из данной вершины до
остальных вершин. Построим множество S вершин, для которых
кратчайшие пути от начальной вершины уже известны. На каждом
шаге к множеству S добавляется та из оставшихся вершин, расстояние до которой от начальной вершины меньше, чем для других
оставшихся вершин.» Укажите название алгоритма.
алгоритм Дейкстры
алгоритм Флойда
волновой алгоритм
алгоритм перебора с возвратом
213
Вариант 2
1. Укажите последовательности, которые не являются бинарными
пирамидами.
7, 5, 7, 4, 2, 6, 5, 3, 2
7, 5, 7, 4, 6, 2, 5, 3, 2
8, 6, 8, 3, 0, 7, 6, 1, 3
8, 6, 8, 3, 0, 7, 6, 1, 3, 1
2. В вершину пирамиды помещен элемент. На какой позиции он
остановится в результате спуска вниз? Нумерация элементов начинается с нуля.
2
7
7
1
8
5
3
1
3
2
5
6
8
3. Дан массив элементов: 4, 7, 3, 8, 5, 6, 3, 7, 2, 6, 8. Укажите порядок
элементов этого массива после выполнения второго прохода сортировки Хоара по неубыванию. Опорный элемент расположен на
средней позиции.
3, 2, 4, 3, 5, 6, 6, 7, 7, 8, 8
2, 3, 3, 4, 5, 6, 6, 7, 7, 8, 8
4, 3, 3, 2, 5, 6, 7, 7, 8, 6, 8
3, 4, 5, 7, 8, 2, 3, 6, 6, 7, 8
4. Дан массив элементов: 4, 7, 3, 0, 3, 2, 6, 8, 7, 2, 6, 4. Укажите порядок элементов этого массива после выполнения одного прохода
сортировки Шелла по невозрастанию с шагом h=6.
214
7, 4, 3, 0, 3, 2, 8, 6, 7, 2, 6, 4
8, 7, 7, 6, 6, 4, 4, 3, 3, 2, 2, 0
8, 7, 6, 6, 4, 2, 7, 4, 3, 3, 2, 0
6, 8, 7, 2, 6, 4, 4, 7, 3, 0, 3, 2
5. В алгоритме внешней сортировки используется два вспомогательных файла и совмещены распределение и слияние. Определите
характеристики такой сортировки.
однофазная
двухфазная
двухпутевая
многопутевая
6. После распределения по двум файлам были получены данные
(серии разделены апострофом).
f1: 3 7 2 8 5 9 1 3
f2: 6 9 3 5 7 7
Выполните слияние этих результатов в один файл согласно алгоритму простой сортировки по неубыванию.
37692835597713
37286935591377
37285913693577
36792358577913
7. Укажите порядок вершин при обходе графа в глубину, начиная с
вершины 1.
1
7
2
5
8
3
6
4
12457368
12346578
1234645178
12346578
215
8. Дано описание алгоритма поиска кратчайшего пути на графе.
«Алгоритм находит кратчайшее расстояние между двумя любыми
вершинами графа на основании факта о том, что всякий неэлементарный кратчайший путь состоит из других кратчайших путей.»
Укажите название алгоритма.
алгоритм Дейкстры
алгоритм Флойда
волновой алгоритм
алгоритм перебора с возвратом
216
Вариант 3
1. Укажите последовательности, которые являются бинарными пирамидами.
9, 6, 8, 3, 5, 7, 4, 2, 1, 4
9, 5, 8, 3, 4, 7, 4, 2, 1, 6
9, 6, 6, 2, 5, 8, 7, 0, 1, 3
9, 6, 8, 2, 5, 6, 7 0, 1, 3
2. В вершину пирамиды помещен элемент. На какой позиции он
остановится в результате спуска вниз? Нумерация элементов начинается с нуля.
2
9
4
1
6
1
3
0
2
8
4
3
2
3. Дан массив элементов: 7, 9, 0, 3, 2, 4, 7, 6, 5, 2, 0. Укажите порядок
элементов этого массива после выполнения второго прохода сортировки Хоара по невозрастанию. Опорный элемент расположен на
средней позиции.
9, 7, 7, 6, 5, 4, 3, 2, 2, 0, 0
7, 9, 5, 6, 7, 4, 2, 3, 0, 2, 0
7, 9, 7, 6, 5, 4, 2, 3, 2, 0, 0
7, 9, 5, 6, 7, 4, 2, 3, 0, 2, 0
4. Дан массив элементов: 5, 0, 6, 4, 9, 7, 9, 2, 1, 0. Укажите порядок
элементов этого массива после выполнения одного прохода сортировки Шелла по неубыванию с шагом h=5.
0, 4, 5, 6, 9, 0, 1, 2, 7, 9
0, 5, 4, 6, 7, 9, 2, 9, 0, 1
5, 0, 2, 1, 0, 7, 9, 6, 4, 9
5, 0, 0, 2, 1, 7, 9, 9, 6, 4
217
5. В алгоритме внешней сортировки используется два вспомогательных файла и отдельно реализуются распределение и слияние.
Определите характеристики такой сортировки.
однофазная
двухфазная
двухпутевая
многопутевая
6. Во входном файле дан массив чисел:
5 6 9 3 2 3 4 5 4 7 8 6 0
Выполните первое распределение входных данных по двум вспомогательным файлам f1 и f2, используя сортировку по невозрастанию
естественным слиянием.
f1: 5 6 2 3 4 7 0
f2: 9 3 4 5 8 6
f1: 9 8 7 6 6 5 5
f2: 4 4 3 3 2 0
f1: 5 9 3 2 4 7
f2: 6 3 5, 4 8 6 0
f1: 9 6 5 4 3 3 2
f2: 8 7 6 5 4 0
7. Укажите порядок вершин при обходе графа в ширину, начиная с
вершины 5.
1
7
2
5
8
3
51472368
51784362
51782436
51478236
6
4
8. Дано описание алгоритма поиска кратчайшего пути на графе.
«Алгоритм находит оптимальное решение задачи о кратчайшем
пути на графе методом проб и ошибок (попробуем сходить в эту
сторону: не получится – вернемся и попробуем в другую).» Укажите
название алгоритма.
алгоритм Дейкстры
алгоритм Флойда
волновой алгоритм
алгоритм перебора с возвратом
218
Учебное издание
ВАНЫКИНА Галина Владиславовна
СУНДУКОВА Татьяна Олеговна
АЛГОРИТМЫ
КОМПЬЮТЕРНОЙ ОБРАБОТКИ ДАННЫХ
Учебное пособие
219
Скачать