SPb HSE, 1 курс, осень 2022/23
Конспект лекций по алгоритмам
Собрано 26 января 2024 г. в 22:45
Содержание
0. Разбор теста 1
0.1. Разбор основных задач . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1. Асимптотика 2
1.1. О курсе. Хорошие алгоритмы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2. Асимптотика, 𝒪-обозначния . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.3. Рекуррентности и Карацуба . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.4. Теоремы о рекуррентных соотношениях . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.5. Доказательства по индукции . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.6. Числа Фибоначчи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.7. (*) 𝒪-обозначения через пределы . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.8. (*) Замена сумм на интегралы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.9. Примеры по теме асимптотики . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.10. Сравнение асимптотик . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2. Структуры данных 11
2.1. С++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.2. Неасимптотические оптимизации . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.3. Частичные суммы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.4. Массив . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.5. Двусвязный список . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.6. Односвязный список . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.7. Список на массиве . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.8. Вектор (расширяющийся массив) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.9. Стек, очередь, дек . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
2.10. Очередь, стек и дек с минимумом . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3. Структуры данных 18
3.1. Амортизационный анализ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
3.2. Разбор арифметических выражений . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
3.3. Бинпоиск . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.3.1. Обыкновенный . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.3.2. Lowerbound и Upperbound . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
3.3.3. Бинпоиск по предикату . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.3.4. Вещественный, корни многочлена . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.4. Два указателя и операции над множествами . . . . . . . . . . . . . . . . . . . . . . 23
3.5. Хеш-таблица . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
3.5.1. Хеш-таблица на списках . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
3.5.2. Хеш-таблица с открытой адресацией . . . . . . . . . . . . . . . . . . . . . . . 24
3.5.3. Сравнение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
3.5.4. C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
4. Структуры данных 27
4.1. Избавляемся от амортизации . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
4.1.1. Вектор (решаем проблему, когда случится) . . . . . . . . . . . . . . . . . . . 27
4.1.2. Вектор (решаем проблему заранее) . . . . . . . . . . . . . . . . . . . . . . . . 27
4.1.3. Сравнение способов . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
4.1.4. Хеш-таблица . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
4.1.5. Очередь с минимумом через два стека . . . . . . . . . . . . . . . . . . . . . . 28
4.2. Бинарная куча . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
4.2.1. GetMin, Add, ExtractMin . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
4.2.2. Обратные ссылки и DecreaseKey . . . . . . . . . . . . . . . . . . . . . . . . . 31
4.2.3. Build, HeapSort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
4.3. Аллокация памяти . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
4.3.1. Стек . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
4.3.2. Список . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
4.3.3. Куча (кратко) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
4.3.4. (*) Куча (подробно) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
4.3.5. (*) Дефрагментация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
4.4. Пополняемые структуры . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
4.4.1. Ничего → Удаление . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
4.4.2. Поиск → Удаление . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
4.4.3. Add → Merge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
4.4.4. Build → Add . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
4.4.5. Build → Add, Del . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36
5. Сортировки 37
5.1. Два указателя и алгоритм Мо . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
5.2. Квадратичные сортировки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
5.3. Оценка снизу на время сортировки . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
5.4. Решение задачи по пройденным темам . . . . . . . . . . . . . . . . . . . . . . . . . . 39
5.5. Быстрые сортировки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
5.5.1. CountSort (подсчётом) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
5.5.2. MergeSort (слиянием) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
5.5.3. QuickSort (реально быстрая) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
5.5.4. Сравнение сортировок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
5.6. (*) Adaptive Heap sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
5.6.1. (*) Модифицированный HeapSort . . . . . . . . . . . . . . . . . . . . . . . . 42
5.6.2. (*) Adaptive Heap Sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
5.6.3. (*) Ссылки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
5.7. (*) Kirkpatrick’84 sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
6. Сортировки (продолжение) 44
6.1. Quick Sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
6.1.1. Оценка времени работы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
6.1.2. Introsort’97 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
6.2. Порядковые статистики . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
6.2.1. Одноветочный QuickSort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
6.2.2. Детерминированный алгоритм . . . . . . . . . . . . . . . . . . . . . . . . . . 46
6.2.3. C++ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
6.3. Integer sorting . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
6.3.1. Count sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
6.3.2. Radix sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
6.3.3. Bucket sort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
6.4. Van Embde Boas’75 trees . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49
6.5. (*) Inplace merge за 𝒪(𝑛) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50
6.6. (*) 3D Мо . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
6.6.1. (*) Применяем для mex . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
7. Кучи 51
7.1. Нижняя оценка на построение бинарной кучи . . . . . . . . . . . . . . . . . . . . . 52
7.2. Min-Max Heap (Atkison’86) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53
7.3. Leftist Heap (Clark’72) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
7.4. Skew Heap (Tarjan’86) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
7.5. Quake Heap (потрясная куча) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
7.5.1. Списко-куча . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 55
7.5.2. Турнирное дерево . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
7.5.3. Список турнирных деревьев . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
7.5.4. DecreaseKey за 𝒪(1), quake! . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
7.6. (*) Pairing Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
7.6.1. История. Ссылки. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
7.7. (*) Биномиальная куча (Vuillemin’78) . . . . . . . . . . . . . . . . . . . . . . . . . . 60
7.7.1. Основные понятия . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
7.7.2. Операции с биномиальной кучей . . . . . . . . . . . . . . . . . . . . . . . . . 61
7.7.3. Add и Merge за 𝒪(1) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
7.8. (*) Куча Фибоначчи (Fredman,Tarjan’84) . . . . . . . . . . . . . . . . . . . . . . . . 61
7.8.1. Фибоначчиевы деревья . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
7.8.2. Завершение доказательства . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
8. Рекурсивный перебор 64
8.1. Перебор перестановок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
8.2. Перебор множеств и запоминание . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
8.3. Перебор путей (коммивояжёр) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
8.4. Разбиения на слагаемые . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
8.5. Доминошки и изломанный профиль . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
9. Динамическое программирование 69
9.1. Базовые понятия . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
9.1.1. Условие задачи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
9.1.2. Динамика назад . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
9.1.3. Динамика вперёд . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
9.1.4. Ленивая динамика . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70
9.2. Ещё один пример . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
9.3. Восстановление ответа . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
9.4. Графовая интерпретация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
9.5. Checklist . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
9.6. Рюкзак . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
9.6.1. Формулировка задачи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
9.6.2. Решение динамикой . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
9.6.3. Оптимизируем память . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
9.6.4. Добавляем bitset . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
9.6.5. Восстановление ответа с линейной памятью . . . . . . . . . . . . . . . . . . . 74
9.7. Квадратичные динамики . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 75
9.8. Оптимизация памяти для НОП . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
9.8.1. Храним биты . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76
9.8.2. Алгоритм Хиршберга (по wiki) . . . . . . . . . . . . . . . . . . . . . . . . . . 76
9.8.3. Оценка времени работы Хиршберга . . . . . . . . . . . . . . . . . . . . . . . 76
9.8.4. (*) Алгоритм Хиршберга (улучшенный) . . . . . . . . . . . . . . . . . . . . . 77
9.8.5. Область применение идеи Хиршберга . . . . . . . . . . . . . . . . . . . . . . 77
10. Динамическое программирование (часть 2) 78
10.1. bitset . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
10.1.1. Рюкзак . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
10.2. НОП → НВП . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
10.3. НВП за 𝒪(𝑛 log 𝑛) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
10.4. Задача про погрузку кораблей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
10.4.1. Измельчение перехода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
10.4.2. Использование пары, как функции динамики . . . . . . . . . . . . . . . . . 80
10.5. Рекуррентные соотношения . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
10.5.1. Пути в графе . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
10.6. Задача о почтовых отделениях . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
10.6.1. Оптимизация Кнута . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
10.6.2. (*) Доказательства неравенств . . . . . . . . . . . . . . . . . . . . . . . . . 82
10.6.3. Оптимизация методом «разделяй и властвуй» . . . . . . . . . . . . . . . . . 83
10.6.4. Стресс тестирование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
11. Динамическое программирование (часть 3) 84
11.1. Динамика по подотрезкам . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
11.2. Комбинаторика . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85
11.3. Работа с множествами . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
11.4. Динамика по подмножествам . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
11.5. Гамильтоновы путь и цикл . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
11.6. Вершинная покраска . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
11.7. Вершинная покраска: решение за 𝒪(3𝑛 ) . . . . . . . . . . . . . . . . . . . . . . . . 89
Алгоритмы, 1 курс, осень 2022/23 Разбор теста
Лекция #0: Разбор теста
1 сентября
0.1. Разбор основных задач
∙ Задача: дано 𝑛, найти число решений 𝑎2 + 𝑏2 = 𝑛
√
Решение, за 𝒪( 𝑛):
1 for ( a = 1; a * a <= n ; a ++) {
2 int b = sqrt ( n - a * a ) ;
3 res += ( a * a + b * b == n ) ;
4 }
√
Решение, за 𝒪( 𝑛) элементарных арифметических операций с целыми числами:
1 int m = sqrt ( n / 2) , b = sqrt ( n ) ; // пусть 𝑎 ⩽ 𝑏 ⇒ 𝑎2 ⩽ 21 𝑛.
2 for ( int a = 1; a <= m ; a ++) {
3 // Идея: если 𝑎 увеличивается, то 𝑏 обязательно уменьшается!
4 while (( tmp = a * a + b * b ) > n ) --b ; // 2(𝑛/2)1/2 умножений
5 if ( tmp == n ) res += ( a == b ? 1 : 2) ;
6 }
√
Последнее решение быстрее за счёт того, что мы избавились от медленного 𝑥.
Так же мы сделали оптимизацию: перебираем только пары ⟨𝑎, 𝑏⟩ : 𝑎 ⩽ 𝑏.
∙ Задача: дано 𝑛, найти число решений 𝑎2 + 𝑏2 + 𝑐2 + 𝑑2 = 𝑛
Сгенерируем за 𝒪(𝑛) множества 𝐴 всех чисел вида 𝑎2 + 𝑏2 от 0 до 𝑛:
for a : a*a <= n for b : b*b <= n count[a*a+b*b]++;.
Теперь исходная задача решается так for ab=0..n answer += count[n-ab].
Глава #0. 1 сентября. 1/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
Лекция #1: Асимптотика
8 сентября
1.1. О курсе. Хорошие алгоритмы.
Что такое алгоритм, вы представляете. А что такое хороший алгоритм?
1. Алгоритм, который работает на всех тестах. Очень важное свойство. Нам не интересны
решения, для которых есть тесты, на которых они не работают.
2. Алгоритм, который работает быстро. Что такое быстро? Время работы программы зависит
от размера входных данных. Размер данных часто обозначают за 𝑛. Алгоритм, находящий
минимум в массиве длины 𝑛 делает ≈4𝑛 операций. Нам прежде всего важна зависимость от 𝑛
(пропорционально 𝑛), и только во-вторых константа (≈4). На самом деле разные операции
выполняются разное время, об этом в следующей главе.
Насколько быстро должны работать наши программы? Обычные процессоры для ноутов и
телефонов имеют несколько ядер, каждое частотой ∼2GHz. Параллельные алгоритмы мы изу-
чать не будем, всё, что изучим, заточено под работу на одном ядре. ∼2GHz это 2 000 000 000
элементарных операций в секунду. Если мы пишем на языке C++ (а мы будем), то это ≈109 ко-
манд в секунду. Если, например, на python, то реально мы успеем выполнить 106 , в ≈1000 раз
меньше команд в секунду. За эталон мы берём именно одну секунду — минута по человеческим
ощущениям очень медленно, а сотую секунды человек не почувствует.
3. Алгоритм, который использует мало оперативной памяти. Вообще память более дорогой
ресурс, чем время, об этом будет в следующей главе, в части про кеш.
4. Простые и понятные алгоритмы. Если алгоритм сложно понять, пересказать (выше порог
вхождения), если он содержит много крайних случаев ⇒ его сложно корректно реализовать, в
нём вероятны ошибки, которые однажды выстрелят.
∙ Асимптотика.
Ближайшие две главы мы будем говорить преимущественно про скорость работы.
Рассмотрим простейший алгоритм, который перебирает все пары 𝑖, 𝑗 : 𝑖 ⩽ 𝑗 ⩽ 𝑛.
1 int ans = 0;
2 for ( int i = 1; i <= n ; i ++) // нам дали 𝑛
3 for ( int j = 1; j <= i ; j ++)
4 ans ++;
5 cout << ans << endl ;
Мы можем посчитать точное число всех операций (сравнение, присваивание, сложение, ...)
в зависимости от 𝑛: 1 + 3(1+2+3+ . . . +𝑛) + 1 и получить 𝑓 (𝑛) = 23 𝑛(𝑛+1) + 2.
𝑓 (𝑛) — время работы программы в зависимости от 𝑛, а 𝑛 — параметр задачи, зачастую «размер
входных данных». Ниже мы будем предполагать, что 𝑛 ∈ N, 𝑓 (𝑛) > 0. Интересно, насколько
быстро растёт время программы в зависимости от 𝑛 (размера данных). Наша 𝑓 (𝑛) ∼ 𝑛2 , мы
будем говорить «асимптотически работает за 𝑛2 » или «за 𝑛2 с точностью до константы», это и
есть асимптотическая часть времени работы, асимптотика времени работы.
Выше мы считали, что все операции работают одно и то же время, просто считали их количе-
ство. А потом ещё и забили на константу 23 при 𝑛2 . Дальше мы разберёмся с константами и с тем,
Глава #1. 8 сентября. 2/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
какие операции медленнее, какие быстрее. А сейчас сосредоточимся только на асимптотике.
1.2. Асимптотика, 𝒪-обозначния
Рассмотрим функции 𝑓, 𝑔 : N → R>0 .
Def 1.2.1. 𝑓 = Θ(𝑔) ∃𝑁 > 0, 𝐶1 > 0, 𝐶2 > 0 : ∀𝑛 ⩾ 𝑁, 𝐶1 · 𝑔(𝑛) ⩽ 𝑓 (𝑛) ⩽ 𝐶2 · 𝑔(𝑛)
Def 1.2.2. 𝑓 = 𝒪(𝑔) ∃𝑁 > 0, 𝐶 > 0 : ∀𝑛 ⩾ 𝑁, 𝑓 (𝑛) ⩽ 𝐶 · 𝑔(𝑛)
Def 1.2.3. 𝑓 = Ω(𝑔) ∃𝑁 > 0, 𝐶 > 0 : ∀𝑛 ⩾ 𝑁, 𝑓 (𝑛) ⩾ 𝐶 · 𝑔(𝑛)
Def 1.2.4. 𝑓 = 𝑜(𝑔) ∀𝐶 > 0 ∃𝑁 > 0 : ∀𝑛 ⩾ 𝑁, 𝑓 (𝑛) ⩽ 𝐶 · 𝑔(𝑛)
Def 1.2.5. 𝑓 = 𝜔(𝑔) ∀𝐶 > 0 ∃𝑁 > 0 : ∀𝑛 ⩾ 𝑁, 𝑓 (𝑛) ⩾ 𝐶 · 𝑔(𝑛)
Понимание Θ: «равны с точностью до константы», «асимптотически равны».
Понимание 𝒪: «не больше с точностью до константы», «асимптотически не больше».
Понимание 𝑜: «асимптотически меньше», «для сколь угодно малой константы не больше».
Θ 𝒪 Ω 𝑜 𝜔
= ⩽ ⩾ < >
Замечание 1.2.6. 𝑓 = Θ(𝑔) ⇔ 𝑔 = Θ(𝑓 )
Замечание 1.2.7. 𝑓 = 𝒪(𝑔), 𝑔 = 𝒪(𝑓 ) ⇔ 𝑓 = Θ(𝑔)
Замечание 1.2.8. 𝑓 = Ω(𝑔) ⇔ 𝑔 = 𝒪(𝑓 )
Замечание 1.2.9. 𝑓 = 𝜔(𝑔) ⇔ 𝑔 = 𝑜(𝑓 )
Замечание 1.2.10. 𝑓 = 𝒪(𝑔), 𝑔 = 𝒪(ℎ) ⇒ 𝑓 = 𝒪(ℎ)
Замечание 1.2.11. Обобщение: ∀𝛽 ∈ {𝒪, 𝑜, Θ, Ω, 𝜔} : 𝑓 = 𝛽(𝑔), 𝑔 = 𝛽(ℎ) ⇒ 𝑓 = 𝛽(ℎ)
Замечание 1.2.12. ∀𝐶 > 0 𝐶·𝑓 = Θ(𝑓 )
Докажем для примера 1.2.6.
1 1
Доказательство. 𝐶1 ·𝑔(𝑛) ⩽ 𝑓 (𝑛) ⩽ 𝐶2 ·𝑔(𝑛) ⇒ 𝐶2
𝑓 (𝑛) ⩽ 𝑔(𝑛) ⩽ 𝐶1
𝑔(𝑛) ⩽ 𝑓 (𝑛) ■
Упражнение 1.2.13. 𝑓 = 𝒪(Θ(𝒪(𝑔))) ⇒ 𝑓 = 𝒪(𝑔)
Упражнение 1.2.14. 𝑓 = Θ(𝑜(Θ(𝒪(𝑔)))) ⇒ 𝑓 = 𝑜(𝑔)
Упражнение 1.2.15. 𝑓 = Ω(𝜔(Θ(𝑔))) ⇒ 𝑓 = 𝜔(𝑔)
Упражнение 1.2.16. 𝑓 = Ω(Θ(𝒪(𝑔))) ⇒ 𝑓 может быть любой функцией
Lm 1.2.17. 𝑔 = 𝑜(𝑓 ) ⇒ 𝑓 ± 𝑔 = Θ(𝑓 )
Доказательство. 𝑔 = 𝑜(𝑓 ) ∃𝑁 : ∀𝑛 ⩾ 𝑁 𝑔(𝑛) ⩽ 12 𝑓 (𝑛) ⇒ 12 𝑓 (𝑛) ⩽ 𝑓 (𝑛) ± 𝑔(𝑛) ⩽ 23 𝑓 (𝑛) ■
Lm 1.2.18. 𝑛𝑘 = 𝑜(𝑛𝑘+1 )
Доказательство. ∀𝐶 ∀𝑛 ⩾ 𝐶 𝑛𝑘+1 ⩾ 𝐶 · 𝑛𝑘 ■
Lm 1.2.19. 𝑃 (𝑥) – многочлен, тогда 𝑃 (𝑥) = Θ(𝑥deg𝑃 ) при старшем коэффициенте > 0.
Доказательство. 𝑃 (𝑥) = 𝑎0 + 𝑎1 𝑥 + 𝑎2 𝑥2 + · · · + 𝑎𝑘 𝑥𝑘 . По леммам 1.2.12, 1.2.18 имеем, что все
слагаемые кроме 𝑎𝑘 𝑥𝑘 являются 𝑜(𝑥deg𝑃 ). Поэтому по лемме 1.2.17 вся сумма является Θ(𝑥𝑘 ). ■
Глава #1. 8 сентября. 3/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
1.3. Рекуррентности и Карацуба
∙ Алгоритм умножения чисел в столбик
Рассмотрим два многочлена 𝐴(𝑥) = 5 + 4𝑥 + 3𝑥2 + 2𝑥3 + 𝑥4 и 𝐵(𝑥) = 9 + 8𝑥 + 7𝑥2 + 6𝑥3 .
Запишем массивы a[] = {5, 4, 3, 2, 1}, b[] = {9, 8, 7, 6}.
1 for ( i = 0; i < an ; i ++) // an = 5
2 for ( j = 0; j < bn ; j ++) // bn = 4
3 c [ i + j ] += a [ i ] * b [ j ];
Мы получили в точности коэффициенты многочлена 𝐶(𝑥) = 𝐴(𝑥)𝐵(𝑥).
Теперь рассмотрим два числа 𝐴 = 12345 и 𝐵 = 6789, запишем те же массивы и сделаем:
1 // Перемножаем числа без переносов, как многочлены
2 for ( i = 0; i < an ; i ++) // an = 5
3 for ( j = 0; j < bn ; j ++) // bn = 4
4 c [ i + j ] += a [ i ] * b [ j ];
5 // Делаем переносы, массив c = [45, 76, 94, 100, 70, 40, 19, 6, 0]
6 for ( i = 0; i < an + bn ; i ++)
7 if ( c [ i ] >= 10)
8 c [ i + 1] += c [ i ] / 10 , c [ i ] %= 10;
9 // Массив c = [5, 0, 2, 0, 1, 8, 3, 8, 0], ответ = 83810205
Данное умножение работает за Θ(𝑛𝑚), или Θ(𝑛2 ) в случае 𝑛 = 𝑚.
Следствие 1.3.1. Чтобы умножать длинные числа достаточно уметь умножать многочлены.
Многочлены мы храним, как массив коэффициентов. При программировании умножения, нам
важно знать не степень многочлена 𝑑, а длину этого массива 𝑛 = 𝑑 + 1.
∙ Алгоритм Карацубы
Чтобы перемножить два многочлена (или два длинных целых числа) 𝐴(𝑥) и 𝐵(𝑥) из 𝑛 коэф-
фициентов каждый, разделим их на части по 𝑘 = 𝑛2 коэффициентов – 𝐴1 , 𝐴2 , 𝐵1 , 𝐵2 .
Заметим, что 𝐴 · 𝐵 = (𝐴1 + 𝑥𝑘 𝐴2 )(𝐵1 + 𝑥𝑘 𝐵2 ) = 𝐴1 𝐵1 + 𝑥𝑘 (𝐴1 𝐵2 + 𝐴2 𝐵1 ) + 𝑥2𝑘 𝐴2 𝐵2 .
Если написать рекурсивную функцию умножения, то получим время работы:
𝑇1 (𝑛) = 4𝑇1 ( 𝑛2 ) + Θ(𝑛)
Из последующей теоремы мы сделаем вывод, что 𝑇1 (𝑛) = Θ(𝑛2 ). Алгоритм можно улучшить,
заметив, что 𝐴1 𝐵2 + 𝐴2 𝐵1 = (𝐴1 + 𝐴2 )(𝐵1 + 𝐵2 ) − 𝐴1 𝐵1 − 𝐴2 𝐵2 , где вычитаемые величины уже
посчитаны. Итого три умножения вместо четырёх:
𝑇2 (𝑛) = 3𝑇2 ( 𝑛2 ) + Θ(𝑛)
Из последующей теоремы мы сделаем вывод, что 𝑇2 (𝑛) = Θ(𝑛log2 3 ) = Θ(𝑛1.585... ).
Данный алгоритм применим и для умножения многочленов, и для умножения чисел.
Глава #1. 8 сентября. 4/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
Псевдокод алгоритма Карацубы для умножения многочленов:
1 Mul (n , a , b ) : // n = 2𝑘 , c(w) = a(w)*b(w)
2 if n == 1: return { a [0] * b [0]}
3 a --> a1 , a2
4 b --> b1 , b2
5 x = Mul ( n / 2 , a1 , b1 )
6 y = Mul ( n / 2 , a2 , b2 )
7 z = Mul ( n / 2 , a1 + a2 , b1 + b2 )
8 // Умножение на w𝑖 - сдвиг массива на i вправо
9 return x + y * w 𝑛 + ( z - x - y ) * w 𝑛/2 ;
Чтобы умножить числа, сперва умножим их как многочлены, затем сделаем переносы.
Глава #1. 8 сентября. 5/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
1.4. Теоремы о рекуррентных соотношениях
Теорема 1.4.1. Мастер Теорема (теорема о простом рекуррентном соотношении)
Пусть 𝑇 (𝑛) = 𝑎𝑇 ( 𝑛𝑏 ) + 𝑓 (𝑛), где 𝑓 (𝑛) = 𝑛𝑐 . При этом 𝑎 > 0, 𝑏 > 1, 𝑐 ⩾ 0. Определим глубину
рекурсии 𝑘 = log𝑏 𝑛. Тогда верно одно из трёх:
⎧
𝑘 log 𝑎
⎨𝑇 (𝑛) = Θ(𝑎 ) = Θ(𝑛 𝑏 )
⎪ 𝑎 > 𝑏𝑐
𝑇 (𝑛) = Θ(𝑓 (𝑛)) = Θ(𝑛𝑐 ) 𝑎 < 𝑏𝑐
⎪
𝑇 (𝑛) = Θ(𝑘 · 𝑓 (𝑛)) = Θ(𝑛𝑐 log 𝑛) 𝑎 = 𝑏𝑐
⎩
Доказательство. Раскроем рекуррентность:
𝑇 (𝑛) = 𝑓 (𝑛) + 𝑎𝑇 ( 𝑛𝑏 ) = 𝑓 (𝑛) + 𝑎𝑓 ( 𝑛𝑏 ) + 𝑎2 𝑓 ( 𝑏𝑛2 ) + · · · = 𝑛𝑐 + 𝑎( 𝑛𝑏 )𝑐 + 𝑎2 ( 𝑏𝑛2 )𝑐 + . . .
(︀ )︀2 (︀ )︀𝑘
Тогда 𝑇 (𝑛) = 𝑓 (𝑛)(1 + 𝑏𝑎𝑐 + 𝑏𝑎𝑐 + · · · + 𝑏𝑎𝑐 ). При этом в сумме 𝑘 + 1 слагаемых.
Обозначим 𝑞 = 𝑏𝑎𝑐 и оценим сумму 𝑆(𝑞) = 1 + 𝑞 + · · · + 𝑞 𝑘 .
Если 𝑞 = 1, то 𝑆(𝑞) = 𝑘 + 1 = log𝑏 𝑛 + 1 = Θ(log𝑏 𝑛) ⇒ 𝑇 (𝑛) = Θ(𝑓 (𝑛) log 𝑛).
𝑘+1
Если 𝑞 < 1, то 𝑆(𝑞) = 1−𝑞 1−𝑞
= Θ(1) ⇒ 𝑇 (𝑛) = Θ(𝑓 (𝑛)).
𝑞 𝑘 −1
Если 𝑞 > 1, то 𝑆(𝑞) = 𝑞 𝑘 + 𝑞−1
= Θ(𝑞 𝑘 ) ⇒ 𝑇 (𝑛) = Θ(𝑎𝑘 ( 𝑏𝑛𝑘 )𝑐 ) = Θ(𝑎𝑘 ). ■
Теорема 1.4.2. Обобщение Мастер Теоремы
Мастер Теорема верна и для 𝑓 (𝑛) = 𝑛𝑐 log𝑑 𝑛
𝑇 (𝑛) = 𝑎𝑇 ( 𝑛𝑏 ) + 𝑛𝑐 log𝑑 𝑛. При 𝑎 > 0, 𝑏 > 1, 𝑐 ⩾ 0, 𝑑 ⩾ 0.
⎧
𝑘 log 𝑎
⎨𝑇 (𝑛) = Θ(𝑎 ) = Θ(𝑛 𝑏 )
⎪ 𝑎 > 𝑏𝑐
𝑇 (𝑛) = Θ(𝑓 (𝑛)) = Θ(𝑛𝑐 log𝑑 𝑛) 𝑎 < 𝑏𝑐
𝑇 (𝑛) = Θ(𝑘 · 𝑓 (𝑛)) = Θ(𝑛𝑐 log𝑑+1 𝑛) 𝑎 = 𝑏𝑐
⎪
⎩
Без доказательства. ■
Теорема 1.4.3.
∑︀ Об экспоненциальном рекуррентном ∑︀соотношении
Пусть 𝑇 (𝑛) = 𝑏𝑖 𝑇 (𝑛 − 𝑎𝑖 ). При этом 𝑎𝑖 > 0, 𝑏𝑖 > 0, 𝑏𝑖 > 1. ∑︀ −𝑎𝑖
Тогда 𝑇 (𝑛) = Θ(𝛼𝑛 ), при этом 𝛼 > 1 и является корнем уравнения 1 = 𝑏𝑖 𝛼 , его можно
найти бинарным поиском.
Доказательство. Предположим, что 𝑇 (𝑛) = 𝛼𝑛 , тогда 𝛼𝑛 = 𝑏𝑖 𝛼𝑛−𝑎𝑖 ⇔ 1 = 𝑏𝑖 𝛼-𝑎𝑖 = 𝑓 (𝛼).
∑︀ ∑︀
Теперь нам нужно решить ∑︀ уравнение 𝑓 (𝛼) = 1 для 𝛼 ∈ [1, +∞).
Если 𝛼 = 1, то 𝑓 (𝛼) = 𝑏𝑖 > 1, если 𝛼 = +∞, то 𝑓 (𝛼) = 0 < 1. Кроме того 𝑓 (𝛼) ↘ [1, +∞).
Получаем, что на [1, +∞) есть единственный корень уравнения 1 = 𝑓 (𝛼) и его множно найти
бинарным поиском.
∑︀ −𝑎𝑖
Мы показали, откуда возникает уравнение 1 = 𝑏𝑖 𝛼 . Доказали, что у него ∃! корень 𝛼.
Теперь докажем по индукции, что 𝑇 (𝑛) = 𝒪(𝛼 ) (оценку сверху) и 𝑇 (𝑛) = Ω(𝛼𝑛 ) (оценку
𝑛
снизу). Доказательства идентичны, покажем 𝑇 (𝑛) = 𝒪(𝛼𝑛 ). База индукции:
∃𝐶 : ∀𝑛 ∈ 𝐵 = [1 − max 𝑎𝑖 , 1] 𝑇 (𝑛) ⩽ 𝐶𝛼𝑛
𝑖
Переход индукции:
∑︁ по индукции ∑︁ (*)
𝑇 (𝑛) = 𝑏𝑖 𝑇 (𝑛 − 𝑎𝑖 ) ⩽ 𝐶 𝑏𝑖 𝛼𝑛−𝑎𝑖 = 𝐶𝛼𝑛
(*) Верно, так как 𝛼 – корень уравнения. ■
Глава #1. 8 сентября. 6/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
1.5. Доказательства по индукции
Lm 1.5.1. Доказательство по индукции
Есть простой метод решения рекуррентных соотношений:
(︀ угадать ответ, доказать
)︀ его по ин-
дукции. Рассмотрим на примере 𝑇 (𝑛) = max 𝑇 (𝑥) + 𝑇 (𝑛 − 𝑥) + 𝑥(𝑛 − 𝑥) .
𝑥=1..𝑛−1
Докажем, что 𝑇 (𝑛) = 𝒪(𝑛2 ), для этого достаточно доказать 𝑇 (𝑛) ⩽ 𝑛2 :
База: 𝑇 (1) = 1 ⩽ 12 . (︀ )︀ (︀ )︀
Переход: 𝑇 (𝑛) ⩽ max 𝑥2 + (𝑛 − 𝑥)2 + 𝑥(𝑛 − 𝑥) ⩽ max 𝑥2 + (𝑛 − 𝑥)2 + 2𝑥(𝑛 − 𝑥) = 𝑛2
𝑥=1..𝑛−1 𝑥=1..𝑛−1
∙ Примеры по теме рекуррентные соотношения
1. 𝑇 (𝑛) = 𝑇 (𝑛 − 1) + 𝑇 (𝑛 − 1) + 𝑇 (𝑛 − 2).
Угадаем ответ 2𝑛 , проверим по индукции: 2𝑛 = 2𝑛−1 + 2𝑛−1 + 2𝑛−2 .
2. 𝑇 (𝑛) = 𝑇 (𝑛 − 3) + 𝑇 (𝑛 − 3) ⇒ 𝑇 (𝑛) = 2𝑇 (𝑛 − 3) = 4𝑇 (𝑛 − 6) = · · · = 2𝑛/3
3. 𝑇 (𝑛) = 𝑇 (𝑛−1)+𝑇 (𝑛−3). Применяем 1.4.3, получаем 1 = 𝛼−1 +𝛼−3 , находим 𝛼 бинпоиском,
получаем 𝛼 = 1.4655 . . . .
1.6. Числа Фибоначчи
Def 1.6.1. 𝑓1 = 𝑓0 = 1, 𝑓𝑖 = 𝑓𝑖−1 + 𝑓𝑖−2 . 𝑓𝑛 – 𝑛-е число Фибоначчи.
∙ Оценки снизу и сверху
𝑓𝑛 = 𝑓𝑛−1 + 𝑓𝑛−2 , рассмотрим 𝑔𝑛 = 𝑔𝑛−1 + 𝑔𝑛−1 , 2𝑛 = 𝑔𝑛 ⩾ 𝑓𝑛 .
𝑓𝑛 = 𝑓𝑛−1 + 𝑓𝑛−2 , рассмотрим 𝑔𝑛 = 𝑔𝑛−2 + 𝑔𝑛−2 , 2𝑛/2 = 𝑔𝑛 ⩽ 𝑓𝑛 . √
Воспользуемся 1.4.3, получим 1 = 𝛼−1 + 𝛼−2 ⇔ 𝛼2 − 𝛼 − 1 = 0, получаем 𝛼 = 5+1
2
≈ 1.618.
𝑓𝑛 = Θ(𝛼𝑛 ).
1.7. (*) 𝒪-обозначения через пределы
𝑓 (𝑛)
Def 1.7.1. 𝑓 = 𝑜(𝑔) Определение через предел: lim =0
𝑛→+∞ 𝑔(𝑛)
𝑓 (𝑛)
Def 1.7.2. 𝑓 = 𝒪(𝑔) Определение через предел: lim <∞
𝑛→+∞ 𝑔(𝑛)
Здесь необходимо пояснение: lim 𝑓 (𝑛) = lim ( sup 𝑓 (𝑥)), где sup – верхняя грань.
𝑛→+∞ 𝑛→+∞ 𝑥∈[𝑛..+∞]
Lm 1.7.3. Определения 𝑜 эквивалентны
Доказательство. Вспомним, что речь о положительных функциях 𝑓 и 𝑔.
Распишем предел по определению: ∀𝐶 > 0 ∃𝑁 ∀𝑛 ⩾ 𝑁 𝑓𝑔(𝑛)
(𝑛)
⩽ 𝐶 ⇔ 𝑓 (𝑛) ⩽ 𝐶𝑔(𝑛). ■
Глава #1. 8 сентября. 7/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
1.8. (*) Замена сумм на интегралы
∫︀𝑏
Def 1.8.1. Определённый интеграл 𝑓 (𝑥)𝑑𝑥 положительной
𝑎
функции 𝑓 (𝑥) – площадь под графиком 𝑓 на отрезке [𝑎..𝑏].
𝑎+1
∫︀
Lm 1.8.2. ∀𝑓 (𝑥) ↗ [𝑎..𝑎+1] ⇒ 𝑓 (𝑎) ⩽ 𝑓 (𝑥)𝑑𝑥 ⩽ 𝑓 (𝑎+1)
𝑎
𝑏
∑︀ 𝑏+1
∫︀
Lm 1.8.3. ∀𝑓 (𝑥) ↗ [𝑎..𝑏+1] ⇒ 𝑓 (𝑖) ⩽ 𝑓 (𝑥)𝑑𝑥
𝑖=𝑎 𝑎
Доказательство. Сложили неравенства из 1.8.2 ■
∫︀𝑏 𝑏
∑︀
Lm 1.8.4. ∀𝑓 (𝑥) ↗ [𝑎..𝑏], 𝑓 > 0 ⇒ 𝑓 (𝑥)𝑑𝑥 ⩽ 𝑓 (𝑖)
𝑎 𝑖=𝑎
Доказательство. Сложили неравенства из 1.8.2, выкинули [𝑎−1, 𝑎] из интеграла. ■
Теорема 1.8.5. Замена суммы на интеграл #1
𝑛
∑︀ ∫︀𝑛 𝑛+1
∫︀
∀𝑓 (𝑥) ↗ [1..∞), 𝑓 > 0, 𝑆(𝑛) = 𝑓 (𝑖), 𝐼1 (𝑛) = , 𝐼2 (𝑛) = , 𝐼1 (𝑛) = Θ(𝐼2 (𝑛))⇒ 𝑆(𝑛) = Θ(𝐼1 (𝑛))
𝑖=1 1 1
Доказательство. Из лемм 1.8.3 и 1.8.4 имеем 𝐼1 (𝑛) ⩽ 𝑆(𝑛) ⩽ 𝐼2 (𝑛).
𝐶1 𝐼1 (𝑛) ⩽ 𝐼2 (𝑛) ⩽ 𝐶2 𝐼1 (𝑛) ⇒ 𝐼1 (𝑛) ⩽ 𝑆(𝑛) ⩽ 𝐼2 (𝑛) ⩽ 𝐶2 𝐼1 (𝑛) ■
Теорема 1.8.6. Замена суммы на интеграл #2
∫︀𝑏 𝑏
∑︀ ∫︀𝑏
∀𝑓 (𝑥) ↗ [𝑎..𝑏], 𝑓 > 0 𝑓 (𝑥)𝑑𝑥 ⩽ 𝑓 (𝑖) ⩽ 𝑓 (𝑏) + 𝑓 (𝑥)𝑑𝑥
𝑎 𝑖=𝑎 𝑎
𝑏−1
∑︀
Доказательство. Первое неравенство – лемма 1.8.3. Второе – 1.8.4, применённая к . ■
𝑖=𝑎
Следствие 1.8.7. Для убывающих функций два последних факта тоже верны.
Во втором ошибкой будет не 𝑓 (𝑏), а 𝑓 (𝑎), которое теперь больше.
∙ Как считать интегралы?
∫︀𝑏
Формула Ньютона-Лейбница: 𝑓 ′ (𝑥)𝑑𝑥 = 𝑓 (𝑏) − 𝑓 (𝑎)
𝑎
∫︀𝑛
Пример: ln′ (𝑛) = 1
𝑛
⇒ 1
𝑥
𝑑𝑥 = ln 𝑛 − ln 1 = ln 𝑛
1
Глава #1. 8 сентября. 8/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
1.9. Примеры по теме асимптотики
∙ Вложенные циклы for
1 # define forn (i , n ) for ( int i = 0; i < n ; i ++)
2 int counter = 0 , n = 100;
3 forn (i , n )
4 forn (j , i )
5 forn (k , j )
6 forn (l , k )
7 forn (m , l )
8 counter ++;
9 cout << counter << endl ;
5
Чему равен counter? Во-первых, есть точный ответ: 𝑛5 ≈ 𝑛5! . Во-вторых, мы можем сходу
(︀ )︀
1
посчитать число циклов и оценить ответ как 𝒪(𝑛5 ), правда константа 120 важна, оценка через 𝒪
не даёт полное представление о времени работы.
∙ За сколько вычисляется 𝑛-е число Фибоначчи?
1 f [0] = f [1] = 1;
2 for ( int i = 2; i < n ; i ++)
3 f [ i ] = f [ i - 1] + f [ i - 2];
Казалось бы за 𝒪(𝑛). Но это в предположении, что «+» выполняется за 𝒪(1). На самом деле
мы знаем, что log 𝑓𝑛 = Θ(𝑛), т.е. складывать нужно числа длины 𝑛 ⇒ «+» выполняется за Θ(𝑖),
а 𝑛-е число Фибоначчи считается за Θ(𝑛2 ).
∙ Задача из теста про 𝑎2 + 𝑏2 = 𝑁
1 int b = sqrt ( N ) ;
2 for ( int a = 1; a * a <= N ; a ++)
3 while ( a * a + b * b >= N ; b - -)
4 ;
5 if ( a * a + b * b == N )
6 cnt ++;
Время работы Θ(𝑁 1/2 ), так как в сумме 𝑏 уменьшится лишь 𝑁 1/2 раз. Здесь мы первый раз
использовали так называемый «метод двух указателей».
∙ Число делителей числа
1 vector < int > divisors [ n + 1]; // все делители числа
2 for ( int a = 1; a <= n ; a ++)
3 for ( int b = a ; b <= n ; b += a )
4 divisors [ b ]. push_back ( a ) ;
За сколько работает программа?
𝑛 𝑛 𝑛
1 1.8.5
∫︀𝑛
⌈ 𝑛𝑎 ⌉ = 𝒪(𝑛) + 𝑛
𝒪(𝑛) + 𝑛 · Θ( 𝑥1 𝑑𝑥) = Θ(𝑛 log 𝑛)
∑︀ ∑︀ ∑︀
𝑎
= 𝒪(𝑛) + 𝑛 𝑎
=
𝑎=1 𝑎=1 𝑎=1 1
Глава #1. 8 сентября. 9/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
∙ Сумма гармонического ряда
Докажем более простым способом, что 𝑛𝑖=1 1𝑖 = Θ(log 𝑛)
∑︀
1 1 1...
⏞ ⏟ ⏞ ⏟ ⏞ ⏟
1 1 1 1 1 1 1 𝑛
1 + ⌊log2 𝑛⌋ ⩾ 11 + + + + + + + + . . . ⩾ 1
= 11 + 21 + 13 + 14 + 15 + 16 + 71 + 18 + . . . ⩾
∑︀
𝑘
2 2 4 4 4 4 8 𝑘=1
1 1 1 1 1 1 1 𝑛
1 1 1
∑︀
1
+ + + + + + + + . . . ⩾ 1 + 2
⌊log 2 𝑛⌋ ⇒ 𝑘
= Θ(log 𝑛)
⏟ 2⏞ ⏟4 ⏞ 4 ⏟8 8 ⏞ 8 8 𝑘=1
1/2 1/2 1/2
1.10. Сравнение асимптотик
𝒪(𝑛)
Def 1.10.1. Линейная сложность
Def 1.10.2. Квадратичная сложность 𝒪(𝑛2 )
∃ 𝑘 > 0 : 𝒪(𝑛𝑘 )
Def 1.10.3. Полиномиальная сложность
Def 1.10.4. Полилогарифм ∃ 𝑘 > 0 : 𝒪(log𝑘 𝑛)
Def 1.10.5. Экспоненциальная сложность ∃ 𝑐 > 0 : 𝒪(2𝑐𝑛 )
Теорема 1.10.6. ∀𝑥, 𝑦 > 0, 𝑧 > 1 ∃𝑁 ∀𝑛 > 𝑁 : log𝑥 𝑛 < 𝑛𝑦 < 𝑧 𝑛
Доказательство. Сперва докажем первую часть неравенства через вторую.
Пусть log 𝑛 = 𝑘, тогда log𝑥 𝑛 < 𝑛𝑦 ⇔ 𝑘 𝑥 < 2𝑘𝑦 = (2𝑦 )𝑘 = 𝑧 𝑘 ⇐ 𝑛𝑦 < 𝑧 𝑛 ■
1
Докажем вторую часть исходного неравенства 𝑛𝑦 < 𝑧 𝑛 ⇔ 𝑛 < 2 𝑦 𝑛 log 𝑧
Пусть 𝑛′ = 𝑦1 𝑛 log 𝑧, обозначим 𝐶 = 1/( 𝑦1 log 𝑧), пусть 𝐶 ⩽ 𝑛′ (возьмём достаточно большое 𝑛),
1 ′ ′
тогда 𝑛𝑦 < 𝑧 𝑛 ⇔ 𝑛 < 2 𝑦 𝑛 log 𝑧 ⇔ 𝐶 · 𝑛′ < 2𝑛 ⇐ (𝑛′ )2 < 2𝑛
Осталось доказать 𝑛2 < 2𝑛 . Докажем по индукции.
База: для любого значения из интервала [10..20) верно,
так как 𝑛2 ∈ [100..400) < 2𝑛 ∈ [1024..1048576).
Если 𝑛 увеличить в два раза, то 𝑛2 → 4 · 𝑛2 , а 2𝑛 → 22𝑛 = 2𝑛 · 2𝑛 ⩾ 4 · 2𝑛 при 𝑛 ⩾ 2.
Значит ∀𝑛 ⩾ 2 если для 𝑛 верно, то и для 2𝑛 верно.
Переход: [10..20) → [20..40) → [40..80) → . . . ■
Следствие 1.10.7. ∀𝑥, 𝑦 > 0, 𝑧 > 1 : log𝑥 𝑛 = 𝒪(𝑛𝑦 ), 𝑛𝑦 = 𝒪(𝑧 𝑛 )
Доказательство. Возьмём константу 1. ■
Следствие 1.10.8. ∀𝑥, 𝑦 > 0, 𝑧 > 1 : log𝑥 𝑛 = 𝑜(𝑛𝑦 ), 𝑛𝑦 = 𝑜(𝑧 𝑛 )
Доказательство. Достаточно перейти к чуть меньшим 𝑦, 𝑧 и воспользоваться теоремой.
∃𝑁 ∀𝑛 ⩾ 𝑁 log𝑥 𝑛 < 𝑛𝑦−𝜀 = 𝑛1𝜀 𝑛𝑦 , 𝑛1𝜀 → 0 ⇒ log𝑥 = 𝑜(𝑛𝑦 ).
𝑛→∞
1 1
∃𝑁 ∀𝑛 ⩾ 𝑁 𝑛𝑦 < (𝑧 − 𝜀)𝑛 = (𝑧/(𝑧−𝜀))
𝑛
𝑛𝑧 , →
(𝑧/(𝑧−𝜀))𝑛 𝑛→∞
0 ⇒ 𝑛𝑦 = 𝑜(𝑧 𝑛 ). ■
Глава #1. 8 сентября. 10/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Асимптотика
∙ Посмотрим как ведут себя функции на графике
𝑛
𝑛2
30 √
𝑛
2
log 𝑛
2𝑛/2
𝑓 (𝑛)
20
10
0
10 20 30 40 50
𝑛
√
Заметим, что 2𝑛/2 , 𝑛2 и log2 𝑛, 𝑛 на бесконечности ведут себя иначе:
√ ·104
200 𝑛
2
3 𝑛2
log 𝑛
2𝑛/2
150
2
𝑓 (𝑛)
𝑓 (𝑛)
100
1
50
0
0 1 2 3 4 5 0
𝑛 5 10 15 20 25 30
·104 𝑛
Глава #2. 8 сентября. 11/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
Лекция #2: Структуры данных
15 сентября
2.1. С++
∙ Warnings
1. Сделайте, чтобы компилятор g++/clang отображал вам как можно больше warning-ов:
-Wall -Wextra -Wshadow
2. Пишите код, чтобы при компиляции не было warning-ов.
∙ Range check errors
Давайте рассмотрим стандартную багу: int a[3]; a[3] = 7;
В результате мы получаем undefined behavior. Код иногда падает по runtime error, иногда нет.
Чтобы такого не было, во-первых, используйте вектора, во-вторых, включите debug-режим.
1 # define _GLIBCXX_DEBUG // должно быть до всех #include
2 // #define _LIBCPP_DEBUG 1 (аналог для компилятора clang)
3 # include < vector > // должно быть после
4 vector < int > a (3) ;
5 a [3] = 7; // Runtime Error!
Для пользователей linux есть более профессиональное решение: valgrind.
UB (undefined behavior) – моменты, когда заранее неизвестно, как поведёт себя программа (из-за
ошибок в коде). Его очень сложно найти (т.к. оно может то проявляться, то нет, у вас локально
работает, на сервере нет и т.д.). Типы:
1. забыл вернуть ответ из функции (ловится через -W...)
2. забыл инициализировать переменную (ловится через -W...)
3. вышел за пределы вектора (ловится #define ...)
4. криво используем итераторы сета/вектора (ловится #define ...)
Типов ещё много, эти самые распространённые. Вам не нужно искать эти ошибки, вам нужно
прописать один раз в жизни в настройки компилятора -W... и в шаблон кода #define, и всё
будет искаться само.
Пожалуйста, берегите своё время и нервы, не ходите по уже хорошо изученным граблям.
Глава #2. 15 сентября. 12/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
∙ Struct (структуры)
1 struct Point {
2 int x , y ;
3 };
4 Point p , q = {2 , 3} , * t = new Point {2 , 3};
5 p . x = 3;
∙ Pointers (указатели)
Рассмотрим указатель int *a;
a – указатель на адрес в памяти (по сути целое число, номер ячейки).
*a – значение, которое лежит по адресу.
1 int b = 3;
2 int * a = & b ; // сохранили адрес b в пременную a типа int*
3 int c [10];
4 a = c ; // указатель на первый элемент массива
5 * a = 7; // теперь c[0] == 7
6 Point * p = new Point {0 , 0}; // выделили память под новый Point, указтель записали в p
7 (* p ) . x = 3; // записали значение в x
8 p - > x = 3; // запись, эквивалентная предыдущей
Глава #2. 15 сентября. 13/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
2.2. Неасимптотические оптимизации
При написании программы, если хочется, чтобы она работала быстро, стоит обращать внимание
не только на асимптотику, но и избегать использования некоторых операций, которые работают
дольше, чем кажется.
1. Ввод и вывод данных. cin/cout, scanf/printf...
Используйте буфферизированный ввод/вывод через fread/fwrite.
2. Операции библиотеки <math.h>: sqrt, cos, sin, atan и т.д.
Эти операции раскладывают переданный аргумент в ряд, что происходит не за 𝒪(1).
3. Взятие числа по модулю, деление с остатком: a / b, a % b.
4. Доступ к памяти. Существует два способа прохода по массиву:
Random access: for (i = 0; i < n; i++) sum += a[p[i]]; где 𝑝 – случайная перестановка
Sequential access: for (i = 0; i < n; i++) sum += a[i];
5. Функции работы с памятью: new, delete. Тоже работают не за 𝒪(1).
6. Вызов функций. Пример, который при 𝑛 = 107 работает секунду и использует ⩾ 320 mb.
1 void go ( int n ) {
2 if ( n <= 0) return ;
3 go ( n - 1) ; // компилируйте с -O0, чтобы оптимизатор не раскрыл рекурсию в цикл
4 }
Для оптимизации можно использовать inline – указание оптимизатору, что функцию
следует не вызывать, а попытаться вставить в код.
∙ История про кеш
В нашем распоряжении есть примерно такие объёмы
1. Жёсткий диск. Самая медленная память, 1 терабайт.
2. Оперативная память. Средняя, 8 гигабайта.
3. Кеш L3. Быстрая, 4 мегабайта.
4. Кеш L1. Сверхбыстрая, 32 килобайта.
Отсюда вывод. Если у нас есть два алгоритма ⟨𝑇1 , 𝑀1 ⟩ и ⟨𝑇2 , 𝑀2 ⟩: 𝑇1 = 𝑇2 = 𝒪(𝑛2 ); 𝑀1 =
𝒪(𝑛2 ); 𝑀2 = Θ(𝑛), то второй алгоритм будет работать быстрее для больших значений 𝑛, так
как у первого будут постоянные промахи мимо кеша.
И ещё один. Если у нас есть два алгоритма ⟨𝑇1 , 𝑀1 ⟩ и ⟨𝑇2 , 𝑀2 ⟩: 𝑇1 = 𝑇2 = Θ(2𝑛 ); 𝑀1 =
Θ(2𝑛 ); 𝑀2 = Θ(𝑛2 ), То первый в принципе не будет работать при 𝑛 ≈ 40, ему не хватит памяти.
Второй же при больших 𝑛 ≈ 40 неспешно, за несколько часов, но отработает.
∙ Быстрые операции
memcpy(a, b, n) (скопировать 𝑛 байт памяти), strcmp(s, t) (сравить строки).
Работают в 8 раз быстрее цикла for за счёт 128-битных SSE и 256-битных AVX регистров!
Глава #2. 15 сентября. 14/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
2.3. Частичные суммы
Дан массив a[] длины 𝑛, нужно отвечать на большое число запросов get(l, r) – посчитать
сумму на отрезке [𝑙, 𝑟] массива a[].
Наивное решение: на каждый запрос отвечать за Θ(𝑟 − 𝑙 + 1) = 𝒪(𝑛).
Префиксные или частичные суммы:
1 void precalc () { // предподсчёт за 𝒪(𝑛)
2 sum [0] = 0;
3 for ( int i = 0; i < n ; i ++) sum [ i + 1] = sum [ i ] + a [ i ]; // 𝑠𝑢𝑚[𝑖 + 1] = [0..𝑖]
4 }
5 int get ( int l , int r ) { // [𝑙..𝑟]
6 return sum [ r +1] - sum [ l ]; // [0..𝑟] − [0..𝑙), 𝒪(1)
7 }
2.4. Массив
Создать массив целых чисел на 𝑛 элементов: int a[n];
Индексация начинается с 0, массивы имеют фиксированный размер. Функции:
1. get(i) – 𝑎[𝑖], обратиться к элементу массива с номером 𝑖, 𝒪(1)
2. set(i,x) – 𝑎[𝑖] = 𝑥, присвоить элементу под номером 𝑖 значение 𝑥, 𝒪(1)
3. find(x) – найти элемент со значением 𝑥, 𝒪(𝑛)
4. add_begin(x), add_end(x) – добавить элемент в начало, в конец, 𝒪(𝑛), 𝒪(𝑛)
5. del_begin(x), del_end(x) – удалить элемент из начала, из конца, 𝒪(𝑛), 𝒪(1)
Последние команды работают долго т.к. нужно найти новый кусок памяти нужного размера,
скопировать весь массив туда, удалить старый.
Другие названия для добавления: insert, append, push.
Другие названия для удаления: remove, erase, pop.
2.5. Двусвязный список
1 struct Node {
2 Node * prev , * next ; // указатели на следующий и предыдущий элементы списка
3 int x ;
4 };
5 struct List {
6 Node * head , * tail ; // head, tail – фиктивные элементы
7 };
get(i), set(i,x) 𝒪(𝑖)
find(x) 𝒪(𝑛)
add_begin(x), add_end(x) Θ(1)
del_begin(), del_end() Θ(1)
delete(Node*) Θ(1)
Указатель tail нужен, чтобы иметь возможность добавлять в конец, удалять из конца за 𝒪(1).
Ссылки prev, чтобы ходить по списку в обратном направлении, удалять из середины за 𝒪(1).
Глава #2. 15 сентября. 15/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
1 Node * find ( List l , int x ) { // найти в списке за линию
2 for ( Node * p = l . head - > next ; p != l . tail ; p = p - > next )
3 if (p - > x == x )
4 return p ;
5 return 0;
6 }
7 Node * erase ( Node * v ) {
8 v - > prev - > next = v - > next ;
9 v - > next - > prev = v - > prev ;
10 }
11 Node * push_back ( List &l , Node * v ) {
12 Node * p = new Node () ;
13 p - > x = x , p - > prev = l . tail - > prev , p - > next = l . tail ;
14 p - > prev - > next = p , p - > next - > prev = p ;
15 }
16 void makeEmpty ( List & l ) { // создать новый пустой список
17 l . head = new Node () , l . tail = new Node () ;
18 l . head - > next = l . tail , l . tail - > prev = l . head ;
19 }
2.6. Односвязный список
1 struct Node {
2 Node * next ; // не храним ссылку назад, нельзя удалять из середины за 𝒪(1)
3 int x ;
4 };
5 // 0 – пустой список
6 Node * head = 0; // не храним tail, нельзя добавлять в конец за 𝒪(1)
7 void push_front ( Node * & head , int x ) {
8 Node * p = new Node () ;
9 p - > x = x , p - > next = head , head = p ;
10 }
2.7. Список на массиве
1 vector < Node > a ; // массив всех Node-ов списка
2 struct {
3 int next , x ;
4 };
5 int head = -1;
6 void push_front ( int & head , int x ) {
7 a . push_back ( Node { head , x }) ;
8 head = a . size () - 1;
9 }
Можно сделать свои указатели.
Тогда next – номер ячейки массива (указатель на ячейку массива).
Глава #2. 15 сентября. 16/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
2.8. Вектор (расширяющийся массив)
Обычный массив не удобен тем, что его размер фиксирован заранее и ограничен.
Идея улучшения: выделим заранее 𝑠𝑖𝑧𝑒 ячеек памяти, когда реальный размер масси-
ва 𝑛 станет больше 𝑠𝑖𝑧𝑒, удвоим 𝑠𝑖𝑧𝑒, перевыделим память. Операции с вектором:
get(i), set(i, x) 𝒪(1) (как и у массива)
find(x) 𝒪(𝑛) (как и у массива)
push_back(x) Θ(1) (в среднем)
pop_back() Θ(1) (в худшем)
1 int size , n , * a ;
2 void push_back ( int x ) {
3 if ( n == size ) {
4 int * b = new int [2 * size ];
5 copy (a , a + size , b ) ;
6 a = b , size *= 2;
7 }
8 a [ n ++] = x ;
9 }
10 void pop_back () { n - -; }
Теорема 2.8.1. Среднее время работы одной операции 𝒪(1)
Доказательство. Заметим, что перед удвоением размера 𝑛 → 2𝑛 будет хотя бы 𝑛2 операций
push_back, значит среднее время работы последней всех push_back между двумя удвоениями,
включая последнее удвоение 𝒪(1) ■
2.9. Стек, очередь, дек
Это названия интерфейсов (множеств функций, доступных пользователю)
Стек (stack) push_back за 𝒪(1), pop_back за 𝒪(1). First In Last Out.
Очередь (queue) push_back за 𝒪(1), pop_front за 𝒪(1). First In First Out.
Дек (deque) все 4 операции добавления/удаления.
Реализовывать все три структуры можно, как на списке так и на векторе.
Деку нужен двусвязный список, очереди и стеку хватит односвязного.
Вектор у нас умеет удваиваться только при push_back. Что делать при push_front?
1. Можно удваиваться в другую сторону.
2. Можно использовать циклический вектор.
∙ Дек на циклическом векторе
deque: { vector<int>a; int start, end; }, данные хранятся в [start, end)
sz(): { return a.size(); }
n(): { return end - start + (start <= end ? 0 : sz()); }
get(i): { return a[(i + start) % sz()]; }
push_front(x): { start = (start - 1 + sz()) % sz(), a[start] = x; }
Глава #2. 15 сентября. 17/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
2.10. Очередь, стек и дек с минимумом
В стеке можно поддерживать минимум.
Для этого по сути нужно поддерживать два стека – стек данных и стек минимумов.
∙ Стек с минимумом – это два стека.
push(x): a.push(x), m.push(min(m.back(), x)
Здесь m – “частичные минимумы”, стек минимумов.
∙ Очередь с минимумом через два стека
Чтобы поддерживать минимум на очереди проще всего представить её, как два стека 𝑎 и 𝑏.
1 Stack a , b ;
2 void push ( int x ) { b . push ( x ) ; }
3 int pop () {
4 if ( a . empty () ) // стек a закончился, пора перенести элементы b в a
5 while ( b . size () )
6 a . push ( b . pop () ) ;
7 return a . pop () ;
8 }
9 int getMin () { return min ( a . getMin () , b . getMin () ) ; }
∙ Очередь с минимумом через дек минимумов
Будет разобрано на практике. См. разбор третьей практики.
∙ Дек с минимумом через два стека
Будет решено на практике. См. разбор третьей практики.
Глава #3. 15 сентября. 18/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
Лекция #3: Структуры данных
22 сентября
3.1. Амортизационный анализ
Мы уже два раза оценивали время в среднем – для вектора и очереди с минимумом. Для
более сложных случаев есть специальная система оценки «времени работы в среднем», которую
называют «амортизационным анализом».
Пусть наша программа состоит из 𝑚 элементарных операций, 𝑖-ая из которых работает 𝑡𝑖 .
Def 3.1.1. 𝑡𝑖 – real time (реальное время одной операции)
∑︀
𝑖 𝑡𝑖
Def 3.1.2. 𝑡𝑎𝑣𝑒 = 𝑚
– average time (среднее время)
Def 3.1.3. 𝑎𝑖 = 𝑡𝑖 + Δ𝜙𝑖 – amortized time (амортизированное время одной операции)
Здесь Δ𝜙𝑖 = 𝜙𝑖+1 − 𝜙𝑖 = изменение функции 𝜙, вызванное 𝑖-й операцией.
𝑎𝑖 – время, амортизированное функцией 𝜙. Что за 𝜙?
Можно рассматривать ∀𝜙! Интересно подобрать такую, чтобы 𝑎𝑖 всегда было небольшим.
∙ Пример: вектор.
Рассмотрим 𝜙 = −𝑠𝑖𝑧𝑒 (размер вектора, взяли такой потенциал из головы).
1. Нет удвоения: 𝑎𝑖 = 𝑡𝑖 + Δ𝜙𝑖 = 1 + 0 = 𝒪(1)
2. Есть удвоение: 𝑎𝑖 = 𝑡𝑖 + Δ𝜙𝑖 = 𝑠𝑖𝑧𝑒 + (𝜙𝑖+1 − 𝜙𝑖 ) = 𝑠𝑖𝑧𝑒 + (−2𝑠𝑖𝑧𝑒 + 𝑠𝑖𝑧𝑒) = 0 = 𝒪(1)
Получили 𝑎𝑖 = 𝒪(1), хочется сделать из этого вывод, что 𝑡𝑎𝑣𝑒 = 𝒪(1)
∙ Строгие рассуждения.
∑︀ ∑︀
Lm 3.1.4. 𝑡𝑖 = 𝑎𝑖 − (𝜙𝑒𝑛𝑑 − 𝜙0 )
Доказательство. Сложили равенства 𝑎𝑖 = 𝑡𝑖 + (𝜙𝑖+1 − 𝜙𝑖 ) ■
𝜙0 −𝜙𝑒𝑛𝑑
Теорема 3.1.5. 𝑡𝑎𝑣𝑒 = 𝒪(max 𝑎𝑖 ) + 𝑚 ∑︀
𝑖 𝑡𝑖
∑︀
Доказательство. В лемме делим равенство на 𝑚, 𝑎𝑖 /𝑚 ⩽ max 𝑎𝑖 , заменяем 𝑚
на 𝑡𝑎𝑣𝑒 ■
Следствие 3.1.6. Если 𝜙0 = 0, ∀𝑖 𝜙𝑖 ⩾ 0, то 𝑡𝑎𝑣𝑒 = 𝒪(max 𝑎𝑖 )
∙ Пример: push, pop(k)
Пусть есть операции push за 𝒪(1) и pop(k) – достать сразу 𝑘 элементов за Θ(𝑘).
Докажем, что в среднем время любой операции 𝒪(1). Возьмём 𝜙 = 𝑠𝑖𝑧𝑒
push: 𝑎𝑖 = 𝑡𝑖 + Δ𝜙 = 1 + 1 = 𝒪(1)
pop: 𝑎𝑖 = 𝑡𝑖 + Δ𝜙 = 𝑘 − 𝑘 = 𝒪(1)
Также заметим, что 𝜙0 = 0, 𝜙𝑒𝑛𝑑 ⩾ 0.
∙ Пример: 𝑎2 + 𝑏2 = 𝑁
1 int y = sqrt ( n ) , cnt = 0;
2 for ( int x = 0; x * x <= n ; x ++)
3 while ( x * x + y * y > n ) y - -;
4 if ( x * x + y * y == n ) cnt ++;
Одной операцией назовём итерацию внешнего цикла for.
Рассмотрим сперва корректный потенциал 𝜙 = 𝑦.
Глава #3. 22 сентября. 19/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
𝑎𝑖 = 𝑡𝑖 + Δ𝜙 = (𝑦𝑜𝑙𝑑 − 𝑦𝑛𝑒𝑤 + 1) + (𝑦𝑛𝑒𝑤 − 𝑦𝑜𝑙𝑑 ) = 𝒪(1)
√ 3.1.5
Также заметим, что 𝜙0 − 𝜙𝑒𝑛𝑑 ⩽ 𝑛 ⇒ 𝑡𝑎𝑣𝑒 = 𝒪(1).
Теперь рассмотрим плохой потенциал 𝜙 = 𝑦 2 .
2 2
𝑎𝑖 = 𝑡𝑖 + Δ𝜙 = (𝑦𝑜𝑙𝑑 − 𝑦𝑛𝑒𝑤 + 1) + (𝑦𝑛𝑒𝑤 − 𝑦𝑜𝑙𝑑 ) = 𝒪(1)
3.1.5 √
Но, при этом 𝜙0 = 𝑛, 𝜙𝑒𝑛𝑑 = 0 ⇒ 𝑡𝑎𝑣𝑒 = 𝒪( 𝑛) =(
Теперь рассмотрим другой плохой потенциал
√ 𝜙˜ = 0.
𝑎𝑖 = 𝑡𝑖 + Δ𝜙˜ = (𝑦𝑜𝑙𝑑 − 𝑦𝑛𝑒𝑤 + 1) = 𝒪( 𝑛) =(
∙ Монетки
Докажем ещё одним способом, что вектор работает в среднем за 𝒪(1).
Когда мы делаем push_back без удвоения памяти, накопим 2 монетки.
Когда мы делаем push_back с удвоением 𝑠𝑖𝑧𝑒 → 2𝑠𝑖𝑧𝑒, это занимает 𝑠𝑖𝑧𝑒 времени, но мы можем
заплатить за это, потратив 𝑠𝑖𝑧𝑒 накопленных монеток. Число денег никогда не будет меньше
нуля, так как до удвоения было хотя бы 𝑠𝑖𝑧𝑒
2
операций «push_back без удвоения».
Эта идея равносильна идее про потенциалы. Мы неявно определяем функцию 𝜙 через её Δ𝜙.
𝜙 – количество накопленных и ещё не потраченных монеток. Δ𝜙 = соответственно +2 и -𝑠𝑖𝑧𝑒.
3.2. Разбор арифметических выражений
Разбор выражений с числами, скобками, операциями.
Предположим, все операции левоассоциативны (вычисляются слева направо).
Решение: идти слева направо, поддерживать два — необработанные операции и аргументы.
Приоритеты: map<char,int> priority = {{’+’:1}, {’-’:1}, {’*’:2}, {’/’:2}, {’(’:-1}};
Почему у «(» такой маленький? Чтобы, пока «(» лежит на стеке, она точно не выполнилась.
1 stack < int > value ; // уже посчитанные значения
2 stack < char > op ; // ещё не выполненные операции
3 void make () : // выполнить последнюю невыполненную операцию
4 int b = value . top () ; value . pop () ;
5 int a = value . top () ; value . pop () ;
6 char o = op . top () ; op . pop () ;
7 value . push ( a o b ) ; // да, не скомпилится, но смысл такой
8 int eval ( string s ) : // пусть 𝑠 без пробелов
9 s = ’( ’ + s + ’) ’ // при выполнении последней ’)’, выражение вычислится
10 for ( char c : s )
11 if ( ’0 ’ <= c && c <= ’9 ’) value . push ( c - ’0 ’) ; // просто добавили
12 else if ( c == ’( ’) op . push ( c ) ; // просто добавили, её приоритет меньше всех
13 else if ( c == ’) ’) { // закрылась? ищем парную открывающую на стеке
14 while ( op . top () != ’( ’) make () ;
15 op . pop () ;
16 } else { // пришла операция? можно выполнить все предыдущие большего приоритета
17 while ( op . size () && priorioty [ op . top () ] >= priorioty [ c ]) make () ;
18 op . push ( c ) ;
19 }
20 return value . top () ;
Теорема 3.2.1. Время разбора выражения 𝑠 со стеком равно Θ(|𝑠|)
Доказательство. В функции eval число вызовов push не больше |𝑠|. Операция make уменьшает
размер стеков, поэтому число вызовов make не больше числа операций push в функции eval. ■
Глава #3. 22 сентября. 20/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
3.3. Бинпоиск
3.3.1. Обыкновенный
Дан отсортированный массив. Сортировать мы пока умеем только так:
int a[n]; sort(a, a + n);
vector<int> a(n); sort(a.begin(), a.end());
Сейчас мы научимся за 𝒪(log 𝑛) искать в этом массиве элемент 𝑥
1 int find ( int l , int r , int x ) : // [l,r]
2 while ( l <= r ) {
3 int m = ( l + r ) / 2;
4 if ( a [ m ] == x ) return m ;
5 if ( a [ m ] < x ) l = m + 1;
6 else r = m - 1;
7 return -1;
Lm 3.3.1. Время работы 𝒪(log 𝑛)
Доказательство. Каждый раз мы уменьшаем длину отрезка [𝑙, 𝑟] как минимум в 2 раза. ■
∙ Задача про нули и единицы.
Решим похожую задачу: есть монотонный массив a[0..n-1] из нулей и единиц (сперва идут ну-
ли, затем единицы). Пример: 0000111111111. Задача: найти позицию последнего нуля и первой
единицы.
Решение бинпоиском: чтобы в массиве точно был хотя бы один ноль и хотя бы одна единица,
мысленно припишем a[-1] = 0, a[n] = 1, поставим указатели L = -1, R = n и будем сле-
дить, чтобы всегда было a[L] = 0, a[R] = 1. Как и выше L и R сближаются, на каждом шаге
отрезок сужается в два раза.
1 while ( R - L > 1) {
2 int m = ( L + R ) / 2; // 0 ⩽ 𝑚 < 𝑛 ⇒ нет выхода за пределы a[]
3 if ( a [ m ] == 0) // можно просто (!a[m] ? L : R) = m;
4 L = m;
5 else
6 R = m;
7 } // после бинпоиска R-L=1,a[L]=0,a[R]=1
3.3.2. Lowerbound и Upperbound
Задача: дан сортированный массив, найти min 𝑖 : 𝑎𝑖 ⩾ 𝑥.
Например a: 1 2 2 2 3 3 7, lower_bound(3): 1 2 2 2 3 3 7, 𝑖 = 4.
Сведём задачу к предыдущей (нули и единицы): 𝑎𝑖 < 𝑥 нули, 𝑎𝑖 ⩾ 𝑥 единицы.
Пишем ровно такой же бинпоиск, как выше, но условие «a[m] == 0» меняется на a[m] < x.
1 int lower_bound ( int l , int r , int x ) : // [l,r)
2 while ( R - L > 1) {
3 int m = ( L + R ) / 2;
4 if ( a [ m ] < x )
5 L = m;
6 else
7 R = m;
8 } // после бинпоиска R-L=1, a[L]<x, a[R]⩾x
Глава #3. 22 сентября. 21/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
Заметим, что этот бинпоиск строго мощнее чем наш первый find:
find(l, r, x): return a[lower_bound(l, r, x)] == x;
В языке C++ есть стандартные функции
1 int a [ n ]; // массив из 𝑛 элементов
2 i = lower_bound (a , a + n , x ) - a ; // min i: a[i] >= x
3 i = upper_bound (a , a + n , x ) - a ; // min i: a[i] > x
4 vector < int > a ( n ) ;
5 i = lower_bound ( a . begin () , a . end () , x ) - a . begin () ; // начало и конец вектора
Зачем нужно две функции, что они делают? 1 1 2 2 2 3 3 7 → 1 1 2 2 2 3 3 7, находят
первое и последнее вхождение числа в сортированный массив, а их разность – число вхождений.
Ещё можно найти max 𝑖 : 𝑎𝑖 ⩽ 𝑥 = upper_bound − 1 и max 𝑖 : 𝑎𝑖 < 𝑥 = lower_bound − 1.
3.3.3. Бинпоиск по предикату
Предикат – функция, которая возвращает только 0 и 1.
Наш бинпоиск на самом деле умеет искать по любом монотонному предикату.
Мы можем найти такие 𝑙 + 1 = 𝑟, что 𝑓 (𝑙) = 0, 𝑓 (𝑟) = 1.
Например, Выше мы искали по предикату 𝑓 (𝑖) = (a[i] ⩽ x ? 0 : 1).
1 void find_predicate ( int &l , int & r ) : // изначально f(l) = 0, f(r) = 1
2 while ( r - l > 1) :
3 int m = ( l + r ) / 2;
4 ( f ( m ) ? r : l ) = m ; // короткая запись if (f(m)) r=m; else l=m;
Пример, как с помощью find_predicate сделать lower_bound.
1 bool f ( int i ) { return a [ i ] >= x ; }
2 int l = -1 , r = n ; // мысленно добавим a[-1] = −∞, a[n] = +∞
3 find_predicate (l , r ) ; // f() будет вызываться только для элементов от l+1 до r-1
4 return r ; // f(r) = 1, f(r-1) = 0
Глава #3. 22 сентября. 22/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
3.3.4. Вещественный, корни многочлена
Дан многочлен 𝑃 нечётной степени со старшим коэффициентом 1. У него есть вещественный
корень и мы можем его найти бинарным поиском с любой наперёд заданной точностью 𝜀.
Сперва нужно найти точки 𝑙, 𝑟 : 𝑃 (𝑙) < 0, 𝑃 (𝑟) > 0.
1 for ( l = -1; P ( l ) >= 0; l *= 2) ;
2 for ( r = +1; P ( r ) <= 0; r *= 2) ;
Теперь собственно поиск корня:
1 while ( r - l > 𝜀)
2 double m = ( l + r ) / 2;
3 (P(m) < 0 ? l : r) = m;
Внешний цикл может быть бесконечным из-за погрешности (𝑙=109 , 𝑟=109 +10−6 , 𝜀=10−9 )
Чтобы он точно завершился, посчитаем, сколько мы хотим итераций: 𝑘 = log2 𝑟−𝑙 𝜀
, и сделаем
ровно 𝑘 итераций: for (int i = 0; i < k; i++).
Поиск всех вещественных корней многочлена степени 𝑛 будет в 6-й практике (см. разбор).
3.4. Два указателя и операции над множествами
Множества можно хранить в виде отсортированных массивов. Наличие элемента в множестве
можно проверять бинпоиском за 𝒪(log 𝑛), а элементы перебирать за линейное время.
Также, зная 𝐴 и 𝐵, за линейное время методом «двух указателей» можно найти 𝐴 ∩ 𝐵, 𝐴 ∪
𝐵, 𝐴 ∖ 𝐵, объединение мультимножеств.
В языке C++ это операции set_intersection, set_union, set_difference, merge.
Все они имеют синтаксис k = merge(a, a+n, b, b+m, c)- c, где 𝑘 – количество элементов в ответе,
𝑐 – указатель «куда сохранить результат». Память под результат должны выделить вы сами.
Пример применение «двух указателей» для поиска пересечения.
Вариант #1, for:
1 B [| B |] = +∞; // барьерный элемент
2 for ( int k = 0 , j = 0 , i = 0; i < | A |; i ++)
3 while ( B [ j ] < A [ i ]) j ++;
4 if ( B [ j ] == A [ i ]) C [ k ++] = A [ i ];
Вариант #2, while:
1 int i = 0 , j = 0;
2 while ( i < | A | && j < | B |)
3 if ( A [ i ] == B [ j ]) C [ k ++] = A [ i ++] , j ++;
4 else ( A [ i ] < B [ j ] ? i : j ) ++;
Глава #3. 22 сентября. 23/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
3.5. Хеш-таблица
Задача: изначально есть пустое множество целых чисел хотим уметь быстро делать много
операций вида добавить элемент, удалить элемент, проверить наличие элемента.
Медленное решение: храним множество в векторе,
add = push_back = 𝒪(1), find = 𝒪(𝑛), del = find + 𝒪(1) (swap с последним и pop_back).
Простое решение: если элементы множества от 0 до 106 , заведём массив is[106 +1].
is[x] = есть ли элемент x в множестве. Все операции за 𝒪(1).
Решение: хеш-таблица – структура данных, умеющая делать операции add, del, find за ран-
домизированное 𝒪(1).
3.5.1. Хеш-таблица на списках
1 list < int > h [ N ]; // собственно хеш-таблица
2 void add ( int x ) { h [ x % N ]. push_back ( x ) ; } // 𝒪(1) в худшем
3 auto find ( int x ) { return find ( h [ x % N ]. begin () , h [ x % N ]. end () , x ) ; }
4 // find работает за длину списка
5 void erase ( int x ) { h [ x % N ]. erase ( find ( x ) ) ; } // работает за find + 𝒪(1)
Вместо list можно использовать любую структуру данных, vector, или даже хеш-таблицу.
Если в хеш-таблице живёт 𝑛 элементов и они равномерно распределены по спискам, в каждом
списке 𝑁𝑛 элементов ⇒ при 𝑛 ⩽ 𝑁 и равномерном распределении элементов, все операции ра-
ботают за 𝒪(1). Как сделать распределение равномерным? Подобрать хорошую хеш-функцию!
Утверждение 3.5.1. 𝑁 — случайное простое ⇒ хеш-функция x → x % N достаточно хорошая.
Без доказательства. Мы утверждаем хорошесть только для списочной хеш-таблицы.
Если добавлять в хеш-таблицу новые элементы, со временем 𝑛 станет больше 𝑁 .
В этот момент нужно перевыделить память 𝑁 → 2𝑁 и передобавить все элементы на новое
место. Возьмём 𝜙 = −𝑁 ⇒ амортизированное время удвоения 𝒪(1).
3.5.2. Хеш-таблица с открытой адресацией
Реализуется на одном циклическом массиве. Хеш-функция используется, чтобы получить на-
чальное значение ячейки. Далее двигаемся вправо, пока не найдём ячейку, в которой живёт
наш элемент или свободную ячейку, куда можно его поселить.
1 int h [ N ]; // собственно хеш-таблица
2 // h[i] = 0 : пустая ячейка
3 // h[i] = -1 : удалённый элемент
4 // h[i] > 0 : лежит что-то полезное
5 int getIndex ( int x ) : // поиск индекса по элементу, требуем 𝑥 > 0
6 int i = x % N ; // используем хеш-функцию
7 while ( h [ i ] && h [ i ] != x )
8 if (++ i == N ) // массив циклический
9 i = 0;
10 return i ;
Глава #3. 22 сентября. 24/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
1. Добавление: h[getIndex(x)] = x;
2. Удаление: h[getIndex(x)] = -1;, нужно потребовать x != -1, ячейка не становится свободной.
3. Поиск: return h[getIndex(x)] != 0;
Lm 3.5.2. Если в хеш-таблице с открытой адресацией размера 𝑁 занято 𝛼𝑁 ячеек, 𝛼 < 1,
1
матожидание время работы getIndex не более 1−𝛼 .
Доказательство. Худший случай – x отсутствует в хеш-таблице. Без доказательства предпо-
ложим, что свободные ячейки при хорошей хеш-функции расположены равномерно.
Тогда на каждой итерации цикла while вероятность «не остановки» равна 𝛼.
Вероятность того, что мы не остановимся и после 𝑘 шагов равна 𝛼𝑘 , то есть, сделаем 𝑘-й шаг (не
∞
∑︀
ровно 𝑘 шагов, а именно 𝑘-й!). Время работы = матожидание числа шагов = 1+ (вероятность
𝑘=1
1
того, что мы сделали 𝑘-й шаг) = 1 + 𝛼 + 𝛼2 + 𝛼3 + · · · = 1−𝛼
. ■
Lm 3.5.3. ∃ тест такой, что ∀𝑁 хеш-функция x → x % N плоха.
Доказательство. Тест: добавляем числа 0, 2, . . . 𝑛 и 𝑟, 𝑟+1, 𝑟+2, . . . 𝑟+𝑛, где 𝑟 = 𝑟𝑎𝑛𝑑𝑜𝑚. ■
Утверждение 3.5.4. Пусть 𝑁 — любое фиксированное простое, а 𝑟 = 𝑟𝑎𝑛𝑑𝑜𝑚[1, 𝑁 −1], фикси-
рованное число, тогда: хеш-функция x → (x · r) % N достаточно хорошая.
Без доказательства. Докажем в следующем семестре в теме «универсальное семейство».
∙ Переполнение хеш-таблицы
При слишком большом 𝛼 операции с хеш-таблицей начинают работать долго.
При 𝛼 = 1 (нет свободных ячеек), getIndex будет бесконечно искать свободную. Что делать?
При 𝛼 > 32 удваивать размер и за линейное время передобавлять все элементы в новую таблицу.
При копировании, конечно, пропустим все −1 (уже удалённые ячейки) ⇒ удалённые ячейки
занимают лишнюю память ровно до ближайшего перевыделения памяти.
3.5.3. Сравнение
У нас есть два варианта хеш-таблицы. Давайте сравним.
Пусть мы храним 𝑥 байт на объект и 8 на указатель, тогда хеш-таблицы используют:
∙ Списки (если ровно 𝑛 списков): 8𝑛 + 𝑛(𝑥+8) = 𝑛(𝑥+16) байт.
∙ Открытая адресация (если запас в 1.5 раз): 1.5𝑛·𝑥 байт.
Время работы: открытася адресация делает 1 просмотр, списки 2 (к списку, затем к первому
элементу) ⇒ списки в два раза дольше.
Глава #3. 22 сентября. 25/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
3.5.4. C++
В плюсах зачем-то реализовали на списках... напишите свою, будет быстрее.
unordered_set<int> h; – хеш-таблица, хранящая множество int-ов.
Использование:
1. unordered_set<int> h(N); выделить заранее память под 𝑁 ячеек
2. h.count(x); проверить наличие 𝑥
3. h.insert(x); добавить 𝑥, если уже был, ничего не происходит
4. h.erase(x); удалить 𝑥, если его не было, ничего не происходит
unordered_map<int, int> h; – хеш-таблица, хранящая pair<int, int>, пары int-ов.
Использование:
1. unordered_map<int, int> h(N); выделить заранее память под 𝑁 ячеек
2. h[i] = x; 𝑖-й ячейкой можно пользовать, как обычным массивом
3. h.count(i); есть ли пара с первой половиной 𝑖 (ключ 𝑖)
4. h.erase(i); удалить пару с первой половиной 𝑖 (ключ 𝑖)
Относиться к unordered_map можно, как к обычному массиву с произвольными индексами.
В теории эта структура называется «ассоциативный массив»: каждому ключу 𝑖 в соответствие
ставится его значение ℎ[𝑖].
Замечание 3.5.5. Чтобы работало всегда, придётся выделять память со случайным запасом:
unordered_map<int, int> h(N + randomTime() % N),
чтобы ушлые люди не могли подобрать к вашей программ анти-хеш теста.
Глава #3. 22 сентября. 26/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
Лекция #4: Структуры данных
29 сентября
4.1. Избавляемся от амортизации
Серьёзный минус вектора – амортизированное время работы. Сейчас мы модифицируем струк-
туру данных, она начнёт чуть дольше работать, использовать чуть больше памяти, но время
одной операции в худшем будет 𝒪(1).
4.1.1. Вектор (решаем проблему, когда случится)
В тот push_back, когда старый вектор a переполнился, выделим память под новый вектор b,
новый элемент положим в b, копировать a пока не будем. Сохраним pos = |a|.
Инвариант: первые pos элементов лежат в a, все следующие в b. Каждый push_back будем
копировать по одному элементу.
1 int *a , * b ; // выделенные области памяти
2 int pos = -1; // разделитель скопированной и не скопированной частей
3 int n , size ; // количество элементов; выделенная память
4 void push_back ( int x ) {
5 if ( pos >= 0) b [ pos ] = a [ pos ] , pos - -;
6 if ( n == size ) {
7 delete [] a ; // мы его уже скопировали, он больше не нужен
8 a = b;
9 size *= 2;
10 pos = n - 1 , b = new int [ size ];
11 }
12 b [ n ++] = x ;
13 }
Как мы знаем, new работает за 𝒪(log 𝑛), это нас устроит.
Тем не менее в этом месте тоже можно получить 𝒪(1).
Lm 4.1.1. К моменту n == size вектор a целиком скопирован в b.
Доказательство. У нас было как минимум 𝑛 операций push_back, каждая уменьшала pos. ■
Операция обращения к 𝑖-му элементу обращается теперь к (i <= pos ? a : b).
Время на копировани не увеличилось. Время обращения к 𝑖-му элементу чуть увеличилось
(лишний if). Памяти в среднем теперь нужно в 1.5 раз больше, т.к. мы в каждый момент
храним и старую, и новую версию вектора.
4.1.2. Вектор (решаем проблему заранее)
Сделаем так, чтобы время обращения к 𝑖-му элементу не изменилось.
Мы начнём копировать заранее, в момент size = 2n, когда вектор находится в нормальном со-
стоянии. Нужно к моменту очередного переполнения получить копию вектора в память больше-
го размера. За n push_back-ов должны успеть скопировать все size = 2n элементов. Поэтому
будем копировать по 2 элемента. Когда в такой вектор мы записываем новые значения (a[i]=x),
нам нужно записывать в обе версии – и старую, и новую.
Глава #4. 29 сентября. 27/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
4.1.3. Сравнение способов
Чтение a[i] во 2-м способе быстрее:
во 2-м способе всегда обратимся к старой версии,
в 1-м способе if (i < pos) a[i] else b[i]
Запись a[i]=x в 1-м способе быстрее:
в 1-м способе записать в одну из двух версий,
во 2-м способе нужно писать в обе версии.
Память: в 1-м способе меньше пустых ячеек.
Как мы увидим на примере очереди с минимумом, 2-й способ более универсальный.
4.1.4. Хеш-таблица
Хеш-таблица – ещё одна структура данных, которая при переполнении удваивается. К ней мож-
но применить оба описанных подхода. Применим первый. Чтобы это сделать, достаточно на-
учиться перебирать все элементы хеш-таблицы и добавлять их по одному в новую хеш-таблицу.
(а) Можно кроме хеш-таблицы дополнительно хранить «список добавленных элементов».
(б) Можно пользоваться тем, что число ячеек не более чем в два раза больше числа элементов,
поэтому будем перебирать ячейки, а из них выбирать не пустые.
Новые элементы, конечно, мы будет добавлять только в новую хеш-таблицу.
4.1.5. Очередь с минимумом через два стека
Напомним, что есть очередь с минимумом.
1 Stack a , b ;
2 void push ( int x ) { b . push ( x ) ; }
3 int pop () {
4 if ( a . empty () ) // стек a закончился, пора перенести элементы b в a
5 while ( b . size () )
6 a . push ( b . pop () ) ;
7 return a . pop () ;
8 }
Воспользуемся вторым подходом «решаем проблему заранее». К моменту, когда стек 𝑎 опустеет,
у нас должна быть уже готова перевёрнутая версия 𝑏. Вот общий шаблон кода.
1 Stack a , b , a1 , b1 ;
2 void push ( int x ) {
3 b1 . push ( x ) ; // кидаем не в b, а в b1, копию b
4 STEP; // сделать несколько шагов копирования
5 }
6 int pop () {
7 if (копирование завершено)
8 a = a1 , b = b1 , начать новое копирование;
9 STEP; // сделать несколько шагов копирования
10 return a . pop () ;
11 }
Почему нам вообще нужно копировать внутри push? Если мы делаем сперва 106 push, затем
106 pop, к моменту всех этих pop у нас уже должен быть подготовлен длинный стек a. Если в
Глава #4. 29 сентября. 28/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
течение push мы его не подготовили, его взять неоткуда.
При копировании мы хотим построить новый стек a1 по старым a и b следующим образом
(STEP сделает несколько шагов как раз этого кода):
1 while ( b . size () ) a1 . push ( b . pop () ) ;
2 for ( int i = 0; i < a . size () ; i ++) a1 . push ( a [ i ]) ;
Заметим, что a.size() будет меняться при вызовах a.pop_back(). for проходит элементы a
снизу вверх. Так можно делать, если стек a реализован через вектор без амортизации. Из кода
видно, что копирование состоит из |𝑎| + |𝑏| шагов. Будем поддерживаем инвариант, что до
начала копирования |𝑎| ⩾ |𝑏|. В каждом pop будем делать 1 шаг копирования, в каждом push
также 1 шаг. Проверка инварианта после серии push: за 𝑘 пушей мы сделали ⩾ 𝑘 копирований,
поэтому |𝑎1 | ⩾ |𝑏1 |. Проверка корректности pop: после первых |𝑏| операций поп все элементы 𝑏
уже скопировались, далее мы докопируем часть a, которая не подверглась pop_back-ам.
Глава #4. 29 сентября. 29/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
4.2. Бинарная куча
Рассмотрим массив 𝑎[1..𝑛]. Его элементы образуют бинарное дерево с корнем в 1.
Дети 𝑖 – вершины 2𝑖, 2𝑖 + 1. Отец 𝑖 – вершина ⌊ 2𝑖 ⌋.
Def 4.2.1. Бинарная куча – массив, индексы которого образуют описанное выше дерево, в
котором верно основное свойство кучи: для каждой вершины 𝑖 значение 𝑎[𝑖] является мини-
мумом в поддереве 𝑖.
Lm 4.2.2. Высота кучи равна ⌊log2 𝑛⌋
Доказательство. Высота равна длине пути от 𝑛 до корня.
Заметим, что для всех чисел от 2𝑘 до 2𝑘+1 − 1 длина пути в точности 𝑘. ■
∙ Интерфейс
Бинарная куча за 𝒪(log 𝑛) умеет делать следующие операции.
1. GetMin(). Нахождение минимального элемента.
2. Add(x). Добавление элемента.
3. ExtractMin(). Извлеяение (удаление) минимума.
Если для элементов хранятся «обратные указатели», позволяющие за 𝒪(1) переходить от эле-
мента к ячейке кучи, содержащей элемент, то куча также за 𝒪(log 𝑛) умеет:
4. DecreaseKey(x, y). Уменьшить значение ключа 𝑥 до 𝑦.
5. Del(x). Удалить из кучи 𝑥.
4.2.1. GetMin, Add, ExtractMin
Реализуем сперва три простые операции.
Наша куча: int n, *a;. Память выделена, её достаточно.
1 void Init () { n = 0; }
2 int GetMin () { return a [1]; }
3 void Add ( int x ) { a [++ n ] = x , siftUp ( n ) ; }
4 void ExtractMin () { swap ( a [1] , a [n - -]) , siftDown (1) ; }
5 // ExtractMin перед удалением сохранил минимум в a[n]
Здесь siftUp – проталкивание элемента вверх, а siftDown – проталкивание элемента вниз. Обе
процедуры считают, что дерево обладает свойством кучи везде, кроме указанного элемента.
1 void siftUp ( int i ) {
2 while ( i > 1 && a [ i / 2] > a [ i ]) // пока мы не корень и отец нас больше
3 swap ( a [ i ] , a [ i / 2]) , i /= 2;
4 }
5 void siftDown ( int i ) {
6 while (1) {
7 int l = 2 * i ;
8 if ( l + 1 <= n && a [ l + 1] < a [ l ]) l ++; // выбрать меньшего из детей
9 if (!( l <= n && a [ l ] < a [ i ]) ) break ; // если все дети не меньше нас, это конец
10 swap ( a [ l ] , a [ i ]) , i = l ; // перейти в ребёнка
11 }
12 }
Глава #4. 29 сентября. 30/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
Lm 4.2.3. Обе процедуры корректны
Доказательство. По индукции на примере siftUp. В каждый момент времени верно, что под-
дерево 𝑖 – корректная куча. Когда мы выйдем из while, у 𝑖 нет проблем с отцом, поэтому вся
куча корректна из предположения «корректно было всё кроме 𝑖». ■
Lm 4.2.4. Обе процедуры работают за 𝒪(log 𝑛)
Доказательство. Они работают за высоту кучи, которая по 4.2.2 равна 𝒪(log 𝑛). ■
4.2.2. Обратные ссылки и DecreaseKey
Давайте предположим, что у нас есть массив значений: vector<int> value.
В куче будем хранить индексы этого массива. Тогда все сравнения a[i] < a[j] следует заме-
нить на сравнения через value: value[a[i]] < value[a[j]]. Чтобы добавить элемент, теперь
нужно сперва добавить его в конец value: value.push_back(x), а затем сделать добавление в
кучу Add(value.size() - 1). Хранение индексов позволяет нам для каждого i помнить пози-
цию в куче pos[i]: a[pos[i]] == i. Значения pos[] нужно пересчитывать каждый раз, когда
мы меняем значения a[]. Как теперь удалить произвольный элемент с индексом i?
1 void Del ( int i ) {
2 i = pos [ i ];
3 h [ i ] = h [n - -] , pos [ h [ i ]] = i ; // не забыли обновить pos
4 siftUp ( i ) , siftDown ( i ) ; // новый элемет может быть и меньше, и больше
5 }
Процедура DecreaseKey(i) делается похоже: перешли к pos[i], сделали siftUp.
Lm 4.2.5. Del и DecreaseKey корректны и работают за 𝒪(log 𝑛)
Доказательство. Следует из корректности и времени работы siftUp, siftDown ■
Благодаря обратным ссылкам мы получили структуру данных, которая умеет обрабатывать
запросы: a[i]=x, getMin(a), a.push_back(x), extractMin.
Решение: «push_back» = add, «a[i]=x» = del(i), поменять a[i]), add(i).
4.2.3. Build, HeapSort
1 void Build ( int n , int * a ) {
2 for ( int i = n ; i >= 1; i - -)
3 siftDown ( i ) ;
4 }
Lm 4.2.6. Функция Build построит корректную бинарную кучу.
Доказательство. Когда мы проталкиваем 𝑖, по индукции слева и справа уже корректные би-
нарные кучи. По корректности операции sift_down после проталкивания 𝑖, поддерево 𝑖 явля-
ется корректной бинарной кучей. ■
Lm 4.2.7. Время работы функции Build Θ(𝑛)
Глава #4. 29 сентября. 31/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
Доказательство. Пусть 𝑛 = 2𝑘 − 1, тогда наша куча – полное бинарное дерево. На самом
последнем (нижнем) уровне будет 2𝑘−1 элементов, на предпоследнем 2𝑘−2 элементов и т.д.
sift_down(i) работает за 𝒪(глубины поддерева 𝑖), поэтому суммарное
𝑘 𝑘
𝑖 (*) 𝑘
2𝑘−𝑖 𝑖 = 2𝑘
∑︀ ∑︀
время работы 2𝑖
= 2 · Θ(1) = Θ(𝑛). (*) доказано на практике. ■
𝑖=1 𝑖=1
1 void HeapSort () {
2 Build (n , a ) ; // строим очередь с максимумом, O(n)
3 forn (i , n ) DelMax () ; // максимум окажется в конце и т.д., O(nlogn)
4 }
Lm 4.2.8. Функция HeapSort работает за 𝒪(𝑛 log 𝑛), использует 𝒪(1) дополнительной памяти.
Доказательство. Важно, что функция Build не копирует массив, строит кучу прямо в a. ■
4.3. Аллокация памяти
Нам дали много памяти. А конкретно MAX_MEM байт: uint8_t mem[MAX_MEM]. Мы – менеджер
памяти. Мы должны выделять, когда надо, освобождать, когда память больше не нужна.
Задача: реализовать две функции
1. int new(int x) выделяет x байт, возвращает адрес первой свободной ячейки
2. void delete(int addr) освобождает память, по адресу addr, которую когда-то вернул new
В общем случае задача сложная. Сперва рассмотрим популярное решение более простой задачи.
4.3.1. Стек
Разрешим освобождать не любую область памяти, а только последнюю выделенную.
Тогда сделаем из массива mem стек: первые pos ячеек — занятая память, остальное свободно.
Выделить 𝑛 байт: pos += n;. Освободить последние 𝑛 байт: pos -= n;. Код:
1 int pos = 0; // указатель на первую свободную ячейку
2 int new ( uint32_t n ) : // push n bytes
3 pos += n ;
4 assert ( pos <= MAX_MEM ) ; // проверить, что памяти всё ещё хватает
5 return pos - n ;
6 void delete ( uint32_t old_pos ) : // освободили всю память, выделенную с момента old_pos
7 pos = old_pos ; // очищать можно только последнюю выделенную
В C++ при вызове функции, при создании локальных переменных используется ровно та-
кая же модель аллокации память, называется также — «стек». Иногда имеет смысл реализо-
вать свой стек-аллокатор и перегрузить глобальный operator new, так как стандартные STL-
контейнеры vector, set внутри много раз обращаются к медленному operator new.
Эффект ощутим: vector<vector<int>> a(10,000,000) ускоряется в 4 раза.
[code], [vector-experiment]
4.3.2. Список
Ещё один частный простой случай x = CONST, все выделяемые ячейки одного размера.
Идея: разобьём всю память на куски по x байт. Свободные куски образуют список (односвяз-
Глава #4. 29 сентября. 32/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
ный), мы храним голову этого списка. Выделить память: откусить голову списка. Особожде-
ние памяти: добавить в начало списка. Подробнее + детали реализации:
Пусть наше адресуемое пространство 32-битное, то есть, MAX_MEM ⩽ 232 . Тогда давайте исходные
MAX_MEM байт памяти разобьём на 4 байта head и на 𝑘 = ⌊ MAX_MEM−4
max(𝑥,4)
⌋ ячеек по max(𝑥, 4) байт.
Каждая из 𝑘 ячеек или свободная, тогда она – «указатель на следующую свободную», или
занята, тогда она – «𝑥 байт полезной информации». head – начало списка свободных ячеек,
первая свободная. Изначально все ячейки свободны и объединены в список.
1 const uint32_t size ; // размер блока, size >= 4
2 uint32_t head = 0; // указатель на первый свободный блок
3 uint8_t mem [ MAX_MEM -4]; // часть памяти, которой пользуется new
4 uint32_t * pointer ( uint32_t i ) { return ( uint32_t *) ( mem + i ) ; } // magic =)
5 void init () {
6 for ( uint32_t i = 0; i + size <= MAX_MEM -4; i += size )
7 * pointer ( i ) = i + size ; // указываем на следующий блок
8 }
9 uint32_t new () { // вернёт адрес в нашем 32-битном пространстве mem
10 uint32_t res = head ;
11 head = * pointer ( head ) ; // следующий свободный блок
12 return res ;
13 }
14 void delete ( uint32_t x ) {
15 * pointer ( x ) = head ; // записали в ячейки [x..x+4) старый head
16 head = x ;
17 }
4.3.3. Куча (кратко)
В общем случае (выделяем сколько угодно байт, освобождаем память в любом порядке) массив
mem разбит на отрезки свободной памяти и отрезки занятой памяти. Какой свободный отрезок
памяти использовать, кода просят выделить 𝑛 байт? Любой длины ⩾ 𝑥, максимальный подойдёт
⇒ отрезки свободной памяти будем хранить в куче по длине (в корне максимум).
∙ Операция new(x).
Если в корне кучи максимум меньше 𝑥, память не выделяется.
Иначе память выделяется за 𝒪(1) + ⟨время просеивания вниз в куче⟩ = 𝒪(log 𝑛).
∙ Операция delete(addr).
Нужно понять про отрезки слева/справа от addr — заняты они, или свободны.
Если свободны, узнать их длину, удалить из кучи, добавить новый большой свободный отрезок.
4.3.4. (*) Куча (подробно)
Сделаем 32-битную версию (все указатели по 4 байта).
Храним кучу свободных кусков. Пусть все свободные куски имеют размер хотя бы 12 байт.
(a) Память = служебная информация + пользовательская память.
(b) Первые 4 + 𝑛 · 8 байт = кол-во элементов в куче + собственно куча.
Каждая ячейка кучи — пара ⟨размер, указатель⟩. В корне максимум.
Далее идёт пользовательская память, которая и будет аллоцироваться.
(c) new(size) : смотрим корень, если heap[1] >= size, возвращаем size последних байт,
уменьшаем heap[1] на size, вызываем siftDown.
Глава #4. 29 сентября. 33/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
(d) delete(addr,size). Если соседи — не свободные куски, добавим новый элемент в кучу.
Для этого расширим: кучу, откусим 8 байт смежного с кучей свободного куска. Если
соседи — пустые куски, объединим нас и их в один большой кусок, сделаем siftUp в куче.
Как выделять память под кучу? Можно заранее фиксировать 𝑁 и выделить 8𝑁 байт
памяти. Можно надеяться, что смежный с кучей кусок свободен, и при n++ отщеплять от него
очередные 8 байт. Можно по образу вектора с удвоением по надобности выделять память,
используя себя же, как источник памяти. Выше выбран второй вариант.
Как понять, пусты ли соседи?
Давайте поддерживать, что каждый свободный кусок устроен так: первые 4 байта = указатель
на конец куска, вторые 4 байта = обратная ссылка (индекс куска в куче), последние 4 байта =
указатель на начало куска. Смотрим на соседей, пытаемся их интерпретировать как свободные,
за 𝒪(1) проверяем, что место, на которое они указали в куче хранит именно их.
4.3.5. (*) Дефрагментация
На входе дефрагментации используемая память = набор мелких отрезков, на выходе мы хотим,
чтобы вся используемая память шла подряд (образовывала один отрезок). Это делают для
жёстких дисков. Это же мы можем сделать и при аллокации оперативной памяти.
Стековый аллокатор можно переделать в универсальный.
Идея: освободить памяти = лениво пометить ячейку, как свободную. Когда память кончилась,
делаем дефрагментацию: пройдёмся за линию двумя указателями, оставим только реально
существующие ячейки. Чтобы это работало нам нужно уметь подменить уже существующие
указатели на новые + помечать ячейки, как свободные.
С аллокатором кучей можно иногда делать то же. Там это не столь критично,
но тоже ценно в ситуации, например, 010101...01 (0 — свободная ячейка).
4.4. Пополняемые структуры
Все описанные в этом разделе идеи применимы не ко всем структурам данных. Тем не менее к
любой структуре любую из описанных идей можно попробовать применить.
4.4.1. Ничего → Удаление
Вид «ленивого удаления». Пример: куча.
Есть операция DelMin, хотим операцию удаления произвольного элемента, ничего не делая.
Будем хранить две кучи – добавленные элементы и удалённые элементы.
1 Heap a , b ;
2 void Add ( int x ) { a . add ( x ) ; }
3 void Del ( int x ) { b . add ( x ) ; }
4 int DelMin () {
5 while ( b . size () && a . min () == b . min () )
6 a . delMin () , b . delMin () ; // пропускаем уже удалённые элементы
7 return a . delMin () ;
8 }
Время работы DelMin осталось тем же, стало амортизированным.
В худшем случае все DelMin в сумме работают Θ(𝑛 log 𝑛).
Зачем это нужно? Например, std::priority_queue.
Глава #4. 29 сентября. 34/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
4.4.2. Поиск → Удаление
Вид «ленивого удаления». Таким приёмом мы уже пользовались при удалении из хеш-таблицы
с открытой адресацией. Идея: у нас есть операция Find, отлично, найдём элемент, пометим его,
как удалённый. Удалять прямо сейчас не будем.
4.4.3. Add → Merge
Merge (слияние) – операция, получаещая на вход две структуры данных, на выход даёт одну,
равную их объединению. Старые структуры объявляются невалидными.
Пример #1. Merge двух сортированных массивов.
Пример #2. Merge двух куч. Сейчас мы научимся делать его быстро.
∙ Идея. У нас есть операция добавления одного элемента, переберём все элементы меньшей
структуры данных и добавим их в большую.
1 Heap Merge ( Heap a , Heap b ) {
2 if ( a . size < b . size ) swap (a , b ) ;
3 for ( int x : b ) a . Add ( x ) ;
4 return a ;
5 }
Lm 4.4.1. Если мы начинаем с ∅ и делаем 𝑁 произвольных операций из множества {Add,
Merge}, функция Add вызовется не более 𝑁 log2 𝑁 раз.
Доказательство. Посмотрим на код и заметим, что |𝑎| + |𝑏| ⩾ 2|𝑏|, поэтому для каждого 𝑥,
переданного Add верно, что «размер структуры, в которой живёт 𝑥, хотя бы удвоился» ⇒ ∀𝑥
количество операций Add(𝑥) не более log2 𝑁 ⇒ суммарное число всех Add не более 𝑁 log2 𝑁 . ■
4.4.4. Build → Add
Хотим взять структуру данных, которая умеет только Build и Get, научить её Add.
∙ Пример задачи
Структура данных: сортированный массив.
Построение (Build): сортировка за 𝒪(𝑛 log 𝑛).
Запрос (Get): количество элементов со значением от 𝐿 до 𝑅, два бинпоиска за 𝒪(log 𝑛)
∙ Решение #1. Корневая.
Структура данных: храним два сортированных массива – √︀
большой 𝑎 (старые элементы) и маленький 𝑏 (новые элементы), поддерживаем |𝑏| ⩽ |𝑎|.
Новый Get(L,R): return a.Get(L,R) + b.Get(L,R)
кинуть 𝑥 в 𝑏; вызвать √︀
b.Build();
Add(x):
если |𝑏| стало больше |𝑎|, перенести все элементы 𝑏 в 𝑎 и вызвать a.Build().
Замечание 4.4.2. В данной конкретной задаче можно вызов пересортировки за 𝒪(𝑛 log 𝑛) заме-
нить на merge за 𝒪(𝑛). В общем случае у нас есть только Build.
√𝑛 = |𝑎|.
√ от 𝑚 элементов,
Обозначим Build(m) – время работы функции
Между двумя вызовами a.Build() было 𝑛 вызовов Add ⇒ 𝑛 операций Add отработали за
Глава #4. 29 сентября. 35/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Структуры данных
√ √
𝒪(Build(𝑛) + 𝑛·Build( 𝑛))√ = 𝒪(Build(𝑛))
√ (для выпуклых функций Build) ⇒ среднее время
работы Add = 𝒪(Build(𝑛)/ 𝑛) = 𝒪( 𝑛 log 𝑛).
∙ Решение #2. Пополняемые структуры.
Пусть у нас есть структура S с интерфейсом S.Build, S.Get, S.AllElements. У любого числа
𝑁 есть единственное представление в двоичной системе счисления 𝑎1 𝑎2 . . .𝑎𝑘 Для хранения 𝑁 =
2𝑎1 +2𝑎2 +· · ·+2𝑎𝑘 элементов будем хранить 𝑘 структур S из 2𝑎1 , 2𝑎2 , . . . , 𝑎𝑎𝑘 элементов. 𝑘 ⩽ 𝑙𝑜𝑔2 𝑛.
Новый Get работает за 𝑘 · S.Get, обращается к каждой из 𝑘 частей. Сделаем Add(x). Для
этого добавим ещё одну структуру из 1 элемента. Теперь сделаем так, чтобы не было структур
одинакового размера.
1 for ( i = 1; есть две структуры размера i ; i *= 2)
2 Добавим S . Build ( A . AllElements + B . AllElements ) . // A, B – те самые две структуры
3 Удалим две старые структуры
Заметим, что по сути мы добавляли к числу 𝑁 единицу в двоичной системе счисления.
Lm 4.4.3. Пусть мы начали с пустой структуры, было 𝑛 вызовов ∑︀𝑘 Add. Пусть эти 𝑛 Add 𝑘 раз
дёрнули Build: Build(𝑎1 ), Build(𝑎2 ), . . . , Build(𝑎𝑘 ). Тогда 𝑖=1 𝑎𝑖 ⩽ 𝑛 log2 𝑛
Доказательство. Когда элемент проходит через Build размер структуры, в которой он живёт,
удваивается. Поэтому каждый 𝑥 пройдёт через Build не более log2 𝑛 раз. ■
Lm 4.4.4. Суммарное время обработки 𝑛 запросов не более Build(𝑛 log2 𝑛)
Доказательство. Чтобы получить эту лемму из предыдущей, нужно наложить ограничение
«выпуклость» на время работы Build. ■
𝑎𝑖 )𝑘 ⩾ 𝑎𝑘𝑖 (без доказательства)
∑︀ ∑︀
Lm 4.4.5. ∀𝑘 ⩾ 1, 𝑎𝑖 > 0 : (
Лемма постулирует «полиномы таки выпуклы, поэтому к ним можно применить 4.4.4».
Применение этой идеи для сортированного массива будем называть «пополняемый массив».
4.4.5. Build → Add, Del
Научим «пополняемый массив обрабатывать запросы».
1. Count(l, r) – посчитать число 𝑥 : 𝑙 ⩽ 𝑥 ⩽ 𝑟
2. Add(x) – добавить новый элемент
3. Del(x) – удалить ранее добавленный элемент
Для этого будем хранить два «пополняемых массива» – добавленные элементы, удалённый
элементы. Когда нас просят сделать Count, возвращаем разность Count-ов за 𝒪(log2 𝑛). Add и
Del работают амортизированно за 𝒪(log 𝑛), так как вместо Build, который должен делать sort,
мы вызовем merge двух сортированных массивов за 𝒪(𝑛).
Глава #4. 29 сентября. 36/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
Лекция #5: Сортировки
3 октября
5.1. Два указателя и алгоритм Мо
∙ Задача: дан массив длины 𝑛 и 𝑚 запросов вида
«количество различных чисел на отрезке [𝑙𝑖 , 𝑟𝑖 ]».
Если 𝑙𝑖 ⩽ 𝑙𝑖+1 , 𝑟𝑖 ⩽ 𝑟𝑖+1 – это обычный метод двух указателей с хеш-таблицей внутри. Решение
работает за 𝒪(𝑛 + 𝑚) операций с хеш-таблицей. Такую идею можно применить и к другим
типам запросов. Для этого достаточно, зная ответ и поддерживая некую структуру данных,
для отрезка [𝑙, 𝑟] научиться быстро делать операции l++, r++.
√
Если же 𝑙𝑖 и 𝑟𝑖 произвольны, то есть решение за 𝒪(𝑛 𝑚), что,
конечно, хуже 𝒪(𝑛 + 𝑚), но гораздо лучше обычного 𝒪(𝑛𝑚).
∙ Алгоритм Мо
Во-первых, потребуем теперь четыре типа операций: l++, r++, l--, r--.
Зная ответ для [𝑙𝑖 , 𝑟𝑖 ], получить ответ для [𝑙𝑖+1 , 𝑟𝑖+1 ] можно за∑︀|𝑙𝑖+1
(︀ −𝑙𝑖 | + |𝑟𝑖+1 −𝑟𝑖 | операций.
)︀
Осталось перебирать запросы в правильном порядке, чтобы 𝑖 |𝑙𝑖+1 − 𝑙𝑖 | + |𝑟𝑖+1 − 𝑟𝑖 | → min.
Чтобы получить правильный порядок, отсортируем отрезки по ⟨⌊ 𝑙𝑘𝑖 ⌋, 𝑟𝑖 ⟩, где 𝑘 – константа,
которую ещё предстоит подобрать. После сортировки пары ⟨𝑙𝑖 , 𝑟𝑖 ⟩ разбились на 𝑛𝑘 групп (по 𝑙𝑖 ).
Посмотрим, как меняется ∑︀ 𝑙𝑖 . Внутри группы |𝑙𝑖+1 − 𝑙𝑖 | ⩽ 𝑘, при переходе между группами
(движение только вперёд) |𝑙𝑖+1 − 𝑙𝑖 | ⩽ 2𝑛. Итого 𝑚𝑘+2𝑛 шагов 𝑙𝑖 . Посмотрим, как меняется
𝑟𝑖 . Внутри группы указатель 𝑟 сделает в сумме ⩽ 𝑛 шагов вперёд, при переходе между группами
сделает ⩽ 𝑛 шагов назад. Итого 2𝑛 𝑛𝑘 шагов 𝑟𝑖 . Итого Θ(𝑚 + (𝑚𝑘+2𝑛) + 𝑛𝑘 𝑛) операций.
Подбираем 𝑘: 𝑓 + 𝑔 = Θ(max(𝑓, 𝑔)), при этом с ростом 𝑘 𝑓 = 𝑚𝑘↗, g = 𝑛𝑘 𝑛↘ ⇒
𝑛
оптимально взять 𝑘 : 𝑚𝑘 = √
𝑘
𝑛 ⇒ 𝑘√= (𝑛2 /𝑚)1/2 = 𝑛/𝑚1/2 ⇒ √
время работы 𝑚𝑘 + 𝑛𝑘 𝑛 = 𝑛 𝑚 + 𝑛 𝑚 ⇒ общее время работы Θ(𝑚 + 𝑛 𝑚).
Глава #5. 3 октября. 37/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
5.2. Квадратичные сортировки
Def 5.2.1. Сортировка называется стабильной, если одинаковые элементы она оставляет в
исходном порядке.
Пример: сортируем людей по имени. Люди с точки зрения сортировки считаются равными,
если у них одинаковое имя. Тем не менее порядок людей в итоге важен. Во всех таблицах
(гуглдок и т.д.) сортировки, которые вы применяете к данным, стабильные.
Def 5.2.2. Инверсия – пара 𝑖 < 𝑗 : 𝑎𝑖 > 𝑎𝑗
Def 5.2.3. I – обозначение для числа инверсий в массиве
Lm 5.2.4. Массив отсортирован ⇔ I = 0
∙ Selection sort (сортировка выбором)
На каждом шаге выбираем минимальный элемент, ставим его в начале.
1 for ( int i = 0; i < n ; i ++) {
2 j = index of min on [ i .. n ) ;
3 swap ( a [ j ] , a [ i ]) ;
4 }
∙ Insertion sort (сортировка вставками)
Пусть префикс длины 𝑖 уже отсортирован, возьмём 𝑎𝑖 и вставим куда надо.
1 for ( int i = 0; i < n ; i ++)
2 for ( int j = i ; j > 0 && a [ j ] < a [j -1]; j - -)
3 swap ( a [ j ] , a [j -1]) ;
Корректность: по индукции по 𝑖 ■
Заметим, что можно ускорить сортировку, место для вставки искать бинпоиском.
Сортировка всё равно останется квадратичной.
∙ Bubble sort (сортировка пузырьком)
Бесполезна. Изучается, как дань истории. Простая.
1 for ( int i = 0; i < n ; i ++)
2 for ( int j = 1; j < n ; j ++)
3 if ( a [j -1] > a [ j ])
4 swap ( a [j -1] , a [ j ]) ;
Корректность: на каждой итерации внешнего цикла очередной максимальный элемент встаёт
на своё место, «всплывает».
∙ Сравним пройденные сортировки.
Название < swap stable
2
Selection 𝒪(𝑛 ) 𝒪(𝑛) -
Insertion 𝒪(𝑛 + I) 𝒪(I) +
Ins + B.S. 𝒪(𝑛 log 𝑛) 𝒪(I) +
Bubble 𝒪(𝑛2 ) 𝒪(I) +
Три нижние стабильны, т.к. swap применяется только к соседям, образующим инверсию. Ко-
личество swap-ов в Insertion равно I (каждый swap ровно на 1 уменьшает I).
Чем ценна сортировка выбором? swap может быть дорогой операцией. Пример: мы сортируем
Глава #5. 3 октября. 38/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
103 тяжёлых для swap объектов, не имея дополнительной памяти.
Чем ценна сортировка вставками? Малая константа. Самая быстрая по константе.
5.3. Оценка снизу на время сортировки
Пусть для сортировки объектов нам разрешено общаться с этими объектами единственным
способом – сравнивать их на больше/меньше. Такие сортировки называются основанные на
сравнениях.
Lm 5.3.1. Сортировка, основанная на сравнениях, делает на всех тестах 𝑜(𝑛 log 𝑛) сравнений
⇒ ∃ тест, на котором результат сортировки не корректен.
Доказательство. Докажем, что ∃ тест вида «перестановка». Всего есть 𝑛! различных переста-
новок. Пусть сортировка делает не более 𝑘 сравнений. Заставим её делать ровно 𝑘 сравнений
(возможно, несколько бесполезных). Результат каждого сравнения – меньше (0) или больше
(1). Сортировка получает 𝑘 бит информации, и результат её работы зависит только от этих 𝑘
бит. То есть, если для двух перестановок она получит одни и те же 𝑘 бит, одну из этих двух
перестановок она отсортирует неправильно. Сортировка корректна ⇒ 2𝑘 ⩾ 𝑛!.
2𝑘 < 𝑛! ⇒ сортировка не корректна. Осталось вспомнить, что log(𝑛!) = Θ(𝑛 log 𝑛). ■
Мы доказали нижнюю оценку на время работы произвольной сортировки сравнениями. Дока-
зали, что любая детерминированная (без использования случайных чисел) корректная сорти-
ровка делает хотя бы Ω(𝑛 log 𝑛) сравнений.
5.4. Решение задачи по пройденным темам
Задача: даны два массива, содержащие множества, найти размер пересечения.
Решения:
1. Отсортировать первый массив, бинпоиском найти элементы второго. 𝒪(𝑛 log 𝑛).
2. Отсортировать оба массива, пройти двумя указателями. 𝒪(𝑠𝑜𝑟𝑡).
3. Элементы одного массива положить в хеш-таблицу, наличие элементов второго проверить.
𝒪(𝑛), но требует Θ(𝑛) доппамяти, и имеет большую константу.
5.5. Быстрые сортировки
Мы уже знаем одну сортировку за 𝒪(𝑛 log 𝑛) – HeapSort.
Отметим её замечательные свойства: не использует дополнительной памяти, детерминирована.
5.5.1. CountSort (подсчётом)
Целые числа от 0 до 𝑚 − 1 можно отсортировать за 𝒪(𝑛 + 𝑚).
В частности целые число от 0 до 2𝑛 можно отсортировать за 𝒪(𝑛).
1 int n , a [ n ];
2 for ( int i = 0; i < n ; i ++) // Θ(𝑛)
3 count [ x ]++; // насчитали, сколько раз 𝑥 встречается в 𝑎
4 for ( int x = 0; x < m ; x ++) // Θ(𝑚), перебрали 𝑥 в порядке возрастания
5 while ( count [ x ] - -)
6 out ( x ) ;
Глава #5. 3 октября. 39/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
Мы уже доказали, что сортировки, основанные на сравнениях не могут работать за 𝒪(𝑛). В дан-
ном случае мы пользовались операцией count[x]++, ячейки массива count упорядочены также,
как и числа. Именно это даёт ускорение.
5.5.2. MergeSort (слиянием)
Идея: отсортируем левую половину массива, правую половину массива, сольём два отсортиро-
ванных массива в один методом двух указателей.
1 void MergeSort ( int l , int r , int *a , int * buffer ) { // [l, r)
2 if ( r - l <= 1) return ;
3 int m = ( l + r ) / 2;
4 MergeSort (l , m , a , buffer ) ;
5 MergeSort (m , r , a , buffer ) ;
6 Merge (l , m , r , a , buffer ) ; // слияние за 𝒪(𝑟−𝑙), используем буффер
7 }
buffer – дополнительная память, которая нужна функции Merge. Функция Merge берёт от-
сортированные куски [𝑙, 𝑚), [𝑚, 𝑟), запускает метод двух указателей, который отсортированное
объединение записывает в buffer. Затем buffer копируется обратно в a[𝑙, 𝑟).
Lm 5.5.1. Время работы 𝒪(𝑛 log 𝑛)
Доказательство. 𝑇 (𝑛) = 2𝑇 ( 𝑛2 ) + 𝑛 = Θ(𝑛 log 𝑛) (по мастер-теореме) ■
∙ Нерекурсивная версия без копирования памяти
Представим, что 𝑛 = 2𝑚 . В рекурсивной версии мы обходим дерево рекурсии сверху вниз. Снизу
у нас куски массива длины 1, чуть выше 2, 4 и т.д. Давайте перебирать те же самые вершины
дерева рекурсии снизу вверх нерекурсивно:
1 int n ;
2 vector < int > a ( n ) , buffer ( n ) ;
3 for ( int k = 0; (1 << k ) < n ; k ++)
4 for ( int i = 0; i < n ; i += 2 * (1 << k ) )
5 Merge (i , min (n , i + (1 << k ) , min (n , i + 2 * (1 << k ) ) , a , buffer )
6 swap (a , bufffer ) ; // 𝒪(1)
7 return a ; // результат содержится именно тут, указатель может отличаться от исходного 𝑎
Этот код лучше тем, что нет копирования буфера (−𝐶1 · 𝑛 log 𝑛) и нет рекурсии (−𝐶2 · 𝑛).
Глава #5. 3 октября. 40/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
5.5.3. QuickSort (реально быстрая)
Идея: выберем некий 𝑥, разобьём наш массив 𝑎 на три части < 𝑥, = 𝑥, > 𝑥, сделаем два рекур-
сивных вызова, чтобы отсортировать первую и третью части. Утверждается, что сортировка
будет быстро работать, если как 𝑥 взять случайный элемент 𝑎
1 def QuickSort ( a ) :
2 if len ( a ) <= 1: return a
3 x = random . choice ( a )
4 b0 = select ( < x ) .
5 b1 = select (= x ) .
6 b2 = select ( > x ) .
7 return QuickSort ( b0 ) + b1 + QuickSort ( b2 )
Этот псевдокод описывает общую идею, но обычно, чтобы QuickSort была реально быстрой
сортировкой, используют другую версию разделения массива на части.
Код 5.5.2. Быстрый partition.
1 void Partition ( int l , int r , int x , int *a , int &i , int & j ) { // [𝑙, 𝑟], 𝑥 ∈ 𝑎[𝑙, 𝑟]
2 i = l, j = r;
3 while ( i <= j ) {
4 while ( a [ i ] < x ) i ++;
5 while ( a [ j ] > x ) j - -;
6 if ( i <= j ) swap ( a [ i ++] , a [j - -]) ;
7 }
8 }
Этот вариант разбивает отрезок [𝑙, 𝑟] массива 𝑎 на части [𝑙, 𝑗](𝑗, 𝑖)[𝑖, 𝑟].
Замечание 5.5.3. 𝑎[𝑙, 𝑗] ⩽ 𝑥, 𝑎(𝑗, 𝑖) = 𝑥, 𝑎[𝑖, 𝑟] ⩾ 𝑥
Замечание 5.5.4. Алгоритм не выйдет за пределы [𝑙, 𝑟]
Доказательство. 𝑥 ∈ 𝑎[𝑙, 𝑟], поэтому выполнится хотя бы один swap. После swap
верно 𝑙 < 𝑖 ⩾ 𝑗 < 𝑟. Более того 𝑎[𝑙] ⩽ 𝑥, 𝑎[𝑟] ⩾ 𝑥 ⇒ циклы в строках (4)(5) не выйдут за 𝑙, 𝑟. ■
Код 5.5.5. Собственно код быстрой сортировки:
1 void QuickSort ( int l , int r , int * a ) { // [l, r]
2 if ( l >= r ) return ;
3 int i , j ;
4 Partition (l , r , a [ random [l , r ]] , i , j ) ;
5 QuickSort (l , j , a ) ; // j < i
6 QuickSort (i , r , a ) ; // j < i
7 }
5.5.4. Сравнение сортировок
Название Время space stable
HeapSort 𝒪(𝑛 log 𝑛) Θ(1) -
MergeSort Θ(𝑛 log 𝑛) Θ(𝑛) +
QuickSort 𝒪(𝑛 log 𝑛) Θ(log 𝑛) -
Интересен вопрос существования стабильной сортировки, работающей за 𝒪(𝑛 log 𝑛), не исполь-
зующей дополнительную память. Среди уже изученных такой нет.
Такая сортировка существует. Она получается на основе MergeSort и merge за 𝒪(𝑛) без допол-
нительной памяти. На практике мы научимся делать inplace stable merge за 𝒪(𝑛 log 𝑛).
Глава #5. 3 октября. 41/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
5.6. (*) Adaptive Heap sort
Цель данного блока, предявить хотя бы одну сортировку, основанную на сравнениях, которая в
худшем, конечно, за Θ(𝑛 log 𝑛), но на почти отсортированных массивах работает сильно быстрее.
5.6.1. (*) Модифицированный HeapSort
Работает за 𝒪(𝑛 log 𝑛), но бывает быстрее. Сначала построим кучу ℎ за 𝒪(𝑛), а еще создадим
кучу кандидатов на минимальность 𝐶. Изначально 𝐶 содержит только корень кучи ℎ. Теперь
𝑛 раз делаем: x = C.extractMin, добавляем x в конец отсортированного массива, добавляем в
кучу 𝐶 детей x в куче ℎ. Размер кучи 𝐶 в худшем случае может быть 𝑛+12
, но в лучшем (когда
минимальные элементы в куче ℎ лежат в порядке обхода dfs-а) он не превышает log 𝑛 ⇒ на
некоторых входах можно добиться времени работы порядка 𝒪(𝑛 log log 𝑛).
5.6.2. (*) Adaptive Heap Sort
Алгоритм создан в 1992 году.
Берём массив 𝑎 и рекурсивно строим бинарное дерево: корень = минимальный элемент, левое
поддерево = рекурсивный вызов от левой половины, правое поддерево = рекурсивный вызов от
правой половины. Полученный объект обладает свойством кучи. На самом деле, мы построили
декартово дерево на парах (𝑥𝑖 =𝑖, 𝑦𝑖 =𝑎𝑖 ). ∃ простой алгоритм со стеком построения декартова
дерева за 𝒪(𝑛), мы его изучим во 2-м семестре.
Используем на декартовом дереве модифицированный HeapSort из предыдущего пункта.
Есть две оценки на скорость работы этого чуда.
Теорема 5.6.1. Если в массив можно разбить на 𝑘 возрастающих подпоследовательностей, в
куче кандидатов никогда не будет более 𝑘 элементов.
Следствие 5.6.2. Время работы 𝒪(𝑛 log 𝑘).
∑︀
Теорема 5.6.3. Обозначим 𝑘𝑖 – количество кандидатов на 𝑖-м шаге, тогда (𝑘𝑖 −1) ⩽ 𝐼,
где 𝐼 – количество инверсий.
Следствие 5.6.4. Сортировка работает за 𝒪(𝑛 log(1+⌈ 𝑛𝐼 ⌉)).
Теорема 5.6.5. Блок – отрезок подряд стоящих элементов, которые в сортированном порядке
стоят вместе и в таком же порядке. Если наш массив можно представить в виде конкатенации
𝑏 блоков, то время сортировки 𝒪(𝑛 + 𝑏 log 𝑏).
5.6.3. (*) Ссылки
[AdaptiveHeapSort]. Levcopoulos and Petersson’92. Там же доказаны все теоремы.
Глава #5. 3 октября. 42/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки
5.7. (*) Kirkpatrick’84 sort
Научимся сортировать 𝑛 целых чисел из промежутка [0, 𝐶) за 𝒪(𝑛 log log 𝐶).
𝑘−1 𝑘 𝑘
Пусть 22 < 𝐶 ⩽ 22 , округлим вверх до 22 (log log 𝐶 увеличился не более чем на 1).
Если числа достаточно короткие, отсортируем их подсчётом, иначе каждое 2𝑘 -битное число 𝑥𝑖
представим в виде двух 2𝑘−1 -битных половин: 𝑥𝑖 = ⟨𝑎𝑖 , 𝑏𝑖 ⟩.
Отсортируем отдельно 𝑎𝑖 и 𝑏𝑖 рекурсивными вызовами.
1 vector < int > Sort ( int k , vector < int > & x ) {
2 int n = x . size ()
3 if ( n == 1 || n >= 2^{2^ k }) return CountSort ( x ) // за 𝒪(𝑛)
4 vector < int > a ( n ) , b ( n ) , as , result ;
5 unordered_map < int , vector < int > > BS ; // хеш-таблица
6 for ( int i = 0; i < n ; i ++) {
7 a [ i ] = старшие 2𝑘−1 бит x [ i ];
8 b [ i ] = младшие 2𝑘−1 бит x [ i ];
9 BS [ a [ i ]]. push_back ( b [ i ]) ; // для каждого a[i] храним все парные с ним b[i]
10 }
11 for ( auto & p : A ) as . push_back ( p . first ) ; // храним все 𝑎-шки, каждую 1 раз
12 as = Sort ( k - 1 , as ) ; // отсортировали все a[i]
13 for ( int a : as ) {
14 vector < int > & bs = BS [ a ]; // теперь нужно отсортировать вектор bs
15 int i = max_element ( bs . begin () , bs . end () ) - bs . begin () , max_b = bs [ i ];
16 swap ( bs [ i ] , bs . back () ) , bs . pop_back () ; // удалили максимальный элемент
17 bs = Sort ( k - 1 , bs ) ; // отсортировали всё кроме максимума
18 for ( int b : bs ) result . push_back ( <a , b >) ; // выписали результат без максимума
19 result . push_back ( <a , max_b >) ; // отдельно добавили максимальный элемент
20 }
21 return result ;
22 }
∑︀
Оценим время работы. 𝑇 (𝑘, 𝑛) = 𝑛 + 𝑖 𝑇 (𝑘 − 1, 𝑚𝑖 ). 𝑚𝑖 – размеры подзадач, рекурсивных
вызовов. Вспомним, что мы из каждого списка 𝑏𝑠 выкинули 1 элемент, максимум ⇒
∑︁ ∑︁ ∑︁
𝑚𝑖 = |𝑎𝑠| + (|𝑏𝑠𝑎 | − 1) = |𝑏𝑠𝑎 | = 𝑛
𝑎 𝑎
Глубина рекурсии не более 𝑘, на каждом уровне рекурсии суммарный размер всех подзадач не
более 𝑛 ⇒ суммарное время работы 𝒪(𝑛𝑘) = 𝒪(𝑛 log log 𝐶).
Жаль, но на практике из-за большой константы хеш-таблицы преимущества мы не получим.
Глава #5. 3 октября. 43/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
Лекция #6: Сортировки (продолжение)
10 октября
6.1. Quick Sort
∙ Глубина
Можно делать не два рекурсивных вызова, а только один, от меньшей части.
Тогда в худшем случае доппамять = глубина = 𝒪(log 𝑛).
Вместо второго вызова (𝑙2 , 𝑟2 ) сделаем l = l2 , r = r2 , goto start.
∙ Выбор 𝑥
На практике и в дз мы показали, что при любом детерминированом выборе 𝑥 или даже как
медианы элементов любых трёх фиксированных элементов, ∃ тест, на котором время работы
сортировки Θ(𝑛2 ). Чтобы на любом тесте QuickSort работал 𝒪(𝑛 log 𝑛), нужно выбирать x =
a[random l..r]. Тем не менее, так как рандом — медленная функция, иногда для скорости
пишут версию без рандома.
6.1.1. Оценка времени работы
Будем оценивать QuickSort, основанный на partition, который делит элементы на (< 𝑥), 𝑥,
(> 𝑥). Также мы предполагаем, что все элементы различны.
∙ Доказательство #1.
Теорема 6.1.1. 𝑇 (𝑛) ⩽ 𝐶𝑛 ln 𝑛, где 𝐶 = 2 + 𝜀
Доказательство. Докажем по индукции.
Сначала распишем 𝑇 (𝑛), как среднее арифметическое по всем выборам 𝑥.
∑︁ 𝑛−1
∑︁
1 2
𝑇 (𝑛) = 𝑛 + 𝑛
(𝑇 (𝑖) + 𝑇 (𝑛 − 𝑖 − 1)) = 𝑛 + 𝑛
𝑇 (𝑖)
𝑖=0..𝑛−1 𝑖=0
Воспользуемся индукционным предположением (индукция же!)
𝑛−1
∑︁ 𝑛−1
∑︁ 𝑛−1
∑︁
2 2 2𝐶
𝑛
𝑇 (𝑖) ⩽ 𝑛
(𝐶𝑖 ln 𝑖) = 𝑛
(𝑖 ln 𝑖)
𝑖=0 𝑖=0 𝑖=0
Осталось оценить противную сумму. Проще всего это сделать, перейдя к интегралу.
𝑛−1
∑︁ ∫︁ 𝑛
(𝑖 ln 𝑖) ⩽ 𝑖 ln 𝑖 d𝑖
𝑖=0 1
Такой интеграл берётся по частям. Мы просто угадаем ответ ( 12 𝑥2 ln 𝑥 − 41 𝑥2 )′ = 𝑥 ln 𝑥.
𝑇 (𝑛) ⩽ 𝑛 + 2𝐶
𝑛
(( 12 𝑛2 ln 𝑛 − 14 𝑛2 ) − (0 − 41 )) = 𝑛 + 𝐶𝑛 ln 𝑛 − 2𝐶
4
𝑛 + 2𝐶
4𝑛
= 𝐶𝑛 ln 𝑛 + 𝑛(1 − 𝐶2 ) + 𝑜(1) = 𝐹
Какое же 𝐶 выбрать? Мы хотим 𝐹 ⩽ 𝐶𝑛 ln 𝑛, берём 𝐶 > 2, профит. ■
Глава #6. 10 октября. 44/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
∙ Доказательство #2.
Время работы вероятностного алгоритма — среднее арифметическое по всем рандомам. Время
QuickSort пропорционально числу сравнений. Число сравнений — сумма по всем парам 𝑖 < 𝑗
характеристической функции «сравнивали ли мы эту пару», каждую пару мы сравним не более
одного раза.
∑︁ ∑︁ (︀∑︁ )︀ ∑︁(︀ 1 ∑︁ )︀ ∑︁
1 1
𝑅
𝑇 (𝑖) = 𝑅
𝑖𝑠(𝑖, 𝑗) = 𝑅
𝑖𝑠(𝑖, 𝑗) = 𝑃 𝑟[сравнения(𝑖, 𝑗)]
𝑟𝑎𝑛𝑑𝑜𝑚 𝑟𝑎𝑛𝑑𝑜𝑚 𝑖<𝑗 𝑖<𝑗 𝑟𝑎𝑛𝑑𝑜𝑚 𝑖<𝑗
Где 𝑃 𝑟 — вероятность. Осталось оценить вероятность. Для этого скажем, что у каждого эле-
мента есть его индекс в отсортированном массиве.
2
Lm 6.1.2. 𝑃 𝑟[сравнения(𝑖, 𝑗)] = 𝑗−𝑖+1
при 𝑖 < 𝑗
Доказательство. Сравниться 𝑖 и 𝑗 могут только, если при некотором Partition выбор пал на
один из них. Рассмотрим дерево рекурсии. Посмотрим на самую глубокую вершину, [𝑙, 𝑟) всё
ещё содержит и 𝑖-й, и 𝑗-й элементы. Все элементы 𝑖 + 1, 𝑖 + 2, . . . , 𝑗 − 1 также содержатся (т.к.
𝑖 и 𝑗 — индексы в отсортированном массиве). 𝑖 и 𝑗 разделятся ⇒ Partition выберет один из
2
𝑗 − 𝑖 + 1 элементов отрезка [𝑖, 𝑗]. С вероятностью 𝑗−𝑖+1 он совпадёт с 𝑖 или 𝑗, тогда и только
тогда 𝑖 и 𝑗 сравнятся. ■
2 1
∑︀ ∑︀ ∑︀
Осталось посчитать сумму 𝑖<𝑗 𝑗−𝑖+1 = 2 𝑖 𝑗>𝑖 𝑗−𝑖+1 ⩽ 2(𝑛 ln 𝑛 + Θ(𝑛)) ■
6.1.2. Introsort’97
На основе Quick Sort можно сделать быструю сортировку, работающую в худшем за 𝒪(𝑛 log 𝑛).
1. Делаем Quick Sort от 𝑁 элементов
2. Если 𝑟 − 𝑙 не более 10, переключаемся на Insertion Sort
3. Если глубина более 3 ln 𝑁 , переключаемся на Heap Sort
Такая сортировка называется Introsort, в C++::STL используется именно она.
Глава #6. 10 октября. 45/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
6.2. Порядковые статистики
Задача поиска 𝑘-й порядковой статистики формулируется своим простейшим решением
1 int statistic (a , k )
2 sort ( a ) ;
3 return a [ k ];
6.2.1. Одноветочный QuickSort
Вспомним реализацию Quick Sort 5.5.5
Quick Sort = выбрать 𝑥 + Partition + 2 рекурсивных вызова Quick Sort.
Будем делать только 1 рекурсивный вызов:
Код 6.2.1. Порядковая статистика
1 int Statistic ( int l , int r , int *a , int k ) // [l, r]
2 if ( r <= l ) return -1; // так может случиться, только если исходно !(0<=k<=n)
3 int i , j , x = a [ random [l , r ]];
4 Partition (l , r , x , i , j ) ;
5 if ( j < k && k < i ) return x ;
6 return k <= j ? Statistic (l ,j ,a , k ) : Statistic (i ,r ,a , k ) ;
Действительно, зачем вызываться от второй половины, если ответ находится именно в первой?
Теорема 6.2.2. Время работы 6.2.1 равно Θ(𝑛)
Доказательство. С вероятностью 13 мы попадем в элемент, который лежит во второй трети
сортированного массива. Тогда после Partition размеры кусков будут не более 23 𝑛. Если же не
попали, то размеры не более 𝑛, вероятность этого 23 . Итого:
𝑇 (𝑛) = 𝑛 + 13 𝑇 ( 23 𝑛) + 23 𝑇 (𝑛) ⇒ 𝑇 (𝑛) = 3𝑛 + 𝑇 ( 32 𝑛) ⩽ 9𝑛 = Θ(𝑛) ■
Замечание 6.2.3.
∑︀ Мы могли бы повторить доказательство 6.1.1, тогда нам нужно было бы оце-
нить сумму 𝑇 (max(𝑖, 𝑛 − 𝑖 − 1)). Это технически сложнее, зато дало бы константу 4.
6.2.2. Детерминированный алгоритм
Statistic = выбрать 𝑥 + Partition + 1 рекурсивный вызов Statistic.
Чтобы этот алгоритм стал детерминированным, нужно хорошо выбирать 𝑥.
∙ Идея. Разобьем 𝑛 элементов на группы по 5 элементов, в каждой группе выберем медиану,
из полученных 𝑛5 медиан выберем медиану, это и есть 𝑥.
Утверждение 6.2.4. На массиве длины 5 медиану можно выбрать за 6 сравнений.
Поскольку из 𝑛5 меньшие 10 𝑛
не больше 𝑥, хотя бы 10 3
𝑛 элементов исходного массива не более
3
выбранного 𝑥. Аналогично хотя бы 10 𝑛 элементов не менее выбранного 𝑥. Это значит, что после
7
Partition размеры кусков не будут превосходить 10 𝑛. Теперь оценим время работы алгоритма:
(︀ 9 )︀2
𝑇 (𝑛) ⩽ 6 𝑛5 + 𝑇 ( 𝑛5 ) + 𝑛 + 𝑇 ( 10
7
𝑛) = 2.2(𝑛 + 109
𝑛 + 10 𝑛 + . . . ) = 22𝑛 = Θ(𝑛) ■
Глава #6. 10 октября. 46/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
6.2.3. C++
В C++::STL есть следующие функции
1. nth_element(a, a + k, a + n) — 𝑘-я статистика на основе одноветочного Quick Sort. По-
сле вызова функции 𝑘-я статистика стоит на своём месте, слева меньшие, справа большие.
2. partition(a, a + n, predicate) — Partition по произвольному предикату.
6.3. Integer sorting
За счёт чего получается целые числа сортировать быстрее чем произвольные объекты?
∀𝑘 операция деления нацело на 𝑘 : 𝑥 → ⌊ 𝑥𝑘 ⌋ сохраняет порядок.
Если мы хотим сортировать вещественные числа, данные с точностью ±𝜀, их можно привести
к целым: домножить на 1𝜀 и округлить, после чего сортировать целые.
6.3.1. Count sort
Давайте используем уже известный нам Count Sort, чтобы стабильно отсортировать пары ⟨𝑎𝑖 , 𝑏𝑖 ⟩
1 void CountSort ( int n , int *a , int * b ) // 0 <= a[i] < m
2 for ( int i = 0; i < n ; i ++)
3 count [ a [ i ]]++; // сколько раз встречается
4 // pos[i] –- "позиция начала куска ответа, состоящего из пар <i, ?>"
5 for ( int i = 0; i + 1 < m ; i ++)
6 pos [ i + 1] = pos [ i ] + count [ i ];
7 for ( int i = 0; i < n ; i ++)
8 result [ pos [ a [ i ]]++] = { a [ i ] , b [ i ]}; // нужна доппамять!
Сортировка выше сортирует пары по a[i]. Сортировать по b[i] аналогично.
Важно то, что сортировка стабильна, из этого следует наш следующий алгоритм:
6.3.2. Radix sort
Задача: сортируем 𝑛 строк длины 𝐿, символ строки — целое число из [0, 𝑘).
∙ Алгоритм: отсортируем сперва по последнему символу, затем по предпоследнему и т.д.
∙ Корректность: мы сортируем стабильной сортировкой строки по символу номер 𝑖, строки
уже отсортированы по символам (𝑖, 𝐿]. Из стабильности имеем, что строки равные по 𝑖-му
символу будут отсортированы как раз по (𝑖, 𝐿] ⇒ теперь строки отсортированы по [𝑖, 𝐿].
∙ Время работы: 𝐿 раз вызвали сортировку подсчётом ⇒ сортируем строки Θ(𝐿(𝑛 + 𝑘)).
Задача: сортируем целые числа из [0, 𝑚).
∀ число из [0, 𝑚) – строка длины log𝑘 𝑚 над алфавитом [0, 𝑘) (цифры в системе счисления 𝑘) ⇒
умеем сортировать числа из [0, 𝑚) за Θ((𝑛 + 𝑘)⌈log𝑘 𝑚⌉). При 𝑘 = 𝑛 получаем время 𝑛⌈log𝑛 𝑚⌉.
∙ Выбор системы счисления:
Time = Θ((𝑛 + 𝑘)⌈log𝑘 𝑚⌉) = Θ(max(𝑛, 𝑘) log 𝑚
log 𝑘
).
log 𝑚
При 𝑘 ⩽ 𝑛 это Θ(𝑛 log 𝑘 ), min достигается при 𝑘 = 𝑛.
При 𝑘 ⩾ 𝑛 это Θ(𝑘 log 𝑚
log 𝑘
), min достигается при 𝑘 = 𝑛.
Глава #6. 10 октября. 47/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
∙ Использование на практике:
Вы же помните, что деление — операция не быстрая? Значит, выгодно брать 𝑘 = 2что-то .
Иногда выгодно взять 𝑘 чуть больше, чтобы ⌈log𝑘 𝑚⌉ стал на 1 меньше.
Иногда выгодно взять 𝑘 чуть меньше, чтобы лучше кешировалось.
6.3.3. Bucket sort
Главная идея заключается в том, чтобы числа от min до max разбить на 𝑛 бакетов (пакетов,
карманов, корзин). Числовая прямая бьётся на 𝑛 отрезков равной длины, 𝑖-й отрезок:
min + 𝑛𝑖 (max−min+1), min + 𝑖+1
[︀ )︀
𝑛
(max−min+1)
𝑗 𝑥 −min
Каждое число 𝑥𝑗 попадает в отрезок номер 𝑖𝑗 = ⌊ max−min+1 𝑛⌋. Бакеты уже упорядочены: все
числа в 0-м меньше всех чисел в 1-м и т.д. Осталось упорядочить числа внутри бакетов. Это
можно сделать или вызовом Insertion Sort (алгоритм B.I.), чтобы минимизировать константу,
или рекурсивным вызовом Bucket Sort (алгоритм B.B.)
Код 6.3.1. Bucket Sort
1 void BB ( vector < int > & a ) // результат будет записан в 𝑎
2 if ( a . empty () ) return ;
3 int n = a . size , min = min_element ( a ) , max = max_element ( a ) ;
4 if ( min == max ) return ; // уже отсортирован
5 vector < int > b [ n ];
6 for ( int x : a )
7 int i = ( int64_t ) n * ( x - min ) / ( max - min + 1) ; // номер бакета
8 b [ i ]. push_back ( x ) ;
9 a . clear () ;
10 for ( int i = 0; i < n ; i ++)
11 BB ( b [ i ]) ; // отсортировали каждый бакет рекурсивным вызовом
12 for ( int x : b [ i ]) a . push_back ( x ) ; // сложили результат в массив a
Lm 6.3.2. max − min ⩽ 𝑛 ⇒ и BB, и BI работают за Θ(𝑛)
Доказательство. В каждом рекурсивном вызове max − min ⩽ 1 ■
Lm 6.3.3. BB работает за 𝒪(𝑛⌈log(max−min)⌉)
Доказательство. Ветвление происходит при 𝑛 ⩾ 2 ⇒ длина диапазона сокращается как ми-
нимум в два раза ⇒ глубина рекурсии не более log. На каждом уровне рекурсии суммарно не
более 𝑛 элементов. ■
Замечание 6.3.4. На самом деле ещё быстрее, так как уменьшение не в 2 раза, а в 𝑛 раз.
Lm 6.3.5. На массиве, сгенерированном равномерным распределением, время BI = Θ(𝑛)
Доказательство. Время работы BI: 𝑇 (𝑛) = 𝑅1
∑︀ (︀ ∑︀ 2 )︀
𝑖 𝑘𝑖 , где 𝑘𝑖 — размер 𝑖-го бакета.
∑︀ 2 𝑟𝑎𝑛𝑑𝑜𝑚
Заметим, что 𝑖 𝑘𝑖 — число пар элементов, у которых совпал номер бакета.
∑︁ (︀ ∑︁ )︀ 𝑛 ∑︁
∑︁ (︀ ∑︁ 𝑛 𝑛 ∑︁
𝑛 𝑛 ∑︁
𝑛
1 2 1
)︀ ∑︁ (︀ 1 ∑︁ )︀ ∑︁
𝑅
𝑘𝑖 = 𝑅 [𝑖𝑗1 == 𝑖𝑗2 ] = 𝑅
[𝑖𝑗1 == 𝑖𝑗2 ] = Pr[𝑖𝑗1 == 𝑖𝑗2 ]
𝑟𝑎𝑛𝑑𝑜𝑚 𝑖 𝑟𝑎𝑛𝑑𝑜𝑚 𝑗1 =1 𝑗2 =1 𝑗1 =1 𝑗2 =1 𝑟𝑎𝑛𝑑𝑜𝑚 𝑗1 =1 𝑗2 =1
Осталось посчитать вероятность, при 𝑗1 = 𝑗2 получаем 1, при 𝑗1 ̸= 𝑗2 получаем 𝑛1 из равномер-
ности распределения 𝑇 (𝑛) = 𝑛 · 1 + 𝑛(𝑛 − 1) · 𝑛1 = 2𝑛 − 1 = Θ(𝑛). Получили точное среднее время
работы BI на случайных данных. ■
Глава #6. 10 октября. 48/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
6.4. Van Embde Boas’75 trees
Куча над целыми числами из [0, 𝐶), умеющая всё, что подобает уметь куче, за 𝒪(log log 𝐶).
При описании кучи есть четыре принципиально разных случая:
1. Мы храним пустое множество
2. Мы храним ровно одно число
3. 𝐶⩽2
4. Мы храним хотя бы два числа, 𝐶 > 2.
Первые три вы разберёте самостоятельно, здесь же детально описан только 4-й случай.
𝑘−1 𝑘 𝑘
Пусть 22 < 𝐶 ⩽ 22 , округлим 𝐶 вверх до 22 (log log√ 𝐶 увеличился не √более чем на 1).
Основная идея — промежуток [0, 𝐶) разбить √ на √ 𝐶 кусков длины
√ √𝐶. Также, как и в
𝑘−1
BucketSort, 𝑖-й кусок содержит числа из [𝑖 𝐶, (𝑖+1) 𝐶). Заметим, 𝐶 = 22𝑘 = 22 .
𝑘
Теперь опишем кучу уровня 𝑘 ⩾ 1, Heap<k>, хранящую числа из [0, 22 ).
1 struct Heap <k > {
2 int min , size ; // отдельно храним минимальный элемент и размер
3 Heap <k -1 >* notEmpty ; // номера непустых кусков
4 unoredered_map < int , Heap <k -1 >* > parts ; // собственно куски
5 };
∙ Как добавить новый элемент?
Номер куска по числу 𝑥 : index(x) = (x >> 2𝑘−1 );
1 void Heap <k >:: add ( int x ) : // size ⩾ 2, k ⩾ 2, разбираем только интересный случай
2 int i = index ( x ) ;
3 if parts [ i ] is empty
4 notEmpty - > add ( i ) ; // появился новый непустой кусок, рекурсивный вызов
5 parts [ i ] = { x }; // 𝒪(1)
6 else
7 parts [ i ] - > add ( x ) ; // рекурсивный вызов
8 size ++ , min = parts [ notEmpty - > min () ] - > min () ; // пересчитать минимум, 𝒪(1)
Время работы равна глубине рекурсии = 𝒪(𝑘) = 𝒪(log log 𝐶).
∙ Как удалить элемент?
1 void Heap <k >:: del ( int x ) : // size ⩾ 2, k ⩾ 2, разбираем только интересный случай
2 int i = index ( x ) ;
3 if parts [ i ] - > size == 1
4 notEmpty - > del ( i ) ; // кусок стал пустым, делаем рекурсивный вызов
5 parts [ i ] = empty heap
6 else
7 parts [ i ] - > del ( i )
8 min = parts [ notEmpty - > min () ] - > min () ; // пересчитать минимум, 𝒪(1)
Время работы и анализ такие же, как при добавлении. Получается, мы можем удалить не
только минимум, а произвольный элемент по значению за 𝒪(log log 𝐶).
Жаль, но на практике из-за большой константы хеш-таблицы преимущества мы не получим.
Глава #6. 10 октября. 49/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
6.5. (*) Inplace merge за 𝒪(𝑛)
Мы уже умеем делать inplace rotate, что позволяет нам менять нам местами соседние блоки.
∙ Inplace stable merge за 𝒪(|𝑎| + |𝑏|2 ).
Два указателя с конца. Ищем линейным поиском, куда в 𝑎 вставить последний элемент 𝑏.
1 // merge: массив x[0..an)[an..an+bn)
2 for ( k = an ; bn > 0; bn - - , k = i ) // −1 элемент 𝑏, − все пройденные элементы 𝑎
3 for ( i = k ; i > 0 && x [i -1] > x [ k + bn -1]; i - -)
4 ;
5 rotate ( x + i , x + k , x + k + bn ) ; // swap(a[i..|a|), b) → x[0..i) x[k..k+bn) x[i..k)
Время работы√– сумма длин кусков в rotate. Это ровно |𝑎| + |𝑏|2 .
Если |𝑏| = 𝒪( 𝑛), задача решена за линию.
∙ Inplace stable merge с внешним буфером.
Нам достаточно буфера длины min(|𝑎|, |𝑏|). Элементы буфера никуда не потеряются.
Если |𝑏| < |𝑎|, мы можем поменять 𝑎, 𝑏 местами (с сохранением стабильности). Пусть |𝑏𝑢𝑓 | = |𝑎|.
1 // merge: массив x[0..an)[an..an+bn), буффер buf[0..an)
2 for ( int i = 0; i < k ; i ++)
3 swap ( buf [ i ] , x [ i ]) ; // именно swap!
4 for ( int i = 0 , j = 0 , p = 0; p < an + bn ; p ++)
5 swap ( x [ p ] , i == an || ( j < bn && x [ an + j ] < buf [ i ]) ? x [ j ++] : buf [ i ++]) ;
Элементы, лежавшие изначально в 𝑏𝑢𝑓 , в конце оказались там также, но перемешались.
∙ Inplace merge без внешнего буфера.
Наш merge(a, b) не будет стабильным.
√︀
0. Пусть 𝑘 = |𝑎| + |𝑏|. Разобьём 𝑎 и 𝑏 на куски по 𝑘 элементов.
Если у 𝑎 остался хвост, сделаем swap(tail(a),b) с помощью rotate.
Теперь у нас есть 𝑚 ⩽ 𝑘 отсортированных кусков по 𝑘 элементов и хвост, в котором от 0 до
2𝑘−1 элементов. Если в хвосте меньше 𝑘 элементов, добавим в него последний кусок длины 𝑘,
теперь длина хвоста от 𝑘 до 2𝑘−1. Будем использовать хвост, как буфер 𝑧.
1. Отсортируем 𝑚 кусков сортировкой выбором за 𝑚2 + 𝑚 · 𝑘, сравнивая куски по первому
элементу. Время работы: 𝑚2 сравнений и 𝑚 операций swap кусков.
2. Вызываем merge(1, 2, 𝑧) merge(2, 3, 𝑧) merge(3, 4, 𝑧) . . . для всех 𝑚−1 пар соседних кусков.
3. Сортируем элементы 𝑧 за квадрат.
4. Первые 𝑘 · 𝑚 элементов и 𝑧 отсортированы. Осталось смерджить их за 𝒪(𝑘𝑚 + |𝑧|2 ).
Нестабильность появляется в сортировке выбором и перемешивании элементов буфера 𝑧.
Глава #6. 10 октября. 50/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Сортировки (продолжение)
6.6. (*) 3D Мо
(та же задача, что для Мо, только теперь массив может меняться)
В Offline даны массив длины 𝑛 и 𝑞 запросов двух типов:
∙ get(l𝑖 ,r𝑖 ) – запрос на отрезке
∙ a[k𝑖 ] = x𝑖 – поменять значение одного элемента массива.
Пусть мы, зная get(l,r) в 𝑎, умеем за 𝒪(1) находить get(l±1,r±1) в 𝑎 и get(l,r) в 𝑎' = 𝑎
с одним изменённым элементом (например, запрос – число различных чисел на отрезке, а мы
храним частоты чисел unordered_map<int, int> count;).
Решение в лоб работает 𝒪(𝑛𝑞). Мы с вами решим быстрее.
Пусть 𝑖-й запрос имеет тип get. Можно рассматривать его над исходным массивом, но от трёх
параметров: get(l,r,i) – сперва применить первые 𝑖 изменений, затем сделать get. Заметим,
что мы за 𝒪(1) пересчитывать get, если на 1 поменять 𝑙 или 𝑟 или 𝑖.
∙ Алгоритм
Зафиксируем константы 𝑥 и 𝑦. Отсортируем запросы, сравнивая их по ⟨⌊ 𝑥𝑖 ⌋, ⌊ 𝑦𝑙 ⌋, 𝑟⟩.
Между запросами будем переходить за Δ𝑖 + Δ𝑙 + Δ𝑟.
∙ Время работы
∑︀
∑︀ Δ𝑖 = 𝑞𝑥 + 2𝑞 𝑞(внутри блоков по 𝑖 + между блоками)
∑︀ Δ𝑙 = 𝑞𝑦 +
𝑞𝑛
2𝑛 𝑥 (внутри блоков по 𝑙 + между блоками по 𝑙 * число блоков по 𝑖)
Δ𝑟 = 2𝑛 𝑥 𝑦 (движемся по возрастания * на число блоков)
2 𝑛2 𝑞
𝑇 = Θ(max( 𝑛𝑥𝑦𝑞 , 𝑞(𝑥+𝑦)), возьмём 𝑥, 𝑦 : 𝑥𝑦
= 𝑞(𝑥+𝑦) ⇔ 𝑛2 =𝑥𝑦(𝑥+𝑦) ⇒ 𝑥=𝑦=Θ(𝑛2/3 ), 𝑇 = 𝑞𝑛2/3
∙ Оптимизации
В два раза можно ускорить (убрать все константы 2 из времени работы), чередуя порядки
внутри внешних и внутренних блоков – сперва по возрастанию, затем по убыванию и т.д.
6.6.1. (*) Применяем для mex
Def 6.6.1. 𝑚𝑒𝑥(𝐴) = min(N ∪ {0} ∖ 𝐴).
Пример 𝑚𝑒𝑥({1,2,3}) = 0, 𝑚𝑒𝑥({0,1,1,4}) = 2.
Задача – 𝑚𝑒𝑥 на отрезке меняющегося массива в offline.
Идея выше позволяет решить за 𝒪(𝑞𝑛2/3 log 𝑛): кроме unordered_map<int,int> count нужно
ещё поддерживать кучу (set) {𝑥 : count[𝑥] = 0}.
Уберём лишний log. Для этого заметим, что к куче у нас будет 𝑞𝑛2/3 запросов вида add/del и
лишь q запросов getMin. Сейчас мы и то, и то обрабатываем за log, получаем 𝑞𝑛2/3 · log +𝑞· log.
Используем вместо кучи «корневую» (см.ниже), чтобы получить add/del за 𝒪(1), getMin за
𝒪(𝑛1/2 ), получим 𝑞𝑛2/3 · 1 + 𝑞 · 𝑛1/2 = Θ(𝑞𝑛2/3 ).
∙ Корневая для минимума
√ целых чисел [0, 𝐶).
Хотим хранить диапазон
Разобьём диапазон на 𝐶 кусков. В каждом хотим хранить количество чисел.
add/del числа 𝑥 – обновление счётчиков count[𝑥] и sum_in_block[⌊ √𝑥𝐶 ⌋].
get – найти перебором блок 𝑖 : sum_in_block[𝑖] > 0 и внутри блока 𝑥 : count[𝑥] > 0.
Глава #7. 10 октября. 51/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
Лекция #7: Кучи
17 октября
7.1. Нижняя оценка на построение бинарной кучи
Мы уже умеем давать нижние оценки на число сравнений во многих алгоритмах. Везде это
делалось по одной и той же схеме, например, для сортировки “нам нужно различать 𝑛! пере-
становок, поэтому нужно сделать хотя бы log(𝑛!) = Θ(𝑛 log 𝑛) сравнений”.
В случае построения бинарной кучи от перестановки, ситуация сложнее. Есть несколько воз-
можных корректных ответов. Обозначим за 𝐻(𝑛) количество перестановок, являющихся кор-
ректной бинарной кучей. Процедура построения бинарной кучи переводит любую из 𝑛! пере-
становок в какую-то из 𝐻(𝑛) перестановок, являющихся кучей.
Lm 7.1.1. 𝐻(𝑛) = ∏︀ 𝑛! , где 𝑠𝑖𝑧𝑒𝑖 – размер поддерева 𝑖-й вершины кучи
𝑖 𝑠𝑖𝑧𝑒𝑖
Доказательство. По индукции. 𝑙 + 𝑟 = 𝑛−1.
(︂ )︂
𝑙+𝑟
𝐻(𝑛) = 𝐻(𝑙)𝐻(𝑟) = (𝑙+𝑟)!
𝑙!𝑟!
∏︀ 𝑙! · ∏︀ 𝑟!
= ∏︀ (𝑛−1)! = ∏︀ 𝑛!
𝑙 𝑖∈𝐿 𝑠𝑖𝑧𝑒𝑖 𝑖∈𝑅 𝑠𝑖𝑧𝑒𝑖 𝑖∈𝐿∪𝑅 𝑠𝑖𝑧𝑒𝑖 𝑖 𝑠𝑖𝑧𝑒𝑖
В последнем переходе мы добавили в числитель 𝑛, а в знаменатель 𝑠𝑖𝑧𝑒𝑟𝑜𝑜𝑡 = 𝑛.
■
Теорема 7.1.2. Любой корректный алгоритм построения бинарной кучи делает в худшем слу-
чае не менее 1.364𝑛 сравнений
Доказательство. Пусть алгоритм делает 𝑘 сравнений, тогда он разбивает 𝑛! перестановок на
2𝑘 классов. Класс – те перестановки, на которых наш алгоритм одинаково сработает. Заметим,
что алгоритм делающий одно и то же с разными перестановками, на выходе даст разные пере-
становки. Если 𝑥𝑖 – количество элементов в 𝑖-м классе, корректный алгоритм переведёт эти 𝑥𝑖
перестановок в 𝑥𝑖 различных бинарных куч. Поэтому
𝑥𝑖 ⩽ 𝐻(𝑛)
𝑛!
∑︀
Из 𝑖=1..2𝑘 𝑥𝑖 = 𝑛! имеем max𝑖=1..2𝑘 𝑥𝑖 ⩾ 2𝑘
. Итого:
∏︁ ∑︁
𝐻(𝑛) ⩾ max 𝑥𝑖 ⩾ 𝑛!
𝑘
⇒ ∏︀ 𝑛! ⩾ 𝑛!
2𝑘
⇒ 2𝑘 ⩾ 𝑠𝑖𝑧𝑒𝑖 ⇒ 𝑘 ⩾ log 𝑠𝑖𝑧𝑒𝑖
𝑖 𝑠𝑖𝑧𝑒𝑖
Рассмотрим случай полного бинарного дерева 𝑛 = 2𝑘 − 1 ⇒
log 𝑠𝑖𝑧𝑒𝑖 = (log 3) 𝑛+1 + (log 7) 𝑛+1 + (log 15) 𝑛+1 + · · · = (𝑛 + 1)( log4 3 + log8 7 + log1615 + . . . ).
∑︀
4 ∑︀ 8 16
При 𝑛 → +∞ величина log 𝑛+1
𝑠𝑖𝑧𝑒𝑖
имеет предел, вычисление первых 20 слагаемых даёт 1.36442 . . .
и ошибку в 5-м знаке после запятой. ■
Глава #7. 17 октября. 52/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.2. Min-Max Heap (Atkison’86)
Min-Max куча – Inplace структура данных, которая строится на исходном массиве и умеет
делать Min, Max за 𝒪(1), а также Add и ExtractMin за 𝒪(log 𝑛).
Заметим, что мы могли бы просто завести две кучи, одну на минимум, вторую на максимум,
а между ними хранить обратные ссылки. Минусы этого решения – в два раза больше памяти,
примерно в два раза больше константа времени.
Дети в Min-Max куче такие же, как в бинарной 𝑖 → 2𝑖, 2𝑖 + 1.
Инвариант: на каждом нечётном уровне хранится минимум в
поддереве, на каждом чётном максимум в поддереве. В корне
ℎ[1] хранится минимум. Максимум считается как max(ℎ[2], ℎ[3]).
Операции Add и ExtractMin также, как в бинарной куче выра-
жаются через SiftUp, SiftDown.
∙ SiftUp
Предположим, что вершина 𝑣, которую нам нужно поднять, находится на уровне минимумов
(противоположный случай аналогичен). Тогда 𝑣2 , отец 𝑣, находится на уровне максимумов.
Возможны следующие ситуации:
1. Значение у 𝑣 небольше, чем у отца, тогда делаем обычный SiftUp с шагом 𝑖 → 4𝑖 .
2. Иначе меняем местами 𝑣 и 𝑣2 , и из 𝑣2 делаем обычный SiftUp с шагом 𝑖 → 4𝑖 .
log 𝑛
Время работы, очевидно, 𝒪(log 𝑛). Алгоритм сделает не более чем 2
+ 1 сравнение, то есть,
примерно в два раза быстрее SiftUp от обычной бинарной кучи.
∙ Корректность SiftUp.
Если нет конфликта с отцом, то вся цепочка от 𝑖 с шагом два (𝑖, 4𝑖 , 16𝑖 , . . . ) не имеет конфликтов
с цепочкой с шагом два от отца. После swap внутри SiftUp конфликт не появится. Если же с
отцом был конфликт, то после swap(𝑣, 𝑣2 ) у 𝑣2 и его отца, 𝑣4 , конфликта нет. ■
∙ SiftDown
Предположим, что вершина 𝑣, которую нам нужно спустить, находится на уровне миниму-
мов (противоположный случай аналогичен). Тогда дети 𝑣 находятся на уровне максимумов.
Возможны следующие ситуации:
1. У 𝑣 меньше 4 внуков. Обработает случай руками за 𝒪(1).
2. Среди внуков 𝑣 есть те, что меньше 𝑣. Тогда найдём наименьшего внука 𝑣 и поменяем
его местами с 𝑣. Осталось проверить, если на новом месте 𝑣 конфликтует со своим отцом,
поменять их местами. Продолжаем SiftDown из места, где первоначально был наименьший
внук 𝑣.
3. У 𝑣 все внуки неменьше. Тогда ничего исправлять не нужно.
На каждой итерации выполняется 5 сравнений – за 4 выберем минимум из 5 элементов, ещё за
1 решим возможный конфликт с отцом. После этого глубина уменьшается на 2. Итого 25 log2 𝑛 +
𝒪(1) сравнений. Что чуть больше, чем у обычной бинарной кучи (2 log2 𝑛 сравнений).
MinMax кучу можно построить за линейное время inplace аналогично двоичной куче.
Глава #7. 17 октября. 53/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.3. Leftist Heap (Clark’72)
Пусть в корневом дереве каждая вершина имеет степень 0, 1 или 2. Введём для каждой вершины
Def 7.3.1. 𝑑(𝑣) – расстояние вниз от 𝑣 до ближайшего отсутствия вершины.
Заметим, что 𝑑(NULL) = 0, 𝑑(𝑣) = min(𝑑(𝑣.𝑙𝑒𝑓 𝑡), 𝑑(𝑣.𝑟𝑖𝑔ℎ𝑡)) + 1
Lm 7.3.2. 𝑑(𝑣) ⩽ 𝑙𝑜𝑔2 (𝑠𝑖𝑧𝑒 + 1)
Доказательство. Заметим, что полное бинарное дерево высоты 𝑑(𝑣)−1
является нашим поддеревом ⇒ 𝑠𝑖𝑧𝑒 ⩾ 2𝑑(𝑣) − 1 ⇔ 𝑠𝑖𝑧𝑒+1 ⩾ 2𝑑(𝑣) ■
Def 7.3.3. Левацкая куча (leftist heap) – структура данных в виде бинарного дерева, в котором
в каждой вершине один элемент. Для этого дерева выполняются
условие кучи и условие leftist: ∀𝑣 𝑑(𝑣.𝑙𝑒𝑓 𝑡) ⩾ 𝑑(𝑣.𝑟𝑖𝑔ℎ𝑡)
Следствие 7.3.4. В левацкой куче log2 𝑛 ⩾ 𝑑(𝑣) = 𝑑(𝑣.𝑟𝑖𝑔ℎ𝑡) + 1
Главное преимущество левацких куч над предыдущими – возможностью быстрого слияния
(Merge). Через Merge выражаются Add и ExtractMin (слияние осиротевших детей).
∙ Merge
Идея. Есть две кучи a и b. Минимальный из a->x, b->x будет новым корнем.
Пусть это a->x, тогда сделаем a->r = Merge(a->r, b) (рекурсия). Конец =)
Для удобства реализации EMPTY – пустое дерево, у которого l = r = EMPTY, x = +∞, d = 0.
1 Node * Merge ( Node * a , Node * b ) :
2 if (! a || ! b ) return a ? a : b ; // если есть пустая, Merge не нужен
3 if (a - > x > b - > x ) swap (a , b ) ; // теперь a – общий корень
4 a - > r = Merge (a - >r , b ) ;
5 if (a - >r - > d > a - >l - > d ) // если нарушен инвариант leftist
6 swap (a - >r , a - > l ) ; // исправили инвариант leftist
7 a - > d = a - >r - > d + 1; // обновили d
8 return a ;
Время работы: на каждом шаге рекурсии величина a->d + b->d уменьшается ⇒
глубина рекурсии ⩽ a->d + b->d ⩽ 2 log2 𝑛.
7.4. Skew Heap (Tarjan’86)
Уберём условие 𝑑(𝑣.𝑙𝑒𝑓 𝑡) ⩾ 𝑑(𝑣.𝑟𝑖𝑔ℎ𝑡). В функции Merge уберём 5 и 7 строки.
То есть, в Merge мы теперь не храним 𝑑, а просто всегда делаем swap детей.
Полученная куча называется «скошенной» (skew heap). В худшем случае один Merge теперь
может работать Θ(𝑛), но мы докажем амортизированную сложность 𝒪(log2 𝑛). Скошенная куча
выгодно отличается короткой реализацией и константой времени работы.
∙ Доказательство времени работы
Def 7.4.1. Пусть 𝑣 – вершина, 𝑝 – её отец, 𝑠𝑖𝑧𝑒 – размер поддерева. Ребро 𝑝 → 𝑣 называется
Тяжёлым, если 𝑠𝑖𝑧𝑒(𝑣) > 21 𝑠𝑖𝑧𝑒(𝑝)
Лёгкиим, если 𝑠𝑖𝑧𝑒(𝑣) ⩽ 12 𝑠𝑖𝑧𝑒(𝑝)
Глава #7. 17 октября. 54/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
Def 7.4.2. Ребро 𝑝𝑎𝑟𝑒𝑛𝑡 → 𝑣 называется правым, если 𝑣 – правый сын 𝑝𝑎𝑟𝑒𝑛𝑡.
Заметим, что из вершины может быть не более 1 тяжёлого ребра вниз.
Lm 7.4.3. На любом вертикальном пути не более log2 𝑛 лёгких рёбер.
Доказательство. При спуске по лёгкому ребру размер поддерева меняется хотя бы в 2 раза. ■
Как теперь оценить время работы Merge? Нужно чем-то самортизировать количество тяжёлых
рёбер. Введём потенциал 𝜙 = “количество правых тяжёлых рёбер”.
Теорема 7.4.4. Время работы Merge в среднем 𝒪(log 𝑛)
Доказательство. Разделим время работы 𝑖-й операции на две части – количество лёгких и
тяжёлых рёбер на пройденном в 𝑖-й операции пути.
𝑡𝑖 = Л𝑖 + Т𝑖 ⩽ 𝑙𝑜𝑔2 𝑛 + Т𝑖
Теперь распишем изменения потенциала 𝜙. Самое важное: когда мы прошли по тяжёлому ребру,
после swap оно станет лёгким, потенциал уменьшится! А вот когда мы прошли по лёгкому ребру,
потенциал мог увеличиться... но лёгких же мало.
Δ𝜙 ⩽ Л𝑖 − Т𝑖 ⩽ log2 𝑛 − Т𝑖 ⇒ 𝑎𝑖 = 𝑡𝑖 + Δ𝜙 ⩽ 2 log 𝑛
Осталось заметить, что 0 ⩽ 𝜙 ⩽ 𝑛 − 1, поэтому среднее время работы 𝒪(log 𝑛). ■
7.5. Quake Heap (потрясная куча)
∙ Что у нас уже есть?
∘ Обычная бинарная куча не умеет Merge, к ней можно прикрутить Merge через Add за log2 𝑛.
∘ Сегодня изучили Leftist и Skew, которые умеют всё за log 𝑛.
∙ Наша цель.
Получить кучу, которая умеет Add, Merge, DecreaseKey, Min за 𝒪(1) в худшем и ExtractMin за
амортизированный 𝒪(log 𝑛). Лучше не получится, т.к. нельзя сортировать быстрее 𝑛 log 𝑛.
Раньше для таких целей использовали Фибоначчиеву или Тонкую (Thin) кучи. Мы изучим
более современную и простую quake-heap. Эта куча даёт в теории все нужные 𝒪(1), неплоха
на практике, но не является самой быстрой или простой в реализации, главное её достоинство
среди подобных — её просто понять.
[QuakeHeap’2009]. Полное описание quake-heap от автора (Timothy Chan).
[Benchmarks’2014]. Тарьян и ко. замеряют, какие кучи когда быстрее.
∙ План. Нам нужно несколько идей. Пройдём идеи, соединим их в quake-heap.
7.5.1. Списко-куча
Список тоже можно использовать как кучу!
Будем хранить элементы в односвязном списке, поддерживаем указатель на текущий минимум.
Heap = {head,tail,min}. ∀ элемента x будем поддерживать обратную ссылку: x → node*.
Операции Merge, Add, DecreaseKey, Min работают за 𝒪(1). Операции ExtractMin нужно найти
новый минимум, для этого она пробежится по всем списку за Θ(𝑛).
Глава #7. 17 октября. 55/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.5.2. Турнирное дерево
Пусть у нас есть массив {10,13,5,8}. Устроим над элементами массива турнир
по олимпийской системе, в каждом туре выигрывает минимум. Получим дерево,
как на картинке, в корне будет минимум, в листьях элементы исходного массива.
Более опытные из вас уже видели такое под названием «дерево отрезков».
Само по себе турнирное дерево — уже куча.
Можно поменять значение в листе, и за 𝒪(log 𝑛) пересчитать значения на пути до корня.
Чтобы извлечь минимум, заменим значение в соответствующем листе на +∞. Чтобы по корню
понять, из какого листа пришло значение, в вершинах храним не значения, а ссылки на листья.
Как хранить дерево? Ссылочная структура. У каждой вершины есть три ссылки: дети, отец.
7.5.3. Список турнирных деревьев
Если есть два турнирных дерева на массивах длины 2𝑘 и у каждого мы знаем ссылку на корень,
за 𝒪(1) их можно соединить в одно дерево: создадим новую вершину, положим туда минимум
корней. Далее все деревья имеют размер ровно 2𝑘 , 𝑘 будем называть рангом, нужно по дереву
быстро понимать ранг, для этого храним ранг в корне.
Собственно структура: список корней турнирных деревьев.
ExtractMin за 𝒪(log 𝑛). Обозначим длину списка корней за 𝑅. Пусть минимум оказался кор-
нем дерева ранга 𝑘. После удаления минимума (весь путь от листа до корня), дерево распадётся
на 𝑘 более мелких деревьев. Время работы 𝑅 + 𝑘. 𝑘 ⩽ log 𝑛, а вот 𝑅 может быть большим. Как
самортизировать? Уменьшим 𝑅 до «⩽ log 𝑛». Тогда для потенциала 𝜙 = 𝑅 получается
𝑎𝑖 = 𝑡𝑖 + Δ𝜙 ⩽ (𝑅+𝑘) − (𝑅−log 𝑛) ⩽ 2 log 𝑛
Чтобы корней было не больше log 𝑛, потребуем «не больше одного корня каждого ранга».
Если видим два дерева одинакового ранга 𝑘, их можно объединить в одно дерево ранга 𝑘+1.
1 vector < Node * > m (log 𝑛, 0) ; // для каждого ранга 𝑘 храним или 0, или дерево ранга 𝑘
2 for root ∈ ListOfRoots : // просматриваем все имеющиеся корни
3 k = root - > rank ;
4 while m [ k ] != 0: // уже есть дерево такого же ранга?
5 root = join ( root , m [ k ]) , m [ k ] = 0 , k ++; // соединим в k+1!
6 m [ k ] = root ;
𝑅 = |ListOfRoots|. В строке 5 уменьшается общее число корней ⇒
строка 5 выполнится не более 𝑅−1 раз ⇒ время работы алгоритма Θ(𝑅).
1 ListOfRoots = {} // очистим
2 for root ∈ m :
3 if ( root != 0) ListOfRoots . push_back ( root ) // собрали всё, что лежит в 𝑚 в список
В новом списке ⩽ log 𝑛 корней ⇒ Δ𝜙 ⩽ log 𝑛 − 𝑅 ⇒ амортизированное время работы 𝒪(log 𝑛).
Остальные операции. Merge за 𝒪(1) — объединить два списка, Add за 𝒪(1) добавить в конец
списка, Min за 𝒪(1) не забывать везде обновлять указатель на минимум.
DecreaseKey. Эта операция может только уменьшить ключ. Важно, что только уменьшить,
а в корне минимум. Сейчас понятно, как делать за 𝒪(log 𝑛): поднимемся от листа до корня.
Глава #7. 17 октября. 56/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.5.4. DecreaseKey за 𝒪(1), quake!
Чтобы сделать за 𝒪(1), будем для каждого листа x поддерживать ссылку up[x] на его самое
верхнее вхождение в турнирное дерево (до куда он дошёл в турнире). Промужеточные вершины
турнирного дерева хранят ссылку на лист ⇒ само значение нужно менять только в листе, 𝒪(1).
Если теперь up[x] имеет конфликт с отцом up[x]->p, решим конфликт радикально за 𝒪(1):
отрежем up[x] от его отца и добавим в общий список корней.
Сейчас DecreaseKey работает за 𝒪(1), а оценка времени ExtractMin могла испортиться.
Поймём, как. Количество деревьев после ExtractMin = количеству различных рангов ⩽ макси-
мальной высота дерева. Раньше мы знали 𝑠𝑖𝑧𝑒 = 2ℎ𝑒𝑖𝑔𝑡ℎ ⇒ ℎ𝑒𝑖𝑔ℎ𝑡 ⩽ log 𝑛, теперь DecreaseKey
обрезает деревья и нарушает свойства размера.
∙ Идея.
Выберем 𝛼 ∈ (0.5,1), например 𝛼 = 0.75 и будем следить, что 𝑛𝑖+1 < 𝛼𝑛𝑖 , где 𝑛𝑖 — суммарное
число вершин на уровне 𝑖, здесь 𝑛0 = 𝑛, листья лежат на уровне 0. Теперь высота ⩽ log1/𝛼 𝑛.
∙ Что делать, когда испортилось?
Испортится в момент, когда ExtractMin удаляет корень дерева. Если корень, лежал на уровне
𝑖, то 𝑛𝑖 уменьшилось, 𝑛𝑖+1 не изменилось, могло сломаться 𝑛𝑖+1 < 𝛼𝑛𝑖 . Устром землятресение!
Удалим все вершины на слоях ⩾𝑖+1 (для этого ∀𝑖 поддерживаем список вершин 𝑖-го слоя).
∙ Время работы.
Время работы землятресения 𝑡𝑞𝑢𝑎𝑘𝑒 = 𝑛𝑖+1 + 𝑛𝑖+2 + 𝑛𝑖+3 + · · · = Θ(𝑛𝑖+1 ) (на уровнях выше
условие не нарушено). Назовём вершину больной, если у неё отрезали детей ⇒ степень не 2,
а 1. Обобзначим 𝐵 число больных вершин. Если больных вершин нет, то 𝑛𝑖+1 ⩽ 21 𝑛𝑖 , если все
больные, то 𝑛𝑖+1 = 𝑛𝑖 . Сейчас 𝑛𝑖+1 = 34 𝑛𝑖 ⇒ «число больных вершин в слое 𝑖+1» = 21 𝑛𝑖 ⇒
Δ𝐵 ⩽ − 12 𝑛𝑖 (ещё вершины в верхних слоях) и 𝑡𝑞𝑢𝑎𝑘𝑒 = Θ(|Δ𝐵|) < 9|Δ𝐵|, Δ𝑅 = Θ(|Δ𝐵|) < 9|Δ𝐵|.
Возьмём потенциал 𝜙 = 𝑅 + 20 · 𝐵, получим 𝑎𝑞𝑢𝑎𝑘𝑒 = 𝑡𝑞𝑢𝑎𝑘𝑒 + Δ𝐵 ⩽ 9|Δ𝐵| + 9|Δ𝐵| + 20Δ𝐵 < 0.
Глава #7. 17 октября. 57/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.6. (*) Pairing Heap
Сейчас мы из списко-кучи получим чудо-структуру, которая умеет амортизированно
Add, Merge за 𝒪(1), ExtractMin за 𝒪(log 𝑛).
DecreaseKey за 𝑜(log 𝑛), сколько точно, никто не знает.
Пусть ExtractMin проходится по списку длины 𝑘. Чтобы с амортизировать Θ(𝑘), разобьём
элементы списка на пары... Формально получится длинная история:
∙ PairingHeap = minElement + список детей вида PairingHeap
Если главный минимум не рисовать, то PairingHeap представляет из себя список корней
нескольких деревьев, для каждого дерева выполняется свойство кучи, то есть, значение в любой
вершине – минимум в поддереве.
Детей вершины мы храним двусвязным списком. Будем хранить указатель на первого ребенка,
для каждого ребенка указатели на соседей в списке и отца. Итого:
1 struct PairingHeap {
2 int x ;
3 PairingHeap * child , * next , * prev , * parent ;
4 };
5 PairingHeap * root = new PairingHeap { minElement , otherElements , 0 , 0 , 0};
Далее в коде есть куча крайних случаев – списки длины 0, 1, 2. Цель этого конспекта – показать
общую идею, на частности не отвлекаемся. Для начала вспомним, что мы умеем со списками:
1 // связали два узла списка
2 Link (a , b ) { a - > next = b , b - > prev = a ; }
3 // удалить 𝑎 из списка (𝑎 перестанет быть ребёнком 𝑎->𝑝𝑎𝑟𝑒𝑛𝑡).
4 ListDelete ( a ) { Link (a - > left , a - > right ) ; }
5 // в список детей 𝑎 добавить 𝑏
6 ListInsert (a , b ) { Link (b , a - > child ) , a - > child = b ; }
Глава #7. 17 октября. 58/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
Основная операция Pair — создать из двух куч одну. Выбирает кучу с меньшим ключом, к ней
подвешивает вторую.
1 Pair (a , b ) :
2 if (a - > x > b - > x ) swap (a , b ) ; // корень – меньший из двух
3 ListInsert (a , b ) ; // в список детей 𝑎 добавим 𝑏
4 return a ;
Merge — объединить два списка. Add — один Merge. Уже получили Add и Merge за 𝒪(1) худшем.
1 DecreaseKey (a , newX ) :
2 a - > x = newX ;
3 ListDelete ( a ) ; // удалили 𝑎 из списка её отца
4 root = Pair ( root , a ) ;
Теперь и DecreaseKey за 𝒪(1) в худшем. Delete ∀ не минимума это DecreaseKey + ExtractMin.
Осталась самая сложная часть – ExtractMin.
Чтобы найти новый минимум нужно пройтись по всем детям. Пусть их 𝑘.
1 def ExtractMin :
2 root = Pairing ( root - > child )
3 def Pairing ( a ) : # пусть наши списки питоно-подобны, "a— список куч
4 if | a | = 0 return Empty
5 if | a | = 1 return a [0]
6 return Pair ( Pair ( a [0] , a [1]) , Pairing ( a [2:]) ) ;
Время работы Θ(𝑘). Результат сей магии – список детей сильно ужался. Точный амортизаци-
онный анализ читайте в оригинальной работе Тарьяна. Сейчас важно понять, что поскольку
𝑎𝑖 = 𝑡𝑖 + Δ𝜙, из-за потенциала поменяется также время работы Add, Merge, DecreasyKey. В
итоге получатся заявленные ранее.
7.6.1. История. Ссылки.
[Tutorial]. Красивый и простой функциональный PairingHeap. Код. Картинки.
[Pairing heap]. Fredman, Sleator, Tarjan 1986. Здесь они доказывают оценку 𝒪(log 𝑛) и ссыла-
ются на свою работу, опубликованную годом ранее – Сплэй-Деревья.
[Rank-Pairing Heap]. Tarjan 2011. Скрестили Pairing и кучу Фибоначчи.
Оценки времени работы DecreasyKey в PairingHeap рождались в таком порядке:
∙ Ω(log√log 𝑛) – Fredman 1999.
∙ 𝒪(22 log log 𝑛 ) – Pettie 2005.
∙ Небольшая модификация Pairing Heap, которая даёт 𝒪(log log 𝑛) – Amr Elmasry 2009.
Оценка 𝒪(log log 𝑛) для оригинальной Pairing Heap на 2019-й год не доказана.
Глава #7. 17 октября. 59/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.7. (*) Биномиальная куча (Vuillemin’78)
7.7.1. Основные понятия
Определим понятие «биномиальное дерево» рекурсивно.
Def 7.7.1. Биномиальным деревом ранга 0 или 𝑇0 будем на-
зывать одну вершину. Биномиальным деревом ранга 𝑛+1 или
𝑇𝑛+1 будем называть дерево 𝑇𝑛 , к корню которого подвесили
еще одно дерево 𝑇𝑛 (порядок следования детей не важен). При
этом биномиальное дерево должно удовлетворять свойству
кучи (значение в вершине не меньше значения в предках).
Выпишем несколько простых свойств биномиальных деревьев.
Lm 7.7.2. |𝑇𝑛 | = 2𝑛
Доказательство. Индукция по рангу дерева (далее эта фраза и проверка базы индукции будет
опускаться). База: для 𝑛 = 0 дерево состоит из одной вершины.
Переход: |𝑇𝑛+1 | = |𝑇𝑛 | + |𝑇𝑛 | = 2𝑛 + 2𝑛 = 2𝑛+1 . ■
Lm 7.7.3. 𝑑𝑒𝑔𝑅𝑜𝑜𝑡(𝑇𝑛 ) = 𝑛
Доказательство. 𝑑𝑒𝑔𝑅𝑜𝑜𝑡(𝑇𝑛+1 ) = 𝑑𝑒𝑔𝑅𝑜𝑜𝑡(𝑇𝑛 ) + 1 = 𝑛 + 1 ■
Lm 7.7.4. Сыновьями 𝑇𝑛 являются деревья 𝑇0 , 𝑇1 , .., 𝑇𝑛−1 .
Доказательство. Сыновья 𝑇𝑛+1 – все сыновья 𝑇𝑛 , т.е. 𝑇0 , .., 𝑇𝑛−1 , и новый 𝑇𝑛 . ■
Lm 7.7.5. 𝑑𝑒𝑝𝑡ℎ(𝑇𝑛 ) = 𝑛
Доказательство. 𝑑𝑒𝑝𝑡ℎ(𝑇𝑛+1 ) = 𝑚𝑎𝑥(𝑑𝑒𝑝𝑡ℎ(𝑇𝑛 ), 1 + 𝑑𝑒𝑝𝑡ℎ(𝑇𝑛 )) = 1 + 𝑑𝑒𝑝𝑡ℎ(𝑇𝑛 ) = 1 + 𝑛 ■
∙ Как хранить биномиальное дерево?
1 struct Node :
2 Node * next , * child , * parent ;
3 int x , rank ;
Здесь child – ссылка на первого сына, next – ссылка на брата, x – полезные данные, которые
мы храним. Список сыновей вершины v: v->child, v->child->next, v->child->next->next, . . .
Теперь определим понятие «биномиальная куча».
Def 7.7.6. Биномиальная куча – список биномиальных деревьев различного ранга.
У любого числа 𝑛 есть единственное представление в двоичной системе счисления 𝑛 = 𝑖 2𝑘𝑖 .
∑︀
В биномиальной куче из 𝑛 элементов деревья будут иметь размеры как раз 2𝑘𝑖 . Заметим, что в
списке не более log2 𝑛 элементов.
Глава #7. 17 октября. 60/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.7.2. Операции с биномиальной кучей
Add и ExtractMin выражаются, также как и в левацкой куче, через Merge. Чтобы выразить
ExtractMin заметим, что дети корня любого биномиального дерева по определению являются
биномиальной кучей. То есть, после удаления минимума нужно сделать Merge от кучи, образо-
ванной детьми удалённой вершины и оставшихся деревьев.
DecreaseKey – обычный SiftUp, работает по лемме 7.7.5 за 𝒪(log 𝑛).
В чём проблема Merge, почему просто не соединить два списка? После соединения появятся
биномиальные деревья одинакового ранга. К счастью, по определению мы можем превратить
их в одно дерево большего ранга
1 Node * join ( Node * a , Node * b ) : // a->rank == b->rank
2 if (a - > x > b - > x ) swap (a , b ) ;
3 b - > next = a - > child , a - > child = b ; // добавили b в список детей a
4 return a ;
Теперь пусть у нас есть список с деревьями возможно одинакового ранга. Отсортируем их по
рангу и будем вызывать join, пока есть деревья одного ранга.
1 list < Node * > Normalize ( list < Node * > & a ) :
2 list < Node * > roots [ maxRank +1] , result ;
3 for ( Node * v : a ) roots [v - > rank ]. push_back ( v ) ;
4 for ( int i = 0; i <= maxRank ; i ++)
5 while ( roots [ i ]. size () >= 2) :
6 Node * a = roots [ i ]. back () ; roots [ i ]. pop_back () ;
7 Node * b = roots [ i ]. back () ; roots [ i ]. pop_back () ;
8 roots [ i +1]. push_back ( join (a , b ) ) ;
9 if ( roots [ i ]. size () ) : result . push_back ( roots [ i ][0]) ;
10 return result ;
На каждом шаге цикла while уменьшается общее число деревьев ⇒ время работы Normalize
равно |𝑎| + 𝑚𝑎𝑥𝑅𝑎𝑛𝑘 = 𝒪(log 𝑛). Можно написать чуть умнее, будет |𝑎|.
7.7.3. Add и Merge за 𝒪(1)
У нас уже полностью описана классическая биномиальная куча. Давайте её ускорять. Уберём
условие на «все ранги должны быть различны». То есть, новая куча – список произвольных
биномиальных деревьев. Теперь Add, Merge, GetMin, очевидно, работают за 𝒪(1). Но ExtractMin
теперь работает за длину списка. Вызовем после ExtractMin процедуру Normalize, которая
укоротит список до 𝒪(log2 𝑛) корней. Теперь время ExtractMin самортизируется потенциалом
𝜙 = Roots (длина списка, количество корней).
Теорема 7.7.7. Среднее время работы ExtractMin равно 𝒪(log 𝑛)
Доказательство. 𝑡𝑖 = Roots + maxRank, Δ𝜙 ⩽ log2 𝑛 − Roots ⇒ 𝑎𝑖 = 𝑡𝑖 + Δ𝜙 = 𝒪(log 𝑛).
Заметим также, 0 ⩽ 𝜙 ⩽ 𝑛 ⇒ среднее время ExtractMin 𝒪(log 𝑛). ■
7.8. (*) Куча Фибоначчи (Fredman,Tarjan’84)
Отличается от всех вышеописанных куч тем, что умеет делать DecreaseKey за 𝒪(1). Является
апгрейдом биномиальной кучи. Собственно биномиальные кучи практической ценности не име-
Глава #7. 17 октября. 61/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
ют, они во всём хуже левацкой кучи, а нужны они нам, как составная часть кучи Фибоначчи.
Если DecreaseKey будет основан на SiftUp, как ни крути, быстрее log 𝑛 он работать не будет.
Нужна новая идея для DecreaseKey, вот она: отрежем вершину со всем её поддеревом и поме-
стим в список корней. Чтобы «отрезать» за 𝒪(1) нужно хранить ссылку на отца и двусвязный
список детей (left, right).
1 struct Node :
2 Node * child , * parent , * left , * right ;
3 int x , degree ; // ранг биномиального дерева равен степени
4 bool marked ; // удаляли ли мы уже сына у этой вершины
∙ Пометки marked
Чтобы деревья большого ранга оставались большого размера, нужно запретить удалять у них
много детей. Скажем, что marked – флаг, равный единице, если мы уже отрезали сына вершины.
Когда мы пытаемся отрезать у 𝑣 второго сына, отрежем рекурсивно и вершину 𝑣 тоже.
1 list < Node * > heap ; // куча – список корней биномиальных деревьев
2 void CascadingCut ( Node * v ) :
3 Node * p = v - > parent ;
4 if ( p == NULL ) return ; // мы уже корень
5 p - > degree - -; // поддерживаем степень, будем её потом использовать, как ранг
6 v - > left - > right = v - > right , v - > right - > left = v - > left ; // удалили из списка
7 heap . push_back ( v ) , v - > marked = 0; // начнём новую жизнь в качестве корня!
8 if (p - > parent && p - > marked ++) // если папа – корень, ничего не нужно делать
9 CascadingCut ( p ) ; // у 𝑝 только что отрезали второго сына, пора сделать его корнем
10
11 void DecreaseKey ( int i , int x ) : // 𝑖 – номер элемента
12 pos [ i ] - > x = x ; // pos[i] = обратная ссылка
13 CascadingCut ( pos [ i ]) ;
Важно заметить, что когда вершина v становится корнем, её mark обнуляется, она обретает
новую жизнь, как корневая вершина ранга v->degree.
Def 7.8.1. Ранг вершины в Фибоначчиевой куче – её степень на тот момент, когда вершина
последний раз была корнем.
Если мы ни разу не делали DecreaseKey, то rank = degree. В общем случае:
Lm 7.8.2. v.rank = v.degree + v.mark
Заметим, что по коду ранги нам нужны только в Normalize, то есть, в тот момент, когда
вершина является корнем. В доказательстве важно будет, что у любой вершины ранги детей
различны.
Теорема 7.8.3. DecreseKey работает в среднем за 𝒪(1)
Доказательство. Marked – число помеченных вершин. Пусть 𝜙 = Roots + 2Marked.
Амортизированное время операций кроме DecreaseKey не поменялось, так как они не меняют
Marked. Пусть DecreaseKey отрезал 𝑘 + 1 вершину, тогда ΔMarked ⩽ −𝑘, ΔRoots ⩽ 𝑘 + 1,
𝑎𝑖 = 𝑡𝑖 + Δ𝜙 = 𝑡𝑖 + ΔRoots + 2ΔMarked ⩽ (𝑘 + 1) + (𝑘 + 1) − 2𝑘 = Θ(1). ■
Глава #7. 17 октября. 62/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Кучи
7.8.1. Фибоначчиевы деревья
Чтобы оценка 𝒪(log 𝑛) на ExtractMin не испортилась нам нужно показать, что
𝑠𝑖𝑧𝑒(𝑟𝑎𝑛𝑘) – всё ещё экспоненциальная функция.
Def 7.8.4. Фибоначчиево дерево 𝐹𝑛 – биномиальное дерево 𝑇𝑛 , с которым произвели рекурсив-
ное обрезание: отрезали не более 1 сына, и рекурсивно запустились от выживших детей.
Оценим 𝑆𝑛 – минимальный размер дерева 𝐹𝑛
Lm 7.8.5. ∀𝑛 ⩾ 2 : 𝑆𝑛 = 1 + 𝑆0 + 𝑆1 + · · · + 𝑆𝑛−2
Доказательство. Мы хотим минимизировать размер.
Отрезать ли сына? Конечно, да! Какого отрезать? Самого толстого, то есть, 𝐹𝑛−1 . ■
Заметим, что полученное рекуррентное соотношение верно также и для чисел Фибоначчи:
Lm 7.8.6. ∀𝑛 ⩾ 2 : 𝐹 𝑖𝑏𝑛 = 1 + 𝐹 𝑖𝑏0 + 𝐹 𝑖𝑏1 + · · · + 𝐹 𝑖𝑏𝑛−2
Доказательство. Индукция. Пусть 𝐹 𝑖𝑏𝑛−1 = 𝐹 𝑖𝑏0 + 𝐹 𝑖𝑏1 + · · · + 𝐹 𝑖𝑏𝑛−3 , тогда
1+𝐹 𝑖𝑏0 +𝐹 𝑖𝑏1 +· · ·+𝐹 𝑖𝑏𝑛−2 = (1+𝐹 𝑖𝑏0 +𝐹 𝑖𝑏1 +· · ·+𝐹 𝑖𝑏𝑛−3 )+𝐹 𝑖𝑏𝑛−2 = 𝐹 𝑖𝑏𝑛−1 +𝐹 𝑖𝑏𝑛−2 = 𝐹 𝑖𝑏𝑛 ■
Lm 7.8.7. 𝑆𝑛 = 𝐹 𝑖𝑏𝑛
Доказательство. База: 𝐹 𝑖𝑏0 = 𝑆0 = 1, 𝐹 𝑖𝑏1 = 𝑆1 = 1. Формулу перехода уже доказали. ■
√
√1 𝜙𝑛 , 1+ 5
Получили оценку снизу на размер дерева Фибоначчи ранга 𝑛: 𝑆𝑛 ⩾ 5
где 𝜙 = 2
.
И поняли, почему куча называется именно Фибоначчиевой.
7.8.2. Завершение доказательства
Фибоначчиева куча – список деревьев. Эти деревья не являются Фибоначчиевыми по на-
шему определению. Но размер дерева ранга 𝑘 не меньше 𝑆𝑘 .
Покажем, что новые деревья, которые мы получаем по ходу операций Normalize и DecreaseKey
не меньше Фибоначчиевых.
∀𝑣 дети 𝑣, отсортированные по рангу, обозначим 𝑥𝑖 , 𝑖 = 0..𝑘−1, 𝑟𝑎𝑛𝑘(𝑥𝑖 ) ⩽ 𝑟𝑎𝑛𝑘(𝑥𝑖+1 ).
Будем параллельно по индукции доказывать два факта:
1. Размер поддерева ранга 𝑘 не меньше 𝑆𝑘 .
2. ∀ корня 𝑣 𝑟𝑎𝑛𝑘(𝑥𝑖 ) ⩾ 𝑖.
То есть, ранг детей поэлементно не меньше рангов детей биномиального дерева.
Про размеры: когда 𝑣 было корнем, его дети были не меньше детей биномиального дерева того
же ранга, до тех пор, пока ранг 𝑣 не поменяется, у 𝑣 удалят не более одного сына, поэтому дети
𝑣 будут не меньше детей фибоначчиевого дерева того же ранга.
Теперь рассмотрим ситуации, когда ранг меняется.
Переход #1: 𝑣 становится корнем. Детей 𝑣 на момент, когда 𝑣 в предыдущий раз было корнем,
обозначим 𝑥𝑖 . Новые дети 𝑥′𝑖 появились удалением из 𝑥𝑖 одного или двух детей. 𝑥′𝑖 ⩾ 𝑥𝑖 ⩾ 𝑖 ■
Переход #2: Join объединяет два дерева ранга 𝑘. Раньше у корня 𝑖-й ребёнок был ранга хотя бы
𝑖 для 𝑖 = 0..𝑘−1. Теперь мы добавили ему ребёнка ранга ровно 𝑘, отсортируем детей по рангу,
теперь ∀𝑖 = 0..𝑘 𝑟𝑎𝑛𝑘(𝑥𝑖 ) ⩾ 𝑖. ■
Глава #7. 17 октября. 63/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Рекурсивный перебор
Лекция #8: Рекурсивный перебор
10 ноября
∑︀
Вспомним задачу с практики «в каком порядке расположить числа 𝑎𝑖 , чтобы сумма 𝑎𝑖 𝑏 𝑖
была максимальна?» Если задача кажется вам слишком простой, можете представить себе
более сложную задачу «то же, но каждое число можно двигать вправо-влево не больше чем на
два».
Пусть вы придумали решение. Как надёжно проверить его корректность, если вы уже не первом
курсе и под рукой нет тестирующей системы с готовыми тестами?
1. Сгенерить случайный тест.
2. Запустить медленное, зато точно правильное решение.
Откуда взять медленное решение? Рекурсивный перебор всех возможных вариантов. Для задах
выше нужен перебор перестановок с него и начнём.
8.1. Перебор перестановок
∙ next_permutation
Если вы пишете на языке C++ можно воспользоваться next_permutation.
Такой вариант отработает корректно даже, если 𝑎 содержит одинаковые элементы.
1 sort ( a . begin () , a . end () ) ; // минимальная перестановка
2 do { ...
3 } while ( next_permutation ( a . begin () , a . end () ) ) ; // все перестановки массива 𝑎
Можно также перебирать перестановки 𝑎 не меняя порядок исходного массива.
1 vector < int > p = {0 , 1 , ... n -1};
2 do { // перемешанный массив 𝑎: a[p[0]], a[p[1]], a[p[2]],...
3 } while ( next_permutation ( p . begin () , p . end () ) ) ; // все перестановки массива 𝑎
∑︀
Перестановок 𝑛!. Если ещё хотим посчитать для каждой перестановки целевую функцию 𝑎𝑖 𝑏 𝑖 ,
время работы решения будет 𝒪(𝑛! · 𝑛).
∙ Рекурсивное решение
Перебираем, что поставить на первую позицию...
Для каждого варианта перебираем, что поставить на вторую позицию...
1 void go ( int i ) : // 𝑖 –- позиция в перестановке
2 if ( i == n ) :
3 // что-нибудь сделать с нашей перестановкой
4 return
5 for ( int x = 0; x < n ; x ++)
6 if (! used [ x ]) :
7 used [ x ] = 1 // далее нельзя использовать элемент 𝑥
8 p [ i ] = x , go ( i +1) ; // поставили 𝑥, перебираем дальше
9 used [ x ] = 0 // а в других ветках рекурсии можно
10 go (0) ;
Рекурсивный вариант более гибкий:
1. По ходу рекурсии можно насчитывать нужные нам суммы.
2. Можно перебирать не все перестановки, а только нужные.
Глава #8. 10 ноября. 64/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Рекурсивный перебор
1 void go ( int i , int s ) : // 𝑖 –- позиция в перестановке
2 if ( i == n ) :
3 best = max ( best , s ) ; // решение сложной из двух версий задачи выше
4 return
5 for ( int x = 0; x < n ; x ++)
6 if (! used [ x ]) :
7 if ( abs ( i - x ) > 2) continue ; // пропускаем лишние
8 used [ x ] = 1 // далее нельзя использовать элемент 𝑥
9 p [ i ] = x , go ( i +1 , s + p [ i ]* b [ i ]) ; // пересчитали сумму
10 used [ x ] = 0 // а в других ветках рекурсии можно
11 go (0) ;
Получили код, работающий ровно за количество «перестановок, которые нужно перебрать».
Ещё говорят за 𝒪(ответа), имея в виду задачу «вывести все перестановки такие что».
8.2. Перебор множеств и запоминание
Пусть у нас есть 𝑛 предметов, у каждого есть вес 𝑤𝑖 и мы хотим выбрать подмножество с
суммой весов ровно 𝑆. Решение рекурсивным перебором: каждый предмет или берём, или не
берём.
1 void go ( int i , int sum ) :
2 if ( sum > S ) return // оптимизация!
3 if ( i == n )
4 if ( sum == S ) // набрали подмножество суммы 𝑆
5 return
6 used [ i ]=0 , go ( i + 1 , sum ) // не берём
7 used [ i ]=1 , go ( i + 1 , sum + w [ i ]) // берём
Количество множеств 2𝑛 , перебор работает за 𝒪(2𝑛 ). Если включить оптимизацию и перебирать
мн-ва только суммы ⩽𝑆, то за 𝒪(ответа). Массив used нужен только, если хотим сохранить
само множество, а не только проверить «можем ли набрать». В любом случае сумму весов, как
и раньше, насчитываем по ходу рекурсии.
∙ Запоминание
Результат работы рекурсии зависит только от параметров функции. Второй раз вызываемся с
теми же пераметрами? Ничего нового мы уже не найдём (для конкретной задачи: если раньше
не нашли множества суммы 𝑆, то и в этот раз не найдём).
1 set < pair < int , int > > mem ; // запоминание
2 void go ( int i , int sum ) :
3 if ( sum > S ) return // оптимизация!
4 if ( i == n ) return // конец
5 if ( mem . count ({ i , S }) ) return ; // были уже в таком состоянии?
6 mem . insert ({ i , S }) ; // запомним, что теперь «уже были»
7 used [ i ]=0 , go ( i + 1 , sum ) // не берём
8 used [ i ]=1 , go ( i + 1 , sum + w [ i ]) // берём
∙ Время работы перебора с запоминанием
Для каждой комбинации параметров 𝑖, 𝑠𝑢𝑚 зайдём в рекурсию не более одного раза. Комби-
наций 𝑛·𝑆 (𝑖 до 𝑛, 𝑠𝑢𝑚 до 𝑆). Время работы 𝒪(𝑛𝑆), если 𝑆 мало, это лучше старого 𝒪(2𝑛 ).
Глава #8. 10 ноября. 65/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Рекурсивный перебор
∙ Ограбление банка (рюкзак)
Унести предметы суммарного веса ⩽𝑊 максимальной суммарной стоимости.
1 void go ( int i , int sw , int scost ) :
2 if ( sw > W ) return // оптимизация!
3 if ( i == n )
4 best = max ( best , scost ) ;
5 return
6 go ( i + 1 , sw , scost ) // не берём
7 go ( i + 1 , sw + w [ i ] , scost + cost [ i ]) // берём
8 go (0 , 0 , 0) ; cout << best << endl ;
Поменяем перебор. Пусть он нам возвращает «какую ещё стоимость можно набрать из остав-
шихся предметов, если свободного места в рюкзаке 𝑊 ».
1 int go ( int i , int W ) :
2 if ( W <= 0 || i == n ) return 0
3 return max ( go ( i + 1 , W ) , // не берём
4 go ( i + 1 , W - w [ i ] , cost + cost [ i ]) ) ; // берём, уменьшили свободное место
5 cout << go (0 , 0) << endl ;
В таком виде можно добавить запоминание: map<pair<int,int>, int> —
для каждой пары {𝑖, 𝑊 }, от которой мы уже запускались хранить результат.
1 map < pair < int , int > , int > mem ;
2 int go ( int i , int W ) :
3 if ( W <= 0 || i == n ) return 0
4 if ( mem . count ({ i , W }) ) return mem [{ i , W }];
5 return mem [{ i , W }] = max ( go ( i + 1 , W ) , // не берём
6 go ( i + 1 , W - w [ i ] , cost + cost [ i ]) ) ; // берём, уменьшили свободное место
7 cout << go (0 , 0) << endl ;
Время работы теперь 𝒪(𝑛 · 𝑊 ) — для каждой пары {𝑖, 𝑊 } считаемся один раз.
∙ Технические оптимизации
Конечно, можно вместо map использовать unordered_map (но от пары нет хеша, поэтому нужно
сперва pair<int,int> → int64_t), или даже двумерный массив (он же вектор векторов).
8.3. Перебор путей (коммивояжёр)
Задача: мы развозчик грузов, есть один грузовик и 𝑛 заказов «что куда отвезти», начинаем
в точке 𝑠, нужно всё развести за минимальное время. С точки зрения графов: найти путь,
проходящий по всем выделенным точкам минимальной суммарной длины.
Решение рекурсивным перебором: перебираем, как перестановки, куда поехать сперва, куда
поехать потом, куда дальше...
1 int go ( int cnt , int v , vector < int > & used ) : // 𝑐𝑛𝑡 – сколько заказов уже выполнили
2 if ( cnt == n ) : return 0 // посетили всё, что хотели
3 int best = INT_MAX ; // лучший (минимальный) вариант
4 for ( int i = 0; i < n ; i ++) // выбираем, какой заказ обработать следующим
5 if (! used [ i ]) :
6 used [ i ] = 1 // теперь доехать от 𝑣 до 𝑖 и рекурсивно вызваться
7 best = min ( best , dist (v , i ) + go ( cnt +1 , i , used ) ) ;
8 used [ i ] = 0
9 return best
Глава #8. 10 ноября. 66/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Рекурсивный перебор
Всегда можно добавить запоминание. Добавим. mem: int,vector<int> → int.
Время работы 2𝑛 𝑛 · 𝑛 · map: всего 2𝑛 различных used, 𝑛 различных 𝑣 ⇒ дойдём до цикла for
мы 2𝑛 𝑛 раз, 2𝑛 𝑛 раз сделаем 𝑛 итераций цикла.
8.4. Разбиения на слагаемые
Задача: сколько есть разбиений числа 𝑁 на строго возрастающие слагаемые?
Пример: 6 = 1 + 2 + 3, 6 = 1 + 5, 6 = 2 + 4, 6 = 6
Решение перебором #1: перебираем сперва первое слагаемое, затем второе...
1 int go ( int n , int x ) : // последнее слагаемое было 𝑥 ⇒ наше и следующие >𝑥
2 if ( n == 0) : return 1 // ровно 1 способ разбить 0 на слагаемые
3 int ans = 0;
4 for ( int y = x + 1; y <= n ; y ++)
5 ans += go ( n - y , y ) // перебрали очередное слагаемое 𝑦
6 return ans
7 go (N , 0) // первым слагаемым попробуем все 1..N
Решение перебором #2: каждое слагаемое или берём или не берём
1 int go ( int n , int x ) :
2 if ( n == 0) : return 1 // ровно 1 способ разбить 0 на слагаемые
3 return go (n , x - 1) + (n < x ? 0 : go (n -x , x ) )
√
Сейчас оба решения работают за 𝒪(ответа). Разбиений на слагаемые 2Θ( 𝑁)
.
После добавления запоминания превое решение будет работает за 𝒪(𝑁 3 ), второе за 𝒪(𝑁 2 ).
Время работы: (сколько раз мы зайдём в go(n,x)) · (число рекурсивных вызовов).
8.5. Доминошки и изломанный профиль
Решим задачу про покрытие доминошками: есть доска с дырка-
ми размера 𝑤 × ℎ. Сколько способов замостить её доминошками
(фигуры 1×2) так, чтобы каждая не дырка была покрыта ровно
один раз? Для начала напишем рекурсивный перебор, который
берёт первую непокрытую клетку и пытается её покрыть. Если
перебирать клетки сверху вниз, а в одной строке слева направо,
то есть всего два способа покрыть текущую клетку.
1 int go ( int x , int y ) : {
2 if ( x == w ) x = 0 , y ++; // начали следующую строку
3 if ( y == h ) return 1; // все строки заполнены, 1 способ закончить заполнение
4 if (! empty [ y ][ x ]) return go ( x + 1 , y ) ;
5 int result = 0;
6 if ( y + 1 < h && empty [ y + 1][ x ]) :
7 empty [ y + 1][ x ] = empty [ y ][ x ] = 0; // поставили вертикальную доминошку
8 result += go ( x + 1 , y ) ;
9 empty [ y + 1][ x ] = empty [ y ][ x ] = 1; // убрали за собой
10 if ( x + 1 < w && empty [ y ][ x +1]) : // аналогично для горизонтальной
11 empty [ y + 1][ x ] = empty [ y ][ x ] = 0;
12 result += go ( x + 1 , y ) ;
13 empty [ y + 1][ x ] = empty [ y ][ x ] = 1;
14 return result ;
15 }
Глава #8. 10 ноября. 67/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Рекурсивный перебор
go(x, y) вместо того, чтобы каждый раз искать с нуля первую непокрытую клетку помнит
«всё, что выше-левее (𝑥, 𝑦) мы уже покрыли». Возвращает функция go число способов докра-
сить всё до конца. empty – глобальный массив, ячейка пуста ⇔ там нет ни дырки, ни доминошки.
Время работы данной функции не более 2число доминошек ⩽ 2𝑤ℎ/2 . Давайте теперь, как и задаче про
клики добавим к нашему перебору запоминание. Что есть состояние перебора? Вся матрица.
Получаем следующий код:
1 vector < vector < bool > > empty ;
2 map < vector < vector < bool > > , int > m ; // запоминание
3 int go ( int x , int y ) :
4 if ( x == w ) x = 0 , y ++; // начали следующую строку
5 if ( y == h ) return 1; // все строки заполнены, 1 способ закончить заполнение
6 if (! empty [ y ][ x ]) return go ( x + 1 , y ) ;
7 if (m.count(empty)) return m[empty];
8 int result = &m[empty];
9 if ( y + 1 < h && empty [ y + 1][ x ]) :
10 empty [ y + 1][ x ] = empty [ y ][ x ] = 0; // поставили вертикальную доминошку
11 result += go ( x + 1 , y ) ;
12 empty [ y + 1][ x ] = empty [ y ][ x ] = 1; // убрали за собой
13 if ( x + 1 < w && empty [ y ][ x +1]) : // аналогично для горизонтальной
14 empty [ y + 1][ x ] = empty [ y ][ x ] = 0;
15 result += go ( x + 1 , y ) ;
16 empty [ y + 1][ x ] = empty [ y ][ x ] = 1;
17 return result ;
Теорема 8.5.1. Количество состояний динамики 𝒪(2𝑤 ℎ𝑤).
⟨𝑥,𝑦⟩
Доказательство. Когда мы находимся в клетке (𝑥, 𝑦). Что мы
можем сказать про покрытость остальных? Все клетки выше-
левее (𝑥, 𝑦) точно not empty. А все ниже-правее? Какие-то мог-
ли быть задеты уже поставленными доминошками, но не более
чем на одну «изломанную строку» снизу от (𝑥, 𝑦). Кроме этих
𝑤 все клетки находятся в исходном состоянии. ■
Глава #8. 10 ноября. 68/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
Лекция #9: Динамическое программирование
24 ноября
9.1. Базовые понятия
«Метод динамического программирования» будем кратко называть «динамикой». Познакомим-
ся с этим методом через простой пример.
9.1.1. Условие задачи
У нас есть число 1, за ход можно заменить его на любое из 𝑥 + 1, 𝑥 + 7, 2𝑥, 3𝑥. За какое
минимальное число ходов мы можем получить 𝑛?
9.1.2. Динамика назад
𝑓 [𝑥] — минимальное число ходов, чтобы получить число 𝑥.
Тогда 𝑓 [𝑥] = min(𝑓 [𝑥−1], 𝑓 [𝑥−7], 𝑓 [ 𝑥2 ], 𝑓 [ 𝑥3 ]), причём запрещены переходы в
не натуральные числа. При этом мы знаем, что 𝑓 [1] = 0, получается решение:
1 vector < int > f ( n + 1 , 0) ;
2 f [1] = 0; // бесполезная строчка, просто подчеркнём факт
3 for ( int i = 2; i <= n ; i ++) {
4 f [ i ] = f [ i - 1] + 1;
5 if ( i - 7 >= 1) f [ i ] = min ( f [ i ] , f [ i - 7] + 1) ;
6 if ( i % 2 == 0) f [ i ] = min ( f [ i ] , f [ i / 2] + 1) ;
7 if ( i % 3 == 0) f [ i ] = min ( f [ i ] , f [ i / 3] + 1) ;
8 }
Когда мы считаем значение 𝑓 [𝑥], для всех 𝑦 < 𝑥 уже посчитано 𝑓 [𝑦], поэтому 𝑓 [𝑥] посчитает-
ся верно. Важно, что мы не пытаемся думать, что выгоднее сделать «вычесть 7» или «поде-
лить на 2», мы честно перебираем все возможные ходы и выбираем оптимум. Введём операцию
relax — улучшение ответа. Далее мы будем использовать во всех «динамиках».
1 void relax ( int &a , int b ) { a = min (a , b ) ; }
2 vector < int > f ( n + 1 , 0) ;
3 for ( int i = 2; i <= n ; i ++) :
4 int r = f [ i - 1];
5 if ( i - 7 >= 1) relax (r , f [ i - 7]) ;
6 if ( i % 2 == 0) relax (r , f [ i / 2]) ;
7 if ( i % 3 == 0) relax (r , f [ i / 3]) ;
8 f [ i ] = r + 1;
Операция relax именно улучшает ответ,
в зависимости от задачи или минимизирует его, или максимизирует.
Введём основные понятия
1. 𝑓 [𝑥] — функция динамики
2. 𝑥 — состояние динамики
3. 𝑓 [1] = 0 — база динамики
4. 𝑥 → 𝑥 + 1, 𝑥 + 7, 2𝑥, 3𝑥 — переходы динамики
Исходная задача — посчитать 𝑓 [𝑛].
Чтобы её решить, мы сводим её к подзадачам такого же вида меньшего размера – посчитать
Глава #9. 24 ноября. 69/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
для всех 1 ⩽ 𝑖 < 𝑛, тогда сможем посчитать и 𝑓 [𝑛]. Важно, что для каждой подзадачи (для
каждого 𝑥) мы считаем значение 𝑓 [𝑥] ровно 1 раз. Время работы Θ(𝑛).
9.1.3. Динамика вперёд
Решим ту же самую задачу тем же самым методом, но пойдём в другую сторону.
1 void relax ( int &a , int b ) { a = min (a , b ) ; }
2 vector < int > f (3 * n , INT_MAX ) ; // 3 * n –- чтобы меньше if-ов писать
3 f [1] = 0;
4 for ( int i = 1; i < n ; i ++) :
5 int F = f [ i ] + 1;
6 relax ( f [ i + 1] , F ) ;
7 relax ( f [ i + 7] , F ) ;
8 relax ( f [2 * i ] , F ) ;
9 relax ( f [3 * i ] , F ) ;
Для данной задачи код получился немного проще (убрали if-ы).
В общем случае нужно помнить про оба способа, выбирать более удобный.
Суть не поменялась: для каждого 𝑥 будет верно 𝑓 [𝑥] = 1 + min(𝑓 [𝑥−1], 𝑓 [𝑥−7], 𝑓 [ 𝑥2 ], 𝑓 [ 𝑥3 ]).
∙ Интуиция для динамики вперёд и назад.
Назад: посчитали 𝑓 [𝑥] через уже посчитанные подзадачи.
Вперёд: если 𝑓 [𝑥] верно посчитано, мы можем обновить ответы для 𝑓 [𝑥+1], 𝑓 [𝑥+7], . . .
9.1.4. Ленивая динамика
Это рекурсивный способ писать динамику назад, вычисляя значение только для тех состояний,
которые действительно нужно посчитать.
1 vector < int > f ( n + 1 , -1) ;
2 int calc ( int x ) :
3 int & r = f [ x ]; // результат вычисления f[x]
4 if ( r != -1) return r ; // функция уже посчитана
5 if ( r == 1) return r = 0; // база динамики
6 r = calc ( x - 1) ;
7 if ( x - 7 >= 1) relax (r , calc ( x - 7) ) ; // стандартная ошибка: написать f[x-7]
8 if ( x % 2 == 0) relax (r , calc ( x / 2) ) ;
9 if ( x % 3 == 0) relax (r , calc ( x / 3) ) ;
10 // теперь r=f[x] верно посчитан, в следующий раз для x сразу вернём уже посчитанный f[x]
11 return ++ r ;
Для данной задачи этот код будет работать дольше, чем обычная «динамика назад циклом for»,
так как переберёт те же состояния с большей константой (рекурсия хуже цикла).
Тем не менее представим, что переходы были бы 𝑥 → 2𝑥 + 1, 2𝑥 + 7, 3𝑥 + 2, 3𝑥 + 10. Тогда,
например, ленивая динамика точно не зайдёт в состояния [ 𝑛2 ..𝑛), а если посчитать точно будет
вообще работать за 𝒪(log 𝑛). Чтобы она корректно работала для 𝑛 порядка 1018 нужно лишь
vector<int> f(n + 1, -1); заменить на map<long long, int> f;.
Глава #9. 24 ноября. 70/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
𝑦
9.2. Ещё один пример
5
Вам дана матрица с непроходимыми клетками. В x
некоторых клетках лежат монетки разной ценно- 4
сти. За один ход можно сместиться вверх или впра- 8 7
во. Рассмотрим все пути из левой-нижней клетки в 3
верхнюю-правую.
2
(a) Нужно найти число таких путей.
2
(b) Нужно найти путь, сумма ценностей монет на
1
котором максимальна/минимальна.
Решим задачу динамикой назад: 𝑥
{︃ 1 2 3 4 5
𝑐𝑛𝑡[𝑥−1, 𝑦] + 𝑐𝑛𝑡[𝑥, 𝑦−1] если клетка проходима
𝑐𝑛𝑡[𝑥, 𝑦] =
0 если клетка не проходима
{︃
𝑚𝑎𝑥(𝑓 [𝑥−1, 𝑦], 𝑓 [𝑥, 𝑦−1]) + 𝑣𝑎𝑙𝑢𝑒[𝑥, 𝑦] если клетка проходима
𝑓 [𝑥, 𝑦] =
-∞ если клетка не проходима
Где 𝑐𝑛𝑡[𝑥, 𝑦] — количество путей из (0, 0) в (𝑥, 𝑦),
𝑓 [𝑥, 𝑦] — вес максимального пути из (0, 0) в (𝑥, 𝑦),
𝑣𝑎𝑙𝑢𝑒[𝑥, 𝑦] — ценность монеты в клетке (𝑥, 𝑦).
Решим задачу динамикой вперёд:
1 cnt <-- 0 , f <-- -∞; // нейтральные значения
2 cnt [0 ,0] = 1 , f [0 ,0] = 0; // база
3 for ( int x = 0; x < width ; x ++)
4 for ( int y = 0; y < height ; y ++) :
5 if (клетка не проходима) continue ;
6 cnt [ x +1 , y ] += cnt [x , y ];
7 cnt [x , y +1] += cnt [x , y ];
8 f [x , y ] += value [x , y ];
9 relax ( f [ x +1 , y ] , f [x , y ]) ;
10 relax ( f [x , y +1] , f [x , y ]) ;
Ещё больше способов писать динамику.
Можно считать 𝑐𝑛𝑡[𝑥, 𝑦] — число путей из (0, 0) в (𝑥, 𝑦). Это мы сейчас и делаем.
А можно считать 𝑐𝑛𝑡′ [𝑥, 𝑦] — число путей из (𝑥, 𝑦) в (𝑤𝑖𝑑𝑡ℎ−1, ℎ𝑒𝑖𝑔ℎ𝑡−1).
9.3. Восстановление ответа
Посмотрим на задачу про матрицу и максимальный путь. Нас могут попросить найти только
вес пути, а могут попросить найти и сам путь, то есть, «восстановить ответ».
∙ Первый способ. Обратные ссылки.
Будем хранить 𝑝[𝑥, 𝑦] — из какого направления мы пришли в клетку (𝑥, 𝑦). 0 — слева, 1 — снизу.
Функцию релаксации ответа нужно теперь переписать следующим образом:
1 void relax ( int x , int y , int F , int P ) :
2 if ( f [x , y ] < F )
3 f [x , y ] = F , p [x , y ] = P ;
Чтобы восстановить путь, пройдём по обратным ссылкам от конца пути до его начала:
Глава #9. 24 ноября. 71/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
1 void outputPath () {
2 for ( int x = width -1 , y = height -1; !( x == 0 && y == 0) ; p [x , y ] ? y - - : x - -)
3 print (x , y ) ;
4 }
∙ Второй способ. Не хранить обратные ссылки.
Заметим, что чтобы понять, куда нам идти назад из клетки (𝑥, 𝑦), достаточно повторить то,
что делает динамика назад, понять, как получилось значение 𝑓 [𝑥, 𝑦]:
1 if ( x > 0 && f [x , y ] == f [x -1 , y ] + value [x , y ]) // f[x,y] получилось из f[x-1,y]
2 x - -;
3 else
4 y - -;
Второму способу нужно меньше памяти, но обычно он требует больше строк кода.
∙ Оптимизации по памяти
Если нам не нужно восстанавливать путь, заметим, что достаточно хранить только две строки
динамики — 𝑓 [𝑥], 𝑓 [𝑥+1], где 𝑓 [𝑥] уже посчитана, а 𝑓 [𝑥+1] мы сейчас вычисляем. Напомним,
решение за Θ(𝑛2 ) времени и Θ(𝑛) памяти (в отличии от Θ(𝑛2 ) памяти) попадёт в кеш и будет
работать значительно быстрее.
9.4. Графовая интерпретация
Рассмотрим граф, в котором вершины — состояния динами-
ки, ориентированные рёбра — переходы динамики (𝑎 → 𝑏 𝑥−1 𝑥+1
обозначает переход из 𝑎 в 𝑏). Тогда мы только что решали 𝑥/2 𝑥 2𝑥
задачи поиска пути из 𝑠 (начальное состояние) в 𝑡 (конечное
состояние), минимального/максимального веса пути, а так 𝑥/3 3𝑥
же научились считать количество путей из 𝑠 в 𝑡.
Утверждение 9.4.1. Любой задаче динамики соответствует ацикличный граф.
При этому динамика вперёд перебирала исходящие из 𝑣 рёбра, а динамика назад перебирала
входящие в 𝑣 рёбра. Верно и обратное:
Утверждение 9.4.2. Для любого ацикличного графа и выделенных вершин 𝑠, 𝑡 мы умеем искать
min/max путь из 𝑠 в 𝑡 и считать количество путей из 𝑠 в 𝑡, используя ленивую динамику.
Почему именно ленивую?
В произвольном графе мы не знаем, в каком порядке вычислять функцию для состояния. Но
знаем, чтобы посчитать 𝑓 [𝑣], достаточно знать значение динамики для начал всех входящих в
𝑣 рёбер.
Почему только на ацикличном?
Пусть есть ориентированный цикл 𝑎1 → 𝑎2 → · · · → 𝑎𝑘 → 𝑎1 . Пусть мы хотим посчитать
значение функции в вершине 𝑎1 , для этого нужно знать значение в вершине 𝑎𝑘 , для этого в
𝑎𝑘−1 , и так далее до 𝑎1 . Получили, чтобы посчитать значение в 𝑎1 , нужно его знать заранее.
Для произвольного ацикличного графа из 𝑉 вершин и 𝐸 рёбер динамика будет работать за
𝒪(𝑉 + 𝐸). При этом будут посещены лишь достижимые по обратным рёбрам из 𝑡 вершины.
Глава #9. 24 ноября. 72/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
9.5. Checklist
Вы придумываете решение задачи, используя метод динамического программирования, или
даже собираетесь писать код. Чтобы придумать решение, нужно увидеть некоторый процесс,
например «мы идём слева направо, снизу вверх по матрице». После этого, чтобы получилось
корректное решение нужно увидеть
1. Состояние динамики (процесса) — мы стоим в клетке (𝑥, 𝑦)
2. Переходы динамики — сделать шаг вправо или вверх
3. Начальное состояние динамики — стоим в клетке (0, 0)
4. Как ответ к исходной задаче выражается через посчитанную динамику (конечное состо-
яние). В данном случае всё просто, ответ находится в 𝑓 [𝑤𝑖𝑑𝑡ℎ−1, ℎ𝑒𝑖𝑔ℎ𝑡−1]
5. Порядок перебора состояний: если мы пишем динамику назад,
то при обработке состояния (𝑥, 𝑦) должны быть уже посчитаны (𝑥−1, 𝑦) и (𝑥, 𝑦−1).
Всегда можно писать лениво, но цикл for быстрее рекурсии.
6. Если нужно восстановить ответ, не забыть подумать, как это делать.
9.6. Рюкзак
9.6.1. Формулировка задачи
Нам дано 𝑛 предметов с натуральными весами 𝑎0 , 𝑎1 , . . . , 𝑎𝑛−1 .
Требуется выбрать подмножество предметов суммарного веса ровно 𝑆.
1. Задача NP-трудна, если решить её за 𝒪(𝑝𝑜𝑙𝑦(𝑛)), получите 1 000 000$.
2. Простое переборное решение рекурсией за 2𝑛 .
3. ∃ решение за 2𝑛/2 (meet in middle)
9.6.2. Решение динамикой
Будем рассматривать предметы по одному в порядке 0, 1, 2, . . .
Каждый из них будем или брать в подмножество-ответ, или не брать.
Состояние: перебрав первые 𝑖 предметов, мы набрали вес 𝑥
Функция: 𝑖𝑠[𝑖, 𝑥] ⇔ мы могли выбрать подмножество веса 𝑥 из первых 𝑖 предметов
Начальное состояние: (0, 0)
Ответ на задачу: содержится в 𝑖𝑠[𝑛, 𝑆]
{︃
[𝑖, 𝑥] → [𝑖+1, 𝑥] (не брать)
Переходы:
[𝑖, 𝑥] → [𝑖+1, 𝑥+𝑎𝑖 ] (берём в ответ)
1 bool is [ n +1][2 S +1] <-- 0; // пусть a[i] ⩽ S, запаса 2S хватит
2 is [0 ,0] = 1;
3 for ( int i = 0; i < n ; i ++)
4 for ( int j = 0; j <= S ; j ++)
5 if ( is [ i ][ j ])
6 is [ i +1][ j ] = is [ i +1][ j + a [ i ]] = 1;
7 // Answer = is[n][S]
Время работы Θ(𝑛𝑆), память Θ(𝑛𝑆).
Глава #9. 24 ноября. 73/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
9.6.3. Оптимизируем память
Мы уже знаем, что, если не нужно восстанавливать ответ, то достаточно хранить лишь две
строки динамики 𝑖𝑠[𝑖], 𝑖𝑠[𝑖+1], в задаче о рюкзаке можно хранить лишь одну строку динамики:
Код 9.6.1. Рюкзак с линейной памятью
1 bool is [ S +1] <-- 0;
2 is [0] = 1;
3 for ( int i = 0; i < n ; i ++)
4 for ( int j = S - a [ i ]; j >= 0; j - -) // поменяли направление, важно
5 if ( is [ j ])
6 is [ j + a [ i ]] = 1;
7 // Answer = is[S]
Путь к пониманию кода состоит из двух шагов.
(а) 𝑖𝑠[𝑖, 𝑥] = 1 ⇒ 𝑖𝑠[𝑖+1, 𝑥] = 1. Единицы остаются, если мы умели набирать вес 𝑖, как подмно-
жество из 𝑖 предметов, то как подмножество из 𝑖+1 предмета набрать, конечно, тоже сможем.
(б) После шага 𝑗 часть массива 𝑖𝑠[𝑗..𝑆] содержит значения строки 𝑖+1, а часть массива 𝑖𝑠[0..𝑗−1]
ещё не менялась и содержит значения строки 𝑖.
9.6.4. Добавляем bitset
bitset — массив бит. Им можно пользоваться, как обычным массивом. С другой стороны с
bitset можно делать все логические операции |, &, ^ , «, как с целыми числами. Целое число
𝑛
можно рассматривать, как bitset из 64 бит, а bitset из 𝑛 бит устроен, как массив ⌈ 64 ⌉ целых
чисел. Для асимптотик введём обозначения w от word_size (размер машинного слова). Тогда
наш код можно реализовать ещё короче и быстрее.
1 bitset < S +1 > is ;
2 is [0] = 1;
3 for ( int i = 0; i < n ; i ++)
𝑆
4 is |= is << w [ i ]; // выполняется за 𝒪( 𝑤 )
Заметим, что мы делали ранее ровно указанную операцию над битовыми массивами.
Время работы 𝒪( 𝑛𝑆
𝑤
), то есть, в 64 раза меньше, чем раньше.
9.6.5. Восстановление ответа с линейной памятью
Модифицируем код 9.6.1, чтобы была возможность восстанавливать ответ.
1 int last [ S +1] <-- -1;
2 last [0] = 0;
3 for ( int i = 0; i < n ; i ++)
4 for ( int j = S - a [ i ]; j >= 0; j - -)
5 if ( last [ j ] != -1 && last[j + a[i]] == -1)
6 last [ j + a [ i ]] = i;
7 // Answer = (last[S] == -1 ? NO : YES)
И собственно восстановление ответа.
1 for ( int w = S ; w > 0; w -= a [ last [ w ]])
2 print ( last [ w ]) ; // индекс взятого в ответ предмета
Почему это работает?
Заметим, что когда мы присваиваем last[j+a[i]] = i, верно, что на пути по обратным ссыл-
кам из j: last[j], last[j-a[last[j]]], . . . все элементы строго меньше 𝑖.
Глава #9. 24 ноября. 74/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
9.7. Квадратичные динамики
Def 9.7.1. Подпоследовательностью последовательности 𝑎1 , 𝑎2 , . . . , 𝑎𝑛 будем называть
𝑎𝑖1 , 𝑎𝑖2 , . . . , 𝑎𝑖𝑘 : 1 ⩽ 𝑖1 < 𝑖2 < · · · < 𝑖𝑘 ⩽ 𝑛
Задача НОП (LCS). Поиск наибольшей общей подпоследовательности.
Даны две последовательности 𝑎 и 𝑏, найти 𝑐, являющуюся подпоследовательностью и 𝑎, и 𝑏
такую, что 𝑙𝑒𝑛(𝑐) → max. Например, для последовательностей ⟨1, 3, 10, 2, 7⟩ и ⟨3, 5, 1, 2, 7, 11, 12⟩
возможными ответами являются ⟨3, 2, 7⟩ и ⟨1, 2, 7⟩.
Решение за 𝒪(𝑛𝑚)
𝑓 [𝑖, 𝑗] — длина НОП для префиксов a[1..i] и b[1..j].
Ответ содержится в 𝑓 [𝑛, 𝑚], где 𝑛 и 𝑚 — длины последовательностей.
Посмотрим на последние элементы префиксов a[i] и b[j].
Или мы один из них не возьмём в ответ, или возьмём оба. Делаем переходы:
⎧
⎨𝑓 [𝑖−1, 𝑗]
⎪
𝑓 [𝑖, 𝑗] = max 𝑓 [𝑖, 𝑗−1]
⎪
𝑓 [𝑖−1, 𝑗−1] + 1, если 𝑎𝑖 = 𝑏𝑗
⎩
Время работы Θ(𝑛2 ), количество памяти Θ(𝑛2 ).
Задача НВП (LIS). Поиск наибольшей возрастающей подпоследовательности.
Дана последовательность 𝑎, найти возрастающую подпоследовательность 𝑎: длина → max.
Например, для последовательности ⟨5, 3, 3, 1, 7, 8, 1⟩ возможным ответом является ⟨3, 7, 8⟩.
Решение за 𝒪(𝑛2 )
𝑓 [𝑖] — длина НВП, заканчивающейся ровно в 𝑖-м элементе.
Ответ содержится в max(𝑓 [1], 𝑓 [2], . . . , 𝑓 [𝑛]), где 𝑛 — длина последовательности.
Пересчёт: 𝑓 [𝑖] = 1 + max 𝑓 [𝑗], максимум пустого множества равен нулю.
𝑗<𝑖,𝑎𝑗 <𝑎𝑖
Время работы Θ(𝑛2 ), количество памяти Θ(𝑛).
Расстояние Левенштейна. Оно же «редакционное расстояние».
Дана строка 𝑠 и операции INS, DEL, REPL — добавление, удаление, замена одного символа.
Минимальным числом операций нужно получить строку 𝑡.
Например, чтобы из строки STUDENT получить строку POSUDA,
можно добавить зелёное (2), удалить красное (3), заменить синее (1), итого 6 операций.
Решение за 𝒪(𝑛𝑚)
При решении задачи вместо добавлений в 𝑠 будем удалять из 𝑡. Нужно сделать 𝑠 и 𝑡 равными.
𝑓 [𝑖, 𝑗] — редакционное расстояние между префиксами s[1..i] и t[1..j]
⎧
⎨𝑓 [𝑖−1, 𝑗] + 1
⎪ удаление из 𝑠
𝑓 [𝑖, 𝑗] = min 𝑓 [𝑖, 𝑗−1] + 1 удаление из 𝑡
⎪
𝑓 [𝑖−1, 𝑗−1] + 𝑤 если 𝑠𝑖 = 𝑡𝑗 , то 𝑤 = 0, иначе 𝑤 = 1
⎩
Ответ содержится в 𝑓 [𝑛, 𝑚], где 𝑛 и 𝑚 — длины строк.
Восстановление ответа.
Заметим, что пользуясь стандартными методами из раздела «восстановление ответа», мы мо-
жем найти не только число, но и восстановить сами общую последовательность, возрастающую
последовательность и последовательность операций для редакционного расстояния.
Глава #9. 24 ноября. 75/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
9.8. Оптимизация памяти для НОП
Рассмотрим алгоритм для НОП. Если нам не требуется восстановление ответа, можно хранить
только две строки динамики, памяти будет Θ(𝑛). Восстанавливать ответ мы пока умеем только
за Θ(𝑛2 ) памяти, давайте улучшать.
9.8.1. Храним биты
Можно хранить не 𝑓 [𝑖, 𝑗], а разность соседних 𝑑𝑓 [𝑖, 𝑗] = 𝑓 [𝑖, 𝑗] − 𝑓 [𝑖, 𝑗−1], она или 0, или 1.
Храним Θ(𝑛𝑚) бит = Θ( 𝑛𝑚 𝑤
) машинных слов. Этого достаточно, чтобы восстановить ответ: мы
умеем восстанавливать путь, храня только 𝑓 , чтобы сделать 1 шаг назад в этом пути, достаточно
знать 2 строки f[], восстановим их за 𝒪(𝑚), сделаем шаг.
9.8.2. Алгоритм Хиршберга (по wiki)
Общая идея. Восстановить ответ = восстановить путь из (0, 0) в (𝑛, 𝑚). Хотим за 𝒪(𝑛𝑚) времени
и 𝒪(𝑚) памяти восстановить клетку (𝑟𝑜𝑤, 𝑐𝑜𝑙) этого пути в строке 𝑟𝑜𝑤 = 𝑛2 (посередине). После
этого сделать 2 рекурсивных вызова для кусков задачи (0, 0) → (𝑟𝑜𝑤, 𝑐𝑜𝑙) и (𝑟𝑜𝑤, 𝑐𝑜𝑙) → (𝑛, 𝑚).
Пусть мы ищем НОП (LCS) для последовательностей a[0..n) и b[0..m).
Обозначим 𝑛′ = ⌊ 𝑛2 ⌋. Разделим задачу на подзадачи посчитать НОП на подотрезках [0..𝑛′ )×[0..𝑗)
и [𝑛′ ..𝑛) × [𝑗..𝑚). Как выбрать оптимальное 𝑗? Для этого насчитаем две квадратные динамики:
𝑓 [𝑖, 𝑗] — НОП для первых 𝑖 символов 𝑎 и первых 𝑗 символов 𝑏 и
𝑔[𝑖, 𝑗] — НОП для последних 𝑖 символов 𝑎 и последних 𝑗 символов 𝑏.
Нас интересуют только последние строки — 𝑓 [𝑛′ ] и 𝑔[𝑛−𝑛′ ], поэтому при вычислении можно
хранить лишь две последние строки, 𝒪(𝑚) памяти.
Выберем 𝑗 : 𝑓 [𝑛′ , 𝑗] + 𝑔[𝑛−𝑛′ , 𝑚−𝑗] = max, сделаем два рекурсивных вызова от 𝑎[0..𝑛′ ) × 𝑏[0..𝑗)
и 𝑎[𝑛′ ..𝑛) × 𝑏[𝑗..𝑚), которые восстановят нам половинки ответа.
9.8.3. Оценка времени работы Хиршберга
Заметим, что глубина рекурсии равна ⌈log2 𝑛⌉, поскольку 𝑛 делится пополам.
Lm 9.8.1. Память Θ(𝑚 + log 𝑛)
Доказательство. Для вычисления 𝑓 и 𝑔 мы используем Θ(𝑚) памяти, для стека рекурсии
Θ(log 𝑛) памяти. ■
Lm 9.8.2. Время работы Θ(𝑛𝑚)
Доказательство. Глубина рекурсии 𝒪(log 𝑛).
Суммарный размер подзадач на 𝑖-м уровне рекурсии = 𝑚. Например, на 2-м уровне это 𝑖 +
(𝑚−𝑖) = 𝑚. Значит 𝑇 𝑖𝑚𝑒 ⩽ 𝑛𝑚 + ⌈ 𝑛2 ⌉𝑚 + ⌈ 𝑛4 ⌉𝑚 + . . . ⩽ 4𝑛𝑚. ■
Подведём итог проделаной работы:
Теорема 9.8.3.
Мы умеем искать НОП с восстановлением ответа за Θ(𝑛𝑚) времени, Θ(𝑚 + log 𝑛) памяти.
Алгоритм полностью описан в wiki.
Глава #9. 24 ноября. 76/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование
9.8.4. (*) Алгоритм Хиршберга (улучшенный)
Пусть мы ищем НОП (LCS) для последовательностей a[0..n) и b[0..m).
Пишем обычную динамику 𝑙𝑐𝑠[𝑖, 𝑗] = ⟨длина НОП для a[0..i), b[0..j), ссылка назад⟩. Будем
хранить только две последние строки динамики. Если раньше ссылку из 𝑖-й строки мы хранили
или на 𝑖-ю, или на (𝑖−1)-ю, теперь для восстановления ответа будем для строк [ 𝑛2 ..𝑛) хранить
ссылку на то место, где мы были в строке номер 𝑛2 .
TODO: здесь очень нужна картинка.
1 def relax (i , j , pair , add ) :
2 pair . first += add
3 lcs [i , j ] = min ( lcs [i , j ] , pair )
4
5 def solve (n , a , m , b ) :
6 if n <= 2 or m <= 2:
7 return naive_lcs (n , a , m , b ) # запустили наивное решение
8
9 # ВАЖНО: обязательно нужно хранить только 2 последние строчки lcs
10 # Для простоты чтения кода, эта оптимизация здесь специально не сделана
11 lcs [] <-- -INF , lcs [0 ,0] = 0; # инициализация
12 for i = 0.. n -1:
13 for j = 0.. m -1:
14 relax (i , j , lcs [i ,j -1] , 0)
15 relax (i , j , lcs [i -1 , j ] , 0)
16 if ( i > 0 and j > 0 and a [i -1] == b [j -1]) :
17 relax (i , j , lcs [i -1 ,j -1] , 1)
18 if ( i == n /2) :
19 lcs [i , j ]. second = j # самое важное, сохранили, где мы были в n/2-й строке
20
21 # нашли клетку, через которую точно проходит последовательность-ответ
22 i , j = n /2 , lcs [n , m ]. second ;
23 return solve (i , a , j , b ) + solve (n -i , a +i , m -j , b + j )
Заметим, что и глубина рекурсия, и ширина, и размеры всех подзадач будут такими же, как в
доказательстве 9.8.3 ⇒ оценки те же.
Теорема 9.8.4.
Новый алгоритм ищет НОП с восстановлением ответа за Θ(𝑛𝑚) времени, Θ(𝑚 + log 𝑛) памяти.
9.8.5. Область применение идеи Хиршберга
Данным алгоритмом (иногда достаточно простой версии, иногда нужна вторая) можно восста-
новить ответ без ухудшения времени работы для огромного класса задач. Например
1. Рюкзак со стоимостями
2. Расстояние Левенштейна
3. «Задача о погрузке кораблей», которую мы обсудим на следующей паре
4. «Задача о серверах», которую мы обсудим на следующей паре
Глава #9. 24 ноября. 77/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
Лекция #10: Динамическое программирование (часть 2)
1 декабря
10.1. bitset
bitset – структура для хранения 𝑁 бит.
Обладает полезными свойствами и массива, и целых чисел. Заметим, что с целыми 64-битными
числами мы можем делать логические операции |, &, «, ^ за один такт. То есть, если рас-
сматривать число, как массив из 64 бит, параллельно за один процессорный такт применять
𝑁
операцию OR к нескольким ячейкам массива. bitset<N> хранит массив из ⌊ 64 ⌋ целых чисел.
Число 64 – константа, описывающая свойство современных процессоров. В дальнейшем бу-
дем все алгоритмы оценивать для абстрактной w-RAM машины, машины, на которой за 1 такт
производятся базовые арифметические операции с 𝑤-битовыми регистрами.
𝑤 – сокращение от word size
1 bitset <N > a , b ; // N – константа; в C++, к сожалению, размер bitset-а должен быть константой
2 x = a [ i ] , a [ j ] = y ; // 𝒪(1) – операции с массивом
3 a = b | ( a << 100) , a &= b ; // 𝒪( 𝑁 𝑤 ) – битовые операции
10.1.1. Рюкзак
Применим новую идею к задаче о рюкзаке:
1 bitset < S +1 > is ;
2 is [0] = 1; // изначально умеет получать пустым множеством суммарный вес 0
3 for ( int i = 0; i < n ; i ++)
4 is |= is << a [ i ]; // если is[w] было 1, теперь is[w + a[i]] тоже 1
10.2. НОП → НВП
На прошлой паре уже решили НОП за квадрат. В общем случае люди не умеют за 𝒪(𝑛2−𝜀 ), в
некоторых случаях можно бысрее. Пусть мы ищем НОП 𝑎 и 𝑏, и все элементы 𝑏 различны.
1. Хеш-таблицей сделаем предподсчёт «где такой элемент в 𝑏»: position[𝑏𝑖 ] = i.
2. Сгенерим массив 𝑝𝑖 = position[𝑎𝑖 ].
3. Утверждение: НОП(𝑎,𝑏) = НВП(𝑝). Более того — есть биекция между возрастающими под-
последовательностями 𝑝 и общими подпоследовательностями 𝑎 и 𝑏.
10.3. НВП за 𝒪(𝑛 log 𝑛)
Пусть дана последовательность 𝑎1 , 𝑎2 , . . . , 𝑎𝑛 .
С прошлой пары уже умеем искать за 𝒪(𝑛2 ) времени. Улучшим.
Код 10.3.1. Алгоритм поиска НВП за 𝒪(𝑛 log 𝑛)
1 x [] <-- inf
2 x [0] = -inf , answer = 0;
3 for ( int i = 0; i < n ; i ++) :
4 int j = lower_bound (x , x + n , a [ i ]) - x ;
5 x [ j ] = a [ i ] , answer = max ( answer , j ) ;
Глава #10. 1 декабря. 78/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
Попробуем понять не только сам код, но и как его можно придумать, собрать из стандартных
низкоуровневых идей оптимизации динамики.
Для начало рассмотрим процесс: идём слева направо, некоторые число берём в ответ (НВП),
не которые не берём. Состояние этого процесса можно описать (𝑘, 𝑙𝑒𝑛, 𝑖) – мы рассмотрели
первые 𝑘 чисел, из них 𝑙𝑒𝑛 взяли в ответ, последнее взятое равно 𝑖. У нас есть два перехода
(𝑘, 𝑙𝑒𝑛, 𝑖) → (𝑘+1, 𝑙𝑒𝑛, 𝑖), и, если 𝑎𝑘 > 𝑎𝑖 , можно сделать переход (𝑘, 𝑙𝑒𝑛, 𝑖) → (𝑘+1, 𝑙𝑒𝑛+1, 𝑘)
Обычная идея преобразования «процесс → решение динамикой» – максимизировать 𝑙𝑒𝑛[𝑘, 𝑖].
Но можно сделать 𝑖[𝑘, 𝑙𝑒𝑛] и минимизировать конец выбранной последовательности 𝑥[𝑖]
(𝑥[𝑘, 𝑙𝑒𝑛]). Пойдём вторым путём, поймём, как вычислять 𝑥[𝑘, 𝑙𝑒𝑛] быстрее чем 𝒪(𝑛2 ).
Lm 10.3.2. ∀𝑘, 𝑙𝑒𝑛 𝑥[𝑘, 𝑙𝑒𝑛] ⩽ 𝑥[𝑘, 𝑙𝑒𝑛 + 1]
Доказательство. Если была последовательность длины 𝑙𝑒𝑛+1, выкинем из неё любой элемент,
получим длину 𝑙𝑒𝑛. ■
Обозначим 𝑥𝑘 [𝑙𝑒𝑛] = 𝑥[𝑘, 𝑙𝑒𝑛] – т.е. 𝑥𝑘 – одномерный массив, строка массива 𝑥.
Lm 10.3.3. В реализации 10.3.1 строка 5 преобразует 𝑥𝑖 в 𝑥𝑖+1
Доказательство. При переходе от 𝑥𝑖 к 𝑥𝑖+1 , мы пытаемся дописать элемент 𝑎𝑖 ко всем суще-
ствующим возрастающим подпоследовательностям, из 10.3.2 получаем, что приписать можно
только к 𝑥[0..𝑗), где 𝑗 посчитано в строке 4. Заметим, что ∀𝑘 < 𝑗 − 1 к 𝑥[𝑘] дописывать беспо-
лезно, так как 𝑥[𝑘 + 1] < 𝑎[𝑖]. Допишем к 𝑥[𝑗 − 1], получим, что 𝑥[𝑗] уменьшилось до 𝑎𝑖 . ■
Восстановление ответа: кроме значения 𝑥[𝑗] будем также помнить его позицию 𝑥𝑝[𝑗]:
1 x [ j ] = a [ i ] , xp [ j ] = i ; // a[i] – значения, i – позиция
2 prev [ i ] = xp [j -1];
Насчитали таким образом ссылки prev на предыдущий элемент последовательности.
10.4. Задача про погрузку кораблей
Дан склад грузов, массив 𝑎1 , 𝑎2 ,∑︀
. . . , 𝑎𝑛 . Есть бесконечное множество кораблей размера 𝑆. При
погрузке должно выполняться 𝑎𝑖 ⩽ 𝑆. Грузим корабли по одному, погруженный корабль
сразу уплывает. Погрузчик может брать только самый левый/правый груз из массива. Задача:
минимизировать число кораблей, использованное для перевозки грузов.
Представим себе процесс погрузки: 𝑘 кораблей уже погружено полностью, на складе остал-
ся отрезок грузов [𝐿, 𝑅]. Состояние такого процесса описывается (𝑘, 𝐿, 𝑅). В будущем будем
использовать круглые скобки для описания состояния процесса и квадратные для состояния
динамики. Такое видение процесса даёт нам динамику 𝑘[𝐿, 𝑅] → min. Переходы:
(𝑘, 𝐿, 𝑅) → (𝑘+1, 𝐿′ , 𝑅′ ), при этом 𝑠𝑢𝑚[𝐿..𝐿′ ) + 𝑠𝑢𝑚(𝑅′ ..𝑅] ⩽ 𝑆
Состояний 𝑛2 , время работы 𝒪(𝑛4 ).
10.4.1. Измельчение перехода
Идея оптимизации динамики в таких случаях – «измельчить переход». Сейчас переход – «по-
грузить корабль с нуля целиком», новый переход будет «погрузить один предмет на текущий
Глава #10. 1 декабря. 79/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
корабль или закончить его погрузку». Итак, пусть 𝑤 – суммарный размер грузов в текущем
корабле. Переходы:
⎧
⎨(𝑘, 𝑤, 𝐿, 𝑅) → (𝑘, 𝑤 + 𝑎𝐿 , 𝐿 + 1, 𝑅) погрузить самый левый, если 𝑤 + 𝑎𝐿 ⩽ 𝑆
⎪
(𝑘, 𝑤, 𝐿, 𝑅) → (𝑘, 𝑤 + 𝑎𝑅 , 𝐿, 𝑅 − 1) погрузить самый правый, если 𝑤 + 𝑎𝑅 ⩽ 𝑆
⎪
(𝑘, 𝑤, 𝐿, 𝑅) → (𝑘 + 1, 0, 𝐿, 𝑅) начать погрузку следующего корабля
⎩
Что выбрать за состояние динамики, что за функцию?
0 ⩽ 𝑘, 𝐿, 𝑅 ⩽ 𝑛, 0 ⩽ 𝑤 ⩽ 𝑆, выбираем 𝑤[𝑘, 𝐿, 𝑅] → 𝑚𝑖𝑛, поскольку 𝑤 не ограничена через 𝑛.
Минимизируем, так как при прочих равных выгодно иметь максимально пустой корабль.
Получили больше состояний, но всего 3 перехода: 𝑛3 состояний, время работы 𝒪(𝑛3 ).
10.4.2. Использование пары, как функции динамики
Можно сделать ещё лучше: ⟨𝑘, 𝑤⟩[𝐿, 𝑅].
Минимизировать сперва 𝑘, при равенстве 𝑘 минимизировать 𝑤.
Теперь мы сохраняем не все состояния процесса, а из пар ⟨𝑘1 , 𝑤1 ⟩ ⩽ ⟨𝑘2 , 𝑤2 ⟩ только ⟨𝑘1 , 𝑤1 ⟩.
Действительно, если 𝑘1 = 𝑘2 , то выгодно оставить пару с меньшим 𝑤, а если 𝑘1 < 𝑘2 , то можно
отправить корабль ⟨𝑘1 , 𝑤1 ⟩ → ⟨𝑘1 + 1, 0⟩ ⩽ ⟨𝑘2 , 𝑤2 ⟩ (𝑘1 + 1 ⩽ 𝑘2 , 0 ⩽ 𝑤2 ). К сожалению, переход
(𝑘, 𝑤, 𝐿, 𝑅) → (𝑘 + 1, 0, 𝐿, 𝑅) теперь является петлёй.
Динамике же на вход нужен граф без циклов. Поэтому делаем так:
⎧
⎨(𝑘, 𝑤 + 𝑎𝐿 , 𝐿 + 1, 𝑅)
⎪ если 𝑤 + 𝑎𝐿 ⩽ 𝑆
(𝑘, 𝑤, 𝐿, 𝑅) → (𝑘 + 1, 𝑎𝐿 , 𝐿 + 1, 𝑅) если 𝑤 + 𝑎𝐿 > 𝑆
⎪
аналогичные переходы для 𝑅 → 𝑅 − 1
⎩
Итог: 𝒪(𝑛2 ) времени, 𝒪(𝑛2 ) памяти.
Заметим, что без восстановления ответа можно хранить только две строки, 𝒪(𝑛) памяти, а при
восстановлении ответа применим алгоритм Хиршберга, что даёт также 𝒪(𝑛) памяти.
10.5. Рекуррентные соотношения
Def 10.5.1. Линейное рекуррентное соотношение:
даны 𝑓0 , 𝑓1 , . . . , 𝑓𝑘−1 , ∀𝑛 𝑓𝑛 = 𝑓𝑛−1 𝑎1 + 𝑓𝑛−2 𝑎2 + . . . 𝑓𝑛−𝑘 𝑎𝑘 + 𝑏
Задача: даны 𝑓0 , . . . , 𝑓𝑘−1 ; 𝑎1 , . . . , 𝑎𝑘 ; 𝑏, найти 𝑓𝑛 .
Очевидно решение динамикой за 𝒪(𝑛𝑘).
Научимся решать быстрее сперва для простейшего случая — числа Фибоначчи.
(︂ )︂ (︂ )︂ (︂ )︂ (︂ )︂𝑛−1 (︂ )︂
𝐹𝑛 1 1 𝐹𝑛−1 1 1 𝐹1
= · = ·
𝐹𝑛−1 1 0 𝐹𝑛−2 1 0 𝐹0
Напомним, как работает умножение матриц: строка левой умножается скалярно на столбец
правой. То есть, первое равенство читается как (𝐹𝑛 = 1·𝐹𝑛−1 +1·𝐹𝑛−2 )∧(𝐹𝑛−1 = 1·𝐹𝑛−1 +0·𝐹𝑛−2 )
Lm 10.5.2. Возведение в степень можно сделать за 𝒪(log 𝑛)
Доказательство. Пусть определена ассоциативная операция 𝑎 ∘ 𝑏, 𝑎𝑛 = 𝑎
⏟ ∘ 𝑎 ∘ ⏞· · · ∘ 𝑎 тогда:
𝑛
𝑎2𝑘 = (𝑎𝑘 )2 ∧ 𝑎2𝑘+1 = (𝑎𝑘 )2 ∘ 𝑎 ∧ 𝑎1 = 𝑎
Глава #10. 1 декабря. 80/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
Итог: рекурсивная процедура возведения в степень 𝑛, делающую не более 2 log 𝑛 операций ∘. ■
Следствие 10.5.3.
𝐹𝑛 можно посчитать за 𝒪(log 𝑛) арифметических операций с числами порядка 𝐹𝑛 .
𝐹𝑛 можно посчитать по модулю 𝑃 за 𝒪(log 𝑛) арифметических операций с числами порядка 𝑃 .
Вернёмся к общему случаю, нужно увидеть возведение матрицы в степень.
Теорема 10.5.4. Существует решение за 𝒪(𝑘 3 log 𝑛)
Доказательство.
⎛ ⎞ ⎛ ⎞ ⎛ ⎞
𝑓𝑛+𝑘+1 𝑎1 𝑎2 . . . 𝑎𝑘−1 𝑎𝑘 𝑏 𝑓𝑛+𝑘
⎜ 𝑓𝑛+𝑘 ⎟ ⎜ 1 0 ... 0 0 0⎟ ⎟ ⎜𝑓𝑛+𝑘−1 ⎟
⎜ ⎟
⎜ ⎟ ⎜
⎜𝑓𝑛+𝑘−1 ⎟ ⎜ 0 1 ... 0 0 0 ⎟ ⎜𝑓𝑛+𝑘−2 ⎟
⎟ ⎜
⎜ · · · ⎟ = ⎜· · ·
⎜ ⎟ ⎜ ·⎜ ⎟=𝐴·𝑉
⎜ ⎟ ⎜ ··· ··· ··· ··· ⎟ ⎜ ··· ⎟
· · ·⎟ ⎟
⎝ 𝑓𝑛+1 ⎠ ⎝ 0 0 ... 1 0 0 ⎠ ⎝ 𝑓𝑛 ⎠
1 0 0 ... 0 0 1 1
В общем случае иногда выгодно 𝒪(𝑛𝑘), иногда 𝒪(𝑘 3 log 𝑛). ∃ решение за 𝒪(𝑘 log 𝑘 log 𝑛) ■
10.5.1. Пути в графе
Def 10.5.5. Матрица смежности 𝐶 графа 𝐺: 𝐶𝑖𝑗 – есть ли ребро между (𝑖, 𝑗) в 𝐺.
Задача: найти количество путей из 𝑠 в 𝑡 длины ровно 𝑘.
2
Решение динамикой ∑︀за 𝒪(𝑘𝑛 ): 𝑓 [𝑘, 𝑣] – количество путей𝑘 из 𝑠 в 𝑣 длины ровно 𝑘.
Пересчёт 𝑓 [𝑘, 𝑣] = 𝑖 𝑓 [𝑘 − 1, 𝑖] · 𝐶𝑖,𝑣 ⇒ 𝑓𝑘 = 𝐶 · 𝑓𝑘−1 = 𝐶 𝑓0 .
Подсчёт 𝑓 [𝑘, 𝑡] в лоб будет работать за 𝒪(𝑘𝑛2 ), с быстрого помощью возведения матрицы в
степень за 𝒪(𝑛3 log 𝑘), так как одно умножение матриц работает за 𝑛3 .
10.6. Задача о почтовых отделениях
На прямой расположены города в координатах 𝑥1 , 𝑥2 , . . . , 𝑥𝑛 , население городов 𝑤1 , 𝑤2 , . . . , 𝑤𝑛
(𝑤𝑖 > 0). Нужно в каких-то 𝑘 из этих городов 𝑖1 , 𝑖2 , . . . 𝑖𝑘 открыть почтовые отделения, мини-
мизируя при этом суммарное расстояние по всем людям до ближайшего почтового отделения:
∑︁
𝑤𝑗 min |𝑥𝑗 − 𝑥𝑖𝑡 | −→ min
𝑡
𝑗
Упростим себе жизнь сортировками. Пусть 𝑥1 < 𝑥2 < · · · < 𝑥𝑛 , 𝑖1 < 𝑖2 < · · · < 𝑖𝑘 .
Города разобьются на 𝑘 отрезков — к какому почтовому отделению относится город.
Кстати, границы отрезков будут проходить в 12 (𝑥𝑖𝑡 + 𝑥𝑖𝑡+1 ).
Оптимальный центр для отрезка [𝑙, 𝑟) будем обозначать ∑︀ 𝑚[𝑙, 𝑟].
Суммарную стоимость отрезка обозначим 𝑐𝑜𝑠𝑡(𝑙,𝑟) = 𝑖∈[𝑙,𝑟] 𝑤𝑖 |𝑥𝑖 − 𝑥𝑚[𝑙,𝑟] |.
∑︀
Задача в выборе границ отрезков 𝑝1 =1, 𝑝2 , 𝑝3 , . . . , 𝑝𝑘+1 =𝑛+1 : 𝑖=1..𝑘 𝑐𝑜𝑠𝑡(𝑝𝑖 , 𝑝𝑖+1 −1) → min.
Задача решается динамикой:
𝑓 [𝑘, 𝑛] — стоимость разбиения первых 𝑛 городов на 𝑘 отрезков.
𝑝[𝑘, 𝑛] — оптимальная граница 𝑘-го отрезка, в него входят точки (𝑝𝑘,𝑛 , 𝑛].
Тогда 𝑎𝑛𝑠 = 𝑓 [𝐾,𝑁 ], 𝑓 [0, 0] = 0, 𝑓 [𝑘, 𝑛] = 𝑓 [𝑘−1, 𝑝𝑘,𝑛 ] + 𝑐𝑜𝑠𝑡(𝑝𝑘,𝑛 +1, 𝑛) .
Время работы 𝒪(𝑘𝑛2 ) + 𝑛2 · calcCost, где 𝑐𝑜𝑠𝑡(𝑙, 𝑟) в лоб считается за 𝒪(𝑛2 ). Можно быстрее.
Lm 10.6.1. Зная 𝑚[𝑙,𝑟], можно посчитать 𝑐𝑜𝑠𝑡(𝑙, 𝑟) за 𝒪(1)
Глава #10. 1 декабря. 81/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
Доказательство. Обозначим 𝑚 = 𝑚[𝑙, 𝑟].
∑︁ ∑︁ ∑︁ ∑︁ ∑︁ ∑︁ ∑︁
𝑤𝑖 |𝑥𝑖 −𝑥𝑚 | = 𝑤𝑖 (𝑥𝑚 −𝑥𝑖 )+ 𝑤𝑖 (𝑥𝑖 −𝑥𝑚 ) = 𝑥𝑚 ( 𝑤𝑖 − 𝑤𝑖 )− 𝑥𝑖 𝑤 𝑖 + 𝑥𝑖 𝑤 𝑖
𝑖=𝑙..𝑟 𝑖=𝑙..𝑚 𝑖=𝑚..𝑟 𝑖=𝑙..𝑚 𝑖=𝑚..𝑟 𝑖=𝑙..𝑚 𝑖=𝑚..𝑟
Получили четыре суммы на отрезках, каждая считается через префиксные суммы. ■
∑︀ ∑︀
Lm 10.6.2. 𝑚[𝑙, 𝑟] = min 𝑖 : 𝑗∈[𝑙,𝑖] 𝑤𝑗 ⩾ 𝑗∈(𝑖,𝑟] 𝑤𝑗
Доказательство. Задача с практики. ■
Lm 10.6.3. ∀𝑙 все 𝑚[𝑙,𝑟] вычисляется за 𝒪(𝑛) двумя указателями: 𝑟↑ ⇒ 𝑚[𝑙,𝑟]↗.
1 int i = l ;
2 for ( int r = l ; r <= n ; r ++) :
3 while ( sum (l , i ) < sum ( i +1 , r ) ) // сумма на отрезке за 𝒪(1)
4 i ++;
5 m [l , r ] = i ;
Теорема 10.6.4. Задача про почтовые отделения решена динамикой за 𝒪(𝑛2 𝑘)
Доказательство. Сперва за 𝒪(𝑛2 ) предподсчитали все 𝑚[𝑙, 𝑟], затем за 𝒪(𝑛2 𝑘) 𝑓 [𝑘, 𝑛] ■
Замечание 10.6.5. Далее мы научимся считать динамику 𝑓 [𝑘, 𝑛] быстрее.
Если мы получаем время 𝑜(𝑛2 ), то придётся улучшать уже подсчёт 𝑚[𝑙, 𝑟]: для пары ⟨𝑙, 𝑟⟩ оп-
тимальное 𝑚[𝑙, 𝑟] находится за 𝒪(log 𝑛) бинпоиском.
10.6.1. Оптимизация Кнута
Предположим, что 𝑝𝑘−1,𝑛 ⩽ 𝑝𝑘,𝑛 ⩽ 𝑝𝑘,𝑛+1 ,
тогда в решении можно перебирать 𝑝𝑘,𝑛 не от 0 до 𝑛−1, а между 𝑝𝑘−1,𝑛 и 𝑝𝑘,𝑛+1 . Итого:
1 // обнулили f[] и p[]
2 for ( int k = 2; k <= K ; k ++) // порядок перебора состояний следует из неравенств
3 for ( int n = N ; n >= k ; n - -) : // порядок перебора состояний следует из неравенств
4 f [k , n ] = ∞;
5 for ( int i = p [k -1 , n ]; i <= p [k , n +1]; i ++) : // уже посчитаны
6 int tmp = f [k -1 , i ] + cost ( i +1 , n ) ;
7 if ( tmp < f [k , n ])
8 f [k , n ] = tmp , p [k , n ] = i ;
Теорема 10.6.6. Описанное решение работает 𝒪(𝑛2 )
∑︀
Доказательство. 𝑇 𝑖𝑚𝑒 = 𝑛,𝑘 (𝑝𝑘,𝑛+1 − 𝑝𝑘−1,𝑛 ). Заметим, что все кроме 𝑛 + 𝑘 слагаемые при-
сутствуют в сумме и с +, и с -, значит, сократятся ⇒ 𝑇 𝑖𝑚𝑒 ⩽ (𝑛 + 𝑘)𝑛 = 𝒪(𝑛2 ). ■
10.6.2. (*) Доказательства неравенств
Теперь докажем корректность 𝑝𝑘−1,𝑛 ⩽ 𝑝𝑘,𝑛 ⩽ 𝑝𝑘,𝑛+1 . Заметим сразу, что не для всех возмож-
ных оптимальных ответов это неравенство верно, но если уже фиксированы любые 𝑝𝑘−1,𝑛 и
𝑝𝑘,𝑛+1 , дающие оптимальный ответ, то ∃𝑝𝑘,𝑛 , удовлетворяющее обоим неравенствам и дающее
оптимальный ответ 𝑓𝑘,𝑛 .
Lm 10.6.7. ∀𝑘,𝑛 ∀оптимальных [𝑘, 𝑛] и [𝑘, 𝑛+1] разбиений верно, что 𝑝𝑘,𝑛+1 ⩾ 𝑝𝑘,𝑛
Глава #10. 1 декабря. 82/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
Доказательство. Рассмотрим оптимальные решения задач [𝑘, 𝑛] и [𝑘, 𝑛+1].
Центр последнего отрезка, 𝑚[𝑝𝑘,𝑛 , 𝑛], обозначим 𝑞𝑘,𝑛 .
Центр последнего отрезка, 𝑚[𝑝𝑘,𝑛+1 , 𝑛], обозначим 𝑞𝑘,𝑛+1 .
Доказывать будем от противного, то есть, 𝑝𝑘,𝑛+1 < 𝑝𝑘,𝑛 .
Рассмотрим четыре разбиения:
𝑝𝑘,𝑛 𝑛
1. Оптимальное для [𝑘, 𝑛]
𝑝𝑘,𝑛 𝑛+1
2. Копия 1, в последний отрезок добавили точку 𝑛+1
𝑝𝑘,𝑛+1 𝑛+1
3. Оптимальное для [𝑘, 𝑛+1]
𝑝𝑘,𝑛+1 𝑛
4. Копия 3, из последнего отрезка убрали точку 𝑛+1
Функции 𝑓 от разбиений обозначим 𝑓1 , 𝑓2 , 𝑓3 , 𝑓4 . В последнем отрезке каждого из разбиений
нужно выбрать центр. В 1-м это 𝑞𝑘,𝑛 , в 3-м 𝑞𝑘,𝑛+1 , а в 2 и 4 можем взять любой.
Чтобы прийти к противоречию достаточно получить 𝑓4 < 𝑓1 (противоречит с тем, что 𝑓1 опти-
мально). От противного мы уже знаем, что 𝑓3 < 𝑓2 .
∙ Случай #1: 𝑞𝑘,𝑛 ⩾ 𝑞𝑘,𝑛+1 . Пусть 2-е разбиение имеет центр 𝑞𝑘,𝑛 , а 4-e центр 𝑞𝑘,𝑛+1 .
При 𝑓1 → 𝑓2 функция увеличилась на 𝑤𝑛+1 (𝑥𝑛+1 − 𝑥𝑞𝑘,𝑛 ).
При 𝑓3 → 𝑓4 функция уменьшилась на 𝑤𝑛+1 (𝑥𝑛+1 − 𝑥𝑞𝑘,𝑛+1 ).
𝑞𝑘,𝑛 ⩾ 𝑞𝑘,𝑛+1 ⇒ уменьшилась хотя бы на столько же, на сколько увеличилась 𝑓3 < 𝑓2 ⇒ 𝑓4 < 𝑓1 .
∙ Случай #2: 𝑞𝑘,𝑛 < 𝑞𝑘,𝑛+1 . Пусть 2-е разбиение имеет центр 𝑞𝑘,𝑛+1 , а 4-e центр 𝑞𝑘,𝑛 .
Напомним что 𝑝𝑘,𝑛+1 < 𝑝𝑘,𝑛 ⩽ 𝑞𝑘,𝑛 < 𝑞𝑘,𝑛+1 .
Обозначим 𝑓23 = 𝑓2 − 𝑓3 , 𝑓14 = 𝑓1 − 𝑓4 . Докажем 0 < 𝑓23 ⩽ 𝑓14 . Первое уже есть, нужно второе.
Доказываем 𝑓14 − 𝑓23 ⩾ 0. При вычитании сократится всё кроме последнего отрезка.
В последнем отрезке сократится всё кроме расстояний для точек (𝑝𝑘,𝑛+1 , 𝑝𝑘,𝑛 ].
Множество точек в 𝑓14 и 𝑓23 одно и то же, а расстояния считаются до 𝑞𝑘,𝑛 и 𝑞𝑘,𝑛+1 соответственно.
Заметим, что расстояние до 𝑞𝑘,𝑛+1 больше и берётся со знаком минус ⇒
𝑓14 − 𝑓23 ⩾ 0 ⇒ 𝑓14 > 0 ⇒ 𝑓1 > 𝑓4 ⇒ 𝑓1 не оптимален. Противоречие. ■
Lm 10.6.8. Для задач, где 𝑝𝑘,𝑛 ⩽ 𝑝𝑘,𝑛+1 верно и 𝑝𝑘−1,𝑛 ⩽ 𝑝𝑘,𝑛 .
Доказательство. От противного. Посмотрим на цепочки для восстановления ответа
𝑛 → 𝑝𝑘,𝑛 −1 → . . . и 𝑛 → 𝑝𝑘−1,𝑛 −1 → . . . Если в 𝑝𝑘,𝑛 < 𝑝𝑘−1,𝑛 , то вторая цепочка изначально
обгоняет первую, первая в итоге должна догнать и перегнать (в первой больше отрезков, а
конец у них общий. ■
10.6.3. Оптимизация методом «разделяй и властвуй»
Divide et impera! — принцип государственной власти,
согласно которому, лучший метод управления разнородным
государством — разжигание и использование вражды
между его частями. Одним из первых применил на
практике политику Гай Юлий Цезарь.
Пусть мы уже насчитали строку динамики 𝑓 [𝑘−1], то есть, знаем 𝑓 [𝑘−1, 𝑥] для всех 𝑥.
Глава #10. 1 декабря. 83/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 2)
Найдём строку 𝑝𝑘 методом разделяй и властвуй, через неё за 𝒪(𝑛) посчитаем 𝑓 [𝑘].
Уже доказали 10.6.7 ∀𝑛,𝑘 𝑝𝑘,𝑛 ⩽ 𝑝𝑘,𝑛+1 .
Напишем функцию, которая считает 𝑝𝑘,𝑛 для всех 𝑛 ∈ [𝑙, 𝑟], зная, что 𝐿 ⩽ 𝑝𝑘,𝑛 ⩽ 𝑅.
1 void go ( int l , int r , int L , int R ) :
2 if ( l > r ) return ; // пустой отрезок
3 int m = ( l + r ) / 2;
4 Найдём p [k , m ] в лоб за 𝒪(𝑅 − 𝐿 + 1)
5 go (l , m - 1 , L , p [k , m ]) ;
6 go ( m + 1 , r , p [k , m ] , R ) ;
7 go (1 , n , 1 , n ) ; // посчитать всю строку 𝑝[𝑘] для 𝑥 = 1..𝑛
Теорема 10.6.9. Время работы пересчёта 𝑓 [𝑘−1] → 𝑝𝑘 всего лишь 𝒪(𝑛 log 𝑛)
Доказательство. Глубина рекурсии не более log 𝑛 (длина отрезка уменьшается в 2 раза).
На каждом уровне рекурсии выполняются подзадачи
∑︀ (𝐿1 , 𝑅1 ), (𝐿2 , 𝑅2 ), . . . (𝐿𝑘 , 𝑅𝑘 ).
𝐿1 = 1, 𝑅𝑖 = 𝐿𝑖+1 , 𝑅𝑘 = 𝑛 ⇒ время работы = (𝑅𝑖 − 𝐿𝑖 + 1) = (𝑛 − 1) + 𝑘 ⩽ 2𝑛 = 𝒪(𝑛) ■
10.6.4. Стресс тестирование
Это нужно для проверки инварианта, который позволяет применять разделяй и властвуй.
Как вы видели из доказательства 10.6.7, оно весьма нетривиально.
Обозначим за 𝑃1 вероятность того, что вам попадётся динамика, где можно применить оп-
тимизацию Кнута (разд. 10.6.1) или «Разделяй и Властвуй» (разд. 10.6.3). Обозначим за 𝑃2
вероятность того, что задача попадётся, и вы сможете доказать требуемое ∀𝑛,𝑘 𝑝𝑘,𝑛 ⩽ 𝑝𝑘,𝑛+1 .
Пусть 𝑃1 > 0.
𝑃2
𝑃1
≈0
Но зачем доказывать, если можно написать версии с/без оптимизации и пострессить?
Есть несколько способов.
#1. Взять решение без Кнута, с Кнутом и на случайных тестах сравнить ответ.
#2. На случайных тестах сравнить массивы f[n,k] целиком.
#3. Поставить в решении без Кнута внутри assert(∀𝑛,𝑘 𝑝𝑘,𝑛 ⩽ 𝑝𝑘,𝑛+1 ).
Второе более сильное свидетельство. Гораздо более сильное. (2) всегда лучше (1).
С третьим нужно быть аккуратным. Во-первых, аккуратным на крайних значениях 𝑛, 𝑘. Во-
вторых, это сработает только если ∀ корректного ответа для p[n,k] выполняются такие нера-
венства (в смысле, есть случаи, когда assert падает, а решение всё равно работает).
Доказательство с точки зрения программирования: пострессить на правильном наборе тестов.
Тут важно понимать, что крайние случаи в основном проявляются на маленьких тестах.
Например, в задаче про почтовые отделения это 2 ⩽ 𝑘 ⩽ 𝑛 ⩽ 8 и веса, координаты в [1, 8].
На 500 ⩽ 𝑘 ⩽ 1000 таких тестов рандомом не найти.
Глава #11. 1 декабря. 84/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 3)
Лекция #11: Динамическое программирование (часть 3)
3 декабря
11.1. Динамика по подотрезкам
Рассмотрим динамику по подотрезкам (практика, задача 3). Например, задачу о произведении
матриц, которую, кстати, в 1981-м Hu & Shing решили за 𝒪(𝑛 log 𝑛).
Решение динамикой: насчитать 𝑓𝑙,𝑟 , стоимость произведения отрезка матриц [𝑙, 𝑟]
𝑓𝑙,𝑟 = min (𝑓𝑙,𝑚 + 𝑓𝑚+1,𝑟 + 𝑎𝑙 𝑎𝑚+1 𝑎𝑟 )
𝑚∈[𝑙,𝑟)
Порядок перебора состояний: 𝑟 ↑ 𝑙 ↓
Можно рассмотреть ацикличный граф на вершинах [𝑙, 𝑟] и рёбрах [𝑙, 𝑚], [𝑚+1, 𝑟] → [𝑙, 𝑟], соот-
ветствующий нашему решению. Задача, которую мы решаем, уже не выражается в терминах
«поиск пути» или «количество путей» в графе. Верным остаётся лишь то, что ответ в вершине
[𝑙, 𝑟] можно выразить через ответы для подзадач, то есть, для соседей вершины [𝑙, 𝑟].
Занимательный факт: все задачи динамического программирования кроме «динамики по подо-
трезкам», которые мы решали ранее или решим сегодня, таки выражаются, как «поиск пути»
или «количество путей».
11.2. Комбинаторика
Дан комбинаторный объект, например, перестановка. Научимся по объекту получать его номер
в лексикографическом порядке и наоборот по номеру объекту восстанавливать объект.
Например, есть 6 перестановок из трёх элементов, лексикографически их можно упорядочить,
как вектора: (1, 2, 3), (1, 3, 2), (2, 1, 3), (2, 3, 1), (3, 1, 2), (3, 2, 1).
Занумеруем с нуля, тогда номер перестановки (3, 1, 2) – 4.
Цель – научиться быстро переходить между номером объекта и самим объектом.
Зачем это может быть нужно?
(a) Закодировать перестановку минимальным числом бит, сохранить на диск
(b) Использовать номер, как индекс массива, если состоянием динамики является переста-
новка
∙ Объект → номер (перестановки)
На примере перестановок изучим общий алгоритм.
Нужно посчитать количество перестановок лексикографически меньших 𝑝.
{𝑎1 , 𝑎2 , . . . , 𝑎𝑛 } < {𝑝1 , 𝑝2 , . . . , 𝑝𝑛 } ⇔ ∃𝑘 : 𝑎1 = 𝑝1 , 𝑎2 = 𝑝2 , . . . 𝑎𝑘−1 = 𝑝𝑘−1 , 𝑎𝑘 < 𝑝𝑘 . Переберём 𝑘 и
𝑎𝑘 , после этого к ответу нужно прибавить количество способов закончить префикс 𝑎1 , . . . , 𝑎𝑘 ,
для перестановок это (𝑛 − 𝑘)!. Здесь 𝑎𝑘 может быть любым числом, не равным 𝑎1 , . . . 𝑎𝑘−1 .
1 vector < bool > was ( n +1) ;
2 int result = 0;
3 for ( int k = 1; k <= n ; k ++) {
4 was [ p [ k ]] = true ;
5 for ( int j = 1; j < p [ k ]; j ++)
6 if (! was [ j ]) // выбираем 𝑎1 = 𝑝1 , . . . , 𝑎𝑘−1 = 𝑝𝑘−1 , 𝑎𝑘 = 𝑗
7 result += factorial [n - k ];
8 }
Глава #11. 3 декабря. 85/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 3)
Время работы 𝒪(𝑛2 ). Для перестановок существует алгоритм за 𝒪(𝑛 log 𝑛),
наша цель – лишь продемонстрировать общую схему.
∙ Объект → номер (правильные скобочные последовательности)
Пусть у нас один тип скобок и ’(’ < ’)’. Ищем количество ПСП, меньших 𝑠, |𝑠| = 𝑛. Переберём
𝑘, если 𝑠𝑘 = ’)’, попытаемся заменить, на ’(’. Сколько способов закончить, зависит только от
𝑘 и текущего баланса 𝑏, разности числа открывающих и закрывающих скобок в 𝑠[1..𝑘) + ’(’.
Предподсчитаем динамику 𝑑𝑝[𝑘, 𝑏] – число способов закончить. База: 𝑑𝑝[𝑛, 0] = 1.
𝑑𝑝[𝑘, 𝑏] = 𝑑𝑝[𝑘 + 1, 𝑏 + 1] + (𝑏 = 0 ? 0 : 𝑑𝑝[𝑘 + 1, 𝑏 − 1])
1 int balance = 0 , result = 0;
2 for ( int k = 0; k < n ; k ++) {
3 if ( s [ k ] == ’) ’)
4 result += dp [k , balance +1]
5 balance += ( s [ k ] == ’( ’ ? 1 : -1) ;
6 }
Время работы алгоритма 𝒪(𝑛), время предподсчёта – 𝒪(𝑛2 ).
Замечание 11.2.1. Математики поговаривают, что dp[n,k] – разность цэшек.
Зная это, можно придумать алгоритм за 𝒪(𝑛) без предподсчёта.
∙ Номер → объект (перестановки)
Чтобы получить по лексикографическом номеру 𝑘 сам объект, нужно строить его слева на-
право. Переберём, что стоит на первом месте в перестановке. Пусть стоит 𝑑, сколько способов
продолжить перестановку? (𝑛 − 1)! способов. Если 𝑘 ⩽ (𝑛 − 1)!, то нужно ставить минимальную
цифру, иначе уменьшить 𝑘 на (𝑛 − 1)! и попробовать поставить следующую...
1 vector < bool > was ( n +1) ;
2 for ( int i = 1; i <= n ; i ++)
3 for ( int d = 1;; d ++) {
4 if ( was [ d ]) // нельзя поставить 𝑑 на 𝑖-ю позицию, т.к. уже используется ранее
5 continue ;
6 if ( k <= factorial [n - i ]) {
7 p [ i ] = d , was [ d ] = 1; // ставим 𝑑 на 𝑖-ю позицию
8 break ;
9 }
10 k -= factorial [n - i ]; // пропускаем (𝑛−𝑖)! перестановок, начинающихся с 𝑑
11 }
Время работы 𝒪(𝑛2 ). Для перестановок есть алгоритм за 𝒪(𝑛 log 𝑛).
∙ Номер → объект (правильные скобочные последовательности)
Действуем по той же схеме: пытаемся на первую позицию поставить сперва ’(’, затем ’)’. Раз-
ница лишь в том, что чтобы найти «число способов дополнить префикс до правильной скобоч-
ной последовательности», нужно пользоваться предподсчитанной динамикой dp[i,balance].
1 int balance = 0;
2 for ( int i = 0; i < n ; i ++)
3 if ( k <= dp [ i +1 , balance +1])
4 s [ i ] = ’( ’ , balance ++;
5 else
6 k -= dp [ i +1 , balance +1] , // пропускаем все последовательности, начинающиеся с ’(’
7 s [ i ] = ’) ’ , balance - -;
Глава #11. 3 декабря. 86/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 3)
11.3. Работа с множествами
Существует биекция между подмножествами 𝑛-элементного множества 𝑋 = {0, 1, 2, . . . 𝑛−1} и
целыми числами от 0 до 2 − 1. 𝑓 : 𝐴 → 𝑥∈𝐴 2𝑥 . Пример {0, 3} → 20 + 23 = 9.
𝑛
∑︀
Таким образом множество можно использовать, как индекс массива.
Lm 11.3.1. 𝐴 ⊆ 𝐵 ⇒ 𝑓 (𝐴) ⩽ 𝑓 (𝐵)
С множествами, закодированными числами, многое можно делать за 𝒪(1):
(1 « n) - 1 Всё 𝑛-элементное множество 𝑋
(A » i) & 1 Проверить наличие 𝑖-го элемента в множестве
A | (1 « i) Добавить 𝑖-й элемент
A & ˜(1 « i) Удалить 𝑖-й элемент
A ˆ (1 « i) Добавить/удалить 𝑖-й элемент, был ⇒ удалить, не был ⇒ добавить
A & B Пересечение
A | B Объединение
X & ˜B Дополнение
A & ˜B Разность
(A & B) = A Проверить, является ли 𝐴 подмножеством 𝐵
11.4. Динамика по подмножествам
Теперь решим несколько простых задач.
∙ Число бит в множестве (размер множества)
1 for ( int A = 1; A < (1 << n ) ; A ++)
2 bit_cnt [ A ] = bit_cnt [ A >> 1] + ( A & 1) ;
Заметим, что аналогичный результат можно было получить простым перебором:
1 void go ( int i , int A , int result ) {
2 if ( i == n ) {
3 bit_cnt [ A ] = result ;
4 return ;
5 }
6 go ( i + 1 , A , result ) ;
7 go ( i + 1 , A | (1 << i ) , result + 1) ;
8 }
9 go (0 , 0 , 0) ;
Здесь 𝑖 – номер элемента, 𝐴 – набранное множество, 𝑟𝑒𝑠𝑢𝑙𝑡 – его размер.
∙ Сумма в множестве.
Пусть 𝑖-й элемент множества имеет вес 𝑤𝑖 , ∑︀
задача: ∀𝐴 найти найти сумму весов 𝑠[𝐴] = 𝑥∈𝐴 𝑤𝑥 .
Рекурсивное решение почти не поменяется:
1 go ( i + 1 , A | (1 << i ) , result + w[i])
В решении динамикой, чтобы насчитать 𝑠[𝐴], достаточно знать любой единичный бит числа 𝐴.
Научимся поддерживать старший бит числа, обозначим его 𝑢𝑝.
Глава #11. 3 декабря. 87/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 3)
1 up = 0;
2 for ( int A = 1; A < (1 << n ) ; A ++) :
3 if ( A == 2𝑢𝑝+1 ) up ++;
4 s [ A ] = s [ A ^ (1 << up ) ] + w [ up ];
11.5. Гамильтоновы путь и цикл
Def 11.5.1. Гамильтонов путь – путь, проходящий по всем вершинам ровно по одному разу.
Будем искать гамильтонов путь динамическим программированием.
Строим путь. Что нам нужно помнить, чтобы продолжить строить путь? Где мы сейчас стоим
и через какие вершины мы уже проходили, чтобы не пройти через них второй раз.
is[A,v] – можно ли построить путь, который проходит ровно по 𝐴, заканчивается в 𝑣.
Пусть 𝑔[𝑎, 𝑏] – есть ли ребро из 𝑏 в 𝑎, 𝑛 – число вершин в графе, тогда:
1 for ( int i = 0; i < n ; i ++)
2 is [1 << i , i ] = 1; // База: 𝐴 = {𝑖}, путь из одной вершины
3 for ( int A = 0; A < (1 << n ) ; A ++)
4 for ( int v = 0; v < n ; v ++)
5 if ( is [A , v ])
6 for ( int x = 0; x < n ; x ++) // Переберём следующую вершину
7 if ( x ̸∈ A && g [x , v ])
8 is [ A | (1 << x ) , x ] = 1;
Время работы 𝒪(2𝑛 𝑛2 ), память 𝒪(2𝑛 𝑛) машинных слов.
∙ Оптимизируем память.
Строка динамики 𝑖𝑠[𝐴] состоит из 𝑛 бит, её можно хранить, как одно машинное слово (int).
Физический смысл тогда будет такой:
𝑒𝑛𝑑𝑠[𝐴] – множество вершин, на которые может заканчиваться путь, проходящий по 𝐴.
∙ Оптимизируем время.
Теперь можно убрать из нашего кода перебор вершины 𝑣,
за 𝒪(1) проверяя, есть ли общий элемент у 𝑔[𝑥] и 𝑒𝑛𝑑𝑠[𝐴]:
1 for ( int i = 0; i < n ; i ++)
2 ends [1 << i ] = 1 << i ;
3 for ( int A = 0; A < (1 << n ) ; A ++)
4 for ( int x = 0; x < n ; x ++) // Переберём следующую вершину
5 if ( x ̸∈ A && g [ x ] ∩ ends [ A ] ̸= ∅)
6 ends [ A | (1 << x ) ] |= 1 << x ;
Время работы 𝒪(2𝑛 𝑛), память 𝒪(2𝑛 ) машинных слов.
Предполагается, что с числами порядка 𝑛 все арифметические операции происходят за 𝒪(1).
∙ Цикл.
База. Начнём с первой вершины. Она, как и все, точно лежит в цикле.
Динамика такая же, насчитали 𝑒𝑛𝑑𝑠[2𝑛 −1]. 𝒪(2𝑛 𝑛).
Проверили, что 𝑔[0] и 𝑒𝑛𝑑𝑠[2𝑛 −1] имеют общий элемент.
Глава #11. 3 декабря. 88/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 3)
11.6. Вершинная покраска
∙ Задача: покрасить вершины графа в минимальное число цветов так, чтобы соседние вер-
шины имели различные цвета.
Сразу заметим, что вершины одного цвета образуют так называемое «независимое множество»:
между вершинами одного цвета попарно нет рёбер.
Предподсчитаем для каждого множества 𝐴, 𝑖𝑠[𝐴] – является ли оно независимым.
Если считать в лоб, 2𝑛 𝑛2 , но можно и за 2𝑛 :
1 A 1 = A ^ (1 << up ) ; // всё кроме одной вершины
2 is [ A ] = is [ A 1 ] && ( g [ up ] ∩ A == ∅)
Теперь решим исходную задачу динамикой:
𝑓 [𝐴] – минимальное число цветов, чтобы покрасить вершины из множества 𝐴.
∙ Решение за 𝒪(4𝑛 )
Переберём 𝐴, 𝐵 : 𝐴 ⊂ 𝐵 ∧ 𝑖𝑠[𝐵 ∖ 𝐴]. 𝐵 называется надмножеством 𝐴.
Динамика вперёд: переход 𝐴 → 𝐵.
1 for A =0..2 𝑛 -1
2 for B =0..2 𝑛 -1
3 if A ⊂ B and is [ B ∖ A ]
4 relax ( f [ B ] , f [ A ] + 1)
Время работы 2𝑛 × 2𝑛 = 4𝑛 .
11.7. Вершинная покраска: решение за 𝒪(3𝑛 )
∙ Перебор надмножеств
Научимся быстрее перебирать все надмножества 𝐴. Можно просто взять все 𝑛 − |𝐴| элементов,
которые не лежат в 𝐴 и перебрать из 2𝑛−|𝐴| подмножеств. Можно проще, не выделяя отдельно
эти 𝑛 − |𝐴| элементов.
1 for ( A = 0; A < 2 𝑛 ; A ++)
2 for ( B = A ; B < 2 𝑛 ; B ++ , B |= A )
3 if is [ B ∖ A ]
4 relax ( f [ B ] , f [ A ] + 1)
Благодаря «B |= A», понятно, что мы перебираем именно надмножества 𝐴. Почему мы пере-
берём все? Мы знаем, что если бы мы выделили те 𝑛 − |𝐴| элементов, то перебирать нужно
было бы все целые число от 0 до 2𝑛−|𝐴| − 1 в порядке возрастания. Следующее число получа-
ется операцией «+1», которая меняет младший 0 на 1, а хвост из единиц на нули. Ровно это
сделает наша операция «+1», разница лишь в том, что биты нашего числа идут вперемешку с
«единицами множества 𝐴». Пример (красным выделены биты 𝐴):
1101011111 Число 𝐵
1101100000 Число 𝐵 + 1
1101101001 Число (𝐵 + 1) | 𝐴
Теорема 11.7.1. Время работы 3𝑛
Доказательство. Когда множества 𝐴 и 𝐵 зафиксированы, каждый элемент находится в одном
из трёх состояний: лежит в 𝐴, лежит в 𝐵 ∖ 𝐴, лежит в дополнении 𝐵. Всего 3𝑛 вариантов. ■
Глава #11. 3 декабря. 89/90 Автор конспекта: Сергей Копелиович
Алгоритмы, 1 курс, осень 2022/23 Динамическое программирование (часть 3)
Доказательство.
∑︀ Другой∑︀способ доказать
∑︀ (︀ )︀ теорему.
Мы считаем 𝐴 2𝑛−|𝐴| = 𝐶 2|𝐶| = 𝑘 𝑛𝑘 2𝑘 = (1 + 2)𝑛 = 3𝑛 ■
∙ Перебор подмножеств
Можно было бы наоборот перебирать 𝐴 ⊂ 𝐵 по данному 𝐵.
1 for ( B = 0; B < 2^ n ; B ++)
2 for ( C = B ; C > 0; C - - , C &= B ) // все непустые подмножества 𝐵
3 if is [ C ]
4 relax ( f [ B ] , f [ B ∖ C ] + 1)
Заметим некое сходство: операцией «C--» я перехожу к предыдущему подмножеству, операцией
«C &= B» я гарантирую, что в каждый момент времени 𝐶 – всё ещё подмножество. Суммарная
время работы также 𝒪(3𝑛 ). Важная тонкость: мы перебираем все подмножества кроме пустого.
Глава #11. 3 декабря. 90/90 Автор конспекта: Сергей Копелиович