From d48c1c7f5cb10f3e11fc1f02cbc3aa47e06afb27 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Wed, 29 Apr 2026 17:28:39 +0300 Subject: [PATCH 1/5] feat: add hash tables --- .github/workflows/{mypy.yml => lint.yml} | 2 +- README.md | 9 +- .../ABSTRACT.md => abstract/base_ds.md | 0 src/dnc/ABSTRACT.md => abstract/dnc.md | 0 src/dp/ABSTRACT.md => abstract/dp.md | 0 src/dp2/ABSTRACT.md => abstract/dp2.md | 0 src/graphs/ABSTRACT.md => abstract/graphs.md | 0 src/greedy/ABSTRACT.md => abstract/greedy.md | 0 src/intro/ABSTRACT.md => abstract/intro.md | 0 .../ABSTRACT.md => abstract/number_theory.md | 0 .../ABSTRACT.md => abstract/prefix_sums.md | 0 .../ABSTRACT.md => abstract/scanline.md | 0 src/search/ABSTRACT.md => abstract/search.md | 0 .../ABSTRACT.md => abstract/sorting.md | 0 src/trees/ABSTRACT.md => abstract/trees.md | 0 .../ABSTRACT.md => abstract/two_pointers.md | 0 src/base_ds/calculator.py | 2 +- src/hash_tables/README.md | 227 ++++++++++++ src/hash_tables/__init__.py | 0 src/hash_tables/chain_hashing.py | 71 ++++ src/hash_tables/chaining.py | 104 ++++++ src/hash_tables/contact_book.py | 45 +++ src/hash_tables/direct_address.py | 42 +++ src/hash_tables/hash_table_emulation.py | 4 + src/hash_tables/open_address.py | 178 +++++++++ src/hash_tables/pattern_search.py | 35 ++ src/heaps/priority_queue.py | 7 +- tests/test_hash_tables/__init__.py | 0 tests/test_hash_tables/test_chain_hashing.py | 234 ++++++++++++ tests/test_hash_tables/test_chaining.py | 276 ++++++++++++++ tests/test_hash_tables/test_contact_book.py | 190 ++++++++++ tests/test_hash_tables/test_direct_address.py | 245 ++++++++++++ tests/test_hash_tables/test_open_address.py | 348 ++++++++++++++++++ tests/test_hash_tables/test_pattern_search.py | 164 +++++++++ 34 files changed, 2171 insertions(+), 12 deletions(-) rename .github/workflows/{mypy.yml => lint.yml} (97%) rename src/base_ds/ABSTRACT.md => abstract/base_ds.md (100%) rename src/dnc/ABSTRACT.md => abstract/dnc.md (100%) rename src/dp/ABSTRACT.md => abstract/dp.md (100%) rename src/dp2/ABSTRACT.md => abstract/dp2.md (100%) rename src/graphs/ABSTRACT.md => abstract/graphs.md (100%) rename src/greedy/ABSTRACT.md => abstract/greedy.md (100%) rename src/intro/ABSTRACT.md => abstract/intro.md (100%) rename src/number_theory/ABSTRACT.md => abstract/number_theory.md (100%) rename src/prefix_sums/ABSTRACT.md => abstract/prefix_sums.md (100%) rename src/scanline/ABSTRACT.md => abstract/scanline.md (100%) rename src/search/ABSTRACT.md => abstract/search.md (100%) rename src/sorting/ABSTRACT.md => abstract/sorting.md (100%) rename src/trees/ABSTRACT.md => abstract/trees.md (100%) rename src/two_pointers/ABSTRACT.md => abstract/two_pointers.md (100%) create mode 100644 src/hash_tables/README.md create mode 100644 src/hash_tables/__init__.py create mode 100644 src/hash_tables/chain_hashing.py create mode 100644 src/hash_tables/chaining.py create mode 100644 src/hash_tables/contact_book.py create mode 100644 src/hash_tables/direct_address.py create mode 100644 src/hash_tables/hash_table_emulation.py create mode 100644 src/hash_tables/open_address.py create mode 100644 src/hash_tables/pattern_search.py create mode 100644 tests/test_hash_tables/__init__.py create mode 100644 tests/test_hash_tables/test_chain_hashing.py create mode 100644 tests/test_hash_tables/test_chaining.py create mode 100644 tests/test_hash_tables/test_contact_book.py create mode 100644 tests/test_hash_tables/test_direct_address.py create mode 100644 tests/test_hash_tables/test_open_address.py create mode 100644 tests/test_hash_tables/test_pattern_search.py diff --git a/.github/workflows/mypy.yml b/.github/workflows/lint.yml similarity index 97% rename from .github/workflows/mypy.yml rename to .github/workflows/lint.yml index be8c9ad..a1e9f8a 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Mypy +name: Lint on: push: diff --git a/README.md b/README.md index d45306b..b9de801 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ## Ключевые особенности - **15+** разобранных тем, к каждой теме легкий конспект -- **70+** практических задач с LeetCode, тренировок от Яндекса и реальных собеседований +- **70+** практических задач с LeetCode, Stepik и собеседований в БигТех - Решение **к каждой задаче** на лаконичном Python с комментариями -- **800+** автоматизированных тестов для проверки решений +- **1000+** автоматизированных тестов для проверки решений ## Структура курса @@ -28,11 +28,12 @@ | 13 | Деревья | Продвинутые структуры данных | `trees` | | 14 | Кучи | Продвинутые структуры данных | `heaps` | | 15 | Непересекающиеся множества | Продвинутые структуры данных | `dsu` | -| 16 | Графы | Продвинутые структуры данных | `graphs` | +| 16 | Хеш-таблицы | Продвинутые структуры данных | `hash_tables` | +| 17 | Графы | Продвинутые структуры данных | `graphs` | Каждая тема содержит: -* **Теоретический конспект**, легко и доступно объясняющий тему (файл `ABSTRACT.md`) +* **Теоретический конспект**, легко и доступно объясняющий тему (папка `/abstract/`, например `/abstract/intro.md`) * **Подборка задач** разного уровня сложности на отработку полученных навыков (файл `README.md`) * **Решения задач** из подборки на Python (файлы `*.py`, например `fib.py`) * **Автотесты** для каждого решения, покрывающие минимальный набор ошибок (папка `/tests/`, например diff --git a/src/base_ds/ABSTRACT.md b/abstract/base_ds.md similarity index 100% rename from src/base_ds/ABSTRACT.md rename to abstract/base_ds.md diff --git a/src/dnc/ABSTRACT.md b/abstract/dnc.md similarity index 100% rename from src/dnc/ABSTRACT.md rename to abstract/dnc.md diff --git a/src/dp/ABSTRACT.md b/abstract/dp.md similarity index 100% rename from src/dp/ABSTRACT.md rename to abstract/dp.md diff --git a/src/dp2/ABSTRACT.md b/abstract/dp2.md similarity index 100% rename from src/dp2/ABSTRACT.md rename to abstract/dp2.md diff --git a/src/graphs/ABSTRACT.md b/abstract/graphs.md similarity index 100% rename from src/graphs/ABSTRACT.md rename to abstract/graphs.md diff --git a/src/greedy/ABSTRACT.md b/abstract/greedy.md similarity index 100% rename from src/greedy/ABSTRACT.md rename to abstract/greedy.md diff --git a/src/intro/ABSTRACT.md b/abstract/intro.md similarity index 100% rename from src/intro/ABSTRACT.md rename to abstract/intro.md diff --git a/src/number_theory/ABSTRACT.md b/abstract/number_theory.md similarity index 100% rename from src/number_theory/ABSTRACT.md rename to abstract/number_theory.md diff --git a/src/prefix_sums/ABSTRACT.md b/abstract/prefix_sums.md similarity index 100% rename from src/prefix_sums/ABSTRACT.md rename to abstract/prefix_sums.md diff --git a/src/scanline/ABSTRACT.md b/abstract/scanline.md similarity index 100% rename from src/scanline/ABSTRACT.md rename to abstract/scanline.md diff --git a/src/search/ABSTRACT.md b/abstract/search.md similarity index 100% rename from src/search/ABSTRACT.md rename to abstract/search.md diff --git a/src/sorting/ABSTRACT.md b/abstract/sorting.md similarity index 100% rename from src/sorting/ABSTRACT.md rename to abstract/sorting.md diff --git a/src/trees/ABSTRACT.md b/abstract/trees.md similarity index 100% rename from src/trees/ABSTRACT.md rename to abstract/trees.md diff --git a/src/two_pointers/ABSTRACT.md b/abstract/two_pointers.md similarity index 100% rename from src/two_pointers/ABSTRACT.md rename to abstract/two_pointers.md diff --git a/src/base_ds/calculator.py b/src/base_ds/calculator.py index 7382c21..537b630 100644 --- a/src/base_ds/calculator.py +++ b/src/base_ds/calculator.py @@ -64,7 +64,7 @@ def evaluate_postfix(expression: str) -> float: if token in operators: operand2 = stack.pop() operand1 = stack.pop() - result = operators[token](operand1, operand2) # type: ignore[no-untyped-call] + result = operators[token](operand1, operand2) stack.append(result) else: stack.append(float(token)) diff --git a/src/hash_tables/README.md b/src/hash_tables/README.md new file mode 100644 index 0000000..2d64d97 --- /dev/null +++ b/src/hash_tables/README.md @@ -0,0 +1,227 @@ +# Задачи на хеш-таблицы + +## A. Телефонная книга + +| Поле | Значение | +|-----------|---------------------------------------------------| +| Сложность | Легкая | +| Источник | https://stepik.org/lesson/41562/step/1?unit=20016 | + +Вам нужно реализовать телефонную книгу, которая обрабатывает три вида запросов: + +* `add number name` — добавить запись с номером и именем. Если номер уже существует, имя заменяется. +* `del number` — удалить запись с таким номером. Если номера нет — ничего не делать. +* `find number` — найти имя по номеру. Если запись есть, вывести имя, иначе вывести `not found`. + +Особенность: телефонные номера содержат не более семи цифр и не имеют ведущих нулей. **Используйте прямую адресацию** по +номеру как индексу. + +**Гарантируется**, что имя не равно строке `not found`. + +**Формат входа** + +* Первая строка: целое число `n` — количество запросов (1 ≤ n ≤ 100 000). +* Следующие `n` строк — запросы одного из трёх типов: + * `add number name` + * `del number` + * `find number` +* `number` — строка из 1–7 цифр без ведущих нулей. +* `name` — непустая строка из латинских букв длиной не более 15. + +**Формат выхода** + +Для каждого запроса `find` выведите в отдельной строке либо имя, либо `not found`. + +**Пример** + +Ввод: + +```text +12 +add 911 police +add 76213 Mom +add 17239 Bob +find 76213 +find 910 +find 911 +del 910 +del 911 +find 911 +find 76213 +add 76213 daddy +find 76213 +``` + +Вывод: + +```text +Mom +not found +police +not found +Mom +daddy +``` + +--- + +## B. Хеширование цепочками + +| Поле | Значение | +|-----------|---------------------------------------------------| +| Сложность | Средняя | +| Источник | https://stepik.org/lesson/41562/step/2?unit=20016 | + +Реализуйте хеш-таблицу с **методом цепочек**. Хеш-функция для строки `S` вычисляется так: + +$$ +h(S) = \left( \sum_{i=0}^{|S|-1} S[i] \cdot x^i \bmod p \right) \bmod m +$$ + +где: + +* `S[i]` — ASCII-код i-го символа, +* `p = 1 000 000 007` — большое простое число, +* `x = 263`, +* `m` — размер таблицы. + +**Типы запросов:** + +* `add string` — добавить строку. Если строка уже есть, запрос игнорируется. Новая строка добавляется **в начало** + цепочки. +* `del string` — удалить строку. Если строки нет, ничего не делать. +* `find string` — проверить наличие строки. Вывести `yes` или `no`. +* `check i` — вывести все строки в цепочке `i` через пробел в том порядке, в котором они хранятся. Если цепочка пуста — + вывести пустую строку. + +**Формат входа** + +* Первая строка: `m` — размер хеш-таблицы. +* Вторая строка: `n` — количество запросов (1 ≤ n ≤ 100 000). +* Гарантируется: `n/5 ≤ m ≤ n`. +* Далее `n` строк — запросы одного из четырёх типов. +* Все строки в запросах состоят из латинских букв, длина от 1 до 15. + +**Формат выхода** + +Для каждого запроса `find` и `check` выведите ответ в отдельной строке. + +**Пример** + +Ввод: + +```text +5 +12 +add world +add HellO +check 4 +find World +find world +del world +check 4 +del HellO +add luck +add GooD +check 2 +del good +``` + +Вывод: + +```text +HellO world +no +yes +HellO +GooD luck +``` + +**Пояснение:** `h("world") = 4`, `h("HellO") = 4` — они попадают в одну цепочку. При добавлении "HellO" после "world" +она встаёт в начало, поэтому порядок: `HellO world`. + +**Подсказки по реализации** + +* Используйте 64-битный целый тип (`long long` в C++, `long` в Java) для промежуточных вычислений, чтобы избежать + переполнения. +* Берите результат по модулю `p` **после каждой арифметической операции**. +* Отрицательные числа по модулю: вместо `a % p` используйте `((a % p) + p) % p`. + +--- + +## C. Поиск образца в тексте + +| Поле | Значение | +|-----------|---------------------------------------------------| +| Сложность | Средняя | +| Источник | https://stepik.org/lesson/41562/step/3?unit=20016 | + +Найдите все вхождения строки **Pattern** в строку **Text** с помощью **алгоритма Карпа–Рабина**. +Используйте полиномиальное хеширование с основанием `x = 263` и модулем `p = 1 000 000 007`. + +Индексы считаются с нуля. Вхождения могут перекрываться. + +**Формат входа** + +* Первая строка: `Pattern` — образец. +* Вторая строка: `Text` — текст. +* 1 ≤ |Pattern| ≤ |Text| ≤ 500 000. +* Строки содержат только буквы латинского алфавита. +* Суммарная длина всех вхождений образца в текст не превышает 10⁸. + +**Формат выхода** + +Выведите через пробел в порядке возрастания все индексы начала вхождений `Pattern` в `Text`. Если вхождений нет, +выведите пустую строку. + +**Пример 1** + +Ввод: + +```text +aba +abacaba +``` + +Вывод: + +```text +0 4 +``` + +**Пример 2** + +Ввод: + +```text +Test +testTesttesT +``` + +Вывод: + +```text +4 +``` + +**Пример 3** + +Ввод: + +```text +aaaaa +baaaaaaa +``` + +Вывод: + +```text +1 2 3 +``` + +**Подсказки по реализации** + +* Во избежание переполнения берите результат по модулю `p` после каждой арифметической операции. +* Для отрицательных чисел применяйте `((a % p) + p) % p`. +* Не извлекайте подстроки посимвольно без необходимости — это может привести к превышению времени или памяти. + Используйте скользящий хеш. diff --git a/src/hash_tables/__init__.py b/src/hash_tables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hash_tables/chain_hashing.py b/src/hash_tables/chain_hashing.py new file mode 100644 index 0000000..ce4140b --- /dev/null +++ b/src/hash_tables/chain_hashing.py @@ -0,0 +1,71 @@ +from collections import deque +from collections.abc import Callable +from contextlib import suppress + +INITIAL_CAPACITY = 8 +P = 1_000_000_007 +X = 263 + + +class ChainingSet[T]: + """Хеш-таблица с методом цепочек (закрытая адресация).""" + + def __init__(self, hash_function: Callable[[T], int], capacity: int = INITIAL_CAPACITY) -> None: + self.hash_function = hash_function + self.capacity = capacity + # Основной массив цепочек: каждая ячейка — deque для O(1) вставки в начало + self.table: list[deque[T]] = [deque() for _ in range(capacity)] + + def add(self, x: T) -> None: + """Добавить элемент. Если уже есть — ничего не делать.""" + idx = self.hash_function(x) % self.capacity + chain = self.table[idx] + if x not in chain: + chain.appendleft(x) # добавление в начало цепочки + + def delete(self, x: T) -> None: + """Удалить элемент. Если элемента нет — ничего не делать.""" + idx = self.hash_function(x) % self.capacity + chain = self.table[idx] + with suppress(ValueError): # элемент не найден — молча выходим + chain.remove(x) + + def find(self, x: T) -> bool: + """Проверить наличие элемента.""" + idx = self.hash_function(x) % self.capacity + return x in self.table[idx] + + def get_chain(self, i: int) -> str: + """Вернуть содержимое i-й цепочки через пробел (как в условии).""" + if 0 <= i < self.capacity: + return " ".join(str(x) for x in self.table[i]) + return "" + + +def h(s: str) -> int: + result = 0 + power = 1 + for ch in s: + result = (result + ord(ch) * power) % P + power = (power * X) % P + return result + + +def chain_hashing(m: int, queries: list[tuple[str, str]]) -> list[str]: + table = ChainingSet[str](h, m) + output: list[str] = [] + + for command, arg in queries: + if command == "add": + table.add(arg) + elif command == "del": + table.delete(arg) + elif command == "find": + output.append("yes" if table.find(arg) else "no") + elif command == "check": + idx = int(arg) + output.append(table.get_chain(idx)) + else: + raise NotImplementedError(f"Неизвестная команда: {command}") + + return output diff --git a/src/hash_tables/chaining.py b/src/hash_tables/chaining.py new file mode 100644 index 0000000..78cf796 --- /dev/null +++ b/src/hash_tables/chaining.py @@ -0,0 +1,104 @@ +from collections.abc import Callable +from typing import Generic, TypeVar + +K = TypeVar("K") +V = TypeVar("V") + +INITIAL_CAPACITY = 8 +REHASH_FACTOR = 0.75 +GROWTH_FACTOR = 2 + + +class Slot(Generic[K, V]): + """Пара ключ-значение.""" + + def __init__(self, key: K, value: V) -> None: + self.key = key + self.value = value + + +class ChainingHashMap(Generic[K, V]): + """ + HashMap с закрытой адресацией (chaining). + Каждая ячейка — список (цепочка коллизий). + """ + + def __init__(self, hash_function: Callable[[K], int], capacity: int = INITIAL_CAPACITY) -> None: + self.hash_function = hash_function + self.capacity = capacity + self.size = 0 + + # массив бакетов (каждый бакет — список Entry) + self.table: list[list[Slot[K, V]]] = [[] for _ in range(capacity)] + + # ------------------------- + # HASH UTIL + # ------------------------- + + def _index(self, key: K) -> int: + """Вычисление индекса бакета.""" + return self.hash_function(key) % self.capacity + + # ------------------------- + # PUBLIC API + # ------------------------- + + def add(self, key: K, value: V) -> None: + """Вставка или обновление значения.""" + if self.size / self.capacity >= REHASH_FACTOR: + self._rehash() + + index = self._index(key) + bucket = self.table[index] + + # проверяем, есть ли ключ уже + for entry in bucket: + if entry.key == key: + entry.value = value + return + + # если нет — добавляем новый + bucket.append(Slot(key, value)) + self.size += 1 + + def get(self, key: K, default: V | None = None) -> V | None: + """Получение значения по ключу.""" + index = self._index(key) + bucket = self.table[index] + + for entry in bucket: + if entry.key == key: + return entry.value + + return default + + def delete(self, key: K) -> None: + """Удаление ключа.""" + index = self._index(key) + bucket = self.table[index] + + for i, entry in enumerate(bucket): + if entry.key == key: + bucket.pop(i) + self.size -= 1 + return + + raise KeyError(key) + + # ------------------------- + # REHASH + # ------------------------- + + def _rehash(self) -> None: + """ + Увеличение таблицы и перераспределение элементов. + """ + old_table = self.table + + self.capacity *= GROWTH_FACTOR + self.table = [[] for _ in range(self.capacity)] + self.size = 0 + + for bucket in old_table: + for entry in bucket: + self.add(entry.key, entry.value) diff --git a/src/hash_tables/contact_book.py b/src/hash_tables/contact_book.py new file mode 100644 index 0000000..0117d6e --- /dev/null +++ b/src/hash_tables/contact_book.py @@ -0,0 +1,45 @@ +import contextlib + +from src.hash_tables.direct_address import DirectAddressMap +from src.hash_tables.open_address import OpenAddressHashMap + +MAX_NUMBER = 10_000_000 +PRIME = 11 + + +def h(key: int) -> int: + return key * PRIME + + +def contact_book_direct_address(queries: list[tuple[str, str, str]]) -> list[str]: + d = DirectAddressMap[str](MAX_NUMBER) + result: list[str] = [] + for query in queries: + command = query[0] + if command == "add": + d.add(int(query[1]), query[2]) + elif command == "find": + result.append(d.get(int(query[1]), "not found")) + elif command == "del": + with contextlib.suppress(KeyError): + d.delete(int(query[1])) + else: + raise NotImplementedError + return result + + +def contact_book_open_address(queries: list[tuple[str, str, str]]) -> list[str]: + d = OpenAddressHashMap[int, str](h, len(queries)) + result: list[str] = [] + for query in queries: + command = query[0] + if command == "add": + d.add(int(query[1]), query[2]) + elif command == "find": + result.append(d.get(int(query[1]), "not found")) + elif command == "del": + with contextlib.suppress(KeyError): + d.delete(int(query[1])) + else: + raise NotImplementedError + return result diff --git a/src/hash_tables/direct_address.py b/src/hash_tables/direct_address.py new file mode 100644 index 0000000..d518a35 --- /dev/null +++ b/src/hash_tables/direct_address.py @@ -0,0 +1,42 @@ +from typing import cast, overload + +_EMPTY = object() +"""Уникальный маркер \"ячейка пуста\"""" + + +class DirectAddressMap[V]: + def __init__(self, size: int) -> None: + if size < 0: + raise ValueError("max_key must be non-negative") + self._data: list[V | object] = [_EMPTY] * size + + def add(self, key: int, value: V) -> None: + """Сохраняет значение по ключу (перезаписывает, если уже существует).""" + self._check_key(key) + self._data[key] = value + + @overload + def get(self, key: int, default: None = None) -> V | None: ... + + @overload + def get(self, key: int, default: V) -> V: ... + + def get(self, key: int, default: V | None = None) -> V | None: + """Возвращает значение по ключу, или default (None) если ключ отсутствует.""" + self._check_key(key) + entry = self._data[key] + if entry is _EMPTY: + return default + return cast(V, entry) # гарантированно не _EMPTY + + def delete(self, key: int) -> None: + """Удаляет ключ; если ключ отсутствует — KeyError.""" + self._check_key(key) + if self._data[key] is _EMPTY: + raise KeyError(key) + self._data[key] = _EMPTY + + def _check_key(self, key: int) -> None: + """Проверяет, что ключ в допустимом диапазоне.""" + if not (0 <= key < len(self._data)): + raise KeyError(key) diff --git a/src/hash_tables/hash_table_emulation.py b/src/hash_tables/hash_table_emulation.py new file mode 100644 index 0000000..c058aed --- /dev/null +++ b/src/hash_tables/hash_table_emulation.py @@ -0,0 +1,4 @@ +""" +https://contest.yandex.ru/contest/31463/problems/B/ + +""" diff --git a/src/hash_tables/open_address.py b/src/hash_tables/open_address.py new file mode 100644 index 0000000..408820e --- /dev/null +++ b/src/hash_tables/open_address.py @@ -0,0 +1,178 @@ +from collections.abc import Callable +from enum import StrEnum, auto +from typing import Generic, Literal, TypeVar, overload + +K = TypeVar("K") +V = TypeVar("V") + +REHASH_FACTOR = 0.75 +INITIAL_CAPACITY = 8 +GROWTH_FACTOR = 2 + + +class SlotStatus(StrEnum): + EMPTY = auto() + """Никогда не использовался""" + ACTIVE = auto() + """Содержит пару key-value""" + DELETED = auto() + """Был удалён (tombstone)""" + + +class Slot(Generic[K, V]): + """Ячейка хеш-таблицы.""" + + def __init__(self) -> None: + self.status = SlotStatus.EMPTY + self.key: K | None = None + self.value: V | None = None + + def is_active(self) -> bool: + return self.status == SlotStatus.ACTIVE + + def is_empty(self) -> bool: + return self.status == SlotStatus.EMPTY + + def is_deleted(self) -> bool: + return self.status == SlotStatus.DELETED + + def set(self, key: K, value: V) -> None: + self.status = SlotStatus.ACTIVE + self.key = key + self.value = value + + def delete(self) -> None: + self.status = SlotStatus.DELETED + self.key = None + self.value = None + + +class OpenAddressHashMap(Generic[K, V]): + """ + HashMap с открытой адресацией и линейным пробированием. + """ + + def __init__(self, hash_function: Callable[[K], int], capacity: int = INITIAL_CAPACITY) -> None: + self.hash_function = hash_function + self.capacity = capacity + self.size = 0 + self.table: list[Slot[K, V]] = [Slot() for _ in range(capacity)] + + # ------------------------- + # PUBLIC API + # ------------------------- + + def add(self, key: K, value: V) -> None: + """Вставка или обновление значения.""" + if self.size / self.capacity >= REHASH_FACTOR: + self._rehash() + + index, found = self._probe_for_insert(key) + + if not found: + self.size += 1 + + self.table[index].set(key, value) + + @overload + def get(self, key: K, default: Literal[None] = None) -> V | None: ... + + @overload + def get(self, key: K, default: V) -> V: ... + + def get(self, key: K, default: V | None = None) -> V | None: + """Получение значения по ключу.""" + index = self._probe_for_search(key) + if index is None: + return default + return self.table[index].value + + def delete(self, key: K) -> None: + """Удаление ключа (tombstone).""" + index = self._probe_for_search(key) + if index is None: + raise KeyError(key) + + if self.table[index].is_active(): + self.table[index].delete() + self.size -= 1 + + # ------------------------- + # PROBING LOGIC + # ------------------------- + + def _base_index(self, key: K) -> int: + """Начальный индекс.""" + return self.hash_function(key) % self.capacity + + def _probe_for_search(self, key: K) -> int | None: + """ + Поиск существующего ключа. + Возвращает индекс или None. + """ + index = self._base_index(key) + + for _ in range(self.capacity): + slot = self.table[index] + + if slot.is_empty(): + return None # ключ точно не существует + + if slot.is_active() and slot.key == key: + return index + + index = (index + 1) % self.capacity + + return None + + def _probe_for_insert(self, key: K) -> tuple[int, bool]: + """ + Поиск позиции для вставки. + Возвращает: + - индекс + - найден ли уже ключ (для update vs insert) + """ + index = self._base_index(key) + + first_deleted: int | None = None + + for _ in range(self.capacity): + slot = self.table[index] + + # ключ уже существует → обновление + if slot.is_active() and slot.key == key: + return index, True + + # запоминаем первый tombstone + if slot.is_deleted() and first_deleted is None: + first_deleted = index + + # пустая ячейка — можем вставить сюда + if slot.is_empty(): + return (first_deleted if first_deleted is not None else index), False + + index = (index + 1) % self.capacity + + # таблица "закольцована" + return (first_deleted if first_deleted is not None else index), False + + # ------------------------- + # REHASH + # ------------------------- + + def _rehash(self) -> None: + """ + Увеличение таблицы и перераспределение элементов. + """ + old_table = self.table + + self.capacity *= GROWTH_FACTOR + self.table = [Slot() for _ in range(self.capacity)] + self.size = 0 + + for slot in old_table: + if slot.is_active(): + # вставляем заново через add (уже в новой таблице) + assert slot.key is not None + assert slot.value is not None + self.add(slot.key, slot.value) diff --git a/src/hash_tables/pattern_search.py b/src/hash_tables/pattern_search.py new file mode 100644 index 0000000..47de6fb --- /dev/null +++ b/src/hash_tables/pattern_search.py @@ -0,0 +1,35 @@ +INITIAL_P = 1_000_000_007 +INITIAL_X = 263 + + +def rabin_karp(pattern: str, text: str, p: int = INITIAL_P, x: int = INITIAL_X) -> list[int]: + n, m = len(text), len(pattern) + + if m > n: + return [] + + # precompute x^(m-1) % p + x_pow = pow(x, m - 1, p) + + # hash pattern + pattern_hash = 0 + for c in pattern: + pattern_hash = (pattern_hash * x + ord(c)) % p + + # hash first window + window_hash = 0 + for i in range(m): + window_hash = (window_hash * x + ord(text[i])) % p + + result = [] + + for i in range(n - m + 1): + # проверка совпадения + if window_hash == pattern_hash and text[i : i + m] == pattern: # защита от коллизий + result.append(i) + + # rolling hash + if i < n - m: + window_hash = ((window_hash - ord(text[i]) * x_pow) * x + ord(text[i + m])) % p + + return result diff --git a/src/heaps/priority_queue.py b/src/heaps/priority_queue.py index 51eb75c..668ea49 100644 --- a/src/heaps/priority_queue.py +++ b/src/heaps/priority_queue.py @@ -1,11 +1,6 @@ -""" -Макс-куча -""" - - class PriorityQueue: def __init__(self, arr: list[int] | None = None) -> None: - self.arr: list[int] = [] if arr is None else arr + self.arr: list[int] = [] if arr is None else arr.copy() self.size = len(self.arr) self.heapify() diff --git a/tests/test_hash_tables/__init__.py b/tests/test_hash_tables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_hash_tables/test_chain_hashing.py b/tests/test_hash_tables/test_chain_hashing.py new file mode 100644 index 0000000..13f2236 --- /dev/null +++ b/tests/test_hash_tables/test_chain_hashing.py @@ -0,0 +1,234 @@ +from collections import deque + +import pytest + +from src.hash_tables.chain_hashing import ChainingSet, chain_hashing, h + + +# ============================== +# Тесты для хеш-функции h +# ============================== +def test_hash_function_deterministic() -> None: + assert h("abc") == h("abc") + + +def test_hash_function_different_strings() -> None: + # Ожидаем, что разные строки дают разные значения (не обязательно, но вероятно) + # Здесь просто проверяем, что функция не падает и возвращает целое + assert isinstance(h("hello"), int) + + +def test_hash_function_empty_string() -> None: + assert h("") == 0 # по логике схема Горнера с пустой строкой даст 0 + + +# ============================== +# Тесты для ChainingSet +# ============================== +class TestChainingSetBasic: + @pytest.fixture() + def empty_set(self) -> ChainingSet[str]: + return ChainingSet[str](h, capacity=4) + + def test_empty_table_creation(self, empty_set: ChainingSet[str]) -> None: + assert empty_set.capacity == 4 + assert len(empty_set.table) == 4 + assert all(isinstance(chain, deque) for chain in empty_set.table) + assert all(len(chain) == 0 for chain in empty_set.table) + + def test_add_single(self, empty_set: ChainingSet[str]) -> None: + empty_set.add("abc") + assert empty_set.find("abc") is True + assert empty_set.find("xyz") is False + + def test_add_duplicate_ignored(self, empty_set: ChainingSet[str]) -> None: + empty_set.add("abc") + empty_set.add("abc") + # Размер цепочки должен остаться 1 + idx = h("abc") % 4 + assert len(empty_set.table[idx]) == 1 + + def test_add_multiple_same_hash(self, empty_set: ChainingSet[str]) -> None: + # Используем модифицированную хеш-функцию с постоянным значением + const_set = ChainingSet[str](lambda s: 0, capacity=4) + const_set.add("first") + const_set.add("second") + const_set.add("third") + idx = 0 + chain = const_set.table[idx] + assert len(chain) == 3 + # Порядок: добавлялись first, second, third; каждый в начало. + # После всех вставок должно быть: third, second, first + assert list(chain) == ["third", "second", "first"] + + def test_find_existing(self, empty_set: ChainingSet[str]) -> None: + empty_set.add("key") + assert empty_set.find("key") is True + + def test_find_non_existing(self, empty_set: ChainingSet[str]) -> None: + assert empty_set.find("ghost") is False + + def test_delete_existing(self, empty_set: ChainingSet[str]) -> None: + empty_set.add("to_delete") + empty_set.delete("to_delete") + assert empty_set.find("to_delete") is False + + def test_delete_non_existing_silent(self, empty_set: ChainingSet[str]) -> None: + # Не должно вызывать исключения + empty_set.delete("ghost") + # Состояние не меняется + assert empty_set.find("ghost") is False + + def test_delete_from_empty_chain(self, empty_set: ChainingSet[str]) -> None: + # Даже если цепочка пуста, удаление не вызывает ошибки + empty_set.delete("anything") + assert True # не упали + + def test_get_chain_normal(self, empty_set: ChainingSet[str]) -> None: + empty_set.add("a") + empty_set.add("b") + # a и b могут оказаться в разных цепочках; проверим содержимое + # Лучше создать контролируемую хеш-функцию + const_set = ChainingSet[str](lambda s: 0, capacity=2) + const_set.add("1") + const_set.add("2") + const_set.add("3") + assert const_set.get_chain(0) == "3 2 1" + assert const_set.get_chain(1) == "" + + def test_get_chain_out_of_bounds(self, empty_set: ChainingSet[str]) -> None: + assert empty_set.get_chain(-1) == "" + assert empty_set.get_chain(4) == "" + assert empty_set.get_chain(100) == "" + + def test_get_chain_non_string_elements(self) -> None: + int_set = ChainingSet[int](lambda x: x % 2, capacity=2) + int_set.add(10) + int_set.add(20) + # проверяем str(x) в выводе + assert int_set.get_chain(0) == "20 10" # 20 и 10 четные, порядок: 20 добавлен позже -> в начало + + def test_delete_removes_only_one_occurrence(self, empty_set: ChainingSet[str]) -> None: + # Так как дубликаты не добавляются, проверяем, что remove удаляет именно этот элемент + const_set = ChainingSet[str](lambda s: 0, capacity=1) + const_set.add("x") + const_set.add("y") + const_set.delete("x") + assert list(const_set.table[0]) == ["y"] + + def test_capacity_initialization_default(self) -> None: + s = ChainingSet[str](h) + assert s.capacity == 8 + + +# ============================== +# Тесты для chain_hashing (интеграционные) +# ============================== +class TestChainHashingIntegration: + def test_empty_queries(self) -> None: + assert chain_hashing(5, []) == [] + + def test_unknown_command_raises(self) -> None: + with pytest.raises(NotImplementedError): + chain_hashing(5, [("invalid", "data")]) + + def test_example_from_problem(self) -> None: + m = 5 + queries = [ + ("add", "world"), + ("add", "HellO"), + ("check", "4"), + ("find", "World"), + ("find", "world"), + ("del", "world"), + ("check", "4"), + ("del", "HellO"), + ("add", "luck"), + ("add", "GooD"), + ("check", "2"), + ("del", "good"), + ] + result = chain_hashing(m, queries) + expected = [ + "HellO world", + "no", + "yes", + "HellO", + "GooD luck", + ] + assert result == expected + + def test_second_example_problem(self) -> None: + m = 4 + queries = [ + ("add", "test"), + ("add", "test"), + ("find", "test"), + ("del", "test"), + ("find", "test"), + ("find", "Test"), + ("add", "Test"), + ("find", "Test"), + ] + result = chain_hashing(m, queries) + expected = [ + "yes", + "no", + "no", + "yes", + ] + assert result == expected + + def test_third_example_problem(self) -> None: + m = 3 + queries = [ + ("check", "0"), + ("find", "help"), + ("add", "help"), + ("add", "del"), + ("add", "add"), + ("find", "add"), + ("find", "del"), + ("del", "del"), + ("find", "del"), + ("check", "0"), + ("check", "1"), + ("check", "2"), + ] + result = chain_hashing(m, queries) + expected = [ + "", + "no", + "yes", + "yes", + "no", + "", + "add help", + "", + ] + assert result == expected + + def test_check_boundaries(self) -> None: + # m=1 + queries = [("check", "0"), ("add", "a"), ("check", "0"), ("check", "1")] + result = chain_hashing(1, queries) + assert result == ["", "a", ""] # check 1 за границей -> "" + + def test_add_and_find_multiple(self) -> None: + m = 10 + queries = [("add", f"key{i}") for i in range(20)] + queries += [("find", f"key{i}") for i in range(20)] + queries += [("find", "missing")] + expected = ["yes"] * 20 + ["no"] + chain_hashing(m, queries[20:]) # только find команды + # нужно собрать все find команды + all_queries = queries + find_results = chain_hashing(m, all_queries) + # первые 20 add ничего не выводят + assert find_results == expected + + def test_del_ignore_missing(self) -> None: + m = 5 + queries = [("del", "x"), ("find", "x"), ("add", "x"), ("find", "x")] + result = chain_hashing(m, queries) + assert result == ["no", "yes"] diff --git a/tests/test_hash_tables/test_chaining.py b/tests/test_hash_tables/test_chaining.py new file mode 100644 index 0000000..79b4d7f --- /dev/null +++ b/tests/test_hash_tables/test_chaining.py @@ -0,0 +1,276 @@ +import pytest + +# Предполагаем, что класс ChainingHashMap определён в модуле src.hash_tables.chaining +from src.hash_tables.chaining import ChainingHashMap + +# ============================== +# Вспомогательные хеш-функции +# ============================== + + +def hash_identity(key: int) -> int: + """Хеш-функция, возвращающая сам ключ.""" + return key + + +def hash_constant(key: int) -> int: + """Хеш-функция, всегда возвращающая 0 (максимальные коллизии).""" + return 0 + + +def hash_mod_10(key: int) -> int: + """Пример хеш-функции с ограниченным диапазоном.""" + return key % 10 + + +# ============================== +# Тесты +# ============================== + + +class TestChainingHashMap: + # ---------------------------------------------------------------- + # Базовая функциональность + # ---------------------------------------------------------------- + + def test_empty_map(self) -> None: + m = ChainingHashMap[int, str](hash_identity, capacity=4) + assert m.size == 0 + assert m.get(1) is None + # Проверка, что delete выбрасывает KeyError + with pytest.raises(KeyError): + m.delete(1) + + @pytest.mark.parametrize("hash_func", [hash_identity, hash_constant]) + def test_add_and_get(self, hash_func) -> None: + m = ChainingHashMap[int, str](hash_func, capacity=8) + m.add(10, "Alice") + assert m.get(10) == "Alice" + assert m.size == 1 + + @pytest.mark.parametrize("hash_func", [hash_identity, hash_constant]) + def test_add_update_value(self, hash_func) -> None: + m = ChainingHashMap[int, str](hash_func, capacity=4) + m.add(42, "old") + m.add(42, "new") + assert m.get(42) == "new" + # Размер не должен измениться при обновлении + assert m.size == 1 + + @pytest.mark.parametrize("hash_func", [hash_identity, hash_constant]) + def test_delete_existing(self, hash_func) -> None: + m = ChainingHashMap[int, str](hash_func, capacity=4) + m.add(7, "seven") + m.delete(7) + assert m.get(7) is None + assert m.size == 0 + with pytest.raises(KeyError): + m.delete(7) + + def test_delete_non_existing(self) -> None: + m = ChainingHashMap[int, str](hash_identity, capacity=4) + with pytest.raises(KeyError) as exc_info: + m.delete(99) + # Проверяем, что исключение содержит ключ + assert exc_info.value.args[0] == 99 + + @pytest.mark.parametrize("hash_func", [hash_identity, hash_constant]) + def test_multiple_operations(self, hash_func) -> None: + m = ChainingHashMap[int, str](hash_func, capacity=4) + # Добавляем три элемента + m.add(1, "one") + m.add(2, "two") + m.add(3, "three") + assert m.size == 3 + + # Обновляем один + m.add(2, "TWO") + assert m.get(2) == "TWO" + assert m.size == 3 # размер не изменился + + # Удаляем другой + m.delete(3) + assert m.get(3) is None + assert m.size == 2 + + # Пытаемся удалить несуществующий + with pytest.raises(KeyError): + m.delete(3) + + # Проверяем оставшиеся + assert m.get(1) == "one" + assert m.get(2) == "TWO" + + # ---------------------------------------------------------------- + # Коллизии + # ---------------------------------------------------------------- + + def test_collision_same_bucket(self) -> None: + """Все ключи попадают в бакет 0, цепочка растёт.""" + m = ChainingHashMap[int, str](hash_constant, capacity=4) + m.add(5, "five") + m.add(8, "eight") + m.add(13, "thirteen") + assert m.size == 3 + # Все три должны быть доступны + assert m.get(5) == "five" + assert m.get(8) == "eight" + assert m.get(13) == "thirteen" + + # Удаляем элемент из середины цепочки + m.delete(8) + assert m.get(8) is None + assert m.size == 2 + # Оставшиеся на месте + assert m.get(5) == "five" + assert m.get(13) == "thirteen" + + def test_collision_with_different_hash_values(self) -> None: + """Коллизия из-за остатка от деления на capacity.""" + # При capacity=4 hash=1 и hash=5 оба дают индекс 1. + m = ChainingHashMap[int, str](lambda k: k, capacity=4) + m.add(1, "one") + m.add(5, "five") + assert m.get(1) == "one" + assert m.get(5) == "five" + # Удаляем первый, второй остаётся + m.delete(1) + assert m.get(1) is None + assert m.get(5) == "five" + assert m.size == 1 + + # ---------------------------------------------------------------- + # Рехэширование + # ---------------------------------------------------------------- + + def test_rehash_triggers(self) -> None: + """При факторе загрузки >= 0.75 должно происходить расширение.""" + # Начальная capacity = 4, REHASH_FACTOR = 0.75 => порог size=3. + m = ChainingHashMap[int, str](lambda k: k, capacity=4) + # Добавляем 3 элемента – рехэша ещё нет (size/capacity = 0.75) + for i in range(3): + m.add(i, str(i)) + assert m.capacity == 4 + assert m.size == 3 + + # Четвёртый элемент вызывает рехэш (size станет 4, capacity станет 8) + m.add(3, "three") + assert m.capacity == 8 + assert m.size == 4 + + # Все предыдущие должны быть доступны + for i in range(3): + assert m.get(i) == str(i) + assert m.get(3) == "three" + assert m.get(99) is None + + def test_rehash_preserves_collisions(self) -> None: + """После рехэша элементы из переполненных цепочек остаются доступны.""" + # Все ключи идут в бакет 0 из-за хеш-функции, вызываем рехэш. + m = ChainingHashMap[int, str](hash_constant, capacity=4) + for i in range(10): + m.add(i, f"val{i}") + assert m.capacity > 4 # точно был рехэш + assert m.size == 10 + for i in range(10): + assert m.get(i) == f"val{i}" + + # Удаление пары элементов после рехэша + m.delete(0) + m.delete(9) + assert m.get(0) is None + assert m.get(9) is None + assert m.size == 8 + for i in range(1, 9): + assert m.get(i) == f"val{i}" + + def test_rehash_with_low_initial_capacity(self) -> None: + """Проверка поведения при начальной capacity=1.""" + m = ChainingHashMap[int, str](hash_identity, capacity=1) + # Первое добавление: size=0, 0/1 < 0.75, рехэша нет. + m.add(1, "one") + assert m.capacity == 1 # ёмкость не изменилась + assert m.size == 1 + assert m.get(1) == "one" + + # Второе добавление: size=1, 1/1 >= 0.75, срабатывает рехэш до вставки. + m.add(2, "two") + assert m.capacity == 2 # увеличилась в 2 раза + assert m.size == 2 + assert m.get(1) == "one" + assert m.get(2) == "two" + + # ---------------------------------------------------------------- + # Различные типы ключей и значений + # ---------------------------------------------------------------- + + def test_string_keys(self) -> None: + """Проверка работы со строками.""" + + def str_len_hash(s: str) -> int: + return len(s) + + m = ChainingHashMap[str, float](str_len_hash, capacity=8) + m.add("hello", 1.23) + m.add("world", 4.56) + assert m.get("hello") == 1.23 + assert m.get("world") == 4.56 + m.delete("hello") + assert m.get("hello") is None + with pytest.raises(KeyError): + m.delete("hello") + + def test_custom_class_key(self) -> None: + """Проверка, что класс корректно работает с ключами-объектами.""" + + class Point: + def __init__(self, x: int, y: int) -> None: + self.x = x + self.y = y + + def __eq__(self, other) -> bool: + return self.x == other.x and self.y == other.y + + def __hash__(self) -> int: + return self.x + self.y + + def point_hash(p: Point) -> int: + return hash(p) + + m = ChainingHashMap[Point, str](point_hash, capacity=4) + p1 = Point(1, 2) + p2 = Point(3, 4) + m.add(p1, "first") + m.add(p2, "second") + assert m.get(p1) == "first" + # Ключ с такими же координатами должен считаться тем же (если __eq__ и hash) + assert m.get(Point(1, 2)) == "first" + m.delete(p2) + assert m.get(p2) is None + assert m.size == 1 + + # ---------------------------------------------------------------- + # Краевые случаи + # ---------------------------------------------------------------- + + def test_large_number_of_items(self) -> None: + """Стресс-тест: 1000 элементов с постоянной хеш-функцией.""" + m = ChainingHashMap[int, int](hash_constant, capacity=4) + n = 1000 + for i in range(n): + m.add(i, i * 10) + assert m.size == n + # Выборочная проверка + for i in range(0, n, 100): + assert m.get(i) == i * 10 + + # Удалим все чётные + for i in range(0, n, 2): + m.delete(i) + assert m.size == n // 2 + # Проверим, что нечётные остались + for i in range(1, n, 2): + assert m.get(i) == i * 10 + # Попытка удалить уже удалённый + with pytest.raises(KeyError): + m.delete(0) diff --git a/tests/test_hash_tables/test_contact_book.py b/tests/test_hash_tables/test_contact_book.py new file mode 100644 index 0000000..5bbbbea --- /dev/null +++ b/tests/test_hash_tables/test_contact_book.py @@ -0,0 +1,190 @@ +from collections.abc import Callable + +import pytest + +from src.hash_tables.contact_book import contact_book_direct_address, contact_book_open_address + +MAX_NUMBER = 10_000_000 +PRIME = 11 +FUNC_T = Callable[[list[tuple[str, str, str]]], list[str]] + + +# Параметризуем обе реализации +@pytest.mark.parametrize("func", [contact_book_direct_address, contact_book_open_address]) +class TestContactBook: + # Пустой список запросов + def test_empty(self, func: FUNC_T) -> None: + assert func([]) == [] + + # Базовый сценарий из условия + def test_basic_scenario(self, func: FUNC_T) -> None: + queries = [ + ("add", "911", "police"), + ("add", "76213", "Mom"), + ("add", "17239", "Bob"), + ("find", "76213", ""), + ("find", "910", ""), + ("find", "911", ""), + ("del", "910", ""), + ("del", "911", ""), + ("find", "911", ""), + ("find", "76213", ""), + ("add", "76213", "daddy"), + ("find", "76213", ""), + ] + expected = [ + "Mom", + "not found", + "police", + "not found", + "Mom", + "daddy", + ] + assert func(queries) == expected + + # Добавление и поиск нескольких номеров + def test_add_and_find_multiple(self, func: FUNC_T) -> None: + queries = [ + ("add", "1", "Alice"), + ("add", "2", "Bob"), + ("add", "3", "Charlie"), + ("find", "1", ""), + ("find", "2", ""), + ("find", "3", ""), + ("find", "4", ""), + ] + expected = ["Alice", "Bob", "Charlie", "not found"] + assert func(queries) == expected + + # Замена имени при повторном добавлении + def test_add_replaces_name(self, func: FUNC_T) -> None: + queries = [ + ("add", "42", "Old"), + ("add", "42", "New"), + ("find", "42", ""), + ] + expected = ["New"] + assert func(queries) == expected + + # Удаление существующего номера + def test_delete_existing(self, func: FUNC_T) -> None: + queries = [ + ("add", "100", "Test"), + ("find", "100", ""), + ("del", "100", ""), + ("find", "100", ""), + ] + expected = ["Test", "not found"] + assert func(queries) == expected + + # Удаление несуществующего номера не влияет на остальные записи + def test_delete_nonexisting(self, func: FUNC_T) -> None: + queries = [ + ("add", "55", "Keeper"), + ("del", "999", ""), + ("find", "55", ""), + ("find", "999", ""), + ] + expected = ["Keeper", "not found"] + assert func(queries) == expected + + # Двойное удаление одного номера + def test_double_delete(self, func: FUNC_T) -> None: + queries = [ + ("add", "5", "Five"), + ("del", "5", ""), + ("del", "5", ""), + ("find", "5", ""), + ] + expected = ["not found"] + assert func(queries) == expected + + # Работа с граничными номерами (0 и MAX_NUMBER-1) + def test_boundary_numbers(self, func: FUNC_T) -> None: + max_num = MAX_NUMBER - 1 + queries = [ + ("add", "0", "Zero"), + ("add", str(max_num), "Max"), + ("find", "0", ""), + ("find", str(max_num), ""), + ("find", "1", ""), + ("del", "0", ""), + ("del", str(max_num), ""), + ("find", "0", ""), + ("find", str(max_num), ""), + ] + expected = [ + "Zero", + "Max", + "not found", + "not found", + "not found", + ] + assert func(queries) == expected + + # Смешанные операции: чередование add/del/find + def test_mixed_operations(self, func: FUNC_T) -> None: + queries = [ + ("add", "10", "a"), + ("find", "10", ""), + ("add", "10", "b"), + ("find", "10", ""), + ("del", "10", ""), + ("find", "10", ""), + ("add", "20", "c"), + ("del", "10", ""), # уже удалён + ("find", "20", ""), + ] + expected = ["a", "b", "not found", "c"] + assert func(queries) == expected + + # Много запросов (на запас прочности) + def test_many_queries(self, func: FUNC_T) -> None: + queries = [] + expected = [] + # Добавим 50 номеров + for i in range(50): + queries.append(("add", str(i), f"name{i}")) # noqa: PERF401 + # Поиск 25 добавленных + 5 несуществующих + for i in range(25): + queries.append(("find", str(i), "")) + expected.append(f"name{i}") + for i in range(50, 55): + queries.append(("find", str(i), "")) + expected.append("not found") + # Удалим 10 номеров + for i in range(10): + queries.append(("del", str(i), "")) # noqa: PERF401 + # Снова поиск удалённых и оставшихся + for i in range(15): + queries.append(("find", str(i), "")) + if i < 10: + expected.append("not found") + else: + expected.append(f"name{i}") + assert func(queries) == expected + + # Проверка, что при открытой адресации коллизии не ломают логику + # (хеш-функция h(key) = key * 11 всегда даёт коллизию для ключей 1 и 6 при размере 5) + def test_collision_handling(self, func: FUNC_T) -> None: + # Для прямого адреса коллизий нет, тест всё равно корректен + # Готовим запросы с capacity = 5 (len(queries) будет 7, но внутри open address + # передаётся len(queries) как размер таблицы) + queries = [ + ("add", "1", "One"), + ("add", "6", "Six"), # h(1)=11%7=4, h(6)=66%7=3? Разные. Создадим коллизию иначе. + ] + # Подберём ключи так, чтобы h(key) % len(queries) совпадали. + # Пусть len(queries) = 5, тогда h(k)=k*11 %5. h(1)=1, h(6)=11%5=1 -> коллизия. + # Для этого сформируем список запросов длиной 5. + queries = [ + ("add", "1", "First"), + ("add", "6", "Second"), # оба h%5=1 + ("find", "1", ""), + ("find", "6", ""), + ("find", "0", ""), + ] + expected = ["First", "Second", "not found"] + # Примечание: для прямого адреса этот тест тривиален, для открытой адресации проверяем, + # что оба ключа находятся, несмотря на коллизию. + assert func(queries) == expected diff --git a/tests/test_hash_tables/test_direct_address.py b/tests/test_hash_tables/test_direct_address.py new file mode 100644 index 0000000..7b8abd2 --- /dev/null +++ b/tests/test_hash_tables/test_direct_address.py @@ -0,0 +1,245 @@ +import pytest + +from src.hash_tables.direct_address import DirectAddressMap + + +# --------------------------------------------------------------------------- +# Тесты конструктора +# --------------------------------------------------------------------------- +class TestConstructor: + def test_valid_size(self) -> None: + m = DirectAddressMap[int](5) + assert len(m._data) == 5 + + def test_zero_size(self) -> None: + m = DirectAddressMap[str](0) + assert len(m._data) == 0 + + def test_negative_size_raises(self) -> None: + with pytest.raises(ValueError): # noqa: PT011 + DirectAddressMap[int](-1) + + +# --------------------------------------------------------------------------- +# Тесты add +# --------------------------------------------------------------------------- +class TestAdd: + def test_add_single(self) -> None: + m = DirectAddressMap[int](10) + m.add(3, 42) + assert m.get(3) == 42 + + def test_add_overwrite(self) -> None: + m = DirectAddressMap[int](10) + m.add(3, 42) + m.add(3, 99) + assert m.get(3) == 99 + + def test_add_none_value(self) -> None: + """None должен сохраняться как обычное значение, а не интерпретироваться как отсутствие.""" + m = DirectAddressMap[int | None](10) + m.add(5, None) + assert m.get(5) is None + # Убедимся, что ключ действительно присутствует (не возвращает дефолт) + assert m.get(5, default=123) is None # вернёт None, а не "missing" + + def test_add_out_of_bounds_left(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError): + m.add(-1, 10) + + def test_add_out_of_bounds_right(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError): + m.add(5, 10) # max index = 4 + + def test_add_to_zero_size_map(self) -> None: + m = DirectAddressMap[int](0) + with pytest.raises(KeyError): + m.add(0, 1) + + def test_add_multiple_keys(self) -> None: + m = DirectAddressMap[str](4) + m.add(0, "a") + m.add(3, "b") + assert m.get(0) == "a" + assert m.get(3) == "b" + + +# --------------------------------------------------------------------------- +# Тесты get +# --------------------------------------------------------------------------- +class TestGet: + def test_get_existing(self) -> None: + m = DirectAddressMap[str](5) + m.add(2, "hello") + assert m.get(2) == "hello" + + def test_get_missing_returns_none(self) -> None: + m = DirectAddressMap[int](5) + assert m.get(2) is None + + def test_get_missing_returns_custom_default(self) -> None: + m = DirectAddressMap[int](5) + assert m.get(2, -1) == -1 + + def test_get_with_explicit_none_default(self) -> None: + m = DirectAddressMap[int](5) + assert m.get(2, None) is None + + def test_get_out_of_bounds_left(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError): + m.get(-1) + + def test_get_out_of_bounds_right(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError): + m.get(5) + + def test_get_from_zero_size_map(self) -> None: + m = DirectAddressMap[int](0) + with pytest.raises(KeyError): + m.get(0) + + def test_get_after_delete_returns_none(self) -> None: + m = DirectAddressMap[int](10) + m.add(7, 100) + m.delete(7) + assert m.get(7) is None + + def test_get_after_delete_with_default(self) -> None: + m = DirectAddressMap[int](10) + m.add(7, 100) + m.delete(7) + assert m.get(7, 123) == 123 + + +# --------------------------------------------------------------------------- +# Тесты delete +# --------------------------------------------------------------------------- +class TestDelete: + def test_delete_existing(self) -> None: + m = DirectAddressMap[int](10) + m.add(4, 42) + m.delete(4) + # после удаления ключ не должен находиться + assert m.get(4) is None + + def test_delete_nonexistent_raises_keyerror(self) -> None: + m = DirectAddressMap[int](10) + with pytest.raises(KeyError): + m.delete(5) + + def test_delete_twice_raises_keyerror(self) -> None: + m = DirectAddressMap[int](10) + m.add(1, 10) + m.delete(1) + with pytest.raises(KeyError): + m.delete(1) # уже пусто + + def test_delete_out_of_bounds_left(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError): + m.delete(-1) + + def test_delete_out_of_bounds_right(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError): + m.delete(5) + + def test_delete_from_zero_size_map(self) -> None: + m = DirectAddressMap[int](0) + with pytest.raises(KeyError): + m.delete(0) + + def test_delete_preserves_other_keys(self) -> None: + m = DirectAddressMap[str](10) + m.add(1, "one") + m.add(2, "two") + m.delete(1) + assert m.get(2) == "two" + assert m.get(1) is None + + def test_delete_allows_reinsertion(self) -> None: + """Убедимся, что после удаления ячейка становится полностью пустой + и в неё можно снова добавить значение.""" + m = DirectAddressMap[int](10) + m.add(3, 100) + m.delete(3) + m.add(3, 200) + assert m.get(3) == 200 + + def test_delete_then_store_none(self) -> None: + """После удаления можно сохранить None и получить его.""" + m = DirectAddressMap[int | None](10) + m.add(3, 100) + m.delete(3) + m.add(3, None) + assert m.get(3) is None + assert m.get(3, 123) is None # убеждаемся, что это не default + + +# --------------------------------------------------------------------------- +# Тесты на sentinel и None +# --------------------------------------------------------------------------- +class TestSentinelNoneSeparation: + def test_none_is_not_emtpy(self) -> None: + m = DirectAddressMap[int | None](5) + m.add(0, None) + # ключ существует, поэтому get не возвращает default + assert m.get(0, 123) is None + # удаление существующего ключа с None работает + m.delete(0) + with pytest.raises(KeyError): + m.delete(0) # теперь пусто + + def test_emtpy_slot_returns_default(self) -> None: + m = DirectAddressMap[int | None](5) + # слот пуст, get возвращает default + assert m.get(0, 123) == 123 + # default None работает и возвращает None, но это именно default + assert m.get(0, None) is None + + +# --------------------------------------------------------------------------- +# Тесты на корректность исключений +# --------------------------------------------------------------------------- +class TestExceptions: + def test_keyerror_contains_key(self) -> None: + m = DirectAddressMap[int](5) + with pytest.raises(KeyError) as exc_info: + m.delete(99) + assert exc_info.value.args[0] == 99 + + def test_keyerror_out_of_bounds_contains_key(self) -> None: + m = DirectAddressMap[int](3) + with pytest.raises(KeyError) as exc_info: + m.add(-1, 0) + assert exc_info.value.args[0] == -1 + + +# --------------------------------------------------------------------------- +# Тесты граничных ключей (0 и size-1) +# --------------------------------------------------------------------------- +class TestBoundaryKeys: + def test_key_zero_works(self) -> None: + m = DirectAddressMap[int](10) + m.add(0, 123) + assert m.get(0) == 123 + m.delete(0) + assert m.get(0) is None + + def test_key_max_works(self) -> None: + m = DirectAddressMap[int](10) + max_key = 9 + m.add(max_key, 999) + assert m.get(max_key) == 999 + m.delete(max_key) + with pytest.raises(KeyError): + m.delete(max_key) + + def test_key_max_plus_one_raises(self) -> None: + m = DirectAddressMap[int](5) # keys 0..4 + with pytest.raises(KeyError): + m.add(5, 1) diff --git a/tests/test_hash_tables/test_open_address.py b/tests/test_hash_tables/test_open_address.py new file mode 100644 index 0000000..d8bf26c --- /dev/null +++ b/tests/test_hash_tables/test_open_address.py @@ -0,0 +1,348 @@ +import pytest + +from src.hash_tables.open_address import ( + OpenAddressHashMap, +) + +# --------------------------------------------------------------------------- +# Вспомогательные хэш-функции +# --------------------------------------------------------------------------- + + +def constant_hash(x) -> int: + """Все ключи попадают в одно и то же место — крайняя коллизия.""" + return 0 + + +def identity_hash(x: int) -> int: + """Для целочисленных ключей — сам ключ.""" + return x + + +def negative_hash(x: int) -> int: + """Хэш, возвращающий отрицательные числа (проверка модуля).""" + return -abs(x) + + +# --------------------------------------------------------------------------- +# Тесты базовых операций +# --------------------------------------------------------------------------- + + +class TestBasicOperations: + def test_add_and_get_single(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + m.add(10, "a") + assert m.get(10) == "a" + + def test_get_missing_returns_none(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + assert m.get(1) is None + + def test_get_missing_with_default(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + assert m.get(1, "default") == "default" + # default может быть любым объектом, в том числе None + assert m.get(1, None) is None + # ключ существует — default игнорируется + m.add(1, "value") + assert m.get(1, "default") == "value" + + def test_add_updates_existing(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + m.add(5, "first") + m.add(5, "second") + assert m.get(5) == "second" + # размер не должен увеличиться при обновлении + assert m.size == 1 + + def test_delete_existing(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + m.add(7, "x") + m.delete(7) + assert m.get(7) is None + assert m.size == 0 + + def test_delete_raises_keyerror_for_missing(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + with pytest.raises(KeyError): + m.delete(99) + + def test_delete_twice_raises_keyerror(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + m.add(3, "v") + m.delete(3) + with pytest.raises(KeyError): + m.delete(3) # уже DELETED, не ACTIVE + + def test_size_after_operations(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash) + assert m.size == 0 + m.add(1, "a") + assert m.size == 1 + m.add(2, "b") + assert m.size == 2 + m.add(2, "b2") # обновление + assert m.size == 2 + m.delete(1) + assert m.size == 1 + m.delete(2) + assert m.size == 0 + + +# --------------------------------------------------------------------------- +# Тесты коллизий и линейного пробирования +# --------------------------------------------------------------------------- + + +class TestCollisionsAndProbing: + def test_linear_probing_insert(self) -> None: + # Все ключи получают индекс 0, заполняем подряд + m = OpenAddressHashMap[int, str](constant_hash, capacity=8) + keys = [10, 20, 30, 40, 50] + for k in keys: + m.add(k, str(k)) + # Должны занять слоты 0..4 + for k in keys: + assert m.get(k) == str(k) + # Проверим состояние таблицы + assert m.table[0].key == 10 + assert m.table[1].key == 20 + assert m.table[2].key == 30 + assert m.table[3].key == 40 + assert m.table[4].key == 50 + assert m.table[5].is_empty() + + def test_tombstone_usage(self) -> None: + m = OpenAddressHashMap[int, str](constant_hash, capacity=8) + m.add(1, "one") + m.add(2, "two") + m.add(3, "three") + m.add(4, "four") + m.add(5, "five") # слоты 0..4 заняты + m.delete(2) # слот 1 становится DELETED + m.delete(4) # слот 3 становится DELETED + + # Вставляем новый ключ — должен занять первый tombstone (индекс 1) + m.add(6, "six") + assert m.table[1].key == 6 + assert m.table[1].value == "six" + assert m.table[1].is_active() + # Старый ключ 2 не должен находиться + assert m.get(2) is None + assert m.get(4) is None + # Ключи 1,3,4,5 всё ещё на месте + assert m.get(1) == "one" + assert m.get(3) == "three" + assert m.get(5) == "five" + assert m.get(6) == "six" + + def test_search_stops_at_empty(self) -> None: + """Поиск несуществующего ключа останавливается на EMPTY, + даже если дальше есть DELETED и ACTIVE.""" + m = OpenAddressHashMap[int, str](constant_hash, capacity=8) + m.add(10, "a") + m.add(20, "b") # слот 1 + m.delete(10) # слот 0 -> DELETED + # Теперь слоты: 0 DELETED, 1 ACTIVE(20), 2..7 EMPTY + # Ищем ключ 99 (нет в таблице). Пробирование идёт с 0: + # 0 DELETED -> пропускаем; 1 ACTIVE но ключ 20 != 99; + # 2 EMPTY -> возвращаем None. + assert m.get(99) is None + + def test_probe_wraps_around(self) -> None: + """Проверка закольцовывания probing.""" + m = OpenAddressHashMap[int, str](identity_hash, capacity=8) + # Займём слоты 7, 0, 1 + m.add(7, "a") + m.add(8, "b") # 8 % 8 = 0 -> попадёт в 0 + m.add(9, "c") # 9 % 8 = 1 -> попадёт в 1 + # Теперь добавляем ключ 15 (15 % 8 = 7). Он должен попасть в 2 после пробирования. + m.add(15, "d") + assert m.table[2].key == 15 + assert m.table[2].value == "d" + + +# --------------------------------------------------------------------------- +# Тесты рехеширования +# --------------------------------------------------------------------------- + + +class TestRehash: + def test_rehash_triggers_on_load_factor(self) -> None: + # capacity=8, 6 элементов -> 0.75, следующий add вызовет rehash + m = OpenAddressHashMap[int, str](identity_hash, capacity=8) + for i in range(6): + m.add(i, str(i)) + assert m.capacity == 8 + assert m.size == 6 + + # Добавляем 7-й элемент — должен сработать rehash + m.add(6, "6") + assert m.capacity == 16 # 8 * GROWTH_FACTOR + # Все 7 ключей должны быть доступны + for i in range(7): + assert m.get(i) == str(i) + + def test_rehash_preserves_data(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash, capacity=4) # начнём с маленькой capacity + elements = [(1, "a"), (2, "b"), (3, "c")] + for k, v in elements: + m.add(k, v) + # 3/4 = 0.75, порог достигнут, добавим четвёртый — должен произойти rehash + m.add(4, "d") + assert m.capacity == 8 + for k, v in [*elements, (4, "d")]: + assert m.get(k) == v + # Размер корректен + assert m.size == 4 + + def test_rehash_with_tombstones(self) -> None: + """Rehash не переносит DELETED слоты.""" + m = OpenAddressHashMap[int, str](identity_hash, capacity=8) + for i in range(6): + m.add(i, str(i)) + # Удалим два элемента + m.delete(1) + m.delete(3) + assert m.size == 4 + # Добавим новый, который вызовет rehash (size=5 при вставке? 4 + 1 = 5, + # 5/8=0.625 < 0.75, rehash не будет. Чтобы вызвать rehash, нужно добавить + # больше. Добавим ещё 2 ключа: сейчас size=4, добавим 6 и 7 -> size=6, + # 6/8=0.75, затем добавим 8 -> rehash. + m.add(6, "six") + m.add(7, "seven") + assert m.size == 6 + m.add(8, "eight") # триггерит rehash + assert m.capacity == 16 + # Проверяем наличие всех активных ключей + active_keys = [0, 2, 4, 5, 6, 7, 8] + for k in active_keys: + assert m.get(k) is not None + # Удалённые ключи отсутствуют + assert m.get(1) is None + assert m.get(3) is None + + +# --------------------------------------------------------------------------- +# Тесты поведения с пустой таблицей, только с DELETED, без EMPTY +# --------------------------------------------------------------------------- + + +class TestEdgeCasesWithStatuses: + def test_all_slots_deleted_search(self) -> None: + """Если все слоты DELETED, поиск возвращает None.""" + m = OpenAddressHashMap[int, str](constant_hash, capacity=4) + m.add(1, "a") + m.add(2, "b") + m.add(3, "c") + m.add(4, "d") # все 4 слота заняты + for k in (1, 2, 3, 4): + m.delete(k) + # Теперь все слоты DELETED, size=0 + assert m.size == 0 + # Поиск любого ключа проходит через все слоты и возвращает None + assert m.get(1) is None + assert m.get(99) is None + + def test_insert_into_all_deleted_table(self) -> None: + """Вставка в таблицу, где все слоты DELETED, использует tombstone.""" + m = OpenAddressHashMap[int, str](constant_hash, capacity=4) + for k in range(4): + m.add(k, str(k)) + for k in range(4): + m.delete(k) + # Вставляем новый ключ, size=0 -> фактор 0, rehash не будет + m.add(100, "new") + assert m.size == 1 + # Ключ должен быть доступен + assert m.get(100) == "new" + # Первый же tombstone (индекс 0) стал ACTIVE + assert m.table[0].key == 100 + assert m.table[0].is_active() + + def test_mixed_deleted_and_empty(self) -> None: + """Проверка, что вставка предпочитает первый tombstone, а не EMPTY.""" + m = OpenAddressHashMap[int, str](constant_hash, capacity=4) + m.add(10, "a") + m.add(20, "b") # слот 1 + m.delete(10) # слот 0 -> DELETED + # Теперь: 0 DELETED, 1 ACTIVE(20), 2 EMPTY, 3 EMPTY + m.add(30, "c") # должен занять слот 0, а не 2 + assert m.table[0].key == 30 + assert m.table[0].value == "c" + assert m.table[2].is_empty() + + +# --------------------------------------------------------------------------- +# Тесты на граничные ключи и хэш-функции +# --------------------------------------------------------------------------- + + +class TestSpecialKeysAndHashes: + def test_negative_hash_index(self) -> None: + """Хэш возвращает отрицательное число — модуль должен дать + корректный положительный индекс.""" + m = OpenAddressHashMap[int, str](negative_hash, capacity=8) + m.add(3, "value") # hash = -3, -3 % 8 = 5 + # Проверяем, что ключ доступен + assert m.get(3) == "value" + + def test_none_key(self) -> None: + """Ключ None допустим, если hash_function поддерживает None.""" + m = OpenAddressHashMap[None, str](hash, capacity=8) # встроенный hash умеет None + m.add(None, "none_value") + assert m.get(None) == "none_value" + m.delete(None) + assert m.get(None) is None + + def test_none_value(self) -> None: + """Значения могут быть None.""" + m = OpenAddressHashMap[int, str | None](identity_hash) + m.add(1, None) + assert m.get(1) is None + # Проверяем, что ключ существует (get с default, который не None) + assert m.get(1, "missing") is None # вернёт None, а не "missing" + # Убедимся, что ключ именно присутствует, а не отсутствует + m.add(2, "check") + assert m.get(1, "missing") is None + + def test_string_keys(self) -> None: + m = OpenAddressHashMap[str, str](hash, capacity=8) + m.add("hello", "world") + assert m.get("hello") == "world" + m.add("hello", "updated") + assert m.get("hello") == "updated" + + def test_float_keys(self) -> None: + m = OpenAddressHashMap[float, str](hash) + m.add(3.14, "pi") + assert m.get(3.14) == "pi" + + +# --------------------------------------------------------------------------- +# Дополнительные тесты на внутренние механики (проверка статусов) +# --------------------------------------------------------------------------- + + +class TestSlotStatusIntegrity: + def test_slot_empties_after_delete(self) -> None: + m = OpenAddressHashMap[int, str](identity_hash, capacity=4) + m.add(1, "a") + m.delete(1) + slot = m.table[1 % 4] # индекс зависит от хэша + assert slot.is_deleted() + assert not slot.is_active() + assert slot.key is None + assert slot.value is None + + def test_no_active_slots_after_clear_all(self) -> None: + m = OpenAddressHashMap[int, int](identity_hash, capacity=4) + keys = [0, 4, 8, 12] # займут 0,1,2,3 + for k in keys: + m.add(k, k) + for k in keys: + m.delete(k) + for slot in m.table: + assert not slot.is_active() + assert m.size == 0 diff --git a/tests/test_hash_tables/test_pattern_search.py b/tests/test_hash_tables/test_pattern_search.py new file mode 100644 index 0000000..f05d27e --- /dev/null +++ b/tests/test_hash_tables/test_pattern_search.py @@ -0,0 +1,164 @@ +import random +import string + +# Функция теперь должна принимать p и x (см. ниже) +from src.hash_tables.pattern_search import rabin_karp + + +# ------------------------------------------------------------ +# 1. Базовые проверки +# ------------------------------------------------------------ +def test_simple_case() -> None: + assert rabin_karp("aba", "abacaba") == [0, 4] + + +def test_single_match() -> None: + assert rabin_karp("Test", "testTesttesT") == [4] + + +def test_no_match() -> None: + assert rabin_karp("abc", "defgh") == [] + + +# ------------------------------------------------------------ +# 2. Перекрывающиеся вхождения +# ------------------------------------------------------------ +def test_overlapping() -> None: + assert rabin_karp("aaa", "aaaaa") == [0, 1, 2] + + +def test_overlapping_two_chars() -> None: + assert rabin_karp("aa", "aaa") == [0, 1] + + +def test_full_overlap() -> None: + assert rabin_karp("a", "aaaa") == [0, 1, 2, 3] + + +def test_overlapping_long_pattern() -> None: + assert rabin_karp("aaaaa", "baaaaaaa") == [1, 2, 3] + + +# ------------------------------------------------------------ +# 3. Крайние случаи длин +# ------------------------------------------------------------ +def test_pattern_equals_text() -> None: + assert rabin_karp("abc", "abc") == [0] + + +def test_pattern_longer_than_text() -> None: + assert rabin_karp("abcd", "abc") == [] + + +def test_empty_pattern() -> None: + assert rabin_karp("", "abc") == [0, 1, 2, 3] + + +def test_empty_text() -> None: + assert rabin_karp("a", "") == [] + + +def test_text_length_one() -> None: + assert rabin_karp("a", "a") == [0] + assert rabin_karp("b", "a") == [] + + +# ------------------------------------------------------------ +# 4. Чувствительность к регистру и спецсимволы +# ------------------------------------------------------------ +def test_case_sensitive() -> None: + assert rabin_karp("a", "A") == [] + assert rabin_karp("A", "A") == [0] + + +def test_case_difference_longer() -> None: + assert rabin_karp("abc", "ABC") == [] + + +def test_digits_and_symbols() -> None: + assert rabin_karp("123", "012345") == [1] + assert rabin_karp("!@#", "!!@#") == [1] + + +# ------------------------------------------------------------ +# 5. Множественные вхождения +# ------------------------------------------------------------ +def test_many_matches() -> None: + text = "abc" * 100 + pattern = "abc" + expected = list(range(0, len(text), 3)) + assert rabin_karp(pattern, text) == expected + + +def test_multiple_non_overlapping() -> None: + assert rabin_karp("ab", "ab ab ab") == [0, 3, 6] + + +# ------------------------------------------------------------ +# 6. Защита от коллизий (управляемые p и x) +# ------------------------------------------------------------ +def test_collision_protection() -> None: + # Без изменения p и x – пример, где коллизия возможна, + # но посимвольная проверка отсекает ложное срабатывание + text = "abcxabcdabxabcdabcdabcy" + pattern = "abcdabcy" + assert rabin_karp(pattern, text) == [15] + + +def test_collision_with_small_p_x() -> None: + # Используем p=2, x=1: хеш строки = ord(c) % 2 + # 'a'(97) и 'c'(99) оба нечётные -> хеш 1, но строки разные + assert rabin_karp("a", "c", p=2, x=1) == [] + # Та же ситуация с совпадающим символом – должно быть найдено + assert rabin_karp("a", "a", p=2, x=1) == [0] + + # Дополнительно: p=3, x=2, символы 'a'(97%3=1) и 'd'(100%3=1) + assert rabin_karp("a", "d", p=3, x=2) == [] + assert rabin_karp("a", "a", p=3, x=2) == [0] + + +# ------------------------------------------------------------ +# 7. Случайные тесты (fuzzing) +# ------------------------------------------------------------ +def naive_search(pattern: str, text: str) -> list[int]: + result: list[int] = [] + m, n = len(pattern), len(text) + for i in range(n - m + 1): + if text[i : i + m] == pattern: + result.append(i) # noqa: PERF401 + return result + + +def test_random_small() -> None: + for _ in range(100): + text = "".join(random.choice(string.ascii_lowercase) for _ in range(50)) + pattern = "".join(random.choice(string.ascii_lowercase) for _ in range(5)) + assert rabin_karp(pattern, text) == naive_search(pattern, text) + + +# ------------------------------------------------------------ +# 8. Стресс-тесты +# ------------------------------------------------------------ +def test_large_input_short_pattern() -> None: + text = "a" * 10000 + pattern = "aaa" + expected = list(range(0, len(text) - len(pattern) + 1)) + assert rabin_karp(pattern, text) == expected + + +def test_large_input_long_pattern() -> None: + text = "a" * 10000 + "b" + pattern = "a" * 5000 + expected = list(range(0, 10001 - 5000)) + assert rabin_karp(pattern, text) == expected + + +# ------------------------------------------------------------ +# 9. Одиночные символы и границы +# ------------------------------------------------------------ +def test_single_char() -> None: + assert rabin_karp("a", "bbbbba") == [5] + + +def test_all_same_chars() -> None: + assert rabin_karp("aaa", "aaaaaa") == [0, 1, 2, 3] From f7c94339ba0c65b05cea567c93245d06f670cf03 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Wed, 29 Apr 2026 17:31:24 +0300 Subject: [PATCH 2/5] feat: update linting dependencies in requirements --- .github/workflows/lint.yml | 2 +- requirements.txt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a1e9f8a..2db1536 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: python-version: 3.12 - name: Install dependencies run: | - pip install -r requirements.txt + pip install ruff==0.3.4 mypy==1.20.2 - name: Lint run: | make lint diff --git a/requirements.txt b/requirements.txt index 0acf6d2..89db889 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,11 +6,12 @@ distlib==0.3.9 filelock==3.17.0 identify==2.6.7 iniconfig==2.0.0 -mypy==1.8.0 +librt==0.9.0 +mypy==1.20.2 mypy-extensions==1.0.0 nodeenv==1.9.1 packaging==23.2 -pathspec==0.12.1 +pathspec==1.1.1 platformdirs==4.2.0 pluggy==1.4.0 pre_commit==4.1.0 From d3b8a38b36a35377c0b3833bab698ae88dfd9160 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Wed, 29 Apr 2026 18:22:51 +0300 Subject: [PATCH 3/5] feat: add emulation task --- src/hash_tables/README.md | 228 ++++++++++++++++++ src/hash_tables/emulation.py | 171 ++++++++++++++ src/hash_tables/hash_table_emulation.py | 4 - tests/test_hash_tables/test_emulation.py | 282 +++++++++++++++++++++++ 4 files changed, 681 insertions(+), 4 deletions(-) create mode 100644 src/hash_tables/emulation.py delete mode 100644 src/hash_tables/hash_table_emulation.py create mode 100644 tests/test_hash_tables/test_emulation.py diff --git a/src/hash_tables/README.md b/src/hash_tables/README.md index 2d64d97..5a1a927 100644 --- a/src/hash_tables/README.md +++ b/src/hash_tables/README.md @@ -225,3 +225,231 @@ baaaaaaa * Для отрицательных чисел применяйте `((a % p) + p) % p`. * Не извлекайте подстроки посимвольно без необходимости — это может привести к превышению времени или памяти. Используйте скользящий хеш. + +# D. Эмуляция хеш-таблицы + +| Поле | Значение | +|-----------|-----------------------------------------------------| +| Сложность | Сложная | +| Источник | https://contest.yandex.ru/contest/31463/problems/B/ | + +Реализуйте хеш-таблицу со строковыми ключами и целочисленными значениями, используя: + +* **полиномиальный хеш** +* **открытую адресацию** +* **линейное пробирование (linear probing)** + +--- + +**Хеш-функция** + +Для строки `S = s₁s₂...sₖ` хеш вычисляется так: + +$$ +\sum_{i=1}^{k} s_i \cdot p^{\,i-1} \bmod q +$$ + +где: + +* `p` — основание хеша +* `q` — размер таблицы +* `s_i` — i-й символ строки + +--- + +**Поддерживаемые операции** + +`PUT X Y` + +Добавить ключ `X` со значением `Y`. + +* Если ячейка `h(X)` свободна → вставка без коллизий +* Если занята → использовать **линейное пробирование** +* Если таблица полностью заполнена → `overflow` + +--- + +`GET X` + +Получить значение по ключу `X`. + +* Если найден → вернуть значение +* Если не найден: + + * без коллизий → `no_key` + * при пробировании → указать, где остановились + +--- + +`DEL X` + +Удалить ключ `X`. + +* Если найден → удалить (оставив "могилку" / tombstone) +* Если не найден: + + * без коллизий → `no_key` + * при пробировании → указать последнюю проверенную ячейку + +--- + +**Формат входа** + +* Первая строка: + + ``` + q p n + ``` + + где: + + * `q` — размер таблицы (1 ≤ q ≤ ?) + * `p` — основание хеша + * `n` — количество операций (1 ≤ n ≤ ?) + +* Далее `n` строк — операции: + +``` +PUT key value +GET key +DEL key +``` + +--- + +**Формат выхода** + +Для **каждой операции** нужно вывести результат в формате: + +``` +key= hash= operation= result= [доп. параметры] +``` + +--- + +**Детализация вывода** + +`PUT` + +* Без коллизий: + +``` +key=X hash=H operation=PUT result=inserted value=Y +``` + +* С коллизией: + +``` +key=X hash=H operation=PUT result=collision linear_probing=I value=Y +``` + +* Переполнение: + +``` +key=X hash=H operation=PUT result=overflow +``` + +--- + +`GET` + +* Найден без коллизий: + +``` +key=X hash=H operation=GET result=found value=Y +``` + +* Найден с коллизиями: + +``` +key=X hash=H operation=GET result=collision linear_probing=I value=Y +``` + +* Не найден без коллизий: + +``` +key=X hash=H operation=GET result=no_key +``` + +* Не найден с коллизиями: + +``` +key=X hash=H operation=GET result=collision linear_probing=I value=no_key +``` + +--- + +`DEL` + +* Удалён без коллизий: + +``` +key=X hash=H operation=DEL result=removed +``` + +* Удалён с коллизиями: + +``` +key=X hash=H operation=DEL result=collision linear_probing=I value=removed +``` + +* Не найден без коллизий: + +``` +key=X hash=H operation=DEL result=no_key +``` + +* Не найден с коллизиями: + +``` +key=X hash=H operation=DEL result=collision linear_probing=I value=no_key +``` + +--- + +**Пример** + +**Ввод:** + +```text +28 29 3 +PUT itmn 421 +GET itmn +DEL itmn +``` + +**Вывод:** + +```text +key=itmn hash=0 operation=PUT result=inserted value=421 +key=itmn hash=0 operation=GET result=found value=421 +key=itmn hash=0 operation=DEL result=removed +``` + +--- + +**Важные детали реализации** + +* Используйте **линейное пробирование**: + + ``` + (h + i) % q + ``` +* При удалении используйте **tombstone (DELETED)**, а не `EMPTY` +* Поиск должен: + + * останавливаться на `EMPTY` + * игнорировать `DELETED` +* Таблица имеет **фиксированный размер** (без rehash) + +--- + +**Подсказки** + +* Храните состояние ячейки: `EMPTY / ACTIVE / DELETED` +* Не используйте нативные хеш-мапы языка — реализуйте вручную +* Следите за корректной обработкой коллизий +* Важно правильно определить: + + * была ли коллизия + * где остановился probing diff --git a/src/hash_tables/emulation.py b/src/hash_tables/emulation.py new file mode 100644 index 0000000..f9e12a4 --- /dev/null +++ b/src/hash_tables/emulation.py @@ -0,0 +1,171 @@ +from dataclasses import dataclass +from enum import Enum, auto + +# ------------------------- +# SLOT MODEL +# ------------------------- + + +class SlotStatus(Enum): + EMPTY = auto() + ACTIVE = auto() + DELETED = auto() + + +@dataclass +class Slot: + status: SlotStatus = SlotStatus.EMPTY + key: str | None = None + value: int | None = None + + def set(self, key: str, value: int) -> None: + self.status = SlotStatus.ACTIVE + self.key = key + self.value = value + + def delete(self) -> None: + self.status = SlotStatus.DELETED + self.key = None + self.value = None + + +# ------------------------- +# RESULT MODEL +# ------------------------- + + +@dataclass +class OperationResult: + key: str + hash: int + operation: str + result: str + value: str | None = None + probing_index: int | None = None + + def __str__(self) -> str: + parts = [ + f"key={self.key}", + f"hash={self.hash}", + f"operation={self.operation}", + f"result={self.result}", + ] + + if self.probing_index is not None: + parts.append(f"linear_probing={self.probing_index}") + + if self.value is not None: + parts.append(f"value={self.value}") + + return " ".join(parts) + + +# ------------------------- +# HASH TABLE +# ------------------------- + + +class HashTable: + def __init__(self, q: int, p: int) -> None: + self.capacity = q + self.p = p + self.table: list[Slot] = [Slot() for _ in range(q)] + + # ------------------------- + # PUT + # ------------------------- + def put(self, key: str, value: int) -> OperationResult: + h = self._hash(key) + found, idx, collision = self._find_for_put(key) + + if found is not None: + self.table[found].set(key, value) + return OperationResult(key, h, "PUT", "inserted", str(value)) + + if idx is not None and self.table[idx].status in (SlotStatus.EMPTY, SlotStatus.DELETED): + self.table[idx].set(key, value) + if not collision: + return OperationResult(key, h, "PUT", "inserted", str(value)) + return OperationResult(key, h, "PUT", "collision", str(value), idx) + + return OperationResult(key, h, "PUT", "overflow") + + def get(self, key: str) -> OperationResult: + h = self._hash(key) + found, idx, collision = self._find_for_get(key) + + if found is not None: + value = self.table[found].value + if not collision: + return OperationResult(key, h, "GET", "found", str(value)) + return OperationResult(key, h, "GET", "collision", str(value), found) + + if not collision: + return OperationResult(key, h, "GET", "no_key") + return OperationResult(key, h, "GET", "collision", "no_key", idx) + + def delete(self, key: str) -> OperationResult: + h = self._hash(key) + found, idx, collision = self._find_for_get(key) + + if found is not None: + self.table[found].delete() + if not collision: + return OperationResult(key, h, "DEL", "removed") + return OperationResult(key, h, "DEL", "collision", "removed", found) + + if not collision: + return OperationResult(key, h, "DEL", "no_key") + return OperationResult(key, h, "DEL", "collision", "no_key", idx) + + # ------------------------- + # HASH FUNCTION + # ------------------------- + + def _hash(self, key: str) -> int: + result = 0 + for i, ch in enumerate(key): + result = (result + (ord(ch) - ord("a") + 1) * pow(self.p, i, self.capacity)) % self.capacity + return result + + # ------------------------- + # PROBING + # ------------------------- + + def _find_for_put(self, key: str) -> tuple[int | None, int, bool]: + """Поиск слота для вставки: останавливается на EMPTY или DELETED.""" + start = self._hash(key) + index = start + collision = False + + for _ in range(self.capacity): + slot = self.table[index] + if slot.status in (SlotStatus.EMPTY, SlotStatus.DELETED): + # Свободный слот для вставки. + # Коллизия — только если мы уже прошли мимо ACTIVE с чужим ключом. + return None, index, collision + if slot.status == SlotStatus.ACTIVE: + if slot.key == key: + return index, index, collision # обновление существующего + collision = True + index = (index + 1) % self.capacity + + return None, index, True # overflow + + def _find_for_get(self, key: str) -> tuple[int | None, int, bool]: + """Поиск ключа: останавливается на EMPTY, игнорирует DELETED.""" + start = self._hash(key) + index = start + collision = False + + for _ in range(self.capacity): + slot = self.table[index] + if slot.status == SlotStatus.EMPTY: + return None, index, collision + if slot.status == SlotStatus.ACTIVE and slot.key == key: + return index, index, collision + if slot.status == SlotStatus.DELETED or (slot.status == SlotStatus.ACTIVE and slot.key != key): + collision = True + index = (index + 1) % self.capacity + + return None, index, True diff --git a/src/hash_tables/hash_table_emulation.py b/src/hash_tables/hash_table_emulation.py deleted file mode 100644 index c058aed..0000000 --- a/src/hash_tables/hash_table_emulation.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -https://contest.yandex.ru/contest/31463/problems/B/ - -""" diff --git a/tests/test_hash_tables/test_emulation.py b/tests/test_hash_tables/test_emulation.py new file mode 100644 index 0000000..bb3f791 --- /dev/null +++ b/tests/test_hash_tables/test_emulation.py @@ -0,0 +1,282 @@ +from typing import Any + +import pytest + +from src.hash_tables.emulation import HashTable, OperationResult + + +def result_dict(res: OperationResult) -> dict[str, Any]: + """Преобразует результат в словарь для удобного сравнения.""" + d = { + "key": res.key, + "hash": res.hash, + "operation": res.operation, + "result": res.result, + } + if res.value is not None: + d["value"] = res.value + if res.probing_index is not None: + d["linear_probing"] = res.probing_index + return d + + +# ------------------------------------------------------------------ +# Хеш-функция +# ------------------------------------------------------------------ +class TestHashFunction: + def test_basic(self) -> None: + ht = HashTable(q=10, p=3) + assert ht._hash("a") == 1 # 1*1 mod 10 + + def test_multiple_chars(self) -> None: + ht = HashTable(q=7, p=5) + # 'abc': (1 + 2*5 + 3*25) mod 7 = 86 mod 7 = 2 + assert ht._hash("abc") == 2 + + def test_same_hash_different_strings(self) -> None: + ht = HashTable(q=3, p=2) + # 'a'=1, 'd'=4 → оба 1 mod 3 (при p=2: 1*1=1, 4*1=4 mod3=1) + assert ht._hash("a") == ht._hash("d") == 1 + + +# ------------------------------------------------------------------ +# Базовые операции без коллизий +# ------------------------------------------------------------------ +class TestNoCollisions: + @pytest.fixture() + def empty_table(self) -> HashTable: + return HashTable(q=10, p=31) + + def test_put_inserted(self, empty_table: HashTable) -> None: + res = empty_table.put("key1", 100) + h = empty_table._hash("key1") + assert result_dict(res) == {"key": "key1", "hash": h, "operation": "PUT", "result": "inserted", "value": "100"} + + def test_get_found(self, empty_table: HashTable) -> None: + empty_table.put("key1", 200) + res = empty_table.get("key1") + h = empty_table._hash("key1") + assert result_dict(res) == {"key": "key1", "hash": h, "operation": "GET", "result": "found", "value": "200"} + + def test_get_no_key(self, empty_table: HashTable) -> None: + res = empty_table.get("missing") + h = empty_table._hash("missing") + assert result_dict(res) == {"key": "missing", "hash": h, "operation": "GET", "result": "no_key"} + + def test_delete_removed(self, empty_table: HashTable) -> None: + empty_table.put("key1", 300) + res = empty_table.delete("key1") + h = empty_table._hash("key1") + assert result_dict(res) == {"key": "key1", "hash": h, "operation": "DEL", "result": "removed"} + # После удаления слот DELETED, следующая ячейка EMPTY → это коллизия + get_res = empty_table.get("key1") + assert get_res.result == "collision" + # probing_index — следующая EMPTY-ячейка после хеша + # (hash + 1) % q, если нет других ключей + assert get_res.probing_index == (h + 1) % 10 + assert get_res.value == "no_key" + + def test_delete_no_key(self, empty_table: HashTable) -> None: + res = empty_table.delete("ghost") + h = empty_table._hash("ghost") + assert result_dict(res) == {"key": "ghost", "hash": h, "operation": "DEL", "result": "no_key"} + + +# ------------------------------------------------------------------ +# Операции с коллизиями +# ------------------------------------------------------------------ +class TestCollisions: + @pytest.fixture() + def small_table(self) -> HashTable: + """Таблица размером 3, p=1 (хеш = код первого символа % 3).""" + return HashTable(q=3, p=1) + + def test_put_collision(self, small_table: HashTable) -> None: + # 'a'=1 -> hash=1, ячейка 1 + small_table.put("a", 10) + # 'd'=4%3=1 -> коллизия, probing остановится на 2 + res = small_table.put("d", 20) + assert result_dict(res) == { + "key": "d", + "hash": 1, + "operation": "PUT", + "result": "collision", + "linear_probing": 2, + "value": "20", + } + + def test_get_collision_found(self, small_table: HashTable) -> None: + small_table.put("a", 10) # hash=1 → ячейка 1 + small_table.put("d", 20) # hash=1 → коллизия, ячейка 2 + res = small_table.get("d") + assert result_dict(res) == { + "key": "d", + "hash": 1, + "operation": "GET", + "result": "collision", + "linear_probing": 2, + "value": "20", + } + + def test_get_collision_no_key(self, small_table: HashTable) -> None: + small_table.put("a", 10) # ячейка 1 занята + # 'd'=1 (коллизия), следующая ячейка 2 EMPTY → останов + res = small_table.get("d") + assert result_dict(res) == { + "key": "d", + "hash": 1, + "operation": "GET", + "result": "collision", + "linear_probing": 2, + "value": "no_key", + } + + def test_delete_collision_removed(self, small_table: HashTable) -> None: + small_table.put("a", 10) # ячейка 1 + small_table.put("d", 20) # ячейка 2 (коллизия) + res = small_table.delete("d") + assert result_dict(res) == { + "key": "d", + "hash": 1, + "operation": "DEL", + "result": "collision", + "linear_probing": 2, + "value": "removed", + } + # после удаления 'd' ячейка 2 DELETED, дальнейший поиск 'd': + # старт 1 (ACTIVE 'a'), 2 (DELETED), 0 (EMPTY) → стоп + get_res = small_table.get("d") + assert result_dict(get_res) == { + "key": "d", + "hash": 1, + "operation": "GET", + "result": "collision", + "linear_probing": 0, + "value": "no_key", + } + + def test_delete_collision_no_key(self, small_table: HashTable) -> None: + small_table.put("a", 10) # ячейка 1 занята + # 'd'=1 -> коллизия, следующая 2 EMPTY → останов + res = small_table.delete("d") + assert result_dict(res) == { + "key": "d", + "hash": 1, + "operation": "DEL", + "result": "collision", + "linear_probing": 2, + "value": "no_key", + } + + +# ------------------------------------------------------------------ +# Переполнение (overflow) +# ------------------------------------------------------------------ +class TestOverflow: + def test_put_overflow(self) -> None: + ht = HashTable(q=2, p=1) + ht.put("a", 1) # hash=1 → ячейка 1 + ht.put("b", 2) # 'b'=2%2=0 → ячейка 0 + # таблица полностью ACTIVE + res = ht.put("c", 3) # 'c'=3%2=1 → весь probing занят + assert result_dict(res) == {"key": "c", "hash": 1, "operation": "PUT", "result": "overflow"} + + def test_put_on_deleted_slot(self) -> None: + ht = HashTable(q=2, p=1) + ht.put("a", 1) # ячейка 1 + ht.put("b", 2) # ячейка 0 + ht.delete("a") # ячейка 1 DELETED + # Вставляем ключ с хешем 1 — попадаем в DELETED, без коллизий + res = ht.put("c", 3) # 'c' = 3 % 2 = 1 + assert result_dict(res) == {"key": "c", "hash": 1, "operation": "PUT", "result": "inserted", "value": "3"} + + +# ------------------------------------------------------------------ +# Поведение tombstone (DELETED) +# ------------------------------------------------------------------ +class TestTombstoneBehavior: + def test_probe_skips_deleted(self) -> None: + """Поиск игнорирует DELETED и идёт до EMPTY.""" + ht = HashTable(q=4, p=1) + ht.put("a", 1) # 'a'=1 → ячейка 1 + ht.put("b", 2) # 'b'=2 → ячейка 2 + ht.delete("a") # ячейка 1 DELETED + # ищем 'e' (5%4=1): старт 1 DELETED, 2 ACTIVE 'b', 3 EMPTY → стоп + res = ht.get("e") + assert result_dict(res) == { + "key": "e", + "hash": 1, + "operation": "GET", + "result": "collision", + "linear_probing": 3, + "value": "no_key", + } + + def test_delete_already_deleted(self) -> None: + """Повторное удаление уже удалённого ключа — no_key с коллизией.""" + ht = HashTable(q=3, p=1) + ht.put("a", 1) # ячейка 1 + ht.delete("a") # → removed + # повторное удаление: старт 1 DELETED, 2 EMPTY → стоп + res = ht.delete("a") + assert result_dict(res) == { + "key": "a", + "hash": 1, + "operation": "DEL", + "result": "collision", + "linear_probing": 2, + "value": "no_key", + } + + +# ------------------------------------------------------------------ +# Перезапись существующего ключа (UPDATE) +# ------------------------------------------------------------------ +class TestUpdate: + def test_put_existing_key(self) -> None: + ht = HashTable(q=10, p=31) + ht.put("key", 42) + res = ht.put("key", 99) + h = ht._hash("key") + assert result_dict(res) == {"key": "key", "hash": h, "operation": "PUT", "result": "inserted", "value": "99"} + get_res = ht.get("key") + assert get_res.value == "99" + + def test_put_existing_key_after_collision(self) -> None: + ht = HashTable(q=2, p=1) + ht.put("a", 1) # hash=1 → ячейка 1 + ht.put("c", 2) # 'c'=3%2=1 → коллизия, записалась в 0 + res = ht.put("c", 77) + assert result_dict(res) == {"key": "c", "hash": 1, "operation": "PUT", "result": "inserted", "value": "77"} + get_res = ht.get("c") + assert get_res.value == "77" + + +# ------------------------------------------------------------------ +# Формат вывода (str результата) +# ------------------------------------------------------------------ +class TestOutputFormat: + def test_str_method(self) -> None: + res = OperationResult("mykey", 5, "PUT", "inserted", "10", None) + expected = "key=mykey hash=5 operation=PUT result=inserted value=10" + assert str(res) == expected + + res2 = OperationResult("k", 3, "GET", "collision", "no_key", 7) + expected2 = "key=k hash=3 operation=GET result=collision linear_probing=7 value=no_key" + assert str(res2) == expected2 + + +class TestTaskScenario: + def test_test_9(self) -> None: + ht = HashTable(q=17, p=31) + ht.put("jfkmfith", 123) + ht.delete("jfkmfith") + get_res = ht.get("jfkmfith") + assert get_res.probing_index == 3 + assert get_res.result == "collision" + put_res = ht.put("kc", 64) + assert put_res.result == "inserted" + assert put_res.hash == 2 + get_kc = ht.get("kc") + assert get_kc.result == "found" + assert get_kc.value == "64" From e599c52522951abc2e87c71023c4f3c0df490db5 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Wed, 29 Apr 2026 18:25:57 +0300 Subject: [PATCH 4/5] feat: update README for hash table section --- src/hash_tables/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/hash_tables/README.md b/src/hash_tables/README.md index 5a1a927..8340eec 100644 --- a/src/hash_tables/README.md +++ b/src/hash_tables/README.md @@ -63,8 +63,6 @@ Mom daddy ``` ---- - ## B. Хеширование цепочками | Поле | Значение | @@ -147,7 +145,6 @@ GooD luck * Берите результат по модулю `p` **после каждой арифметической операции**. * Отрицательные числа по модулю: вместо `a % p` используйте `((a % p) + p) % p`. ---- ## C. Поиск образца в тексте From fba3221bcec749740fd20d0da6a30ce48bbb67ff Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Wed, 29 Apr 2026 18:29:11 +0300 Subject: [PATCH 5/5] feat: add tasks.xlsx to .gitignore --- .gitignore | 1 + tasks.xlsx | Bin 12823 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 tasks.xlsx diff --git a/.gitignore b/.gitignore index 226bbf0..b27a28a 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ assets/extra/ +tasks.xlsx diff --git a/tasks.xlsx b/tasks.xlsx deleted file mode 100644 index 1a8df4c1f3a2c39023b3bf37b4c2a6776390773e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12823 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 F{{gWgAsqk!