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