Алгоритмы и сложность

Оценка и измерение стоимости алгоритмов, массивы, списки, стек вызовов, рекурсия и сортировки

Вячеслав Федоров

Лекция 17

Разработка и применение ПО

в физических исследованиях

O(n)

Содержание

От оценки сложности к выбору структуры данных

01

Алгоритм и сложность

02

Время и память

03

Память и массивы

04

Списки и очереди

05

Рекурсия

06

Сортировки

Цель лекции

Оценивать стоимость алгоритма до запуска и подтверждать оценку измерением

Миллион событий детектора, обработанный алгоритмом O(n²), требует 10¹² операций, и NumPy, написанный на C, порядка роста не меняет. Удачно выбранная структура данных сокращает время на порядки, причём чаще, чем низкоуровневая оптимизация.

Алгоритмы

в работе физика

nO(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), графы

Алгоритм и сложность

Свойства алгоритма, поиск в массиве, O-нотация и её измерение

01

Алгоритм и сложность

Время и память

Память и массивы

Списки и очереди

Рекурсия

Сортировки

Алгоритм

и его свойства

Пояснение

Алгоритм — конечная последовательность точных инструкций, приводящая к результату за конечное число шагов.

Важно

Проблема останова: алгоритма, который по программе и входу определяет, остановится ли она, не существует.

01

Дискретность

отдельные шаги в определённом порядке

02

Детерминированность

один вход — один результат

03

Понятность

шаг посилен исполнителю, без двоякого толкования

04

Завершаемость

конечное число шагов

05

Массовость

класс входных данных, а не один набор

06

Результативность

ответ, в том числе «решения нет»

Сложность:

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)перебор подмножеств

Сложность

по виду кода

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.

Демонстрация:

сложность

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 — запись со стенда

Результат:

сложность

Время одной операции от размера входа N в логарифмических осях для пяти операций, наклоны прямых в легенде

Что наблюдается

Пять прямых с наклонами 0,04; 0,11; 1,02; 1,29; 2,01: в логарифмических осях показатель степени читается как наклон.

Время и память

Оценка времени исполнения, пространственная сложность, разбор задач главы

02

Алгоритм и сложность

Время и память

Память и массивы

Списки и очереди

Рекурсия

Сортировки

Оценка

времени исполнения

t = N · (тактов на итерацию) / f
  = 10⁹ · 1 / 2,5·10⁹ = 0,4 с
 
итерация: i += 1, проверка, переход
3·10⁹ инструкций  →  около 1,2 с

Важно

При -O2 компилятор удаляет цикл, результат которого не используется или вычисляется заранее, и замер показывает почти ноль — ловушка микробенчмарков.

01

Такт и инструкция

ядро выполняет несколько инструкций за такт

02

Порядок величины

оценка верна с точностью до множителя

03

Интерпретатор

итерация Python — сотни машинных инструкций

Демонстрация:

пустой цикл

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 — запись со стенда

Пространственная

сложность

01

Определение

зависимость объёма памяти от размера входа, асимптотически

02

Дополнительная память

сверх входных данных: O(1), O(K), O(n)

03

Обмен памяти на время

таблица заранее вычисленных значений, кеш результатов

Пояснение

При нехватке памяти операционная система переносит страницы в файл подкачки на диске, и программа замедляется на порядки даже при малой временной сложности.

Для физика

Таблица калибровки каналов детектора вместо пересчёта по формуле для каждого события — память в обмен на время.

Краевые случаи

и тесты

ТестСтрокиЧислаМассивы
Самый маленькийпустая строка0, наименьшее отрицательноепустой набор
Самый большойсамая длинная по условиюнаибольшее допустимоемаксимальный размер
Особые случаирегистр, кириллица и латиницазнак, ноль, чётность, дробноеравные элементы, упорядоченный и нет

Важно

Решето с numbers[0] = numbers[1] = False при n = 0 завершается IndexError: в списке один элемент.

Пояснение

Самый большой тест проверяет время и память, то есть оценку сложности.

Задача:

частый символ

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 — число различных символов; память учитывается сверх входа.

Для физика

Шумящий канал детектора — самый частый номер в потоке срабатываний.

Демонстрация:

частый канал

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 — запись со стенда

Простые числа

и решето

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].

Демонстрация:

решето

01

Число операций при n = 10⁴, 10⁵, 10⁶

02

Перебор делителей до n и до √n

03

Решето Эратосфена и линейное решето

04

Время, минимум из трёх запусков

Что наблюдается

Линейное решето делает вдвое меньше записей, но не быстрее: 66,4 мс против 60,1 мс при n = 10⁶. Решето со срезами — 18,4 мс.

root@lab — запись со стенда

Память и массивы

Представление данных в памяти, массивы постоянного и переменного размера, реаллокация

