Tablice haszujące: Jak działają

Kurs: Wstęp do programowania
Lekcja 14: Struktury danych z bibliotek
Temat 2: Tablice haszujące: Jak działają

⇓ spis treści ⇓


Tablice haszujące (ang. hash tables) to jedna z najważniejszych i najczęściej stosowanych struktur danych w programowaniu. Zapewniają one wyjątkowo szybki dostęp do danych, co czyni je niezwykle użytecznymi w wielu aplikacjach, od baz danych po systemy operacyjne. W tej lekcji zagłębimy się w zasadę działania tablic haszujących, omówimy ich implementację, analizę wydajności, oraz wyjaśnimy, jak radzić sobie z problemami, takimi jak kolizje. Dzięki temu materiałowi zdobędziesz szczegółowe zrozumienie, jak działają tablice haszujące i kiedy najlepiej je stosować.

Podstawowe pojęcia i zasada działania

Tablica haszująca to struktura danych, która wykorzystuje funkcję haszującą do mapowania kluczy na indeksy w tablicy. Funkcja haszująca przyjmuje klucz jako wejście i zwraca liczbę całkowitą (hasz), która jest następnie używana do indeksowania w tablicy. Dzięki temu możliwe jest szybkie wyszukiwanie, wstawianie i usuwanie elementów, ponieważ operacje te mają średnią złożoność O(1).

Funkcja haszująca

Funkcja haszująca jest kluczowym elementem tablicy haszującej. Jej zadaniem jest równomierne rozproszenie kluczy w tablicy, aby zminimalizować kolizje. Dobra funkcja haszująca powinna być deterministyczna, szybka do obliczenia i powinna generować różne wartości dla różnych kluczy, aby uniknąć konfliktów. Przykład prostej funkcji haszującej dla liczb całkowitych:

int hashFunction(int key, int tableSize) {
    return key % tableSize;
}

W tym przykładzie funkcja haszująca oblicza resztę z dzielenia klucza przez rozmiar tablicy, co jest prostym i szybkim sposobem generowania indeksu. Jednak w praktyce bardziej skomplikowane funkcje haszujące są potrzebne, aby równomiernie rozproszyć dane w tablicy.

Kolizje i ich rozwiązania

Kolizje występują, gdy dwa różne klucze są mapowane na ten sam indeks w tablicy haszującej. Ponieważ każda komórka w tablicy może przechowywać tylko jeden element, konieczne jest zastosowanie metod radzenia sobie z kolizjami. Istnieją dwie główne techniki: łańcuchowanie (ang. chaining) i otwarte adresowanie (ang. open addressing).

1. Łańcuchowanie (chaining)

W metodzie łańcuchowania każda komórka tablicy haszującej zawiera listę (zwykle listę wskaźnikową), w której przechowywane są wszystkie elementy, które zostały zmapowane na ten sam indeks. Jeśli wystąpi kolizja, nowy element jest po prostu dodawany do listy w odpowiedniej komórce.

#include <iostream>
#include <list>
#include <vector>

class HashTable {
private:
    std::vector<std::list<int>> table;
    int size;

    int hashFunction(int key) {
        return key % size;
    }

public:
    HashTable(int tableSize) : size(tableSize) {
        table.resize(size);
    }

    void insert(int key) {
        int index = hashFunction(key);
        table[index].push_back(key);
    }

    bool search(int key) {
        int index = hashFunction(key);
        for (int element : table[index]) {
            if (element == key) return true;
        }
        return false;
    }

    void remove(int key) {
        int index = hashFunction(key);
        table[index].remove(key);
    }

    void display() {
        for (int i = 0; i < size; ++i) {
            std::cout << i << ": ";
            for (int element : table[i]) {
                std::cout << element << " ";
            }
            std::cout << std::endl;
        }
    }
};

int main() {
    HashTable ht(7);
    ht.insert(10);
    ht.insert(3);
    ht.insert(17);
    ht.display();

    return 0;
}

W powyższym przykładzie implementujemy tablicę haszującą z łańcuchowaniem, gdzie każda komórka zawiera listę elementów. Łańcuchowanie jest efektywne, jeśli funkcja haszująca dobrze rozprasza klucze, ale wydajność może spaść, jeśli zbyt wiele elementów jest zmapowanych na ten sam indeks.

2. Otwarte adresowanie (open addressing)

W metodzie otwartego adresowania wszystkie elementy są przechowywane bezpośrednio w tablicy haszującej. Gdy wystąpi kolizja, algorytm szuka następnej dostępnej komórki zgodnie z określoną strategią, taką jak liniowe próbkowanie (ang. linear probing), kwadratowe próbkowanie (ang. quadratic probing) lub podwójne haszowanie (ang. double hashing).

Liniowe próbkowanie (linear probing)

