o Лабораторно-практическая работа 1
Алгоритмы сортировки данных
Сортировкой последовательности называется расположение элементов этой
последовательности согласно определённому линейному отношению порядка. Наиболее
привычным является отношение "", в этом случае говорят о сортировке по неубыванию.
Существует большое число различных методов сортировки. В данной лабораторной
работе рассматривается два метода – сортировка простыми вставками и "быстрая"
сортировка (метод Хоара).
Сортировка вставками относится к целому классу методов, которые чрезвычайно
просты в понимании и реализации, однако, имеют более высокий порядок сложности, чем
более совершенные алгоритмы.
Пусть первые (i-1) элементов последовательности уже отсортированы. На очередном
шаге возьмём элемент, стоящий на i-м месте, и поместим его в нужное место
отсортированной части последовательности. Будем делать так до тех пор, пока вся
последовательность не окажется отсортированной.
Один из способов вставки элемента будет выглядеть так. Запомним i-й элемент в
переменной t. Будем двигаться от (i-1)-го элемента к началу массива, сдвигая элементы на 1
вправо, пока они больше t. Наконец, поместим элемент t на освободившееся место.
Иллюстрация показана на рисунке 1.
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;
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]
Самостоятельные задания
1. Решить не менее трёх задач из лабораторной работы №2 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений
4
o Лабораторно-практическая работа 3
Стеки и очереди
Стек представляет собой структуру данных – контейнер, в котором данные помещаются
и извлекаются с одного и того же конца (вершины стека – англ. top), выполняется принцип
LIFO (last in – first out), то есть помещенный последним элемент извлечён будет первым.
5
Рассмотрим пример применения стека. Стек используется в большом числе алгоритмов.
Одним из них является вычисление выражений в постфиксной записи. Смысл постфиксной
(обратной польской) записи арифметического выражения состоит в том, что вначале
пишутся аргументы, а после них – знак операции. Например, запись "2 3 + 4 *" означает,
что нужно сложить 2 и 3, затем полученный результат умножить на 4. Плюсы постфиксной
записи – не требуется скобки, удобство машинного вычисления.
Посмотрим, каким образом вычисляется выражение в постфиксной записи с
применением стека. Алгоритм выглядит так. Берём очередную входную лексему. Если это
число, то кладём его в стек. Если же это знак операции, то извлекаем два элемента из стека,
выполняем операцию, результат кладём в стек. По окончании входных данных на вершине
стека будет лежать ответ.
Реализация очереди при помощи массива немного сложнее, чем реализация стека.
Вспомним, что вставка и удаление элементов выполняются с разных концов. Поэтому
используют два индекса head и tail, ссылающиеся на голову и хвост очереди.
При вставках и удалениях элементов очередь как бы передвигается по массиву,
постепенно приближаясь к его границе. При этом в начале массива появляется свободное
пространство за счет освободившихся при удалении элементов. Как только хвост очереди
достигнет верхней границы массива, следующие элементы будут добавляться уже в
свободные ячейки в начале массива. Такую реализацию очереди называют кольцевой или
циклической.
Заметим, что только по двум индексам head и tail не можем отличить пустую очередь от
полностью заполненной. Чтобы устранить данный недостаток, достаточно создавать массив
хотя бы на один элемент больше, чем максимально возможная длина очереди (либо просто
хранить в отдельной переменной текущее число элементов).
В случае, когда максимальное число элементов в очереди заранее неизвестно, её можно
реализовать с помощью односвязного списка. Поскольку вставка и удаление выполняются
на разных концах, то для эффективной реализации нужно хранить два дополнительных
указателя — на хвост и голову очереди.
6
Рис. 15. Реализация очереди на односвязном списке
Очереди, как и стеки, используются в программировании весьма широко — например,
очередь сообщений в операционной системе Windows, очередь сетевых пакетов в
маршрутизаторе и др.
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №3 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений
o Лабораторно-практическая работа 4
Связные списки
Структура данных (в узком смысле) – это способ представления данных и связей
между данными в памяти машины. В динамической структуре данные и связи могут
создаваться и изменяться динамически, то есть по ходу работы программы.
Чаще всего элементами данных являются записи (record), а связи между записями
реализуются с помощью указателей.
Простейшим вариантом динамической структуры данных является односвязный список
– см. рисунок.
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;
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;
9
Рис.6. Состояние переменных после строк new(p); p^.v:=v;
10
o Лабораторно-практическая работа 5
Бинарные деревья поиска
Примером иерархической структуры является бинарное поисковое дерево – см.
рисунок. Каждый узел данного дерева имеет от 0 до 2-х детей (левого сына и правого
сына). Значения (числа v) в узлах дерева расположены так, что для любого узла r
выполняется следующее требование: значения во всех узлах левого поддерева меньше, чем
значение в r, а значения в узлах правого поддерева – больше, чем в r.Такое дерево
применяется для представления множеств в памяти ЭВМ. Рассмотрим особенности
машинной реализации.
type
PNode = ^TNode;
TNode = record
v: integer;
left, right: PNode;
end;
var
root: PNode;
begin
root:=NIL;
end.
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 и локальные переменные):
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 уровней вверх с помощью
так называемых вращений. Вращением называется изменение структуры дерева, при
котором заданный элемент оказывается на один уровень выше, но при этом дерево остаётся
по прежнему корректным. На следующем рисунке показана схема малого правого
вращения.
14
За счёт того, что количество вращений определяется случайно, с увеличением числа
элементов вероятность "плохих" случаев стремится к нулю. Процедура вставки в
рандомизированное дерево представлена ниже.
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)
16
Рис. 18. Разрешение коллизий методом цепочек
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.
type
TArray = array[0..NMAX] of byte;
o Лабораторно-практическая работа 9
Исчерпывающий поиск
На практике встречается очень много таких задач, которые имеют конечное (но,
возможно, очень большое) множество допустимых решений, при этом "качество" решения
определяется значением какой-то функции. Требуется найти наилучшее (оптимальное)
решение, при котором значение функции будет минимальным (или максимальным).
Например, пусть имеется несколько камней, известны их веса. Требуется разбить их на
две кучи так, чтобы суммарные веса каждой кучи отличались как можно меньше.
Задачи такого рода называются задачами дискретной оптимизации. Одним из способов
решения таких задач является перебор возможных вариантов решений и выбор среди них
наилучшего. Однако, такого рода алгоритмы имеют крайне высокую вычислительную
сложность и поэтому применимы лишь для очень маленьких входных данных — не более
нескольких десятков элементов.
18
В качестве примера рассмотрим описанную выше задачу о камнях. По сути, нам
требуется перебрать все способы получения первой группы, так как все предметы, не
вошедшие в неё, попадают во вторую. То есть, требуется выполнить перебор всех
подмножеств множества из N элементов.
const
NMAX = 1000;
type
TArray = array[1..NMAX] of byte;
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.
20
Разумеется, при рекурсивной реализации также можно (и нужно) использовать
отсечения.
Самостоятельные задания
1. Решите не менее трёх задач из лабораторной работы №9 на сайте codeforces.com.
2. В отчёте опишите кратко алгоритм решения задач и вставьте исходный код решений
Лабораторно-практическая работа 10
Двоичная куча (пирамида), очередь с приоритетами
Пирамидой называется двоичное дерево, в вершинах которого
размещаются заданные нам элементы. При этом должны выполняться
следующие требования:
все уровни, за исключением последнего, должны быть
заполнены полностью
последний уровень (т.е. уровень листьев дерева)
может быть заполнен частично, но обязательно слева направо
без пропусков
основное свойство пирамиды: ни один элемент не
может быть больше своего родителя
Сортировка пирамидой.
Пирамиды могут использоваться для нескольких целей, в том числе для
сортировки данных. Сортировка пирамидой включает в себя две фазы. На
первой фазе мы преобразуем исходную последовательность в пирамиду.
Пирамиду можно построить прямо в исходном массиве, то есть
дополнительная память не требуется.
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;
}
}
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 (s1) / 2
8 2 6 2 1, если s нечетно
здесь s – наименьшее число, такое что 3hs+1n.
При таком выборе время сортировки в худшем случае составляет 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;
}
}
}
Самостоятельные задания
С 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
Сортировка подсчётом.
Алгоритм сортировки подсчетом (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;
}
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);
}
Самостоятельные задания
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.
Придумайте и реализуйте как можно более эффективный способ ответа на
такие запросы. Подойдет ли ваш способ в случае, если искомой функцией
вместо суммы будет: а). минимум; б). среднее арифметическое; в). количество
чётных элементов?
29
Однако, даже при сравнительно небольших значениях x вычисления
выполняются достаточно долго: например, вычисление f(500) на современном
персональном компьютере идёт около минуты. Причина в том, что
многократно выполняются вычисления одних и тех же подзадач − смотрите
следующий рисунок:
30
for (int i = 1; i <= x; i++)
v[i] = v[i - 1] + v[i / 2];
return v[x];
}
При этом для нахождения решений подзадач 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 можно
рассмотреть отдельно, тогда результирующее рекуррентное соотношение
имеет вид:
31
for (int i = 4; i <= n; ++i) {
f[i] = f[i-1] + f[i-2] + f[i-3];
}
return f[n];
}
Задачи
С 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
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
34
этом имеет меньшую (или равную) стоимость, поскольку стоимость ребра u-v
меньше или равна стоимости удаленного ребра. Таким образом, наше
предположение не выполняется, и утверждение верно.
Алгоритм Прима.
В начале работы алгоритма Прима дерево будет состоять из одной
вершины – её можно выбрать произвольно. На каждом следующем шаге
выбирается ребро с минимальным весом, такое, что одна его вершина
принадлежит уже построенной части дерева, а другая – нет (пример работы
алгоритма см. на рисунке 7.2).
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
Обход в ширину.
Обход (или поиск) в ширину, известный также как метод волны,
позволяет найти в графе кратчайшие пути от заданной вершины до всех
остальных (длиной пути считается число дуг в нём). Алгоритм выглядит
следующим образом. Пусть a – начальная вершина. Поместим вершину a в
очередь и отметим её как посещенную.
Далее, пока не опустеет очередь, делаем следующее. Извлекаем из
очереди очередную вершину u. Добавляем в очередь все непосещённые
вершины, смежные с u. Для каждой из них запомним, откуда мы пришли в неё,
и отметим вершину как посещённую.
Рассмотрим пример, показанный на рисунке 5.1. Вначале мы помещаем в
очередь вершину a. На следующем шаге извлекаем её из очереди, помещаем в
неё вершины c и d и отмечаем в них, что пришли из a. Далее извлечём из
очереди вершину c, поместим b и f и так далее.
37
Рис. 5.1. Пример поиска в ширину
38
Обход в глубину.
Обход (или поиск) в глубину лежит в основе большого количества
алгоритмов на графах. Процесс поиска в глубину обычно можно начинать с
любой вершины. Эта вершина отмечается как посещенная, после чего мы
переходим к первой смежной с ней непосещённой вершине. С ней повторяется
то же самое и так далее, пока это возможно. На рисунке 5.2(а) мы, начав с
вершины a, таким образом дойдём до вершины c по пути a→b→c.
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
40
Топологическая сортировка.
Пусть дан ориентированный ациклический граф. Вершины любого такого
графа можно перенумеровать таким образом, чтобы любая дуга шла из
вершины с меньшим номером в вершину с большим номером.
Пусть, например, вершины графа представляют собой учебные курсы, а
дуга uv показывает, что прежде чем читать курс v, студентам нужно сначала
прочитать курс u. Тогда топологическая сортировка даст нам возможный
порядок, в котором следует вести курсы.
Заметим, что если расположить все вершины графа в одну линию слева
направо в порядке возрастания полученных номеров, то все дуги графа будет
идти слева направо.
41
Необходимость достаточно очевидна – если в цикле мы заходим в
вершину по какому-то ребру, то по другому ребру должны из неё выйти, то
есть общее число рёбер, выходящих из вершины, должно быть чётным.
Достаточность можно доказать по индукции, получив заодно и алгоритм
поиска эйлерова цикла. Возьмём граф, найдём в нём какой-нибудь цикл C и
удалим его рёбра из графа. После этого удалим вершины, от которых больше
не исходит рёбер. В результате граф распадётся на несколько компонентов. В
каждом компоненте степень каждой вершины также будет чётной, и по
предположению индукции для каждого из них должны существовать свои
эйлеровы циклы. Пример показан на рисунке – удаляем из графа цикл 1-2-5-4-
3-1, в результате у нас остался один компонент связности с эйлеровым циклом
2-7-6-5-3-2.
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
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".
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.
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
КУРСОВОЙ ПРОЕКТ
48
- формулировка задачи по возможности должна быть привязана к
практике;
- условие задачи должно быть сформулировано четко и ясно, без каких-
либо возможных предположений или разночтений; для всех входных
данных должны быть указаны допустимые диапазоны; если выходные
данные являются вещественными числами, должна быть указана требуемая
точность.
- набор тестов должен охватывать все возможные категории возможных
входных данных, включая частные и вырожденные случаи, и быть
составлен так, чтобы отсекать подавляющую часть неэффективных решений
с помощью ограничений по времени или памяти.
- проверяющая программа должна распознавать типовые причины
неверных ответов и выдавать соответствующие подсказки, корректно
выбирать вердикты «неверный ответ» или «ошибка представления».
- каждый тест должен быть прокомментирован, к наиболее интересным
тестам выполнен поясняющий рисунок.
Примерная тематика/формулировки заданий (по вариантам):
49
Примерный объем пояснительной записки: 20 стр., шрифт 14, через 1.3
интервала. Примерный объем графической части: 2 листа формата A4.
ЗАКЛЮЧЕНИЕ
50
полезны в профессиональной деятельности в области разработки
программного обеспечения.
51