Хеш-таблицы в задачах
Хеш-таблица (dict, set) даёт поиск, вставку и удаление в среднем за O(1). Это самый частый способ превратить квадратичное решение в линейное: вместо того чтобы искать по списку, мы помним увиденное.
Шаблон 1: «уже видели?»
def contains_duplicate(nums):
seen = set()
for x in nums:
if x in seen:
return True
seen.add(x)
return False
print(contains_duplicate([1, 2, 3, 1]), contains_duplicate([1, 2, 3]))
print(len(set([1, 2, 3, 1])) != len([1, 2, 3, 1])) # то же одной строкой
Шаблон 2: значение → индекс (Two Sum)
Классика собеседований. Для каждого x нужно найти target - x среди предыдущих элементов:
def two_sum(nums, target):
index = {} # значение → индекс
for i, x in enumerate(nums):
need = target - x
print(f" i={i}, x={x}, ищем {need}, словарь={index}")
if need in index:
return [index[need], i]
index[x] = i
print(two_sum([2, 7, 11, 15], 9))
print(two_sum([3, 2, 4], 6))
Сравните: перебор пар — O(n²) времени и O(1) памяти; словарь — O(n) времени и O(n) памяти. Это типичный компромисс время/память.
Шаблон 3: подсчёт частот
from collections import Counter
def is_anagram(a, b):
return Counter(a) == Counter(b) # O(n); sorted(a) == sorted(b) — O(n log n)
print(is_anagram("listen", "silent"), is_anagram("rat", "car"))
def can_build(note, magazine):
"""Можно ли составить записку из букв журнала (каждая буква — один раз)."""
need, have = Counter(note), Counter(magazine)
return all(have[ch] >= cnt for ch, cnt in need.items()) # или: not (need - have)
print(can_build("aab", "baa"), can_build("aa", "ab"))
def top_k_frequent(nums, k):
return [x for x, _ in Counter(nums).most_common(k)]
print(top_k_frequent([1, 1, 1, 2, 2, 3], 2))
Шаблон 4: группировка по ключу
Главное — придумать ключ, одинаковый для элементов одной группы:
from collections import defaultdict
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
key = "".join(sorted(w)) # у анаграмм одинаковый отсортированный вид
groups[key].append(w)
return list(groups.values())
print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
def group_by_shift(words):
"""Строки, получаемые друг из друга сдвигом букв: abc, bcd, xyz."""
groups = defaultdict(list)
for w in words:
key = tuple((ord(c) - ord(w[0])) % 26 for c in w) # разности между буквами
groups[key].append(w)
return list(groups.values())
print(group_by_shift(["abc", "bcd", "acef", "xyz", "az", "ba", "a", "z"]))
| Задача | Ключ |
|---|---|
| анаграммы | "".join(sorted(w)) или tuple(Counter(w).items()) отсортированный / массив из 26 счётчиков |
| точки на одной прямой | наклон как несократимая дробь (dy // g, dx // g) |
| изоморфные строки | шаблон: индексы первых вхождений символов |
| дубликаты файлов | хеш содержимого |
Ключ словаря должен быть хешируемым: строки, числа, кортежи. Список ключом быть не может — превращайте в кортеж.
Шаблон 5: префиксы + словарь
Подмассивы с заданной суммой (уже встречали в уроке про окно): храним, сколько раз встречалась каждая префиксная сумма.
def longest_balanced(bits):
"""Самый длинный подмассив с равным числом 0 и 1."""
first_index = {0: -1} # баланс → первая позиция
balance = best = 0
for i, b in enumerate(bits):
balance += 1 if b == 1 else -1
if balance in first_index:
best = max(best, i - first_index[balance])
else:
first_index[balance] = i
return best
print(longest_balanced([0, 1, 0, 0, 1, 1, 0]))
Самая длинная последовательность подряд
Красивый приём: начинаем счёт только с начала цепочки — числа x, для которого x - 1 нет в множестве. Тогда каждый элемент просматривается не больше двух раз → O(n).
def longest_consecutive(nums):
values = set(nums)
best = 0
for x in values:
if x - 1 not in values: # начало цепочки
length = 1
while x + length in values:
length += 1
best = max(best, length)
return best
print(longest_consecutive([100, 4, 200, 1, 3, 2]))
LRU-кеш
Частая задача на проектирование: кеш на capacity элементов, при переполнении вытесняется давно не использованный. get и put — за O(1). Решение — хеш-таблица + двусвязный список, в Python — OrderedDict:
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # недавно использован
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False) # вытеснить самый старый
cache = LRUCache(2)
cache.put(1, "a"); cache.put(2, "b")
print(cache.get(1)) # a — теперь 1 свежий
cache.put(3, "c") # вытеснит 2
print(cache.get(2), cache.get(3), list(cache.data))
На собеседовании могут попросить реализовать двусвязный список вручную — урок «Связные списки».
Когда хеш-таблица — сигнал
Если в решении есть «для каждого элемента найди другой элемент / проверь, встречался ли» — почти наверняка нужен set или dict. Если нужен порядок или «ближайший больший/меньший» — хеш-таблица не поможет: смотрите на сортировку, кучу, стек или бинарный поиск.
Итог
- Хеш-таблица превращает поиск в O(1) и многие O(n²) решения — в O(n) ценой O(n) памяти.
- Шаблоны: «уже видели» (set), значение → индекс (dict), частоты (Counter), группировка по ключу (defaultdict), префиксы + словарь.
- Главное в группировке — придумать хешируемый ключ.
- LRU-кеш —
OrderedDictили хеш-таблица + двусвязный список.
Практика решено 0 из 5
Two Sum. Дан список nums (не отсортирован) и target. Верните индексы [i, j], i < j,
двух чисел с суммой target (гарантируется, что ответ ровно один). Решение за O(n) со словарём.
Какие тесты проверяют код (2)
assert two_sum([2, 7, 11, 15], 9) == [0, 1] and two_sum([3, 2, 4], 6) == [1, 2] and two_sum([3, 3], 6) == [0, 1] import time nums = list(range(1, 3_001)) + [100_000] start = time.perf_counter() assert two_sum(nums, 103_000) == [2_999, 3_000] assert time.perf_counter() - start < 0.05, "Нужно решение за O(n)"
def two_sum(nums, target):
index = {}
for i, x in enumerate(nums):
if target - x in index:
return [index[target - x], i]
index[x] = i
Группировка анаграмм. Сгруппируйте слова, состоящие из одних и тех же букв.
Верните список групп; внутри группы слова в исходном порядке, группы — в порядке первого появления.
["eat", "tea", "tan", "ate", "nat", "bat"] → [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]].
Какие тесты проверяют код (2)
assert group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]) == [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]] assert group_anagrams([]) == [] and group_anagrams(["a"]) == [["a"]] and group_anagrams(["", ""]) == [["", ""]]
from collections import defaultdict
def group_anagrams(words):
groups = defaultdict(list)
for w in words:
groups["".join(sorted(w))].append(w)
return list(groups.values())
Самая длинная последовательность подряд идущих чисел. Верните длину самой длинной цепочки
последовательных целых чисел, элементы которой есть в списке (порядок в списке не важен).
[100, 4, 200, 1, 3, 2] → 4 (1, 2, 3, 4). Требуется O(n), без сортировки.
Какие тесты проверяют код (3)
assert longest_consecutive([100, 4, 200, 1, 3, 2]) == 4 and longest_consecutive([]) == 0 assert longest_consecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]) == 9 and longest_consecutive([1, 2, 0, 1]) == 3 import time nums = list(range(200_000, 0, -1)) start = time.perf_counter() assert longest_consecutive(nums) == 200_000 assert time.perf_counter() - start < 1, "Слишком медленно — начинайте счёт только с начала цепочки"
def longest_consecutive(nums):
values = set(nums)
best = 0
for x in values:
if x - 1 not in values: # x — начало цепочки
length = 1
while x + length in values:
length += 1
best = max(best, length)
return best
Первый неповторяющийся символ. Верните индекс первого символа строки, который встречается
в ней ровно один раз, или -1. Два прохода и Counter.
Какие тесты проверяют код (2)
assert first_unique("leetcode") == 0 and first_unique("loveleetcode") == 2 and first_unique("aabb") == -1
import time
s = "ab" * 15_000 + "c"
start = time.perf_counter()
assert first_unique(s) == 30_000
assert time.perf_counter() - start < 0.05, "s.count в цикле — O(n²); посчитайте частоты один раз"
from collections import Counter
def first_unique(s):
counts = Counter(s)
for i, ch in enumerate(s):
if counts[ch] == 1:
return i
return -1
Почему в Two Sum со словарём проверку target - x in index делают до добавления x?
Войдите, чтобы отмечать пройденные уроки.