Формат v3: уроки D1 (теория+разбор), задачи ступенями, карточки 101 с уровнями и пояснениями
This commit is contained in:
@@ -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` — кольцевой буфер, база для сетевого кода.
|
||||
@@ -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-заданиями).
|
||||
Reference in New Issue
Block a user