Вы находитесь на странице: 1из 51

ВВЕДЕНИЕ

Методические указания разработаны для поддержки курса «Алгоритмы и структуры


данных» и содержат 18 лабораторно-практических работ, которые предназначены для
приобретения умений и навыков по разработке эффективных алгоритмов и структур
данных для решения различных типов задач, а также по анализу алгоритмов.
Следует понимать, что полноценное освоение предмета невозможно без серьезной
самостоятельной работы. Поэтому в дополнение к практикуму студенты выполняют
курсовой проект, который состоит в разработке полноценного приложения с графическим
интерфейсом, демонстрирующего работу выбранного алгоритма или структуры данных.
Тематика заданий курсовых работ может быть несколько шире, чем рассматриваемый в
практикуме материал, и требует самостоятельной работы с литературой.
Для выполнения многих самостоятельных заданий используются web-архивы задач по
программированию со встроенной автоматической проверкой решений.

o Лабораторно-практическая работа 1
Алгоритмы сортировки данных
Сортировкой последовательности называется расположение элементов этой
последовательности согласно определённому линейному отношению порядка. Наиболее
привычным является отношение "", в этом случае говорят о сортировке по неубыванию.
Существует большое число различных методов сортировки. В данной лабораторной
работе рассматривается два метода – сортировка простыми вставками и "быстрая"
сортировка (метод Хоара).
Сортировка вставками относится к целому классу методов, которые чрезвычайно
просты в понимании и реализации, однако, имеют более высокий порядок сложности, чем
более совершенные алгоритмы.
Пусть первые (i-1) элементов последовательности уже отсортированы. На очередном
шаге возьмём элемент, стоящий на i-м месте, и поместим его в нужное место
отсортированной части последовательности. Будем делать так до тех пор, пока вся
последовательность не окажется отсортированной.
Один из способов вставки элемента будет выглядеть так. Запомним i-й элемент в
переменной t. Будем двигаться от (i-1)-го элемента к началу массива, сдвигая элементы на 1
вправо, пока они больше t. Наконец, поместим элемент t на освободившееся место.
Иллюстрация показана на рисунке 1.

Рис. 1. Принцип работы сортировки вставками


Быстрая сортировка Хоара является реализацией принципа “разделяй и властвуй”.
Элементы сортируемого массива переставляются так, чтобы массив условно разделился на
две части – левую и правую, причём никакой элемент из левой части не должен
превосходить никакого элемента из правой.
Рис.2. Принцип работы быстрой сортировки Хоара
Для получения такого разбиения можно действовать следующим образом. Пусть дан
массив a[p..r]. Элемент m=a[(p+r) div 2] выбирается в качестве граничного (опорного).
Разбиение производится следующим образом. Двигаясь от начала массива к концу,
найдём элемент  x. Затем, двигаясь от конца к началу, найдём элемент  x. Поменяем эти
элементы местами. Будем продолжать действовать таким образом, пока не встретимся где-
то внутри массива.
После этого процедура сортировки вызывается рекурсивно для левой и правой части, в
результате чего массив будет отсортирован.
Пример программной реализации приведён ниже.

Type TArray = array [1..N] of integer;

procedure Qsort(var a: TArray; left, right : integer);


var m, i, j, t : integer;
begin
m := a[(left+right) div 2];
i := left; j := right;
repeat
while a[i] < m do inc(i);
while a[j] > m do dec(j);
if i <= j then
begin
t := a[i]; a[i] := a[j]; a[j] := t;
inc(i); dec(j);
end;
until i > j;

if j > left then Qsort(a, left, j);


if i < right then Qsort(a, i, right);
end;

var
a: TArray;

begin
...
Qsort(a, 1, N);
end.
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №1 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

o Лабораторно-практическая работа 2
Двоичный поиск

2
Пусть дан упорядоченный по неубыванию массив a[1..n], и требуется найти в нём
позицию элемента k (если их несколько, то любую). Это можно эффективно сделать
следующим образом. Вначале положим left := n (левая граница), right := n (правая граница).
Далее в цикле будем делать следующее. Возьмём элемент из середины массива
a[(left+right) div 2] и сравним его с x. Если элементы равны, то поиск закончен. Если x
меньше элемента из середины, то очевидно, что ответ может быть только левее (ведь
массив отсортирован), а в противном случае - только правее. То есть мы можем отбросить
половину массива из рассмотрения и продолжить поиск в оставшейся части, используя те
же самые рассуждения. Описанный алгоритм несложно реализовать программно:

const
NMAX = 100000;

type
TKey = longint;
TArray = array[1..NMAX] of TKey;

// функция bsearch возвращает позицию элемента k в массиве a длины n


// если таких элементов несколько, возвращается позиция любого из них
// если таких элементов нет, то функция вернёт -1
function bsearch(const a: TArray; n: longint; k: TKey): longint;
var
m: TKey;
left, right: longint;
begin
left := 1; right := n;
while left <= right do begin
m := (left + right) div 2;
if a[m] = k then begin
bsearch := m;
exit;
end;
if k < a[m] then
right := m - 1
else
left := m + 1;
end;
bsearch := -1;
end;

Выполним оценку вычислительной сложности. Пусть изначально размер массива равен


N элементов. На каждой итерации цикла размер уменьшается примерно вдвое, пока не
останется один элемент. Нам нужно найти число итераций. Несложно получить
следующую формулу: T(N) = log2(N), то есть асимптотическая сложность составляет
O(log(N)).
В качестве слегка более сложного примера рассмотрим похожую задачу – найти в
массиве самое маленькое число, большее или равное k (так называемую нижнюю границу).
Отличие от предыдущей реализации в том, что, найдя очередное значение >= k, мы
запоминаем его, но не останавливаем поиск (так как могут найтись элементы и левее этого).
Пример реализации:

const NMAX = 100000;

type
TKey = longint;
TArray = array[1..NMAX] of TKey;

3
// функция возвращает позицию наименьшего элемента, который >= k
// если таких элементов нет, то функция вернёт -1
// n - длина массива a
function lower_bound(const a:TArray; n:longint; k:TKey):longint;
var
m: TKey;
left, right, answer: longint;
begin
answer := -1;
left := 1; right := n;
while left <= right do begin
m := (left + right) div 2;
if a[m] >= k then begin
answer := m;
right := m - 1;
end else
left := m + 1;
end;
lower_bound := answer;
end;

Метод бинарного поиска может применяться и для других целей – например, для
численного решения уравнений. Пусть дана непрерывная строго монотонная функция f(x),
которая на отрезке [a, b] пересекается с осью OX. Требуется найти точку пересечения, то
есть решить уравнение f(x)=0.
Пусть c = (a+b)/2 - середина отрезка [a, b]. Вычислим f(c). Если f(a)·f(c)>0, т.е. значения
функции на концах отрезка [a, c] одинаковы по знаку. Тогда корень уравнения находится на
отрезке [c, b], и отрезок [a, c] можно исключить из дальнейшего рассмотрения, перенеся
точку a в точку c. В противном случае точка b переносится в точку c. Описанный способ
иллюстрируется следующим рисунком [11]

Рис. 3. Решение уравнения f(x)=0 методом дихотомии

Процесс прекращается, когда длина отрезка [a, b] становится меньше требуемой


точности.

Самостоятельные задания
1. Решить не менее трёх задач из лабораторной работы №2 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

4
o Лабораторно-практическая работа 3
Стеки и очереди
Стек представляет собой структуру данных – контейнер, в котором данные помещаются
и извлекаются с одного и того же конца (вершины стека – англ. top), выполняется принцип
LIFO (last in – first out), то есть помещенный последним элемент извлечён будет первым.

Рис. 10. Схема работы стека


Очередь представляет собой структуру данных – контейнер, в котором данные
помещаются с одного конца (англ. back), а извлекаются с другого (голова очереди – англ.
front), выполняется принцип FIFO (first in – first out), то есть помещенный первым элемент
извлечён будет также первым.

Рис. 11. Схема работы очереди


Рассмотрим возможные способы стеков и очередей. Если максимально возможное
количество элементов в стеке реально ограничено каким-либо значением, можно
предложить самый простой способ реализации — массив. В этом случае для стека
выделяется непрерывная область памяти ограниченных размеров. Рис. 12 поясняет работу
со стеком на основе массива. Идея проста — достаточно завести дополнительный индекс
top, ссылающийся на вершину стека. Если стек пуст, то top может хранить значение 0.

Рис. 12. Представление стека с помощью массива

Алгоритмы для реализации операций удаления и вставки элементов просты и сводятся к


изменению значения указателя top на единицу. При этом, конечно, нельзя забыть о крайних
случаях, связанных с добавлением элемента в переполненный стек или попыткой
извлечения элемента из пустого стека.
В случае, когда максимальное число элементов в стеке заранее неизвестно, его можно
реализовать с помощью односвязного списка. Данный способ иллюстрирует рисунок 13.

Рис. 13. Представление стека с помощью списка

5
Рассмотрим пример применения стека. Стек используется в большом числе алгоритмов.
Одним из них является вычисление выражений в постфиксной записи. Смысл постфиксной
(обратной польской) записи арифметического выражения состоит в том, что вначале
пишутся аргументы, а после них – знак операции. Например, запись "2 3 + 4 *" означает,
что нужно сложить 2 и 3, затем полученный результат умножить на 4. Плюсы постфиксной
записи – не требуется скобки, удобство машинного вычисления.
Посмотрим, каким образом вычисляется выражение в постфиксной записи с
применением стека. Алгоритм выглядит так. Берём очередную входную лексему. Если это
число, то кладём его в стек. Если же это знак операции, то извлекаем два элемента из стека,
выполняем операцию, результат кладём в стек. По окончании входных данных на вершине
стека будет лежать ответ.
Реализация очереди при помощи массива немного сложнее, чем реализация стека.
Вспомним, что вставка и удаление элементов выполняются с разных концов. Поэтому
используют два индекса head и tail, ссылающиеся на голову и хвост очереди.
При вставках и удалениях элементов очередь как бы передвигается по массиву,
постепенно приближаясь к его границе. При этом в начале массива появляется свободное
пространство за счет освободившихся при удалении элементов. Как только хвост очереди
достигнет верхней границы массива, следующие элементы будут добавляться уже в
свободные ячейки в начале массива. Такую реализацию очереди называют кольцевой или
циклической.

Рис. 14. Положение головы и хвоста очереди


в разные моменты времени

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

6
Рис. 15. Реализация очереди на односвязном списке
Очереди, как и стеки, используются в программировании весьма широко — например,
очередь сообщений в операционной системе Windows, очередь сетевых пакетов в
маршрутизаторе и др.
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №3 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

o Лабораторно-практическая работа 4
Связные списки
Структура данных (в узком смысле) – это способ представления данных и связей
между данными в памяти машины. В динамической структуре данные и связи могут
создаваться и изменяться динамически, то есть по ходу работы программы.
Чаще всего элементами данных являются записи (record), а связи между записями
реализуются с помощью указателей.
Простейшим вариантом динамической структуры данных является односвязный список
– см. рисунок.

