Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
- `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые
|
- `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые
|
||||||
ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них.
|
ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них.
|
||||||
- `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
|
- `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
|
||||||
|
- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref).
|
||||||
- `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма
|
- `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма
|
||||||
(«почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом.
|
(«почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом.
|
||||||
- `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git,
|
- `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git,
|
||||||
|
|||||||
@@ -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.
|
@@ -8,19 +8,26 @@
|
|||||||
протоколах.
|
протоколах.
|
||||||
- **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы
|
- **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы
|
||||||
вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы.
|
вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы.
|
||||||
|
На практике `bswap` нужен ровно потому, что сеть передаёт многобайтовые поля в network byte
|
||||||
|
order (big-endian), а x86 внутри — little-endian: без разворота байт число читается неверно.
|
||||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`.
|
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`.
|
||||||
|
|
||||||
## Глава II. Что нужно знать до старта
|
## Глава II. Что нужно знать до старта
|
||||||
|
|
||||||
Четыре инструмента, которых достаточно:
|
Четыре инструмента, которых достаточно:
|
||||||
|
|
||||||
1. `x & (x - 1)` — гасит самый младший единичный бит. Отсюда классика: число единиц можно
|
1. `x & (x - 1)` — гасит самый младший единичный бит. Механизм: в дополнительном коде `x - 1`
|
||||||
считать циклом, пока `x` не станет нулём.
|
переворачивает все нули справа от младшего единичного бита в единицы, а сам этот бит — в ноль,
|
||||||
|
биты выше не трогает; операция `&` с исходным `x` поэтому обнуляет ровно один бит — младший
|
||||||
|
единичный. Отсюда классика: число единиц можно считать циклом, пока `x` не станет нулём, и
|
||||||
|
число итераций равно числу единичных бит, а не 32.
|
||||||
2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо.
|
2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо.
|
||||||
3. `x & (1u << k)` — проверка k-го бита.
|
3. `x & (1u << k)` — проверка k-го бита.
|
||||||
4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного
|
4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного
|
||||||
числа. Комбинация «сдвинул и сложил» даёт любой порядок байт.
|
числа. Комбинация «сдвинул и сложил» даёт любой порядок байт.
|
||||||
|
|
||||||
|
Почему дальше: та же логика масок и сдвигов нужна в задаче 04 — разбор IPv4-заголовка byte-по-byte.
|
||||||
|
|
||||||
## Глава III. Задание
|
## Глава III. Задание
|
||||||
|
|
||||||
Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`,
|
Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`,
|
||||||
@@ -83,12 +90,25 @@ uint32_t bswap32(uint32_t x); // поменять порядок ба
|
|||||||
Если перепутать — биты уедут на одну позицию.
|
Если перепутать — биты уедут на одну позицию.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Глава VII. Частые ошибки
|
## Глава VII. Ловушки
|
||||||
|
|
||||||
- Сдвиги в `int` вместо `uint32_t` → UB, UBSAN ругается.
|
- Сдвиги в `int` вместо `uint32_t` → сдвиг `1 << 31` для знакового типа — UB → UBSAN падает
|
||||||
- Забыт ноль в `is_power_of_two`.
|
на этой строке с диагностикой конкретного сдвига.
|
||||||
- В `bswap32` перепутаны направления сдвигов (влево/вправо) — проверь на `0x11223344`.
|
- Забыт ноль в `is_power_of_two` → `0 & (0 - 1) == 0`, функция возвращает `true` на нуле →
|
||||||
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому.
|
тест на `x == 0` красный.
|
||||||
|
- В `bswap32` перепутаны направления сдвигов (влево/вправо) → байты встают не на свои места →
|
||||||
|
`bswap32(0x11223344) != 0x44332211`, видно прямым сравнением.
|
||||||
|
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому;
|
||||||
|
формально тест это не всегда ловит по выводу, но нарушает условие задачи.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сколько байт меняет местами `bswap32`? — 4
|
||||||
|
- core | Что делает `x & (x - 1)`? — гасит младший единичный бит `x`
|
||||||
|
- core | Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF`? — 32 (по числу единичных бит)
|
||||||
|
- deep | Почему `1u << 31` пишут с суффиксом `u`, а не как `int`? — сдвиг знакового `int` в
|
||||||
|
знаковый бит — UB, `uint32_t` определён стандартом для любых сдвигов в пределах разрядности
|
||||||
|
- base | Какой порядок байт использует сеть для многобайтовых полей? — network byte order
|
||||||
|
(big-endian)
|
||||||
|
|
||||||
## После сдачи
|
## После сдачи
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Задача 02 — связный список (C++)
|
# Задача 02 — связный список (C++)
|
||||||
|
|
||||||
|
## Глава I. Общая информация
|
||||||
|
|
||||||
|
- **Цель:** развернуть список, найти середину и определить цикл — без единой дополнительной
|
||||||
|
аллокации, только перелинковка указателей.
|
||||||
|
- **Почему это в Eltex:** списки соединений, очереди задач, таблицы состояний — везде связные
|
||||||
|
структуры. Приём двух указателей (slow/fast), которым решаются `find_middle` и `has_cycle`,
|
||||||
|
— это алгоритм Флойда, тот же паттерн всплывает при поиске циклов в графах зависимостей
|
||||||
|
и в обходе кольцевых структур.
|
||||||
|
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 02`.
|
||||||
|
|
||||||
|
## Глава II. Что нужно знать до старта
|
||||||
|
|
||||||
Даны функции над односвязным списком `Node{int value; Node* next;}`:
|
Даны функции над односвязным списком `Node{int value; Node* next;}`:
|
||||||
|
|
||||||
```c
|
```c
|
||||||
@@ -8,10 +20,57 @@ Node* find_middle(Node* head); // середина; для чётной дл
|
|||||||
bool has_cycle(Node* head); // есть ли цикл
|
bool has_cycle(Node* head); // есть ли цикл
|
||||||
```
|
```
|
||||||
|
|
||||||
Требования:
|
Механизм двух указателей: `slow` идёт на 1 узел за шаг, `fast` — на 2. Для `find_middle`, когда
|
||||||
|
`fast` доходит до конца (`fast == nullptr` или `fast->next == nullptr`), `slow` стоит ровно в
|
||||||
|
середине — за счёт того, что `slow` проходит вдвое меньше узлов, чем `fast`. Для чётной длины
|
||||||
|
условие остановки должно давать именно второй из двух средних узлов — это проверяется на
|
||||||
|
списке длины 2 и 4.
|
||||||
|
|
||||||
|
Для `has_cycle` тот же дуэт указателей ловит цикл иначе: если цикл есть, `fast` заходит в него
|
||||||
|
и после каждого шага сокращает расстояние до `slow` внутри цикла на 1 узел (потому что относительная
|
||||||
|
скорость `fast` к `slow` внутри цикла — 1 узел/шаг), значит рано или поздно `slow == fast`; если
|
||||||
|
цикла нет, `fast` первым дойдёт до `nullptr`.
|
||||||
|
|
||||||
|
`reverse_list` разворачивается за один проход тремя указателями `prev/cur/next`: на каждом шаге
|
||||||
|
переставляется `cur->next = prev` до итерации по цепочке. Рекурсивный разворот сюда не годится —
|
||||||
|
он тратит O(n) памяти стека вызовов и нарушает требование O(1) дополнительной памяти.
|
||||||
|
|
||||||
|
Почему дальше: тот же принцип «два индекса вместо лишней памяти» — в задаче 03, только на
|
||||||
|
массиве, а не на указателях.
|
||||||
|
|
||||||
|
## Глава III. Требования
|
||||||
|
|
||||||
- никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно;
|
- никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно;
|
||||||
- `find_middle` и `has_cycle` — через два указателя (медленный/быстрый);
|
- `find_middle` и `has_cycle` — через два указателя (медленный/быстрый);
|
||||||
- корректная работа с пустым списком и списком из одного узла.
|
- корректная работа с пустым списком и списком из одного узла.
|
||||||
|
|
||||||
|
## Глава IV. Критерии приёмки
|
||||||
|
|
||||||
Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте
|
Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте
|
||||||
считаются ошибкой — тест сам освобождает память).
|
считаются ошибкой — тест сам освобождает память).
|
||||||
|
|
||||||
|
## Глава V. Ловушки
|
||||||
|
|
||||||
|
- Забыть проверку `head == nullptr` в начале любой из трёх функций → разыменование нулевого
|
||||||
|
указателя → падение/ASAN SEGV на пустом списке.
|
||||||
|
- В `find_middle` неверное условие остановки цикла (`fast->next` без проверки самого `fast`
|
||||||
|
на `nullptr`) → на списке чётной длины либо возвращается не тот из двух средних узлов, либо
|
||||||
|
падение на последнем шаге.
|
||||||
|
- В `reverse_list` переставить `cur->next = prev` до сохранения старого `cur->next` во
|
||||||
|
временную переменную → потеря хвоста списка, обход обрывается раньше конца.
|
||||||
|
- Рекурсивный `reverse_list` вместо итеративного → лишняя память стека на каждый вызов →
|
||||||
|
нарушение требования O(1), на длинном списке возможен stack overflow.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей? — в 2 раза
|
||||||
|
- core | Почему рекурсивный разворот списка не годится под требование O(1) памяти? — каждый
|
||||||
|
вызов кладёт кадр в стек, суммарно O(n) памяти
|
||||||
|
- core | На чём основано доказательство, что `fast` догонит `slow` при цикле? — внутри цикла
|
||||||
|
расстояние между ними сокращается на 1 узел за шаг
|
||||||
|
- base | Сколько дополнительных указателей нужно для итеративного разворота списка? — 3
|
||||||
|
(`prev`, `cur`, `next`)
|
||||||
|
|
||||||
|
## После сдачи
|
||||||
|
|
||||||
|
Разбор: почему алгоритм Флойда работает за O(n) времени и O(1) памяти в обоих режимах
|
||||||
|
(поиск середины и поиск цикла), и как тот же приём переносится на поиск цикла в графе.
|
||||||
|
|||||||
+33
-10
@@ -7,18 +7,26 @@
|
|||||||
- **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов.
|
- **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов.
|
||||||
- **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками —
|
- **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками —
|
||||||
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
|
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
|
||||||
собеседовании.
|
собеседовании. Механизм тот же, что у сетевой карты: пакеты пишутся в кольцо по мере
|
||||||
|
прихода, драйвер вычитывает их с другого конца, и обе стороны никогда не двигают уже
|
||||||
|
записанные данные по памяти — двигаются только индексы `head`/`tail`.
|
||||||
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`.
|
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`.
|
||||||
|
|
||||||
## Глава II. Что нужно знать до старта
|
## Глава II. Что нужно знать до старта
|
||||||
|
|
||||||
Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда
|
Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда
|
||||||
читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`.
|
читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`.
|
||||||
Сдвигать элементы не нужно никогда — в этом весь смысл.
|
Сдвигать элементы не нужно никогда — в этом весь смысл: `push`/`pop` двигают только индекс,
|
||||||
|
а не байты в памяти, поэтому обе операции остаются O(1) независимо от размера буфера.
|
||||||
|
|
||||||
Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от
|
Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от
|
||||||
полного, если оба индекса совпали?** Варианты: хранить счётчик `size`, либо оставлять одну
|
полного, если оба индекса совпали?** При `head == tail` возможны оба состояния — буфер мог
|
||||||
ячейку свободной. В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
|
быть только что создан (пуст) или заполнен ровно `capacity` раз (полон) — по одним индексам
|
||||||
|
это не различить. Варианты: хранить счётчик `size`, либо оставлять одну ячейку свободной.
|
||||||
|
В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
|
||||||
|
|
||||||
|
Почему дальше: тот же счётчик `size_` вместо пересчёта по индексам — частый приём и в
|
||||||
|
`std::deque`, и в реализациях lock-free очередей, где индексы вообще нельзя лишний раз читать.
|
||||||
|
|
||||||
## Глава III. Задание
|
## Глава III. Задание
|
||||||
|
|
||||||
@@ -89,14 +97,29 @@ public:
|
|||||||
`std::vector<int>` — тогда вопрос исчезает.
|
`std::vector<int>` — тогда вопрос исчезает.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Глава VII. Частые ошибки
|
## Глава VII. Ловушки
|
||||||
|
|
||||||
- Сдвиг элементов вместо индексов (теряется весь смысл O(1)).
|
- Сдвиг элементов вместо индексов → цена `push`/`pop` вырастает до O(n) → теряется весь
|
||||||
- Путаница «пусто/полно» при совпавших индексах.
|
смысл структуры, видно по сравнению с наивным `std::vector` на бенчмарке.
|
||||||
|
- Путаница «пусто/полно» при совпавших индексах (`head_ == tail_`) без отдельного `size_` →
|
||||||
|
`full()` и `empty()` дают одинаковый ответ на разных состояниях → тест на заполненный буфер
|
||||||
|
ёмкости > 1 падает.
|
||||||
- `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании
|
- `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании
|
||||||
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`).
|
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`, деление по модулю дороже
|
||||||
- Копирование объекта без правила трёх → двойное освобождение. Если сдаёшь с ручным `new[]`,
|
сравнения).
|
||||||
запрети копирование (`= delete`).
|
- Копирование объекта без правила трёх/пяти → два объекта владеют одним и тем же `new[]`-буфером
|
||||||
|
→ двойное освобождение при выходе обоих из области видимости, ловится ASAN. Если сдаёшь с
|
||||||
|
ручным `new[]`, запрети копирование (`= delete`).
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность `push`/`pop` кольцевого буфера? — O(1)
|
||||||
|
- core | Как отличить пустой буфер от полного при `head_ == tail_`? — хранить отдельный
|
||||||
|
счётчик `size_` (или жертвовать одной ячейкой)
|
||||||
|
- core | Чем `delete[]` отличается от `delete` для массива, выделенного `new[]`? — `delete`
|
||||||
|
без `[]` на массиве — UB, вызовет деструктор только для первого элемента и испортит подсчёт
|
||||||
|
размера аллокации
|
||||||
|
- base | Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов? —
|
||||||
|
`if (++idx == cap) idx = 0;`
|
||||||
|
|
||||||
## После сдачи
|
## После сдачи
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,35 @@
|
|||||||
# Задача 04 — разбор IPv4-пакета и контрольная сумма (C++)
|
# Задача 04 — разбор IPv4-пакета и контрольная сумма (C++)
|
||||||
|
|
||||||
Это то, что реально делают сетевые железки: взять буфер из сокета и корректно разобрать
|
## Глава I. Общая информация
|
||||||
заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
|
|
||||||
|
- **Цель:** разобрать буфер байт из сокета в структуру заголовка вручную, безопасно и без
|
||||||
|
UB — то, что реально делают сетевые железки на приёмном тракте.
|
||||||
|
- **Почему это в Eltex:** это то, что реально делают сетевые железки: взять буфер из сокета
|
||||||
|
и корректно разобрать заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
|
||||||
|
Коммутатор/маршрутизатор обязан отбросить пакет с битой контрольной суммой или враньём в
|
||||||
|
`total_length`, иначе он либо читает чужую память, либо пересылает мусор дальше.
|
||||||
|
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 04`.
|
||||||
|
|
||||||
|
## Глава II. Что нужно знать до старта
|
||||||
|
|
||||||
|
Минимальная длина IPv4-заголовка — 20 байт (без опций). Байт-порядок в заголовке смешанный:
|
||||||
|
`total_length` и `header_checksum` хранятся в хост-порядке в структуре `Ipv4Header` (в задании
|
||||||
|
явно указано «хост-порядок» — значит в `parse_ipv4` их нужно правильно собрать из сетевого
|
||||||
|
порядка на проводе), а IP-адреса (`src_ip`, `dst_ip`) — «байты как на проводе»:
|
||||||
|
`10.0.0.1 -> 0x0A000001`, то есть без перестановки байт при сборке в `uint32_t`.
|
||||||
|
|
||||||
|
Почему нельзя просто `reinterpret_cast<const Ipv4Header*>(buf)`: у `buf` нет гарантии
|
||||||
|
выравнивания под `uint32_t` (нужно кратно 4 байтам) — компилятор вправе сгенерировать код,
|
||||||
|
предполагающий выровненный доступ, и на невыровненном адресе это UB (UBSAN ловит как
|
||||||
|
misaligned address); плюс раскладка полей в `Ipv4Header` определяется компилятором
|
||||||
|
(padding), а не байтовым форматом протокола — сети всё равно, как выровнены поля в C++-структуре.
|
||||||
|
Правильный путь — читать каждый байт по отдельности и собирать сдвигами (тот же приём, что
|
||||||
|
в задаче 01: маски и сдвиги для сборки многобайтового числа из байт).
|
||||||
|
|
||||||
|
Почему дальше: если разбор заголовка освоен, логичный следующий шаг — контрольная сумма TCP/UDP
|
||||||
|
поверх псевдозаголовка, которая считается тем же алгоритмом сложения в дополнительном коде.
|
||||||
|
|
||||||
|
## Глава III. Задание
|
||||||
|
|
||||||
```c++
|
```c++
|
||||||
struct Ipv4Header {
|
struct Ipv4Header {
|
||||||
@@ -30,3 +58,34 @@ bool checksum_valid(const uint8_t* buf, size_t len);
|
|||||||
Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые.
|
Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые.
|
||||||
Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть —
|
Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть —
|
||||||
на невыровненных адресах и в другом порядке байт это UB.
|
на невыровненных адресах и в другом порядке байт это UB.
|
||||||
|
|
||||||
|
## Глава IV. Ловушки
|
||||||
|
|
||||||
|
- `reinterpret_cast<Ipv4Header*>(buf)` вместо побайтового разбора → чтение по невыровненному
|
||||||
|
адресу и/или с padding-полями структуры вместо реального формата пакета → UB, UBSAN
|
||||||
|
сообщает misaligned address, а на реальных данных поля читаются со сдвигом.
|
||||||
|
- Не обнулить поле `header_checksum` перед вызовом `compute_checksum` → в сумму попадает
|
||||||
|
старое значение поля → результат не совпадёт с ожидаемым, тест на `compute_checksum` падает.
|
||||||
|
- Пропустить перенос (carry) при сложении 16-битных слов в дополнительном коде, если
|
||||||
|
промежуточная сумма считается в 16-битном типе → биты переноса теряются вместо того чтобы
|
||||||
|
прибавиться обратно к младшим битам → неверная контрольная сумма на буферах, где сумма слов
|
||||||
|
переполняет 16 бит.
|
||||||
|
- Проверить `ihl*4 > len` до проверки `len >= 20` (или в неверном порядке) → чтение поля `ihl`
|
||||||
|
из буфера короче 1 байта → выход за границы, ASAN сообщает heap-buffer-overflow.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Минимальная длина IPv4-заголовка без опций? — 20 байт
|
||||||
|
- base | Код `protocol` для TCP / UDP / ICMP? — 6 / 17 / 1
|
||||||
|
- core | В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию? — «как на проводе»,
|
||||||
|
без перестановки байт при сборке в `uint32_t`
|
||||||
|
- core | Почему `reinterpret_cast` буфера в `Ipv4Header*` — UB? — нет гарантии выравнивания
|
||||||
|
адреса под 4-байтные поля структуры
|
||||||
|
- deep | Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем
|
||||||
|
checksum) при верной контрольной сумме? — 0xFFFF
|
||||||
|
|
||||||
|
## После сдачи
|
||||||
|
|
||||||
|
Разбор: как поле `header_checksum` защищает именно заголовок (не данные), почему на каждом
|
||||||
|
маршрутизаторе TTL уменьшается на 1 и, как следствие, контрольную сумму заголовка приходится
|
||||||
|
пересчитывать заново на каждом хопе, и чем отличается контрольная сумма TCP/UDP (считается с
|
||||||
|
псевдозаголовком) от чисто IP.
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
# Задача 05 — длиннейшая возрастающая подпоследовательность (C++)
|
# Задача 05 — длиннейшая возрастающая подпоследовательность (C++)
|
||||||
|
|
||||||
|
## Глава I. Общая информация
|
||||||
|
|
||||||
|
- **Цель:** посчитать длину строго возрастающей подпоследовательности массива за
|
||||||
|
O(n log n), а не за наивные O(n²).
|
||||||
|
- **Почему это в Eltex:** задача — эталон на «знаешь ли ты приём tails + бинарный поиск
|
||||||
|
поверх DP», а сам паттерн «поддерживать отсортированный массив минимально возможных хвостов
|
||||||
|
и подставлять новый элемент через `lower_bound`» переиспользуется в задачах на планирование
|
||||||
|
и в сравнении версий (diff-подобные алгоритмы ищут именно длиннейшие совпадающие/растущие
|
||||||
|
подпоследовательности).
|
||||||
|
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 05`.
|
||||||
|
|
||||||
|
## Глава II. Что нужно знать до старта
|
||||||
|
|
||||||
|
Наивное решение — DP: `dp[i]` = длина LIS, заканчивающейся на элементе `i`, пересчитывается
|
||||||
|
перебором всех `j < i` — O(n²), при 100 000 элементах это ~10¹⁰ операций и тест не пройдёт
|
||||||
|
(ограничение — 2 секунды).
|
||||||
|
|
||||||
|
Быстрое решение — «хвосты» (patience sorting): массив `tails`, где `tails[k]` — минимально
|
||||||
|
возможный последний элемент возрастающей подпоследовательности длины `k+1`, накопленной по
|
||||||
|
уже просмотренному префиксу. Массив `tails` всегда отсортирован по построению, поэтому для
|
||||||
|
каждого нового `x` бинарным поиском (`lower_bound`) находится первая позиция с
|
||||||
|
`tails[pos] >= x`: если такая позиция есть — `x` заменяет значение в ней (подпоследовательность
|
||||||
|
той же длины, но с меньшим хвостом — выгоднее для будущих продолжений); если позиции нет —
|
||||||
|
`x` дописывается в конец, увеличивая длину LIS на 1. Именно `lower_bound`, а не `upper_bound` —
|
||||||
|
строгое возрастание требует заменить первый элемент `>= x`, а не `> x` (иначе повторяющиеся
|
||||||
|
значения ошибочно продлевают подпоследовательность).
|
||||||
|
|
||||||
|
Итоговая длина `tails` в конце обработки массива и есть `lis_length`. Значения в `tails` —
|
||||||
|
это не сама LIS, только корректная её длина.
|
||||||
|
|
||||||
|
Почему дальше: тот же приём «поддерживай отсортированный инвариант + `lower_bound`» стоит
|
||||||
|
знать и для задач на минимальное число возрастающих подпоследовательностей на весь массив.
|
||||||
|
|
||||||
|
## Глава III. Задание
|
||||||
|
|
||||||
```c++
|
```c++
|
||||||
int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей)
|
int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей)
|
||||||
```
|
```
|
||||||
@@ -11,4 +46,29 @@ int lis_length(const std::vector<int>& a); // длина НВП (строго
|
|||||||
- подпоследовательность не обязана быть непрерывной.
|
- подпоследовательность не обязана быть непрерывной.
|
||||||
|
|
||||||
Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений.
|
Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений.
|
||||||
Разбор после сдачи: «хвосты» — массив минимальных последних элементов + lower_bound.
|
|
||||||
|
## Глава IV. Ловушки
|
||||||
|
|
||||||
|
- `upper_bound` вместо `lower_bound` при строгом возрастании → повторяющиеся значения
|
||||||
|
ошибочно продлевают подпоследовательность → длина LIS завышена на массивах с дубликатами
|
||||||
|
(например, `[1, 1, 1]` должен дать 1, а не 3).
|
||||||
|
- Наивная DP-версия за O(n²) → проходит маленькие тесты, но на 100 000 элементах превышает
|
||||||
|
лимит в 2 секунды → `grade.py 05` роняет прогон по таймауту, а не по неверному ответу.
|
||||||
|
- Не обработан пустой вектор отдельным веткой → если код полагается на то, что цикл по
|
||||||
|
пустому диапазону сам вернёт 0, это обычно верно, но стоит явно проверить — тихая логическая
|
||||||
|
ошибка здесь не кидает исключение, просто даёт неверный ответ на пограничном тесте.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность наивного DP-решения LIS? — O(n²)
|
||||||
|
- core | Сложность решения через `tails` + бинарный поиск? — O(n log n)
|
||||||
|
- core | Что хранит `tails[k]`? — минимально возможный последний элемент возрастающей
|
||||||
|
подпоследовательности длины `k+1`
|
||||||
|
- deep | Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound`? —
|
||||||
|
`lower_bound` находит первый элемент `>= x` и заменяет его, не давая повторам продлевать
|
||||||
|
подпоследовательность
|
||||||
|
|
||||||
|
## После сдачи
|
||||||
|
|
||||||
|
Разбор: почему массив `tails` не является самой LIS, а только хранит её длину через
|
||||||
|
минимальные возможные хвосты; как по `tails` при необходимости восстановить саму
|
||||||
|
подпоследовательность (дополнительный массив предшественников).
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# Задача 06 — потокобезопасная ограниченная очередь (C++)
|
# Задача 06 — потокобезопасная ограниченная очередь (C++)
|
||||||
|
|
||||||
Классический вопрос на собеседовании в embedded/сетевую разработку: не «знаешь ли ты
|
На собеседовании это не вопрос «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
|
||||||
std::thread», а «умеешь ли ты не сломать счётчик под нагрузкой».
|
счётчик под нагрузкой» — то есть понимаешь ли механику мьютекса и условной переменной
|
||||||
|
настолько, чтобы очередь не зависла и не словила гонку данных под ThreadSanitizer.
|
||||||
|
|
||||||
|
## Интерфейс и требования
|
||||||
|
|
||||||
```c++
|
```c++
|
||||||
class BlockingQueue {
|
class BlockingQueue {
|
||||||
@@ -16,12 +19,105 @@ public:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Требования:
|
Требования:
|
||||||
- push после close() бросает `std::runtime_error`;
|
- `push` после `close()` бросает `std::runtime_error`;
|
||||||
- закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait);
|
- закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait);
|
||||||
- размер очереди никогда не превышает capacity;
|
- размер очереди никогда не превышает `capacity`;
|
||||||
- ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()`
|
- ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()`
|
||||||
вызывается из другого потока — он тоже должен быть потокобезопасным).
|
вызывается из другого потока — он тоже должен быть потокобезопасным).
|
||||||
|
|
||||||
Проверка: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания.
|
**Факты для карточек**
|
||||||
Разбор после сдачи: почему нужен predicate-цикл вокруг wait, и как один condition_variable
|
- base | Каким флагом компилятора включается ThreadSanitizer? — `-fsanitize=thread`
|
||||||
на оба события даёт ложные пробуждения.
|
- base | Что бросает `push` после `close()`? — `std::runtime_error`
|
||||||
|
- core | Должен ли `size()` быть потокобезопасным в этой задаче? — да, его тоже вызывают из другого потока и он тоже под захватом мьютекса
|
||||||
|
- core | Что происходит с ждущими потоками при `close()`? — все разблокируются (никакого вечного `wait`)
|
||||||
|
|
||||||
|
Почему дальше: раз очередь общая между потоками, нужен механизм, который защищает и её
|
||||||
|
состояние, и само ожидание — мьютекс плюс условная переменная.
|
||||||
|
|
||||||
|
## Механизм: mutex + condition_variable + predicate-цикл
|
||||||
|
|
||||||
|
Общее состояние очереди (буфер, счётчик элементов, флаг `closed`) защищено одним
|
||||||
|
`std::mutex` через RAII-обёртку (`std::lock_guard`/`std::unique_lock`) — так `unlock()`
|
||||||
|
происходит автоматически в деструкторе при выходе из области видимости любым путём,
|
||||||
|
включая исключение, и его невозможно забыть вызвать вручную.
|
||||||
|
|
||||||
|
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток — атомарность
|
||||||
|
важна: если бы освобождение и уход в сон были двумя отдельными шагами, между ними мог бы
|
||||||
|
вклиниться другой поток, изменить состояние и отправить `notify`, которое тогда потеряется,
|
||||||
|
потому что ждущий поток ещё не успел зайти в `wait`. При пробуждении `wait` снова захватывает
|
||||||
|
тот же мьютекс перед тем, как вернуть управление — код после `wait` уже работает под защитой.
|
||||||
|
|
||||||
|
Стандарт C++ прямо разрешает ложные пробуждения (spurious wakeup) — `wait` может вернуться
|
||||||
|
без единого вызова `notify_one`/`notify_all`, просто по решению ОС (futex на Linux иногда
|
||||||
|
пробуждается по внутренним причинам платформы). Из-за этого `if (!ready) cv.wait(lock);`
|
||||||
|
недостаточно: после такого пробуждения предикат мог остаться ложным, и поток пойдёт работать
|
||||||
|
с данными, которые на самом деле не готовы — баг, который не ловится юнит-тестами и стреляет
|
||||||
|
только под конкурентной нагрузкой. Правильный паттерн — цикл с перепроверкой:
|
||||||
|
|
||||||
|
```c++
|
||||||
|
std::unique_lock<std::mutex> lock(mtx_);
|
||||||
|
while (!(size_ > 0 || closed_)) // predicate-цикл, не if
|
||||||
|
not_empty_.wait(lock);
|
||||||
|
```
|
||||||
|
|
||||||
|
Для `push` и `pop` нужны разные условия ожидания (`не полна` и `не пуста` или `закрыта`),
|
||||||
|
поэтому один `condition_variable` на оба события удобен только тогда, когда `notify_all`
|
||||||
|
будит все ждущие потоки и каждый сам перепроверяет свой предикат в цикле — так ложные
|
||||||
|
пробуждения для «не того» условия просто не проходят проверку и поток снова засыпает.
|
||||||
|
Если использовать `notify_one` при одном общем `condition_variable` для двух разных условий,
|
||||||
|
можно разбудить не тот поток (например, разбудить ждущего `push`, когда освободилось место
|
||||||
|
для `pop`), а нужный так и останется спать — отсюда правило: либо `notify_all`, либо два
|
||||||
|
раздельных `condition_variable`.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
|
||||||
|
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
|
||||||
|
- Изменение состояния без удержания мьютекса (например, `size_++` вне `lock_guard`) →
|
||||||
|
гонка данных, UB → ловится ThreadSanitizer как data race при `size()` из другого потока.
|
||||||
|
- `notify_one` при общем `condition_variable` на два разных предиката → не тот поток проснулся,
|
||||||
|
нужный продолжает ждать → зависание, видно по таймауту теста, а не по явной ошибке.
|
||||||
|
- Забыть разбудить всех при `close()` → часть потоков навсегда в `wait` → зависание процесса,
|
||||||
|
тест не завершается за отведённое время.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
|
||||||
|
- core | Почему `while` вокруг `wait`, а не `if`? — из-за spurious wakeup: `wait` может вернуться без вызова notify
|
||||||
|
- core | Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one`? — можно разбудить не тот поток, а нужный останется ждать
|
||||||
|
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — futex
|
||||||
|
|
||||||
|
Почему дальше: раз тест явно требует ThreadSanitizer и отсутствия зависаний, логично понять,
|
||||||
|
что именно проверяет `grade.py` и почему «просто работает на глаз» здесь недостаточно.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
Запуск: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. TSan
|
||||||
|
инструментирует каждое обращение к памяти и отслеживает happens-before между потоками во
|
||||||
|
время исполнения — то есть ловит гонку по факту конкретного запуска, а не статическим
|
||||||
|
анализом кода, поэтому «прогнать разок и не увидеть краша» не равно «гонки нет»: раскладка
|
||||||
|
потоков в конкретном запуске могла просто не проявить проблему.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Какой командой запускается проверка задачи 06? — `python3 grade.py 06`
|
||||||
|
- core | Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Проверь себя</summary>
|
||||||
|
|
||||||
|
1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же
|
||||||
|
мьютекс, что защищает очередь, а не произвольный лок?
|
||||||
|
<details><summary>Ответ</summary>Потому что `wait` должен атомарно освободить именно этот
|
||||||
|
мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы
|
||||||
|
предикат под разными блокировками, и защита состояния очереди перестала бы работать.</details>
|
||||||
|
|
||||||
|
2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста?
|
||||||
|
<details><summary>Ответ</summary>Требование — `close()` опустошает остаток: `pop()` должен
|
||||||
|
продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно
|
||||||
|
пуста — отдавать `false`, не уходя в ожидание.</details>
|
||||||
|
|
||||||
|
3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует
|
||||||
|
захвата мьютекса?
|
||||||
|
<details><summary>Ответ</summary>Потому что запись в `size_` из `push`/`pop` без
|
||||||
|
синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает
|
||||||
|
без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
+106
-4
@@ -4,14 +4,116 @@
|
|||||||
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
|
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
|
||||||
завершается по SIGTERM/SIGINT аккуратно (exit 0).
|
завершается по SIGTERM/SIGINT аккуратно (exit 0).
|
||||||
|
|
||||||
Требования:
|
## Требования
|
||||||
|
|
||||||
- мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента);
|
- мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента);
|
||||||
- сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи;
|
- сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи;
|
||||||
- `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту;
|
- `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту;
|
||||||
- до 64 одновременных соединений, закрытие по EOF со стороны клиента;
|
- до 64 одновременных соединений, закрытие по EOF со стороны клиента;
|
||||||
- никаких утечек дескрипторов (тест проверяет закрытие).
|
- никаких утечек дескрипторов (тест проверяет закрытие).
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | На каком адресе и с каким флагом сокета должен слушать сервер? — 127.0.0.1, `SO_REUSEADDR`
|
||||||
|
- base | Сколько одновременных соединений сервер должен держать минимум? — 64
|
||||||
|
- core | По какому событию сервер закрывает соединение с клиентом? — по EOF со стороны клиента
|
||||||
|
|
||||||
|
Почему дальше: раз сервер должен обслуживать до 64 соединений одним потоком без блокировки
|
||||||
|
на каждом, нужен механизм, который скажет, какие именно дескрипторы готовы — `epoll`.
|
||||||
|
|
||||||
|
## Механизм: epoll, уровень vs фронт
|
||||||
|
|
||||||
|
`epoll` — мультиплексор ввода-вывода ядра Linux: `epoll_ctl` регистрирует дескрипторы в
|
||||||
|
«списке интереса», который ядро хранит между вызовами; когда состояние дескриптора меняется
|
||||||
|
(пришли данные, освободился буфер записи), ядро само кладёт его в «список готовых»;
|
||||||
|
`epoll_wait` просто возвращает этот список. Отсюда сложность на каждый вызов пропорциональна
|
||||||
|
числу реально готовых дескрипторов, а не общему их числу (в отличие от `select`/`poll`,
|
||||||
|
которые каждый раз заново проходят весь набор).
|
||||||
|
|
||||||
|
У `epoll` два режима уведомления:
|
||||||
|
- **LT (level-triggered, по умолчанию)** — `epoll_wait` возвращает дескриптор снова и снова,
|
||||||
|
пока в его буфере остаются непрочитанные данные (условие «уровня» истинно). Прочитал не
|
||||||
|
всё за один раз — на следующем `epoll_wait` дескриптор опять придёт в списке готовых.
|
||||||
|
- **ET (edge-triggered, флаг `EPOLLET`)** — `epoll_wait` сообщает о готовности только один раз,
|
||||||
|
в момент перехода состояния «не готов → готов» (фронт). Если не вычитать буфер целиком за
|
||||||
|
этот раз, повторного уведомления не будет, пока не придут новые данные (новый фронт).
|
||||||
|
|
||||||
|
Из этого прямо следует требование к сокетам: **ET обязывает читать/писать в цикле до
|
||||||
|
`EAGAIN`**, потому что единственный сигнал готовности уже был использован и другого не будет,
|
||||||
|
пока состояние снова не изменится. А раз цикл идёт до `EAGAIN`, дескриптор обязан быть
|
||||||
|
**неблокирующим** (`O_NONBLOCK`) — на блокирующем сокете последний вызов `read`/`write` в этом
|
||||||
|
цикле, когда данных больше нет, завис бы навсегда вместо того, чтобы вернуть `-1`/`EAGAIN`.
|
||||||
|
При LT неблокирующий режим тоже нужен по той же причине разделения потока между многими
|
||||||
|
клиентами, но не читать «всё до EAGAIN» за раз не так опасно — событие придёт снова.
|
||||||
|
|
||||||
|
`EAGAIN` (синоним `EWOULDBLOCK`) — не ошибка сбоя, а сигнал «сейчас нечего делать, попробуй
|
||||||
|
позже»; `EINTR` — вызов прерван доставкой сигнала (например, при обработке SIGTERM/SIGINT) и
|
||||||
|
его нужно просто повторить, а не трактовать как реальную ошибку. Частичное чтение/запись
|
||||||
|
(`read`/`write` вернул меньше байт, чем просили) — нормальное поведение сокета, а не признак
|
||||||
|
проблемы: буфер ядра ограничен, и остаток нужно дочитывать/дописывать следующим вызовом.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Трактовать `EAGAIN` как ошибку и закрывать соединение → сервер рвёт рабочие соединения
|
||||||
|
просто потому, что в момент проверки данные ещё не пришли → видно как случайные обрывы
|
||||||
|
под нагрузкой, ловится логированием `errno` перед реакцией на ошибку.
|
||||||
|
- Забыть вычитывать буфер в цикле до `EAGAIN` при `EPOLLET` → повторного уведомления не будет,
|
||||||
|
часть данных «зависает» в буфере ядра → клиент не получает остаток эха, тест на эхо большого
|
||||||
|
объёма данных повисает или обрезает ответ.
|
||||||
|
- Не закрывать `fd` при ошибке/отключении клиента → утечка дескрипторов → процесс упирается
|
||||||
|
в лимит `ulimit -n` и получает `EMFILE` на новые `accept`, видно через `lsof -p PID` или
|
||||||
|
`/proc/PID/fd`.
|
||||||
|
- Игнорировать частичную запись (`write` вернул меньше, чем передали) → часть эха теряется
|
||||||
|
без ошибки → видно как обрезанный ответ клиенту при большом объёме данных за одну посылку.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что означает `EAGAIN` на неблокирующем сокете? — не ошибка, сигнал «данных пока нет, попробуй позже»
|
||||||
|
- core | В чём разница между LT и ET режимами epoll? — LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов»
|
||||||
|
- core | Почему ET требует неблокирующих дескрипторов? — цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет
|
||||||
|
- deep | Какая асимптотика у `epoll_wait` относительно select/poll? — O(1) на готовое событие против O(n) на весь набор у select/poll
|
||||||
|
|
||||||
|
Почему дальше: раз сервер должен «аккуратно завершаться по SIGTERM/SIGINT», нужно понимать,
|
||||||
|
как доставка сигнала прерывает системные вызовы и как это связать с циклом `epoll_wait`.
|
||||||
|
|
||||||
|
## Завершение по сигналу
|
||||||
|
|
||||||
|
Аккуратное завершение (`exit 0`) означает: сервер должен закрыть все сокеты и выйти из цикла
|
||||||
|
`epoll_wait`, а не просто дать процессу упасть от сигнала по умолчанию. Практический механизм —
|
||||||
|
установить обработчик на SIGTERM/SIGINT, который выставляет флаг (`volatile sig_atomic_t`), и
|
||||||
|
проверять этот флаг в цикле после каждого возврата из `epoll_wait` (который сам может вернуться
|
||||||
|
с `-1`/`EINTR` из-за прихода сигнала — это нужно отличать от реальной ошибки и не падать на ней).
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT? — 0
|
||||||
|
- core | Что возвращает `epoll_wait`, если его прервал сигнал? — -1 с `errno == EINTR`, не ошибка выполнения
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
Запускается как самостоятельный бинарник: `./solution <порт>`.
|
Запускается как самостоятельный бинарник: `./solution <порт>`.
|
||||||
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения,
|
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, льёт
|
||||||
льёт данные, проверяет эхо и корректное завершение по SIGTERM.
|
данные, проверяет эхо и корректное завершение по SIGTERM. Критерий: все `ok` + ревью кода
|
||||||
Критерий: все `ok` + ревью кода (использование epoll, обработка ошибок).
|
(использование epoll, обработка ошибок).
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сколько соединений открывает тест `grade.py 07`? — 3
|
||||||
|
- base | Каким сигналом тест проверяет корректное завершение сервера? — SIGTERM
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Проверь себя</summary>
|
||||||
|
|
||||||
|
1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это
|
||||||
|
покрыло все данные, которые были на момент события?
|
||||||
|
<details><summary>Ответ</summary>Потому что ET сообщает о фронте один раз; если между этим
|
||||||
|
чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел,
|
||||||
|
можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт
|
||||||
|
`EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.</details>
|
||||||
|
|
||||||
|
2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
|
||||||
|
<details><summary>Ответ</summary>Требование — до 64 одновременных соединений одним потоком;
|
||||||
|
блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные —
|
||||||
|
отсюда обязательность `epoll` и неблокирующих сокетов.</details>
|
||||||
|
|
||||||
|
3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
|
||||||
|
<details><summary>Ответ</summary>Это не ошибка: буфер сокета ограничен по размеру, ядро
|
||||||
|
приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим
|
||||||
|
вызовом `write`, когда `EPOLLOUT` снова покажет готовность.</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
+121
-1
@@ -11,7 +11,8 @@ TOP <ip> <число запросов>
|
|||||||
5XX <число ответов со статусом 500-599>
|
5XX <число ответов со статусом 500-599>
|
||||||
```
|
```
|
||||||
|
|
||||||
Правила:
|
## Правила формата
|
||||||
|
|
||||||
- IP — первое поле строки;
|
- IP — первое поле строки;
|
||||||
- TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию
|
- TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию
|
||||||
строки IP (лексикографически);
|
строки IP (лексикографически);
|
||||||
@@ -20,6 +21,125 @@ TOP <ip> <число запросов>
|
|||||||
по времени — нужен awk/sort или один-два прохода;
|
по времени — нужен awk/sort или один-два прохода;
|
||||||
- пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет.
|
- пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сколько строк TOP выводится, если уникальных IP меньше трёх? — столько, сколько есть уникальных IP
|
||||||
|
- base | Что печатает скрипт для пустого файла? — `TOTAL 0`, `5XX 0`, без строк TOP
|
||||||
|
- core | По какому полю строки лога определяется IP? — по первому полю
|
||||||
|
|
||||||
|
Почему дальше: раз файл может быть на миллион строк, нужно понять, почему построчный
|
||||||
|
`while read` в bash не укладывается по времени и что вместо этого реально делает работу
|
||||||
|
за O(n) без интерпретации каждой строки внутри самого bash.
|
||||||
|
|
||||||
|
## Механизм: почему не построчный цикл, а awk/sort
|
||||||
|
|
||||||
|
Построчный `while read line; do ... done < file` на миллионе строк запускает bash-интерпретатор
|
||||||
|
заново на каждую итерацию цикла (разбор строки, вызов внешних утилит вроде `cut`/`grep` из
|
||||||
|
цикла добавляет ещё и `fork+exec` процесса на каждую строку) — при миллионе строк это
|
||||||
|
миллион запусков процессов, что на порядки медленнее одного прохода специализированной
|
||||||
|
утилитой. `awk` и `sort` — скомпилированные бинарники, которые сами читают файл целиком
|
||||||
|
за один проход (awk) без порождения процесса на строку, и делают всю агрегацию (подсчёт по
|
||||||
|
IP, подсчёт 5xx, TOTAL) внутри одного вызова.
|
||||||
|
|
||||||
|
Практическая схема: один проход `awk` по файлу одновременно считает `TOTAL` (число строк),
|
||||||
|
инкрементирует счётчик по IP в ассоциативном массиве и считает строки с кодом 500–599
|
||||||
|
(код ответа — обычно отдельное поле в access.log), а на выходе печатает пары `IP count`.
|
||||||
|
Дальше `sort` сортирует эти пары по счётчику по убыванию, а при равенстве — по IP по
|
||||||
|
возрастанию (составной ключ сортировки), и `head -3` берёт первые три строки. Итог — два
|
||||||
|
прохода по данным (awk-агрегация, потом sort уже по маленькому списку уникальных IP, а не
|
||||||
|
по миллиону исходных строк), а не миллион запусков процессов.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- core | Почему `while read line` в bash медленный на миллионе строк? — каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку
|
||||||
|
- core | Что даёт связка awk + sort вместо построчного цикла? — один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов
|
||||||
|
- deep | По какому полю сортируется список IP при равенстве числа запросов? — по возрастанию строки IP (вторичный ключ сортировки)
|
||||||
|
|
||||||
|
Почему дальше: раз скрипт нетривиальный (несколько шагов, внешние утилиты, граничный случай
|
||||||
|
с пустым файлом), в нём легко замаскировать ошибку — отсюда `set -e` и его реальные границы.
|
||||||
|
|
||||||
|
## `set -e` и где он реально ломается
|
||||||
|
|
||||||
|
`set -e` (`errexit`) останавливает скрипт при первой команде, вернувшей ненулевой код
|
||||||
|
возврата, вместо того чтобы по умолчанию молча идти дальше — это отклонение от исторического
|
||||||
|
поведения bash, оптимизированного под интерактивную сессию, где одна неудачная команда не
|
||||||
|
должна обрывать сессию целиком. `set -o pipefail` отдельно нужен для конвейеров (`awk ... |
|
||||||
|
sort | head`): без него код возврата конвейера — это код возврата только последней команды
|
||||||
|
(`head`), и падение `awk` или `sort` посередине останется незамеченным, даже если `set -e`
|
||||||
|
включён — сам по себе `-e` конвейер как единое целое не покрывает.
|
||||||
|
|
||||||
|
`set -e` **не срабатывает**, если неудачная команда стоит частью условия — в `if`, `while`,
|
||||||
|
`until`, слева или справа от `&&`/`||`, либо после `!` — потому что в этих позициях код
|
||||||
|
возврата команды явно проверяется вызывающим кодом, а не игнорируется, и bash считает это
|
||||||
|
ожидаемым путём выполнения, а не аварией.
|
||||||
|
|
||||||
|
Отдельная и менее очевидная ловушка — **команда в присваивании переменной внутри `local`**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
local total=$(wc -l < "$file") # опасно под set -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь исполняются фактически две команды: `wc -l` внутри подстановки `$(...)` и сам `local`.
|
||||||
|
Если `wc -l` завершится с ошибкой, `set -e` эту ошибку не поймает, потому что итоговый код
|
||||||
|
возврата всей строки — это код возврата `local`, а `local` возвращает 0 (успех) сам по себе,
|
||||||
|
даже если команда внутри `$(...)` упала. Подстановка «теряется» внутри составной команды.
|
||||||
|
Лечится разделением на две строки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
total=$(wc -l < "$file") # код возврата — это код возврата wc -l, set -e сработает
|
||||||
|
local total
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Полагаться на голый `set -e` для пайплайна `awk | sort | head` → падение `awk` посередине
|
||||||
|
незаметно, `head` вернёт 0 → нужен `set -o pipefail`, видно только по неверному выводу,
|
||||||
|
не по коду возврата.
|
||||||
|
- `local var=$(cmd)` в одну строку → ошибка `cmd` маскируется кодом возврата `local` (всегда 0
|
||||||
|
для валидного объявления) → `set -e` не остановит скрипт, видно по неверному/пустому `var`
|
||||||
|
без явной ошибки.
|
||||||
|
- Не обработать пустой файл отдельно → `sort`/`head` на пустом входе не печатают строк TOP,
|
||||||
|
но если код по ошибке считает `TOTAL` через конвейер с `wc -l | ...`, легко перепутать
|
||||||
|
0 строк с ошибкой чтения файла.
|
||||||
|
- Не заквотить `"$file"` → путь с пробелом разбивается на несколько слов → скрипт падает
|
||||||
|
на несуществующем файле или обрабатывает не тот аргумент.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что делает `set -e`? — останавливает скрипт при первой команде с ненулевым кодом возврата
|
||||||
|
- core | В каких позициях `set -e` не останавливает скрипт при ошибке команды? — в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!`
|
||||||
|
- core | Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал? — итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри
|
||||||
|
- core | Зачем `set -o pipefail` отдельно от `set -e`? — без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
Запуск: `bash solution.sh access.log`
|
Запуск: `bash solution.sh access.log`
|
||||||
Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе).
|
Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе).
|
||||||
Критерий: точное совпадение вывода, код возврата 0.
|
Критерий: точное совпадение вывода, код возврата 0.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Каким кодом возврата должен завершаться `solution.sh` при успехе? — 0
|
||||||
|
- core | Что именно сверяет `grade.py 08` с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Проверь себя</summary>
|
||||||
|
|
||||||
|
1. Почему пайплайн `grep 500 access.log | wc -l` под одним `set -e` (без `pipefail`) может
|
||||||
|
молча пропустить ошибку `grep` (например, если файл не существует)?
|
||||||
|
<details><summary>Ответ</summary>Код возврата конвейера по умолчанию — это код возврата
|
||||||
|
последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней
|
||||||
|
упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую
|
||||||
|
упавшую команду.</details>
|
||||||
|
|
||||||
|
2. Почему `local total=$(false)` не остановит скрипт при `set -e`, а `total=$(false)` (без
|
||||||
|
`local` на той же строке) — остановит?
|
||||||
|
<details><summary>Ответ</summary>В первом случае итоговый код возврата строки — это код
|
||||||
|
возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе
|
||||||
|
объявления независимо от результата подстановки внутри; во втором случае код возврата
|
||||||
|
присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e`
|
||||||
|
сработает.</details>
|
||||||
|
|
||||||
|
3. Почему построчный `while read ip status rest; do ...; done < access.log` с миллионом строк
|
||||||
|
не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?
|
||||||
|
<details><summary>Ответ</summary>Каждая итерация цикла — это работа самого интерпретатора
|
||||||
|
bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на
|
||||||
|
порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл
|
||||||
|
целиком за один запуск процесса.</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
+125
-12
@@ -3,21 +3,134 @@
|
|||||||
В `crash.c` лежит программа, которая падает на части входов и портит память.
|
В `crash.c` лежит программа, которая падает на части входов и портит память.
|
||||||
Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**.
|
Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**.
|
||||||
|
|
||||||
Ожидаемое поведение после починки:
|
## Ожидаемое поведение после починки
|
||||||
|
|
||||||
- `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0;
|
- `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0;
|
||||||
- `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0;
|
- `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0;
|
||||||
- `./crash ""` печатает `len=0` и завершается с кодом 0;
|
- `./crash ""` печатает `len=0` и завершается с кодом 0;
|
||||||
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
|
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
|
||||||
|
|
||||||
Что сделать:
|
**Факты для карточек**
|
||||||
1. Собрать с отладочной информацией и санитайзерами:
|
- base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0
|
||||||
`gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`
|
- base | Что печатает `./crash ""` после починки? — `len=0`, код возврата 0
|
||||||
2. Разобраться, что именно портит память, а что приводит к падению при выходе.
|
- core | Какие два санитайзера должны не давать сообщений после починки? — ASan и UBSan (`-fsanitize=address,undefined`)
|
||||||
Полезно: `gdb ./crash`, `run`, `bt`, `frame`, `info locals`, `watch`.
|
|
||||||
3. Исправить `crash.c` (минимальные правки, стиль сохранить).
|
|
||||||
4. Заполнить `answer.txt`: сколько дефектов нашёл, какие именно, какими командами
|
|
||||||
отладчика это подтвердил (по шагам), почему падало именно так.
|
|
||||||
|
|
||||||
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN
|
Почему дальше: чтобы вообще видеть номера строк и имена переменных в отладчике, а не голый
|
||||||
и прогоняет 5 входов.
|
ассемблер, программу нужно собрать определённым образом — с этого и начинается разбор.
|
||||||
`answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а не только результат).
|
|
||||||
|
## Сборка с отладочной информацией и санитайзерами
|
||||||
|
|
||||||
|
```
|
||||||
|
gcc -g -O0 -fsanitize=address,undefined crash.c -o crash
|
||||||
|
```
|
||||||
|
|
||||||
|
`-g` встраивает в бинарник отладочную информацию в формате DWARF — таблицу соответствия
|
||||||
|
машинных адресов номерам строк исходника, именам переменных, их типам и границам функций.
|
||||||
|
Именно из DWARF gdb берёт всё, что показывает человеку: `bt` печатает имена функций и строки
|
||||||
|
вместо голых адресов, `print x` знает тип `x` и умеет напечатать его по правилам этого типа
|
||||||
|
(структуру — по полям, массив — по элементам), `list` показывает исходный код вокруг текущей
|
||||||
|
точки. Без `-g` DWARF-секций в бинарнике нет, и gdb видит только адреса и ассемблер.
|
||||||
|
|
||||||
|
`-O0` отключает оптимизации компилятора: оптимизатор переставляет и удаляет инструкции,
|
||||||
|
инлайнит функции и переиспользует один регистр под несколько переменных с непересекающимся
|
||||||
|
временем жизни — из-за этого отладочная информация от `-O2` часто не соответствует
|
||||||
|
однозначно исходному коду (строки «прыгают», переменная в `print` показывает не то значение,
|
||||||
|
потому что регистр уже переиспользован под другую переменную). Отсюда правило: для отладки
|
||||||
|
всегда пересобирают отдельно с `-g -O0`, а не отлаживают прод-сборку с оптимизациями.
|
||||||
|
|
||||||
|
`-fsanitize=address,undefined` — это ASan и UBSan вместе, инструментирование на этапе
|
||||||
|
компиляции, а не отдельный инструмент поверх готового бинарника:
|
||||||
|
- **ASan** окружает каждый выделенный блок памяти недоступными «красными зонами» и ведёт
|
||||||
|
теневую карту состояния памяти — ловит выход за границы буфера, use-after-free и двойное
|
||||||
|
освобождение прямо в момент обращения, с точным стеком, а не когда испорченные данные
|
||||||
|
позже вызовут крах в случайном другом месте;
|
||||||
|
- **UBSan** инструментирует места, где поведение по стандарту C не определено — знаковое
|
||||||
|
переполнение, сдвиг за пределы разрядности типа, разыменование `NULL` с невалидным типом —
|
||||||
|
и печатает конкретную строку кода нарушения.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Собрать без `-g` и пытаться понять крах по голому `bt` с адресами вместо строк → отладка
|
||||||
|
вслепую по ассемблеру, не соответствует задаче «найти минимальными правками».
|
||||||
|
- Отлаживать сборку с `-O2` → строки в `bt`/`print` не совпадают с реальным местом бага
|
||||||
|
из-за инлайнинга и переиспользования регистров → ложный след при поиске дефекта.
|
||||||
|
- Пропустить пересборку с `-fsanitize=address,undefined` перед финальной проверкой → баг,
|
||||||
|
который не падает явным креша при обычном запуске (например, чтение за границей внутри
|
||||||
|
тем же выделенного блока), останется незамеченным до `grade.py`.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что даёт флаг `-g` при сборке для gdb? — отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных
|
||||||
|
- core | Почему для отладки собирают с `-O0`, а не с `-O2`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику
|
||||||
|
- core | Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение? — все три, в момент обращения к памяти, а не постфактум
|
||||||
|
- deep | Что именно ловит UBSan, в отличие от ASan? — неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью
|
||||||
|
|
||||||
|
Почему дальше: раз отладочная информация уже в бинарнике, нужно знать конкретные команды
|
||||||
|
gdb, которыми по ней ходят при разборе краша.
|
||||||
|
|
||||||
|
## Команды gdb для разбора краша
|
||||||
|
|
||||||
|
Рабочий цикл: `gdb ./crash`, затем `run [аргумент]` — передаёт управление процессу; если
|
||||||
|
процесс получает сигнал (обычно `SIGSEGV` от порчи памяти или abort от санитайзера), gdb сам
|
||||||
|
останавливается на инструкции, вызвавшей сбой.
|
||||||
|
|
||||||
|
- `bt` — стек вызовов, цепочка кадров от текущей функции до `main`; первое, на что смотрят
|
||||||
|
после остановки — показывает, в какой функции и через какие вызовы дошли до краша.
|
||||||
|
- `frame N` — переключает контекст `print`/`list` на конкретный кадр стека из `bt`, чтобы
|
||||||
|
посмотреть локальные переменные вызывающей функции, а не только текущей.
|
||||||
|
- `info locals` — печатает все локальные переменные текущего кадра с их значениями по
|
||||||
|
информации из DWARF.
|
||||||
|
- `watch var` — аппаратная точка наблюдения: останавливает выполнение при каждом изменении
|
||||||
|
значения переменной. Нужна, когда переменная меняется как будто сама по себе из другого
|
||||||
|
места кода (типичный симптом переполнения буфера, которое затирает соседнюю память) — без
|
||||||
|
`watch` пришлось бы расставлять точки останова по подозрению вручную.
|
||||||
|
- `print x` — печатает текущее значение `x`, по типу из DWARF (структуру — по полям).
|
||||||
|
|
||||||
|
Санитайзер добавляет отдельный слой: при нарушении (ASan/UBSan) программа останавливается
|
||||||
|
сама, печатая стек и описание нарушения ещё до того, как повреждение памяти успело привести
|
||||||
|
к крашу в другом, не связанном с причиной месте — это часто быстрее, чем ловить последствия
|
||||||
|
вручную в gdb по голому `SIGSEGV`.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Какая команда gdb показывает цепочку вызовов до краша? — `bt`
|
||||||
|
- core | Чем `watch var` отличается от обычного `break`? — останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода
|
||||||
|
- core | Зачем нужен `frame N` после `bt`? — переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции
|
||||||
|
|
||||||
|
## Отчёт и проверка
|
||||||
|
|
||||||
|
Исправить `crash.c` (минимальные правки, стиль сохранить). Заполнить `answer.txt`: сколько
|
||||||
|
дефектов нашёл, какие именно, какими командами отладчика это подтвердил (по шагам), почему
|
||||||
|
падало именно так.
|
||||||
|
|
||||||
|
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN и
|
||||||
|
прогоняет 5 входов. `answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а
|
||||||
|
не только результат).
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сколько входов прогоняет `grade.py 09`? — 5
|
||||||
|
- base | Каким компилятором собирается `crash.c` на проверке? — gcc (компилятор C)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Проверь себя</summary>
|
||||||
|
|
||||||
|
1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с `-O2` для
|
||||||
|
финальной проверки производительности?
|
||||||
|
<details><summary>Ответ</summary>Оптимизатор переставляет и удаляет инструкции, инлайнит
|
||||||
|
вызовы и переиспользует регистры под разные переменные — отладочная информация от такой
|
||||||
|
сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или
|
||||||
|
неверные значения; для отладки нужна отдельная сборка `-g -O0`.</details>
|
||||||
|
|
||||||
|
2. Программа падает с `SIGSEGV` без санитайзеров, но при сборке с ASan вместо краша печатается
|
||||||
|
сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?
|
||||||
|
<details><summary>Ответ</summary>ASan останавливает программу в момент фактического выхода
|
||||||
|
за границу буфера — в точке причины; без ASan повреждённая память могла не привести к
|
||||||
|
немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные
|
||||||
|
использовались уже в другом месте — это типичная картина «краш далеко от причины».</details>
|
||||||
|
|
||||||
|
3. Чем `watch var` полезнее обычной точки останова (`break`) для поиска места, где переменная
|
||||||
|
портится неожиданно?
|
||||||
|
<details><summary>Ответ</summary>`break` останавливает выполнение в заданной строке кода,
|
||||||
|
но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения,
|
||||||
|
которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже
|
||||||
|
если изменение происходит из неожиданного места (например, из-за записи за границей другого
|
||||||
|
буфера, затирающей соседнюю память).</details>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
+109
-23
@@ -14,17 +14,52 @@
|
|||||||
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
|
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
|
||||||
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`.
|
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`.
|
||||||
|
|
||||||
## Глава II. Что нужно знать до старта
|
**Факты для карточек**
|
||||||
|
- base | Сколько времени закладывается на задачу 10? — 1,5–2,5 часа со ступенями
|
||||||
|
- base | Какой командой проверяется решение? — `python3 grade.py 10`
|
||||||
|
- deep | Где в реальных устройствах используется похожая структура? — таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти
|
||||||
|
|
||||||
1. `idx = hash & (cap - 1)` работает как «остаток от деления», только если `cap` — степень
|
Почему дальше: прежде чем писать код, нужно понять, почему индекс считается через `&`,
|
||||||
двойки. Пример: `cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`.
|
а не через `%`, и что физически происходит при коллизии.
|
||||||
2. Линейное зондирование: если ячейка занята, пробуем `(i+1) & (cap-1)`, потом `(i+2) & …`
|
|
||||||
и так по кругу, пока не найдём свободную (для вставки) или нужный ключ (для поиска).
|
## Глава II. Механизм: индекс, зондирование, tombstone, load factor
|
||||||
3. Удалять «в ноль» нельзя: если между началом зондирования и элементом появится пустая
|
|
||||||
ячейка, поиск до него не дойдёт. Поэтому пустая ячейка помечается **tombstone** —
|
**Индекс через маску.** `idx = hash & (cap - 1)` работает как «остаток от деления», только
|
||||||
«здесь был элемент, иди дальше».
|
если `cap` — степень двойки. Причина: у степени двойки `cap - 1` в двоичном виде — это
|
||||||
4. Фактор загрузки `load = size / capacity`. Держим ≤ 0.7: при 0.8+ пробеги становятся
|
подряд идущие единицы во всех младших битах (например, `cap = 8 = 0b1000` → `cap-1 = 0b0111`).
|
||||||
длинными, и O(1) превращается в O(n).
|
Операция `AND` с такой маской обнуляет все биты хеша выше этой границы и оставляет ровно
|
||||||
|
младшие `log2(cap)` бит — а это математически то же самое, что `hash mod cap`. Пример:
|
||||||
|
`cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`.
|
||||||
|
`%` работает для любой ёмкости, но требует инструкции деления (на некоторых платформах
|
||||||
|
дороже, чем сдвиг/AND); отсюда практика ограничивать ёмкость степенью двойки и считать
|
||||||
|
индекс битовой маской.
|
||||||
|
|
||||||
|
**Линейное зондирование.** Если ячейка `idx` занята, пробуем `(idx+1) & (cap-1)`, потом
|
||||||
|
`(idx+2) & (cap-1)` и так по кругу, пока не найдём свободную ячейку (для вставки) или
|
||||||
|
нужный ключ (для поиска). Механически это обход массива по кругу начиная с `idx`.
|
||||||
|
|
||||||
|
**Tombstone.** Удалять «в ноль» (ставить `EMPTY`) нельзя: если между началом зондирования
|
||||||
|
и искомым элементом появится пустая ячейка, поиск остановится на ней раньше, чем дойдёт до
|
||||||
|
элемента — элемент физически на месте, но недостижим для `get`. Поэтому удалённая ячейка
|
||||||
|
помечается **tombstone** — «здесь был элемент, поиск должен идти дальше», но **вставка**
|
||||||
|
может переиспользовать tombstone-ячейку под новый ключ.
|
||||||
|
|
||||||
|
**Load factor и длина пробега.** `load = size / capacity`. По формуле среднего числа проб
|
||||||
|
при линейном зондировании (Кнут): удачный поиск — `0.5·(1 + 1/(1-load))` проб,
|
||||||
|
неудачный — `0.5·(1 + 1/(1-load)²)`. При `load = 0.7`: удачный поиск ≈ 2,2 пробы,
|
||||||
|
неудачный ≈ 6,1. При `load = 0.9`: удачный ≈ 5,5, неудачный ≈ 50,5 — рост нелинейный,
|
||||||
|
именно поэтому порог держат на 0.7, а не поднимают его «для экономии памяти».
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Почему `hash & (cap-1)` эквивалентно `hash % cap`? — только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления
|
||||||
|
- base | Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс? — `19 & 7 = 3`
|
||||||
|
- core | Среднее число проб при удачном поиске, load factor 0.7? — ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`)
|
||||||
|
- core | То же при load factor 0.9? — ≈5,5 — рост почти в 2,5 раза от значения при 0.7
|
||||||
|
- deep | Среднее число проб при НЕудачном поиске, load factor 0.9? — ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска
|
||||||
|
- core | Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей? — поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте
|
||||||
|
|
||||||
|
Почему дальше: механизм понятен — теперь нужен точный интерфейс и числа, по которым
|
||||||
|
`grade.py` проверяет решение.
|
||||||
|
|
||||||
## Глава III. Задание
|
## Глава III. Задание
|
||||||
|
|
||||||
@@ -55,6 +90,12 @@ public:
|
|||||||
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
|
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
|
||||||
и вставки возвращается к правильному значению.
|
и вставки возвращается к правильному значению.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сколько ключей вставляет тест на кластеризацию и какие они? — 512 ключей, кратных 16
|
||||||
|
- base | За какое время должны пройти 100 000 вставок? — быстрее 2 секунд
|
||||||
|
- base | Какой порог load factor держит таблица? — ≤ 0.7
|
||||||
|
- base | Минимальная ёмкость таблицы по умолчанию? — 16, степень двойки
|
||||||
|
|
||||||
## Глава IV. Ступени (делай по одной, после каждой — прогон)
|
## Глава IV. Ступени (делай по одной, после каждой — прогон)
|
||||||
|
|
||||||
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
|
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
|
||||||
@@ -63,7 +104,10 @@ public:
|
|||||||
**Ступень 1. Хеш-функция и индекс (20 минут).**
|
**Ступень 1. Хеш-функция и индекс (20 минут).**
|
||||||
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей
|
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей
|
||||||
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
|
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
|
||||||
`h = (uint64_t)key * 2654435761u;` (это золотое сечение, классика).
|
`h = (uint64_t)key * 2654435761u;` (это `2^32 / φ` при золотом сечении `φ ≈ 1.618`,
|
||||||
|
классика — умножение на нечётную константу обратимо по модулю `2^32` и «размазывает»
|
||||||
|
младшие биты ключа по всей ширине результата, поэтому соседние по младшим битам ключи
|
||||||
|
после умножения расходятся по разным индексам).
|
||||||
Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
|
Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
|
||||||
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
|
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
|
||||||
свалятся в одну ячейку.
|
свалятся в одну ячейку.
|
||||||
@@ -87,9 +131,11 @@ Tombstone можно переиспользовать под вставку, н
|
|||||||
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
|
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
|
||||||
|
|
||||||
**Ступень 6. Перехеширование (30 минут).**
|
**Ступень 6. Перехеширование (30 минут).**
|
||||||
Когда `size * 10 > capacity * 7`, создай массив вдвое больше и **заново вставь все занятые
|
Когда `size * 10 > capacity * 7` (то есть `load > 0.7`), создай массив вдвое больше и
|
||||||
ключи** (tombstone не переносим — в новой таблице пусто). Учти: после этого `size` не должен
|
**заново вставь все занятые ключи** (tombstone не переносим — в новой таблице пусто).
|
||||||
измениться, а пробеги станут короче. Прогон: `python3 grade.py 10`.
|
Учти: после этого `size` не должен измениться, а пробеги станут короче (см. формулу проб
|
||||||
|
из главы II — вдвое большая ёмкость при том же `size` резко снижает `load`, а с ним и
|
||||||
|
среднее число проб). Прогон: `python3 grade.py 10`.
|
||||||
|
|
||||||
**Ступень 7. Прогон под санитайзерами (10 минут).**
|
**Ступень 7. Прогон под санитайзерами (10 минут).**
|
||||||
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
|
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
|
||||||
@@ -133,16 +179,56 @@ Tombstone можно переиспользовать под вставку, н
|
|||||||
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
|
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Глава VII. Частые ошибки и как их не допустить
|
## Глава VII. Ловушки
|
||||||
|
|
||||||
- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая.
|
- Забыть перемешать хеш (использовать `key` напрямую как индекс) → ключи, кратные 16,
|
||||||
- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на
|
все попадают в одну-две ячейки → кластеризация, пробег растёт линейно с числом таких
|
||||||
медленных платформах деление дороже.
|
ключей → тест на 512 ключей, кратных 16, падает по таймауту или по «not found».
|
||||||
- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место.
|
- Забыть про tombstone при rehash (перенести признак `TOMBSTONE` в новую таблицу вместо
|
||||||
- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше
|
того, чтобы его отбросить) → «мёртвые» ячейки переезжают и занимают место в новой
|
||||||
держать их в одной структуре ячейки.
|
таблице без необходимости → эффективная ёмкость меньше заявленной, load factor растёт
|
||||||
- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги
|
быстрее ожидаемого.
|
||||||
огромны, время уходит за лимит 2 секунды.
|
- Хранить ключ отдельно от значения в параллельных массивах и перепутать индекс при
|
||||||
|
перестановке → ASAN поймает не сразу, а в момент обращения к рассинхронизированной
|
||||||
|
ячейке; надёжнее держать ключ и значение в одной структуре ячейки.
|
||||||
|
- Проверять `size == capacity` вместо load factor (`size * 10 > capacity * 7`) → таблица
|
||||||
|
почти полностью заполняется, пробеги становятся длиной в десятки-сотни ячеек (см. формулу
|
||||||
|
неудачного поиска при load → 1), суммарное время `put` уходит за лимит 2 секунды на
|
||||||
|
100 000 ключей.
|
||||||
|
|
||||||
|
## Проверь себя
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы?</summary>
|
||||||
|
`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)`
|
||||||
|
перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой,
|
||||||
|
а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при
|
||||||
|
котором работает битовая маска вместо деления.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor
|
||||||
|
вырастет с 0.7 до 0.9?</summary>
|
||||||
|
Примерно в 2,5 раза: `0.5·(1+1/(1-0.7)) ≈ 2,2` против `0.5·(1+1/(1-0.9)) ≈ 5,5`.
|
||||||
|
Для неудачного поиска рост ещё резче — с ≈6,1 до ≈50,5, то есть почти в 8 раз.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже
|
||||||
|
такой ключ?</summary>
|
||||||
|
`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если
|
||||||
|
инкрементировать `size` без предварительного поиска ключа по цепочке зондирования,
|
||||||
|
повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт
|
||||||
|
элементов упадёт.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании?</summary>
|
||||||
|
В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только
|
||||||
|
реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы
|
||||||
|
место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами
|
||||||
|
цепочки проб после rehash пересчитываются заново.
|
||||||
|
</details>
|
||||||
|
|
||||||
## После сдачи
|
## После сдачи
|
||||||
|
|
||||||
|
|||||||
+141
-1
@@ -1,6 +1,7 @@
|
|||||||
# Задача 11 — куча: построение за O(n) и top-K (C++)
|
# Задача 11 — куча: построение за O(n) и top-K (C++)
|
||||||
|
|
||||||
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`), без `std::priority_queue`.
|
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`),
|
||||||
|
без `std::priority_queue`.
|
||||||
|
|
||||||
```c++
|
```c++
|
||||||
void heapify(std::vector<int>& a); // перестроить массив в кучу
|
void heapify(std::vector<int>& a); // перестроить массив в кучу
|
||||||
@@ -20,5 +21,144 @@ std::vector<int> top_k(const std::vector<int>& a, int k); // k наибо
|
|||||||
Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений,
|
Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений,
|
||||||
ASAN/UBSAN чистые.
|
ASAN/UBSAN чистые.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Индексы детей и родителя в max-heap на массиве? — дети `2i+1`, `2i+2`; родитель `(i-1)/2`
|
||||||
|
- base | Что за 2 секунды должны выполниться на 3 000 000 элементов? — `heapify` и `kth_largest` с `k=1000` (по отдельности)
|
||||||
|
- base | Какой ключевой запрет на реализацию `heapify`? — нельзя строить через `push` в цикле, только bottom-up за O(n)
|
||||||
|
|
||||||
|
Почему дальше: массив интерпретируется как дерево через индексную арифметику — дальше
|
||||||
|
нужно понять, как именно поддерживается инвариант кучи и почему bottom-up быстрее push-цикла.
|
||||||
|
|
||||||
|
## Механизм: массив как дерево, sift-down/sift-up
|
||||||
|
|
||||||
|
Массив хранится линейно (`std::vector<int>`), но интерпретируется как полное бинарное
|
||||||
|
дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без
|
||||||
|
указателей. Инвариант max-heap: значение в родителе не меньше значений в обоих детях.
|
||||||
|
|
||||||
|
**sift-up** (используется при вставке одного элемента): новый элемент кладётся в конец
|
||||||
|
массива и сравнивается с родителем; пока он больше родителя — меняются местами, индекс
|
||||||
|
сдвигается к корню. Длина пути от листа до корня — высота дерева, `O(log n)`.
|
||||||
|
|
||||||
|
**sift-down** (используется при извлечении максимума и при bottom-up heapify): элемент в
|
||||||
|
корне (или в произвольном узле) сравнивается с обоими детьми; если хотя бы один ребёнок
|
||||||
|
больше — меняется местами с большим из них, и процесс повторяется на новой позиции, пока
|
||||||
|
инвариант не восстановится или узел не станет листом. Тоже `O(log n)` на один вызов от
|
||||||
|
корня.
|
||||||
|
|
||||||
|
**top()** — доступ к максимуму — `O(1)`: по инварианту кучи максимум всегда лежит в корне,
|
||||||
|
то есть в начале массива, никакого поиска не требуется.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность доступа к максимуму (`top`)? — O(1), максимум всегда в корне по инварианту кучи
|
||||||
|
- base | Сложность одного `sift-up`/`sift-down` от произвольного узла? — O(log n) — путь ограничен высотой дерева
|
||||||
|
- core | Что сравнивается на каждом шаге `sift-down`? — узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла
|
||||||
|
|
||||||
|
## Механизм: почему bottom-up heapify — O(n), а push-цикл — O(n log n)
|
||||||
|
|
||||||
|
**Через push в цикле.** Вставка i-го элемента (при текущем размере кучи `i`) требует до
|
||||||
|
`O(log i)` шагов `sift-up`. Суммарная работа — `Σ log₂(i)` для `i = 1..n`, это `log₂(n!)`,
|
||||||
|
по формуле Стирлинга порядка `n·log₂(n)`. Итого `O(n log n)` — почти вся вставленная масса
|
||||||
|
элементов всплывает от листьев, где высота дерева максимальна (`~log n`), к своему месту.
|
||||||
|
|
||||||
|
**Через bottom-up heapify.** Алгоритм стартует с последнего нелистового узла (индекс
|
||||||
|
`n/2 - 1`) и идёт к корню (индекс `0`), вызывая `sift-down` на каждом узле. Ключевое
|
||||||
|
отличие: работа `sift-down` в узле пропорциональна **высоте этого узла `h`**, а не высоте
|
||||||
|
всего дерева. Узлов с большой высотой мало (в корне — один узел высоты `log n`), а узлов с
|
||||||
|
малой высотой — экспоненциально больше (листьев высоты 0 — примерно `n/2`, они вообще
|
||||||
|
пропускаются). Суммарная работа:
|
||||||
|
|
||||||
|
```
|
||||||
|
Σ h=0..log n (n / 2^(h+1)) · h
|
||||||
|
```
|
||||||
|
|
||||||
|
Ряд `Σ h/2^h` при `h → ∞` сходится к константе `2` (не растёт с `n`), поэтому вся сумма
|
||||||
|
ограничена `n · const = O(n)` — линейно, а не `n log n`. Для `n = 3 000 000`
|
||||||
|
(`log₂ n ≈ 21,5`) это даёт примерно на порядок (~10 раз) меньше операций сравнения, чем
|
||||||
|
push-цикл — именно поэтому требование задачи «bottom-up, а не push» не формальность, а
|
||||||
|
разница на порядок при 3 миллионах элементов.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- core | За какое время строится куча через n последовательных `push`? — O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n`
|
||||||
|
- core | За какое время строится куча через bottom-up heapify? — O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n`
|
||||||
|
- deep | С какого индекса начинается bottom-up heapify и в какую сторону идёт? — с последнего нелистового узла `n/2 - 1`, к корню (индекс 0)
|
||||||
|
- core | Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000? — примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up
|
||||||
|
|
||||||
|
Почему дальше: сама куча даёт максимум за O(1) и перестройку за O(log n) — на этом строятся
|
||||||
|
`kth_largest` и `top_k`, но у них есть более эффективная альтернатива для потоковых данных.
|
||||||
|
|
||||||
|
## kth_largest и top_k
|
||||||
|
|
||||||
|
Базовый подход: `heapify` за `O(n)`, затем `k` раз `pop` (извлечь максимум, `sift-down`
|
||||||
|
корня) — каждый `pop` стоит `O(log n)`. Итого `O(n + k log n)`. При `k = 1000` и
|
||||||
|
`n = 3 000 000` слагаемое `n` доминирует — отсюда требование «быстрее 2 секунд» выполнимо
|
||||||
|
за счёт того, что сама куча строится линейно.
|
||||||
|
|
||||||
|
Граничные случаи по условию: `k > n` — `top_k` отдаёт все элементы по убыванию, а
|
||||||
|
`kth_largest` — значение минимального элемента, без падения и без обращения за границу
|
||||||
|
массива.
|
||||||
|
|
||||||
|
**Потоковый top-K.** Когда данные не помещаются в память целиком (поток), вместо
|
||||||
|
построения полной кучи на все `n` элементов держат **min-heap размера k**: каждый новый
|
||||||
|
элемент сравнивается с минимумом кучи (`top`), и если новый элемент больше — минимум
|
||||||
|
удаляется, новый добавляется. Сложность — `O(n log k)` вместо `O(n log n)` для полной
|
||||||
|
сортировки, и память — `O(k)`, а не `O(n)`.
|
||||||
|
|
||||||
|
**nth_element vs куча.** `std::nth_element` (Хоара, quickselect) находит элемент на нужной
|
||||||
|
позиции и частично упорядочивает массив вокруг него (всё левее — не больше него, всё правее
|
||||||
|
— не меньше, но без порядка внутри частей) в среднем за `O(n)` — стандарт требует линейного
|
||||||
|
времени в среднем, но не гарантирует худший случай, в отличие от кучи, которая всегда даёт
|
||||||
|
`O(n + k log n)`. Для одного k-го элемента `nth_element` быстрее кучи по константе; для
|
||||||
|
top-K как **отсортированного** списка кучу всё равно придётся досортировать после
|
||||||
|
`nth_element`, тогда как `pop` из кучи уже отдаёт элементы по убыванию.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность `kth_largest`/`top_k` через полную кучу? — O(n + k log n): O(n) на heapify плюс k раз pop по O(log n)
|
||||||
|
- core | Когда используют min-heap размера k вместо полной кучи на весь массив? — на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k)
|
||||||
|
- core | Чем `std::nth_element` отличается от кучи для top-K? — в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop
|
||||||
|
|
||||||
|
## Ловушки
|
||||||
|
|
||||||
|
- Построить кучу через `push` в цикле вместо bottom-up `heapify` → машинная проверка
|
||||||
|
свойств кучи пройдёт (инвариант тот же), но сложность — O(n log n) вместо O(n) → на
|
||||||
|
3 000 000 элементов заметно медленнее и явно видно по коду при ревью (это как раз спросят
|
||||||
|
на собеседовании отдельным вопросом).
|
||||||
|
- Потерять или продублировать элементы при перестройке `sift-down` (например, скопировать
|
||||||
|
значение вместо обмена местами) → мультимножество элементов меняется → видно сравнением
|
||||||
|
отсортированной копии массива до и после `heapify`.
|
||||||
|
- Не ограничить `k` при `k > n` → обращение к `pop` на пустой куче или выход за границу
|
||||||
|
массива → падение или UB, видно по ASAN, а не просто неверный ответ.
|
||||||
|
|
||||||
|
## Проверь себя
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>1. Почему bottom-up heapify — O(n), хотя высота дерева log n?</summary>
|
||||||
|
Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте
|
||||||
|
всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты
|
||||||
|
`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится
|
||||||
|
к `n`, умноженному на константу, а не на `log n`.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения,
|
||||||
|
чем bottom-up heapify?</summary>
|
||||||
|
Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up
|
||||||
|
heapify) — то есть на порядок больше сравнений.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3. Почему `top()` кучи — O(1), а не O(log n)?</summary>
|
||||||
|
По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое
|
||||||
|
обращение по индексу `0`, без поиска и без перестройки структуры.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь
|
||||||
|
массив?</summary>
|
||||||
|
Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что
|
||||||
|
не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k
|
||||||
|
текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и
|
||||||
|
заменяет его за O(log k) — суммарно O(n log k) и память O(k).
|
||||||
|
</details>
|
||||||
|
|
||||||
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
|
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
|
||||||
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.
|
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.
|
||||||
|
|||||||
@@ -14,8 +14,134 @@ long long shortest_path(int n,
|
|||||||
- 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue`
|
- 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue`
|
||||||
(O((V+E) log V)), а не O(V²) перебор минимума.
|
(O((V+E) log V)), а не O(V²) перебор минимума.
|
||||||
|
|
||||||
Подсказки по разбору (после сдачи): «ленивое» удаление устаревших записей из очереди
|
|
||||||
(`if (d != dist[v]) continue;`), почему нельзя Дейкстру с отрицательными рёбрами и когда
|
|
||||||
нужен Беллман–Форд.
|
|
||||||
|
|
||||||
Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений.
|
Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что возвращает функция при `src == dst`? — 0
|
||||||
|
- base | Что возвращает функция при недостижимости `dst`? — -1
|
||||||
|
- base | В каком типе суммируются веса и почему не `int`? — `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути
|
||||||
|
|
||||||
|
Почему дальше: чтобы уложиться в 2 секунды на 100 000 вершин и 200 000 рёбер, важно понять,
|
||||||
|
почему наивный перебор минимума не подходит и что именно даёт куча.
|
||||||
|
|
||||||
|
## Механизм: куча против наивного перебора
|
||||||
|
|
||||||
|
**Наивная Дейкстра.** На каждом из `V` шагов алгоритм сканирует массив `dist[]` целиком,
|
||||||
|
чтобы найти непосещённую вершину с минимальным расстоянием — это `O(V)` на шаг. Суммарно
|
||||||
|
`V` шагов по `O(V)` — `O(V²)`, плюс `O(E)` на релаксацию рёбер (эта часть не доминирует).
|
||||||
|
Подходит, если граф плотный (`E ~ V²`), но не в этой задаче.
|
||||||
|
|
||||||
|
**Дейкстра с priority_queue.** Вместо линейного скана используется min-куча по текущему
|
||||||
|
известному расстоянию. При релаксации ребра `(u, v, w)`, если найден более короткий путь до
|
||||||
|
`v`, в кучу **добавляется новая запись** `(dist, v)` — `push` стоит `O(log размер_кучи)`.
|
||||||
|
Так как для одной вершины может накопиться несколько записей (по одной на каждое улучшение
|
||||||
|
расстояния), размер кучи ограничен числом релаксаций, то есть `O(V + E)`, и `log` от этой
|
||||||
|
величины по порядку — тот же `O(log V)` (`log(V²) = 2 log V`, то есть смена основания
|
||||||
|
не меняет асимптотику). Суммарно: `O((V + E) log V)`.
|
||||||
|
|
||||||
|
**Числа задачи.** `V = 100 000`, `E = 200 000`. Наивный `O(V²) = 100 000² = 10^10` операций
|
||||||
|
— в 2 секунды не укладывается ни при каких обстоятельствах. Куча: `(V+E)·log₂V ≈
|
||||||
|
300 000 · 16,6 ≈ 5·10^6` операций — на 3–4 порядка меньше, укладывается с большим запасом.
|
||||||
|
|
||||||
|
**«Ленивое» удаление устаревших записей.** `std::priority_queue` не умеет `decrease-key`
|
||||||
|
за `O(log n)` (не тот интерфейс), поэтому вместо обновления существующей записи в куче
|
||||||
|
для вершины `v` просто добавляется новая пара `(dist, v)` при каждом улучшении. В куче
|
||||||
|
одновременно может лежать несколько устаревших записей для одной вершины. При извлечении
|
||||||
|
самой записи с минимальным `dist` проверяется, актуальна ли она:
|
||||||
|
|
||||||
|
```c++
|
||||||
|
auto [d, v] = pq.top(); pq.pop();
|
||||||
|
if (d != dist[v]) continue; // запись устарела — лучшая уже обработана раньше
|
||||||
|
```
|
||||||
|
|
||||||
|
Это дешевле, чем поддерживать структуру с настоящим `decrease-key` (например, indexed
|
||||||
|
heap), и корректность не страдает — устаревшая запись всегда хуже уже найденного `dist[v]`
|
||||||
|
и просто пропускается.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность наивной Дейкстры (перебор минимума по массиву)? — O(V²)
|
||||||
|
- base | Сложность Дейкстры с бинарной кучей? — O((V+E) log V)
|
||||||
|
- core | При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды? — 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка
|
||||||
|
- core | Что означает «ленивое удаление» устаревших записей в очереди? — вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая
|
||||||
|
- deep | Почему `std::priority_queue` не используют с decrease-key напрямую? — у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop
|
||||||
|
|
||||||
|
Почему дальше: скорость решена кучей, но у Дейкстры есть жёсткое условие корректности —
|
||||||
|
неотрицательные веса. Нужно понять механизм, почему это условие обязательно.
|
||||||
|
|
||||||
|
## Механизм: почему нужны только неотрицательные веса
|
||||||
|
|
||||||
|
Дейкстра — жадный алгоритм: когда вершина `v` извлекается из очереди с минимальным на
|
||||||
|
данный момент расстоянием, алгоритм считает `dist[v]` **окончательным** и больше не
|
||||||
|
пересматривает эту вершину. Это верно только если все ещё не обработанные пути до `v` не
|
||||||
|
могут оказаться короче — а это гарантировано лишь при неотрицательных весах: любой другой
|
||||||
|
путь до `v` проходит через вершины с расстоянием `≥ dist[v]` и добавляет ребро `≥ 0`, то
|
||||||
|
есть не может дать сумму меньше `dist[v]`.
|
||||||
|
|
||||||
|
Если в графе есть отрицательное ребро, эта гарантия ломается: путь через уже
|
||||||
|
«финализированную» вершину может позже пройти по отрицательному ребру и дать меньшую
|
||||||
|
сумму, но алгоритм эту вершину уже не пересматривает — результат будет неверным без явного
|
||||||
|
падения или ошибки, то есть тихо неправильным.
|
||||||
|
|
||||||
|
Для графов с отрицательными весами (без отрицательных циклов) используется
|
||||||
|
**Беллман-Форд**: `V-1` раз релаксируются все `E` рёбер, `O(V·E)`. Дополнительно на `V`-м
|
||||||
|
проходе можно проверить, продолжает ли что-то релаксироваться — если да, в графе
|
||||||
|
отрицательный цикл, кратчайший путь не определён (можно уменьшать бесконечно).
|
||||||
|
|
||||||
|
Self-loop с весом `w ≥ 0` никогда не уменьшает кратчайший путь (добавление неотрицательного
|
||||||
|
веса к текущему расстоянию не улучшает его), поэтому не требует отдельной обработки —
|
||||||
|
алгоритм просто никогда не выберет такое ребро для релаксации выгодно.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- core | Почему Дейкстра ломается на отрицательных рёбрах? — алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается
|
||||||
|
- base | Какой алгоритм нужен при отрицательных весах без отрицательных циклов? — Беллман-Форд, O(V·E)
|
||||||
|
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл
|
||||||
|
- base | Почему self-loop с w≥0 не требует отдельной обработки? — добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию
|
||||||
|
|
||||||
|
## Ловушки
|
||||||
|
|
||||||
|
- Использовать `int` вместо `long long` для накопленной суммы весов → при весах рёбер до
|
||||||
|
10^9 и длинном пути сумма переполняет `int` → тихо неверный (отрицательный или
|
||||||
|
«случайный») результат без явного падения.
|
||||||
|
- Забыть проверку `if (d != dist[v]) continue` при извлечении из очереди → обрабатываются
|
||||||
|
устаревшие записи повторно → не влияет на корректность, но раздувает число операций и
|
||||||
|
на 200 000 рёбер может вывести время за лимит 2 секунды.
|
||||||
|
- Запустить алгоритм на графе с отрицательным ребром без проверки условия применимости →
|
||||||
|
результат тихо неверный (нет явной ошибки времени выполнения) — Дейкстра не обнаруживает
|
||||||
|
нарушение своего предположения сама.
|
||||||
|
- Перепутать направление рёбер (граф ориентированный) и релаксировать в обе стороны →
|
||||||
|
находится путь, которого нет в графе условия задачи, `shortest_path` возвращает заниженное
|
||||||
|
значение.
|
||||||
|
|
||||||
|
## Проверь себя
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>1. Почему при V=100 000 и E=200 000 наивный O(V²) не проходит по времени, а вариант
|
||||||
|
с кучей — проходит?</summary>
|
||||||
|
`O(V²) = 100 000² = 10^10` операций — на 3–4 порядка больше, чем позволяют 2 секунды.
|
||||||
|
Вариант с кучей — `O((V+E) log V) ≈ 300 000 · log₂(100 000) ≈ 300 000 · 16,6 ≈ 5·10^6`
|
||||||
|
операций, укладывается с большим запасом.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2. Что произойдёт с результатом Дейкстры, если в графе есть ребро веса -5, а
|
||||||
|
остальные рёбра положительные?</summary>
|
||||||
|
Результат может быть неверным без явной ошибки: если вершина на дешёвом с виду пути уже
|
||||||
|
извлечена из очереди и «финализирована», а позже к ней ведёт более короткий путь через
|
||||||
|
ребро -5, алгоритм это улучшение не увидит, так как вершина повторно не пересматривается.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3. Почему в очереди могут одновременно лежать несколько записей для одной и той же
|
||||||
|
вершины, и почему это не ошибка?</summary>
|
||||||
|
Каждое найденное улучшение расстояния до вершины добавляет новую запись `(dist, v)` в кучу
|
||||||
|
вместо обновления старой (`decrease-key` не поддерживается `std::priority_queue`). Это не
|
||||||
|
ошибка, потому что при извлечении устаревшая запись (`d != dist[v]`) просто пропускается —
|
||||||
|
корректность сохраняется, платится только лишней памятью в очереди и лишним `pop`.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4. Почему self-loop с весом w≥0 никогда не меняет кратчайший путь?</summary>
|
||||||
|
Self-loop добавляет вершине путь до самой себя длиной `w ≥ 0`. Поскольку путь длины 0 (не
|
||||||
|
двигаться) уже не хуже, прибавление неотрицательного веса к текущему `dist[v]` не может дать
|
||||||
|
меньшее значение — релаксация через такое ребро никогда не проходит условие «короче».
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -32,5 +32,121 @@ int prefix_for_hosts(int hosts); // самая узкая п
|
|||||||
|
|
||||||
Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений.
|
Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сколько узлов даёт /24? — 254
|
||||||
|
- base | Сколько узлов даёт /26? — 62
|
||||||
|
- base | Сколько узлов даёт /30? — 2
|
||||||
|
- base | Сеть, broadcast и диапазон узлов для `10.0.1.130/26`? — сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190`
|
||||||
|
|
||||||
|
Почему дальше: чтобы получить эти числа программно, а не подбором, нужен механизм вычисления
|
||||||
|
маски, границ сети и обратной задачи — подбора префикса по числу узлов.
|
||||||
|
|
||||||
|
## Механизм: маска, границы сети, обратный подбор префикса
|
||||||
|
|
||||||
|
**Маска по префиксу.** Маска — это `prefix` единиц в старших битах и `32-prefix` нулей в
|
||||||
|
младших: `mask = 0xFFFFFFFFu << (32 - prefix)` для `prefix > 0`. Для `prefix == 0` формула
|
||||||
|
не годится напрямую — сдвиг на 32 бита для 32-битного типа в C/C++ является неопределённым
|
||||||
|
поведением (UB), поэтому этот случай обрабатывается отдельно: `mask = 0`.
|
||||||
|
|
||||||
|
**Границы сети.** `network = ip & mask` — обнуляет все биты хостовой части (сохраняет
|
||||||
|
только биты сети). `broadcast = network | ~mask` — выставляет все биты хостовой части в
|
||||||
|
единицу (`~mask` — это как раз маска хостовой части, инвертированная сетевая). Число бит,
|
||||||
|
отданных под узлы, — `32 - prefix`; всего адресов в блоке — `2^(32-prefix)`.
|
||||||
|
|
||||||
|
**Разбор эталонного примера `10.0.1.130/26`.** `/26` оставляет `32-26=6` бит под узлы,
|
||||||
|
блок из `2^6 = 64` адресов. Адрес `.130` попадает в блок `128..191` (границы блоков кратны
|
||||||
|
64): `network = 10.0.1.128`, `broadcast = 10.0.1.191`. Обычные узлы — `first_host =
|
||||||
|
network+1 = .129`, `last_host = broadcast-1 = .190`. Из 64 адресов блока минус 2 служебных
|
||||||
|
(сеть и broadcast) — 62 узла, что совпадает с `/26 → 62 узла`.
|
||||||
|
|
||||||
|
**Почему `/26` даёт именно 62 узла.** `host_count = 2^(32-26) - 2 = 2^6 - 2 = 64 - 2 = 62`.
|
||||||
|
Аналогично `/24`: `2^8 - 2 = 254`; `/30`: `2^2 - 2 = 2` — ровно минимум для соединения
|
||||||
|
точка-точка между двумя маршрутизаторами.
|
||||||
|
|
||||||
|
**`/31` — исключение (RFC 3021).** По общей формуле `/31` дал бы `2^1 - 2 = 0` узлов —
|
||||||
|
бессмысленный результат для блока из 2 адресов. RFC 3021 разрешает для каналов
|
||||||
|
точка-точка (ровно 2 узла на линке) отдавать под хосты оба адреса блока: широковещательный
|
||||||
|
адрес такому каналу физически не нужен (получателей всего два, и оба известны заранее), а
|
||||||
|
резервировать половину 2-адресного блока под network/broadcast — чистая потеря адресного
|
||||||
|
пространства. Поэтому `host_count = 2`, `first_host = network`, `last_host = broadcast`.
|
||||||
|
|
||||||
|
**`/32` — единичный адрес.** Блок из одного адреса: `network = broadcast = first_host =
|
||||||
|
last_host = ip`, `host_count = 1`. Используется для маршрутов на конкретный узел
|
||||||
|
(host route) или loopback-адресов маршрутизатора.
|
||||||
|
|
||||||
|
**Обратная задача — `prefix_for_hosts(h)`.** Нужен наибольший `prefix` (самая узкая
|
||||||
|
подсеть), при котором `2^(32-prefix) - 2 >= h`, то есть `2^(32-prefix) >= h+2`, то есть
|
||||||
|
`32-prefix >= log2(h+2)`. Так как число хостовых бит — целое, наименьшее подходящее
|
||||||
|
значение — `32-prefix = ceil(log2(h+2))`, откуда:
|
||||||
|
|
||||||
|
```
|
||||||
|
prefix_for_hosts(h) = 32 - ceil(log2(h+2))
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка на эталонных примерах: `h=62` → `h+2=64`, `log2=6`, `prefix=26` ✓. `h=63` →
|
||||||
|
`h+2=65`, `log2(65)≈6.02`, `ceil=7`, `prefix=25` ✓ (62 узла `/26` уже не хватает на 63-й
|
||||||
|
хост, нужен на бит шире — `/25` с 126 узлами). `h=254` → `h+2=256`, `log2=8`, `prefix=24` ✓.
|
||||||
|
`h=1` → `h+2=3`, `log2(3)≈1.58`, `ceil=2`, `prefix=30` ✓.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Формула маски для префикса p (0<p≤32)? — `0xFFFFFFFF << (32-p)`; для p=0 маска = 0 отдельным случаем (сдвиг на 32 — UB)
|
||||||
|
- base | Как получить network и broadcast из ip и mask? — `network = ip & mask`, `broadcast = network | ~mask`
|
||||||
|
- core | Формула host_count для prefix ≤ 30? — `2^(32-prefix) - 2`
|
||||||
|
- core | Почему /31 — исключение и даёт 2 узла вместо 0 по общей формуле? — RFC 3021: у канала точка-точка ровно 2 узла, broadcast не нужен, поэтому оба адреса 2-адресного блока отдаются под хосты вместо потери половины блока
|
||||||
|
- core | Что возвращает subnet_of для /32? — network=broadcast=first_host=last_host=ip, host_count=1 (host route/loopback)
|
||||||
|
- core | Формула `prefix_for_hosts(h)`? — `32 - ceil(log2(h+2))`, из условия `2^(32-prefix) >= h+2`
|
||||||
|
- deep | Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1? — /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами
|
||||||
|
|
||||||
|
## Ловушки
|
||||||
|
|
||||||
|
- Вычислить маску как `0xFFFFFFFF << (32 - prefix)` при `prefix == 0` без отдельной ветки
|
||||||
|
→ сдвиг на 32 бита для 32-битного типа — неопределённое поведение в C/C++ (компилятор
|
||||||
|
может дать любое значение, не обязательно 0) → UBSan ловит это как сдвиг за пределы
|
||||||
|
разрядности типа.
|
||||||
|
- Применить общую формулу `host_count = 2^(32-p) - 2` к `/31` без проверки исключения →
|
||||||
|
получится 0 узлов вместо 2 → тест на `/31` (RFC 3021) падает.
|
||||||
|
- Использовать `int` вместо `long long` для `host_count` → для `/0` значение `2^32 - 2` не
|
||||||
|
влезает в `int` (переполнение со знаком — UB) → в условии структуры явно указан `long long`
|
||||||
|
именно из-за этого случая.
|
||||||
|
- Перепутать host byte order с network byte order при сравнении с реальным трафиком или
|
||||||
|
утилитами вроде `tcpdump` → `10.0.0.1` в host order этой задачи — это `0x0A000001`, но в
|
||||||
|
сетевом порядке байты переставлены (`0x0100000A` при little-endian хосте) — конвертация
|
||||||
|
через `htonl`/`ntohl`, а не прямое сравнение чисел.
|
||||||
|
|
||||||
|
## Проверь себя
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>1. Почему /31 не подчиняется общей формуле «минус 2 служебных адреса»?</summary>
|
||||||
|
Потому что у 2-адресного блока резервирование network и broadcast по общей формуле оставило
|
||||||
|
бы 0 узлов — бессмысленно для канала точка-точка, где всего два участника и оба заранее
|
||||||
|
известны, широковещание не нужно. RFC 3021 явно отдаёт оба адреса блока под хосты.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2. Дано `ip=10.0.1.130`, `prefix=26`. Посчитать network, broadcast, first_host,
|
||||||
|
last_host по шагам.</summary>
|
||||||
|
Хостовых бит: `32-26=6`, размер блока `2^6=64`. Адрес `.130` попадает в блок, начинающийся
|
||||||
|
на границе, кратной 64: `128 <= 130 < 192`, значит `network=10.0.1.128`,
|
||||||
|
`broadcast=10.0.1.128+63=10.0.1.191`, `first_host=network+1=10.0.1.129`,
|
||||||
|
`last_host=broadcast-1=10.0.1.190`.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3. Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 только на
|
||||||
|
единицу?</summary>
|
||||||
|
`/26` физически даёт ровно 62 адреса под хосты — этого не хватает даже на один хост меньше
|
||||||
|
требуемых 63. Следующий шаг «расширения» подсети — не +1 узел, а удвоение блока: снятие
|
||||||
|
одного бита из префикса (`/25`) сразу даёт 126 узлов. Промежуточных вариантов между /26 и
|
||||||
|
/25 не существует, поэтому единственный подходящий ответ — /25.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4. Почему для `prefix=0` нельзя вычислять маску как `0xFFFFFFFF << (32-0)`?</summary>
|
||||||
|
Это сдвиг на 32 бита для 32-битного беззнакового типа — стандарт C/C++ определяет сдвиг
|
||||||
|
только для величины меньше ширины типа в битах, сдвиг на саму ширину или больше — UB
|
||||||
|
(на практике часто даёт исходное значение без сдвига вместо ожидаемого 0). Поэтому
|
||||||
|
`prefix == 0` обрабатывается отдельной веткой: `mask = 0` напрямую.
|
||||||
|
</details>
|
||||||
|
|
||||||
Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`),
|
Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`),
|
||||||
почему `/31` — исключение, что такое маска в бинарном виде.
|
почему `/31` — исключение, что такое маска в бинарном виде.
|
||||||
|
|||||||
+122
-17
@@ -3,10 +3,11 @@
|
|||||||
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
|
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
|
||||||
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
|
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
|
||||||
|
|
||||||
## 1. Что такое O-нотация (без воды)
|
## 1. Что такое O-нотация
|
||||||
|
|
||||||
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
|
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
|
||||||
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n).
|
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое
|
||||||
|
100 и множитель 3 не меняют форму роста, поэтому их не пишут.
|
||||||
|
|
||||||
Три правила, которых хватает для 90% вопросов:
|
Три правила, которых хватает для 90% вопросов:
|
||||||
|
|
||||||
@@ -18,14 +19,32 @@ O-нотация отвечает на вопрос «как растёт вре
|
|||||||
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
|
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
|
||||||
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
|
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
|
||||||
|
|
||||||
**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция
|
**Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не
|
||||||
«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` —
|
гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`:
|
||||||
обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно
|
когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её,
|
||||||
**амортизированное O(1)**, потому что ёмкость удваивается.
|
выделяет новый блок и переносит туда все элементы — это разовая операция O(n). Из-за
|
||||||
|
геометрического роста ёмкости такие реаллокации случаются экспоненциально реже: после
|
||||||
|
k-й реаллокации следующая наступит примерно через 2^k новых вставок. Сумма стоимости всех
|
||||||
|
реаллокаций на n вставок — геометрическая прогрессия n/2 + n/4 + n/8 + ... ≈ n, то есть
|
||||||
|
суммарно O(n) на n операций, а не O(n²) — отсюда амортизированное **O(1)** на одну вставку.
|
||||||
|
Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка копировала бы весь массив —
|
||||||
|
суммарно O(n²). Геометрический рост — не оптимизация, а необходимое условие амортизации.
|
||||||
|
Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на
|
||||||
|
элементы вектора, потому что блок памяти физически переехал — источник use-after-free,
|
||||||
|
который ловит ASAN.
|
||||||
|
|
||||||
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
|
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
|
||||||
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
|
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Во что превращается 3n + 100 в O-нотации? — O(n)
|
||||||
|
- base | Сколько шагов у бинарного поиска в массиве из 10⁶ элементов? — 20 (log₂ 10⁶ ≈ 20)
|
||||||
|
- core | Почему push_back в среднем O(1), а не O(n)? — ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n²
|
||||||
|
- core | Что ломает удвоение ёмкости у vector? — указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти)
|
||||||
|
- deep | Что будет, если ёмкость vector растить на +1 за раз вместо удвоения? — суммарная стоимость n вставок станет O(n²)
|
||||||
|
|
||||||
|
Почему дальше: если push_back амортизированно O(1) за счёт удвоения, какие структуры данных вообще гарантируют O(1) в среднем на операцию — переходим к таблице сложностей и хеш-таблицам.
|
||||||
|
|
||||||
## 2. Таблица сложностей, которую надо знать
|
## 2. Таблица сложностей, которую надо знать
|
||||||
|
|
||||||
| Структура | Поиск | Вставка | Удаление | Память |
|
| Структура | Поиск | Вставка | Удаление | Память |
|
||||||
@@ -38,17 +57,44 @@ O-нотация отвечает на вопрос «как растёт вре
|
|||||||
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
|
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
|
||||||
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
|
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
|
||||||
|
|
||||||
|
Почему связный список даёт O(1) на вставку/удаление: если узел уже найден (есть указатель),
|
||||||
|
операция — просто перелинковка соседних указателей без сдвига остальных элементов; O(n) в
|
||||||
|
поиске появляется отдельно, потому что нет арифметики адреса — только последовательный проход.
|
||||||
|
Почему у отсортированного массива поиск O(log n), а вставка O(n): бинарный поиск делит
|
||||||
|
диапазон пополам, но вставка сдвигает все элементы после точки вставки, чтобы сохранить
|
||||||
|
непрерывность блока памяти.
|
||||||
|
|
||||||
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
|
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
|
||||||
частый вопрос), вставка одного элемента — O(log n), взятие максимума — O(1).
|
частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа
|
||||||
|
по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание
|
||||||
|
вверх на высоту кучи), взятие максимума — O(1) (это корень).
|
||||||
|
|
||||||
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
|
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
|
||||||
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
|
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
|
||||||
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
|
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
|
||||||
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя.
|
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево
|
||||||
|
решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n).
|
||||||
|
|
||||||
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
|
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
|
||||||
bubble, counting. Неустойчивы: quick, heap, selection.
|
bubble, counting. Неустойчивы: quick, heap, selection.
|
||||||
|
|
||||||
|
**map vs unordered_map**: `map` — красно-чёрное дерево, инвариант балансировки (чередование
|
||||||
|
цветов узлов, равное число чёрных узлов на любом пути от корня до листа) держит высоту
|
||||||
|
порядка log n, отсюда O(log n) на все операции и ключи всегда в отсортированном порядке при
|
||||||
|
обходе. `unordered_map` в среднем быстрее на чистом поиске/вставке (O(1) и меньше косвенных
|
||||||
|
переходов по указателям), но не даёт упорядоченного обхода и не гарантирует порядок бакетов
|
||||||
|
между вызовами rehash.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность построения кучи (heapify) из произвольного массива? — O(n), не O(n log n)
|
||||||
|
- base | Какая сортировка всегда O(n log n) и устойчива? — mergesort
|
||||||
|
- core | Худший случай quicksort и когда он достигается? — O(n²), на уже отсортированном массиве при плохом выборе опорного
|
||||||
|
- core | Нижняя граница сортировки сравнениями? — Ω(n log n)
|
||||||
|
- core | За счёт чего map держит высоту log n? — инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа
|
||||||
|
- deep | Почему нельзя полагаться на порядок обхода unordered_map? — порядок бакетов не гарантирован и может меняться при rehash
|
||||||
|
|
||||||
|
Почему дальше: таблица говорит, что хеш-таблица в среднем O(1) — дальше разбираем механизм, который это обеспечивает и почему он иногда ломается до O(n).
|
||||||
|
|
||||||
## 3. Хеш-таблица: как устроена
|
## 3. Хеш-таблица: как устроена
|
||||||
|
|
||||||
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
|
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
|
||||||
@@ -57,7 +103,8 @@ bubble, counting. Неустойчивы: quick, heap, selection.
|
|||||||
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
|
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
|
||||||
загрузки** (сколько занято от общего размера).
|
загрузки** (сколько занято от общего размера).
|
||||||
|
|
||||||
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка.
|
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка: при n ключах и m
|
||||||
|
корзинах коллизии статистически неизбежны уже при n, сравнимом с √m (парадокс дней рождения).
|
||||||
|
|
||||||
Два способа разрешения:
|
Два способа разрешения:
|
||||||
|
|
||||||
@@ -66,17 +113,44 @@ bubble, counting. Неустойчивы: quick, heap, selection.
|
|||||||
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
|
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
|
||||||
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
|
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
|
||||||
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
|
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
|
||||||
Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка
|
Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе
|
||||||
зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone**
|
разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить
|
||||||
(надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять.
|
ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение —
|
||||||
|
**tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт
|
||||||
|
дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался
|
||||||
|
бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит
|
||||||
|
дальше по цепочке зондирования.
|
||||||
|
|
||||||
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
|
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
|
||||||
чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое
|
чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число
|
||||||
больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево).
|
проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически
|
||||||
|
выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново
|
||||||
|
по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые
|
||||||
|
позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит
|
||||||
|
она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда
|
||||||
|
**амортизированное O(1)** на вставку, а не просто «в среднем быстро».
|
||||||
|
|
||||||
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
|
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
|
||||||
деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие
|
деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре.
|
||||||
биты (для строк — FNV-1a или `std::hash<std::string>`).
|
Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при
|
||||||
|
делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)),
|
||||||
|
для строк — FNV-1a или `std::hash<std::string>`.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Взять ёмкость не степенью двойки при использовании `hash & (cap-1)` → маска отрежет не те биты → часть корзин никогда не используется, видно по неравномерному распределению цепочек.
|
||||||
|
- Удалять элемент простой очисткой ячейки при открытой адресации вместо tombstone → поиск последующих элементов той же цепочки зондирования обрывается раньше времени → `get` возвращает false для существующего ключа, видно в тесте «insert A, B (коллизия с A), delete A, get B» → false.
|
||||||
|
- Не проверять load factor перед вставкой → цепочки/пробеги растут неограниченно → поиск деградирует к O(n), видно по профилировщику как рост времени `unordered_map::find` с размером таблицы.
|
||||||
|
- Пользовательский ключ с плохим/предсказуемым хешем → все элементы в одном бакете → тихая деградация до O(n) без ошибки компиляции, ловится только профилировщиком.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Формула фактора загрузки? — load = size / capacity
|
||||||
|
- base | Порог load factor при открытой адресации, после которого делают rehash? — обычно ≤ 0.7
|
||||||
|
- core | Зачем tombstone при открытой адресации? — чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже
|
||||||
|
- core | Почему ёмкость хеш-таблицы берут степенью двойки? — idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре
|
||||||
|
- core | Что физически происходит при rehash? — выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap)
|
||||||
|
- deep | Почему rehash даёт амортизированное O(1), а не O(n) на вставку? — та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n²
|
||||||
|
|
||||||
|
Почему дальше: те же формулы сложности стоит применить к конкретному коду — переходим к разбору задач, где нужно на глаз определить сложность.
|
||||||
|
|
||||||
## 4. Разбор примера: считаем сложности
|
## 4. Разбор примера: считаем сложности
|
||||||
|
|
||||||
@@ -109,6 +183,16 @@ bool has_pair_fast(const std::vector<int>& v, int k) { // O(n) в среднем
|
|||||||
|
|
||||||
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
|
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
|
||||||
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
|
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
|
||||||
|
Механизм ускорения: (б) на каждой паре (i, j) делает сравнение за O(1), но пар — O(n²);
|
||||||
|
(в) вместо перебора пар один раз кладёт каждый элемент в хеш-множество (O(1) в среднем на
|
||||||
|
вставку) и один раз проверяет наличие дополнения k - x (O(1) в среднем на поиск) — итого
|
||||||
|
O(n) вставок и O(n) поисков вместо O(n²) сравнений.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Сложность has_pair (вложенный цикл по всем парам)? — O(n²)
|
||||||
|
- core | Сложность has_pair_fast по времени и по памяти? — O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество
|
||||||
|
|
||||||
|
Почему дальше: чтобы поверить в O(1) на вставку/поиск из примера (в), нужно понять, что внутри unordered_set/unordered_map — переходим к ручной сборке хеш-таблицы.
|
||||||
|
|
||||||
## 5. Разбор примера: как руками собрать хеш-таблицу
|
## 5. Разбор примера: как руками собрать хеш-таблицу
|
||||||
|
|
||||||
@@ -168,11 +252,21 @@ struct HashTable {
|
|||||||
|
|
||||||
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
|
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
|
||||||
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
|
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
|
||||||
**все** узлы, потому что индекс зависит от размера массива.
|
**все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) —
|
||||||
|
после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому
|
||||||
|
пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) —
|
||||||
|
степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы
|
||||||
|
тот же результат быстрее, именно потому что cap — степень двойки.
|
||||||
|
|
||||||
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
|
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
|
||||||
до свободной ячейки, а при удалении ставим tombstone.
|
до свободной ячейки, а при удалении ставим tombstone.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211
|
||||||
|
- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны
|
||||||
|
|
||||||
|
Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок.
|
||||||
|
|
||||||
## 6. Что спросят на собеседовании (готовые ответы)
|
## 6. Что спросят на собеседовании (готовые ответы)
|
||||||
|
|
||||||
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
|
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
|
||||||
@@ -184,6 +278,10 @@ struct HashTable {
|
|||||||
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
|
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
|
||||||
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
|
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами
|
||||||
|
- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда)
|
||||||
|
|
||||||
## 7. Материалы (первопартийные)
|
## 7. Материалы (первопартийные)
|
||||||
|
|
||||||
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
|
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
|
||||||
@@ -198,6 +296,8 @@ struct HashTable {
|
|||||||
3. `heapify` из произвольного массива — за сколько?
|
3. `heapify` из произвольного массива — за сколько?
|
||||||
4. Какая из сортировок устойчива: quick, merge, heap?
|
4. Какая из сортировок устойчива: quick, merge, heap?
|
||||||
5. Зачем tombstone при открытой адресации?
|
5. Зачем tombstone при открытой адресации?
|
||||||
|
6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме?
|
||||||
|
7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Ответы</summary>
|
<summary>Ответы</summary>
|
||||||
@@ -208,6 +308,11 @@ struct HashTable {
|
|||||||
4. merge (устойчива), quick и heap — нет.
|
4. merge (устойчива), quick и heap — нет.
|
||||||
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
|
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
|
||||||
ячейки до элемента, который был вставлен за ней.
|
ячейки до элемента, который был вставлен за ней.
|
||||||
|
6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая
|
||||||
|
операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость
|
||||||
|
n операций ≈ n, а не n².
|
||||||
|
7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю —
|
||||||
|
дешевле для процессора.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 9. Ссылки на задачи этого дня
|
## 9. Ссылки на задачи этого дня
|
||||||
|
|||||||
+205
-25
@@ -1,20 +1,30 @@
|
|||||||
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
|
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
|
||||||
|
|
||||||
Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения +
|
Модель одна на весь урок: процесс = адресное пространство (`mm_struct` в ядре) + поток
|
||||||
открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает
|
выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов
|
||||||
результат, сигнал — асинхронное уведомление.
|
(`task_struct`). `fork` копирует эту запись и помечает память как copy-on-write, `exec`
|
||||||
|
заменяет содержимое адресного пространства, оставляя PID и дескрипторы, `wait` забирает у
|
||||||
|
ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.
|
||||||
|
|
||||||
## 1. fork(): что реально происходит
|
## 1. fork(): что реально происходит
|
||||||
|
|
||||||
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
|
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
|
||||||
пространство, свой PID, свой поток. Родитель продолжает с того же места.
|
пространство, свой PID, свой поток. Родитель продолжает с того же места.
|
||||||
|
|
||||||
|
Механически ядро: (1) выделяет новый `task_struct` и PID; (2) копирует таблицу файловых
|
||||||
|
дескрипторов — обе записи после `fork` указывают на те же открытые файловые описания в
|
||||||
|
ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы
|
||||||
|
данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь
|
||||||
|
планировщика. Само копирование данных при этом не происходит — см. COW ниже.
|
||||||
|
|
||||||
Возвращает **дважды** — и это ключ к пониманию:
|
Возвращает **дважды** — и это ключ к пониманию:
|
||||||
- в родителе — PID ребёнка (> 0);
|
- в родителе — PID ребёнка (> 0);
|
||||||
- в ребёнке — 0;
|
- в ребёнке — 0;
|
||||||
- при ошибке — −1 (и `errno`), ребёнок не создан.
|
- при ошибке — −1 (и `errno`), ребёнок не создан.
|
||||||
|
|
||||||
Поэтому классический код всегда ветвится:
|
Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова —
|
||||||
|
единственный способ понять, в какой копии сейчас исполняется код, это проверить
|
||||||
|
возвращённое значение. Поэтому классический код всегда ветвится:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
@@ -31,13 +41,41 @@ if (pid == 0) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни страницы,
|
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни физические
|
||||||
помеченные «только чтение». При первой записи в страницу ядро делает её копию — только
|
страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи
|
||||||
тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают
|
в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую
|
||||||
в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
|
страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц
|
||||||
при первой записи**.
|
только пишущего процесса — только тогда. Поэтому `fork` дешёвый, даже если процесс занимает
|
||||||
|
гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в
|
||||||
|
формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
|
||||||
|
при первой записи**, а не при самом `fork`.
|
||||||
|
|
||||||
Совет: `fflush(stdout)` перед `fork`, иначе буфер вывода может продублироваться в ребёнке.
|
Причина именно такого устройства — частый паттерн «`fork` сразу за которым `exec`»: если бы
|
||||||
|
ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы
|
||||||
|
выброшенной, ведь `exec` тут же заменяет содержимое памяти новой программой.
|
||||||
|
|
||||||
|
Совет: `fflush(stdout)` перед `fork`, иначе непустой буфер `stdout` скопируется в ребёнка
|
||||||
|
вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Не проверить возвращаемое значение `fork()` и не разветвить логику по нему → родитель и
|
||||||
|
ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID в
|
||||||
|
`ps aux`, делающих одну и ту же работу.
|
||||||
|
- Ребёнок долго не вызывает `exec()`, активно пишет в большие структуры данных → всплеск
|
||||||
|
реального потребления памяти именно в момент записи (COW-копирование страниц), а не в
|
||||||
|
момент `fork` → видно по росту RSS в `top`/`ps` уже после fork, а не сразу.
|
||||||
|
- Вызвать `exit()` вместо `_exit()` в ребёнке после неудачного `exec` → `exit()` сбрасывает
|
||||||
|
стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может
|
||||||
|
продублировать ранее не выведенный текст родителя.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что возвращает `fork()` в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1
|
||||||
|
- core | Что происходит со страницами памяти при `fork()`? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault)
|
||||||
|
- core | Почему `fork` дешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят
|
||||||
|
- base | Что нужно сделать с `stdout` перед `fork`, если он не пуст? — вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке
|
||||||
|
- deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ
|
||||||
|
|
||||||
|
Почему дальше: раз память после `fork` временно общая и почти всегда тут же заменяется — что конкретно делает `exec` с этим адресным пространством?
|
||||||
|
|
||||||
## 2. exec(): замена образа
|
## 2. exec(): замена образа
|
||||||
|
|
||||||
@@ -45,40 +83,122 @@ if (pid == 0) {
|
|||||||
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
|
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
|
||||||
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
|
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
|
||||||
|
|
||||||
Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы.
|
Механизм: `execve` (системный вызов, к которому в итоге сводится всё семейство `exec*`)
|
||||||
|
загружает исполняемый файл с диска, разбирает его как ELF, строит новый `mm_struct` —
|
||||||
|
новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство
|
||||||
|
текущего `task_struct`, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые
|
||||||
|
до `exec`, остаются открытыми в новой программе **кроме** помеченных флагом `FD_CLOEXEC` —
|
||||||
|
это то, чем перенаправление ввода-вывода (`dup2` на 0/1/2 перед `exec`) переживает замену
|
||||||
|
образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет.
|
||||||
|
|
||||||
|
Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы: `fork` даёт
|
||||||
|
новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы
|
||||||
|
для редиректа), а `exec` в этом новом процессе подгружает нужную программу, не трогая
|
||||||
|
родителя. Если вызвать `exec` без предварительного `fork`, текущая программа заменится и не
|
||||||
|
вернёт управление — например, `exec` внутри shell-скрипта заменяет саму оболочку.
|
||||||
|
|
||||||
Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
|
Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Забыть `_exit(127)` (или любой выход) после неудачного `exec*` в ребёнке → код продолжает
|
||||||
|
исполняться как будто это родительская логика → дублирование родительской работы в
|
||||||
|
дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска.
|
||||||
|
- Не поставить `FD_CLOEXEC` на служебный/секретный дескриптор перед `exec` → он утекает в
|
||||||
|
запущенную внешнюю программу → видно в `/proc/<pid>/fd` запущенного процесса — там лишний
|
||||||
|
открытый файл, которого «не должно быть».
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Чем `exec` отличается от `fork`? — `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового
|
||||||
|
- core | Что сохраняется у процесса после успешного `exec`? — PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC`
|
||||||
|
- core | Почему `exec` при успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом
|
||||||
|
- base | Разница между `execlp`, `execv`, `execvp`? — `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`
|
||||||
|
|
||||||
|
Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно?
|
||||||
|
|
||||||
## 3. wait/waitpid и коды возврата
|
## 3. wait/waitpid и коды возврата
|
||||||
|
|
||||||
Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код
|
Завершившийся ребёнок не исчезает: когда он вызывает `exit()`, ядро не удаляет его
|
||||||
возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает
|
`task_struct` немедленно, а сохраняет минимальную запись (PID, код возврата, статистику
|
||||||
память, но занимает слот в таблице процессов — их накопление плохо.
|
использования ресурсов), пока родитель не заберёт её через `wait`/`waitpid`. Такой процесс
|
||||||
|
называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память данных, но занимает
|
||||||
|
слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе.
|
||||||
|
Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме
|
||||||
|
как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего
|
||||||
|
сообщить не может.
|
||||||
|
|
||||||
- `wait(&status)` — ждёт любого ребёнка;
|
- `wait(&status)` — ждёт любого ребёнка;
|
||||||
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
|
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
|
||||||
|
|
||||||
Разбор `status` делается макросами, а не вручную:
|
Разбор `status` делается макросами, а не вручную, потому что в одном `int` закодированы
|
||||||
|
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
|
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
|
||||||
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
|
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Дополнительно есть `WIFSTOPPED`/`WSTOPSIG` (ребёнок остановлен, например, по `SIGSTOP`) и
|
||||||
|
`WCOREDUMP(status)` (завершение по сигналу сопровождалось дампом памяти на диск, `core`).
|
||||||
|
|
||||||
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
|
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
|
||||||
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
|
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
|
||||||
- **139 = 128 + 11** → SIGSEGV (падение по памяти);
|
- **139 = 128 + 11** → SIGSEGV (падение по памяти);
|
||||||
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
|
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
|
||||||
|
|
||||||
**Сирота** — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби.
|
**Сирота** — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1,
|
||||||
|
либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому
|
||||||
|
сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но
|
||||||
|
нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал
|
||||||
|
`wait`; сирота — родитель умер, но дождаться его теперь придётся init.
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Родитель никогда не вызывает `waitpid` для завершившихся детей → записи зомби копятся →
|
||||||
|
видно как растущий список `ps aux | grep Z`, в пределе — упор в лимит PID.
|
||||||
|
- Сравнивать `status` напрямую с кодом возврата вместо `WEXITSTATUS(status)` → в `status`
|
||||||
|
закодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем,
|
||||||
|
что вернула программа.
|
||||||
|
- Долгоживущий процесс с `fork`-воркерами не занимается сбором детей вообще → зомби
|
||||||
|
накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни
|
||||||
|
аптайма ростом числа `Z`-процессов.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Зачем нужен `waitpid`? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов
|
||||||
|
- core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait`
|
||||||
|
- core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1)
|
||||||
|
- base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу
|
||||||
|
- core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую
|
||||||
|
- deep | Что показывает `WCOREDUMP(status)`? — что завершение по сигналу сопровождалось записью core-дампа на диск
|
||||||
|
|
||||||
|
Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет?
|
||||||
|
|
||||||
## 4. Сигналы
|
## 4. Сигналы
|
||||||
|
|
||||||
Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9,
|
Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное
|
||||||
нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11),
|
исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя
|
||||||
SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился).
|
действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить).
|
||||||
|
Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя
|
||||||
|
перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается),
|
||||||
|
SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe),
|
||||||
|
SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился).
|
||||||
|
|
||||||
Обработчик ставится `sigaction` (надёжнее устаревшего `signal`), внутри обработчика можно
|
Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности
|
||||||
менять только `volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe).
|
управления системой: администратору и супервизору всегда нужен гарантированный способ
|
||||||
|
остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик
|
||||||
|
сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика,
|
||||||
|
снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM
|
||||||
|
устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы,
|
||||||
|
сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают
|
||||||
|
SIGTERM и ждут; так, `systemctl stop`/`docker stop` по умолчанию ждут несколько секунд
|
||||||
|
(в systemd таймаут задаётся `TimeoutStopSec`, по умолчанию около 90 секунд) и только затем,
|
||||||
|
если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные
|
||||||
|
на диск, и незавершённая операция может остаться в неконсистентном состоянии.
|
||||||
|
|
||||||
|
Обработчик ставится `sigaction` (надёжнее устаревшего `signal` — поведение `signal`
|
||||||
|
исторически различалось между Unix-системами), внутри обработчика можно менять только
|
||||||
|
`volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe: `malloc` не
|
||||||
|
реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур,
|
||||||
|
что при вызове `malloc`/`printf` из обработчика способно повредить кучу или подвесить
|
||||||
|
процесс).
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
static volatile sig_atomic_t stop = 0;
|
static volatile sig_atomic_t stop = 0;
|
||||||
@@ -91,12 +211,35 @@ int main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Вызвать `printf`/`malloc`/`free` внутри обработчика сигнала → не async-signal-safe → в
|
||||||
|
редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу
|
||||||
|
ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой.
|
||||||
|
- Использовать `signal()` вместо `sigaction()` → поведение (сброс обработчика в default
|
||||||
|
после первого срабатывания, поведение при повторном сигнале) исторически различается
|
||||||
|
между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой.
|
||||||
|
- Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик →
|
||||||
|
`docker stop`/`systemctl stop` в итоге шлют SIGKILL по таймауту → в логах виден резкий
|
||||||
|
обрыв процесса без финализации (незакрытые файлы, недописанные данные).
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17
|
||||||
|
- core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую
|
||||||
|
- core | Что можно делать внутри обработчика сигнала? — только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe)
|
||||||
|
- base | Чем `sigaction` лучше `signal`? — поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику
|
||||||
|
- deep | Что происходит, если сервис игнорирует SIGTERM при `systemctl stop`? — по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно
|
||||||
|
|
||||||
|
Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше?
|
||||||
|
|
||||||
## 5. errno и возвраты системных вызовов
|
## 5. errno и возвраты системных вызовов
|
||||||
|
|
||||||
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
|
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
|
||||||
можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить;
|
можно только сразу после ошибки, до вызова любой другой функции, которая может сама
|
||||||
EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже;
|
переписать `errno` (например, `printf` при внутренней ошибке форматирования). EINTR — вызов
|
||||||
EINPROGRESS — неблокирующее соединение в процессе.
|
прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе,
|
||||||
|
повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся
|
||||||
|
в лимит открытых дескрипторов (`ulimit -n`), диагностируется через `lsof -p PID` или
|
||||||
|
`/proc/PID/fd`.
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
ssize_t n = read(fd, buf, sizeof buf);
|
ssize_t n = read(fd, buf, sizeof buf);
|
||||||
@@ -107,6 +250,20 @@ if (n < 0) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Проверить `errno` без предварительной проверки, что вызов вообще вернул ошибку → `errno`
|
||||||
|
может быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание.
|
||||||
|
- Вызвать любую функцию (даже `printf`) между системным вызовом и чтением `errno` →
|
||||||
|
промежуточный вызов может переписать `errno` → в обработчике окажется код чужой ошибки.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов
|
||||||
|
- base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже»
|
||||||
|
- core | Когда безопасно читать `errno`? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать
|
||||||
|
- deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся `ulimit -n`)
|
||||||
|
|
||||||
|
Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым?
|
||||||
|
|
||||||
## 6. Разбор примера: мини-шелл на 40 строк
|
## 6. Разбор примера: мини-шелл на 40 строк
|
||||||
|
|
||||||
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
|
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
|
||||||
@@ -157,11 +314,28 @@ int main() {
|
|||||||
|
|
||||||
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
|
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
|
||||||
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
|
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
|
||||||
несуществующая команда даёт 127.
|
несуществующая команда даёт 127. Дочерний процесс перед `execvp` уже унаследовал от
|
||||||
|
родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через `fork`, поэтому вывод запущенной
|
||||||
|
программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не
|
||||||
|
требуется перенаправление в файл или pipe.
|
||||||
|
|
||||||
Проверка на утечки и падения — санитайзеры:
|
Проверка на утечки и падения — санитайзеры:
|
||||||
`g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell`
|
`g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell`
|
||||||
|
|
||||||
|
**Ловушки**
|
||||||
|
- Не проверять, пуст ли `argv` перед `execvp` → `execvp(nullptr, ...)` на пустой строке →
|
||||||
|
неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже
|
||||||
|
предусмотрено проверкой `if (argv.empty()) continue;`, но при рефакторинге легко потерять).
|
||||||
|
- Забыть `_exit(127)` после неудачного `execvp` → дочерний процесс продолжит исполнять
|
||||||
|
тело цикла `while` наравне с родителем → двойной ввод команд из одного терминала.
|
||||||
|
|
||||||
|
**Факты для карточек**
|
||||||
|
- base | Какой код возврата у шелла даст несуществующая команда? — 127
|
||||||
|
- base | Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork`
|
||||||
|
- core | Каким флагом собрать бинарник для проверки на утечки и UB? — `-fsanitize=address,undefined -fno-omit-frame-pointer`
|
||||||
|
|
||||||
|
Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ?
|
||||||
|
|
||||||
## 7. Что спросят на собеседовании (готовые ответы)
|
## 7. Что спросят на собеседовании (готовые ответы)
|
||||||
|
|
||||||
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
|
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
|
||||||
@@ -190,6 +364,8 @@ int main() {
|
|||||||
3. Что такое зомби и кто его убирает?
|
3. Что такое зомби и кто его убирает?
|
||||||
4. Откуда код возврата 137 и 139?
|
4. Откуда код возврата 137 и 139?
|
||||||
5. Почему `exec` не возвращает управление при успехе?
|
5. Почему `exec` не возвращает управление при успехе?
|
||||||
|
6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет?
|
||||||
|
7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Ответы</summary>
|
<summary>Ответы</summary>
|
||||||
@@ -200,9 +376,13 @@ int main() {
|
|||||||
если родитель умер).
|
если родитель умер).
|
||||||
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
|
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
|
||||||
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
|
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
|
||||||
|
6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`.
|
||||||
|
7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт
|
||||||
|
SIGKILL принудительно.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 10. Задачи дня
|
## 10. Задачи дня
|
||||||
|
|
||||||
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
|
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
|
||||||
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
|
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
|
||||||
|
</content>
|
||||||
|
|||||||
Reference in New Issue
Block a user