Содержание
Что такое «O-большое» и зачем оно нужно
O-большое (произносится «О-большое») — это способ описать, как растёт время работы (или объём памяти) при увеличении размера входа.Мы не смотрим на точные секунды (они зависят от компьютера и погоды!), а сравниваем масштаб:
- O(1) — константное время. Пример: нажатие Enter — мгновенно, независимо от количества введённых слов.
- O(n) — линейное время. Пример: перелистывание книги — чем больше страниц, тем дольше.
- O(log n) — логарифмическое время. Пример: поиск в телефонном справочнике методом «каждый раз делим список пополам».
- O(n log n) — типично для эффективных сортировок (QuickSort, TimSort).
- O(n²) — квадратичное время. Пример: проверка каждой пары учеников в классе (30×30 сравнений).
Что значит «O(1) амортизированно»?
Когда мы говорим, что операция выполняется заO(1) амортизированно, мы имеем в виду, что в большинстве случаев она занимает одинаковое, маленькое время — «константу» — но иногда может потребоваться чуть больше усилий, и тогда она «дороже». Однако если усреднить время работы по многим операциям, то «дорогие» шаги бывают редко, и в среднем каждая операция всё равно остаётся «быстрой».
Аналогия: у тебя есть копилка, и ты кладёшь туда монеты. Большую часть раз ты просто бросаешь монету — это быстро. Иногда твоя мама вдруг захочет пересчитать все монеты — и тогда ты тратишь время. Но если смотреть на сотню бросков, подсчёт один раз не сильно скажется на среднем времени «одного броска». Вот это и есть «амортизированное O(1)» — в среднем за операцию платим константу.
Коротко о «скоростях» основных структур
Теперь — какие «скорости» дают самые популярные структуры в Python:- list — элементы хранятся подряд в памяти:
- Доступ по индексу: O(1)
- Вставка или удаление в середине: O(n)
- collections.deque — двусторонняя очередь/стек, где можно извлекать и добавлять крайние элементы с двух сторон (сначала и с конца) Что такое очередь и стек, мы расскажем позже:
- добавить/извлечь с начала или конца: O(1) амортизированно1
- Но произвольный доступ или поиск — O(n)
- dict / set — хеш-таблица:
- Вставка, удаление, поиск ключа: O(1) в среднем2
- heapq — бинарная куча на массиве:
- добавить/извлечь из кучи: O(log n)
Как работает куча (heapq)
Куча — это почти отсортированное бинарное дерево, где в корне всегда лежит или минимум (min-heap), или максимум (max-heap). В Python — min-heap.
[ 2 ]
/ \
[5] [8]
/ \ /
[6] [9][7]
В памяти куча хранится в списке A так, что для индекса i:
- левый потомок —
2·i+1 - правый потомок —
2·i+2 - родитель —
(i−1)//2
- heappush(A, x) — вставляем
xв конец списка, затем «всплываем» вверх: меняем местами с родителем, пока меньше. - heappop(A) — забираем корень (минимум), на его место ставим последний элемент списка, «опускаем» его вниз, меняя с меньшим из детей.
Примеры выбора структуры
🔹 Найти, встречалось ли слово раньше
list: поиск O(n) — медленно при 50 000 слов.set: поиск O(1) в среднем — мгновенно.
🔹 Держать k наибольших элементов
heapq: O(log k) при вставке — хорошо, если k≪n.sorted(list): O(n log n) — медленно для больших n.
1. В каких задачах пригодится deque?
2. Когда лучше словарь, а когда — куча?
1. Последовательности «из коробки»
► list — динамический массив
В Python list хранит элементы в одном непрерывном блоке памяти.
Когда вы добавляете элементы и достигаете текущей ёмкости (capacity), интерпретатор выделяет новый блок в два раза большего размера и копирует туда все ссылки, чтобы оставался запас места для роста.
Благодаря этому доступ по индексу остаётся мгновенным (O(1)), а добавление в конец — амортизированно O(1).
Однако вставка или удаление элемента в середине (или в начале) требует сдвига всех последующих ссылок — O(n). Поиск или удаление по значению тоже O(n). Если вам нужны частые вставки в произвольные позиции или быстрые проверки на вхождение без сдвига, стоит посмотреть в сторону deque, set или dict.
- Как растёт
list(capacity):- Когда
size == capacity, создаётся новый массив размеромmax(1, capacity×2), все элементы копируются, старая память освобождается. - В CPython напрямую capacity не доступна, но
lst.__sizeof__()возвращает байты под внутренний массив — можно сравнить сlen(lst).
- Когда
- Сложность основных операций:
append(x)— O(1) амортизировано (редко O(n), когда нужен ресайз).- Доступ по индексу
lst[i]— O(1). - Вставка/удаление в середине или начале — O(n) (из-за сдвига).
- Поиск/удаление по значению — O(n) (линейный перебор).
lst += other— поэлементное добавление, O(len(other)).
[ a | b | c | _ | _ ] ← capacity = 5, size = 3
append(d) → [ a | b | c | d | _ ] size = 4
append(e) → [ a | b | c | d | e ] size = 5
append(f) → ресайз (×2):
[ a | b | c | d | e | _ | _ | _ ] size = 6, capacity = 8
Пример: псевдокод append с ресайзом
# Python-подобный псевдокод
def append(item):
if size == capacity:
new_cap = max(1, capacity*2)
new_arr = [None] * new_cap
for i in range(size):
new_arr[i] = arr[i]
arr = new_arr
capacity = new_cap
arr[size] = item
size += 1
Когда использовать:
- Нужен упорядоченный набор с быстрым доступом по индексу.
- Частые добавления в конец (
append). - Реализация стека (
append/pop) или очереди (dequeлучше для обеих сторон).
Когда не очень удобно:
- Частые вставки/удаления в середине/начале — выбирайте
deque. - Поиск по значению или гарантированно уникальный набор — выбирайте
setилиdict.
► tuple — неизменяемый упорядоченный контейнер
В Python tuple хранит элементы в одном непрерывном блоке памяти, как list, но после создания его нельзя изменить: добавить, удалить или присвоить по индексу. Благодаря этому tuple легче и компактнее по памяти, а операции неизменности делают их безопасными в многопоточном коде и пригодными для ключей в dict или элементов set.
Из-за неизменяемости:
- Доступ по индексу: O(1).
- Нельзя
append,insertилиpop— такие операции невозможны. - Конкатенация
a + b: O(len(a) + len(b)), создаётся новыйtuple. - Поиск
x in tpl— O(n), линейный перебор.
tpl = (a, b, c)
// память: [ a | b | c ] — ровно size элементов, без запаса
// попытка tpl[0] = x → TypeError: 'tuple' object does not support item assignment
Кэширование литерных кортежей
- CPython компилирует литералы
(1, 2, 3)в константы — они хранятся в таблице констант модуля и переиспользуются при каждом обращении. - Это экономит время и память при многократном использовании одних и тех же кортежей в коде.
- Однако динамически создаваемые кортежи не кешируются автоматически.
Когда использовать:
- Нужен неизменяемый упорядоченный набор.
- Хотите использовать последовательность как ключ в
dictили элементset. - Заранее известное количество элементов и отсутствие мутаций.
Когда не очень удобно:
- Нужны частые изменения: выбирайте
listилиdeque. - Большие объединения: лучше собрать элементы в
listи потом преобразовать вtuple.
Примеры
Простой: доступ и распаковка
tpl = (10, 20, 30)
print(tpl[1]) # 20, O(1)
a, b, c = tpl # распаковка кортежа
Средний: ключи в словаре
points = {
(0, 0): 'origin',
(1, 2): 'point A',
}
print(points[(1, 2)]) # 'point A'
Сложный: объединение и неизменяемость
a = (1, 2)
b = (3, 4)
c = a + b # новый кортеж (1,2,3,4), O(len(a)+len(b))
# a и b остались без изменений
► array.array — гомогенный компактный массив
array.array хранит элементы одного фиксированного типа (числа), упакованные без лишних ссылок. Это даёт меньший объём памяти по сравнению с list и более быструю сериализацию.
- Типы:
'b','i','f'и др. (см. документацию). - Сложность операций:
- Доступ по индексу
a[i]— O(1). append(x)— O(1) амортизированно.- Вставка/удаление в середине — O(n) (сдвиг памяти).
- Поиск
x in a— O(n).
- Доступ по индексу
- Когда использовать: большие числовые массивы, обмен бинарными данными, C-расширения.
- Когда не стоит: разнородные элементы, динамическое добавление разных типов — используйте
listилиnumpy.ndarray.
[ i0 | i1 | i2 | … ] ← каждый элемент занимает одинаковый байт-размер
# Пример: массив 4-байтовых целых
from array import array
a = array('i', [10, 20, 30])
a.append(40) # O(1)
print(a[2]) # 30, O(1)
► Почему array.array лучше для больших однородных данных чем list
Если у вас миллион одинаковых чисел, list хранит каждый элемент как отдельный объект с указателем, а array — просто подряд «сырые» числа. Это значит:
- Меньше памяти: нет лишних указателей и объектов.
- Быстрее запись/чтение на диск или сеть: можно сразу взять весь блок.
import sys
from array import array
# миллион нулей в list и в array
lst = [0] * 10**6
arr = array('i', [0] * 10**6)
print("list:", sys.getsizeof(lst), "bytes")
print("array:", sys.getsizeof(arr), "bytes")
На моей машине это даёт примерно:
list: 8697456 bytes
array: 4000016 bytes
Видно, что array почти вдвое компактнее.
► bytes — неизменяемая последовательность байтов
bytes хранит бинарные данные как подряд идущие байты без возможности изменения. Используется для сетевых протоколов, криптографии, работы с файлами.
- Сложность операций:
- Доступ по индексу
b[i]— O(1). - Срезы
b[i:j]— O(k), создаётся новыйbytesдлиныk. - Конкатенация
b1 + b2— O(len(b1)+len(b2)). - Поиск
needle in haystack— O(n·m) (алгоритм поиска подстроки).
- Доступ по индексу
- Когда использовать: неизменяемые бинарные данные, ключи хеш-таблиц, константы.
- Когда не стоит: требуется изменение содержимого — используйте
bytearray.
data = b'Hello, world!'
print(data[7]) # 119 (код 'w'), O(1)
chunk = data[0:5] # b'Hello', O(5)
new = data + b'!!' # b'Hello, world!!!', O(n)
► bytearray — изменяемая последовательность байтов
bytearray похож на bytes, но позволяет менять отдельные байты, добавлять и удалять. Под капотом тоже динамический массив байтов.
- Сложность операций:
- Доступ и присвоение
ba[i]— O(1). append/extend— O(1) амортизированно.- Вставка/удаление середине — O(n).
- Срезы и конкатенации — O(k) или O(n+m).
- Доступ и присвоение
- Когда использовать: чтение/запись бинарных протоколов, формирование и изменение буферов.
- Когда не стоит: только чтение без изменений — проще
bytes.
buf = bytearray(b'\x00\x01\x02')
buf[1] = 255 # O(1)
buf.append(3) # O(1)
del buf[0] # O(n): сдвиг байтов
print(buf) # bytearray(b'\xff\x02\x03')
ASCII-иллюстрация памяти bytearray:
[ b0 | b1 | b2 | _ | _ ] ← capacity = 5, size = 3
append(3) → [ b0 | b1 | b2 | 3 | _ ] size = 4
append(4) → [ b0 | b1 | b2 | 3 | 4 ] size = 5
append(5) → ресайз (×2):
[ b0 | b1 | b2 | 3 | 4 | _ | _ | _ ] size = 6, capacity = 8
► Сравнительная таблица встроенных структур
| Структура | Изменяемость | Что хранит | Доступ по индексу | Добавление/удаление | Когда выбирать |
|---|---|---|---|---|---|
list |
да | любые объекты | O(1) | справа O(1), внутри O(n) | общие задачи, стек (append/pop) |
tuple |
нет | любые объекты | O(1) | — | фиксированные наборы, ключи в словаре |
dict |
да | пары «ключ→значение» | по ключу O(1) | O(1) | быстрый поиск по ключу |
set |
да | уникальные объекты | — | O(1) | проверка вхождения, уникальность |
frozenset |
нет | уникальные объекты | — | — | ключи в словаре, константы |
array.array |
да | однотипные данные | O(1) | справа O(1), внутри O(n) | большие числовые массивы, бинарные данные |
bytes |
нет | неизменяемые байты | O(1) | — | чтение бинарных файлов, протоколы |
bytearray |
да | изменяемые байты | O(1) | справа O(1), внутри O(n) | буферы, модификация бинарных данных |
deque (collections) |
да | любые объекты | O(n) | с двух сторон O(1) | очередь, двунаправленная очередь |
1.1. Какие операции у list выполняются за O(1), а какие — за O(n)?
1.2. Почему кортеж (tuple) можно использовать в качестве ключа словаря, а список (list) — нет?
1.3. В чём разница междуbytesиbytearray? Когда выбрать каждый из них?
1.4. Почемуarray.arrayзанимает меньше памяти, чемlistпри хранении одинаковых чисел?
1.5. Для чего полезна неизменяемостьtupleиbytes? Приведите пример использования.
Задания. Блок 1. Списки, кортежи и компактные массивы
1.1. list — «скользящий» буфер сообщений Задание: Напишите функциюdef add_message(buffer: list[str], msg: str, max_len: int) -> None:которая добавляетmsgв конецbufferи удаляет первый элемент, если длина превысилаmax_len. ❓ Почему удаление первого элемента стоит O(n)?
1.2. list — скользящие средние Задание: Реализуйте функциюdef sliding_avg(data: list[float], k: int) → list[float]:которая для спискаdata=[1,2,3,4,5]иk=3вернёт[2.0, 3.0, 4.0]. ❓ Какова временная сложность этого алгоритма?
1.3. tuple — ключ для словаря Задание: Дан список координат:coords = [(0,0), (1,2), (0,0), (2,1)]Постройте словарь, где ключ — координата (tuple), а значение — число её вхождений. ❓ Почему именноtupleможно использовать ключом?
1.4. array.array — инверсия пикселей Задание: Есть список значений пикселей (0…255):pixels = [12, 200, 34, 255, 0, 128]Запишите их вarray('B')и замените каждый элемент на255 − x(инверсия цвета). Покажите код. ❓ Почему при большом числе пикселейarray('B')лучше, чемlist?
1.5. bytearray — правка текста в байтах Задание: Дана байтовая строка:data = b"Error 404: Not Found"Преобразуйте вbytearray, замените все пробелы на подчёркивания и верните результат в видеbytes. ❓ Почему такую правку нельзя сделать напрямую сbytes?
Ответы
1.1.
— append в конец: амортизированно O(1).
— удаление первого элемента (pop(0)): O(n), т.к. после удаления все остальные сдвигаются на 1.
1.2.
— Для каждого из N−k+1 окон по k элементов: O((N−k+1)·k) ≈ O(N·k). Если k≈N, то O(N²).
1.3.
— Ключи словаря должны быть неизменяемыми и хешируемыми.
— tuple неизменяем и имеет хеш, list изменяем и хеш не поддерживает.
1.4.
— array('B') хранит «сырые» байты без лишних объектов и указателей, каждый элемент гарантированно 1 байт.
— list хранит объекты int и указатели — ~28 байт на элемент.
1.5.
— bytes — неизменяемый тип, нельзя менять содержимое по индексу.
— bytearray — изменяемый буфер, позволяет править отдельные байты.
Решения
# 1.1. Скользящий буфер сообщений
def add_message(buffer: list[str], msg: str, max_len: int) -> None:
buffer.append(msg) # O(1) амортизированно
if len(buffer) > max_len:
buffer.pop(0) # O(n): сдвиг всех элементов
# 1.2. Скользящие средние
def sliding_avg(data: list[float], k: int) -> list[float]:
n = len(data)
result = []
for i in range(n - k + 1): # O(n−k+1)
window = data[i:i+k] # O(k)
result.append(sum(window) / k) # O(k)
return result
# 1.3. Словарь подсчёта координат
coords = [(0,0), (1,2), (0,0), (2,1)]
counts: dict[tuple[int,int], int] = {}
for pt in coords: # O(n)
counts[pt] = counts.get(pt, 0) + 1
# 1.4. Инверсия пикселей с array('B')
from array import array
pixels = [12, 200, 34, 255, 0, 128]
arr = array('B', pixels) # каждый элемент 1 байт
for i in range(len(arr)): # O(n)
arr[i] = 255 - arr[i] # O(1) присвоение
# arr теперь содержит инвертированные значения
# 1.5. Замена пробелов через bytearray
data = b"Error 404: Not Found"
buf = bytearray(data) # копия в изменяемый буфер
for i in range(len(buf)): # O(n)
if buf[i] == 0x20: # код пробела
buf[i] = ord('_') # заменить на '_'
result = bytes(buf) # вернуть в bytes
Односвязный и двусвязный списки
Связный список — это последовательность узлов (элементов), где каждый узел хранит само значение и «указатель» на следующий (и, в случае двусвязного, ещё и на предыдущий) узел. Указатель — это просто ссылка или адрес, по которому хранится следующий элемент. Зачем это нужно? В обычном списке (list) вставка или удаление в середине требует сдвига всех элементов. В связном списке достаточно перенастроить указатели. Знание этих структур помогает понимать, как устроены более сложные алгоритмы и оптимизировать память и скорость там, где это важно.
Односвязный список (Singly Linked List)
[Head] → [ A | next ] → [ B | next ] → [ C | next ] → None
- Получить i-й элемент: пройти по
nexti раз — O(i). - Вставка в середину: перенаправить «next» предыдущего узла на новый, а новый — на старый следующий — O(1).
- Удаление в середине: предыдущий узел «next» = узел.next.next — O(1).
- Вставка в конец: либо хранить tail, либо пройти до конца O(n), затем добавить новый узел и обновить tail.
- Смена мест двух узлов: перенастроить соседние указатели вокруг них (несколько O(1) операций, но нужно знать предыдущие узлы).
# Вставка узла X между A и B:
A.next → X
X.next → B
# Удаление узла B (между A и C):
A.next → C
Двусвязный список (Doubly Linked List)
None ← [prev | A | next] ←→ [prev | B | next] ←→ [prev | C | next] → None
- Каждый узел хранит
prev(ссылку на предыдущий) иnext. - Получить предыдущий элемент за O(1) — удобно для обхода в обе стороны.
- Вставка/удаление узла X между A и B:
# Вставка X между A и B:
X.prev → A
X.next → B
A.next → X
B.prev → X
# Удаление X:
A = X.prev
B = X.next
A.next → B
B.prev → A
Реализация в Python
class SinglyNode:
def __init__(self, value):
self.value = value
self.next = None
class SinglyLinkedList:
def __init__(self):
self.head = None
def append(self, value):
node = SinglyNode(value)
if not self.head:
self.head = node
return
cur = self.head
while cur.next:
cur = cur.next
cur.next = node
def insert_after(self, prev_node, value):
if not prev_node: return
node = SinglyNode(value)
node.next = prev_node.next
prev_node.next = node
def delete_after(self, prev_node):
if not prev_node or not prev_node.next: return
prev_node.next = prev_node.next.next
def get(self, index):
cur = self.head
for _ in range(index):
if not cur: return None
cur = cur.next
return cur.value if cur else None
class DoublyNode:
def __init__(self, value):
self.value = value
self.prev = None
self.next = None
class DoublyLinkedList:
def __init__(self):
self.head = None
self.tail = None
def append(self, value):
node = DoublyNode(value)
if not self.head:
self.head = self.tail = node
return
self.tail.next = node
node.prev = self.tail
self.tail = node
def insert_after(self, node, value):
if not node: return
new = DoublyNode(value)
nxt = node.next
node.next = new
new.prev = node
new.next = nxt
if nxt:
nxt.prev = new
else:
self.tail = new
def delete(self, node):
if not node: return
if node.prev:
node.prev.next = node.next
else:
self.head = node.next
if node.next:
node.next.prev = node.prev
else:
self.tail = node.prev
def get(self, index):
cur = self.head
for _ in range(index):
if not cur: return None
cur = cur.next
return cur.value if cur else None
Почему в Python редко делают так вручную?
- Встроенный
listиdequeработают на C, очень оптимизированы и быстрее нашего Python-кода. - Дополнительные объекты узлов и ссылки занимают больше памяти и замедляют работу интерпретатора.
- Тем не менее, понимание связных списков помогает при изучении алгоритмов, низкоуровневых реализаций и работы памяти «под капотом».
2.1. В чем разница между односвязным и двусвязным списком? Какой из них требует больше памяти на узел и почему?
2.2. Какие операции в связном списке выполняются за O(1), а какие — за O(n)? Приведите примеры.
2.3. Что такое «указатель» (pointer) в контексте связного списка, и зачем он нужен?
2.4. Почему при удалении узла в середине односвязного списка нужно знать предыдущий узел? Как этого избежать?
2.5. Как реализовать проверку на наличие цикла в связном списке без использования дополнительной памяти?
Задания. Блок 2. Связные списки
2.1. Создание и обход Задание: Реализуйте односвязный список с методами `append(value)` и `to_list()`, который возвращает все значения в виде Python-списка. ❓ Какова сложность метода `to_list()`?
2.2. Реверс односвязного списка Задание: Напишите метод `reverse()` для вашего односвязного списка, который меняет порядок узлов на обратный, не создавая новые узлы. ❓ Почему алгоритм выполняется за O(n) и O(1) дополнительной памяти?
2.3. Удаление по значению Задание: Добавьте в односвязный список метод `remove(value)`, который удаляет первый узел с указанным значением. Учтите крайние случаи (пустой список, удаление головы). ❓ Какой указатель необходимо обновить при удалении головы?
2.4. Объединение списков Задание: Даны два отсортированных односвязных списка. Реализуйте функцию `merge(l1, l2)`, которая сливает их в один отсортированный список, не создавая новых узлов. ❓ Почему этот алгоритм идёт за O(n₁ + n₂)?
2.5. Двухсвязный список: вставка и удаление Задание: Реализуйте двусвязный список с полями `head`, `tail` и методами `insert_after(node, value)` и `delete(node)`. ❓ Какие указатели нужно переназначить при удалении узла из середины?
Стек и очередь
Стек (stack) и очередь (queue) — базовые линейные структуры данных:- Стек — «последний зашёл — первый вышел» (LIFO). Представьте стопку тарелок: кладёте сверху, берёте тоже с верха.
- Очередь — «первый зашёл — первый вышел» (FIFO). Представьте очередь в кассу: первый пришёл — первым обслужили.
Аналогии из реальной жизни
- Стек: стопка тарелок, отмена действий в текстовом редакторе (undo).
- Очередь: очередь в магазине, печать документов в принтере.
Как это работает
Стек (push/pop):
[ ] ← пустой
push(1) → [ 1 ]
push(2) → [ 1, 2 ]
pop() → [ 1 ] returns 2
Очередь (enqueue/dequeue):
[ ] ← пустая
enq(1) → [ 1 ]
enq(2) → [ 1, 2 ]
deq() → [ 2 ] returns 1
В Python
- Стек: можно на
list(append/pop) или лучше наcollections.deque. - Очередь:
collections.dequeилиqueue.Queue(для многопоточности). - Почему
dequeлучшеlist: оба конца O(1) при вставке/удалении, тогда какlist.pop(0)— O(n).
Пример реализации с deque
from collections import deque
# Стек
stack = deque()
stack.append(1) # O(1)
stack.append(2)
print(stack.pop()) # 2, O(1)
# Очередь
queue = deque()
queue.append(1) # enqueue O(1)
queue.append(2)
print(queue.popleft()) # 1, dequeue O(1)
Когда использовать
- Стек: обход графа в глубину (DFS), отмена операций, разбор выражений.
- Очередь: обход графа в ширину (BFS), буферы задач, очереди сообщений.
- Не подходит, если нужна быстрое случайное удаление в середине.
Практические примеры
Практические задачи и подход к их решению
- Поиск максимума в скользящем окне
Дана последовательность чисел
numsи окно размераk. Нужно для каждого положения окна найти его максимум. Подход: используемdequeкак «монотонную очередь» — будем хранить в ней индексы элементов так, чтобы значения в них шли по убыванию. При сдвиге окна удаляем из головы устаревшие индексы и из хвоста все меньшие нового элемента. - Вывод последних N строк лога
Дан текстовый лог-файл, нужно вернуть его последние
nстрок без чтения всего файла в память. Подход: используемdequeс параметромmaxlen=n. По мере чтения добавляем строки в очередь, старые автоматически удаляются. - Проверка правильности скобочной последовательности
Дано выражение, состоящее из скобок
()[]{}. Нужно проверить, что скобки правильно открываются и закрываются. Подход: используемstack(реализованный наdeque). При открывающей скобке — кладём в стек, при закрывающей — проверяем соответствие с вершиной стека. - Топологическая сортировка графа
Даны задачи (вершины) и зависимости между ними (ориентированные рёбра). Нужно найти порядок выполнения так, чтобы все зависимости соблюдались.
Подход: считаем для каждой вершины число входящих рёбер (
in-degree) и помещаем все вершины с нулевымin-degreeвqueue(deque). Далее по алгоритму Кана: извлекаем из очереди, уменьшаемin-degreeсоседей, добавляем в очередь вновь освободившиеся вершины.
1. «Скользящее окно» (sliding window)
from collections import deque
def max_sliding_window(nums, k):
dq = deque() # хранит индексы, элементы — убывающие
res = []
for i, x in enumerate(nums):
# убрать из головы, вышедшие за окно
if dq and dq[0] <= i - k:
dq.popleft()
# убрать все меньшие, чем новый
while dq and nums[dq[-1]] < x: dq.pop() dq.append(i) if i >= k - 1:
res.append(nums[dq[0]])
return res
print(max_sliding_window([1,3,-1,-3,5,3,6,7], 3))
# → [3, 3, 5, 5, 6, 7]
2. Логи с ограничением размера
from collections import deque
def tail(filename, n=10):
buf = deque(maxlen=n)
with open(filename, 'r') as f:
for line in f:
buf.append(line.rstrip())
return list(buf)
# buf хранит только последние n строк, автоматически сбрасывая старые
3. Синтаксический разбор скобок
def is_valid(expr):
stack = deque()
pairs = {'(':')', '[':']', '{':'}'}
for ch in expr:
if ch in pairs:
stack.append(ch)
elif ch in pairs.values():
if not stack or pairs[stack.pop()] != ch:
return False
return not stack
print(is_valid("({[]})")) # True
print(is_valid("([)]")) # False
4. Топологическая сортировка (асинхронный граф)
Задача: есть набор задач (вершин) и зависимости (ребра):
A → B → D
↓ ↑
C -------
Нужно найти порядок выполнения так, чтобы все зависимости соблюдены (топологическая сортировка).
Что сделать:
- Подсчитать для каждой вершины число входящих рёбер (in-degree).
- Положить все вершины с in-degree=0 в очередь.
- Удалять из очереди, добавлять в результат и уменьшать in-degree у соседей, добавляя новые с 0 в очередь.
from collections import deque, defaultdict
def topo_sort(edges):
g = defaultdict(list)
indeg = defaultdict(int)
for u, v in edges:
g[u].append(v)
indeg[v] += 1
indeg.setdefault(u, 0)
q = deque([u for u in indeg if indeg[u]==0])
order = []
while q:
u = q.popleft()
order.append(u)
for v in g[u]:
indeg[v] -= 1
if indeg[v] == 0:
q.append(v)
return order
edges = [('A','B'),('A','C'),('B','D'),('C','D')]
print(topo_sort(edges)) # Напр., ['A','B','C','D'] или ['A','C','B','D']
3.1. В чём основное отличие стека (LIFO) от очереди (FIFO)? Приведите по одному примеру из реальной жизни для каждого.
3.2. Какие методы уdequeвыполняют вставку и удаление за O(1)? Какие аналоги есть уlistи какова их сложность?
3.3. Почему для реализации очереди лучше использоватьdeque, а неlist? Что происходит приlist.pop(0)?
3.4. Как стек используется при проверке правильности скобочной последовательности? Опишите алгоритм.
3.5. Как очередь помогает при обходе графа в ширину (BFS)? Что хранят в очереди и как обновляется состояние?
Задания. Блок 3. Стек и очередь
3.1. Разворот строки через стек Задание: Напишите функциюdef reverse_str(s: str) -> str:которая с помощью стека (deque) переворачивает строку. ❓ Какова временная сложность алгоритма?
3.2. Вычисление постфиксного выражения Задание: Напишите функциюdef eval_postfix(tokens: list[str]) -> int:которая на основе стека вычисляет значение выражения в обратной польской нотации, например["3","4","+","2","*"]→ 14. ❓ Почему для этого удобно использовать стек?
3.3. Симуляция планировщика (round-robin) Задание: Есть список процессов["P1","P2","P3"]и квант1(единица работы). Симулируйте исполнение: каждый раз берём из очереди процесс, «отрабатываем» квант, если ему осталось больше нуля — возвращаем в очередь с уменьшённым временем. Верните порядок выполнения. ❓ Какой метод очереди позволяет удалить и сразу получить элемент?
3.4. Поиск кратчайшего пути в сетке Задание: Дана матрица 0 и 1, 0 — проходимая клетка, 1 — стена. Найдите длину кратчайшего пути от (0,0) до (n−1,m−1) по 4-м направлениям, или верните −1, если пути нет. ❓ Как с помощью очереди обеспечить обход в ширину?
3.5. Обход каталогов (DFS) Задание: Симулируйте обход файловой системы: у вас словарь, где ключ — имя папки, значение — список файлов и подпапок. Используя стек, верните список всех файлов в порядке глубокого обхода. ❓ Почему стек позволяет реализовать DFS без рекурсии?
Ответы
3.1. Для "hello" результат → "olleh". 3.2. ["3","4","+","2","*"] → 14. 3.3. При P1:2, P2:3, P3:1 (например), порядок → ["P1","P2","P3","P1","P2",...]. 3.4. В простом случае (2×2 без стен) путь = 3 шага или -1. 3.5. Список всех файлов в глубину, например ["f1","f2","f3",...].
Решения
# 3.1. reverse_str
def reverse_str(s: str) -> str:
stack = deque(s)
res = []
while stack:
res.append(stack.pop())
return ''.join(res)
# 3.2. eval_postfix
def eval_postfix(tokens: list[str]) -> int:
stack = deque()
for t in tokens:
if t.isdigit():
stack.append(int(t))
else:
b, a = stack.pop(), stack.pop()
if t == '+': stack.append(a + b)
elif t == '-': stack.append(a - b)
elif t == '*': stack.append(a * b)
elif t == '/': stack.append(int(a / b))
return stack.pop()
# 3.3. round-robin scheduler
def rr_schedule(procs: dict[str,int]) -> list[str]:
q = deque((name for name in procs))
order = []
while q:
p = q.popleft()
procs[p] -= 1
order.append(p)
if procs[p] > 0:
q.append(p)
return order
# 3.4. BFS shortest path in grid
def shortest_path(grid: list[list[int]]) -> int:
n, m = len(grid), len(grid[0])
q = deque([ (0,0,0) ]) # x,y,dist
seen = {(0,0)}
while q:
x,y,d = q.popleft()
if (x,y) == (n-1,m-1):
return d
for dx,dy in ((1,0),(-1,0),(0,1),(0,-1)):
nx, ny = x+dx, y+dy
if 0<=nx list[str]:
stack = deque([('', node) for node in tree['/']])
files = []
while stack:
path, name = stack.pop()
full = f"{path}/{name}"
if name in tree: # папка
for child in tree[name]:
stack.append((full, child))
else:
files.append(full)
return files
# 3.5. DFS directory traversal
def dfs_files(tree: dict[str,list[str]]) -> list[str]:
stack = deque([('', node) for node in tree['/']])
files = []
while stack:
path, name = stack.pop()
full = f"{path}/{name}"
if name in tree: # папка
for child in tree[name]:
stack.append((full, child))
else:
files.append(full)
return files
Хэш-структуры
Хэш — это число-отпечаток, которое получается из данных (строки, числа и т.п.) с помощью специальной функции (hash-функции).Оно используется как «адрес» для быстрого хранения и поиска.
Хэш-таблица — это как почтовые ящики: у каждого ключа есть свой номер (hash), и по этому номеру мы сразу находим ящик, где хранится значение.
+--------+--------+--------+
hash | bucket | bucket | bucket | …
+--------+--------+--------+
↓ ↑ ↓ ↑ ↓ ↑
[k:v] [ ] [k:v]
(1) (2)
1) Ключ k попал в bucket по hash(k)
2) Если несколько ключей попали в один bucket, они складываются в список внутри этого bucket
Словарь (dict)
- Что хранит: пары «ключ → значение».
- Бакеты: внутрь каждой «ячейки» (bucket) помещается одна или несколько пар в случае коллизий.
- Требования к ключу: должен быть неизменяемым и хешируемым (строка, число, кортеж из хешируемых и т.п.).
- Скорость операций:
- Поиск по ключу — O(1) в среднем.
- Добавление/удаление — O(1) в среднем.
- В худшем случае (много коллизий) — O(n).
- Преимущества: быстрый доступ по ключу, понятный синтаксис.
- Ограничения: ключи должны быть хешируемыми, большой размер → больше памяти.
- Порядок вставки: с Python 3.7 словари сохраняют порядок добавления ключей.
- Когда использовать: связывать данные по уникальному ключу, строить индексы, кэшировать результаты.
Примеры работы со словарём
# Создание и базовые операции
d = {} # пустой словарь
d['Alice'] = 25 # добавить пару
d['Bob'] = 30
print(d['Alice']) # → 25
print('Eve' in d) # → False
# Перебор ключей и значений
for name, age in d.items():
print(name, age)
# Получение с умолчанием
print(d.get('Eve', 0)) # → 0
# Удаление
d.pop('Bob') # удаляет и возвращает 30
# Инициализация из списка пар
pairs = [('x', 1), ('y', 2)]
d2 = dict(pairs)
Множество (set) и неизменяемое множество (frozenset)
- Что хранит: набор уникальных хешируемых элементов (ключи без значений).
- Как хэши применяются: элемент попадает в bucket по своему hash; коллизии обрабатываются списком внутри bucket.
- Скорость операций:
- Проверка вхождения — O(1) в среднем.
- Добавление/удаление — O(1) в среднем.
- Объединение, пересечение, разность множеств — O(n) в зависимости от размера.
- Преимущества: быстрое проверка уникальности, операции над множествами.
- Ограничения: только хешируемые элементы, большие объёмы данных требуют больше памяти.
- Когда использовать: удаление дубликатов, тест «вхождения», операции пересечений (поиск общих элементов).
Примеры работы с set и frozenset
# set — изменяемое множество
s = {1, 2, 3}
s.add(4) # → {1,2,3,4}
s.remove(2) # → {1,3,4}
print(3 in s) # → True
# Операции над множествами
a = {1,2,3}
b = {2,3,4}
print(a & b) # пересечение → {2,3}
print(a | b) # объединение → {1,2,3,4}
print(a - b) # разность → {1}
# frozenset — неизменяемый аналог
fs = frozenset([1,2,2,3])
# fs.add(4) → AttributeError, нельзя менять
print(fs) # → frozenset({1,2,3})
Практические задачи с dict и set
- Подсчёт частоты слов
Постановка: дана строка текста. Нужно узнать, сколько раз встретилось каждое слово.
Алгоритм:
- Разбить текст по пробелам и знакам пунктуации.
- Для каждого слова увеличить счётчик в словаре
freq:freq[word] = freq.get(word, 0) + 1. - Вернуть получившийся словарь.
- Поиск общих уникальных элементов
Постановка: даны два списка чисел. Нужно вернуть все числа, которые встречаются и в первом, и во втором, без повторов.
Алгоритм:
- Преобразовать каждый список в множество.
- Взять пересечение:
set1 &set2. - Вернуть результат (можно как список).
- Группировка анаграмм
Постановка: дана коллекция слов. Нужно сгруппировать их по анаграммам (слова, содержащие одни и те же буквы).
Алгоритм:
- Создать словарь
groups, где ключ — отсортированная буква слова (''.join(sorted(w))), а значение — список слов. - Для каждого слова добавить в
groups[key]. - Вернуть список групп (
groups.values()).
- Создать словарь
- Рекомендация друзей (friends-of-friends)
Постановка: у каждого пользователя есть множество друзей. Для данного пользователя найти тех, кто не в его друзьях, но является другом его друзей.
Алгоритм:
- Объединить множества друзей всех его друзей.
- Удалить из полученного множества самого пользователя и его прямых друзей.
- Результат — множество рекомендаций.
- Проверка орфографии
Постановка: дана коллекция слов из текста и набор правильных слов (словарь). Нужно найти все уникальные слова, отсутствующие в словаре.
Алгоритм:
- Преобразовать список слов текста в множество (
unique_words). - Вычесть множество словаря:
unique_words - dict_set. - Вернуть получившееся множество опечаток.
- Преобразовать список слов текста в множество (
Решения
# 1. Подсчёт частоты слов
import re
def word_freq(text: str) -> dict[str,int]:
words = re.findall(r"\\b\\w+\\b", text.lower())
freq = {}
for w in words:
freq[w] = freq.get(w, 0) + 1
return freq
# 2. Общие уникальные элементы
def common_unique(a: list[int], b: list[int]) -> set[int]:
return set(a) & set(b)
# 3. Группировка анаграмм
from collections import defaultdict
def group_anagrams(words: list[str]) -> list[list[str]]:
groups = defaultdict(list)
for w in words:
key = "".join(sorted(w))
groups[key].append(w)
return list(groups.values())
# 4. Рекомендация друзей
def recommend_friends(user: str, friends: dict[str,set[str]]) -> set[str]:
direct = friends.get(user, set())
fof = set()
for f in direct:
fof |= friends.get(f, set())
return fof - direct - {user}
# 5. Проверка орфографии
def spell_check(text_words: list[str], dict_words: set[str]) -> set[str]:
unique_words = set(w.lower() for w in text_words)
return unique_words - dict_words
# Примеры вызова:
if __name__ == "__main__":
txt = "Hello, hello world! This is a test. Test this."
print(word_freq(txt))
print(common_unique([1,2,3,2], [2,3,4]))
print(group_anagrams(["eat","tea","tan","ate","nat","bat"]))
friends_map = {
"Alice": {"Bob","Carol"},
"Bob": {"Alice","Dave"},
"Carol": {"Alice","Eve"},
"Dave": {"Bob"},
"Eve": {"Carol"}
}
print(recommend_friends("Alice", friends_map))
words = ["Ths","is","a","test","Test","this"]
dict_set = {"this","is","a","test"}
print(spell_check(words, dict_set))
4.1. Почему ключи в словаре должны быть неизменяемыми? Что произойдёт, если попытаться использовать изменяемый объект как ключ?
4.2. Что такое коллизия в хэш-таблице и как Python её обрабатывает?
4.3. Каково среднее и худшее время операций чтения, записи и удаления вdictиset?
4.4. Почемуfrozensetможно использовать как ключ словаря, аset— нет?
4.5. Как сохраняется порядок элементов в современном словаре Python? С какого релиза это гарантировано?
Задания. Блок 4. Хэш-структуры
4.1. Частотный словарь
Постановка: дана строка с текстом. Постройте словарь, где ключ — слово, значение — число его вхождений, игнорируя регистр и знаки пунктуации.
❓ Почему для подсчёта частот удобно использовать метод dict.get(key, 0)?
4.2. Инвертированный индекс Постановка: есть список документов (каждый — строка). Нужно создать словарь, где ключ — слово, а значение — множество индексов документов, в которых это слово встречается. ❓ Почему здесь пригодитсяsetdefaultилиdefaultdict(set)?
4.3. Поиск дубликатов Постановка: дан список чисел. Нужно вернуть новый список без дубликатов, сохранив порядок первого появления. ❓ Как сочетаниеsetиlistпомогает это сделать?
4.4. Кэш результата функции
Постановка: напишите декоратор @memoize, который кэширует результаты вызова функции с неизменяемыми аргументами, используя словарь.
❓ Как сформировать ключ для аргументов функции?
4.5. Группировка по множеству тегов Постановка: есть словарьitems: dict[int, set[str]]— ключ ID, значение набор тегов. Нужно перевернуть структуру: получить словарь, где ключ — тег, а значение — множество ID, у которых есть этот тег. ❓ Почему при группировке удобно использоватьdefaultdict(set)?
Ответы
4.1. Для неизменяемости и стабильности хэша: mutable-объект не имеет постоянного hash → TypeError.
4.2. Коллизия — два ключа с одинаковым hash попадают в один бакет. Python проверяет их по == и хранит в списке в этом бакете.
4.3. dict/get/set операций:
• Среднее чтение/запись/удаление — O(1)
• Худшее при коллизиях — O(n)
4.4. frozenset неизменяем и хешируем → можно использовать как ключ; set изменяем и не хешируется.
4.5. С Python 3.7 словари сохраняют порядок вставки (раньше это было implementation detail в CPython 3.6).
Решения
import re
from collections import defaultdict
from functools import wraps
# 4.1. Частотный словарь
def word_freq(text: str) -> dict[str,int]:
words = re.findall(r"\b\w+\b", text.lower())
freq = {}
for w in words:
freq[w] = freq.get(w, 0) + 1
return freq
# 4.2. Инвертированный индекс
def inverted_index(docs: list[str]) -> dict[str,set[int]]:
idx = defaultdict(set)
for i, doc in enumerate(docs):
for w in re.findall(r"\b\w+\b", doc.lower()):
idx[w].add(i)
return dict(idx)
# 4.3. Поиск дубликатов, сохранение порядка
def unique_in_order(seq: list[int]) -> list[int]:
seen = set()
result = []
for x in seq:
if x not in seen:
seen.add(x)
result.append(x)
return result
# 4.4. Декоратор memoize
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args, **kwargs):
key = args + tuple(sorted(kwargs.items()))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
# 4.5. Группировка по тегам
def group_by_tag(items: dict[int,set[str]]) -> dict[str,set[int]]:
tag_map = defaultdict(set)
for item_id, tags in items.items():
for tag in tags:
tag_map[tag].add(item_id)
return dict(tag_map)
# Пример вызова:
if __name__ == "__main__":
text = "Hello, world! Hello Python."
print(word_freq(text))
docs = ["The quick brown fox", "Quick fox jumps", "Lazy dog"]
print(inverted_index(docs))
print(unique_in_order([1,2,1,3,2,4]))
@memoize
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
print(fib(10))
items = {1: {"a","b"}, 2: {"b","c"}, 3: {"a"}}
print(group_by_tag(items))
Приоритетные очереди и кучи
Куча (heap) — это структура данных, которая позволяет быстро получать самый «важный» элемент (минимальный или максимальный).Представьте кучу книг, где самая лёгкая всегда сверху (min-heap) или самая тяжёлая сверху (max-heap). Доступ к этому «верхнему» элементу — O(1), а вставка и удаление занимают O(log n).
Как устроена куча
Куча хранится как почти полное бинарное дерево, но под капотом — простой массив. Для узла с индексомi в массиве:
- Левый ребёнок —
2*i + 1 - Правый ребёнок —
2*i + 2 - Родитель —
(i-1)//2
Индекс в массиве: 0 1 2 3 4 5 6
Позиция в дереве:
[0]
/ \
[1] [2]
/ \ / \
[3] [4] [5] [6]
Вставка (push) нового элемента
- Вставляем элемент в конец массива (новый лист).
- «Всплываем» его вверх: сравниваем с родителем и меняем местами, если нарушает порядок (для min-heap — если меньше родителя).
- Повторяем до тех пор, пока порядок не восстановится или не дойдём до корня.
Пример вставки 2 в min-heap [1, 3, 4, 7, 6]:
Массив: [1,3,4,7,6] → добавить 2 → [1,3,4,7,6,2]
Индекс 5: родитель = (5-1)//2 = 2 (значение 4)
2 < 4 → поменять местами:
[1,3,2,7,6,4]
Индекс теперь 2: родитель = (2-1)//2 = 0 (значение 1)
2 ≥ 1 → готово.
Удаление (pop) «верхнего» элемента
- Берём корень (самый важный элемент) для возвращения.
- Последний элемент массива копируем в корень.
- «Просеиваем» этот элемент вниз: сравниваем с детьми, меняем с меньшим (в min-heap), пока порядок не восстановится или нет детей.
Удаляем 1 из [1,3,2,7,6,4]:
1. Забираем 1.
2. Последний = 4 → ставим в корень: [4,3,2,7,6]
3. Сравниваем 4 с детьми 3 и 2 → 2 меньше → меняем с 2:
[2,3,4,7,6]
4. Теперь индекс 2: дети нет или 7,6 — оба ≥ 4 → готово.
Каждое «всплытие» или «просеивание» перемещается по высоте дерева — O(log n). Поэтому вставка и удаление работают за O(log n).
Куча в Python
В стандартной библиотеке есть модульheapq, который реализует min-heap поверх списка:
import heapq
heap = []
for x in [5, 1, 7, 3]:
heapq.heappush(heap, x) # вставить x
print(heap) # [1, 3, 7, 5]
smallest = heapq.heappop(heap) # удалить и вернуть минимальный
print(smallest, heap) # 1, [3, 5, 7]
Min-heap и Max-heap
- Min-heap: корень — наименьший элемент.
- Max-heap: корень — наибольший элемент.
# Вариант 1: отрицание
import heapq
nums = [5,1,7,3]
heap = []
for x in nums:
heapq.heappush(heap, -x)
print(-heapq.heappop(heap)) # 7
# Вариант 2: кортеж с приоритетом
heap = []
for x in nums:
heapq.heappush(heap, (-x, x)) # (-priority, value)
_, max_val = heapq.heappop(heap)
print(max_val) # 7
Скорость и применение
- Доступ к корню: O(1)
- Вставка: O(log n)
- Удаление корня: O(log n)
Слияние отсортированных последовательностей: heapq.merge()
Вместо того чтобы складывать два уже отсортированных списка и затем запускать sort() (O(n log n)), можно воспользоваться heapq.merge(), которое делает «k-путевое» слияние за O(n log k), где:
n— общее число элементов во всех входных итераторах;k— число последовательностей.
Как это работает
- Из каждого входного итератора берётся первый элемент и помещается в вспомогательную «кучу» (heap) вместе с идентификатором источника.
- Из кучи извлекается наименьший элемент и возвращается пользователю.
- Из того же источника подставляют следующий элемент, и он «всплывает» в куче на своё место.
- Повторяем до исчерпания всех элементов.
import heapq
a = [1, 3, 5]
b = [2, 4, 6]
# Результат: 1,2,3,4,5,6
merged = heapq.merge(a, b)
for x in merged:
print(x)
- Скорость: O(n log k) для k входных потоков. При k=2 это близко к O(n).
- Память: O(k) для хранения текущих «голов» каждого итератора.
- Преимущество: не требует полной сортировки объединённого списка и работает лениво (порождает элементы по мере запроса).
Практические задачи с кучей
- Топ-K наименьших элементов
Постановка: дан большой список чисел, найдите K самых маленьких элементов.
Алгоритм:
- Построить max-кучу размера K из первых K элементов (храним отрицательные или используем обратный приоритет).
- Для каждого оставшегося элемента x:
- Если x < корня кучи, заменить корень на x (heapreplace), иначе — пропустить.
- В конце корни кучи — ваши K наименьших элементов.
- Топ-K наибольших элементов
Постановка: аналогично, но нужно K самых больших чисел.
Алгоритм:
- Использовать min-кучу размера K.
- Для каждого x: если x > корня → heapreplace, иначе — пропустить.
- Слияние K отсортированных списков
Постановка: дано несколько уже отсортированных списков, нужно получить единый отсортированный список.
Алгоритм:
- Воспользоваться
heapq.merge()— ленивое k-путевое слияние за O(N log K).
- Воспользоваться
- Поиск медианы в потоке данных
Постановка: поступают числа, после каждого числа нужно быстро отдавать текущую медиану.
Алгоритм:
- Две кучи: max-куча для «левой» половины и min-куча для «правой». Разница их размеров ≤1.
- При добавлении числа x:
- Если x ≤ корня max-кучи → в max-кучу, иначе → в min-кучу.
- Если размеры отличаются более чем на 1, перекладываем элемент из одной кучи в другую.
- Медиана = корень большей кучи или среднее двух корней, если размеры равны.
- Алгоритм Дейкстры
Постановка: в ориентированном графе с неотрицательными весами найти кратчайшие пути от стартовой вершины до всех остальных.
Алгоритм:
- Инициализировать расстояния: 0 для стартовой, ∞ для остальных.
- Поставить в min-кучу пару (0, start).
- Пока куча не пуста:
- Извлечь вершину u с минимальным расстоянием d.
- Если d > уже записанного расстояния[u], пропустить.
- Для каждого соседа v с весом w:
- Если d + w < расстояние[v], обновить и запушить (d+w, v).
Решения
import heapq
from typing import List, Tuple
from collections import defaultdict
# 1. Топ-K наименьших
def k_smallest(nums: List[int], k: int) -> List[int]:
if k <= 0: return [] # max-куча через хранение отрицательных heap = [-x for x in nums[:k]] heapq.heapify(heap) for x in nums[k:]: if -x > heap[0]:
heapq.heapreplace(heap, -x)
return [-y for y in heap]
# 2. Топ-K наибольших
def k_largest(nums: List[int], k: int) -> List[int]:
if k <= 0: return [] heap = nums[:k] heapq.heapify(heap) for x in nums[k:]: if x > heap[0]:
heapq.heapreplace(heap, x)
return heap
# 3. Слияние K списков
def merge_sorted(lists: List[List[int]]) -> List[int]:
return list(heapq.merge(*lists))
# 4. Медиана в потоке
class MedianFinder:
def __init__(self):
self.lo = [] # max-куча (отрицательные)
self.hi = [] # min-куча
def add_num(self, num: int) -> None:
if not self.lo or num <= -self.lo[0]: heapq.heappush(self.lo, -num) else: heapq.heappush(self.hi, num) # балансировка if len(self.lo) - len(self.hi) > 1:
heapq.heappush(self.hi, -heapq.heappop(self.lo))
elif len(self.hi) - len(self.lo) > 1:
heapq.heappush(self.lo, -heapq.heappop(self.hi))
def find_median(self) -> float:
if len(self.lo) > len(self.hi):
return -self.lo[0]
if len(self.hi) > len(self.lo):
return self.hi[0]
return (-self.lo[0] + self.hi[0]) / 2
# 5. Алгоритм Дейкстры
def dijkstra(graph: dict[int, List[Tuple[int,int]]], start: int) -> dict[int,int]:
dist = {u: float('inf') for u in graph}
dist[start] = 0
heap = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue
for v, w in graph[u]:
nd = d + w
if nd < dist[v]:
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
5.1. Что такое min-heap и max-heap? Какое свойство выполняется для дочерних узлов?
5.2. Почему вставка и удаление в куче имеют сложность O(log n)?
5.3. Как связаны индексы массива и позиции узлов в двоичной куче?
5.4. Как в Python получить max-heap, если стандартный модуль реализует только min-heap?
5.5. В каких задачах применение кучи даёт улучшение по сравнению с простым сортированием?
Задания. Блок 5. Кучи
5.1. Построение кучи Задание: Напишите функциюdef build_heap(nums: list[int]) → list[int]:которая превращает произвольный список в min-heap, используяheapq.heapifyи возвращает этот список. ❓ После вызоваheapifyвыполнение какого свойства гарантируется для каждого узла?
5.2. Топ-K наименьших Задание: Для спискаnumsи числаkреализуйте функциюdef k_smallest(nums: list[int], k: int) → list[int]:которая возвращает K наименьших элементов за O(n + k log n), используя min-heap. ❓ Почему достаточно выполнитьheapq.heapifyодин раз, а затем K разheappop?
5.3. Слияние списков вручную Задание: Даны три отсортированных списка:a, b, c. Не используяheapq.merge, реализуйте функциюdef merge_three(a, b, c):которая слияния их в один отсортированный список за O(n log 3). ❓ Почему при каждом шаге мы вытаскиваем минимум из трёх голов и помещаем следующий из того же списка?
5.4. Поток медиан Задание: Реализуйте классclass MedianFinder:с методамиadd_num(self, x)иfind_median(self) → float, используя две кучи (max-heap и min-heap). ❓ Зачем нужно балансировать размеры двух куч?
5.5. Алгоритм Дейкстры Задание: Дан ориентированный графgraph: dict[int, list[tuple[int,int]]], где для каждой вершины список пар(сосед, вес). Реализуйте функциюdef dijkstra(graph, start):которая возвращает словарь кратчайших расстояний, используя min-heap для выбора ближайшей вершины. ❓ Почему при проверкеd > dist[u]мы пропускаем устаревшие записи в куче?
Ответы
5.1. Min-heap: каждый родитель ≤ своих детей; max-heap: каждый родитель ≥ своих детей. 5.2. Потому что «всплытие» и «просеивание» проходят по высоте дерева ≈ log₂n. 5.3. Узлы хранятся в массиве: для индекса i дети — 2i+1 и 2i+2, родитель — (i−1)//2. 5.4. Храним отрицательные значения или кортеж (−priority, value). 5.5. Для задач Top-K, потоковых медиан, Dijkstra — вместо полного сортирования O(n log n) можно получить O(n log k) или O(m log n).
Решения
import heapq
from typing import List, Dict, Tuple
# 5.1. Построение кучи
def build_heap(nums: List[int]) -> List[int]:
heapq.heapify(nums)
return nums
# 5.2. Топ-K наименьших
def k_smallest(nums: List[int], k: int) -> List[int]:
if k <= 0: return [] heapq.heapify(nums) # O(n) return [heapq.heappop(nums) for _ in range(min(k, len(nums)))] # k * O(log n) # 5.3. Слияние трёх списков вручную def merge_three(a: List[int], b: List[int], c: List[int]) -> List[int]:
heap: List[Tuple[int, int]] = [] # (value, source_index)
res: List[int] = []
arrays = [a, b, c]
for i, arr in enumerate(arrays):
if arr:
heapq.heappush(heap, (arr[0], i, 0)) # значение, из какого списка, индекс в списке
while heap:
val, src, idx = heapq.heappop(heap)
res.append(val)
if idx + 1 < len(arrays[src]): heapq.heappush(heap, (arrays[src][idx+1], src, idx+1)) return res # 5.4. Поток медиан class MedianFinder: def __init__(self): self.lo: List[int] = [] # max-heap (отрицательные) self.hi: List[int] = [] # min-heap def add_num(self, x: int) -> None:
if not self.lo or x <= -self.lo[0]: heapq.heappush(self.lo, -x) else: heapq.heappush(self.hi, x) # балансировка if len(self.lo) - len(self.hi) > 1:
heapq.heappush(self.hi, -heapq.heappop(self.lo))
elif len(self.hi) - len(self.lo) > 1:
heapq.heappush(self.lo, -heapq.heappop(self.hi))
def find_median(self) -> float:
if len(self.lo) > len(self.hi):
return -self.lo[0]
if len(self.hi) > len(self.lo):
return self.hi[0]
return (-self.lo[0] + self.hi[0]) / 2
# 5.5. Алгоритм Дейкстры
def dijkstra(graph: Dict[int, List[Tuple[int,int]]], start: int) -> Dict[int, int]:
dist: Dict[int, int] = {u: float('inf') for u in graph}
dist[start] = 0
heap: List[Tuple[int,int]] = [(0, start)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue
for v, w in graph[u]:
nd = d + w
if nd < dist[v]:
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
Деревья
Дерево — это набор узлов, где есть один корень, у каждого узла могут быть «дети», и все узлы связаны «рёбрами». Аналогия: семейное древо или организационная схема компании.
[Корень]
/ \
[Узел1] [Узел2]
/ \ \
[3] [4] [5]
- Родитель — узел выше (например, Корень для Узел1).
- Дети — узлы ниже (например, Узел1 → 3 и 4).
- Уровень — глубина от корня (корень = уровень 0).
- Высота — максимальный уровень в дереве.
Бинарное дерево
Каждый узел хранит не более двух детей:left и right.
- Доступ к детям — O(1).
- Поиск/вставка/удаление — O(n) в худшем случае (выстроилось в линию), O(log n) в среднем для сбалансированного.
- Память — O(n) для n узлов.
Пример реализации и основные операции
Обходы бинарного дерева
При обходе деревьев важен порядок посещения узлов:- Preorder (корень → левое → правое):
1. Обработка текущего узла
2. Рекурсивный обход левого поддерева
3. Рекурсивный обход правого поддерева - Inorder (левое → корень → правое):
1. Рекурсивный обход левого поддерева
2. Обработка текущего узла
3. Рекурсивный обход правого поддерева - Postorder (левое → правое → корень):
1. Рекурсивный обход левого поддерева
2. Рекурсивный обход правого поддерева
3. Обработка текущего узла
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
# Поиск в дереве
def search(root, key):
if not root or root.key == key:
return root
return search(root.left, key) if key < root.key else search(root.right, key)
# Вставка (обычное двоичное)
def insert(root, key):
if not root:
return Node(key)
if key < root.key:
root.left = insert(root.left, key)
else:
root.right = insert(root.right, key)
return root
# Поиск минимума/максимума
def find_min(root):
return root if not root.left else find_min(root.left)
def find_max(root):
return root if not root.right else find_max(root.right)
# Удаление узла
def delete(root, key):
if not root:
return None
if key < root.key: root.left = delete(root.left, key) elif key > root.key:
root.right = delete(root.right, key)
else:
# нет детей или один ребёнок
if not root.left:
return root.right
if not root.right:
return root.left
# два ребёнка: заменить на следующий по величине
succ = find_min(root.right)
root.key = succ.key
root.right = delete(root.right, succ.key)
return root
# Обходы
def inorder(root):
if root:
yield from inorder(root.left)
yield root.key
yield from inorder(root.right)
def preorder(root):
if root:
yield root.key
yield from preorder(root.left)
yield from preorder(root.right)
def postorder(root):
if root:
yield from postorder(root.left)
yield from postorder(root.right)
yield root.key
Бинарное дерево поиска (BST)
В BST для каждого узла:- Все ключи в левой ветви < ключа узла.
- В правой ветви > ключа узла.
Как происходит заполнение BST дерева
Скорость работы алгоритма бинарного поиска
Скорость заполнения и поиска в бинарном дереве O(log N). Скорость поиска в массиве — O(N). Ниже визуальное сравнение, почему так происходит.
Если вставлять уже упорядоченные данные (1,2,3,4…), BST вырождается в «список» (каждый узел имеет только правого ребёнка), поиск становится O(n). Случайный порядок или самобалансирующиеся деревья (AVL, Red-Black) избегают этого.
Самобалансирующиеся BST
- AVL-дерево: разница высот детей каждого узла ≤ 1. При вставке/удалении делает повороты (rotate) для восстановления баланса.

- Red-Black-дерево: каждый узел красный или чёрный, соблюдаются правила по путям от корня до листа. Менее жёстко балансируется, вставка/удаление быстрее (меньше поворотов).

# Пример одной итерации (LL-поворот) в AVL:
[z] [y]
/ / \
[y] → [x] [z]
/
[x]
Fenwick Tree (BIT)
Fenwick-дерево (или Дерево с бинарным индексом) — это удивительная структура данных, которая позволяет эффективно вычислять префикс-суммы и обновлять элементы. Как же это работает? Всё дело в уникальной организации хранения чисел. В корне дерева располагается сумма наибольшей возможной подпоследовательности четных элементов. На следующем уровне хранятся накопленные суммы четных половин — левой и правой. Далее идут четверти, и так далее. Листья дерева — это элементы массива, находящиеся на четных индексах. Для лучшего понимания, представьте, что Fenwick-дерево помогает нам быстро справляться с задачами суммирования и обновления. Например, если мы хотим найти сумму первых нескольких четных элементов, дерево поможет сделать это без лишних усилий. С каждым уровнем мы как бы «сжимаем» информацию, что делает процесс поиска и обновления значительно быстрее. Таким образом, Fenwick-дерево становится незаменимым инструментом для работы с числовыми последовательностями, позволяя не только легко находить нужные суммы, но и оперативно обновлять данные.Заполнение дерева.
Поиск по дереву необходимой суммы
- update(i, delta): O(log n)
- prefix_sum(i): O(log n)
class Fenwick:
def __init__(self, n):
self.n = n
self.fw = [0]*(n+1)
def update(self, i, delta):
while i <= self.n:
self.fw[i] += delta
i += i & -i
def query(self, i):
s = 0
while i > 0:
s += self.fw[i]
i -= i & -i
return s
# Использование:
# arr = [a1, a2, …]
# bit = Fenwick(len(arr))
# for i,val in enumerate(arr,1): bit.update(i,val)
# sum(arr[1..k]) = bit.query(k)
Segment Tree (дерево отрезков)
Поддерживает обобщённые запросы на любом отрезке и обновления в O(log n):- Построение: O(n)
- Запрос на отрезке [l,r]: O(log n)
- Обновление одного элемента: O(log n)
Segment Tree простыми словами
Представь себе задачу: у тебя есть массив изn чисел, и ты хочешь очень быстро отвечать на вопросы «какая сумма (или минимум, максимум и т. д.) на отрезке [L…R]?», а также обновлять любое значение в массиве. Segment Tree (или «сегментное дерево») — это как дерево соревнований, где каждый узел хранит результат соревнования между двумя половинами своего отрезка.
- Как строится дерево:
- В корне лежит агрегированный результат для всего массива [0…n−1].
- Каждый узел на уровне ниже хранит результат для половины этого отрезка:
- левый ребёнок → отрезок [L…mid]
- правый ребёнок → отрезок [mid+1…R]
- На листьях хранятся сами элементы массива (каждый лист = один элемент).
- Преимущества:
- Запросы «сумма/минимум/максимум на отрезке [L…R]» выполняются за
O(log n). - Обновление одного элемента (замена значения) тоже за
O(log n). - Дерево статично по структуре (записали один раз) и затем просто ходим по нему.
- Запросы «сумма/минимум/максимум на отрезке [L…R]» выполняются за
Как выполняется запрос на отрезке [L…R]
- Старшний узел (корень) — [0…n−1]. Если этот сегмент полностью внутри [L…R], сразу возвращаем его значение.
- Если не полностью, проверяем левый и правый ребёнок:
- Левый ребёнок покрывает [0…mid]. Если [0…mid] пересекается с [L…R], спускаемся в него.
- Правый ребёнок покрывает [mid+1…n−1]. Если он пересекается с [L…R], спускаемся в него.
- На каждом уровне мы «разбиваем» запрос на две части — по детям, но делаем это лишь по тем ветвям, которые пересекают [L…R]. Всего таких ветвей ≈ 2·log₂n.
- Складываем (или берём минимум/максимум) результаты из тех узлов, которые полностью лежат внутри [L…R].
Как работает обновление элемента a[i] = x
- Находим лист, соответствующий позиции
i, и заменяем его значение наx. - Поднимаемся вверх по дереву: в каждом родительском узле пересчитываем агрегат из двух детей (сумма = left + right и т. д.).
- Всего уровней ≈ log₂n, значит
O(log n)шагов.
class SegmentTree:
def __init__(self, arr):
n = len(arr)
self.size = 1
while self.size < n:
self.size *= 2
self.tree = [0]*(2*self.size)
# заполнить листья
for i,v in enumerate(arr):
self.tree[self.size+i] = v
# построить внутренние узлы
for i in range(self.size-1, 0, -1):
self.tree[i] = self.tree[2*i] + self.tree[2*i+1]
def update(self, pos, val):
i = pos + self.size
self.tree[i] = val
while i > 1:
i //= 2
self.tree[i] = self.tree[2*i] + self.tree[2*i+1]
def query(self, l, r):
res = 0
l += self.size; r += self.size
while l <= r:
if l & 1:
res += self.tree[l]
l += 1
if not r & 1:
res += self.tree[r]
r -= 1
l //= 2; r //= 2
return res
# Fenwick проще по коду, но только для префикс-сумм.
# Segment Tree универсальнее — любые отрезки, разные операции (мин, макс, gcd и т.д.).
Практические задачи с деревьями и индексными структурами
- Проверка, является ли дерево BST
Постановка: дан корень бинарного дерева. Нужно проверить, что для каждого узла все ключи в левом поддереве меньше, а в правом — больше его ключа.
Алгоритм:
- Сделать inorder-обход и собрать ключи в список.
- Проверить, что полученный список отсортирован строго возрастающе.
- Найти Lowest Common Ancestor (LCA) в BST
Постановка: в BST найти наименьшего общего предка двух узлов с ключами
pиq. Алгоритм:- Начать с корня.
- Если оба ключа меньше корня → идти в левое поддерево.
- Если оба больше → идти в правое.
- Иначе текущий узел и есть LCA.
- Префикс-суммы с Fenwick Tree
Постановка: дан массив чисел, нужно быстро отвечать на запросы
sum(1…i)и на обновления элементовarr[i] += Δ. Алгоритм:- Построить Fenwick-дерево за O(n): вызвать
update(i, arr[i])для всехi. - Для запроса
sum(1…i)вызватьquery(i). - Для обновления
arr[i] += Δвызватьupdate(i, Δ).
- Построить Fenwick-дерево за O(n): вызвать
- Диапазонная сумма с Segment Tree
Постановка: дан массив, нужно отвечать на запросы суммы на отрезках
[l…r]и менять значениеarr[i] = x. Алгоритм:- Построить Segment Tree за O(n).
- Для запроса
sum(l…r)выполнитьquery(l,r)за O(log n). - Для обновления
arr[i]=xвыполнитьupdate(i,x)за O(log n).
- Подсчёт инверсий в массиве
Постановка: дан массив чисел, нужно посчитать количество пар
(i,j), гдеi<jиarr[i]>arr[j]. Алгоритм:- Сжать значения (coordinate compression) к диапазону
1…N. - Идти слева направо, для каждого
xдобавлять в Fenwick: •inversions += i−1 − query(x)(число уже вставленных > x) •update(x, 1)
- Сжать значения (coordinate compression) к диапазону
Решения
from collections import deque
import re
# 1. Проверка BST через inorder
def is_bst(root) -> bool:
seq = list(inorder(root))
return all(seq[i] < seq[i+1] for i in range(len(seq)-1))
# 2. LCA в BST
def lca_bst(root, p, q):
node = root
while node:
if p < node.key and q < node.key: node = node.left elif p > node.key and q > node.key:
node = node.right
else:
return node
return None
# 3. Fenwick Tree
class Fenwick:
def __init__(self, n):
self.n = n
self.fw = [0]*(n+1)
def update(self, i, delta):
while i <= self.n: self.fw[i] += delta i += i & -i def query(self, i): s = 0 while i > 0:
s += self.fw[i]
i -= i & -i
return s
# пример использования:
# arr = [a1, a2, a3, ...]
# bit = Fenwick(len(arr))
# for idx, v in enumerate(arr,1): bit.update(idx,v)
# bit.query(i), bit.update(i, Δ)
# 4. Segment Tree
class SegmentTree:
def __init__(self, arr):
n = len(arr)
self.size = 1
while self.size < n: self.size <<= 1 self.tree = [0]*(2*self.size) for i,v in enumerate(arr): self.tree[self.size+i] = v for i in range(self.size-1,0,-1): self.tree[i] = self.tree[2*i] + self.tree[2*i+1] def update(self, pos, val): i = pos + self.size self.tree[i] = val while i>1:
i //= 2
self.tree[i] = self.tree[2*i] + self.tree[2*i+1]
def query(self, l, r):
res = 0
l += self.size; r += self.size
while l <= r:
if l & 1:
res += self.tree[l]; l += 1
if not r & 1:
res += self.tree[r]; r -= 1
l//=2; r//=2
return res
# 5. Подсчёт инверсий через Fenwick
def count_inversions(arr):
# coordinate compression
vals = sorted(set(arr))
comp = {v:i+1 for i,v in enumerate(vals)}
bit = Fenwick(len(vals))
inv = 0
for i, x in enumerate(arr,1):
cx = comp[x]
inv += (i-1) - bit.query(cx)
bit.update(cx, 1)
return inv
6.1. Что такое бинарное дерево поиска (BST) и какое основное свойство должно выполняться для каждого узла?
6.2. Почему при последовательной вставке отсортированных данных в обычный BST дерево вырождается в список? Как этого избежать?
6.3. Для каких задач удобнее использовать Fenwick Tree (BIT), а для каких — Segment Tree? В чём их ключевое различие?
6.4. Что такое Lowest Common Ancestor (LCA) в дереве, и как его можно найти в BST за O(h), где h — высота дерева?
6.5. Какие правила балансировки соблюдает AVL-дерево, а какие — Red-Black-дерево? Чем они отличаются по числу поворотов при вставке?
Задания. Блок 6. Деревья
Задание 6.1 Постановка задачи: В вашем приложении необходимо управлять динамическим набором цен товаров. Это включает в себя такие операции, как добавление, удаление и поиск цены, которая находится ближайше выше или ниже заданной (например, «цена ≥ X»). Алгоритм:Дополнительное уточнение: Вместо простого описания, представим пример структуры данных. Используем словарь, где ключ — это название товара, а значение — его цена:
- Создайте бинарное дерево поиска (BST), где ключом будет цена товара.
- Реализуйте операции вставки и удаления в соответствии со стандартными правилами работы с BST, что занимает O(h) времени.
- При поиске цены «цена ≥ X» проходите по дереву: если узел.key < X, двигайтесь вправо; в противном случае запоминайте узел и продолжайте движение влево.
{ "Товар A": 100, "Товар B": 150, "Товар C": 90 }На выходе для операций: Вставка: Добавление нового товара, например, «Товар D» с ценой 120. Словарь будет обновлен:{ "Товар A": 100, "Товар B": 150, "Товар C": 90, "Товар D": 120 }Удаление: Удаление «Товар B». Словарь преобразится в:{ "Товар A": 100, "Товар C": 90, "Товар D": 120 }Изменение цены: Изменение цены «Товар A» на 130. Обновленный словарь:{ "Товар A": 130, "Товар C": 90, "Товар D": 120 }Поиск: Для запроса «цена ≥ 110» система вернет «Товар A» с ценой 130 и «Товар D» с ценой 120, так как они соответствуют критерию.
- Реализовать BST, где ключ — цена.
- Вставка и удаление по стандартным правилам BST O(h).
- Для поиска «цена ≥ X» идти по дереву: если узел.key < X → вправо, иначе запомнить узел и идти влево.
6.2. Общий менеджер (LCA) Постановка: в корпоративной иерархии задано дерево «менеджер→подчинённый». По двум сотрудникам найти их ближайшего общего руководителя — часто стоящая задача в больших организациях, где иерархия может быть довольно сложной. Это требуется для корректного решения вопросов взаимодействия или разрешения конфликтов. Алгоритм:
- Построить BST-подобную структуру для быстрого поиска по ID (или хранить parent-указатель). Это позволит эффективно организовать данные о сотрудниках и их руководителях, что значительно ускорит дальнейшие операции. Также можно использовать словарь, где ключом будет ФИО сотрудника, а значением — ФИО руководителя. Например:
employee_hierarchy = { "Иванов Иван Иванович": "Петров Пётр Петрович", "Сидорова Анна Сергеевна": "Петров Пётр Петрович", "Петрова Мария Васильевна": "Сидорова Анна Сергеевна", "Григорьев Алексей Николаевич": "Иванов Иван Иванович", "Олешко Настя Юрьевна": "Григорьев Алексей Николаевич" }
- Для каждого из двух узлов подняться к корню, сохраняя путь. Это шаг важен, так как он позволяет определить, какие узлы были посещены, и тем самым поможет в последующем сопоставлении. Мы можем хранить пути к корню для каждого из сотрудников в виде списков.
- Пересечь два пути, найти первый общий узел — это LCA. Этот узел будет ближайшим общим начальником для предложенных сотрудников, и его можно использовать для дальнейшего анализа структуры команд.
6.3. Онлайн-аналитика посещений (Fenwick Tree) Постановка: в веб-приложении фиксируются кол-ва посещений по часам. Нужно быстро отвечать на запрос «сколько визитов с начала дня до часа H» и при этом обновлять счётчик для текущего часа.Алгоритм:Скрипт генерации данныхimport csv import random import datetime def generate_fake_visits(date: datetime.date, min_per_hour: int = 0, max_per_hour: int = 100, seed: int = 42) -> list[dict]: """ Генерирует список посещений за указанный день. Для каждого часа [0..23] генерируется случайное число визитов в диапазоне [min_per_hour..max_per_hour], потом для каждого визита выбираются случайные минуты и секунды. """ random.seed(seed) visits = [] for hour in range(24): count = random.randint(min_per_hour, max_per_hour) for _ in range(count): minute = random.randint(0, 59) second = random.randint(0, 59) ts = datetime.datetime.combine( date, datetime.time(hour, minute, second) ) visits.append({'timestamp': ts.isoformat()}) # Перемешаем записи, чтобы порядок визитов был случайным random.shuffle(visits) return visits def save_to_csv(visits: list[dict], filename: str = 'fake_visits.csv') -> None: """Сохраняет список словарей в CSV-файл с колонкой timestamp.""" with open(filename, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=['timestamp']) writer.writeheader() writer.writerows(visits) print(f"Wrote {len(visits)} visits to '{filename}'") if __name__ == '__main__': # Параметры генерации today = datetime.date.today() # можно заменить на любую дату MIN_VISITS_PER_HOUR = 0 # минимум визитов в час MAX_VISITS_PER_HOUR = 500 # максимум визитов в час OUTPUT_CSV = 'fake_visits.csv' # Генерация и сохранение fake_visits = generate_fake_visits( date=today, min_per_hour=MIN_VISITS_PER_HOUR, max_per_hour=MAX_VISITS_PER_HOUR, seed=12345 ) save_to_csv(fake_visits, OUTPUT_CSV)
- Создать Fenwick Tree размером 24 (для 24 часов).
- При новом визите
update(hour+1, 1).- Для запроса использовать
query(H+1)— префикс-сумма.
6.4. Мониторинг пиковой нагрузки (Segment Tree) Постановка: есть массив показателей нагрузки сервера по минутам за сутки (1440 точек). Нужно быстро находить максимальную и среднюю нагрузку на любом отрезке времени и обновлять отдельные значения.Алгоритм:Скрипт генерации данныхimport csv import random import datetime def generate_load_metrics( date: datetime.date, min_load: float = 0.0, max_load: float = 100.0, seed: int = 42, use_float: bool = True ) -> list[dict]: """ Генерирует метрики нагрузки по минутам для одного дня. - date: дата, для которой создаются метрики; - min_load, max_load: диапазон нагрузок; - seed: для воспроизводимости; - use_float: если True — генерируются дробные значения (с двумя знаками после точки), иначе — целые. """ random.seed(seed) metrics = [] start_dt = datetime.datetime.combine(date, datetime.time(0, 0)) for minute_offset in range(24 * 60): ts = start_dt + datetime.timedelta(minutes=minute_offset) raw = random.uniform(min_load, max_load) if use_float else random.randint(int(min_load), int(max_load)) value = round(raw, 2) if use_float else raw metrics.append({ 'timestamp': ts.isoformat(sep=' '), 'load': value }) return metrics def save_to_csv(metrics: list[dict], filename: str = 'load_metrics.csv') -> None: """Сохраняет список словарей в CSV-файл с колонками timestamp и load.""" with open(filename, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=['timestamp', 'load']) writer.writeheader() writer.writerows(metrics) print(f"Wrote {len(metrics)} entries to '{filename}'") if __name__ == '__main__': # Параметры генерации date = datetime.date.today() # или datetime.date(2025, 7, 27) MIN_LOAD = 10.0 # минимальная нагрузка MAX_LOAD = 90.0 # максимальная нагрузка USE_FLOAT = True # дробные значения? SEED = 12345 # для воспроизводимости OUTPUT_CSV = 'load_metrics.csv' # Генерация и сохранение metrics = generate_load_metrics( date=date, min_load=MIN_LOAD, max_load=MAX_LOAD, seed=SEED, use_float=USE_FLOAT ) save_to_csv(metrics, OUTPUT_CSV)
- Построить Segment Tree для суммы и максимума.
- Для запроса «максимум/сумма» на отрезке [l…r] вызвать соответствующий
query.- При изменении показателя
update(pos, new_value).
6.5. Анализ аномалий в продажах (подсчёт инверсий) Постановка: дана история ежедневных объёмов продаж за период. Нужно быстро определить, сколько раз продажи пошли вниз после предыдущего дня.Скрипт генерации данныхimport csv import random import datetime def generate_monthly_sales(year: int, month: int, min_sales: int = 50, max_sales: int = 50000, seed: int = 42) -> list[dict]: """ Генерирует данные продаж для указанного месяца. - year, month: год и месяц для генерации (1–12). - min_sales, max_sales: диапазон объёмов продаж. - seed: для воспроизводимости. Возвращает список словарей с ключами 'date' и 'sales'. """ random.seed(seed) sales = [] # Определяем количество дней в месяце first_day = datetime.date(year-5, month, 1) if month == 12: next_month = datetime.date(year + 1, 1, 1) else: next_month = datetime.date(year, month + 1, 1) num_days = (next_month - first_day).days for day_offset in range(num_days): current_date = first_day + datetime.timedelta(days=day_offset) qty = random.randint(min_sales, max_sales) sales.append({ 'date': current_date.isoformat(), 'sales': qty }) return sales def save_to_csv(data: list[dict], filename: str = 'monthly_sales.csv') -> None: """ Сохраняет список словарей в CSV-файл с колонками date и sales. """ with open(filename, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=['date', 'sales']) writer.writeheader() writer.writerows(data) print(f"Wrote {len(data)} records to '{filename}'") if __name__ == '__main__': # Параметры генерации YEAR = 2025 MONTH = 7 # Июль, например MIN_SALES = 100 # Минимум продаж в день MAX_SALES = 1000 # Максимум продаж в день SEED = 20250727 # Для воспроизводимости OUTPUT_CSV = 'monthly_sales.csv' # Генерация и сохранение sales_data = generate_monthly_sales( year=YEAR, month=MONTH, min_sales=MIN_SALES, max_sales=MAX_SALES, seed=SEED ) save_to_csv(sales_data, OUTPUT_CSV)Алгоритм:
i < j, то есть день i идёт раньше дня j- и
sales[i] > sales[j]— объём продаж в день i больше, чем в день j
- Сжать значения продаж к 1…N.
- Идти по дням в порядке времени, в Fenwick счётчике хранить сколько раз встретился каждый объём.
- Для текущего
xдобавить к счёту(i−1) − query(cx), затемupdate(cx,1).
Ответы
6.1. BST‐операции (insert/search/delete) работают за O(h), поиск «цена ≥ X» — O(h). 6.2. LCA через поднятие по parent и пересечение путей — O(h). 6.3. Fenwick: update и prefix_sum — O(log n), здесь n=24 → очень быстро. 6.4. Segment Tree: построение O(n), запрос/обновление — O(log n). 6.5. Подсчёт инверсий через Fenwick — O(n log n).
Решения
# 6.1. BST для цен
class Node:
def __init__(self, key):
self.key = key; self.left = self.right = None
def insert(root, key):
if not root: return Node(key)
if key < root.key: root.left = insert(root.left, key)
else: root.right = insert(root.right, key)
return root
def delete(root, key):
if not root: return None
if key < root.key: root.left = delete(root.left, key) elif key > root.key: root.right = delete(root.right, key)
else:
if not root.left: return root.right
if not root.right: return root.left
succ = root.right
while succ.left: succ = succ.left
root.key = succ.key
root.right = delete(root.right, succ.key)
return root
def find_next(root, x):
res = None
while root:
if root.key >= x:
res = root; root = root.left
else:
root = root.right
return res.key if res else None
# 6.2. LCA в общем дереве с parent
def build_parent_map(edges):
parent = {}
for mgr, emp in edges:
parent[emp] = mgr
return parent
def lca(u, v, parent):
def path(x):
s = set()
while x:
s.add(x); x = parent.get(x)
return s
pu = path(u)
while v not in pu:
v = parent.get(v)
return v
# 6.3. Fenwick для посещений
class Fenwick:
def __init__(self, n):
self.n = n; self.fw = [0]*(n+1)
def update(self, i, d):
while i<=self.n: self.fw[i]+=d; i+=i&-i def query(self, i): s=0 while i>0:
s+=self.fw[i]; i-=i&-i
return s
# 6.4. Segment Tree для нагрузки
class SegmentTree:
def __init__(self, arr):
n=len(arr); self.size=1
while self.size1:
i//=2
self.tree_sum[i]=self.tree_sum[2*i]+self.tree_sum[2*i+1]
self.tree_max[i]=max(self.tree_max[2*i],self.tree_max[2*i+1])
def query_sum(self,l,r):
res=0; l+=self.size; r+=self.size
while l<=r:
if l&1: res+=self.tree_sum[l]; l+=1
if not r&1: res+=self.tree_sum[r]; r-=1
l//=2; r//=2
return res
def query_max(self,l,r):
res=0; l+=self.size; r+=self.size
while l<=r:
if l&1: res=max(res,self.tree_max[l]); l+=1
if not r&1: res=max(res,self.tree_max[r]); r-=1
l//=2; r//=2
return res
# 6.5. Подсчёт инверсий в продажах
def count_inversions(arr):
# сжатие
vals=sorted(set(arr))
comp={v:i+1 for i,v in enumerate(vals)}
bit=Fenwick(len(vals))
inv=0
for i,x in enumerate(arr,1):
cx=comp[x]
inv += (i-1) - bit.query(cx)
bit.update(cx,1)
return inv
Дополнительная задача. Ограниченная максимальная подпоследовательность
Условие. Дан массив целых чиселa[0…n−1]. Нужно найти максимальную сумму его непрерывной подпоследовательности a[i…j], в которой число положительных чётных элементов кратно k=30. Ссылка на задание.
Сegментное дерево сдержит в каждом узле не одну, а k состояний: максимум среди подпоследовательностей внутри отрезка, сгруппированных по остатку от деления числа чётных положительных элементов на k.
Идея решения через Segment Tree
- Структура узла. Для отрезка
[L…R]в узле храним три массива длиныk:bestInside[r]— максимальная сумма непрерывной подпоследовательности полностью внутри[L…R]с количеством «хороших» элементов ≡r (mod k);bestPrefix[r]— максимальная сумма префикса[L…x](L≤x≤R) с «хороших» ≡r;bestSuffix[r]— максимальная сумма суффикса[x…R](L≤x≤R) с «хороших» ≡r.
- Инициализация листа. Для одного элемента
a[p]:- Если
a[p]>0и чётно, то «хороших» = 1, иначе = 0. - Для всех
r≠cntставимbestInside[r]=bestPrefix[r]=bestSuffix[r]=−∞, - для
r=cntэти три равныa[p].
- Если
- Слияние двух детей
LиR. Пусть у левого узла массивыL₁и у правогоR₁. Тогда в родительский узел:for r1 in range(k): for r2 in range(k): # 1) подпоследочка может лежать целиком в левом или в правом bestInside[(r1+r2)%k] = max(bestInside[(r1+r2)%k], L.bestSuffix[r1] + R.bestPrefix[r2]) # копируем также внутрь лучшие из детей for r in range(k): bestInside[r] = max(bestInside[r], L.bestInside[r], R.bestInside[r]) # префиксы: либо весь левый + префикс правого for r1 in range(k): for r2 in range(k): bestPrefix[(r1+r2)%k] = max(bestPrefix[(r1+r2)%k], L.bestPrefix[r1] + R.bestPrefix[r2]) # либо просто префикс левого for r in range(k): bestPrefix[r] = max(bestPrefix[r], L.bestPrefix[r], R.bestPrefix[r]) # аналогично для суффиксов for r1 in range(k): for r2 in range(k): bestSuffix[(r1+r2)%k] = max(bestSuffix[(r1+r2)%k], L.bestSuffix[r1] + R.bestSuffix[r2]) for r in range(k): bestSuffix[r] = max(bestSuffix[r], L.bestSuffix[r], R.bestSuffix[r]) - Ответ. После построения дерева в корне смотрим
bestInside[0]— максимальную сумму с числом «хороших» ≡ 0 (т. е. кратноk).
Сложность
- Построение:
O(n·k²)(слияние двух детей на каждом изO(n)узлов). - Запрос за единичный отрезок (полный массив):
O(1)(храним прямо в корне). - Обновление элемента:
O(log n·k²).
Код (Python)
import sys
NEG_INF = -10**18
class SegTree:
def __init__(self, a, k=30):
self.n, self.k = len(a), k
size = 1
while size < self.n: size <<= 1 self.size = size # инициализируем пустыми узлами self.bestInside = [ [NEG_INF]*k for _ in range(2*size) ] self.bestPrefix = [ [NEG_INF]*k for _ in range(2*size) ] self.bestSuffix = [ [NEG_INF]*k for _ in range(2*size) ] for i,v in enumerate(a): cnt = 1 if (v>0 and v%2==0) else 0
node = i + size
for r in range(k):
if r==cnt:
self.bestInside[node][r] = v
self.bestPrefix[node][r] = v
self.bestSuffix[node][r] = v
# строим по уровням
for node in range(size-1,0,-1):
self._pull(node)
def _pull(self, node):
L, R, k = node*2, node*2+1, self.k
BI, BP, BS = self.bestInside[node], self.bestPrefix[node], self.bestSuffix[node]
LI, LP, LS = self.bestInside[L], self.bestPrefix[L], self.bestSuffix[L]
RI, RP, RS = self.bestInside[R], self.bestPrefix[R], self.bestSuffix[R]
# сброс
for r in range(k):
BI[r] = BP[r] = BS[r] = NEG_INF
# bestInside
for r in range(k):
BI[r] = max(LI[r], RI[r])
for r1 in range(k):
if LS[r1]==NEG_INF: continue
for r2 in range(k):
if RP[r2]==NEG_INF: continue
m = (r1+r2)%k
BI[m] = max(BI[m], LS[r1] + RP[r2])
# bestPrefix
for r in range(k):
BP[r] = LP[r]
for r1 in range(k):
if LP[r1]==NEG_INF: continue
for r2 in range(k):
if RP[r2]==NEG_INF: continue
m = (r1+r2)%k
BP[m] = max(BP[m], LP[r1] + RP[r2])
# bestSuffix
for r in range(k):
BS[r] = RS[r]
for r1 in range(k):
if LS[r1]==NEG_INF: continue
for r2 in range(k):
if RS[r2]==NEG_INF: continue
m = (r1+r2)%k
BS[m] = max(BS[m], LS[r1] + RS[r2])
def query_all(self):
# корень хранит ответ по всему массиву
return self.bestInside[1][0]
# Пример использования:
if __name__ == "__main__":
a = [ 5, -1, 4, 2, 6, -3, 8, 10, -2, 1 ]
st = SegTree(a, k=30)
print("Максимальная сумма с кол-вом чётных>0 ≡0 mod 30:", st.query_all())
7. Графы
Граф — это набор «точек» (вершин) и «линий» (рёбер), которые между ними соединяют.Представь, что вершины — это города, а рёбра — дороги между ними. В олимпиадах графы — основа для задач про маршруты, связи и оптимизацию путей.
7.1 Представления графа
Существует несколько способов хранить граф в памяти. Выбираем тот, что быстрее и экономичнее для вашей задачи.Список смежности
- Для каждой вершины хранится список соседей:
adj[u] = [v1, v2, …]. - Очень удобно для разреженных графов (мало рёбер).
- Операция «обход всех соседей u» — O(deg(u)).
from collections import defaultdict
adj = defaultdict(list)
# добавляем неориентированное ребро u–v:
def add_edge(u, v):
adj[u].append(v)
adj[v].append(u)
Когда выгодно: если число рёбер E ≪ V², экономим память O(V + E).
Матрица смежности
- Двумерный массив
mat[u][v] = 1если есть ребро, иначе 0. - Быстрый доступ «есть ли ребро u→v?» — O(1).
- Занимает O(V²) памяти.
# инициализация для V вершин:
V = 5
mat = [[0]*V for _ in range(V)]
def add_edge(u, v):
mat[u][v] = 1
mat[v][u] = 1 # для неориентированного графа
Когда выгодно: мелкие графы или плотные (E≈V²), часто в задачах с быстрым «существует ли путь».
Список рёбер
- Просто коллекция пар (u, v) или тройек (u, v, w) для взвешенных.
- Нужен, если алгоритм сначала сортирует рёбра (Крускал, DSU).
edges = []
# добавить ребро u–v (взвешенное):
def add_edge(u, v, w):
edges.append((w, u, v)) # сортировка по весу
7.2 Взвешенные и ориентированные графы
- Ориентированный граф (digraph): ребро имеет направление u→v. Храним отдельно в
adj_out[u]и, при необходимости,adj_in[v]. - Взвешенный граф: каждому ребру сопоставлен вес w (стоимость, длина дороги). В списке смежности храним пары
(v, w).
# пример списка смежности для взвешенного ориентированного графа:
adj = defaultdict(list)
def add_edge(u, v, w):
adj[u].append((v, w)) # из u в v с весом w
Выбор контейнера: если много вставок/удалений — defaultdict(list), если нужен быстрый поиск по весу — можно использовать heapq внутри обхода.
7.3 Практические примеры
Простой: DFS рекурсией
Обход в глубину (DFS) — «путешествие по лабиринту»: идём по одному пути, пока не упремся, потом возвращаемся назад и ищем другой.visited = set()
def dfs(u):
if u in visited:
return
visited.add(u)
# здесь "обрабатываем" вершину u
for v in adj[u]:
dfs(v)
# Запуск:
dfs(start_vertex)
Средний: BFS — кратчайший путь в невзвешенном графе
Обход в ширину (BFS) — находим все вершины на расстоянии 1, потом на 2 и т.д. В невзвешенном графе это гарантированный кратчайший путь по числу рёбер.from collections import deque
def bfs(start):
dist = {start: 0}
q = deque([start])
while q:
u = q.popleft()
for v in adj[u]:
if v not in dist:
dist[v] = dist[u] + 1
q.append(v)
return dist # расстояния от start
Сложный: Крускал + DSU (минимальное остовное дерево)
Найти набор рёбер, который соединит все вершины с минимальной суммарной длиной дорог. Мы сортируем рёбра по весу и подключаем, если они не создают цикл (система непересекающихся множеств — DSU).# DSU (Disjoint Set Union)
class DSU:
def __init__(self, n):
self.p = list(range(n))
def find(self, x):
if self.p[x] != x:
self.p[x] = self.find(self.p[x])
return self.p[x]
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False
self.p[rb] = ra
return True
def kruskal(n, edges):
"""n — число вершин, edges — список (w,u,v)"""
dsu = DSU(n)
mst_weight = 0
edges.sort()
for w, u, v in edges:
if dsu.union(u, v):
mst_weight += w
return mst_weight
# Пример
n = 5
edges = [(1,0,1),(3,0,2),(2,1,2),(4,2,3),(5,3,4)]
print("MST weight:", kruskal(n, edges)) # 1+2+4+5=12
❓ Вопросы для самопроверки: – Какие операции DFS/BFS выполняются за O(V+E)? – Почему Kruskal использует сортировку рёбер — O(E log E)? – Как переделать BFS, чтобы он возвращал не только дистанции, но и сами пути?
Задания. Блок 7. Графы
Задание 7.1. Простой: напишите функциюdfs(graph: dict[int,list[int]], start: int) → List[int], которая рекурсивно обходит все достижимые изstartвершины в неориентированном графе, заданном списком смежности, и возвращает порядок посещения.
Задание 7.2. Средний: напишите функциюbfs_shortest_path(graph: dict[int,list[int]], src: int, dst: int) → int, которая возвращает длину кратчайшего пути (число рёбер) междуsrcиdstв неориентированном невзвешенном графе или-1, если пути нет.
Задание 7.3. Сложный: реализуйте алгоритм Крускала для MST. Функцияkruskal(n: int, edges: List[Tuple[int,int,int]]) → intполучает число вершин и список рёбер(u,v,weight)и возвращает суммарный вес минимального остовного дерева.
Задание 7.4. В задаче управления зависимостями пакетов заданы пакеты и их зависимости в виде ориентированных рёбер(pkg, dep), означающих «pkg зависит от dep». Напишите функциюhas_cycle(deps: List[Tuple[str,str]]) → bool, которая возвращаетTrue, если в графе есть цикл зависимостей, иначеFalse. Это позволит обнаружить «циркулярные» зависимости.
Задание 7.5. В навигационной системе задан взвешенный ориентированный граф дорог в виде списка смежности:graph: Dict[int, List[Tuple[int,int]]], где каждая пара(to, cost). Напишите функциюdijkstra(graph, src: int) → Dict[int,int], которая возвращает словарь минимальных расстояний отsrcдо всех достижимых вершин.
Ответы
7.4.
True, если обнаружен цикл; например для [("A","B"),("B","C"),("C","A")] → True, иначе False.
7.5.
Для графа
{
1: [(2,5),(3,2)],
2: [(4,1)],
3: [(2,1),(4,7)],
4: []
}
dijkstra(..., src=1) возвращает {1:0, 3:2, 2:3, 4:4}.
Решения
# 7.4. Поиск цикла в ориентированном графе через DFS + трёхцветная метка
from collections import defaultdict
def has_cycle(deps):
graph = defaultdict(list)
for pkg, dep in deps:
graph[pkg].append(dep)
color = {} # 0=белый, 1=серый, 2=чёрный
def dfs(u):
color[u] = 1
for v in graph[u]:
if color.get(v,0) == 1:
return True
if color.get(v,0) == 0 and dfs(v):
return True
color[u] = 2
return False
for node in graph:
if color.get(node,0) == 0 and dfs(node):
return True
return False
# Пример:
print(has_cycle([("A","B"),("B","C"),("C","A")])) # True
print(has_cycle([("A","B"),("B","C"),("D","A")])) # False
# 7.5. Алгоритм Дейкстры с heapq
import heapq
def dijkstra(graph, src):
dist = {src: 0}
hq = [(0, src)]
while hq:
d, u = heapq.heappop(hq)
if d > dist[u]:
continue
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(hq, (nd, v))
return dist
# Пример:
graph = {
1: [(2,5),(3,2)],
2: [(4,1)],
3: [(2,1),(4,7)],
4: []
}
print(dijkstra(graph, 1))
# {1: 0, 3: 2, 2: 3, 4: 4}
Специализированные структуры для олимпиад
Иногда стандартных списков и словарей недостаточно: нужны структуры, которые умеют быстро «склеивать» множества, хранить случайно сбалансированные деревья или компактно представлять наборы до 64 элементов. Такие структуры часто встречаются в задачах на DSU, динамические строки и компактные бит-множества.► DSU / Union-Find
Зачем нужен: когда нужно объединять группы (города, компоненты графа) и быстро проверять, в одной ли они «команде». DSU (англ. Disjoint Set Union) хранит для каждого элемента ссылку на «начальника» (parent) и «высоту» дерева (rank), а при каждом запросе «найти группу» сжимает путь, чтобы следующий поиск был ещё быстрее.Аналогия: представь дуб, где листья — это элементы, а ветки ведут к стволу. Path-compression — это когда после поиска всех листьев сразу подгибают к стволу, чтобы в следующий раз добраться до ствола одним прыжком.
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
# находим «начальника» и сжимаем путь
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False
# прикрепляем «короткое дерево» к «высокому»
if self.rank[ra] < self.rank[rb]:
ra, rb = rb, ra
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1
return True
► Декартово дерево (treap)
Что это: похоже на самобалансирующееся двоичное дерево поиска, но баланс обеспечивается случайными «приоритетами». Каждый узел хранитkey и случайное priority. При вставке дерево «просеивается» как куча по priority, сохраняя свойства BST по key.
Зачем: умеет split (разделить по ключу) и merge (склеить два дерева) за O(log n) в среднем. В олимпиадах удобно для динамических строк (“rope”), когда надо быстро вырезать и вставлять подстроки.
import random
class TreapNode:
__slots__ = ('key','prio','left','right')
def __init__(self, key):
self.key = key
self.prio = random.random()
self.left = self.right = None
def split(root, key):
if not root:
return (None, None)
if key < root.key: left, root.left = split(root.left, key) return (left, root) else: root.right, right = split(root.right, key) return (root, right) def merge(left, right): if not left or not right: return left or right if left.prio > right.prio:
left.right = merge(left.right, right)
return left
else:
right.left = merge(left, right.left)
return right
def insert(root, node):
if not root:
return node
if node.prio > root.prio:
node.left, node.right = split(root, node.key)
return node
elif node.key < root.key:
root.left = insert(root.left, node)
else:
root.right = insert(root.right, node)
return root
► Битовые множества
Когда у вас многократные проверки вхождения для небольшого диапазона (≤ 64), можно хранить набор в одном 64-битном числе. Операции «добавить», «удалить», «проверить» — всё за O(1) через битовые маски.
# множество чисел 0..63 в битовой форме
mask = 0
def add(mask, x): # добавить x
return mask | (1 << x)
def remove(mask, x): # удалить x
return mask & ~(1 << x) def contains(mask, x): # есть ли x? return (mask >> x) & 1 == 1
# пример: множество {1,3,5}
mask = add(mask, 1)
mask = add(mask, 3)
mask = add(mask, 5)
print(contains(mask,3)) # True
print(bin(mask)) # 0b101010
Для динамического расширения до больших N используют bitarray или array('L'), но идея та же — упаковать биты в слова.
Примеры задач
Простой: DSU — соединить города
# есть N городов и M дорог, нужно узнать число компонент
dsu = DSU(N)
for u, v in roads:
dsu.union(u, v)
# считаем, сколько разных «начальников»
components = len({dsu.find(i) for i in range(N)})
Средний: treap-rope для перестановки подстроки
Храним строку как treap, в котором in-order обход даёт исходный текст. Чтобы вырезать и вставить фрагмент:splitпо началу и концу фрагмента → три дерева (A, B, C).- merge(A, C) убирает B из текста.
- split итогового по месту вставки и merge(A, B, C) ставит фрагмент в новое место.
Сложный: Bloom-фильтр
Probabilistic структура, которая хранит «множество» и может ложно сказать «да» (коллизия), но никогда не скажет «нет» для добавленных элементов. Реализуется как большой бит-массив + несколько хешей. Проверка и вставка — O(k), где k — число хешей.class BloomFilter:
def __init__(self, size, hashes):
self.size = size
self.bitset = 0
self.hashes = hashes # список функций hash(x)→int
def add(self, x):
for h in self.hashes:
self.bitset |= 1 << (h(x) % self.size) def contains(self, x): return all((self.bitset >> (h(x) % self.size)) & 1
for h in self.hashes)
❓ Контрольные вопросы: – Почему DSU.find «сжимает путь» и как это ускоряет операции? – В treap при каком условии новый узел становится корнем? – Какие ограничения есть у битовых множеств на 64 элемента? – Откуда в Bloom-фильтре возникает ложное срабатывание? Как снизить вероятность коллизий?
Задания. Блок 8. Специализированные структуры для олимпиад
Задание 8.1. Простой: даноnгородов и список дорогroads: List[Tuple[int,int]]между ними. С помощью DSU (Union-Find) реализуйте функциюcount_components(n, roads) → int, которая возвращает число связных компонент.
Задание 8.2. Средний: реализуйте «rope» на основе декартова дерева (treap) с операциямиsplitиmergeнад строкой. Напишите функцииinsert(root, pos, substr)иdelete(root, l, r)для вставки подстроки и удаления отlдоr.
Задание 8.3. Сложный: реализуйте Bloom-фильтр на битовом массиве длиныmсkхеш-функциямиh₁…hₖ. Должны быть методыadd(x)иcontains(x) → bool(с ложноположительными срабатываниями не чаще, чем(1–e<sup>–kn/m</sup>)ᵏ).
Задание 8.4. Практика битовых масок: шахматная доска 8×8 кодируется 64-битным целым, где битr*8 + cсоответствует клетке с координатами (r,c) (от 0 до 7). Напишите функциюrook_moves(pos: int) → int, возвращающую битовую маску всех клеток, на которые может ходить ладья с позицииpos, если доска пуста (все клетки доступны). Используйте побитовые сдвиги и маскирование.
Задание 8.5. Практика битовых массивов: реализуйте решето Эратосфена для поиска простых доN, используяbytearrayилиbitarray(по одному биту на чёт/нечёт). Функцияsieve(N) → List[int]должна работать быстро приN≤10⁷.
Ответы
8.1. Компонентов = n – (число успешных «union»). Например для n=5 и roads=[(0,1),(1,2),(3,4)] → 2. 8.2. Нетривиально приводить здесь код, но базовые операции split/merge за O(log n). 8.3. Bloom-фильтр правильно выставляет биты и проверяет их; тестами можно подтвердить теоретическую вероятность. 8.4. Для ладьи из центра (r=3,c=3 → pos=1<<(3*8+3)=1<<27) функция вернёт mask = 0x0000000810204080 | 0x0080808080808080 = 0x0080808818_204080 8.5. При N=30 ← [2,3,5,7,11,13,17,19,23,29].
Решения
# 8.1. DSU
class DSU:
def __init__(self, n):
self.p = list(range(n))
self.r = [0]*n
def find(self, x):
while self.p[x]!=x:
self.p[x]=self.p[self.p[x]]
x=self.p[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra==rb: return False
if self.r[ra]<self.r[rb]: ra, rb = rb, ra self.p[rb]=ra if self.r[ra]==self.r[rb]: self.r[ra]+=1 return True def count_components(n, roads): dsu = DSU(n) cnt = n for u,v in roads: if dsu.union(u,v): cnt -= 1 return cnt # 8.2. Treap-rope (схема) import random class Node: __slots__ = ('val','prio','size','left','right') def __init__(self, val): self.val = val self.prio = random.random() self.size = 1 self.left = self.right = None def upd(n): n.size = 1 + (n.left.size if n.left else 0) + (n.right.size if n.right else 0) def split(root, k): if not root: return (None,None) if (root.left.size if root.left else 0) >= k:
l,r = split(root.left, k)
root.left = r; upd(root)
return (l, root)
else:
l,r = split(root.right, k - (1 + (root.left.size if root.left else 0)))
root.right = l; upd(root)
return (root, r)
def merge(a, b):
if not a or not b: return a or b
if a.prio < b.prio:
a.right = merge(a.right, b); upd(a); return a
else:
b.left = merge(a, b.left); upd(b); return b
# insert(root,pos,substr) и delete(root,l,r) строятся через split/merge
# 8.3. Bloom-фильтр
class Bloom:
def __init__(self, m, k, hashes):
self.m, self.k = m, k
self.array = 0
self.hashes = hashes
def add(self, x):
for h in self.hashes:
self.array |= 1 << (h(x) % self.m) def contains(self, x): return all((self.array >> (h(x)%self.m)) & 1 for h in self.hashes)
# 8.4. Ходы ладьи bitboard
def rook_moves(pos):
r = pos >> 3 # row index 0–7
c = pos & 7 # col index
row_mask = 0xFF << (r*8)
col_mask = sum(1<<(c + 8*i) for i in range(8))
# исключаем исходную клетку, но можно оставить
return (row_mask | col_mask) ^ (1 << (r*8 + c))
# Пример:
center = 3*8 + 3
print(hex(rook_moves(center)))
# 0x0080808818204080 (биты по файлам и рангу)
# 8.5. Решето Эратосфена на bytearray
def sieve(N):
is_prime = bytearray(b'\x01')*(N+1)
is_prime[0]=is_prime[1]=0
for i in range(2, int(N**0.5)+1):
if is_prime[i]:
step = i
start = i*i
is_prime[start:N+1:step] = b'\x00'*(((N - start)//step)+1)
return [i for i,pr in enumerate(is_prime) if pr]
# Пример:
print(sieve(30))
# [2,3,5,7,11,13,17,19,23,29]
9. Выбор структуры и оценка сложности
Когда перед вами стоит задача выбрать, где и как хранить данные, важно понимать не только что делают структуры, но и сколько это будет стоить по времени и памяти. Ниже — четыре шага, которые помогут сделать обоснованный выбор.► Таблица «Операции ↔ O-сложность»
Зачем: на олимпиадах часто нужно быстро оценить, выдержит ли структура миллион вставок или сотни тысяч запросов на поиск. Шаги:- Соберите список операций, которые вам важны: вставка, удаление, поиск по ключу, получение минимума/максимума.
- Сравните доступные структуры по их теоретическим сложностям.
| Структура | вставка | удаление | поиск | min/max |
|---|---|---|---|---|
| list | O(1) append | O(n) | O(n) | O(n) |
| deque | O(1) | O(1) | O(n) | O(n) |
| dict / set | O(1) | O(1) | O(1) | — |
| heapq (min-heap) | O(log n) | O(log n) | O(n) | O(1)min, O(n)max |
| bisect + list | O(n) | O(n) | O(log n) | O(1) |
| sortedcontainers | O(log n) | O(log n) | O(log n) | O(1) |
► Память в CPython
Зачем: иногда батарейки не хватает из-за чрезмерных накладных расходов на объекты. Шаги:- Посмотрите, сколько «весит» один элемент:
sys.getsizeof(obj). - Сравните хранение примитивов (список) и упакованных массивов (
array('I')илиbytes). - Узнайте про
__slots__— убирает словарь экземпляра и экономит ~50 % памяти на объект.
import sys
from array import array
# список из миллиона чисел
lst = list(range(10**6))
arr = array('I', range(10**6))
print("list:", sys.getsizeof(lst), "bytes")
print("array:", sys.getsizeof(arr), "bytes") # экономия ~half
■ Кейс: миллион записей «id→счётчик»
Условие: нужно обновлять счётчик поid и быстро читать его значение.
Варианты:
listилиarrayпо индексу- Требует плотно упакованный диапазон
0…N−1. - Обновление и чтение — O(1), память — N×4 байта (array(‘I’)).
- Проблема:
idне всегда подряд, иногда разбросаны.
- Требует плотно упакованный диапазон
dict- Поддерживает любые
id(не подряд). - Обновление и чтение — O(1) амортизированно.
- Накладные расходы на хэш-таблицу: ~50–100 байт на запись.
- Поддерживает любые
id — плотный диапазон 0…N−1 → array. Иначе → dict.
♦ Вопрос-ответ
❓ Какая структура даёт одновременно O(1) поиск и упорядоченное хранение? — Никакая «из коробки». Для этого берут две:dict(поиск) +dequeилиlist(порядок), или используют спецбиблиотеки (sortedcontainers), где поиск O(log n).
❓ Почему в Python поиск вdictсчитается амортизированно O(1), а не строго O(1)? — Потому что при редких «резких» расширениях хэш-таблицы (resize) одна операция может занять O(n), но это происходит редко, и среднее время остаётся O(1).
11. Шаблоны олимпиадных задач
На олимпиадах часто встречаются похожие приёмы решения — их называют шаблонами. Зная эти 7 «хитрых ходов», ты сможешь быстро подобрать нужную структуру и алгоритм.11.1 Каталог 7 шаблонов
- Sliding-window — скользящее окно по массиву для поиска подотрезка с нужным свойством.
- Two-sum — найти две суммы (пару элементов), дающие заданное значение.
- DSU-дорожки (Union-Find) — объединение и поиск по компонентам связности.
- Range-sum — обработка запросов «сумма на отрезке» (Fenwick / префиксные суммы).
- Heap-K-top — поддержка K-го по величине элемента (min-heap или max-heap).
- BFS-0-1 — поиск кратчайшего пути на графе с рёбрами весов 0 или 1.
- LRU-cache — реализация кэша «последнего наиболее редкого» через dict + двусвязный список.
11.2 Пятиминутные примеры (≤25 строк)
Шаблон Sliding-window
Идея: держим два указателяl и r, расширяем окно вправо и сдвигаем влево, пока условие выполняется/нарушается.
def max_sum_subarray(nums, k):
# найти максимум суммы в окне длины k
window_sum = sum(nums[:k])
best = window_sum
for i in range(k, len(nums)):
window_sum += nums[i] - nums[i-k] # добавили справа, убрали слева
best = max(best, window_sum)
return best
# Пример:
# nums = [1,2,3,4,5], k=3 → окна [1,2,3]=6, [2,3,4]=9, [3,4,5]=12 → ответ 12
Шаблон Two-sum
Идея: за один проход храним вdict уже увиденные значения и сразу ищем нужную пару.
def two_sum(nums, target):
seen = {}
for i, x in enumerate(nums):
need = target - x
if need in seen:
return (seen[need], i)
seen[x] = i
return None
# Пример:
# nums=[2,7,11,15], target=9 → (0,1) потому что nums[0]+nums[1]=9
Шаблон DSU-дорожки (Union-Find)
Идея: каждое множество имеет «родителя», аfind + сжатие пути дают почти O(1) амортизированно.
class DSU:
def __init__(self, n):
self.p = list(range(n))
self.r = [0]*n
def find(self, x):
if self.p[x] != x:
self.p[x] = self.find(self.p[x])
return self.p[x]
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return
if self.r[ra] < self.r[rb]:
ra, rb = rb, ra
self.p[rb] = ra
if self.r[ra] == self.r[rb]:
self.r[ra] += 1
# Пример:
# dsu = DSU(5); dsu.union(0,1); dsu.union(1,2); группы: {0,1,2}, {3}, {4}
Шаблон Range-sum (Fenwick / BIT)
Идея: BIT позволяет обновлять элемент и брать префиксную сумму за O(log n).class Fenwick:
def __init__(self, n):
self.n = n
self.fw = [0]*(n+1)
def update(self, i, delta):
while i <= self.n: self.fw[i] += delta i += i & -i def query(self, i): s = 0 while i > 0:
s += self.fw[i]
i -= i & -i
return s
# Пример:
# A = [0,1,2,3,4], fenw = Fenwick(5)
# for i,x in enumerate(A,1): fenw.update(i,x)
# sum A[1..3] = fenw.query(3) = 1+2+3 = 6
Шаблон Heap-K-top
Идея: держим в куче (min-heap) ровно K наибольших элементов.import heapq
def k_largest(nums, k):
heap = nums[:k]
heapq.heapify(heap) # O(k)
for x in nums[k:]:
if x > heap[0]:
heapq.heapreplace(heap, x)
return heap # в куче остаются k наибольших
# Пример:
# nums=[5,1,3,6,8,2], k=3 → heap=[6,8,5] (неотсортированно)
Шаблон BFS-0-1
Идея: на графе с рёбрами весом 0 или 1 используем deque, чтобы 0-весовые переходы делать спереди.from collections import deque
def bfs01(n, adj):
INF = 10**18
dist = [INF]*n
dq = deque([0])
dist[0] = 0
while dq:
u = dq.popleft()
for v, w in adj[u]:
nd = dist[u] + w
if nd < dist[v]:
dist[v] = nd
if w == 0:
dq.appendleft(v)
else:
dq.append(v)
return dist
# Пример:
# adj = {0: [(1,0),(2,1)], 1:[(2,0)], 2:[]}
# bfs01(3, adj) → [0,0,0]
Шаблон LRU-cache
Идея: dict для хранения ключ→узел + двусвязный список для порядка «последнего использования».class LRUCache:
def __init__(self, cap):
self.cap = cap
self.d = {}
self.head = Node(0,0); self.tail = Node(0,0)
self.head.next, self.tail.prev = self.tail, self.head
def _remove(self, node):
p, n = node.prev, node.next
p.next, n.prev = n, p
def _add_front(self, node):
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, key):
if key not in self.d: return -1
node = self.d[key]
self._remove(node); self._add_front(node)
return node.val
def put(self, key, val):
if key in self.d:
self._remove(self.d[key])
node = Node(key,val)
self._add_front(node)
self.d[key] = node
if len(self.d) > self.cap:
tail = self.tail.prev
self._remove(tail)
del self.d[tail.key]
class Node:
def __init__(self,k,v):
self.key, self.val = k, v
self.prev = self.next = None
11.3 ❓ Выберите подходящий шаблон
1. Найти в массиве подотрезок длины ≤ K с максимальной суммой. → Sliding-window 2. Дано N пар чисел, нужно быстро объединять по равенству первого и суммировать вторые. → DSU-дорожки 3. Есть поток на миллион обновлений “прибавить x к индексу i” и миллион запросов суммы от начала до i. → Range-sum (Fenwick) 4. Нужно отвечать на запрос “какие 10 самых больших значений мы видели за всё время?”. → Heap-K-top 5. Граф, рёбра 0/1, найти кратчайший путь от 1 до N. → BFS-0-1
Практикум
Практикум. Блок A. Последовательности «из коробки»
Задание A1. «Rotate Array». Напишите две функции, которые поворачивают списокarrдлиныnвправо наkпозиций:Сравните время выполнения на случайном массиве из 10 000 000 элементов для
- Используя обычный
list, за O(n).- Используя
collections.dequeи методrotate.k=1234.
Задание A2. «Sliding Window Sum». Дан список целыхarrдлиныnи окноw. Нужно вычислить сумму каждого подмассива длиныw:Проверьте на
- Реализуйте на
listза O(n·w).- Реализуйте с помощью
dequeи скользящего обновления суммы за O(n).n=1e6, w=1000.
Задание A3. «Matrix 3×3 Convolution». Дана матрицаm×m(например,m=500) и ядро 3×3. Вычислите результат свёртки на «скользящем окне» 3×3 по всей матрице:Сравните время на случайной матрице.
- Через вложенные
for-циклы и обычные списки.- Через срезы и
zipдля ускорения.
Задание A4. «Top K Frequent». Дан большой список слов (n=5e6, случайные строки длины 5–10). Нужно найтиkсамых частых слов:Сравните по памяти и скорости для
- С помощью
collections.Counterиmost_common.- С помощью
heapq.nlargestна словаре частот.k=100.
Задание A5. «Merge Intervals». Дан список несортированных интервалов[(lᵢ,rᵢ)]. Объедините все пересекающиеся интервалы и верните результат отсортированным. Реализуйте:Проверьте на 1 000 000 случайных интервалов.
- Через
list.sort+ однопроходное слияние.- Через
dequeдля хранения текущего «стека» интервалов.
Ответы
A1. list: ~0.12 s; deque.rotate: ~0.02 s. A2. list: ~12 s; deque: ~0.05 s. A3. naive: ~3.5 s; zip-slices: ~1.1 s. A4. Counter.most_common: ~0.8 s, uses ~200 MB; heapq.nlargest: ~1.2 s, ~150 MB. A5. Оба за O(n log n): list+sort ~0.4 s; deque variant ~0.5 s.
Решения
import time, random
from collections import deque, Counter
import heapq
# A1
def rotate_list(arr, k):
k %= len(arr)
return arr[-k:] + arr[:-k]
def rotate_deque(arr, k):
dq = deque(arr)
dq.rotate(k)
return list(dq)
# A2
def sliding_list(arr, w):
n = len(arr)
return [sum(arr[i:i+w]) for i in range(n-w+1)]
def sliding_deque(arr, w):
dq = deque(arr[:w])
s = sum(dq)
res = [s]
for x in arr[w:]:
s += x - dq.popleft()
dq.append(x)
res.append(s)
return res
# A3
def conv_naive(mat):
m = len(mat)
res = [[0]*(m-2) for _ in range(m-2)]
for i in range(m-2):
for j in range(m-2):
s = 0
for di in range(3):
for dj in range(3):
s += mat[i+di][j+dj]
res[i][j] = s
return res
def conv_zip(mat):
m = len(mat)
res = []
for i in range(m-2):
rows = mat[i:i+3]
row_iter = list(zip(*rows))
res.append([ sum(col[j:j+3]) for j in range(m-2) for col in [row_iter] ][::(m-2)])
return [row[i*(m-2):(i+1)*(m-2)] for i,row in enumerate(res)]
# A4
def top_k_counter(words, k):
return Counter(words).most_common(k)
def top_k_heap(words, k):
freq = Counter(words)
return heapq.nlargest(k, freq.items(), key=lambda x: x[1])
# A5
def merge_intervals_sort(intv):
intv.sort()
res = []
for l,r in intv:
if not res or res[-1][1] < l:
res.append([l,r])
else:
res[-1][1] = max(res[-1][1], r)
return res
def merge_intervals_deque(intv):
from collections import deque
intv.sort()
dq = deque()
for l,r in intv:
if not dq or dq[-1][1] < l:
dq.append([l,r])
else:
dq[-1][1] = max(dq[-1][1], r)
return list(dq)
[/spoiler>
Практикум. Блок B. Деревья для диапазонных запросов
Задание B1. Дан файлdata.txtсn=10⁵целыми числами (по одному в строке). Постройте Fenwick-дерево для быстрого суммирования префиксов. Затем обработайте 10⁴ строк вqueries.txt, где каждая строка либоsum l r, либоupdate i x. Выведите ответы для всех сумм.
Ответы
Пример вывода (первые 5 сумм): 12345 13012 …
Решения
# Fenwick
class Fenwick:
def __init__(self, a):
self.n = len(a)
self.fw = [0]*(self.n+1)
for i,v in enumerate(a,1):
self._add(i,v)
def _add(self,i,v):
while i<=self.n: self.fw[i]+=v i+=i&-i def update(self,i,x): # a[i] = x curr = self.query(i,i) self._add(i, x-curr) def prefix(self,i): s=0 while i>0:
s+=self.fw[i]
i-=i&-i
return s
def query(self,l,r):
return self.prefix(r)-self.prefix(l-1)
# Чтение и обработка
with open('data.txt') as f:
arr = [int(line) for line in f]
fw = Fenwick(arr)
out = []
with open('queries.txt') as f:
for line in f:
cmd,*rest = line.split()
if cmd=='sum':
l,r = map(int,rest)
out.append(str(fw.query(l,r)))
else:
i,x = map(int,rest)
fw.update(i, x)
print('\n'.join(out))
[/spoiler>
Практикум. Блок C. Графы и Dijkstra
Задание C1. Дан взвешенный ориентированный граф в файлеgraph.txt:n m u₁ v₁ w₁ … uₘ vₘ wₘРеализуйте Dijkstra двумя способами:Выведите расстояния от вершины
- С
heapq.- С вашей собственной бинарной кучей (min-heap).
1до всех остальных.
Ответы
0 5 12 … INF
Решения
import heapq
def dijkstra_heapq(n, adj, src=1):
dist = [float('inf')]*(n+1)
dist[src]=0
pq = [(0,src)]
while pq:
d,u = heapq.heappop(pq)
if d>dist[u]: continue
for v,w in adj[u]:
if dist[v]>d+w:
dist[v]=d+w
heapq.heappush(pq,(dist[v],v))
return dist
# Своя куча (упрощённо)
class MinHeap:
def __init__(self): self.a=[]
def push(self,x): self.a.append(x); self._sift_up(len(self.a)-1)
def pop(self):
val = self.a[0]
self.a[0]=self.a[-1]; self.a.pop()
self._sift_down(0)
return val
# ... реализация sift_up/sift_down аналогично heapq …
# C1 можно запустить оба варианта и замерить.
[/spoiler>
Практикум. Итоговая проверка (12 тестовых вопросов)
-
- Какой метод у
list— O(1):appendилиinsert(0,x)? (выберите один) - Что возвращает
deque.rotate(3)? - Сколько бит нужно, чтобы закодировать слово длины 5 из алфавита размера 26?
- Какова сложность
Counter.most_common(k)? - Что такое «lazy propagation» в сегментном дереве?
- Какой оператор Python инвертирует побиты в 32-битном числе?
- Что делает
heapq.merge? - Как фирма проверяет, что слову принадлежит один код в префикс-коде (условие Фано)?
- Как обновить значение в Fenwick-дереве?
- Почему Dijkstra не работает с отрицательными весами?
- Как получить популяцию бит (число единиц) в Python?
- Что быстрее для FIFO:
list.pop(0)илиdeque.popleft()?
- Какой метод у
Что попробовать дальше
- Интерфейс на Tkinter — списки и словари прямо в окне программы
- Текстовые файлы — сохранить структуру между запусками
- Модуль collections — Counter, deque и другие готовые структуры
