awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Хеш-таблицы: множества и словари

Суть Хеш-множество отвечает на вопрос «есть ли X в наборе?» за O(1). Хеш-словарь хранит пары ключ–значение и ищет по ключу за O(1). Оба используют хеш-функцию, чтобы прыгать сразу к ответу.
◷ 20 min

На уроке о времени и памяти ты узнал, что проверка «есть ли значение X в массиве?» вложенным циклом стоит O(n²) времени. Потом ты узнал, что множество — неупорядоченная коллекция уникальных значений — может ответить за O(n) времени и O(n) памяти: трейд-офф между временем и памятью. Но есть ещё быстрее: хеш-множество. Оно отвечает «есть ли X?» за O(1) среднего времени, без цикла вообще. Секрет — в хеш-функции, которая работает как ключ к картотеке: она превращает значение в номер ящика, и ты смотришь туда мгновенно.

Цель

После этого урока ты можешь объяснить, как хеш-функция преобразует ключ в индекс массива, понять, почему хеш-множества и хеш-словари дают O(1) среднего поиска, построить простое хеш-множество или хеш-словарь через JavaScript Set или Map, решить задачу «содержит ли массив дубликат?» используя хеш-множество за O(n), и заметить трейд-офф: O(1) среднего времени стоит памяти и не сохраняет порядок.

Идея

Проблема, которую мы решаем:

Представь справочник. В нём 1 миллион имён и телефонов. Если ты хранишь их в массиве и ищешь «Алиса», то скользишь с начала: Алиса? Нет. Боб? Нет. Виктор? Нет. … — O(n) времени.

Но справочник организован по алфавиту. Ты прыгаешь на раздел «А», потом скользишь только по нескольким именам. Всё ещё линейно, но быстрее на практике.

Можем ли мы быстрее? Да, если мы можем прыгнуть прямо к ответу без скольжения вообще. Это то, что делает хеширование.

Идея хеширования:

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

function hash(key, arraySize) {
  // Превращу ключ в число от 0 до arraySize - 1
  return someFormula(key) % arraySize;
}

const index = hash("Alice", 1000);  // Возвращает, скажем, 342
// Смотру позицию 342 — телефон Алисы лежит там (в идеале)

В хорошей хеш-функции разные ключи преобразуются в разные индексы. «Alice» всегда → 342. «Bob» может → 157. «Charlie» → 899. Массив имеет ящик на каждом индексе, и пара ключ–значение лежит в том ящике.

Почему O(1)? Вычислю индекс (одна формула) и смотру на тот индекс (одна операция). Готово. Нет цикла.

Хеш-множество против хеш-словаря:

Хеш-множество (неупорядоченное множество) хранит только значения — оно отвечает «есть ли X в множестве?». Оно использует хеш-функцию, чтобы преобразовать каждое значение в индекс массива, потом хранит значение (или маркер) на том индексе.

1 const mySet = new Set();
2 mySet.add(10);
3 mySet.add(20);
4 mySet.add(30);
5
6 console.log(mySet.has(20)); // true, найдено мгновенно через хеш
7 console.log(mySet.has(99)); // false, смотрели и нет
  • L1 Создаю пустое множество
  • L2 Хеширую 10, храню на результирующем индексе
  • L6 Хеширую 20, проверяю индекс, найдено за O(1)
  • L7 Хеширую 99, проверяю индекс, нет, но всё ещё O(1)

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

1 const myMap = new Map();
2 myMap.set("Alice", "555-1234");
3 myMap.set("Bob", "555-5678");
4
5 console.log(myMap.get("Alice")); // "555-1234", найдено за O(1)
6 console.log(myMap.get("Charlie")); // undefined, нет, но всё ещё O(1)
  • L1 Создаю пустой словарь
  • L2 Хеширую 'Alice', храню пару ключ–значение на результирующем индексе
  • L5 Хеширую 'Alice', прыгаю на индекс, получаю значение

Основные операции на хеш-множестве или хеш-словаре:

  1. Добавить/установить по ключу — Хешировать ключ, прыгнуть на индекс, положить значение. O(1) в среднем.
  2. Поиск/содержит — Хешировать ключ, прыгнуть на индекс, проверить, есть ли там. O(1) в среднем.
  3. Удалить по ключу — Хешировать ключ, прыгнуть на индекс, убрать. O(1) в среднем.

Слово «в среднем» важно: при плохой хеш-функции или при тяжёлых коллизиях (смотри граничный случай) производительность может упасть. Но при хорошей хеш-функции и разумном факторе загрузки (число элементов / размер массива) O(1) в среднем надёжно.

Трейд-офф:

  • Стоимость памяти: Хеш-множество или хеш-словарь использует дополнительную память — массив для хранения ящиков, обычно с запасом, чтобы коллизии были редки. Ты тратишь память, чтобы сэкономить время.
  • Нет порядка: Итерация по хеш-множеству или хеш-словарю не следует никакому предсказуемому порядку (кроме того, что JavaScript Maps итерируют в порядке вставки — уступка ожиданиям разработчика).
  • Ключи должны быть хешируемы: В некоторых языках только определённые типы могут быть ключами (неизменяемые типы в Python, например). JavaScript более снисходителен: ты можешь хешировать любое значение.
