diff --git a/README.md b/README.md
index 39ff122..7d3a3c9 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
- `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые
ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них.
- `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
+- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref).
- `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма
(«почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом.
- `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git,
diff --git a/diag/cards_src.tsv b/diag/cards_src.tsv
new file mode 100644
index 0000000..1595ba3
--- /dev/null
+++ b/diag/cards_src.tsv
@@ -0,0 +1,167 @@
+level domain question answer ref
+base algo Во что превращается 3n + 100 в O-нотации O(n) урок D1_algo.md
+base algo Сколько шагов у бинарного поиска в массиве из 10⁶ элементов 20 (log₂ 10⁶ ≈ 20) урок D1_algo.md
+core algo Почему push_back в среднем O(1), а не O(n) ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n² урок D1_algo.md
+core algo Что ломает удвоение ёмкости у vector указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти) урок D1_algo.md
+deep algo Что будет, если ёмкость vector растить на +1 за раз вместо удвоения суммарная стоимость n вставок станет O(n²) урок D1_algo.md
+base algo Сложность построения кучи (heapify) из произвольного массива O(n), не O(n log n) урок D1_algo.md
+base algo Какая сортировка всегда O(n log n) и устойчива mergesort урок D1_algo.md
+core algo Худший случай quicksort и когда он достигается O(n²), на уже отсортированном массиве при плохом выборе опорного урок D1_algo.md
+core algo Нижняя граница сортировки сравнениями Ω(n log n) урок D1_algo.md
+core algo За счёт чего map держит высоту log n инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа урок D1_algo.md
+deep algo Почему нельзя полагаться на порядок обхода unordered_map порядок бакетов не гарантирован и может меняться при rehash урок D1_algo.md
+base algo Формула фактора загрузки load = size / capacity урок D1_algo.md
+base algo Порог load factor при открытой адресации, после которого делают rehash обычно ≤ 0.7 урок D1_algo.md
+core algo Зачем tombstone при открытой адресации чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже урок D1_algo.md
+core algo Почему ёмкость хеш-таблицы берут степенью двойки idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре урок D1_algo.md
+core algo Что физически происходит при rehash выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap) урок D1_algo.md
+deep algo Почему rehash даёт амортизированное O(1), а не O(n) на вставку та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n² урок D1_algo.md
+base algo Сложность has_pair (вложенный цикл по всем парам) O(n²) урок D1_algo.md
+core algo Сложность has_pair_fast по времени и по памяти O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество урок D1_algo.md
+base algo Какие константы использует FNV-1a в этом коде (offset basis / prime) 1469598103934665603 / 1099511628211 урок D1_algo.md
+core algo Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны урок D1_algo.md
+core algo Чем открытая адресация выигрывает у цепочек по производительности и почему она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами урок D1_algo.md
+deep algo Может ли load factor у цепочек быть больше 1 да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда) урок D1_algo.md
+base linux Что возвращает `fork()` в родителе и в ребёнке в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 урок D1_linux.md
+core linux Что происходит со страницами памяти при `fork()` ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) урок D1_linux.md
+core linux Почему `fork` дешёвый даже для процесса с гигабайтами памяти копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят урок D1_linux.md
+base linux Что нужно сделать с `stdout` перед `fork`, если он не пуст вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке урок D1_linux.md
+deep linux Какой размер страницы памяти на x86-64, о которой копия делается при COW 4 КБ урок D1_linux.md
+base linux Чем `exec` отличается от `fork` `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового урок D1_linux.md
+core linux Что сохраняется у процесса после успешного `exec` PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC` урок D1_linux.md
+core linux Почему `exec` при успехе никогда не возвращает управление старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом урок D1_linux.md
+base linux Разница между `execlp`, `execv`, `execvp` `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH` урок D1_linux.md
+base linux Зачем нужен `waitpid` забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов урок D1_linux.md
+core linux Что такое зомби и почему он не исчезает сам процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait` урок D1_linux.md
+core linux Чем зомби отличается от сироты зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) урок D1_linux.md
+base linux Откуда код возврата 137 и 139 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу урок D1_linux.md
+core linux Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую урок D1_linux.md
+deep linux Что показывает `WCOREDUMP(status)` что завершение по сигналу сопровождалось записью core-дампа на диск урок D1_linux.md
+base linux Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD 2/9/15/11/13/17 урок D1_linux.md
+core linux Чем SIGTERM отличается от SIGKILL SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую урок D1_linux.md
+core linux Что можно делать внутри обработчика сигнала только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe) урок D1_linux.md
+base linux Чем `sigaction` лучше `signal` поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику урок D1_linux.md
+deep linux Что происходит, если сервис игнорирует SIGTERM при `systemctl stop` по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно урок D1_linux.md
+base linux Что означает EINTR и как на него реагировать вызов прерван доставкой сигнала; корректная реакция — повторить вызов урок D1_linux.md
+base linux Чем EAGAIN отличается от обычной ошибки чтения данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже» урок D1_linux.md
+core linux Когда безопасно читать `errno` сразу после ошибки вызова, до любого другого вызова, способного его перезаписать урок D1_linux.md
+deep linux Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса EMFILE (лимит задаётся `ulimit -n`) урок D1_linux.md
+base linux Какой код возврата у шелла даст несуществующая команда 127 урок D1_linux.md
+base linux Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork` урок D1_linux.md
+core linux Каким флагом собрать бинарник для проверки на утечки и UB `-fsanitize=address,undefined -fno-omit-frame-pointer` урок D1_linux.md
+base algo Сколько байт меняет местами `bswap32` 4 задача 01_bits
+core algo Что делает `x & (x - 1)` гасит младший единичный бит `x` задача 01_bits
+core algo Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF` 32 (по числу единичных бит) задача 01_bits
+deep algo Почему `1u << 31` пишут с суффиксом `u`, а не как `int` сдвиг знакового `int` в задача 01_bits
+base algo Какой порядок байт использует сеть для многобайтовых полей network byte order задача 01_bits
+base algo Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей в 2 раза задача 02_list
+core algo Почему рекурсивный разворот списка не годится под требование O(1) памяти каждый задача 02_list
+core algo На чём основано доказательство, что `fast` догонит `slow` при цикле внутри цикла задача 02_list
+base algo Сколько дополнительных указателей нужно для итеративного разворота списка 3 задача 02_list
+base algo Сложность `push`/`pop` кольцевого буфера O(1) задача 03_ring
+core algo Как отличить пустой буфер от полного при `head_ == tail_` хранить отдельный задача 03_ring
+core algo Чем `delete[]` отличается от `delete` для массива, выделенного `new[]` `delete` задача 03_ring
+base algo Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов задача 03_ring
+base net Минимальная длина IPv4-заголовка без опций 20 байт задача 04_ipv4
+base net Код `protocol` для TCP / UDP / ICMP 6 / 17 / 1 задача 04_ipv4
+core net В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию «как на проводе», задача 04_ipv4
+core net Почему `reinterpret_cast` буфера в `Ipv4Header*` UB? — нет гарантии выравнивания задача 04_ipv4
+deep net Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем задача 04_ipv4
+base algo Сложность наивного DP-решения LIS O(n²) задача 05_lis
+core algo Сложность решения через `tails` + бинарный поиск O(n log n) задача 05_lis
+core algo Что хранит `tails[k]` минимально возможный последний элемент возрастающей задача 05_lis
+deep algo Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound` задача 05_lis
+base threads Каким флагом компилятора включается ThreadSanitizer `-fsanitize=thread` задача 06_threads
+base threads Что бросает `push` после `close()` `std::runtime_error` задача 06_threads
+core threads Должен ли `size()` быть потокобезопасным в этой задаче да, его тоже вызывают из другого потока и он тоже под захватом мьютекса задача 06_threads
+core threads Что происходит с ждущими потоками при `close()` все разблокируются (никакого вечного `wait`) задача 06_threads
+base threads Что атомарно делает `cv.wait(lock)` при входе освобождает мьютекс и усыпляет поток одной неделимой операцией задача 06_threads
+core threads Почему `while` вокруг `wait`, а не `if` из-за spurious wakeup: `wait` может вернуться без вызова notify задача 06_threads
+core threads Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one` можно разбудить не тот поток, а нужный останется ждать задача 06_threads
+deep threads На каком примитиве ОС реализован `condition_variable` на Linux futex задача 06_threads
+base threads Какой командой запускается проверка задачи 06 `python3 grade.py 06` задача 06_threads
+core threads Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом задача 06_threads
+base linux На каком адресе и с каким флагом сокета должен слушать сервер 127.0.0.1, `SO_REUSEADDR` задача 07_epoll
+base linux Сколько одновременных соединений сервер должен держать минимум 64 задача 07_epoll
+core linux По какому событию сервер закрывает соединение с клиентом по EOF со стороны клиента задача 07_epoll
+base linux Что означает `EAGAIN` на неблокирующем сокете не ошибка, сигнал «данных пока нет, попробуй позже» задача 07_epoll
+core linux В чём разница между LT и ET режимами epoll LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов» задача 07_epoll
+core linux Почему ET требует неблокирующих дескрипторов цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет задача 07_epoll
+deep linux Какая асимптотика у `epoll_wait` относительно select/poll O(1) на готовое событие против O(n) на весь набор у select/poll задача 07_epoll
+base linux Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT 0 задача 07_epoll
+core linux Что возвращает `epoll_wait`, если его прервал сигнал -1 с `errno == EINTR`, не ошибка выполнения задача 07_epoll
+base linux Сколько соединений открывает тест `grade.py 07` 3 задача 07_epoll
+base linux Каким сигналом тест проверяет корректное завершение сервера SIGTERM задача 07_epoll
+base linux Сколько строк TOP выводится, если уникальных IP меньше трёх столько, сколько есть уникальных IP задача 08_bash
+base linux Что печатает скрипт для пустого файла `TOTAL 0`, `5XX 0`, без строк TOP задача 08_bash
+core linux По какому полю строки лога определяется IP по первому полю задача 08_bash
+core linux Почему `while read line` в bash медленный на миллионе строк каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку задача 08_bash
+core linux Что даёт связка awk + sort вместо построчного цикла один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов задача 08_bash
+deep linux По какому полю сортируется список IP при равенстве числа запросов по возрастанию строки IP (вторичный ключ сортировки) задача 08_bash
+base linux Что делает `set -e` останавливает скрипт при первой команде с ненулевым кодом возврата задача 08_bash
+core linux В каких позициях `set -e` не останавливает скрипт при ошибке команды в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!` задача 08_bash
+core linux Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри задача 08_bash
+core linux Зачем `set -o pipefail` отдельно от `set -e` без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным задача 08_bash
+base linux Каким кодом возврата должен завершаться `solution.sh` при успехе 0 задача 08_bash
+core linux Что именно сверяет `grade.py 08` с эталоном точное совпадение вывода (все четыре строки) на фикстуре и на большом логе задача 08_bash
+base linux Что печатает `./crash` без аргументов после починки `len=5`, код возврата 0 задача 09_gdb
+base linux Что печатает `./crash ""` после починки `len=0`, код возврата 0 задача 09_gdb
+core linux Какие два санитайзера должны не давать сообщений после починки ASan и UBSan (`-fsanitize=address,undefined`) задача 09_gdb
+base linux Что даёт флаг `-g` при сборке для gdb отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных задача 09_gdb
+core linux Почему для отладки собирают с `-O0`, а не с `-O2` оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику задача 09_gdb
+core linux Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение все три, в момент обращения к памяти, а не постфактум задача 09_gdb
+deep linux Что именно ловит UBSan, в отличие от ASan неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью задача 09_gdb
+base linux Какая команда gdb показывает цепочку вызовов до краша `bt` задача 09_gdb
+core linux Чем `watch var` отличается от обычного `break` останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода задача 09_gdb
+core linux Зачем нужен `frame N` после `bt` переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции задача 09_gdb
+base linux Сколько входов прогоняет `grade.py 09` 5 задача 09_gdb
+base linux Каким компилятором собирается `crash.c` на проверке gcc (компилятор C) задача 09_gdb
+base algo Сколько времени закладывается на задачу 10 1,5–2,5 часа со ступенями задача 10_hash
+base algo Какой командой проверяется решение `python3 grade.py 10` задача 10_hash
+deep algo Где в реальных устройствах используется похожая структура таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти задача 10_hash
+base algo Почему `hash & (cap-1)` эквивалентно `hash % cap` только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления задача 10_hash
+base algo Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс `19 & 7 = 3` задача 10_hash
+core algo Среднее число проб при удачном поиске, load factor 0.7 ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`) задача 10_hash
+core algo То же при load factor 0.9 ≈5,5 — рост почти в 2,5 раза от значения при 0.7 задача 10_hash
+deep algo Среднее число проб при НЕудачном поиске, load factor 0.9 ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска задача 10_hash
+core algo Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте задача 10_hash
+base algo Сколько ключей вставляет тест на кластеризацию и какие они 512 ключей, кратных 16 задача 10_hash
+base algo За какое время должны пройти 100 000 вставок быстрее 2 секунд задача 10_hash
+base algo Какой порог load factor держит таблица ≤ 0.7 задача 10_hash
+base algo Минимальная ёмкость таблицы по умолчанию 16, степень двойки задача 10_hash
+base algo Индексы детей и родителя в max-heap на массиве дети `2i+1`, `2i+2`; родитель `(i-1)/2` задача 11_heap
+base algo Что за 2 секунды должны выполниться на 3 000 000 элементов `heapify` и `kth_largest` с `k=1000` (по отдельности) задача 11_heap
+base algo Какой ключевой запрет на реализацию `heapify` нельзя строить через `push` в цикле, только bottom-up за O(n) задача 11_heap
+base algo Сложность доступа к максимуму (`top`) O(1), максимум всегда в корне по инварианту кучи задача 11_heap
+base algo Сложность одного `sift-up`/`sift-down` от произвольного узла O(log n) — путь ограничен высотой дерева задача 11_heap
+core algo Что сравнивается на каждом шаге `sift-down` узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла задача 11_heap
+core algo За какое время строится куча через n последовательных `push` O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n` задача 11_heap
+core algo За какое время строится куча через bottom-up heapify O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n` задача 11_heap
+deep algo С какого индекса начинается bottom-up heapify и в какую сторону идёт с последнего нелистового узла `n/2 - 1`, к корню (индекс 0) задача 11_heap
+core algo Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000 примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up задача 11_heap
+base algo Сложность `kth_largest`/`top_k` через полную кучу O(n + k log n): O(n) на heapify плюс k раз pop по O(log n) задача 11_heap
+core algo Когда используют min-heap размера k вместо полной кучи на весь массив на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k) задача 11_heap
+core algo Чем `std::nth_element` отличается от кучи для top-K в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop задача 11_heap
+base algo Что возвращает функция при `src == dst` 0 задача 12_dijkstra
+base algo Что возвращает функция при недостижимости `dst` -1 задача 12_dijkstra
+base algo В каком типе суммируются веса и почему не `int` `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути задача 12_dijkstra
+base algo Сложность наивной Дейкстры (перебор минимума по массиву) O(V²) задача 12_dijkstra
+base algo Сложность Дейкстры с бинарной кучей O((V+E) log V) задача 12_dijkstra
+core algo При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка задача 12_dijkstra
+core algo Что означает «ленивое удаление» устаревших записей в очереди вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая задача 12_dijkstra
+deep algo Почему `std::priority_queue` не используют с decrease-key напрямую у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop задача 12_dijkstra
+core algo Почему Дейкстра ломается на отрицательных рёбрах алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается задача 12_dijkstra
+base algo Какой алгоритм нужен при отрицательных весах без отрицательных циклов Беллман-Форд, O(V·E) задача 12_dijkstra
+core algo Как Беллман-Форд обнаруживает отрицательный цикл если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл задача 12_dijkstra
+base algo Почему self-loop с w≥0 не требует отдельной обработки добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию задача 12_dijkstra
+base net Сколько узлов даёт /24 254 задача 13_subnet
+base net Сколько узлов даёт /26 62 задача 13_subnet
+base net Сколько узлов даёт /30 2 задача 13_subnet
+base net Сеть, broadcast и диапазон узлов для `10.0.1.130/26` сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190` задача 13_subnet
+base net Формула маски для префикса p (0
= h+2` задача 13_subnet
+deep net Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1 /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами задача 13_subnet
diff --git a/diag/tasks/01_bits/task.md b/diag/tasks/01_bits/task.md
index d32a33d..01485c6 100644
--- a/diag/tasks/01_bits/task.md
+++ b/diag/tasks/01_bits/task.md
@@ -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); // поменять порядок ба
Если перепутать — биты уедут на одну позицию.
-## Глава 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)
## После сдачи
diff --git a/diag/tasks/02_list/task.md b/diag/tasks/02_list/task.md
index ffbdef1..28e5a3b 100644
--- a/diag/tasks/02_list/task.md
+++ b/diag/tasks/02_list/task.md
@@ -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) памяти в обоих режимах
+(поиск середины и поиск цикла), и как тот же приём переносится на поиск цикла в графе.
diff --git a/diag/tasks/03_ring/task.md b/diag/tasks/03_ring/task.md
index 595dac9..57c1728 100644
--- a/diag/tasks/03_ring/task.md
+++ b/diag/tasks/03_ring/task.md
@@ -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` — тогда вопрос исчезает.
-## Глава 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;`
## После сдачи
diff --git a/diag/tasks/04_ipv4/task.md b/diag/tasks/04_ipv4/task.md
index 914e235..2279279 100644
--- a/diag/tasks/04_ipv4/task.md
+++ b/diag/tasks/04_ipv4/task.md
@@ -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(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(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.
diff --git a/diag/tasks/05_lis/task.md b/diag/tasks/05_lis/task.md
index 8ae0dfa..339f158 100644
--- a/diag/tasks/05_lis/task.md
+++ b/diag/tasks/05_lis/task.md
@@ -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& a); // длина НВП (строго возрастающей)
```
@@ -11,4 +46,29 @@ int lis_length(const std::vector& 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` при необходимости восстановить саму
+подпоследовательность (дополнительный массив предшественников).
diff --git a/diag/tasks/06_threads/task.md b/diag/tasks/06_threads/task.md
index 694bce6..3fa8f2a 100644
--- a/diag/tasks/06_threads/task.md
+++ b/diag/tasks/06_threads/task.md
@@ -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 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 может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом
+
+
+Проверь себя
+
+1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же
+ мьютекс, что защищает очередь, а не произвольный лок?
+ Ответ
Потому что `wait` должен атомарно освободить именно этот
+ мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы
+ предикат под разными блокировками, и защита состояния очереди перестала бы работать.
+
+2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста?
+ Ответ
Требование — `close()` опустошает остаток: `pop()` должен
+ продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно
+ пуста — отдавать `false`, не уходя в ожидание.
+
+3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует
+ захвата мьютекса?
+ Ответ
Потому что запись в `size_` из `push`/`pop` без
+ синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает
+ без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».
+
+
diff --git a/diag/tasks/07_epoll/task.md b/diag/tasks/07_epoll/task.md
index 75d39e8..e6aaf69 100644
--- a/diag/tasks/07_epoll/task.md
+++ b/diag/tasks/07_epoll/task.md
@@ -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
+
+
+Проверь себя
+
+1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это
+ покрыло все данные, которые были на момент события?
+ Ответ
Потому что ET сообщает о фронте один раз; если между этим
+ чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел,
+ можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт
+ `EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.
+
+2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
+ Ответ
Требование — до 64 одновременных соединений одним потоком;
+ блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные —
+ отсюда обязательность `epoll` и неблокирующих сокетов.
+
+3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
+ Ответ
Это не ошибка: буфер сокета ограничен по размеру, ядро
+ приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим
+ вызовом `write`, когда `EPOLLOUT` снова покажет готовность.
+
+
diff --git a/diag/tasks/08_bash/task.md b/diag/tasks/08_bash/task.md
index 03e2cbb..53ff616 100644
--- a/diag/tasks/08_bash/task.md
+++ b/diag/tasks/08_bash/task.md
@@ -11,7 +11,8 @@ TOP <число запросов>
5XX <число ответов со статусом 500-599>
```
-Правила:
+## Правила формата
+
- IP — первое поле строки;
- TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию
строки IP (лексикографически);
@@ -20,6 +21,125 @@ TOP <число запросов>
по времени — нужен 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` с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
+
+
+Проверь себя
+
+1. Почему пайплайн `grep 500 access.log | wc -l` под одним `set -e` (без `pipefail`) может
+ молча пропустить ошибку `grep` (например, если файл не существует)?
+ Ответ
Код возврата конвейера по умолчанию — это код возврата
+ последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней
+ упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую
+ упавшую команду.
+
+2. Почему `local total=$(false)` не остановит скрипт при `set -e`, а `total=$(false)` (без
+ `local` на той же строке) — остановит?
+ Ответ
В первом случае итоговый код возврата строки — это код
+ возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе
+ объявления независимо от результата подстановки внутри; во втором случае код возврата
+ присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e`
+ сработает.
+
+3. Почему построчный `while read ip status rest; do ...; done < access.log` с миллионом строк
+ не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?
+ Ответ
Каждая итерация цикла — это работа самого интерпретатора
+ bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на
+ порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл
+ целиком за один запуск процесса.
+
+
diff --git a/diag/tasks/09_gdb/task.md b/diag/tasks/09_gdb/task.md
index 0b129b3..9ac0380 100644
--- a/diag/tasks/09_gdb/task.md
+++ b/diag/tasks/09_gdb/task.md
@@ -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)
+
+
+Проверь себя
+
+1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с `-O2` для
+ финальной проверки производительности?
+ Ответ
Оптимизатор переставляет и удаляет инструкции, инлайнит
+ вызовы и переиспользует регистры под разные переменные — отладочная информация от такой
+ сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или
+ неверные значения; для отладки нужна отдельная сборка `-g -O0`.
+
+2. Программа падает с `SIGSEGV` без санитайзеров, но при сборке с ASan вместо краша печатается
+ сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?
+ Ответ
ASan останавливает программу в момент фактического выхода
+ за границу буфера — в точке причины; без ASan повреждённая память могла не привести к
+ немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные
+ использовались уже в другом месте — это типичная картина «краш далеко от причины».
+
+3. Чем `watch var` полезнее обычной точки останова (`break`) для поиска места, где переменная
+ портится неожиданно?
+ Ответ
`break` останавливает выполнение в заданной строке кода,
+ но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения,
+ которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже
+ если изменение происходит из неожиданного места (например, из-за записи за границей другого
+ буфера, затирающей соседнюю память).
+
+
diff --git a/diag/tasks/10_hash/task.md b/diag/tasks/10_hash/task.md
index 5b7706f..b95ee33 100644
--- a/diag/tasks/10_hash/task.md
+++ b/diag/tasks/10_hash/task.md
@@ -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`.
-## Глава 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 ключей.
+
+## Проверь себя
+
+
+1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы?
+`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)`
+перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой,
+а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при
+котором работает битовая маска вместо деления.
+
+
+
+2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor
+вырастет с 0.7 до 0.9?
+Примерно в 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 раз.
+
+
+
+3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже
+такой ключ?
+`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если
+инкрементировать `size` без предварительного поиска ключа по цепочке зондирования,
+повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт
+элементов упадёт.
+
+
+
+4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании?
+В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только
+реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы
+место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами
+цепочки проб после rehash пересчитываются заново.
+
## После сдачи
diff --git a/diag/tasks/11_heap/task.md b/diag/tasks/11_heap/task.md
index 6243cd1..fc4135f 100644
--- a/diag/tasks/11_heap/task.md
+++ b/diag/tasks/11_heap/task.md
@@ -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& a); // перестроить массив в кучу
@@ -20,5 +21,144 @@ std::vector top_k(const std::vector& 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`), но интерпретируется как полное бинарное
+дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без
+указателей. Инвариант 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, а не просто неверный ответ.
+
+## Проверь себя
+
+
+1. Почему bottom-up heapify — O(n), хотя высота дерева log n?
+Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте
+всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты
+`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится
+к `n`, умноженному на константу, а не на `log n`.
+
+
+
+2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения,
+чем bottom-up heapify?
+Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up
+heapify) — то есть на порядок больше сравнений.
+
+
+
+3. Почему `top()` кучи — O(1), а не O(log n)?
+По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое
+обращение по индексу `0`, без поиска и без перестройки структуры.
+
+
+
+4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь
+массив?
+Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что
+не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k
+текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и
+заменяет его за O(log k) — суммарно O(n log k) и память O(k).
+
+
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.
diff --git a/diag/tasks/12_dijkstra/task.md b/diag/tasks/12_dijkstra/task.md
index e2709c4..9cf187b 100644
--- a/diag/tasks/12_dijkstra/task.md
+++ b/diag/tasks/12_dijkstra/task.md
@@ -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` возвращает заниженное
+ значение.
+
+## Проверь себя
+
+
+1. Почему при V=100 000 и E=200 000 наивный O(V²) не проходит по времени, а вариант
+с кучей — проходит?
+`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`
+операций, укладывается с большим запасом.
+
+
+
+2. Что произойдёт с результатом Дейкстры, если в графе есть ребро веса -5, а
+остальные рёбра положительные?
+Результат может быть неверным без явной ошибки: если вершина на дешёвом с виду пути уже
+извлечена из очереди и «финализирована», а позже к ней ведёт более короткий путь через
+ребро -5, алгоритм это улучшение не увидит, так как вершина повторно не пересматривается.
+
+
+
+3. Почему в очереди могут одновременно лежать несколько записей для одной и той же
+вершины, и почему это не ошибка?
+Каждое найденное улучшение расстояния до вершины добавляет новую запись `(dist, v)` в кучу
+вместо обновления старой (`decrease-key` не поддерживается `std::priority_queue`). Это не
+ошибка, потому что при извлечении устаревшая запись (`d != dist[v]`) просто пропускается —
+корректность сохраняется, платится только лишней памятью в очереди и лишним `pop`.
+
+
+
+4. Почему self-loop с весом w≥0 никогда не меняет кратчайший путь?
+Self-loop добавляет вершине путь до самой себя длиной `w ≥ 0`. Поскольку путь длины 0 (не
+двигаться) уже не хуже, прибавление неотрицательного веса к текущему `dist[v]` не может дать
+меньшее значение — релаксация через такое ребро никогда не проходит условие «короче».
+
diff --git a/diag/tasks/13_subnet/task.md b/diag/tasks/13_subnet/task.md
index c274d51..1e72415 100644
--- a/diag/tasks/13_subnet/task.md
+++ b/diag/tasks/13_subnet/task.md
@@ -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= 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`, а не прямое сравнение чисел.
+
+## Проверь себя
+
+
+1. Почему /31 не подчиняется общей формуле «минус 2 служебных адреса»?
+Потому что у 2-адресного блока резервирование network и broadcast по общей формуле оставило
+бы 0 узлов — бессмысленно для канала точка-точка, где всего два участника и оба заранее
+известны, широковещание не нужно. RFC 3021 явно отдаёт оба адреса блока под хосты.
+
+
+
+2. Дано `ip=10.0.1.130`, `prefix=26`. Посчитать network, broadcast, first_host,
+last_host по шагам.
+Хостовых бит: `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`.
+
+
+
+3. Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 только на
+единицу?
+`/26` физически даёт ровно 62 адреса под хосты — этого не хватает даже на один хост меньше
+требуемых 63. Следующий шаг «расширения» подсети — не +1 узел, а удвоение блока: снятие
+одного бита из префикса (`/25`) сразу даёт 126 узлов. Промежуточных вариантов между /26 и
+/25 не существует, поэтому единственный подходящий ответ — /25.
+
+
+
+4. Почему для `prefix=0` нельзя вычислять маску как `0xFFFFFFFF << (32-0)`?
+Это сдвиг на 32 бита для 32-битного беззнакового типа — стандарт C/C++ определяет сдвиг
+только для величины меньше ширины типа в битах, сдвиг на саму ширину или больше — UB
+(на практике часто даёт исходное значение без сдвига вместо ожидаемого 0). Поэтому
+`prefix == 0` обрабатывается отдельной веткой: `mask = 0` напрямую.
+
+
Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`),
почему `/31` — исключение, что такое маска в бинарном виде.
diff --git a/lessons/D1_algo.md b/lessons/D1_algo.md
index c5d37a6..5751941 100644
--- a/lessons/D1_algo.md
+++ b/lessons/D1_algo.md
@@ -3,10 +3,11 @@
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
-## 1. Что такое O-нотация (без воды)
+## 1. Что такое O-нотация
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
-больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n).
+больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое
+100 и множитель 3 не меняют форму роста, поэтому их не пишут.
Три правила, которых хватает для 90% вопросов:
@@ -18,14 +19,32 @@ O-нотация отвечает на вопрос «как растёт вре
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
-**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция
-«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` —
-обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно
-**амортизированное O(1)**, потому что ёмкость удваивается.
+**Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не
+гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`:
+когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её,
+выделяет новый блок и переносит туда все элементы — это разовая операция O(n). Из-за
+геометрического роста ёмкости такие реаллокации случаются экспоненциально реже: после
+k-й реаллокации следующая наступит примерно через 2^k новых вставок. Сумма стоимости всех
+реаллокаций на n вставок — геометрическая прогрессия n/2 + n/4 + n/8 + ... ≈ n, то есть
+суммарно O(n) на n операций, а не O(n²) — отсюда амортизированное **O(1)** на одну вставку.
+Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка копировала бы весь массив —
+суммарно O(n²). Геометрический рост — не оптимизация, а необходимое условие амортизации.
+Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на
+элементы вектора, потому что блок памяти физически переехал — источник use-after-free,
+который ловит ASAN.
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
+**Факты для карточек**
+- base | Во что превращается 3n + 100 в O-нотации? — O(n)
+- base | Сколько шагов у бинарного поиска в массиве из 10⁶ элементов? — 20 (log₂ 10⁶ ≈ 20)
+- core | Почему push_back в среднем O(1), а не O(n)? — ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n²
+- core | Что ломает удвоение ёмкости у vector? — указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти)
+- deep | Что будет, если ёмкость vector растить на +1 за раз вместо удвоения? — суммарная стоимость n вставок станет O(n²)
+
+Почему дальше: если push_back амортизированно O(1) за счёт удвоения, какие структуры данных вообще гарантируют O(1) в среднем на операцию — переходим к таблице сложностей и хеш-таблицам.
+
## 2. Таблица сложностей, которую надо знать
| Структура | Поиск | Вставка | Удаление | Память |
@@ -38,17 +57,44 @@ O-нотация отвечает на вопрос «как растёт вре
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
+Почему связный список даёт O(1) на вставку/удаление: если узел уже найден (есть указатель),
+операция — просто перелинковка соседних указателей без сдвига остальных элементов; O(n) в
+поиске появляется отдельно, потому что нет арифметики адреса — только последовательный проход.
+Почему у отсортированного массива поиск O(log n), а вставка O(n): бинарный поиск делит
+диапазон пополам, но вставка сдвигает все элементы после точки вставки, чтобы сохранить
+непрерывность блока памяти.
+
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
-частый вопрос), вставка одного элемента — O(log n), взятие максимума — O(1).
+частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа
+по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание
+вверх на высоту кучи), взятие максимума — O(1) (это корень).
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
-сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя.
+сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево
+решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n).
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
bubble, counting. Неустойчивы: quick, heap, selection.
+**map vs unordered_map**: `map` — красно-чёрное дерево, инвариант балансировки (чередование
+цветов узлов, равное число чёрных узлов на любом пути от корня до листа) держит высоту
+порядка log n, отсюда O(log n) на все операции и ключи всегда в отсортированном порядке при
+обходе. `unordered_map` в среднем быстрее на чистом поиске/вставке (O(1) и меньше косвенных
+переходов по указателям), но не даёт упорядоченного обхода и не гарантирует порядок бакетов
+между вызовами rehash.
+
+**Факты для карточек**
+- base | Сложность построения кучи (heapify) из произвольного массива? — O(n), не O(n log n)
+- base | Какая сортировка всегда O(n log n) и устойчива? — mergesort
+- core | Худший случай quicksort и когда он достигается? — O(n²), на уже отсортированном массиве при плохом выборе опорного
+- core | Нижняя граница сортировки сравнениями? — Ω(n log n)
+- core | За счёт чего map держит высоту log n? — инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа
+- deep | Почему нельзя полагаться на порядок обхода unordered_map? — порядок бакетов не гарантирован и может меняться при rehash
+
+Почему дальше: таблица говорит, что хеш-таблица в среднем O(1) — дальше разбираем механизм, который это обеспечивает и почему он иногда ломается до O(n).
+
## 3. Хеш-таблица: как устроена
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
@@ -57,7 +103,8 @@ bubble, counting. Неустойчивы: quick, heap, selection.
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
загрузки** (сколько занято от общего размера).
-Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка.
+Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка: при n ключах и m
+корзинах коллизии статистически неизбежны уже при n, сравнимом с √m (парадокс дней рождения).
Два способа разрешения:
@@ -66,17 +113,44 @@ bubble, counting. Неустойчивы: quick, heap, selection.
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
- Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка
- зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone**
- (надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять.
+ Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе
+ разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить
+ ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение —
+ **tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт
+ дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался
+ бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит
+ дальше по цепочке зондирования.
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
-чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое
-больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево).
+чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число
+проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически
+выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново
+по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые
+позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит
+она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда
+**амортизированное O(1)** на вставку, а не просто «в среднем быстро».
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
-деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие
-биты (для строк — FNV-1a или `std::hash`).
+деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре.
+Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при
+делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)),
+для строк — FNV-1a или `std::hash`.
+
+**Ловушки**
+- Взять ёмкость не степенью двойки при использовании `hash & (cap-1)` → маска отрежет не те биты → часть корзин никогда не используется, видно по неравномерному распределению цепочек.
+- Удалять элемент простой очисткой ячейки при открытой адресации вместо tombstone → поиск последующих элементов той же цепочки зондирования обрывается раньше времени → `get` возвращает false для существующего ключа, видно в тесте «insert A, B (коллизия с A), delete A, get B» → false.
+- Не проверять load factor перед вставкой → цепочки/пробеги растут неограниченно → поиск деградирует к O(n), видно по профилировщику как рост времени `unordered_map::find` с размером таблицы.
+- Пользовательский ключ с плохим/предсказуемым хешем → все элементы в одном бакете → тихая деградация до O(n) без ошибки компиляции, ловится только профилировщиком.
+
+**Факты для карточек**
+- base | Формула фактора загрузки? — load = size / capacity
+- base | Порог load factor при открытой адресации, после которого делают rehash? — обычно ≤ 0.7
+- core | Зачем tombstone при открытой адресации? — чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже
+- core | Почему ёмкость хеш-таблицы берут степенью двойки? — idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре
+- core | Что физически происходит при rehash? — выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap)
+- deep | Почему rehash даёт амортизированное O(1), а не O(n) на вставку? — та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n²
+
+Почему дальше: те же формулы сложности стоит применить к конкретному коду — переходим к разбору задач, где нужно на глаз определить сложность.
## 4. Разбор примера: считаем сложности
@@ -109,6 +183,16 @@ bool has_pair_fast(const std::vector& v, int k) { // O(n) в среднем
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
+Механизм ускорения: (б) на каждой паре (i, j) делает сравнение за O(1), но пар — O(n²);
+(в) вместо перебора пар один раз кладёт каждый элемент в хеш-множество (O(1) в среднем на
+вставку) и один раз проверяет наличие дополнения k - x (O(1) в среднем на поиск) — итого
+O(n) вставок и O(n) поисков вместо O(n²) сравнений.
+
+**Факты для карточек**
+- base | Сложность has_pair (вложенный цикл по всем парам)? — O(n²)
+- core | Сложность has_pair_fast по времени и по памяти? — O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество
+
+Почему дальше: чтобы поверить в O(1) на вставку/поиск из примера (в), нужно понять, что внутри unordered_set/unordered_map — переходим к ручной сборке хеш-таблицы.
## 5. Разбор примера: как руками собрать хеш-таблицу
@@ -168,11 +252,21 @@ struct HashTable {
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
-**все** узлы, потому что индекс зависит от размера массива.
+**все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) —
+после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому
+пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) —
+степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы
+тот же результат быстрее, именно потому что cap — степень двойки.
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
до свободной ячейки, а при удалении ставим tombstone.
+**Факты для карточек**
+- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211
+- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны
+
+Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок.
+
## 6. Что спросят на собеседовании (готовые ответы)
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
@@ -184,6 +278,10 @@ struct HashTable {
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
+**Факты для карточек**
+- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами
+- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда)
+
## 7. Материалы (первопартийные)
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
@@ -198,6 +296,8 @@ struct HashTable {
3. `heapify` из произвольного массива — за сколько?
4. Какая из сортировок устойчива: quick, merge, heap?
5. Зачем tombstone при открытой адресации?
+6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме?
+7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
Ответы
@@ -208,6 +308,11 @@ struct HashTable {
4. merge (устойчива), quick и heap — нет.
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
ячейки до элемента, который был вставлен за ней.
+6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая
+ операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость
+ n операций ≈ n, а не n².
+7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю —
+ дешевле для процессора.
## 9. Ссылки на задачи этого дня
diff --git a/lessons/D1_linux.md b/lessons/D1_linux.md
index b3fed71..359c1ee 100644
--- a/lessons/D1_linux.md
+++ b/lessons/D1_linux.md
@@ -1,20 +1,30 @@
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
-Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения +
-открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает
-результат, сигнал — асинхронное уведомление.
+Модель одна на весь урок: процесс = адресное пространство (`mm_struct` в ядре) + поток
+выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов
+(`task_struct`). `fork` копирует эту запись и помечает память как copy-on-write, `exec`
+заменяет содержимое адресного пространства, оставляя PID и дескрипторы, `wait` забирает у
+ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.
## 1. fork(): что реально происходит
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
пространство, свой PID, свой поток. Родитель продолжает с того же места.
+Механически ядро: (1) выделяет новый `task_struct` и PID; (2) копирует таблицу файловых
+дескрипторов — обе записи после `fork` указывают на те же открытые файловые описания в
+ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы
+данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь
+планировщика. Само копирование данных при этом не происходит — см. COW ниже.
+
Возвращает **дважды** — и это ключ к пониманию:
- в родителе — PID ребёнка (> 0);
- в ребёнке — 0;
- при ошибке — −1 (и `errno`), ребёнок не создан.
-Поэтому классический код всегда ветвится:
+Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова —
+единственный способ понять, в какой копии сейчас исполняется код, это проверить
+возвращённое значение. Поэтому классический код всегда ветвится:
```cpp
pid_t pid = fork();
@@ -31,13 +41,41 @@ if (pid == 0) {
}
```
-**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни страницы,
-помеченные «только чтение». При первой записи в страницу ядро делает её копию — только
-тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают
-в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
-при первой записи**.
+**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни физические
+страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи
+в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую
+страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц
+только пишущего процесса — только тогда. Поэтому `fork` дешёвый, даже если процесс занимает
+гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в
+формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
+при первой записи**, а не при самом `fork`.
-Совет: `fflush(stdout)` перед `fork`, иначе буфер вывода может продублироваться в ребёнке.
+Причина именно такого устройства — частый паттерн «`fork` сразу за которым `exec`»: если бы
+ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы
+выброшенной, ведь `exec` тут же заменяет содержимое памяти новой программой.
+
+Совет: `fflush(stdout)` перед `fork`, иначе непустой буфер `stdout` скопируется в ребёнка
+вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды.
+
+**Ловушки**
+- Не проверить возвращаемое значение `fork()` и не разветвить логику по нему → родитель и
+ ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID в
+ `ps aux`, делающих одну и ту же работу.
+- Ребёнок долго не вызывает `exec()`, активно пишет в большие структуры данных → всплеск
+ реального потребления памяти именно в момент записи (COW-копирование страниц), а не в
+ момент `fork` → видно по росту RSS в `top`/`ps` уже после fork, а не сразу.
+- Вызвать `exit()` вместо `_exit()` в ребёнке после неудачного `exec` → `exit()` сбрасывает
+ стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может
+ продублировать ранее не выведенный текст родителя.
+
+**Факты для карточек**
+- base | Что возвращает `fork()` в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1
+- core | Что происходит со страницами памяти при `fork()`? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault)
+- core | Почему `fork` дешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят
+- base | Что нужно сделать с `stdout` перед `fork`, если он не пуст? — вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке
+- deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ
+
+Почему дальше: раз память после `fork` временно общая и почти всегда тут же заменяется — что конкретно делает `exec` с этим адресным пространством?
## 2. exec(): замена образа
@@ -45,40 +83,122 @@ if (pid == 0) {
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
-Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы.
+Механизм: `execve` (системный вызов, к которому в итоге сводится всё семейство `exec*`)
+загружает исполняемый файл с диска, разбирает его как ELF, строит новый `mm_struct` —
+новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство
+текущего `task_struct`, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые
+до `exec`, остаются открытыми в новой программе **кроме** помеченных флагом `FD_CLOEXEC` —
+это то, чем перенаправление ввода-вывода (`dup2` на 0/1/2 перед `exec`) переживает замену
+образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет.
+
+Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы: `fork` даёт
+новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы
+для редиректа), а `exec` в этом новом процессе подгружает нужную программу, не трогая
+родителя. Если вызвать `exec` без предварительного `fork`, текущая программа заменится и не
+вернёт управление — например, `exec` внутри shell-скрипта заменяет саму оболочку.
+
Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
+**Ловушки**
+- Забыть `_exit(127)` (или любой выход) после неудачного `exec*` в ребёнке → код продолжает
+ исполняться как будто это родительская логика → дублирование родительской работы в
+ дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска.
+- Не поставить `FD_CLOEXEC` на служебный/секретный дескриптор перед `exec` → он утекает в
+ запущенную внешнюю программу → видно в `/proc//fd` запущенного процесса — там лишний
+ открытый файл, которого «не должно быть».
+
+**Факты для карточек**
+- base | Чем `exec` отличается от `fork`? — `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового
+- core | Что сохраняется у процесса после успешного `exec`? — PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC`
+- core | Почему `exec` при успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом
+- base | Разница между `execlp`, `execv`, `execvp`? — `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`
+
+Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно?
+
## 3. wait/waitpid и коды возврата
-Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код
-возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает
-память, но занимает слот в таблице процессов — их накопление плохо.
+Завершившийся ребёнок не исчезает: когда он вызывает `exit()`, ядро не удаляет его
+`task_struct` немедленно, а сохраняет минимальную запись (PID, код возврата, статистику
+использования ресурсов), пока родитель не заберёт её через `wait`/`waitpid`. Такой процесс
+называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память данных, но занимает
+слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе.
+Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме
+как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего
+сообщить не может.
- `wait(&status)` — ждёт любого ребёнка;
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
-Разбор `status` делается макросами, а не вручную:
+Разбор `status` делается макросами, а не вручную, потому что в одном `int` закодированы
+сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
```cpp
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
```
+Дополнительно есть `WIFSTOPPED`/`WSTOPSIG` (ребёнок остановлен, например, по `SIGSTOP`) и
+`WCOREDUMP(status)` (завершение по сигналу сопровождалось дампом памяти на диск, `core`).
+
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
- **139 = 128 + 11** → SIGSEGV (падение по памяти);
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
-**Сирота** — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби.
+**Сирота** — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1,
+либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому
+сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но
+нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал
+`wait`; сирота — родитель умер, но дождаться его теперь придётся init.
+
+**Ловушки**
+- Родитель никогда не вызывает `waitpid` для завершившихся детей → записи зомби копятся →
+ видно как растущий список `ps aux | grep Z`, в пределе — упор в лимит PID.
+- Сравнивать `status` напрямую с кодом возврата вместо `WEXITSTATUS(status)` → в `status`
+ закодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем,
+ что вернула программа.
+- Долгоживущий процесс с `fork`-воркерами не занимается сбором детей вообще → зомби
+ накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни
+ аптайма ростом числа `Z`-процессов.
+
+**Факты для карточек**
+- base | Зачем нужен `waitpid`? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов
+- core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait`
+- core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1)
+- base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу
+- core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую
+- deep | Что показывает `WCOREDUMP(status)`? — что завершение по сигналу сопровождалось записью core-дампа на диск
+
+Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет?
## 4. Сигналы
-Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9,
-нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11),
-SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился).
+Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное
+исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя
+действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить).
+Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя
+перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается),
+SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe),
+SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился).
-Обработчик ставится `sigaction` (надёжнее устаревшего `signal`), внутри обработчика можно
-менять только `volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe).
+Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности
+управления системой: администратору и супервизору всегда нужен гарантированный способ
+остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик
+сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика,
+снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM
+устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы,
+сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают
+SIGTERM и ждут; так, `systemctl stop`/`docker stop` по умолчанию ждут несколько секунд
+(в systemd таймаут задаётся `TimeoutStopSec`, по умолчанию около 90 секунд) и только затем,
+если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные
+на диск, и незавершённая операция может остаться в неконсистентном состоянии.
+
+Обработчик ставится `sigaction` (надёжнее устаревшего `signal` — поведение `signal`
+исторически различалось между Unix-системами), внутри обработчика можно менять только
+`volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe: `malloc` не
+реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур,
+что при вызове `malloc`/`printf` из обработчика способно повредить кучу или подвесить
+процесс).
```cpp
static volatile sig_atomic_t stop = 0;
@@ -91,12 +211,35 @@ int main() {
}
```
+**Ловушки**
+- Вызвать `printf`/`malloc`/`free` внутри обработчика сигнала → не async-signal-safe → в
+ редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу
+ ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой.
+- Использовать `signal()` вместо `sigaction()` → поведение (сброс обработчика в default
+ после первого срабатывания, поведение при повторном сигнале) исторически различается
+ между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой.
+- Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик →
+ `docker stop`/`systemctl stop` в итоге шлют SIGKILL по таймауту → в логах виден резкий
+ обрыв процесса без финализации (незакрытые файлы, недописанные данные).
+
+**Факты для карточек**
+- base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17
+- core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую
+- core | Что можно делать внутри обработчика сигнала? — только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe)
+- base | Чем `sigaction` лучше `signal`? — поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику
+- deep | Что происходит, если сервис игнорирует SIGTERM при `systemctl stop`? — по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно
+
+Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше?
+
## 5. errno и возвраты системных вызовов
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
-можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить;
-EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже;
-EINPROGRESS — неблокирующее соединение в процессе.
+можно только сразу после ошибки, до вызова любой другой функции, которая может сама
+переписать `errno` (например, `printf` при внутренней ошибке форматирования). EINTR — вызов
+прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе,
+повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся
+в лимит открытых дескрипторов (`ulimit -n`), диагностируется через `lsof -p PID` или
+`/proc/PID/fd`.
```cpp
ssize_t n = read(fd, buf, sizeof buf);
@@ -107,6 +250,20 @@ if (n < 0) {
}
```
+**Ловушки**
+- Проверить `errno` без предварительной проверки, что вызов вообще вернул ошибку → `errno`
+ может быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание.
+- Вызвать любую функцию (даже `printf`) между системным вызовом и чтением `errno` →
+ промежуточный вызов может переписать `errno` → в обработчике окажется код чужой ошибки.
+
+**Факты для карточек**
+- base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов
+- base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже»
+- core | Когда безопасно читать `errno`? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать
+- deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся `ulimit -n`)
+
+Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым?
+
## 6. Разбор примера: мини-шелл на 40 строк
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
@@ -157,11 +314,28 @@ int main() {
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
-несуществующая команда даёт 127.
+несуществующая команда даёт 127. Дочерний процесс перед `execvp` уже унаследовал от
+родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через `fork`, поэтому вывод запущенной
+программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не
+требуется перенаправление в файл или pipe.
Проверка на утечки и падения — санитайзеры:
`g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell`
+**Ловушки**
+- Не проверять, пуст ли `argv` перед `execvp` → `execvp(nullptr, ...)` на пустой строке →
+ неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже
+ предусмотрено проверкой `if (argv.empty()) continue;`, но при рефакторинге легко потерять).
+- Забыть `_exit(127)` после неудачного `execvp` → дочерний процесс продолжит исполнять
+ тело цикла `while` наравне с родителем → двойной ввод команд из одного терминала.
+
+**Факты для карточек**
+- base | Какой код возврата у шелла даст несуществующая команда? — 127
+- base | Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork`
+- core | Каким флагом собрать бинарник для проверки на утечки и UB? — `-fsanitize=address,undefined -fno-omit-frame-pointer`
+
+Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ?
+
## 7. Что спросят на собеседовании (готовые ответы)
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
@@ -190,6 +364,8 @@ int main() {
3. Что такое зомби и кто его убирает?
4. Откуда код возврата 137 и 139?
5. Почему `exec` не возвращает управление при успехе?
+6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет?
+7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
Ответы
@@ -200,9 +376,13 @@ int main() {
если родитель умер).
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
+6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`.
+7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт
+ SIGKILL принудительно.
## 10. Задачи дня
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
+