Learning Platform
Глоссарий Troubleshooting
Урок 17.04 · 25 мин
Начальный
sorted-listbalanced-treerange-queriesdata-engineeringsortedcontainers

Проблема: list + bisect плохо растёт

В прошлом уроке мы видели: bisect находит элемент в отсортированном list за O(log n). Прекрасно. Но что если нужно вставить элемент, сохранив порядок?

import bisect

xs = [1, 3, 5, 7, 9, 11]

# найти позицию — O(log n), быстро
pos = bisect.bisect_left(xs, 6)

# но вставить — O(n)!
xs.insert(pos, 6)

list.insert(i, x) — это O(n), потому что под капотом нужно сдвинуть все элементы от позиции i до конца на одну ячейку вправо. Из-за этого list + bisect плохо подходит для задач, где данные постоянно меняются.

Если массив используется только для чтения (поиска) — list + bisect отлично. Если же мы хотим вставлять, удалять и поддерживать порядок — нужна другая структура.

Вставка в отсортированный list требует сдвига

Каждая insert — O(n). На миллионе элементов 1000 вставок занимают O(n * k) = 10^9 операций.

до1индекс 0
3индекс 1
5индекс 2
7индекс 3
9индекс 4
11индекс 5
вставляем 6между 5 и 7bisect_left даёт позицию 3, но физически вставка требует сдвига
после1не сдвигался
3не сдвигался
5не сдвигался
6новый, на позиции 3
7сдвинут на позицию 4
9сдвинут на позицию 5
11сдвинут на позицию 6

SortedList — O(log n) для всех операций

Библиотека sortedcontainers — стандарт де-факто в Python для отсортированных коллекций. Установка: pip install sortedcontainers. Она написана на чистом Python (!) и обгоняет многие реализации на C, потому что использует умную внутреннюю структуру.

from sortedcontainers import SortedList

sl = SortedList()

# вставка — O(log n)
sl.add(5)
sl.add(2)
sl.add(8)
sl.add(1)
# теперь sl == [1, 2, 5, 8]

# поиск — O(log n)
print(2 in sl)             # True
print(sl.index(5))         # 2 — позиция элемента

# удаление — O(log n)
sl.remove(2)
# теперь sl == [1, 5, 8]

# random access по индексу — O(log n)
print(sl[0])               # 1
print(sl[-1])              # 8 (последний)

# range query — O(log n + k), где k — число элементов в диапазоне
left = sl.bisect_left(3)
right = sl.bisect_right(7)
print(sl[left:right])      # [5]

Все операции — add, remove, __contains__, __getitem__, bisect_left/right — работают за O(log n). Это не «log n с большой константой» — это реально быстро.

Что внутри SortedList

Если открыть исходники sortedcontainers, можно удивиться: там нет красно-чёрного дерева, нет AVL, нет skip list. Это список списков.

Структура хранит элементы в виде нескольких отсортированных подсписков. По умолчанию длина каждого подсписка около 1000. Когда подсписок становится слишком большим, он расщепляется на два. Когда слишком маленьким — сливается с соседним.

Внутренняя структура SortedList

Список из нескольких списков. Каждый внутренний список отсортирован.

bucket 01..1000первая 1000 отсортированных значений
max999индекс максимума бакета
bucket 11001..2000следующая 1000
max1999индекс максимума бакета
bucket 22001..3000и так далее
max2999индекс максимума бакета
indexbisect на массиве максимумовчтобы найти, в каком бакете лежит элемент, делаем bisect на массиве maxes
внутриbisect на бакетевнутри найденного бакета снова bisect

Когда мы ищем элемент, делаем bisect сначала на массиве максимумов бакетов (быстро, бакетов мало), потом bisect внутри найденного бакета. Сложность — O(log n).

Когда вставляем — то же самое: находим бакет, делаем bisect внутри, вставляем (это O(k), где k — длина бакета, около 1000). Поскольку k константа, операция считается O(log n).

Почему это побеждает self-balancing BST (AVL, red-black)? Потому что в BST каждый узел — отдельный объект Python, с указателями и аллокациями. Pointer chasing убивает cache locality. SortedList же хранит элементы массивами — отлично работает с кэшем CPU.

Сравнение с self-balancing BST

В академических курсах обычно учат AVL-дерево или красно-чёрное дерево — структуры, гарантирующие O(log n) для всех операций через балансировку.

свойствоSortedListbalanced BST
вставкаO(log n)O(log n)
удалениеO(log n)O(log n)
поискO(log n)O(log n)
индексацияO(log n)требует augmented BST
итерация по порядкуO(n), очень быстроO(n), медленнее (pointer chasing)
памятькомпактно (массивы)каждый узел — pointer-heavy
cache localityотличнаяплохая

На практике SortedList на 5-10x быстрее питоновской реализации AVL. Поэтому в Python для отсортированной коллекции это де-факто стандарт.

Применение в Data Engineering

Где SortedList реально нужен:

Online median / percentile. Поток событий, нужно поддерживать медиану time-to-process. Простое решение — две SortedList (max-half и min-half) или одна с индексным доступом к середине.

Range queries в скользящем окне. «Сколько событий с латентностью между X и Y в последние 10 минут?». bisect_left(window, X) и bisect_right(window, Y) — разность даёт ответ за O(log n).

