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

Оглавление

ПОНЯТИЕ АЛГОРИТМА. АЛГОРИТМИЧЕСКИЕ МОДЕЛИ. МАШИНА С ПРОИЗВОЛЬНЫМ ДОСТУПОМ


К ПАМЯТИ. ОСНОВНЫЕ ХАРАКТЕРИСТИКИ АЛГОРИТМОВ. АБСТРАКТНЫЕ ТИПЫ ДАННЫХ. ............1

СЛОЖНОСТЬ АЛГОРИТМОВ. АСИМПТОТИЧЕСКИЕ ОБОЗНАЧЕНИЯ. ОЦЕНКИ СЛОЖНОСТИ..............1

ЛИНЕЙНЫЕ СТРУКТУРЫ ДАННЫХ. ДИНАМИЧЕСКИЕ МАССИВЫ. СТЕКИ И ОЧЕРЕДИ. СВЯЗНЫЕ


СПИСКИ............................................................................................................................................... 3

ХЭШ-ТАБЛИЦЫ. ХЭШ-ФУНКЦИИ........................................................................................................ 6

ДВОИЧНЫЕ ДЕРЕВЬЯ ПОИСКА. ОСНОВНЫЕ ОПЕРАЦИИ НАД ДВОИЧНЫМИ ДЕРЕВЬЯМИ ПОИСКА. 7

КРАСНО-ЧЕРНЫЕ ДЕРЕВЬЯ. ОСНОВНЫЕ ОПЕРАЦИИ НАД КРАСНО-ЧЕРНЫМИ ДЕРЕВЬЯМИ. ..........10

AVL-ДЕРЕВЬЯ. ОСНОВНЫЕ ОПЕРАЦИИ НАД AVL-ДЕРЕВЬЯМИ.........................................................14

КОСЫЕ ДЕРЕВЬЯ (SPLAY TREE). ОСНОВНЫЕ ОПЕРАЦИИ НАД КОСЫМИ ДЕРЕВЬЯМИ. ОСНОВНЫЕ
ТЕОРЕМЫ О КОСЫХ ДЕРЕВЬЯХ.........................................................................................................17

ДВОИЧНЫЕ КУЧИ. ОЧЕРЕДИ С ПРИОРИТЕТАМИ..............................................................................20

АЛГОРИТМ ПИРАМИДАЛЬНОЙ СОРТИРОВКИ................................................................................. 22

АЛГОРИТМ СОРТИРОВКИ СЛИЯНИЕМ..............................................................................................22

АЛГОРИТМ БЫСТРОЙ СОРТИРОВКИ................................................................................................ 23

АЛГОРИТМ АРИФМЕТИЧЕСКОЙ СОРТИРОВКИ (СОРТИРОВКИ ПОДСЧЕТОМ).................................. 24

ПОНЯТИЕ О ГИБРИДНЫХ СОРТИРОВКАХ. АЛГОРИТМ INTROSORT. ПРИНЦИПЫ АЛГОРИТМА


TIMSORT............................................................................................................................................ 25

ОСНОВНЫЕ СВОЙСТВА АЛГОРИТМОВ СОРТИРОВКИ. НИЖНЯЯ ОЦЕНКА СЛОЖНОСТИ СОРТИРОВКИ


СРАВНЕНИЯМИ................................................................................................................................. 28

АМОРТИЗАЦИОННЫЙ АНАЛИЗ........................................................................................................ 29

ДИНАМИЧЕСКОЕ ПРОГРАММИРОВАНИЕ.........................................................................................31

ЖАДНЫЕ АЛГОРИТМЫ..................................................................................................................... 32

1
РАССТОЯНИЕ ЛЕВЕНШТЕЙНА. ЕГО СВОЙСТВА И АЛГОРИТМ ВЫЧИСЛЕНИЯ....................................33

ВЫЧИСЛИТЕЛЬНО СЛОЖНЫЕ ЗАДАЧИ И ОСНОВНЫЕ ПОДХОДЫ К ИХ РЕШЕНИЮ.........................34

ОПТИМИЗАЦИЯ ПЕРЕБОРА. МЕТОД ВЕТВЕЙ И ГРАНИЦ...................................................................34

ПРИБЛИЖЕННЫЕ АЛГОРИТМЫ. ПРИБЛИЖЕННЫЕ АЛГОРИТМЫ ДЛЯ ЗАДАЧИ О ВЕРШИННОМ


ПОКРЫТИИ, МЕТРИЧЕСКОЙ ЗАДАЧИ КОММИВОЯЖЕРА И ЗАДАЧИ О РЮКЗАКЕ............................35

ПОНЯТИЕ О МЕТАЭВРИСТИЧЕСКИХ АЛГОРИТМАХ. ЛОКАЛЬНЫЙ ПОИСК. АЛГОРИТМ ИМИТАЦИИ


ОТЖИГА. ГЕНЕТИЧЕСКИЕ АЛГОРИТМЫ............................................................................................ 37

ЗАДАЧА О РЮКЗАКЕ. ТОЧНЫЕ МЕТОДЫ РЕШЕНИЯ ЗАДАЧИ О РЮКЗАКЕ....................................... 38

ЗАДАЧА О РЮКЗАКЕ. ПРИБЛИЖЕННЫЕ МЕТОДЫ РЕШЕНИЯ ЗАДАЧИ О РЮКЗАКЕ.........................39

ВЕРОЯТНОСТНЫЕ АЛГОРИТМЫ. ПРОВЕРКА НА ПРОСТОТУ НА ОСНОВЕ МАЛОЙ ТЕОРЕМЫ ФЕРМА.


.......................................................................................................................................................... 40

ВЕРОЯТНОСТНЫЕ АЛГОРИТМЫ. ФИЛЬТР БЛУМА. АЛГОРИТМ MINHASH....................................... 40

ВЕРОЯТНОСТНЫЕ АЛГОРИТМЫ. ФИЛЬТР БЛУМА. АЛГОРИТМ HYPERLOGLOG................................42

ВЕРОЯТНОСТНЫЕ АЛГОРИТМЫ. АЛГОРИТМ MINHASH. ПРИБЛИЖЕННЫЙ ВЕРОЯТНОСТНЫЙ


АЛГОРИТМ ДЛЯ ЗАДАЧИ MAX-3SAT.................................................................................................43

ПЕРСИСТЕНТНЫЕ СТРУКТУРЫ ДАННЫХ............................................................................................44

31. ПОИСК ПОДСТРОКИ В СТРОКЕ.................................................................................................... 46

32. РЕКУРСИЯ. ВИДЫ РЕКУРСИИ. ОПТИМИЗАЦИЯ ХВОСТОВОЙ РЕКУРСИИ....................................48

Понятие алгоритма. Алгоритмические модели. Машина с


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

2
Алгоритмическая модель – это разновидность информационной модели, где
содержится описание последовательности действий (план), строгое исполнение
которых приводит к решению поставленной задачи за конечное число шагов.
Примером алгоритмической модели может являться RAM.
Машина с произвольным доступом к памяти (Random Access Machine, RAM,
Равновероятная адресная машина) может рассматриваться как компьютер,
работающий следующим образом:
• алгоритм состоит из конечного числа команд;
• для исполнения простой операции {+, ∗, −, =, if, . . . } требуется один
шаг;
• каждое обращение к памяти занимает один шаг;
• объем памяти неограничен;
• имеются циклы, условия, безусловные переходы;
• один шаг выполняется за одну единицу времени;
• любой алгоритм может быть представлен в виде слова над некоторым
алфавитом. Мы будем считать, что алгоритм записан на некотором
языке программирования.

Основные характеристики алгоритмов:


• Корректность – способность выдать правильный ответ для любого
входа;
• Сложность (время работы) – количество шагов. Зависит от длины
входа;
• Требования к памяти;
• Способность к распараллеливанию.

Абстрактные типы данных – это тип данных, который предоставляет набор


функций для работы с элементами. При этом клиентские программы не имеют
доступа к внутренней реализации типа данных (инкапсуляция – информация
скрыта). Пример АТД – оперирования с числами.

Сложность алгоритмов. Асимптотические обозначения. Оценки


сложности.

Сложность (время работы) — это количество шагов. Сложность зависит от


длины входа. Пусть τ (x) — время работы на входе x.

Сложность в худшем случае:


T ( n )= max τ (x )
x∈ X :||x||=n

Сложность в лучшем случае:


T b ( n )= min τ (x )
x∈ X :||x|=n

Сложность в среднем:
3
1
T m ( n )=
¿ { x ∈ X :||x||=n }∨¿ ∑ τ (x)¿
x ∈ X :||x||=n

Асимптотические обозначения
Рассмотрим две функции f, g: N → N. Обозначение f(n) = O(g(n)) означает,
что существуют такие c ∈ R и n0 ∈ N, что при любом n > n0 выполняется f(n) ≤ c ·
g(n).
Запись вида f(n) = O(g(n)) означает, что ф-ия f(n) возрастает медленнее
чем ф-ия g(n) при с = с1 и n = N, где c1 и N могут быть сколь угодно большими
числами, т.е. При c = c1 и n >= N, c*g(n) >=f(n).  O – верхнее ограничение
сложности алгоритма.
Обозначение f(n) = Ω(g(n)) означает, что g(n) = O(f(n)).

Обозначение f(n) = Θ(g(n)) означает, что f(n) = O(g(n)) и, при этом, g(n) =
O(f(n)).
Назовем функцию f: N → N полиномиальной, если существует такое k ∈ R,
что f(n) = O(nk).
Назовем функцию f: N → N экспоненциальной, если существует такое a ∈ R,
что f(n) = Ω(an).
Свойства асимптотических обозначений:
Сложение:
f(n) + g(n) = O(max(f(n), g(n)))
f(n) + g(n) = Ω(max(f(n), g(n)))
f(n) + g(n) = Θ(max(f(n), g(n)))
Умножение:
f(n) · g(n) = O(f(n) · g(n))
f(n) · g(n) = Ω(f(n) · g(n))
f(n) · g(n) = Θ(f(n) · g(n))

Транзитивность: если f(n) = O(g(n)) и g(n) = O(h(n)), то f(n) = O(h(n)).


Примеры:
O(n) – линейная сложность (поиск max в неотсортированном массиве);
O(log n ) – бинарный поиск.
O(n2) – алгоритмы сортировки вставками, пузырьком, выбором.
O(n log n) – алгоритм быстрой сортировки
О(nC), c > 1, с = const – полиноминальная сложность (n2)
О(Cn), c > 1. c = const – экспоненциальная сложность (2n)
# 2n + 1000n100 − 10 = O(2n)
4
Линейные структуры данных. Динамические массивы. Стеки и
очереди. Связные списки.
Структура данных – это программная единица, позволяющая хранить и
обрабатывать множество однотипных и/или логически
связанных данных в вычислительной технике. Для добавления, поиска,
изменения и удаления данных структура данных предоставляет некоторый
набор функций, составляющих её интерфейс.

СД – способ хранения и организации данных, облегчающий доступ к этим


данным.

Основные операции над данными: добавление элемента (вставка), поиск,


удаление.

Дополнительные операции: перебор, сортировка, рекурсия.

Характеристики структуры данных: сложность основных операций,


требования к памяти.

Массив – это структура данных, хранящая набор значений


(элементов массива), идентифицируемых по индексу или набору индексов,
принимающих целые (или приводимые к целым) значения из некоторого
заданного непрерывного диапазона.
Сложность основных операций для массива длины n:
• Изменение элемента (по индексу): O(1).
• Вставка элемента (в произвольное место): O(n).
• Удаление элемента: O(n).
• Поиск элемента: O(n).
• Доступ по индексу: O(1).
• Дополнительный расход памяти: 0.
Достоинства: постоянное время доступа по индексу, эффективное
использование памяти, локальность.
Недостатки: невозможность изменения размера в процессе работы
программы, большое время поиска.
Динамический массив – массив, размер которого может изменяться во
время выполнения программы.
Пусть a.size — число элементов в динамическом массиве a; a.capacity —
объем выделенной памяти (в элементах).
Добавление элемента x в конец дин. массива а:
5
Добавление элемента x в конец дин. массива а:
func InsertEnd(a,x)
{
if (a.size==a.capacity)
{
Выделяем память k*capacity; O(1)
a.capacity= k*a.capacity; O(1)
Копируем туда старый массив; O(n)
Освобождаем память; O(1)
}
a[a.size]=x;
a.size=a.size+1;
}

