Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv

This commit is contained in:
Kodlo-chan
2026-09-24 13:38:58 +07:00
parent e0ad8e0fee
commit 4259fbce75
17 changed files with 1688 additions and 115 deletions
+1
View File
@@ -10,6 +10,7 @@
- `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые - `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые
ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них. ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них.
- `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы). - `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref).
- `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма - `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма
(«почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом. («почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом.
- `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git, - `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git,
+167
View File
@@ -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<p≤32) `0xFFFFFFFF << (32-p)`; для p=0 маска = 0 отдельным случаем (сдвиг на 32 — UB) задача 13_subnet
base net Как получить network и broadcast из ip и mask `network = ip & mask`, `broadcast = network | ~mask` задача 13_subnet
core net Формула host_count для prefix ≤ 30 `2^(32-prefix) - 2` задача 13_subnet
core net Почему /31 исключение и даёт 2 узла вместо 0 по общей формуле? — RFC 3021: у канала точка-точка ровно 2 узла, broadcast не нужен, поэтому оба адреса 2-адресного блока отдаются под хосты вместо потери половины блока задача 13_subnet
core net Что возвращает subnet_of для /32 network=broadcast=first_host=last_host=ip, host_count=1 (host route/loopback) задача 13_subnet
core net Формула `prefix_for_hosts(h)` `32 - ceil(log2(h+2))`, из условия `2^(32-prefix) >= h+2` задача 13_subnet
deep net Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1 /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами задача 13_subnet
Can't render this file because it contains an unexpected character in line 108 and column 45.
+27 -7
View File
@@ -8,19 +8,26 @@
протоколах. протоколах.
- **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы - **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы
вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы. вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы.
На практике `bswap` нужен ровно потому, что сеть передаёт многобайтовые поля в network byte
order (big-endian), а x86 внутри — little-endian: без разворота байт число читается неверно.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`. - **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`.
## Глава II. Что нужно знать до старта ## Глава II. Что нужно знать до старта
Четыре инструмента, которых достаточно: Четыре инструмента, которых достаточно:
1. `x & (x - 1)` — гасит самый младший единичный бит. Отсюда классика: число единиц можно 1. `x & (x - 1)` — гасит самый младший единичный бит. Механизм: в дополнительном коде `x - 1`
считать циклом, пока `x` не станет нулём. переворачивает все нули справа от младшего единичного бита в единицы, а сам этот бит — в ноль,
биты выше не трогает; операция `&` с исходным `x` поэтому обнуляет ровно один бит — младший
единичный. Отсюда классика: число единиц можно считать циклом, пока `x` не станет нулём, и
число итераций равно числу единичных бит, а не 32.
2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо. 2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо.
3. `x & (1u << k)` — проверка k-го бита. 3. `x & (1u << k)` — проверка k-го бита.
4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного 4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного
числа. Комбинация «сдвинул и сложил» даёт любой порядок байт. числа. Комбинация «сдвинул и сложил» даёт любой порядок байт.
Почему дальше: та же логика масок и сдвигов нужна в задаче 04 — разбор IPv4-заголовка byte-по-byte.
## Глава III. Задание ## Глава III. Задание
Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`, Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`,
@@ -83,12 +90,25 @@ uint32_t bswap32(uint32_t x); // поменять порядок ба
Если перепутать — биты уедут на одну позицию. Если перепутать — биты уедут на одну позицию.
</details> </details>
## Глава VII. Частые ошибки ## Глава VII. Ловушки
- Сдвиги в `int` вместо `uint32_t` → UB, UBSAN ругается. - Сдвиги в `int` вместо `uint32_t` → сдвиг `1 << 31` для знакового типа — UB → UBSAN падает
- Забыт ноль в `is_power_of_two`. на этой строке с диагностикой конкретного сдвига.
- В `bswap32` перепутаны направления сдвигов (влево/вправо) — проверь на `0x11223344`. - Забыт ноль в `is_power_of_two` → `0 & (0 - 1) == 0`, функция возвращает `true` на нуле →
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому. тест на `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)
## После сдачи ## После сдачи
+60 -1
View File
@@ -1,5 +1,17 @@
# Задача 02 — связный список (C++) # Задача 02 — связный список (C++)
## Глава I. Общая информация
- **Цель:** развернуть список, найти середину и определить цикл — без единой дополнительной
аллокации, только перелинковка указателей.
- **Почему это в Eltex:** списки соединений, очереди задач, таблицы состояний — везде связные
структуры. Приём двух указателей (slow/fast), которым решаются `find_middle` и `has_cycle`,
— это алгоритм Флойда, тот же паттерн всплывает при поиске циклов в графах зависимостей
и в обходе кольцевых структур.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 02`.
## Глава II. Что нужно знать до старта
Даны функции над односвязным списком `Node{int value; Node* next;}`: Даны функции над односвязным списком `Node{int value; Node* next;}`:
```c ```c
@@ -8,10 +20,57 @@ Node* find_middle(Node* head); // середина; для чётной дл
bool has_cycle(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) дополнительной памяти, один проход там, где это возможно; - никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно;
- `find_middle` и `has_cycle` — через два указателя (медленный/быстрый); - `find_middle` и `has_cycle` — через два указателя (медленный/быстрый);
- корректная работа с пустым списком и списком из одного узла. - корректная работа с пустым списком и списком из одного узла.
## Глава IV. Критерии приёмки
Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте
считаются ошибкой — тест сам освобождает память). считаются ошибкой — тест сам освобождает память).
## Глава V. Ловушки
- Забыть проверку `head == nullptr` в начале любой из трёх функций → разыменование нулевого
указателя → падение/ASAN SEGV на пустом списке.
- В `find_middle` неверное условие остановки цикла (`fast->next` без проверки самого `fast`
на `nullptr`) → на списке чётной длины либо возвращается не тот из двух средних узлов, либо
падение на последнем шаге.
- В `reverse_list` переставить `cur->next = prev` до сохранения старого `cur->next` во
временную переменную → потеря хвоста списка, обход обрывается раньше конца.
- Рекурсивный `reverse_list` вместо итеративного → лишняя память стека на каждый вызов →
нарушение требования O(1), на длинном списке возможен stack overflow.
**Факты для карточек**
- base | Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей? — в 2 раза
- core | Почему рекурсивный разворот списка не годится под требование O(1) памяти? — каждый
вызов кладёт кадр в стек, суммарно O(n) памяти
- core | На чём основано доказательство, что `fast` догонит `slow` при цикле? — внутри цикла
расстояние между ними сокращается на 1 узел за шаг
- base | Сколько дополнительных указателей нужно для итеративного разворота списка? — 3
(`prev`, `cur`, `next`)
## После сдачи
Разбор: почему алгоритм Флойда работает за O(n) времени и O(1) памяти в обоих режимах
(поиск середины и поиск цикла), и как тот же приём переносится на поиск цикла в графе.
+33 -10
View File
@@ -7,18 +7,26 @@
- **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов. - **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов.
- **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками — - **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками —
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
собеседовании. собеседовании. Механизм тот же, что у сетевой карты: пакеты пишутся в кольцо по мере
прихода, драйвер вычитывает их с другого конца, и обе стороны никогда не двигают уже
записанные данные по памяти — двигаются только индексы `head`/`tail`.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`. - **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`.
## Глава II. Что нужно знать до старта ## Глава II. Что нужно знать до старта
Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда
читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`. читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`.
Сдвигать элементы не нужно никогда — в этом весь смысл. Сдвигать элементы не нужно никогда — в этом весь смысл: `push`/`pop` двигают только индекс,
а не байты в памяти, поэтому обе операции остаются O(1) независимо от размера буфера.
Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от
полного, если оба индекса совпали?** Варианты: хранить счётчик `size`, либо оставлять одну полного, если оба индекса совпали?** При `head == tail` возможны оба состояния — буфер мог
ячейку свободной. В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик. быть только что создан (пуст) или заполнен ровно `capacity` раз (полон) — по одним индексам
это не различить. Варианты: хранить счётчик `size`, либо оставлять одну ячейку свободной.
В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
Почему дальше: тот же счётчик `size_` вместо пересчёта по индексам — частый приём и в
`std::deque`, и в реализациях lock-free очередей, где индексы вообще нельзя лишний раз читать.
## Глава III. Задание ## Глава III. Задание
@@ -89,14 +97,29 @@ public:
`std::vector<int>` — тогда вопрос исчезает. `std::vector<int>` — тогда вопрос исчезает.
</details> </details>
## Глава VII. Частые ошибки ## Глава VII. Ловушки
- Сдвиг элементов вместо индексов (теряется весь смысл O(1)). - Сдвиг элементов вместо индексов → цена `push`/`pop` вырастает до O(n) → теряется весь
- Путаница «пусто/полно» при совпавших индексах. смысл структуры, видно по сравнению с наивным `std::vector` на бенчмарке.
- Путаница «пусто/полно» при совпавших индексах (`head_ == tail_`) без отдельного `size_` →
`full()` и `empty()` дают одинаковый ответ на разных состояниях → тест на заполненный буфер
ёмкости > 1 падает.
- `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании - `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`). спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`, деление по модулю дороже
- Копирование объекта без правила трёх → двойное освобождение. Если сдаёшь с ручным `new[]`, сравнения).
запрети копирование (`= delete`). - Копирование объекта без правила трёх/пяти → два объекта владеют одним и тем же `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;`
## После сдачи ## После сдачи
+61 -2
View File
@@ -1,7 +1,35 @@
# Задача 04 — разбор IPv4-пакета и контрольная сумма (C++) # Задача 04 — разбор IPv4-пакета и контрольная сумма (C++)
Это то, что реально делают сетевые железки: взять буфер из сокета и корректно разобрать ## Глава I. Общая информация
заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
- **Цель:** разобрать буфер байт из сокета в структуру заголовка вручную, безопасно и без
UB — то, что реально делают сетевые железки на приёмном тракте.
- **Почему это в Eltex:** это то, что реально делают сетевые железки: взять буфер из сокета
и корректно разобрать заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
Коммутатор/маршрутизатор обязан отбросить пакет с битой контрольной суммой или враньём в
`total_length`, иначе он либо читает чужую память, либо пересылает мусор дальше.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 04`.
## Глава II. Что нужно знать до старта
Минимальная длина IPv4-заголовка — 20 байт (без опций). Байт-порядок в заголовке смешанный:
`total_length` и `header_checksum` хранятся в хост-порядке в структуре `Ipv4Header` (в задании
явно указано «хост-порядок» — значит в `parse_ipv4` их нужно правильно собрать из сетевого
порядка на проводе), а IP-адреса (`src_ip`, `dst_ip`) — «байты как на проводе»:
`10.0.0.1 -> 0x0A000001`, то есть без перестановки байт при сборке в `uint32_t`.
Почему нельзя просто `reinterpret_cast<const Ipv4Header*>(buf)`: у `buf` нет гарантии
выравнивания под `uint32_t` (нужно кратно 4 байтам) — компилятор вправе сгенерировать код,
предполагающий выровненный доступ, и на невыровненном адресе это UB (UBSAN ловит как
misaligned address); плюс раскладка полей в `Ipv4Header` определяется компилятором
(padding), а не байтовым форматом протокола — сети всё равно, как выровнены поля в C++-структуре.
Правильный путь — читать каждый байт по отдельности и собирать сдвигами (тот же приём, что
в задаче 01: маски и сдвиги для сборки многобайтового числа из байт).
Почему дальше: если разбор заголовка освоен, логичный следующий шаг — контрольная сумма TCP/UDP
поверх псевдозаголовка, которая считается тем же алгоритмом сложения в дополнительном коде.
## Глава III. Задание
```c++ ```c++
struct Ipv4Header { struct Ipv4Header {
@@ -30,3 +58,34 @@ bool checksum_valid(const uint8_t* buf, size_t len);
Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые. Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые.
Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть — Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть —
на невыровненных адресах и в другом порядке байт это UB. на невыровненных адресах и в другом порядке байт это UB.
## Глава IV. Ловушки
- `reinterpret_cast<Ipv4Header*>(buf)` вместо побайтового разбора → чтение по невыровненному
адресу и/или с padding-полями структуры вместо реального формата пакета → UB, UBSAN
сообщает misaligned address, а на реальных данных поля читаются со сдвигом.
- Не обнулить поле `header_checksum` перед вызовом `compute_checksum` → в сумму попадает
старое значение поля → результат не совпадёт с ожидаемым, тест на `compute_checksum` падает.
- Пропустить перенос (carry) при сложении 16-битных слов в дополнительном коде, если
промежуточная сумма считается в 16-битном типе → биты переноса теряются вместо того чтобы
прибавиться обратно к младшим битам → неверная контрольная сумма на буферах, где сумма слов
переполняет 16 бит.
- Проверить `ihl*4 > len` до проверки `len >= 20` (или в неверном порядке) → чтение поля `ihl`
из буфера короче 1 байта → выход за границы, ASAN сообщает heap-buffer-overflow.
**Факты для карточек**
- base | Минимальная длина IPv4-заголовка без опций? — 20 байт
- base | Код `protocol` для TCP / UDP / ICMP? — 6 / 17 / 1
- core | В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию? — «как на проводе»,
без перестановки байт при сборке в `uint32_t`
- core | Почему `reinterpret_cast` буфера в `Ipv4Header*` — UB? — нет гарантии выравнивания
адреса под 4-байтные поля структуры
- deep | Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем
checksum) при верной контрольной сумме? — 0xFFFF
## После сдачи
Разбор: как поле `header_checksum` защищает именно заголовок (не данные), почему на каждом
маршрутизаторе TTL уменьшается на 1 и, как следствие, контрольную сумму заголовка приходится
пересчитывать заново на каждом хопе, и чем отличается контрольная сумма TCP/UDP (считается с
псевдозаголовком) от чисто IP.
+61 -1
View File
@@ -1,5 +1,40 @@
# Задача 05 — длиннейшая возрастающая подпоследовательность (C++) # Задача 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++ ```c++
int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей) int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей)
``` ```
@@ -11,4 +46,29 @@ int lis_length(const std::vector<int>& a); // длина НВП (строго
- подпоследовательность не обязана быть непрерывной. - подпоследовательность не обязана быть непрерывной.
Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений. Проверка: `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` при необходимости восстановить саму
подпоследовательность (дополнительный массив предшественников).
+103 -7
View File
@@ -1,7 +1,10 @@
# Задача 06 — потокобезопасная ограниченная очередь (C++) # Задача 06 — потокобезопасная ограниченная очередь (C++)
Классический вопрос на собеседовании в embedded/сетевую разработку: не «знаешь ли ты На собеседовании это не вопрос «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
std::thread», а «умеешь ли ты не сломать счётчик под нагрузкой». счётчик под нагрузкой» — то есть понимаешь ли механику мьютекса и условной переменной
настолько, чтобы очередь не зависла и не словила гонку данных под ThreadSanitizer.
## Интерфейс и требования
```c++ ```c++
class BlockingQueue { class BlockingQueue {
@@ -16,12 +19,105 @@ public:
``` ```
Требования: Требования:
- push после close() бросает `std::runtime_error`; - `push` после `close()` бросает `std::runtime_error`;
- закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait); - закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait);
- размер очереди никогда не превышает capacity; - размер очереди никогда не превышает `capacity`;
- ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()` - ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()`
вызывается из другого потока — он тоже должен быть потокобезопасным). вызывается из другого потока — он тоже должен быть потокобезопасным).
Проверка: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. **Факты для карточек**
Разбор после сдачи: почему нужен predicate-цикл вокруг wait, и как один condition_variable - base | Каким флагом компилятора включается ThreadSanitizer? — `-fsanitize=thread`
на оба события даёт ложные пробуждения. - base | Что бросает `push` после `close()`? — `std::runtime_error`
- core | Должен ли `size()` быть потокобезопасным в этой задаче? — да, его тоже вызывают из другого потока и он тоже под захватом мьютекса
- core | Что происходит с ждущими потоками при `close()`? — все разблокируются (никакого вечного `wait`)
Почему дальше: раз очередь общая между потоками, нужен механизм, который защищает и её
состояние, и само ожидание — мьютекс плюс условная переменная.
## Механизм: mutex + condition_variable + predicate-цикл
Общее состояние очереди (буфер, счётчик элементов, флаг `closed`) защищено одним
`std::mutex` через RAII-обёртку (`std::lock_guard`/`std::unique_lock`) — так `unlock()`
происходит автоматически в деструкторе при выходе из области видимости любым путём,
включая исключение, и его невозможно забыть вызвать вручную.
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток — атомарность
важна: если бы освобождение и уход в сон были двумя отдельными шагами, между ними мог бы
вклиниться другой поток, изменить состояние и отправить `notify`, которое тогда потеряется,
потому что ждущий поток ещё не успел зайти в `wait`. При пробуждении `wait` снова захватывает
тот же мьютекс перед тем, как вернуть управление — код после `wait` уже работает под защитой.
Стандарт C++ прямо разрешает ложные пробуждения (spurious wakeup) — `wait` может вернуться
без единого вызова `notify_one`/`notify_all`, просто по решению ОС (futex на Linux иногда
пробуждается по внутренним причинам платформы). Из-за этого `if (!ready) cv.wait(lock);`
недостаточно: после такого пробуждения предикат мог остаться ложным, и поток пойдёт работать
с данными, которые на самом деле не готовы — баг, который не ловится юнит-тестами и стреляет
только под конкурентной нагрузкой. Правильный паттерн — цикл с перепроверкой:
```c++
std::unique_lock<std::mutex> lock(mtx_);
while (!(size_ > 0 || closed_)) // predicate-цикл, не if
not_empty_.wait(lock);
```
Для `push` и `pop` нужны разные условия ожидания (`не полна` и `не пуста` или `закрыта`),
поэтому один `condition_variable` на оба события удобен только тогда, когда `notify_all`
будит все ждущие потоки и каждый сам перепроверяет свой предикат в цикле — так ложные
пробуждения для «не того» условия просто не проходят проверку и поток снова засыпает.
Если использовать `notify_one` при одном общем `condition_variable` для двух разных условий,
можно разбудить не тот поток (например, разбудить ждущего `push`, когда освободилось место
для `pop`), а нужный так и останется спать — отсюда правило: либо `notify_all`, либо два
раздельных `condition_variable`.
**Ловушки**
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
- Изменение состояния без удержания мьютекса (например, `size_++` вне `lock_guard`) →
гонка данных, UB → ловится ThreadSanitizer как data race при `size()` из другого потока.
- `notify_one` при общем `condition_variable` на два разных предиката → не тот поток проснулся,
нужный продолжает ждать → зависание, видно по таймауту теста, а не по явной ошибке.
- Забыть разбудить всех при `close()` → часть потоков навсегда в `wait` → зависание процесса,
тест не завершается за отведённое время.
**Факты для карточек**
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
- core | Почему `while` вокруг `wait`, а не `if`? — из-за spurious wakeup: `wait` может вернуться без вызова notify
- core | Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one`? — можно разбудить не тот поток, а нужный останется ждать
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — futex
Почему дальше: раз тест явно требует ThreadSanitizer и отсутствия зависаний, логично понять,
что именно проверяет `grade.py` и почему «просто работает на глаз» здесь недостаточно.
## Проверка
Запуск: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. TSan
инструментирует каждое обращение к памяти и отслеживает happens-before между потоками во
время исполнения — то есть ловит гонку по факту конкретного запуска, а не статическим
анализом кода, поэтому «прогнать разок и не увидеть краша» не равно «гонки нет»: раскладка
потоков в конкретном запуске могла просто не проявить проблему.
**Факты для карточек**
- base | Какой командой запускается проверка задачи 06? — `python3 grade.py 06`
- core | Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом
<details>
<summary>Проверь себя</summary>
1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же
мьютекс, что защищает очередь, а не произвольный лок?
<details><summary>Ответ</summary>Потому что `wait` должен атомарно освободить именно этот
мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы
предикат под разными блокировками, и защита состояния очереди перестала бы работать.</details>
2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста?
<details><summary>Ответ</summary>Требование — `close()` опустошает остаток: `pop()` должен
продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно
пуста — отдавать `false`, не уходя в ожидание.</details>
3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует
захвата мьютекса?
<details><summary>Ответ</summary>Потому что запись в `size_` из `push`/`pop` без
синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает
без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».</details>
</details>
+106 -4
View File
@@ -4,14 +4,116 @@
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно, принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
завершается по SIGTERM/SIGINT аккуратно (exit 0). завершается по SIGTERM/SIGINT аккуратно (exit 0).
Требования: ## Требования
- мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента); - мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента);
- сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи; - сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи;
- `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту; - `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту;
- до 64 одновременных соединений, закрытие по EOF со стороны клиента; - до 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 <порт>`. Запускается как самостоятельный бинарник: `./solution <порт>`.
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, льёт
льёт данные, проверяет эхо и корректное завершение по SIGTERM. данные, проверяет эхо и корректное завершение по SIGTERM. Критерий: все `ok` + ревью кода
Критерий: все `ok` + ревью кода (использование epoll, обработка ошибок). (использование epoll, обработка ошибок).
**Факты для карточек**
- base | Сколько соединений открывает тест `grade.py 07`? — 3
- base | Каким сигналом тест проверяет корректное завершение сервера? — SIGTERM
<details>
<summary>Проверь себя</summary>
1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это
покрыло все данные, которые были на момент события?
<details><summary>Ответ</summary>Потому что ET сообщает о фронте один раз; если между этим
чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел,
можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт
`EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.</details>
2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
<details><summary>Ответ</summary>Требование — до 64 одновременных соединений одним потоком;
блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные —
отсюда обязательность `epoll` и неблокирующих сокетов.</details>
3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
<details><summary>Ответ</summary>Это не ошибка: буфер сокета ограничен по размеру, ядро
приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим
вызовом `write`, когда `EPOLLOUT` снова покажет готовность.</details>
</details>
+121 -1
View File
@@ -11,7 +11,8 @@ TOP <ip> <число запросов>
5XX <число ответов со статусом 500-599> 5XX <число ответов со статусом 500-599>
``` ```
Правила: ## Правила формата
- IP — первое поле строки; - IP — первое поле строки;
- TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию - TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию
строки IP (лексикографически); строки IP (лексикографически);
@@ -20,6 +21,125 @@ TOP <ip> <число запросов>
по времени — нужен awk/sort или один-два прохода; по времени — нужен awk/sort или один-два прохода;
- пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет. - пустой файл: `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` Запуск: `bash solution.sh access.log`
Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе). Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе).
Критерий: точное совпадение вывода, код возврата 0. Критерий: точное совпадение вывода, код возврата 0.
**Факты для карточек**
- base | Каким кодом возврата должен завершаться `solution.sh` при успехе? — 0
- core | Что именно сверяет `grade.py 08` с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
<details>
<summary>Проверь себя</summary>
1. Почему пайплайн `grep 500 access.log | wc -l` под одним `set -e` (без `pipefail`) может
молча пропустить ошибку `grep` (например, если файл не существует)?
<details><summary>Ответ</summary>Код возврата конвейера по умолчанию — это код возврата
последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней
упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую
упавшую команду.</details>
2. Почему `local total=$(false)` не остановит скрипт при `set -e`, а `total=$(false)` (без
`local` на той же строке) — остановит?
<details><summary>Ответ</summary>В первом случае итоговый код возврата строки — это код
возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе
объявления независимо от результата подстановки внутри; во втором случае код возврата
присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e`
сработает.</details>
3. Почему построчный `while read ip status rest; do ...; done < access.log` с миллионом строк
не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?
<details><summary>Ответ</summary>Каждая итерация цикла — это работа самого интерпретатора
bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на
порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл
целиком за один запуск процесса.</details>
</details>
+125 -12
View File
@@ -3,21 +3,134 @@
В `crash.c` лежит программа, которая падает на части входов и портит память. В `crash.c` лежит программа, которая падает на части входов и портит память.
Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**. Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**.
Ожидаемое поведение после починки: ## Ожидаемое поведение после починки
- `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0; - `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0;
- `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0; - `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0;
- `./crash ""` печатает `len=0` и завершается с кодом 0; - `./crash ""` печатает `len=0` и завершается с кодом 0;
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке. - сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
Что сделать: **Факты для карточек**
1. Собрать с отладочной информацией и санитайзерами: - base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0
`gcc -g -O0 -fsanitize=address,undefined crash.c -o crash` - base | Что печатает `./crash ""` после починки? — `len=0`, код возврата 0
2. Разобраться, что именно портит память, а что приводит к падению при выходе. - core | Какие два санитайзера должны не давать сообщений после починки? — ASan и UBSan (`-fsanitize=address,undefined`)
Полезно: `gdb ./crash`, `run`, `bt`, `frame`, `info locals`, `watch`.
3. Исправить `crash.c` (минимальные правки, стиль сохранить).
4. Заполнить `answer.txt`: сколько дефектов нашёл, какие именно, какими командами
отладчика это подтвердил (по шагам), почему падало именно так.
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN Почему дальше: чтобы вообще видеть номера строк и имена переменных в отладчике, а не голый
и прогоняет 5 входов. ассемблер, программу нужно собрать определённым образом — с этого и начинается разбор.
`answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а не только результат).
## Сборка с отладочной информацией и санитайзерами
```
gcc -g -O0 -fsanitize=address,undefined crash.c -o crash
```
`-g` встраивает в бинарник отладочную информацию в формате DWARF — таблицу соответствия
машинных адресов номерам строк исходника, именам переменных, их типам и границам функций.
Именно из DWARF gdb берёт всё, что показывает человеку: `bt` печатает имена функций и строки
вместо голых адресов, `print x` знает тип `x` и умеет напечатать его по правилам этого типа
(структуру — по полям, массив — по элементам), `list` показывает исходный код вокруг текущей
точки. Без `-g` DWARF-секций в бинарнике нет, и gdb видит только адреса и ассемблер.
`-O0` отключает оптимизации компилятора: оптимизатор переставляет и удаляет инструкции,
инлайнит функции и переиспользует один регистр под несколько переменных с непересекающимся
временем жизни — из-за этого отладочная информация от `-O2` часто не соответствует
однозначно исходному коду (строки «прыгают», переменная в `print` показывает не то значение,
потому что регистр уже переиспользован под другую переменную). Отсюда правило: для отладки
всегда пересобирают отдельно с `-g -O0`, а не отлаживают прод-сборку с оптимизациями.
`-fsanitize=address,undefined` — это ASan и UBSan вместе, инструментирование на этапе
компиляции, а не отдельный инструмент поверх готового бинарника:
- **ASan** окружает каждый выделенный блок памяти недоступными «красными зонами» и ведёт
теневую карту состояния памяти — ловит выход за границы буфера, use-after-free и двойное
освобождение прямо в момент обращения, с точным стеком, а не когда испорченные данные
позже вызовут крах в случайном другом месте;
- **UBSan** инструментирует места, где поведение по стандарту C не определено — знаковое
переполнение, сдвиг за пределы разрядности типа, разыменование `NULL` с невалидным типом —
и печатает конкретную строку кода нарушения.
**Ловушки**
- Собрать без `-g` и пытаться понять крах по голому `bt` с адресами вместо строк → отладка
вслепую по ассемблеру, не соответствует задаче «найти минимальными правками».
- Отлаживать сборку с `-O2` → строки в `bt`/`print` не совпадают с реальным местом бага
из-за инлайнинга и переиспользования регистров → ложный след при поиске дефекта.
- Пропустить пересборку с `-fsanitize=address,undefined` перед финальной проверкой → баг,
который не падает явным креша при обычном запуске (например, чтение за границей внутри
тем же выделенного блока), останется незамеченным до `grade.py`.
**Факты для карточек**
- base | Что даёт флаг `-g` при сборке для gdb? — отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных
- core | Почему для отладки собирают с `-O0`, а не с `-O2`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику
- core | Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение? — все три, в момент обращения к памяти, а не постфактум
- deep | Что именно ловит UBSan, в отличие от ASan? — неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью
Почему дальше: раз отладочная информация уже в бинарнике, нужно знать конкретные команды
gdb, которыми по ней ходят при разборе краша.
## Команды gdb для разбора краша
Рабочий цикл: `gdb ./crash`, затем `run [аргумент]` — передаёт управление процессу; если
процесс получает сигнал (обычно `SIGSEGV` от порчи памяти или abort от санитайзера), gdb сам
останавливается на инструкции, вызвавшей сбой.
- `bt` — стек вызовов, цепочка кадров от текущей функции до `main`; первое, на что смотрят
после остановки — показывает, в какой функции и через какие вызовы дошли до краша.
- `frame N` — переключает контекст `print`/`list` на конкретный кадр стека из `bt`, чтобы
посмотреть локальные переменные вызывающей функции, а не только текущей.
- `info locals` — печатает все локальные переменные текущего кадра с их значениями по
информации из DWARF.
- `watch var` — аппаратная точка наблюдения: останавливает выполнение при каждом изменении
значения переменной. Нужна, когда переменная меняется как будто сама по себе из другого
места кода (типичный симптом переполнения буфера, которое затирает соседнюю память) — без
`watch` пришлось бы расставлять точки останова по подозрению вручную.
- `print x` — печатает текущее значение `x`, по типу из DWARF (структуру — по полям).
Санитайзер добавляет отдельный слой: при нарушении (ASan/UBSan) программа останавливается
сама, печатая стек и описание нарушения ещё до того, как повреждение памяти успело привести
к крашу в другом, не связанном с причиной месте — это часто быстрее, чем ловить последствия
вручную в gdb по голому `SIGSEGV`.
**Факты для карточек**
- base | Какая команда gdb показывает цепочку вызовов до краша? — `bt`
- core | Чем `watch var` отличается от обычного `break`? — останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода
- core | Зачем нужен `frame N` после `bt`? — переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции
## Отчёт и проверка
Исправить `crash.c` (минимальные правки, стиль сохранить). Заполнить `answer.txt`: сколько
дефектов нашёл, какие именно, какими командами отладчика это подтвердил (по шагам), почему
падало именно так.
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN и
прогоняет 5 входов. `answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а
не только результат).
**Факты для карточек**
- base | Сколько входов прогоняет `grade.py 09`? — 5
- base | Каким компилятором собирается `crash.c` на проверке? — gcc (компилятор C)
<details>
<summary>Проверь себя</summary>
1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с `-O2` для
финальной проверки производительности?
<details><summary>Ответ</summary>Оптимизатор переставляет и удаляет инструкции, инлайнит
вызовы и переиспользует регистры под разные переменные — отладочная информация от такой
сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или
неверные значения; для отладки нужна отдельная сборка `-g -O0`.</details>
2. Программа падает с `SIGSEGV` без санитайзеров, но при сборке с ASan вместо краша печатается
сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?
<details><summary>Ответ</summary>ASan останавливает программу в момент фактического выхода
за границу буфера — в точке причины; без ASan повреждённая память могла не привести к
немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные
использовались уже в другом месте — это типичная картина «краш далеко от причины».</details>
3. Чем `watch var` полезнее обычной точки останова (`break`) для поиска места, где переменная
портится неожиданно?
<details><summary>Ответ</summary>`break` останавливает выполнение в заданной строке кода,
но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения,
которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже
если изменение происходит из неожиданного места (например, из-за записи за границей другого
буфера, затирающей соседнюю память).</details>
</details>
+109 -23
View File
@@ -14,17 +14,52 @@
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне. - **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`. - **Что сдаётся:** файл `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) & …`
и так по кругу, пока не найдём свободную (для вставки) или нужный ключ (для поиска). ## Глава II. Механизм: индекс, зондирование, tombstone, load factor
3. Удалять «в ноль» нельзя: если между началом зондирования и элементом появится пустая
ячейка, поиск до него не дойдёт. Поэтому пустая ячейка помечается **tombstone** — **Индекс через маску.** `idx = hash & (cap - 1)` работает как «остаток от деления», только
«здесь был элемент, иди дальше». если `cap` — степень двойки. Причина: у степени двойки `cap - 1` в двоичном виде — это
4. Фактор загрузки `load = size / capacity`. Держим ≤ 0.7: при 0.8+ пробеги становятся подряд идущие единицы во всех младших битах (например, `cap = 8 = 0b1000` → `cap-1 = 0b0111`).
длинными, и O(1) превращается в O(n). Операция `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. Задание ## Глава III. Задание
@@ -55,6 +90,12 @@ public:
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления - удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
и вставки возвращается к правильному значению. и вставки возвращается к правильному значению.
**Факты для карточек**
- base | Сколько ключей вставляет тест на кластеризацию и какие они? — 512 ключей, кратных 16
- base | За какое время должны пройти 100 000 вставок? — быстрее 2 секунд
- base | Какой порог load factor держит таблица? — ≤ 0.7
- base | Минимальная ёмкость таблицы по умолчанию? — 16, степень двойки
## Глава IV. Ступени (делай по одной, после каждой — прогон) ## Глава IV. Ступени (делай по одной, после каждой — прогон)
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
@@ -63,7 +104,10 @@ public:
**Ступень 1. Хеш-функция и индекс (20 минут).** **Ступень 1. Хеш-функция и индекс (20 минут).**
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей Напиши `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 должны Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16 дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
свалятся в одну ячейку. свалятся в одну ячейку.
@@ -87,9 +131,11 @@ Tombstone можно переиспользовать под вставку, н
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем. Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
**Ступень 6. Перехеширование (30 минут).** **Ступень 6. Перехеширование (30 минут).**
Когда `size * 10 > capacity * 7`, создай массив вдвое больше и **заново вставь все занятые Когда `size * 10 > capacity * 7` (то есть `load > 0.7`), создай массив вдвое больше и
ключи** (tombstone не переносим — в новой таблице пусто). Учти: после этого `size` не должен **заново вставь все занятые ключи** (tombstone не переносим — в новой таблице пусто).
измениться, а пробеги станут короче. Прогон: `python3 grade.py 10`. Учти: после этого `size` не должен измениться, а пробеги станут короче (см. формулу проб
из главы II — вдвое большая ёмкость при том же `size` резко снижает `load`, а с ним и
среднее число проб). Прогон: `python3 grade.py 10`.
**Ступень 7. Прогон под санитайзерами (10 минут).** **Ступень 7. Прогон под санитайзерами (10 минут).**
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь, `grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
@@ -133,16 +179,56 @@ Tombstone можно переиспользовать под вставку, н
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`. значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
</details> </details>
## Глава VII. Частые ошибки и как их не допустить ## Глава VII. Ловушки
- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая. - Забыть перемешать хеш (использовать `key` напрямую как индекс) → ключи, кратные 16,
- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на все попадают в одну-две ячейки → кластеризация, пробег растёт линейно с числом таких
медленных платформах деление дороже. ключей → тест на 512 ключей, кратных 16, падает по таймауту или по «not found».
- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место. - Забыть про tombstone при rehash (перенести признак `TOMBSTONE` в новую таблицу вместо
- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше того, чтобы его отбросить) → «мёртвые» ячейки переезжают и занимают место в новой
держать их в одной структуре ячейки. таблице без необходимости → эффективная ёмкость меньше заявленной, load factor растёт
- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги быстрее ожидаемого.
огромны, время уходит за лимит 2 секунды. - Хранить ключ отдельно от значения в параллельных массивах и перепутать индекс при
перестановке → ASAN поймает не сразу, а в момент обращения к рассинхронизированной
ячейке; надёжнее держать ключ и значение в одной структуре ячейки.
- Проверять `size == capacity` вместо load factor (`size * 10 > capacity * 7`) → таблица
почти полностью заполняется, пробеги становятся длиной в десятки-сотни ячеек (см. формулу
неудачного поиска при load → 1), суммарное время `put` уходит за лимит 2 секунды на
100 000 ключей.
## Проверь себя
<details>
<summary>1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы?</summary>
`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)`
перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой,
а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при
котором работает битовая маска вместо деления.
</details>
<details>
<summary>2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor
вырастет с 0.7 до 0.9?</summary>
Примерно в 2,5 раза: `0.5·(1+1/(1-0.7)) ≈ 2,2` против `0.5·(1+1/(1-0.9)) ≈ 5,5`.
Для неудачного поиска рост ещё резче — с ≈6,1 до ≈50,5, то есть почти в 8 раз.
</details>
<details>
<summary>3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже
такой ключ?</summary>
`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если
инкрементировать `size` без предварительного поиска ключа по цепочке зондирования,
повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт
элементов упадёт.
</details>
<details>
<summary>4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании?</summary>
В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только
реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы
место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами
цепочки проб после rehash пересчитываются заново.
</details>
## После сдачи ## После сдачи
+141 -1
View File
@@ -1,6 +1,7 @@
# Задача 11 — куча: построение за O(n) и top-K (C++) # Задача 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++ ```c++
void heapify(std::vector<int>& a); // перестроить массив в кучу void heapify(std::vector<int>& a); // перестроить массив в кучу
@@ -20,5 +21,144 @@ std::vector<int> top_k(const std::vector<int>& a, int k); // k наибо
Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений, Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений,
ASAN/UBSAN чистые. ASAN/UBSAN чистые.
**Факты для карточек**
- base | Индексы детей и родителя в max-heap на массиве? — дети `2i+1`, `2i+2`; родитель `(i-1)/2`
- base | Что за 2 секунды должны выполниться на 3 000 000 элементов? — `heapify` и `kth_largest` с `k=1000` (по отдельности)
- base | Какой ключевой запрет на реализацию `heapify`? — нельзя строить через `push` в цикле, только bottom-up за O(n)
Почему дальше: массив интерпретируется как дерево через индексную арифметику — дальше
нужно понять, как именно поддерживается инвариант кучи и почему bottom-up быстрее push-цикла.
## Механизм: массив как дерево, sift-down/sift-up
Массив хранится линейно (`std::vector<int>`), но интерпретируется как полное бинарное
дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без
указателей. Инвариант max-heap: значение в родителе не меньше значений в обоих детях.
**sift-up** (используется при вставке одного элемента): новый элемент кладётся в конец
массива и сравнивается с родителем; пока он больше родителя — меняются местами, индекс
сдвигается к корню. Длина пути от листа до корня — высота дерева, `O(log n)`.
**sift-down** (используется при извлечении максимума и при bottom-up heapify): элемент в
корне (или в произвольном узле) сравнивается с обоими детьми; если хотя бы один ребёнок
больше — меняется местами с большим из них, и процесс повторяется на новой позиции, пока
инвариант не восстановится или узел не станет листом. Тоже `O(log n)` на один вызов от
корня.
**top()** — доступ к максимуму — `O(1)`: по инварианту кучи максимум всегда лежит в корне,
то есть в начале массива, никакого поиска не требуется.
**Факты для карточек**
- base | Сложность доступа к максимуму (`top`)? — O(1), максимум всегда в корне по инварианту кучи
- base | Сложность одного `sift-up`/`sift-down` от произвольного узла? — O(log n) — путь ограничен высотой дерева
- core | Что сравнивается на каждом шаге `sift-down`? — узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла
## Механизм: почему bottom-up heapify — O(n), а push-цикл — O(n log n)
**Через push в цикле.** Вставка i-го элемента (при текущем размере кучи `i`) требует до
`O(log i)` шагов `sift-up`. Суммарная работа — `Σ log₂(i)` для `i = 1..n`, это `log₂(n!)`,
по формуле Стирлинга порядка `n·log₂(n)`. Итого `O(n log n)` — почти вся вставленная масса
элементов всплывает от листьев, где высота дерева максимальна (`~log n`), к своему месту.
**Через bottom-up heapify.** Алгоритм стартует с последнего нелистового узла (индекс
`n/2 - 1`) и идёт к корню (индекс `0`), вызывая `sift-down` на каждом узле. Ключевое
отличие: работа `sift-down` в узле пропорциональна **высоте этого узла `h`**, а не высоте
всего дерева. Узлов с большой высотой мало (в корне — один узел высоты `log n`), а узлов с
малой высотой — экспоненциально больше (листьев высоты 0 — примерно `n/2`, они вообще
пропускаются). Суммарная работа:
```
Σ h=0..log n (n / 2^(h+1)) · h
```
Ряд `Σ h/2^h` при `h → ∞` сходится к константе `2` (не растёт с `n`), поэтому вся сумма
ограничена `n · const = O(n)` — линейно, а не `n log n`. Для `n = 3 000 000`
(`log₂ n ≈ 21,5`) это даёт примерно на порядок (~10 раз) меньше операций сравнения, чем
push-цикл — именно поэтому требование задачи «bottom-up, а не push» не формальность, а
разница на порядок при 3 миллионах элементов.
**Факты для карточек**
- core | За какое время строится куча через n последовательных `push`? — O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n`
- core | За какое время строится куча через bottom-up heapify? — O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n`
- deep | С какого индекса начинается bottom-up heapify и в какую сторону идёт? — с последнего нелистового узла `n/2 - 1`, к корню (индекс 0)
- core | Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000? — примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up
Почему дальше: сама куча даёт максимум за O(1) и перестройку за O(log n) — на этом строятся
`kth_largest` и `top_k`, но у них есть более эффективная альтернатива для потоковых данных.
## kth_largest и top_k
Базовый подход: `heapify` за `O(n)`, затем `k` раз `pop` (извлечь максимум, `sift-down`
корня) — каждый `pop` стоит `O(log n)`. Итого `O(n + k log n)`. При `k = 1000` и
`n = 3 000 000` слагаемое `n` доминирует — отсюда требование «быстрее 2 секунд» выполнимо
за счёт того, что сама куча строится линейно.
Граничные случаи по условию: `k > n` — `top_k` отдаёт все элементы по убыванию, а
`kth_largest` — значение минимального элемента, без падения и без обращения за границу
массива.
**Потоковый top-K.** Когда данные не помещаются в память целиком (поток), вместо
построения полной кучи на все `n` элементов держат **min-heap размера k**: каждый новый
элемент сравнивается с минимумом кучи (`top`), и если новый элемент больше — минимум
удаляется, новый добавляется. Сложность — `O(n log k)` вместо `O(n log n)` для полной
сортировки, и память — `O(k)`, а не `O(n)`.
**nth_element vs куча.** `std::nth_element` (Хоара, quickselect) находит элемент на нужной
позиции и частично упорядочивает массив вокруг него (всё левее — не больше него, всё правее
— не меньше, но без порядка внутри частей) в среднем за `O(n)` — стандарт требует линейного
времени в среднем, но не гарантирует худший случай, в отличие от кучи, которая всегда даёт
`O(n + k log n)`. Для одного k-го элемента `nth_element` быстрее кучи по константе; для
top-K как **отсортированного** списка кучу всё равно придётся досортировать после
`nth_element`, тогда как `pop` из кучи уже отдаёт элементы по убыванию.
**Факты для карточек**
- base | Сложность `kth_largest`/`top_k` через полную кучу? — O(n + k log n): O(n) на heapify плюс k раз pop по O(log n)
- core | Когда используют min-heap размера k вместо полной кучи на весь массив? — на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k)
- core | Чем `std::nth_element` отличается от кучи для top-K? — в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop
## Ловушки
- Построить кучу через `push` в цикле вместо bottom-up `heapify` → машинная проверка
свойств кучи пройдёт (инвариант тот же), но сложность — O(n log n) вместо O(n) → на
3 000 000 элементов заметно медленнее и явно видно по коду при ревью (это как раз спросят
на собеседовании отдельным вопросом).
- Потерять или продублировать элементы при перестройке `sift-down` (например, скопировать
значение вместо обмена местами) → мультимножество элементов меняется → видно сравнением
отсортированной копии массива до и после `heapify`.
- Не ограничить `k` при `k > n` → обращение к `pop` на пустой куче или выход за границу
массива → падение или UB, видно по ASAN, а не просто неверный ответ.
## Проверь себя
<details>
<summary>1. Почему bottom-up heapify — O(n), хотя высота дерева log n?</summary>
Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте
всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты
`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится
к `n`, умноженному на константу, а не на `log n`.
</details>
<details>
<summary>2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения,
чем bottom-up heapify?</summary>
Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up
heapify) — то есть на порядок больше сравнений.
</details>
<details>
<summary>3. Почему `top()` кучи — O(1), а не O(log n)?</summary>
По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое
обращение по индексу `0`, без поиска и без перестройки структуры.
</details>
<details>
<summary>4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь
массив?</summary>
Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что
не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k
текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и
заменяет его за O(log k) — суммарно O(n log k) и память O(k).
</details>
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи. нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.
+130 -4
View File
@@ -14,8 +14,134 @@ long long shortest_path(int n,
- 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue` - 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue`
(O((V+E) log V)), а не O(V²) перебор минимума. (O((V+E) log V)), а не O(V²) перебор минимума.
Подсказки по разбору (после сдачи): «ленивое» удаление устаревших записей из очереди
(`if (d != dist[v]) continue;`), почему нельзя Дейкстру с отрицательными рёбрами и когда
нужен Беллман–Форд.
Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений. Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений.
**Факты для карточек**
- base | Что возвращает функция при `src == dst`? — 0
- base | Что возвращает функция при недостижимости `dst`? — -1
- base | В каком типе суммируются веса и почему не `int`? — `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути
Почему дальше: чтобы уложиться в 2 секунды на 100 000 вершин и 200 000 рёбер, важно понять,
почему наивный перебор минимума не подходит и что именно даёт куча.
## Механизм: куча против наивного перебора
**Наивная Дейкстра.** На каждом из `V` шагов алгоритм сканирует массив `dist[]` целиком,
чтобы найти непосещённую вершину с минимальным расстоянием — это `O(V)` на шаг. Суммарно
`V` шагов по `O(V)` — `O(V²)`, плюс `O(E)` на релаксацию рёбер (эта часть не доминирует).
Подходит, если граф плотный (`E ~ V²`), но не в этой задаче.
**Дейкстра с priority_queue.** Вместо линейного скана используется min-куча по текущему
известному расстоянию. При релаксации ребра `(u, v, w)`, если найден более короткий путь до
`v`, в кучу **добавляется новая запись** `(dist, v)` — `push` стоит `O(log размер_кучи)`.
Так как для одной вершины может накопиться несколько записей (по одной на каждое улучшение
расстояния), размер кучи ограничен числом релаксаций, то есть `O(V + E)`, и `log` от этой
величины по порядку — тот же `O(log V)` (`log(V²) = 2 log V`, то есть смена основания
не меняет асимптотику). Суммарно: `O((V + E) log V)`.
**Числа задачи.** `V = 100 000`, `E = 200 000`. Наивный `O(V²) = 100 000² = 10^10` операций
— в 2 секунды не укладывается ни при каких обстоятельствах. Куча: `(V+E)·log₂V ≈
300 000 · 16,6 ≈ 5·10^6` операций — на 3–4 порядка меньше, укладывается с большим запасом.
**«Ленивое» удаление устаревших записей.** `std::priority_queue` не умеет `decrease-key`
за `O(log n)` (не тот интерфейс), поэтому вместо обновления существующей записи в куче
для вершины `v` просто добавляется новая пара `(dist, v)` при каждом улучшении. В куче
одновременно может лежать несколько устаревших записей для одной вершины. При извлечении
самой записи с минимальным `dist` проверяется, актуальна ли она:
```c++
auto [d, v] = pq.top(); pq.pop();
if (d != dist[v]) continue; // запись устарела — лучшая уже обработана раньше
```
Это дешевле, чем поддерживать структуру с настоящим `decrease-key` (например, indexed
heap), и корректность не страдает — устаревшая запись всегда хуже уже найденного `dist[v]`
и просто пропускается.
**Факты для карточек**
- base | Сложность наивной Дейкстры (перебор минимума по массиву)? — O(V²)
- base | Сложность Дейкстры с бинарной кучей? — O((V+E) log V)
- core | При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды? — 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка
- core | Что означает «ленивое удаление» устаревших записей в очереди? — вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая
- deep | Почему `std::priority_queue` не используют с decrease-key напрямую? — у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop
Почему дальше: скорость решена кучей, но у Дейкстры есть жёсткое условие корректности —
неотрицательные веса. Нужно понять механизм, почему это условие обязательно.
## Механизм: почему нужны только неотрицательные веса
Дейкстра — жадный алгоритм: когда вершина `v` извлекается из очереди с минимальным на
данный момент расстоянием, алгоритм считает `dist[v]` **окончательным** и больше не
пересматривает эту вершину. Это верно только если все ещё не обработанные пути до `v` не
могут оказаться короче — а это гарантировано лишь при неотрицательных весах: любой другой
путь до `v` проходит через вершины с расстоянием `≥ dist[v]` и добавляет ребро `≥ 0`, то
есть не может дать сумму меньше `dist[v]`.
Если в графе есть отрицательное ребро, эта гарантия ломается: путь через уже
«финализированную» вершину может позже пройти по отрицательному ребру и дать меньшую
сумму, но алгоритм эту вершину уже не пересматривает — результат будет неверным без явного
падения или ошибки, то есть тихо неправильным.
Для графов с отрицательными весами (без отрицательных циклов) используется
**Беллман-Форд**: `V-1` раз релаксируются все `E` рёбер, `O(V·E)`. Дополнительно на `V`-м
проходе можно проверить, продолжает ли что-то релаксироваться — если да, в графе
отрицательный цикл, кратчайший путь не определён (можно уменьшать бесконечно).
Self-loop с весом `w ≥ 0` никогда не уменьшает кратчайший путь (добавление неотрицательного
веса к текущему расстоянию не улучшает его), поэтому не требует отдельной обработки —
алгоритм просто никогда не выберет такое ребро для релаксации выгодно.
**Факты для карточек**
- core | Почему Дейкстра ломается на отрицательных рёбрах? — алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается
- base | Какой алгоритм нужен при отрицательных весах без отрицательных циклов? — Беллман-Форд, O(V·E)
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл
- base | Почему self-loop с w≥0 не требует отдельной обработки? — добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию
## Ловушки
- Использовать `int` вместо `long long` для накопленной суммы весов → при весах рёбер до
10^9 и длинном пути сумма переполняет `int` → тихо неверный (отрицательный или
«случайный») результат без явного падения.
- Забыть проверку `if (d != dist[v]) continue` при извлечении из очереди → обрабатываются
устаревшие записи повторно → не влияет на корректность, но раздувает число операций и
на 200 000 рёбер может вывести время за лимит 2 секунды.
- Запустить алгоритм на графе с отрицательным ребром без проверки условия применимости →
результат тихо неверный (нет явной ошибки времени выполнения) — Дейкстра не обнаруживает
нарушение своего предположения сама.
- Перепутать направление рёбер (граф ориентированный) и релаксировать в обе стороны →
находится путь, которого нет в графе условия задачи, `shortest_path` возвращает заниженное
значение.
## Проверь себя
<details>
<summary>1. Почему при V=100 000 и E=200 000 наивный O(V²) не проходит по времени, а вариант
с кучей — проходит?</summary>
`O(V²) = 100 000² = 10^10` операций — на 3–4 порядка больше, чем позволяют 2 секунды.
Вариант с кучей — `O((V+E) log V) ≈ 300 000 · log₂(100 000) ≈ 300 000 · 16,6 ≈ 5·10^6`
операций, укладывается с большим запасом.
</details>
<details>
<summary>2. Что произойдёт с результатом Дейкстры, если в графе есть ребро веса -5, а
остальные рёбра положительные?</summary>
Результат может быть неверным без явной ошибки: если вершина на дешёвом с виду пути уже
извлечена из очереди и «финализирована», а позже к ней ведёт более короткий путь через
ребро -5, алгоритм это улучшение не увидит, так как вершина повторно не пересматривается.
</details>
<details>
<summary>3. Почему в очереди могут одновременно лежать несколько записей для одной и той же
вершины, и почему это не ошибка?</summary>
Каждое найденное улучшение расстояния до вершины добавляет новую запись `(dist, v)` в кучу
вместо обновления старой (`decrease-key` не поддерживается `std::priority_queue`). Это не
ошибка, потому что при извлечении устаревшая запись (`d != dist[v]`) просто пропускается —
корректность сохраняется, платится только лишней памятью в очереди и лишним `pop`.
</details>
<details>
<summary>4. Почему self-loop с весом w≥0 никогда не меняет кратчайший путь?</summary>
Self-loop добавляет вершине путь до самой себя длиной `w ≥ 0`. Поскольку путь длины 0 (не
двигаться) уже не хуже, прибавление неотрицательного веса к текущему `dist[v]` не может дать
меньшее значение — релаксация через такое ребро никогда не проходит условие «короче».
</details>
+116
View File
@@ -32,5 +32,121 @@ int prefix_for_hosts(int hosts); // самая узкая п
Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений. Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений.
**Факты для карточек**
- base | Сколько узлов даёт /24? — 254
- base | Сколько узлов даёт /26? — 62
- base | Сколько узлов даёт /30? — 2
- base | Сеть, broadcast и диапазон узлов для `10.0.1.130/26`? — сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190`
Почему дальше: чтобы получить эти числа программно, а не подбором, нужен механизм вычисления
маски, границ сети и обратной задачи — подбора префикса по числу узлов.
## Механизм: маска, границы сети, обратный подбор префикса
**Маска по префиксу.** Маска — это `prefix` единиц в старших битах и `32-prefix` нулей в
младших: `mask = 0xFFFFFFFFu << (32 - prefix)` для `prefix > 0`. Для `prefix == 0` формула
не годится напрямую — сдвиг на 32 бита для 32-битного типа в C/C++ является неопределённым
поведением (UB), поэтому этот случай обрабатывается отдельно: `mask = 0`.
**Границы сети.** `network = ip & mask` — обнуляет все биты хостовой части (сохраняет
только биты сети). `broadcast = network | ~mask` — выставляет все биты хостовой части в
единицу (`~mask` — это как раз маска хостовой части, инвертированная сетевая). Число бит,
отданных под узлы, — `32 - prefix`; всего адресов в блоке — `2^(32-prefix)`.
**Разбор эталонного примера `10.0.1.130/26`.** `/26` оставляет `32-26=6` бит под узлы,
блок из `2^6 = 64` адресов. Адрес `.130` попадает в блок `128..191` (границы блоков кратны
64): `network = 10.0.1.128`, `broadcast = 10.0.1.191`. Обычные узлы — `first_host =
network+1 = .129`, `last_host = broadcast-1 = .190`. Из 64 адресов блока минус 2 служебных
(сеть и broadcast) — 62 узла, что совпадает с `/26 → 62 узла`.
**Почему `/26` даёт именно 62 узла.** `host_count = 2^(32-26) - 2 = 2^6 - 2 = 64 - 2 = 62`.
Аналогично `/24`: `2^8 - 2 = 254`; `/30`: `2^2 - 2 = 2` — ровно минимум для соединения
точка-точка между двумя маршрутизаторами.
**`/31` — исключение (RFC 3021).** По общей формуле `/31` дал бы `2^1 - 2 = 0` узлов —
бессмысленный результат для блока из 2 адресов. RFC 3021 разрешает для каналов
точка-точка (ровно 2 узла на линке) отдавать под хосты оба адреса блока: широковещательный
адрес такому каналу физически не нужен (получателей всего два, и оба известны заранее), а
резервировать половину 2-адресного блока под network/broadcast — чистая потеря адресного
пространства. Поэтому `host_count = 2`, `first_host = network`, `last_host = broadcast`.
**`/32` — единичный адрес.** Блок из одного адреса: `network = broadcast = first_host =
last_host = ip`, `host_count = 1`. Используется для маршрутов на конкретный узел
(host route) или loopback-адресов маршрутизатора.
**Обратная задача — `prefix_for_hosts(h)`.** Нужен наибольший `prefix` (самая узкая
подсеть), при котором `2^(32-prefix) - 2 >= h`, то есть `2^(32-prefix) >= h+2`, то есть
`32-prefix >= log2(h+2)`. Так как число хостовых бит — целое, наименьшее подходящее
значение — `32-prefix = ceil(log2(h+2))`, откуда:
```
prefix_for_hosts(h) = 32 - ceil(log2(h+2))
```
Проверка на эталонных примерах: `h=62` → `h+2=64`, `log2=6`, `prefix=26` ✓. `h=63` →
`h+2=65`, `log2(65)≈6.02`, `ceil=7`, `prefix=25` ✓ (62 узла `/26` уже не хватает на 63-й
хост, нужен на бит шире — `/25` с 126 узлами). `h=254` → `h+2=256`, `log2=8`, `prefix=24` ✓.
`h=1` → `h+2=3`, `log2(3)≈1.58`, `ceil=2`, `prefix=30` ✓.
**Факты для карточек**
- base | Формула маски для префикса p (0<p≤32)? — `0xFFFFFFFF << (32-p)`; для p=0 маска = 0 отдельным случаем (сдвиг на 32 — UB)
- base | Как получить network и broadcast из ip и mask? — `network = ip & mask`, `broadcast = network | ~mask`
- core | Формула host_count для prefix ≤ 30? — `2^(32-prefix) - 2`
- core | Почему /31 — исключение и даёт 2 узла вместо 0 по общей формуле? — RFC 3021: у канала точка-точка ровно 2 узла, broadcast не нужен, поэтому оба адреса 2-адресного блока отдаются под хосты вместо потери половины блока
- core | Что возвращает subnet_of для /32? — network=broadcast=first_host=last_host=ip, host_count=1 (host route/loopback)
- core | Формула `prefix_for_hosts(h)`? — `32 - ceil(log2(h+2))`, из условия `2^(32-prefix) >= h+2`
- deep | Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1? — /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами
## Ловушки
- Вычислить маску как `0xFFFFFFFF << (32 - prefix)` при `prefix == 0` без отдельной ветки
→ сдвиг на 32 бита для 32-битного типа — неопределённое поведение в C/C++ (компилятор
может дать любое значение, не обязательно 0) → UBSan ловит это как сдвиг за пределы
разрядности типа.
- Применить общую формулу `host_count = 2^(32-p) - 2` к `/31` без проверки исключения →
получится 0 узлов вместо 2 → тест на `/31` (RFC 3021) падает.
- Использовать `int` вместо `long long` для `host_count` → для `/0` значение `2^32 - 2` не
влезает в `int` (переполнение со знаком — UB) → в условии структуры явно указан `long long`
именно из-за этого случая.
- Перепутать host byte order с network byte order при сравнении с реальным трафиком или
утилитами вроде `tcpdump` → `10.0.0.1` в host order этой задачи — это `0x0A000001`, но в
сетевом порядке байты переставлены (`0x0100000A` при little-endian хосте) — конвертация
через `htonl`/`ntohl`, а не прямое сравнение чисел.
## Проверь себя
<details>
<summary>1. Почему /31 не подчиняется общей формуле «минус 2 служебных адреса»?</summary>
Потому что у 2-адресного блока резервирование network и broadcast по общей формуле оставило
бы 0 узлов — бессмысленно для канала точка-точка, где всего два участника и оба заранее
известны, широковещание не нужно. RFC 3021 явно отдаёт оба адреса блока под хосты.
</details>
<details>
<summary>2. Дано `ip=10.0.1.130`, `prefix=26`. Посчитать network, broadcast, first_host,
last_host по шагам.</summary>
Хостовых бит: `32-26=6`, размер блока `2^6=64`. Адрес `.130` попадает в блок, начинающийся
на границе, кратной 64: `128 <= 130 < 192`, значит `network=10.0.1.128`,
`broadcast=10.0.1.128+63=10.0.1.191`, `first_host=network+1=10.0.1.129`,
`last_host=broadcast-1=10.0.1.190`.
</details>
<details>
<summary>3. Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 только на
единицу?</summary>
`/26` физически даёт ровно 62 адреса под хосты — этого не хватает даже на один хост меньше
требуемых 63. Следующий шаг «расширения» подсети — не +1 узел, а удвоение блока: снятие
одного бита из префикса (`/25`) сразу даёт 126 узлов. Промежуточных вариантов между /26 и
/25 не существует, поэтому единственный подходящий ответ — /25.
</details>
<details>
<summary>4. Почему для `prefix=0` нельзя вычислять маску как `0xFFFFFFFF << (32-0)`?</summary>
Это сдвиг на 32 бита для 32-битного беззнакового типа — стандарт C/C++ определяет сдвиг
только для величины меньше ширины типа в битах, сдвиг на саму ширину или больше — UB
(на практике часто даёт исходное значение без сдвига вместо ожидаемого 0). Поэтому
`prefix == 0` обрабатывается отдельной веткой: `mask = 0` напрямую.
</details>
Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`), Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`),
почему `/31` — исключение, что такое маска в бинарном виде. почему `/31` — исключение, что такое маска в бинарном виде.
+122 -17
View File
@@ -3,10 +3,11 @@
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз, Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
код в разборах можно копировать и запускать — это образец, а не ответ на задачу. код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
## 1. Что такое O-нотация (без воды) ## 1. Что такое O-нотация
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n). больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое
100 и множитель 3 не меняют форму роста, поэтому их не пишут.
Три правила, которых хватает для 90% вопросов: Три правила, которых хватает для 90% вопросов:
@@ -18,14 +19,32 @@ O-нотация отвечает на вопрос «как растёт вре
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`. Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n. Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция **Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не
«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` — гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`:
обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её,
**амортизированное O(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(1), но если хеш-функция плохая
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**. и все ключи попали в одну корзину, поиск вырождается в перебор → **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. Таблица сложностей, которую надо знать ## 2. Таблица сложностей, которую надо знать
| Структура | Поиск | Вставка | Удаление | Память | | Структура | Поиск | Вставка | Удаление | Память |
@@ -38,17 +57,44 @@ O-нотация отвечает на вопрос «как растёт вре
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) | | Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
| Сбалансированное BST (map) | O(log 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(n)** (не O(n log n) — это
частый вопрос), вставка одного элемента — O(log n), взятие максимума — O(1). частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа
по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание
вверх на высоту кучи), взятие максимума — O(1) (это корень).
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**; массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя. сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево
решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n).
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion, Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
bubble, counting. Неустойчивы: quick, heap, selection. 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. Хеш-таблица: как устроена ## 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):** все элементы лежат в самом массиве. Занято — - **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`, ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`. квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе
зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone** разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить
(надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять. ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение —
**tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт
дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался
бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит
дальше по цепочке зондирования.
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7: **Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число
больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево). проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически
выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново
по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые
позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит
она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда
**амортизированное O(1)** на вставку, а не просто «в среднем быстро».
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре.
биты (для строк — FNV-1a или `std::hash<std::string>`). Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при
делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)),
для строк — FNV-1a или `std::hash<std::string>`.
**Ловушки**
- Взять ёмкость не степенью двойки при использовании `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. Разбор примера: считаем сложности ## 4. Разбор примера: считаем сложности
@@ -109,6 +183,16 @@ bool has_pair_fast(const std::vector<int>& v, int k) { // O(n) в среднем
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n) (в) — типовой ответ на собеседовании: «перебор 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. Разбор примера: как руками собрать хеш-таблицу ## 5. Разбор примера: как руками собрать хеш-таблицу
@@ -168,11 +252,21 @@ struct HashTable {
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
**все** узлы, потому что индекс зависит от размера массива. **все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) —
после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому
пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) —
степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы
тот же результат быстрее, именно потому что cap — степень двойки.
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
до свободной ячейки, а при удалении ставим tombstone. до свободной ячейки, а при удалении ставим tombstone.
**Факты для карточек**
- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211
- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны
Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок.
## 6. Что спросят на собеседовании (готовые ответы) ## 6. Что спросят на собеседовании (готовые ответы)
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n) - «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
@@ -184,6 +278,10 @@ struct HashTable {
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1, - «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone. но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
**Факты для карточек**
- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами
- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда)
## 7. Материалы (первопартийные) ## 7. Материалы (первопартийные)
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map - cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
@@ -198,6 +296,8 @@ struct HashTable {
3. `heapify` из произвольного массива — за сколько? 3. `heapify` из произвольного массива — за сколько?
4. Какая из сортировок устойчива: quick, merge, heap? 4. Какая из сортировок устойчива: quick, merge, heap?
5. Зачем tombstone при открытой адресации? 5. Зачем tombstone при открытой адресации?
6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме?
7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
<details> <details>
<summary>Ответы</summary> <summary>Ответы</summary>
@@ -208,6 +308,11 @@ struct HashTable {
4. merge (устойчива), quick и heap — нет. 4. merge (устойчива), quick и heap — нет.
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой 5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
ячейки до элемента, который был вставлен за ней. ячейки до элемента, который был вставлен за ней.
6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая
операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость
n операций ≈ n, а не n².
7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю —
дешевле для процессора.
</details> </details>
## 9. Ссылки на задачи этого дня ## 9. Ссылки на задачи этого дня
+205 -25
View File
@@ -1,20 +1,30 @@
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок) # D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения + Модель одна на весь урок: процесс = адресное пространство (`mm_struct` в ядре) + поток
открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов
результат, сигнал — асинхронное уведомление. (`task_struct`). `fork` копирует эту запись и помечает память как copy-on-write, `exec`
заменяет содержимое адресного пространства, оставляя PID и дескрипторы, `wait` забирает у
ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.
## 1. fork(): что реально происходит ## 1. fork(): что реально происходит
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное `pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
пространство, свой PID, свой поток. Родитель продолжает с того же места. пространство, свой PID, свой поток. Родитель продолжает с того же места.
Механически ядро: (1) выделяет новый `task_struct` и PID; (2) копирует таблицу файловых
дескрипторов — обе записи после `fork` указывают на те же открытые файловые описания в
ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы
данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь
планировщика. Само копирование данных при этом не происходит — см. COW ниже.
Возвращает **дважды** — и это ключ к пониманию: Возвращает **дважды** — и это ключ к пониманию:
- в родителе — PID ребёнка (> 0); - в родителе — PID ребёнка (> 0);
- в ребёнке — 0; - в ребёнке — 0;
- при ошибке — −1 (и `errno`), ребёнок не создан. - при ошибке — −1 (и `errno`), ребёнок не создан.
Поэтому классический код всегда ветвится: Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова —
единственный способ понять, в какой копии сейчас исполняется код, это проверить
возвращённое значение. Поэтому классический код всегда ветвится:
```cpp ```cpp
pid_t pid = fork(); pid_t pid = fork();
@@ -31,13 +41,41 @@ if (pid == 0) {
} }
``` ```
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни страницы, **Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни физические
помеченные «только чтение». При первой записи в страницу ядро делает её копию — только страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи
тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую
в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются страницу (обычно 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(): замена образа ## 2. exec(): замена образа
@@ -45,40 +83,122 @@ if (pid == 0) {
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда: всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1. при успехе функция не возвращается (программа уже другая), при ошибке возвращает −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`. Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
**Ловушки**
- Забыть `_exit(127)` (или любой выход) после неудачного `exec*` в ребёнке → код продолжает
исполняться как будто это родительская логика → дублирование родительской работы в
дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска.
- Не поставить `FD_CLOEXEC` на служебный/секретный дескриптор перед `exec` → он утекает в
запущенную внешнюю программу → видно в `/proc/<pid>/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 и коды возврата ## 3. wait/waitpid и коды возврата
Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код Завершившийся ребёнок не исчезает: когда он вызывает `exit()`, ядро не удаляет его
возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает `task_struct` немедленно, а сохраняет минимальную запись (PID, код возврата, статистику
память, но занимает слот в таблице процессов — их накопление плохо. использования ресурсов), пока родитель не заберёт её через `wait`/`waitpid`. Такой процесс
называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память данных, но занимает
слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе.
Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме
как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего
сообщить не может.
- `wait(&status)` — ждёт любого ребёнка; - `wait(&status)` — ждёт любого ребёнка;
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться. - `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
Разбор `status` делается макросами, а не вручную: Разбор `status` делается макросами, а не вручную, потому что в одном `int` закодированы
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
```cpp ```cpp
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status)); if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status)); else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
``` ```
Дополнительно есть `WIFSTOPPED`/`WSTOPSIG` (ребёнок остановлен, например, по `SIGSTOP`) и
`WCOREDUMP(status)` (завершение по сигналу сопровождалось дампом памяти на диск, `core`).
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала: **Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer); - **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
- **139 = 128 + 11** → SIGSEGV (падение по памяти); - **139 = 128 + 11** → SIGSEGV (падение по памяти);
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение). - 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. Сигналы ## 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 ```cpp
static volatile sig_atomic_t stop = 0; 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 и возвраты системных вызовов ## 5. errno и возвраты системных вызовов
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno` Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить; можно только сразу после ошибки, до вызова любой другой функции, которая может сама
EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже; переписать `errno` (например, `printf` при внутренней ошибке форматирования). EINTR — вызов
EINPROGRESS — неблокирующее соединение в процессе. прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе,
повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся
в лимит открытых дескрипторов (`ulimit -n`), диагностируется через `lsof -p PID` или
`/proc/PID/fd`.
```cpp ```cpp
ssize_t n = read(fd, buf, sizeof buf); 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 строк ## 6. Разбор примера: мини-шелл на 40 строк
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня. Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
@@ -157,11 +314,28 @@ int main() {
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка); Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»; `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` `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. Что спросят на собеседовании (готовые ответы) ## 7. Что спросят на собеседовании (готовые ответы)
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка, - «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
@@ -190,6 +364,8 @@ int main() {
3. Что такое зомби и кто его убирает? 3. Что такое зомби и кто его убирает?
4. Откуда код возврата 137 и 139? 4. Откуда код возврата 137 и 139?
5. Почему `exec` не возвращает управление при успехе? 5. Почему `exec` не возвращает управление при успехе?
6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет?
7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
<details> <details>
<summary>Ответы</summary> <summary>Ответы</summary>
@@ -200,9 +376,13 @@ int main() {
если родитель умер). если родитель умер).
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка. 4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
5. Потому что адресное пространство заменено новым образом: старого кода больше нет. 5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`.
7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт
SIGKILL принудительно.
</details> </details>
## 10. Задачи дня ## 10. Задачи дня
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода). - `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями). - Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
</content>