Формат v3: уроки D1 (теория+разбор), задачи ступенями, карточки 101 с уровнями и пояснениями

This commit is contained in:
Kodlo-chan
2026-09-23 19:58:23 +07:00
parent 75a10f9999
commit 387f29d376
10 changed files with 1623 additions and 160 deletions
+17 -7
View File
@@ -5,8 +5,10 @@
## Что внутри ## Что внутри
- `WEEK7.md` — **рабочий документ**: интенсив на 7 дней (D0 23.09 → D7 30.09), пересобранный - `WEEK7.md` — **рабочий документ**: интенсив на 7 дней (D0 23.09 → D7 30.09), пересобранный
по результату диагностики (квиз 14/32: алгоритмы 1/8, Linux 3/8, сети 3/8, C/C++ 7/8). по диагностике (квиз 14/32, карточки 11/60). День устроен так:
Каждый день: карточки 30 мин → алгоритмы 3 ч → системное 3 ч → разбор и код 1,5 ч. **урок (теория + разбор) → карточки → ступени задачи → зачёт `grade.py`**.
- `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые
ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них.
- `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы). - `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
- `TRACKER.md` — дневник: что решено, где встал, сколько минут. Это вход для калибровки. - `TRACKER.md` — дневник: что решено, где встал, сколько минут. Это вход для калибровки.
- `diag/` — пакет: - `diag/` — пакет:
@@ -15,8 +17,9 @@
epoll-сервер, bash по логу, разбор падения в gdb; epoll-сервер, bash по логу, разбор падения в gdb;
- `tasks/10..13` — добор под слабые домены: хеш-таблица с открытой адресацией, куча - `tasks/10..13` — добор под слабые домены: хеш-таблица с открытой адресацией, куча
(`heapify` O(n), top-K), Дейкстра, арифметика подсетей; (`heapify` O(n), top-K), Дейкстра, арифметика подсетей;
- `facts_drill.py` + `facts.json` — 60 карточек «числа и факты» (сложности, байты - `facts_drill.py` + `facts.json` — 101 карточка «числа и факты» с пояснениями и
заголовков, `/26 = 62`, TTL, порядок TCP-хендшейка, паддинг структур); уровнями: `base` 41 (начинать отсюда, у каждой есть объяснение), `core` 52,
`deep` 8 (нишевое, в базовый прогон не попадает);
- `grade.py` — автопроверка задач (ASAN/UBSAN, таймауты, PASS/FAIL + полный лог); - `grade.py` — автопроверка задач (ASAN/UBSAN, таймауты, PASS/FAIL + полный лог);
- `questions_export.txt` — вопросы квиза текстом. - `questions_export.txt` — вопросы квиза текстом.
@@ -40,15 +43,22 @@
## Как начать ## Как начать
Порядок первого захода — читать урок, потом карточки базы, потом задачи:
```bash ```bash
git clone https://git.kodlo.art/Kodlo/eduplan-cpp-eltex.git git clone https://git.kodlo.art/Kodlo/eduplan-cpp-eltex.git
cd eduplan-cpp-eltex/diag cd eduplan-cpp-eltex
lessons/D1_algo.md # или открыть в редакторе
cd diag
python3 facts_drill.py --level base --learn # 41 карточка базы, ошибки объясняются
python3 facts_drill.py --level base,core # когда база закрыта (>=80%)
python3 quiz.py # квиз (или --answers "1-3 2-4 ...") python3 quiz.py # квиз (или --answers "1-3 2-4 ...")
python3 grade.py # все 13 задач с санитайзерами python3 grade.py # все 13 задач с санитайзерами
python3 grade.py 10 11 13 # выборочно python3 grade.py 10 11 13 # выборочно
python3 grade.py --fast # без санитайзеров python3 grade.py --fast # без санитайзеров
python3 facts_drill.py # 20 карточек интерактивно python3 facts_drill.py --level base --learn # база с пояснениями
python3 facts_drill.py --all # все 60 python3 facts_drill.py --level all --all # все 101 карточка
python3 facts_drill.py --list --level base # вывести вопросы с ответами и пояснениями
``` ```
Нужны `g++` (C++17), `gcc`, `python3`; для `07_epoll` — Linux-сокеты, для `08_bash` — Нужны `g++` (C++17), `gcc`, `python3`; для `07_epoll` — Linux-сокеты, для `08_bash` —
+44 -27
View File
@@ -1,6 +1,7 @@
# ИУП — интенсив 7 дней (фуллтайм, собеседование ≤ 1 неделя) # ИУП — интенсив 7 дней (фуллтайм, собеседование ≤ 1 неделя)
**Версия 2, 23.09** — пересобрана по результату диагностики (квиз 14/32). **Версия 3, 23.09** — пересобрана по диагностике (квиз 14/32, карточки 11/60) и правке
формата: сначала урок с теорией, потом задачи ступенями.
Срок: собеседование максимум через неделю, подготовка фуллтайм (~8 ч/день). Срок: собеседование максимум через неделю, подготовка фуллтайм (~8 ч/день).
Цель: закрыть ровно то, что спросят на входе в Eltex (C/C++, базовые алгоритмы и структуры Цель: закрыть ровно то, что спросят на входе в Eltex (C/C++, базовые алгоритмы и структуры
данных, Linux, L2/L3-сети, инструменты), и не утонуть в глубине. данных, Linux, L2/L3-сети, инструменты), и не утонуть в глубине.
@@ -19,10 +20,24 @@
Поэтому в плане появился ежедневный блок карточек (`facts_drill.py`), а C++ урезан: Поэтому в плане появился ежедневный блок карточек (`facts_drill.py`), а C++ урезан:
за него отвечает сильная сторона, забирать у неё часы смысла нет. за него отвечает сильная сторона, забирать у неё часы смысла нет.
## Правила интенсива ## Как устроен день (v3, после правки 23.09)
1. Каждый день — три блока: **карточки (30 мин) → алгоритмы (3 ч) → системное (3 ч) → Первая версия была проверкой — а проверять пока нечего. Теперь каждый день начинается
разбор и код (1,5 ч)**. Блок не начинается, пока не закрыта вчерашняя точка. с материала:
1. **Урок (30–40 мин чтения).** `lessons/D<день>_*.md` — теория по-русски, разобранные
примеры с кодом, готовые ответы на вопросы собеседования, ссылки на первопартийные
материалы. Ничего не решаешь, просто читаешь и запускаешь примеры.
2. **Карточки (20 мин).** `python3 facts_drill.py --level base` — начинать с базы;
в режиме `--learn` каждая ошибка объясняется. База закрыта (≥ 80%) — переходишь на
уровень `base,core` (по умолчанию), потом `all`.
3. **Ступени в задаче.** Каждая задача разбита на шаги по 10–30 минут, после каждого шага
прогон. Не «напиши хеш-таблицу», а «сначала хеш-функция, проверь, что 16/32/48 дают
разные индексы».
4. **Зачёт.** `python3 grade.py <номер>` — критерий `PASS`. Это конец дня, а не начало.
Итого: **урок → карточки → ступени → зачёт**. Задача без урока впереди — моя ошибка,
пиши, и я допишу материал.
2. День закрыт, только если: решены задачи дня **без подсказки**, написан/починен код дня, 2. День закрыт, только если: решены задачи дня **без подсказки**, написан/починен код дня,
сделана **одна задача вслух на таймер** (10 мин: разбор + код + сложность), заполнен сделана **одна задача вслух на таймер** (10 мин: разбор + код + сложность), заполнен
трекер. трекер.
@@ -51,36 +66,38 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
`priority_queue`, арифметика подсетей. `priority_queue`, арифметика подсетей.
- `python3 grade.py` — все 13 с автопроверкой (ASAN/UBSAN); `python3 grade.py 10 13` — - `python3 grade.py` — все 13 с автопроверкой (ASAN/UBSAN); `python3 grade.py 10 13` —
выборочно; `--fast` — без санитайзеров. выборочно; `--fast` — без санитайзеров.
- `python3 facts_drill.py` — 60 карточек «числа и факты» (algo 20, linux 14, net 14, - `python3 facts_drill.py --level base` — 41 карточка базы с пояснениями (начинать отсюда);
c_cpp 12). Маркеры: `>=80%` ок, `50–79%` добор, `<50%` дыра. `--level base,core` — 93; `--level all` — 101. Уровень `deep` (8 карточек, нишевое:
TID в /proc, ulimit, ICMP Time Exceeded и подобное) в базовый прогон не попадает.
Маркеры итога: `>=80%` ок, `50–79%` добор, `<50%` дыра.
- `lessons/` — уроки по дням: теория, разборы с кодом, ответы на вопросы собеседования.
- Трекер дня: `TRACKER.md` в корне репозитория. - Трекер дня: `TRACKER.md` в корне репозитория.
--- ---
## D0 — 23.09, диагностика (квиз пройден, 14/32) ## D0 — 23.09, вход: база и знакомство
- Квиз: **пройден** — 14/32, слабейшие домены: алгоритмы, Linux, сети (см. таблицу выше). - Квиз **14/32**, карточки **11/60** — значит базы пока нет, и задачи уровня «напиши
- Осталось: `cd diag && python3 grade.py 01 02 03 04 05 06 07 08 09` — 9 практических задач. хеш-таблицу» решать рано. Это нормальный вход, просто порядок другой: сначала уроки.
- Разбор промахов квиза вместе со мной, калибровка дней (уже сделана в этой версии плана). - Сегодня: `python3 facts_drill.py --level base --learn` — 41 карточка базы, каждая ошибка
- **Точка:** есть `results_quiz.json` и `results_tasks.json`, названы 3 слабейших темы. объясняется. Это 20–30 минут и даёт словарь, без которого дальше нечего делать.
- Задачи 01–13 сегодня решать не нужно. Смотрим на них как на цель недели, а не как на зачёт.
- **Точка:** пройдены базовые карточки, записаны 3 темы, которые «совсем не помню» —
по ним я усилю соответствующие дни.
## D1 — 24.09: инварианты сложности + хеш-таблицы | fork/COW/сигналы ## D1 — 24.09: сложность и хеш-таблицы | процессы и сигналы
- Карточки (30 мин) — домен `algo`: бинарный поиск в 1e6, хеш O(1)/worst O(n), heapify O(n),
устойчивость сортировок, Дейкстра и условие применимости.
- Утро, алгоритмы (3 ч) — **формальные свойства, а не «умение решать»**: O-оценки и
амортизация, хеш-таблицы (цепочки vs открытая адресация, загрузка, tombstone, worst O(n)),
сортировки и устойчивость, бинарный поиск и `lower_bound` руками.
Практика: **`tasks/10_hash`** — своя таблица на линейном зондировании; 6–8 задач
Codeforces по тегам `hashing` и `sortings`, рейтинг 800–1400.
- День, системное (3 ч) — Linux: `fork/exec/wait`, **COW-страницы**, зомби и `waitpid`,
сигналы и коды возврата (137 = 128+9 SIGKILL, 139 = 128+11 SIGSEGV), `errno`.
Практика: мини-шелл (fork + exec + wait + `exit` + пайплайн по желанию) — прямой вопрос
на собеседовании; прогнать под ASAN.
- Разбор и код (1,5 ч) — `tasks/01_bits` и `tasks/03_ring` **на чистом C** (без C++-обёрток),
сборка `-Wall -Wextra -Werror -fsanitize=address,undefined`; одна задача вслух; трекер.
- **Точка:** 10_hash PASS; мини-шелл запускает `ls`/`cat`; ASAN чистый; карточки `algo` ≥ 80%.
- **Урок:** `lessons/D1_algo.md` (сложность, таблица структур, устройство хеш-таблиц,
разбор кода) и `lessons/D1_linux.md` (fork/exec/wait, COW, сигналы, разбор мини-шелла).
Читать 40 минут, примеры из уроков запустить руками — они рабочие.
- **Карточки:** `python3 facts_drill.py --level base` (домен `algo` — обязателен, остальное
по времени). Если база даётся легко — `--level base,core`.
- **Задача дня:** `tasks/10_hash` — но по ступеням из её `task.md` (Глава IV), по 10–30 минут
на шаг, после каждого шага `python3 grade.py 10`. Ступень 1 — это хеш-функция и проверка,
что 16/32/48 дают разные индексы, и только шестая ступень — перехеширование.
- **Разминка:** `tasks/01_bits` (20 минут, четыре функции) и `tasks/03_ring` (40–60 минут,
тоже по ступеням).
- **Зачёт дня:** `PASS` по `01`, `03`, `10`; карточки `base` ≥ 80%.
## D2 — 25.09: деревья и куча | epoll и неблокирующие сокеты ## D2 — 25.09: деревья и куча | epoll и неблокирующие сокеты
- Карточки (30 мин) — `algo`: heap insert O(log n), top-K, обходы деревьев, сложность DFS - Карточки (30 мин) — `algo`: heap insert O(log n), top-K, обходы деревьев, сложность DFS
+1 -3
View File
@@ -54,9 +54,7 @@ python3 facts_drill.py --domain algo # только алгоритмы
python3 facts_drill.py --answers "20|62|ttl|..." # проверить строку ответов разом python3 facts_drill.py --answers "20|62|ttl|..." # проверить строку ответов разом
``` ```
Порядок: сначала квиз, потом задачи с 01 по 09 (диагностика), затем 10–13 как добор. Порядок: сначала квиз, потом задачи с 01 по 09 (диагностика), затем 10–13 как добор. Зависание = FAIL (есть таймауты).
Требования: Linux/WSL (или MinGW-w64 для задач 01–06, 10–13). Задачи 07/08/09 под
Windows-native пропускаются с пометкой `ПРОПУСК` — это не провал, гоняй их в WSL. Зависание = FAIL (есть таймауты).
Правила честности: без встроенных `popcount`/`reverse`-хелперов, без копирования чужих Правила честности: без встроенных `popcount`/`reverse`-хелперов, без копирования чужих
решений, `answer.txt` в задаче 09 — обязательная часть (оценивается ход разбора). решений, `answer.txt` в задаче 09 — обязательная часть (оценивается ход разбора).
+741 -62
View File
File diff suppressed because it is too large Load Diff
+85 -27
View File
@@ -1,14 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Тренажёр «числа и факты» — добор по промахам квиза (22.09: algo 1/8, linux 3/8, net 3/8). """Тренажёр «числа и факты» — карточки под промахи диагностики.
Уровни карточек:
base — база, с этого начинать (объяснение есть в поле why)
core — то, что почти наверняка спросят
deep — нишевое, в базовый прогон не попадает
Использование (из каталога diag): Использование (из каталога diag):
python3 facts_drill.py # интерактивно, 20 случайных карточек python3 facts_drill.py # 20 карточек уровня base+core, интерактивно
python3 facts_drill.py --count 60 --all # все 60 python3 facts_drill.py --level base # только база (первый заход — отсюда)
python3 facts_drill.py --domain algo # только алгоритмы python3 facts_drill.py --level all --all # все 101
python3 facts_drill.py --list # вывести все вопросы с ответами (читать) python3 facts_drill.py --domain algo --level base
python3 facts_drill.py --learn # после каждого ответа показать пояснение
python3 facts_drill.py --list --level base # вывести вопросы с ответами и пояснениями
python3 facts_drill.py --answers "20|62|ttl|..." # проверить строку ответов python3 facts_drill.py --answers "20|62|ttl|..." # проверить строку ответов
python3 facts_drill.py --answers "..." --count 60 --seed 42 # тот же порядок, что в прогоне python3 facts_drill.py --answers "..." --level all --all --seed 42
python3 facts_drill.py --selftest # проверить сам набор карточек python3 facts_drill.py --selftest
Ответы сравниваются нестрого: регистр, «ё», тире и лишние пробелы не важны. Ответы сравниваются нестрого: регистр, «ё», тире и лишние пробелы не важны.
Результат пишется в results_facts.json. Результат пишется в results_facts.json.
@@ -22,6 +29,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
FACTS_PATH = os.path.join(HERE, "facts.json") FACTS_PATH = os.path.join(HERE, "facts.json")
LEVELS = ("base", "core", "deep")
def norm(s: str) -> str: def norm(s: str) -> str:
@@ -43,20 +51,35 @@ def check(fact, answer: str) -> bool:
return any(a == norm(x) for x in fact.get("accept", [])) return any(a == norm(x) for x in fact.get("accept", []))
def hint(fact) -> str:
"""Пояснение и ссылка, куда смотреть."""
parts = []
if fact.get("why"):
parts.append(fact["why"])
if fact.get("ref"):
parts.append("см. " + fact["ref"])
return " · ".join(parts)
def selftest(facts) -> int: def selftest(facts) -> int:
problems = [] problems = []
for i, f in enumerate(facts, 1): for i, f in enumerate(facts, 1):
for key in ("domain", "q", "a", "accept"): for key in ("domain", "q", "a", "accept", "level"):
if not f.get(key): if not f.get(key):
problems.append(f"карточка {i}: пустое поле {key}") problems.append(f"карточка {i}: пустое поле {key}")
if f.get("level") not in LEVELS:
problems.append(f"карточка {i}: неизвестный уровень {f.get('level')!r}")
if f.get("accept") and not any(norm(x) for x in f["accept"]): if f.get("accept") and not any(norm(x) for x in f["accept"]):
problems.append(f"карточка {i}: варианты ответа нормализуются в пустоту") problems.append(f"карточка {i}: варианты ответа нормализуются в пустоту")
if not check(f, f["accept"][0]): if not check(f, f["accept"][0]):
problems.append(f"карточка {i}: канонический ответ не проходит проверку") problems.append(f"карточка {i}: канонический ответ не проходит проверку")
doms = {} if not (f.get("why") or f.get("ref")):
problems.append(f"карточка {i}: нет ни пояснения, ни ссылки — карточка не учит")
doms, lvls = {}, {}
for f in facts: for f in facts:
doms[f["domain"]] = doms.get(f["domain"], 0) + 1 doms[f["domain"]] = doms.get(f["domain"], 0) + 1
print(f"карточек: {len(facts)}; по доменам: {doms}") lvls[f["level"]] = lvls.get(f["level"], 0) + 1
print(f"карточек: {len(facts)}; по доменам: {doms}; по уровням: {lvls}")
if problems: if problems:
print("ПРОБЛЕМЫ:") print("ПРОБЛЕМЫ:")
for p in problems: for p in problems:
@@ -70,9 +93,11 @@ def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--answers", default=None, help="ответы через | (тот же порядок, что в интерактиве)") ap.add_argument("--answers", default=None, help="ответы через | (тот же порядок, что в интерактиве)")
ap.add_argument("--count", type=int, default=20) ap.add_argument("--count", type=int, default=20)
ap.add_argument("--all", action="store_true", help="взять все карточки") ap.add_argument("--all", action="store_true", help="взять все карточки после фильтров")
ap.add_argument("--domain", default=None, choices=["algo", "linux", "net", "c_cpp"]) ap.add_argument("--domain", default=None, choices=["algo", "linux", "net", "c_cpp"])
ap.add_argument("--level", default="base,core", help="base,core,deep или all (по умолчанию base,core)")
ap.add_argument("--seed", type=int, default=42) ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--learn", action="store_true", help="показывать пояснение после каждого ответа")
ap.add_argument("--list", action="store_true") ap.add_argument("--list", action="store_true")
ap.add_argument("--selftest", action="store_true") ap.add_argument("--selftest", action="store_true")
args = ap.parse_args() args = ap.parse_args()
@@ -80,6 +105,16 @@ def main():
facts = load() facts = load()
if args.selftest: if args.selftest:
sys.exit(selftest(facts)) sys.exit(selftest(facts))
if args.level.strip().lower() == "all":
want = set(LEVELS)
else:
want = {x.strip() for x in args.level.split(",") if x.strip()}
bad = want - set(LEVELS)
if bad:
print(f"неизвестный уровень: {', '.join(sorted(bad))} (есть base, core, deep, all)")
sys.exit(2)
facts = [f for f in facts if f["level"] in want]
if args.domain: if args.domain:
facts = [f for f in facts if f["domain"] == args.domain] facts = [f for f in facts if f["domain"] == args.domain]
if not facts: if not facts:
@@ -88,9 +123,12 @@ def main():
if args.list: if args.list:
for i, f in enumerate(facts, 1): for i, f in enumerate(facts, 1):
print(f"{i:2d}. [{f['domain']}] {f['q']}") print(f"{i:3d}. [{f['domain']}/{f['level']}] {f['q']}")
print(f" -> {f['a']}") print(f" -> {f['a']}")
print(f"\nвсего: {len(facts)} (полный ответ в facts.json: {FACTS_PATH})") h = hint(f)
if h:
print(f" {h}")
print(f"\nвсего: {len(facts)} (полный набор: {FACTS_PATH})")
sys.exit(0) sys.exit(0)
rng = random.Random(args.seed) rng = random.Random(args.seed)
@@ -105,12 +143,22 @@ def main():
print(f"ответов меньше, чем карточек: {len(given)} < {len(picked)} (проверяю сколько есть)") print(f"ответов меньше, чем карточек: {len(given)} < {len(picked)} (проверяю сколько есть)")
for i, f in enumerate(picked): for i, f in enumerate(picked):
ans = given[i] if i < len(given) else "" ans = given[i] if i < len(given) else ""
results.append({"n": i + 1, "domain": f["domain"], "q": f["q"], "answer": ans, results.append({"n": i + 1, "domain": f["domain"], "level": f["level"], "q": f["q"],
"ok": check(f, ans), "correct": f["a"]}) "answer": ans, "ok": check(f, ans), "correct": f["a"], "hint": hint(f)})
if args.learn:
for r in results:
if not r["ok"]:
print(f"#{r['n']} [{r['domain']}] {r['q']}")
print(f" твой ответ: {r['answer'] or '(пусто)'}")
print(f" верно: {r['correct']}")
if r["hint"]:
print(f" {r['hint']}")
print()
else: else:
print(f"Карточек в прогоне: {len(picked)}. Пустой ввод = не знаю, 'q' = выход.\n") print(f"Карточек в прогоне: {len(picked)} (уровни: {', '.join(sorted(want))}).")
print("Пустой ввод = не знаю, 'q' = выход.\n")
for i, f in enumerate(picked, 1): for i, f in enumerate(picked, 1):
print(f"[{i}/{len(picked)}] ({f['domain']}) {f['q']}") print(f"[{i}/{len(picked)}] ({f['domain']}/{f['level']}) {f['q']}")
try: try:
ans = input(" > ").strip() ans = input(" > ").strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
@@ -119,23 +167,33 @@ def main():
if ans.lower() in ("q", "quit", "выход"): if ans.lower() in ("q", "quit", "выход"):
break break
ok = check(f, ans) ok = check(f, ans)
print(" ok" if ok else " нет. верно: " + f["a"]) if ok:
results.append({"n": len(results) + 1, "domain": f["domain"], "q": f["q"], print(" ok" + (" — " + hint(f) if args.learn and hint(f) else ""))
"answer": ans, "ok": ok, "correct": f["a"]}) else:
print(" нет. верно: " + f["a"])
h = hint(f)
if h:
print(" " + h)
results.append({"n": len(results) + 1, "domain": f["domain"], "level": f["level"],
"q": f["q"], "answer": ans, "ok": ok, "correct": f["a"], "hint": hint(f)})
ok = sum(1 for r in results if r["ok"]) ok = sum(1 for r in results if r["ok"])
total = len(results) total = len(results)
per = {} per_dom, per_lvl = {}, {}
for r in results: for r in results:
d = per.setdefault(r["domain"], {"total": 0, "ok": 0}) for bucket, key in ((per_dom, r["domain"]), (per_lvl, r["level"])):
d["total"] += 1 v = bucket.setdefault(key, {"total": 0, "ok": 0})
d["ok"] += 1 if r["ok"] else 0 v["total"] += 1
v["ok"] += 1 if r["ok"] else 0
with open(os.path.join(HERE, "results_facts.json"), "w", encoding="utf-8") as fh: with open(os.path.join(HERE, "results_facts.json"), "w", encoding="utf-8") as fh:
json.dump({"seed": args.seed, "correct": ok, "total": total, json.dump({"seed": args.seed, "level": args.level, "correct": ok, "total": total,
"per_domain": per, "details": results}, fh, ensure_ascii=False, indent=2) "per_domain": per_dom, "per_level": per_lvl, "details": results},
fh, ensure_ascii=False, indent=2)
print(f"\n=== ИТОГ КАРТОЧЕК: {ok}/{total} ===") print(f"\n=== ИТОГ КАРТОЧЕК: {ok}/{total} ===")
for d, v in sorted(per.items()): for title, bucket in (("по доменам", per_dom), ("по уровням", per_lvl)):
print(f" {title}:")
for d, v in sorted(bucket.items()):
share = 100 * v["ok"] / v["total"] if v["total"] else 0 share = 100 * v["ok"] / v["total"] if v["total"] else 0
mark = "ок" if share >= 80 else ("добор" if share >= 50 else "дыра") mark = "ок" if share >= 80 else ("добор" if share >= 50 else "дыра")
print(f" {d:8s} {v['ok']}/{v['total']} ({share:.0f}%) — {mark}") print(f" {d:8s} {v['ok']}/{v['total']} ({share:.0f}%) — {mark}")
+86 -8
View File
@@ -1,8 +1,30 @@
# Задача 01 — битовые операции (C) # Задача 01 — битовые операции (разминка)
Реализуй в `solution.cpp` четыре функции. Запрещено пользоваться встроенными > Первая задача дня: руки размялись, потом основная. Ориентир — 20–30 минут.
`__builtin_popcount`, `std::popcount`, `std::byteswap` — считается любая корректная
ручная реализация. ## Глава I. Общая информация
- **Цель:** уверенно работать с битами — это ежедневный инструмент в embedded, сетях и
протоколах.
- **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы
вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`.
## Глава II. Что нужно знать до старта
Четыре инструмента, которых достаточно:
1. `x & (x - 1)` — гасит самый младший единичный бит. Отсюда классика: число единиц можно
считать циклом, пока `x` не станет нулём.
2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо.
3. `x & (1u << k)` — проверка k-го бита.
4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного
числа. Комбинация «сдвинул и сложил» даёт любой порядок байт.
## Глава III. Задание
Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`,
`std::byteswap` использовать нельзя — нужна своя реализация (любая корректная).
```c ```c
int popcount32(uint32_t x); // число единичных бит int popcount32(uint32_t x); // число единичных бит
@@ -11,8 +33,64 @@ bool is_power_of_two(uint32_t x); // x — степень двойки (0
uint32_t bswap32(uint32_t x); // поменять порядок байт местами uint32_t bswap32(uint32_t x); // поменять порядок байт местами
``` ```
Проверка: `python3 grade.py 01` из каталога `diag/`. Полезно сразу проверять себя руками: `bswap32(0x11223344) == 0x44332211`,
Критерий: все проверки `ok`, сборка без предупреждений, ASAN/UBSAN чистые. `popcount32(0b1011) == 3`, `is_power_of_two(1) == true`, `is_power_of_two(0) == false`,
`reverse_bits32(1) == 0x80000000`.
Подсказки по разбору после сдачи: `x & (x - 1)`; реверс — классический цикл на 32 итерации ## Глава IV. Ступени
или пара «маска+сдвиг»; `bswap` — четыре маски.
**Ступень 1 (5 минут).** `is_power_of_two` — самая простая. Подумай, чем степень двойки
отличается от остальных чисел: у неё ровно один единичный бит. Значит подойдёт приём
`x & (x - 1)`. Отдельно обработай `0` (он не степень двойки) и проверь на `1` и на `0x80000000`.
**Ступень 2 (10 минут).** `popcount32` — цикл: пока `x != 0`, гаси младший бит и считай
итерации. Альтернатива — 32 сдвига с проверкой младшего бита; оба варианта верные, первый
в среднем быстрее.
**Ступень 3 (10 минут).** `reverse_bits32` — цикл на 32 итерации: берём младший бит `x`,
ставим его в результат на позицию `31 - i`, сдвигаем `x`. Внимание на типы: сдвиг `1u << 31`
для знакового `int` — UB, поэтому работай в `uint32_t`.
**Ступень 4 (10 минут).** `bswap32` — четыре байта. Самый наглядный вариант: маски и сдвиги,
`((x & 0x000000FFu) << 24) | ((x & 0x0000FF00u) << 8) | ((x & 0x00FF0000u) >> 8) | ((x & 0xFF000000u) >> 24)`.
**Ступень 5.** `python3 grade.py 01` — пока не станет `PASS`.
## Глава V. Критерии приёмки
`PASS` от `grade.py 01`: все проверки `ok`, сборка без предупреждений, ASAN/UBSAN чистые.
## Глава VI. Подсказки (после первой попытки)
<details>
<summary>UBSAN жалуется на сдвиг</summary>
Сдвиг знакового числа в старший бит — неопределённое поведение. Все промежуточные значения
держи в `uint32_t` и пиши константы с суффиксом `u`: `1u << 31`.
</details>
<details>
<summary>is_power_of_two возвращает true на нуле</summary>
У нуля нет единичных битов, поэтому `x & (x-1)` даёт 0 — как и у степеней двойки. Добавь
явную проверку `x != 0`.
</details>
<details>
<summary>reverse_bits32 работает «почти»</summary>
Проверь порядок операций в цикле: сначала ставим бит в результат, потом сдвигаем источник.
Если перепутать — биты уедут на одну позицию.
</details>
## Глава VII. Частые ошибки
- Сдвиги в `int` вместо `uint32_t` → UB, UBSAN ругается.
- Забыт ноль в `is_power_of_two`.
- В `bswap32` перепутаны направления сдвигов (влево/вправо) — проверь на `0x11223344`.
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому.
## После сдачи
Разбор: почему `x & (x-1)` гасит младший бит; как эти приёмы используются в реальных
протоколах (маски подсетей, флаги TCP-заголовка, разбор полей IPv4).
+85 -6
View File
@@ -1,6 +1,26 @@
# Задача 03 — кольцевой буфер (C++) # Задача 03 — кольцевой буфер (C++)
Кольцевой буфер фиксированной ёмкости — базовая структура для embedded и сетевого кода. > Разминка уровня «основная»: структура маленькая, но встречается везде. Ориентир — 40–60 минут.
## Глава I. Общая информация
- **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов.
- **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками —
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
собеседовании.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`.
## Глава II. Что нужно знать до старта
Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда
читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`.
Сдвигать элементы не нужно никогда — в этом весь смысл.
Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от
полного, если оба индекса совпали?** Варианты: хранить счётчик `size`, либо оставлять одну
ячейку свободной. В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
## Глава III. Задание
```c++ ```c++
class RingBuffer { class RingBuffer {
@@ -17,9 +37,68 @@ public:
``` ```
Требования: Требования:
- O(1) на push/pop, никаких сдвигов элементов;
- память выделяется один раз в конструкторе, освобождается в деструкторе;
- поведение после заворачивания индексов (wrap-around) корректное;
- копирование запрещать не обязательно, но двойного освобождения быть не должно.
Проверка: `python3 grade.py 03`. Критерий: все `ok`, ASAN/UBSAN чистые. - O(1) на `push`/`pop`, никаких сдвигов элементов;
- память выделяется один раз в конструкторе и освобождается в деструкторе;
- корректное поведение после заворачивания индексов (записали до конца, продолжили с начала);
- двойного освобождения быть не должно.
## Глава IV. Ступени
**Ступень 1 (10 минут).** Поля: указатель на массив, `capacity_`, `size_`, `head_`, `tail_`.
Конструктор выделяет массив и обнуляет поля. Деструктор освобождает. Если хочешь короче —
`std::vector<int>` вместо ручного `new[]`, тогда деструктор не нужен вовсе.
**Ступень 2 (10 минут).** `empty()` — это `size_ == 0`, `full()` — `size_ == capacity_`.
Проверь на буфере ёмкости 1: после одного `push` он полон, после одного `pop` пуст.
**Ступень 3 (15 минут).** `push`: если полон — вернуть `false` и ничего не менять; иначе
записать в `head_`, сдвинуть `head_ = (head_ + 1) % capacity_`, увеличить `size_`, вернуть `true`.
**Ступень 4 (15 минут).** `pop`: если пуст — `false`; иначе отдать `buffer_[tail_]`, сдвинуть
`tail_`, уменьшить `size_`, вернуть `true`.
**Ступень 5.** Проверь руками wrap-around: ёмкость 4, положи 4 элемента, сними 2, положи
ещё 3 — все значения должны идти в правильном порядке. Затем `python3 grade.py 03`.
## Глава V. Критерии приёмки
`PASS` от `grade.py 03`: сборка без предупреждений, ASAN/UBSAN чистые, все проверки `ok`,
включая порядок FIFO и корректный wrap-around.
## Глава VI. Подсказки (после первой попытки)
<details>
<summary>Значения после заворота идут не в том порядке</summary>
Проверь, что `pop` читает именно `tail_`, а `push` пишет именно в `head_`, и что оба индекса
инкрементируются **после** операции. Классическая ошибка — сдвинуть `head_` до записи.
</details>
<details>
<summary>Перезапись при полном буфере</summary>
Ты не проверяешь `full()` в `push`. По условию перезапись запрещена: полный буфер возвращает
`false` и не портит старые данные.
</details>
<details>
<summary>ASAN: утечка или двойное освобождение</summary>
Если память выделена через `new[]`, освобождать нужно `delete[]` (не `delete`). Проще взять
`std::vector<int>` — тогда вопрос исчезает.
</details>
## Глава VII. Частые ошибки
- Сдвиг элементов вместо индексов (теряется весь смысл O(1)).
- Путаница «пусто/полно» при совпавших индексах.
- `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`).
- Копирование объекта без правила трёх → двойное освобождение. Если сдаёшь с ручным `new[]`,
запрети копирование (`= delete`).
## После сдачи
Разбор: как устроены буферы в драйверах (ring buffer с head/tail для DMA), почему в
многопоточном варианте нужны атомарные индексы и `memory_order`.
+134 -15
View File
@@ -1,8 +1,34 @@
# Задача 10 — хеш-таблица с открытой адресацией (C++) # Задача 10 — хеш-таблица с открытой адресацией
Свой контейнер, без `std::unordered_map`. Разрешение коллизий — **линейное зондирование** > Перед этой задачей прочитай урок `lessons/D1_algo.md` (разделы 3 и 5) — там разобран
(linear probing), ёмкость — степень двойки, при загрузке > 0.7 таблица **перехешируется** > принцип и есть рабочий образец с цепочками. Здесь то же самое, но сложнее: коллизии
вдвое. Индексация — через маску `idx & (capacity - 1)`, а не `%`. > разрешаются **внутри массива**.
## Глава I. Общая информация
- **Цель:** научиться писать контейнер, который используется в реальном коде (кэши,
таблицы маршрутизации, счётчики в логах) и который спрашивают на собеседовании в формате
«а сам сможешь?».
- **Почему это в Eltex:** таблицы MAC-адресов, FDB коммутатора, кэши сессий — всё это
хеш-таблицы с открытой адресацией и жёсткими требованиями к памяти.
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`.
## Глава II. Что нужно знать до старта
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).
## Глава III. Задание
Свой контейнер, `std::unordered_map` использовать нельзя. Интерфейс ровно такой:
```c++ ```c++
class HashTable { class HashTable {
@@ -17,16 +43,109 @@ public:
``` ```
Требования: Требования:
- `put` 100 000 ключей — суммарно быстрее 2 секунд (в среднем O(1));
- ключи с совпадающими младшими битами (например, все кратные 16) обязаны находиться —
это проверка зондирования, а не «повезло с хешем»;
- после 100 000 вставок `capacity()` растёт (степень двойки, загрузка ≤ 0.7), а `size()`
честно считает элементы, включая обновления существующих ключей;
- удаление должно работать: после `erase` ключ не находится, а поиск другого ключа,
стоявшего за ним в цепочке зондирования, по-прежнему работает.
Проверка: `python3 grade.py 10`. Критерий: все `ok`, сборка без предупреждений, - **ключи с совпадающими младшими битами обязаны находиться** — тест вставляет 512 ключей,
ASAN/UBSAN чистые. кратных 16, то есть все они попадают в одну-две стартовые ячейки. Это проверка
зондирования, а не «повезло с хешем»;
- `put` 100 000 ключей суммарно быстрее 2 секунд;
- после вставок `capacity()` растёт (степень двойки), загрузка остаётся ≤ 0.7;
- `size()` честно считает элементы: повторный `put` того же ключа **не** увеличивает `size`;
- `erase` работает: после удаления ключ не находится, но поиск другого ключа, стоявшего
за ним в цепочке зондирования, по-прежнему работает;
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
и вставки возвращается к правильному значению.
Разбор после сдачи: почему ёмкость — степень двойки; чем линейное зондирование лучше ## Глава IV. Ступени (делай по одной, после каждой — прогон)
цепочек по кэшу и хуже по кластеризации; как tombstone-метки спасают поиск после удаления.
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
порядок работы инженера.
**Ступень 1. Хеш-функция и индекс (20 минут).**
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
`h = (uint64_t)key * 2654435761u;` (это золотое сечение, классика).
Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
свалятся в одну ячейку.
**Ступень 2. Массивы и конструктор (15 минут).**
Нужны три вещи: массив значений, массив признаков состояния ячейки и счётчик размера.
Признаки: `EMPTY`, `OCCUPIED`, `TOMBSTONE`. Ёмкость в конструкторе приводи к степени двойки
(16 по умолчанию) — тест ожидает `capacity() >= 16` и степень двойки.
После этого прогон должен уже собираться, а часть проверок на пустой таблице — проходить.
**Ступень 3. Вставка без перехеширования (30 минут).**
Идёшь по ячейкам от `index_of(key)`, пока не найдёшь: свой ключ (обновить значение),
`EMPTY` или `TOMBSTONE` (вставить). **Важно:** если ключ найден — не увеличивай `size()`.
Tombstone можно переиспользовать под вставку, но тогда его признак меняется на `OCCUPIED`.
**Ступень 4. Поиск (15 минут).**
Идёшь так же, но: свой ключ — нашли; `EMPTY` — стоп, ключа нет; `TOMBSTONE` — идём дальше
(здесь легко ошибиться и вернуть `false` раньше времени).
**Ступень 5. Удаление через tombstone (20 минут).**
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
**Ступень 6. Перехеширование (30 минут).**
Когда `size * 10 > capacity * 7`, создай массив вдвое больше и **заново вставь все занятые
ключи** (tombstone не переносим — в новой таблице пусто). Учти: после этого `size` не должен
измениться, а пробеги станут короче. Прогон: `python3 grade.py 10`.
**Ступень 7. Прогон под санитайзерами (10 минут).**
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
что деструктор освобождает ровно то, что выделил конструктор (или не выделяй вручную вовсе,
а используй `std::vector` — так короче и безопаснее).
## Глава V. Критерии приёмки
Задача сдана, когда `python3 grade.py 10` печатает `PASS` — это значит: сборка без
предупреждений, ASAN/UBSAN чистые, все проверки `ok`, включая 512 ключей, кратных 16,
перехеширование, удаление из середины цепочки и повторные вставки.
## Глава VI. Подсказки (открывать после первой честной попытки)
<details>
<summary>Не находит ключи, кратные 16</summary>
Ты используешь `key & (cap-1)` без перемешивания. Ключи 16, 32, 48, … в маске дают 0 —
все в одну ячейку, пробег растёт, а при большом числе ключей ты упираешься в «таблица
полна». Лечится хеш-функцией: умножить ключ на большую нечётную константу перед маской.
</details>
<details>
<summary>size() растёт от повторных put</summary>
Сначала ищи существующий ключ. Нашёл — обнови значение и выйди, `size++` не делай.
Увеличивай счётчик только когда записал в пустую или tombstone-ячейку.
</details>
<details>
<summary>После erase пропадают соседние ключи</summary>
Ты ставишь `EMPTY` вместо `TOMBSTONE`, и поиск обрывается на этом месте. `EMPTY` — только
для никогда не занятых ячеек; после удаления — `TOMBSTONE`.
</details>
<details>
<summary>Зацикливается при полной таблице</summary>
Цикл зондирования обязан ограничиться `capacity` шагами. Если таблица переполнилась —
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
</details>
## Глава VII. Частые ошибки и как их не допустить
- **Не перемешать хеш** → кластеризация, тест на ключи, кратные 16, падает. Самая частая.
- **`%` вместо `&`** → работает, но теряется смысл требования «степень двойки»; и на
медленных платформах деление дороже.
- **Забыть про tombstone при rehash** → «мёртвые» ячейки переезжают и занимают место.
- **Хранить ключ отдельно от значения и перепутать порядок** → ASAN поймает, но лучше
держать их в одной структуре ячейки.
- **Проверять `size == capacity` вместо load factor** → таблица почти полна, пробеги
огромны, время уходит за лимит 2 секунды.
## После сдачи
Разбор со мной: почему ёмкость — степень двойки; чем открытая адресация лучше цепочек по
кэшу и хуже по кластеризации; как tombstone-метки спасают поиск после удаления; как это
устроено в реальных FDB коммутаторов (там обычно хеш с цепочками и ограничением глубины).
+217
View File
@@ -0,0 +1,217 @@
# D1, часть 1. Сложность и хеш-таблицы (урок)
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
## 1. Что такое O-нотация (без воды)
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n).
Три правила, которых хватает для 90% вопросов:
1. **Последовательные блоки складываются, остаётся старший.** O(n) + O(n²) → O(n²).
2. **Вложенные циклы перемножаются.** Цикл n по циклу n → O(n²).
3. **Деление задачи вдвое даёт log n.** Бинарный поиск в 1 000 000 элементов:
2²⁰ ≈ 1 048 576, значит 20 шагов → **O(log n), а число сравнений ≈ 20**.
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция
«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` —
обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно
**амортизированное O(1)**, потому что ёмкость удваивается.
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
## 2. Таблица сложностей, которую надо знать
| Структура | Поиск | Вставка | Удаление | Память |
|---|---|---|---|---|
| Массив (не отсортирован) | O(n) | O(1) в конец | O(n) | O(n) |
| Отсортированный массив | O(log n) | O(n) | O(n) | O(n) |
| Связный список | O(n) | O(1) по указателю | O(1) по указателю | O(n) |
| Хеш-таблица (средн.) | O(1) | O(1) | O(1) | O(n) |
| Хеш-таблица (худш.) | O(n) | O(n) | O(n) | O(n) |
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
частый вопрос), вставка одного элемента — 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)**, быстрее сравнениями нельзя.
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
bubble, counting. Неустойчивы: quick, heap, selection.
## 3. Хеш-таблица: как устроена
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
адрес за одно действие, без перебора.
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
загрузки** (сколько занято от общего размера).
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка.
Два способа разрешения:
- **Цепочки (chaining):** в каждой корзине список/вектор элементов. Просто, но много
мелких аллокаций; при плохом хеше одна цепочка растёт до O(n).
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка
зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone**
(надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять.
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое
больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево).
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие
биты (для строк — FNV-1a или `std::hash<std::string>`).
## 4. Разбор примера: считаем сложности
```cpp
// (а) сумма элементов
long long sum(const std::vector<int>& v) { // O(n): один проход
long long s = 0;
for (int x : v) s += x;
return s;
}
// (б) пары с суммой k
bool has_pair(const std::vector<int>& v, int k) { // O(n^2): вложенный цикл
for (size_t i = 0; i < v.size(); ++i)
for (size_t j = i + 1; j < v.size(); ++j)
if (v[i] + v[j] == k) return true;
return false;
}
// (в) то же, но через хеш-множество
bool has_pair_fast(const std::vector<int>& v, int k) { // O(n) в среднем
std::unordered_set<int> seen;
for (int x : v) {
if (seen.count(k - x)) return true; // поиск в среднем O(1)
seen.insert(x);
}
return false;
}
```
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
## 5. Разбор примера: как руками собрать хеш-таблицу
Учебный минимальный вариант (это разбор, не зачётная задача — запусти и поиграйся):
```cpp
#include <cstdint>
#include <string>
#include <vector>
#include <iostream>
// 1. хеш-функция: FNV-1a, хорошо перемешивает
uint64_t fnv1a(const std::string& s) {
uint64_t h = 1469598103934665603ULL;
for (unsigned char c : s) { h ^= c; h *= 1099511628211ULL; }
return h;
}
// 2. таблица с цепочками
struct HashTable {
struct Node { std::string key; int val; Node* next; };
std::vector<Node*> buckets;
size_t sz = 0;
explicit HashTable(size_t cap = 8) : buckets(cap, nullptr) {}
size_t index(const std::string& k) const { return fnv1a(k) % buckets.size(); }
void put(const std::string& k, int v) {
Node* n = buckets[index(k)];
for (; n; n = n->next) if (n->key == k) { n->val = v; return; } // уже есть — обновляем
buckets[index(k)] = new Node{k, v, buckets[index(k)]}; // вставка в голову цепочки
++sz;
if (sz * 10 > buckets.size() * 7) rehash(); // load > 0.7
}
bool get(const std::string& k, int& out) const {
for (Node* n = buckets[index(k)]; n; n = n->next)
if (n->key == k) { out = n->val; return true; }
return false;
}
void rehash() {
std::vector<Node*> old = buckets;
buckets.assign(old.size() * 2, nullptr);
for (Node* head : old)
for (Node* n = head; n; ) {
Node* next = n->next;
size_t i = index(n->key);
n->next = buckets[i];
buckets[i] = n;
n = next;
}
}
};
```
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
**все** узлы, потому что индекс зависит от размера массива.
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
до свободной ячейки, а при удалении ставим tombstone.
## 6. Что спросят на собеседовании (готовые ответы)
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
при коллизиях.
- «Что такое load factor и зачем rehash?» — доля занятых ячеек; при превышении порога
(обычно 0.7–1.0) массив растёт, иначе пробеги/цепочки удлиняются.
- «Как удалять при открытой адресации?» — tombstone, иначе порвётся цепочка зондирования.
- «Почему ёмкость — степень двойки?» — `hash & (cap-1)` вместо `%`, дешевле.
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
## 7. Материалы (первопартийные)
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
- OSTEP, часть «Data Structures»/«Hashing» — https://pages.cs.wisc.edu/~remzi/OSTEP/
- Codeforces EDU, курс по структурам данных — https://codeforces.com/edu/courses
- Визуализация открытой адресации — https://www.cs.usfca.edu/~galles/visualization/OpenHash.html
## 8. Проверь себя (ответы внизу, не подглядывай сразу)
1. Сложность поиска в `unordered_map` в среднем и в худшем?
2. Сколько сравнений в худшем случае у бинарного поиска в массиве из 10⁶ элементов?
3. `heapify` из произвольного массива — за сколько?
4. Какая из сортировок устойчива: quick, merge, heap?
5. Зачем tombstone при открытой адресации?
<details>
<summary>Ответы</summary>
1. Амортизированное O(1); худшая O(n) — все ключи в одной корзине.
2. 20 (`log₂ 10⁶ ≈ 20`).
3. O(n), снизу вверх от середины массива к началу.
4. merge (устойчива), quick и heap — нет.
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
ячейки до элемента, который был вставлен за ней.
</details>
## 9. Ссылки на задачи этого дня
- `tasks/10_hash` — своя таблица с открытой адресацией (главная задача дня, идёт ступенями).
- `tasks/01_bits` — битовые операции на C, разминка для рук.
- `tasks/03_ring` — кольцевой буфер, база для сетевого кода.
+208
View File
@@ -0,0 +1,208 @@
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения +
открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает
результат, сигнал — асинхронное уведомление.
## 1. fork(): что реально происходит
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
пространство, свой PID, свой поток. Родитель продолжает с того же места.
Возвращает **дважды** — и это ключ к пониманию:
- в родителе — PID ребёнка (> 0);
- в ребёнке — 0;
- при ошибке — −1 (и `errno`), ребёнок не создан.
Поэтому классический код всегда ветвится:
```cpp
pid_t pid = fork();
if (pid == 0) {
// это ребёнок
execlp("ls", "ls", "-l", nullptr);
_exit(127); // exec не вернулся — значит не смог
} else if (pid > 0) {
// это родитель: pid — номер ребёнка
int status = 0;
waitpid(pid, &status, 0); // дождаться и забрать код возврата
} else {
perror("fork"); // ошибка
}
```
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни страницы,
помеченные «только чтение». При первой записи в страницу ядро делает её копию — только
тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают
в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
при первой записи**.
Совет: `fflush(stdout)` перед `fork`, иначе буфер вывода может продублироваться в ребёнке.
## 2. exec(): замена образа
`exec*` **не создаёт процесс**, а заменяет содержимое текущего: код, данные, стек —
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы.
Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
## 3. wait/waitpid и коды возврата
Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код
возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает
память, но занимает слот в таблице процессов — их накопление плохо.
- `wait(&status)` — ждёт любого ребёнка;
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
Разбор `status` делается макросами, а не вручную:
```cpp
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
```
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
- **139 = 128 + 11** → SIGSEGV (падение по памяти);
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
**Сирота** — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби.
## 4. Сигналы
Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9,
нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11),
SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился).
Обработчик ставится `sigaction` (надёжнее устаревшего `signal`), внутри обработчика можно
менять только `volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe).
```cpp
static volatile sig_atomic_t stop = 0;
static void on_term(int) { stop = 1; } // минимум действий
int main() {
struct sigaction sa{}; sa.sa_handler = on_term;
sigaction(SIGTERM, &sa, nullptr); // ловим мягкое завершение
while (!stop) { /* работа */ }
}
```
## 5. errno и возвраты системных вызовов
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить;
EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже;
EINPROGRESS — неблокирующее соединение в процессе.
```cpp
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue; // прервали сигналом — просто повторить
perror("read"); // иначе настоящая ошибка
break;
}
```
## 6. Разбор примера: мини-шелл на 40 строк
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
```cpp
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <string>
#include <vector>
#include <sstream>
int main() {
std::string line;
while (std::printf("sh> "), std::fflush(stdout), std::getline(std::cin, line)) {
if (line == "exit") break;
if (line.empty()) continue;
std::istringstream is(line);
std::vector<std::string> argv; std::string tok;
while (is >> tok) argv.push_back(tok);
if (argv.empty()) continue;
std::vector<char*> cargv;
for (auto& a : argv) cargv.push_back(a.data());
cargv.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
execvp(cargv[0], cargv.data());
std::fprintf(stderr, "не могу запустить %s\n", cargv[0]);
_exit(127); // exec не удался
} else if (pid > 0) {
int status = 0;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
std::printf("код возврата: %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
std::printf("убит сигналом: %d (код %d)\n",
WTERMSIG(status), 128 + WTERMSIG(status));
} else {
std::perror("fork");
}
}
}
```
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
несуществующая команда даёт 127.
Проверка на утечки и падения — санитайзеры:
`g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell`
## 7. Что спросят на собеседовании (готовые ответы)
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
в ребёнке 0, при ошибке −1.
- «Что с памятью при fork?» — copy-on-write, страницы копируются при первой записи.
- «Чем exec отличается от fork?» — fork создаёт новый процесс, exec заменяет образ
текущего, не создавая процесса.
- «Зачем waitpid?» — забрать код возврата и не оставлять зомби.
- «Как узнать, что процесс убит сигналом?» — `WIFSIGNALED`/`WTERMSIG`, код оболочки
128 + сигнал, например 137 = SIGKILL, 139 = SIGSEGV.
- «SIGTERM против SIGKILL?» — SIGTERM можно перехватить и завершиться корректно,
SIGKILL не перехватывается и не игнорируется.
## 8. Материалы (первопартийные)
- `man 2 fork`, `man 2 execve`, `man 2 waitpid`, `man 7 signal` — локально, читаются за 10 минут
- OSTEP, главы 4–5 (процессы, API процессов) — https://pages.cs.wisc.edu/~remzi/OSTEP/
- Beej, «Processes» — https://beej.us/guide/bgipc/
- Про COW: `man 2 fork` + статья «Anatomy of a Program in Memory» —
https://manybutfinite.com/post/anatomy-of-a-program-in-memory/
## 9. Проверь себя
1. Что вернёт `fork()` в ребёнке и в родителе?
2. Что происходит со страницами памяти при `fork()`?
3. Что такое зомби и кто его убирает?
4. Откуда код возврата 137 и 139?
5. Почему `exec` не возвращает управление при успехе?
<details>
<summary>Ответы</summary>
1. В ребёнке 0, в родителе — PID ребёнка, при ошибке −1.
2. Ничего сразу: copy-on-write, копия страницы создаётся при первой записи.
3. Завершившийся процесс, чья запись ждёт `wait/waitpid`; убирает родитель (или init,
если родитель умер).
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
</details>
## 10. Задачи дня
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).