# Структуры данных
Цель работы: Реализовать три различные структуры данных «с нуля», применить их для хранения записей телефонного справочника и экспериментально сравнить производительность основных операций. Вы должны собственными руками написать код, чтобы понять внутреннее устройство связного списка, хеш-таблицы и двоичного дерева поиска, а также осознать их сильные и слабые стороны на практике.

## Подготовка среды
```Python
import time
from pathlib import Path
import random
import csv
import sys
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
sys.setrecursionlimit(12000) #увеличивает глубину рекурсии
```

# Базовые операции
```Python
#Связный список
def ll_insert(head, name, phone):
    current = head
    while current:
        if current['name'] == name:
            current['phone'] = phone
            return head
        current = current['next']
    new_node = {'name': name, 'phone': phone, 'next': None}
    new_node['next'] = head
    return new_node

def ll_find(head, name):
    current = head
    while current:
        if current['name'] == name:
            return current['phone']
        current = current['next']
    return None

def ll_delete(head, name):
    if head['name'] == name:
        return head['next']
    current = head
    while current['next']:
        if current['next']['name'] == name:
            current['next'] = current['next']['next']
            break
        current = current['next']
    return head

def ll_list_all(head):
    data= []
    current = head
    while current:
        data.append((current['name'], current['phone']))
        current = current['next']
    return sorted(data)

#хеш-таблица
def ht_insert(buckets, name, phone):
    id=hash(name)%len(buckets)
    buckets[id] = ll_insert(buckets[id], name, phone)

def ht_find(buckets, name):
    id= hash(name)%len(buckets)
    return ll_find(buckets[id], name)

def ht_delete(buckets, name):
    id= hash(name)%len(buckets)
    buckets[id] = ll_delete(buckets[id], name)

def ht_list_all(buckets):
    data = []
    for head in buckets:
        current = head
        while current:
            data.append((current['name'], current['phone']))
            current = current['next']
    return sorted(data)



#Двоичное дерево поиска
def bst_insert(root, name, phone):
    if root is None:
        return {'name': name, 'phone': phone, 'left': None, 'right': None}
    if name == root['name']:
        root['phone'] = phone
    elif name < root['name']:
        root['left'] = bst_insert(root['left'], name, phone)
    else:
        root['right'] = bst_insert(root['right'], name, phone)
    return root

def bst_find(root, name):
    if root is None:
        return None
    if root['name'] == name:
        return root['phone']
    elif name<root['name']:
        return bst_find(root['left'], name)
    else:
        return bst_find(root['right'], name)

def minimum(node):
    current = node
    while current['left'] is not None:
        current = current['left']
    return current

def bst_delete(root, name):
    if root is None:
        return None
    if name < root['name']:
        root['left'] = bst_delete(root['left'], name)
    elif name > root['name']:
        root['right'] = bst_delete(root['right'], name)
    else:
        if root['left'] is None:
            return root['right']
        elif root['right'] is None:
            return root['left']
        min=minimum(root['right'])
        root['name']=min['name']
        root['phone']=min['phone']
        root['right']=bst_delete(root['right'], min['name'])
    return root

def bst_list_all(root):
    result=[]
    if root:
        result.extend(bst_list_all(root['left']))
        result.append((root['name'], root['phone']))
        result.extend(bst_list_all(root['right']))
    return result
```

# Экспериментальная часть

## Генерация 
Создаем список records из N=10000 элементов. Каждый элемент — кортеж (name, phone).
Имена генерируются как f"User_{i:05d}" (равномерное распределение). Для проверки влияния порядка подготовим два варианта одного и того же набора:

records_shuffled — случайный порядок.

records_sorted — отсортированный по имени (по алфавиту).
```Python
def generate(n=10000):
    records = [(f"User_{i:05d}", f"+7 ({random.randint(100, 999)}) {random.randint(100, 999)}-{random.randint(00, 99):02}-{random.randint(00, 99):02}") for i in range(n)]
    records_sorted =records.copy()
    records_shuffled=records.copy()
    random.shuffle(records_shuffled)
    return records_sorted, records_shuffled
```
## Проведение замеров

