Хеш-таблицы
Записка о выкупе
Проверить можно ли составить записку из заданного набора букв
Постановка задачи
Описание (LeetCode)
Даны две строки ransomNote и magazine, верните true, если строка
ransomNote может быть составлена из букв строки magazine, иначе вернуть
false.
Каждая буква из строки magazine может быть использована только один раз.
Пример 1:
ransomNote = "a", magazine = "b"
Результат:
false
Пояснение:
Строка a не может быть составлена из букв b.
Пример 2:
ransomNote = "aa", magazine = "ab"
Результат:
false
Пояснение:
Строка aa не может быть составлена, так как нужно две буквы a.
Пример 3:
ransomNote = "aa", magazine = "aab"
Результат:
true
Пояснение:
Строку aa можно составить, взяв первые две буквы из magazine = "aab".
Ограничения:
1 <= ransomNote.length <= 10⁵1 <= magazine.length <= 10⁵ransomNoteиmagazineсостоят из строчных английских букв.
Решение
Представим, что есть похититель, который хочет отправить анонимную записку о
выкупе. Чтобы его почерк не узнали, он вырезает буквы из журнала. Есть две
строки: ransomNote - текст записки и magazine - текст в журнале. Задача в
том, чтобы проверить, хватит ли букв из журнала magazine, чтобы составить
записку ransomNote при условии, что каждую букву из журнала можно использовать
только один раз.
Задача о записке - это классический учебный пример, который иллюстрирует фундаментальный принцип работы хеш-таблиц. Она наглядно показывает, как замена вложенных циклов на подходящую структуру данных превращает медленный алгоритм в эффективное решение. Кроме того, на этой задаче можно хорошо понять компромисс "память против скорости", где выделение небольшого количества памяти позволяет в разы ускорить обработку данных.
Если рассуждать максимально просто, мы можем для каждой буквы из записки делать поиск в журнале. Если нашли, то "вырезаем" ее из журнала, чтобы не использовать дважды.
- Идем циклом по строке
ransomNote. - Для каждого текущего символа запускаем поиск в строке
magazine.- Если символ найден, "удаляем" его из
magazine. - Если символ не найден, возвращаем
false, так как составить записку не получится.
- Если символ найден, "удаляем" его из
- Если цикл завершился успешно, возвращаем
true.
Давайте теперь оценим такое решение по сложности. Для каждой из n букв записки
ransomNote мы выполняем поиск и удаление, которые в худшем случае потребуют
прохода по всей длине строки magazine. Если обозначить длину строки magazine
за m, то сложность такого решения можно записать как O(n * m). Если в
записке 1000 букв и в журнале 1000, это будет около миллиона операций. По сути,
это квадратичная сложность, и такое решение не годится, его нужно
оптимизировать.
При проверке каждой буквы из записки мы снова и снова повторяем проход по строке
magazine. Эти повторные проходы и приводят к лишней работе и неэффективности.
Если бы мы могли избежать этих повторных проходов, то смогли бы значительно
улучшить алгоритм. Это подсказка, что нам здесь нужна подходящая структура
данных.
В таких задачах надо уметь заметить идею "инвентаризации" доступных элементов
для учета их изначального количества и последующего изменения по мере
использования. Давайте сгруппируем все буквы из magazine в какую-нибудь
структуру данных, где для каждой буквы будем хранить ее количество, то есть
частоту. Так мы сможем избежать повторных проходов по строке magazine и вместо
медленного поиска по строке будем быстро узнавать из структуры данных, есть ли
буква и сколько таких букв осталось.
Какая структура данных подойдет? Когда требуется отслеживать количество элементов или нужно быстро по элементу получать значение, то это намек на хеш-таблицу.
Вот еще несколько ключевых фраз, на которые вы можете обращать внимание на
интервью: "сколько раз встречается", "есть ли дубликаты", "найти самый частый
элемент", "заменить один символ на другой", "сгруппировать элементы", "чего не
хватает", "найти пересечение". Скорее всего вам пригодится хеш-таблица для
группировки одинаковых элементов под одним ключом, чтобы мгновенно за O(1)
узнавать их количество.
Использование хеш-таблицы для учета букв в журнале - это эффективный способ решения этой задачи.
Основные шаги:
-
Считаем буквы в журнале: проходим по строке
magazineи записываем в хеш-таблицу, сколько раз встретилась каждая буква. -
Проверяем записку: идем циклом по строке
ransomNoteи для каждой буквы проверяем, есть ли она в нашем "запасе" из журнала:- Если буквы нет или ее количество равно нулю, возвращаем
false, записка не может быть составлена. - Если есть, уменьшаем счетчик этой буквы в таблице на единицу.
- Если буквы нет или ее количество равно нулю, возвращаем
-
При завершении цикла мы прошли всю записку до конца, значит, букв нам хватило, и записка может быть составлена. Возвращаем
true.
В этой задаче можно хорошо показать принцип работы хеш-таблиц. Так как алфавит допустимых символов - это строчные английские буквы, то вместо настоящей хеш-таблицы мы можем использовать массив из 26 элементов.
Если вы не знакомы с тем, как устроены хеш-таблицы, то вот главная идея: хеш-таблицу можно представлять себе как массив, в котором вместо целого числа в качестве индекса используется ключ - элемент почти любого типа данных, например буква, строка или произвольная структура данных с определенными свойствами. При чтении или записи в хеш-таблицу по ключу ключ при помощи хеш-функции переводится в индекс и далее по индексу мы можем найти нужный элемент.
Для того чтобы объект мог быть ключом в хеш-таблице, он должен обладать двумя фундаментальными свойствами. Если эти правила нарушаются, структура данных "ломается": вы либо не сможете найти добавленный элемент, либо получите ошибку еще на этапе вставки.
-
Неизменяемость (Immutability): добавленный в таблицу ключ не должен изменяться. Если вы измените объект ключа, его хеш-код изменится. В результате хеш-таблица будет искать его в другой "корзине" (bucket) и просто не найдет.
-
Сравниваемость (Equality): хеш-таблице недостаточно знать хеш-код, ей нужно уметь проверять объекты на равенство. Это необходимо для обработки коллизий (когда у разных объектов совпал хеш). Таблица находит нужную ячейку по хешу, а затем перебирает объекты внутри, сравнивая их напрямую, чтобы найти точное совпадение.
В разных языках эти требования реализуются по-разному, поэтому рекомендуем вам обратиться к документации по вашему языку программирования для прояснения деталей.
Рассмотрим это подробнее на нашем примере. У нас есть массив из 26 элементов -
это наша хеш-таблица. В качестве хеш-функции мы возьмем f(c) = c - 'a',
которая для любой строчной английской буквы переведет ее в число от 0до25,
то есть в индекс ячейки массива. По этому индексу мы сразу получаем доступ к
значению, которое хранится в таблице для ключа. Для текущей задачи такая
хеш-функция идеальна, так как количество возможных ключей, a, b, c, ..., x, y, z, совпадает с количеством ячеек в нашей таблице, и у нас не возникнет коллизий.
Давайте сделаем ручной прогон и увидим, как это работает на примере:
ransomNote: "abc"
magazine: "aabbc"
- Подготовка таблицы
Мы берем пустую таблицу, то есть массив, и считаем буквы в журнале, проходя по
magazine.
- Буква 'a', таблица [1, 0, 0, ...]
- Буква 'a', таблица [2, 0, 0, ...]
- Буква 'b', таблица [2, 1, 0, ...]
- Буква 'b', таблица [2, 2, 0, ...]
- Буква 'c', таблица [2, 2, 1, ...]
Итог шага: у нас есть таблица частот букв из журнала. Мы знаем, что букв a
- 2 штуки,
b- 2 штуки,c- 1 штука.
- Проверяем записку
Теперь мы берем нашу записку "abc" и проверяем каждую букву по очереди, заглядывая в таблицу.
- Буква 'a':
- Вопрос: Есть ли 'a' в таблице и больше ли ее количество, чем 0?
- Ответ: Да, там их 2.
- Действие: Забираем одну. В таблице остается: [1, 2, 1, ...].
- Буква 'b':
- Вопрос: Есть ли 'b'?
- Ответ: Да, там их 2.
- Действие: Забираем одну. В таблице остается: [1, 1, 1, ...].
- Буква 'c':
- Вопрос: Есть ли 'c'?
- Ответ: Да, она 1.
- Действие: Забираем. В таблице остается: [1, 1, 0, ...].
- Завершение цикла
Записка закончилась, мы ни разу не наткнулись на нехватку букв.
Результат: true - записку можно составить.
А если бы букв не хватило?
Если бы в записке была еще одна буква 'c' ("abcc"):
- Мы бы посмотрели в таблицу при проверке второй буквы 'c'. Увидели бы, что напротив 'c' стоит 0.
- Сразу бы вернули:
false(буквы закончились).
Почему это эффективно?
Нам не нужно каждый раз заново пробегать по всему журналу. Мы смотрим только в одну "ячейку" таблицы, что происходит мгновенно.
Оценка сложности
Временная сложность
Мы проходим по строке magazine ровно один раз с помощью одного цикла и на
каждом шаге обновляем хеш-таблицу. Операция обновления элемента в хеш-таблице в
среднем занимает O(1), поэтому сложность построения таблицы получается O(m),
где m - длина журнала.
Далее делаем за один цикл проверку, проходя по строке ransomNote, поэтому
сложность проверки получается O(n), где n - длина записки.
Общая временная сложность: O(m + n).
Пространственная сложность
Так как мы используем хеш-таблицу, в данном случае массив фиксированного размера
из 26 букв, по памяти получается константная сложность O(1).
Если алфавит будет больше, чем 26 символов, тогда пространственная сложность
будет равна O(k), где k - размер алфавита журнала.
Код решения
Приведем код решения.
func canConstruct(ransomNote string, magazine string) bool {
var counts [26]int
for _, char := range magazine {
// вычитая 'a' из символа, мы получаем индекс от 0 до 25
counts[char-'a']++
}
for _, char := range ransomNote {
index := char - 'a'
if counts[index] == 0 {
return false
}
counts[index]--
}
return true
}
Итоги
Вот основные итоги разбора этой задачи, которые пригодятся на интервью и на практике:
-
Эффективность хеш-таблиц. Используйте хеш-таблицы, чтобы избежать повторных поисков элементов.
-
Когда использовать хеш-таблицы.
- Если нужно считать количество/частоту чего-либо.
- Нужно мгновенно проверять существование элемента.
- Нужно найти пары или дубликаты.
- Требуется связать одни данные с другими (ключ-значение).
-
Сложность оптимального решения. Временная:
O(m + n)- линейный проход по обеим строкам. Пространственная:O(1)илиO(k)- память зависит от размера алфавита, а не от длины входных строк. -
Главный урок задачи: "память против скорости" (Space-Time Tradeoff). Вместо того, чтобы тратить время на повторный поиск букв в журнале для каждого символа записки, что привело бы к медленной работе
O(m * n), мы выделяем дополнительную память (хеш-таблицу или массив) для хранения состояния.