03

Алгоритм и сложность

Время и память

Память и массивы

Списки и очереди

Рекурсия

Сортировки

Память

и базовые типы

Пояснение

Память разбита на ячейки по 1 байту = 8 бит, у каждой ячейки свой адрес.

Тип C++БайтЗначения
char1256 значений, ASCII
int4−2³¹ … 2³¹ − 1
unsigned int40 … 2³² − 1
double8до 10³⁰⁸, 53 разряда

double: знак 1 + порядок 11 + мантисса 52
мантисса: 52 бита + скрытая 1 = 53 разряда
eps = 2^−52 ≈ 2,2·10^−16 — шаг сетки у 1
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 находит взаимные ссылки

Демонстрация:

память

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 — запись со стенда

Массив

постоянного размера

numbers: десять чисел int по 4 байта, нулевой элемент по адресу 1000

[0]

1000

[1]

1004

[2]

1008

[3]

1012

[4]

1016

…

…

[9]

1036

адрес(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

Размер

задан при создании и не меняется

Динамический

массив

7

[0]

2

[1]

9

[2]

4

[3]

3

[4]

8

[5]

 

[6]

 

[7]

size = 6 занятых ячеек, capacity = 8 выделенных

Пояснение

list в Python — динамический массив ссылок, а не связный список: insert(0, x) и pop(0) требуют O(n).

ОперацияСдвиговСложность
вставка в началоnO(n)
вставка в позицию in − iO(n − i)
добавление в конец0O(1)
удаление из началаn − 1O(n)
удаление с конца0O(1)

Реаллокация

и амортизация

рост на 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 расширяет блок на месте, если за ним свободно: оценка описывает худший случай.

Демонстрация:

реаллокация

01

Ёмкость списка по sys.getsizeof

02

86 реаллокаций до 10⁶ элементов

03

Модель главы: рост на 100 и вдвое

04

Рисунок ёмкости и копирований

Что наблюдается

Ёмкость растёт в 1,125 раза: не более 8,45 копирования на элемент, append — 7–9 нс. Рост на 100 ячеек при n = 10⁶ — 4999,5.

root@lab — запись со стенда

Результат:

реаллокация

Слева ёмкость списка CPython над прямой длины; справа число копирований на одно добавление при росте на 100 ячеек, вдвое и по формуле CPython

Что наблюдается

Ступени ёмкости растут вместе с длиной. Рост на 100 ячеек — прямая с наклоном 1, рост вдвое и CPython — полосы около 1–2 и 7–9 копирований.

Списки и очереди

Связный список, стек, очередь и дек: интерфейс и стоимость операций

04

Алгоритм и сложность

Время и память

Память и массивы

Списки и очереди

Рекурсия

Сортировки

Связный

список

head

→

7

•

→

2

•

→

9

•

→

None

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 — динамический массив.

Стек:

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

Применения

«назад» в браузере, отмена, стек вызовов, обход в глубину

Пояснение

Стек — интерфейс, а не способ хранения: реализуется на массиве или на связном списке.

Очередь

и дек

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) — скользящее окно из последней тысячи отсчётов детектора.

Демонстрация:

список и дек

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 — запись со стенда

Выбор

структуры

СтруктураИндексВ конецВ началоПоиск
массив постоянного размераO(1)нетнетO(n)
list, динамический массивO(1)O(1)*O(n)O(n)
связный списокO(n)O(1)**O(1)O(n)
dequeO(n)O(1)O(1)O(n)

* амортизированно; ** при ссылке на хвост, иначе O(n)

Пояснение

insert(0, x) или pop(0) в цикле означают, что требуется дек.

Важно