Рис. 4. Связный список

Список состоит из некоторого количества записей, где каждая предыдущая с помощью


указателя ссылается на следующую. В последней записи указатель содержит значение NIL
(нулевой адрес).
Для получения доступа к элементам списка создана переменная-запись list, содержащая
3 указателя: head, tail и cur, где:
o head – адрес первого элемента списка
o tail – адрес последнего элемента списка
o cur – адрес текущего элемента списка
Подобная структура находит применение в достаточно многих задачах. В данной
лабораторной работе вы выполните её машинную реализацию и примените для решения
задач в проверяющей системе.

7
Часть 1. Объявление типов, создание и инициализация переменных.
Создайте в Delphi (или другой версии языка Pascal) новое консольное приложение,
сохраните проект.
Вначале объявим типы данных. Нам понадобится 2 типа записей – один тип для
элементов списка и один для переменной list.
Теперь разберёмся с элементом списка. Эта запись должна иметь 2 поля – v (значение) и
next – адрес следующего. Её тип создаётся следующим образом:

type
PElem = ^TElem;
TElem = record
v: integer;
next: PElem;
end;

Запись для переменной list должна просто содержать 3 указателя – head, cur и tail:

TLinkList = record
head, cur, tail: PElem;
end;

Нам потребуется пока один список, поэтому создадим одну глобальную переменную
list:

var
list: TLinkList;

Delphi не гарантирует инициализацию глобальных переменных нулями, и поля


переменной list при запуске программы теоретически могут содержать какие-то случайные
адреса. Поэтому сразу после основного begin вставьте следующую инициализацию:

list.head:=nil; list.tail:=nil; list.cur := nil;

Часть 2. Разработка процедур и функций, реализующих типовые действия над


списком.
Типичные действия над списком включают:
- вставить новый элемент после текущего
- удалить элемент, стоящий после текущего
- сделать текущим элементом тот, что стоит после текущего (т.е. сдвинуться к
следующему)
Рассмотрим процедуру вставки нового элемента. Будем передавать процедуре 2
аргумента – список, в который будем вставлять, и вставляемое значение. Для
универсальности напишем процедуру так, что если cur=NIL (то есть текущего элемента
нет), то вставка пойдёт в начало списка. Приведём код процедуры.

//вставить элемент после текущего (если cur=nil, то в начало списка)


procedure ins(var list: TLinkList; v: integer);
var
p: PElem;
begin
with list do begin
new(p);
p^.v:=v;

8
if cur=nil then begin //выполняем вставку в начало списка
p^.next := head;
head := p;
if tail=nil then tail:=p; //это если список был пустой
exit;
end;
//вставка после текущего элемента
p^.next := cur^.next;
cur^.next := p;
if p^.next = nil then tail:=p; //это если вставляли после последнего
end;
end;

Теперь рассмотрим процедуру удаления элемента после текущего. Процедуре


передаётся один аргумент – список, из которого удаляем. Для большей универсальности
напишем процедуру так, что если cur=NIL (то есть текущего элемента нет), то удалится
первый элемент списка. Приведём код процедуры.

//удалить элемент после текущего (а если cur=nil, то первый)


procedure del(var list: TLinkList);
var
p: PElem;
begin
with list do begin
if head=nil then exit; //нечего удалять, список пуст
if cur=nil then begin //выполняем удаление первого элемента
p:=head;
head:=head^.next;
dispose(p);
if head=nil then tail:=nil;
exit;
end;
//удаление элемента за текущим
if cur^.next=nil then exit; //нечего удалять
p:=cur^.next;
cur^.next:=p^.next;
dispose(p);
if cur^.next=nil then tail:=cur;
end;
end;
Для лучшего понимания того, как работают структуры данных на указателях (в том числе
связные списки), полезно использовать схематические рисунки того, какие переменные у
нас используются, что они содержат и как изменяются на каждом шаге. Для примера
проиллюстрируем работы процедуры ins для случая вставки в пустой список:

Рис.5. Состояние переменных при входе в процедуру ins

9
Рис.6. Состояние переменных после строк new(p); p^.v:=v;

Рис.7. Состояние переменных после строки p^.next := head;

Рис.8. Состояние переменных после строк


head := p; if tail=nil then tail:=p;

Наконец, по команде exit вышли из процедуры, при этом локальная переменная p и


переменная-параметр v уничтожились. Конечный результат:

Рис. 9. Связный список после выхода из процедуры ins


Самостоятельные задания
1. Решить не менее трёх задач из лабораторной работы №4 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений
3. В отчёте проиллюстрируйте (см. пример выше), как работает процедура ins в случае,
когда list.cur не равно NIL.

10
o Лабораторно-практическая работа 5
Бинарные деревья поиска
Примером иерархической структуры является бинарное поисковое дерево – см.
рисунок. Каждый узел данного дерева имеет от 0 до 2-х детей (левого сына и правого
сына). Значения (числа v) в узлах дерева расположены так, что для любого узла r
выполняется следующее требование: значения во всех узлах левого поддерева меньше, чем
значение в r, а значения в узлах правого поддерева – больше, чем в r.Такое дерево
применяется для представления множеств в памяти ЭВМ. Рассмотрим особенности
машинной реализации.

Рис. 16. Двоичное дерево


поиска

Типы данных и инициализация переменных. Очевидно, что элементом дерева будет


запись из трех полей – одно числовое и 2 указателя. Скелет программы на данном этапе
будет выглядеть так:

type
PNode = ^TNode;
TNode = record
v: integer;
left, right: PNode;
end;

var
root: PNode;

begin
root:=NIL;
end.

Основные операции. Вставка элемента x во множество начинается с корневого узла


дерева (его адрес записан в root). Сравниваем x со значением v в этом узле. Если они равны,
то мы нашли x и делать ничего не надо – выходим из процедуры. Если x меньше v, то
переходим по левому указателю (left), если больше – по правому (right). Продолжаем делать
так до тех пор, пока указатель, по которому надо переходить, не окажется NIL. В этом
случае создаём новый узел и его адрес помещаем в этот указатель.
Не следует забывать ещё один нюанс. Если элемент вставляется в пустое дерево, то
адрес созданного узла нужно записать в переменную root.
Приведём пример реализации без использования рекурсии.

11
//вставка элемента в двоичное дерево
//root - адрес корневого узла, x - вставляемый элемент
procedure ins(var root: PNode; x: integer);
var
r: PNode; //адрес текущего узла дерева
begin
//вначале проверим частный случай, когда дерево пусто
if root=NIL then begin
new(root);
root^.left:=NIL; root^.right:=NIL; root^.v:=x;
exit;
end;
r:=root; //начинаем спускаться от корня
repeat
if r^.v=x then exit; //элемент уже есть в дереве
if x<r^.v then //надо идти влево
begin
if r^.left<>NIL then
r:=r^.left //узел слева есть - идём в него
else
begin
//слева ничего нет - создаём новый узел
new(r^.left);
r:=r^.left;
r^.left:=NIL; r^.right:=NIL; r^.v:=x;
exit;
end
end
else
begin //надо идти вправо
if r^.right<>NIL then
r:=r^.right //узел справа есть - идём в него
else
begin
//справа ничего нет - создаём новый узел
new(r^.right);
r:=r^.right;
r^.left:=NIL; r^.right:=NIL; r^.v:=x;
exit;
end;
end;
until false;
end;

Удаление – несколько более сложная процедура, чем вставка. Пусть нам надо удалить
элемент x. Найдём узел r, который его содержит. Если такого узла найти не удалось, то и
делать ничего не надо.
Если у узла r нет сыновей, то всё хорошо: уничтожаем узел и пишем NIL в указатель,
который на него ссылался.
Если у узла только один сын, то тоже всё понятно: уничтожаем узел, а в указатель,
который на него ссылался, заносим адрес сына.
Наконец, что делать, если у удаляемого узла r два сына? В этом случае нам надо найти в
его правом поддереве узел с самым маленьким элементом. Это несложно: берём правого
сына и идём от него влево, пока не найдём узел q, у которого левого сына нет. Он и будет
искомым. Теперь значение из q запишем в узел r, а удалять будем уже не r, а q. Как это
делается, см. выше.

12
Приведём пример реализации (для удобства он сделан в виде рекурсивной процедуры.
Для экономии места в стеке внутренняя рекурсивная процедура вложена во внешнюю и
используется её параметр x и локальные переменные):

//Удалить элемент x из двоичного дерева


//root-корень дерева, x - удаляемый элемент
procedure del(var root: PNode; x: Integer);
var
p,q: PNode;

//удалить элемент x из поддерева с корнем в r


procedure del_rec(var r: PNode);
begin
if r=NIL then exit; //элемента x в множестве не нашлось
if x<r^.v then begin del_rec(r^.left); exit; end;
if x>r^.v then begin del_rec(r^.right); exit; end;
//если у узла 0 или 1 ребёнок
if (r^.left=NIL) or (r^.right=NIL) then
begin
p:=r; //запомним адрес узла во временной переменной
//в указатель из родителя запишем адрес единственного ребёнка
if r^.left=NIL then r:=r^.right else r:=r^.left;
dispose(p); //освободим память, занимаемую узлом
exit;
end;
//у узла есть оба сына
p:=r^.right;
//частный случай: у узла, на который указывает p, нет левого сына
if p^.left=nil then
begin
r^.v:=p^.v;
r^.right:=p^.right;
dispose(p);
exit;
end;
//идём влево, пока есть куда
q:=p^.left;
while q^.left<>NIL do begin
p:=q; q:=q^.left;
end;
//значение из q^ кладём в r^, и удалять будем уже не r^, а q^
r^.v:=q^.v;
p^.left:=q^.right;
dispose(q);
end;
begin
del_rec(root);
end;
Вопросы для самопроверки
1. Почему в процедуре ins аргумент root передаётся через var? Как называется такой способ
передачи? В чем будет ошибка и как она проявится, если var убрать? Аналогичный вопрос
для процедуры del.
2. Объясните использование указателей p и q в конце процедуры del.
3. Если в дереве N элементов, какая у него может быть максимальная и минимальная
высота (высота – это количество рёбер на пути от корня до самого дальнего листа)
4. Если у дерева высота h, какое у него может быть минимальное и максимальное число
узлов?

13
5. Если у дерева высота h, каково максимальное количество рекурсивных вызовов может
быть в процедуре del?
6. Если в дереве N листьев, сколько в нём максимум и минимум всего узлов?
7. Если убрать вызов dispose в процедуре del, что изменится в работе программы?
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №5 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