**А. Вставка всех записей**  
Создаем пустую структуру.  
Засекаем время, выполняем insert для каждой записи из входного списка.  
Фиксируем общее время вставки.  
```Python
def task_A(structure_name, data):
    start =time.perf_counter()
    if structure_name=="LinkedList":
        head=None
        for name, phone in data:
            head = ll_insert(head, name, phone)
        container=head
    elif structure_name=="HashTable":
        buckets=[None]*1000
        for name, phone in data:
            ht_insert(buckets, name, phone)
        container=buckets
    elif structure_name=="BinarySearchTree":
        root=None
        for name, phone in data:
            root = bst_insert(root, name, phone)
        container=root
    end = time.perf_counter()
    elapsed = end - start
    return elapsed, container
```

**Б. Поиск 100 случайных записей**  
Берем 100 случайных имён из того же набора (гарантированно существующих) и 10 имён, которых нет ("None_{i}").  
Засекаем время на выполнение всех 110 вызовов find.  
```Python
def task_B(structure_name,container, data):
    start=time.perf_counter()
    if structure_name=="LinkedList":
        for name in data:
            ll_find(container, name)
    elif structure_name=="HashTable":
        for name in data:
            ht_find(container, name)
    elif structure_name=="BinarySearchTree":
        for name in data:
            bst_find(container, name)
    end=time.perf_counter()
    elapsed = end - start
    return elapsed
```

**В. Удаление 50 случайных записей**  
Берем 50 случайных имён из набора.  
Засекаем время на выполнение delete для каждого.
```Python
def task_C(structure_name,container, data):
    start=time.perf_counter()
    if structure_name=="LinkedList":
        for name in data:
            container=ll_delete(container, name)
    elif structure_name=="HashTable":
        for name in data:
            ht_delete(container, name)
    elif structure_name=="BinarySearchTree":
        for name in data:
            container = bst_delete(container, name)
    end=time.perf_counter()
    elapsed = end - start
    return elapsed
```

### Реализация замеров
```Python
results=[["Структура", "Режим", "Операция", "Время (сек)"]]
structures_name=["LinkedList", "HashTable", "BinarySearchTree"]
experiment_name=["Вставка", "Поиск", "Удаление"]
mode_of_data=["Случайный", "Отсортированный"]

records_sorted, records_shuffled = generate()
container_shuffled=[]#хранилище структур со случайными данными
container_sorted=[]#хранилище структур с отсортированными данными
names=[record[0] for record in records_shuffled]
#Данные для задания Б
random_names=random.sample(names, 100)
missing_names=[f"None_{i}" for i in range(10)]
names_for_test=random_names+missing_names
#Данные для задания В
names_to_delete=random.sample(names,50)

for i in range(3):
    container_shuffled.append(task_A(structures_name[i], records_shuffled)[1])
    container_sorted.append(task_A(structures_name[i], records_sorted)[1])
    for j in range(5):
        # Реализация задания А
        result_shuffled = task_A(structures_name[i], records_shuffled)[0]
        results.append([structures_name[i], mode_of_data[0], experiment_name[0], result_shuffled])

        result_sorted= task_A(structures_name[i], records_sorted)[0]
        results.append([structures_name[i], mode_of_data[1], experiment_name[0], result_sorted])
        print(f"{structures_name[i]}: Время вставки всех записей {mode_of_data[0]}: {result_shuffled}  {mode_of_data[1]}: {result_sorted}")
        # Реализация задания Б
        result_shuffled = task_B(structures_name[i], container_shuffled[i], names_for_test)
        results.append([structures_name[i], mode_of_data[0], experiment_name[1], result_shuffled])

        result_sorted = task_B(structures_name[i], container_sorted[i], names_for_test)
        results.append([structures_name[i], mode_of_data[1], experiment_name[1], result_sorted])
        print(f"{structures_name[i]}: Время нахождения 110 записей для {mode_of_data[0]}: {result_shuffled}  {mode_of_data[1]}: {result_sorted}  ")
        #Реализация задания В
        shuffled = container_shuffled[i]
        sorted = container_sorted[i]
        result_shuffled = task_C(structures_name[i], shuffled, names_to_delete)
        results.append([structures_name[i], mode_of_data[0], experiment_name[2], result_shuffled])

        result_sorted = task_C(structures_name[i], sorted, names_to_delete)
        results.append([structures_name[i], mode_of_data[1], experiment_name[2], result_sorted])
        print(f"{structures_name[i]}: Время удаления 50 записей для {mode_of_data[0]}: {result_shuffled}  {mode_of_data[1]}: {result_sorted}")
```

