Хеширане
Hashing
Какво е хеш и какво е хеширане?
Защо ни трябва да ползваме хеширане?
Хешираща функция.
Какво е колизия? Начини за справяне с колизиите.
Rolling Hash.
Алгоритъм на Rabin-Karp.
Употреба в реалния живот.
Защо ни трябва да ползваме хеширане?
Хешираща функция.
Какво е колизия? Начини за справяне с колизиите.
Rolling Hash.
Алгоритъм на Rabin-Karp.
Употреба в реалния живот.
В състезателната информатика основно се налага да работим с числа, които са удобни за сравнение, местене и променяне. В други типове задачи си представяме различни обекти като числа, за да ползваме отново същите удобства (например в графовите алгоритми представяме върховете на графа като числа).
Съществуват, обаче, и определен брой задачи, в които не е толкова лесно да представим обектите като числа. Примери за такива задачи е когато зададените ни обекти са, например,
string
, vector
, или друга по-сложна структура. В тази тема ще разгледаме как можем да представим и тях като "числа", ползвайки техника, наречена хеширане.Какво е хеш?
? | Hash на английски означава "каша, бъркотия", а като глагол - "нарязвам". Това в основен смисъл е начинът, по който се получават "псевдонимите" на входните обекти - те се нарязват и разбъркват по някакъв начин, и (малка част от) кашата, която се получи е така нареченият хеш. Важно е да се отбележи, че от "кашата" не може да се възстанови първоначалният обект - само се знае, че е получена от него. |
int
в байтове е фиксиран).Основното изискване към хеша е да бъде:
- Лесно сравним: обикновено ще ползваме хешовете за да търсим дубликати (повтарящи се елементи) между обектите, които те представят. Съответно най-често искаме да имат дефиниран
operator==
, който работи заO(1)
време. - Компактен: когато хешът е малък по размер, той може да бъде местен с минимален разход на време. Допълнително, тъй като обикновено имаме голям брой обекти, на които даваме хеш стойност, а така и съответно голям брой хешове, ако те са малки по размер няма да заемат голямо количество памет.
Много е важно да забележите, че това представяне не е задължително уникално или еднозначно - хешът е просто псевдоним. Наистина, двама различни човека могат да имат един и същи псевдоним (например има поне няколко мутри с прякор "Картофа"). По-нататък в темата ще видим защо представянето не е еднозначно, дали това е проблем, и как да се справим с него.
Какво е хеширане?
? | Един възможен начин да "хешираме" човек е да ползваме неговия пръстов отпечатък - нещо много по-малко от самия човек, но което го определя (почти) еднозначно. Затова понякога хешът се нарича digital fingerprint, или на български - дигитален пръстов отпечатък. Нарязването и ползването на ДНК-то е друг добър, но за съжаление не твърде приложим начин за хеширане на човек. |
int
, няколко int
-а, последователност от битове, или нещо друго), това е по-скоро техника, отколкото алгоритъм.Защо ни трябва да хешираме?
? | За разлика от много други алгоритми и техники, които се ползват често в състезателното програмиране (например Динамично Оптимиране или Потоци), хеширането намира огромно приложение и в "реалния живот" - тоест в комерсиалното програмиране. Без него целият интернет и голям брой програми (включително операционната система) биха били значително по-бавни и несигурни, отколкото ги познаваме днес. |
Нека имаме стринга "Elly". На него можем да съпоставим числото (хеша) 1164733561 (защо и как точно решихме да е това число ще разгледаме малко по-надолу). Допълнително имаме стринговете "Stancho" с хеш 1852008559 и "Ellen" с хеш 1819043182. Нека имаме масив с голям брой от тези стрингове и искаме да видим кой от тях се среща най-често. Ако ползваме самите стрингове, ще имаме много сравнения между "Elly" и "Elly", "Elly" и "Stancho", "Elly" и "Ellen", "Stancho" и "Stancho", "Stancho" и "Ellen", и "Ellen" и "Ellen". Докато при двойките "Elly" и "Stancho" и "Stancho" и "Ellen" бихме намерили разлика още на първата буква, при всички останали ще ни трябват по няколко сравнения докато намерим разлика или установим, че са един и същ стринг. Наистина, например при "Elly" и "Ellen" ще намерим разлика чак на четвъртата буква, а при "Stancho" и "Stancho" ще сравним всичките осем, за да установим, че е един и същ стринг.
От друга страна, ако сравнявахме техните хешове, щяхме да се нуждаем от точно едно сравнение, за да видим дали стринговете са различни или не.
Ако имахме същата задача, но със стринговете "aaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaab", и "aaaaaaaaaaaaaaaaaaac", разликата щеше да е още по-осезаема - решението с хешове ще е двадесет пъти по-бързо! Можете да видите, че ако максималната дължина на стринговете е
O(N)
, решението с хешове ще е около N пъти по-бързо.Хешираща функция
Окей, да приемем, че хеширането е полезно - как точно го правим? Как решихме, че хешът на "Elly" е 1164733561, а този на "Stancho" е 1852008559?Реално, в зависимост от входните обекти (какво искаме да хешираме) ползваме различни хеширащи функции. Хешираща функция е функция, която приема като аргумент обект и връща неговия хеш. Логиката в тях е различна именно в зависимост какви са обектите. Например бихме ползвали различна логика ако хешираме стрингове и ако хешираме дати.
! | Бройната система, в която представяме стринга, се нарича "база". Изискване за нея е да е по-голяма от най-голямата стойност, която кодираме. Тъй като в случая имаме ASCII символи, най-голямата стойност е 255, правейки 256 валидна база. Ако изискването за базата не е изпълнено хеш стойностите ще са "дефектни" - ще можем много лесно да направим два различни стринга с един и същ хеш. |
Така хеширащата функция би изглеждала по следния начин:
unsigned simpleHash(const string& str) {
unsigned ret = 0;
for (int i = 0; i < (int)str.size(); i++) {
ret = ret * 256u + (unsigned)str[i];
}
return ret;
}
? | По-късно ще видим, че това не е особено добра хешираща функция за стрингове. По възможност ползвайте дадената по-долу версия с проста база и модул. |
unsigned int
). Именно с тази функция сме кодирали и имената "Elly", "Stancho", и "Ellen" в по-горните примери.Колизии
Нека разгледаме следния пример. Отнякъде сме взели информация за имената на всички хора по света (малко над седем милиарда, в момента на писане на тази статия), и техния ден на раждане във формат YYYY-MM-DD. Записали сме тази информация в един огромен файл, като на всеки ред е информацията за един от тези хора във формат "<собствено_име> <фамилия> <ден_на_раждане>". Например "Alexander Georgiev 1987-04-12".? | Задачата е еквивалентна на това да преброим колко неуникални реда има във файла. |
Можем да хешираме всеки ред (ползвайки функцията, която дадохме малко по-горе, или някоя друга, която да се справя със специалните знаци в имената), да заделим един голям масив с 232 клетки, и след всеки ред да увеличаваме клетката с индекс получения хеш с единица. Накрая всяка клетка със стойност по-голяма от едно ще означава, че в нея са попаднали двойници, тоест отговорът би бил сумата на всички клетки със стойност ≥ 2. Или поне би било така в един прекрасен свят.
Защо това не е така? Ако имаме седем милиарда души и от тях 1% си имат "двойник", то 6,930,000,000 биха били без двойник - тоест би трябвало да имат различни хешове. Но тъй като хешът ни е с лимит от само 232 == 4,294,967,296, то от принципа на чекмеджетата следва, че поне две от тях ще имат еднакъв хеш, въпреки, че са различни!
Случаи като горния, в които два различни обекта са асоциирани с една и съща хеш стойност се наричат колизия.
! | Хеширане, при което не възникват колизии, се нарича перфектно (на английски perfect hashing). Това най-често се случва когато имаме директно представяне на обекта в число, и максималното резултатно число е относително малко. Например дата във формата YYYY-MM-DD може да се превърне в число YYYY * 13 * 32 + MM * 32 + DD, което да е достатъчно малко (малко над 4 милиона различни стойности) за да ползваме директно като хеш. Интересно свойство на перфектното хеширане е, че от хеша може да се възстанови оригиналния обект, стига да се знае хеширащата функция. |
Тук идва моментът да се запитаме дали хеширащата функция не е "счупена", след като за различни стойности връща еднакъв хеш, дори когато би могла да не връща (ако връща стойности в интервала [0, 9,999,999,999])? Оказва се, че не е - на практика е много трудно да се направи такава, освен ако хешираните обекти нямат някакви специфични свойства.
Справяне с колизии
! | Тъй като хеширането рядко е перфектно, съответно не е еднозначно, то няма как тази трансформация да е двупосочна - тоест от обект да получавате хеш, и по хеша да можете да получите обратно обекта. Наистина, ако познаваме двама човека с прякор "картофа", то казвайки "Онзи ден срещнах картофа в центъра." няма как да знаем за кого от двамата става дума. |
За съжаление, в състезателните задачи (където се изисква оптимално решение в най-лошия случай), това не е така. Проблемът идва от това, че при получаване на еднакви хешове ще сравняваме два обекта "по бавния начин" както за различни стойности, имащи колизия, така и за еднакви стойности (при които трябва да увеличим отговора). Така, например, в горната задача можем да въведем еднакви имена и дати на почти всички хора, като така почти винаги се получава еднакъв хеш. Тъй като не сме сигурни дали е просто колизия или валидно съвпадение, трябва да сравним редовете по бавния начин - което прави цялото решение около L пъти по-бавно (където всеки ред е с дължина
O(L)
).Единственото, което можем да направим за да избегнем това, е да не сравняваме оригиналните обекти по бавния начин, допускайки възможност да имаме колизия и да не я забележим (и, съответно, да върнем грешен резултат). За да работи това трябва да сведем шанса за колизия до пренебрежимо малък.
! | Можем да ползваме два unsigned int -а вместо един unsigned long long , тъй като операции от типа на умножение и взимане на остатък са по-бързи при стандартния тип за процесора - в случая int . |
unsigned long long
вместо в unsigned int
, бихме имали много по-голямо "пространство", където да попадат стойностите, и, в следствие на това, значително по-малък шанс за колизия.Двоен и троен хеш
Доразвиване на идеята за разширяване на пространството от стойности на хешовете би било да ползваме две или дори три числа като хеш (което се нарича, съответно, "двойно" и "тройно" хеширане).? | Шансът за колизия при троен хеш е по-малък от шанса лъч, идващ от космоса, да проникне в паметта и да промени бит там, като това да доведе до грешка. |
unsigned
за всеки един от тях, бихме постигнали стойности до около 1018 за двойно и 1027 за тройно хеширане. Тройното хеширане дава достатъчно голямо хеш пространство за да не се получават колизии при повечето състезателни задачи.Нека да се върнем на задачата с хората по света и "двойниците" им. Ако има 7,000,000,000 човека и ползваме троен хеш (тоест стойности до 1027), шансът за колизия е по-малък от 0.000003% - достатъчно малък за да считаме за пренебрежим. В състезателни задачи рядко хешираме повече от милион елемента, в който случай шансът за колизия при троен хеш би бил по-малък от 0.000000000000005%.
Разбира се, за да не са еднакви тези два или три хеша, трябва всеки от тях да е генериран с различна хешираща функция. В случая можем да променим единствено числото, по което умножаваме (базата на бройната система, в която представяме стринга). Да кажем първият хеш ще е с база 256 (както до сега), вторият с база 257, а третият - с база 258.
Стандартни методи за хеширане
Тук ще разгледаме няколко стандартни метода за хеширане на различни обекти, които често се ползват в състезанията.Хеширане на числа
? | Други възможности можете да видите тук. |
Най-простият, а и най-често ползваният начин за това е като просто ги вземем по модул някакво число MOD:
unsigned hash(unsigned num) {
return num % MOD;
}
Доналд Кнут предлага съвсем малко по-сложен вариант, който се държи по-добре (и е по-труден, но все пак далеч от невъзможен за счупване):
unsigned hash(unsigned num) {
return (long long)num * (num + 3) % MOD;
}
Още малко по-усложнен вариант е да приложим няколко операции върху входното число, като например в следната функция (взета от тук):
unsigned hash(unsigned num) {
num = ((num >> 16) ^ num) * 0x45d9f3b;
num = ((num >> 16) ^ num) * 0x45d9f3b;
num = ((num >> 16) ^ num);
return num;
}
Хеширане на стрингове
По-горе вече показахме един вариант за хеширане на стрингове. Той обаче има няколко проблема:- Първоначалната стойност на хеша (променливата ret) е нула. Това не е добре, тъй като ако символът със стойност 0 е валиден, произволно-дълга последователност от този символ ще има хеш 0 (независимо от базата). Например ако имаме азбуката {A-Z} и сме я сбили до числата в интервала [0, 25], стринговете "A" и "AA" ще имат хеш нула и ще предизвикат колизия. Едно от възможните решения на този проблем е да инициализираме началния хеш със стойност 1. Друг вариант е да не ползваме символ със стойност 0.
- Базата, която ползваме, е 256 - точно една четвърт от 32-битовото число, което ползваме за хеш. Така при всяка буква от петата нататък изхвърляме по една буква от хеша и добавяме нова. Отново е много лесно да се създаде колизия - например "Stancho" и "ncho" биха имали един и същ хеш, а както и {"aaaa", "aaaaa", "aaaaaa", ...}. Това може да се оправи като или променим базата (например я направим 257), или променим модула (да сложим наш модул, а не да ползваме overflow-а, който на практика е 232).
- Модулът, който ползваме, е 232. Има лесни за създаване, специфични тестове, на които ще има колизии независимо каква база изберем! Можем да ползваме избран от нас модул (за предпочитане просто число).
? | Тъй като повечето символи, които обикновено се дават на вход, са с ASCII стойности под 127, то 127 почти винаги е по-добър избор на база от 257. |
const int BASE = 257;
const int MOD = 1000000007;
int stringHash(const string& str) {
int ret = 1;
for (int i = 0; i < (int)str.size(); i++) {
ret = ((long long)ret * BASE + str[i]) % MOD;
}
return ret;
}
А защо избрахме модулът да е 1,000,000,007 (просто число около 1,000,000,000), а не, примерно, 2,000,000,011 (просто число около 2,000,000,000)?
Често ще ни се налага не само да хешираме числата, ами и да извършваме някакви операции с тях (виж Rolling Hash и Rabin-Karp по-надолу).
! | Научавайки наизуст простите числа 1,000,000,007 и 1,000,000,009 ни дава бърз начин за избор на подходящи модули за много задачи. |
unsigned
, а от друга ще ни се налага да събираме хешове (потенциално получавайки двойно по-големи от модула числа) - което не позволява да ползваме повече от 1,073,741,823.? | При двойно и тройно хеширане кастването и извършването на деление с long long може осезаемо да забавят програмата ви. Вместо това можете да смалите модулите до достатъчно малки числа за да извършвате всички операции в int . |
long long
можем да ползваме по-малък модул - например 7782067
. Избрахме тази константа, защото ни трябва число, което умножено по базата да не overflow-ва типа. Тъй като типът е int
, който поддържа до малко над 2,000,000,000, то взехме най-голямото просто число, което е по-малко от 2000000000 / 257
. Това обаче значително намали възможните стойности на хеша (близо 128 пъти) - което предразполага за получаване на повече колизии (срещу едва 2-3 пъти подобрение на бързодействието). Ползвайте този трик само ако ви трябва наистина бързо хеширане - например ако имате малко на брой различни обекти, които се налага да хеширате много пъти, или пък когато ползвате двойно или тройно хеширане.Последно, тъй като обикновено се налага да хеширате стрингове, съставени от някакво малко подмножество на ASCII (например само малките и главните латински букви), то може да намалим базата.
! | Тук е особено важно да инициализираме началния хеш със стойност, различна от нула, тъй като в противен случай стринговете "A", "AA", "AAA", и т.н. ще имат еднакъв хеш. |
long long
), а от друга вероятно ще доведе до по-добро запълване на хеш-пространството ни. За целта, обаче, трябва да "прекодираме" буквите от 'A' до 'Z' с числата от 0 до 25, а 'a' до 'z' - с тези от 26 до 51. Това става с лесна модификация:
const int BASE = 53;
const int MOD = 37735849;
int stringHash(const string& str) {
int ret = 1;
for (int i = 0; i < (int)str.size(); i++) {
int charCode = str[i] <= 'Z' ? str[i] - 'A' : str[i] - 'a' + 26;
ret = (ret * BASE + charCode) % MOD;
}
return ret;
}
Хеширане на произволни обекти
! | Тук е важно да вземем байтовете на самите данни, а не на указателите към тях! Например ако имаме char* или int[] то трябва да вземем байтовете на всеки един елемент от тях, а не на "самия масив" (който реално е само указател). |
operator ==
трябва да хешираме само данните, които се ползват там. Един сложен, но хубав начин за хеширане на произволни обекти, е алгоритъмът за хеширане MD5, разработен от единия от авторите на книгата Introduction to Algorithms.Overflow вместо модул
? | Всъщност и ние ползвахме това в първия пример в темата, като казахме, че това не е хубава имплементация на хеширане на стринг. Тук ще покажем още една причина защо. |
unsigned int
или unsigned long long
за хеш като ползват overflow-а на числото вместо модул. Това реално е еквивалентно на това да ползваме модул, съответно, 232 и 264. Ако изберем за база просто число (различно от 2), то базата и модулът ще са взаимно-прости което, поне на теория, трябва да работи добре. Макар и неколкократно по-ефективно откъм скорост, а също така и ползващо изцяло пространството на типа, това не винаги е добра идея - особено на състезания!Причината е, че преди няколко месеца едни руснаци постнаха много интересен блог-пост за това как може да се счупи тройно, че дори и по-голямо хеширане с произволни бази, стига те да ползват за модул 232 или 264 (и, всъщност, две на която и да е степен по-малка или равна на 64). При това за целта те ползват фиксиран стринг, съставен от едва два различни символа! Въпросният стринг е от вида:
ABBABAABBAABABBABAABABBAABBABAABBAABABBAABBABAABABBABAABBAABABBA...
който се образува като многократно приложим S = S + ~S
, където ~S
означава всяко 'A'
в S е заменено с 'B'
и обратно, започвайки с S = "A"
. Реално първите няколко стъпки са:S = "A";
S = "A" + "B";
S = "AB" + "BA";
S = "ABBA" + "BAAB";
S = "ABBABAAB" + "BAABABBA";
? | Задачка, свързана с дадената редица, е CodeZeroOne. |
char S[N];
for (int i = 0; i < N; i++) {
S[i] = 'A' + __builtin_popcount(i) % 2;
}
Rolling Hash
Често в състезателни задачи трикът е не само да ползваме хешове, ами и да можем да ги генерираме бързо. Досега намирането на стринг с дължина L ставаше заO(L)
. Можем да се възползваме от факта, че обикновено генерираме хешовете на събстрингове на даден по-голям стринг и да забързаме тази процедура до O(1)
.Примерна задача
Представете си следната задача:Даден ви е стринг S с дължина N, съставен от главни латински букви. Да се намери най-дългият му substring, който се среща поне K пъти. Разрешено е срещанията да се застъпват. Ограниченията са 1 ≤ K ≤ N ≤ 100,000; 'A' ≤ Si ≤ 'Z'.
Например за "ABRACADABRABE" и K = 2, този събстринг е "ABRA". Ако K беше 3, той би бил "AB".
Тук най-тъпото решение би било да вземем всеки възможен substring и да преброим колко пъти се среща той в целия входен стринг. Тъй като сравнението на два стринга с дължина L е Например за "ABRACADABRABE" и K = 2, този събстринг е "ABRA". Ако K беше 3, той би бил "AB".
O(L)
, а има O(N2)
възможни събстринга, цялото решение би било със сложност O(N4)
(сравняваме всеки възможен събстринг с всеки друг).? | Задачата има относително просто O(N3) по време и O(N2) по памет решение без хеширане. Сещате ли се какво?Като подсказка, за всеки индекс i трябва да преброите колко са еднаквите събстрингове на тези с дължина 1, 2, ..., N започващи от този индекс. |
O(logN∙N∙N∙N)
.Следващото наблюдение, което трябва да направим, е че само сравняваме дали двойки стрингове са еднакви. При това го правим за
O(N)
! Тук като че ли е много удачно да ползваме хеширане.Един вариант е да преизчислим хешовете на събстринговете между всяка двойка индекси i и j. Така вече сравнението ще става за
O(1)
, а прекалкулацията ще е за O(N2)
. Имайки предвид, че все пак трябва да фиксираме дължината (O(logN)
), да обходим всяко начало, където би могъл да започва търсеният събстринг (O(N)
), и да преброим на колко други места се среща (O(N)
), постигнахме решение със сложност О(N2 + logN∙N∙N)
, демек O(logN∙N∙N)
. Ползвайки хеширане в най-простия му вариант намалихме сложността с фактор от N!Ако сме малко по-хитри ще забележим, че можем да премахнем още
O(N)
от втората част, като просто намерим хешовете на стринговете с фиксираната дължина, започващи от всяка позиция и преброим кой колко пъти се среща ползвайки map
. Ако има такъв, който се среща повече от K пъти, значи с дадената дължина имаме отговор. Това решение е O(N∙N + logN∙N)
. И макар да направихме втората част доста бърза, сега самото преизчисляване на хешовете стана bottle neck - тя е O(N2)
. При дадените ограничения това все още не е окей.Rolling hash
За да решим проблема ще ползваме нещо, наречено плъзгащ се хеш (на английски rolling hash). Основната идея е, че ако имаме някакъв събстринг с дължина L и искаме да намерим хеша на събстринг със същата дължина, започващ една позиция надясно от текущия, можем да направим това много бързо. Например ако имаме хеша на "CADAB" и търсим този на "ADABR" в стринга "ABRACADABRA" (ABRACADABRA -> ABRACADABRA), можем да направим следното:Нека h1 е хешът на "CADAB". От него можем да премахнем първата буква като направим
h2 = h1 - 'C' * pow(BASE, 4)
. Това е така, тъй като при хеширането всеки път като сме добавяли буква след началното 'C' сме умножавали хеша по BASE.
! | Забележете, че всички тези операции трябва да извършваме по модул MOD. При изваждането (h1 - 'C' * pow(BASE, 4) ) може да се получи отрицателна стойност. Това не е хубаво, но пък можем лесно да се справим с проблема, като добавим MOD към резултата ако той стане отрицателен. |
'R'
), получавайки хеша на "ADABR". Забележете, че единствената неконстантна операция беше pow(BASE, 4)
. Но тъй като дължината е фиксирана тази стойност можем да изчислим само веднъж и да я ползваме за плъзгането на хеша по целия стринг.Така кодът, който ще имаме за плъзгащия хеш, е нещо от сорта на:
// Фиксирали сме дължината len с двоичното.
int rollingHash(const string& s, int len) {
// Намираме хеша на първия събстринг (с дължина len)
int hash = 0;
for (int i = 0; i < len; i++)
hash = (hash * BASE + s[i]) % MOD;
// Тук ще пазим кой хеш колко пъти сме срещали
map <int, int> cnt;
cnt[hash]++;
// Изчисляваме веднъж колко е BASE^(len - 1);
int basePow = 1;
for (int i = 0; i < len - 1; i++)
basePow = (basePow * BASE) % MOD;
// Обхождаме остатъка от стринга с rolling hash
for (int i = len; i < (int)s.size(); i++) {
// Махаме първата буква
hash = hash - (s[i - len] * basePow) % MOD;
if (hash < 0)
hash += MOD;
// Добавяме новата буква
hash = (hash * BASE + s[i]) % MOD;
// "Преброяваме" новия събстринг в map-а
cnt[hash]++;
// Ако това е K-тото срещане на този събстринг, връщаме true
if (cnt[hash] >= K)
return true;
}
// Не сме намерили събстринг, който да се среща K или повече пъти.
return false;
}
? | Разбира се, при имплементацията на тази задача бихме ползвали двоен или троен хеш за да се справим с колизиите, но промяната не е голяма. |
map
тази функция е със сложност O(N∙logN)
. Като имаме предвид, че я викаме логаритъм на брой пъти (заради двоичното), така цялото решение е със сложност O(logN∙N∙logN)
- значително по-добре от началното O(N4)
! В (тясно-свързаната с тази) тема за хештаблица ще видим как можем да свалим тази сложност до O(logN∙N)
, правейки map
-ът да работи със сложност O(1)
вместо O(logN)
.Алгоритъм на Rabin-Karp
При Rolling Hash сме леко ограничени от това, че можем да намерим константно само:- Хеша на стрингове с еднаква дължина.
- Хеша на почти изцяло припокриващи се стрингове.
O(1)
. За целта, обаче, е нужна O(N)
допълнителна памет и O(N)
по време преизчисление. Това надграждане се нарича алгоритъм на Рабин-Карп (макар и да е по-скоро структура данни, отколкото алгоритъм).Идеята е да направим масив с хешовете на всеки префикс на входния стринг - тоест с хеша на първата буква, на първите две букви, на първите три букви и т.н. Ако входният стринг е "ABRACADABRA" бихме имали хешовете на {"A", "AB", "ABR", "ABRA", "ABRAC", ..., "ABRACADABRA"}. Нека запишем тези хешове в масив prefixHash[], като на i-та позиция е хешът на префикса от стринга до позиция i включително.
Допълнително ни трябва втори масив, в който сме записали всички степени на базата по модул - тоест basePow[], където
basePow[0] = 1;
, basePow[1] = BASE % MOD;
, basePow[2] = (BASE * BASE) % MOD;
и т.н.Тези преизчисления можем да направим със следния код (тук и в остатъка от примера ще считаме, че BASE * MOD * 2 ≤ 2,000,000,000):
int basePow[MAX];
int prefixHash[MAX];
void rabinKarpPrecalc(const string& s) {
basePow[0] = 1;
for (int i = 1; i < (int)s.size(); i++)
basePow[i] = (basePow[i - 1] * BASE) % MOD;
prefixHash[0] = s[0];
for (int i = 1; i < (int)s.size(); i++)
prefixHash[i] = (prefixHash[i - 1] * BASE + s[i]) % MOD;
}
След като имаме тези масиви, когато ни поискат хеша на събстринга, започващ в индекс left и завършващ в индекс right:
- Взимаме хеша на префикса до right, включително;
- От него изваждаме хеша на префикса до left - 1, включително, умножен по BASE на степен дължината на събстринга, чиито хеш търсим.
O(1)
.Реално което правим може би ще се види по-лесно с пример. Нека имаме низа "ABRACADABRA" и искаме да намерим хеша на подниза от индекс 4 до индекс 6, включително (тоест "CAD").
- Взимаме хеша на "ABRACAD";
- Взимаме хеша на "ABRA";
- Умножаваме втория хеш по базата на степен дължината на събстринга, който търсим (в случая 3). Получаваме хеша на "ABRA___" (където с '_' сме отбелязали "празен" символ).
- От хеша на "ABRACAD" изваждаме хеша на "ABRA___" и получаваме хеша на "CAD".
Откъм код (ползвайки двата вече преизчислени масива) това би изглеждало нещо от сорта на:
int getSubstringHash(int left, int right) {
int h1 = prefixHash[right];
int h2 = left > 0 ? prefixHash[left - 1] : 0;
h2 = ((long long)h2 * basePow[right - left + 1]) % MOD;
return (h1 - h2 + MOD) % MOD;
}
long long
, защото макар и MOD * BASE
да не овърфлоува, стойностите в basePow[] са също с размер до MOD. Така при тази операция имаме MOD * MOD
, което вече най-вероятно ще овърфлоува.Употреба в реалния живот
Хеширането намира разноорбазни употреби в реалния живот. Някои от тях сме споменали тук.Пазене на пароли
Голяма част от сайтовете, които изискват парола, не пазят самата парола на сървърите си, а само нейн хеш. Всеки път, когато се log-вате на сайта, въведената от вас парола се хешира и се изпраща на сайта, а там хешът се сравнява със запазения при регистрацията хеш.? | Признайте си, че ползвате или сте ползвали една и съща парола за поне няколко различни сайта. |
Верификация на файлове
? | Това е познато като чексума (от английски, checksum). |
.exe
) от даден сайт, понякога фирмата производител предоставя за него хеш, с който можете да проверите, че файлът не е променян (примерно не е заразен с вирус). Така теглите файла, намирате неговия очакван хеш от производителя, хеширате изтегления файл и сравнявате хешовете. Ако те са еднакви, значи файлът не е променян (и е окей за ползване). Ако не са еднакви, значи или е имало проблем при тегленето, или файлът е инфектиран с нещо.Internet Cache
Когато отваряте някой сайт, има голям шанс част от него да не бъде променян от последното му зареждане. Най-често това са картинки и бутони, flash картинки/игрички и други. Тези файлове, които са малко вероятно да бъдат променени, биват кеширани на друго ниво - например на някой сървър по пътя от вашия компютър до сайта, или дори на вашия компютър. Когато отваряте страница за първи път, цялото нейно статично (непроменливо) съдържание се сваля на компютъра ви, включително картинките, ако има такива. Следващият път, когато отворите сайта (и кешът на компютъра ви не се е изтрил), картинките, например, няма да бъдат свалени наново от сървъра (и да обиколят половината свят за да стигнат до вас), а ще бъдат показани вече свалените такива! Реално компютърът ви знае, че ги има свалени, като пази хеш на техния адрес.Bloom Filters
Друга честа употреба на хеширането са така наречените Bloom Filters (повече информация за тях тук). Те се ползват за бърза проверка дали даден обект не принадлежи на дадено множество. Например ако имаме огромна база данни (или огромен файл с някаква информация) и търсим в него даден обект, това би могло да бъде много бавно - особено ако обектът го няма и се наложи да обходим всичките данни. Блуум филтърът представлява комбинация на хешовете на всички обекти в базата данни (или файла), която ни позволява (хеширайки обекта, който търсим) да проверим дали него със сигурност го няма там - в който случай не би се наложило изобщо да го търсим. Ако пък блуум филътърт каже, че е възможно да го има, ще трябва да направим отново стандартното (бавно) търсене. Забележете, че е възможно отново да сме обходили всички данни и да не го намерим - това би се случило ако сме имали колизия в блуум филтъра.Много прост пример за блуум филтър е да хешираме обектите и да правим побитово "или" на техните хешове в една променлива. Нека създадем такъв блуум филтър за имената "Elly", "Stancho", и "Ellen", ползвайки най-простата хешираща функция (дадена в началото на темата):
String Hash Hash (as bits)
-------------------------------------------------------------
Elly 1164733561 01000101011011000110110001111001
Stancho 1852008559 01101110011000110110100001101111
Ellen 1819043182 01101100011011000110010101101110
=============================================================
Bloom 1869573503 01101111011011110110110101111111
От друга страна името "Didi" с хеш 1147757673 и битова репрезентация 01000100011010010110010001101001 би минало блуум филтъра и въпреки всичко не присъства сред оригиналните имена. Затова отново наблягаме, че блуум филтрите само могат да ни кажат кога един елемент със сигурност не е в дадено множество (няма false negatives) но може да "излъже" ако каже, че е (може да има false positives).
Може би забелязвате, че при блуум филтрите имаме много по-голям шанс за колизия, отколкото при хеш таблица. Това е така, тъй като всичките хешове са наблъскани в много малко пространство - което е именно и предимството им. При стандартна хеш таблица бихме ползвали поне
O(N)
памет. Блуум филтрите ни позволяват да смалим тази памет до произволно малко число (например O(logN)
), което е практично ако примерно правим алгоритъм за устройство с ограничени ресурси - да кажем GSM.Задачи за тренировка
Примерни задачки, които се решават с хешове:- Sequence Members: За да е достатъчно ефективно решението ни трябва да нямаме колизии при броенето. За да стане това трябва първо да намерим перфектен хеш за специалните числа, и чак после да генерираме другите.
- Chelly: Можем да хешираме входните думи, да сортираме хешовете им и после за всеки начален индекс в текста и всяка дължина да намерим хеша на дадения substring и да го потърсим с двоично търсене във входните думи. Алтернативно, можем да ползваме хеш таблица.
Chelly
PrAnKA 2010
Author: Alexander Georgiev
Tags: Medium, Rolling Hash, Hashmap
Statement | Archive | Online Judge - Gattaca: Относително сложна задачка, подобна на примера за Rolling Hash (леко по-сложен вариант).
- Davy Jones's Organ: Трябва първо да намерите къде е "центърът" на палиндрома (ако единият стринг е с дължина N, а другият с M, то "центърът" е с дължина max(N, M) - min(N, M) и се намира в по-дългата дума). След това задачата се свежда до прилагане на Rolling Hash (или Рабин-Карп) в права и обратна посока.
- Cipher Message 2: Трябва да правите "плъзгащ се прозорец" с дължина K по стринга, като при всяко местене изхвърляте всички събстрингове, започващи в премахнатата буква и добавяте всички, завършващи в новата. Думите от прозореца пазите в хеш таблица.
Допълнителни материали
- Хештаблица (www.informatika.bg)
- Hash Function (en.wikipedia.org)
- MD5 message-digest algorithm (en.wikipedia.org)
- Birthday Paradox (en.wikipedia.org)
- Hash Functions (www.cs.hmc.edu)
- Good hash functions for integers (www.stackoverflow.com)
- Anti-hash test (www.codeforces.com)
За да предложите корекция, селектирайте думата или текста, който искате да бъде променен,
натиснете Enter и изпратете Вашето предложение.
натиснете Enter и изпратете Вашето предложение.
Страницата е посетена 16152 пъти.