Код

Построение множества — «содержит ли массив дубликат?»

Вспомни из урока о времени и памяти: вложенный цикл — O(n²).

function hasDuplicateNested(arr) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] === arr[j]) return true;
    }
  }
  return false;
}

С хеш-множеством мы делаем один проход и O(1) поиски:

function hasDuplicateSet(arr) {
  const seen = new Set();
  for (let num of arr) {
    if (seen.has(num)) return true;  // O(1) поиск
    seen.add(num);                   // O(1) добавление
  }
  return false;
}

Время: O(n) (один цикл, каждая операция O(1)). Память: O(n) (храним до n значений).

Построение словаря — «храню и ищу телефоны по имени»

function createPhonebook() {
  const book = new Map();

  // Добавляю записи
  book.set("Alice", "555-1234");
  book.set("Bob", "555-5678");
  book.set("Charlie", "555-9999");

  // Получаю
  console.log(book.get("Alice"));    // "555-1234", O(1) поиск
  console.log(book.get("David"));    // undefined, всё ещё O(1)

  // Проверяю наличие
  console.log(book.has("Bob"));      // true
  console.log(book.has("Eve"));      // false

  // Удаляю
  book.delete("Bob");

  // Итерирую (порядок вставки в JS)
  for (const [name, phone] of book) {
    console.log(`${name}: ${phone}`);
  }

  return book;
}

Попробуй сравнение:

Вывод
 
Пошаговый разбор

Давай трассируем подход с хеш-множеством на [1, 2, 3, 4, 5, 3]:

1 function hasDuplicateSet(arr) {
2 const seen = new Set();
3 for (let num of arr) {
4 if (seen.has(num)) return true;
5 seen.add(num);
6 }
7 return false;
8 }

Заметь: мы нашли дубликат на 6-м элементе, не сравнивая его со всеми 5 предыдущими. Хеш-множество прыгает прямо на ящик для 3 и находит его там.

Сложность

Операции на хеш-множестве и хеш-словаре:

ОперацияВремя (среднее)Память
Добавить / УстановитьO(1)
Поиск / СодержитO(1)
УдалитьO(1)
Итерировать всёO(n)O(1) доп
Содержит дубликат (массив)O(n)O(n) доп

Почему «среднее» важно?

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

На практике, со встроенными Set и Map JavaScript, реализация оптимизирована, и O(1) в среднем надёжно.

Практика 0 / 5

Почему хеш-множество отвечает на «есть ли X в множестве?» за O(1) среднего времени?

У тебя есть массив [5, 2, 9, 5, 3]. Какая структура данных находит дубликат (5) наиболее эффективно?

В хеш-словаре с записями 'Alice' → '555-1234' и 'Bob' → '555-5678', что делает map.get('Alice')?

Какой главный трейд-офф использования хеш-множества вместо хранения значений в массиве?

Если ты проверяешь дубликаты в массиве из 1000 элементов используя хеш-множество, сколько раз нужно вызвать хеш-функцию (приблизительно)?

Граничные случаи

Коллизии: Если два разных ключа хешируются на один индекс, они коллизируют. Разрешение коллизий (хранение нескольких пар ключ–значение на одном индексе через цепочку или открытую адресацию) — предмет следующего урока в этом разделе. Пока знай, что хорошая хеш-функция и правильный фактор загрузки (обычно держи utilization ниже 75%) минимизируют коллизии и сохраняют O(1) среднего времени.

lesson.inset.misconception

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

Проверь себя
Викторина

Почему хеширование решает задачу «найти дубликат за O(n)», а вложенный цикл занимает O(n²)?

Итог

Хеш-множество и хеш-словарь — структуры данных, которые меняют память на скорость.

Ключевые факты:

  1. Хеш-функция: Преобразует ключ в индекс массива мгновенно. Основная идея: index = hash(key) % arraySize.
  2. O(1) среднего поиска: Вычисли индекс, прыгни туда, готово. Сканирование не требуется (средний случай).
  3. Хеш-множество: Хранит уникальные значения. Отвечает «есть ли X?» за O(1) в среднем.
  4. Хеш-словарь: Хранит пары ключ–значение. Ищет значение по ключу за O(1) в среднем.
  5. Трейд-офф время–память: Потратить O(n) памяти, чтобы сэкономить O(n) времени (по сравнению с вложенным циклом O(n²)).
  6. Инструменты JavaScript: Используй встроенный Set для уникальных значений, Map для пар ключ–значение.
  7. Коллизии: Разные ключи, хеширующиеся на один индекс, ухудшают производительность. Хорошая хеш-функция и управление фактором загрузки держат это редким.

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

Продолжить восхождение ↑Подсчёт частоты
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.