o Лабораторно-практическая работа 6
Рандомизированные деревья поиска
Основной проблемой простых бинарных деревьев поиска является возможность
вырожденных случаев. Достаточно, чтобы входные данные были изначально
отсортированы, и полученное дерево, по сути, превратится в связный список. При этом
вычислительная сложность возрастёт от O(log(n)) до O(n), что для многих практических
задач будет неприемлемо.
Существуют различные способы, обеспечивающие поддержку бинарного дерева поиска
в хорошем состоянии. Один из самых простых подходов состоит в том, чтобы просто
случайно перемешать входные данных перед построением дерева. При росте количества
входных данных вероятность получения "плохого" дерева будет стремиться к нулю.
Однако, этот вариант годится лишь для случая, когда все данные известны изначально.
Во многих практически важных задачах данные поступают поэлементно, и каждый элемент
должен быть вставлен в дерево сразу после поступления.
Чуть более сложный подход заключается в том, чтобы немного усложнить процедуру
вставки элемента в дерево. Вначале элемент вставляется абсолютно так же, как описано в
предыдущей работе. При этом подсчитывается, на какой глубине h он оказался. После этого
берётся случайное число k от 0 до h, и элемент поднимается на k уровней вверх с помощью
так называемых вращений. Вращением называется изменение структуры дерева, при
котором заданный элемент оказывается на один уровень выше, но при этом дерево остаётся
по прежнему корректным. На следующем рисунке показана схема малого правого
вращения.

Рис.17. Схема малого правого вращения

Здесь b - это элемент, который нужно поднять, a - его родитель, P, Q и R - поддеревья.

Рис. 18. Пример малого правого вращения

14
За счёт того, что количество вращений определяется случайно, с увеличением числа
элементов вероятность "плохих" случаев стремится к нулю. Процедура вставки в
рандомизированное дерево представлена ниже.

function add(var r: PNode; v: integer; level: integer = 0): integer;


var
rest, vtmp: integer;
q, pa, pb, pc: PNode;
begin
if r = nil then begin
new(r);
r^.left := nil;
r^.right := nil;
r^.v := v;
add := random(level div 2);
exit;
end;
if v = r^.v then begin
add := 0;
exit;
end;
if v < r^.v then begin
rest := add(r^.left, v, level + 1);
if rest > 0 then begin
//левое вращение
q := r^.left;
pa := q^.left;
pb := q^.right;
pc := r^.right;
vtmp := r^.v; r^.v := q^.v; q^.v := vtmp;
r^.left := pa;
r^.right := q;
q^.left := pb;
q^.right := pc;
end;
add := rest - 1;
end else begin
rest := add(r^.right, v, level + 1);
if rest > 0 then begin
//правое вращение
q := r^.right;
vtmp := r^.v; r^.v := q^.v; q^.v := vtmp;
pa := r^.left;
pb := q^.left;
pc := q^.right;
vtmp := r^.v; r^.v := q^.v; q^.v := vtmp;
r^.left := q;
q^.left := pa;
q^.right := pb;
r^.right := pc;
end;
add := rest - 1;
end;
end;
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №6 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

15
o Лабораторно-практическая работа 7
Хеш-таблицы
Основной идеей хеш-таблиц является использование ключа в качестве индекса
элемента массива (таблицы). Например, при продаже билетов в кино или на концерт
схему зрительного зала можно представить в виде двухмерной таблицы. Каждый ключ
представляет собой пару из двух индексов (ряд и место) и однозначно задаёт элемент
таблицы, в которой каждый элемент хранит логическое значение (занято/свободно).
При хорошей наполняемости зрительного зала расходы памяти на хранение незанятых
мест будут минимальны. Подобные таблицы называются таблицами прямого доступа.
На практике такие структуры встречаются редко. В большинстве случаев диапазон
возможных значений ключей достаточно широк, поэтому таблица прямого доступа либо
вообще не поместится в память, либо будет использовать память слишком
неэффективно.
Однако, сама идея использования ключа в качестве индекса элемента массива
заслуживает самого пристального внимания, поскольку на ее основе может возникнуть
другая — преобразование значения ключа в индекс элемента массива с использованием
какой-либо функции, возвращающей результат в виде целого числа в заданном
ограниченном диапазоне. В этом случае расход памяти становится управляемым и
может быть достигнут разумный компромисс между скоростью и памятью.
Математическая функция h(K), которая преобразует значения ключей K в индексы
элементов массива, называется хеш-функцией. Сами индексы иначе называются хеш-
адресами и находятся в диапазоне от 0 до M-1, где M — некоторое положительное целое
число. Массив размером M, в котором ведётся поиск, называется хеш-таблицей и
обычно представляет собой массив записей (ключи и связанная информация). В частном
случае элементами хеш-таблицы могут быть просто значения ключей.
Например, пусть входная последовательность ключей имеет вид: 3, 25, 7, 48, 71.
Будем использовать простейшую хеш-функцию h(K) = K mod M (где операция mod
означает остаток от деления). Поскольку входных данных немного, выберем M = 7.
Тогда все хеш-адреса будут находиться в диапазоне от 0 до 6, а хеш-таблица будет почти
заполнена (см. табл. 1)

Таблица 1 . Хеш-таблица для последовательности 3 25 7 48 71


при применении хеш-функции h(k) = k mod 7
Хеш- 0 1 2 3 4 5 6
адрес
Значен 7 71 3 25 48
ие

При вставке в хеш-таблицу может возникнуть следующая проблема. Предположим,


что нужно вставить в хеш-таблицу еще одно значение ключа, на этот раз равное 8.
Подсчитываем значение хеш-функции: 8 mod 7 = 1. Однако ячейка с хеш-адресом 1 уже
занята ключом 71. Такая ситуация иначе называется конфликтом или коллизией.
Существует два основных метода разрешения коллизий. Метод цепочек состоит в
том, что все элементы с одинаковыми хеш-адресами объединяются в связный список
(хеш-цепочку). Тогда хеш-таблица представляет собой массив из N указателей на хеш-
цепочки. Такая структура показана на рис. __.
Реализация хеш-таблицы с использованием метода цепочек довольно проста. Сами
цепочки обычно реализуются в виде однонаправленных связных списков, для хранения
указателей на цепочки используется обычный массив.

16
Рис. 18. Разрешение коллизий методом цепочек

Второй метод разрешения коллизий — хеширование с открытой адресацией (в


некоторых источниках он называется закрытым хешированием, что вносит некоторую
путаницу). Коллизии в этом случае разрешаются следующим образом. Если вычисленный
по ключу хеш-адрес оказывается занятым, каким-либо способом находится другая
незанятая позиция, куда и помещается новый элемент. Если все позиции заняты, то элемент
вставить нельзя (место кончилось). Этот процесс поиска подходящей позиции называется
исследованием хеш-таблицы.
Наиболее простым способом исследования хеш-таблицы является линейное
зондирование. При этом hi(x)=(h(x)+i) mod N. То есть если ячейка с номером i уже занята, то
проверяется ячейка с номером i+1,затем с номером i+2 и т.д. пока не найдётся свободная.
При поиске элемента x необходимо просмотреть всю последовательность, начиная с
вычисленного хеш-адреса, пока не будет найден x, не встретится пустой элемент, или не
будут просмотрены все элементы.
Заметим, что при удалении из хеш-таблицы освободившийся элемент необходимо
помечать специальным образом, чтобы отличать от изначально пустого — иначе поиск
будет работать неверно.
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №7 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

o Лабораторно-практическая работа 8
Битовые массивы
Частным случаем описанных в предыдущей работе таблиц прямого доступа являются
битовые массивы. Пусть требуется хранить в памяти компьютера множество целых чисел,
элементы которого могут принимать значения от 0 до N-1 (где N – константа). Одним из
простейших вариантов является создание массива a: array[0..N-1] of Boolean, где a[k]=false
означает, что элемент k не принадлежит множеству, а a[k]=true – принадлежит.
Недостаток такого подхода заключается в неэффективном расходовании памяти. При
больших значениях N массив в память может просто не поместиться. Однако, достаточно
легко можно уменьшить требования памяти в 8 раз! Заметим, что одно значение типа
Boolean занимает целый байт, тогда как на самом деле для представления логического
значения достаточно одного бита. Это связано с тем, что в языке Pascal (как и в
большинстве других языков) любой тип данных не может занимать менее одного байта.
Однако, в языке Pascal имеется набор операций для работы с битами, что позволяет
решить данную проблему. Нам потребуются следующие операции: AND – побитовое "И",
OR – побитовое "ИЛИ", XOR – побитовое "исключающее ИЛИ", NOT – побитовое
инвертирование, SHL – побитовый сдвиг влево, SHR – побитовый сдвиг вправо.

17
Рассмотрим вначале следующую подзадачу. Пусть имеется байт x, требуется записать
единицу в его бит с номером bitPos, при этом не затронув соседние биты. С этой целью нам
потребуются две операции - SHL и OR. Вначале с помощью операции SHL сформируем
битовую маску – двоичное число, в котором все разряды равны 0, и только в разряде с
номером bitPos стоит 1. Затем выполняем побитовое "ИЛИ" данной маски и x.
mask := 1 SHL bitPos;
x := x OR mask;
Следующая таблица поясняет данные действия на примере x=73 (01001001),
bitPos=2.

Таблица 2. Пример использования побитовых операций


x 01001001
mask 00000100
x OR mask 01001101

Теперь рассмотрим общий случай. Пусть имеется массив a: array[0..N-1] of Byte.


Требуется установить в нём i-й по порядку бит в единицу. Каждый элемент данного массива
занимает 8 битов. Номер байта, где лежит требуемый бит, найдётся как i div 8. Номер бита
внутри байта найдётся как i mod 8. Далее применяем решение вышеописанной задачи.

const NMAX = 1000000;

type
TArray = array[0..NMAX] of byte;

procedure Add(var a: TArray; i: longint);


var
bytePos: longint;
bitPos, mask: byte;
begin
bytePos := i div 8;
bitPos := i mod 8;
mask := 1 shl bitPos;
a[bytePos] := a[bytePos] or mask;
end;
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №8 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

o Лабораторно-практическая работа 9
Исчерпывающий поиск
На практике встречается очень много таких задач, которые имеют конечное (но,
возможно, очень большое) множество допустимых решений, при этом "качество" решения
определяется значением какой-то функции. Требуется найти наилучшее (оптимальное)
решение, при котором значение функции будет минимальным (или максимальным).
Например, пусть имеется несколько камней, известны их веса. Требуется разбить их на
две кучи так, чтобы суммарные веса каждой кучи отличались как можно меньше.
Задачи такого рода называются задачами дискретной оптимизации. Одним из способов
решения таких задач является перебор возможных вариантов решений и выбор среди них
наилучшего. Однако, такого рода алгоритмы имеют крайне высокую вычислительную
сложность и поэтому применимы лишь для очень маленьких входных данных — не более
нескольких десятков элементов.

18
В качестве примера рассмотрим описанную выше задачу о камнях. По сути, нам
требуется перебрать все способы получения первой группы, так как все предметы, не
вошедшие в неё, попадают во вторую. То есть, требуется выполнить перебор всех
подмножеств множества из N элементов.

Часть 1. Полный двоичный перебор.


