Алгоритмы и сложность
Оценка и измерение стоимости алгоритмов, массивы, списки, стек вызовов, рекурсия и сортировки
Вячеслав Федоров
Лекция 17
Разработка и применение ПО
в физических исследованиях
Изложение. Семнадцатая лекция открывает часть курса, посвящённую алгоритмам и структурам данных. Предыдущие лекции рассматривали язык Python, инструменты разработки, библиотеки обработки данных и машинное обучение; теперь предметом изучения становится стоимость самих вычислений. Алгоритмическая грамотность представляет собой умение заранее оценить, выполним ли расчёт за приемлемое время и поместятся ли данные в память, а затем подтвердить оценку измерением. В лекции рассматриваются понятие алгоритма и O-нотация, оценка времени и памяти, представление данных в памяти, массивы и списки, стек вызовов и рекурсия и, наконец, сортировки, на которых впервые проявляются граница n log n и устойчивость. Надпись на титуле — O(n): так записывается линейная сложность, с которой сравниваются остальные оценки лекции.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в терминале виртуальной машины lab с Python 3.12, далее называемой стендом: на слайде проигрывается запись тех же команд. Данные демонстраций синтетические — метки времени событий, координаты частиц, коды АЦП — и генерируются с фиксированным зерном, поэтому повторный запуск даёт те же входы. Счётчики операций, сравнений и копирований от машины не зависят и совпадают на любом компьютере, а измеренные времена зависят от процессора и интерпретатора и воспроизводятся лишь по порядку величины.
Содержание
От оценки сложности к выбору структуры данных
Изложение. Лекция состоит из шести частей. Первая вводит понятие алгоритма, сравнивает линейный и бинарный поиск и определяет O-нотацию вместе со способом её измерения. Вторая посвящена оценке времени исполнения и пространственной сложности и разбирает две задачи главы: самый частый символ и поиск простых чисел. Третья рассматривает представление данных в памяти, объекты Python, массивы постоянного и переменного размера и реаллокацию. Четвёртая — связный список, стек, очередь и дек. Пятая — стек вызовов, рекурсию и её пределы. Шестая посвящена сортировкам: устойчивости, сортировке слиянием и быстрой сортировке, встроенной сортировке Python и сортировке подсчётом, которая обходит границу n log n.
Цель лекции
Оценивать стоимость алгоритма до запуска и подтверждать оценку измерением
Миллион событий детектора, обработанный алгоритмом O(n²), требует 10¹² операций, и NumPy, написанный на C, порядка роста не меняет. Удачно выбранная структура данных сокращает время на порядки, причём чаще, чем низкоуровневая оптимизация.
Изложение. Цель лекции состоит в том, чтобы оценивать стоимость алгоритма до запуска и подтверждать оценку измерением. Оценка до запуска необходима потому, что порядок роста определяет выполнимость расчёта сильнее, чем язык или компьютер. Рассмотрим миллион событий детектора, которые требуется сравнить попарно, например в поиске совпадений по времени: алгоритм O(n²) совершает около 10¹² операций. Цикл Python выполняет порядка 3·10⁷ простейших итераций в секунду, и такой расчёт занял бы около восьми часов. Переход к коду на C, будь то NumPy или компилируемый язык, повышает скорость примерно до 10⁹ операций в секунду, то есть уменьшает константу, но не порядок роста, и расчёт всё равно занимает около четверти часа. Алгоритм O(n log n) — сортировка событий по времени и проход по соседним парам — выполняет ту же работу примерно за 2·10⁷ операций, то есть меньше чем за секунду даже на чистом Python. Таким образом, удачно выбранный алгоритм или структура данных сокращает время на порядки, причём чаще, чем низкоуровневая оптимизация. Вместе с тем оценка остаётся гипотезой, пока не подтверждена измерением: константы, кеш процессора и устройство интерпретатора меняют реальное время, и каждая оценка лекции проверяется на стенде.
Алгоритмы
в работе физика
n O(n) O(n log n) O(n²)
10³ 30 мкс 0,3 мс 30 мс
10⁶ 30 мс 0,6 с 8 ч
10⁹ 30 с 15 мин 900 лет
Время при 28 нс на операцию — итерацию пустого цикла Python на стенде; код на C — около 1 нс.
Для физика
Отбор событий, поиск соседей, свёртка спектров — задачи, где порядок роста важнее языка.
Лекция 17
сложность и её измерение, массивы, списки, рекурсия, сортировки: поиск по значению — O(n)
Лекция 18
хеш-таблицы — O(1) в среднем, сбалансированные деревья — O(log n), графы
Изложение. Таблица переводит порядок роста во время. Возьмём за единицу стоимости одну итерацию пустого цикла Python: на стенде она занимает 28 нс, что соответствует примерно 3,5·10⁷ итераций в секунду. Числа таблицы представляют собой оценку по порядку величины, а не точный расчёт. Цикл главы выполняется на уровне модуля, где каждое обращение к переменной требует поиска по имени; внутри функции простая операция обходится в несколько раз дешевле — линейный поиск в демонстрации тратит на одно сравнение около 12 нс, — а операции с объектами и вызовы функций — дороже. При тысяче элементов разница между классами несущественна: даже квадратичный алгоритм укладывается в 30 мс. При миллионе элементов линейный алгоритм выполняется за 30 мс, алгоритм O(n log n) — меньше чем за секунду, а квадратичный — около восьми часов. При миллиарде элементов, характерном для набора событий длительного сеанса или для многомерной расчётной сетки, допустимы лишь линейные и близкие к ним алгоритмы, а квадратичный потребовал бы около 900 лет. Код на C выполняет порядка 10⁹ простых операций в секунду и сдвигает границы таблицы примерно на полтора порядка, но не меняет её вида. Справа показана карта части курса. Лекция 17 вводит сложность и её измерение, массивы, списки, рекурсию и сортировки — структуры и алгоритмы, у которых поиск по значению требует O(n) операций. Лекция 18 рассматривает хеш-таблицы, деревья и графы, снимающие это ограничение. Для физика типичные задачи такого рода — отбор событий, поиск соседей в пространстве координат, свёртка спектров: в каждой из них порядок роста определяет результат сильнее, чем выбор языка.
Алгоритм и сложность
Свойства алгоритма, поиск в массиве, O-нотация и её измерение
01
Алгоритм и сложность
Время и память
Память и массивы
Списки и очереди
Рекурсия
Сортировки
Изложение. Первая часть вводит понятие алгоритма и его свойства, сравнивает линейный и бинарный поиск по числу шагов и по времени и определяет O-нотацию — язык, на котором сложность алгоритма записывается без привязки к конкретной машине. Завершает часть способ измерить показатель степени экспериментально.
Алгоритм
и его свойства
Пояснение
Алгоритм — конечная последовательность точных инструкций, приводящая к результату за конечное число шагов.
Важно
Проблема останова: алгоритма, который по программе и входу определяет, остановится ли она, не существует.
01
Дискретность
отдельные шаги в определённом порядке
02
Детерминированность
один вход — один результат
03
Понятность
шаг посилен исполнителю, без двоякого толкования
04
Завершаемость
конечное число шагов
05
Массовость
класс входных данных, а не один набор
06
Результативность
ответ, в том числе «решения нет»
Изложение. Алгоритмом называется конечная последовательность точных инструкций, приводящая к результату за конечное число шагов. От такой последовательности требуются шесть свойств. Дискретность означает, что алгоритм распадается на отдельные шаги, выполняемые по одному в определённом порядке. Детерминированность — что на одних и тех же входных данных получается один и тот же результат при любом числе запусков; при разборе демонстрации с частым символом будет показано, как легко нарушить это свойство, перебирая элементы множества строк. Понятность — что каждый шаг посилен исполнителю и не допускает двоякого толкования: инструкция «посолить по вкусу» в алгоритм не годится. Завершаемость — что работа заканчивается за конечное число шагов. Массовость — что алгоритм решает задачу для целого класса входных данных, а не для единственного набора. Результативность — что на выходе получается ответ, пусть даже им является сообщение об отсутствии решения. Существуют задачи, для которых доказано, что решающего их алгоритма нет. Самая известная из них — проблема останова: по алгоритму и входным данным определить, остановится ли алгоритм или будет работать бесконечно. Доказательство ведётся от противного: предполагаемый распознаватель встраивается в программу, которая поступает наоборот его предсказанию, и эта программа подаётся на вход самой себе — получается противоречие. Отсюда, в частности, вытекает, что никакой анализатор кода не позволяет в общем случае гарантировать завершение произвольной программы.
Линейный
и бинарный поиск
def find_element(numbers, x): for i in range(len(numbers)): if numbers[i] == x: return i return -1 def binary_search(arr, x): mid, low, high = 0, 0, len(arr) - 1 while low <= high: mid = (high + low) // 2 if arr[mid] < x: low = mid + 1 elif arr[mid] > x: high = mid - 1 else: return mid return -1
01
Линейный поиск
до N сравнений: элемент последний или отсутствует
02
Бинарный поиск
до ⌊log₂ N⌋ + 1 итераций: отрезок делится пополам
03
Словарь из 100 страниц
не более 7 делений пополам: 2⁷ = 128 ≥ 100
Важно
Бинарный поиск требует упорядоченного массива: на неупорядоченном он может не найти и имеющийся элемент.
Изложение. Рассмотрим задачу: в массиве длины N найти заданное число и вернуть его индекс либо −1, если числа нет. Линейный поиск перебирает элементы по порядку; в худшем случае, когда искомый элемент оказывается последним или отсутствует, он выполняет N сравнений, и вычислительная сложность линейно зависит от размера входа. Вычислительная сложность представляет собой число элементарных операций, совершаемых алгоритмом, как функцию размера входных данных. Если массив упорядочен по возрастанию, искомое число находится гораздо быстрее. Бинарный поиск сравнивает x с серединой рассматриваемого отрезка и отбрасывает ту половину, в которой x заведомо нет; после каждой итерации длина отрезка уменьшается вдвое, и в худшем случае требуется ⌊log₂ N⌋ + 1 итераций. Пример главы — словарь из 100 страниц: делить его пополам можно не более семи раз, поскольку 2⁷ = 128 ≥ 100, и для поиска слова достаточно просмотреть не более семи страниц. Алгоритм известен также как двоичный поиск, метод деления пополам и дихотомия; физику он знаком по методу половинного деления при поиске корня уравнения. Предусловие алгоритма существенно: бинарный поиск применим только к упорядоченному массиву. На неупорядоченных данных он не завершается ошибкой, а, как правило, возвращает −1 для элементов, которые в массиве есть, — это будет видно в демонстрации.
Демонстрация:
поиск в массиве
01
Метки времени событий с шагом 25 нс
02
Поиск последней метки: N от 10 до 10⁶
03
Своя функция против bisect на C
04
Бинарный поиск в перемешанном массиве
Что наблюдается
На 10⁶ меток: 10⁶ сравнений и 12 мс против 20 итераций и 0,72 мкс. bisect на C быстрее своей функции в 9 раз.
root@lab — запись со стенда
Демонстрация. Сценарий search.py берёт упорядоченные метки времени событий детектора с шагом 25 нс и ищет последнюю из них — худший случай для линейного поиска; функции поиска взяты из главы и дополнены счётчиком итераций. Для N от 10 до 10⁶ линейный поиск выполняет ровно N сравнений, и время растёт примерно вдесятеро с каждым порядком N: 0,16; 1,17; 11,95; 122; 1229 и 12 215 мкс. Бинарный поиск выполняет 4, 7, 10, 14, 17 и 20 итераций, то есть ⌊log₂ N⌋ + 1, и его время за пять порядков роста N увеличивается лишь с 0,135 до 0,724 мкс — примерно во столько же раз, во сколько выросло число итераций. На миллионе меток бинарный поиск быстрее линейного в 16 875 раз. Таблица главы, 12 340 и 0,69 мкс и отношение «почти восемнадцать тысяч», воспроизводится по порядку величины, и разрыв растёт вместе с N. Второй запуск подтверждает оценку для словаря: на 100 элементах бинарный поиск требует не более семи делений пополам. Затем собственная функция binary_search сравнивается с bisect.bisect_left из стандартной библиотеки, написанной на C: 0,756 мкс против 0,086 мкс, то есть в 9 раз быстрее при той же сложности O(log n). Константа реализации, таким образом, остаётся существенной и при одинаковой асимптотике. Последние строки показывают нарушение предусловия: в перемешанном массиве из 100 000 меток бинарный поиск не нашёл ни одного из 1000 заведомо присутствующих элементов, тогда как оператор in, выполняющий линейный поиск, нашёл все.
Сложность:
O-нотация
f(n) = O(g(n)), если найдутся c > 0 и n₀: f(n) ≤ c·g(n) при всех n ≥ n₀ 5n + 3 ≤ 6n при n ≥ 3 ⇒ 5n + 3 = O(n)
Пояснение
Основание логарифма под знаком O роли не играет: логарифмы различаются множителем.
Важно
Константы определяют применимость при конкретных размерах данных: лучшая асимптотика может проиграть при большой константе.
Класс Пример
O(1) элемент массива по индексу
O(log n) бинарный поиск
O(n) проход по массиву
O(n log n) сортировка слиянием
O(n²) перебор пар частиц
O(n³) умножение матриц n × n
O(2^n) перебор подмножеств
Изложение. Чтобы говорить о скорости алгоритма без привязки к конкретной машине, используется O-нотация. Запись f(n) = O(g(n)) означает, что существуют постоянная c > 0 и порог n₀, начиная с которого f(n) ≤ c·g(n): функция f растёт не быстрее g с точностью до постоянного множителя. Например, алгоритм, совершающий 5n + 3 операций, имеет сложность O(n), поскольку 5n + 3 ≤ 6n при n ≥ 3. Константы и младшие члены в оценке отбрасываются: они зависят от процессора, компилятора и интерпретатора, тогда как характер роста от них не зависит. В таблице собраны классы, встречающиеся чаще всего, с примерами из лекции. Обращение к элементу массива по индексу требует O(1) операций, бинарный поиск — O(log n), проход по массиву — O(n), сортировка слиянием — O(n log n), перебор всех пар n частиц — O(n²), умножение матриц n × n по определению — O(n³), перебор всех подмножеств n предметов — O(2^n). Основание логарифма под знаком O роли не играет: логарифмы по разным основаниям отличаются постоянным множителем. Отброшенные константы, однако, не исчезают из практики. Они определяют применимость алгоритма при конкретных размерах данных: асимптотически лучший алгоритм может проигрывать на практике, если его константа велика, а выигрыш от асимптотики растёт медленно. В демонстрации с простыми числами линейное решето O(n) окажется не быстрее классического решета O(n log log n), а в демонстрации с поиском функция на Python уступает функции на C в девять раз при одной и той же сложности.
Сложность
по виду кода
01
Проход по массиву
один цикл по n элементам — O(n)
02
Вложенные циклы
сумма итераций: решето — O(n log log n)
03
Деление пополам
отрезок делится пополам — O(log n)
04
Полный перебор
все подмножества — O(2^n)
t(N) ~ N^k ⇒ t(2N) / t(N) = 2^k log t = k·log N + const рост в 2 раза → k = 1, O(n) рост в 4 раза → k = 2, O(n²) рост чуть больше 2 → O(n log n)
Пояснение
В логарифмических осях степенная зависимость — прямая, наклон которой равен показателю k.
Изложение. Сложность чаще всего видна по структуре кода, и несколько признаков покрывают большинство случаев. Цикл, проходящий по всему массиву, означает линейную сложность. Для вложенных циклов необходимо подсчитать суммарное число итераций внутреннего цикла, а не глубину вложенности: часто получается произведение длин, но не всегда. У решета Эратосфена два вложенных цикла, а работает оно за O(n log log n), поскольку внутренний цикл для простого p выполняется около n/p раз, а сумма обратных простых растёт как log log n. Деление пополам на каждом шаге означает логарифмическую сложность, а полный перебор всех комбинаций — экспоненциальную. Оценку по коду подтверждает эмпирическое правило, которое позволяет определить показатель степени по измерениям. Если время работы растёт как степень размера входа, t ~ N^k, то при удвоении N время возрастает в 2^k раз: вдвое для линейного алгоритма и вчетверо для квадратичного. Прологарифмировав, получим log t = k·log N + const: в логарифмических осях степенная зависимость изображается прямой, наклон которой равен показателю k. Для O(n log n) отношение немного больше двух, а наклон немного больше единицы, поскольку логарифм растёт медленнее любой степени. Таким образом, двух-трёх замеров с удвоением N достаточно, чтобы отличить линейный алгоритм от квадратичного, не заглядывая в код. Правило работает при одном условии: размеры должны быть достаточно велики, чтобы постоянные накладные расходы — вызов функции, создание объектов — не маскировали рост.
Демонстрация:
сложность
01
Координаты N частиц, N от 500 до 8000
02
Пять операций: от a[N // 2] до перебора пар
03
Отношение t(2N)/t(N) и наклон МНК
04
Рисунок в логарифмических осях
Что наблюдается
Рост при удвоении N: 0,95; 1,09; 1,99; 2,42; 4,10, наклоны от −0,06 до 2,03. Перебор пар при N = 8000 занимает 741 мс.
root@lab — запись со стенда
Демонстрация. Сценарий bigo.py измеряет время пяти операций над координатами N частиц на отрезке [0, 1), удваивая N от 500 до 8000: обращения к элементу a[N // 2], бинарного поиска, суммы sum(a), сортировки sorted(a) и перебора всех пар частиц в поисках близких. Для каждой операции печатаются времена при трёх последних N, среднее отношение t(2N)/t(N) и наклон прямой, проведённой методом наименьших квадратов через точки (log N, log t). Обращение по индексу занимает 0,05 мкс при любом N: рост 0,95, наклон −0,06, то есть ноль в пределах погрешности. Бинарный поиск замедляется с 0,45 до 0,52 мкс, наклон 0,12: логарифм при росте N от 500 до 8000 увеличивается в 1,44 раза, что соответствует наклону около 0,13. Сумма растёт вдвое на каждое удвоение, наклон 0,99. Перебор пар растёт в 4,10 раза, наклон 2,03, и при N = 8000 занимает 741 мс. Сортировка даёт наклон 1,28. Число сравнений Timsort на случайных данных, около n·log₂ n − 1,44n, на этом диапазоне растёт с наклоном около 1,15, а остаток вносит память: с ростом N данные хуже помещаются в кеш процессора, и одно сравнение обходится дороже. Таким образом, измерение позволяет восстановить класс сложности по нескольким точкам, а отличие наклона от целого числа указывает на логарифмический множитель или на эффекты памяти. Второй запуск строит рисунок; режим plot измеряет времена заново, поэтому наклоны на рисунке немного отличаются от таблицы.
Результат:
сложность
Что наблюдается
Пять прямых с наклонами 0,04; 0,11; 1,02; 1,29; 2,01: в логарифмических осях показатель степени читается как наклон.
Демонстрация. На рисунке время одной операции отложено против N в логарифмических осях, наклоны прямых приведены в легенде: 0,04 для обращения по индексу, 0,11 для бинарного поиска, 1,02 для суммы, 1,29 для сортировки и 2,01 для перебора пар. Между нижней и верхней линиями при N = 8000 семь порядков: от 0,05 мкс до 0,7 с. Линии суммы и сортировки идут почти параллельно, но вторая круче, и расстояние между ними растёт — так проявляется множитель log n вместе с эффектами памяти. Рисунок позволяет и экстраполировать: при сохранении наклона 2 перебор пар для миллиона частиц занял бы в (10⁶/8000)² ≈ 1,6·10⁴ раз больше времени, чем для 8000, то есть около трёх часов. Отметим, что для такой экстраполяции необходима уверенность в том, что механизм роста не изменится: при выходе данных за пределы оперативной памяти время растёт быстрее.
Время и память
Оценка времени исполнения, пространственная сложность, разбор задач главы
02
Алгоритм и сложность
Время и память
Память и массивы
Списки и очереди
Рекурсия
Сортировки
Изложение. Вторая часть переходит от числа операций к секундам и байтам: оценивает время исполнения по тактовой частоте и проверяет оценку пустым циклом на Python и C++, вводит пространственную сложность и краевые случаи тестирования и разбирает две задачи главы — самый частый символ и поиск простых чисел, на которых константы и асимптотика вступают в противоречие.
Оценка
времени исполнения
t = N · (тактов на итерацию) / f = 10⁹ · 1 / 2,5·10⁹ = 0,4 с итерация: i += 1, проверка, переход 3·10⁹ инструкций → около 1,2 с
Важно
При -O2 компилятор удаляет цикл, результат которого не используется или вычисляется заранее, и замер показывает почти ноль — ловушка микробенчмарков.
01
Такт и инструкция
ядро выполняет несколько инструкций за такт
02
Порядок величины
оценка верна с точностью до множителя
03
Интерпретатор
итерация Python — сотни машинных инструкций
Изложение. Оценим время исполнения цикла из 10⁹ итераций. Тактовая частота современного процессора составляет около 2,5 ГГц: за секунду проходит 2,5·10⁹ тактов. Если каждая итерация занимает один такт, получим t = 10⁹ · 1 / 2,5·10⁹ = 0,4 с. Глава уточняет, что итерация пустого цикла состоит из трёх действий — прибавить единицу, проверить условие и перейти к началу цикла, — и 3·10⁹ инструкций при одной инструкции за такт займут около 1,2 с. Такая оценка верна лишь по порядку величины. Тактовая частота и число инструкций в секунду являются разными величинами: суперскалярное ядро выполняет несколько независимых инструкций за такт, сложные инструкции занимают несколько тактов, а обращение к памяти может занять сотни тактов. Поэтому константу в оценке временной сложности заранее узнать не удаётся, и это ещё одна причина пользоваться O-нотацией. Интерпретатор Python добавляет свою константу: каждая итерация цикла на Python выполняется сотнями машинных инструкций — выборка байт-кода, проверка типов, поиск имён, создание объектов целых чисел, работа со счётчиками ссылок, — тогда как итерация того же цикла на C++ без оптимизации занимает около десяти инструкций. Сравнение с компилируемым языком требует осторожности. При сборке с оптимизацией -O2 компилятор обнаруживает, что переменная цикла нигде не используется, и удаляет цикл целиком, так что измерение показывает почти нулевое время. Это классическая ловушка микробенчмарков, и одного использования результата для её обхода недостаточно: если значение счётчика после цикла вычислимо заранее, как здесь i = 10⁹, компилятор подставляет готовое значение и цикл не выполняет (в GCC такая замена включена начиная с -O1). Поэтому счётчик объявляют volatile либо делают результат цикла зависящим от данных, неизвестных при компиляции.
Демонстрация:
пустой цикл
01
Python: 10⁷ итераций и пересчёт на 10⁹
02
C++, g++ -O0: 10⁹ итераций
03
C++, g++ -O2: цикл удалён
04
C++, -O2 и счётчик volatile
Что наблюдается
Python: 28,4 нс на итерацию, 28 с на 10⁹. C++ -O0: 0,23 с, в 120 раз быстрее; -O2: 4·10^−8 с; volatile: 0,87 с.
root@lab — запись со стенда
Демонстрация. Сценарий loop.py повторяет пустой цикл главы на Python, уменьшив число итераций в сто раз: 10⁷ итераций занимают на стенде 0,284 с, то есть 28,4 нс на итерацию, и пересчёт на 10⁹ даёт 28 с. Глава приводит 27,7 с, полученные на CPython 3.11; на версиях до 3.11 и более старых процессорах тот же цикл выполнялся около 100 с — начиная с версии 3.11 интерпретатор CPython заметно ускорен. Тот же цикл на C++, собранный g++ без оптимизации, выполняет 10⁹ итераций за 0,23 с: в 120 раз быстрее Python и в пять раз быстрее оценки главы 1,2 с, поскольку процессор стенда работает с более высокой тактовой частотой и выполняет несколько инструкций за такт. Отношение Python к C++ зависит от процессора и компилятора — глава называет диапазон от 60 до 120, на стенде получено около 120 — и представляет собой оценку порядка, а не постоянную. Сборка с -O2 печатает 4,1·10^−8 с: компилятор удалил цикл, и измерено лишь обращение к часам. Вариант со счётчиком volatile обязывает компилятор выполнить каждое чтение и запись переменной, и время снова конечно: 0,87 с. Отметим, что на стенде этот вариант оказался медленнее сборки -O0, хотя и в нём, и в ней счётчик хранится в памяти: соотношение подобных микровариантов определяется устройством конкретного процессора и на другие машины не переносится. Таким образом, оценка по тактовой частоте верна по порядку величины для компилируемого кода, интерпретатор добавляет к ней два порядка, а измерение с оптимизатором и без него отвечает на разные вопросы.
Пространственная
сложность
01
Определение
зависимость объёма памяти от размера входа, асимптотически
02
Дополнительная память
сверх входных данных: O(1), O(K), O(n)
03
Обмен памяти на время
таблица заранее вычисленных значений, кеш результатов
Пояснение
При нехватке памяти операционная система переносит страницы в файл подкачки на диске, и программа замедляется на порядки даже при малой временной сложности.
Для физика
Таблица калибровки каналов детектора вместо пересчёта по формуле для каждого события — память в обмен на время.
Изложение. Временная сложность показывает, насколько экономно алгоритм расходует процессорное время; вторым ресурсом является оперативная память. Пространственной сложностью алгоритма называется зависимость объёма потребляемой памяти от размера входных данных; как и для времени, интерес представляет асимптотика, а не точный объём. Различают полную и дополнительную память. Вход занимает O(n) у любого алгоритма, поэтому при сравнении алгоритмов необходимо учитывать дополнительную память, сверх входных данных: без этой оговорки все решения одной задачи требовали бы одинаковых O(n), и сравнивать было бы нечего. Время и память допускают обмен. Сохранённая вспомогательная информация позволяет вычислять меньше: таблица заранее вычисленных значений, кеш результатов, индекс по ключу. Выбор не всегда прост: либо хранить больше и меньше вычислять, либо вычислять медленнее, но уложиться в отведённую память. Выход за пределы памяти обходится особенно дорого. Если программам не хватает оперативной памяти, операционная система переносит редко используемые страницы в файл подкачки на диске и возвращает их по мере обращения. Диск медленнее оперативной памяти на несколько порядков, поэтому программа замедляется на порядки даже при малой временной сложности, а когда исчерпан и файл подкачки, одна из программ завершается аварийно. Для физика типичный пример обмена — таблица калибровки детектора: поправки к каждому каналу вычисляются один раз и затем берутся из массива, а не пересчитываются по формуле для каждого события.
Краевые случаи
и тесты
Тест Строки Числа Массивы
Самый маленький пустая строка 0, наименьшее отрицательное пустой набор
Самый большой самая длинная по условию наибольшее допустимое максимальный размер
Особые случаи регистр, кириллица и латиница знак, ноль, чётность, дробное равные элементы, упорядоченный и нет
Важно
Решето с numbers[0] = numbers[1] = False при n = 0 завершается IndexError: в списке один элемент.
Пояснение
Самый большой тест проверяет время и память, то есть оценку сложности.
Изложение. Код, разделённый на функции, удобно тестировать: для каждой функции пишутся юнит-тесты. Необходимо продумать краевые случаи — входные данные, на которых программы отказывают чаще всего; таблица главы сводит их в три группы. Самый маленький тест — пустая строка, ноль или наименьшее допустимое отрицательное число, пустой набор данных. Самый большой тест — самая длинная строка и наибольшее число, допустимые условием, набор максимального размера; он позволяет проверить не столько ответ, сколько время и память, то есть оценку сложности. Особые случаи — строки с разным регистром, с кириллицей и латиницей, отрицательные числа, ноль, чётные и нечётные, вещественные числа, наборы с одинаковыми элементами, упорядоченные и неупорядоченные наборы. Пример ошибки, которую находит самый маленький тест, даёт решето Эратосфена в распространённой записи: строка numbers[0] = numbers[1] = False при n = 0 обращается ко второму элементу списка, состоящего из одного элемента, и завершается исключением IndexError. Исправление — отдельная ветвь для n < 2, показанная на слайде о решете. Наборы с одинаковыми элементами, в свою очередь, проверяют сортировки: быстрая сортировка, раскладывающая элементы лишь на две части — меньшие опоры и все остальные, — на таком наборе уходит в бесконечную рекурсию, которая в Python завершается исключением RecursionError.
Задача:
частый символ
s = 'ababa' ans, anscnt, symcnt = '', 0, {} for now in s: if now not in symcnt: symcnt[now] = 0 symcnt[now] += 1 if symcnt[now] > anscnt: ans, anscnt = now, symcnt[now] print(ans) # a
Решение Время Память
1: все пары позиций O(N²) O(1)
2: проход для каждого символа O(NK) O(K)
3: словарь счётчиков O(N) O(K)
Пояснение
N — длина строки, K — число различных символов; память учитывается сверх входа.
Для физика
Шумящий канал детектора — самый частый номер в потоке срабатываний.
Изложение. Задача главы: дана строка, требуется найти самый часто встречающийся в ней символ. Глава приводит три решения. Первое перебирает все позиции строки и для каждой ещё раз проходит строку целиком, подсчитывая совпадения: N² сравнений и никакой памяти, кроме счётчиков. Второе перебирает различные символы строки и для каждого проходит строку: N·K сравнений, где K — число различных символов, и O(K) памяти на их множество. Третье, показанное на слайде, проходит строку один раз и накапливает счётчики в словаре, где ключом служит символ, а значением — число его вхождений: O(N) операций и O(K) памяти. Память в таблице учитывается дополнительная, сверх входной строки. Таблица показывает, что сложность может зависеть от нескольких параметров: решение 2 при малом K почти линейно, а при K, сопоставимом с N, становится квадратичным. Третье решение быстрее остальных за счёт словаря, поиск в котором требует в среднем O(1) операций; почему это так, объясняет лекция 18. Для физика та же задача возникает при поиске шумящего канала: поток срабатываний детектора представляет собой последовательность номеров каналов, и требуется найти канал, срабатывающий чаще остальных.
Демонстрация:
частый канал
01
Поток номеров каналов, K = 16, канал 3 шумит
02
Три решения главы и collections.Counter
03
N от 1000 до 4000: рост при удвоении
04
N = 20 000, K от 4 до 256
Что наблюдается
При удвоении N решение 1 растёт в 4,0 раза, остальные — в 2,0–2,1. Решение 2 растёт с K: 1,43 → 57,1 мс; решение 3 и Counter от K не зависят.
root@lab — запись со стенда
Демонстрация. Сценарий freq.py применяет три решения главы, переписанные для списка чисел, и collections.Counter к потоку номеров каналов: K = 16 каналов, канал 3 шумит и срабатывает втрое чаще остальных, поток генерируется с фиксированным зерном. При N = 1000, 2000 и 4000 решение 1 занимает 13,6, 56,3 и 228 мс, решение 2 — 0,18, 0,38 и 0,78 мс, решение 3 — 0,04, 0,08 и 0,16 мс, Counter — 0,012, 0,026 и 0,054 мс; все четыре отвечают «канал 3». При последнем удвоении N время решения 1 выросло в 4,0 раза, остальных — в 2,0–2,1 раза: чтобы отличить квадратичное решение от линейных, достаточно трёх точек. Второй запуск фиксирует N = 20 000 и меняет K от 4 до 256. Решение 2 замедляется с 1,43 до 57,1 мс, на последних двух шагах почти пропорционально K, тогда как решение 3 и Counter от K не зависят: 0,74–0,94 и 0,27–0,34 мс. Решение 3 при большом K даже немного ускоряется: оно обновляет текущий максимум столько раз, сколько срабатываний у шумящего канала, а их доля с ростом K уменьшается. Counter быстрее решения 3 примерно в три раза при той же сложности, поскольку подсчёт выполняется циклом на C. Решение 2, как и в главе, перебирает sorted(set(s)), а не set(s): порядок обхода множества строк зависит от соли хеша, выбираемой при запуске интерпретатора, и без сортировки при равных частотах решение печатало бы от запуска к запуску разные символы, нарушая свойство детерминированности. Для чисел порядок обхода постоянен, однако сортировка делает решение детерминированным для любых ключей.
Простые числа
и решето
def eratosthenes(n): if n < 2: return [False] * (n + 1) numbers = list(range(n + 1)) numbers[0] = numbers[1] = False for num in range(2, math.isqrt(n) + 1): if numbers[num]: for j in range(num * num, n + 1, num): numbers[j] = False return numbers
01
Перебор до n
деление на все меньшие числа — O(n²)
02
Перебор до √n
парный делитель меньше √n — O(n√n)
03
Решето Эратосфена
кратные p отмечаются с p² — O(n log log n)
04
Линейное решето
lp[x] — наименьший простой делитель, O(n)
Пояснение
Линейное решето записывает каждое составное число ровно один раз, на шаге i = x / lp[x].
Изложение. Простейшая проверка числа на простоту перебирает все возможные делители от 2 до n − 1; поиск всех простых до n таким способом требует порядка n² делений. Делители, превышающие √n, проверять необязательно: если n = a·b и a > √n, то b < √n, и делитель b был бы найден раньше. Это сокращает работу до O(n√n), однако для каждого числа делители по-прежнему перебираются заново. Решето Эратосфена устроено иначе: выписываются числа от 0 до n, 0 и 1 отмечаются как непростые, а затем для каждого очередного простого p отмечаются составными все кратные p. Отмечать их достаточно начиная с p², поскольку меньшие кратные уже отмечены меньшими простыми, а внешний цикл достаточно вести до √n. Решето работает за O(n log log n): для простого p внутренний цикл выполняет около n/p отметок, а сумма обратных простых растёт как log log n. Линейное решето выполняет O(n) операций: вместо отметок оно записывает для каждого числа x его наименьший простой делитель lp[x], и каждое составное число записывается ровно один раз — на шаге i = x / lp[x]. Множитель log log n растёт настолько медленно, что при n = 10⁶ составляет около 2,6, и разница в асимптотике легко перекрывается разницей констант — демонстрация это покажет. Решето требует O(n) памяти, тогда как перебор делителей — O(1): ещё один пример обмена памяти на время.
Демонстрация:
решето
01
Число операций при n = 10⁴, 10⁵, 10⁶
02
Перебор делителей до n и до √n
03
Решето Эратосфена и линейное решето
04
Время, минимум из трёх запусков
Что наблюдается
Линейное решето делает вдвое меньше записей, но не быстрее: 66,4 мс против 60,1 мс при n = 10⁶. Решето со срезами — 18,4 мс.
root@lab — запись со стенда
Демонстрация. Сценарий primes.py считает операции четырёх методов при n = 10⁴, 10⁵ и 10⁶: деления с остатком при переборе делителей и записи в массив в решетах. Перебор всех делителей уже при n = 10⁴ выполняет 5 775 223 деления и для больших n не запускается; перебор до √n — 117 527 и 2 745 694 деления. Решето Эратосфена при n = 10⁶ делает 2 122 048 отметок — меньше, чем n·ln ln n = 2 625 792, поскольку отметки начинаются с p². Линейное решето делает 921 501 запись, что в точности равно n − 1 − π(n), где π(n) = 78 498 — число простых до 10⁶: каждое составное число записано один раз. По числу операций линейное решето выигрывает примерно вдвое на всех трёх n. Второй запуск измеряет время, минимум из трёх запусков: при n = 10⁶ решето Эратосфена занимает 60,1 мс, а линейное — 66,4 мс, при n = 10⁵ — 3,7 и 6,5 мс. Вдвое меньшее число операций выигрыша не дало: каждая итерация линейного решета дороже — вложенный цикл по списку простых, умножение, два сравнения, — и константа перекрывает выигрыш асимптотики. Таким образом, константы определяют применимость алгоритма при конкретных размерах данных. Последняя строка показывает действие константы в обратную сторону: то же решето Эратосфена, у которого внутренний цикл заменён присваиванием среза, выполняемым на C, занимает 18,4 мс — в 3,3 раза меньше без изменения асимптотики.
Память и массивы
Представление данных в памяти, массивы постоянного и переменного размера, реаллокация
03
Алгоритм и сложность
Время и память
Память и массивы
Списки и очереди
Рекурсия
Сортировки
Изложение. Третья часть рассматривает, как данные расположены в памяти: устройство оперативной памяти и представление базовых типов, стоимость объектов Python, массивы постоянного размера и динамические массивы и, наконец, реаллокацию — механизм, благодаря которому добавление в конец списка требует в среднем O(1) операций.
Память
и базовые типы
Пояснение
Память разбита на ячейки по 1 байту = 8 бит, у каждой ячейки свой адрес.
Тип C++ Байт Значения
char 1 256 значений, ASCII
int 4 −2³¹ … 2³¹ − 1
unsigned int 4 0 … 2³² − 1
double 8 до 10³⁰⁸, 53 разряда
double: знак 1 + порядок 11 + мантисса 52 мантисса: 52 бита + скрытая 1 = 53 разряда eps = 2^−52 ≈ 2,2·10^−16 — шаг сетки у 1 0.1 + 0.2 ≠ 0.3
Важно
При вычитании близких величин совпадающие старшие цифры уничтожаются: остаются младшие, искажённые округлением.
Изложение. Оперативная память разбита на ячейки размером в один байт, и у каждой ячейки есть свой номер — адрес; процессор обращается к данным по адресам. Один байт из восьми бит позволяет закодировать 2⁸ = 256 значений, например целое число от 0 до 255 или символ ASCII типа char. Тип int в C++ обычно занимает 4 байта. Целые со знаком представлены в дополнительном коде: старший бит отвечает за знак, 2³¹ комбинаций отведены отрицательным числам от −2³¹ до −1, ещё 2³¹ — неотрицательным от 0 до 2³¹ − 1; ноль занимает один из неотрицательных кодов, поэтому положительных чисел на одно меньше, чем отрицательных. Беззнаковый unsigned int хранит числа от 0 до 2³² − 1, около 4,3 миллиарда. Вещественные числа чаще всего представлены типом double из 8 байт: 1 бит знака, 11 бит порядка и 52 бита мантиссы, к которым добавляется подразумеваемая старшая единица, — всего 53 двоичных разряда, около шестнадцати десятичных цифр. Диапазон double простирается до 10³⁰⁸ по модулю, однако диапазон не то же самое, что точность. Расстояние от единицы до следующего представимого числа называется машинным эпсилон и равно 2^−52 ≈ 2,2·10^−16; добавка, которую единица ещё различает при округлении к ближайшему, примерно вдвое меньше. Шаг сетки представимых чисел растёт вместе с самим числом, оставаясь относительным. Отсюда 0.1 + 0.2 ≠ 0.3: ни одна из этих дробей в двоичной записи не конечна. Отсюда же потеря точности при вычитании близких величин: совпадающие старшие цифры взаимно уничтожаются, и в разности остаётся столько верных цифр, сколько их было в различающейся младшей части.
Объекты Python
в памяти
import sys sys.getsizeof(42) # 28 sys.getsizeof(1.0) # 24 sys.getsizeof([]) # 56 sys.getsizeof(list(range(1, 11))) # 136
Пояснение
Десять различных чисел: 56 + (8 + 28)·10 = 416 байт против 40 в C++.
Важно
Числа от −5 до 256 хранятся в одном экземпляре: список 1–10 добавляет лишь 136 байт самого списка.
01
Заголовок объекта
счётчик ссылок и тип — 16 байт
02
Список
56 байт и по 8 байт на ссылку
03
Подсчёт ссылок
объект без ссылок удаляется сразу
04
Сборщик циклов
модуль gc находит взаимные ссылки
Изложение. В Python каждый объект хранится в обёртке, содержащей, помимо данных, служебную информацию: счётчик ссылок и указатель на тип занимают 16 байт. Поэтому короткое целое число занимает 28 байт вместо четырёх, число с плавающей точкой — 24 байта вместо восьми, а пустой список — 56 байт. Список хранит не сами объекты, а ссылки на них, по 8 байт на элемент: список из десяти чисел занимает 56 + 8·10 = 136 байт, а сами числа лежат отдельно. Подсчёт главы складывает ссылки и объекты: 56 + (8 + 28)·10 = 416 байт против 40 байт массива int в C++. Подсчёт верен для десяти различных больших чисел. Числа от −5 до 256 интерпретатор хранит в одном экземпляре — это кеш малых целых, — и список из чисел от 1 до 10 добавляет к уже существующим объектам лишь 136 байт самого списка: 56 байт заголовка и 80 байт ссылок. Размеры приведены для CPython 3.11 и 3.12 на 64-битной платформе; в других версиях отдельные значения отличаются, например в 3.9 литерал из десяти чисел занимает 152 байта. Освобождение памяти в C++ возлагается на программиста или на контейнеры, такие как std::vector, которые отдают память при выходе переменной из области видимости. В Python объект уничтожается, как только на него не остаётся ссылок: этим занимается подсчёт ссылок, а циклические структуры, ссылающиеся друг на друга, обнаруживает отдельный сборщик циклов, модуль gc. Сборка мусора является трудоёмкой операцией, поэтому некоторые языки откладывают её, пока свободное пространство не подойдёт к концу: программы на Java, если не ограничить их при запуске, часто занимают много памяти из-за старых объектов, ожидающих очередной сборки.
Демонстрация:
память
01
Размеры объектов по sys.getsizeof
02
Подсчёт главы и кеш малых целых
03
Миллион отсчётов АЦП в пяти контейнерах
04
Машинный эпсилон и вычитание близких
Что наблюдается
Десять чисел: 136 + 280 = 416 байт. Миллион отсчётов: list — 34,7 МБ, uint16 — 2,0 МБ. 1 + 1.2e-16 ≠ 1, хотя 1.2e-16 < eps.
root@lab — запись со стенда
Демонстрация. Сценарий memory.py сначала печатает размеры объектов по sys.getsizeof: целое 42 — 28 байт, целое 2**100 — 40 байт, поскольку длинная арифметика хранит число частями по 30 бит, число 1.0 — 24 байта, пустой список — 56, список list(range(1, 11)) — 136 = 56 + 8·10 байт ссылок. Десять различных целых вне кеша занимают ещё 280 байт, и подсчёт главы даёт 416 байт против 40 в C++. Следующие строки показывают кеш малых целых: int('7') is int('7') возвращает True — оба вызова дают один и тот же объект, — а int('1000') is int('1000') возвращает False; числа 1–10 в списке являются общими объектами кеша, и список добавляет к ним 136 байт. Второй запуск измеряет модулем tracemalloc память миллиона отсчётов 12-битного АЦП в пяти контейнерах. Список чисел с плавающей точкой занимает 32,4 МБ, по 8 байт ссылки и 24 байта объекта на отсчёт; список целых кодов — 34,7 МБ; array('H') и массив NumPy uint16 — по 2,0 МБ, то есть два байта на отсчёт, в 17 раз меньше списка; массив float64 — 8,0 МБ. Третий запуск обращается к точности double: машинный эпсилон равен 2.220446049250313e-16 = 2**-52, мантисса содержит 53 разряда. Добавка eps/2 к единице теряется при округлении, а добавка 1.2e-16, меньшая эпсилон, различима: эпсилон является шагом сетки у единицы, а не наименьшей различимой добавкой. Шаг виден и в шестнадцатеричной записи: следующее за единицей число отличается последним битом мантиссы. Число 2**53 + 1.0 равно 2**53: целые выше 2⁵³ представимы не все. Вычитание близких величин теряет точность: разность энергий (10¹² + 0,1) − 10¹² эВ вычисляется как 0,0999755859375 эВ, ошибка 0,02 %, а (1 + 10^−15) − 1 — с ошибкой 11 %.
Массив
постоянного размера
numbers: десять чисел int по 4 байта, нулевой элемент по адресу 1000
адрес(i) = адрес(0) + i · размер адрес(1) = 1000 + 1 · 4 = 1004 int numbers[10]; // C std::array<int, 10> numbers; // C++
01
Чтение по индексу
одно вычисление адреса — O(1)
02
Запись по индексу
так же O(1)
03
Размер
задан при создании и не меняется
Изложение. Структурой данных называется способ организации информации в памяти, позволяющий выполнять с ней определённый набор операций. Простейшая структура — массив постоянного размера: он хранит элементы одного типа в одном непрерывном участке памяти, нулевой элемент в начале участка и далее по порядку, без пропусков. Адрес участка программа получает в момент создания массива от распределителя памяти — функции malloc и её аналогов — или от указателя стека. Пусть массив numbers из десяти целых чисел типа int начинается с адреса 1000. Каждое число занимает 4 байта, нулевой элемент занимает байты с 1000 по 1003, и элемент с индексом 1 записан по адресу 1004; в общем случае адрес элемента i равен адресу начала плюс i, умноженному на размер элемента. Поэтому чтение и запись по индексу требуют одного вычисления адреса и выполняются за O(1), независимо от длины массива. Других операций у массива постоянного размера нет: добавить элемент или записать объект другого типа нельзя. В C такой массив объявляется как int numbers[10], в C++ — как std::array<int, 10>. Непрерывное расположение обеспечивает и второе преимущество, не видное в O-нотации: процессор загружает память не по байту, а строками кеша длиной 64–128 байт, и последовательный проход по массиву использует каждую загруженную строку целиком.
Динамический
массив
size = 6 занятых ячеек, capacity = 8 выделенных
Пояснение
list в Python — динамический массив ссылок, а не связный список: insert(0, x) и pop(0) требуют O(n).
Операция Сдвигов Сложность
вставка в начало n O(n)
вставка в позицию i n − i O(n − i)
добавление в конец 0 O(1)
удаление из начала n − 1 O(n)
удаление с конца 0 O(1)
Изложение. Заранее не всегда известно, какая длина массива понадобится, поэтому необходима возможность дописывать элементы в уже созданный массив. Динамический массив хранит два размера: size — число занятых ячеек и capacity — ёмкость выделенного участка, причём size ≤ capacity. Элемент можно вставить в любую позицию i, однако все элементы правее неё приходится сдвинуть на одну позицию, и вставка требует O(n − i) операций. Худший случай — вставка в начало, O(n), лучший — добавление в конец, O(1), пока есть свободная ёмкость. Удаление симметрично: элементы правее удаляемого сдвигаются влево, и удаление из начала требует O(n), а с конца — O(1). В C++ динамический массив реализован классом std::vector, поэтому такие массивы иногда называют векторами. В Python динамический массив представлен классом list, и, несмотря на название, он является массивом ссылок, а не связным списком: обращение по индексу выполняется за O(1), а insert(0, x) и pop(0) сдвигают все элементы. Рост списка уже наблюдался в лекции 4 через sys.getsizeof; следующий слайд объясняет, почему добавление в конец остаётся дешёвым и тогда, когда ёмкость исчерпана.
Реаллокация
и амортизация
рост на 100 ячеек: S = 100 + 200 + … ≈ n²/200 → O(n²) рост вдвое, 2^k < n ≤ 2^(k+1): S = 1 + 2 + … + 2^k = 2^(k+1) − 1 ≤ 2n рост в r раз: S < n · r/(r − 1) r = 2 → 2 копии, r = 1,125 → 9 копий
01
Реаллокация
новый участок и копирование всех элементов — O(n)
02
Амортизация
редкие дорогие добавления в среднем дают O(1)
03
CPython
ёмкость растёт примерно в 1,125 раза
Пояснение
realloc расширяет блок на месте, если за ним свободно: оценка описывает худший случай.
Изложение. Когда ёмкость исчерпана, массив необходимо расширить. Расширить его на месте удаётся не всегда: если за участком в памяти уже лежат данные других объектов, расширение перезаписало бы их. Тогда программа производит реаллокацию: выделяет новый, больший участок и копирует туда накопленные элементы, что требует O(n) операций. Почему при этом добавление в конец требует в среднем O(1)? Рассмотрим две стратегии роста. Если увеличивать ёмкость каждый раз на 100 ячеек, реаллокации происходят в сто раз реже, чем при росте на одну ячейку, но при каждой из них копируется всё накопленное: 100 + 200 + … ≈ n²/200 копирований, то есть O(n²) в сумме и O(n) на одно добавление. Если же увеличивать ёмкость вдвое, то при 2^k < n ≤ 2^(k+1) суммарное число копирований равно 1 + 2 + … + 2^k = 2^(k+1) − 1 ≤ 2n, и на одно добавление приходится не более двух копирований. В общем случае при росте в r раз сумма копирований меньше n·r/(r − 1): геометрическая прогрессия сходится. Оценку такого рода называют амортизированной: редкие дорогие операции распределяются по всем дешёвым, и средняя стоимость остаётся постоянной. CPython растит список не вдвое, а примерно в 1,125 раза: новая ёмкость вычисляется как newsize + newsize/8 + 6 с округлением вниз до кратного четырём. Копирований при этом больше — оценка даёт не более девяти на элемент вместо двух, — но их число по-прежнему линейно, а запас ёмкости у больших списков составляет около 12,5 %. Отметим, что функция realloc расширяет блок на месте, если за ним свободно, и копирования не происходит вовсе; приведённая оценка описывает худший случай.
Демонстрация:
реаллокация
01
Ёмкость списка по sys.getsizeof
02
86 реаллокаций до 10⁶ элементов
03
Модель главы: рост на 100 и вдвое
04
Рисунок ёмкости и копирований
Что наблюдается
Ёмкость растёт в 1,125 раза: не более 8,45 копирования на элемент, append — 7–9 нс. Рост на 100 ячеек при n = 10⁶ — 4999,5.
root@lab — запись со стенда
Демонстрация. Сценарий realloc.py добавляет элементы в список по одному и вычисляет ёмкость по sys.getsizeof: 56 байт заголовка и 8 байт на каждую выделенную ячейку. Ёмкость меняется при длинах 1, 5, 9, 17, 25, 33, 41, 53, 65, … и принимает значения 4, 8, 16, 24, 32, 40, 52, 64, 76, 92, 108, 128, 148: сначала рост вдвое, затем всё ближе к 1,125. До миллиона элементов происходит 86 реаллокаций, последняя — при длине 938 737. Если бы каждая реаллокация копировала все элементы, на один элемент пришлось бы 8,45 копирования — меньше оценки r/(r − 1) = 9. Время append составляет 7,1, 7,0 и 8,8 нс на элемент при n = 10⁴, 10⁵ и 10⁶, то есть при росте n в сто раз увеличивается лишь на четверть: амортизированная оценка O(1) подтверждается измерением. Второй запуск применяет модель главы, в которой каждая реаллокация копирует всё накопленное. При росте на 100 ячеек число копирований на одно добавление растёт пропорционально n: 4,5, 49,5, 499,5 и 4999,5 при n от 10³ до 10⁶. При росте вдвое оно колеблется между 1 и 2 — 1,02, 1,64, 1,31 и 1,05 — в зависимости от того, насколько последняя реаллокация опередила n. Рост по формуле CPython даёт от 7,56 до 8,45, и при n = 10⁶ модель совпадает с измеренным списком. Третий запуск строит рисунок. В лекции 4 ступени ёмкости списка уже наблюдались; здесь они получают количественное объяснение.
Результат:
реаллокация
Что наблюдается
Ступени ёмкости растут вместе с длиной. Рост на 100 ячеек — прямая с наклоном 1, рост вдвое и CPython — полосы около 1–2 и 7–9 копирований.
Демонстрация. Слева — ёмкость списка CPython в зависимости от длины при добавлении по одному элементу: ступени над пунктирной прямой длины. Каждая ступень соответствует реаллокации, ширина ступеней растёт вместе с длиной, а зазор между ёмкостью и длиной остаётся пропорциональным длине — признак геометрического роста. Справа в логарифмических осях — число копирований на одно добавление при трёх стратегиях. Рост на 100 ячеек даёт прямую с наклоном 1: стоимость добавления растёт пропорционально n и при миллионе элементов достигает пяти тысяч копирований. Рост вдвое и рост CPython дают горизонтальные полосы около 1–2 и 7–9 копирований; зубцы соответствуют значениям n, оказавшимся сразу после очередной реаллокации. Таким образом, рисунок позволяет отличить арифметический рост от геометрического одним взглядом: стоимость, не зависящая от n, и представляет собой амортизированную O(1).
Списки и очереди
Связный список, стек, очередь и дек: интерфейс и стоимость операций
04
Алгоритм и сложность
Время и память
Память и массивы
Списки и очереди
Рекурсия
Сортировки
Изложение. Четвёртая часть рассматривает структуры, у которых важен не столько способ хранения, сколько интерфейс: связный список, стек, очередь и дек. Для каждой структуры выясняется, какие операции она выполняет быстро, какие медленно и почему, и как она представлена в Python.
Связный
список
class Node: def __init__(self, value, nxt=None): self.value = value self.nxt = nxt head = Node(7, Node(2, Node(9))) head.nxt = Node(5, head.nxt) # вставка — O(1)
01
Вставка после узла
две ссылки — O(1)
02
Доступ по индексу
проход от головы — O(n)
03
Удаление узла
требуется предыдущий узел, его поиск — O(n)
Пояснение
std::list в C++, LinkedList в Java; list в Python — динамический массив.
Изложение. Элементы массива расположены в памяти последовательно, поэтому вставка в начало и удаление из начала требуют O(n) операций. Структурой, не требующей при этом сдвигать остальные элементы, является связный список. В нём каждый элемент — узел — хранит значение и ссылку на следующий узел; последний узел ссылается в никуда, в Python — на None. Точку входа называют головой списка. Вставка нового узла после известного требует изменения двух ссылок и выполняется за O(1), независимо от длины списка. Платой за это является доступ по индексу: узлы лежат в памяти вразброс, и до нужного приходится идти от головы, перебирая звенья, что требует O(n) операций. Двигаться по односвязному списку можно только в одном направлении, поэтому быстро удалить узел не удаётся: неизвестно, какой узел на него ссылается, и поиск предыдущего требует O(n). Двусвязный список хранит ссылки в обе стороны и позволяет удалить узел за O(1). Узлы, разбросанные по памяти, удобны, когда элементов много и разместить их единым блоком не удаётся, однако последовательный проход по ним медленнее, чем по массиву: каждый переход к следующему узлу представляет собой обращение по новому адресу. Связный список присутствует в стандартных библиотеках многих языков: std::list в C++, LinkedList в Java. В Python класс list остаётся динамическим массивом, хотя и называется списком.
Стек:
LIFO
class Stack: def __init__(self): self.items = [] def push(self, item): self.items.append(item) def pop(self): return self.items.pop() def peek(self): return self.items[-1] def size(self): return len(self.items)
01
push, pop
добавление и извлечение на вершине — O(1)
02
peek, size
вершина без извлечения и число элементов
03
Применения
«назад» в браузере, отмена, стек вызовов, обход в глубину
Пояснение
Стек — интерфейс, а не способ хранения: реализуется на массиве или на связном списке.
Изложение. Стек работает по принципу LIFO — last in, first out, «последним пришёл, первым ушёл»: извлечь можно только элемент, положенный последним. Наглядной моделью служит стопка книг в коробке: взять можно только верхнюю. В отличие от массивов и связных списков, стек представляет собой прежде всего интерфейс к данным, а не способ их хранения. Он определяет операции push — добавить элемент на вершину, pop — извлечь элемент с вершины, peek — получить вершину без извлечения, size — узнать число элементов, иногда также isEmpty. Обычно стек реализуют на массиве: добавление и удаление в конце динамического массива выполняются за O(1). Класс Stack главы делает именно это, используя список Python; тело класса записано с отступом в четыре пробела по PEP 8. Если элементов очень много и выделить под них единый блок невозможно, предпочтительнее связный список. На стеке реализована кнопка «назад» в браузере и отмена последних действий в редакторах, на нём основан обход дерева или графа в глубину, и, наконец, сам исполняемый код опирается на стек вызовов, которому посвящена следующая часть.
Очередь
и дек
from collections import deque buf = deque() buf.append(1) # [1] buf.append(2) # [1, 2] buf.appendleft(0) # [0, 1, 2] buf.popleft() # 0, из начала buf.pop() # 2, с конца window = deque(maxlen=1000)
01
Очередь
FIFO: вход в хвост, выход из головы
02
Дек
добавление и удаление с обоих концов — O(1)
03
collections.deque
связанные блоки по 64 ссылки
04
queue.Queue
обмен данными между потоками
Для физика
deque(maxlen=1000) — скользящее окно из последней тысячи отсчётов детектора.
Изложение. Очередь работает по принципу FIFO — first in, first out: первым извлекается элемент, добавленный раньше всех, как в очереди к кассе. Как и стек, очередь задаёт интерфейс: push добавляет элемент в конец, pop извлекает из начала, peek возвращает начало без удаления, size — число элементов; реализуют её так, чтобы и вставка, и удаление выполнялись за O(1). Дек, от double-ended queue, представляет собой двустороннюю очередь: он позволяет добавлять и удалять элементы с любого конца за O(1). Дек обобщает обе структуры: работа с одним концом даёт стек, вход с одного конца и выход с другого — очередь. В Python дек находится в модуле collections. Класс deque устроен как цепочка связанных блоков по 64 ссылки: добавление к любому концу не затрагивает уже уложенные элементы, а при заполнении крайнего блока к цепочке присоединяется новый. Отдельного класса простой очереди для однопоточного кода в Python нет, её роль выполняет дек; классы queue.Queue и queue.SimpleQueue решают другую задачу — обмен данными между потоками, рассмотренный в лекции 9. Для физика дек ограниченной длины является готовым скользящим окном: deque(maxlen=1000) хранит последнюю тысячу отсчётов детектора, и при добавлении нового отсчёта самый старый удаляется автоматически, за O(1).
Демонстрация:
список и дек
01
Заполнение с начала: insert(0, x) и appendleft
02
N = 10³, 10⁴, 10⁵ и отношение времён
03
Скользящее окно из 1000 отсчётов
04
Обращение к середине списка и дека
Что наблюдается
Отношение растёт с N: 7, 48, 406. Окно: список 26,6 мс, дек 1,8 мс. Середина: 0,006 мкс у списка, 0,53 мкс у дека.
root@lab — запись со стенда
Демонстрация. Сценарий ends.py повторяет замеры главы. Первый запуск заполняет структуру с начала: N вставок list.insert(0, x) против deque.appendleft(x), минимум из нескольких запусков. При N = 1000, 10 000 и 100 000 список требует 0,196, 7,37 и 664 мс, дек — 0,029, 0,153 и 1,64 мс. Важны не сами числа, а последний столбец: отношение составляет 7, 48 и 406 и растёт вместе с N. Рост отношения является признаком разной сложности: если бы дек был просто реализован эффективнее, выигрыш оставался бы постоянным, а здесь время списка растёт как O(n²) — n вставок по O(n), — а время дека как O(n). Таблица главы, 13, 107 и 996 раз, воспроизводится по характеру роста, абсолютные отношения на стенде меньше. Второй запуск решает задачу обработки потока: среднее по скользящему окну из 1000 последних отсчётов на потоке из 200 000. Список с pop(0) затрачивает 26,6 мс, дек с maxlen — 1,8 мс, в 15 раз меньше; среднее по последнему окну в обоих случаях равно 99,937 мВ. Последние строки показывают обратную сторону дека: обращение к середине структуры из 100 000 элементов занимает 0,0062 мкс у списка и 0,53 мкс у дека, в 86 раз дольше. Доступ к середине дека требует перехода по блокам: до элемента с индексом 50 000 приходится пройти 781 блок, тогда как к концам дек обращается за 0,009–0,011 мкс. Время доступа к середине дека заметно меняется от запуска к запуску, поэтому для выводов достаточно числа переходов между блоками и порядка величины.
Выбор
структуры
Структура Индекс В конец В начало Поиск
массив постоянного размера O(1) нет нет O(n)
list, динамический массив O(1) O(1)* O(n) O(n)
связный список O(n) O(1)** O(1) O(n)
deque O(n) O(1) O(1) O(n)
* амортизированно; ** при ссылке на хвост, иначе O(n)
Пояснение
insert(0, x) или pop(0) в цикле означают, что требуется дек.
Важно
Хеш-таблицы и деревья поиска снимают ограничение O(n) на поиск по значению — лекция 18.
Изложение. Сводная таблица главы позволяет выбрать структуру под задачу. Массив постоянного размера даёт доступ по индексу за O(1), но не допускает вставок. Динамический массив, list в Python, даёт доступ по индексу за O(1) и добавление в конец за O(1) амортизированно, однако вставка в начало требует O(n). Связный список вставляет в начало за O(1), но доступ по индексу требует O(n); добавление в конец списка, хранящего только голову, тоже требует O(n), а при ссылке на хвост — O(1), и так устроены реализации стандартных библиотек. Дек выполняет операции с обоими концами за O(1), а доступ к середине требует O(n). Правило выбора, работающее почти всегда, таково: если необходим произвольный доступ по индексу — список; если необходимы быстрые операции с обоими концами, а середина лишь пробегается целиком, — дек; вызовы insert(0, x) или pop(0) внутри цикла почти наверняка означают, что требуется дек. Универсальной структуры не существует: если элементы постоянно добавляются и снимаются с обоих концов, а в коде используется список, программа выполняет O(n) операций там, где достаточно O(1). Последний столбец таблицы объединяет все структуры: поиск по значению у каждой требует O(n) операций. Это ограничение снимают хеш-таблицы, в которых поиск выполняется в среднем за O(1), и сбалансированные деревья поиска с их O(log n); им посвящена лекция 18.
Рекурсия
Стек вызовов, рекурсивный и базовый случаи, пределы глубины, перебор вариантов
05
Алгоритм и сложность
Время и память
Память и массивы
Списки и очереди
Рекурсия
Сортировки
Изложение. Пятая часть посвящена стеку вызовов и рекурсии: как программа хранит локальные переменные и адреса возврата, из чего складывается корректная рекурсия, какие пределы глубины устанавливают интерпретатор и операционная система, как рекурсию заменяют циклом и как с её помощью перебирают варианты.
Стек
вызовов
вершина стека
print_horoscope
name = 'ГОША', адрес возврата
say_hello
name = 'Гоша', адрес возврата
<module>
вызов say_hello('Гоша')
дно стека
def say_hello(name): print(f"Привет, {name}") print_horoscope(name.upper()) print(f"Пока, {name}, хорошего дня!") def print_horoscope(name): print(f"{name}! Сегодня подходящий день") say_hello('Гоша')
01
Кадр
адрес возврата, аргументы, локальные переменные
02
Вызов и возврат
кадр кладётся на вершину и снимается после возврата
Изложение. Код программы разделён на функции, и вся программа представима как одна функция, вызывающая множество других. Пусть функция main вызывает функцию A, та вызывает B, а B — функцию C. После возврата из C управление должно вернуться в B, туда, откуда C была вызвана, поэтому при каждом вызове на стек вызовов помещается кадр: адрес инструкции, ожидающей выполнения после возврата, аргументы функции и её локальные переменные. Кадр выполняющейся функции лежит на вершине, под ним — кадры функций, ожидающих возврата. Рассмотрим пример главы: функция say_hello приветствует пользователя по имени и вызывает print_horoscope, передавая ей имя в верхнем регистре. При входе в say_hello на стеке выделяется кадр с аргументом name = 'Гоша'. Вызов print_horoscope кладёт поверх него новый кадр со своим name = 'ГОША'. Когда print_horoscope завершается, её кадр снимается, управление возвращается по сохранённому адресу, и в say_hello переменная name снова равна 'Гоша'. Таким образом, одноимённые переменные разных вызовов не конфликтуют: каждая существует в своём кадре. Вызовы print и upper на схеме не показаны, хотя и они помещаются на стек и снимаются с него. Кадр существует, пока функция не вернула управление, поэтому глубина стека в каждый момент равна длине цепочки незавершённых вызовов.
Рекурсивный
и базовый случаи
def stairs_builder(n): if n == 0: # базовый случай return build_step() print(f"Осталось построить {n - 1} ступеней.") stairs_builder(n - 1) # рекурсивный случай def factorial(n): if n == 1 or n == 0: return 1 return n * factorial(n - 1)
01
Рекурсивный случай
сводит задачу к меньшей
02
Базовый случай
ответ без рекурсивного вызова
03
Ошибки
нет базового случая или параметр не меняется
04
Память
O(d) на стеке, d — глубина рекурсии
Важно
Бесконечная рекурсия в Python не зависает: примерно через 1000 вызовов возникает RecursionError.
Изложение. Функция, вызывающая саму себя с изменёнными аргументами, называется рекурсивной; она пользуется тем же стеком вызовов. Корректная рекурсия состоит из двух частей. Рекурсивный случай сводит задачу к меньшей: функция stairs_builder главы строит одну ступеньку, сообщает, что осталось построить n − 1 ступеней, и вызывает себя для оставшихся. Базовый случай останавливает углубление и вычисляет ответ явно, без рекурсивного вызова: при n = 0 строить больше нечего. Факториал записывается так же: n! = n·(n − 1)!, а 0! = 1! = 1 составляет базовый случай. Типичных ошибок две: пропущенный базовый случай и параметр, который в рекурсивном вызове не меняется или меняется не в сторону базового случая; в обоих случаях цепочка вызовов не обрывается. Необходимо убедиться, что при любом допустимом входе функция за конечное число шагов сводится к базовому случаю. Каждый уровень рекурсии хранит на стеке свой кадр, поэтому рекурсия глубины d требует O(d) памяти, и вычисление n! расходует O(n). Бесконечная рекурсия не работает вечно и не приводит к зависанию: каждый вызов расходует память стека, а она ограничена. В Python углубление останавливает сам интерпретатор: примерно после тысячи вложенных вызовов возникает исключение RecursionError.
Глубина
рекурсии
01
Предел интерпретатора
sys.getrecursionlimit() = 1000, далее RecursionError
02
Python 3.10 и ранее
каждый вызов расходовал стек C: сбой на десятках тысяч уровней
03
Python 3.11 и новее
вызов Python из Python без стека C: 10⁶ уровней
04
Через код на C
через __repr__ стек C расходуется по-прежнему
import sys sys.getrecursionlimit() # 1000 sys.setrecursionlimit(200_000) depth(100_000) # 100000
Важно
Стек потока ОС — обычно 8 МБ; его исчерпание завершает процесс аварийно, без исключения.
Изложение. Ограничений глубины рекурсии два. Первое устанавливает интерпретатор: по умолчанию sys.getrecursionlimit() возвращает 1000, и при превышении предела возникает RecursionError; функция sys.setrecursionlimit() позволяет поднять порог. Второе ограничение — стек потока операционной системы, обычно 8 МБ; его достигают, только сняв первое. Здесь важна версия Python. До версии 3.10 включительно каждый вызов питоновской функции рекурсивно вызывал функцию интерпретатора на C и расходовал стек C, поэтому даже с поднятым порогом рекурсию глубже нескольких десятков тысяч уровней довести было нельзя: на Python 3.9 глубина 30 000 проходит, а на 50 000 процесс завершается аварийно. Начиная с 3.11 вызов одной питоновской функции из другой обходится без рекурсии на C, и чистая рекурсия с поднятым порогом достигает миллиона уровней, расходуя память кучи. Если же цепочка вызовов проходит через код на C, например через метод __repr__, вызываемый встроенной функцией, стек C по-прежнему расходуется на каждом уровне, и в 3.11 такая рекурсия на глубине в несколько десятков тысяч уровней завершает процесс аварийно, без исключения. В 3.12 предел интерпретатора распространяется только на код Python, а рекурсию через код на C ограничивает отдельный механизм. Фактический предел, таким образом, определяется памятью, а для вызовов через код на C — размером стека потока. В разных языках переполнение стека проявляется по-разному: исключением StackOverflowError в Java или ошибкой сегментации в программе на C.
Демонстрация:
стек вызовов
01
Кадры внутри print_horoscope
02
Кадры в базовом случае factorial(4)
03
Предел 1000 и RecursionError
04
Явный стек и поднятый предел
Что наблюдается
У каждого кадра своё name: 'ГОША' и 'Гоша'. depth(5000) — RecursionError; после setrecursionlimit(200_000) глубина 10⁵ проходит. Рекурсия в 2,5 раза медленнее цикла.
root@lab — запись со стенда
Демонстрация. Сценарий stack.py выполняет пример главы с гороскопом и печатает кадры стека модулем inspect. Внутри print_horoscope на вершине стека лежит её кадр с name = 'ГОША', под ним — кадр say_hello с name = 'Гоша', на дне — кадр модуля: одноимённые переменные разных вызовов хранятся в разных кадрах, и после возврата say_hello печатает прежнее имя. В базовом случае factorial(4) стек содержит четыре кадра factorial с n = 1, 2, 3 и 4 — по кадру на каждый незавершённый вызов, — и результат 4! = 24. Второй запуск показывает пределы. sys.getrecursionlimit() возвращает 1000; рекурсия глубины 900 проходит, глубины 5000 — завершается исключением RecursionError. Хвостовая рекурсия, в которой рекурсивный вызов является последним действием функции, не помогает: CPython такие вызовы не оптимизирует, и depth_tail(5000) также завершается RecursionError. Затем строится цепочка из 100 000 элементов, например элементов канала транспортировки, каждый из которых ссылается на следующий. Рекурсивный подсчёт её длины завершается RecursionError, а тот же подсчёт с явным стеком — списком, в который складываются ещё не посещённые элементы, — возвращает 100 000. После sys.setrecursionlimit(200_000) рекурсия глубины 100 000 проходит, и рекурсивный подсчёт длины тоже: на Python 3.12 вызовы между питоновскими функциями не расходуют стек C. Последняя строка сравнивает время: 900 уровней рекурсии занимают 38 мкс, цикл той же длины — 15 мкс, в 2,5 раза меньше. В отличие от лекции 5, где наблюдалось лишь исключение при превышении предела, здесь видны сами кадры и глубина 10⁵, достижимая на современном интерпретаторе.
Рекурсия
и цикл
def factorial(n): accumulator = 1 i = n while i > 1: accumulator *= i i -= 1 return accumulator
def length(e): # явный стек stack, count = [e], 0 while stack: cur = stack.pop() if cur is not None: count += 1 stack.append(cur.nxt) return count
01
Цикл
факториал без стека вызовов: O(1) памяти
02
Явный стек
список вместо стека вызовов: предел — память
03
Хвостовой вызов
CPython его не оптимизирует
Пояснение
Рекурсия экономит время программиста, но не процессора: вызов функции дороже итерации цикла.
Изложение. Против переполнения стека применяются два приёма. Иногда рекурсию можно заменить циклом: факториал вычисляется накоплением произведения в переменной, стек вызовов не растёт вовсе, а память составляет O(1). В общем случае, например при обходе дерева каталогов, вместо стека вызовов используется собственный стек — список, в который складываются ещё не обработанные элементы: такой стек позволяет обойти предел интерпретатора, и ограничением становится лишь доступная память. Функция length подсчитывает длину цепочки именно так. Отдельный случай представляет хвостовая рекурсия, в которой рекурсивный вызов является последним действием функции. Компиляторы ряда языков превращают такой вызов в переход без нового кадра, однако CPython этого не делает: интерпретатор сохраняет полную трассировку стека для сообщений об ошибках, и хвостовая рекурсия расходует стек так же, как обычная. Рекурсия не ускоряет работу программы и, как показало измерение, может её замедлить: каждый вызов функции дороже итерации цикла. Вместе с тем рекурсия экономит время программиста: код, написанный рекурсивно, получается компактным и понятным, и для задач, рекурсивных по своей природе, — обхода деревьев, разбиения пополам, перебора вариантов — он проще и надёжнее цикла, нагруженного счётчиками.
Перебор
вариантов
def generate(n, prefix): if n == 0: print(prefix) return generate(n - 1, prefix + '0') generate(n - 1, prefix + '1') generate(3, '') # 000 001 … 111
«» 0 1 00 01 10 11 000 001 010 011 100 101 110 111
01
Два вызова
prefix + '0' и prefix + '1'
02
Дерево вариантов
глубина n, 2^n листьев
03
Память и время
стек O(n), время O(2^n)
04
Подмножества
1 — предмет взят, 0 — отложен
Для физика
2^n конфигураций n корректоров: при n = 64 — 1,8·10¹⁹, полный перебор невозможен.
Изложение. Рекурсия часто применяется для генерации объектов, устроенных единообразно, например последовательностей. Рассмотрим генерацию всех последовательностей длины n из нулей и единиц. Функция generate принимает число n ещё не дописанных символов и строку prefix, накопленную к этому моменту. Базовый случай — n = 0: строка готова и печатается. В рекурсивном случае функция вызывает себя дважды: в первый раз дописывая к prefix цифру 0, во второй — цифру 1, и каждый раз уменьшая n на единицу. Решение удобно представлять бинарным деревом, растущим от пустой строки: переход влево соответствует приписыванию 0, вправо — приписыванию 1. Дерево имеет глубину n, а его листья — ровно 2^n построенных последовательностей. Глубина рекурсии, а значит и память стека, составляет всего O(n), тогда как число вызовов пропорционально числу листьев, O(2^n); поскольку каждый лист строит и печатает строку длины n, точная оценка времени составляет O(n·2^n). К той же схеме сводится перебор подмножеств: если каждый из n предметов может быть взят или отложен, набор описывается строкой, где 1 означает взятый предмет, а 0 — отложенный, и наборов ровно 2^n. Для физика такую структуру имеет, например, перебор конфигураций n корректоров, каждый из которых включён или выключен: при n = 20 конфигураций около миллиона, при n = 64 — 1,8·10¹⁹, и полный перебор невозможен; необходимо применять методы, отбрасывающие заведомо неудачные ветви дерева.
Демонстрация:
перебор
01
generate(3, '') из главы
02
Подсчёт конфигураций при n от 12 до 22
03
Время на одну конфигурацию
04
Оценка для n = 30, 40, 64
Что наблюдается
Каждые два разряда — вчетверо больше конфигураций; 63 нс на конфигурацию. n = 40 — около 19 ч, n = 64 — около 37 000 лет.
root@lab — запись со стенда
Демонстрация. Сценарий generate.py сначала выполняет пример главы generate(3, ''): восемь строк от 000 до 111 и проверку 2³ = 8. Второй запуск не печатает последовательности, а считает листья дерева рекурсии для n от 12 до 22 через каждые два разряда: от 4096 до 4 194 304 конфигураций. Время растёт с 0,001 до 0,264 с. При каждом шаге на два разряда число конфигураций увеличивается вчетверо, а измеренное отношение времён при n от 14 до 20 составляет лишь 2,6–3,7 и достигает 4,0 только при n = 22. Отклонение систематическое, а не случайное: каждое n измерено одним запуском без повторения, и в начале прогона одна конфигурация обходилась дороже — около 140 нс при n = 16 и 100 нс при n = 18 против 64 и 63 нс при n = 20 и 22. В установившемся режиме на одну конфигурацию приходится 63 нс. Экстраполяция по этому числу даёт около минуты для n = 30, около 19 часов для n = 40 и около 37 000 лет для n = 64. Таким образом, экспоненциальная сложность ограничивает полный перебор несколькими десятками элементов, и уменьшение константы эту границу заметно не сдвигает: каждый лишний разряд удваивает время, и ускорение в тысячу раз позволяет добавить лишь десять разрядов.
Сортировки
Устойчивость, ключ, слияние, быстрая сортировка, встроенная сортировка Python и подсчёт
06
Алгоритм и сложность
Время и память
Память и массивы
Списки и очереди
Рекурсия
Сортировки
Изложение. Шестая часть посвящена сортировкам — самой изученной задаче информатики, на которой впервые проявляются оценка O(n log n), нижняя граница для сортировок сравнением и устойчивость. Рассматриваются критерии выбора алгоритма, сортировка по ключу, сортировки слиянием и быстрая, встроенная сортировка Python и сортировка подсчётом, обходящая границу n log n.
Зачем
сортировать
01
Бинарный поиск
O(log n) — только на упорядоченных данных
02
Выборка лучших
«10 лучших» — первые элементы порядка
03
Узкое место
сортировка нередко определяет время решения
Ситуация Выбор алгоритма
старый сервер, отчёт через неделю медленный, O(1) памяти
новый сервер, обновление каждую секунду быстрый, O(n) памяти
Пояснение
Критерии: срочность ответа, объём данных, свободная память. Лучшей сортировки в общем случае нет.
Изложение. Сортировки представляют собой алгоритмы, упорядочивающие данные: контакты в телефоне показываются по алфавиту, расписание составляется по времени отправления, поисковая система ранжирует ответы по релевантности. Во многих задачах с упорядоченными данными работать проще. Найти элемент быстрее чем за O(n), воспользовавшись бинарным поиском, возможно только в упорядоченном массиве. Таблицу «10 лучших» проще собрать из списка, упорядоченного по нужному признаку. Нередко сортировка становится узким местом всего решения. Лучшей сортировки в общем случае не существует: для данных разной природы и разных ограничений выигрывают разные алгоритмы. Глава иллюстрирует выбор двумя ситуациями. В первой данные лежат на старом сервере, где почти не осталось свободной памяти, а отчёт требуется через неделю: предпочтительнее более медленный алгоритм, обходящийся O(1) дополнительной памяти. Во второй на новом мощном сервере каждую секунду обновляется рейтинг, и список лучших необходимо пересчитывать без задержек: предпочтительнее быстрый алгоритм, требующий больше памяти. Обстоятельств, определяющих выбор, три: срочность ответа; объём входных данных — тысячу элементов любой алгоритм упорядочит за неощутимое время, а на пяти миллионах разница становится решающей; объём свободной памяти, позволяющий или не позволяющий собрать результат во второй копии массива.
Устойчивость
и ключ
events.sort(key=lambda e: e.time) events.sort(key=lambda e: e.det) # время сохранено sorted(events, key=lambda e: (e.det, e.time)) from functools import cmp_to_key def cmp(a, b): # -1, 0 или 1 ka, kb = (a.det, a.time), (b.det, b.time) return (ka > kb) - (ka < kb) sorted(events, key=cmp_to_key(cmp))
01
Устойчивость
равные по ключу сохраняют исходный порядок
02
Ключ key(x)
вычисляется один раз на элемент
03
Компаратор
функция двух аргументов, в ключ — через cmp_to_key
04
Составной ключ
кортеж: сначала детектор, затем время
Изложение. Сортировка, сохраняющая взаимный порядок элементов с равным значением сравниваемого признака, называется устойчивой. Разница важна, когда данные сортируют несколько раз подряд по разным ключам: устойчивая сортировка сохраняет результат предыдущего прохода, а неустойчивая его перемешивает. Пусть события двух детекторов упорядочены по времени; после второго прохода по номеру детектора устойчивая сортировка оставит события каждого детектора в порядке времени. Признак, по которому элементы сравниваются, называется ключом сортировки. Функции сортировки задают порядок двумя способами. Функция одного аргумента key вычисляет ключ для каждого объекта; sorted и list.sort в Python вызывают её один раз на элемент и сравнивают вычисленные ключи. Компаратор — функция двух аргументов, сравнивающая объекты напрямую, — задаёт порядок гибче, и сортировку по любому ключу легко переписать через компаратор. Обратное преобразование выполняет обёртка functools.cmp_to_key; однако компаратор вызывается на каждом сравнении, и не всякий компаратор задаёт корректный порядок, поэтому обычно проще пользоваться ключом. Сортировка по нескольким признакам сразу записывается составным ключом-кортежем: кортежи сравниваются поэлементно, сначала по детектору, затем по времени, и результат совпадает с двумя последовательными проходами устойчивой сортировки.
Демонстрация:
устойчивость
01
События двух детекторов по времени
02
Второй проход sorted() по детектору
03
Составной ключ и cmp_to_key
04
Пирамидальная и быстрая сортировки
Что наблюдается
sorted() сохранила порядок по времени внутри детектора, пирамидальная — нет. Быстрая с тремя списками сохранила его в 1000 испытаниях из 1000, разбиение на месте — в 0.
root@lab — запись со стенда
Демонстрация. Сценарий stable.py берёт десять событий двух детекторов, A и B, с метками времени в наносекундах и упорядочивает их по времени. Второй проход sorted() по детектору даёт A:12 A:21 A:23 A:29 A:48 A:61, затем B:18 B:40 B:60 B:71: внутри каждого детектора порядок по времени сохранён, поскольку sorted() устойчива. Составной ключ (детектор, время) и компаратор, обёрнутый functools.cmp_to_key, дают тот же порядок за один проход. Пирамидальная сортировка по детектору, реализованная в сценарии, неустойчива: события детектора A выходят в порядке 61, 23, 12, 29, 21, 48, и порядок по времени потерян. Второй запуск проверяет устойчивость быстрой сортировки. Тысячу раз сортируются по детектору 40 событий четырёх детекторов, заранее упорядоченных по времени. Быстрая сортировка главы, раскладывающая элементы по трём новым спискам в исходном порядке, сохранила порядок по времени во всех 1000 испытаниях: она устойчива. Быстрая сортировка с разбиением Ломуто на месте не сохранила его ни разу: при разбиении элементы переставляются через весь массив. Таким образом, неустойчивость является свойством разбиения на месте, а не быстрой сортировки вообще, и версия с тремя списками обеспечивает устойчивость ценой O(n) дополнительной памяти.
Сортировка
слиянием
def merge_sort(nums): if len(nums) <= 1: return nums middle = len(nums) // 2 return merge(merge_sort(nums[:middle]), merge_sort(nums[middle:])) def merge(left, right): merged, i, j = [], 0, 0 while i < len(left) and j < len(right): if right[j] < left[i]: # равные — из левой merged.append(right[j]) j += 1 else: merged.append(left[i]) i += 1 return merged + left[i:] + right[j:]
[5 2 9 1] → [5 2] [9 1] → [5] [2] [9] [1] [5] [2] [9] [1] → [2 5] [1 9] → [1 2 5 9]
01
Время
O(n log n) и в худшем случае: log₂ n уровней
02
Память
O(n): живы только списки текущей ветви
03
Устойчивость
при равенстве берётся элемент левой части
Изложение. Сортировка слиянием складывается из трёх действий, повторяемых на каждом уровне: массив разбивается на две части примерно одинакового размера, каждая часть сортируется тем же алгоритмом рекурсивно, а две упорядоченные части сливаются в одну. Базовый случай — массив из одного элемента, уже отсортированный. Слияние выполняется одним проходом: из головных элементов двух частей каждый раз забирается меньший, а хвост, оставшийся от одной из частей, дописывается целиком. Оценим сложность. Разбиение пополам продолжается, пока длина части не станет равной единице, поэтому глубина рекурсии составляет log₂ n уровней. На каждом уровне все части вместе содержат n элементов, и слияния уровня требуют O(n) операций. Получим O(n log n), причём в худшем случае так же, как в среднем: от входных данных глубина не зависит. Дополнительная память составляет O(n). В приведённой реализации срезы и слитые списки создаются заново, однако одновременно существуют только списки текущей ветви рекурсии: n/2 + n/4 + … < n элементов и результат слияния. Сортировка слиянием устойчива: при равенстве головных элементов приоритет отдаётся элементу левой части, и это правило обеспечивает сохранение взаимного порядка равных элементов на каждом уровне, вплоть до последнего слияния.
Быстрая
сортировка
def partition(nums, pivot): less, equal, greater = [], [], [] for num in nums: if num < pivot: less.append(num) elif num > pivot: greater.append(num) else: equal.append(num) return less, equal, greater def quick_sort(nums): if len(nums) <= 1: return nums pivot = random.choice(nums) less, equal, greater = partition(nums, pivot) return quick_sort(less) + equal + quick_sort(greater)
01
Разбиение
меньшие, равные и большие опоры — O(n)
02
Время
O(n log n) в среднем, O(n²) в худшем случае
03
Случайная опора
плохое разбиение зависит от жребия
04
На месте
Ломуто: O(log n) стека в среднем, неустойчива
Пояснение
Версия с тремя списками устойчива, но требует O(n) дополнительной памяти.
Изложение. Быстрая сортировка, предложенная Ч. Хоаром, опирается на стратегию «разделяй и властвуй», но распределяет работу иначе, чем слияние: разделение на части сложнее, а части, отсортированные рекурсивно, объединяются простым дописыванием. Выбирается опорный элемент; элементы, меньшие опоры, раскладываются в один массив, большие — в другой, а равные опоре остаются посередине и в рекурсию больше не передаются: без этой предосторожности массив одинаковых чисел приводил бы к бесконечной рекурсии. Разбиение выполняется одним проходом за O(n). Если опора делит массив примерно пополам, уровней рекурсии O(log n), и сложность составляет O(n log n) в среднем. Если же опорой оказывается максимум или минимум, длина части уменьшается лишь на единицу, уровней становится n, и сложность возрастает до O(n²). Под любое простое фиксированное правило — первый элемент, средний, медиана трёх — можно подобрать вырождающий массив (гарантию O(n log n) даёт лишь опора, равная точной медиане, найденной за линейное время, но такой выбор сильно увеличивает константу); при случайной опоре плохое разбиение зависит не от данных, а от жребия, и вероятность квадратичного исхода исчезающе мала. Версия главы с тремя новыми списками требует O(n) дополнительной памяти, и на практике от неё избавляются, выполняя разбиение на месте: по схеме Ломуто массив проходится один раз, и каждый элемент, меньший опоры, меняется местами с элементом на границе. Памяти такой сортировке почти не требуется — только стек вызовов, O(log n) при удачных разбиениях. Однако разбиение на месте неустойчиво: элементы перебрасываются через весь массив. Версия с тремя списками, напротив, порядок равных сохраняет, что и подтвердила демонстрация устойчивости.
Демонстрация:
сравнения
01
Сравнения при n = 1000 на трёх входах
02
Вставки, слияние, быстрая и sorted()
03
Время при n = 10⁵ против sorted()
04
Рисунок числа сравнений
Что наблюдается
Опора «первый элемент» на упорядоченном входе — 1 000 998 сравнений. sorted() — 999 на упорядоченном входе; слияние и sorted() близки к границе 8529.
root@lab — запись со стенда
Демонстрация. Сценарий sorts.py считает сравнения пяти сортировок обёрткой, увеличивающей счётчик при каждом сравнении, на трёх входах из n = 1000 меток времени: случайный порядок, упорядоченный и почти упорядоченный, в котором каждое событие задержано не более чем на три такта. Сортировка вставками выполняет 250 099 сравнений на случайном входе, около n²/4, и 999 — на упорядоченном. Сортировка слиянием выполняет 8705, 4932 и 5068 сравнений. Быстрая сортировка с опорой «первый элемент» на случайном входе выполняет 17 373 сравнения, а на упорядоченном — 1 000 998 и на почти упорядоченном — 761 522: первый элемент оказывается минимумом или близок к нему, разбиение вырождается, и сложность становится квадратичной. Такие входы часты: события приходят в порядке, почти совпадающем с порядком меток. Случайная опора устраняет вырождение: от 16 до 18 тысяч сравнений на всех входах. Встроенная sorted() выполняет 8625 сравнений на случайном входе, 999 = n − 1 на упорядоченном и 4419 на почти упорядоченном. Нижняя граница для сортировок сравнением составляет log₂(1000!) ≈ 8529: слияние и sorted() на случайном входе превышают её лишь на 1–2 %. Второй запуск измеряет время при n = 10⁵. На случайных числах слияние занимает 95,6 мс, быстрая сортировка со случайной опорой — 62,0 мс, sorted() — 8,5 мс, в 7–11 раз меньше. На почти упорядоченном входе, где переставлен 1 % соседних пар, sorted() занимает 0,5 мс — примерно в сто раз меньше собственных реализаций: адаптивность позволяет ей использовать готовые упорядоченные участки.
Результат:
сравнения
Что наблюдается
Вставки и быстрая с первой опорой — прямые с наклоном 2; слияние и sorted() лежат на границе log₂(n!). Почти упорядоченный вход позволяет sorted() опуститься ниже границы.
Демонстрация. На рисунке число сравнений отложено против n от 100 до 3200 в логарифмических осях. Вставки на случайном входе и быстрая сортировка с первой опорой на упорядоченном входе дают прямые с наклоном 2; квадратичные линии построены до n = 1600. Слияние и sorted() на случайном входе почти совпадают с пунктирной нижней границей log₂(n!) на всём диапазоне. Штриховая линия sorted() на почти упорядоченном входе лежит ниже границы, и противоречия здесь нет: граница log₂(n!) относится к худшему случаю и к среднему по всем перестановкам, а на конкретном почти упорядоченном входе адаптивный алгоритм вправе выполнить меньше сравнений, используя готовые серии. Отметим, что при n = 1600 вставки выполняют в 44 раза, а быстрая сортировка с первой опорой на упорядоченном входе — в 172 раза больше сравнений, чем sorted() на случайном, и разрыв растёт пропорционально n / log n; на том же упорядоченном входе sorted() выполняет лишь n − 1 = 1599 сравнений.
Встроенная
сортировка
01
Серии
упорядоченные участки; короткие — вставками
02
Слияние
попарно; с 3.11 порядок слияний — powersort
03
Галоп
экспоненциальный поиск при слиянии длинных серий
04
Свойства
устойчива; на упорядоченном входе n − 1 сравнений
log₂(n!) ≈ n·log₂ n − 1,44·n n = 1000: log₂(1000!) ≈ 8529
Пояснение
C++: std::sort в libstdc++ и libc++ — интроспективная сортировка, быстрая с переходом к пирамидальной.
Важно
Свою сортировку в рабочем коде писать незачем: sorted() быстрее и лучше отлажена.
Изложение. Встроенные sorted() и list.sort() реализуют Timsort — гибрид сортировки слиянием и вставками, предложенный Т. Петерсом для Python в 2002 году. Алгоритм находит во входе уже упорядоченные участки — серии, — короткие серии удлиняет сортировкой вставками до нескольких десятков элементов, а затем сливает серии попарно. Если при слиянии одна серия выигрывает сравнение много раз подряд, включается галоп — экспоненциальный поиск места, с которого начинается следующий блок, что позволяет переносить целые участки без поэлементного сравнения. Сортировка устойчива и адаптивна: на упорядоченном входе она выполняет n − 1 сравнений и O(n) операций. Начиная с версии 3.11 порядок слияния серий выбирается по правилу powersort, остальное устройство Timsort сохранилось. Сравним с нижней границей. Любая сортировка сравнением должна различить все n! перестановок входа, а каждое сравнение даёт не более одного бита информации, поэтому в худшем случае необходимо не менее log₂(n!) ≈ n·log₂ n − 1,44n сравнений; при n = 1000 это 8529. Сортировка слиянием и Timsort на случайных данных к этой границе близки. В стандартных библиотеках C++ функция std::sort в распространённых реализациях, libstdc++ и libc++, представляет собой интроспективную сортировку: быструю сортировку, переходящую к пирамидальной при слишком глубокой рекурсии; стандарт требует лишь O(n log n) сравнений. Вывод главы остаётся в силе: собственную сортировку в рабочем коде писать незачем, поскольку встроенная почти всегда быстрее и лучше отлажена, — в демонстрации разница составила от 7 до 100 раз. Знать устройство встроенной сортировки необходимо, чтобы понимать, чего от неё ожидать.
Сортировка
подсчётом
nums = [5, 7, 1, 0, 1, 5, 11, 1] # месяцы counts = [1, 3, 0, 0, 0, 2, 0, 1, 0, 0, 0, 1] # → [0, 1, 1, 1, 5, 5, 7, 11] def counting_sort(nums, k): counts = [0] * k for num in nums: counts[num] += 1 nums = [] for i in range(len(counts)): for j in range(counts[i]): nums.append(i) return nums
01
Время
O(n + k): два прохода без сравнений
02
Память
O(k) на счётчики и O(n) на результат
03
Предел
k сопоставимо с n; при k ≫ n теряет смысл
04
Устойчивость
префиксные суммы — начало блока каждого значения
Для физика
Первый проход — гистограмма амплитудного спектра по кодам АЦП.
Изложение. Отсортировать быстрее чем за O(n log n) возможно, но лишь для узкого класса задач, устроенных особым образом. Пример главы — массив чисел, обозначающих номера месяцев от 0 до 11. Заводится массив из 12 счётчиков, заполненный нулями; проход по исходному массиву увеличивает счётчик каждого встреченного месяца; затем проход по счётчикам выписывает каждый номер столько раз, сколько накоплено в его счётчике. Для значений от 0 до k − 1 алгоритм выполняет O(n + k) операций и требует O(k) памяти на счётчики и O(n) на результат. Элементы ни разу не сравниваются друг с другом, поэтому граница log₂(n!), доказанная для сортировок сравнением, на сортировку подсчётом не распространяется. Выигрыш сохраняется, пока k сопоставимо с n: сортировать подсчётом миллион чисел, разбросанных по диапазону до миллиарда, бессмысленно — массив счётчиков окажется в тысячу раз длиннее исходного. Приведённая реализация восстанавливает массив из одних счётчиков: она годится для чисел, но записи, привязанные к ключам, не переносит вовсе. Для записей необходим устойчивый вариант. По массиву счётчиков вычисляются префиксные суммы — позиции, с которых начинаются блоки значений, — и затем каждая запись ставится на очередное место своего блока; записи перебираются слева направо, и после каждой граница её блока сдвигается на одну позицию вперёд, поэтому записи с равными ключами сохраняют исходный порядок. Проход справа налево также устойчив, однако для него префиксные суммы должны давать концы блоков, а граница перед каждой записью уменьшается; с началами блоков такой проход выводит записи за пределы своего блока. Для физика первый проход сортировки подсчётом знаком: массив счётчиков по кодам АЦП представляет собой гистограмму амплитудного спектра.
Демонстрация:
подсчёт
01
10⁶ кодов 12-битного АЦП, k = 4096
02
Подсчёт на Python против sorted()
03
Устойчивый вариант для записей
04
Память счётчиков, рисунок спектра
Что наблюдается
Подсчёт на Python — 22,5 мс, sorted() — 84,7 мс, в 3,8 раза дольше. Порядок записей внутри канала сохранён. Для 30-битного кода счётчики занимают 8 ГиБ.
root@lab — запись со стенда
Демонстрация. Сценарий counting.py моделирует амплитудный спектр: миллион кодов 12-битного АЦП, k = 4096, — две линии на экспоненциальном фоне. Сортировка подсчётом на чистом Python занимает 22,5 мс, встроенная sorted() — 84,7 мс, в 3,8 раза дольше: алгоритм O(n + k), написанный на интерпретируемом языке, оказывается быстрее реализации O(n log n) на C. Второй проход в сценарии выписывает каждый блок одним вызовом extend, а не вложенным циклом с append, как в листинге главы на предыдущем слайде; с циклом главы подсчёт занимает примерно на четверть больше времени, но остаётся более чем вдвое быстрее sorted(). Счёт операций это объясняет: подсчёт выполняет n + k = 1 004 096 операций, а сортировка сравнением — порядка n·log₂ n ≈ 2·10⁷ сравнений. В NumPy та же пара — bincount с repeat против np.sort — даёт 1,0 и 1,8 мс. Второй запуск показывает устойчивый вариант с префиксными суммами на 20 записях вида «канал:номер события». После сортировки по каналу номера событий внутри каждого канала возрастают, и результат совпадает с устойчивой sorted(). Последние строки показывают предел применимости: для 12-битного кода счётчики занимают 32 КиБ, для 16-битного — 512 КиБ, для 24-битного — 128 МиБ, а для 30-битного — 8 ГиБ, больше всей памяти стенда; при таком k подсчёт теряет смысл. Третий запуск строит рисунок массива счётчиков.
Результат:
спектр
Что наблюдается
Массив счётчиков — гистограмма спектра по каналам АЦП: линии около кодов 1320 и 2860 на экспоненциальном фоне.
Демонстрация. На рисунке — массив counts, построенный первым проходом сортировки подсчётом по миллиону кодов: по горизонтали код АЦП от 0 до 4095, по вертикали число событий с этим кодом. Видны две линии около кодов 1320 и 2860, шириной в несколько десятков каналов, на экспоненциально спадающем фоне. Это гистограмма амплитудного спектра с шириной бина в один канал АЦП: сортировка подсчётом и построение гистограммы выполняют один и тот же первый проход. Отличие лишь в цели: гистограмма останавливается на счётчиках, а сортировка разворачивает их обратно в упорядоченный массив. Функция numpy.bincount, использованная в демонстрации, вычисляет именно этот массив.
Итоги
лекции
01
Сложность оценивается до запуска и подтверждается удвоением N.
02
Константы определяют применимость при конкретных размерах данных.
03
Память — второй ресурс; геометрический рост даёт списку O(1) в среднем.
04
list — для доступа по индексу, deque — для работы с концами.
05
Глубина рекурсии ограничена; явный стек снимает предел.
06
Сравнение не быстрее log₂(n!); подсчёт обходит границу при малом k.
07
Устойчивость необходима при сортировке по нескольким ключам.
Изложение. Подведём итоги. Сложность алгоритма оценивается до запуска и подтверждается измерением: при удвоении N линейный алгоритм замедляется вдвое, квадратичный — вчетверо, а в логарифмических осях наклон прямой равен показателю степени. Константы, отброшенные O-нотацией, определяют применимость при конкретных размерах данных: линейное решето оказалось не быстрее классического, а функция на C при той же сложности быстрее в девять раз. Память является вторым ресурсом: объекты Python занимают в 4–17 раз больше памяти, чем те же данные в массиве NumPy, а геометрический рост ёмкости обеспечивает списку O(1) на добавление в среднем — не более девяти копирований на элемент при росте в 1,125 раза. Список подходит для доступа по индексу, дек — для работы с концами, и рост отношения времён с N указывает на разную сложность. Глубина рекурсии ограничена интерпретатором и стеком, а явный стек снимает предел. Сортировка сравнением не может выполнить меньше log₂(n!) сравнений в худшем случае, а сортировка подсчётом обходит эту границу при малом диапазоне значений. Наконец, устойчивость сортировки необходима, когда данные упорядочивают по нескольким ключам.
Упражнения
и задание
01
Левая граница
бинарный поиск и сравнение с bisect_left
02
Рост в 1,5 раза
свой динамический массив, подсчёт копирований
03
Явный стек
обход дерева каталогов без рекурсии
04
Подсчёт для записей
устойчивая сортировка событий по каналу
Пояснение
Практикум «Решение задач на Python и анализ сложности»: две задачи CodeRun лёгкого уровня — код, обоснование структуры данных, время и память.
Для физика
Решение сопровождается оценкой: сначала порядок роста, затем измерение.
Изложение. Упражнения служат самопроверке. Первое — реализовать бинарный поиск левой границы, то есть первой позиции, на которую можно вставить x без нарушения порядка, и сравнить результаты и время с bisect.bisect_left на массивах с повторяющимися значениями. Второе — написать динамический массив с ростом ёмкости в 1,5 раза, подсчитать копирования при добавлении миллиона элементов и сравнить с оценкой r/(r − 1) = 3. Третье — переписать рекурсивный обход дерева каталогов через явный стек и проверить его на дереве, глубина которого превышает предел рекурсии. Четвёртое — реализовать устойчивую сортировку подсчётом для записей событий с ключом «номер канала» и проверить устойчивость сравнением с sorted(). Задание практикума книги «Решение задач на Python и анализ сложности» требует решить две задачи лёгкого уровня на платформе CodeRun и для каждой представить ссылку на задачу, код, описание хода решения, обоснование выбора алгоритма и структур данных и анализ временной и пространственной сложности.
Источники
1
Книга курса: главы части «Алгоритмы и структуры данных» — введение в алгоритмы, основные структуры данных, рекурсия и сортировки
phys-dev.github.io/soft-dev-book
2
Кормен Т., Лейзерсон Ч., Ривест Р., Штайн К. Алгоритмы: построение и анализ. – 3-е изд. – М.: Вильямс, 2013
3
Седжвик Р., Уэйн К., Дондеро Р. Программирование на языке Python: учебный курс. – М.: Вильямс, 2017; Кнут Д. Искусство программирования. Т. 1. – 3-е изд. – М.: Вильямс, 2018
4
Gorelick M., Ozsvald I. High performance Python. – 2nd ed. – O'Reilly Media, 2020
Изложение. Лекции соответствуют четыре главы части книги курса «Алгоритмы и структуры данных»: введение, «Введение в алгоритмы», «Основные структуры данных» и «Рекурсия и сортировки». Строгое изложение оценок сложности, амортизационного анализа и нижней границы для сортировок сравнением приведено у Кормена, Лейзерсона, Ривеста и Штайна, реализации алгоритмов на Python — у Седжвика, Уэйна и Дондеро, фундаментальный анализ основных алгоритмов и структур данных — у Кнута. Измерение времени и памяти программ на Python подробно рассмотрено у Горелика и Освальда. Справочником по модулям, использованным в демонстрациях, служит документация Python, а устройство встроенной сортировки описано её авторами в файле listsort.txt исходников CPython.
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Лекция завершена. Вопросы по материалу удобно задавать, опираясь на главы части «Алгоритмы и структуры данных» и на демонстрации: все сценарии используют синтетические данные с фиксированным зерном и выполняются за секунды, поэтому любой результат можно повторить и изменить. Следующая лекция продолжает тему структурами, которые снимают ограничение O(n) на поиск по значению: хеш-таблицами, деревьями и графами.