Сложность добавления элемента в динамический массив


В худшем случае T(n) = O(n), где n – число элементов в массиве.
В среднем случае. Начнем с пустого массива и последовательно добавим
n элементов. При этом половина элементов перемещается один раз, четверть —
два раза, и т.д. Общее число перемещений:
log n
i∙ n
M =∑ =2 n
i=1 2i
Значит, средняя сложность:
k ∙M
T m ( n )= =O(1)
n
Стек – это структура данных, позволяющая добавлять и получать элемент
только в начало. Принцип LIFO. Стек можно реализовать на основе
односвязного списка или массива. В стеке доступен 1 элемент: добавленный
последним.
func push(s,x)
{
s.top=s.top+1;
s[s.top]=x;
}
func pop(s)
{
if(s.top<0)
error:”underflow”;
else
{
s.top=s.top-1;
return s[s.top+1];
}

Стек на основе массива


Массив S[1..n]. Наряду с ним будем хранить число S.top — индекс
последнего добавленного в стек элемента. S[1] — нижний элемент стека (дно).
6
S[S.top] — верхний элемент стека (вершина). Если S.top = 0, то стек пуст. Если
S.top = n, то попытка добавления элемента приводит к переполнению.
Поиск: O(n)
Очередь – это структура данных, добавление элементов в которой
происходит при помощи метода enqueuer (в конец), получение и удаление
элементов – при помощи метода dequeuer (начало). Метод FIFO.
Очередь на основе массива
func Enqueue(Q, x)
{
Q[Q.tail]=x;
if(Q.tail==Q.length-1)
Q.tail=1
else
Q.tail=Q.tail+1;
}
func Dequeue(Q)
{
x = Q[Q.head];
if(Q.head == Q.length-1)
Q.head =1
else
Q.head = Q.head +1;
return x;
}
У очереди Q есть голова (элемент с индексом Q.head) и хвост (элемент с
индексом Q.tail). Очередь состоит из элементов массива с индексами Q.head,
Q.head + 1, . . . , Q.tail − 1 (Предполагается, что массив свернут в кольцо).

Связный список – это структура данных, состоящая из узлов, каждый из


которых содержит как данные, так и ссылку на следующий элемент.
А двусвязный список – на предыдущий элемент ещё.
Элемент списка содержит два поля: ссылку на следующий элемент и
данные.
Пусть x — элемент списка, x.next — следующий элемент, x.key —
полезные данные (ключ).
Пустую ссылку будем обозначать nil.
L.head — голова списка L (т.е. первый элемент). Если L.head = nil, то
список пуст.
func InsertAfter(node, x)
{
x.next = node.next;
node.next=x;
}
func RemoveAfter(node)
{

7
t = node.next;
node.next=t.next;
удалить t из памяти;
}

Двухсвязный список – это структура данных, которая состоит из узлов,


состоящих из данных (key), ссылки на предыдущий элемент (prev) и ссылки на
следующий элемент (next).
Плюсы списков: переменный размер, быстрая вставка и удаление
элементов.
Минусы: повышенный расход памяти, большое время поиска.
func InsertAfter(node, x)
{
x.next = node.next;
x.prev=node;
node.next=x;
x.next.prev=x;
}
func Remove(node)
{
node.prev.next=node.next;
node.next.prev=node.prev;
удалить node из памяти;
}

Хэш-таблицы. Хэш-функции.
Хэш-таблица – это структура данных, реализующая интерфейс
ассоциативного массива. Она позволяет хранить пары ключ-значение и
выполнять три операции: добавление, удаление, поиск.
Хэш-функция – это функция h: U  Zm, где U – множество всех
возможных ключей, m << |U|. Хэш-функция позволяет выполнять алгоритм
преобразования массива входных данных произвольной длины в выходную
битовую строку установленной длины.
Случай, при котором хэш-функция преобразует несколько разных ключей
в одинаковые хэш-коды, называется коллизией ((x1, x2): x1≠ x2, но h(x1) = h(x2)).
Методы борьбы с коллизиями в хэш-таблицах:
• метод цепочек (метод прямого связывания);
• метод открытой адресации.
При использовании метода цепочек в хеш-таблице хранятся пары
«связный список ключей» — «хэш-код». Для каждого ключа хеш-функцией
вычисляется хэш-код; если хэш-код был получен ранее (для другого ключа),
ключ добавляется в существующий список ключей, парный хэш-коду; иначе
создаётся новая пара «список ключей» — «хэш-код», и ключ добавляется в
8
созданный список. В общем случае, если имеется N ключей и M списков,
средний размер хеш-таблицы составит N/M. В этом случае при поиске по
таблице по сравнению со случаем, в котором поиск выполняется
последовательно, средний объём работ уменьшится примерно в M раз.
Второй распространенный метод — открытая индексация. Это значит, что
пары ключ-значение хранятся непосредственно в хэш-таблице. А алгоритм
вставки проверяет ячейки в некотором порядке, пока не будет найдена пустая
ячейка. Порядок вычисляется на лету.
Алгоритмы открытой адресации:

 Линейное пробирование: ячейки хеш-таблицы последовательно


просматриваются с некоторым фиксированным интервалом k между
ячейками (обычно k = 1), то есть i-й элемент последовательности проб — это
ячейка с номером a_l = (hash(x) + ik) mod N. Для того, чтобы все ячейки
оказались просмотренными по одному разу, необходимо,
чтобы k было взаимно-простым с размером хеш-таблицы.
 Квадратичное пробирование: интервал между ячейками с каждым шагом
увеличивается на константу. Если размер хеш-таблицы равен степени двойки
(N = 2p), то одним из примеров последовательности, при которой каждый
элемент будет просмотрен по одному разу, является: hash(x) mod N, (hash(x)
+ 1*1) mod N, (hash(x) + 2*2) mod N, (hash(x) + 3*3) mod N… (a_q =
(hash(x) + i2k) mod N).
 Двойное хеширование: интервал между ячейками фиксирован, как при
линейном пробировании, но, в отличие от него, размер интервала
вычисляется второй, вспомогательной хеш-функцией(например, a_dh = h1(k)
+i(h2(k)) mod N, а значит, может быть различным для разных ключей.
Значения этой хеш-функции должны быть ненулевыми и взаимно-простыми
с размером хеш-таблицы, что проще всего достичь, взяв простое число в
качестве размера, и потребовав, чтобы вспомогательная хеш-функция
принимала значения от 1 до N — 1.
Пусть m – длина массива, n – число элементов в таблице. Тогда
коэффициент заполненности  = n/m.

Лучший Средний Худший


Цепочки Открытая
адресация
Поиск О(1) О(1+) O(1/(1-)) O(N)
Вставка О(1) О(1+) O(1/(1-)) O(N)
Удаление О(1) О(1+) O(1/(1-)) O(N)

9
Двоичные деревья поиска. Основные операции над двоичными
деревьями поиска.

Двоичное дерево поиска – это связный ориентированный ациклический


граф, у которого есть корневой узел. Каждый узел имеет до двух дочерних
узлов. В двоичном дереве поиска выполняется свойство упорядоченности: пусть
x — произвольная вершина двоичного дерева поиска. Если вершина y
находится в левом поддереве вершины x, то y.key ≤ x.key. Если y находится в
правом поддереве x, то y.key ≥ x.key
Узел двоичного дерева поиска представляет собой структуру со
следующими полями:
• Ключ key;
• Дополнительные данные;
• Указатель на левого потомка left;
• Указатель на правого потомка right;
• Указатель на родителя p.
Теорема: если вершина двоичного дерева поиска имеет двоих детей, то
следующая за ней вершина не имеет левого ребенка, а предшествующая —
правого.
Основные операции над двоичными деревьями поиска:
Память – O(n)/O(n)
Поиск – O(log n)/O(n)
Вставка – O(log n)/O(n)
Удаление – O(log n)/O(n)

func InorderTreeWalk(root){
if (root != null){
InorderTreeWalk(root.left);
print root.key;
InorderTreeWalk(root.right);
}
}

//Печать ключей по неубыв. O(n)


func TreeMinimum(root){
x=root;
while(x.left != null)
x = x.left;
return x;
}
//Максимум аналогично

//Поиск эл-та по ключу в итер. виде


func TreeSearchIterative(root, key){
x =root;

10
while(( x != null) and (key != x.key)){
if (key < x.key)
x = x.left;
else
x = x.right;
}
return x;
}

//В рекурсивном виде


func TreeSearch(root, key){
if (root == null)
return null;
if(root.key == key)
return root;
if(key < root.key)
return TreeSearch(root.left, ket);
else
return TreeSearch(root.right, key);
}
O(h)

//Добавление элемента
func TreeInsert(T, z){
y = null;
x = T.root;
while( x != null)
{
y = x;
if (z.key < x.key)
x = x.left;
else
x = x.right;
}
z.p = y;
if (y == null)
T.root = z;
else if (z.key < y.key)
y.left = z;
else
y.right = z;
}

Если вершина х имеет правого ребенка, то вершина, следующая за х не имеет левого ребенка
//Удаление

func TreeDelete(T, z){


if ((z.left == null) or (z.right == null))
y=z;
else
y= TreeSuccessor(z);
if(y.left != null)
x = y.left;

11
else
x=y.right;
if (y.p == null)
T.root = x;
else if (y == y.p.left)
y.p.left = x;
else
y.p.right = x;
if(y != z)
z.key = y.key;
return y;
}

Красно-черные деревья. Основные операции над красно-черными


деревьями.
Красно-чёрное дерево (red-black tree) — это двоичное дерево поиска,
вершины которого разделены на красные (red) и чёрные (black). Таким образом,
каждая вершина хранит один дополнительный бит — её цвет
Свойства красно-черного дерева:
• каждая вершина либо красная, либо черная;
• корень дерева всегда черный;
• оба ребенка красной вершины черные;
• все пути, идущие от корня к листьям, содержат одинаковое
количество черных вершин.
Черная высота bh(x) данной вершины х – это число черных вершин на
любом пути от вершины x к листу (не считая саму вершину x) этой вершины.
Чёрной высотой дерева называется чёрная высота его корня.
Теорема: поддерево с корнем в x содержит по меньшей мере 2 bh(x) − 1
внутренних вершин.
∷ Для листьев чёрная высота равна 0 и утверждение выполняется.
Пусть теперь вершина x не является листом и имеет чёрную высоту k.
Тогда оба её ребёнка имеют чёрную высоту не меньше k − 1 (красный ребёнок
будет иметь высоту k, чёрный — k − 1). По предположению индукции левое и
правое поддеревья вершины x содержат не менее 2 k−1 − 1 вершин, и потому
поддерево с корнем в x содержит по меньшей мере 2 k−1 − 1 + 2k−1 − 1 + 1 = 2k − 1
внутренних вершин.∎
Теорема: красно-чёрное дерево с n внутренними вершинами имеет высоту
не больше 2log(n + 1).
∷Обозначим высоту дерева через h. Согласно свойствам красно-чёрного
дерева, не менее половины вершин на пути от корня к листу, не считая корень,
составляют чёрные вершины. Поэтому, чёрная высота дерева не меньше h/2.
Тогда n ≥ 2h/2 − 1. Логарифмируя, получим log(n + 1) ≥ h/2, или h ≤ 2log(n + 1).∎
Поиск и другие операции доступа к данным (Search, Minimum, Maximum,
Successor, Predecessor) имеют сложность O(h) = O(2 log2 (n + 1)) = O(log n).
12
Вставка элемента:
1. Добавляем элемент как в ДДП
2. Красим добавленную вершину в красный цвет
3. Вызываем для добавленной вершины процедуру коррекции
4. Красим корень дерева в черный цвет
Процедура коррекции для добавляемой вершины:
1. Отец черный - делать ничего не нужно
2. Случай, когда "дядя" вставляемого узла красный. Тогда просто
перекрашиваем "отца" и "дядю" в чёрный цвет, а "деда" — в красный. В
таком случае черная высота в этом поддереве одинакова для всех листьев
и у всех красных вершин "отцы" черные. Проверяем, не нарушена ли
балансировка. Если в результате этих перекрашиваний мы дойдём до
корня, то в нём в любом случае ставим чёрный цвет.

3. "Дядя" чёрный. Если выполнить только перекрашивание, то может


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

4. Отец — красный, а дядя — черный. Текущий узел — правый потомок


отца, а отец — левый потомок деда. Производится левое вращение. (Оба
ребенка красной вершины — черные) все еще нарушено. Изменяется
положение двух красных узлов. Далее – как в (3).

Удаление элемента:

13
Замечание: при удалении вершины из двоичного дерева поиска, мы всегда
удаляем вершину с не более чем одним нелистовым ребенком.

• Если у вершины нет детей, то изменяем указатель на неё у родителя


на nil;
• Если у неё только один ребёнок, то делаем у родителя ссылку на него
вместо этой вершины.
• Если же имеются оба ребёнка, то находим вершину со следующим
значением ключа. У такой вершины нет левого ребёнка (так как такая
вершина находится в правом поддереве исходной вершины и она самая
левая в нем, иначе бы мы взяли ее левого ребенка. Иными словами
сначала мы переходим в правое поддерево, а после спускаемся вниз в
левое до тех пор, пока у вершины есть левый ребенок). Удаляем уже эту
вершину описанным во втором пункте способом, скопировав её ключ в
изначальную вершину.

Проверим балансировку дерева. Так как при удалении красной вершины


свойства дерева не нарушаются, то восстановление балансировки потребуется
только при удалении чёрной. Рассмотрим ребёнка удалённой вершины.

1. Если брат этого ребёнка красный, то делаем вращение вокруг ребра


между отцом и братом, тогда брат становится родителем отца. Красим
его в чёрный, а отца — в красный цвет, сохраняя таким образом черную
высоту дерева. Хотя все пути по-прежнему содержат одинаковое
количество чёрных узлов, сейчас x имеет чёрного брата и красного отца.
Таким образом, мы можем перейти к следующему шагу.

2. Если брат текущей вершины был чёрным, то получаем три случая:

• Оба ребёнка у брата чёрные. Красим брата в красный цвет и рассматриваем


далее отца вершины. Делаем его черным, это не повлияет на количество
чёрных узлов на путях, проходящих через b, но добавит один к числу
чёрных узлов на путях, проходящих через x, восстанавливая тем самым
влияние удаленного чёрного узла. Таким образом, после удаления вершины
черная глубина от отца этой вершины до всех листьев в этом поддереве
будет одинаковой.

 Если у брата правый ребёнок чёрный, а левый красный, то перекрашиваем


брата и его левого сына и делаем вращение. Все пути по-прежнему содержат

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

 Если у брата правый ребёнок красный, то перекрашиваем брата в цвет отца,


его ребёнка и отца - в чёрный, делаем вращение. Поддерево по-прежнему
имеет тот же цвет корня, поэтому свойство 3 и 4 не нарушаются. Но
у xx теперь появился дополнительный чёрный предок: либо aa стал чёрным,
или он и был чёрным и bb был добавлен в качестве чёрного дедушки. Таким
образом, проходящие через xx пути проходят через один дополнительный
чёрный узел. Выходим из алгоритма.

Продолжаем тот же алгоритм, пока текущая вершина чёрная и мы не дошли


до корня дерева. Из рассмотренных случаев ясно, что при удалении
выполняется не более трёх вращений.

Сложность коррекции O(log n)


func RotateLeft(T, x){
y = x.right
x.right = y.left
if (y.left != null)
y.left.p = x;
y.p = x.p;
if (x.p = null)
T.root = y;
else if (x = x.p.left)
x.p.left = y;
else
x.p.right = y;
y.left = x;
x.p = y;
}

//Вставка
func RBInsert(T, z){
y = null;
x = T.root;
while (x != null){
y = x;
if (z.key < x.key)
x = x.left;
15
else
x = x.right;
}
z.p = y;
if (y == null)
T.root = z;
else if (z.key < y.key)
y.left = z;
else
y.right = z;
z.left = null;
z.color = red;
RBInsertFixup(T, z);
}

func RBInsertFixup(T, z){


while (z.p.color == red){
if (z.p == z.p.p.left){
y = z.p.p.right;
if (y.color = red){
z.p.color = black;
y.color = black;
z.p.p.color = red;
z = z.p.p;
}
else{
if (z == z.p.right){
z = z.p;
LeftRotate(T, z);
}
z.p.color = black;
z.p.p.color = red;
RightRotate(T, z.p.p);
}
}
else{ то же, что и в if, но с заменой left на right и наоборот }
T.root.color = black;
}
}

AVL-деревья. Основные операции над AVL-деревьями.


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

Вставка, поиск и удаление выполняются за О(log n) в любом случае. По


памяти – О(n).

Теорема: максимальная высота AVL-дерева при заданном числе узлов:


h ≤ ¿.

16
∷ Пусть Nh – минимальное число вершин в AVL-дереве с высотой h.
h 0 1 2 3 4
Nh 1 2 4 7 12

Nh = Nh-1 + Nh-2 + 1

( ) ( )
k k
1+ √ 5 1− √ 5

2 2
F ФИБ=
√5
Nh = Fh+3 – 1.

Пусть Nh-1 = Fh+2 – 1 и Nh-2 = Fh+1 – 1, тогда Nh = Nh-1 + Nh-2 + 1 = Fh+2 – 1 +


Fh+1 – 1 + 1 = Fh+3 – 1.

( ) ( )
h+3 h+3
1+ √ 5 1−√ 5

2 2
N h= −1
√5

( ) ( )
h+3
1 1−√ 5 1
N h= −1+ 1+
√5 2 √5

h≤
(
log n+1+
1
√5 )
+log ( √ 5 )
−3 ≈ 1 , 44 log n−3 ∎
log(1+ √ 5
2 )
Вставка узла:

1. Вставка узла как в обычном двоичном дереве поиска (с помощью


рекурсии):

а) Спуск вниз по пути поиска, пока не убедимся, что такого узла в дереве
нет.

б) Включения новой вершины в дерево.

