Хешування рядків
Алгоритми хешування допомагають розв'язувати багато задач.
Ми хочемо ефективно розв'язати задачу порівняння рядків. Спосіб «в лоб» — це просто порівняти літери обох рядків, що має часову складність , якщо і — розміри двох рядків. Ми хочемо краще. Ідея хешування рядків полягає в наступному: ми відображаємо кожен рядок у ціле число і порівнюємо ці числа замість самих рядків. Це дозволяє нам скоротити час порівняння рядків до .
- Потрібно порівнювати довільні підрядки за або рахувати кількість різних підрядків простіше, ніж суфіксними структурами? (якщо потрібні лексикографічні запити на суфіксах → Суфіксний масив)
- Прийнятна ймовірнісна відповідь (рідкісні колізії хешів допустимі)? (якщо потрібна детермінована відповідь для одного взірця → КМП)
- Шукаєте взірець у тексті саме через хеші ковзним вікном? (тоді див. конкретну реалізацію → Рабін–Карп)
Для такого перетворення нам потрібна так звана хеш-функція. Її мета — перетворити рядок на ціле число, так званий хеш рядка. Має виконуватися така умова: якщо два рядки і рівні (), то їхні хеші також мають бути рівними (). Інакше ми не зможемо порівнювати рядки.
Зауважимо, що зворотний напрямок виконуватися не зобов'язаний. Якщо хеші рівні (), то рядки не обов'язково мають бути рівними. Наприклад, коректною хеш-функцією була б проста для кожного . Звісно, це просто безглуздий приклад, бо така функція буде абсолютно некорисною, але вона є коректною хеш-функцією. Причина, чому зворотний напрямок виконуватися не зобов'язаний, полягає в тому, що рядків експоненційно багато. Якщо ми хочемо, щоб ця хеш-функція розрізняла лише всі рядки з малих літер довжиною менше за 15, то вже хеш не вмістився б у 64-бітне ціле число (наприклад, unsigned long long), бо їх так багато. І, звісно, ми не хочемо порівнювати довільно довгі цілі числа, бо це також матиме складність .
Тож зазвичай ми хочемо, щоб хеш-функція відображала рядки на числа з фіксованого діапазону , тоді порівняння рядків — це просто порівняння двох цілих чисел фіксованої довжини. І, звісно, ми хочемо, щоб було дуже ймовірним, якщо .
Це важлива частина, яку ви маєте тримати в голові. Використання хешування не буде на 100% детерміновано правильним, бо два цілком різні рядки можуть мати однаковий хеш (хеші колізують). Однак у переважній більшості задач це можна спокійно ігнорувати, оскільки ймовірність того, що хеші двох різних рядків зколізують, усе одно дуже мала. А ще в цій статті ми обговоримо деякі техніки, як тримати ймовірність колізій дуже низькою.
Обчислення хешу рядка
Хороший і широко вживаний спосіб означити хеш рядка довжини — це
де і — деякі обрані додатні числа. Це називають поліноміальним хешем (polynomial rolling hash function).
Розумно обрати простим числом, приблизно рівним кількості символів у вхідному алфавіті. Наприклад, якщо вхід складається лише з малих літер англійського алфавіту, то — хороший вибір. Якщо вхід може містити як великі, так і малі літери, то можливим вибором є . Код у цій статті використовуватиме .
Очевидно, має бути великим числом, оскільки ймовірність колізії двох випадкових рядків становить приблизно . Інколи обирають , бо тоді переповнення 64-бітних цілих працює точно так само, як операція за модулем. Однак існує метод, який генерує колізуючі рядки (і він працює незалежно від вибору ). Тож на практиці не рекомендується. Хорошим вибором для є деяке велике просте число. Код у цій статті просто використовуватиме . Це велике число, але водночас достатньо мале, щоб ми могли виконувати множення двох значень за допомогою 64-бітних цілих.
Ось приклад обчислення хешу рядка , який містить лише малі літери. Ми перетворюємо кожен символ на ціле число. Тут ми використовуємо перетворення , , , . Перетворення — не дуже хороша ідея, бо тоді хеші рядків , , , усі дорівнюватимуть .
- C++
- Python
- TypeScript
- Go
long long compute_hash(string const& s) {
const int p = 31;
const int m = 1e9 + 9;
long long hash_value = 0;
long long p_pow = 1;
for (char c : s) {
hash_value = (hash_value + (c - 'a' + 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return hash_value;
}
def compute_hash(s: str) -> int:
p = 31
m = 10**9 + 9
hash_value = 0
p_pow = 1
for c in s:
# Python має необмежені цілі, тож переповнення немає
hash_value = (hash_value + (ord(c) - ord('a') + 1) * p_pow) % m
p_pow = (p_pow * p) % m
return hash_value
function computeHash(s: string): bigint {
const p = 31n;
const m = 1000000009n;
// Використовуємо BigInt: добуток двох ~1e9 значень (~1e18)
// переповнює звичайний number (надійний лише до 2^53)
let hashValue = 0n;
let pPow = 1n;
for (const c of s) {
const code = BigInt(c.charCodeAt(0) - 'a'.charCodeAt(0) + 1);
hashValue = (hashValue + code * pPow) % m;
pPow = (pPow * p) % m;
}
return hashValue;
}
func computeHash(s string) int64 {
const p int64 = 31
const m int64 = 1e9 + 9
var hashValue int64 = 0
var pPow int64 = 1
for _, c := range s {
// int64 (до ~9.2e18) достатньо: добуток < 1e9 * 1e9 = 1e18
hashValue = (hashValue + (int64(c)-int64('a')+1)*pPow) % m
pPow = (pPow * p) % m
}
return hashValue
}
Попереднє обчислення степенів може дати приріст продуктивності.
Приклади задач
Пошук дублікатів рядків у масиві рядків
Задача: дано список з рядків , кожен не довший за символів; знайти всі дублікати рядків і розбити їх на групи.
З очевидного алгоритму, що передбачає сортування рядків, ми отримали б часову складність , де сортування потребує порівнянь, а кожне порівняння займає часу. Однак, використовуючи хеші, ми зменшуємо час порівняння до , що дає нам алгоритм, який працює за час.
Ми обчислюємо хеш для кожного рядка, сортуємо хеші разом з індексами, а потім групуємо індекси за однаковими хешами.
- C++
- Python
- TypeScript
- Go
vector<vector<int>> group_identical_strings(vector<string> const& s) {
int n = s.size();
vector<pair<long long, int>> hashes(n);
for (int i = 0; i < n; i++)
hashes[i] = {compute_hash(s[i]), i};
sort(hashes.begin(), hashes.end());
vector<vector<int>> groups;
for (int i = 0; i < n; i++) {
if (i == 0 || hashes[i].first != hashes[i-1].first)
groups.emplace_back();
groups.back().push_back(hashes[i].second);
}
return groups;
}
def group_identical_strings(s: list[str]) -> list[list[int]]:
n = len(s)
# Пари (хеш, індекс), відсортовані за хешем
hashes = sorted((compute_hash(s[i]), i) for i in range(n))
groups: list[list[int]] = []
for i in range(n):
if i == 0 or hashes[i][0] != hashes[i - 1][0]:
groups.append([])
groups[-1].append(hashes[i][1])
return groups
function groupIdenticalStrings(s: string[]): number[][] {
const n = s.length;
// Пари [хеш, індекс]
const hashes: [bigint, number][] = s.map((str, i) => [computeHash(str), i]);
// Сортуємо за хешем (BigInt порівнюємо через віднімання)
hashes.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
const groups: number[][] = [];
for (let i = 0; i < n; i++) {
if (i === 0 || hashes[i][0] !== hashes[i - 1][0]) {
groups.push([]);
}
groups[groups.length - 1].push(hashes[i][1]);
}
return groups;
}
func groupIdenticalStrings(s []string) [][]int {
n := len(s)
type pair struct {
hash int64
idx int
}
hashes := make([]pair, n)
for i := 0; i < n; i++ {
hashes[i] = pair{computeHash(s[i]), i}
}
sort.Slice(hashes, func(i, j int) bool {
return hashes[i].hash < hashes[j].hash
})
var groups [][]int
for i := 0; i < n; i++ {
if i == 0 || hashes[i].hash != hashes[i-1].hash {
groups = append(groups, []int{})
}
groups[len(groups)-1] = append(groups[len(groups)-1], hashes[i].idx)
}
return groups
}
Швидке обчислення хешів підрядків заданого рядка
Задача: дано рядок та індекси і ; знайти хеш підрядка .
За означенням маємо:
Множення на дає:
Отже, знаючи значення хешу кожного префікса рядка , ми можемо обчислити хеш будь-якого підрядка напряму за цією формулою. Єдина проблема, з якою ми стикаємося під час обчислення, — це те, що ми маємо вміти ділити на . Тому нам потрібно знайти обернений елемент за модулем до , а потім виконати множення на цей обернений елемент. Ми можемо попередньо обчислити обернений елемент до кожного , що дозволяє обчислювати хеш будь-якого підрядка за час.
Однак існує простіший спосіб. У більшості випадків, замість того щоб обчислювати хеші підрядків точно, достатньо обчислити хеш, помножений на деякий степінь . Припустімо, ми маємо два хеші двох підрядків: один помножений на , а інший — на . Якщо , то ми множимо перший хеш на , інакше множимо другий хеш на . Зробивши це, ми отримуємо обидва хеші, помножені на той самий степінь (який дорівнює максимуму з та ), і тепер ці хеші можна легко порівнювати без потреби в будь-якому діленні.
Застосування хешування
Ось деякі типові застосування хешування:
- Алгоритм Рабіна — Карпа для пошуку взірця в рядку за час
- Підрахунок кількості різних підрядків рядка за (див. нижче)
- Підрахунок кількості паліндромних підрядків у рядку.
Визначення кількості різних підрядків у рядку
Задача: дано рядок довжини , що складається лише з малих англійських літер; знайти кількість різних підрядків у цьому рядку.
Щоб розв'язати цю задачу, ми перебираємо всі довжини підрядків . Для кожної довжини підрядка ми будуємо масив хешів усіх підрядків довжини , помножених на той самий степінь . Кількість різних елементів у масиві дорівнює кількості різних підрядків довжини у рядку. Це число додається до підсумкової відповіді.
Для зручності ми використовуватимемо як хеш префікса з символів і визначимо .
- C++
- Python
- TypeScript
- Go
int count_unique_substrings(string const& s) {
int n = s.size();
const int p = 31;
const int m = 1e9 + 9;
vector<long long> p_pow(n);
p_pow[0] = 1;
for (int i = 1; i < n; i++)
p_pow[i] = (p_pow[i-1] * p) % m;
vector<long long> h(n + 1, 0);
for (int i = 0; i < n; i++)
h[i+1] = (h[i] + (s[i] - 'a' + 1) * p_pow[i]) % m;
int cnt = 0;
for (int l = 1; l <= n; l++) {
unordered_set<long long> hs;
for (int i = 0; i <= n - l; i++) {
long long cur_h = (h[i + l] + m - h[i]) % m;
cur_h = (cur_h * p_pow[n-i-1]) % m;
hs.insert(cur_h);
}
cnt += hs.size();
}
return cnt;
}
def count_unique_substrings(s: str) -> int:
n = len(s)
p = 31
m = 10**9 + 9
p_pow = [1] * n
for i in range(1, n):
p_pow[i] = (p_pow[i - 1] * p) % m
# h[i] — хеш префікса з i символів, h[0] = 0
h = [0] * (n + 1)
for i in range(n):
h[i + 1] = (h[i] + (ord(s[i]) - ord('a') + 1) * p_pow[i]) % m
cnt = 0
for length in range(1, n + 1):
hs = set()
for i in range(n - length + 1):
# Хеш підрядка, помножений на спільний степінь p
cur_h = (h[i + length] - h[i]) % m
cur_h = (cur_h * p_pow[n - i - 1]) % m
hs.add(cur_h)
cnt += len(hs)
return cnt
function countUniqueSubstrings(s: string): number {
const n = s.length;
const p = 31n;
const m = 1000000009n;
// BigInt обов'язковий: добутки ~1e18 переповнюють number
const pPow: bigint[] = new Array(n).fill(1n);
for (let i = 1; i < n; i++) {
pPow[i] = (pPow[i - 1] * p) % m;
}
// h[i] — хеш префікса з i символів, h[0] = 0
const h: bigint[] = new Array(n + 1).fill(0n);
for (let i = 0; i < n; i++) {
const code = BigInt(s.charCodeAt(i) - 'a'.charCodeAt(0) + 1);
h[i + 1] = (h[i] + code * pPow[i]) % m;
}
let cnt = 0;
for (let len = 1; len <= n; len++) {
const hs = new Set<bigint>();
for (let i = 0; i <= n - len; i++) {
// Додаємо m, щоб уникнути від'ємного значення
let curH = (h[i + len] + m - h[i]) % m;
curH = (curH * pPow[n - i - 1]) % m;
hs.add(curH);
}
cnt += hs.size;
}
return cnt;
}
func countUniqueSubstrings(s string) int {
n := len(s)
const p int64 = 31
const m int64 = 1e9 + 9
pPow := make([]int64, n)
if n > 0 {
pPow[0] = 1
}
for i := 1; i < n; i++ {
pPow[i] = (pPow[i-1] * p) % m
}
// h[i] — хеш префікса з i символів, h[0] = 0
h := make([]int64, n+1)
for i := 0; i < n; i++ {
h[i+1] = (h[i] + (int64(s[i])-int64('a')+1)*pPow[i]) % m
}
cnt := 0
for length := 1; length <= n; length++ {
hs := make(map[int64]struct{})
for i := 0; i <= n-length; i++ {
// Додаємо m, щоб уникнути від'ємного значення
curH := (h[i+length] + m - h[i]) % m
curH = (curH * pPow[n-i-1]) % m
hs[curH] = struct{}{}
}
cnt += len(hs)
}
return cnt
}
Зауважимо, що — не найкраща можлива часова складність для цієї задачі. Розв'язок зі складністю описано у статті про суфіксні масиви, і його навіть можна обчислити за за допомогою суфіксного дерева або суфіксного автомата.
Покращення ймовірності відсутності колізій
Доволі часто згаданого вище поліноміального хешу цілком достатньо, і жодних колізій під час тестів не станеться. Пам'ятайте, що ймовірність того, що станеться колізія, становить лише . Для ймовірність становить , що доволі мало. Але зауважте, що ми зробили лише одне порівняння. Що, якщо ми порівнювали рядок з різними рядками. Імовірність того, що станеться хоча б одна колізія, тепер становить . А якщо ми хочемо порівняти різних рядків між собою (наприклад, підрахувавши, скільки існує унікальних рядків), то ймовірність того, що станеться хоча б одна колізія, уже становить . Майже гарантовано, що ця задача завершиться колізією і поверне неправильний результат.
Є справді простий прийом, щоб отримати кращі ймовірності. Ми можемо просто обчислити для кожного рядка два різні хеші (використавши два різні та/або різні ) і порівнювати ці пари замість окремих хешів. Якщо для кожної з двох хеш-функцій становить близько , то це більш-менш еквівалентно одній хеш-функції з . Коли ми порівнюємо рядків між собою, ймовірність того, що станеться хоча б одна колізія, тепер зменшується до .
Задачі для практики
- Good Substrings - Codeforces
- A Needle in the Haystack - SPOJ
- String Hashing - Kattis
- Double Profiles - Codeforces
- Password - Codeforces
- SUB_PROB - SPOJ
- INSQ15_A
- SPOJ - Ada and Spring Cleaning
- GYM - Text Editor
- 12012 - Detection of Extraterrestrial
- Codeforces - Games on a CD
- UVA 11855 - Buzzwords
- Codeforces - Santa Claus and a Palindrome
- Codeforces - String Compression
- Codeforces - Palindromic Characteristics
- SPOJ - Test
- Codeforces - Palindrome Degree
- Codeforces - Deletion of Repeats
- HackerRank - Gift Boxes