Хеширование или как внутри устроен HashSet
Известно, что HashSet<T> реализован крайне эффективно: на операции добавления в множество и проверки на принадлежность элемента множеству тратится в среднем O(1) операций, где n - размер множества. То есть, сложность этих операций не зависит от размера множества!
Это кажется невозможным, но это так: время проверки, есть ли элемент во множестве, не зависит от размера множества! Во множестве с 1 млн элементов и со 100 млн элементов время проверки одинаково?
Попробуем разобраться, как HashSet<T>, а вместе с ним и Dictionary<T,V> устроены внутри. В основе внутреннего представления лежит так называемая хеш-таблица и алгоритм хеширования.
Суть подхода состоит в следующем: для хранения возможных элементов заводится хеш-таблица: массив размера , заведомо превышающего будущее количество элементов множества с некоторым запасом. Например, если в множестве будет не более 900 тыс. элементов, то в качестве размера таблицы можно выбрать n=1 млн. Для эффективности алгоритма принято выбирать n простым числом. Положим скажем n=1000019.
Для хранения будущих элементов заведем таблицу
var table := new integer[n];
Все её ячейки первоначально заполнены нулями - это значит, что во множестве нет элементов.
Пусть у нас есть элемент k, который надо добавить в хеш-таблицу. Если предположить, что все k меньше n, то сделать это просто:
table[k] := k;
И тогда проверка на принадлежность множеству - мгновенная: элемент принадлежит множеству если
table[k] <> 0
Но в реальности k будет больше размера таблицы (например, k=MaxInt). В этом случае поступим следующим образом: напишем так называемую хеш-функцию, отображающую k в индексы нашего массива. Сделать это просто:
function Hash(k: integer): integer; begin Result := k mod n; end;
Теперь для добавления элемента в хеш-таблицу будем вначале вычислять хеш-функцию от элемента - она-то и будет давать индекс в массиве table:
var h := Hash(k); if table[h] = 0 then table[h] := k else if table[h] = k then // уже есть такой элемент exit
Заметим, что если на месте h находится ровно элемент k, то во множестве уже есть такой элемент k, и ничего во множество добавлять не надо.
Проблема возникает если table[h] равно некоторому значению x, и оно не совпадает с k. Это означает, что у элементов x и k - одно хеш-значение, и называется такая ситуация конфликтом. Для разрешения конфликта мы используем модифицированную хеш-функцию
function Hash(k,i: integer): integer; begin Result := (Hash(k) + i*i) mod n; end;
где i вначале равно 1, затем 2 и т.д. Если место в таблице h=Hash(k) уже занято, то мы пытаемся добавить элемент по индексу h=Hash(k,1), а если он занят, то по индексу h=Hash(k,2) и т.д. пока не найдем свободное место. Общий алгоритм добавления выглядит так:
procedure Add(k: integer); begin var h := Hash(k); if table[h] = 0 then // конфликтов нет table[h] := k else if table[h] = k then // уже есть такой элемент exit else // конфликт begin var i := 0; repeat // разрешение конфликта i += 1; h := Hash(k,i); until (table[h] = 0) or (table[h] = k); table[h] := k; end; end;
Проверка на принадлежность элемента множеству реализуется аналогично:
var res := 0; // количество поисков
function Contains(k: integer): boolean; begin var h := Hash(k); if (table[h] = 0) or (table[h] = k) then res := 1 else begin var i := 0; repeat i += 1; h := Hash(k,i); until (table[h] = 0) or (table[h] = k); res := i + 1; end; if table[h] = 0 then Result := False else Result := True end;
Здесь для наших иллюстраций в глобальной переменной res будем подсчитывать, сколько раз мы вызывали хеш-функцию и искали элемент в таблице по некоторому индексу.
Замечательное свойство хеш-таблицы и алгоритма хеширования, доказанное теоретически, состоит в том, что при менее чем 90% заполненности таблицы table требуется в среднем 2.5 поиска (!!!) независимо от количества элементов (!!).
Проверим этот замечательный факт. Для этого сформируем массив случайных чисел из n элементов:
var a := ArrRandomInteger(n,0,n*2);
Сами элементы будут в диапазоне от 0 до n*2, поэтому среди них наверняка есть повторяющиеся. Повторы не попадут в хеш-таблицу как следует из алгоритма Add.
Поместим все эти элементы в хеш-таблицу:
foreach var x in a do Add(x); Println(a.Distinct.Count);
Реально в нашей хеш-таблице оказалось
787286
значений. То есть, она заполнена почти на 80%.
Теперь сформируем другой случайный массив того же размера:
var a1 := ArrRandomInteger(n,0,n*2);
Сколько в нем элементов, совпадающих с элементами массива a? Выведем это значение:
Println((HSet(a) * HSet(a1)).Count);
310065
То есть, примерно половина значений совпадает со значениями из a, а вторая половина - нет.
Будем искать эти элементы в хеш-таблице table и определим среднее количество поисков:
var s := 0; foreach var x in a1 do begin var b := Contains(x); s += res; L.Add(res); end; Println(s / a1.Length);
3.07435658722484
поисков на элемент. Итак, внимание: в среднем на поиск каждого элемента в хеш-таблице тратится 3 (три !) операции. Найти один элемент среди миллиона - это три действия!
А теперь - самое интересное. Увеличим размер хех-таблицы n примерно в 10 раз:
var n := 10000019;
и проделаем те же вычисления. Результат:
3.08343924146544
поисков на элемент! То есть, элементов стало в 10 раз больше, а количество поисков в среднем по-прежнему три!
В этом и состоит замечательное свойство хеш-таблицы - скорость поиска элемента в ней не зависит от размера хеш-таблицы, а зависит лишь от ее заполненности. И - мы видим, что при заполненности примерно на 80% требуется в среднем 3 поиска! Говорят также, что асимптотическое время поиска = O(1).
Следует отметить, что несмотря на то, что среднее время поиска = 3 операции, но в худшем случае может тратиться существенно больше поисков.
Посмотрим, сколько раз встречалось то или иное значение res. Для этого заведем список, будем в него добавлять все значения res, а в конце посчитаем количество каждого res:
var s := 0; var L := new List<integer>; foreach var x in a1 do begin var b := Contains(x); s += res; L.Add(res); end; Println(s / a1.Length); L.EachCount.OrderBy(t->t.Key).PrintLines;
(1,5000792) (2,1480904) (3,875330) (4,605335) (5,446648) (6,333656) (7,266949) (8,205677) (9,161970) (10,128179) (11,102380) (12,80320) (13,64785) (14,50246) (15,40709) (16,32706) (17,25692) (18,20049) (19,16162) (20,12644) (21,10242) (22,8052) (23,6366) (24,5053) (25,4190) (26,3263) (27,2430) (28,1943) (29,1576) (30,1226) (31,959) (32,766) (33,592) (34,479) (35,350) (36,310) (37,195) (38,178) (39,142) (40,99) (41,82) (42,72) (43,59) (44,57) (45,41) (46,22) (47,24) (48,34) (49,12) (50,10) (51,19) (52,7) (53,12) (54,5) (55,5) (56,3) (59,1) (60,3) (61,1) (62,1) (63,2) (65,1) (66,1) (69,1)
uses PlotWPF,GraphWPF; ... var seq := L.EachCount.OrderBy(t->t.Key); var xx := seq.Select(kv -> real(kv.Key)); var yy := seq.Select(kv -> (real(kv.Value))); Window.Title := 'Сколько раз возникает количество поисков res'; LineGraphWPF.Create(xx,yy);
L.EachCount.OrderBy(t->t.Key).PrintLines; var seq := L.EachCount.OrderBy(t->t.Key); var xx := seq.Select(kv -> real(kv.Key)); var yy := seq.Select(kv -> (log(real(kv.Value)))); Window.Title := 'Сколько раз возникает количество поисков res'; LineGraphWPF.Create(xx,yy);
Код
uses PlotWPF,GraphWPF; var n := 10000019; // память - простое число var table := new integer[n]; function Hash(k: integer): integer; begin Result := k mod n; end; function Hash(k,i: integer): integer; begin Result := (Hash(k) + i*i) mod n; end; procedure Add(k: integer); begin var h := Hash(k); if table[h] = 0 then // конфликтов нет table[h] := k else if table[h] = k then // уже есть такой элемент exit else // конфликт begin var i := 0; repeat // разрешение конфликта i += 1; h := Hash(k,i); until (table[h] = 0) or (table[h] = k); table[h] := k; end; end; var res := 0; // возвращает количество поисковfunction Contains(k: integer): boolean; function Contains(k: integer): boolean; begin var h := Hash(k); if (table[h] = 0) or (table[h] = k) then res := 1 else begin var i := 0; repeat i += 1; h := Hash(k,i); until (table[h] = 0) or (table[h] = k); res := i + 1; end; if table[h] = 0 then Result := False else Result := True end; begin var a := ArrRandomInteger(n,0,n*2); foreach var x in a do Add(x); Println(a.Distinct.Count); var a1 := ArrRandomInteger(n,0,n*2); Println((HSet(a) * HSet(a1)).Count); var s := 0; var L := new List<integer>; foreach var x in a1 do begin var b := Contains(x); s += res; L.Add(res); end; Println(s / a1.Length); L.EachCount.OrderBy(t->t.Key).PrintLines; var seq := L.EachCount.OrderBy(t->t.Key); var xx := seq.Select(kv -> real(kv.Key)); var yy := seq.Select(kv -> (log(real(kv.Value)))); Window.Title := 'Сколько раз возникает количество поисков res'; LineGraphWPF.Create(xx,yy); end.
Вывод
Хеширование - замечательный быстрый алгоритм поиска элемента во множестве с асимптотической сложностью O(1), что означает, что время поиска не зависит от n - количества элементов множества. Основная расплата за это - для хранения множеств требуется память с некоторым запасом (скажем, 20% от максимального размера множества).