Для представления подмножеств создадим массив a из N элементов, где a[i]=1, если i-й
элемент входит в текущее подмножество, и 0 - если нет. Заметим, что массив a можно
рассматривать как запись некоторого N-разрядного двоичного числа, где элемент a[i]
содержит значение его i-го разряда. При этом каждому подмножеству из N элементов
однозначно соответствует некоторое N-разрядное двоичное число. Получив
последовательно в массиве a записи всех N-разрядных двоичных чисел, мы тем самым
переберём все подмножества.
Будем получать числа в порядке возрастания. Для того, чтобы перейти от предыдущего
числа к следующему, необходимо прибавить к нему единицу. В случае двоичной записи это
делается следующим образом. Движемся от младших разрядов к старшим, пока все они
равняются единице, и присваиваем им значение 0. Дойдя до первого нуля, присваиваем ему
значение 1. Например, при прибавлении 1 к числу 010010111 получится число 010011000.
Программная реализация не представляет сложности. Ниже представлен пример кода,
который выводит на экран все перебираемые подмножества.

const
NMAX = 1000;

type
TArray = array[1..NMAX] of byte;

function NextSubset(var a: TArray; n: integer): boolean;


begin
while a[n] = 1 do begin
a[n] := 0;
dec(n);
end;
if n < 1 then
NextSubset := false
else
begin
NextSubset := true;
a[n] := 1;
end;
end;

procedure printSubset(const a: TArray; n: integer);


var
i: integer;
begin
for i := 1 to n do write(a[i]);
writeln;
end;

var
a: TArray;
n, i: longint;

begin

19
read(n);
for i := 1 to n do a[i] := 0;
repeat
printSubset(a, n);
until not NextSubset(a, n);
end.

Поскольку количество всех подмножеств экспоненциально зависит от размера


множества, то алгоритм имеет экспоненциальную сложность.
Вышеприведённый код несложно модифицировать для решения задачи о камнях.
Значение a[i]=0 говорит о том, что камень идёт в кучу с номером 0, а значение a[i]=1 — в
кучу с номером 1. Получив очередной вариант решения, просто сравниваем модуль
разности весов куч с наилучшим найденным ранее.
Заметим, что веса обеих куч удобно не пересчитывать заново после генерации каждого
подмножества, а модифицировать их непосредственно в процедуре NextSubset.

Часть 2. Ускорение полного перебора. Отсечения.


Если в какой-то момент времени веса куч стали равными, то очевидно, перебор можно
уже закончить. Это тривиальный пример отсечения. Рассмотрим теперь более сложный
пример.
Пусть C0 — вес кучи с номером 0, C1 — вес кучи с номером 1. Предположим, что в
какой-то момент значение (C1−C0) превышает текущую наилучшую разницу весов куч.
Пусть начиная с какого-то k все элементы a[k], … , a[n] равны нулю.
Заметим, что нам нет никакого смысла перебирать следующие 2(n-k) −1 вариантов,
поскольку при этом будут изменяться только элементы a[k], …, a[n]. Изменение же этих
нулей на единицы будет давать решения ещё хуже текущего, так как разность (C1−C0) при
этом только вырастет.
Следовательно, мы спокойно можем данные варианты пропустить. Для этого достаточно
в процедуре NextSubset начинать цикл не c n, а с (k−1).
Эта простая идея позволяет снизить время работы программы при N=40 с нескольких
часов до менее одной секунды. Однако, сложность алгоритма по-прежнему остаётся
экспоненциальной.

Часть 3. Рекурсивная реализация перебора (backrack).


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

procedure subsets(var a: TArray; n: integer; i:integer = 1);


begin
if i>n then
printSubset (a, n)
else
begin
a[i] := 0;
subsets(a, n, i + 1);
a[i] := 1;
subsets(a, n, i + 1);
end;
end;

20
Разумеется, при рекурсивной реализации также можно (и нужно) использовать
отсечения.
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №9 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений

Лабораторно-практическая работа 10
Двоичная куча (пирамида), очередь с приоритетами
Пирамидой называется двоичное дерево, в вершинах которого
размещаются заданные нам элементы. При этом должны выполняться
следующие требования:
 все уровни, за исключением последнего, должны быть
заполнены полностью
 последний уровень (т.е. уровень листьев дерева)
может быть заполнен частично, но обязательно слева направо
без пропусков
 основное свойство пирамиды: ни один элемент не
может быть больше своего родителя

Рис. 1.1. Пример пирамиды


Очевидно, что на вершине пирамиды находится наибольший её элемент.
Для представления пирамиды в памяти удобно использовать массив, при
этом пирамида хранится в массиве следующим образом. Сыновья элемента с
индексом i будут иметь индексы i∙2+1 и i∙2+2, а его родитель − индекс
(i−1) div 2. Например, для элемента с индексом 3 сыновьями будут элементы с
индексами 3∙2+1 = 7 и 3∙2+2 = 8, а родителем − элемент с индексом (3−1) div
2 = 1.

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

21
Итак, пусть задан массив a из n элементов. Заметим, что элементы с
индексами i  n/2 заведомо не превосходят своих детей, поскольку их не
имеют (индексы i∙2+1 и i∙2+2 выходят за границы массива). Чтобы это
свойство пирамиды выполнялось и для других элементов, поступим
следующим образом. Будем двигаться по массиву справа налево, начиная с
индекса n/2−1.
Встав на очередной элемент a[i], выберем максимального из его сыновей
a[i∙2+1] и a[i∙2+2] (не забывая, что у элемента может быть только один сын
или не быть их вовсе). Если максимальный из сыновей не превосходит
родителя, то ничего делать не нужно, иначе поменяем их местами и выполним
аналогичную проверку для нашего элемента уже на его новом месте.
Возможно, этот шаг придётся выполнить несколько раз.
Приведём пример функции, выполняющей “погружение” очередного
элемента вглубь пирамиды. Параметрами функции будут исходный массив a,
его длина n и индекс элемента i.
void downheap(int a[], int n, int i){
while (i<n/2) //при i>=n/2 детей нет, и основное свойство
{ //пирамиды выполняется тривиальным образом
//определяем максимального из сыновей
int i_max = i*2+1;
if (i_max+1 < n) //второго сына может и не быть
if (a[i_max+1] > a[i_max])
i_max = i_max+1;
//проверяем, выполняется ли основное свойство пирамиды
if (a[i] >= a[i_max]) break;
//меняем местами элемент и его сына и корректируем i
int tmp = a[i]; a[i] = a[i_max]; a[i_max] = tmp;
i = i_max;
}
}

Теперь, чтобы преобразовать исходный массив в пирамиду, осталось


только вызвать функцию downheap для всех элементов от n/2−1 до 0:
for(int i=n/2-1; i>=0; i--) downheap(a,n,i);

На второй фазе полученная пирамида преобразуется в отсортированный


массив. Это делается следующим образом. Из основного свойства пирамиды
следует, что максимальный элемент находится на её вершине, то есть это
элемент a[0]. Поменяем местами элемент a[0] c последним элементом массива
– a[n−1]. В результате a[0] встанет на своё конечное место (действительно,
поскольку он наибольший, то в итоговом массиве должен стоять в самом
конце).
Чтобы больше этот элемент не рассматривать, уменьшим на 1 длину
массива (предварительно запомнив исходную длину).

22
Наконец, вызвав функцию downheap для элемента a[0], мы снова получим
пирамиду, только в ней будет на один элемент меньше.
Выполняя в цикле вышеперечисленные действия, мы будем получать
отсортированную последовательность, начиная с конца массива. После n−1
итераций, мы, очевидно, получим полностью отсортированную
последовательность. Приведём пример реализации второй фазы алгоритма:
for(int i=n-1; i>0; i--)
{ // a[0] - сейчас максимальный элемент среди a[0]..a[i]
// поставим его на своё место
int tmp = a[i]; a[i] = a[0]; a[0] = tmp;
// восстановим пирамидальность оставшейся части массива
downheap(a,i,0);
}
Несложно показать, что вычислительная сложность данного алгоритма
составляет Θ(n·log(n)).

Очередь с приоритетами на базе пирамиды.


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

Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:
1.1 "Построение пирамиды":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=293
1.2. "Пирамида":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=370
1.3. "Коллекционирование этикеток":
http://www.acmp.ru/index.asp?main=task&id_task=216
1.4. "Коммерческий калькулятор":
http://www.acmp.ru/index.asp?main=task&id_task=262
2. Реализуйте очередь с приоритетами для типа int на базе пирамиды.
Сравните её производительность с классом std::priority_queue<int>, сделайте
выводы.

23
Лабораторно-практическая работа 11. Сортировка Шелла
Алгоритм сортировки Шелла представляет собой усовершенствование
алгоритма простых вставок, который рассматривался в предшествующем
курсе по алгоритмам. Данный алгоритм работает следующим образом.
Вначале выполняется сортировка простыми вставками
подпоследовательностей элементов, отстоящих друг от друга на некоторое
расстояние hs. Далее аналогичная операция выполняется для hs-1, hs-2, … h1. При
этом h1 должен быть равен 1 − это гарантирует корректность сортировки,
поскольку на последнем проходе она превращается в обычную сортировку
вставками.
Рассмотрим пример. Пусть приращения выглядят следующим образом:
h1=1, h2=5, исходная последовательность − <3,6,2,8,4,2,9,1,5,7,11,0,10,14,8,12>.
На первом проходе мы выполняем сортировку подпоследовательностей,
элементы которых расположены с шагом 5: <3,2,11,12>, <6,9,0>, <2,1,10>,
<8,5,14> и <4,7,8>. В результате исходная последовательность преобразуется к
следующей: <2,0,1,5,4,3,6,2,8,7,11,9,10,14,8,12>.
На втором проходе h1=1, т.е. мы выполняем сортировку вставками всей
оставшейся последовательности и получаем окончательный результат:
<0,1,2,2,3,4,5,6,7,8,8,9,10,11,12,14>
На первый взгляд может показаться, что алгоритм Шелла должен
работает ещё медленнее, чем обычная сортировка вставками, так как
последний проход алгоритма как раз к ней и сводится. На самом деле это не
так. Упрощённо высокую эффективность алгоритма можно объяснить
следующим образом. На начальных проходах сортируемые группы
сравнительно невелики, но в результате этих проходов значительная часть
элементов оказывается вблизи нужных позиций. При росте же длины
подпоследовательностей для их сортировки требуется уже сравнительно
небольшое число перестановок, так как они тому времени становятся
довольно хорошо упорядочены.
При анализе данного алгоритма возникают сложные математические
задачи, многие из которых ещё не решены. В частности, неизвестно, какая
последовательность приращений даёт наилучшие результаты. Кнут показал,
что для большинства случаев хорошим выбором является последовательность
h1=1, h2=3h1+1, …, hs=3hs-1+1. Величина s − наименьшее число, такое что
hs+2  n, где n – число элементов массива [3].
Например, для n=1000 нужно взять следующую последовательность
приращений (в обратном порядке): 1, 4, 13, 40, 121. При таком выборе среднее
время сортировки составит O(n1.25), в наихудшем случае – O(n1.5).
Седжвиком была предложена следующая последовательность
приращений [3]:

