О чём лекция
Пять простых алгоритмов, на которых отрабатываются оценки из первой лекции: разложение на простые множители, НОД двумя версиями алгоритма Евклида, линейный и бинарный поиск в массиве и бинарный поиск по ответу — на примере квадратного корня. Главная мысль: одна и та же задача решается за и за , и на больших входах это разница между часами и мгновением.
Как читать
Доказательства в блоках «Не из лекции» дописаны к конспекту: на лекции их не было или они обрывались. Всё остальное — с доски.
Маршрут
- Как оценивать → Асимптотические оценки, Время работы алгоритма
- НОД → Алгоритм Евклида
- Поиск в отсортированном массиве и по ответу → Бинарный поиск
- Дробные числа в поиске → Типы с плавающей точкой
1. Разложение на простые множители
Число делят на наименьший возможный делитель, пока не останется 1. Разбор в столбик — слева число, справа делитель; зачёркнуто то, что не поделилось:
30 | 2 24 | 2
15 | 2 ✗ 12 | 2
15 | 3 6 | 2
5 | 3 ✗ 3 | 3
5 | 4 ✗ 1 |
5 | 5
1 |, .
Алгоритм
Разложить число N на простые множители:
d = 2
while (N ≠ 1)
если N ⋮ d
N = N / d
вывести(d)
иначе
d = d + 1Асимптотика: — линейная, .
: кроме самого N алгоритм хранит только d, сколько бы ни было
N.
Не из лекции: почему выводятся только простые числа
Когда алгоритм делит на
d, у текущего N нет делителей от 2 до : каждый такой делитель уже был проверен, и на него поделили, пока делилось.Пусть выведенное
dсоставное: , . Тогда и , значит — а делителей меньшеdу N не осталось. Противоречие, значитdпростое.Множители выводятся по неубыванию и с кратностью: для 24.
Не из лекции: почему алгоритм остановится и почему
Пока , у N есть делитель не больше самого N — например, само N. Поэтому
dникогда не перерастёт текущее N: на каком-то шаге делитель найдётся, и N уменьшится.Каждая итерация цикла — либо деление, либо
d = d + 1.
- Делений не больше : каждое уменьшает N хотя бы вдвое, потому что .
- Увеличений
dне больше N:dрастёт от 2 и не превышает исходное N.Всего итераций не больше , это . Оценка достигается: для простого N
dпроходит все значения от 2 до N — это увеличений и одно деление в конце, итерация.
2. Наибольший общий делитель
Через разложения:
Определение. , если — максимальное число, для которого и .
Раскладывать на множители долго — это на каждое число. Алгоритм Евклида находит НОД без разложения. Подробнее — Алгоритм Евклида.
3. Алгоритм Евклида: вычитание
Свойства
- ;
- , если ;
- ; .
Доказательство второго свойства
На лекции записано начало:
- по определению : и , — максимальный;
- по определению : и , — максимальный;
- .
Не из лекции: доказательство целиком
Покажем, что у пар и одни и те же общие делители. Тогда совпадают и наибольшие из них, то есть .
(⇒) Пусть и : , . Тогда , то есть . Значит, — общий делитель и .
(⇐) Пусть и : , . Тогда , то есть . Значит, — общий делитель и .
Множества общих делителей равны, их максимумы равны: . ∎
Остальные свойства:
- — в определении и входят симметрично.
- — у единицы единственный делитель 1.
- при — на делится и , и 0 (), а больше делителя у быть не может.
Пример
Каждый раз из большего числа вычитаем меньшее:
Алгоритм
, :
пока a ≠ 0 и b ≠ 0
если a > b
a = a - b
иначе
b = b - a
вывести(a + b)Асимптотика: .
Не из лекции: почему ответ —
Цикл заканчивается, когда одно из чисел стало нулём. Если , то ; если — симметрично. Сумма — просто способ вывести ненулевое число без ещё одного
если.Каждый шаг заменяет пару на пару с тем же НОД — это свойства выше. Значит, в конце выводится НОД исходной пары.
Не из лекции: почему остановится и почему
Пока оба числа положительны, каждый шаг уменьшает хотя бы на 1: вычитаемое не меньше 1. Натуральная сумма не может уменьшаться бесконечно, поэтому шагов не больше — это .
Далее : оценки и отличаются константой 2 и совпадают.
Оценка достигается: вычитанием делает шагов.
4. Алгоритм Евклида: остаток
Тот же пример, раскрашенный на лекции. Первая полоса — пока вычитаем 24:
Вторая — пока вычитаем 4:
Серия вычитаний одного и того же числа — это взятие остатка (Целочисленное деление и остаток).
Вывод: при — работает быстрее.
Не из лекции: доказательство
, где — неполное частное. Применим свойство ровно раз: . ∎
Алгоритм
пока a ≠ 0 и b ≠ 0
если a > b
a = a % b
иначе
b = b % a
вывести(a + b)На том же примере — два шага вместо одиннадцати: .
Асимптотика: — на лекции без доказательства.
Не из лекции: запись оценки
На доске написано — видимо, имелось в виду . Асимптотически это одно и то же: .
Не из лекции: доказательство
Лемма. Если , то .
- Если : остаток меньше делителя, .
- Если : тогда , частное равно 1, и .
Следствие. На каждом шаге алгоритм заменяет большее число остатком — то есть уменьшает его больше чем вдвое. Посмотрим на два шага подряд. На первом меняется большее число, на втором — другое, которое к этому моменту стало большим. За два шага каждое число уменьшилось хотя бы вдвое, значит, и уменьшился хотя бы вдвое.
Максимум — натуральное число. Уменьшаться вдвое от до 0 оно может не больше раз, а шагов — вдвое больше:
5. Поиск key в массиве
Массив — ряд элементов, пронумерованных с нуля:
0 1 2 3 ...
arr = [ 1 | 2 | 17| 31| ... ]
└── arr[3] = 31Задача: нужно проверить, что key есть в массиве.
Алгоритм
flag = false
for i = 0 ... N
если arr[i] = key
flag = true
вывести(i)
break
если flag = false
вывести("не нашёл")Асимптотика: , .
Граница цикла
Индексы массива из N элементов — от 0 до . На досках курса
0 ... Nнад массивом обычно означает полуинтервал — так записаны сортировки в L03. При таком чтении цикл выше за массив не выходит.Но запись непостоянна:
1 ... pв L01 и2 ... Nв решете (L03) правую границу включают. Если0 ... Nвключает N, последняя итерация обращается кarr[N]— за пределы массива, в C++ это Неопределённое поведение. В коде на C++ пишут явно:i < n.
Не из лекции: почему и почему
breakне помогаетЕсли
keyв массиве нет,breakне срабатывает, и цикл проверяет все N элементов. Быстрее нельзя, пока про массив ничего не известно: любой непроверенный элемент мог оказаться искомым. Оценка — худший случай, тот же довод, что сbreakв проверке на простоту в L01.
6. Бинарный поиск
Задача: проверить, что key есть в массиве, но массив отсортирован:
.
Идея
Берём середину M отрезка и сравниваем arr[M] с key:
- ⇒ ищем в левой половине;
- ⇒ ищем в правой половине.
За одно сравнение отбрасываем половину массива.
Алгоритм
arr = [ ... ]
L = 0
R = N - 1
пока (R - L > 1) {
M = (L + R) / 2 // в C++: M = L + (R - L) / 2
если arr[M] > key
R = M
иначе если arr[M] < key
L = M
иначе
L = M
break
}
если arr[L] = key или arr[R] = key
вывести("нашли")
иначе
вывести("не нашли")Цикл идёт, пока между L и R есть хотя бы один элемент (R − L > 1). В конце
остаются два соседних кандидата, их и проверяют.
Не из лекции: почему в C++
При больших индексах сумма
L + Rможет не поместиться вint: например, при максимумеint. Переполнение знакового целого — это Неопределённое поведение.R − Lне переполняется: .Значение от этого не меняется. Для и : .
Не из лекции: доказательство правильности
Инвариант: если
keyесть в массиве, то он лежит среди .
- Вначале , — это весь массив.
arr[M] > key: массив отсортирован, поэтому при , и справа от искомого нет. Он в — послеR = Mинвариант сохранён.arr[M] < key: симметрично, искомое в .arr[M] = key: найден,L = M,break— финальная проверкаarr[L] = keyего увидит.Остановка. При середина лежит строго внутри: . Значит,
R = MилиL = Mстрого уменьшает , и цикл закончится.Ответ. После цикла : в отрезке не больше двух элементов,
LиR. По инварианту, еслиkeyесть, он среди них — проверка обоих даёт верный ответ.Крайний случай: при получится , и проверка
arr[L]обратится к несуществующему элементу. Пустой массив надо проверять до поиска.
Асимптотика
Сколько элементов остаётся после каждого шага:
шаг 0 → N
шаг 1 → N/2
шаг 2 → N/4
шаг 3 → N/8
...
шаг k → N/2^k
итого ? шагов → 1Не из лекции: то же аккуратно
Пусть . Середина делит отрезок на части и , и остаётся одна из них. Значит, новое . Начиная с , после шагов . Цикл заканчивается, когда , — не позже чем через шагов. Каждый шаг — несколько тактов, память — только L, R, M: , .
Сравнение
| N | Оценка | Итераций | Время при итер./с |
|---|---|---|---|
| с | |||
| с |
секунд — почти три часа. Здесь логарифм двоичный: .
7. Бинарный поиск по ответу
Различие:
- обычный — ищем число в массиве;
- по ответу — ищем число, но в функции.
Если функция монотонная на , её значения упорядочены так же, как элементы отсортированного массива: . Значит, тем же делением пополам можно искать , при котором равна нужному значению. Подробнее — Бинарный поиск.
Дробные числа: корень
Задача: найти . По определению — ищем , при котором монотонная функция равна N.
Вариант 1 — до точности eps:
float L = 0, R = N // почему √N ∈ [0; N]
eps = 10^-5
пока (|L*L - N| > eps) {
M = (L + R) / 2
если M*M < N
L = M
иначе
R = M
}Вариант 2 — фиксированное число итераций (на полях: «гарантия количества итераций»):
float L = 0, R = N
for i = 0 ... 1000
M = (L + R) / 2
если M*M < N
L = M
иначе
R = MНе из лекции: почему
Корень неотрицателен. При : , извлекаем корень — . При это неверно: . Для таких N правую границу берут , в общем случае .
Не из лекции: почему это работает
Инвариант: , то есть . Вначале и (при ). Ветка
M*M < Nсохраняет левую половину неравенства, ветка «иначе» — правую. Отрезок каждый раз уменьшается вдвое: после итераций .
Не из лекции: почему вариант 1 может зависнуть — проверено
floatхранит около 7 значащих цифр. Рядом с числом 100 соседние значенияfloatотстоят примерно на , рядом с — на . Когда отрезок сжимается до соседних чисел,Lперестаёт меняться, а так и не попадает в . Точность просто недостижима.Прогон варианта 1 на
float, ограниченный итерациями (GCC 16,-O0):
N Вариант 1 (eps) Вариант 2: полезных итераций из 1000 2 стоп за 19 итераций 24 10 стоп за 22 итерации 26 100 завис 26 завис 34 Вариант 2 останавливается всегда. После 24–34 итераций
LиRуже соседниеfloat, и оставшиеся итерации ничего не меняют. Это та же мысль, что в Типы с плавающей точкой: допуск должен соответствовать масштабу чисел.
Грабли
- Бинарный поиск по неотсортированному массиву даёт неверный ответ без всякой ошибки: отбрасывание половины опирается на порядок.
M = (L + R) / 2в C++ может переполнитьint— пишутL + (R − L) / 2.for i = 0 ... Nна доске обычно значит ; если граница включается, последняя итерация уходит за массив. В C++ — явноеi < n.- Цикл по
epsна дробных числах может не остановиться. Фиксированное число итераций гарантирует остановку. - только при .
- Евклид вычитанием на паре делает шагов. С остатком — один.
После этой лекции должно получаться
- разложить число на простые множители и объяснить, почему алгоритм выводит только простые;
- доказать и ;
- объяснить, почему Евклид с остатком работает за ;
- написать бинарный поиск и доказать его правильность через инвариант;
- вывести из «каждый шаг — вдвое меньше»;
- объяснить, зачем в C++
L + (R − L) / 2; - найти корень бинарным поиском по ответу и объяснить, почему цикл по
epsопасен.