Краткое содержание
До сих пор курс судил об алгоритмах по одному-единственному вопросу: делают ли они то, что обещают? Этот урок добавляет второй, от которого зависит, пригодна ли правильная программа на практике: во что она обходится? Сложность сначала измеряется без часов, подсчётом того, сколько раз выполняется доминирующая операция, и оказывается функцией размера данных. В этой функции важно не её значение при данном , а то, как она растёт: таблица значений , , и показывает, что различия между алгоритмами, незаметные при десяти данных, превращаются в пропасть при миллионе. Нотация вводится как сокращённая запись этих порядков роста, без формального определения, на примерах, уже встречавшихся в курсе: алгоритм Евклида логарифмический; сумма вектора линейная; сравнение всех пар квадратичное; наивное вычисление чисел Фибоначчи экспоненциальное. Затем время измеряется часами из <chrono>, std::chrono::steady_clock, и объясняется, почему одно измерение обманывает, почему измеряют с оптимизацией и без санитайзеров и как сопоставить измерение с предсказанием: при удвоении линейный алгоритм удваивает своё время, а квадратичный увеличивает его вчетверо.
Цели обучения
- Подсчитывать доминирующие операции алгоритма с одним или двумя циклами как функцию размера данных.
- Относить алгоритм к константным, логарифмическим, линейным, или квадратичным и предсказывать, как меняется его время при удвоении .
- Измерять время выполнения с помощью
std::chrono::steady_clockи сравнивать измерение с предсказанием. - Выбирать между двумя алгоритмами для заданного размера данных, обосновывая выбор порядком роста.
Подсчёт операций
Второй вопрос
Уроки Проектирование правильных циклов и Рекурсия научили доказывать, что алгоритм правилен: что он завершается и что его результат удовлетворяет постусловию. Однако урок Численные алгоритмы показал два одинаково правильных теста на простоту, один примерно с делениями, другой примерно с , а урок Рекурсия показал два правильных способа вычислить , один с вызовами, другой с 29 сложениями. Если это так, правильности недостаточно для выбора: из двух правильных алгоритмов один может ответить мгновенно, а другой не завершиться прежде, чем ответ потеряет значение. Поэтому второй вопрос, который задают алгоритму, таков: во что он обходится.
Сложность можно было бы измерять часами, и четвёртый параграф это сделает. Но время одного запуска зависит от машины, от компилятора, от его опций и от того, чем компьютер занят в то же самое время; из него ничего не следует об алгоритме на другой машине. От всего этого не зависит число шагов, которые делает алгоритм. Отсюда следует первое решение этого урока: измерять сложность подсчётом, а часы оставить для проверки подсчёта.
Размер и доминирующая операция
Подсчитывать все шаги (каждое сложение, каждое сравнение, каждое увеличение индекса, каждое обращение к памяти) было бы утомительно и бесполезно, потому что почти все они сопровождают один главный шаг в постоянной пропорции. Поэтому выбирают доминирующую операцию: ту, что выполняется не реже любой другой, с точностью до постоянного множителя, и потому представляет их все. В сумме элементов вектора это сложение; в поиске максимума это сравнение; в тесте на простоту это деление. Число её выполнений выражается как функция размера входа, который обозначают : это число элементов вектора, проверяемое число, число цифр.
Простой цикл, который обходит вектор из элементов и выполняет с каждым фиксированный объём работы, выполняет свою доминирующую операцию раз. Поиск максимума непустого вектора, напротив, начинает с первого элемента как кандидата и сравнивает с ним каждый из остальных: сравнений. Разница между и реальна, но, как будет видно в третьем параграфе, она не меняет ничего из того, что важно.
Худший случай
Некоторые алгоритмы выполняют не всегда одно и то же число операций на данных одного размера. Поиск значения в векторе, при котором вектор обходят, пока значение не найдётся, делает одно сравнение, если значение стоит на первой позиции, и , если его нет. Чтобы говорить о его сложности, выбирают одну из двух ситуаций, и обычно выбирают худший случай: наибольшее число операций среди всех входов размера . Причина та же, по которой контракт пишется для всех входов, удовлетворяющих предусловию: худший случай есть гарантия, а лучший лишь надежда. Так что линейный поиск требует сравнений в худшем случае, и когда этот урок говорит «сложность» без уточнений, он имеет в виду сложность в худшем случае.
Вложенные циклы
Урок Циклы научил считать итерации двух вложенных циклов. Если внутренний всегда делает итераций на каждую из итераций внешнего, внутреннее тело выполняется раз. Если внутренний зависит от внешнего, суммируют построчно. При работе с коллекциями чаще всего встречается случай всех пар: каждый элемент сравнивают с каждым из следующих за ним, как при проверке, есть ли в векторе повторы.
#include <print>int main(){ for (int n{10}; n <= 10'000; n *= 10) { long long comparisons{0}; for (int i{0}; i < n; ++i) { for (int j{i + 1}; j < n; ++j) { ++comparisons; } } std::println("n = {:>6}: {:>10} сравнений; n(n - 1)/2 = {}", n, comparisons, static_cast<long long>(n) * (n - 1) / 2); }}n = 10: 45 сравнений; n(n - 1)/2 = 45n = 100: 4950 сравнений; n(n - 1)/2 = 4950n = 1000: 499500 сравнений; n(n - 1)/2 = 499500n = 10000: 49995000 сравнений; n(n - 1)/2 = 49995000Счётчик занимает место сравнения, которое сделал бы настоящий алгоритм. На итерации внешнего цикла внутренний идёт от до и делает итераций; так что всего получается , сумма из урока Циклы:
Вывод подтверждает формулу для каждого размера. Счётчик имеет тип long long, потому что начиная с около шестидесяти пяти тысяч число пар превышает максимум int (32-битного на этой платформе), и формула по той же причине умножает после преобразования: счётчик квадратичного алгоритма переполняется гораздо раньше, чем данные.
Заметим уже сейчас то, что обобщит третий параграф: при умножении на 10 число сравнений умножается примерно на 100. При десяти данных 45 сравнений; при десяти тысячах почти пятьдесят миллионов.
Загрузка содержимого…