24
9  2 s  9  2 s / 2  1, если s четно
hs   s (s1) / 2
8  2  6  2  1, если s нечетно
здесь s – наименьшее число, такое что 3hs+1n.
При таком выборе время сортировки в худшем случае составляет O(n4/3), в
среднем – O(n7/6). Пример сортировки Шелла с использованием
последовательности Седжвика:
void shellsort(int a[], int n){
//заранее просчитанная последовательность приращений Седжвика
const unsigned long h[] =
{1,19,41,109,209,505,929,2161,3905,8929,16001,
36289,64769,146305,260609,587521,1045505,2354689,
4188161,9427969,16764929,37730305,67084289,150958081,
268386305,603906049,1073643521,2415771649,4294770689};
//определяем начальное приращение
int s;
for(s=0; h[s+1]<n/3; s++);
//цикл по приращениям
for(; s>=0; s--)
{ //выполняем сортировку вставками с шагом h[s].
//Внешний цикл проходит по всем элементам, внутренний цикл
//вставляет элемент на соответствующее место в его группе
int step = h[s];
for(int i=step; i<n; i++)
{ int t = a[i];
int j;
for(j=i-step; (j>=0) && (a[j]>t); j=j-step)
a[j+step] = a[j];
a[j+step]=t;
}
}
}

Хотя асимптотически время работы данного алгоритма и превышает


nlog(n), но для реальных входных данных (помещающихся в оперативную
память современных машин) он вполне способен конкурировать с другими
быстрыми способами сортировки – например, с алгоритмом, описанным в
следующей лабораторной работе.

Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:

25
1. Сравните время работы вашей реализации сортировки Шелла c
приращениями Кнута и Седжвика со временем работы стандартной функции
std::sort при n = 106, 107, 108. Сделайте выводы.
2. Сравните время работы вашей реализации сортировки Шелла c
приращениями Кнута и Седжвика со временем работы стандартной функции
std::stable_sort при n = 106, 107, 108. Сделайте выводы.
3. "Сортировка Шелла":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=300
4. "Определение приращения":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=583

Лабораторно-практическая работа 12. Сортировка за линейное время


Можно доказать, что алгоритмы сортировки, основанные на сравнениях
элементов, не способны работать быстрее, чем за O(n∙log(n)). Однако, если
разрешить использование других операций – например, извлечение разрядов
сортируемых элементов и использование их в качестве индексов, то можно
добиться линейного времени работы.

Сортировка подсчётом.
Алгоритм сортировки подсчетом (counting sort) применим, если
сортируемые значения представляют собой целые положительные числа в
известном диапазоне (не превосходящие заранее известного k). В простейшем
варианте алгоритм выглядит следующим образом. Создаётся вспомогательный
массив с, размер которого совпадает с диапазоном возможных значений
исходных чисел. Для каждого элемента x мы подсчитываем, сколько раз он
встречается в исходной последовательности, используя в качестве счётчика
элемент c[x]. Наконец, проходя по массиву c слева направо, выводим каждое
число столько раз, сколько оно встречается. Пример реализации
(предполагается, что все исходные элементы лежат в диапазоне от 0 до 65535):
void countSort(unsigned int a[], int n) {
unsigned int c[65536];
memset(c,0,sizeof(c));
int i,j,k=0;
//подсчитываем, сколько раз встречается каждое число
for(i=0; i < n; i++) c[a[i]]++;
//формируем ответ
for(i = 0; i <= 65535; i++)
for(j = 0; j < c[i]; j++)
a[k++] = i;
}

У описанного способа реализации имеется большой недостаток – его


нельзя использовать для сортировки не самих числовых значений, а записей,
содержащих эти значения в качестве ключей. Для того, чтобы это было
возможным, логика работы алгоритма несколько изменяется. Первый шаг

26
остаётся прежним – для каждого элемента мы подсчитываем в массиве c,
сколько раз он встречается в исходной последовательности.
На следующем шаге мы проходим по массиву c и формируем в нём сумму
с накоплением – то есть после этого элемент c[x] будет содержать сумму всех
элементов в c, стоящих левее. Смысл этого в том, что элемент c[x] будет
показывать, какое количество элементов в результирующей
последовательности должны оказаться слева от x, то есть, по сути, итоговую
позицию x.
На последнем шаге нам потребуется ещё один вспомогательный массив b
для формирования результата. Проходя по исходному массиву, для каждого его
элемента a[i] мы сразу определяем индекс в массиве b, где он должен
оказаться: он равен c[a[i]]−1. Поместив туда элемент, необходимо вычесть
единицу из c[a[i]], чтобы следующий элемент с таким же значением оказался
на одну позицию левее.
При выполнении последнего шага есть важная особенность: чтобы
сортировка была устойчивой, исходный массив проходится обязательно справа
налево. Пример реализации:
void countSort(unsigned int a[], int n){
unsigned int c[65536];
memset(c, 0, sizeof(c));
int i, j;
//подсчитываем, сколько раз встречается каждое число
for(i=0; i < n; i++) c[a[i]]++;
//считаем сумму с накоплением
for(i = 1; i <= 65535; i++) c[i] += c[i-1];
//формируем ответ
unsigned int *b = new unsigned int[n];
for(i = n-1; i >= 0; i--)
b[ --c[a[i]] ] = a[i];
memcpy(a, b, n * sizeof(unsigned int));
delete [] b;
}

Распределяющая сортировка от младшего разряда к старшему.


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

27
Рассмотрим реализацию алгоритма. Лежащая в его основе сортировка
подсчётом несколько усложняется – добавляется код для выделения значения
заданного разряда-байта, при этом используются битовые операции сдвига
влево <<, сдвига вправо >> и побитового “И” &.
//сортировка подсчетом по байту с номером bytenum
void countsort(unsigned int a[], int n, int bytenum){
int c[256], i;
int shift = bytenum * 8; //смещение этого байта в битах
int mask = 0xFF << shift; //битовая маска для выделения
разряда
//подсчитываем количества
memset(c,0,sizeof(c));
for (i=0; i<n; i++) c[ (a[i]&mask) >> shift ]++;
//подсчитываем сумму с накоплением
for (i=1; i<256; i++) c[i]+=c[i-1];
//заполняем результирующий массив b
unsigned int *b = new unsigned int[n];
memset(b,0,sizeof(int)*n);
for (i=n-1; i>=0; i--){
b[--c[(a[i]&mask)>>shift]]=a[i];
}
memcpy(a,b,n*sizeof(int));
delete[] b;
}
// Собственно распределяющая сортировка заключается в вызове
// функции countsort для всех разрядов:
void radsort(int a[], int n)
{ for(int i=0; i<sizeof(int); i++)
countsort(a, n, i);
}

Если k – число разрядов в сортируемых числах, то сложность алгоритма


составляет Θ(k∙n). Обычно k ограничено небольшой константой, то есть время
работы составляет Θ(n). Таким образом, данный алгоритм является наиболее
быстрым из вышерассмотренных (хотя разница становится заметной лишь при
больших объёмах данных).
Недостатком алгоритма является зависимость от типа сортируемых
данных: необходимо, чтобы данные допускали возможность разбития на
разряды, и чтобы их количество было ограничено. Примерами таких типов
данных являются числа и короткие строки. Ещё один недостаток алгоритма –
использование дополнительной памяти: требуется вспомогательный массив
такого же размера, что и исходный.

Самостоятельные задания

28
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:
1. Модифицируйте приведённый код сортировки подсчётом (либо
напишите свой вариант), чтобы можно было сортировать целые числа в
диапазоне [-32768, 32767].
2. Модифицируйте приведённый код распределяющей сортировки, чтобы
можно было сортировать произвольные числа типа int (в том числе
отрицательные).
3. Сравните скорость работы вашей реализации распределяющей
сортировки со стандартными функциями std::sort и std::stable_sort при n = 106,
107, 108. Сделайте выводы.
4. Придумайте и реализуйте алгоритм распределяющей сортировки для
сортировки элементов односвязного списка (тип ключей − int). Разрешается
использовать лишь константный объём дополнительной памяти.
5. Решите следующую задачу. Вводится n чисел a1, a2, …, aN. Затем на
вход программы подаётся большое количество запросов нахождения суммы
всех элементов, индексы которых лежат между заданными индексами li и ri.
Придумайте и реализуйте как можно более эффективный способ ответа на
такие запросы. Подойдет ли ваш способ в случае, если искомой функцией
вместо суммы будет: а). минимум; б). среднее арифметическое; в). количество
чётных элементов?

Лабораторно-практическая работа 13.


Динамическое программирование
Основная идея метода динамического программирования заключается в
рекурсивном разбиении задачи на более мелкие подзадачи такого же вида. При
этом, чтобы избежать многократного решения одних и тех же подзадач,
каждая из них вычисляется только один раз, и её ответ сохраняется в
некоторой структуре данных (чаще всего − в векторе или матрице).
Для примера рассмотрим следующую задачу. Необходимо вычислить
значение функции
 f ( x 1)  f ( x div 2), если x  0
f ( x)  
1, если x  0
Здесь операция div означает целую часть от деления.
Для начала можно попытаться напрямую выразить эту функцию на языке
программирования:
int f(int x){
if (x==0)
return 1;
else
return f(x-1)+f(x/2);
}

29
Однако, даже при сравнительно небольших значениях x вычисления
выполняются достаточно долго: например, вычисление f(500) на современном
персональном компьютере идёт около минуты. Причина в том, что
многократно выполняются вычисления одних и тех же подзадач − смотрите
следующий рисунок:

Рис. 2.1 Дерево рекурсивных вызовов при вычислении f(5)

Для того, чтобы избежать лишних вычислений, создадим


дополнительный вектор v, в который будем записывать уже вычисленные
ответы на подзадачи. Вначале заполним v значениями -1 − это будет означать,
что эта подзадача ещё не решена.
Вычисляя f(x), посмотрим сначала на элемент v[x]: если он
неотрицателен, то подзадача уже решалась ранее. Если же элемент
отрицательный, то выполним вычисления и сохраним ответ в v[x]:

int f(int x){


static std::vector<int> v(x + 1, -1);
v[0] = 1;
if (v[x] < 0)
v[x] = f(x - 1) + f(x / 2);
return v[x];
}

Примечание. Данная функция будет верно работать лишь при x ≤ 506,


поскольку при больших значениях x произойдёт переполнение типа int. Чтобы
функция работала при больших x, необходимо применять другой тип данных.
Такое решение с рекурсивной функцией, к которой добавлено
запоминание ответов для подзадач, иногда называют «ленивым»
динамическим программированием (поскольку если решение какой-то
подзадачи нам не потребуется, то оно и не будет вычисляться). Заметим, что
для нашей задачи можно легко написать и решение без использования
рекурсии:

int f(int x){


std::vector<int> v(x + 1);
v[0] = 1;

30
for (int i = 1; i <= x; i++)
v[i] = v[i - 1] + v[i / 2];
return v[x];
}