Top-K с динамическим обновлением. Когда в потоке нужно поддерживать «топ-100 пользователей по активности» и активность постоянно меняется. SortedList + словарь дают вставку, обновление и срез верхушки за O(log n).

Joining sorted streams. Слияние нескольких отсортированных потоков по времени — merge K sorted lists через heap (об этом будет в модуле 18), но иногда удобнее использовать SortedList в качестве буфера.

from sortedcontainers import SortedList
import time

# имитация: поток событий с латентностью
latencies = SortedList()
window_size = 10000

events = [(time.time(), 42 + i * 0.1) for i in range(100_000)]

for ts, latency in events:
    latencies.add(latency)
    if len(latencies) > window_size:
        # удалить самый старый (тут упрощенно: удалить наименьший — для демо)
        latencies.pop(0)

    # быстрая медиана: индекс len//2
    if len(latencies) % 100 == 0:
        median = latencies[len(latencies) // 2]
        p99 = latencies[int(len(latencies) * 0.99)]

Если бы у нас был обычный list, каждое add и pop(0) было бы O(n). Со SortedList — O(log n). Разница на 10 000 элементах: примерно 1000x ускорение.

TIP

SortedList не нужен, если данные сортируются один раз и читаются. Тогда list + bisect быстрее и проще. SortedList — для случаев, когда вставки/удаления происходят постоянно. Главный тест: «изменяются ли мои данные после первоначальной сортировки?». Если нет — list + bisect. Если да — SortedList.

SortedDict и SortedSet

В библиотеке sortedcontainers есть ещё две полезные структуры:

from sortedcontainers import SortedDict, SortedSet

# словарь, упорядоченный по ключу
sd = SortedDict()
sd[10] = "ten"
sd[5] = "five"
sd[20] = "twenty"

# итерация по упорядоченным ключам
for k in sd:
    print(k, sd[k])
# 5 five
# 10 ten
# 20 twenty

# range query
print(list(sd.irange(5, 15)))   # [5, 10]

# отсортированное множество
ss = SortedSet([5, 10, 5, 20, 10])
print(ss)   # SortedSet([5, 10, 20])
print(ss.bisect_left(10))   # 1

SortedDict особенно полезен для time-series: ключ — timestamp, значение — данные. Можно быстро получать срезы по времени.

Замеры

Сравним вставку 100 000 случайных элементов:

import random
import timeit
import bisect
from sortedcontainers import SortedList

n = 100_000
data = [random.randint(0, n * 10) for _ in range(n)]

# вариант 1: list + bisect.insort
def insert_list():
    xs = []
    for x in data:
        bisect.insort(xs, x)
    return xs

# вариант 2: SortedList
def insert_sortedlist():
    sl = SortedList()
    for x in data:
        sl.add(x)
    return sl

t1 = timeit.timeit(insert_list, number=1)
t2 = timeit.timeit(insert_sortedlist, number=1)

print(f"list + insort: {t1:.2f} s")
print(f"SortedList:    {t2:.2f} s")
print(f"speedup:       {t1 / t2:.1f}x")

Типичный результат: list + insort около 4-5 секунд (O(n²) из-за сдвигов), SortedList около 0.1 секунды (O(n log n)). Разница в 50 раз на 100 000 элементов, и она растёт с размером данных.

Попробуй сам

Реализуйте online median (медиана в потоке) с помощью SortedList:

from sortedcontainers import SortedList

class OnlineMedian:
    def __init__(self):
        self.values = SortedList()

    def add(self, x):
        self.values.add(x)

    def median(self):
        n = len(self.values)
        if n == 0:
            return None
        if n % 2 == 1:
            return self.values[n // 2]
        return (self.values[n // 2 - 1] + self.values[n // 2]) / 2

om = OnlineMedian()
for x in [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]:
    om.add(x)
    print(f"after {x}: median = {om.median()}")

Каждое добавление O(log n), каждый запрос медианы O(log n) (благодаря индексному доступу). На потоке миллиона событий это работает за миллисекунды.

Обслуживание B-tree: расщепление и слияние страниц
Проверка знанийKnowledge check
Почему обычный list + bisect плохо подходит для часто меняющихся отсортированных данных, и какое преимущество даёт внутренняя структура SortedList (список списков) по сравнению с self-balancing BST?
ОтветAnswer
list.insert(i, x) — это O(n), потому что требует сдвига всех элементов от позиции i до конца. Поэтому n вставок в отсортированный list — O(n^2). SortedList же даёт O(log n) на вставку благодаря структуре из бакетов (списков длиной около 1000). Преимущество над BST в двух вещах. Первое — cache locality: внутри бакета элементы лежат в массиве, процессор префетчит их эффективно, тогда как BST состоит из узлов-объектов, разбросанных в памяти. Каждое обращение к узлу — потенциальный cache miss на 100 тактов. Второе — память: BST хранит каждый элемент в отдельном объекте Python с указателями (overhead 50-80 байт на узел), SortedList хранит компактными массивами. На практике SortedList обгоняет питоновские реализации AVL в 5-10 раз.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Почему list + bisect плохо подходит для часто меняющихся отсортированных данных?

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

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

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

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