Drzewo Przedziałowe
Cel i motywacja
Drzewo przedziałowe to jedna z najbardziej wszechstronnych struktur w informatyce, służąca do efektywnego zarządzania danymi rozłożonymi na liniowej osi. Rozwiązuje ono fundamentalny problem: jak pogodzić częste aktualizacje danych z częstymi zapytaniami o wyniki operacji na ich podzbiorach.
W przypadku zwykłej tablicy mamy do czynienia z kompromisem:
- Aktualizacja punktowa:
. - Zapytanie o przedział: wymaga przejścia pętlą przez elementy, co zajmuje
.
Drzewo przedziałowe dzięki swojej hierarchicznej budowie sprowadza obie te operacje do złożoności logarytmicznej
Struktura i budowa
Drzewo przedziałowe jest pełnym drzewem binarnym. Każdy jego poziom reprezentuje coraz większe rozdrobnienie danych:
- Korzeń: Reprezentuje cały zakres tablicy, od indeksu
do . - Węzły wewnętrzne: Każdy taki węzeł dzieli zakres swojego rodzica na dwie, niemal równe części. Jeśli rodzic obsługuje zakres
, to jego lewe dziecko obsłuży , a prawe , gdzie . - Liście: To najniższy poziom drzewa. Każdy liść odpowiada dokładnie jednemu elementowi oryginalnej tablicy.
Ile pamięci potrzeba?
Dla tablicy o rozmiarze
Podstawowe warianty drzew
W zależności od tego, jaką funkcję agregującą przechowujemy w węzłach, drzewo zmienia swoje przeznaczenie.
1. Drzewo typu SUM (Suma na przedziale)
W każdym węźle przechowujemy sumę wartości jego dzieci.
- Zastosowanie: Obliczanie sumy elementów w zakresie
. - Węzeł rodzica:
tree[v] = tree[2*v] + tree[2*v+1]. - Neutralny element: Dla operacji sumowania elementem neutralnym jest
(zwracane, gdy zapytanie nie pokrywa się z zakresem węzła).
2. Drzewo typu MIN (Minimum na przedziale)
W każdym węźle przechowujemy najmniejszą wartość znalezioną w jego poddrzewie.
- Zastosowanie: Rozwiązywanie problemu RMQ (Range Minimum Query). Pozwala błyskawicznie sprawdzić, jaka jest najmniejsza wartość na danym odcinku.
- Węzeł rodzica:
tree[v] = min(tree[2*v], tree[2*v+1]). - Neutralny element: Nieskończoność (infinity), ponieważ każda inna liczba będzie od niej mniejsza.
3. Drzewo typu MAX (Maksimum na przedziale)
Działa analogicznie do drzewa typu MIN, ale przechowuje wartości największe.
- Zastosowanie: Szukanie najwyższego punktu, największego zysku lub limitów w danym zakresie.
- Węzeł rodzica:
tree[v] = max(tree[2*v], tree[2*v+1]). - Neutralny element: Minus nieskończoność lub
(jeśli operujemy tylko na liczbach dodatnich).
Operacje podstawowe krok po kroku
Budowa (Build)
Proces budowy zaczynamy od liści (wartości tablicy) i idziemy w górę. Jest to proces rekurencyjny. Najpierw budujemy lewe poddrzewo, potem prawe, a na końcu obliczamy wartość dla bieżącego węzła na podstawie wyników jego dzieci. Złożoność wynosi
void build(vector<int> arr, int v, int tl, int tr) {
if (tl == tr) {
// Dotarliśmy do liścia
tree[v] = arr[tl];
} else {
int tm = (tl + tr) / 2;
// Budujemy lewe i prawe poddrzewo
build(arr, 2 * v, tl, tm);
build(arr, 2 * v + 1, tm + 1, tr);
// Sumujemy wartości dzieci (dla MIN użyj: min, dla MAX: max)
tree[v] = tree[2 * v] + tree[2 * v + 1];
}
}
Aktualizacja punktowa (Update)
Kiedy zmienia się wartość
-
Znaleźć liść odpowiadający indeksowi
. -
Zmienić jego wartość.
-
Cofnąć się wzdłuż ścieżki do korzenia i przeliczyć wartości we wszystkich węzłach znajdujących się nad tym liściem.
Ponieważ wysokość drzewa to
, wykonujemy tylko tyle operacji przeliczenia.
// arr - tablica wejściowa, tree - tablica drzewa
// v - aktualny węzeł, tl/tr - lewy i prawy brzeg zakresu węzła
void build(int arr[], int v, int tl, int tr) {
if (tl == tr) {
tree[v] = arr[tl];
} else {
int tm = (tl + tr) / 2;
build(arr, 2 * v, tl, tm);
build(arr, 2 * v + 1, tm + 1, tr);
tree[v] = tree[2 * v] + tree[2 * v + 1];
}
}
Zapytanie o przedział (Query)
To najbardziej wyrafinowana część. Zapytanie o zakres
- Całkowicie mieści się w
: Zwracamy gotową wartość z tego węzła. - Całkowicie leży poza
: Zwracamy element neutralny (np. 0 lub nieskończoność). - Częściowo nachodzi na
: Dzielimy zapytanie i wysyłamy je do obojga dzieci, a następnie łączymy wyniki.
Nawet jeśli przedział jest bardzo szeroki, zostanie on rozbity na najwyżej
// l, r - zakres zapytania użytkownika
int query(int v, int tl, int tr, int l, int r) {
if (l > r) {
return 0; //Element neutralny
}
if (l == tl && r == tr) {
return tree[v];
}
int tm = (tl + tr) / 2;
return query(2 * v, tl, tm, l, min(r, tm))
+ query(2 * v + 1, tm + 1, tr, max(l, tm + 1), r);
}
Dalsze sposoby użycia drzew przedziałowych
To, co zostało omówione powyżej to dopiero wierzchołek góry lodowej, jeżeli chodzi o drzewa przedziałowe. [TODO]