2. Выход из рекурсии с проверкой показателя сбалансированности и


балансировкой узла при необходимости.

Удаление узла:

1. Поиск элемента(узла).
2. В случае нахождения элемента запоминаем его корни правого r и левого l
поддеревьев:

a) Если вершина — лист, то удалим её и вызовем балансировку всех её


предков в порядке от родителя к корню.

17
б) Если у найденный узел p не имеет правого поддерева, то по
свойству АВЛ-дерева слева у этого узла может быть только один
единственный дочерний узел (дерево высоты 1). Необходимо удалить
узел p и вернуть в качестве результата указатель на левый дочерний узел
узла p. Иначе в правом поддереве находим узел min с наименьшим
ключом и заменяем удаляемый узел p на найденный узел min. По
свойству двоичного дерева поиска этот ключ (минимальный в правом
поддереве от удаляемого узла) находится в конце левой ветки, начиная от
корня поддерева. По свойству АВЛ-дерева у минимального элемента
справа либо есть единственный узел, либо он отсутствует. Cлева к min
подвешиваем l, справа — r. При выходе из рекурсии производим
балансировку каждого пройденного узла.

Процедура балансировки

Относительно АВЛ-дерева балансировкой вершины называется операция,


которая в случае разницы высот левого и правого поддеревьев = 2, изменяет
связи предок-потомок в поддереве данной вершины так, что разница становится
<= 1, иначе ничего не меняет. Указанный результат получается вращениями
поддерева данной вершины.

Всего существует 4 вида вращений:

1. Большое левое вращение. Данное вращение используется тогда, когда


(высота b-поддерева — высота L) = 2 и высота c-поддерева > высота R.

2. Малое правое вращение. Данное вращение используется тогда, когда


(высота b-поддерева — высота R) = 2 и высота С <= высота L.

3. Большое правое вращение. Данное вращение используется тогда, когда


(высота b-поддерева — высота R) = 2 и высота c-поддерева > высота L.
18
Косые деревья (Splay tree). Основные операции над косыми
деревьями. Основные теоремы о косых деревьях.
Косое дерево – двоичное дерево поиска, в котором поддерживается
свойство сбалансированности. В нем не требуется память для хранения
дополнительной информации. Основные операции модифицируют структуру
дерева таким образом, чтобы элемент, к которому осуществлялся доступ в
последний раз, оказался корнем дерева.

Вставка, поиск и удаление за О(log n).

Перекос дерева — операция изменения структуры дерева, так что


определенная вершина становится корнем дерева.

"Splay" делится на 3 случая:

1. zig: если p — корень дерева с сыном x, то совершаем один поворот


вокруг ребра (x,p), делая x корнем дерева. Данный случай является
крайним и выполняется только один раз в конце, если изначальная
глубина x была нечетной.

2. zig-zig: если p — не корень дерева, а x и p — оба левые или оба правые


дети, то делаем поворот ребра (p,g), где g отец p, а затем поворот ребра
(x,p).

19
3. zig-zag: если p — не корень дерева и x — левый ребенок, а p —
правый, или наоборот, то делаем поворот вокруг ребра (x,p), а затем
поворот нового ребра (x,g), где g — бывший родитель p.

Поиск:

• Выполнить поиск как обычно.


• Если вершина найдена, выполнить для нее процедуру Splay. Если не
найдена — выполнить Splay для последней вершины на пути поиска.

20
Добавление вершины

• Выполнить добавление вершины как обычно.


• Выполнить для добавленной вершины процедуру Splay.

Удаление вершины х из дерева Т:

• Вызывается Splay(T, x).


• Удаляется корень из дерева. Остаются два поддерева: левое L и правое R.
• В L ищется вершина z с максимальным ключом.
• Вызывается Splay(T, z).
• Теперь z — корень дерева, но у него нет правого поддерева. В качестве
правого поддерева пристыковывается дерево R.
Алгоритм перекоса (splay)
func splay(T, x){
while (x != T.root){
if (x.p == T.root)
zig(x);
else{
if (x и z.p - левые)
zigzigL(x)
else if (x и x.p - правые)
zigzigR(x)
else if (x - левый, а x.p - правый)
zigzagL
else if (x - правый, а x.p - левый ребенок)
zigzagR(x);
}
}
}

Анализ SplayTree

w(x) — вес вершины x.

s(x) — сумма весов всех вершин в поддереве с корнем x.

r(x) = log s(x) — ранг вершины x.

Будем обозначать w', s', r' — соответствующие вершины после выполнения


Splay.

Лемма (о доступе): учетное время выполнения процедуры Splay(T, x) для дерева


T с корнем t не превышает 3(r(t) − r(x)) + 1 = O(log(s(t)/s(x))).

Док-во: Будем считать, что одно вращение происходит за единицу времени.


