Коллега попросил вас написать простую систему учёта: нужно хранить, сколько раз каждый сотрудник открыл корпоративный портал за день. Логи выглядят так: список имён пользователей в порядке их входов.

Задача: Напишите программу, которая принимает массив строк loginLog (имена пользователей) и выводит каждое уникальное имя с количеством входов. Попробуйте решить это без Dictionary — только массивами или списками.

Подсказка: Можно хранить имена в одном списке, счётчики — в другом. Для каждого нового имени ищите, есть ли оно уже в списке.

Мы уже знаем, как искать элементы в массиве — перебором за O(n), или бинарным поиском за O(log n) если массив отсортирован. Деревья дали нам O(log n) даже без сортировки. Неплохо. Но есть задачи, где и это медленно.

Представьте: вы пишете систему для интернет-магазина. Каждую секунду приходят тысячи запросов вида «есть ли товар с артикулом SKU-48291 на складе?». Товаров на складе — миллион. Бинарный поиск сделает около 20 сравнений. Умножьте на тысячи запросов в секунду — и сервер начнёт задыхаться. Хочется что-то принципиально быстрее.

А что если поиск занимал бы ровно один шаг, вне зависимости от размера склада? Не 20, не 10 — а один. Это и есть идея хеш-таблицы. А хеш-функция — ключевой механизм, который делает это возможным.

Чтобы оценить разницу — вот сравнение временной сложности операций поиска:

Массив без сортировки: O(n) — перебираем каждый элемент.

Отсортированный массив, бинарный поиск: O(log n) — делим пространство поиска пополам.

Бинарное дерево поиска: O(log n) в среднем.

Хеш-таблица: O(1) в среднем — прямой переход по индексу. Идея простая: возьмём обычный массив фиксированного размера — скажем, 1000 ячеек. Нам нужно хранить пары «ключ — значение»: артикул товара → количество на складе. Если бы ключи были числами от 0 до 999, всё было бы просто: кладём значение в ячейку с нужным номером, достаём за O(1). Но ключи — произвольные строки вроде "SKU-48291". Как превратить строку в индекс от 0 до 999?

Именно это и делает хеш-функция.

Посмотрим на самый простой пример. Строку можно «свернуть» в число, просуммировав коды её символов:

// Очень упрощённая хеш-функция для строки
int SimpleHash(string key)
{
    int sum = 0;
    foreach (char c in key)
        sum += (int)c;
    return sum;
}

string sku = "SKU-48291";
int hashCode = SimpleHash(sku);      // например, 756
int index = hashCode % 1000;         // индекс в массиве: 756

Строка "SKU-48291" превращается в число 756. Оператор % (остаток от деления) гарантирует, что результат не выйдет за границы массива — сколько бы большим ни было исходное число, после % 1000 получим значение от 0 до 999.

Запись: кладём значение в ячейку 756. Чтение: снова вычисляем хеш от ключа, получаем 756, идём прямо туда. Никакого перебора — один шаг.

Обратите внимание на то, что происходит при чтении: мы не храним «где лежит SKU-48291». Мы просто снова вычисляем хеш — и всегда получаем тот же самый индекс. Вот почему детерминированность хеш-функции так важна: если при записи получили 756, при чтении тоже должны получить 756, а не 912.

"SKU-48291" хеш- функция 756 % 1000 [756] 42 шт ключ индекс значение

Вот и вся суть: ключ → хеш-функция → индекс → массив. Запись и чтение за O(1). Теперь посмотрим, как это работает в реальном C#. Каждый тип данных в .NET умеет вычислять свой хеш-код — через метод GetHashCode(), который унаследован от базового класса object.

string username = "alice";
int hashCode = username.GetHashCode();
Console.WriteLine(hashCode);         // например, -1558733584
Console.WriteLine(hashCode % 1000);  // индекс: 416 (или отрицательный!)

Конкретное число зависит от реализации и версии .NET — не удивляйтесь, если у вас будет другое. Важно другое: метод есть у любого объекта — строки, числа, пользовательского класса.

Что делает хорошую хеш-функцию? У неё три ключевых свойства.

Детерминированность. Один и тот же ключ всегда даёт одно и то же число. Если сегодня "alice" даёт 416, завтра она тоже должна дать 416 — иначе мы просто не найдём, куда положили данные. Это единственное жёсткое требование: нарушить его — значит сломать всю структуру.

Равномерное распределение. Разные ключи должны давать разные индексы, равномерно разбросанные по всему массиву. Если все 10 000 товаров окажутся в ячейке 0, а остальные 999 будут пустыми — никакой пользы от хеш-таблицы нет. Наш простой «сумма кодов символов» страдает именно этим: строки "abc" и "cab" и "bca" дают одинаковую сумму и попадают в одну ячейку. Реальные хеш-функции учитывают позицию символа, чтобы этого избежать.

Скорость. Вычисление должно быть быстрым — константным O(1) или хотя бы O(длины ключа). Смысл теряется, если сама хеш-функция медленнее линейного поиска. Хорошая реализация обрабатывает строку за микросекунды.

Теперь посмотрим, как всё это выглядит в реальной задаче. Классический пример — подсчёт слов в тексте. Нужно для каждого слова хранить, сколько раз оно встречается.

string text = "кот сел на кот и кот ушёл";
string[] words = text.Split(' ');

Dictionary<string, int> wordCount = new Dictionary<string, int>();

foreach (string word in words)
{
    if (wordCount.ContainsKey(word))
        wordCount[word]++;
    else
        wordCount[word] = 1;
}

foreach (var pair in wordCount)
    Console.WriteLine($"{pair.Key}: {pair.Value}");

Вывод:

кот: 3
сел: 1
на: 1
и: 1
ушёл: 1

Dictionary<string, int> в C# — это и есть хеш-таблица под капотом. Когда вы пишете wordCount["кот"] = 3, Dictionary вычисляет хеш от строки "кот", находит нужный «бакет» (ячейку) и кладёт туда пару ключ-значение. Поиск по ключу работает точно так же — за O(1).

Хеш-функция — функция, которая превращает ключ произвольного типа в целое число (хеш-код). Хеш-код используется как индекс в массиве, что позволяет находить элементы за O(1).

Хеш-таблица работает так: при записи вычисляем index = Math.Abs(key.GetHashCode()) % arraySize и кладём значение по этому индексу. При чтении — снова вычисляем тот же индекс и идём прямо туда.

Три свойства хорошей хеш-функции: детерминированность (один ключ — всегда один хеш), равномерное распределение (разные ключи — разные индексы), скорость (O(1)).

Коллизия — ситуация, когда два разных ключа дают одинаковый индекс. Это неизбежно при достаточном количестве ключей — из-за парадокса дня рождения. Хеш-таблицы умеют обрабатывать коллизии (следующий урок).

В C# метод GetHashCode() есть у любого объекта. Dictionary<K, V> — стандартная хеш-таблица в .NET: запись и поиск по ключу работают за O(1) в среднем случае.

Не путайте криптографические хеш-функции (MD5, SHA) с хеш-функциями для структур данных: для паролей нужны специальные медленные алгоритмы — bcrypt, Argon2, PBKDF2.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.