W liniowym próbkowaniu, jeśli komórka jest już zajęta, szukamy następnej wolnej komórki, przesuwając się o jeden indeks za każdym razem:

int linearProbing(int key, int tableSize, int attempt) {
    return (key + attempt) % tableSize;
}

W tej metodzie łatwo może dojść do problemu zwanego „zbijaniem klastrów” (ang. primary clustering), gdzie elementy skupiają się w jednym obszarze tablicy, co może znacznie obniżyć wydajność.

Kwadratowe próbkowanie (quadratic probing)

Kwadratowe próbkowanie unika tworzenia klastrów, używając kwadratowego przesunięcia przy każdej próbie:

int quadraticProbing(int key, int tableSize, int attempt) {
    return (key + attempt * attempt) % tableSize;
}

Kwadratowe próbkowanie jest bardziej efektywne niż liniowe, ale może nie gwarantować znalezienia wolnej komórki, jeśli rozmiar tablicy nie jest odpowiednio dobrany.

Podwójne haszowanie (double hashing)

Podwójne haszowanie używa drugiej funkcji haszującej do określenia rozmiaru kroku przy każdej próbie, co zapewnia lepsze rozproszenie:

int doubleHashing(int key, int tableSize, int attempt, int hash2) {
    return (key + attempt * hash2) % tableSize;
}

Podwójne haszowanie jest jedną z najskuteczniejszych metod otwartego adresowania, ale wymaga starannego wyboru drugiej funkcji haszującej, aby uniknąć cykli.

Analiza wydajności

Wydajność tablic haszujących zależy od kilku czynników, w tym jakości funkcji haszującej, rozmiaru tablicy i strategii radzenia sobie z kolizjami. W przypadku idealnej funkcji haszującej operacje wstawiania, wyszukiwania i usuwania mają średnią złożoność O(1). Jednak w praktyce, w zależności od liczby kolizji, wydajność może się pogorszyć.

Wskaźnik obciążenia (load factor)

Wskaźnik obciążenia to stosunek liczby elementów w tablicy do jej rozmiaru. Wysoki wskaźnik obciążenia zwiększa ryzyko kolizji i może prowadzić do spadku wydajności. Aby uniknąć tego problemu, tablica haszująca może być dynamicznie rozszerzana, gdy wskaźnik obciążenia przekroczy określony próg.

Zastosowania tablic haszujących

Tablice haszujące są szeroko stosowane w różnych aplikacjach, w tym:

  • Bazy danych: Tablice haszujące są używane do implementacji indeksów, co umożliwia szybkie wyszukiwanie rekordów na podstawie klucza.
  • Kompilatory: Tablice haszujące są wykorzystywane do przechowywania tabel symboli, które śledzą zmienne, funkcje i inne elementy w kodzie źródłowym.
  • Systemy pamięci podręcznej (cache): Hashowanie jest używane w systemach cache, aby szybko znajdować i przechowywać wyniki obliczeń.
  • Filtrowanie duplikatów: Tablice haszujące mogą być używane do szybkiego sprawdzania, czy element już istnieje, co jest przydatne w algorytmach filtrowania danych.

Podsumowanie

Tablice haszujące to potężne narzędzie w programowaniu, które umożliwia szybki dostęp do danych. Ich skuteczność zależy od dobrze zaprojektowanej funkcji haszującej i efektywnych metod radzenia sobie z kolizjami. Zrozumienie, jak działają tablice haszujące, i umiejętność ich implementacji to kluczowe umiejętności, które pozwalają na tworzenie wydajnych i skalowalnych aplikacji. Niezależnie od tego, czy budujesz bazę danych, system cache, czy analizujesz duże zbiory danych, tablice haszujące są nieocenione w rozwiązywaniu problemów związanych z wyszukiwaniem i organizacją danych.

Następny temat ==> Zbiory: Przechowywanie unikalnych wartości



Spis Treści - Wstęp do programowania

Lekcja 3: Rozwiązywanie problemów i poprawność programów Lekcja 4: Praca z różnymi typami danych Lekcja 5: Obsługa plików i pamięci Lekcja 6: Zaawansowane techniki programistyczne Lekcja 7: Wskaźniki i pamięć dynamiczna Lekcja 8: Struktura kodu i abstrakcja Lekcja 9: Rekurencja i jej zastosowania Lekcja 10: Analiza wydajności algorytmów Lekcja 11: Technika "dziel i zwyciężaj" Lekcja 12: Struktury danych o dynamicznej budowie Lekcja 13: Struktury hierarchiczne: Drzewa Lekcja 14: Struktury danych z bibliotek Lekcja 15: Algorytmy z nawrotami Lekcja 16: Programowanie dynamiczne Lekcja 17: Programowanie zachłanne Lekcja 18: Praca z grafami

Jeśli chciałbyś być poinformowany o następnych kursach to zapisz się do naszego newslettera: