Лого на 91. НЕГ „Проф. Константин Гълъбов“

Модул 2 · Урок 8

Хеш-таблица. Речник

Адресиране и хеш-функции — търсене за константно време. Колизии и разрешаването им. Алгоритъм на Рабин–Карп. Колекцията Dictionary.

Може ли по-бързо от двоичното търсене?

Вече познаваме задачата за търсене на обект в множество от n обекта. С последователно търсене тя се решава със сложност O(n). Ако сортираме елементите, с двоично търсене ускоряваме до O(log n). Но това ли е най-добрият алгоритъм?

Оказва се — не. С правилната идея търсенето може да стане за константно време: скорост, която изобщо не зависи от броя на елементите. Идеята се нарича хеширане.

Адресиране

Дадено е множество от n обекта, всеки идентифициран с уникален ключ — който може и да не е число: име, ЕГН, регистрационен номер на автомобил. Разполагаме с m единици памет, номерирани 0, 1, …, m−1. Номерът на единица памет наричаме адрес (или индекс).

Търсим функция H, която на всеки ключ съпоставя адрес. Такава задача наричаме изчисляване на адрес или просто адресиране:

  • За да съхраним обект с ключ K — пресмятаме H(K) и го записваме на този адрес.
  • За да проверим дали обект с ключ K е в паметта — отново пресмятаме H(K) и поглеждаме там.
ИванМарияПетърH(ключ)хеш-функция0Мария1*2*3Иван4*5*6Петър7*хеш-таблица (* = свободен адрес)
Фиг. 1 — Хеш-функцията превръща ключа директно в адрес. Една сметка — и знаем точно къде да четем или пишем.

Защо това е мощно

Скоростта на търсене вече не зависи нито от броя на елементите в множеството, нито от това колко от тях са в паметта — а само от сложността на изчисляване на адреса. Затова адресиращата функция трябва да се смята достатъчно бързо.

Популярната техника за построяване на подходящи адресиращи функции се нарича хеширане (от англ. hash — каша, бъркотия). Функциите, построени с нея, наричаме хеш-функции, а паметта, достъпвана чрез тях — хеш-таблица.

Хеширане и хеш-функции

Прост пример, при който n = m: ключовете са главните букви на кирилицата, а хеш-таблицата има по една клетка за всяка буква. Хеш-функцията съпоставя на всяка буква поредния ѝ номер, започвайки от 0:

int H(char c) { return c - 'А'; }

Тя се пресмята с една операция. Ето и важните функции, свързани с всяка хеш-таблица — сложността на всичките е константа:

static char[] HashTable;

void Insert(char c) { HashTable[H(c)] = c; }
void Delete(char c) { HashTable[H(c)] = '*'; }
bool Search(char c)
{   if (HashTable[H(c)] == '*') return false;
    else return true;
}

Бележка

Работата с хеш-таблици налага използването на някакъв елемент, който не е ключ, за да се отбелязват с него незаетите адреси. В примера това е знакът '*'.

Колизии

Нека сега ключовете са имената на 30-те ученици от един клас, а хеш-функцията е поредният номер в азбуката на първата буква от името. Много вероятно е в класа да има двама ученици с имена, започващи с една и съща буква — и хеш-стойностите им ще съвпаднат.

Колизия

Наличието на два или повече ключа с една и съща хеш-стойност наричаме колизия. (Множеството от ключове не може да съдържа два еднакви ключа — затова колизия означава, че различни ключове имат един и същ адрес.) При колизии таблицата става неизползваема, ако не направим нещо: или сменяме хеш-функцията с такава, която не поражда колизии (невинаги е лесно), или се заемаме с разрешаване на колизиите.

По-добри хеш-функции за низове

Една възможност е да използваме няколко букви: гледаме на поредните номера на буквите като на цифри на бройна система с основа 30. Тогава няколко знака представляват число и взимаме остатъка му при деление на 30:

int H(string s)
{   return ((s[0]-'А')*900 + (s[1]-'А')*30 + (s[2]-'А')) % 30; }

Когато хешираме низове, по-добре е да вземем не първите три букви, а например първата, средната и последната:

int H(string s)
{   return ((s[0]-'А')*900 + (s[s.Length/2]-'А')*30
           + (s[s.Length-1]-'А')) % 30; }

Друга възможност е да разширим малко таблицата и да взимаме остатъка при деление на новия размер m.

Разрешаване на колизии

Когато ще бъдат хеширани не повече от m елемента, а хеш-функцията поражда колизии, очевидно има адреси, на които не съответстват хеш-стойности. В тях можем да поставяме елементите, чиито хеш-стойности влизат в колизия.

ИванИлияH(Иван) = H(Илия) = 3КОЛИЗИЯ!пробвамеследващия0*1*2*3Иван4Илия5*Линейно пробване: адрес 3 е зает → Илия отива на първия свободен (4)
Фиг. 2 — Линейно пробване: новодошлият Илия заварва адреса си зает от Иван и заема първия свободен адрес след него.

Функцията за добавяне използва първия намерен свободен адрес — а ако стигне края на таблицата, търсенето продължава от нулевия адрес (циклично).

Тънкост: кой е „на мястото си"?

Адресът на новодошлия може да е зает от елемент с различен хеш — натрапник, който не е на своето място. В такъв случай е редно натрапникът да се премести на свободен адрес, а новият елемент да влезе на полагащото му се място. Ако обаче на адреса стои елемент със същия хеш — новият заема първото свободно място:

void Insert(string s)
{   int hnew, hold; string sold;
    hnew = H(s);
    if (HashTable[hnew] == "*")
    {   HashTable[hnew] = s; return; }
    hold = H(HashTable[hnew]);
    if (hold != hnew)              // натрапник с друг хеш
    {   sold = HashTable[hnew];
        HashTable[hnew] = s;       // новият влиза на мястото си
        s = sold;                  // а натрапникът търси ново място
    }
    int i = (hnew + 1) % m;
    while (HashTable[i] != "*") i = (i + 1) % m;
    HashTable[i] = s;
}

Внимание — пълна таблица

Тази функция разчита, че в хеш-таблицата има свободно място — иначе ще влезе в безкраен цикъл! Преди да увеличим i в цикъла трябва да проверим дали не е достигнало началната си стойност и ако е така — да изведем аварийно съобщение и да прекратим.

Функциите за търсене и изтриване са много по-прости: или елементът е на мястото си и изпълняваме операцията направо, или пускаме подобен цикъл, с който го търсим.

Класът HashAlgorithm

C# има вграден механизъм за хеширане на низове — класът HashAlgorithm от пространството System.Security.Cryptography. Той обаче не е предназначен за хеш-таблици: хеширащите му методи връщат низ. Класът служи за криптографски алгоритми, използвани за защита на данните от неразрешен достъп.

using (SHA256 sha256H = SHA256.Create())
{   string hash = GetHash(sha256H, s);
    // други действия в това пространство
}

Бележка

Хеширането е една от важните техники в съвременната криптография — пароли, цифрови подписи, блокчейн. Класът е изключително сложно устроен и запознаването с него е извън рамките на учебника.

Търсене на образец в низ — Рабин–Карп

Хеш-таблиците не са единственото приложение на хеширането. Със задачата за търсене на образец в текст се запознахме при низовете — наивният алгоритъм има сложност O(n·m). Алгоритъмът на Рабин–Карп използва хеширане:

  • Хешираме образеца (с дължина n).
  • Разглеждаме последователно поднизовете на текста с дължина n.
  • Ако хешът на подниза е различен от този на образеца — преминаваме нататък без сравнение.
  • Ако хешовете съвпадат — сравняваме двата низа символ по символ (за да се предпазим от колизия). При равенство — намерили сме срещане.

Хеш-функцията гледа буквите като цифри на бройна система с основа 26 (за малки латински букви), пресметната по правилото на Хорнер:

const long mod = 1000007;

static long H(string s)
{   long result = 0;
    for (int i = 0; i < s.Length; i++)
    {   result = (26 * result + s[i] - 'a') % mod; }
    return result;
}

Трикът за истинска скорост

Реализацията „хешираме всеки подниз поотделно" не е по-бърза от наивната. За бързодействието, на което е способен Рабин–Карп, е нужен трик: пресмятаме бързо хеша на поредния подниз, като от предишния хеш извадим първата буква, умножена по съответната степен на 26, и добавим новата буква. Това се нарича плъзгащ се (rolling) хеш — всяка стъпка струва константа.

Колекцията Dictionary

Колекцията Dictionary<TKey, TValue> вече я познаваме от урока за колекции — елементите ѝ са двойки ключ–стойност, а ключовете трябва да са уникални. Сега знаем и тайната ѝ: свръхбързото търсене за константно време се осъществява именно с хеширане.

SortedList<TKey, TValue>Dictionary<TKey, TValue>
ПодредбаПоддържа елементите сортирани по ключБез гарантирана подредба
Търсене по ключO(log n) — двоичноO(1) — чрез хеширане
Кога я ползвамеТрябва ни сортиран обходМного елементи, чести търсения (речник с хиляди думи)

Пример от учебника — речник на ключовите думи на C# с обяснения:

Dictionary<string, string> SharpWords
    = new Dictionary<string, string>();
SharpWords.Add("int", "декларира променлива от тип int");
SharpWords.Add("string", "декларира променлива от тип string");
SharpWords.Add("for", "оператор за цикъл");
SharpWords.Add("while", "оператор за цикъл с предусловие");
SharpWords.Add("break", "прекъсва изпълнението на цикъл");

Console.WriteLine(SharpWords.Count);     // 5
Console.WriteLine(SharpWords["for"]);    // оператор за цикъл
SharpWords["for"] = "оператор за постъпков цикъл";   // промяна
SharpWords.Remove("string");             // премахване, връща bool
Console.WriteLine(SharpWords.Count);     // 4

foreach (KeyValuePair<string, string> pairKV in SharpWords)
{   Console.WriteLine("Key = {0}, Value = {1}",
        pairKV.Key, pairKV.Value);
}
  • Достъпът до стойностите е като индексиране на масив, но ролята на индекс играе ключът: SharpWords["for"].
  • Понеже ключовете не са индекси, обхождането става с foreach през класа KeyValuePair.
  • Remove(ключ) проверява за наличие, изтрива и връща true; иначе връща false.

Какво трябва да запомниш

  • Адресиране: функция H превръща ключа директно в адрес — търсене за константно време, независимо от броя на елементите.
  • Хеш-функция трябва да се смята бързо; свободните адреси се отбелязват със специален не-ключ (напр. '*').
  • Колизия = различни ключове с еднаква хеш-стойност. Разрешава се с по-добра хеш-функция или с линейно пробване (първия свободен адрес, циклично).
  • Рабин–Карп: търсене на образец чрез сравняване на хешове; при съвпадение — проверка символ по символ заради колизии.
  • Dictionary<K, V> е хеш-таблица: O(1) търсене; SortedList е сортиран: O(log n), но с подреден обход.