## Сохранение результатов
```Python
current_dir=Path.cwd()
target=current_dir.parent/"docs"/"data"
csv_file=target /"results.csv"
with open(csv_file, "w", newline="",encoding="utf-8-sig") as f:
    writer = csv.writer(f)
    writer.writerows(results)
```

# Анализ результатов  

## Построение графиков
```Python
df = pd.read_csv(csv_file)
df_avg = df.groupby(["Структура", "Режим", "Операция"])["Время (сек)"].mean().reset_index()
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
for i, experiment in enumerate(experiment_name):
    data_experiment = df_avg[df_avg["Операция"] == experiment]
    sns.barplot(ax=axes[i],data=data_experiment, x="Структура",y="Время (сек)",hue="Режим")
    axes[i].set_title(experiment)
    axes[i].set_ylabel("Среднее время (сек)")
    axes[i].set_yscale("log")
plt.tight_layout()
png_file= target/"graphics.png"
plt.savefig(png_file, dpi=300, bbox_inches='tight')
plt.show()
```
![](data/graphics.png)

### Как порядок входных данных влияет на скорость вставки в BST
Если подать на вход отсортированные данные, дерево превращается в связный список: каждый новый узел становится правым потомком предыдущего. И сложность меняется с логарифмической O(log n) на линейную O(n). Вставка для неотсортированных данных заняла 0.016531 с, а для отсортированных: 7.112118 с, разница в 430 раз. Получается, что BST сильно зависит от входных данных.
### Почему хеш-таблица почти не чувствительна к порядку
Хеш-таблица имеет низкую чувствительность к порядку входных данных, поскольку хеш-функция вычисляет индекс в массиве на основе значения ключа, обеспечивая равномерное распределение элементов по бакетам независимо от их исходной последовательности. По графикам видно, что разница между случайными и отсортированными данными минимальна. И для всех операций сложность составляет O(1).
### Почему связный список всегда медленен при поиске
Связный список всегда медленен при поиске, потому что у него отсутствует прямой доступ к элементам, и нужно перебирать все элементы по порядку. И из-за этого связный список имееет сложность O(n).
### Как удаление работает в каждой структуре
- **Связный список:** Сначала программа ищет нужный элемент, перебирая их по порядку от головы, что занимает время O(n). Как только элемент найден, то у предыдущего обновляется ссылка на элемент, который шел после удаляемого, что занимает время O(1). По графикам видно, что время удаления близко ко времени поиска. Время удаления для отсортированных данных: 0.017500 с, а для случайных: 0.018947 с.
- **Хеш-таблица:** Программа определяет нужный бакет и удаляет элемент из короткого связного списка внутри этого бакета за O(1). Время удаления для отсортированных данных: 0.000036 с, а для случайных: 0.000043 с.
- **Двоичное дерево поиска:** Нет потомков: Узел просто стирается. Один потомок: Потомок занимает место удаленного родителя. Два потомка: На место удаленного узла ставится самый минимальный элемент из его правого поддерева. Для случайных данных занимает O(log n), а для отсортированных данных занимает O(n). Время удаления для отсортированных данных: 0.039463 с, а для случайных: 0.000153 с.

# Вывод
На основе полученных результатов можно сделать вывод:
- **Связный список:** всегда имеет линейную сложность O(n), что делает его неподходящим для задач частых вставок, частого поиска и получения данных в порядке. Но подходит только в узких случаях: максимально быстрая вставка и удаление элементов в начало или конец структуры(очереди, стеки).
- **Хеш-таблица:** является лучшим выбором для максимально задач частого поиска, добавления и удаления элементов, которые имеют сложность O(1), при этом порядок входных данных не имеет значение. Она идеально подходит для словарей и кэшей.
- **Двоичное дерево поиска:** Необходимо использовать в тех случаях, когда необходимо получать данные в отсортированном состоянии и выполнять поиск в заданном диапазоне значений. При случайных входных данных имеет хорошую сложность O(log n), но при получении отсортированных входных данных сложность возрастает до линейной O(n).

Таким образом, для реальных задач наиболее подходят хеш-таблицы или сбалансированные деревья, если требуется получить данные в отсортированном виде.