Как несложно видеть, в этом и предыдущем решении вычислительная


сложность составляет Θ(x).
Второй вариант реализации позволяет иногда существенно сэкономить
память. Дело в том, что для некоторых задач разбиение на подзадачи имеет
многоуровневую структуру, схематично изображённую на рисунке 2.2.

Рис. 2.2 Возможная многоуровневая структура


разбиения задач на подзадачи

При этом для нахождения решений подзадач i-го уровня нужно знать
лишь решения подзадач (i+1)-го уровня. То есть, найдя решения всех подзадач
i-го уровня, можно удалить из памяти решения подзадач (i+1)-го уровня,
поскольку они больше не потребуются.
Рассмотрим пример. На вершине лесенки, содержащей N ступенек,
находится мячик, который начинает прыгать по ним вниз к основанию. Мячик
может прыгнуть на следующую ступеньку, на ступеньку через одну или через
две. Например, если мячик лежит на восьмой ступеньке, то он может
прыгнуть на пятую, шестую или седьмую. Требуется определить количество
всевозможных маршрутов мячика с вершины лесенки на землю.
Обозначим через f(i) количество вариантов попасть на землю с i-й
ступеньки. За один шаг мячик может прыгнуть на ступеньки с номерами i−1,
i−2, i−3. Получаем следующее рекуррентное соотношение: f(i) = f(i−1)+
f(i−2)+f(i−3). Это соотношение верно при i > 3. Случай i ≤ 3 можно
рассмотреть отдельно, тогда результирующее рекуррентное соотношение
имеет вид:

Очевидное нерекурсивное решение с вектором длины n выглядит так:


int count(int n) {
std::vector<int> f(std::max(n + 1, 4));
f[1] = 1; f[2] = 2; f[3] = 4;

31
for (int i = 4; i <= n; ++i) {
f[i] = f[i-1] + f[i-2] + f[i-3];
}
return f[n];
}

Однако, несложно заметить, что для вычисления следующего значения


достаточно знать лишь три предыдущих. Это позволяет написать решение,
которое использует константный объём памяти:
int count(int n) {
int f[4];
f[1] = 1; f[2] = 2; f[3] = 4;
if (n < 4)
return f[n];
for (int i = 4; i <= n; ++i) {
int v = f[1] + f[2] + f[3];
f[1] = f[2]; f[2] = f[3]; f[3] = v;
}
return f[3];
}

Задачи
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com.
Дополнительные задания для подготовки к экзамену:
1. "Как получить единицу":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=1336
2. "У магазина":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=298
3. "Быстрое питание":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=12
4. "Восстановление скобок":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=67
5. "Выражение":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=14
6. "Коробки":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=901
7. "Отчёт":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=1066
8. "Куча камней - 3":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=870
9. "Палиндром":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=296
10. "Расписание лекций":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=834
11. "Триангуляция":

32
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=291
12. "Упаковка":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=10
13. "Избыточная система":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=1639
14. "Последовательность":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=1432
15. "Скобки":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=88

Лабораторно-практическая работа 14. Жадные алгоритмы


В жадном алгоритме на очередном шаге всегда делается выбор, который
кажется наилучшим в данный момент (так называемый жадный выбор). При
этом предполагается, что итоговое решение также окажется оптимальным.
Однако, это не всегда верно: легко может оказаться, что в погоне за
сиюминутной выгодой жадный алгоритм приведёт в итоге к неоптимальному
решению. Поэтому корректность таких алгоритмов всегда необходимо
доказывать. Во многих случаях хорошо работает следующая схема
доказательства:
а). Показываем, что жадный выбор на первом шаге не закрывает путь к
оптимальному решению: для любого решения есть другое, согласованное с
жадным выбором и не хуже первого.
б). Показываем, что подзадача, возникшая после жадного выбора на
первом шаге, аналогична исходной. Тогда по индукции получается, что такая
последовательность жадных выборов даёт оптимальное решение.
Рассмотрим несколько примеров.
Пример 1 − задача об отрезках. Дано n точек на координатной прямой.
Нужно провести наименьшее количество отрезков длины 1, чтобы через
каждую точку проходил хотя бы один отрезок.
Решение. Идём по всем точкам в порядке возрастания их координат. Если
очередная точка не принадлежит отрезку, построенному до этого, то проводим
из неё вправо очередной отрезок.
Обоснование. Заметим сначала, что достаточно ограничиться
рассмотрением лишь таких отрезков, которые начинаются в какой-либо из
заданных точек. Действительно, любой отрезок из оптимального решения
можно сдвинуть вправо к ближайшей точке – оптимальность решения при
этом не нарушится.
Будем рассматривать точки слева направо. Заметим, что в состав
оптимального решения должен входить отрезок, начинающийся в первой
точке: какой-нибудь отрезок через неё обязан проходить, а левее её других
точек нет.
Теперь осталось провести отрезки через оставшиеся точки, которые не
покрыты первым отрезком. Полученная задача аналогична исходной.

33
Пример 2 - задача о заявках. Даны N заявок на проведение занятий в
некоторой аудитории. В каждой заявке указаны начало и конец занятия.
Требуется выбрать максимальное количество совместных друг с другом
заявок.
Решение. Решение данной задачи очень похоже на решение задачи об
отрезках. Упорядочим заявки по времени их окончания. Возьмём первую
заявку и отбросим все заявки, пересекающиеся с ней. Дальше возьмём первую
из оставшихся заявок, и так далее.
Обоснование. Для доказательства заметим, что заявка с самым ранним
временем окончания входит в хотя бы одно оптимальное решение − если в
оптимальном решении её нет, то самую раннюю заявку в оптимальном
решении можно заменить на неё, и решение по-прежнему останется
оптимальным. Затем, отбросив все заявки, пересекающиеся с первой, мы
приходим к задаче, аналогичной исходной.

Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:
1. "Удвоение и инкремент":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=880
2. "Заявки на презентации":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=1179
3. "Рестораны":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=691
4. "Подпись":
http://www.acmp.ru/index.asp?main=task&id_task=415
5. "Маршрутка":
http://acm.timus.ru/problem.aspx?space=1&num=1424

Лабораторно-практическая работа 15. Минимальное остовное дерево


Остовным деревом графа называется дерево, полученное в результате
удаления некоторых его рёбер. Минимальное остовное дерево (ОДМС) –
остовное дерево, сумма весов рёбер которого даёт минимальный вес. Пример
показан на рисунке 7.1.
Существуют различные алгоритмы построения ОДМС, но все они
основываются на следующем утверждении. Пусть дан связный
неориентированный граф. Если u-v − ребро минимальной стоимости в этом
графе, то существует хотя бы одно ОДМС, содержащее это ребро.
Доказательство. Построим дерево минимальной стоимости, содержащее
ребро u-v. Предположим, что в графе существует другое дерево с меньшей
стоимостью, это ребро не содержащее. Проведём в этом дереве ребро u-v и
удалим какое-нибудь ребро на пути из u в v (такой путь в дереве обязательно
существует). В итоге мы получим дерево, которое содержит ребро u-v и при

34
этом имеет меньшую (или равную) стоимость, поскольку стоимость ребра u-v
меньше или равна стоимости удаленного ребра. Таким образом, наше
предположение не выполняется, и утверждение верно.

Рис. 7.1. Связный граф и его минимальное остовное дерево

Алгоритм Прима.
В начале работы алгоритма Прима дерево будет состоять из одной
вершины – её можно выбрать произвольно. На каждом следующем шаге
выбирается ребро с минимальным весом, такое, что одна его вершина
принадлежит уже построенной части дерева, а другая – нет (пример работы
алгоритма см. на рисунке 7.2).

Рис. 7.2. Иллюстрация к алгоритму Прима

Чтобы отмечать, какие вершины уже вошли в дерево, вводится булевский


массив tree. Для эффективной реализации алгоритма необходимо быстро

35
находить очередное ребро. Для этого вводится массив f. Для каждой вершины
i, ещё не вошедшей в дерево, элемент f[i] содержит номер такой вершины в
дереве, что ребро f[i]−i имеет минимальный вес. Например, на рис. 7.2(c)
f[5] = 3, так как от вершины 5 есть рёбра в вершины 3 и 6 дерева, и из них
ребро 5-3 весит меньше, чем ребро 5-6.
Для поиска очередного ребра достаточно пройти по массиву f и выбрать
такую вершину k, что вес ребра f[k]-k минимален и при этом tree[k] ложно.
Вершина k добавляется в дерево, после чего необходимо обновить массив f –
проверить для каждого i, не будет ли ребро k-i короче, чем f[i]. Если да, то
присваиваем f[i] = k. Например, на рис. 7.2 (c) после добавления вершины 6 в
дерево значение f[4] заменяется c 1 на 6, так как ребро 1-6 весит меньше, чем
ребро 1-4. Такой пересчет можно выполнить также за один проход по массиву.
Кроме всего прочего, массив f будет одновременно служить и достаточно
удобным представлением результирующего дерева по окончании работы
алгоритма, поэтому его можно рассматривать и в качестве выходных данных.
Приведём пример реализации. В данном примере граф задан матрицей
стоимостей, то есть g[i][j] − это вес ребра i-j. Нумерация вершин с единицы.
std::vector<bool> tree(n + 1);
tree[1] = true;
std::vector<int> f (n + 1, 1);
f[1] = -1;
for (int i = 2; i < n; i++) {
// ищем такую вершину v не из дерева,
// что ребро из неё в дерево минимально
int v = -1;
for (int j = 1; j <= n; j++) {
if (!tree[j] && (v == -1 || g[v][ f[v] ] > g[j][f [j] ]))
v = j;
}
tree[v] = true;
// пересчитываем массив f
for (int j = 1; j <= n; j++) {
if (!tree[j] && g[j][v] < g[j][ f[j] ])
f[j] = v;
}
}
//выводим ответ - рёбра дерева
for (int i = 2; i <= n; i++) {
printf("%d - %d\n", i, f[i]);
}

Ещё одним широко применяемым алгоритмом построения ОДМС


является алгоритм Краскала, он будет рассматриваться на лекции.

Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:

36
1. "ОДМС":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=170
2. "Минимальный каркас":
https://acmp.ru/?main=task&id_task=142
3. "Города":
https://acmp.ru/index.asp?main=task&id_task=302
4. "Network":
http://acm.timus.ru/problem.aspx?space=1&num=1160
5. "Пингвин-Авиа":
http://acm.timus.ru/problem.aspx?space=1&num=1709

Лабораторно-практическая работа 16. Обходы графов


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