Случай Zig. Одно вращение. Изменяются ранги только двух вершин: x и y.
Учетное время: 1 + r’(x) + r'(y) − r(x) − r(y) ≤ 1 + r'(x) − r(x) ≤ 1 + 3(r'(x) − r(x)),
поскольку r(y) не уменьшился, а r(x) — не увеличился.

21
Случай Zig-Zig. Два вращения. Изменяются ранги только трех вершин: x, y, z.
Учетное время: 2 + r'(x) + r'(y) + r'(z) − r(x) − r(y) − r(z) = 2 + r'(y) + r'(z) − r(x) −
r(y) ≤ 2 + r'(x) + r'(z) − 2r(x), поскольку r'(x) = r(z), r'(x)  r'(y) и r(y)  r(x).

Теперь покажем, что 2 + r'(x) + r'(z) − 2r(x) ≤ 3(r’(x) − r(x)). Действительно, это
эквивалентно тому, что 2r'(x) − r(x) – r'(z)  2. Легко видеть, что log x + log y при
x + y ≤ 1 имеет максимум, равный −2 при x = y = 1/2 . Следовательно, учитывая,
что s(x) + s'(z) ≤ s'(x), выполняется −(2r'(x) − r(x) – r'(z)) = log(s(x)/s'(x) ) +
log(s'(z)/s'(x)) ≤ −2.

Случай Zig-Zag. Учетное время: 2 + r’(x) + r'(y) + r'(z) − r(x) − r(y) − r(z) ≤ 2 +
r’(y) + r'(z) − 2r(x), поскольку r’(x) = r(z) и r(x) ≤ r(y).

Теперь покажем, что 2 + r'(y) + r'(z) − 2r(x) ≤ 2(r'(x) − r(x)). Это эквивалентно
тому, что 2r'(x) – r'(y) – r'(z)  2. Это доказывается аналогично предыдущему
случаю, если учесть, что s'(y) + s'(z) ≤ s'(x). Итак, учетное время в этом случае
составляет 2(r'(x) − r(x)) ≤ 3(r'(x) − r(x)). Если теперь просуммировать
полученные оценки учетного времени по всем шагам алгоритма перекоса,
получим оценку 3(r'(t) − r(x)) + 1 = 3(r(t) − r(x)) + 1, ЧТД.

Теорема (о балансе): Пусть T — дерево, состоящее из n вершин.


Последовательность из m операций с деревом T выполняется за время O((m +
n)log n).

Замечание: Рассмотрим последовательность из m операций над деревом,


состоящим из n вершин. Если вес каждой вершины не меняется, то суммарное
n n
W
уменьшение потенциала не превышает Ф0 −Фm =∑ , где W =∑ w (i).
i=1 w(i) i=1

Док-во: в качестве весовой функции зададим функцию w(x) = 1/n . Тогда W = 1,


учетное время каждой операции: 3logn + 1. После выполнения
последовательности, потенциал уменьшается на nlogn. Отсюда следует
утверждение теоремы.

Теорема (о рабочем наборе): пусть T — дерево, состоящее из n вершин.


Рассмотрим последовательность из m операций. Пусть xj — вершина, к которой
осуществляется доступ Пусть tj — число различных элементов не равных x j к
которым осуществлялся доступ перед j-ой операцией, после последнего
обращения к элементу xj. Тогда последовательность выполнится за время O ¿

22
Двоичные кучи. Очереди с приоритетами.
Двоичная куча – это, по сути, бинарное дерево поиска, хранящееся в
массиве A[1],…,A[heapsize], где корню соответствует индекс 1, левый ребенок
имеет индекс 2i, а правый – 2i+1.

Выполняется ОСК: MaxHeap – A[Parent(i)]  A[i], MinHeap – A[Parent(i)]


≤ A[i]. Все уровни, кроме последнего заполнены. Последний заполняется справа
налево.

Двоичная куча - это СД кот предсталвяет собой ДД хранящее


в массиве каждый узел которого обладает свойством что он больше
своих потомков при этом в массиве располагается по индексам
2n+1 от левой и 2n+2 правый потомок
Рассмотрим алгоритмы, применяющиеся при построении двоичной кучи.

Heapify — вспомогательный алгоритм, получающий на вход массив A и


индекс i и делающий так, чтобы основное свойство кучи для поддерева с корнем
i выполнялось. Предполагается, что поддеревья с корнями Left(i) и Right(i)
обладают основным свойством кучи. Сложность O(log n).

BuildHeap. Пусть дан массив A[1...n], из которого мы хотим получить


двоичную кучу. Процедура BuildHeap позволяет сделать это за O(n). Сначала
необходимо построить дерево из всех элементов массива, не заботясь о
соблюдении основного свойства кучи, а потом вызвать метод heapify для всех
вершин, у которых есть хотя бы один потомок. Потомки гарантировано есть у
первых heapSize/2 вершин.
Время работы процедуры Heapify(A, i) пропорционально высоте вершины
i. Заметим, что если t — высота дерева, то n < 2 t+1. Число вершин высоты h не
превышает 2t−h. Тогда время работы BuildHeap можно оценить как:
t

∑ 2t−h O ( h )=O ¿ ¿.
h=0

Добавление элемента. (О(log n)) Новый элемент добавляется на


последнее место в массиве, то есть позицию с индексом heapSize. Если ОСК
будет нарушено, то тогда новый элемент поднимается с каждым шагом на один
уровень, пока ОСК не будет соблюдено.
Удаление (извлечение) max/min элемента (O(log n)). В
упорядоченном max/min-heap максимальный/минимальный элемент всегда
хранится в корне. Восстановить упорядоченность двоичной кучи после
удаления элемента можно, поставив на его место последний элемент и
вызвав heapify для корня, то есть упорядочив все дерево.
Очередь с приоритетами

23
Очередь с приоритетами (priority queue) — это множество S, элементами
которого являются пары <key, α>, где key — число, определяющее приоритет
элемента, а α — связанная с ним информация. Для простоты изложения, мы
будем считать, что элементами множества являются только ключи. При
извлечении элемента из очереди, каждый раз извлекается элемент с наибольшим
приоритетом.
Реализация основана на куче. Для добавления элемента используется
алгоритм HeapInsert. Для извлечения элемента — алгоритм HeapExtractMax. Обе
эти операции требуют времени O(log n).
func Heapify(A, i){
l = left(i);
r = right(i);
if (l < heapsize and A[l]> A[i])
largest = i;
else largest = i;
if (r < heapsize and A[r]>A[largest])
largest = r;
if (largest != i){
swap(A[i], A[largest]);
Heapify(A, largest);
}
}

func BuildHeap(A){
heapsize = length(A);
for (i = heapsize / 2; i >= 0; i--)
Heapify(A, i);
}

Алгоритм пирамидальной сортировки.


Агоритм сортировки с помощью кучи состоит из двух частей. Сначала
вызываетя процедура Build-Heap, после выполнения которой массив становится
кучей. Идея второй части проста: максимальный элемент массива теперь
находится в корне дерева (A[1]). Его следует поменять с A[n], уменьшить размер
кучи на 1 и восстановить основное свойство в корневой вершине (поскольку
поддеревья с корнями Left(1) и Right(1) не утратили основного свойства кучи,
это можно сделать с помощью процедуры Heapify). После этого в корне будет
находиться максимальный из оставшихся элементов. Так делается до тех пор,
пока в куче не останется всего один элемент.
procedure HeapSort(A)
BuildHeap(A)
for i := length(A) downto 2 do
Поменять A[1] и A[i].
heapsize := heapsize − 1
Heapify(A,1)

24
Построение дерева требует времени O(n). В цикле n раз производится
обмен корня с последним элементом и вызов Heapify, что занимает время O(log
n). Таким образом, сложность пирамидальной сортировки составляет О(n log n).

Алгоритм сортировки слиянием


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

Исходная последовательность рекурсивно разбивается на половины


функцией MergeSort, пока не получим подпоследовательности по 1 элементу. Из
полученных подпоследовательностей формируем упорядоченные пары методом
слияния, затем - упорядоченные четверки и т.д.

Процедура слияния (Merge, сложность Θ(n)) предполагает объединение


двух предварительно упорядоченных подпоследовательностей
размерности n/2 в единую последовательность размерности n. Начальные
элементы предварительно упорядоченных последовательностей сравниваются
между собой, и из них выбирается наименьший. Соответствующий указатель
перемещается на следующий элемент. Процедура повторяется до тех пор, пока
не достигнут конец одной из подпоследовательностей. Оставшиеся элементы
другой подпоследовательности при этом передаются в результирующую
последовательность в неизменном виде.
procedure MergeSort(A, p, r) // Сортировка слиянием. Сортирует подмассив A[p . . . r]
if p<r then
q:=[(p+r)/2]
MergeSort(A,p,q)
MergeSort(A,q+1,r)
Merge(A,p,q,r)

Сложность сортировки слиянием:

T(n) = ¿, T(n) = O(n log n)

Алгоритм быстрой сортировки

Алгоритм быстрой сортировки состоит из трех шагов.


1. Выбрать опорный элемент массива. Опорный элемент можно выбирать
разными способами, но лучший вариант – выбирать случайно, т.к.
худший случай наступает тогда, когда при каждом шаге неправильно
делим.
25
2. Разбиение: перераспределение элементов в массиве.
3. Рекурсивно применить первые два шага к двум подмассивам слева и
справа от опорного элемента. Рекурсия не применяется к массиву, в
котором только один элемент или отсутствуют элементы.
(1) Будем считать, что сначала каждому элементу случайно
присваиваются различные ранги (от 1 до n), а на каждом шаге выбирается
элемент минимального ранга.
(2) Каждый элементу сопоставим его номер в отсортированном массиве
(3) Для каждых двух номеров i, j из {1,…,n} через p(i, j) обозначим
вероятность того, что элементы с этими номерами будут сравниваться друг с
другом. Например, p(i, i+1) = 1, поскольку соседние по номеру элементы
должны быть сравнены обязательно (сравнения с другими элементами их не
различают).
(4) Заметим, что p(i, i + 2) = 2/3. В самом деле, сравнения не произойдёт в
том и только том случае, когда из трёх элементов с номерами i, i+1 и i+2
элемент i + 1 имеет наименьший ранг. Аналогично, p(i, i+k) = 2/(k+1) (элементы
с номерами i и i+k сравниваются, если среди k + 1 элементов i, i+1,…, i+k один
из двух крайних имеет наименьший ранг).
(5) Мат. ожидание числа сравнений можно разбить в сумму ∑ m(i , j) мат.
ожиданий числа сравнений между элементами с номерами i и j. Но поскольку
два данных элемента сравниваются не более одного раза, m(i, j) = p(i, j). Таким
образом, получаем выражение для мат. ожидания числа сравнений:
2
∑ j−i+1
1 ≤i< j ≤n

(6) Группируя в этой сумме равные члены и учитывая, что 1 + 1/2 + 1/3 +
…+ 1/k = O(log k), получаем оценку O(n log n) для математического ожидания
времени работы быстрой сортировки.

Алгоритм арифметической сортировки (сортировки подсчетом)

Замечание: Алгоритм сортировки подсчётом (counting sort) применим,


если каждый из n элементов сортируемой последовательности — целое
неотрицательное число, меньшее заранее известного k.
Пусть исходная последовательность из n элементов, лежащих в диапазоне
от 0 до k, хранится в массиве А.
Последовательность действий:

26
1. Создадим вспомогательный массив C с индексами от 0 до k. Пройдемся
по исходному массиву А и запишем в C[i] количество элементов, равных
i.
2. Массив C превратим в массив индексов (P=(1,0,3,3)  P=(1,1,4,7))
3. Создадим массив B, в котором будет храниться отсортированный массив.
4. Заполним массив В.
Алгоритм имеет сложность не больше, чем O(n+k).
Данная сортировка является устойчивой, т.к. при записи одинаковых
элементов будет учитываться порядок, который был в изначальном массиве.
procedure CountingSort(A, B, k)
for i := 0 to k − 1 do C[i] := 0
for j := 0 to n − 1 do C[A[j]] := C[A[j]] + 1
for i := 1 to k − 1 do C[i] := C[i] + C[i − 1]
for j := n − 1 downto 0 do
B[C[A[j]]] := A[j]
C[A[j]] := C[A[j]] – 1

Понятие о гибридных сортировках. Алгоритм IntroSort. Принципы


алгоритма TimSort
Гибридные сортировки – сортировки, в которых используются сразу
несколько известных алгоритмов сортировки.
IntroSort – алгоритм сортировки, использующий быструю сортировку и
переключающийся на пирамидальную сортировку, когда глубина рекурсии
превысит некоторый заранее установленный уровень. Этот подход сочетает в
себе достоинства обоих методов с худшим случаем O(n log n) и
быстродействием, сравнимым с быстрой сортировкой. Так как оба алгоритма
используют сравнения, этот алгоритм также принадлежит классу сортировок на
основе сравнений.

TimSort. Алгоритм состоит из нескольких частей.

 Начало.
 Шаг 1. Входной массив разделяется на подмассивы фиксированной длины,
вычисляемой определённым образом.
 Шаг 2. Каждый подмассив сортируется сортировкой вставками, сортировкой
пузырьком или любой другой устойчивой сортировкой.
 Шаг 3. Отсортированные подмассивы объединяются в один массив с
помощью модифицированной сортировки слиянием.
 Конец.

1. Ищем упорядоченные или частично упорядоченные последовательности,


длиной не менее minrun. Сортируем их сортировкой вставками и добавляем
индексы начала и длину в стек

2. 𝑥,𝑦,𝑧 – 3 верхних элемента стека


27
Длина 𝑥 > длина 𝑦 + длина 𝑧
И длина 𝑦 > длина 𝑧
Сливаем 𝑥 и 𝑦
Иначе сливаем 𝑦 и min(𝑥,𝑧)

Алгоритм
Используемые понятия

 N — размер входного массива


 run — упорядоченный подмассив во входном массиве. Причём упорядоченный либо
нестрого по возрастанию, либо строго по убыванию. Т.е или «a0 <= a1 <= a2 <= ...»,
либо «a0 > a1 > a2 > ...»
 minrun — как было сказано выше, на первом шаге алгоритма входной массив будет
поделен на подмассивы. minrun — это минимальный размер такого подмассива. Это
число рассчитывается по определённой логике из числа N.

Шаг 0. Вычисление minrun.

Число minrun определяется на основе N исходя из следующих принципов:

1. Оно не должно быть слишком большим, поскольку к подмассиву


размера minrun будет в дальнейшем применена сортировка вставками, а она
эффективна только на небольших массивах
2. Оно не должно быть слишком маленьким, поскольку чем меньше подмассив — тем
больше итераций слияния подмассивов придётся выполнить на последнем шаге
алгоритма.
3. Хорошо бы, чтобы N \ minrun было степенью числа 2 (или близким к нему). Это
требование обусловлено тем, что алгоритм слияния подмассивов наиболее
эффективно работает на подмассивах примерно равного размера.

В этом месте автор алгоритма ссылается на собственные эксперименты, показавшие, что


при minrun> 256 нарушается пункт 1, при minrun < 8 — пункт 2 и наиболее эффективно
использовать значения из диапазона (32;65). Исключение — если N < 64,
тогда minrun = N и timsort превращается в простую сортировку вставкой. В данный
момент алгоритм расчёта minrun просто до безобразия: берём старшие 6 бит из N и
добавляем единицу, если в оставшихся младших битах есть хотя бы один ненулевой.

Шаг 1. Разбиение на подмассивы и их сортировка.

Итак, на данном этапе у нас есть входной массив, его размер N и вычисленное
число minrun. Алгоритм работы этого шага:

1. Ставим указатель текущего элемента в начало входного массива.


2. Начиная с текущего элемента, ищем во входном массиве run (упорядоченный
подмассив). По определению, в этот run однозначно войдет текущий элемент и
следующий за ним, а вот дальше — уже как повезет. Если получившийся подмассив

28
упорядочен по убыванию — переставляем элементы так, чтобы они шли по
возрастанию (это простой линейный алгоритм, просто идём с обоих концов к
середине, меняя элементы местами).
3. Если размер текущего run'а меньше чем minrun — берём следующие за
найденным run-ом элементы в количестве minrun — size(run). Таким образом, на
выходе у нас получается подмассив размером minrun или больше, часть которого (а в
идеале — он весь) упорядочена.
4. Применяем к данному подмассиву сортировку вставками. Так как размер подмассива
невелик и часть его уже упорядочена — сортировка работает быстро и эффективно.
5. Ставим указатель текущего элемента на следующий за подмассивом элемент.
6. Если конец входного массива не достигнут — переход к пункту 2, иначе — конец
данного шага.

Шаг 2. Слияние.

На данном этапе у нас имеется входной массив, разбитый на подмассивы, каждый из


которых упорядочен. Если данные входного массива были близки к случайным — размер
упорядоченных подмассивов близок к minrun, если в данных были упорядоченные
диапазоны (а исходя из рекомендаций по применению алгоритма, у нас есть основания на
это надеяться) — упорядоченные подмассивы имеют размер, превышающий minrun.
Теперь нам нужно объединить эти подмассивы для получения результирующего,
полностью упорядоченного массива. Причём по ходу этого объединения нужно
выполнить 2 требования:

1. Объединять подмассивы примерно равного размера (так получается эффективнее).


2. Сохранить стабильность алгоритма — т.е. не делать бессмысленных перестановок
(например, не менять два последовательно стоящих одинаковых числа местами).

Достигается это таким образом.

1. Создаем пустой стек пар <индекс начала подмассива>-<размер подмассива>. Берём


первый упорядоченный подмассив.
2. Добавляем в стек пару данных <индекс начала>-<размер> для текущего подмассива.
3. Определяем, нужно ли выполнять процедуру слияния текущего подмассива с
предыдущими. Для этого проверяется выполнение 2 правил (пусть X, Y и Z —
размеры трёх верхних в стеке подмассивов):
X>Y+Z
Y>Z
4. Если одно из правил нарушается — массив Y сливается с меньшим из массивов X и Z.
Повторяется до выполнения обоих правил или полного упорядочивания данных.
5. Если еще остались не рассмотренные подмассивы — берём следующий и переходим к
пункту 2. Иначе — конец.

Процедура слияния подмассивов

Как Вы помните, на втором шаге алгоритма мы занимаемся слиянием двух подмассивов в


один упорядоченный. Мы всегда соединяем 2 последовательных подмассива. Для их
29
слияния используется дополнительная память.

1. Создаём временный массив в размере меньшего из соединяемых подмассивов.


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

Основные свойства алгоритмов сортировки. Нижняя оценка


сложности сортировки сравнениями.

К основным свойствам алгоритмов сортировки относятся:


1. Сложность – функция зависимости объема работы, выполняемой
некоторым алгоритмом, от размера входных данных. Асимптотически
зависит от входа.
2. Память – на месте или требуется выделение дополнительной памяти.
3. Принцип – сравнения или нет.
4. Устойчивость – не изменяет порядок элементов с одинаковыми ключами.
5. Адаптивность – быстрее для частично отсортированных/
отсортированных массивов.
6. Способность к распараллеливанию.

Время Памят Принци Устойчивост Адаптивност Распа


Лучш Средн Худш ь п ь ь р
2
Пузырьком n n 1 сравн да да нет
Вставками n n2 1 сравн да да нет
Выбором n2 1 сравн да нет нет
Пирамидальная nlogn 1 сравн нет нет нет
Слиянием nlogn n n сравн да нет да
Быстрая nlogn n2 logn сравн нет нет да
Подсчетом n+k n+k не сравн да нет нет
Разрядная n*k/d n+2d не сравн нет нет нет
Intro nlogn logn сравн нет нет нет
Tim n nlogn n сравн да да да

Теорема (о нижней оценке для сортировки сравнениями): высота любого


решающего дерева, сортирующего n элементов, есть Ω(n log n). (продолжение

30
Пример дерева для алгоритма сортировки трех элементов.
Количество листьев:  n!
Имеет место формула Стирлинга:
n! √ 2 πn ( n/ e )n
Докажем ее усеченную версию:
Теорема: n!  (n/e)n.
n n n n
n (
Док-во: ln n !=∑ ln k ≥ ∫ lnxdx=n ln n−n+1, т.к. ∫ lnxdx=x ln x−x+C .  n ! ≥ e
n
n
≥ n /e )
k=1 1 1 e

Доказательство теоремы (о нижней оценке для сортировки сравнениями):


Среди листьев сортирующего дерева должны быть представлены все
перестановки n элементов, число листьев не менее n!. Двоичное дерево высоты
h имеет не более 2h листьев, имеем n!≤2h . Учитывая, что n!(n/e)n , получаем
(n/e)n≤2h. Логарифмируя, получаем: h  n log n − n log e = Ω(n log n).
Следствие. Пирамидальная сортировка и сортировка слиянием
асимптотически оптимальны.

Амортизационный анализ.

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


нескольких операций с какой-либо структурой данных (или, что равносильно,
среднего времени выполнения одной операции).
При амортизационном анализе каждой операции присваивается некоторая
учётная стоимость (amortized cost), которая может отличаться от реальной
сложности операции. Для любой последовательности операций фактическая
суммарная длительность всех операций не должна превосходить суммы их
учётных стоимостей. Для одной и той же структуры данных и одних и тех же
алгоритмов можно корректно назначить учётные стоимости различными
способами.
Существует три метода амортизационного анализа.
1. Метод группировки. Состоит в следующем: для каждого n оценивается
время T(n), требуемое на выполнение n операций (в худшем случае).
31
Время в расчёте на одну операцию, то есть отношение T(n)/n,
объявляется учётной стоимостью одной операции. При этом учётные
стоимости всех операций оказываются одинаковыми.
#Мультистэк: рассмотрим стек S с тремя доступными операциями:
push(S,x) – выполняется за единицу времени;
pop(S) – выполняется за единицу времени;
multipop(S,k) – k раз выполняется pop(S).
Анализ стека методом группировки: Оценим суммарную стоимость n
операций, применённых к изначально пустому стеку. Самая сложная
операция multipop стоит не более n, поскольку за n операций в стеке не
наберётся более n элементов). Следовательно, суммарная стоимость n
операций есть O(n2). Но это очень грубая оценка. Заметим, что
поскольку в начале стек был пуст, общее число реально выполняемых
операций pop, включая вызываемые из процедуры multipop не
превосходит общего числа операций push, которое не превосходит n.
Следовательно, стоимость последовательности из n операций push, Pop
и Multipop есть O(n). Поэтому, учетная стоимость каждой из операций
есть O(n)/n = O(1).
2. Метод потенциалов. Пусть над структурой данных предстоит
произвести последовательность из n операций. Di — состояние
структуры данных после i-й операции (D0 — исходное состояние).
Потенциал (потенциальная функция) — это функция Φ, отображающая
множество состояний структуры данных во множество R. Пусть c i —
реальная стоимость i-й операции. Учетной стоимостью i-й операции
объявим число c^i= ci + Φ(Di) − Φ(Di−1). Тогда суммарная учетная
n n
стоимость всех операций равна ∑ c^i =∑ c i +Ф ( Dn )−Ф ( D0 ).
i=1 i=1

Если Ф(Dn)  Ф(D0), то суммарная учетная стоимость даст верхнюю


оценку реальной стоимости последовательности n операций.
# В качестве потенциальной функции Φ возьмем количество элементов
в стеке. Поскольку мы начинаем с пустого стека, имеем Ф(D i)  0 =
Ф(D0). Поэтому, сумма учётных стоимостей оценивает сверху
реальную стоимость последовательности операций. Найдем теперь
учётные стоимости индивидуальных операций. Так как операция push
имеет реальную стоимость 1 и увеличивает потенциал на 1, её учётная
стоимость равна 1 + 1 = 2; операция Multipop(S, m) имеет стоимость m,
но уменьшает потенциал на m, поэтому её учётная стоимость равна m
− m = 0. Учетная стоимость операции Pop равна нулю. Итак, учётная
стоимость каждой операции не превосходит 2, поэтому стоимость
последовательности из n операций, есть O(n).
3. Метод предоплаты. Представим, что использование определенного
количества времени равносильно использованию определенного
количества монет (плата за выполнение каждой операции). В методе
предоплаты каждому типу операций присваивается своя учётная
стоимость. Эта стоимость может быть больше фактической, в таком
случае лишние монеты используются как резерв для выполнения
32
других операций в будущем, а может быть меньше, тогда
гарантируется, что текущего накопленного резерва достаточно для
выполнения операции. Для доказательства оценки средней
амортизационной стоимости O(f(n,m)) нужно построить учётные
стоимости так, что для каждой операции она будет составлять
O(f(n,m)). Когда для последовательности из n операций суммарно
будет затрачено n* O(f(n,m)) монет, следовательно, средняя
амортизационная стоимость операций
n

будет
∑ ti n ∙ O(f ( n , m ))
α = i=1 = =O ( f ( n ,m ) ) .
n n
# При выполнении операции push будем использовать две монеты —
одну для самой операции, а вторую — в качестве резерва. Тогда для
операций pop и multipop учётную стоимость можно принять равной
нулю и использовать для удаления элемента монету, оставшуюся
после операции push.
Таким образом, для каждой операции требуется O(1) монет, значит,
средняя амортизационная стоимость операций a=O(1).

Динамическое программирование.
Динамическое программирование — метод решения задачи путём её
разбиения на несколько одинаковых подзадач, рекуррентно связанных между
собой. Самым простым примером будут числа Фибоначчи — чтобы вычислить
некоторое число в этой последовательности, нам нужно сперва вычислить
третье число, сложив первые два, затем четвёртое таким же образом на основе
второго и третьего, и так далее.

Необходимо наличие значений начальных состояний. В результате


долгого разбиения на подзадачи необходимо свести функцию либо к уже
известным значениям (как в случае с Фибоначчи — заранее определены первые
два члена), либо к задаче, решаемой элементарно.
Последовательность Фибоначчи Fn задается формулами: F1 = 1, F2 = 1, Fn
= Fn – 1 + Fn – 2 при n > 1. Необходимо найти Fn по номеру n.
Один из способов решения, который может показаться логичным и
эффективным, — решение с помощью рекурсии:
int F(int n) {
if (n < 2) return 1;
else return F(n - 1) + F(n - 2);
}
Используя такую функцию, мы будем решать задачу «с конца» — будем
шаг за шагом уменьшать n, пока не дойдем до известных значений.
Но как можно заметить, такая, казалось бы, простая программа уже при n
= 40 работает заметно долго. Это связано с тем, что одни и те же
промежуточные данные вычисляются по несколько раз — число операций
33
нарастает с той же скоростью, с какой растут числа Фибоначчи —
экспоненциально.
Один из выходов из данной ситуации — сохранение уже найденных
промежуточных результатов с целью их повторного использования:
int F(int n) {
if (A[n] != -1) return A[n];
if (n < 2) return 1;
else {
A[n] = F(n - 1) + F(n - 2);
return A[n];
}
}
Приведенное решение является корректным и эффективным. Но для
данной задачи применимо и более простое решение:
F[0] = 1;
F[1] = 1;
for (i = 2; i < n; i++) F[i] = F[i - 1] + F[i - 2];
Такое решение можно назвать решением «с начала» — мы первым делом
заполняем известные значения, затем находим первое неизвестное значение
(F3), потом следующее и т.д., пока не дойдем до нужного.
Именно такое решение и является классическим для динамического
программирования: мы сначала решили все подзадачи (нашли все Fi для i < n),
затем, зная решения подзадач, нашли ответ (Fn = Fn – 1+ Fn – 2, Fn – 1 и Fn – 2
уже найдены).
Динамическое программирование:
- восходящее — это простое запоминание результатов решения тех подзадач,
которые могут повторно встретиться в дальнейшем;
- нисходящее – включает в себя переформулирование сложной задачи в виде
рекурсивной последовательности более простых подзадач.

Нисходящий — это способ по рекурсии - мы берем сложную задачу, разбиваем


её на чуть менее сложную, потом еще, и так до элементарной.

Восходящий - берет задачу и сначала идет с элементарной формы этой задачи,


постепенно усложняя её и на ходу используя уже вычисленные результаты
подзадач

Жадные алгоритмы

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


локально наилучшее решение.
Рассмотрим на примере.
# Задача о выборе заявки
(s ¿ ¿ i , f i )¿ – заявка, где si – начало, а f i – окончание
¿ ¿– удовлетворяет max числу заявок, т.к. начало одной = концу другой
заявки.
34
GreedyActivitySelector(S, f)
// заявки отсортированы по возрастанию времени окончания.
n = length(S)
A = {1}
i=1
for i = 2 to n
if si f j
A = A {i}
j=i
return A
Доказательство корректности: пусть z – оптимальный набор заявок,
отсортированный по не убыванию времени окончания, x – набор всех заявок,
отсортированный.
Заявку z 1 можно заменить на x 1 оптимальный набор можно создать
выбором заявки с min t, используя следующий алгоритм:
1. Взять заявку с минимальным t;
2. Выкинуть из исходного набора все несовместные заявки;
3. Таким образом, задача сводится к меньшей.
4. Далее по индукции.
Сложность выполнения алгоритма – О(n log n)

#Задача о рюкзаке
Vi Wi V i /W i
1 30 10 3
2 40 20 2
3 50 30 5/3

Необходимо, чтобы Wmax = 50


Жадный алгоритм возьмет W 1 +W 2, а это не оптимально. Оптимально –
W 2 +W 3.

Расстояние Левенштейна. Его свойства и алгоритм вычисления

Расстояние Левенштейна – это минимальное количество элементарных


операций, необходимых для превращения одной строки в другую.
Операции: добавление, удаление, замена.
Свойства расстояния Левенштейна:
1. d(S1, S2) >= || S1 - S2||
2. d(S1, S2) <= max (|S1|, |S2|)
3. d(S1, S2) = 0 => S1 = S2
РЛ составляет не меньше разницы длин слов
35
РЛ не больше, чем большее слово
Если РЛ равно нулю, то слова совпадают
Dab(i, j) = Da[1…i]b[1…j]
Dab(n, m) = Dab
Выведем рекурентную формулу:
Dab(i, j) = min (Dab(i-1, j)+1, Dab(i, j-1)+1, Dab(i-1, j-1)+tab(i, j))

{1 ,если a[i]≠ b [ j]
tab(i, j) =
0 ,если a [ i ] =b [ j]

Dab(0,i) = i
Dab(i,0) = i
# D”САРУМАН”,”САУРОН”:
С А У Р О Н
0 1 2 3 4 5 6
С 1 0 1 2 3 4 5
А 2 1 0 1 2 3 4
Р 3 2 1 1 1 2 3
У 4 3 2 1 2 2 3
М 5 4 3 2 2 3 3
А 6 5 3 3 3 3 4
Н 7 6 4 4 4 4 3

САРУМАН  САУМАН  САУРАН  САУРОН (D”САРУМАН”,”САУРОН”= 3)

Вычислительно сложные задачи и основные подходы к их


решению.
Это NP-полные задачи, для которых можно проверить решение за
полиномиальное время, но не решить.
Основных подходов три:
- Точные методы (ДП, перебор и оптимизации перебора (с возвратом,
методом ветвей и границ)
- Приближенные алгоритмы (ЖА), они же эвристические
- Вероятностные алгоритмы

Оптимизация перебора. Метод ветвей и границ.

Оптимизация перебора методом перебора с возвратом. Перебор с


возвратом – это общий метод упорядоченного перебора. Перебор с возвратом
особенно удобен для решения задач, требующих проверки потенциально
большого, но конечного числа решений.
Метод перебора с возвратом основан на том, что при поиске решения
многократно делается попытка расширить текущее частичное решение, то есть
36
его продолжить. Если расширение невозможно, то происходит возврат к
предыдущему более короткому частичному решению, и делается попытка его
расширить другим возможным способом.
# КНФ-выполнимость
φ ( w , x , y , z )=(w ⋁ x ⋁ y ⋁ z )(w ⋁ x)(x ⋁ y )( y ⋁ z )( z ⋁ w)(z ⋁ w)

φ( w ,x , y ,z)
w=0: (x ⋁ y ⋁ z )( x)(x ⋁ y)( y ⋁ z) w=1:( x ⋁ y ) ( y ⋁ z ) ( z ) ( z )=0( х)

x=0:( y ⋁ z )( y )( y ⋁ z) x=1:…=0 (х )
y=0: z ⋁ z=0 y=1:…
0

Метод ветвей и границ — алгоритмический метод, являющийся


вариацией полного перебора с отсевом подмножеств допустимых решений,
заведомо не содержащих оптимальных решений.

Идея: если нижняя граница значений функции на подобласти A дерева


поиска больше, чем верхняя граница на какой-либо ранее просмотренной
подобласти B, то A может быть исключена из дальнейшего рассмотрения
(правило отсева). Обычно минимальную из полученных верхних оценок
записывают в глобальную переменную m; любой узел дерева поиска, нижняя
граница которого больше значения m, может быть исключён из дальнейшего
рассмотрения.

Если нижняя граница для узла дерева совпадает с верхней границей, то


это значение является минимумом функции и достигается на соответствующей
подобласти.

Метод используется для решения некоторых NP-полных задач, в том


числе задачи коммивояжёра и задачи о рюкзаке.

# Задача о рюкзаке

45 x 1 +48 x 2 +35 x3 → max

5 x 1+ 8 x 2 +3 x 3 ≤ 10

x 1 , x 2 , x 3 ϵ {0 ,1 }

V i (стоим W i (вес) V i /W i
) (удельн
.
стоим.
)
1 45 5 9
2 48 8 6
37
3 35 3 11 2/3

х1 = 1, х2 = ¼, x3 = 1; U = 92 – оценка сверху

стоимость 0 руб; остаток 10 кг; оценка 92 рубля


x 1=1 : 45 руб; 5 кг; 90 руб x 1=0 : 0 руб; 10 кг; 77 руб
x 2=1 : … руб; -3 кг; … руб x 2=0 : 45 руб; 5 кг; 80 руб
x 3=1 : 80 руб; 2 кг; x 3=0 : 45 руб; 5 кг;
80 руб (лучший 45 руб
вариант)

Приближенные алгоритмы. Приближенные алгоритмы для задачи о


вершинном покрытии, метрической задачи коммивояжера и задачи
о рюкзаке.

Алгоритм обладает коэффициентом аппроксимации p(n), если


max ( Cопт
C
,
C )
C опт
= p( n) , где n – размер входных данных, C – стоимость решения,

Cопт – стоимость оптимального решения.


p(n) – приближенный алгоритм, в котором достигается коэффициент
аппроксимации p(n).
# Задача о вершинном покрытии
func ApproxVertexCover(G)
c=
E’ = G·E
while E’ != 
выбрать произвольное ребро {u, v} ϵ E' (*)
C = C{u, v}
удалить из Е’ ребра инцидентные u или V
return C

A – множество ребер, выбранных на шаге (*).


|С опт|  |A|
|С| = 2|A| ≤ 2 |С опт|
p(n) = 2  алгоритм позволяет решать NP-полную задачу за полином с
p(n) = 2.

Алгоритм называют полиномиальной схемой аппроксимации, если для


0 алгоритм работает за полиномиальное время относительно n с
коэффициентом аппроксимации p = 1+.
Алгоритм называют полностью полиномиальной схемой аппроксимации,
если 1+ – приближенный алгоритм, имеющий полиномиальную сложность
1
относительно n и ε .
Теорема: если P!=NP, то задача коммивояжера не приближаема.
38
Док-во (от противного): Пусть   - приближенный алгоритм для решения
задачи коммивояжера, тогда возьмем произвольный граф G. Построим граф G2:
G2 – полный граф на тех же вершинах, что и G. e ϵ G2, если это ребро есть и в G,
то W(e) = 1, иначе W(e) = n+1.
Если в G есть гамильтонов цикл, то гам. цикл min веса в G2 имеет вес n,
иначе его вес  n – 1 + n +1. = n+n  2n,  если в графе G есть гам. цикл,
приближенный алгоритм G2 выдает цикл  n, иначе – n  P = NP, что
является противоречием!
# Коммивояжер
Задача коммивояжера неприближаема.
Есть свойство, что при незначительном изменении цсловия задачи, ее
сложность может резко измениться.
3-КНФ выполнимость – NP-полная; 2-КНФ выполнимость – решается за
полином.
Приближенный алгоритм для метрической задачи коммивояжера
(сделать рисунок!)
Изначально есть полный граф. Строим min остовное дерево.
Обходим дерево так, чтобы через каждое ребро можно было пройти 2
раза.
Будем добавлять в цикл еще непосещенные вершины. (РИСУНОК)
Получаем ГЦ.
Пусть T – min остовное дерево, L – обход (по каждому ребру 2 раза); С –
ГЦ, который мы нашли. M – min ГЦ.
Если  min ГЦ, то убрав из него 1 ребро, получаем связный ациклический
граф 
W(T) ≤ W(M), W – вес.
W(L) = 2W(T)
W(C) ≤ W(L),  W(C) ≤ 2W(M), коэффициент аппроксимации для этого
алгоритма = 2.
# Приближенный алгоритм для задачи о рюкзаке
M(i, v) – минимальный вес предметов с номерами 1…i, не
превышающими i, стоимостью  v.
M(0, v) = , при v  0;
M(0, v) = 0, при v  0;
M(i, v) = min(M(i-1,v), Wi + M(i-1, v-vi)
n
O(nV), где V = ∑ vi
i=1
Будем округлять стоимости.
^ n vi
v i=⌊ ∙ ⌋,
ε v max
 - некоторый параметр, от которого зависит стоимость и приближение.
xi = {0, 1} – не берем или берем предмет
Тогда алгоритм работает за O(nV^ ¿ = O ¿ = O (n3/) – полиномиальная
n
сложность, при U max ∑ v i n.
i =1

39
n
K=∑ xi v i −стоимость решения оригинальной задачи
i=1

( )
n n n n n
vi n vi
∑ x i v^i=¿ ∑ x i ⌊ nε ∙ v ⌋ ≥ ∑ xi ∙
ε v max
−1 =¿
n

ε v max i=1
x i v i−∑ x i
n
ε v max
K−n ¿
i=1 i =1 max i=1 i=1
n

∑ x^i v^i ≥ ε vn K −n
i=1 max

( )
n n
ε v max n ε v max
∑ x^i v i ≥ ∑ x^i v^i
n

ε v max
K−n
n
= K−¿ ε v max ≥ K (1−ε) ¿
i=1 i =1
1
Коэффициент аппроксимации p(n) = 1−ε ≈ 1+ ε ⟹ этот алгоритм является
полиномиальным в системе аппроксимации.

Понятие о метаэвристических алгоритмах. Локальный поиск.


Алгоритм имитации отжига. Генетические алгоритмы.

Эвристический алгоритм – алгоритм решения задачи, включающий


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

Локальный поиск – алгоритм, в котором мы выбираем некоторое решение


x и ищем в его окрестностях решение x’, которое будет лучше, чем x, повторяя,
пока оно не находится.

Метод имитации отжига.


Пусть x – вершина, l(x)  min
Повторять:
1. выбрать случайное решение x’ из окрестности x;
2. = l(x’) – l(x);
3. если  < 0, то x = x’;
−❑
4. иначе x = x’, с вероятностью l T ;
5. изменяем Т;
6. если выполнилось условие выхода, то выходим

Генетический алгоритм:
1. генерируем начальную популяцию;
2. отбор;
3. применение генетических операторов (мутация и скрещивание).

Мутация – изменение случайных компонентов вектора, представляющего


генотип особи.
Происходит с небольшой вероятностью.

40
a b a d

c d c b

Скрещивание происходит с высокой вероятностью.

Локальный поиск – группа алгоритмов, в которых поиск ведется только


на основании текущего состояния.

Задача о рюкзаке. Точные методы решения задачи о рюкзаке.

Задача о рюкзаке относится к классу NP-полных, и для неё


нет полиномиального алгоритма, решающего её за разумное время. Поэтому
при решении задачи о рюкзаке необходимо выбирать между точными
алгоритмами, которые неприменимы для «больших» рюкзаков, и
приближенными, которые работают быстро, но не гарантируют оптимального
решения задачи.
К точным методам решения задачи о рюкзаке относятся
1. Полный перебор;
2. Метод ветвей и границ (21 вопрос)
3. Методы динамического программирования.

Полный перебор
Задачу о рюкзаке можно решить, полностью перебрав все возможные
решения. Допустим, имеется N предметов, которые можно укладывать в рюкзак.
Нужно определить максимальную стоимость груза, вес которого не
превышает W. Для каждого предмета существует 2 варианта: предмет либо
кладётся в рюкзак, либо нет. Тогда перебор всех возможных вариантов
имеет временную сложность O(2^N), что позволяет его использовать лишь для
небольшого количества предметов. С ростом числа предметов задача становится
неразрешимой данным методом за приемлемое время.
После составления дерева необходимо найти лист с максимальной
ценностью среди тех, вес которых не превышает W.
Метод динамического программирования
При дополнительном ограничении на веса предметов, задачу о рюкзаке
можно решить за псевдополиномиальное время методами динамического
программирования.
Пусть M(i,w) – максимальная стоимость предмета из множества с
номерами 1, 2, …, i и ограничением по w.
Для решения задачи необходимо вычислить оптимальные решения для
всех w ϵ Z: 0 ≤ w ≤ W, где W – заданная грузоподъемность.

41
M(i, W) = { M ( i−1 , W ) , если wi >W
max ( M (i−1 ) , W ) , V i + M (i−1 , W −wi )¿
¿

Так как на каждом шаге необходимо найти максимум из n предметов,


алгоритм имеет вычислительную сложность O(nW). Поскольку W может
зависеть экспоненциально от размера входных данных, алгоритм является
псевдополиномиальным. => эффективность данного алгоритма определяется
значением W. Алгоритм хорошо работает при W ≤ 1000.
Метод ветвей и границ для задачи о рюкзаке

У нас есть узел, это наше текущее состояние, т.е. количество предметов, которое
лежит в данный момент в рюкзаке. Также у нас есть ветви, это соединительные пути к
другим узлам (состояниям), т.е. как будто мы кладём одну вещь в рюкзак.

Допустим у нас есть пустой рюкзак грузоподьёмностью (5кг) и нам надо положить:
ноутбук (2кг), спальник (3кг), молот (5кг).

Тогда дерево полного перебора будет выглядеть следующим образом:

В этом дереве мы можем заметить, что у нас есть узлы, которые превышают вес
рюкзака и при этом у них есть ещё и потомки, а зачем нам продолжать рассматривать
следующие варианты, если дальше будет только хуже? Поэтому мы ограничиваем
весом 5 и получаем такой граф:

42
На выходе получаем, что есть 2 варианта, либо мы кладём в рюкзак: ноутбук и
спальник, либо молот.

В зависимости от реализации, можно искать все варианты, первый или задать


дополнительные критерии оптимальности.

то есть метод ветвей и границ предполагает исключение как раз тех узлов,
которые превышают вес рюкзака? – mtrfnv 2 ноя '17 в 17:43
 В данном, конкретном случае, да – Komdosh 2 ноя '17 в 17:44
 а что будет ценностью решения для каждого узла в данном случае? в тому
же, в задаче о рюкзаке присутствует еще и цена, как все это
связать? – mtrfnv 2 ноя '17 в 17:47
 В качестве цены тут выступает вес, а ценности решения его сумма, вы
можете задать по другому.

Задача о рюкзаке. Приближенные методы решения задачи о


рюкзаке.
Как и для большинства NP-полных задач, не всегда необходимо получать
точное решение, так как решения, близкие к оптимальным, могут применяться в
прикладных задачах.
Приближенным методом решения задачи о рюкзаке может считаться
жадный алгоритм (вопрос 22).

43
Вероятностные алгоритмы. Проверка на простоту на основе малой
теоремы Ферма.
Вероятностные алгоритмы — это алгоритмы, использующие в своей
работе случайные числа. При этом, результат работы таких алгоритмов может
быть не всегда правильным. Тем не менее обычно вероятность ошибки можно
уменьшить до необходимых значений.
Тест Ферма — вероятностный тест на простоту. Он основан на
следующей теореме.
Теорема (малая теорема Ферма): Если p — простое число, то ap = a (mod p)
Доказательство: Пусть (a − 1)p = (a − 1) (mod p). Тогда
p
a p=(1+ ( a−1 )) =∑ C ip (a−1)i=1+(a−1) p=1+ ( a−1 ) =a mod p
p

i=0

Назовем натуральное число n псевдопростым по основанию b, если b n−1 =


1 (mod n).
Теорема. Если n не является псевдопростым хотя бы по одному
основанию, то оно не является псевдопростым не менее, чем по половине
различных оснований.
Доказательство: пусть число n не является псевдопростым по основанию
b и является псевдопростым по основаниям b 1,…, bt. Тогда, по основаниям b1b,
… btb оно псевдопростым не является, т.к. (bib)n−1 = bin−1bn−1 = bn−1 != 1 (mod n).
Тест Ферма состоит в том, что число проверяют на псевдопростоту по k
различным случайным основаниям. Если оно не является псевдопростым по
одному из оснований, то оно является составным. А если является
1
псевдопростым по всем этим основаниям, то оно с вероятностью 1− k является
2
простым. К сожалению, существуют составные числа, являющиеся
псевдопростыми по всем основаниями. Такие числа называются числами
Кармайкла. Их бесконечно много. Минимальное из них – 561. Поэтому на
практике используют другие тесты на простоту, свободные от этого недостатка
(например, тест Соловея–Штрассена и тест Рабина– Миллера).

Вероятностные алгоритмы. Фильтр Блума. Алгоритм MinHash.

Вероятностные алгоритмы (вопрос 26).


Фильтр Блума (Bloom Filter) — вероятностная структура данных,
разработанная в 1970 г. Бёртоном Блумом. Предназначена она для хранения
множества элементов и позволяет проверять принадлежность заданного
элемента множеству. При этом возможно ложноположительные срабатывания
(т.е. выдает, что в множестве есть элемент, хотя его нет), но не

44
ложноотрицательные. Фильтр Блума использует массив из m бит: a 0,…, am−1.
Изначально, все эти биты равны нулю, что означает, что множество пусто.
Пусть определены k различных хэшфункций hi : U → Zm, где U — множество
всех возможных элементов, i ∈ {1,…,k}.

Для добавление элемента e достаточно положить ahi(e) = 1 для всех i ∈ {1,


…,k}.
Теперь для проверки на принадлежность элемента e к множеству
хранимых элементов следует проверить состояние элементов a h1(e),…,ahk(e). Если
хотя бы один из них равен нулю, то элемент e не принадлежит множеству. А
если все они равны единице, то элемент, возможно, принадлежит множеству.
Оценим теперь вероятность ложноположительного срабатывания. Будем
полагать, что множество хэш-функций выбрано случайно и значения всех хэш-
функций равновероятны и независимы в совокупности. Тогда вероятность того,
что в элемент aj не будет записана единица во время операции вставки равна:

( )
k
k1
Pr ( h1 ≠ j∧ …∧ hk ≠ j )=(Pr ⁡(hi ≠ j)) = 1−
m
Теперь, если в пустой фильтр Блума вставляется n различных элементов,
вероятность того, что j-й бит равен нулю, равна:
−kn

( )
kn
1 m
Pr (¿ a j=0)= 1− ≈e ,¿
m
для достаточно большого m (это следует из второго замечательного предела).
Вероятность ложноположительного срабатывания, т.е. вероятность того, что для
некоторого элемента y, который не был вставлен, все a hi(y) = 1, для i = 1,…,k
приблизительно равна:

(1−e )
−kn k
m

Оптимальное число k, минимизирующее эту вероятность, равно:


mln 2 m
k= ≈ 0,693 .
n n
m
В этом случае вероятность ложного срабатывания равна 2−k ≈ 0,618 n .

Объединение и пересечение фильтров Блума одинакового размера с одинаковым


множеством хэш-функций реализуется соответствующими побитовыми операциями
(дизъюнкцией и конъюнкцией) над битовыми массивами этих фильтров.
Фильтры Блума позволяют хранить неограниченное количество элементов в
массиве фиксированной длины. В отличии от других типов данных, фильтр Блума может
хранить универсальное множество, состоящее из всех возможных элементов (все биты
равны единице).
Применяются фильтры Блума в системах, где допускаются ложноположительные
срабатывания — там он позволяет обходиться гораздо меньшими объемами памяти.
Кроме того, они часто используются для уменьшения количества запросов к
отсутствующим данным (например в СУБД Google BigTable).
45
Алгоритм MinHash
Мера Жаккарда – это мера похожести двух множеств:
J ( A , B ) =¿ A ∩B∨ ¿ ¿
¿ A ∪ B∨¿ .¿
Ее можно вычислить с помощью хэш-таблицы или двоичного дерева поиска. Для
уменьшения времени работы и требуемого объема памяти, эту меру можно вычислить с
помощью алгоритма MinHash, который основан на том, что h(e) – какая-нибудь хэш-
функция, а h min ( S )=min
x ∈S
h( x), то вероятность того, что значения h ( A ) и h ( B ) –
min min

совпадают по мере Жаккарда похожести этих двух множеств:


J ( A , B ) =Pr ( hmin ( A )=hmin ( B ) ) .
Действительно, упорядочим все элементы объединения множеств A∪B по
возрастанию хэшей и запишем в виде столбца. Рядом с каждым элементом будем для
каждого множества A и B писать 0, если элемент множеству не принадлежит и 1 — если
принадлежит:
Элемент A B
e1 1 0
e2 0 1
e3 1 0
e4 0 1
e5 1 0
e6 1 1
e7 0 1

Рассмотрим в ней первую строку. Вероятность того, что в ней будут две единицы,
есть количеству строк с двумя единицами деленная на общее количество строк, т.е. в
точности J(A, B).
Для вычисления оценки этой вероятности берется k различных хэш-функций.
Причем это k вычисляется по формуле:

k=
[ ]
1
e
2
,

где e – стандартная ошибка.

Вероятностные алгоритмы. Фильтр Блума. Алгоритм


HyperLogLog.
Вероятностные алгоритмы (вопрос 26).
Фильтр Блума (вопрос 27).
Алгоритм HyperLogLog
Алгоритм HyperLogLog — вероятностный алгоритм вычисления количества
попарно различных элементов в массиве.

46
Алгоритм основан на том наблюдении, что если имеется массив k-
разрядных двоичных случайных чисел и мы обнаружили, что максимальное число
нулевых старших битов среди его элементов равно t, то можно сказать, что в нем
порядка 2t элементов.
Итак, пусть x 0 , … , x N−1 – массив, число уникальных элементов которого
требуется найти. Пусть h: U  {0, 1}k – хэш-функция (типичные значения k ∈ {32,
64}).
Обозначим число нулевых старших битов y как ν(y).
Пусть y i=h(x i). Для j = 0, …,m-1 вычислим следующие значения:
M j = max v ( y i),
b
y i = j(mod 2 )

где b=⌈ log m⌉ .


Теперь оценка количества уникальных элементов в массиве будет равна:
2
am m
E= m −1 ,
∑2 −Mj

j=0

где корректирующая константа am (альфа) равна:

( ( ) du)
inf m −1
2+ u
a m= m ∫ log ⁡
0
1+ u

Стандартная вычислительная ошибка такого алгоритма примерно равна 1.04/m.


Вычислительная сложность: О(m+n)
Приведем интуитивные соображения, почему это работает: очевидно, что для Mj 
log n/m, где n – число уникальных элементов.
1 1 n
m−1
≈ m−1 ≈ .
Следовательно, m2 Отсюда следует справедливость оценки.
∑ 2− Mj ∑ mn
j=0 j=0
Необходимость коррекции обусловлена наличием коллизий в хэш-функции. Строгое
доказательство выходит за рамки лекции.

Вероятностные алгоритмы. Алгоритм MinHash. Приближенный


вероятностный алгоритм для задачи MAX-3SAT.
Вероятностные алгоритмы (вопрос 26).
Алгоритм MinHash (вопрос 27).
Приближенный вероятностный алгоритм для задачи MAX-3SAT
Задача MAX 3-SAT (максимальная задача 3-КНФ выполнимости) формулируется
следующим образом. Дана 3-КНФ, представляющая собой конъюнкцию k различных
дизъюнктов (элементарных дизъюнкций). Требуется найти набор, на котором
выполняется максимальное число дизъюнктов (элементарных дизъюнкций).

47
Заметим, что если выбрать случайный набор, на нем каждый дизъюнкт будет
выполняться с вероятностью 7/8. Действительно, вероятность того, что один литерал не
выполняется равна 1/2, следовательно, дизъюнкт, в котором ровно три переменных, не
выполняется с вероятностью (1/2)3 = 1/8. Следовательно, вероятность, что дизъюнкт
выполняется равна 1 − 1/8 = 7/8. Следовательно, мат. ожидание числа выполняющихся
дизъюнктов равно (7/8)k.
Утверждение. Для любого экземпляра задачи 3-SAT существует набор, на котором
выполняется не менее, чем 7/8 всех условий. (Очевидно)
Если мы хотим найти набор, на котором выполняется не меньше, чем 7/8 всех
дизъюнктов, можно генерировать случайные наборы, пока не найдем такой. Обозначим p i
— вероятность того, что случайное присваивание выполняет ровно i дизъюнктов. Тогда
мат. ожидание:
k

∑ i pi= 78 k .
i=0

Пусть p – вероятность того, что случайный набор выполняет не менее 7/8


дизъюнктов.
p= ∑ p j.
j ≥(7 / 8)k

k
7
k =∑ j p j= ∑ j p j+ ∑ j pj ≤ ∑ k ' p j+ ∑ k p j=¿ ¿
8
() () () ()
j=0 7 7 7 7
j< k j> k j< k j≥ k
8 8 8 8

' ' ' '


¿ k ( 1− p ) +kp=k −k p+ kp ≤ k +kp ,
где k’ – наибольшее натуральное число, строго меньшее 7/8 k
Отсюда,
7 ' 1
kp ≥ k−k ≥ .
8 8
Следовательно:
1
p≥ .
8k

Персистентные структуры данных


Персистентные структуры данных – структуры данных, которые помнят все
предыдущие состояния системы.
1. частично персистентные – все предыдущие состояния доступны для чтения, но
изменять можно только последнее состояние)
2. полностью персистентные – все предыдущие состояния доступны для чтения и
изменения).
Способы перевода на персистентную структуру данных (частично персистентную):
1. Полное копирование
2. Копирование путей

48
O(h)-память; O(h) – время. Для сбалансированных оценки не меняются.
3. Способ толстых узлов(можно с родителями)

Недостаток: нужно искать максимальную версию, не превышающую ту, с которой мы


работаем. Замедление в log(t) раз (ограниченное число версий в узле)
4. Способ Слейтора Тарьяна

Ф( D)- количество полных узлов в последней версии


C empty=O ( 1 ) +2=Ο ( 1 ) ( 2 в сумме C empty это количество полных узлов ) (не затрагиваем
полные узлы, как в 1)
C fat =O ( 1 ) +k −k +2=O (1) (затронуты полные узлы k штук)

49
Общий случай.
P – максимальное количество узлов, ссылающихся на узел. В каждом есть
обратные ссылки на все ссылающиеся на него узлы (Их не более, чем P).
Пусть количество версий в одном узле не превышает 2*P.
Аналогично прошлому: не полностью заполняем, добавляем состояния; полностью
заполняем, клонируем.
Ф( D)- сумма длин всех списков изменения в последней версии O(1)

Полностью персистентные (дерево версий)

1, 2, 3, -3, 4, -4, -2, 5, 6, -6, 7, -7, 8, -8; между 6 и -6 вставляем 9, -9. Сложность О(1)

31. Поиск подстроки в строке.


Регулярные выражения – система обработки текста, основанная на специальной системе
записи образцов для поиска.

O(n)
Алгоритм Бойера-Мура
Алгоритм Бойера-Мура, разработанный двумя учеными — Бойером (Robert S.
Boyer) и Муром (J. Strother Moore), считается наиболее быстрым среди алгоритмов общего
50
назначения, предназначенных для поиска подстроки в строке. Важной особенностью
алгоритма является то, что он выполняет сравнения в шаблоне справа налево в отличие от
многих других алгоритмов.
Алгоритм Бойера-Мура считается наиболее эффективным алгоритмом поиска
шаблонов в стандартных приложениях и командах, таких как Ctrl+F в браузерах и
текстовых редакторах.
Алгоритм сравнивает символы шаблона x справа налево, начиная с самого правого,
один за другим с символами исходной строки y. Если символы совпадают, производится
сравнение предпоследнего символа шаблона и так до конца. Если все символы шаблона
совпали с наложенными символами строки, значит, подстрока найдена, и поиск окончен.
В случае несовпадения какого-либо символа (или полного совпадения всего шаблона) он
использует две предварительно вычисляемых эвристических функций, чтобы сдвинуть
позицию для начала сравнения вправо.
Таким образом для сдвига позиции начала сравнения алгоритм Бойера-Мура
выбирает между двумя функциями, называемыми эвристиками хорошего суффикса и
плохого символа (иногда они называются эвристиками совпавшего суффикса и стоп-
символа). Так как функции эвристические, то выбор между ними простой — ищется такое
итоговое значение, чтобы мы не проверяли максимальное число позиций и при этом
нашли все подстроки равные шаблон

Достоинства:

 Алгоритм Бойера-Мура на хороших данных очень быстр, а вероятность появления


плохих данных крайне мала. Поэтому он оптимален в большинстве случаев, когда нет
возможности провести предварительную обработку текста, в котором проводится
поиск.
 На больших алфавитах (относительно длины шаблона) алгоритм чрезвычайно
быстрый и требует намного меньше памяти, чем алгоритм Ахо-Корасик.
 Позволяет добавить множество модификаций, таких как поиск подстроки,
включающей любой символ (?) (но для реализации множества символов (*) не
подходит, так как длина шаблона должна быть известна заранее).

Недостатки:

 Алгоритмы семейства Бойера-Мура не расширяются до приблизительного поиска,


поиска любой строки из нескольких.
 На больших алфавитах (например, Юникод) может занимать много памяти. В
таких случаях либо обходятся хэш-таблицами, либо дробят алфавит, рассматривая,
например, 4-байтовый символ как пару двухбайтовых.
 На искусственно подобранных неудачных текстах скорость алгоритма Бойера-
Мура серьёзно снижается.

t – длина тектса; p – длина искомого слова; σ – размер алфавита


Средняя сложность O(t); Сложность в худшем O(pt); сложность препроцессинга
O(p+σ ); сложность доп. памяти O(p+σ ).

51
32. Рекурсия. Виды рекурсии. Оптимизация хвостовой рекурсии.
Рекурсия – вызов функции (процедуры) из неё же самой, непосредственно (простая
рекурсия) или же через другие функции (сложная или косвенная или множественная
рекурсия).Например: функция A вызывает функцию B, а функция B – функцию А. Так же
рекурсия имеет условие выхода(останова).

Рекурсивные функции используют так называемый «Стек вызовов» (стек — это


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

Достоинство: алгоритм выглядит легче и изящнее

Недостатки: работает медленнее, чем итеративное метод и занимает больше памяти


в стеке.

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


неоднозначен: с одной стороны, рекурсивная форма может быть структурно проще и
нагляднее, в особенности, когда сам реализуемый алгоритм по сути рекурсивен. Кроме
того, в некоторых функциональных языках (таких как Пролог или Haskell) просто нет
синтаксических средств для организации циклов, и рекурсия в них — единственный
доступный механизм организации повторяющихся вычислений.

Помимо функций, выполняющих один рекурсивный вызов в каждой рекурсивной


ветви, бывают случаи «параллельной рекурсии», когда на одной рекурсивной ветви
делается два или более рекурсивных вызова. Параллельная рекурсия типична при
обработке с

рекурсивной функции — вычисление ряда Фибоначчи, где для получения значения n-го
члена необходимо вычислить (n-1)-й и (n-2)-й.

Хвостовая рекурсия – это частный случай рекурсии, при котором любой


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

Оптимизация хвостой рекурсии - подобный вид рекурсии может быть легко


преобразован в цикл.

Хвостовая рекурсия
Int f(int a) {
If(a<0) return 1;
Return f(a-1)
}
Не хвостовая рекурсия
Int f(int a) {
If (a<0) return 1;
Return f(a-1)*(a+5)
}
Множественная рекурсия
52
Int f(int a) {
If (a<0) return 1;
Return f(a-1)-f(a-2)
}

53

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