2026-rff_mp/SobolevNS/docs/report_01.md

170 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Отчёт по заданию 1. Структуры данных: телефонный справочник
## 1. Цель работы
Реализовать три структуры данных «руками» (без классов, в процедурной парадигме):
**связный список**, **хеш‑таблицу с цепочками** и **двоичное дерево поиска (BST)**.
Сравнить их на одной и той же задаче - телефонный справочник с операциями `insert`,
`find`, `delete`, `list_all` - и понять, какая структура когда лучше.
## 2. Что и как реализовано
Все три структуры реализованы в `phonebook.py`. Каждая - это набор функций,
которые получают/возвращают текущее состояние (узел или контейнер).
Объектная парадигма сознательно не использовалась - это позволяет «увидеть руки»
каждой операции.
### 2.1. Связный список
Узел - обычный словарь `{'name', 'phone', 'next'}`. Голова списка - это либо
такой словарь, либо `None`.
Ключевые функции (полный код - в `phonebook.py`):
| Функция | Сложность |
| --- | --- |
| `ll_insert(head, name, phone)` | вставка в конец или обновление: O(n) |
| `ll_find(head, name)` | линейный поиск: O(n) |
| `ll_delete(head, name)` | O(n) |
| `ll_list_all(head)` | собрать всё + сортировка: O(n log n) |
В `ll_insert` я специально хожу до конца, чтобы потом сравнить с худшим
случаем - это объясняет «провал» на графиках вставки (см. ниже).
### 2.2. Хеш-таблица
Контейнер - словарь `{'size': int, 'buckets': [head|None, ...]}`. Каждый бакет -
голова связного списка, и внутри бакета работают всё те же `ll_*` функции.
Хеш-функция: `hash(name) % size`. Размер таблицы - 2048.
| Функция | Сложность (амортизированно) |
| --- | --- |
| `ht_insert/ht_find/ht_delete` | O(1) при равномерной хеш-функции |
| `ht_list_all` | O(n log n) (надо собрать всё и отсортировать) |
### 2.3. Двоичное дерево поиска (BST)
Узел - словарь `{'name', 'phone', 'left', 'right'}`. Реализация **итеративная**,
без рекурсии - это важно для эксперимента с отсортированным входом (вырожденное
дерево глубины N, рекурсия упёрлась бы в лимит).
| Функция | Случайные данные | Отсортированные данные |
| --- | --- | --- |
| `bst_insert/bst_find/bst_delete` | O(log n) | **O(n)** (вырожденный «список») |
| `bst_list_all` | O(n) (inorder, сразу отсортировано) | O(n) |
## 3. Эксперимент
### 3.1. Методика
* Генерируем `N = 10 000` записей вида `("User_00000", "555-0000000")` и т.д.
* Два режима ввода: **shuffled** (случайный) и **sorted** (отсортированный по имени).
* Каждое испытание повторяется **5 раз**, в CSV сохраняем все 5 значений
и среднее.
* На каждом испытании:
1. строим структуру заново;
2. вставляем все N записей - замеряем время;
3. ищем 100 случайных существующих имён + 10 несуществующих (всего 110 вызовов);
4. удаляем 50 случайных имён.
Замер - `time.perf_counter()`. Все значения в секундах в CSV, в отчёте перевожу
в миллисекунды.
> Для **BST в режиме `sorted`** пришлось снизить N до 2 000. При N = 10 000
> вставка занимает десятки минут (сложность операции - `O(N**2)`, дерево превращается
> в связный список). Это и есть главная иллюстрация «деградации BST».
### 3.2. Результаты (средние, миллисекунды)
| Структура | Режим | N | вставка | поиск 110 ключей | удаление 50 |
| --- | --- | ---: | ---: | ---: | ---: |
| LinkedList | shuffled | 10 000 | **4 027** | 34.87 | 14.43 |
| LinkedList | sorted | 10 000 | 3 056 | 27.58 | 13.34 |
| HashTable | shuffled | 10 000 | 6.71 | 0.068 | 0.038 |
| HashTable | sorted | 10 000 | 6.42 | 0.068 | 0.033 |
| BST | shuffled | 10 000 | 16.84 | 0.172 | 0.115 |
| BST | sorted | **2 000** | **121.17** | 5.20 | 7.48 |
Полные сырые данные - в `data/results.csv`.
### 3.3. Графики
![Вставка](data/task1_data_structures/docs/data/plots/insert_compare.png)
![Поиск](data/task1_data_structures/docs/data/plots/find_compare.png)
![Удаление](data/task1_data_structures/docs/data/plots/delete_compare.png)
![Деградация BST на отсортированных данных](data/task1_data_structures/docs/data/plots/bst_degradation.png)
## 4. Анализ
### 4.1. Почему связный список такой медленный на вставке
Мой `ll_insert` идёт до конца списка, чтобы потом обновить узел, если он уже
есть. На Nм элементе он совершает уже N шагов. Суммарно - около N**2/2
операций. На N = 10 000 это даёт ~50 миллионов проходов по узлам - отсюда
и ~4 секунды.
Если бы мы вставляли **в начало** (за O(1)), общая вставка стала бы линейной,
но тогда:
* возможны дубликаты (обновление пропускается);
* для совместимости с двумя другими структурами всё равно понадобился бы поиск.
Поиск и удаление быстрее: 110 поисков ≈ 35 мс, потому что в среднем ищем
N/2 шагов и таких запросов всего 110. Сложность каждой операции - O(N),
но запросов мало.
### 4.2. Почему хеш-таблица почти не зависит от порядка
Хеш-функция превращает имя в индекс бакета. Сами имена `User_00001`,
`User_00002`, … равномерно распределяются по 2 048 бакетам, поэтому в каждом
бакете лежит в среднем ≈ 5 элементов. Все три операции работают за ~O(1).
Порядок входных данных не имеет значения, потому что `hash(name)` от него не
зависит. На графике видно: shuffled и sorted столбцы у HashTable одинаковой
высоты.
### 4.3. Почему BST деградирует на отсортированном входе
Когда имена приходят в алфавитном порядке, каждый новый ключ всегда больше
предыдущего, поэтому он идёт в правое поддерево. Дерево превращается
в правый «костыль» - фактически в односвязный список. Сложность вставки -
O(N**2), поиска и удаления - O(N).
На графике `bst_degradation.png` это видно очень ярко: даже при **в 5 раз
меньшем N (2 000 вместо 10 000)** все операции у sorted BST занимают
в 510 раз больше времени, чем у shuffled BST.
Это известная слабость наивного BST. В реальном коде её решают
**самобалансирующимися деревьями** (AVL, красно-чёрные, B-деревья),
которые гарантируют глубину O(log n) даже на отсортированном входе.
### 4.4. Удаление
* В связном списке удаление - это пробежка до нужного узла + переключение
ссылок. На 50 запросах суммарно ~14 мс при N = 10 000.
* В хеш-таблице удаление - это пробежка по бакету (короткий список), почти O(1):
всего 0.038 мс на 50 удалений.
* В BST стандартное удаление: если у узла двое детей, заменяем его на минимум
правого поддерева и рекурсивно удаляем его. На случайном дереве - O(log n)
на удаление; на отсортированном - O(N).
## 5. Что выбирать в реальной жизни
| Сценарий | Лучший выбор | Почему |
| --- | --- | --- |
| Частые `insert`/`find`/`delete`, порядок не нужен | **Хеш-таблица** | O(1) на всё |
| Нужно много раз получать **отсортированный** список | **BST** (с балансировкой) | inorder обход - это и есть сортировка, O(N) |
| Очень мало данных или редкие операции; нужна простота | **Связный список** | минимум кода, O(N) - приемлемо |
| Нужны диапазонные запросы («все имена от 'A' до 'D'») | **BST/сбалансированное** | хеш-таблица их не умеет |
| Гарантированная производительность на любых данных | **Хеш-таблица или AVL/RB-tree**, но **не наивное BST** | наивное BST уязвимо к отсортированному входу |
Хеш-таблица в стандартной библиотеке Python - это и есть встроенный `dict`,
которым стоит пользоваться по умолчанию для пар «ключ → значение».
Понимать «руками» связный список и BST полезно, чтобы знать, что лежит
под капотом более сложных контейнеров и понимать,
когда их применять (например, `OrderedDict`, `sortedcontainers.SortedDict`,
индексы в БД и т. д.).