Обход в ширину.
Обход (или поиск) в ширину, известный также как метод волны,
позволяет найти в графе кратчайшие пути от заданной вершины до всех
остальных (длиной пути считается число дуг в нём). Алгоритм выглядит
следующим образом. Пусть a – начальная вершина. Поместим вершину a в
очередь и отметим её как посещенную.
Далее, пока не опустеет очередь, делаем следующее. Извлекаем из
очереди очередную вершину u. Добавляем в очередь все непосещённые
вершины, смежные с u. Для каждой из них запомним, откуда мы пришли в неё,
и отметим вершину как посещённую.
Рассмотрим пример, показанный на рисунке 5.1. Вначале мы помещаем в
очередь вершину a. На следующем шаге извлекаем её из очереди, помещаем в
неё вершины c и d и отмечаем в них, что пришли из a. Далее извлечём из
очереди вершину c, поместим b и f и так далее.

37
Рис. 5.1. Пример поиска в ширину

Cложность алгоритма зависит от способа представления графа. Для


каждой вершины графа нам нужно получить все вершины, смежные с ней. В
случае матрицы смежности это займёт время Ө(n2), при использовании
списков смежности – Ө (m), где n – число вершин, m – число дуг.
В результате выполнения поиска в ширину для каждой вершины будет
храниться номер предыдущей вершины на кратчайшем пути к ней (они
подписаны рядом с каждой вершиной в левой части рисунка). Эти данные, по
сути, задают некоторое дерево. Данное дерево называется деревом поиска в
ширину (правая часть рисунка 5.1).
Приведём пример реализации данного алгоритма. Для представления
графа будем использовать списки смежности: g[i] − это вектор, содержащий
номера всех вершин, в которые выходят дуги из вершины i. Начальная
вершина имеет номер 0.
std::vector<int> lengths(n, -1); // длины путей
lengths[0] = 0;
std::vector<int> from(n, -1); // откуда пришли
std::queue<int> q;
q.push(0);
while (!q.empty()) {
int v = q.front();
q.pop();
for (int to : g[v]) {
if (lengths[to] < 0) {
lengths[to] = lengths[v] + 1;
q.push(to);
from[to] = v;
}
}
}

Кратчайший путь от начальной вершины до любой вершины u теперь


можно получить в обратном порядке: u ← from[u] ← from[from[u]]…

38
Обход в глубину.
Обход (или поиск) в глубину лежит в основе большого количества
алгоритмов на графах. Процесс поиска в глубину обычно можно начинать с
любой вершины. Эта вершина отмечается как посещенная, после чего мы
переходим к первой смежной с ней непосещённой вершине. С ней повторяется
то же самое и так далее, пока это возможно. На рисунке 5.2(а) мы, начав с
вершины a, таким образом дойдём до вершины c по пути a→b→c.

Рис. 5.2. Пример поиска в глубину

Далее возвратимся на шаг назад, возьмём следующую смежную


непосещённую вершину, и так далее. В нашем примере мы возвращаемся к b,
переходим от неё к вершине d и опять возвращаемся к b. Поскольку
непосещенных вершин, смежных с b, больше не осталось, то возвращаемся к
a. От вершины a также идти больше некуда.
Однако, на этом алгоритм не заканчивается. Если в графе ещё остались
непосещённые вершины, необходимо снова взять одну из них в качестве
начальной и повторить всё сначала. В нашем примере ещё остались
необработанные вершины e, f и g, поэтому берём очередную вершину e и
снова повторяем для неё описанный алгоритм.
При выполнении поиска в глубину в вершинах графа можно проставлять
метки времени (это требуется не во всех задачах). Каждая вершина v имеет
две метки: d[v] − момент времени, когда мы впервые пришли в данную
вершину, и f[v] − момент времени, когда мы полностью завершили
рассмотрение вершины v и её потомков и выходим из неё. Поскольку в
каждую вершину мы один раз входим и один раз выходим, то метки будут
представлять собой числа в интервале от 1 до 2∙n. Пример расстановки меток в
результате обхода в глубину представлен на рисунке 5.2(b).
Для ряда задач достаточно формировать метки только входа либо только
выхода. Поскольку при этом значения меток будут получаться в интервале от 1
до n, то их можно рассматривать в качестве новых номеров вершин, иногда
даже используется термин "глубинные номера".

39
В некоторых задачах вместо меток времени удобней использовать цвета.
Вначале все вершины белые. Когда мы впервые заходим в вершину, она
красится в серый цвет. Вершина красится в чёрный цвет, когда мы уже обошли
всех её потомков и возвращаемся из неё в предыдущую вершину.
Приведём пример использования алгоритма dfs для проверки
ацикличности неориентированного графа. Заметим, что для данной задачи не
было необходимости в использовании ни меток времени, ни цветов вершин.
// функция возвращает true, если обнаружен цикл
bool dfs(const std::vector<std::vector<int> > &g,
int v,
std::vector<bool> &visited,
std::vector<int> &from)
{
bool result = false;
visited[v] = true;
for (int i : g[v]) {
if (!visited[i]) {
from[i] = v;
result |= dfs(g, i, visited, from);
} else {
result = true;
}
}
return result;
}

Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:
1. "Скачки":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=297
2. "Переливания":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=204
3. "Бюрократия":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=208
4. "Муха − слон":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=267
5. "Путь в лабиринте":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=246

Лабораторно-практическая работа 17. Применение обходов графов


Алгоритмы обхода графов, особенно обход в глубину (dfs), применяются
как составная часть многих алгоритмов. В данной лабораторной работе мы
познакомимся с некоторыми из них.

40
Топологическая сортировка.
Пусть дан ориентированный ациклический граф. Вершины любого такого
графа можно перенумеровать таким образом, чтобы любая дуга шла из
вершины с меньшим номером в вершину с большим номером.
Пусть, например, вершины графа представляют собой учебные курсы, а
дуга uv показывает, что прежде чем читать курс v, студентам нужно сначала
прочитать курс u. Тогда топологическая сортировка даст нам возможный
порядок, в котором следует вести курсы.
Заметим, что если расположить все вершины графа в одну линию слева
направо в порядке возрастания полученных номеров, то все дуги графа будет
идти слева направо.

Рис. 6.1. Пример топологической сортировки

Топологическую сортировку несложно выполнить с помощью обхода в


глубину: всё, что нужно сделать, это добавить вывод вершины v в последнюю
строчку функции dfs. При этом получается, что вершина v выводится только
после того, как выведены все вершины, достижимые из неё. Напечатанная
последовательность номеров, если читать её справа налево, и даст нам один из
возможных порядков топологической сортировки (последней выведенной
вершине присваиваем номер 1, предпоследней - 2 и т.д.)
Правильность алгоритма следует из того, что вывод на печать вершины v
производится лишь после того, как уже напечатаны все достижимые из неё.
Соответственно, если теперь назначать по порядку номера, идя в обратную
сторону, то вершина v получит номер, меньший, чем все её потомки.

Эйлеровы пути и циклы.


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

41
Необходимость достаточно очевидна – если в цикле мы заходим в
вершину по какому-то ребру, то по другому ребру должны из неё выйти, то
есть общее число рёбер, выходящих из вершины, должно быть чётным.
Достаточность можно доказать по индукции, получив заодно и алгоритм
поиска эйлерова цикла. Возьмём граф, найдём в нём какой-нибудь цикл C и
удалим его рёбра из графа. После этого удалим вершины, от которых больше
не исходит рёбер. В результате граф распадётся на несколько компонентов. В
каждом компоненте степень каждой вершины также будет чётной, и по
предположению индукции для каждого из них должны существовать свои
эйлеровы циклы. Пример показан на рисунке – удаляем из графа цикл 1-2-5-4-
3-1, в результате у нас остался один компонент связности с эйлеровым циклом
2-7-6-5-3-2.

Рис. 6.2. Поиск эйлерова цикла

Теперь осталось слить цикл C и все циклы компонентов в один


результирующий эйлеров цикл. Для этого для каждого компонента связности
возьмём любую его вершину, которая принадлежит также и циклу C. В нашем
примере это вершина 2. Вместо неё в C подставляем эйлеров цикл компонента
– в нашем примере получаем: 1-2-7-6-5-3-2-5-4-3-1.
Аналогично доказывается, что в графе существует эйлеров путь, если
граф связен и ровно две его вершины имеют нечётную степень. При этом путь
будет начинаться в одной из них, а заканчиваться в другой.
Рекурсивный вариант алгоритма можно реализовать как модификацию
обхода в глубину, только удаляться будут не вершины, через которые мы
проходим, а рёбра. Вывод очередной вершины выполняется в самом конце
рекурсивной функции, как и при выполнении топологической сортировки.

Поиск сильно связных компонентов.


Для поиска компонентов сильной связности в ориентированном графе
можно использовать алгоритм, состоящий из следующих трёх шагов.
Первый шаг. Выполняем обход исходного графа G в глубину и получаем
метки времён выхода f. Данные метки будем рассматривать как новые номера
вершин (они получатся в интервале от 1 до n). Для отображения новых
номеров вершин на старые организуем массив f, где f[i] = v будет означать, что
вершине с новым номером i соответствует старый номер v.

42
Второй шаг. Строим новый граф Gr путём изменения направления всех
дуг исходного графа на обратное.
Третий шаг. Переберём вершины в порядке убывания их новых номеров
и для каждой ещё непосещённой вершины вызовем функцию поиска в
глубину для графа Gr. Каждый такой вызов произведёт обход очередного
компонента связности.
Корректность алгоритма можно пояснить следующим образом. Стянем
условно каждый сильно связный компонент в одну «супервершину», в
результате получим граф компонентов. Данный граф будет ациклическим. На
первом шаге алгоритма мы, по сути, выполняем его топологическую
сортировку – нумеруем вершины так, чтобы все дуги между компонентами
шли от вершин с большим номером к вершинам с меньшим. В перевёрнутом
же графе, наоборот, между такими компонентами дуг не будет, и мы по
очереди обойдём каждый из них.

Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:
1. "Игра в города":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=205
2. "Одностороннее движение":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=292
3. "Цепочки знакомств":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=1183
4. "Генеалогическое дерево":
http://acm.timus.ru/problem.aspx?num=1022
5. "Топологическая сортировка":
https://www.e-olymp.com/ru/problems/4003

Лабораторно-практическая работа 18.


Алгоритм Кнута-Морриса-Пратта и Z-алгоритм
Алгоритм Кнута-Морриса-Пратта − один из самых известных
эффективных алгоритмов поиска подстроки в строке. Введём некоторые
обозначения.
• s – строка, в которой выполняется поиск, n − её длина.
• p – подстрока, которую ищем (образец), m − её длина.
• s[i..j] – подстрока строки s с позиции i до позиции j.
• s[0..j] – префикс s. Например, “a”, ”ab” и “abc” – префиксы строки ”abc”.
Префикс, не совпадающий с самой строкой, называется собственным.
• s[j..n−1] – суффикс s. Например, ”c”, “bc”, “abc” – суффиксы строки
“abc”. Суффикс, не совпадающий с самой строкой, называется собственным.

