# теория · шаг 4 из 7
deque, heapq, bisect и array: что брать вместо list
Коротко
У стандартной библиотеки на каждую слабость list есть свой контейнер. deque - двусвязный список
блоков по 64 ссылки: вставка и удаление с обоих концов за O(1), но d[i] в середине идёт по блокам и
стоит O(n). heapq - двоичная куча прямо в обычном списке: heappush и heappop за O(log n).
bisect ищет место в отсортированном списке за O(log n) сравнений, но insort потом сдвигает хвост
за O(n). array хранит числа без объектов - вчетверо меньше памяти. Выбор - по тому, какие
операции в задаче частые.
Если ты знаешь, как устроены deque и heapq, почему insort квадратичен на потоке вставок и когда нужен array, листай до «Теперь сам».
Как думаешь, что быстрее
Нужна очередь с приоритетом: события приходят в случайном порядке, обрабатывать надо самое раннее. Два
решения. Первое - держать список отсортированным: bisect.insort(a, x) находит место двоичным поиском
и вставляет. Второе - куча: heapq.heappush(h, x). Оба выглядят как O(log n) на вставку. На двухстах
тысячах вставок во сколько раз одно быстрее другого?
Ответь себе до чтения. Если «примерно одинаково» - разбор покажет 4,3 секунды против 20 миллисекунд.
На что это похоже
deque - поезд из вагонов по 64 места. Посадить пассажира в голову или в хвост - прицепил вагон, если
крайний полон, и готово. А найти сотысячного пассажира - идти по вагонам от ближнего конца.
Куча - турнирная таблица, где каждый «начальник» меньше двух своих «подчинённых». Новичок встаёт в
конец и поднимается, пока не упрётся в начальника меньше себя: путь - высота дерева, log₂ n шагов. А
отсортированный список - очередь в кассу по росту: найти своё место легко, но всем, кто ниже, придётся
сделать шаг назад.
Механизм: четыре устройства
deque (Modules/_collectionsmodule.c): блоки по 64 ссылки, связанные в обе стороны, плюс индексы
первого и последнего занятого места. append, appendleft, pop, popleft трогают только крайний блок.
d[i] идёт от ближнего конца, прыгая по блокам: min(i, n − i) / 64 переходов. maxlen включает вытеснение:
лишнее вываливается с другого конца, устройство из блоков остаётся тем же.
heapq не заводит своего типа: функции работают с обычным списком, где h[0] - минимум, а дети
ячейки i - 2i + 1 и 2i + 2, как в разделе про кучи. heapify - O(n), nsmallest(k, ...) держит кучу
из k элементов - O(n log k).
bisect - двоичный поиск по отсортированному списку: bisect_left и bisect_right за O(log n).
insort = поиск + list.insert, а вставка сдвигает хвост.
array хранит значения одного числового типа подряд, как массив в C: array('d') - 8 байт на число
против 8 байт ссылки и 24 байт объекта у списка.
Правило
Очередь и стек с двух концов - deque (O(1) на краях, O(n) в середине). Минимум из меняющегося
набора - heapq (O(log n) вставка и извлечение). Поиск в редко меняющемся отсортированном списке -
bisect (O(log n)), но поток вставок через insort - O(n) на каждую. Много чисел одного типа - array
или NumPy.
Разбор: insort против кучи - сравнения и сдвиги
Посчитаем на модели оба вида работы: сравнения (их log n у обоих) и сдвиги элементов.
import random
def insort(a, x):
# двоичный поиск места, затем сдвиг хвоста на одну позицию
lo = 0
hi = len(a)
sravnenij = 0
while lo < hi:
mid = (lo + hi) // 2
sravnenij = sravnenij + 1
if x < a[mid]:
hi = mid
else:
lo = mid + 1
a.append(x)
sdvigov = 0
i = len(a) - 1
while i > lo:
a[i] = a[i - 1]
i = i - 1
sdvigov = sdvigov + 1
a[lo] = x
return [sravnenij, sdvigov]
def heappush(h, x):
# просеивание вверх: родитель ячейки i - (i - 1) // 2
h.append(x)
i = len(h) - 1
sravnenij = 0
sdvigov = 0
while i > 0:
p = (i - 1) // 2
sravnenij = sravnenij + 1
if h[p] <= x:
break
h[i] = h[p]
sdvigov = sdvigov + 1
i = p
h[i] = x
return [sravnenij, sdvigov]
random.seed(3)
for n in [100, 400]:
x = []
for k in range(n):
x.append(random.randint(0, 10 ** 6))
for imya in ["insort", "heappush"]:
a = []
itog = [0, 0]
for v in x:
if imya == "insort":
r = insort(a, v)
else:
r = heappush(a, v)
itog = [itog[0] + r[0], itog[1] + r[1]]
print(n, imya, "сравнений", itog[0], "сдвигов", itog[1])
100 insort сравнений 536 сдвигов 2373
100 heappush сравнений 198 сдвигов 102
400 insort сравнений 2912 сдвигов 41016
400 heappush сравнений 909 сдвигов 518
Сравнений у insort даже больше, но дело не в них: сдвигов при вчетверо большем n стало в 17 раз
больше - квадрат, в среднем полсписка на вставку. У кучи на случайных данных новичок почти сразу
останавливается: меньше двух сдвигов на вставку. Тот же эффект на настоящем Python:
import bisect
import heapq
import random
import time
from collections import deque
def zamer(f):
# лучшее из трёх, в миллисекундах
luchshee = None
for popytka in range(3):
t = time.perf_counter()
f()
dt = (time.perf_counter() - t) * 1000
if luchshee is None or dt < luchshee:
luchshee = dt
return luchshee
n = 1_000_000
spisok = list(range(n))
deka = deque(spisok)
a = zamer(lambda: [spisok[n // 2] for _ in range(1000)])
b = zamer(lambda: [deka[n // 2] for _ in range(1000)])
print(f"середина x 1000: list {a:.2f} мс, deque {b:.1f} мс")
random.seed(1)
x = [random.random() for _ in range(200_000)]
def cherez_insort():
a = []
for v in x:
bisect.insort(a, v)
def cherez_heap():
h = []
for v in x:
heapq.heappush(h, v)
print(f"200000 вставок: insort {zamer(cherez_insort):.0f} мс,",
f"heappush {zamer(cherez_heap):.0f} мс")
середина x 1000: list 0.08 мс, deque 71.9 мс
200000 вставок: insort 4255 мс, heappush 20 мс
Двести тысяч вставок: 4,3 секунды против 20 миллисекунд, в двести раз. Сдвиг в C - быстрая операция
memmove, но квадрат она не отменяет.
Разбор: где deque проигрывает списку
Тот же замер, первая строка: обращение к середине дека на миллион элементов почти в тысячу раз дольше,
чем у списка, - около 72 микросекунд против десятой доли. До середины полмиллиона элементов, то есть около 7800
блоков, и каждый переход - чтение указателя из другого места памяти. Граница правила «deque - это
улучшенный list»: он лучше на краях и хуже везде, где нужен индекс. Очередь задач, скользящее окно,
история последних k событий (deque(maxlen=k)) - да. Массив с произвольным доступом - нет.
Что ломается без этого
Очередь с приоритетом на insort. На тестовых сотнях событий не видно ничего, на продовых сотнях
тысяч - секунды на каждую порцию.
heapq с максимумом. Куча в heapq - только минимальная (так в Python 3.12; в 3.14 добавили heappush_max и
heappop_max). Для максимума кладут -x или пару
(-приоритет, порядковый_номер, задача): номер нужен, чтобы при равных приоритетах кортежи не дошли до
сравнения самих задач, которые сравнивать нельзя.
Индекс в deque в цикле. for i in range(len(d)): d[i] - квадрат. Обходить дек надо самим циклом
for x in d.
Зачем это в работе
Планировщик таймеров (asyncio держит отложенные вызовы в куче heapq), скользящее среднее за последние
k замеров (deque(maxlen=k)), поиск интервала по отсортированным границам тарифов (bisect), буфер
сэмплов на миллионы чисел (array или NumPy) - четыре задачи, четыре контейнера. Для отсортированного
набора с частыми вставками в стандартной библиотеке ничего нет; сторонний sortedcontainers держит список
коротких отсортированных списков, и вставка сдвигает только один короткий кусок.
Теперь сам
- Нужно хранить последние 1000 запросов и регулярно спрашивать, сколько из них пришло с одного адреса. Какой контейнер под окно, какой - под счётчики?
- Почему
heapq.heapify(a)стоитO(n), а неO(n log n)? - Список из 10⁶ отсортированных чисел меняется раз в сутки, а поиск по нему идёт тысячи раз в секунду.
bisectилиset, если нужен ответ «есть ли число» и «сколько чисел меньше x»?
Ответ. 1. Окно - deque(maxlen=1000): новый запрос вытесняет самый старый за O(1); счётчики -
dict (или Counter). Перед добавлением, если окно полно, прочитать самый старый d[0] и уменьшить его
счётчик - потом append его вытеснит.
-
Построение снизу вверх: большинство узлов внизу и просеиваются на одну-две ступени; сумма высот всех узлов - линейная (раздел про кучи).
-
bisect: «сколько меньше x» - это позицияi = bisect_left(a, x), а «есть ли x» -i < len(a) and a[i] == x; множество на этот вопрос не отвечает, а вставок почти нет - сдвиги не страшны.
Главное
Контейнер выбирают по частым операциям: края - deque, минимум - heapq, поиск в редко меняющемся
отсортированном - bisect, много чисел - array. Асимптотика «log n на вставку» у insort обманчива:
поиск быстрый, а сдвиг хвоста - линейный.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий