Колко е красиво {}
?
Той ви позволява да съхранявате стойности по ключ и да ги извличате по много рентабилен начин ( O(1)
, повече за това по-късно).
В този пост искам да приложа една много основна хеш таблица и да разгледам нейната вътрешна работа, за да обясня една от най-гениалните идеи в компютърните науки.
Проблемът
Представете си, че изграждате нов език за програмиране: започвате с доста прости типове (низове, цели числа, плувки, ...) и след това продължавате да прилагате много основни структури от данни. Първо измисляте масива ( []
), след това идва хеш таблицата (иначе известна като речник, асоциативен масив, hashmap, map и ... списъкът продължава).
Чудили ли сте се как работят? Как са толкова адски бързи?
Е, да кажем, че JavaScript не е имал {}
или new Map()
, и нека внедрим нашите собствени DumbMap
!
Бележка за сложността
Преди да започнем да търкаляме топката, трябва да разберем как работи сложността на дадена функция: Уикипедия има добро опресняване на изчислителната сложност, но ще добавя кратко обяснение за мързеливите.
Сложността измерва колко стъпки се изискват от нашата функция - колкото по-малко стъпки, толкова по-бързо е изпълнението (известно още като „време на изпълнение“).
Нека да разгледаме следния фрагмент:
function fn(n, m) { return n * m}
Сложността на изчисленията (от сега просто „сложност“) на fn
е O(1)
, което означава, че е постоянна (можете да прочетете O(1)
като „ цената е една “): без значение какви аргументи предавате, платформата, която изпълнява този код, трябва да извърши само една операция (умножете n
в m
). Отново, тъй като това е една операция, цената е посочена като O(1)
.
Сложността се измерва, като се приеме, че аргументите на вашата функция могат да имат много големи стойности. Нека разгледаме този пример:
function fn(n, m) { let s = 0
for (i = 0; i < 3; i++) { s += n * m }
return s}
Бихте си помислили, че сложността му е O(3)
, нали?
Отново, тъй като сложността се измерва в контекста на много големи аргументи, ние сме склонни да „изпускаме“ константи и да разглеждаме O(3)
същото като O(1)
. Така че, дори и в този случай, бихме казали, че сложността на fn
е O(1)
. Без значение каква е стойността n
и кои m
са, винаги в крайна сметка правите три операции - което отново е постоянна цена (следователно O(1)
).
Сега този пример е малко по-различен:
function fn(n, m) { let s = []
for (i = 0; i < n; i++) { s.push(m) }
return s}
Както виждате, ние циклираме толкова пъти, колкото стойността на n
, която може да бъде в милиони. В този случай ние определяме сложността на тази функция като O(n)
, тъй като ще трябва да направите толкова операции, колкото стойността на един от вашите аргументи.
Други примери?
function fn(n, m) { let s = []
for (i = 0; i < 2 * n; i++) { s.push(m) }
return s}
Това примери цикли 2 * n
пъти, което означава, сложността трябва да бъде O(2n)
. Тъй като споменахме, че константите се „игнорират“ при изчисляване на сложността на дадена функция, този пример също се класифицира като O(n)
.
Още едно?
function fn(n, m) { let s = []
for (i = 0; i < n; i++) { for (i = 0; i < n; i++) { s.push(m) } }
return s}
Тук ние циклираме n
и отново циклираме вътре в основния цикъл, което означава, че сложността е „на квадрат“ ( n * n
): ако n
е 2, ще стартираме s.push(m)
4 пъти, ако 3 ще го изпълним 9 пъти и т.н.
В този случай сложността на функцията е посочена като O(n²)
.
Последен пример?
function fn(n, m) { let s = []
for (i = 0; i < n; i++) { s.push(n) }
for (i = 0; i < m; i++) { s.push(m) }
return s}
В този случай нямаме вложени цикли, но два пъти циклираме два различни аргумента: сложността се определя като O(n+m)
. Кристално чист.
Сега, след като току-що получихте кратко въведение (или опресняване) за сложността, е много лесно да разберете, че функция със сложност O(1)
ще изпълнява много по-добре от тази с O(n)
.
Хеш таблиците имат O(1)
сложност: от гледна точка на неспециалистите те са супер бързи . Да продължим.
(Аз съм доста легнал на хеш таблици, винаги имащ O(1)
сложност, но просто чета нататък;))
Нека да изградим (тъпа) хеш таблица
Нашата хеш таблица има 2 прости метода - set(x, y)
и get(x)
. Нека започнем да пишем малко код:
И нека приложим един много прост, неефективен начин за съхраняване на тези двойки ключ-стойност и извличането им по-късно. Първо започваме, като ги съхраняваме във вътрешен масив (не забравяйте, че не можем да използваме, {}
тъй като изпълняваме {}
- умопомрачение!):
Тогава просто става въпрос за получаване на правилния елемент от списъка:
Нашият пълен пример:
Нашата DumbMap
е невероятна! Той работи веднага, но как ще се представи, когато добавим голямо количество двойки ключ-стойност?
Нека опитаме прост бенчмарк. Първо ще се опитаме да намерим несъществуващ елемент в хеш таблица с много малко елементи и след това да опитаме същото в такъв с голямо количество елементи:
Резултатите? Не толкова обнадеждаващо:
with very few records in the map: 0.118mswith lots of records in the map: 14.412ms
При нашето изпълнение трябва да прегледаме всички елементи вътре, this.list
за да намерим такъв със съответстващия ключ. Цената е O(n)
и е доста ужасна.
Направете го бързо
Трябва да намерим начин да избегнем прелистване на нашия списък: време е да върнем хеш обратно в хеш таблицата .
Някога чудили ли сте се защо тази структура от данни се нарича хеш таблица? Това е така, защото на клавишите, които сте задали и получавате, се използва хешираща функция. Ще използваме тази функция, за да превърнем нашия ключ в цяло число i
и да съхраним стойността си в индекс i
на нашия вътрешен списък. Тъй като достъпът до елемент чрез неговия индекс от списък има постоянна цена ( O(1)
), тогава хеш таблицата също ще има цена от O(1)
.
Нека изпробваме това:
Тук използваме модула низ-хеш, който просто преобразува низ в числов хеш. Използваме го за съхраняване и извличане на елементи в индекса hash(key)
на нашия списък. Резултатите?
with lots of records in the map: 0.013ms
W - O - W. За това говоря!
Не е нужно да преглеждаме всички елементи в списъка и извличането на елементи от тях DumbMap
е супер бързо!
Позволете ми да кажа това възможно най-ясно: хеширането е това, което прави хеш таблиците изключително ефективни . Няма магия. Нищо повече. Нада. Просто проста, умна, гениална идея.
Разходите за избор на правилната хешираща функция
Разбира се, избирането на функция за бързо хеширане е много важно. Ако нашите hash(key)
работи за няколко секунди, нашата функция ще бъде доста бавна, независимо от сложността си.
В същото време е много важно да се уверите, че нашата хешираща функция не води до много сблъсъци , тъй като те биха били в ущърб на сложността на нашата хеш таблица.
Объркан? Нека разгледаме по-отблизо сблъсъците.
Сблъсъци
Може да си помислите „ А, добрата функция на хеширане никога не генерира сблъсъци! ”: Добре, върнете се в реалния свят и помислете отново. Google успя да създаде сблъсъци за алгоритъма за хеширане SHA-1 и е просто въпрос на време или изчислителна мощност, преди функцията за хеширане да се счупи и да върне същия хеш за 2 различни входа. Винаги приемайте, че вашата хешираща функция генерира сблъсъци и прилагайте правилната защита срещу такива случаи.
В конкретен случай, нека се опитаме да използваме hash()
функция, която генерира много сблъсъци:
Тази функция използва масив от 10 елемента за съхраняване на стойности, което означава, че е вероятно елементите да бъдат заменени - неприятна грешка в нашата DumbMap
:
За да разрешим проблема, можем просто да съхраняваме множество двойки ключ-стойност в един и същ индекс. Така че нека изменим нашата хеш таблица:
Както може да забележите, тук се връщаме към първоначалната ни реализация: съхраняваме списък от двойки ключ-стойност и прелистваме през всяка от тях. Това ще бъде доста бавно, когато има много сблъсъци за определен индекс на списъка.
Нека сравним това с помощта на нашата собствена hash()
функция, която генерира индекси от 1 до 10:
with lots of records in the map: 11.919ms
и с помощта на хеш функцията от string-hash
, която генерира произволни индекси:
with lots of records in the map: 0.014ms
Уау! Има цената за избор на правилната хешираща функция - достатъчно бърза, че да не забави изпълнението ни сама по себе си, и достатъчно добра, че да не води до много сблъсъци.
Обикновено O (1)
Помниш ли думите ми?
Hashtables иматO(1)
сложностЕ, излъгах: сложността на хеш таблицата зависи от избраната от вас функция за хеширане. Колкото повече сблъсъци генерирате, толкова по-голяма е сложността O(n)
.
Хешираща функция като:
function hash(key) { return 0}
би означавало, че нашата хеш таблица има сложност от O(n)
.
Ето защо, като цяло, изчислителната сложност има три мерки: най-добрия, средния и най-лошия сценарий. Hashtables имат O(1)
сложност в най-добрия и среден сценарий, но попадат O(n)
в най-лошия им сценарий.
Запомнете: добрата хешираща функция е ключът към ефективната хеш таблица - нищо повече, нищо по-малко.
Повече за сблъсъците ...
Техниката, която използвахме, за да коригираме DumbMap
в случай на сблъсъци, се нарича отделна верига: ние съхраняваме всички двойки ключове, които генерират сблъсъци, в списък и ги прелистваме.
Друга популярна техника е откритото адресиране:
- във всеки индекс от нашия списък съхраняваме една и една единствена двойка ключ-стойност
- когато се опитвате да съхраните чифт в индекс
x
, ако вече има двойка ключ-стойност, опитайте да съхраните новата ни двойка вx + 1
- ако
x + 1
е взето, опитайтеx + 2
и така нататък ... - когато извличате елемент, хеширайте ключа и вижте дали елементът в тази позиция (
x
) съвпада с нашия ключ - ако не, опитайте да получите достъп до елемента на позиция
x + 1
- изплакнете и повторете, докато стигнете до края на списъка или когато намерите празен индекс - това означава, че нашият елемент не е в хеш таблицата
Умен, опростен, елегантен и обикновено много ефективен!
ЧЗВ (или TL; DR)
Хеш таблицата хешира ли стойностите, които съхраняваме?
Не, ключовете са хеширани, за да могат да бъдат превърнати в цяло число i
и ключовете и стойностите се съхраняват на позиция i
в списък.
Хеширащите функции, използвани от хеш таблиците, генерират ли сблъсъци?
Абсолютно - така се прилагат хеш таблици със защитни стратегии, за да се избегнат неприятни грешки.
Хеш таблиците използват ли списък или свързан списък вътрешно?
Зависи, и двете могат да работят. В нашите примери използваме масива JavaScript ( []
), който може да бъде динамично оразмерен:
> a = []
> a[3] = 1
> a[ , 1 ]
Защо избрахте JavaScript за примерите? JS масивите СА хеш таблици!
Например:
> a = [][]
> a["some"] = "thing"'thing'
> a[ some: 'thing' ]
> typeof a'object'
Знам, по дяволите JavaScript.
JavaScript е „универсален“ и може би най-лесният за разбиране език при разглеждане на примерен код. JS може да не е най-добрият език, но се надявам тези примери да са достатъчно ясни.
Вашият пример наистина ли е добра реализация на хеш таблица? Наистина ли е ТОЛКОВА просто?
Нищо подобно.
Погледнете „внедряването на хеш таблица в JavaScript“ от Мат Зунерт, тъй като това ще ви даде малко повече контекст. Има още много неща за научаване, така че бих препоръчал също да проверите:
- Курсът на Пол Кубе по хеш таблици
- Внедряване на нашата собствена хеш таблица с отделно верижно свързване в Java
- Алгоритми, 4-то издание - Хеш таблици
- Проектиране на бърза хеш таблица
В края…
Хеш таблиците са много умна идея, която използваме редовно: без значение дали създавате речник в Python, асоциативен масив в PHP или карта в JavaScript. Всички те споделят едни и същи концепции и прекрасно работят, за да ни позволят да съхраняваме и извличаме елемент чрез идентификатор, при (най-вероятно) постоянна цена.
Надявам се, че тази статия ви е харесала и не се колебайте да споделите с мен отзивите си.
Специална благодарност отива на Джо, който ми помогна, като разгледах тази статия.
Адиос!