О чём лекция

Пять простых алгоритмов, на которых отрабатываются оценки из первой лекции: разложение на простые множители, НОД двумя версиями алгоритма Евклида, линейный и бинарный поиск в массиве и бинарный поиск по ответу — на примере квадратного корня. Главная мысль: одна и та же задача решается за и за , и на больших входах это разница между часами и мгновением.

Как читать

Доказательства в блоках «Не из лекции» дописаны к конспекту: на лекции их не было или они обрывались. Всё остальное — с доски.

Маршрут

  1. Как оценивать → Асимптотические оценки, Время работы алгоритма
  2. НОД → Алгоритм Евклида
  3. Поиск в отсортированном массиве и по ответу → Бинарный поиск
  4. Дробные числа в поиске → Типы с плавающей точкой

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 опасен.