Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv
This commit is contained in:
@@ -8,19 +8,26 @@
|
||||
протоколах.
|
||||
- **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы
|
||||
вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы.
|
||||
На практике `bswap` нужен ровно потому, что сеть передаёт многобайтовые поля в network byte
|
||||
order (big-endian), а x86 внутри — little-endian: без разворота байт число читается неверно.
|
||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
|
||||
Четыре инструмента, которых достаточно:
|
||||
|
||||
1. `x & (x - 1)` — гасит самый младший единичный бит. Отсюда классика: число единиц можно
|
||||
считать циклом, пока `x` не станет нулём.
|
||||
1. `x & (x - 1)` — гасит самый младший единичный бит. Механизм: в дополнительном коде `x - 1`
|
||||
переворачивает все нули справа от младшего единичного бита в единицы, а сам этот бит — в ноль,
|
||||
биты выше не трогает; операция `&` с исходным `x` поэтому обнуляет ровно один бит — младший
|
||||
единичный. Отсюда классика: число единиц можно считать циклом, пока `x` не станет нулём, и
|
||||
число итераций равно числу единичных бит, а не 32.
|
||||
2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо.
|
||||
3. `x & (1u << k)` — проверка k-го бита.
|
||||
4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного
|
||||
числа. Комбинация «сдвинул и сложил» даёт любой порядок байт.
|
||||
|
||||
Почему дальше: та же логика масок и сдвигов нужна в задаче 04 — разбор IPv4-заголовка byte-по-byte.
|
||||
|
||||
## Глава III. Задание
|
||||
|
||||
Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`,
|
||||
@@ -83,12 +90,25 @@ uint32_t bswap32(uint32_t x); // поменять порядок ба
|
||||
Если перепутать — биты уедут на одну позицию.
|
||||
</details>
|
||||
|
||||
## Глава VII. Частые ошибки
|
||||
## Глава VII. Ловушки
|
||||
|
||||
- Сдвиги в `int` вместо `uint32_t` → UB, UBSAN ругается.
|
||||
- Забыт ноль в `is_power_of_two`.
|
||||
- В `bswap32` перепутаны направления сдвигов (влево/вправо) — проверь на `0x11223344`.
|
||||
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому.
|
||||
- Сдвиги в `int` вместо `uint32_t` → сдвиг `1 << 31` для знакового типа — UB → UBSAN падает
|
||||
на этой строке с диагностикой конкретного сдвига.
|
||||
- Забыт ноль в `is_power_of_two` → `0 & (0 - 1) == 0`, функция возвращает `true` на нуле →
|
||||
тест на `x == 0` красный.
|
||||
- В `bswap32` перепутаны направления сдвигов (влево/вправо) → байты встают не на свои места →
|
||||
`bswap32(0x11223344) != 0x44332211`, видно прямым сравнением.
|
||||
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому;
|
||||
формально тест это не всегда ловит по выводу, но нарушает условие задачи.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько байт меняет местами `bswap32`? — 4
|
||||
- core | Что делает `x & (x - 1)`? — гасит младший единичный бит `x`
|
||||
- core | Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF`? — 32 (по числу единичных бит)
|
||||
- deep | Почему `1u << 31` пишут с суффиксом `u`, а не как `int`? — сдвиг знакового `int` в
|
||||
знаковый бит — UB, `uint32_t` определён стандартом для любых сдвигов в пределах разрядности
|
||||
- base | Какой порядок байт использует сеть для многобайтовых полей? — network byte order
|
||||
(big-endian)
|
||||
|
||||
## После сдачи
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Задача 02 — связный список (C++)
|
||||
|
||||
## Глава I. Общая информация
|
||||
|
||||
- **Цель:** развернуть список, найти середину и определить цикл — без единой дополнительной
|
||||
аллокации, только перелинковка указателей.
|
||||
- **Почему это в Eltex:** списки соединений, очереди задач, таблицы состояний — везде связные
|
||||
структуры. Приём двух указателей (slow/fast), которым решаются `find_middle` и `has_cycle`,
|
||||
— это алгоритм Флойда, тот же паттерн всплывает при поиске циклов в графах зависимостей
|
||||
и в обходе кольцевых структур.
|
||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 02`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
|
||||
Даны функции над односвязным списком `Node{int value; Node* next;}`:
|
||||
|
||||
```c
|
||||
@@ -8,10 +20,57 @@ Node* find_middle(Node* head); // середина; для чётной дл
|
||||
bool has_cycle(Node* head); // есть ли цикл
|
||||
```
|
||||
|
||||
Требования:
|
||||
Механизм двух указателей: `slow` идёт на 1 узел за шаг, `fast` — на 2. Для `find_middle`, когда
|
||||
`fast` доходит до конца (`fast == nullptr` или `fast->next == nullptr`), `slow` стоит ровно в
|
||||
середине — за счёт того, что `slow` проходит вдвое меньше узлов, чем `fast`. Для чётной длины
|
||||
условие остановки должно давать именно второй из двух средних узлов — это проверяется на
|
||||
списке длины 2 и 4.
|
||||
|
||||
Для `has_cycle` тот же дуэт указателей ловит цикл иначе: если цикл есть, `fast` заходит в него
|
||||
и после каждого шага сокращает расстояние до `slow` внутри цикла на 1 узел (потому что относительная
|
||||
скорость `fast` к `slow` внутри цикла — 1 узел/шаг), значит рано или поздно `slow == fast`; если
|
||||
цикла нет, `fast` первым дойдёт до `nullptr`.
|
||||
|
||||
`reverse_list` разворачивается за один проход тремя указателями `prev/cur/next`: на каждом шаге
|
||||
переставляется `cur->next = prev` до итерации по цепочке. Рекурсивный разворот сюда не годится —
|
||||
он тратит O(n) памяти стека вызовов и нарушает требование O(1) дополнительной памяти.
|
||||
|
||||
Почему дальше: тот же принцип «два индекса вместо лишней памяти» — в задаче 03, только на
|
||||
массиве, а не на указателях.
|
||||
|
||||
## Глава III. Требования
|
||||
|
||||
- никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно;
|
||||
- `find_middle` и `has_cycle` — через два указателя (медленный/быстрый);
|
||||
- корректная работа с пустым списком и списком из одного узла.
|
||||
|
||||
## Глава IV. Критерии приёмки
|
||||
|
||||
Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте
|
||||
считаются ошибкой — тест сам освобождает память).
|
||||
|
||||
## Глава V. Ловушки
|
||||
|
||||
- Забыть проверку `head == nullptr` в начале любой из трёх функций → разыменование нулевого
|
||||
указателя → падение/ASAN SEGV на пустом списке.
|
||||
- В `find_middle` неверное условие остановки цикла (`fast->next` без проверки самого `fast`
|
||||
на `nullptr`) → на списке чётной длины либо возвращается не тот из двух средних узлов, либо
|
||||
падение на последнем шаге.
|
||||
- В `reverse_list` переставить `cur->next = prev` до сохранения старого `cur->next` во
|
||||
временную переменную → потеря хвоста списка, обход обрывается раньше конца.
|
||||
- Рекурсивный `reverse_list` вместо итеративного → лишняя память стека на каждый вызов →
|
||||
нарушение требования O(1), на длинном списке возможен stack overflow.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей? — в 2 раза
|
||||
- core | Почему рекурсивный разворот списка не годится под требование O(1) памяти? — каждый
|
||||
вызов кладёт кадр в стек, суммарно O(n) памяти
|
||||
- core | На чём основано доказательство, что `fast` догонит `slow` при цикле? — внутри цикла
|
||||
расстояние между ними сокращается на 1 узел за шаг
|
||||
- base | Сколько дополнительных указателей нужно для итеративного разворота списка? — 3
|
||||
(`prev`, `cur`, `next`)
|
||||
|
||||
## После сдачи
|
||||
|
||||
Разбор: почему алгоритм Флойда работает за O(n) времени и O(1) памяти в обоих режимах
|
||||
(поиск середины и поиск цикла), и как тот же приём переносится на поиск цикла в графе.
|
||||
|
||||
+33
-10
@@ -7,18 +7,26 @@
|
||||
- **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов.
|
||||
- **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками —
|
||||
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
|
||||
собеседовании.
|
||||
собеседовании. Механизм тот же, что у сетевой карты: пакеты пишутся в кольцо по мере
|
||||
прихода, драйвер вычитывает их с другого конца, и обе стороны никогда не двигают уже
|
||||
записанные данные по памяти — двигаются только индексы `head`/`tail`.
|
||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
|
||||
Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда
|
||||
читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`.
|
||||
Сдвигать элементы не нужно никогда — в этом весь смысл.
|
||||
Сдвигать элементы не нужно никогда — в этом весь смысл: `push`/`pop` двигают только индекс,
|
||||
а не байты в памяти, поэтому обе операции остаются O(1) независимо от размера буфера.
|
||||
|
||||
Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от
|
||||
полного, если оба индекса совпали?** Варианты: хранить счётчик `size`, либо оставлять одну
|
||||
ячейку свободной. В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
|
||||
полного, если оба индекса совпали?** При `head == tail` возможны оба состояния — буфер мог
|
||||
быть только что создан (пуст) или заполнен ровно `capacity` раз (полон) — по одним индексам
|
||||
это не различить. Варианты: хранить счётчик `size`, либо оставлять одну ячейку свободной.
|
||||
В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
|
||||
|
||||
Почему дальше: тот же счётчик `size_` вместо пересчёта по индексам — частый приём и в
|
||||
`std::deque`, и в реализациях lock-free очередей, где индексы вообще нельзя лишний раз читать.
|
||||
|
||||
## Глава III. Задание
|
||||
|
||||
@@ -89,14 +97,29 @@ public:
|
||||
`std::vector<int>` — тогда вопрос исчезает.
|
||||
</details>
|
||||
|
||||
## Глава VII. Частые ошибки
|
||||
## Глава VII. Ловушки
|
||||
|
||||
- Сдвиг элементов вместо индексов (теряется весь смысл O(1)).
|
||||
- Путаница «пусто/полно» при совпавших индексах.
|
||||
- Сдвиг элементов вместо индексов → цена `push`/`pop` вырастает до O(n) → теряется весь
|
||||
смысл структуры, видно по сравнению с наивным `std::vector` на бенчмарке.
|
||||
- Путаница «пусто/полно» при совпавших индексах (`head_ == tail_`) без отдельного `size_` →
|
||||
`full()` и `empty()` дают одинаковый ответ на разных состояниях → тест на заполненный буфер
|
||||
ёмкости > 1 падает.
|
||||
- `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании
|
||||
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`).
|
||||
- Копирование объекта без правила трёх → двойное освобождение. Если сдаёшь с ручным `new[]`,
|
||||
запрети копирование (`= delete`).
|
||||
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`, деление по модулю дороже
|
||||
сравнения).
|
||||
- Копирование объекта без правила трёх/пяти → два объекта владеют одним и тем же `new[]`-буфером
|
||||
→ двойное освобождение при выходе обоих из области видимости, ловится ASAN. Если сдаёшь с
|
||||
ручным `new[]`, запрети копирование (`= delete`).
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность `push`/`pop` кольцевого буфера? — O(1)
|
||||
- core | Как отличить пустой буфер от полного при `head_ == tail_`? — хранить отдельный
|
||||
счётчик `size_` (или жертвовать одной ячейкой)
|
||||
- core | Чем `delete[]` отличается от `delete` для массива, выделенного `new[]`? — `delete`
|
||||
без `[]` на массиве — UB, вызовет деструктор только для первого элемента и испортит подсчёт
|
||||
размера аллокации
|
||||
- base | Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов? —
|
||||
`if (++idx == cap) idx = 0;`
|
||||
|
||||
## После сдачи
|
||||
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
# Задача 04 — разбор IPv4-пакета и контрольная сумма (C++)
|
||||
|
||||
Это то, что реально делают сетевые железки: взять буфер из сокета и корректно разобрать
|
||||
заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
|
||||
## Глава I. Общая информация
|
||||
|
||||
- **Цель:** разобрать буфер байт из сокета в структуру заголовка вручную, безопасно и без
|
||||
UB — то, что реально делают сетевые железки на приёмном тракте.
|
||||
- **Почему это в Eltex:** это то, что реально делают сетевые железки: взять буфер из сокета
|
||||
и корректно разобрать заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
|
||||
Коммутатор/маршрутизатор обязан отбросить пакет с битой контрольной суммой или враньём в
|
||||
`total_length`, иначе он либо читает чужую память, либо пересылает мусор дальше.
|
||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 04`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
|
||||
Минимальная длина IPv4-заголовка — 20 байт (без опций). Байт-порядок в заголовке смешанный:
|
||||
`total_length` и `header_checksum` хранятся в хост-порядке в структуре `Ipv4Header` (в задании
|
||||
явно указано «хост-порядок» — значит в `parse_ipv4` их нужно правильно собрать из сетевого
|
||||
порядка на проводе), а IP-адреса (`src_ip`, `dst_ip`) — «байты как на проводе»:
|
||||
`10.0.0.1 -> 0x0A000001`, то есть без перестановки байт при сборке в `uint32_t`.
|
||||
|
||||
Почему нельзя просто `reinterpret_cast<const Ipv4Header*>(buf)`: у `buf` нет гарантии
|
||||
выравнивания под `uint32_t` (нужно кратно 4 байтам) — компилятор вправе сгенерировать код,
|
||||
предполагающий выровненный доступ, и на невыровненном адресе это UB (UBSAN ловит как
|
||||
misaligned address); плюс раскладка полей в `Ipv4Header` определяется компилятором
|
||||
(padding), а не байтовым форматом протокола — сети всё равно, как выровнены поля в C++-структуре.
|
||||
Правильный путь — читать каждый байт по отдельности и собирать сдвигами (тот же приём, что
|
||||
в задаче 01: маски и сдвиги для сборки многобайтового числа из байт).
|
||||
|
||||
Почему дальше: если разбор заголовка освоен, логичный следующий шаг — контрольная сумма TCP/UDP
|
||||
поверх псевдозаголовка, которая считается тем же алгоритмом сложения в дополнительном коде.
|
||||
|
||||
## Глава III. Задание
|
||||
|
||||
```c++
|
||||
struct Ipv4Header {
|
||||
@@ -30,3 +58,34 @@ bool checksum_valid(const uint8_t* buf, size_t len);
|
||||
Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые.
|
||||
Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть —
|
||||
на невыровненных адресах и в другом порядке байт это UB.
|
||||
|
||||
## Глава IV. Ловушки
|
||||
|
||||
- `reinterpret_cast<Ipv4Header*>(buf)` вместо побайтового разбора → чтение по невыровненному
|
||||
адресу и/или с padding-полями структуры вместо реального формата пакета → UB, UBSAN
|
||||
сообщает misaligned address, а на реальных данных поля читаются со сдвигом.
|
||||
- Не обнулить поле `header_checksum` перед вызовом `compute_checksum` → в сумму попадает
|
||||
старое значение поля → результат не совпадёт с ожидаемым, тест на `compute_checksum` падает.
|
||||
- Пропустить перенос (carry) при сложении 16-битных слов в дополнительном коде, если
|
||||
промежуточная сумма считается в 16-битном типе → биты переноса теряются вместо того чтобы
|
||||
прибавиться обратно к младшим битам → неверная контрольная сумма на буферах, где сумма слов
|
||||
переполняет 16 бит.
|
||||
- Проверить `ihl*4 > len` до проверки `len >= 20` (или в неверном порядке) → чтение поля `ihl`
|
||||
из буфера короче 1 байта → выход за границы, ASAN сообщает heap-buffer-overflow.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Минимальная длина IPv4-заголовка без опций? — 20 байт
|
||||
- base | Код `protocol` для TCP / UDP / ICMP? — 6 / 17 / 1
|
||||
- core | В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию? — «как на проводе»,
|
||||
без перестановки байт при сборке в `uint32_t`
|
||||
- core | Почему `reinterpret_cast` буфера в `Ipv4Header*` — UB? — нет гарантии выравнивания
|
||||
адреса под 4-байтные поля структуры
|
||||
- deep | Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем
|
||||
checksum) при верной контрольной сумме? — 0xFFFF
|
||||
|
||||
## После сдачи
|
||||
|
||||
Разбор: как поле `header_checksum` защищает именно заголовок (не данные), почему на каждом
|
||||
маршрутизаторе TTL уменьшается на 1 и, как следствие, контрольную сумму заголовка приходится
|
||||
пересчитывать заново на каждом хопе, и чем отличается контрольная сумма TCP/UDP (считается с
|
||||
псевдозаголовком) от чисто IP.
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# Задача 05 — длиннейшая возрастающая подпоследовательность (C++)
|
||||
|
||||
## Глава I. Общая информация
|
||||
|
||||
- **Цель:** посчитать длину строго возрастающей подпоследовательности массива за
|
||||
O(n log n), а не за наивные O(n²).
|
||||
- **Почему это в Eltex:** задача — эталон на «знаешь ли ты приём tails + бинарный поиск
|
||||
поверх DP», а сам паттерн «поддерживать отсортированный массив минимально возможных хвостов
|
||||
и подставлять новый элемент через `lower_bound`» переиспользуется в задачах на планирование
|
||||
и в сравнении версий (diff-подобные алгоритмы ищут именно длиннейшие совпадающие/растущие
|
||||
подпоследовательности).
|
||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 05`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
|
||||
Наивное решение — DP: `dp[i]` = длина LIS, заканчивающейся на элементе `i`, пересчитывается
|
||||
перебором всех `j < i` — O(n²), при 100 000 элементах это ~10¹⁰ операций и тест не пройдёт
|
||||
(ограничение — 2 секунды).
|
||||
|
||||
Быстрое решение — «хвосты» (patience sorting): массив `tails`, где `tails[k]` — минимально
|
||||
возможный последний элемент возрастающей подпоследовательности длины `k+1`, накопленной по
|
||||
уже просмотренному префиксу. Массив `tails` всегда отсортирован по построению, поэтому для
|
||||
каждого нового `x` бинарным поиском (`lower_bound`) находится первая позиция с
|
||||
`tails[pos] >= x`: если такая позиция есть — `x` заменяет значение в ней (подпоследовательность
|
||||
той же длины, но с меньшим хвостом — выгоднее для будущих продолжений); если позиции нет —
|
||||
`x` дописывается в конец, увеличивая длину LIS на 1. Именно `lower_bound`, а не `upper_bound` —
|
||||
строгое возрастание требует заменить первый элемент `>= x`, а не `> x` (иначе повторяющиеся
|
||||
значения ошибочно продлевают подпоследовательность).
|
||||
|
||||
Итоговая длина `tails` в конце обработки массива и есть `lis_length`. Значения в `tails` —
|
||||
это не сама LIS, только корректная её длина.
|
||||
|
||||
Почему дальше: тот же приём «поддерживай отсортированный инвариант + `lower_bound`» стоит
|
||||
знать и для задач на минимальное число возрастающих подпоследовательностей на весь массив.
|
||||
|
||||
## Глава III. Задание
|
||||
|
||||
```c++
|
||||
int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей)
|
||||
```
|
||||
@@ -11,4 +46,29 @@ int lis_length(const std::vector<int>& a); // длина НВП (строго
|
||||
- подпоследовательность не обязана быть непрерывной.
|
||||
|
||||
Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений.
|
||||
Разбор после сдачи: «хвосты» — массив минимальных последних элементов + lower_bound.
|
||||
|
||||
## Глава IV. Ловушки
|
||||
|
||||
- `upper_bound` вместо `lower_bound` при строгом возрастании → повторяющиеся значения
|
||||
ошибочно продлевают подпоследовательность → длина LIS завышена на массивах с дубликатами
|
||||
(например, `[1, 1, 1]` должен дать 1, а не 3).
|
||||
- Наивная DP-версия за O(n²) → проходит маленькие тесты, но на 100 000 элементах превышает
|
||||
лимит в 2 секунды → `grade.py 05` роняет прогон по таймауту, а не по неверному ответу.
|
||||
- Не обработан пустой вектор отдельным веткой → если код полагается на то, что цикл по
|
||||
пустому диапазону сам вернёт 0, это обычно верно, но стоит явно проверить — тихая логическая
|
||||
ошибка здесь не кидает исключение, просто даёт неверный ответ на пограничном тесте.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность наивного DP-решения LIS? — O(n²)
|
||||
- core | Сложность решения через `tails` + бинарный поиск? — O(n log n)
|
||||
- core | Что хранит `tails[k]`? — минимально возможный последний элемент возрастающей
|
||||
подпоследовательности длины `k+1`
|
||||
- deep | Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound`? —
|
||||
`lower_bound` находит первый элемент `>= x` и заменяет его, не давая повторам продлевать
|
||||
подпоследовательность
|
||||
|
||||
## После сдачи
|
||||
|
||||
Разбор: почему массив `tails` не является самой LIS, а только хранит её длину через
|
||||
минимальные возможные хвосты; как по `tails` при необходимости восстановить саму
|
||||
подпоследовательность (дополнительный массив предшественников).
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Задача 06 — потокобезопасная ограниченная очередь (C++)
|
||||
|
||||
Классический вопрос на собеседовании в embedded/сетевую разработку: не «знаешь ли ты
|
||||
std::thread», а «умеешь ли ты не сломать счётчик под нагрузкой».
|
||||
На собеседовании это не вопрос «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
|
||||
счётчик под нагрузкой» — то есть понимаешь ли механику мьютекса и условной переменной
|
||||
настолько, чтобы очередь не зависла и не словила гонку данных под ThreadSanitizer.
|
||||
|
||||
## Интерфейс и требования
|
||||
|
||||
```c++
|
||||
class BlockingQueue {
|
||||
@@ -16,12 +19,105 @@ public:
|
||||
```
|
||||
|
||||
Требования:
|
||||
- push после close() бросает `std::runtime_error`;
|
||||
- `push` после `close()` бросает `std::runtime_error`;
|
||||
- закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait);
|
||||
- размер очереди никогда не превышает capacity;
|
||||
- размер очереди никогда не превышает `capacity`;
|
||||
- ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()`
|
||||
вызывается из другого потока — он тоже должен быть потокобезопасным).
|
||||
|
||||
Проверка: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания.
|
||||
Разбор после сдачи: почему нужен predicate-цикл вокруг wait, и как один condition_variable
|
||||
на оба события даёт ложные пробуждения.
|
||||
**Факты для карточек**
|
||||
- base | Каким флагом компилятора включается ThreadSanitizer? — `-fsanitize=thread`
|
||||
- base | Что бросает `push` после `close()`? — `std::runtime_error`
|
||||
- core | Должен ли `size()` быть потокобезопасным в этой задаче? — да, его тоже вызывают из другого потока и он тоже под захватом мьютекса
|
||||
- core | Что происходит с ждущими потоками при `close()`? — все разблокируются (никакого вечного `wait`)
|
||||
|
||||
Почему дальше: раз очередь общая между потоками, нужен механизм, который защищает и её
|
||||
состояние, и само ожидание — мьютекс плюс условная переменная.
|
||||
|
||||
## Механизм: mutex + condition_variable + predicate-цикл
|
||||
|
||||
Общее состояние очереди (буфер, счётчик элементов, флаг `closed`) защищено одним
|
||||
`std::mutex` через RAII-обёртку (`std::lock_guard`/`std::unique_lock`) — так `unlock()`
|
||||
происходит автоматически в деструкторе при выходе из области видимости любым путём,
|
||||
включая исключение, и его невозможно забыть вызвать вручную.
|
||||
|
||||
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток — атомарность
|
||||
важна: если бы освобождение и уход в сон были двумя отдельными шагами, между ними мог бы
|
||||
вклиниться другой поток, изменить состояние и отправить `notify`, которое тогда потеряется,
|
||||
потому что ждущий поток ещё не успел зайти в `wait`. При пробуждении `wait` снова захватывает
|
||||
тот же мьютекс перед тем, как вернуть управление — код после `wait` уже работает под защитой.
|
||||
|
||||
Стандарт C++ прямо разрешает ложные пробуждения (spurious wakeup) — `wait` может вернуться
|
||||
без единого вызова `notify_one`/`notify_all`, просто по решению ОС (futex на Linux иногда
|
||||
пробуждается по внутренним причинам платформы). Из-за этого `if (!ready) cv.wait(lock);`
|
||||
недостаточно: после такого пробуждения предикат мог остаться ложным, и поток пойдёт работать
|
||||
с данными, которые на самом деле не готовы — баг, который не ловится юнит-тестами и стреляет
|
||||
только под конкурентной нагрузкой. Правильный паттерн — цикл с перепроверкой:
|
||||
|
||||
```c++
|
||||
std::unique_lock<std::mutex> lock(mtx_);
|
||||
while (!(size_ > 0 || closed_)) // predicate-цикл, не if
|
||||
not_empty_.wait(lock);
|
||||
```
|
||||
|
||||
Для `push` и `pop` нужны разные условия ожидания (`не полна` и `не пуста` или `закрыта`),
|
||||
поэтому один `condition_variable` на оба события удобен только тогда, когда `notify_all`
|
||||
будит все ждущие потоки и каждый сам перепроверяет свой предикат в цикле — так ложные
|
||||
пробуждения для «не того» условия просто не проходят проверку и поток снова засыпает.
|
||||
Если использовать `notify_one` при одном общем `condition_variable` для двух разных условий,
|
||||
можно разбудить не тот поток (например, разбудить ждущего `push`, когда освободилось место
|
||||
для `pop`), а нужный так и останется спать — отсюда правило: либо `notify_all`, либо два
|
||||
раздельных `condition_variable`.
|
||||
|
||||
**Ловушки**
|
||||
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
|
||||
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
|
||||
- Изменение состояния без удержания мьютекса (например, `size_++` вне `lock_guard`) →
|
||||
гонка данных, UB → ловится ThreadSanitizer как data race при `size()` из другого потока.
|
||||
- `notify_one` при общем `condition_variable` на два разных предиката → не тот поток проснулся,
|
||||
нужный продолжает ждать → зависание, видно по таймауту теста, а не по явной ошибке.
|
||||
- Забыть разбудить всех при `close()` → часть потоков навсегда в `wait` → зависание процесса,
|
||||
тест не завершается за отведённое время.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
|
||||
- core | Почему `while` вокруг `wait`, а не `if`? — из-за spurious wakeup: `wait` может вернуться без вызова notify
|
||||
- core | Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one`? — можно разбудить не тот поток, а нужный останется ждать
|
||||
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — futex
|
||||
|
||||
Почему дальше: раз тест явно требует ThreadSanitizer и отсутствия зависаний, логично понять,
|
||||
что именно проверяет `grade.py` и почему «просто работает на глаз» здесь недостаточно.
|
||||
|
||||
## Проверка
|
||||
|
||||
Запуск: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. TSan
|
||||
инструментирует каждое обращение к памяти и отслеживает happens-before между потоками во
|
||||
время исполнения — то есть ловит гонку по факту конкретного запуска, а не статическим
|
||||
анализом кода, поэтому «прогнать разок и не увидеть краша» не равно «гонки нет»: раскладка
|
||||
потоков в конкретном запуске могла просто не проявить проблему.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Какой командой запускается проверка задачи 06? — `python3 grade.py 06`
|
||||
- core | Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом
|
||||
|
||||
<details>
|
||||
<summary>Проверь себя</summary>
|
||||
|
||||
1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же
|
||||
мьютекс, что защищает очередь, а не произвольный лок?
|
||||
<details><summary>Ответ</summary>Потому что `wait` должен атомарно освободить именно этот
|
||||
мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы
|
||||
предикат под разными блокировками, и защита состояния очереди перестала бы работать.</details>
|
||||
|
||||
2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста?
|
||||
<details><summary>Ответ</summary>Требование — `close()` опустошает остаток: `pop()` должен
|
||||
продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно
|
||||
пуста — отдавать `false`, не уходя в ожидание.</details>
|
||||
|
||||
3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует
|
||||
захвата мьютекса?
|
||||
<details><summary>Ответ</summary>Потому что запись в `size_` из `push`/`pop` без
|
||||
синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает
|
||||
без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».</details>
|
||||
|
||||
</details>
|
||||
|
||||
+106
-4
@@ -4,14 +4,116 @@
|
||||
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
|
||||
завершается по SIGTERM/SIGINT аккуратно (exit 0).
|
||||
|
||||
Требования:
|
||||
## Требования
|
||||
|
||||
- мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента);
|
||||
- сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи;
|
||||
- `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту;
|
||||
- до 64 одновременных соединений, закрытие по EOF со стороны клиента;
|
||||
- никаких утечек дескрипторов (тест проверяет закрытие).
|
||||
|
||||
**Факты для карточек**
|
||||
- base | На каком адресе и с каким флагом сокета должен слушать сервер? — 127.0.0.1, `SO_REUSEADDR`
|
||||
- base | Сколько одновременных соединений сервер должен держать минимум? — 64
|
||||
- core | По какому событию сервер закрывает соединение с клиентом? — по EOF со стороны клиента
|
||||
|
||||
Почему дальше: раз сервер должен обслуживать до 64 соединений одним потоком без блокировки
|
||||
на каждом, нужен механизм, который скажет, какие именно дескрипторы готовы — `epoll`.
|
||||
|
||||
## Механизм: epoll, уровень vs фронт
|
||||
|
||||
`epoll` — мультиплексор ввода-вывода ядра Linux: `epoll_ctl` регистрирует дескрипторы в
|
||||
«списке интереса», который ядро хранит между вызовами; когда состояние дескриптора меняется
|
||||
(пришли данные, освободился буфер записи), ядро само кладёт его в «список готовых»;
|
||||
`epoll_wait` просто возвращает этот список. Отсюда сложность на каждый вызов пропорциональна
|
||||
числу реально готовых дескрипторов, а не общему их числу (в отличие от `select`/`poll`,
|
||||
которые каждый раз заново проходят весь набор).
|
||||
|
||||
У `epoll` два режима уведомления:
|
||||
- **LT (level-triggered, по умолчанию)** — `epoll_wait` возвращает дескриптор снова и снова,
|
||||
пока в его буфере остаются непрочитанные данные (условие «уровня» истинно). Прочитал не
|
||||
всё за один раз — на следующем `epoll_wait` дескриптор опять придёт в списке готовых.
|
||||
- **ET (edge-triggered, флаг `EPOLLET`)** — `epoll_wait` сообщает о готовности только один раз,
|
||||
в момент перехода состояния «не готов → готов» (фронт). Если не вычитать буфер целиком за
|
||||
этот раз, повторного уведомления не будет, пока не придут новые данные (новый фронт).
|
||||
|
||||
Из этого прямо следует требование к сокетам: **ET обязывает читать/писать в цикле до
|
||||
`EAGAIN`**, потому что единственный сигнал готовности уже был использован и другого не будет,
|
||||
пока состояние снова не изменится. А раз цикл идёт до `EAGAIN`, дескриптор обязан быть
|
||||
**неблокирующим** (`O_NONBLOCK`) — на блокирующем сокете последний вызов `read`/`write` в этом
|
||||
цикле, когда данных больше нет, завис бы навсегда вместо того, чтобы вернуть `-1`/`EAGAIN`.
|
||||
При LT неблокирующий режим тоже нужен по той же причине разделения потока между многими
|
||||
клиентами, но не читать «всё до EAGAIN» за раз не так опасно — событие придёт снова.
|
||||
|
||||
`EAGAIN` (синоним `EWOULDBLOCK`) — не ошибка сбоя, а сигнал «сейчас нечего делать, попробуй
|
||||
позже»; `EINTR` — вызов прерван доставкой сигнала (например, при обработке SIGTERM/SIGINT) и
|
||||
его нужно просто повторить, а не трактовать как реальную ошибку. Частичное чтение/запись
|
||||
(`read`/`write` вернул меньше байт, чем просили) — нормальное поведение сокета, а не признак
|
||||
проблемы: буфер ядра ограничен, и остаток нужно дочитывать/дописывать следующим вызовом.
|
||||
|
||||
**Ловушки**
|
||||
- Трактовать `EAGAIN` как ошибку и закрывать соединение → сервер рвёт рабочие соединения
|
||||
просто потому, что в момент проверки данные ещё не пришли → видно как случайные обрывы
|
||||
под нагрузкой, ловится логированием `errno` перед реакцией на ошибку.
|
||||
- Забыть вычитывать буфер в цикле до `EAGAIN` при `EPOLLET` → повторного уведомления не будет,
|
||||
часть данных «зависает» в буфере ядра → клиент не получает остаток эха, тест на эхо большого
|
||||
объёма данных повисает или обрезает ответ.
|
||||
- Не закрывать `fd` при ошибке/отключении клиента → утечка дескрипторов → процесс упирается
|
||||
в лимит `ulimit -n` и получает `EMFILE` на новые `accept`, видно через `lsof -p PID` или
|
||||
`/proc/PID/fd`.
|
||||
- Игнорировать частичную запись (`write` вернул меньше, чем передали) → часть эха теряется
|
||||
без ошибки → видно как обрезанный ответ клиенту при большом объёме данных за одну посылку.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Что означает `EAGAIN` на неблокирующем сокете? — не ошибка, сигнал «данных пока нет, попробуй позже»
|
||||
- core | В чём разница между LT и ET режимами epoll? — LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов»
|
||||
- core | Почему ET требует неблокирующих дескрипторов? — цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет
|
||||
- deep | Какая асимптотика у `epoll_wait` относительно select/poll? — O(1) на готовое событие против O(n) на весь набор у select/poll
|
||||
|
||||
Почему дальше: раз сервер должен «аккуратно завершаться по SIGTERM/SIGINT», нужно понимать,
|
||||
как доставка сигнала прерывает системные вызовы и как это связать с циклом `epoll_wait`.
|
||||
|
||||
## Завершение по сигналу
|
||||
|
||||
Аккуратное завершение (`exit 0`) означает: сервер должен закрыть все сокеты и выйти из цикла
|
||||
`epoll_wait`, а не просто дать процессу упасть от сигнала по умолчанию. Практический механизм —
|
||||
установить обработчик на SIGTERM/SIGINT, который выставляет флаг (`volatile sig_atomic_t`), и
|
||||
проверять этот флаг в цикле после каждого возврата из `epoll_wait` (который сам может вернуться
|
||||
с `-1`/`EINTR` из-за прихода сигнала — это нужно отличать от реальной ошибки и не падать на ней).
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT? — 0
|
||||
- core | Что возвращает `epoll_wait`, если его прервал сигнал? — -1 с `errno == EINTR`, не ошибка выполнения
|
||||
|
||||
## Проверка
|
||||
|
||||
Запускается как самостоятельный бинарник: `./solution <порт>`.
|
||||
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения,
|
||||
льёт данные, проверяет эхо и корректное завершение по SIGTERM.
|
||||
Критерий: все `ok` + ревью кода (использование epoll, обработка ошибок).
|
||||
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, льёт
|
||||
данные, проверяет эхо и корректное завершение по SIGTERM. Критерий: все `ok` + ревью кода
|
||||
(использование epoll, обработка ошибок).
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько соединений открывает тест `grade.py 07`? — 3
|
||||
- base | Каким сигналом тест проверяет корректное завершение сервера? — SIGTERM
|
||||
|
||||
<details>
|
||||
<summary>Проверь себя</summary>
|
||||
|
||||
1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это
|
||||
покрыло все данные, которые были на момент события?
|
||||
<details><summary>Ответ</summary>Потому что ET сообщает о фронте один раз; если между этим
|
||||
чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел,
|
||||
можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт
|
||||
`EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.</details>
|
||||
|
||||
2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
|
||||
<details><summary>Ответ</summary>Требование — до 64 одновременных соединений одним потоком;
|
||||
блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные —
|
||||
отсюда обязательность `epoll` и неблокирующих сокетов.</details>
|
||||
|
||||
3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
|
||||
<details><summary>Ответ</summary>Это не ошибка: буфер сокета ограничен по размеру, ядро
|
||||
приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим
|
||||
вызовом `write`, когда `EPOLLOUT` снова покажет готовность.</details>
|
||||
|
||||
</details>
|
||||
|
||||
+121
-1
@@ -11,7 +11,8 @@ TOP <ip> <число запросов>
|
||||
5XX <число ответов со статусом 500-599>
|
||||
```
|
||||
|
||||
Правила:
|
||||
## Правила формата
|
||||
|
||||
- IP — первое поле строки;
|
||||
- TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию
|
||||
строки IP (лексикографически);
|
||||
@@ -20,6 +21,125 @@ TOP <ip> <число запросов>
|
||||
по времени — нужен awk/sort или один-два прохода;
|
||||
- пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько строк TOP выводится, если уникальных IP меньше трёх? — столько, сколько есть уникальных IP
|
||||
- base | Что печатает скрипт для пустого файла? — `TOTAL 0`, `5XX 0`, без строк TOP
|
||||
- core | По какому полю строки лога определяется IP? — по первому полю
|
||||
|
||||
Почему дальше: раз файл может быть на миллион строк, нужно понять, почему построчный
|
||||
`while read` в bash не укладывается по времени и что вместо этого реально делает работу
|
||||
за O(n) без интерпретации каждой строки внутри самого bash.
|
||||
|
||||
## Механизм: почему не построчный цикл, а awk/sort
|
||||
|
||||
Построчный `while read line; do ... done < file` на миллионе строк запускает bash-интерпретатор
|
||||
заново на каждую итерацию цикла (разбор строки, вызов внешних утилит вроде `cut`/`grep` из
|
||||
цикла добавляет ещё и `fork+exec` процесса на каждую строку) — при миллионе строк это
|
||||
миллион запусков процессов, что на порядки медленнее одного прохода специализированной
|
||||
утилитой. `awk` и `sort` — скомпилированные бинарники, которые сами читают файл целиком
|
||||
за один проход (awk) без порождения процесса на строку, и делают всю агрегацию (подсчёт по
|
||||
IP, подсчёт 5xx, TOTAL) внутри одного вызова.
|
||||
|
||||
Практическая схема: один проход `awk` по файлу одновременно считает `TOTAL` (число строк),
|
||||
инкрементирует счётчик по IP в ассоциативном массиве и считает строки с кодом 500–599
|
||||
(код ответа — обычно отдельное поле в access.log), а на выходе печатает пары `IP count`.
|
||||
Дальше `sort` сортирует эти пары по счётчику по убыванию, а при равенстве — по IP по
|
||||
возрастанию (составной ключ сортировки), и `head -3` берёт первые три строки. Итог — два
|
||||
прохода по данным (awk-агрегация, потом sort уже по маленькому списку уникальных IP, а не
|
||||
по миллиону исходных строк), а не миллион запусков процессов.
|
||||
|
||||
**Факты для карточек**
|
||||
- core | Почему `while read line` в bash медленный на миллионе строк? — каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку
|
||||
- core | Что даёт связка awk + sort вместо построчного цикла? — один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов
|
||||
- deep | По какому полю сортируется список IP при равенстве числа запросов? — по возрастанию строки IP (вторичный ключ сортировки)
|
||||
|
||||
Почему дальше: раз скрипт нетривиальный (несколько шагов, внешние утилиты, граничный случай
|
||||
с пустым файлом), в нём легко замаскировать ошибку — отсюда `set -e` и его реальные границы.
|
||||
|
||||
## `set -e` и где он реально ломается
|
||||
|
||||
`set -e` (`errexit`) останавливает скрипт при первой команде, вернувшей ненулевой код
|
||||
возврата, вместо того чтобы по умолчанию молча идти дальше — это отклонение от исторического
|
||||
поведения bash, оптимизированного под интерактивную сессию, где одна неудачная команда не
|
||||
должна обрывать сессию целиком. `set -o pipefail` отдельно нужен для конвейеров (`awk ... |
|
||||
sort | head`): без него код возврата конвейера — это код возврата только последней команды
|
||||
(`head`), и падение `awk` или `sort` посередине останется незамеченным, даже если `set -e`
|
||||
включён — сам по себе `-e` конвейер как единое целое не покрывает.
|
||||
|
||||
`set -e` **не срабатывает**, если неудачная команда стоит частью условия — в `if`, `while`,
|
||||
`until`, слева или справа от `&&`/`||`, либо после `!` — потому что в этих позициях код
|
||||
возврата команды явно проверяется вызывающим кодом, а не игнорируется, и bash считает это
|
||||
ожидаемым путём выполнения, а не аварией.
|
||||
|
||||
Отдельная и менее очевидная ловушка — **команда в присваивании переменной внутри `local`**:
|
||||
|
||||
```bash
|
||||
local total=$(wc -l < "$file") # опасно под set -e
|
||||
```
|
||||
|
||||
Здесь исполняются фактически две команды: `wc -l` внутри подстановки `$(...)` и сам `local`.
|
||||
Если `wc -l` завершится с ошибкой, `set -e` эту ошибку не поймает, потому что итоговый код
|
||||
возврата всей строки — это код возврата `local`, а `local` возвращает 0 (успех) сам по себе,
|
||||
даже если команда внутри `$(...)` упала. Подстановка «теряется» внутри составной команды.
|
||||
Лечится разделением на две строки:
|
||||
|
||||
```bash
|
||||
total=$(wc -l < "$file") # код возврата — это код возврата wc -l, set -e сработает
|
||||
local total
|
||||
```
|
||||
|
||||
**Ловушки**
|
||||
- Полагаться на голый `set -e` для пайплайна `awk | sort | head` → падение `awk` посередине
|
||||
незаметно, `head` вернёт 0 → нужен `set -o pipefail`, видно только по неверному выводу,
|
||||
не по коду возврата.
|
||||
- `local var=$(cmd)` в одну строку → ошибка `cmd` маскируется кодом возврата `local` (всегда 0
|
||||
для валидного объявления) → `set -e` не остановит скрипт, видно по неверному/пустому `var`
|
||||
без явной ошибки.
|
||||
- Не обработать пустой файл отдельно → `sort`/`head` на пустом входе не печатают строк TOP,
|
||||
но если код по ошибке считает `TOTAL` через конвейер с `wc -l | ...`, легко перепутать
|
||||
0 строк с ошибкой чтения файла.
|
||||
- Не заквотить `"$file"` → путь с пробелом разбивается на несколько слов → скрипт падает
|
||||
на несуществующем файле или обрабатывает не тот аргумент.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Что делает `set -e`? — останавливает скрипт при первой команде с ненулевым кодом возврата
|
||||
- core | В каких позициях `set -e` не останавливает скрипт при ошибке команды? — в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!`
|
||||
- core | Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал? — итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри
|
||||
- core | Зачем `set -o pipefail` отдельно от `set -e`? — без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным
|
||||
|
||||
## Проверка
|
||||
|
||||
Запуск: `bash solution.sh access.log`
|
||||
Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе).
|
||||
Критерий: точное совпадение вывода, код возврата 0.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Каким кодом возврата должен завершаться `solution.sh` при успехе? — 0
|
||||
- core | Что именно сверяет `grade.py 08` с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
|
||||
|
||||
<details>
|
||||
<summary>Проверь себя</summary>
|
||||
|
||||
1. Почему пайплайн `grep 500 access.log | wc -l` под одним `set -e` (без `pipefail`) может
|
||||
молча пропустить ошибку `grep` (например, если файл не существует)?
|
||||
<details><summary>Ответ</summary>Код возврата конвейера по умолчанию — это код возврата
|
||||
последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней
|
||||
упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую
|
||||
упавшую команду.</details>
|
||||
|
||||
2. Почему `local total=$(false)` не остановит скрипт при `set -e`, а `total=$(false)` (без
|
||||
`local` на той же строке) — остановит?
|
||||
<details><summary>Ответ</summary>В первом случае итоговый код возврата строки — это код
|
||||
возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе
|
||||
объявления независимо от результата подстановки внутри; во втором случае код возврата
|
||||
присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e`
|
||||
сработает.</details>
|
||||
|
||||
3. Почему построчный `while read ip status rest; do ...; done < access.log` с миллионом строк
|
||||
не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?
|
||||
<details><summary>Ответ</summary>Каждая итерация цикла — это работа самого интерпретатора
|
||||
bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на
|
||||
порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл
|
||||
целиком за один запуск процесса.</details>
|
||||
|
||||
</details>
|
||||
|
||||
+125
-12
@@ -3,21 +3,134 @@
|
||||
В `crash.c` лежит программа, которая падает на части входов и портит память.
|
||||
Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**.
|
||||
|
||||
Ожидаемое поведение после починки:
|
||||
## Ожидаемое поведение после починки
|
||||
|
||||
- `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0;
|
||||
- `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0;
|
||||
- `./crash ""` печатает `len=0` и завершается с кодом 0;
|
||||
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
|
||||
|
||||
Что сделать:
|
||||
1. Собрать с отладочной информацией и санитайзерами:
|
||||
`gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`
|
||||
2. Разобраться, что именно портит память, а что приводит к падению при выходе.
|
||||
Полезно: `gdb ./crash`, `run`, `bt`, `frame`, `info locals`, `watch`.
|
||||
3. Исправить `crash.c` (минимальные правки, стиль сохранить).
|
||||
4. Заполнить `answer.txt`: сколько дефектов нашёл, какие именно, какими командами
|
||||
отладчика это подтвердил (по шагам), почему падало именно так.
|
||||
**Факты для карточек**
|
||||
- base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0
|
||||
- base | Что печатает `./crash ""` после починки? — `len=0`, код возврата 0
|
||||
- core | Какие два санитайзера должны не давать сообщений после починки? — ASan и UBSan (`-fsanitize=address,undefined`)
|
||||
|
||||
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN
|
||||
и прогоняет 5 входов.
|
||||
`answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а не только результат).
|
||||
Почему дальше: чтобы вообще видеть номера строк и имена переменных в отладчике, а не голый
|
||||
ассемблер, программу нужно собрать определённым образом — с этого и начинается разбор.
|
||||
|
||||
## Сборка с отладочной информацией и санитайзерами
|
||||
|
||||
```
|
||||
gcc -g -O0 -fsanitize=address,undefined crash.c -o crash
|
||||
```
|
||||
|
||||
`-g` встраивает в бинарник отладочную информацию в формате DWARF — таблицу соответствия
|
||||
машинных адресов номерам строк исходника, именам переменных, их типам и границам функций.
|
||||
Именно из DWARF gdb берёт всё, что показывает человеку: `bt` печатает имена функций и строки
|
||||
вместо голых адресов, `print x` знает тип `x` и умеет напечатать его по правилам этого типа
|
||||
(структуру — по полям, массив — по элементам), `list` показывает исходный код вокруг текущей
|
||||
точки. Без `-g` DWARF-секций в бинарнике нет, и gdb видит только адреса и ассемблер.
|
||||
|
||||
`-O0` отключает оптимизации компилятора: оптимизатор переставляет и удаляет инструкции,
|
||||
инлайнит функции и переиспользует один регистр под несколько переменных с непересекающимся
|
||||
временем жизни — из-за этого отладочная информация от `-O2` часто не соответствует
|
||||
однозначно исходному коду (строки «прыгают», переменная в `print` показывает не то значение,
|
||||
потому что регистр уже переиспользован под другую переменную). Отсюда правило: для отладки
|
||||
всегда пересобирают отдельно с `-g -O0`, а не отлаживают прод-сборку с оптимизациями.
|
||||
|
||||
`-fsanitize=address,undefined` — это ASan и UBSan вместе, инструментирование на этапе
|
||||
компиляции, а не отдельный инструмент поверх готового бинарника:
|
||||
- **ASan** окружает каждый выделенный блок памяти недоступными «красными зонами» и ведёт
|
||||
теневую карту состояния памяти — ловит выход за границы буфера, use-after-free и двойное
|
||||
освобождение прямо в момент обращения, с точным стеком, а не когда испорченные данные
|
||||
позже вызовут крах в случайном другом месте;
|
||||
- **UBSan** инструментирует места, где поведение по стандарту C не определено — знаковое
|
||||
переполнение, сдвиг за пределы разрядности типа, разыменование `NULL` с невалидным типом —
|
||||
и печатает конкретную строку кода нарушения.
|
||||
|
||||
**Ловушки**
|
||||
- Собрать без `-g` и пытаться понять крах по голому `bt` с адресами вместо строк → отладка
|
||||
вслепую по ассемблеру, не соответствует задаче «найти минимальными правками».
|
||||
- Отлаживать сборку с `-O2` → строки в `bt`/`print` не совпадают с реальным местом бага
|
||||
из-за инлайнинга и переиспользования регистров → ложный след при поиске дефекта.
|
||||
- Пропустить пересборку с `-fsanitize=address,undefined` перед финальной проверкой → баг,
|
||||
который не падает явным креша при обычном запуске (например, чтение за границей внутри
|
||||
тем же выделенного блока), останется незамеченным до `grade.py`.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Что даёт флаг `-g` при сборке для gdb? — отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных
|
||||
- core | Почему для отладки собирают с `-O0`, а не с `-O2`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику
|
||||
- core | Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение? — все три, в момент обращения к памяти, а не постфактум
|
||||
- deep | Что именно ловит UBSan, в отличие от ASan? — неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью
|
||||
|
||||
Почему дальше: раз отладочная информация уже в бинарнике, нужно знать конкретные команды
|
||||
gdb, которыми по ней ходят при разборе краша.
|
||||
|
||||
## Команды gdb для разбора краша
|
||||
|
||||
Рабочий цикл: `gdb ./crash`, затем `run [аргумент]` — передаёт управление процессу; если
|
||||
процесс получает сигнал (обычно `SIGSEGV` от порчи памяти или abort от санитайзера), gdb сам
|
||||
останавливается на инструкции, вызвавшей сбой.
|
||||
|
||||
- `bt` — стек вызовов, цепочка кадров от текущей функции до `main`; первое, на что смотрят
|
||||
после остановки — показывает, в какой функции и через какие вызовы дошли до краша.
|
||||
- `frame N` — переключает контекст `print`/`list` на конкретный кадр стека из `bt`, чтобы
|
||||
посмотреть локальные переменные вызывающей функции, а не только текущей.
|
||||
- `info locals` — печатает все локальные переменные текущего кадра с их значениями по
|
||||
информации из DWARF.
|
||||
- `watch var` — аппаратная точка наблюдения: останавливает выполнение при каждом изменении
|
||||
значения переменной. Нужна, когда переменная меняется как будто сама по себе из другого
|
||||
места кода (типичный симптом переполнения буфера, которое затирает соседнюю память) — без
|
||||
`watch` пришлось бы расставлять точки останова по подозрению вручную.
|
||||
- `print x` — печатает текущее значение `x`, по типу из DWARF (структуру — по полям).
|
||||
|
||||
Санитайзер добавляет отдельный слой: при нарушении (ASan/UBSan) программа останавливается
|
||||
сама, печатая стек и описание нарушения ещё до того, как повреждение памяти успело привести
|
||||
к крашу в другом, не связанном с причиной месте — это часто быстрее, чем ловить последствия
|
||||
вручную в gdb по голому `SIGSEGV`.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Какая команда gdb показывает цепочку вызовов до краша? — `bt`
|
||||
- core | Чем `watch var` отличается от обычного `break`? — останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода
|
||||
- core | Зачем нужен `frame N` после `bt`? — переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции
|
||||
|
||||
## Отчёт и проверка
|
||||
|
||||
Исправить `crash.c` (минимальные правки, стиль сохранить). Заполнить `answer.txt`: сколько
|
||||
дефектов нашёл, какие именно, какими командами отладчика это подтвердил (по шагам), почему
|
||||
падало именно так.
|
||||
|
||||
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN и
|
||||
прогоняет 5 входов. `answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а
|
||||
не только результат).
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько входов прогоняет `grade.py 09`? — 5
|
||||
- base | Каким компилятором собирается `crash.c` на проверке? — gcc (компилятор C)
|
||||
|
||||
<details>
|
||||
<summary>Проверь себя</summary>
|
||||
|
||||
1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с `-O2` для
|
||||
финальной проверки производительности?
|
||||
<details><summary>Ответ</summary>Оптимизатор переставляет и удаляет инструкции, инлайнит
|
||||
вызовы и переиспользует регистры под разные переменные — отладочная информация от такой
|
||||
сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или
|
||||
неверные значения; для отладки нужна отдельная сборка `-g -O0`.</details>
|
||||
|
||||
2. Программа падает с `SIGSEGV` без санитайзеров, но при сборке с ASan вместо краша печатается
|
||||
сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?
|
||||
<details><summary>Ответ</summary>ASan останавливает программу в момент фактического выхода
|
||||
за границу буфера — в точке причины; без ASan повреждённая память могла не привести к
|
||||
немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные
|
||||
использовались уже в другом месте — это типичная картина «краш далеко от причины».</details>
|
||||
|
||||
3. Чем `watch var` полезнее обычной точки останова (`break`) для поиска места, где переменная
|
||||
портится неожиданно?
|
||||
<details><summary>Ответ</summary>`break` останавливает выполнение в заданной строке кода,
|
||||
но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения,
|
||||
которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже
|
||||
если изменение происходит из неожиданного места (например, из-за записи за границей другого
|
||||
буфера, затирающей соседнюю память).</details>
|
||||
|
||||
</details>
|
||||
|
||||
+109
-23
@@ -14,17 +14,52 @@
|
||||
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
|
||||
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`.
|
||||
|
||||
## Глава II. Что нужно знать до старта
|
||||
**Факты для карточек**
|
||||
- base | Сколько времени закладывается на задачу 10? — 1,5–2,5 часа со ступенями
|
||||
- base | Какой командой проверяется решение? — `python3 grade.py 10`
|
||||
- deep | Где в реальных устройствах используется похожая структура? — таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти
|
||||
|
||||
1. `idx = hash & (cap - 1)` работает как «остаток от деления», только если `cap` — степень
|
||||
двойки. Пример: `cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`.
|
||||
2. Линейное зондирование: если ячейка занята, пробуем `(i+1) & (cap-1)`, потом `(i+2) & …`
|
||||
и так по кругу, пока не найдём свободную (для вставки) или нужный ключ (для поиска).
|
||||
3. Удалять «в ноль» нельзя: если между началом зондирования и элементом появится пустая
|
||||
ячейка, поиск до него не дойдёт. Поэтому пустая ячейка помечается **tombstone** —
|
||||
«здесь был элемент, иди дальше».
|
||||
4. Фактор загрузки `load = size / capacity`. Держим ≤ 0.7: при 0.8+ пробеги становятся
|
||||
длинными, и O(1) превращается в O(n).
|
||||
Почему дальше: прежде чем писать код, нужно понять, почему индекс считается через `&`,
|
||||
а не через `%`, и что физически происходит при коллизии.
|
||||
|
||||
## Глава II. Механизм: индекс, зондирование, tombstone, load factor
|
||||
|
||||
**Индекс через маску.** `idx = hash & (cap - 1)` работает как «остаток от деления», только
|
||||
если `cap` — степень двойки. Причина: у степени двойки `cap - 1` в двоичном виде — это
|
||||
подряд идущие единицы во всех младших битах (например, `cap = 8 = 0b1000` → `cap-1 = 0b0111`).
|
||||
Операция `AND` с такой маской обнуляет все биты хеша выше этой границы и оставляет ровно
|
||||
младшие `log2(cap)` бит — а это математически то же самое, что `hash mod cap`. Пример:
|
||||
`cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`.
|
||||
`%` работает для любой ёмкости, но требует инструкции деления (на некоторых платформах
|
||||
дороже, чем сдвиг/AND); отсюда практика ограничивать ёмкость степенью двойки и считать
|
||||
индекс битовой маской.
|
||||
|
||||
**Линейное зондирование.** Если ячейка `idx` занята, пробуем `(idx+1) & (cap-1)`, потом
|
||||
`(idx+2) & (cap-1)` и так по кругу, пока не найдём свободную ячейку (для вставки) или
|
||||
нужный ключ (для поиска). Механически это обход массива по кругу начиная с `idx`.
|
||||
|
||||
**Tombstone.** Удалять «в ноль» (ставить `EMPTY`) нельзя: если между началом зондирования
|
||||
и искомым элементом появится пустая ячейка, поиск остановится на ней раньше, чем дойдёт до
|
||||
элемента — элемент физически на месте, но недостижим для `get`. Поэтому удалённая ячейка
|
||||
помечается **tombstone** — «здесь был элемент, поиск должен идти дальше», но **вставка**
|
||||
может переиспользовать tombstone-ячейку под новый ключ.
|
||||
|
||||
**Load factor и длина пробега.** `load = size / capacity`. По формуле среднего числа проб
|
||||
при линейном зондировании (Кнут): удачный поиск — `0.5·(1 + 1/(1-load))` проб,
|
||||
неудачный — `0.5·(1 + 1/(1-load)²)`. При `load = 0.7`: удачный поиск ≈ 2,2 пробы,
|
||||
неудачный ≈ 6,1. При `load = 0.9`: удачный ≈ 5,5, неудачный ≈ 50,5 — рост нелинейный,
|
||||
именно поэтому порог держат на 0.7, а не поднимают его «для экономии памяти».
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Почему `hash & (cap-1)` эквивалентно `hash % cap`? — только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления
|
||||
- base | Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс? — `19 & 7 = 3`
|
||||
- core | Среднее число проб при удачном поиске, load factor 0.7? — ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`)
|
||||
- core | То же при load factor 0.9? — ≈5,5 — рост почти в 2,5 раза от значения при 0.7
|
||||
- deep | Среднее число проб при НЕудачном поиске, load factor 0.9? — ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска
|
||||
- core | Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей? — поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте
|
||||
|
||||
Почему дальше: механизм понятен — теперь нужен точный интерфейс и числа, по которым
|
||||
`grade.py` проверяет решение.
|
||||
|
||||
## Глава III. Задание
|
||||
|
||||
@@ -55,6 +90,12 @@ public:
|
||||
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
|
||||
и вставки возвращается к правильному значению.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько ключей вставляет тест на кластеризацию и какие они? — 512 ключей, кратных 16
|
||||
- base | За какое время должны пройти 100 000 вставок? — быстрее 2 секунд
|
||||
- base | Какой порог load factor держит таблица? — ≤ 0.7
|
||||
- base | Минимальная ёмкость таблицы по умолчанию? — 16, степень двойки
|
||||
|
||||
## Глава IV. Ступени (делай по одной, после каждой — прогон)
|
||||
|
||||
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
|
||||
@@ -63,7 +104,10 @@ public:
|
||||
**Ступень 1. Хеш-функция и индекс (20 минут).**
|
||||
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей
|
||||
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
|
||||
`h = (uint64_t)key * 2654435761u;` (это золотое сечение, классика).
|
||||
`h = (uint64_t)key * 2654435761u;` (это `2^32 / φ` при золотом сечении `φ ≈ 1.618`,
|
||||
классика — умножение на нечётную константу обратимо по модулю `2^32` и «размазывает»
|
||||
младшие биты ключа по всей ширине результата, поэтому соседние по младшим битам ключи
|
||||
после умножения расходятся по разным индексам).
|
||||
Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
|
||||
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
|
||||
свалятся в одну ячейку.
|
||||
@@ -87,9 +131,11 @@ Tombstone можно переиспользовать под вставку, н
|
||||
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
|
||||
|
||||
**Ступень 6. Перехеширование (30 минут).**
|
||||
Когда `size * 10 > capacity * 7`, создай массив вдвое больше и **заново вставь все занятые
|
||||
ключи** (tombstone не переносим — в новой таблице пусто). Учти: после этого `size` не должен
|
||||
измениться, а пробеги станут короче. Прогон: `python3 grade.py 10`.
|
||||
Когда `size * 10 > capacity * 7` (то есть `load > 0.7`), создай массив вдвое больше и
|
||||
**заново вставь все занятые ключи** (tombstone не переносим — в новой таблице пусто).
|
||||
Учти: после этого `size` не должен измениться, а пробеги станут короче (см. формулу проб
|
||||
из главы II — вдвое большая ёмкость при том же `size` резко снижает `load`, а с ним и
|
||||
среднее число проб). Прогон: `python3 grade.py 10`.
|
||||
|
||||
**Ступень 7. Прогон под санитайзерами (10 минут).**
|
||||
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
|
||||
@@ -133,16 +179,56 @@ Tombstone можно переиспользовать под вставку, н
|
||||
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
|
||||
</details>
|
||||
|
||||
## Глава VII. Частые ошибки и как их не допустить
|
||||
## Глава VII. Ловушки
|
||||
|
||||
- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая.
|
||||
- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на
|
||||
медленных платформах деление дороже.
|
||||
- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место.
|
||||
- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше
|
||||
держать их в одной структуре ячейки.
|
||||
- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги
|
||||
огромны, время уходит за лимит 2 секунды.
|
||||
- Забыть перемешать хеш (использовать `key` напрямую как индекс) → ключи, кратные 16,
|
||||
все попадают в одну-две ячейки → кластеризация, пробег растёт линейно с числом таких
|
||||
ключей → тест на 512 ключей, кратных 16, падает по таймауту или по «not found».
|
||||
- Забыть про tombstone при rehash (перенести признак `TOMBSTONE` в новую таблицу вместо
|
||||
того, чтобы его отбросить) → «мёртвые» ячейки переезжают и занимают место в новой
|
||||
таблице без необходимости → эффективная ёмкость меньше заявленной, load factor растёт
|
||||
быстрее ожидаемого.
|
||||
- Хранить ключ отдельно от значения в параллельных массивах и перепутать индекс при
|
||||
перестановке → ASAN поймает не сразу, а в момент обращения к рассинхронизированной
|
||||
ячейке; надёжнее держать ключ и значение в одной структуре ячейки.
|
||||
- Проверять `size == capacity` вместо load factor (`size * 10 > capacity * 7`) → таблица
|
||||
почти полностью заполняется, пробеги становятся длиной в десятки-сотни ячеек (см. формулу
|
||||
неудачного поиска при load → 1), суммарное время `put` уходит за лимит 2 секунды на
|
||||
100 000 ключей.
|
||||
|
||||
## Проверь себя
|
||||
|
||||
<details>
|
||||
<summary>1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы?</summary>
|
||||
`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)`
|
||||
перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой,
|
||||
а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при
|
||||
котором работает битовая маска вместо деления.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor
|
||||
вырастет с 0.7 до 0.9?</summary>
|
||||
Примерно в 2,5 раза: `0.5·(1+1/(1-0.7)) ≈ 2,2` против `0.5·(1+1/(1-0.9)) ≈ 5,5`.
|
||||
Для неудачного поиска рост ещё резче — с ≈6,1 до ≈50,5, то есть почти в 8 раз.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже
|
||||
такой ключ?</summary>
|
||||
`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если
|
||||
инкрементировать `size` без предварительного поиска ключа по цепочке зондирования,
|
||||
повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт
|
||||
элементов упадёт.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании?</summary>
|
||||
В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только
|
||||
реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы
|
||||
место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами
|
||||
цепочки проб после rehash пересчитываются заново.
|
||||
</details>
|
||||
|
||||
## После сдачи
|
||||
|
||||
|
||||
+141
-1
@@ -1,6 +1,7 @@
|
||||
# Задача 11 — куча: построение за O(n) и top-K (C++)
|
||||
|
||||
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`), без `std::priority_queue`.
|
||||
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`),
|
||||
без `std::priority_queue`.
|
||||
|
||||
```c++
|
||||
void heapify(std::vector<int>& a); // перестроить массив в кучу
|
||||
@@ -20,5 +21,144 @@ std::vector<int> top_k(const std::vector<int>& a, int k); // k наибо
|
||||
Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений,
|
||||
ASAN/UBSAN чистые.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Индексы детей и родителя в max-heap на массиве? — дети `2i+1`, `2i+2`; родитель `(i-1)/2`
|
||||
- base | Что за 2 секунды должны выполниться на 3 000 000 элементов? — `heapify` и `kth_largest` с `k=1000` (по отдельности)
|
||||
- base | Какой ключевой запрет на реализацию `heapify`? — нельзя строить через `push` в цикле, только bottom-up за O(n)
|
||||
|
||||
Почему дальше: массив интерпретируется как дерево через индексную арифметику — дальше
|
||||
нужно понять, как именно поддерживается инвариант кучи и почему bottom-up быстрее push-цикла.
|
||||
|
||||
## Механизм: массив как дерево, sift-down/sift-up
|
||||
|
||||
Массив хранится линейно (`std::vector<int>`), но интерпретируется как полное бинарное
|
||||
дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без
|
||||
указателей. Инвариант max-heap: значение в родителе не меньше значений в обоих детях.
|
||||
|
||||
**sift-up** (используется при вставке одного элемента): новый элемент кладётся в конец
|
||||
массива и сравнивается с родителем; пока он больше родителя — меняются местами, индекс
|
||||
сдвигается к корню. Длина пути от листа до корня — высота дерева, `O(log n)`.
|
||||
|
||||
**sift-down** (используется при извлечении максимума и при bottom-up heapify): элемент в
|
||||
корне (или в произвольном узле) сравнивается с обоими детьми; если хотя бы один ребёнок
|
||||
больше — меняется местами с большим из них, и процесс повторяется на новой позиции, пока
|
||||
инвариант не восстановится или узел не станет листом. Тоже `O(log n)` на один вызов от
|
||||
корня.
|
||||
|
||||
**top()** — доступ к максимуму — `O(1)`: по инварианту кучи максимум всегда лежит в корне,
|
||||
то есть в начале массива, никакого поиска не требуется.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность доступа к максимуму (`top`)? — O(1), максимум всегда в корне по инварианту кучи
|
||||
- base | Сложность одного `sift-up`/`sift-down` от произвольного узла? — O(log n) — путь ограничен высотой дерева
|
||||
- core | Что сравнивается на каждом шаге `sift-down`? — узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла
|
||||
|
||||
## Механизм: почему bottom-up heapify — O(n), а push-цикл — O(n log n)
|
||||
|
||||
**Через push в цикле.** Вставка i-го элемента (при текущем размере кучи `i`) требует до
|
||||
`O(log i)` шагов `sift-up`. Суммарная работа — `Σ log₂(i)` для `i = 1..n`, это `log₂(n!)`,
|
||||
по формуле Стирлинга порядка `n·log₂(n)`. Итого `O(n log n)` — почти вся вставленная масса
|
||||
элементов всплывает от листьев, где высота дерева максимальна (`~log n`), к своему месту.
|
||||
|
||||
**Через bottom-up heapify.** Алгоритм стартует с последнего нелистового узла (индекс
|
||||
`n/2 - 1`) и идёт к корню (индекс `0`), вызывая `sift-down` на каждом узле. Ключевое
|
||||
отличие: работа `sift-down` в узле пропорциональна **высоте этого узла `h`**, а не высоте
|
||||
всего дерева. Узлов с большой высотой мало (в корне — один узел высоты `log n`), а узлов с
|
||||
малой высотой — экспоненциально больше (листьев высоты 0 — примерно `n/2`, они вообще
|
||||
пропускаются). Суммарная работа:
|
||||
|
||||
```
|
||||
Σ h=0..log n (n / 2^(h+1)) · h
|
||||
```
|
||||
|
||||
Ряд `Σ h/2^h` при `h → ∞` сходится к константе `2` (не растёт с `n`), поэтому вся сумма
|
||||
ограничена `n · const = O(n)` — линейно, а не `n log n`. Для `n = 3 000 000`
|
||||
(`log₂ n ≈ 21,5`) это даёт примерно на порядок (~10 раз) меньше операций сравнения, чем
|
||||
push-цикл — именно поэтому требование задачи «bottom-up, а не push» не формальность, а
|
||||
разница на порядок при 3 миллионах элементов.
|
||||
|
||||
**Факты для карточек**
|
||||
- core | За какое время строится куча через n последовательных `push`? — O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n`
|
||||
- core | За какое время строится куча через bottom-up heapify? — O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n`
|
||||
- deep | С какого индекса начинается bottom-up heapify и в какую сторону идёт? — с последнего нелистового узла `n/2 - 1`, к корню (индекс 0)
|
||||
- core | Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000? — примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up
|
||||
|
||||
Почему дальше: сама куча даёт максимум за O(1) и перестройку за O(log n) — на этом строятся
|
||||
`kth_largest` и `top_k`, но у них есть более эффективная альтернатива для потоковых данных.
|
||||
|
||||
## kth_largest и top_k
|
||||
|
||||
Базовый подход: `heapify` за `O(n)`, затем `k` раз `pop` (извлечь максимум, `sift-down`
|
||||
корня) — каждый `pop` стоит `O(log n)`. Итого `O(n + k log n)`. При `k = 1000` и
|
||||
`n = 3 000 000` слагаемое `n` доминирует — отсюда требование «быстрее 2 секунд» выполнимо
|
||||
за счёт того, что сама куча строится линейно.
|
||||
|
||||
Граничные случаи по условию: `k > n` — `top_k` отдаёт все элементы по убыванию, а
|
||||
`kth_largest` — значение минимального элемента, без падения и без обращения за границу
|
||||
массива.
|
||||
|
||||
**Потоковый top-K.** Когда данные не помещаются в память целиком (поток), вместо
|
||||
построения полной кучи на все `n` элементов держат **min-heap размера k**: каждый новый
|
||||
элемент сравнивается с минимумом кучи (`top`), и если новый элемент больше — минимум
|
||||
удаляется, новый добавляется. Сложность — `O(n log k)` вместо `O(n log n)` для полной
|
||||
сортировки, и память — `O(k)`, а не `O(n)`.
|
||||
|
||||
**nth_element vs куча.** `std::nth_element` (Хоара, quickselect) находит элемент на нужной
|
||||
позиции и частично упорядочивает массив вокруг него (всё левее — не больше него, всё правее
|
||||
— не меньше, но без порядка внутри частей) в среднем за `O(n)` — стандарт требует линейного
|
||||
времени в среднем, но не гарантирует худший случай, в отличие от кучи, которая всегда даёт
|
||||
`O(n + k log n)`. Для одного k-го элемента `nth_element` быстрее кучи по константе; для
|
||||
top-K как **отсортированного** списка кучу всё равно придётся досортировать после
|
||||
`nth_element`, тогда как `pop` из кучи уже отдаёт элементы по убыванию.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность `kth_largest`/`top_k` через полную кучу? — O(n + k log n): O(n) на heapify плюс k раз pop по O(log n)
|
||||
- core | Когда используют min-heap размера k вместо полной кучи на весь массив? — на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k)
|
||||
- core | Чем `std::nth_element` отличается от кучи для top-K? — в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop
|
||||
|
||||
## Ловушки
|
||||
|
||||
- Построить кучу через `push` в цикле вместо bottom-up `heapify` → машинная проверка
|
||||
свойств кучи пройдёт (инвариант тот же), но сложность — O(n log n) вместо O(n) → на
|
||||
3 000 000 элементов заметно медленнее и явно видно по коду при ревью (это как раз спросят
|
||||
на собеседовании отдельным вопросом).
|
||||
- Потерять или продублировать элементы при перестройке `sift-down` (например, скопировать
|
||||
значение вместо обмена местами) → мультимножество элементов меняется → видно сравнением
|
||||
отсортированной копии массива до и после `heapify`.
|
||||
- Не ограничить `k` при `k > n` → обращение к `pop` на пустой куче или выход за границу
|
||||
массива → падение или UB, видно по ASAN, а не просто неверный ответ.
|
||||
|
||||
## Проверь себя
|
||||
|
||||
<details>
|
||||
<summary>1. Почему bottom-up heapify — O(n), хотя высота дерева log n?</summary>
|
||||
Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте
|
||||
всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты
|
||||
`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится
|
||||
к `n`, умноженному на константу, а не на `log n`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения,
|
||||
чем bottom-up heapify?</summary>
|
||||
Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up
|
||||
heapify) — то есть на порядок больше сравнений.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3. Почему `top()` кучи — O(1), а не O(log n)?</summary>
|
||||
По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое
|
||||
обращение по индексу `0`, без поиска и без перестройки структуры.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь
|
||||
массив?</summary>
|
||||
Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что
|
||||
не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k
|
||||
текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и
|
||||
заменяет его за O(log k) — суммарно O(n log k) и память O(k).
|
||||
</details>
|
||||
|
||||
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
|
||||
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.
|
||||
|
||||
@@ -14,8 +14,134 @@ long long shortest_path(int n,
|
||||
- 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue`
|
||||
(O((V+E) log V)), а не O(V²) перебор минимума.
|
||||
|
||||
Подсказки по разбору (после сдачи): «ленивое» удаление устаревших записей из очереди
|
||||
(`if (d != dist[v]) continue;`), почему нельзя Дейкстру с отрицательными рёбрами и когда
|
||||
нужен Беллман–Форд.
|
||||
|
||||
Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Что возвращает функция при `src == dst`? — 0
|
||||
- base | Что возвращает функция при недостижимости `dst`? — -1
|
||||
- base | В каком типе суммируются веса и почему не `int`? — `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути
|
||||
|
||||
Почему дальше: чтобы уложиться в 2 секунды на 100 000 вершин и 200 000 рёбер, важно понять,
|
||||
почему наивный перебор минимума не подходит и что именно даёт куча.
|
||||
|
||||
## Механизм: куча против наивного перебора
|
||||
|
||||
**Наивная Дейкстра.** На каждом из `V` шагов алгоритм сканирует массив `dist[]` целиком,
|
||||
чтобы найти непосещённую вершину с минимальным расстоянием — это `O(V)` на шаг. Суммарно
|
||||
`V` шагов по `O(V)` — `O(V²)`, плюс `O(E)` на релаксацию рёбер (эта часть не доминирует).
|
||||
Подходит, если граф плотный (`E ~ V²`), но не в этой задаче.
|
||||
|
||||
**Дейкстра с priority_queue.** Вместо линейного скана используется min-куча по текущему
|
||||
известному расстоянию. При релаксации ребра `(u, v, w)`, если найден более короткий путь до
|
||||
`v`, в кучу **добавляется новая запись** `(dist, v)` — `push` стоит `O(log размер_кучи)`.
|
||||
Так как для одной вершины может накопиться несколько записей (по одной на каждое улучшение
|
||||
расстояния), размер кучи ограничен числом релаксаций, то есть `O(V + E)`, и `log` от этой
|
||||
величины по порядку — тот же `O(log V)` (`log(V²) = 2 log V`, то есть смена основания
|
||||
не меняет асимптотику). Суммарно: `O((V + E) log V)`.
|
||||
|
||||
**Числа задачи.** `V = 100 000`, `E = 200 000`. Наивный `O(V²) = 100 000² = 10^10` операций
|
||||
— в 2 секунды не укладывается ни при каких обстоятельствах. Куча: `(V+E)·log₂V ≈
|
||||
300 000 · 16,6 ≈ 5·10^6` операций — на 3–4 порядка меньше, укладывается с большим запасом.
|
||||
|
||||
**«Ленивое» удаление устаревших записей.** `std::priority_queue` не умеет `decrease-key`
|
||||
за `O(log n)` (не тот интерфейс), поэтому вместо обновления существующей записи в куче
|
||||
для вершины `v` просто добавляется новая пара `(dist, v)` при каждом улучшении. В куче
|
||||
одновременно может лежать несколько устаревших записей для одной вершины. При извлечении
|
||||
самой записи с минимальным `dist` проверяется, актуальна ли она:
|
||||
|
||||
```c++
|
||||
auto [d, v] = pq.top(); pq.pop();
|
||||
if (d != dist[v]) continue; // запись устарела — лучшая уже обработана раньше
|
||||
```
|
||||
|
||||
Это дешевле, чем поддерживать структуру с настоящим `decrease-key` (например, indexed
|
||||
heap), и корректность не страдает — устаревшая запись всегда хуже уже найденного `dist[v]`
|
||||
и просто пропускается.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сложность наивной Дейкстры (перебор минимума по массиву)? — O(V²)
|
||||
- base | Сложность Дейкстры с бинарной кучей? — O((V+E) log V)
|
||||
- core | При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды? — 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка
|
||||
- core | Что означает «ленивое удаление» устаревших записей в очереди? — вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая
|
||||
- deep | Почему `std::priority_queue` не используют с decrease-key напрямую? — у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop
|
||||
|
||||
Почему дальше: скорость решена кучей, но у Дейкстры есть жёсткое условие корректности —
|
||||
неотрицательные веса. Нужно понять механизм, почему это условие обязательно.
|
||||
|
||||
## Механизм: почему нужны только неотрицательные веса
|
||||
|
||||
Дейкстра — жадный алгоритм: когда вершина `v` извлекается из очереди с минимальным на
|
||||
данный момент расстоянием, алгоритм считает `dist[v]` **окончательным** и больше не
|
||||
пересматривает эту вершину. Это верно только если все ещё не обработанные пути до `v` не
|
||||
могут оказаться короче — а это гарантировано лишь при неотрицательных весах: любой другой
|
||||
путь до `v` проходит через вершины с расстоянием `≥ dist[v]` и добавляет ребро `≥ 0`, то
|
||||
есть не может дать сумму меньше `dist[v]`.
|
||||
|
||||
Если в графе есть отрицательное ребро, эта гарантия ломается: путь через уже
|
||||
«финализированную» вершину может позже пройти по отрицательному ребру и дать меньшую
|
||||
сумму, но алгоритм эту вершину уже не пересматривает — результат будет неверным без явного
|
||||
падения или ошибки, то есть тихо неправильным.
|
||||
|
||||
Для графов с отрицательными весами (без отрицательных циклов) используется
|
||||
**Беллман-Форд**: `V-1` раз релаксируются все `E` рёбер, `O(V·E)`. Дополнительно на `V`-м
|
||||
проходе можно проверить, продолжает ли что-то релаксироваться — если да, в графе
|
||||
отрицательный цикл, кратчайший путь не определён (можно уменьшать бесконечно).
|
||||
|
||||
Self-loop с весом `w ≥ 0` никогда не уменьшает кратчайший путь (добавление неотрицательного
|
||||
веса к текущему расстоянию не улучшает его), поэтому не требует отдельной обработки —
|
||||
алгоритм просто никогда не выберет такое ребро для релаксации выгодно.
|
||||
|
||||
**Факты для карточек**
|
||||
- core | Почему Дейкстра ломается на отрицательных рёбрах? — алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается
|
||||
- base | Какой алгоритм нужен при отрицательных весах без отрицательных циклов? — Беллман-Форд, O(V·E)
|
||||
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл
|
||||
- base | Почему self-loop с w≥0 не требует отдельной обработки? — добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию
|
||||
|
||||
## Ловушки
|
||||
|
||||
- Использовать `int` вместо `long long` для накопленной суммы весов → при весах рёбер до
|
||||
10^9 и длинном пути сумма переполняет `int` → тихо неверный (отрицательный или
|
||||
«случайный») результат без явного падения.
|
||||
- Забыть проверку `if (d != dist[v]) continue` при извлечении из очереди → обрабатываются
|
||||
устаревшие записи повторно → не влияет на корректность, но раздувает число операций и
|
||||
на 200 000 рёбер может вывести время за лимит 2 секунды.
|
||||
- Запустить алгоритм на графе с отрицательным ребром без проверки условия применимости →
|
||||
результат тихо неверный (нет явной ошибки времени выполнения) — Дейкстра не обнаруживает
|
||||
нарушение своего предположения сама.
|
||||
- Перепутать направление рёбер (граф ориентированный) и релаксировать в обе стороны →
|
||||
находится путь, которого нет в графе условия задачи, `shortest_path` возвращает заниженное
|
||||
значение.
|
||||
|
||||
## Проверь себя
|
||||
|
||||
<details>
|
||||
<summary>1. Почему при V=100 000 и E=200 000 наивный O(V²) не проходит по времени, а вариант
|
||||
с кучей — проходит?</summary>
|
||||
`O(V²) = 100 000² = 10^10` операций — на 3–4 порядка больше, чем позволяют 2 секунды.
|
||||
Вариант с кучей — `O((V+E) log V) ≈ 300 000 · log₂(100 000) ≈ 300 000 · 16,6 ≈ 5·10^6`
|
||||
операций, укладывается с большим запасом.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. Что произойдёт с результатом Дейкстры, если в графе есть ребро веса -5, а
|
||||
остальные рёбра положительные?</summary>
|
||||
Результат может быть неверным без явной ошибки: если вершина на дешёвом с виду пути уже
|
||||
извлечена из очереди и «финализирована», а позже к ней ведёт более короткий путь через
|
||||
ребро -5, алгоритм это улучшение не увидит, так как вершина повторно не пересматривается.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3. Почему в очереди могут одновременно лежать несколько записей для одной и той же
|
||||
вершины, и почему это не ошибка?</summary>
|
||||
Каждое найденное улучшение расстояния до вершины добавляет новую запись `(dist, v)` в кучу
|
||||
вместо обновления старой (`decrease-key` не поддерживается `std::priority_queue`). Это не
|
||||
ошибка, потому что при извлечении устаревшая запись (`d != dist[v]`) просто пропускается —
|
||||
корректность сохраняется, платится только лишней памятью в очереди и лишним `pop`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4. Почему self-loop с весом w≥0 никогда не меняет кратчайший путь?</summary>
|
||||
Self-loop добавляет вершине путь до самой себя длиной `w ≥ 0`. Поскольку путь длины 0 (не
|
||||
двигаться) уже не хуже, прибавление неотрицательного веса к текущему `dist[v]` не может дать
|
||||
меньшее значение — релаксация через такое ребро никогда не проходит условие «короче».
|
||||
</details>
|
||||
|
||||
@@ -32,5 +32,121 @@ int prefix_for_hosts(int hosts); // самая узкая п
|
||||
|
||||
Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Сколько узлов даёт /24? — 254
|
||||
- base | Сколько узлов даёт /26? — 62
|
||||
- base | Сколько узлов даёт /30? — 2
|
||||
- base | Сеть, broadcast и диапазон узлов для `10.0.1.130/26`? — сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190`
|
||||
|
||||
Почему дальше: чтобы получить эти числа программно, а не подбором, нужен механизм вычисления
|
||||
маски, границ сети и обратной задачи — подбора префикса по числу узлов.
|
||||
|
||||
## Механизм: маска, границы сети, обратный подбор префикса
|
||||
|
||||
**Маска по префиксу.** Маска — это `prefix` единиц в старших битах и `32-prefix` нулей в
|
||||
младших: `mask = 0xFFFFFFFFu << (32 - prefix)` для `prefix > 0`. Для `prefix == 0` формула
|
||||
не годится напрямую — сдвиг на 32 бита для 32-битного типа в C/C++ является неопределённым
|
||||
поведением (UB), поэтому этот случай обрабатывается отдельно: `mask = 0`.
|
||||
|
||||
**Границы сети.** `network = ip & mask` — обнуляет все биты хостовой части (сохраняет
|
||||
только биты сети). `broadcast = network | ~mask` — выставляет все биты хостовой части в
|
||||
единицу (`~mask` — это как раз маска хостовой части, инвертированная сетевая). Число бит,
|
||||
отданных под узлы, — `32 - prefix`; всего адресов в блоке — `2^(32-prefix)`.
|
||||
|
||||
**Разбор эталонного примера `10.0.1.130/26`.** `/26` оставляет `32-26=6` бит под узлы,
|
||||
блок из `2^6 = 64` адресов. Адрес `.130` попадает в блок `128..191` (границы блоков кратны
|
||||
64): `network = 10.0.1.128`, `broadcast = 10.0.1.191`. Обычные узлы — `first_host =
|
||||
network+1 = .129`, `last_host = broadcast-1 = .190`. Из 64 адресов блока минус 2 служебных
|
||||
(сеть и broadcast) — 62 узла, что совпадает с `/26 → 62 узла`.
|
||||
|
||||
**Почему `/26` даёт именно 62 узла.** `host_count = 2^(32-26) - 2 = 2^6 - 2 = 64 - 2 = 62`.
|
||||
Аналогично `/24`: `2^8 - 2 = 254`; `/30`: `2^2 - 2 = 2` — ровно минимум для соединения
|
||||
точка-точка между двумя маршрутизаторами.
|
||||
|
||||
**`/31` — исключение (RFC 3021).** По общей формуле `/31` дал бы `2^1 - 2 = 0` узлов —
|
||||
бессмысленный результат для блока из 2 адресов. RFC 3021 разрешает для каналов
|
||||
точка-точка (ровно 2 узла на линке) отдавать под хосты оба адреса блока: широковещательный
|
||||
адрес такому каналу физически не нужен (получателей всего два, и оба известны заранее), а
|
||||
резервировать половину 2-адресного блока под network/broadcast — чистая потеря адресного
|
||||
пространства. Поэтому `host_count = 2`, `first_host = network`, `last_host = broadcast`.
|
||||
|
||||
**`/32` — единичный адрес.** Блок из одного адреса: `network = broadcast = first_host =
|
||||
last_host = ip`, `host_count = 1`. Используется для маршрутов на конкретный узел
|
||||
(host route) или loopback-адресов маршрутизатора.
|
||||
|
||||
**Обратная задача — `prefix_for_hosts(h)`.** Нужен наибольший `prefix` (самая узкая
|
||||
подсеть), при котором `2^(32-prefix) - 2 >= h`, то есть `2^(32-prefix) >= h+2`, то есть
|
||||
`32-prefix >= log2(h+2)`. Так как число хостовых бит — целое, наименьшее подходящее
|
||||
значение — `32-prefix = ceil(log2(h+2))`, откуда:
|
||||
|
||||
```
|
||||
prefix_for_hosts(h) = 32 - ceil(log2(h+2))
|
||||
```
|
||||
|
||||
Проверка на эталонных примерах: `h=62` → `h+2=64`, `log2=6`, `prefix=26` ✓. `h=63` →
|
||||
`h+2=65`, `log2(65)≈6.02`, `ceil=7`, `prefix=25` ✓ (62 узла `/26` уже не хватает на 63-й
|
||||
хост, нужен на бит шире — `/25` с 126 узлами). `h=254` → `h+2=256`, `log2=8`, `prefix=24` ✓.
|
||||
`h=1` → `h+2=3`, `log2(3)≈1.58`, `ceil=2`, `prefix=30` ✓.
|
||||
|
||||
**Факты для карточек**
|
||||
- base | Формула маски для префикса p (0<p≤32)? — `0xFFFFFFFF << (32-p)`; для p=0 маска = 0 отдельным случаем (сдвиг на 32 — UB)
|
||||
- base | Как получить network и broadcast из ip и mask? — `network = ip & mask`, `broadcast = network | ~mask`
|
||||
- core | Формула host_count для prefix ≤ 30? — `2^(32-prefix) - 2`
|
||||
- core | Почему /31 — исключение и даёт 2 узла вместо 0 по общей формуле? — RFC 3021: у канала точка-точка ровно 2 узла, broadcast не нужен, поэтому оба адреса 2-адресного блока отдаются под хосты вместо потери половины блока
|
||||
- core | Что возвращает subnet_of для /32? — network=broadcast=first_host=last_host=ip, host_count=1 (host route/loopback)
|
||||
- core | Формула `prefix_for_hosts(h)`? — `32 - ceil(log2(h+2))`, из условия `2^(32-prefix) >= h+2`
|
||||
- deep | Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1? — /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами
|
||||
|
||||
## Ловушки
|
||||
|
||||
- Вычислить маску как `0xFFFFFFFF << (32 - prefix)` при `prefix == 0` без отдельной ветки
|
||||
→ сдвиг на 32 бита для 32-битного типа — неопределённое поведение в C/C++ (компилятор
|
||||
может дать любое значение, не обязательно 0) → UBSan ловит это как сдвиг за пределы
|
||||
разрядности типа.
|
||||
- Применить общую формулу `host_count = 2^(32-p) - 2` к `/31` без проверки исключения →
|
||||
получится 0 узлов вместо 2 → тест на `/31` (RFC 3021) падает.
|
||||
- Использовать `int` вместо `long long` для `host_count` → для `/0` значение `2^32 - 2` не
|
||||
влезает в `int` (переполнение со знаком — UB) → в условии структуры явно указан `long long`
|
||||
именно из-за этого случая.
|
||||
- Перепутать host byte order с network byte order при сравнении с реальным трафиком или
|
||||
утилитами вроде `tcpdump` → `10.0.0.1` в host order этой задачи — это `0x0A000001`, но в
|
||||
сетевом порядке байты переставлены (`0x0100000A` при little-endian хосте) — конвертация
|
||||
через `htonl`/`ntohl`, а не прямое сравнение чисел.
|
||||
|
||||
## Проверь себя
|
||||
|
||||
<details>
|
||||
<summary>1. Почему /31 не подчиняется общей формуле «минус 2 служебных адреса»?</summary>
|
||||
Потому что у 2-адресного блока резервирование network и broadcast по общей формуле оставило
|
||||
бы 0 узлов — бессмысленно для канала точка-точка, где всего два участника и оба заранее
|
||||
известны, широковещание не нужно. RFC 3021 явно отдаёт оба адреса блока под хосты.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2. Дано `ip=10.0.1.130`, `prefix=26`. Посчитать network, broadcast, first_host,
|
||||
last_host по шагам.</summary>
|
||||
Хостовых бит: `32-26=6`, размер блока `2^6=64`. Адрес `.130` попадает в блок, начинающийся
|
||||
на границе, кратной 64: `128 <= 130 < 192`, значит `network=10.0.1.128`,
|
||||
`broadcast=10.0.1.128+63=10.0.1.191`, `first_host=network+1=10.0.1.129`,
|
||||
`last_host=broadcast-1=10.0.1.190`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3. Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 только на
|
||||
единицу?</summary>
|
||||
`/26` физически даёт ровно 62 адреса под хосты — этого не хватает даже на один хост меньше
|
||||
требуемых 63. Следующий шаг «расширения» подсети — не +1 узел, а удвоение блока: снятие
|
||||
одного бита из префикса (`/25`) сразу даёт 126 узлов. Промежуточных вариантов между /26 и
|
||||
/25 не существует, поэтому единственный подходящий ответ — /25.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4. Почему для `prefix=0` нельзя вычислять маску как `0xFFFFFFFF << (32-0)`?</summary>
|
||||
Это сдвиг на 32 бита для 32-битного беззнакового типа — стандарт C/C++ определяет сдвиг
|
||||
только для величины меньше ширины типа в битах, сдвиг на саму ширину или больше — UB
|
||||
(на практике часто даёт исходное значение без сдвига вместо ожидаемого 0). Поэтому
|
||||
`prefix == 0` обрабатывается отдельной веткой: `mask = 0` напрямую.
|
||||
</details>
|
||||
|
||||
Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`),
|
||||
почему `/31` — исключение, что такое маска в бинарном виде.
|
||||
|
||||
Reference in New Issue
Block a user