43
Рассматриваемая задача формулируется следующим образом: найти все
позиции в строке s, где встречается образец p. Частным случаем является
задача поиска позиции только первого вхождения.
В наивном подходе можно просто сравнить p со всеми подстроками s
длины |p|. Каждое сравнение выполняется посимвольно и останавливается, как
только очередная пара символов не совпадает. Сложность алгоритма в худшем
случае составляет O(n∙m).
Однако, в наивном подходе мы никак не использовали информацию о
структуре подстроки-образца и о результате предыдущего сравнения, хотя эта
информация может значительно ускорить работу.
Предположим, подстрока p="abac" прикладывалась к j-й позиции строки
s и не совпала в 4-м символе. Как видно из рисунка 8.1, при этом мы можем
смело пропустить (j+1)-ю позицию, так как совпадения в ней точно не будет, и
перейти сразу к позиции (j+2) - т.е. сдвинуть подстроку вправо сразу на два
символа. А сравнение символов можно начать даже с (j+3)-й позиции,
поскольку в (j+2)-й позиции они заведомо совпадут.
Теперь общий случай − пусть совпала подстрока p[0..i], а в символе p[i+1]
обнаружилось несовпадение. Возникает вопрос − на какое расстояние мы
можем сдвинуть подстроку, чтобы не пропустить ни одного вхождения? Ответ
− нужно сдвинуть её на минимальное расстояние (большее 0) такое, чтобы
префикс строки p[0..i] совместился с её суффиксом. В нашем пример
p[0..2]="aba", и при сдвиге на расстояние 2 префикс "a" окажется точно под
суффиксом "a".

Рис. 8.1. Иллюстрация возможных принципов ускорения


"наивного" подхода поиска подстроки

Пусть f[i] − длина максимального суффикса p[0..i], совпадающего с её


префиксом (значения так называемой функции отказов или префикс-
функции). Тогда величина сдвига равна i−f[i]+1.
Теперь посмотрим, как эффективно вычислить значения f. Пусть уже
вычислены f[0],...f[i], и требуется получить f[i+1]. Согласно определению f,
p[0..f[i]−1] = p[i−f[i]+1..i]. Поэтому, если p[f[i]] = p[i+1], то f[i+1] = f[i]+1.
Для примера возьмём p="abcabc". f[4]=2 означает, что префикс "ab" длины
2 ещё раз встречается далее и заканчивается в 4-й позиции. А равенство

44
p[2]=[5]='c' будет означать, что префикс "abc" длины 3 будет ещё раз
встречаться далее и заканчиваться в 5-й позиции, т.е. f[5]=3.
Рассмотрим теперь случай, когда p[f[i]]  p[i+1]. В этом случае возьмём
следующий более короткий префикс, копия которого также заканчивается в
позиции i − он будет иметь длину f[f[i]−1]. Будем продолжать так до тех пор,
пока не обнаружим совпадение символов, либо не придём к префиксу нулевой
длины.
Оценим сложность этой части алгоритма. Пусть t=f[i] - текущее значение
в f. Каждое следующее значение t превышает предыдущее не более чем на
единицу, поэтому t увеличивается на 1 не более m−1 раз. При каждом
неудачном сравнении t уменьшается, но никогда не становится меньше 0.
Поэтому суммарное число неудачных сравнений также не превышает m−1.
Таким образом, построение массива f выполняется за линейное время от m.
Сам поиск выполняется также с использованием вышеприведённых
рассуждений: прикладываем подстроку к началу строки и сравниваем
соответствующие символы, пока идут совпадения. При обнаружении
несовпадения мы с помощью массива f определяем, на какое количество
символов сдвинуть подстроку, и с какой позиции в подстроке начинать
следующее сравнение. Несложно доказать, что поиск выполняется за
линейное время от n. Приведём пример реализации.
//алгоритм Кнута-Морриса-Пратта:
//вывод позиций всех вхождений p в s
void kmp(char const *s, char const *p) {
int m = strlen(p), n = strlen(s);
int* f = new int[m];
//заполняем массив f
f[0]=0;
for(int i=1; i<m; i++) {
int t=f[i-1];
while (t>0 && p[t]!=p[i]) t=f[t-1];
f[i]=f[t];
if (p[t]==p[i]) f[i]++;
}
//выполняем поиск
int k=0, i=0;
while (k<n) { //пока идут совпадения
while ( (i<m) && (p[i]==s[k+i]) ) i++;
if (i == m) cout << k << endl;
i--; //место последнего совпадения
if (i>=0) {
k += i - f[i] + 1; //насколько можно сдвинуться
i = f[i]; //и с какого места начать проверять
} else {
k++; i=0;
}
}
delete[] f;
}

Z-алгоритм

45
Рассмотрим ещё один алгоритм, несколько напоминающий алгоритм
Кнута-Морриса-Пратта. Z-алгоритм строит для строки p массив z, где z[i] −
максимальная длина подстроки p, начинающейся в позиции i и совпадающей с
началом строки p.

Рис. 9.1. Иллюстрация к определению z-функции

Пусть на некотором шаге у нас уже вычислены значения z[1],...,z[i−1], и


нужно вычислить z[i]. Пусть также у нас есть переменные l и r, где r − самая
правая позиция, где заканчивается какая-то из ранее рассмотренных подстрок,
а l − позиция, где она начинается. Пример показан на рисунке.

Рис. 9.2. Иллюстрация к вычислению z[i], случай i ≤ r

Если i > r, то вычислим z[i] простым последовательным сравнением


символом − p[i] и p[0], p[i+1] и p[1] и так далее до тех пор, пока не обнаружим
несовпадение. После этого при необходимости изменим значения r и l.
Если же i ≤ r (как показано на рисунке 9.2), то мы можем сразу же узнать
некоторую информацию о z[i]. Действительно, раз совпадают подстроки,
показанные на рисунке серым цветом, то внутри них совпадают и подстроки,
выделенные штрифовкой. Возьмём значение z[i−l+1]. Если z[i−l+1] не
превышает длину заштрихованной части, то есть z[i−l+1] <= r−i+1, то можно
сразу сделать вывод, что z[i] = z[i−l+1] − эта ситуация и изображена на рисунке
выше. Значение r и l, очевидно, изменять не нужно.
Наконец, на рисунке ниже показан случай, когда z[i−l+1] > r−i.

Рис. 9.3. Иллюстрация к вычислению z[i], случай z[i−l+1] > r−i

Поскольку мы ничего не знаем о той части строки, которая правее r, то


можем сказать только, что z[i]≥ r−i. Поэтому присвоим первоначально z[i]=r−i,
после чего последовательно будем сравнивать символы p[r+1] и p[r−i+2],
p[r+2] и p[r−i+3] и так далее, увеличивая z[i] до обнаружения несовпадения.
После этого корректируем значения r и l. Ниже приводится пример кода,
вычисляющего z-функцию для строки p.
int m = strlen(p);
int *z = new int[m];
int l=0, r=0; z[0]=0;

46
for(int k=1;k<m;k++) {
if (k>r) {
l=r=k;
while ( (r<m) && (p[r]==p[r-k]) ) r++;
r--;
z[k] = r-k+1;
}
else {
z[k] = z[k-l];
int r1 = k+z[k];
if (r1<r) continue;
r1 = r+1;
while ((r1<m) && (p[r1]==p[r1-k]) ) r1++;
r1--;
z[k] = r1-k+1;
if (r1>r) {
r = r1; l=k;
}
}
}

Вопросы
1. Возможно ли из массива со значениями Z-функции получить массив со
значениями префикс-функции? Если да, то ответьте, насколько эффективно это
можно сделать, и напишите программу, выполняющую такое преобразование.
2. Возможно ли из массива со значениями префикс-функции получить
массив со значениями Z-функции? Если да, то ответьте, насколько эффективно
это можно сделать, и напишите программу, выполняющую такое
преобразование.
Самостоятельные задания
С 2018 года решение задач проводится в режиме тренировок в системе
codeforces.com. Дополнительные задания для подготовки к экзамену:
1. "Поиск подстроки":
http://acmp.ru/index.asp?main=task&id_task=202
2. "Сообщение":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=176
3. "Как мне помочь":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=859
4. "Спутник":
http://atpp.vstu.edu.ru/cgi-bin/arh_problems.pl?id_prb=568
5. "Сдвиг текста":
http://acmp.ru/index.asp?main=task&id_task=203
6. "Циклическая строка":
http://acmp.ru/index.asp?main=task&id_task=204

47
КУРСОВОЙ ПРОЕКТ

Цель курсового проекта состоит в выполнении задания из двух частей.


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

Пример интерфейса визуализатора алгоритма

Часть 2. Разработка задачи по той же теме с набором тестов и


проверяющей программой и её внедрение в электронный практикум по
программированию, используемый на кафедре АВТ. Основные требования к
этой части проекта:

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

Вариант Задание курсового проекта


1 Нерекурсивный перебор комбинаторных объектов
2 Рекурсивный перебор с отсечениями (backtracking)
3 Декартово дерево
4 Алгоритм Краскала
5 Алгоритм Прима
6 SQRT-декомпозиция
7 Распределяющая сортировка
8 Алгоритм Дейкстры
9 Метод динамического программирования для решения задачи
дискретной оптимизации
10 Метод динамического программирования для решения задачи
подсчёта количества комбинаторных объектов
11 Поиск максимального потока в графе методом Карпа-Эдмондса
12 Поиск в ширину на графе
13 Поиск в глубину на графе
14 Очередь с приоритетами на основе двоичной кучи
15 Поиск максимального паросочетания в двудольном графе методом
чередующихся цепочек
16 Поиск компонентов сильной связности
17 Хеширование с цепочками
18 Хеширование без цепочек
19 Алгоритм Кнута-Морриса-Пратта
20 Дерево отрезков

49
Примерный объем пояснительной записки: 20 стр., шрифт 14, через 1.3
интервала. Примерный объем графической части: 2 листа формата A4.

Примерный план пояснительной записки


1. Описание заданной структуры данных или алгоритма
1.1. Историческая справка
1.2. Описание работы алгоритма.
1.3. Строгое доказательство корректности алгоритма
1.4. Класс входных данных, для которых применим алгоритм или структура
1.5. Анализ временной и пространственной сложности алгоритма
1.6. Сравнение с аналогами
1.7. Примеры практических задач, где может использоваться данный
алгоритм.
2. Разработка визуализатора
2.1. Выбор средств разработки
2.2. Определение отображаемых элементов, проектирование интерфейса
2.3. Проектирование иерархии классов.
2.4. Разработка алгоритмов прямого пошагового выполнения визуализации
и выполнения отката
2.5. Особенности программной реализации
2.6. Методика и результаты тестирования ПО
3. Описание задачи
3.1. Условие
3.2. Разработка верного решения
3.3. Разработка набора тестов. Обоснование достаточности (рассмотреть
возможные неверные решения и обосновать, почему они не пройдут).
3.4. Разработка проверяющей программы (при необходимости)
3.5. Разработка визуализатора.

ЗАКЛЮЧЕНИЕ

Тематика лабораторно-практических работ в данных методических


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

50
полезны в профессиональной деятельности в области разработки
программного обеспечения.

51

Вам также может понравиться