Learning Platform
Глоссарий Troubleshooting
Урок 07.05 · 28 мин
Начальный
dequeblock-linkedcollectionsdouble-endedcache

Зачем гибрид

Мы видели два экстремума:

  • Динамический массив: отличный cache, O(1) random access, но O(n) push_front/pop_front.
  • Linked list: O(1) на любых концах, но pointer chasing убивает cache.

collections.deque берёт лучшее из обоих миров через гибрид: block-linked list. Это doubly linked список блоков, где каждый блок — небольшой массив указателей.

deque:  [block A] <-> [block B] <-> [block C]
           |             |             |
       [items 0..63] [items 64..127] [items 128..191]

В CPython 3.13 каждый блок (BLOCKLEN) — 64 указателя (Modules/_collectionsmodule.c). На 1М значений — это 15625 блоков. Связи между блоками: 15625 next-указателей. Внутри блока — обычный массив, отличный кэш.

deque: блоки по 64 указателя, связанные doubly linked

Внутри блока — плотный буфер. Между блоками — линковка. Pointer chasing редкий.

leftblock*Blockблок, в котором лежит самый левый элемент deque
Block Adata[0..63] = ptr ptr ptr ...первый блок: 64 ячейки указателей
Block Bdata[0..63]следующий блок
Block Cdata[0..63]и так далее
rightblock*Blockблок, в котором лежит самый правый элемент

Структура в исходниках

Псевдокод того, что в _collectionsmodule.c:

typedef struct BLOCK {
    PyObject *data[BLOCKLEN];   // BLOCKLEN = 64
    struct BLOCK *leftlink;     // prev block
    struct BLOCK *rightlink;    // next block
} block;

typedef struct {
    PyObject_VAR_HEAD
    block *leftblock;           // ptr на блок с левым концом
    block *rightblock;          // ptr на блок с правым концом
    Py_ssize_t leftindex;       // позиция в leftblock (0..63)
    Py_ssize_t rightindex;      // позиция в rightblock (0..63)
    Py_ssize_t state;
    Py_ssize_t maxlen;
    /* ... */
} dequeobject;
  • leftblock / rightblock — указатели на крайние блоки.
  • leftindex / rightindex — позиции в крайних блоках. Когда leftindex доходит до 0, нужен новый блок слева.
  • data[64] — массив указателей внутри блока.

Как работает append/appendleft

from collections import deque

dq = deque()
dq.append(10)        # rightindex=0, data[0]=10
dq.append(20)        # rightindex=1, data[1]=20
dq.append(30)        # rightindex=2, data[2]=30
...
dq.append(item64)    # rightindex=63, блок полон
dq.append(item65)    # создаётся новый блок справа, rightblock=*new, rightindex=0

Сравните с appendleft:

dq.appendleft(0)     # leftindex=-1? нет: cначала leftindex был 0, потом если он 0 — создаётся блок слева

В реальной реализации индексы могут идти в обе стороны от центра. Когда индекс выходит за границу блока — создаётся новый блок и линкуется. Это всё O(1).

popleft симметрично: декрементит leftindex или удаляет пустой блок, если опустошился.

appendleft заставляет создать блок слева

Когда место в текущем leftblock закончилось, doubly linked структура добавляет новый блок и обновляет leftblock.

доleftblock=A, leftindex=0место в A слева закончилось
appendleft(x)
новый блок ZZ.data[63] = xставим x в самую правую ячейку нового блока
Z.rightlink= Aсвязь нового блока со старым leftblock
A.leftlink= Zобратная связь
послеleftblock=Z, leftindex=63deque знает: новый левый край в блоке Z

Сложности операций

deque vs list vs linked list

deque — O(1) с обоих концов, лучше cache чем linked, но хуже indexing чем list.

append / popdeque O(1), list O(1) amortized, linked O(1)
appendleft / popleftdeque O(1), list O(n)!, linked O(1)это главное преимущество deque над list
dq[i] (random access)deque O(n) — нужно найти блок и индексхуже чем list O(1); deque не для random access
iteration sum(dq)deque быстрый, хорошая cache localityвнутри блока скан плотный, переходы между блоками редки (раз на 64 элемента)
memory64 ptr на блок + header; +1.5% относительно listплотная упаковка; overhead на header блока маленький

Бенчмарк: deque vs list для FIFO

Очередь FIFO: производитель кладёт справа, потребитель забирает слева. Классическая структура.

import time
from collections import deque

N = 100_000

# list версия — O(n) на каждый popleft
lst = list(range(N))
t0 = time.perf_counter()
while lst:
    lst.pop(0)
t1 = time.perf_counter()
print(f"list FIFO:  {(t1-t0)*1000:>8.2f} ms")

