From 4259fbce75e74ec4e12fafb51682c81bf427fa5f Mon Sep 17 00:00:00 2001 From: Kodlo-chan Date: Thu, 24 Sep 2026 13:38:58 +0700 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=87=D0=B5=D0=B1=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B:=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D1=82=D0=B5=D0=B7=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=20=C2=AB=D0=A4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=B5=D0=BA=C2=BB,=20=D0=9B=D0=BE=D0=B2?= =?UTF-8?q?=D1=83=D1=88=D0=BA=D0=B8=20(Claude=20Code)=20+=20diag/cards=5Fs?= =?UTF-8?q?rc.tsv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + diag/cards_src.tsv | 167 ++++++++++++++++++++++++ diag/tasks/01_bits/task.md | 34 ++++- diag/tasks/02_list/task.md | 61 ++++++++- diag/tasks/03_ring/task.md | 43 ++++-- diag/tasks/04_ipv4/task.md | 63 ++++++++- diag/tasks/05_lis/task.md | 62 ++++++++- diag/tasks/06_threads/task.md | 110 +++++++++++++++- diag/tasks/07_epoll/task.md | 110 +++++++++++++++- diag/tasks/08_bash/task.md | 122 ++++++++++++++++- diag/tasks/09_gdb/task.md | 137 ++++++++++++++++++-- diag/tasks/10_hash/task.md | 132 +++++++++++++++---- diag/tasks/11_heap/task.md | 142 +++++++++++++++++++- diag/tasks/12_dijkstra/task.md | 134 ++++++++++++++++++- diag/tasks/13_subnet/task.md | 116 +++++++++++++++++ lessons/D1_algo.md | 139 +++++++++++++++++--- lessons/D1_linux.md | 230 +++++++++++++++++++++++++++++---- 17 files changed, 1688 insertions(+), 115 deletions(-) create mode 100644 diag/cards_src.tsv diff --git a/README.md b/README.md index 39ff122..7d3a3c9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них. - `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы). +- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref). - `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма («почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом. - `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git, diff --git a/diag/cards_src.tsv b/diag/cards_src.tsv new file mode 100644 index 0000000..1595ba3 --- /dev/null +++ b/diag/cards_src.tsv @@ -0,0 +1,167 @@ +level domain question answer ref +base algo Во что превращается 3n + 100 в O-нотации O(n) урок D1_algo.md +base algo Сколько шагов у бинарного поиска в массиве из 10⁶ элементов 20 (log₂ 10⁶ ≈ 20) урок D1_algo.md +core algo Почему push_back в среднем O(1), а не O(n) ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n² урок D1_algo.md +core algo Что ломает удвоение ёмкости у vector указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти) урок D1_algo.md +deep algo Что будет, если ёмкость vector растить на +1 за раз вместо удвоения суммарная стоимость n вставок станет O(n²) урок D1_algo.md +base algo Сложность построения кучи (heapify) из произвольного массива O(n), не O(n log n) урок D1_algo.md +base algo Какая сортировка всегда O(n log n) и устойчива mergesort урок D1_algo.md +core algo Худший случай quicksort и когда он достигается O(n²), на уже отсортированном массиве при плохом выборе опорного урок D1_algo.md +core algo Нижняя граница сортировки сравнениями Ω(n log n) урок D1_algo.md +core algo За счёт чего map держит высоту log n инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа урок D1_algo.md +deep algo Почему нельзя полагаться на порядок обхода unordered_map порядок бакетов не гарантирован и может меняться при rehash урок D1_algo.md +base algo Формула фактора загрузки load = size / capacity урок D1_algo.md +base algo Порог load factor при открытой адресации, после которого делают rehash обычно ≤ 0.7 урок D1_algo.md +core algo Зачем tombstone при открытой адресации чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже урок D1_algo.md +core algo Почему ёмкость хеш-таблицы берут степенью двойки idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре урок D1_algo.md +core algo Что физически происходит при rehash выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap) урок D1_algo.md +deep algo Почему rehash даёт амортизированное O(1), а не O(n) на вставку та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n² урок D1_algo.md +base algo Сложность has_pair (вложенный цикл по всем парам) O(n²) урок D1_algo.md +core algo Сложность has_pair_fast по времени и по памяти O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество урок D1_algo.md +base algo Какие константы использует FNV-1a в этом коде (offset basis / prime) 1469598103934665603 / 1099511628211 урок D1_algo.md +core algo Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны урок D1_algo.md +core algo Чем открытая адресация выигрывает у цепочек по производительности и почему она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами урок D1_algo.md +deep algo Может ли load factor у цепочек быть больше 1 да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда) урок D1_algo.md +base linux Что возвращает `fork()` в родителе и в ребёнке в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 урок D1_linux.md +core linux Что происходит со страницами памяти при `fork()` ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) урок D1_linux.md +core linux Почему `fork` дешёвый даже для процесса с гигабайтами памяти копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят урок D1_linux.md +base linux Что нужно сделать с `stdout` перед `fork`, если он не пуст вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке урок D1_linux.md +deep linux Какой размер страницы памяти на x86-64, о которой копия делается при COW 4 КБ урок D1_linux.md +base linux Чем `exec` отличается от `fork` `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового урок D1_linux.md +core linux Что сохраняется у процесса после успешного `exec` PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC` урок D1_linux.md +core linux Почему `exec` при успехе никогда не возвращает управление старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом урок D1_linux.md +base linux Разница между `execlp`, `execv`, `execvp` `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH` урок D1_linux.md +base linux Зачем нужен `waitpid` забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов урок D1_linux.md +core linux Что такое зомби и почему он не исчезает сам процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait` урок D1_linux.md +core linux Чем зомби отличается от сироты зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) урок D1_linux.md +base linux Откуда код возврата 137 и 139 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу урок D1_linux.md +core linux Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую урок D1_linux.md +deep linux Что показывает `WCOREDUMP(status)` что завершение по сигналу сопровождалось записью core-дампа на диск урок D1_linux.md +base linux Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD 2/9/15/11/13/17 урок D1_linux.md +core linux Чем SIGTERM отличается от SIGKILL SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую урок D1_linux.md +core linux Что можно делать внутри обработчика сигнала только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe) урок D1_linux.md +base linux Чем `sigaction` лучше `signal` поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику урок D1_linux.md +deep linux Что происходит, если сервис игнорирует SIGTERM при `systemctl stop` по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно урок D1_linux.md +base linux Что означает EINTR и как на него реагировать вызов прерван доставкой сигнала; корректная реакция — повторить вызов урок D1_linux.md +base linux Чем EAGAIN отличается от обычной ошибки чтения данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже» урок D1_linux.md +core linux Когда безопасно читать `errno` сразу после ошибки вызова, до любого другого вызова, способного его перезаписать урок D1_linux.md +deep linux Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса EMFILE (лимит задаётся `ulimit -n`) урок D1_linux.md +base linux Какой код возврата у шелла даст несуществующая команда 127 урок D1_linux.md +base linux Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork` урок D1_linux.md +core linux Каким флагом собрать бинарник для проверки на утечки и UB `-fsanitize=address,undefined -fno-omit-frame-pointer` урок D1_linux.md +base algo Сколько байт меняет местами `bswap32` 4 задача 01_bits +core algo Что делает `x & (x - 1)` гасит младший единичный бит `x` задача 01_bits +core algo Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF` 32 (по числу единичных бит) задача 01_bits +deep algo Почему `1u << 31` пишут с суффиксом `u`, а не как `int` сдвиг знакового `int` в задача 01_bits +base algo Какой порядок байт использует сеть для многобайтовых полей network byte order задача 01_bits +base algo Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей в 2 раза задача 02_list +core algo Почему рекурсивный разворот списка не годится под требование O(1) памяти каждый задача 02_list +core algo На чём основано доказательство, что `fast` догонит `slow` при цикле внутри цикла задача 02_list +base algo Сколько дополнительных указателей нужно для итеративного разворота списка 3 задача 02_list +base algo Сложность `push`/`pop` кольцевого буфера O(1) задача 03_ring +core algo Как отличить пустой буфер от полного при `head_ == tail_` хранить отдельный задача 03_ring +core algo Чем `delete[]` отличается от `delete` для массива, выделенного `new[]` `delete` задача 03_ring +base algo Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов задача 03_ring +base net Минимальная длина IPv4-заголовка без опций 20 байт задача 04_ipv4 +base net Код `protocol` для TCP / UDP / ICMP 6 / 17 / 1 задача 04_ipv4 +core net В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию «как на проводе», задача 04_ipv4 +core net Почему `reinterpret_cast` буфера в `Ipv4Header*` UB? — нет гарантии выравнивания задача 04_ipv4 +deep net Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем задача 04_ipv4 +base algo Сложность наивного DP-решения LIS O(n²) задача 05_lis +core algo Сложность решения через `tails` + бинарный поиск O(n log n) задача 05_lis +core algo Что хранит `tails[k]` минимально возможный последний элемент возрастающей задача 05_lis +deep algo Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound` задача 05_lis +base threads Каким флагом компилятора включается ThreadSanitizer `-fsanitize=thread` задача 06_threads +base threads Что бросает `push` после `close()` `std::runtime_error` задача 06_threads +core threads Должен ли `size()` быть потокобезопасным в этой задаче да, его тоже вызывают из другого потока и он тоже под захватом мьютекса задача 06_threads +core threads Что происходит с ждущими потоками при `close()` все разблокируются (никакого вечного `wait`) задача 06_threads +base threads Что атомарно делает `cv.wait(lock)` при входе освобождает мьютекс и усыпляет поток одной неделимой операцией задача 06_threads +core threads Почему `while` вокруг `wait`, а не `if` из-за spurious wakeup: `wait` может вернуться без вызова notify задача 06_threads +core threads Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one` можно разбудить не тот поток, а нужный останется ждать задача 06_threads +deep threads На каком примитиве ОС реализован `condition_variable` на Linux futex задача 06_threads +base threads Какой командой запускается проверка задачи 06 `python3 grade.py 06` задача 06_threads +core threads Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом задача 06_threads +base linux На каком адресе и с каким флагом сокета должен слушать сервер 127.0.0.1, `SO_REUSEADDR` задача 07_epoll +base linux Сколько одновременных соединений сервер должен держать минимум 64 задача 07_epoll +core linux По какому событию сервер закрывает соединение с клиентом по EOF со стороны клиента задача 07_epoll +base linux Что означает `EAGAIN` на неблокирующем сокете не ошибка, сигнал «данных пока нет, попробуй позже» задача 07_epoll +core linux В чём разница между LT и ET режимами epoll LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов» задача 07_epoll +core linux Почему ET требует неблокирующих дескрипторов цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет задача 07_epoll +deep linux Какая асимптотика у `epoll_wait` относительно select/poll O(1) на готовое событие против O(n) на весь набор у select/poll задача 07_epoll +base linux Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT 0 задача 07_epoll +core linux Что возвращает `epoll_wait`, если его прервал сигнал -1 с `errno == EINTR`, не ошибка выполнения задача 07_epoll +base linux Сколько соединений открывает тест `grade.py 07` 3 задача 07_epoll +base linux Каким сигналом тест проверяет корректное завершение сервера SIGTERM задача 07_epoll +base linux Сколько строк TOP выводится, если уникальных IP меньше трёх столько, сколько есть уникальных IP задача 08_bash +base linux Что печатает скрипт для пустого файла `TOTAL 0`, `5XX 0`, без строк TOP задача 08_bash +core linux По какому полю строки лога определяется IP по первому полю задача 08_bash +core linux Почему `while read line` в bash медленный на миллионе строк каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку задача 08_bash +core linux Что даёт связка awk + sort вместо построчного цикла один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов задача 08_bash +deep linux По какому полю сортируется список IP при равенстве числа запросов по возрастанию строки IP (вторичный ключ сортировки) задача 08_bash +base linux Что делает `set -e` останавливает скрипт при первой команде с ненулевым кодом возврата задача 08_bash +core linux В каких позициях `set -e` не останавливает скрипт при ошибке команды в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!` задача 08_bash +core linux Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри задача 08_bash +core linux Зачем `set -o pipefail` отдельно от `set -e` без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным задача 08_bash +base linux Каким кодом возврата должен завершаться `solution.sh` при успехе 0 задача 08_bash +core linux Что именно сверяет `grade.py 08` с эталоном точное совпадение вывода (все четыре строки) на фикстуре и на большом логе задача 08_bash +base linux Что печатает `./crash` без аргументов после починки `len=5`, код возврата 0 задача 09_gdb +base linux Что печатает `./crash ""` после починки `len=0`, код возврата 0 задача 09_gdb +core linux Какие два санитайзера должны не давать сообщений после починки ASan и UBSan (`-fsanitize=address,undefined`) задача 09_gdb +base linux Что даёт флаг `-g` при сборке для gdb отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных задача 09_gdb +core linux Почему для отладки собирают с `-O0`, а не с `-O2` оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику задача 09_gdb +core linux Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение все три, в момент обращения к памяти, а не постфактум задача 09_gdb +deep linux Что именно ловит UBSan, в отличие от ASan неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью задача 09_gdb +base linux Какая команда gdb показывает цепочку вызовов до краша `bt` задача 09_gdb +core linux Чем `watch var` отличается от обычного `break` останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода задача 09_gdb +core linux Зачем нужен `frame N` после `bt` переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции задача 09_gdb +base linux Сколько входов прогоняет `grade.py 09` 5 задача 09_gdb +base linux Каким компилятором собирается `crash.c` на проверке gcc (компилятор C) задача 09_gdb +base algo Сколько времени закладывается на задачу 10 1,5–2,5 часа со ступенями задача 10_hash +base algo Какой командой проверяется решение `python3 grade.py 10` задача 10_hash +deep algo Где в реальных устройствах используется похожая структура таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти задача 10_hash +base algo Почему `hash & (cap-1)` эквивалентно `hash % cap` только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления задача 10_hash +base algo Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс `19 & 7 = 3` задача 10_hash +core algo Среднее число проб при удачном поиске, load factor 0.7 ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`) задача 10_hash +core algo То же при load factor 0.9 ≈5,5 — рост почти в 2,5 раза от значения при 0.7 задача 10_hash +deep algo Среднее число проб при НЕудачном поиске, load factor 0.9 ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска задача 10_hash +core algo Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте задача 10_hash +base algo Сколько ключей вставляет тест на кластеризацию и какие они 512 ключей, кратных 16 задача 10_hash +base algo За какое время должны пройти 100 000 вставок быстрее 2 секунд задача 10_hash +base algo Какой порог load factor держит таблица ≤ 0.7 задача 10_hash +base algo Минимальная ёмкость таблицы по умолчанию 16, степень двойки задача 10_hash +base algo Индексы детей и родителя в max-heap на массиве дети `2i+1`, `2i+2`; родитель `(i-1)/2` задача 11_heap +base algo Что за 2 секунды должны выполниться на 3 000 000 элементов `heapify` и `kth_largest` с `k=1000` (по отдельности) задача 11_heap +base algo Какой ключевой запрет на реализацию `heapify` нельзя строить через `push` в цикле, только bottom-up за O(n) задача 11_heap +base algo Сложность доступа к максимуму (`top`) O(1), максимум всегда в корне по инварианту кучи задача 11_heap +base algo Сложность одного `sift-up`/`sift-down` от произвольного узла O(log n) — путь ограничен высотой дерева задача 11_heap +core algo Что сравнивается на каждом шаге `sift-down` узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла задача 11_heap +core algo За какое время строится куча через n последовательных `push` O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n` задача 11_heap +core algo За какое время строится куча через bottom-up heapify O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n` задача 11_heap +deep algo С какого индекса начинается bottom-up heapify и в какую сторону идёт с последнего нелистового узла `n/2 - 1`, к корню (индекс 0) задача 11_heap +core algo Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000 примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up задача 11_heap +base algo Сложность `kth_largest`/`top_k` через полную кучу O(n + k log n): O(n) на heapify плюс k раз pop по O(log n) задача 11_heap +core algo Когда используют min-heap размера k вместо полной кучи на весь массив на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k) задача 11_heap +core algo Чем `std::nth_element` отличается от кучи для top-K в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop задача 11_heap +base algo Что возвращает функция при `src == dst` 0 задача 12_dijkstra +base algo Что возвращает функция при недостижимости `dst` -1 задача 12_dijkstra +base algo В каком типе суммируются веса и почему не `int` `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути задача 12_dijkstra +base algo Сложность наивной Дейкстры (перебор минимума по массиву) O(V²) задача 12_dijkstra +base algo Сложность Дейкстры с бинарной кучей O((V+E) log V) задача 12_dijkstra +core algo При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка задача 12_dijkstra +core algo Что означает «ленивое удаление» устаревших записей в очереди вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая задача 12_dijkstra +deep algo Почему `std::priority_queue` не используют с decrease-key напрямую у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop задача 12_dijkstra +core algo Почему Дейкстра ломается на отрицательных рёбрах алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается задача 12_dijkstra +base algo Какой алгоритм нужен при отрицательных весах без отрицательных циклов Беллман-Форд, O(V·E) задача 12_dijkstra +core algo Как Беллман-Форд обнаруживает отрицательный цикл если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл задача 12_dijkstra +base algo Почему self-loop с w≥0 не требует отдельной обработки добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию задача 12_dijkstra +base net Сколько узлов даёт /24 254 задача 13_subnet +base net Сколько узлов даёт /26 62 задача 13_subnet +base net Сколько узлов даёт /30 2 задача 13_subnet +base net Сеть, broadcast и диапазон узлов для `10.0.1.130/26` сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190` задача 13_subnet +base net Формула маски для префикса p (0= h+2` задача 13_subnet +deep net Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1 /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами задача 13_subnet diff --git a/diag/tasks/01_bits/task.md b/diag/tasks/01_bits/task.md index d32a33d..01485c6 100644 --- a/diag/tasks/01_bits/task.md +++ b/diag/tasks/01_bits/task.md @@ -8,19 +8,26 @@ протоколах. - **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы. + На практике `bswap` нужен ровно потому, что сеть передаёт многобайтовые поля в network byte + order (big-endian), а x86 внутри — little-endian: без разворота байт число читается неверно. - **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`. ## Глава II. Что нужно знать до старта Четыре инструмента, которых достаточно: -1. `x & (x - 1)` — гасит самый младший единичный бит. Отсюда классика: число единиц можно - считать циклом, пока `x` не станет нулём. +1. `x & (x - 1)` — гасит самый младший единичный бит. Механизм: в дополнительном коде `x - 1` + переворачивает все нули справа от младшего единичного бита в единицы, а сам этот бит — в ноль, + биты выше не трогает; операция `&` с исходным `x` поэтому обнуляет ровно один бит — младший + единичный. Отсюда классика: число единиц можно считать циклом, пока `x` не станет нулём, и + число итераций равно числу единичных бит, а не 32. 2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо. 3. `x & (1u << k)` — проверка k-го бита. 4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного числа. Комбинация «сдвинул и сложил» даёт любой порядок байт. +Почему дальше: та же логика масок и сдвигов нужна в задаче 04 — разбор IPv4-заголовка byte-по-byte. + ## Глава III. Задание Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`, @@ -83,12 +90,25 @@ uint32_t bswap32(uint32_t x); // поменять порядок ба Если перепутать — биты уедут на одну позицию. -## Глава VII. Частые ошибки +## Глава VII. Ловушки -- Сдвиги в `int` вместо `uint32_t` → UB, UBSAN ругается. -- Забыт ноль в `is_power_of_two`. -- В `bswap32` перепутаны направления сдвигов (влево/вправо) — проверь на `0x11223344`. -- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому. +- Сдвиги в `int` вместо `uint32_t` → сдвиг `1 << 31` для знакового типа — UB → UBSAN падает + на этой строке с диагностикой конкретного сдвига. +- Забыт ноль в `is_power_of_two` → `0 & (0 - 1) == 0`, функция возвращает `true` на нуле → + тест на `x == 0` красный. +- В `bswap32` перепутаны направления сдвигов (влево/вправо) → байты встают не на свои места → + `bswap32(0x11223344) != 0x44332211`, видно прямым сравнением. +- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому; + формально тест это не всегда ловит по выводу, но нарушает условие задачи. + +**Факты для карточек** +- base | Сколько байт меняет местами `bswap32`? — 4 +- core | Что делает `x & (x - 1)`? — гасит младший единичный бит `x` +- core | Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF`? — 32 (по числу единичных бит) +- deep | Почему `1u << 31` пишут с суффиксом `u`, а не как `int`? — сдвиг знакового `int` в + знаковый бит — UB, `uint32_t` определён стандартом для любых сдвигов в пределах разрядности +- base | Какой порядок байт использует сеть для многобайтовых полей? — network byte order + (big-endian) ## После сдачи diff --git a/diag/tasks/02_list/task.md b/diag/tasks/02_list/task.md index ffbdef1..28e5a3b 100644 --- a/diag/tasks/02_list/task.md +++ b/diag/tasks/02_list/task.md @@ -1,5 +1,17 @@ # Задача 02 — связный список (C++) +## Глава I. Общая информация + +- **Цель:** развернуть список, найти середину и определить цикл — без единой дополнительной + аллокации, только перелинковка указателей. +- **Почему это в Eltex:** списки соединений, очереди задач, таблицы состояний — везде связные + структуры. Приём двух указателей (slow/fast), которым решаются `find_middle` и `has_cycle`, + — это алгоритм Флойда, тот же паттерн всплывает при поиске циклов в графах зависимостей + и в обходе кольцевых структур. +- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 02`. + +## Глава II. Что нужно знать до старта + Даны функции над односвязным списком `Node{int value; Node* next;}`: ```c @@ -8,10 +20,57 @@ Node* find_middle(Node* head); // середина; для чётной дл bool has_cycle(Node* head); // есть ли цикл ``` -Требования: +Механизм двух указателей: `slow` идёт на 1 узел за шаг, `fast` — на 2. Для `find_middle`, когда +`fast` доходит до конца (`fast == nullptr` или `fast->next == nullptr`), `slow` стоит ровно в +середине — за счёт того, что `slow` проходит вдвое меньше узлов, чем `fast`. Для чётной длины +условие остановки должно давать именно второй из двух средних узлов — это проверяется на +списке длины 2 и 4. + +Для `has_cycle` тот же дуэт указателей ловит цикл иначе: если цикл есть, `fast` заходит в него +и после каждого шага сокращает расстояние до `slow` внутри цикла на 1 узел (потому что относительная +скорость `fast` к `slow` внутри цикла — 1 узел/шаг), значит рано или поздно `slow == fast`; если +цикла нет, `fast` первым дойдёт до `nullptr`. + +`reverse_list` разворачивается за один проход тремя указателями `prev/cur/next`: на каждом шаге +переставляется `cur->next = prev` до итерации по цепочке. Рекурсивный разворот сюда не годится — +он тратит O(n) памяти стека вызовов и нарушает требование O(1) дополнительной памяти. + +Почему дальше: тот же принцип «два индекса вместо лишней памяти» — в задаче 03, только на +массиве, а не на указателях. + +## Глава III. Требования + - никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно; - `find_middle` и `has_cycle` — через два указателя (медленный/быстрый); - корректная работа с пустым списком и списком из одного узла. +## Глава IV. Критерии приёмки + Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте считаются ошибкой — тест сам освобождает память). + +## Глава V. Ловушки + +- Забыть проверку `head == nullptr` в начале любой из трёх функций → разыменование нулевого + указателя → падение/ASAN SEGV на пустом списке. +- В `find_middle` неверное условие остановки цикла (`fast->next` без проверки самого `fast` + на `nullptr`) → на списке чётной длины либо возвращается не тот из двух средних узлов, либо + падение на последнем шаге. +- В `reverse_list` переставить `cur->next = prev` до сохранения старого `cur->next` во + временную переменную → потеря хвоста списка, обход обрывается раньше конца. +- Рекурсивный `reverse_list` вместо итеративного → лишняя память стека на каждый вызов → + нарушение требования O(1), на длинном списке возможен stack overflow. + +**Факты для карточек** +- base | Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей? — в 2 раза +- core | Почему рекурсивный разворот списка не годится под требование O(1) памяти? — каждый + вызов кладёт кадр в стек, суммарно O(n) памяти +- core | На чём основано доказательство, что `fast` догонит `slow` при цикле? — внутри цикла + расстояние между ними сокращается на 1 узел за шаг +- base | Сколько дополнительных указателей нужно для итеративного разворота списка? — 3 + (`prev`, `cur`, `next`) + +## После сдачи + +Разбор: почему алгоритм Флойда работает за O(n) времени и O(1) памяти в обоих режимах +(поиск середины и поиск цикла), и как тот же приём переносится на поиск цикла в графе. diff --git a/diag/tasks/03_ring/task.md b/diag/tasks/03_ring/task.md index 595dac9..57c1728 100644 --- a/diag/tasks/03_ring/task.md +++ b/diag/tasks/03_ring/task.md @@ -7,18 +7,26 @@ - **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов. - **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками — всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на - собеседовании. + собеседовании. Механизм тот же, что у сетевой карты: пакеты пишутся в кольцо по мере + прихода, драйвер вычитывает их с другого конца, и обе стороны никогда не двигают уже + записанные данные по памяти — двигаются только индексы `head`/`tail`. - **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`. ## Глава II. Что нужно знать до старта Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`. -Сдвигать элементы не нужно никогда — в этом весь смысл. +Сдвигать элементы не нужно никогда — в этом весь смысл: `push`/`pop` двигают только индекс, +а не байты в памяти, поэтому обе операции остаются O(1) независимо от размера буфера. Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от -полного, если оба индекса совпали?** Варианты: хранить счётчик `size`, либо оставлять одну -ячейку свободной. В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик. +полного, если оба индекса совпали?** При `head == tail` возможны оба состояния — буфер мог +быть только что создан (пуст) или заполнен ровно `capacity` раз (полон) — по одним индексам +это не различить. Варианты: хранить счётчик `size`, либо оставлять одну ячейку свободной. +В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик. + +Почему дальше: тот же счётчик `size_` вместо пересчёта по индексам — частый приём и в +`std::deque`, и в реализациях lock-free очередей, где индексы вообще нельзя лишний раз читать. ## Глава III. Задание @@ -89,14 +97,29 @@ public: `std::vector` — тогда вопрос исчезает. -## Глава VII. Частые ошибки +## Глава VII. Ловушки -- Сдвиг элементов вместо индексов (теряется весь смысл O(1)). -- Путаница «пусто/полно» при совпавших индексах. +- Сдвиг элементов вместо индексов → цена `push`/`pop` вырастает до O(n) → теряется весь + смысл структуры, видно по сравнению с наивным `std::vector` на бенчмарке. +- Путаница «пусто/полно» при совпавших индексах (`head_ == tail_`) без отдельного `size_` → + `full()` и `empty()` дают одинаковый ответ на разных состояниях → тест на заполненный буфер + ёмкости > 1 падает. - `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании - спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`). -- Копирование объекта без правила трёх → двойное освобождение. Если сдаёшь с ручным `new[]`, - запрети копирование (`= delete`). + спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`, деление по модулю дороже + сравнения). +- Копирование объекта без правила трёх/пяти → два объекта владеют одним и тем же `new[]`-буфером + → двойное освобождение при выходе обоих из области видимости, ловится ASAN. Если сдаёшь с + ручным `new[]`, запрети копирование (`= delete`). + +**Факты для карточек** +- base | Сложность `push`/`pop` кольцевого буфера? — O(1) +- core | Как отличить пустой буфер от полного при `head_ == tail_`? — хранить отдельный + счётчик `size_` (или жертвовать одной ячейкой) +- core | Чем `delete[]` отличается от `delete` для массива, выделенного `new[]`? — `delete` + без `[]` на массиве — UB, вызовет деструктор только для первого элемента и испортит подсчёт + размера аллокации +- base | Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов? — + `if (++idx == cap) idx = 0;` ## После сдачи diff --git a/diag/tasks/04_ipv4/task.md b/diag/tasks/04_ipv4/task.md index 914e235..2279279 100644 --- a/diag/tasks/04_ipv4/task.md +++ b/diag/tasks/04_ipv4/task.md @@ -1,7 +1,35 @@ # Задача 04 — разбор IPv4-пакета и контрольная сумма (C++) -Это то, что реально делают сетевые железки: взять буфер из сокета и корректно разобрать -заголовок, не выйдя за границы и не поверив «на слово» полям пакета. +## Глава I. Общая информация + +- **Цель:** разобрать буфер байт из сокета в структуру заголовка вручную, безопасно и без + UB — то, что реально делают сетевые железки на приёмном тракте. +- **Почему это в Eltex:** это то, что реально делают сетевые железки: взять буфер из сокета + и корректно разобрать заголовок, не выйдя за границы и не поверив «на слово» полям пакета. + Коммутатор/маршрутизатор обязан отбросить пакет с битой контрольной суммой или враньём в + `total_length`, иначе он либо читает чужую память, либо пересылает мусор дальше. +- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 04`. + +## Глава II. Что нужно знать до старта + +Минимальная длина IPv4-заголовка — 20 байт (без опций). Байт-порядок в заголовке смешанный: +`total_length` и `header_checksum` хранятся в хост-порядке в структуре `Ipv4Header` (в задании +явно указано «хост-порядок» — значит в `parse_ipv4` их нужно правильно собрать из сетевого +порядка на проводе), а IP-адреса (`src_ip`, `dst_ip`) — «байты как на проводе»: +`10.0.0.1 -> 0x0A000001`, то есть без перестановки байт при сборке в `uint32_t`. + +Почему нельзя просто `reinterpret_cast(buf)`: у `buf` нет гарантии +выравнивания под `uint32_t` (нужно кратно 4 байтам) — компилятор вправе сгенерировать код, +предполагающий выровненный доступ, и на невыровненном адресе это UB (UBSAN ловит как +misaligned address); плюс раскладка полей в `Ipv4Header` определяется компилятором +(padding), а не байтовым форматом протокола — сети всё равно, как выровнены поля в C++-структуре. +Правильный путь — читать каждый байт по отдельности и собирать сдвигами (тот же приём, что +в задаче 01: маски и сдвиги для сборки многобайтового числа из байт). + +Почему дальше: если разбор заголовка освоен, логичный следующий шаг — контрольная сумма TCP/UDP +поверх псевдозаголовка, которая считается тем же алгоритмом сложения в дополнительном коде. + +## Глава III. Задание ```c++ struct Ipv4Header { @@ -30,3 +58,34 @@ bool checksum_valid(const uint8_t* buf, size_t len); Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые. Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть — на невыровненных адресах и в другом порядке байт это UB. + +## Глава IV. Ловушки + +- `reinterpret_cast(buf)` вместо побайтового разбора → чтение по невыровненному + адресу и/или с padding-полями структуры вместо реального формата пакета → UB, UBSAN + сообщает misaligned address, а на реальных данных поля читаются со сдвигом. +- Не обнулить поле `header_checksum` перед вызовом `compute_checksum` → в сумму попадает + старое значение поля → результат не совпадёт с ожидаемым, тест на `compute_checksum` падает. +- Пропустить перенос (carry) при сложении 16-битных слов в дополнительном коде, если + промежуточная сумма считается в 16-битном типе → биты переноса теряются вместо того чтобы + прибавиться обратно к младшим битам → неверная контрольная сумма на буферах, где сумма слов + переполняет 16 бит. +- Проверить `ihl*4 > len` до проверки `len >= 20` (или в неверном порядке) → чтение поля `ihl` + из буфера короче 1 байта → выход за границы, ASAN сообщает heap-buffer-overflow. + +**Факты для карточек** +- base | Минимальная длина IPv4-заголовка без опций? — 20 байт +- base | Код `protocol` для TCP / UDP / ICMP? — 6 / 17 / 1 +- core | В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию? — «как на проводе», + без перестановки байт при сборке в `uint32_t` +- core | Почему `reinterpret_cast` буфера в `Ipv4Header*` — UB? — нет гарантии выравнивания + адреса под 4-байтные поля структуры +- deep | Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем + checksum) при верной контрольной сумме? — 0xFFFF + +## После сдачи + +Разбор: как поле `header_checksum` защищает именно заголовок (не данные), почему на каждом +маршрутизаторе TTL уменьшается на 1 и, как следствие, контрольную сумму заголовка приходится +пересчитывать заново на каждом хопе, и чем отличается контрольная сумма TCP/UDP (считается с +псевдозаголовком) от чисто IP. diff --git a/diag/tasks/05_lis/task.md b/diag/tasks/05_lis/task.md index 8ae0dfa..339f158 100644 --- a/diag/tasks/05_lis/task.md +++ b/diag/tasks/05_lis/task.md @@ -1,5 +1,40 @@ # Задача 05 — длиннейшая возрастающая подпоследовательность (C++) +## Глава I. Общая информация + +- **Цель:** посчитать длину строго возрастающей подпоследовательности массива за + O(n log n), а не за наивные O(n²). +- **Почему это в Eltex:** задача — эталон на «знаешь ли ты приём tails + бинарный поиск + поверх DP», а сам паттерн «поддерживать отсортированный массив минимально возможных хвостов + и подставлять новый элемент через `lower_bound`» переиспользуется в задачах на планирование + и в сравнении версий (diff-подобные алгоритмы ищут именно длиннейшие совпадающие/растущие + подпоследовательности). +- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 05`. + +## Глава II. Что нужно знать до старта + +Наивное решение — DP: `dp[i]` = длина LIS, заканчивающейся на элементе `i`, пересчитывается +перебором всех `j < i` — O(n²), при 100 000 элементах это ~10¹⁰ операций и тест не пройдёт +(ограничение — 2 секунды). + +Быстрое решение — «хвосты» (patience sorting): массив `tails`, где `tails[k]` — минимально +возможный последний элемент возрастающей подпоследовательности длины `k+1`, накопленной по +уже просмотренному префиксу. Массив `tails` всегда отсортирован по построению, поэтому для +каждого нового `x` бинарным поиском (`lower_bound`) находится первая позиция с +`tails[pos] >= x`: если такая позиция есть — `x` заменяет значение в ней (подпоследовательность +той же длины, но с меньшим хвостом — выгоднее для будущих продолжений); если позиции нет — +`x` дописывается в конец, увеличивая длину LIS на 1. Именно `lower_bound`, а не `upper_bound` — +строгое возрастание требует заменить первый элемент `>= x`, а не `> x` (иначе повторяющиеся +значения ошибочно продлевают подпоследовательность). + +Итоговая длина `tails` в конце обработки массива и есть `lis_length`. Значения в `tails` — +это не сама LIS, только корректная её длина. + +Почему дальше: тот же приём «поддерживай отсортированный инвариант + `lower_bound`» стоит +знать и для задач на минимальное число возрастающих подпоследовательностей на весь массив. + +## Глава III. Задание + ```c++ int lis_length(const std::vector& a); // длина НВП (строго возрастающей) ``` @@ -11,4 +46,29 @@ int lis_length(const std::vector& a); // длина НВП (строго - подпоследовательность не обязана быть непрерывной. Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений. -Разбор после сдачи: «хвосты» — массив минимальных последних элементов + lower_bound. + +## Глава IV. Ловушки + +- `upper_bound` вместо `lower_bound` при строгом возрастании → повторяющиеся значения + ошибочно продлевают подпоследовательность → длина LIS завышена на массивах с дубликатами + (например, `[1, 1, 1]` должен дать 1, а не 3). +- Наивная DP-версия за O(n²) → проходит маленькие тесты, но на 100 000 элементах превышает + лимит в 2 секунды → `grade.py 05` роняет прогон по таймауту, а не по неверному ответу. +- Не обработан пустой вектор отдельным веткой → если код полагается на то, что цикл по + пустому диапазону сам вернёт 0, это обычно верно, но стоит явно проверить — тихая логическая + ошибка здесь не кидает исключение, просто даёт неверный ответ на пограничном тесте. + +**Факты для карточек** +- base | Сложность наивного DP-решения LIS? — O(n²) +- core | Сложность решения через `tails` + бинарный поиск? — O(n log n) +- core | Что хранит `tails[k]`? — минимально возможный последний элемент возрастающей + подпоследовательности длины `k+1` +- deep | Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound`? — + `lower_bound` находит первый элемент `>= x` и заменяет его, не давая повторам продлевать + подпоследовательность + +## После сдачи + +Разбор: почему массив `tails` не является самой LIS, а только хранит её длину через +минимальные возможные хвосты; как по `tails` при необходимости восстановить саму +подпоследовательность (дополнительный массив предшественников). diff --git a/diag/tasks/06_threads/task.md b/diag/tasks/06_threads/task.md index 694bce6..3fa8f2a 100644 --- a/diag/tasks/06_threads/task.md +++ b/diag/tasks/06_threads/task.md @@ -1,7 +1,10 @@ # Задача 06 — потокобезопасная ограниченная очередь (C++) -Классический вопрос на собеседовании в embedded/сетевую разработку: не «знаешь ли ты -std::thread», а «умеешь ли ты не сломать счётчик под нагрузкой». +На собеседовании это не вопрос «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать +счётчик под нагрузкой» — то есть понимаешь ли механику мьютекса и условной переменной +настолько, чтобы очередь не зависла и не словила гонку данных под ThreadSanitizer. + +## Интерфейс и требования ```c++ class BlockingQueue { @@ -16,12 +19,105 @@ public: ``` Требования: -- push после close() бросает `std::runtime_error`; +- `push` после `close()` бросает `std::runtime_error`; - закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait); -- размер очереди никогда не превышает capacity; +- размер очереди никогда не превышает `capacity`; - ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()` вызывается из другого потока — он тоже должен быть потокобезопасным). -Проверка: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. -Разбор после сдачи: почему нужен predicate-цикл вокруг wait, и как один condition_variable -на оба события даёт ложные пробуждения. +**Факты для карточек** +- base | Каким флагом компилятора включается ThreadSanitizer? — `-fsanitize=thread` +- base | Что бросает `push` после `close()`? — `std::runtime_error` +- core | Должен ли `size()` быть потокобезопасным в этой задаче? — да, его тоже вызывают из другого потока и он тоже под захватом мьютекса +- core | Что происходит с ждущими потоками при `close()`? — все разблокируются (никакого вечного `wait`) + +Почему дальше: раз очередь общая между потоками, нужен механизм, который защищает и её +состояние, и само ожидание — мьютекс плюс условная переменная. + +## Механизм: mutex + condition_variable + predicate-цикл + +Общее состояние очереди (буфер, счётчик элементов, флаг `closed`) защищено одним +`std::mutex` через RAII-обёртку (`std::lock_guard`/`std::unique_lock`) — так `unlock()` +происходит автоматически в деструкторе при выходе из области видимости любым путём, +включая исключение, и его невозможно забыть вызвать вручную. + +`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток — атомарность +важна: если бы освобождение и уход в сон были двумя отдельными шагами, между ними мог бы +вклиниться другой поток, изменить состояние и отправить `notify`, которое тогда потеряется, +потому что ждущий поток ещё не успел зайти в `wait`. При пробуждении `wait` снова захватывает +тот же мьютекс перед тем, как вернуть управление — код после `wait` уже работает под защитой. + +Стандарт C++ прямо разрешает ложные пробуждения (spurious wakeup) — `wait` может вернуться +без единого вызова `notify_one`/`notify_all`, просто по решению ОС (futex на Linux иногда +пробуждается по внутренним причинам платформы). Из-за этого `if (!ready) cv.wait(lock);` +недостаточно: после такого пробуждения предикат мог остаться ложным, и поток пойдёт работать +с данными, которые на самом деле не готовы — баг, который не ловится юнит-тестами и стреляет +только под конкурентной нагрузкой. Правильный паттерн — цикл с перепроверкой: + +```c++ +std::unique_lock lock(mtx_); +while (!(size_ > 0 || closed_)) // predicate-цикл, не if + not_empty_.wait(lock); +``` + +Для `push` и `pop` нужны разные условия ожидания (`не полна` и `не пуста` или `закрыта`), +поэтому один `condition_variable` на оба события удобен только тогда, когда `notify_all` +будит все ждущие потоки и каждый сам перепроверяет свой предикат в цикле — так ложные +пробуждения для «не того» условия просто не проходят проверку и поток снова засыпает. +Если использовать `notify_one` при одном общем `condition_variable` для двух разных условий, +можно разбудить не тот поток (например, разбудить ждущего `push`, когда освободилось место +для `pop`), а нужный так и останется спать — отсюда правило: либо `notify_all`, либо два +раздельных `condition_variable`. + +**Ловушки** +- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия + → трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами. +- Изменение состояния без удержания мьютекса (например, `size_++` вне `lock_guard`) → + гонка данных, UB → ловится ThreadSanitizer как data race при `size()` из другого потока. +- `notify_one` при общем `condition_variable` на два разных предиката → не тот поток проснулся, + нужный продолжает ждать → зависание, видно по таймауту теста, а не по явной ошибке. +- Забыть разбудить всех при `close()` → часть потоков навсегда в `wait` → зависание процесса, + тест не завершается за отведённое время. + +**Факты для карточек** +- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией +- core | Почему `while` вокруг `wait`, а не `if`? — из-за spurious wakeup: `wait` может вернуться без вызова notify +- core | Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one`? — можно разбудить не тот поток, а нужный останется ждать +- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — futex + +Почему дальше: раз тест явно требует ThreadSanitizer и отсутствия зависаний, логично понять, +что именно проверяет `grade.py` и почему «просто работает на глаз» здесь недостаточно. + +## Проверка + +Запуск: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. TSan +инструментирует каждое обращение к памяти и отслеживает happens-before между потоками во +время исполнения — то есть ловит гонку по факту конкретного запуска, а не статическим +анализом кода, поэтому «прогнать разок и не увидеть краша» не равно «гонки нет»: раскладка +потоков в конкретном запуске могла просто не проявить проблему. + +**Факты для карточек** +- base | Какой командой запускается проверка задачи 06? — `python3 grade.py 06` +- core | Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом + +
+Проверь себя + +1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же + мьютекс, что защищает очередь, а не произвольный лок? +
ОтветПотому что `wait` должен атомарно освободить именно этот + мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы + предикат под разными блокировками, и защита состояния очереди перестала бы работать.
+ +2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста? +
ОтветТребование — `close()` опустошает остаток: `pop()` должен + продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно + пуста — отдавать `false`, не уходя в ожидание.
+ +3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует + захвата мьютекса? +
ОтветПотому что запись в `size_` из `push`/`pop` без + синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает + без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».
+ +
diff --git a/diag/tasks/07_epoll/task.md b/diag/tasks/07_epoll/task.md index 75d39e8..e6aaf69 100644 --- a/diag/tasks/07_epoll/task.md +++ b/diag/tasks/07_epoll/task.md @@ -4,14 +4,116 @@ принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно, завершается по SIGTERM/SIGINT аккуратно (exit 0). -Требования: +## Требования + - мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента); - сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи; - `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту; - до 64 одновременных соединений, закрытие по EOF со стороны клиента; - никаких утечек дескрипторов (тест проверяет закрытие). +**Факты для карточек** +- base | На каком адресе и с каким флагом сокета должен слушать сервер? — 127.0.0.1, `SO_REUSEADDR` +- base | Сколько одновременных соединений сервер должен держать минимум? — 64 +- core | По какому событию сервер закрывает соединение с клиентом? — по EOF со стороны клиента + +Почему дальше: раз сервер должен обслуживать до 64 соединений одним потоком без блокировки +на каждом, нужен механизм, который скажет, какие именно дескрипторы готовы — `epoll`. + +## Механизм: epoll, уровень vs фронт + +`epoll` — мультиплексор ввода-вывода ядра Linux: `epoll_ctl` регистрирует дескрипторы в +«списке интереса», который ядро хранит между вызовами; когда состояние дескриптора меняется +(пришли данные, освободился буфер записи), ядро само кладёт его в «список готовых»; +`epoll_wait` просто возвращает этот список. Отсюда сложность на каждый вызов пропорциональна +числу реально готовых дескрипторов, а не общему их числу (в отличие от `select`/`poll`, +которые каждый раз заново проходят весь набор). + +У `epoll` два режима уведомления: +- **LT (level-triggered, по умолчанию)** — `epoll_wait` возвращает дескриптор снова и снова, + пока в его буфере остаются непрочитанные данные (условие «уровня» истинно). Прочитал не + всё за один раз — на следующем `epoll_wait` дескриптор опять придёт в списке готовых. +- **ET (edge-triggered, флаг `EPOLLET`)** — `epoll_wait` сообщает о готовности только один раз, + в момент перехода состояния «не готов → готов» (фронт). Если не вычитать буфер целиком за + этот раз, повторного уведомления не будет, пока не придут новые данные (новый фронт). + +Из этого прямо следует требование к сокетам: **ET обязывает читать/писать в цикле до +`EAGAIN`**, потому что единственный сигнал готовности уже был использован и другого не будет, +пока состояние снова не изменится. А раз цикл идёт до `EAGAIN`, дескриптор обязан быть +**неблокирующим** (`O_NONBLOCK`) — на блокирующем сокете последний вызов `read`/`write` в этом +цикле, когда данных больше нет, завис бы навсегда вместо того, чтобы вернуть `-1`/`EAGAIN`. +При LT неблокирующий режим тоже нужен по той же причине разделения потока между многими +клиентами, но не читать «всё до EAGAIN» за раз не так опасно — событие придёт снова. + +`EAGAIN` (синоним `EWOULDBLOCK`) — не ошибка сбоя, а сигнал «сейчас нечего делать, попробуй +позже»; `EINTR` — вызов прерван доставкой сигнала (например, при обработке SIGTERM/SIGINT) и +его нужно просто повторить, а не трактовать как реальную ошибку. Частичное чтение/запись +(`read`/`write` вернул меньше байт, чем просили) — нормальное поведение сокета, а не признак +проблемы: буфер ядра ограничен, и остаток нужно дочитывать/дописывать следующим вызовом. + +**Ловушки** +- Трактовать `EAGAIN` как ошибку и закрывать соединение → сервер рвёт рабочие соединения + просто потому, что в момент проверки данные ещё не пришли → видно как случайные обрывы + под нагрузкой, ловится логированием `errno` перед реакцией на ошибку. +- Забыть вычитывать буфер в цикле до `EAGAIN` при `EPOLLET` → повторного уведомления не будет, + часть данных «зависает» в буфере ядра → клиент не получает остаток эха, тест на эхо большого + объёма данных повисает или обрезает ответ. +- Не закрывать `fd` при ошибке/отключении клиента → утечка дескрипторов → процесс упирается + в лимит `ulimit -n` и получает `EMFILE` на новые `accept`, видно через `lsof -p PID` или + `/proc/PID/fd`. +- Игнорировать частичную запись (`write` вернул меньше, чем передали) → часть эха теряется + без ошибки → видно как обрезанный ответ клиенту при большом объёме данных за одну посылку. + +**Факты для карточек** +- base | Что означает `EAGAIN` на неблокирующем сокете? — не ошибка, сигнал «данных пока нет, попробуй позже» +- core | В чём разница между LT и ET режимами epoll? — LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов» +- core | Почему ET требует неблокирующих дескрипторов? — цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет +- deep | Какая асимптотика у `epoll_wait` относительно select/poll? — O(1) на готовое событие против O(n) на весь набор у select/poll + +Почему дальше: раз сервер должен «аккуратно завершаться по SIGTERM/SIGINT», нужно понимать, +как доставка сигнала прерывает системные вызовы и как это связать с циклом `epoll_wait`. + +## Завершение по сигналу + +Аккуратное завершение (`exit 0`) означает: сервер должен закрыть все сокеты и выйти из цикла +`epoll_wait`, а не просто дать процессу упасть от сигнала по умолчанию. Практический механизм — +установить обработчик на SIGTERM/SIGINT, который выставляет флаг (`volatile sig_atomic_t`), и +проверять этот флаг в цикле после каждого возврата из `epoll_wait` (который сам может вернуться +с `-1`/`EINTR` из-за прихода сигнала — это нужно отличать от реальной ошибки и не падать на ней). + +**Факты для карточек** +- base | Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT? — 0 +- core | Что возвращает `epoll_wait`, если его прервал сигнал? — -1 с `errno == EINTR`, не ошибка выполнения + +## Проверка + Запускается как самостоятельный бинарник: `./solution <порт>`. -Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, -льёт данные, проверяет эхо и корректное завершение по SIGTERM. -Критерий: все `ok` + ревью кода (использование epoll, обработка ошибок). +Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, льёт +данные, проверяет эхо и корректное завершение по SIGTERM. Критерий: все `ok` + ревью кода +(использование epoll, обработка ошибок). + +**Факты для карточек** +- base | Сколько соединений открывает тест `grade.py 07`? — 3 +- base | Каким сигналом тест проверяет корректное завершение сервера? — SIGTERM + +
+Проверь себя + +1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это + покрыло все данные, которые были на момент события? +
ОтветПотому что ET сообщает о фронте один раз; если между этим + чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел, + можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт + `EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.
+ +2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту? +
ОтветТребование — до 64 одновременных соединений одним потоком; + блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные — + отсюда обязательность `epoll` и неблокирующих сокетов.
+ +3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи? +
ОтветЭто не ошибка: буфер сокета ограничен по размеру, ядро + приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим + вызовом `write`, когда `EPOLLOUT` снова покажет готовность.
+ +
diff --git a/diag/tasks/08_bash/task.md b/diag/tasks/08_bash/task.md index 03e2cbb..53ff616 100644 --- a/diag/tasks/08_bash/task.md +++ b/diag/tasks/08_bash/task.md @@ -11,7 +11,8 @@ TOP <число запросов> 5XX <число ответов со статусом 500-599> ``` -Правила: +## Правила формата + - IP — первое поле строки; - TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию строки IP (лексикографически); @@ -20,6 +21,125 @@ TOP <число запросов> по времени — нужен awk/sort или один-два прохода; - пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет. +**Факты для карточек** +- base | Сколько строк TOP выводится, если уникальных IP меньше трёх? — столько, сколько есть уникальных IP +- base | Что печатает скрипт для пустого файла? — `TOTAL 0`, `5XX 0`, без строк TOP +- core | По какому полю строки лога определяется IP? — по первому полю + +Почему дальше: раз файл может быть на миллион строк, нужно понять, почему построчный +`while read` в bash не укладывается по времени и что вместо этого реально делает работу +за O(n) без интерпретации каждой строки внутри самого bash. + +## Механизм: почему не построчный цикл, а awk/sort + +Построчный `while read line; do ... done < file` на миллионе строк запускает bash-интерпретатор +заново на каждую итерацию цикла (разбор строки, вызов внешних утилит вроде `cut`/`grep` из +цикла добавляет ещё и `fork+exec` процесса на каждую строку) — при миллионе строк это +миллион запусков процессов, что на порядки медленнее одного прохода специализированной +утилитой. `awk` и `sort` — скомпилированные бинарники, которые сами читают файл целиком +за один проход (awk) без порождения процесса на строку, и делают всю агрегацию (подсчёт по +IP, подсчёт 5xx, TOTAL) внутри одного вызова. + +Практическая схема: один проход `awk` по файлу одновременно считает `TOTAL` (число строк), +инкрементирует счётчик по IP в ассоциативном массиве и считает строки с кодом 500–599 +(код ответа — обычно отдельное поле в access.log), а на выходе печатает пары `IP count`. +Дальше `sort` сортирует эти пары по счётчику по убыванию, а при равенстве — по IP по +возрастанию (составной ключ сортировки), и `head -3` берёт первые три строки. Итог — два +прохода по данным (awk-агрегация, потом sort уже по маленькому списку уникальных IP, а не +по миллиону исходных строк), а не миллион запусков процессов. + +**Факты для карточек** +- core | Почему `while read line` в bash медленный на миллионе строк? — каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку +- core | Что даёт связка awk + sort вместо построчного цикла? — один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов +- deep | По какому полю сортируется список IP при равенстве числа запросов? — по возрастанию строки IP (вторичный ключ сортировки) + +Почему дальше: раз скрипт нетривиальный (несколько шагов, внешние утилиты, граничный случай +с пустым файлом), в нём легко замаскировать ошибку — отсюда `set -e` и его реальные границы. + +## `set -e` и где он реально ломается + +`set -e` (`errexit`) останавливает скрипт при первой команде, вернувшей ненулевой код +возврата, вместо того чтобы по умолчанию молча идти дальше — это отклонение от исторического +поведения bash, оптимизированного под интерактивную сессию, где одна неудачная команда не +должна обрывать сессию целиком. `set -o pipefail` отдельно нужен для конвейеров (`awk ... | +sort | head`): без него код возврата конвейера — это код возврата только последней команды +(`head`), и падение `awk` или `sort` посередине останется незамеченным, даже если `set -e` +включён — сам по себе `-e` конвейер как единое целое не покрывает. + +`set -e` **не срабатывает**, если неудачная команда стоит частью условия — в `if`, `while`, +`until`, слева или справа от `&&`/`||`, либо после `!` — потому что в этих позициях код +возврата команды явно проверяется вызывающим кодом, а не игнорируется, и bash считает это +ожидаемым путём выполнения, а не аварией. + +Отдельная и менее очевидная ловушка — **команда в присваивании переменной внутри `local`**: + +```bash +local total=$(wc -l < "$file") # опасно под set -e +``` + +Здесь исполняются фактически две команды: `wc -l` внутри подстановки `$(...)` и сам `local`. +Если `wc -l` завершится с ошибкой, `set -e` эту ошибку не поймает, потому что итоговый код +возврата всей строки — это код возврата `local`, а `local` возвращает 0 (успех) сам по себе, +даже если команда внутри `$(...)` упала. Подстановка «теряется» внутри составной команды. +Лечится разделением на две строки: + +```bash +total=$(wc -l < "$file") # код возврата — это код возврата wc -l, set -e сработает +local total +``` + +**Ловушки** +- Полагаться на голый `set -e` для пайплайна `awk | sort | head` → падение `awk` посередине + незаметно, `head` вернёт 0 → нужен `set -o pipefail`, видно только по неверному выводу, + не по коду возврата. +- `local var=$(cmd)` в одну строку → ошибка `cmd` маскируется кодом возврата `local` (всегда 0 + для валидного объявления) → `set -e` не остановит скрипт, видно по неверному/пустому `var` + без явной ошибки. +- Не обработать пустой файл отдельно → `sort`/`head` на пустом входе не печатают строк TOP, + но если код по ошибке считает `TOTAL` через конвейер с `wc -l | ...`, легко перепутать + 0 строк с ошибкой чтения файла. +- Не заквотить `"$file"` → путь с пробелом разбивается на несколько слов → скрипт падает + на несуществующем файле или обрабатывает не тот аргумент. + +**Факты для карточек** +- base | Что делает `set -e`? — останавливает скрипт при первой команде с ненулевым кодом возврата +- core | В каких позициях `set -e` не останавливает скрипт при ошибке команды? — в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!` +- core | Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал? — итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри +- core | Зачем `set -o pipefail` отдельно от `set -e`? — без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным + +## Проверка + Запуск: `bash solution.sh access.log` Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе). Критерий: точное совпадение вывода, код возврата 0. + +**Факты для карточек** +- base | Каким кодом возврата должен завершаться `solution.sh` при успехе? — 0 +- core | Что именно сверяет `grade.py 08` с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе + +
+Проверь себя + +1. Почему пайплайн `grep 500 access.log | wc -l` под одним `set -e` (без `pipefail`) может + молча пропустить ошибку `grep` (например, если файл не существует)? +
ОтветКод возврата конвейера по умолчанию — это код возврата + последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней + упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую + упавшую команду.
+ +2. Почему `local total=$(false)` не остановит скрипт при `set -e`, а `total=$(false)` (без + `local` на той же строке) — остановит? +
ОтветВ первом случае итоговый код возврата строки — это код + возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе + объявления независимо от результата подстановки внутри; во втором случае код возврата + присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e` + сработает.
+ +3. Почему построчный `while read ip status rest; do ...; done < access.log` с миллионом строк + не проходит по времени, даже если внутри цикла нет вызовов внешних утилит? +
ОтветКаждая итерация цикла — это работа самого интерпретатора + bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на + порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл + целиком за один запуск процесса.
+ +
diff --git a/diag/tasks/09_gdb/task.md b/diag/tasks/09_gdb/task.md index 0b129b3..9ac0380 100644 --- a/diag/tasks/09_gdb/task.md +++ b/diag/tasks/09_gdb/task.md @@ -3,21 +3,134 @@ В `crash.c` лежит программа, которая падает на части входов и портит память. Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**. -Ожидаемое поведение после починки: +## Ожидаемое поведение после починки + - `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0; - `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0; - `./crash ""` печатает `len=0` и завершается с кодом 0; - сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке. -Что сделать: -1. Собрать с отладочной информацией и санитайзерами: - `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash` -2. Разобраться, что именно портит память, а что приводит к падению при выходе. - Полезно: `gdb ./crash`, `run`, `bt`, `frame`, `info locals`, `watch`. -3. Исправить `crash.c` (минимальные правки, стиль сохранить). -4. Заполнить `answer.txt`: сколько дефектов нашёл, какие именно, какими командами - отладчика это подтвердил (по шагам), почему падало именно так. +**Факты для карточек** +- base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0 +- base | Что печатает `./crash ""` после починки? — `len=0`, код возврата 0 +- core | Какие два санитайзера должны не давать сообщений после починки? — ASan и UBSan (`-fsanitize=address,undefined`) -Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN -и прогоняет 5 входов. -`answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а не только результат). +Почему дальше: чтобы вообще видеть номера строк и имена переменных в отладчике, а не голый +ассемблер, программу нужно собрать определённым образом — с этого и начинается разбор. + +## Сборка с отладочной информацией и санитайзерами + +``` +gcc -g -O0 -fsanitize=address,undefined crash.c -o crash +``` + +`-g` встраивает в бинарник отладочную информацию в формате DWARF — таблицу соответствия +машинных адресов номерам строк исходника, именам переменных, их типам и границам функций. +Именно из DWARF gdb берёт всё, что показывает человеку: `bt` печатает имена функций и строки +вместо голых адресов, `print x` знает тип `x` и умеет напечатать его по правилам этого типа +(структуру — по полям, массив — по элементам), `list` показывает исходный код вокруг текущей +точки. Без `-g` DWARF-секций в бинарнике нет, и gdb видит только адреса и ассемблер. + +`-O0` отключает оптимизации компилятора: оптимизатор переставляет и удаляет инструкции, +инлайнит функции и переиспользует один регистр под несколько переменных с непересекающимся +временем жизни — из-за этого отладочная информация от `-O2` часто не соответствует +однозначно исходному коду (строки «прыгают», переменная в `print` показывает не то значение, +потому что регистр уже переиспользован под другую переменную). Отсюда правило: для отладки +всегда пересобирают отдельно с `-g -O0`, а не отлаживают прод-сборку с оптимизациями. + +`-fsanitize=address,undefined` — это ASan и UBSan вместе, инструментирование на этапе +компиляции, а не отдельный инструмент поверх готового бинарника: +- **ASan** окружает каждый выделенный блок памяти недоступными «красными зонами» и ведёт + теневую карту состояния памяти — ловит выход за границы буфера, use-after-free и двойное + освобождение прямо в момент обращения, с точным стеком, а не когда испорченные данные + позже вызовут крах в случайном другом месте; +- **UBSan** инструментирует места, где поведение по стандарту C не определено — знаковое + переполнение, сдвиг за пределы разрядности типа, разыменование `NULL` с невалидным типом — + и печатает конкретную строку кода нарушения. + +**Ловушки** +- Собрать без `-g` и пытаться понять крах по голому `bt` с адресами вместо строк → отладка + вслепую по ассемблеру, не соответствует задаче «найти минимальными правками». +- Отлаживать сборку с `-O2` → строки в `bt`/`print` не совпадают с реальным местом бага + из-за инлайнинга и переиспользования регистров → ложный след при поиске дефекта. +- Пропустить пересборку с `-fsanitize=address,undefined` перед финальной проверкой → баг, + который не падает явным креша при обычном запуске (например, чтение за границей внутри + тем же выделенного блока), останется незамеченным до `grade.py`. + +**Факты для карточек** +- base | Что даёт флаг `-g` при сборке для gdb? — отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных +- core | Почему для отладки собирают с `-O0`, а не с `-O2`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику +- core | Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение? — все три, в момент обращения к памяти, а не постфактум +- deep | Что именно ловит UBSan, в отличие от ASan? — неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью + +Почему дальше: раз отладочная информация уже в бинарнике, нужно знать конкретные команды +gdb, которыми по ней ходят при разборе краша. + +## Команды gdb для разбора краша + +Рабочий цикл: `gdb ./crash`, затем `run [аргумент]` — передаёт управление процессу; если +процесс получает сигнал (обычно `SIGSEGV` от порчи памяти или abort от санитайзера), gdb сам +останавливается на инструкции, вызвавшей сбой. + +- `bt` — стек вызовов, цепочка кадров от текущей функции до `main`; первое, на что смотрят + после остановки — показывает, в какой функции и через какие вызовы дошли до краша. +- `frame N` — переключает контекст `print`/`list` на конкретный кадр стека из `bt`, чтобы + посмотреть локальные переменные вызывающей функции, а не только текущей. +- `info locals` — печатает все локальные переменные текущего кадра с их значениями по + информации из DWARF. +- `watch var` — аппаратная точка наблюдения: останавливает выполнение при каждом изменении + значения переменной. Нужна, когда переменная меняется как будто сама по себе из другого + места кода (типичный симптом переполнения буфера, которое затирает соседнюю память) — без + `watch` пришлось бы расставлять точки останова по подозрению вручную. +- `print x` — печатает текущее значение `x`, по типу из DWARF (структуру — по полям). + +Санитайзер добавляет отдельный слой: при нарушении (ASan/UBSan) программа останавливается +сама, печатая стек и описание нарушения ещё до того, как повреждение памяти успело привести +к крашу в другом, не связанном с причиной месте — это часто быстрее, чем ловить последствия +вручную в gdb по голому `SIGSEGV`. + +**Факты для карточек** +- base | Какая команда gdb показывает цепочку вызовов до краша? — `bt` +- core | Чем `watch var` отличается от обычного `break`? — останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода +- core | Зачем нужен `frame N` после `bt`? — переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции + +## Отчёт и проверка + +Исправить `crash.c` (минимальные правки, стиль сохранить). Заполнить `answer.txt`: сколько +дефектов нашёл, какие именно, какими командами отладчика это подтвердил (по шагам), почему +падало именно так. + +Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN и +прогоняет 5 входов. `answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а +не только результат). + +**Факты для карточек** +- base | Сколько входов прогоняет `grade.py 09`? — 5 +- base | Каким компилятором собирается `crash.c` на проверке? — gcc (компилятор C) + +
+Проверь себя + +1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с `-O2` для + финальной проверки производительности? +
ОтветОптимизатор переставляет и удаляет инструкции, инлайнит + вызовы и переиспользует регистры под разные переменные — отладочная информация от такой + сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или + неверные значения; для отладки нужна отдельная сборка `-g -O0`.
+ +2. Программа падает с `SIGSEGV` без санитайзеров, но при сборке с ASan вместо краша печатается + сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие? +
ОтветASan останавливает программу в момент фактического выхода + за границу буфера — в точке причины; без ASan повреждённая память могла не привести к + немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные + использовались уже в другом месте — это типичная картина «краш далеко от причины».
+ +3. Чем `watch var` полезнее обычной точки останова (`break`) для поиска места, где переменная + портится неожиданно? +
Ответ`break` останавливает выполнение в заданной строке кода, + но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения, + которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже + если изменение происходит из неожиданного места (например, из-за записи за границей другого + буфера, затирающей соседнюю память).
+ +
diff --git a/diag/tasks/10_hash/task.md b/diag/tasks/10_hash/task.md index 5b7706f..b95ee33 100644 --- a/diag/tasks/10_hash/task.md +++ b/diag/tasks/10_hash/task.md @@ -14,17 +14,52 @@ - **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне. - **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`. -## Глава II. Что нужно знать до старта +**Факты для карточек** +- base | Сколько времени закладывается на задачу 10? — 1,5–2,5 часа со ступенями +- base | Какой командой проверяется решение? — `python3 grade.py 10` +- deep | Где в реальных устройствах используется похожая структура? — таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти -1. `idx = hash & (cap - 1)` работает как «остаток от деления», только если `cap` — степень - двойки. Пример: `cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`. -2. Линейное зондирование: если ячейка занята, пробуем `(i+1) & (cap-1)`, потом `(i+2) & …` - и так по кругу, пока не найдём свободную (для вставки) или нужный ключ (для поиска). -3. Удалять «в ноль» нельзя: если между началом зондирования и элементом появится пустая - ячейка, поиск до него не дойдёт. Поэтому пустая ячейка помечается **tombstone** — - «здесь был элемент, иди дальше». -4. Фактор загрузки `load = size / capacity`. Держим ≤ 0.7: при 0.8+ пробеги становятся - длинными, и O(1) превращается в O(n). +Почему дальше: прежде чем писать код, нужно понять, почему индекс считается через `&`, +а не через `%`, и что физически происходит при коллизии. + +## Глава II. Механизм: индекс, зондирование, tombstone, load factor + +**Индекс через маску.** `idx = hash & (cap - 1)` работает как «остаток от деления», только +если `cap` — степень двойки. Причина: у степени двойки `cap - 1` в двоичном виде — это +подряд идущие единицы во всех младших битах (например, `cap = 8 = 0b1000` → `cap-1 = 0b0111`). +Операция `AND` с такой маской обнуляет все биты хеша выше этой границы и оставляет ровно +младшие `log2(cap)` бит — а это математически то же самое, что `hash mod cap`. Пример: +`cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`. +`%` работает для любой ёмкости, но требует инструкции деления (на некоторых платформах +дороже, чем сдвиг/AND); отсюда практика ограничивать ёмкость степенью двойки и считать +индекс битовой маской. + +**Линейное зондирование.** Если ячейка `idx` занята, пробуем `(idx+1) & (cap-1)`, потом +`(idx+2) & (cap-1)` и так по кругу, пока не найдём свободную ячейку (для вставки) или +нужный ключ (для поиска). Механически это обход массива по кругу начиная с `idx`. + +**Tombstone.** Удалять «в ноль» (ставить `EMPTY`) нельзя: если между началом зондирования +и искомым элементом появится пустая ячейка, поиск остановится на ней раньше, чем дойдёт до +элемента — элемент физически на месте, но недостижим для `get`. Поэтому удалённая ячейка +помечается **tombstone** — «здесь был элемент, поиск должен идти дальше», но **вставка** +может переиспользовать tombstone-ячейку под новый ключ. + +**Load factor и длина пробега.** `load = size / capacity`. По формуле среднего числа проб +при линейном зондировании (Кнут): удачный поиск — `0.5·(1 + 1/(1-load))` проб, +неудачный — `0.5·(1 + 1/(1-load)²)`. При `load = 0.7`: удачный поиск ≈ 2,2 пробы, +неудачный ≈ 6,1. При `load = 0.9`: удачный ≈ 5,5, неудачный ≈ 50,5 — рост нелинейный, +именно поэтому порог держат на 0.7, а не поднимают его «для экономии памяти». + +**Факты для карточек** +- base | Почему `hash & (cap-1)` эквивалентно `hash % cap`? — только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления +- base | Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс? — `19 & 7 = 3` +- core | Среднее число проб при удачном поиске, load factor 0.7? — ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`) +- core | То же при load factor 0.9? — ≈5,5 — рост почти в 2,5 раза от значения при 0.7 +- deep | Среднее число проб при НЕудачном поиске, load factor 0.9? — ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска +- core | Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей? — поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте + +Почему дальше: механизм понятен — теперь нужен точный интерфейс и числа, по которым +`grade.py` проверяет решение. ## Глава III. Задание @@ -55,6 +90,12 @@ public: - удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления и вставки возвращается к правильному значению. +**Факты для карточек** +- base | Сколько ключей вставляет тест на кластеризацию и какие они? — 512 ключей, кратных 16 +- base | За какое время должны пройти 100 000 вставок? — быстрее 2 секунд +- base | Какой порог load factor держит таблица? — ≤ 0.7 +- base | Минимальная ёмкость таблицы по умолчанию? — 16, степень двойки + ## Глава IV. Ступени (делай по одной, после каждой — прогон) Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный @@ -63,7 +104,10 @@ public: **Ступень 1. Хеш-функция и индекс (20 минут).** Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей хорошая хеш-функция — перемешать биты умножением на большую нечётную константу: -`h = (uint64_t)key * 2654435761u;` (это золотое сечение, классика). +`h = (uint64_t)key * 2654435761u;` (это `2^32 / φ` при золотом сечении `φ ≈ 1.618`, +классика — умножение на нечётную константу обратимо по модулю `2^32` и «размазывает» +младшие биты ключа по всей ширине результата, поэтому соседние по младшим битам ключи +после умножения расходятся по разным индексам). Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16 свалятся в одну ячейку. @@ -87,9 +131,11 @@ Tombstone можно переиспользовать под вставку, н Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем. **Ступень 6. Перехеширование (30 минут).** -Когда `size * 10 > capacity * 7`, создай массив вдвое больше и **заново вставь все занятые -ключи** (tombstone не переносим — в новой таблице пусто). Учти: после этого `size` не должен -измениться, а пробеги станут короче. Прогон: `python3 grade.py 10`. +Когда `size * 10 > capacity * 7` (то есть `load > 0.7`), создай массив вдвое больше и +**заново вставь все занятые ключи** (tombstone не переносим — в новой таблице пусто). +Учти: после этого `size` не должен измениться, а пробеги станут короче (см. формулу проб +из главы II — вдвое большая ёмкость при том же `size` резко снижает `load`, а с ним и +среднее число проб). Прогон: `python3 grade.py 10`. **Ступень 7. Прогон под санитайзерами (10 минут).** `grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь, @@ -133,16 +179,56 @@ Tombstone можно переиспользовать под вставку, н значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`. -## Глава VII. Частые ошибки и как их не допустить +## Глава VII. Ловушки -- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая. -- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на - медленных платформах деление дороже. -- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место. -- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше - держать их в одной структуре ячейки. -- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги - огромны, время уходит за лимит 2 секунды. +- Забыть перемешать хеш (использовать `key` напрямую как индекс) → ключи, кратные 16, + все попадают в одну-две ячейки → кластеризация, пробег растёт линейно с числом таких + ключей → тест на 512 ключей, кратных 16, падает по таймауту или по «not found». +- Забыть про tombstone при rehash (перенести признак `TOMBSTONE` в новую таблицу вместо + того, чтобы его отбросить) → «мёртвые» ячейки переезжают и занимают место в новой + таблице без необходимости → эффективная ёмкость меньше заявленной, load factor растёт + быстрее ожидаемого. +- Хранить ключ отдельно от значения в параллельных массивах и перепутать индекс при + перестановке → ASAN поймает не сразу, а в момент обращения к рассинхронизированной + ячейке; надёжнее держать ключ и значение в одной структуре ячейки. +- Проверять `size == capacity` вместо load factor (`size * 10 > capacity * 7`) → таблица + почти полностью заполняется, пробеги становятся длиной в десятки-сотни ячеек (см. формулу + неудачного поиска при load → 1), суммарное время `put` уходит за лимит 2 секунды на + 100 000 ключей. + +## Проверь себя + +
+1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы? +`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)` +перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой, +а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при +котором работает битовая маска вместо деления. +
+ +
+2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor +вырастет с 0.7 до 0.9? +Примерно в 2,5 раза: `0.5·(1+1/(1-0.7)) ≈ 2,2` против `0.5·(1+1/(1-0.9)) ≈ 5,5`. +Для неудачного поиска рост ещё резче — с ≈6,1 до ≈50,5, то есть почти в 8 раз. +
+ +
+3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже +такой ключ? +`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если +инкрементировать `size` без предварительного поиска ключа по цепочке зондирования, +повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт +элементов упадёт. +
+ +
+4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании? +В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только +реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы +место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами +цепочки проб после rehash пересчитываются заново. +
## После сдачи diff --git a/diag/tasks/11_heap/task.md b/diag/tasks/11_heap/task.md index 6243cd1..fc4135f 100644 --- a/diag/tasks/11_heap/task.md +++ b/diag/tasks/11_heap/task.md @@ -1,6 +1,7 @@ # Задача 11 — куча: построение за O(n) и top-K (C++) -Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`), без `std::priority_queue`. +Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`), +без `std::priority_queue`. ```c++ void heapify(std::vector& a); // перестроить массив в кучу @@ -20,5 +21,144 @@ std::vector top_k(const std::vector& a, int k); // k наибо Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений, ASAN/UBSAN чистые. +**Факты для карточек** +- base | Индексы детей и родителя в max-heap на массиве? — дети `2i+1`, `2i+2`; родитель `(i-1)/2` +- base | Что за 2 секунды должны выполниться на 3 000 000 элементов? — `heapify` и `kth_largest` с `k=1000` (по отдельности) +- base | Какой ключевой запрет на реализацию `heapify`? — нельзя строить через `push` в цикле, только bottom-up за O(n) + +Почему дальше: массив интерпретируется как дерево через индексную арифметику — дальше +нужно понять, как именно поддерживается инвариант кучи и почему bottom-up быстрее push-цикла. + +## Механизм: массив как дерево, sift-down/sift-up + +Массив хранится линейно (`std::vector`), но интерпретируется как полное бинарное +дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без +указателей. Инвариант max-heap: значение в родителе не меньше значений в обоих детях. + +**sift-up** (используется при вставке одного элемента): новый элемент кладётся в конец +массива и сравнивается с родителем; пока он больше родителя — меняются местами, индекс +сдвигается к корню. Длина пути от листа до корня — высота дерева, `O(log n)`. + +**sift-down** (используется при извлечении максимума и при bottom-up heapify): элемент в +корне (или в произвольном узле) сравнивается с обоими детьми; если хотя бы один ребёнок +больше — меняется местами с большим из них, и процесс повторяется на новой позиции, пока +инвариант не восстановится или узел не станет листом. Тоже `O(log n)` на один вызов от +корня. + +**top()** — доступ к максимуму — `O(1)`: по инварианту кучи максимум всегда лежит в корне, +то есть в начале массива, никакого поиска не требуется. + +**Факты для карточек** +- base | Сложность доступа к максимуму (`top`)? — O(1), максимум всегда в корне по инварианту кучи +- base | Сложность одного `sift-up`/`sift-down` от произвольного узла? — O(log n) — путь ограничен высотой дерева +- core | Что сравнивается на каждом шаге `sift-down`? — узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла + +## Механизм: почему bottom-up heapify — O(n), а push-цикл — O(n log n) + +**Через push в цикле.** Вставка i-го элемента (при текущем размере кучи `i`) требует до +`O(log i)` шагов `sift-up`. Суммарная работа — `Σ log₂(i)` для `i = 1..n`, это `log₂(n!)`, +по формуле Стирлинга порядка `n·log₂(n)`. Итого `O(n log n)` — почти вся вставленная масса +элементов всплывает от листьев, где высота дерева максимальна (`~log n`), к своему месту. + +**Через bottom-up heapify.** Алгоритм стартует с последнего нелистового узла (индекс +`n/2 - 1`) и идёт к корню (индекс `0`), вызывая `sift-down` на каждом узле. Ключевое +отличие: работа `sift-down` в узле пропорциональна **высоте этого узла `h`**, а не высоте +всего дерева. Узлов с большой высотой мало (в корне — один узел высоты `log n`), а узлов с +малой высотой — экспоненциально больше (листьев высоты 0 — примерно `n/2`, они вообще +пропускаются). Суммарная работа: + +``` +Σ h=0..log n (n / 2^(h+1)) · h +``` + +Ряд `Σ h/2^h` при `h → ∞` сходится к константе `2` (не растёт с `n`), поэтому вся сумма +ограничена `n · const = O(n)` — линейно, а не `n log n`. Для `n = 3 000 000` +(`log₂ n ≈ 21,5`) это даёт примерно на порядок (~10 раз) меньше операций сравнения, чем +push-цикл — именно поэтому требование задачи «bottom-up, а не push» не формальность, а +разница на порядок при 3 миллионах элементов. + +**Факты для карточек** +- core | За какое время строится куча через n последовательных `push`? — O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n` +- core | За какое время строится куча через bottom-up heapify? — O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n` +- deep | С какого индекса начинается bottom-up heapify и в какую сторону идёт? — с последнего нелистового узла `n/2 - 1`, к корню (индекс 0) +- core | Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000? — примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up + +Почему дальше: сама куча даёт максимум за O(1) и перестройку за O(log n) — на этом строятся +`kth_largest` и `top_k`, но у них есть более эффективная альтернатива для потоковых данных. + +## kth_largest и top_k + +Базовый подход: `heapify` за `O(n)`, затем `k` раз `pop` (извлечь максимум, `sift-down` +корня) — каждый `pop` стоит `O(log n)`. Итого `O(n + k log n)`. При `k = 1000` и +`n = 3 000 000` слагаемое `n` доминирует — отсюда требование «быстрее 2 секунд» выполнимо +за счёт того, что сама куча строится линейно. + +Граничные случаи по условию: `k > n` — `top_k` отдаёт все элементы по убыванию, а +`kth_largest` — значение минимального элемента, без падения и без обращения за границу +массива. + +**Потоковый top-K.** Когда данные не помещаются в память целиком (поток), вместо +построения полной кучи на все `n` элементов держат **min-heap размера k**: каждый новый +элемент сравнивается с минимумом кучи (`top`), и если новый элемент больше — минимум +удаляется, новый добавляется. Сложность — `O(n log k)` вместо `O(n log n)` для полной +сортировки, и память — `O(k)`, а не `O(n)`. + +**nth_element vs куча.** `std::nth_element` (Хоара, quickselect) находит элемент на нужной +позиции и частично упорядочивает массив вокруг него (всё левее — не больше него, всё правее +— не меньше, но без порядка внутри частей) в среднем за `O(n)` — стандарт требует линейного +времени в среднем, но не гарантирует худший случай, в отличие от кучи, которая всегда даёт +`O(n + k log n)`. Для одного k-го элемента `nth_element` быстрее кучи по константе; для +top-K как **отсортированного** списка кучу всё равно придётся досортировать после +`nth_element`, тогда как `pop` из кучи уже отдаёт элементы по убыванию. + +**Факты для карточек** +- base | Сложность `kth_largest`/`top_k` через полную кучу? — O(n + k log n): O(n) на heapify плюс k раз pop по O(log n) +- core | Когда используют min-heap размера k вместо полной кучи на весь массив? — на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k) +- core | Чем `std::nth_element` отличается от кучи для top-K? — в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop + +## Ловушки + +- Построить кучу через `push` в цикле вместо bottom-up `heapify` → машинная проверка + свойств кучи пройдёт (инвариант тот же), но сложность — O(n log n) вместо O(n) → на + 3 000 000 элементов заметно медленнее и явно видно по коду при ревью (это как раз спросят + на собеседовании отдельным вопросом). +- Потерять или продублировать элементы при перестройке `sift-down` (например, скопировать + значение вместо обмена местами) → мультимножество элементов меняется → видно сравнением + отсортированной копии массива до и после `heapify`. +- Не ограничить `k` при `k > n` → обращение к `pop` на пустой куче или выход за границу + массива → падение или UB, видно по ASAN, а не просто неверный ответ. + +## Проверь себя + +
+1. Почему bottom-up heapify — O(n), хотя высота дерева log n? +Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте +всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты +`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится +к `n`, умноженному на константу, а не на `log n`. +
+ +
+2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения, +чем bottom-up heapify? +Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up +heapify) — то есть на порядок больше сравнений. +
+ +
+3. Почему `top()` кучи — O(1), а не O(log n)? +По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое +обращение по индексу `0`, без поиска и без перестройки структуры. +
+ +
+4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь +массив? +Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что +не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k +текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и +заменяет его за O(log k) — суммарно O(n log k) и память O(k). +
+ Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи. diff --git a/diag/tasks/12_dijkstra/task.md b/diag/tasks/12_dijkstra/task.md index e2709c4..9cf187b 100644 --- a/diag/tasks/12_dijkstra/task.md +++ b/diag/tasks/12_dijkstra/task.md @@ -14,8 +14,134 @@ long long shortest_path(int n, - 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue` (O((V+E) log V)), а не O(V²) перебор минимума. -Подсказки по разбору (после сдачи): «ленивое» удаление устаревших записей из очереди -(`if (d != dist[v]) continue;`), почему нельзя Дейкстру с отрицательными рёбрами и когда -нужен Беллман–Форд. - Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений. + +**Факты для карточек** +- base | Что возвращает функция при `src == dst`? — 0 +- base | Что возвращает функция при недостижимости `dst`? — -1 +- base | В каком типе суммируются веса и почему не `int`? — `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути + +Почему дальше: чтобы уложиться в 2 секунды на 100 000 вершин и 200 000 рёбер, важно понять, +почему наивный перебор минимума не подходит и что именно даёт куча. + +## Механизм: куча против наивного перебора + +**Наивная Дейкстра.** На каждом из `V` шагов алгоритм сканирует массив `dist[]` целиком, +чтобы найти непосещённую вершину с минимальным расстоянием — это `O(V)` на шаг. Суммарно +`V` шагов по `O(V)` — `O(V²)`, плюс `O(E)` на релаксацию рёбер (эта часть не доминирует). +Подходит, если граф плотный (`E ~ V²`), но не в этой задаче. + +**Дейкстра с priority_queue.** Вместо линейного скана используется min-куча по текущему +известному расстоянию. При релаксации ребра `(u, v, w)`, если найден более короткий путь до +`v`, в кучу **добавляется новая запись** `(dist, v)` — `push` стоит `O(log размер_кучи)`. +Так как для одной вершины может накопиться несколько записей (по одной на каждое улучшение +расстояния), размер кучи ограничен числом релаксаций, то есть `O(V + E)`, и `log` от этой +величины по порядку — тот же `O(log V)` (`log(V²) = 2 log V`, то есть смена основания +не меняет асимптотику). Суммарно: `O((V + E) log V)`. + +**Числа задачи.** `V = 100 000`, `E = 200 000`. Наивный `O(V²) = 100 000² = 10^10` операций +— в 2 секунды не укладывается ни при каких обстоятельствах. Куча: `(V+E)·log₂V ≈ +300 000 · 16,6 ≈ 5·10^6` операций — на 3–4 порядка меньше, укладывается с большим запасом. + +**«Ленивое» удаление устаревших записей.** `std::priority_queue` не умеет `decrease-key` +за `O(log n)` (не тот интерфейс), поэтому вместо обновления существующей записи в куче +для вершины `v` просто добавляется новая пара `(dist, v)` при каждом улучшении. В куче +одновременно может лежать несколько устаревших записей для одной вершины. При извлечении +самой записи с минимальным `dist` проверяется, актуальна ли она: + +```c++ +auto [d, v] = pq.top(); pq.pop(); +if (d != dist[v]) continue; // запись устарела — лучшая уже обработана раньше +``` + +Это дешевле, чем поддерживать структуру с настоящим `decrease-key` (например, indexed +heap), и корректность не страдает — устаревшая запись всегда хуже уже найденного `dist[v]` +и просто пропускается. + +**Факты для карточек** +- base | Сложность наивной Дейкстры (перебор минимума по массиву)? — O(V²) +- base | Сложность Дейкстры с бинарной кучей? — O((V+E) log V) +- core | При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды? — 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка +- core | Что означает «ленивое удаление» устаревших записей в очереди? — вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая +- deep | Почему `std::priority_queue` не используют с decrease-key напрямую? — у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop + +Почему дальше: скорость решена кучей, но у Дейкстры есть жёсткое условие корректности — +неотрицательные веса. Нужно понять механизм, почему это условие обязательно. + +## Механизм: почему нужны только неотрицательные веса + +Дейкстра — жадный алгоритм: когда вершина `v` извлекается из очереди с минимальным на +данный момент расстоянием, алгоритм считает `dist[v]` **окончательным** и больше не +пересматривает эту вершину. Это верно только если все ещё не обработанные пути до `v` не +могут оказаться короче — а это гарантировано лишь при неотрицательных весах: любой другой +путь до `v` проходит через вершины с расстоянием `≥ dist[v]` и добавляет ребро `≥ 0`, то +есть не может дать сумму меньше `dist[v]`. + +Если в графе есть отрицательное ребро, эта гарантия ломается: путь через уже +«финализированную» вершину может позже пройти по отрицательному ребру и дать меньшую +сумму, но алгоритм эту вершину уже не пересматривает — результат будет неверным без явного +падения или ошибки, то есть тихо неправильным. + +Для графов с отрицательными весами (без отрицательных циклов) используется +**Беллман-Форд**: `V-1` раз релаксируются все `E` рёбер, `O(V·E)`. Дополнительно на `V`-м +проходе можно проверить, продолжает ли что-то релаксироваться — если да, в графе +отрицательный цикл, кратчайший путь не определён (можно уменьшать бесконечно). + +Self-loop с весом `w ≥ 0` никогда не уменьшает кратчайший путь (добавление неотрицательного +веса к текущему расстоянию не улучшает его), поэтому не требует отдельной обработки — +алгоритм просто никогда не выберет такое ребро для релаксации выгодно. + +**Факты для карточек** +- core | Почему Дейкстра ломается на отрицательных рёбрах? — алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается +- base | Какой алгоритм нужен при отрицательных весах без отрицательных циклов? — Беллман-Форд, O(V·E) +- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл +- base | Почему self-loop с w≥0 не требует отдельной обработки? — добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию + +## Ловушки + +- Использовать `int` вместо `long long` для накопленной суммы весов → при весах рёбер до + 10^9 и длинном пути сумма переполняет `int` → тихо неверный (отрицательный или + «случайный») результат без явного падения. +- Забыть проверку `if (d != dist[v]) continue` при извлечении из очереди → обрабатываются + устаревшие записи повторно → не влияет на корректность, но раздувает число операций и + на 200 000 рёбер может вывести время за лимит 2 секунды. +- Запустить алгоритм на графе с отрицательным ребром без проверки условия применимости → + результат тихо неверный (нет явной ошибки времени выполнения) — Дейкстра не обнаруживает + нарушение своего предположения сама. +- Перепутать направление рёбер (граф ориентированный) и релаксировать в обе стороны → + находится путь, которого нет в графе условия задачи, `shortest_path` возвращает заниженное + значение. + +## Проверь себя + +
+1. Почему при V=100 000 и E=200 000 наивный O(V²) не проходит по времени, а вариант +с кучей — проходит? +`O(V²) = 100 000² = 10^10` операций — на 3–4 порядка больше, чем позволяют 2 секунды. +Вариант с кучей — `O((V+E) log V) ≈ 300 000 · log₂(100 000) ≈ 300 000 · 16,6 ≈ 5·10^6` +операций, укладывается с большим запасом. +
+ +
+2. Что произойдёт с результатом Дейкстры, если в графе есть ребро веса -5, а +остальные рёбра положительные? +Результат может быть неверным без явной ошибки: если вершина на дешёвом с виду пути уже +извлечена из очереди и «финализирована», а позже к ней ведёт более короткий путь через +ребро -5, алгоритм это улучшение не увидит, так как вершина повторно не пересматривается. +
+ +
+3. Почему в очереди могут одновременно лежать несколько записей для одной и той же +вершины, и почему это не ошибка? +Каждое найденное улучшение расстояния до вершины добавляет новую запись `(dist, v)` в кучу +вместо обновления старой (`decrease-key` не поддерживается `std::priority_queue`). Это не +ошибка, потому что при извлечении устаревшая запись (`d != dist[v]`) просто пропускается — +корректность сохраняется, платится только лишней памятью в очереди и лишним `pop`. +
+ +
+4. Почему self-loop с весом w≥0 никогда не меняет кратчайший путь? +Self-loop добавляет вершине путь до самой себя длиной `w ≥ 0`. Поскольку путь длины 0 (не +двигаться) уже не хуже, прибавление неотрицательного веса к текущему `dist[v]` не может дать +меньшее значение — релаксация через такое ребро никогда не проходит условие «короче». +
diff --git a/diag/tasks/13_subnet/task.md b/diag/tasks/13_subnet/task.md index c274d51..1e72415 100644 --- a/diag/tasks/13_subnet/task.md +++ b/diag/tasks/13_subnet/task.md @@ -32,5 +32,121 @@ int prefix_for_hosts(int hosts); // самая узкая п Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений. +**Факты для карточек** +- base | Сколько узлов даёт /24? — 254 +- base | Сколько узлов даёт /26? — 62 +- base | Сколько узлов даёт /30? — 2 +- base | Сеть, broadcast и диапазон узлов для `10.0.1.130/26`? — сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190` + +Почему дальше: чтобы получить эти числа программно, а не подбором, нужен механизм вычисления +маски, границ сети и обратной задачи — подбора префикса по числу узлов. + +## Механизм: маска, границы сети, обратный подбор префикса + +**Маска по префиксу.** Маска — это `prefix` единиц в старших битах и `32-prefix` нулей в +младших: `mask = 0xFFFFFFFFu << (32 - prefix)` для `prefix > 0`. Для `prefix == 0` формула +не годится напрямую — сдвиг на 32 бита для 32-битного типа в C/C++ является неопределённым +поведением (UB), поэтому этот случай обрабатывается отдельно: `mask = 0`. + +**Границы сети.** `network = ip & mask` — обнуляет все биты хостовой части (сохраняет +только биты сети). `broadcast = network | ~mask` — выставляет все биты хостовой части в +единицу (`~mask` — это как раз маска хостовой части, инвертированная сетевая). Число бит, +отданных под узлы, — `32 - prefix`; всего адресов в блоке — `2^(32-prefix)`. + +**Разбор эталонного примера `10.0.1.130/26`.** `/26` оставляет `32-26=6` бит под узлы, +блок из `2^6 = 64` адресов. Адрес `.130` попадает в блок `128..191` (границы блоков кратны +64): `network = 10.0.1.128`, `broadcast = 10.0.1.191`. Обычные узлы — `first_host = +network+1 = .129`, `last_host = broadcast-1 = .190`. Из 64 адресов блока минус 2 служебных +(сеть и broadcast) — 62 узла, что совпадает с `/26 → 62 узла`. + +**Почему `/26` даёт именно 62 узла.** `host_count = 2^(32-26) - 2 = 2^6 - 2 = 64 - 2 = 62`. +Аналогично `/24`: `2^8 - 2 = 254`; `/30`: `2^2 - 2 = 2` — ровно минимум для соединения +точка-точка между двумя маршрутизаторами. + +**`/31` — исключение (RFC 3021).** По общей формуле `/31` дал бы `2^1 - 2 = 0` узлов — +бессмысленный результат для блока из 2 адресов. RFC 3021 разрешает для каналов +точка-точка (ровно 2 узла на линке) отдавать под хосты оба адреса блока: широковещательный +адрес такому каналу физически не нужен (получателей всего два, и оба известны заранее), а +резервировать половину 2-адресного блока под network/broadcast — чистая потеря адресного +пространства. Поэтому `host_count = 2`, `first_host = network`, `last_host = broadcast`. + +**`/32` — единичный адрес.** Блок из одного адреса: `network = broadcast = first_host = +last_host = ip`, `host_count = 1`. Используется для маршрутов на конкретный узел +(host route) или loopback-адресов маршрутизатора. + +**Обратная задача — `prefix_for_hosts(h)`.** Нужен наибольший `prefix` (самая узкая +подсеть), при котором `2^(32-prefix) - 2 >= h`, то есть `2^(32-prefix) >= h+2`, то есть +`32-prefix >= log2(h+2)`. Так как число хостовых бит — целое, наименьшее подходящее +значение — `32-prefix = ceil(log2(h+2))`, откуда: + +``` +prefix_for_hosts(h) = 32 - ceil(log2(h+2)) +``` + +Проверка на эталонных примерах: `h=62` → `h+2=64`, `log2=6`, `prefix=26` ✓. `h=63` → +`h+2=65`, `log2(65)≈6.02`, `ceil=7`, `prefix=25` ✓ (62 узла `/26` уже не хватает на 63-й +хост, нужен на бит шире — `/25` с 126 узлами). `h=254` → `h+2=256`, `log2=8`, `prefix=24` ✓. +`h=1` → `h+2=3`, `log2(3)≈1.58`, `ceil=2`, `prefix=30` ✓. + +**Факты для карточек** +- base | Формула маски для префикса p (0= h+2` +- deep | Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1? — /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами + +## Ловушки + +- Вычислить маску как `0xFFFFFFFF << (32 - prefix)` при `prefix == 0` без отдельной ветки + → сдвиг на 32 бита для 32-битного типа — неопределённое поведение в C/C++ (компилятор + может дать любое значение, не обязательно 0) → UBSan ловит это как сдвиг за пределы + разрядности типа. +- Применить общую формулу `host_count = 2^(32-p) - 2` к `/31` без проверки исключения → + получится 0 узлов вместо 2 → тест на `/31` (RFC 3021) падает. +- Использовать `int` вместо `long long` для `host_count` → для `/0` значение `2^32 - 2` не + влезает в `int` (переполнение со знаком — UB) → в условии структуры явно указан `long long` + именно из-за этого случая. +- Перепутать host byte order с network byte order при сравнении с реальным трафиком или + утилитами вроде `tcpdump` → `10.0.0.1` в host order этой задачи — это `0x0A000001`, но в + сетевом порядке байты переставлены (`0x0100000A` при little-endian хосте) — конвертация + через `htonl`/`ntohl`, а не прямое сравнение чисел. + +## Проверь себя + +
+1. Почему /31 не подчиняется общей формуле «минус 2 служебных адреса»? +Потому что у 2-адресного блока резервирование network и broadcast по общей формуле оставило +бы 0 узлов — бессмысленно для канала точка-точка, где всего два участника и оба заранее +известны, широковещание не нужно. RFC 3021 явно отдаёт оба адреса блока под хосты. +
+ +
+2. Дано `ip=10.0.1.130`, `prefix=26`. Посчитать network, broadcast, first_host, +last_host по шагам. +Хостовых бит: `32-26=6`, размер блока `2^6=64`. Адрес `.130` попадает в блок, начинающийся +на границе, кратной 64: `128 <= 130 < 192`, значит `network=10.0.1.128`, +`broadcast=10.0.1.128+63=10.0.1.191`, `first_host=network+1=10.0.1.129`, +`last_host=broadcast-1=10.0.1.190`. +
+ +
+3. Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 только на +единицу? +`/26` физически даёт ровно 62 адреса под хосты — этого не хватает даже на один хост меньше +требуемых 63. Следующий шаг «расширения» подсети — не +1 узел, а удвоение блока: снятие +одного бита из префикса (`/25`) сразу даёт 126 узлов. Промежуточных вариантов между /26 и +/25 не существует, поэтому единственный подходящий ответ — /25. +
+ +
+4. Почему для `prefix=0` нельзя вычислять маску как `0xFFFFFFFF << (32-0)`? +Это сдвиг на 32 бита для 32-битного беззнакового типа — стандарт C/C++ определяет сдвиг +только для величины меньше ширины типа в битах, сдвиг на саму ширину или больше — UB +(на практике часто даёт исходное значение без сдвига вместо ожидаемого 0). Поэтому +`prefix == 0` обрабатывается отдельной веткой: `mask = 0` напрямую. +
+ Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`), почему `/31` — исключение, что такое маска в бинарном виде. diff --git a/lessons/D1_algo.md b/lessons/D1_algo.md index c5d37a6..5751941 100644 --- a/lessons/D1_algo.md +++ b/lessons/D1_algo.md @@ -3,10 +3,11 @@ Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз, код в разборах можно копировать и запускать — это образец, а не ответ на задачу. -## 1. Что такое O-нотация (без воды) +## 1. Что такое O-нотация O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз -больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n). +больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое +100 и множитель 3 не меняют форму роста, поэтому их не пишут. Три правила, которых хватает для 90% вопросов: @@ -18,14 +19,32 @@ O-нотация отвечает на вопрос «как растёт вре Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`. Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n. -**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция -«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` — -обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно -**амортизированное O(1)**, потому что ёмкость удваивается. +**Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не +гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`: +когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её, +выделяет новый блок и переносит туда все элементы — это разовая операция O(n). Из-за +геометрического роста ёмкости такие реаллокации случаются экспоненциально реже: после +k-й реаллокации следующая наступит примерно через 2^k новых вставок. Сумма стоимости всех +реаллокаций на n вставок — геометрическая прогрессия n/2 + n/4 + n/8 + ... ≈ n, то есть +суммарно O(n) на n операций, а не O(n²) — отсюда амортизированное **O(1)** на одну вставку. +Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка копировала бы весь массив — +суммарно O(n²). Геометрический рост — не оптимизация, а необходимое условие амортизации. +Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на +элементы вектора, потому что блок памяти физически переехал — источник use-after-free, +который ловит ASAN. **Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**. +**Факты для карточек** +- base | Во что превращается 3n + 100 в O-нотации? — O(n) +- base | Сколько шагов у бинарного поиска в массиве из 10⁶ элементов? — 20 (log₂ 10⁶ ≈ 20) +- core | Почему push_back в среднем O(1), а не O(n)? — ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n² +- core | Что ломает удвоение ёмкости у vector? — указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти) +- deep | Что будет, если ёмкость vector растить на +1 за раз вместо удвоения? — суммарная стоимость n вставок станет O(n²) + +Почему дальше: если push_back амортизированно O(1) за счёт удвоения, какие структуры данных вообще гарантируют O(1) в среднем на операцию — переходим к таблице сложностей и хеш-таблицам. + ## 2. Таблица сложностей, которую надо знать | Структура | Поиск | Вставка | Удаление | Память | @@ -38,17 +57,44 @@ O-нотация отвечает на вопрос «как растёт вре | Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) | | Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) | +Почему связный список даёт O(1) на вставку/удаление: если узел уже найден (есть указатель), +операция — просто перелинковка соседних указателей без сдвига остальных элементов; O(n) в +поиске появляется отдельно, потому что нет арифметики адреса — только последовательный проход. +Почему у отсортированного массива поиск O(log n), а вставка O(n): бинарный поиск делит +диапазон пополам, но вставка сдвигает все элементы после точки вставки, чтобы сохранить +непрерывность блока памяти. + Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это -частый вопрос), вставка одного элемента — O(log n), взятие максимума — O(1). +частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа +по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание +вверх на высоту кучи), взятие максимума — O(1) (это корень). Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**; heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки -сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя. +сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево +решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n). Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion, bubble, counting. Неустойчивы: quick, heap, selection. +**map vs unordered_map**: `map` — красно-чёрное дерево, инвариант балансировки (чередование +цветов узлов, равное число чёрных узлов на любом пути от корня до листа) держит высоту +порядка log n, отсюда O(log n) на все операции и ключи всегда в отсортированном порядке при +обходе. `unordered_map` в среднем быстрее на чистом поиске/вставке (O(1) и меньше косвенных +переходов по указателям), но не даёт упорядоченного обхода и не гарантирует порядок бакетов +между вызовами rehash. + +**Факты для карточек** +- base | Сложность построения кучи (heapify) из произвольного массива? — O(n), не O(n log n) +- base | Какая сортировка всегда O(n log n) и устойчива? — mergesort +- core | Худший случай quicksort и когда он достигается? — O(n²), на уже отсортированном массиве при плохом выборе опорного +- core | Нижняя граница сортировки сравнениями? — Ω(n log n) +- core | За счёт чего map держит высоту log n? — инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа +- deep | Почему нельзя полагаться на порядок обхода unordered_map? — порядок бакетов не гарантирован и может меняться при rehash + +Почему дальше: таблица говорит, что хеш-таблица в среднем O(1) — дальше разбираем механизм, который это обеспечивает и почему он иногда ломается до O(n). + ## 3. Хеш-таблица: как устроена Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить @@ -57,7 +103,8 @@ bubble, counting. Неустойчивы: quick, heap, selection. Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор загрузки** (сколько занято от общего размера). -Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка. +Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка: при n ключах и m +корзинах коллизии статистически неизбежны уже при n, сравнимом с √m (парадокс дней рождения). Два способа разрешения: @@ -66,17 +113,44 @@ bubble, counting. Неустойчивы: quick, heap, selection. - **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято — ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`, квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`. - Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка - зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone** - (надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять. + Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе + разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить + ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение — + **tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт + дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался + бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит + дальше по цепочке зондирования. **Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7: -чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое -больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево). +чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число +проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически +выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново +по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые +позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит +она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда +**амортизированное O(1)** на вставку, а не просто «в среднем быстро». Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого -деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие -биты (для строк — FNV-1a или `std::hash`). +деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре. +Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при +делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)), +для строк — FNV-1a или `std::hash`. + +**Ловушки** +- Взять ёмкость не степенью двойки при использовании `hash & (cap-1)` → маска отрежет не те биты → часть корзин никогда не используется, видно по неравномерному распределению цепочек. +- Удалять элемент простой очисткой ячейки при открытой адресации вместо tombstone → поиск последующих элементов той же цепочки зондирования обрывается раньше времени → `get` возвращает false для существующего ключа, видно в тесте «insert A, B (коллизия с A), delete A, get B» → false. +- Не проверять load factor перед вставкой → цепочки/пробеги растут неограниченно → поиск деградирует к O(n), видно по профилировщику как рост времени `unordered_map::find` с размером таблицы. +- Пользовательский ключ с плохим/предсказуемым хешем → все элементы в одном бакете → тихая деградация до O(n) без ошибки компиляции, ловится только профилировщиком. + +**Факты для карточек** +- base | Формула фактора загрузки? — load = size / capacity +- base | Порог load factor при открытой адресации, после которого делают rehash? — обычно ≤ 0.7 +- core | Зачем tombstone при открытой адресации? — чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже +- core | Почему ёмкость хеш-таблицы берут степенью двойки? — idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре +- core | Что физически происходит при rehash? — выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap) +- deep | Почему rehash даёт амортизированное O(1), а не O(n) на вставку? — та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n² + +Почему дальше: те же формулы сложности стоит применить к конкретному коду — переходим к разбору задач, где нужно на глаз определить сложность. ## 4. Разбор примера: считаем сложности @@ -109,6 +183,16 @@ bool has_pair_fast(const std::vector& v, int k) { // O(n) в среднем (в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n) за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа. +Механизм ускорения: (б) на каждой паре (i, j) делает сравнение за O(1), но пар — O(n²); +(в) вместо перебора пар один раз кладёт каждый элемент в хеш-множество (O(1) в среднем на +вставку) и один раз проверяет наличие дополнения k - x (O(1) в среднем на поиск) — итого +O(n) вставок и O(n) поисков вместо O(n²) сравнений. + +**Факты для карточек** +- base | Сложность has_pair (вложенный цикл по всем парам)? — O(n²) +- core | Сложность has_pair_fast по времени и по памяти? — O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество + +Почему дальше: чтобы поверить в O(1) на вставку/поиск из примера (в), нужно понять, что внутри unordered_set/unordered_map — переходим к ручной сборке хеш-таблицы. ## 5. Разбор примера: как руками собрать хеш-таблицу @@ -168,11 +252,21 @@ struct HashTable { Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем -**все** узлы, потому что индекс зависит от размера массива. +**все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) — +после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому +пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) — +степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы +тот же результат быстрее, именно потому что cap — степень двойки. Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву до свободной ячейки, а при удалении ставим tombstone. +**Факты для карточек** +- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211 +- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны + +Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок. + ## 6. Что спросят на собеседовании (готовые ответы) - «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n) @@ -184,6 +278,10 @@ struct HashTable { - «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1, но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone. +**Факты для карточек** +- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами +- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда) + ## 7. Материалы (первопартийные) - cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map @@ -198,6 +296,8 @@ struct HashTable { 3. `heapify` из произвольного массива — за сколько? 4. Какая из сортировок устойчива: quick, merge, heap? 5. Зачем tombstone при открытой адресации? +6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме? +7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
Ответы @@ -208,6 +308,11 @@ struct HashTable { 4. merge (устойчива), quick и heap — нет. 5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой ячейки до элемента, который был вставлен за ней. +6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая + операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость + n операций ≈ n, а не n². +7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю — + дешевле для процессора.
## 9. Ссылки на задачи этого дня diff --git a/lessons/D1_linux.md b/lessons/D1_linux.md index b3fed71..359c1ee 100644 --- a/lessons/D1_linux.md +++ b/lessons/D1_linux.md @@ -1,20 +1,30 @@ # D1, часть 2. Процессы: fork, exec, wait, сигналы (урок) -Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения + -открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает -результат, сигнал — асинхронное уведомление. +Модель одна на весь урок: процесс = адресное пространство (`mm_struct` в ядре) + поток +выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов +(`task_struct`). `fork` копирует эту запись и помечает память как copy-on-write, `exec` +заменяет содержимое адресного пространства, оставляя PID и дескрипторы, `wait` забирает у +ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром. ## 1. fork(): что реально происходит `pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное пространство, свой PID, свой поток. Родитель продолжает с того же места. +Механически ядро: (1) выделяет новый `task_struct` и PID; (2) копирует таблицу файловых +дескрипторов — обе записи после `fork` указывают на те же открытые файловые описания в +ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы +данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь +планировщика. Само копирование данных при этом не происходит — см. COW ниже. + Возвращает **дважды** — и это ключ к пониманию: - в родителе — PID ребёнка (> 0); - в ребёнке — 0; - при ошибке — −1 (и `errno`), ребёнок не создан. -Поэтому классический код всегда ветвится: +Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова — +единственный способ понять, в какой копии сейчас исполняется код, это проверить +возвращённое значение. Поэтому классический код всегда ветвится: ```cpp pid_t pid = fork(); @@ -31,13 +41,41 @@ if (pid == 0) { } ``` -**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни страницы, -помеченные «только чтение». При первой записи в страницу ядро делает её копию — только -тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают -в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются -при первой записи**. +**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни физические +страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи +в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую +страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц +только пишущего процесса — только тогда. Поэтому `fork` дешёвый, даже если процесс занимает +гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в +формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются +при первой записи**, а не при самом `fork`. -Совет: `fflush(stdout)` перед `fork`, иначе буфер вывода может продублироваться в ребёнке. +Причина именно такого устройства — частый паттерн «`fork` сразу за которым `exec`»: если бы +ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы +выброшенной, ведь `exec` тут же заменяет содержимое памяти новой программой. + +Совет: `fflush(stdout)` перед `fork`, иначе непустой буфер `stdout` скопируется в ребёнка +вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды. + +**Ловушки** +- Не проверить возвращаемое значение `fork()` и не разветвить логику по нему → родитель и + ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID в + `ps aux`, делающих одну и ту же работу. +- Ребёнок долго не вызывает `exec()`, активно пишет в большие структуры данных → всплеск + реального потребления памяти именно в момент записи (COW-копирование страниц), а не в + момент `fork` → видно по росту RSS в `top`/`ps` уже после fork, а не сразу. +- Вызвать `exit()` вместо `_exit()` в ребёнке после неудачного `exec` → `exit()` сбрасывает + стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может + продублировать ранее не выведенный текст родителя. + +**Факты для карточек** +- base | Что возвращает `fork()` в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 +- core | Что происходит со страницами памяти при `fork()`? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) +- core | Почему `fork` дешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят +- base | Что нужно сделать с `stdout` перед `fork`, если он не пуст? — вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке +- deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ + +Почему дальше: раз память после `fork` временно общая и почти всегда тут же заменяется — что конкретно делает `exec` с этим адресным пространством? ## 2. exec(): замена образа @@ -45,40 +83,122 @@ if (pid == 0) { всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда: при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1. -Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы. +Механизм: `execve` (системный вызов, к которому в итоге сводится всё семейство `exec*`) +загружает исполняемый файл с диска, разбирает его как ELF, строит новый `mm_struct` — +новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство +текущего `task_struct`, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые +до `exec`, остаются открытыми в новой программе **кроме** помеченных флагом `FD_CLOEXEC` — +это то, чем перенаправление ввода-вывода (`dup2` на 0/1/2 перед `exec`) переживает замену +образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет. + +Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы: `fork` даёт +новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы +для редиректа), а `exec` в этом новом процессе подгружает нужную программу, не трогая +родителя. Если вызвать `exec` без предварительного `fork`, текущая программа заменится и не +вернёт управление — например, `exec` внутри shell-скрипта заменяет саму оболочку. + Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`. +**Ловушки** +- Забыть `_exit(127)` (или любой выход) после неудачного `exec*` в ребёнке → код продолжает + исполняться как будто это родительская логика → дублирование родительской работы в + дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска. +- Не поставить `FD_CLOEXEC` на служебный/секретный дескриптор перед `exec` → он утекает в + запущенную внешнюю программу → видно в `/proc//fd` запущенного процесса — там лишний + открытый файл, которого «не должно быть». + +**Факты для карточек** +- base | Чем `exec` отличается от `fork`? — `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового +- core | Что сохраняется у процесса после успешного `exec`? — PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC` +- core | Почему `exec` при успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом +- base | Разница между `execlp`, `execv`, `execvp`? — `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH` + +Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно? + ## 3. wait/waitpid и коды возврата -Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код -возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает -память, но занимает слот в таблице процессов — их накопление плохо. +Завершившийся ребёнок не исчезает: когда он вызывает `exit()`, ядро не удаляет его +`task_struct` немедленно, а сохраняет минимальную запись (PID, код возврата, статистику +использования ресурсов), пока родитель не заберёт её через `wait`/`waitpid`. Такой процесс +называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память данных, но занимает +слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе. +Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме +как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего +сообщить не может. - `wait(&status)` — ждёт любого ребёнка; - `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться. -Разбор `status` делается макросами, а не вручную: +Разбор `status` делается макросами, а не вручную, потому что в одном `int` закодированы +сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах: ```cpp if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status)); ``` +Дополнительно есть `WIFSTOPPED`/`WSTOPSIG` (ребёнок остановлен, например, по `SIGSTOP`) и +`WCOREDUMP(status)` (завершение по сигналу сопровождалось дампом памяти на диск, `core`). + **Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала: - **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer); - **139 = 128 + 11** → SIGSEGV (падение по памяти); - 143 = 128 + 15 → SIGTERM (корректный запрос на завершение). -**Сирота** — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби. +**Сирота** — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1, +либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому +сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но +нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал +`wait`; сирота — родитель умер, но дождаться его теперь придётся init. + +**Ловушки** +- Родитель никогда не вызывает `waitpid` для завершившихся детей → записи зомби копятся → + видно как растущий список `ps aux | grep Z`, в пределе — упор в лимит PID. +- Сравнивать `status` напрямую с кодом возврата вместо `WEXITSTATUS(status)` → в `status` + закодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем, + что вернула программа. +- Долгоживущий процесс с `fork`-воркерами не занимается сбором детей вообще → зомби + накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни + аптайма ростом числа `Z`-процессов. + +**Факты для карточек** +- base | Зачем нужен `waitpid`? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов +- core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait` +- core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) +- base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу +- core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую +- deep | Что показывает `WCOREDUMP(status)`? — что завершение по сигналу сопровождалось записью core-дампа на диск + +Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет? ## 4. Сигналы -Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9, -нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11), -SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился). +Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное +исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя +действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить). +Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя +перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается), +SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe), +SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился). -Обработчик ставится `sigaction` (надёжнее устаревшего `signal`), внутри обработчика можно -менять только `volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe). +Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности +управления системой: администратору и супервизору всегда нужен гарантированный способ +остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик +сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика, +снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM +устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы, +сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают +SIGTERM и ждут; так, `systemctl stop`/`docker stop` по умолчанию ждут несколько секунд +(в systemd таймаут задаётся `TimeoutStopSec`, по умолчанию около 90 секунд) и только затем, +если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные +на диск, и незавершённая операция может остаться в неконсистентном состоянии. + +Обработчик ставится `sigaction` (надёжнее устаревшего `signal` — поведение `signal` +исторически различалось между Unix-системами), внутри обработчика можно менять только +`volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe: `malloc` не +реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур, +что при вызове `malloc`/`printf` из обработчика способно повредить кучу или подвесить +процесс). ```cpp static volatile sig_atomic_t stop = 0; @@ -91,12 +211,35 @@ int main() { } ``` +**Ловушки** +- Вызвать `printf`/`malloc`/`free` внутри обработчика сигнала → не async-signal-safe → в + редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу + ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой. +- Использовать `signal()` вместо `sigaction()` → поведение (сброс обработчика в default + после первого срабатывания, поведение при повторном сигнале) исторически различается + между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой. +- Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик → + `docker stop`/`systemctl stop` в итоге шлют SIGKILL по таймауту → в логах виден резкий + обрыв процесса без финализации (незакрытые файлы, недописанные данные). + +**Факты для карточек** +- base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17 +- core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую +- core | Что можно делать внутри обработчика сигнала? — только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe) +- base | Чем `sigaction` лучше `signal`? — поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику +- deep | Что происходит, если сервис игнорирует SIGTERM при `systemctl stop`? — по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно + +Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше? + ## 5. errno и возвраты системных вызовов Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno` -можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить; -EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже; -EINPROGRESS — неблокирующее соединение в процессе. +можно только сразу после ошибки, до вызова любой другой функции, которая может сама +переписать `errno` (например, `printf` при внутренней ошибке форматирования). EINTR — вызов +прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе, +повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся +в лимит открытых дескрипторов (`ulimit -n`), диагностируется через `lsof -p PID` или +`/proc/PID/fd`. ```cpp ssize_t n = read(fd, buf, sizeof buf); @@ -107,6 +250,20 @@ if (n < 0) { } ``` +**Ловушки** +- Проверить `errno` без предварительной проверки, что вызов вообще вернул ошибку → `errno` + может быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание. +- Вызвать любую функцию (даже `printf`) между системным вызовом и чтением `errno` → + промежуточный вызов может переписать `errno` → в обработчике окажется код чужой ошибки. + +**Факты для карточек** +- base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов +- base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже» +- core | Когда безопасно читать `errno`? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать +- deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся `ulimit -n`) + +Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым? + ## 6. Разбор примера: мини-шелл на 40 строк Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня. @@ -157,11 +314,28 @@ int main() { Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка); `kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»; -несуществующая команда даёт 127. +несуществующая команда даёт 127. Дочерний процесс перед `execvp` уже унаследовал от +родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через `fork`, поэтому вывод запущенной +программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не +требуется перенаправление в файл или pipe. Проверка на утечки и падения — санитайзеры: `g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell` +**Ловушки** +- Не проверять, пуст ли `argv` перед `execvp` → `execvp(nullptr, ...)` на пустой строке → + неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже + предусмотрено проверкой `if (argv.empty()) continue;`, но при рефакторинге легко потерять). +- Забыть `_exit(127)` после неудачного `execvp` → дочерний процесс продолжит исполнять + тело цикла `while` наравне с родителем → двойной ввод команд из одного терминала. + +**Факты для карточек** +- base | Какой код возврата у шелла даст несуществующая команда? — 127 +- base | Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork` +- core | Каким флагом собрать бинарник для проверки на утечки и UB? — `-fsanitize=address,undefined -fno-omit-frame-pointer` + +Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ? + ## 7. Что спросят на собеседовании (готовые ответы) - «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка, @@ -190,6 +364,8 @@ int main() { 3. Что такое зомби и кто его убирает? 4. Откуда код возврата 137 и 139? 5. Почему `exec` не возвращает управление при успехе? +6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет? +7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
Ответы @@ -200,9 +376,13 @@ int main() { если родитель умер). 4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка. 5. Потому что адресное пространство заменено новым образом: старого кода больше нет. +6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`. +7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт + SIGKILL принудительно.
## 10. Задачи дня - `tasks/03_ring` — кольцевой буфер (база для сетевого кода). - Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями). +