Хеш-таблицы и деревья поиска снимают ограничение O(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

Вызов и возврат

кадр кладётся на вершину и снимается после возврата

Рекурсивный

и базовый случаи

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.

Глубина

рекурсии

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 МБ; его исчерпание завершает процесс аварийно, без исключения.

Демонстрация:

стек вызовов

01

Кадры внутри print_horoscope

02

Кадры в базовом случае factorial(4)

03

Предел 1000 и RecursionError

04

Явный стек и поднятый предел

Что наблюдается

У каждого кадра своё name: 'ГОША' и 'Гоша'. depth(5000) — RecursionError; после setrecursionlimit(200_000) глубина 10⁵ проходит. Рекурсия в 2,5 раза медленнее цикла.

root@lab — запись со стенда

Рекурсия

и цикл

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 его не оптимизирует

Пояснение

Рекурсия экономит время программиста, но не процессора: вызов функции дороже итерации цикла.

Перебор

вариантов

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¹⁹, полный перебор невозможен.

Демонстрация:

перебор

01

generate(3, '') из главы

02

Подсчёт конфигураций при n от 12 до 22

03

Время на одну конфигурацию

04

Оценка для n = 30, 40, 64

Что наблюдается

Каждые два разряда — вчетверо больше конфигураций; 63 нс на конфигурацию. n = 40 — около 19 ч, n = 64 — около 37 000 лет.

root@lab — запись со стенда

Сортировки

Устойчивость, ключ, слияние, быстрая сортировка, встроенная сортировка Python и подсчёт

06

Алгоритм и сложность

Время и память

Память и массивы

Списки и очереди

Рекурсия

Сортировки

Зачем

сортировать

01

Бинарный поиск

O(log n) — только на упорядоченных данных

02

Выборка лучших

«10 лучших» — первые элементы порядка

03

Узкое место

сортировка нередко определяет время решения

СитуацияВыбор алгоритма
старый сервер, отчёт через неделюмедленный, O(1) памяти
новый сервер, обновление каждую секундубыстрый, O(n) памяти

Пояснение

Критерии: срочность ответа, объём данных, свободная память. Лучшей сортировки в общем случае нет.

Устойчивость

и ключ

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

Составной ключ

кортеж: сначала детектор, затем время

Демонстрация:

устойчивость

01

События двух детекторов по времени

02

Второй проход sorted() по детектору

03

Составной ключ и cmp_to_key

04

Пирамидальная и быстрая сортировки

Что наблюдается

sorted() сохранила порядок по времени внутри детектора, пирамидальная — нет. Быстрая с тремя списками сохранила его в 1000 испытаниях из 1000, разбиение на месте — в 0.

root@lab — запись со стенда

Сортировка

слиянием

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

Устойчивость

при равенстве берётся элемент левой части

Быстрая

сортировка

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) дополнительной памяти.

Демонстрация:

сравнения

01

Сравнения при n = 1000 на трёх входах

02

Вставки, слияние, быстрая и sorted()

03

Время при n = 10⁵ против sorted()

04

Рисунок числа сравнений

Что наблюдается

Опора «первый элемент» на упорядоченном входе — 1 000 998 сравнений. sorted() — 999 на упорядоченном входе; слияние и sorted() близки к границе 8529.

root@lab — запись со стенда

Результат:

сравнения

Число сравнений от n в логарифмических осях: вставки и быстрая с первой опорой, слияние и sorted() рядом с нижней границей log₂(n!)

Что наблюдается

Вставки и быстрая с первой опорой — прямые с наклоном 2; слияние и sorted() лежат на границе log₂(n!). Почти упорядоченный вход позволяет sorted() опуститься ниже границы.

Встроенная

сортировка

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() быстрее и лучше отлажена.

Сортировка

подсчётом

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

Устойчивость

префиксные суммы — начало блока каждого значения

Для физика

Первый проход — гистограмма амплитудного спектра по кодам АЦП.

Демонстрация:

подсчёт

01

10⁶ кодов 12-битного АЦП, k = 4096

02

Подсчёт на Python против sorted()

03

Устойчивый вариант для записей

04

Память счётчиков, рисунок спектра

Что наблюдается

Подсчёт на Python — 22,5 мс, sorted() — 84,7 мс, в 3,8 раза дольше. Порядок записей внутри канала сохранён. Для 30-битного кода счётчики занимают 8 ГиБ.

root@lab — запись со стенда

Результат:

спектр

Массив счётчиков сортировки подсчётом по кодам АЦП для миллиона событий: две линии на экспоненциальном фоне

Что наблюдается

Массив счётчиков — гистограмма спектра по каналам АЦП: линии около кодов 1320 и 2860 на экспоненциальном фоне.

Итоги

лекции

01

Сложность оценивается до запуска и подтверждается удвоением N.

02

Константы определяют применимость при конкретных размерах данных.

03

Память — второй ресурс; геометрический рост даёт списку O(1) в среднем.

04

list — для доступа по индексу, deque — для работы с концами.

05

Глубина рекурсии ограничена; явный стек снимает предел.

06

Сравнение не быстрее log₂(n!); подсчёт обходит границу при малом k.

07

Устойчивость необходима при сортировке по нескольким ключам.

Упражнения

и задание

01

Левая граница

бинарный поиск и сравнение с bisect_left

02

Рост в 1,5 раза

свой динамический массив, подсчёт копирований

03

Явный стек

обход дерева каталогов без рекурсии

04

Подсчёт для записей

устойчивая сортировка событий по каналу

Пояснение

Практикум «Решение задач на 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

5

Документация Python: модули timeit, sys, tracemalloc, collections, bisect, functools

docs.python.org/3/library

6

Исходники CPython: устройство Timsort и powersort, Objects/listsort.txt

github.com/python/cpython/…/listsort.txt

Спасибо за внимание

Вопросы

phys-dev.github.io/soft-dev-book

17