# deque версия — O(1) на каждый popleft
dq = deque(range(N))
t0 = time.perf_counter()
while dq:
    dq.popleft()
t1 = time.perf_counter()
print(f"deque FIFO: {(t1-t0)*1000:>8.2f} ms")

Типовой результат:

list FIFO:    1620.50 ms
deque FIFO:      4.20 ms       # в 400 раз быстрее

В 400 раз. Это разница между O(n^2) и O(n). На миллион элементов разрыв был бы x40000 — list просто не отработал бы за разумное время.

maxlen — ring buffer бесплатно

У deque есть параметр maxlen. Когда заполнен — старые элементы выкидываются автоматически:

from collections import deque

last_10 = deque(maxlen=10)
for i in range(100):
    last_10.append(i)
print(list(last_10))   # [90, 91, 92, ..., 99]

Это готовый ring buffer: для трекинга «последних N событий», скользящих окон, hot/cold метрик. Не нужно писать circular buffer руками — есть в стандартной библиотеке.

Под капотом: при достижении maxlen append сначала делает popleft (выкидывает старое), потом добавляет новое. Обе операции O(1).

Почему BLOCKLEN = 64

Авторы CPython выбрали 64 как баланс:

  • Слишком маленький блок (например, 4) — почти как linked list, много pointer chasing между блоками. Cache miss часто.
  • Слишком большой блок (например, 4096) — большой блок = много памяти при маленьком deque. Создание/удаление блока — медленное.
  • 64 — типичная кэш-линия процессора. 64 указателя * 8 байт = 512 байт = 8 cache lines. Получается «вес блока» хорошо помещается в L1 и L2 при типичном использовании.

Кроме того, 64 = 2^6, индексирование через сдвиг и битмаску — быстро.

Cache miss profile

На 1М элементов:

  • list[int] итерация: pointer chasing к каждому PyLongObject (1М потенциальных miss). Но prefetcher отлично работает на буфере указателей.
  • deque[int] итерация: pointer chasing внутри блока (плотный массив) + переходы между блоками (15625 раз). Соседние блоки часто близки в куче (Python pool аллокатора), поэтому миссов меньше.
  • self-linked list: 1М миссов гарантировано — каждый узел отдельный объект.

В реальных бенчмарках deque iter ~37 ms vs self-linked ~140 ms, на python overhead-е разница частично размывается, но в 4 раза быстрее всё равно.

Применения в DE

  1. Скользящие окна (rolling window): deque(maxlen=N) — последние N значений всегда под рукой.
  2. BFS обход графа: deque как очередь вершин для обработки.
  3. Streaming buffer: producer-consumer паттерн в asyncio.
  4. History trim: undo-redo с лимитом глубины (как Vim u-r).
  5. Rate limiting через временное окно: deque меток с popleft когда устаревают.

Попробуй сам

Реализуйте rolling average через deque(maxlen=N):

from collections import deque

class RollingAverage:
    def __init__(self, window_size):
        self.window = deque(maxlen=window_size)
        self.sum = 0.0

    def add(self, value):
        if len(self.window) == self.window.maxlen:
            # окно заполнено, при добавлении самый старый будет выкинут
            self.sum -= self.window[0]
        self.window.append(value)
        self.sum += value

    def average(self):
        return self.sum / len(self.window) if self.window else 0.0


ra = RollingAverage(5)
for x in [10, 20, 30, 40, 50, 60, 70]:
    ra.add(x)
    print(f"add {x}: window={list(ra.window)}, avg={ra.average():.2f}")

Все операции — O(1). Окно автоматически держит только последние 5 значений. Сумма поддерживается инкрементально — никаких полных пересчётов.

Коллекции Python: deque в стандартной библиотеке
Проверка знанийKnowledge check
Почему deque поддерживает O(1) appendleft и popleft, а list — нет? Расскажи через структуру памяти.
ОтветAnswer
list — это плотный массив указателей в одном буфере; ob_size это длина, и элементы лежат от индекса 0. Чтобы убрать первый элемент, надо сдвинуть все остальные на одну позицию влево — O(n). deque — это doubly linked list блоков по 64 указателя. У deque есть leftblock и leftindex, указывающие на самый левый элемент. popleft декрементит leftindex (если он стал отрицательным — освобождает блок и переключает leftblock на следующий). appendleft увеличивает leftindex назад (если блок переполнился — создаётся новый блок слева). Все операции трогают только один блок и один-два указателя — O(1). Платим за это: random access становится O(n), потому что нужно найти нужный блок проходом по цепочке.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что такое block-linked list (как у collections.deque)?

Закончили урок?

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

Войдите чтобы оценить урок

Прогресс модуля
0 из 5