Индексни Дървета
Index Trees
В тази тема ще разгледаме една от най-полезните структури данни, които можете да срещнете по състезания – индексни дървета. Те предоставят ефективен начин за търсене на сума (или друга комутативна функция) в интервал. Поради голямата си популярност, по-често ще я срещате като оптимизация на друг алгоритъм, отколкото сама по себе си.
В темата за префиксни масиви разгледахме един начин, по който можем с константна сложност да търсим сума в подинтервал на масив. Префиксните масиви, обаче, имаха проблема, че се ъпдейтват бавно (линейно), а често в по-сложни задачи имате и чести ъпдейти (добавяне или промяна на елементи от масива). В тези случаи на помощ идва една много кратка, но в същото време мощна структура данни, наречена индексни дървета.
Наименование
Индексните дървета можете да срещнете също като "Дървета на Фенуик" (кръстени на автора им), "Lowest-Bit Index Trees" (в следствие на имплементацията), или "Binary Indexed Trees (BIT)". Все пак, те не трябва да се бъркат със сегментните дървета, които ще разгледаме в следващата тема - те могат да се ползват вместо индексни, но като имплементация и идея на работа са различни.Намиране на най-младшия бит
За да извършваме операциите в индексни дървета бързо, ще трябва да знаем как да намираме най-младшия сетнат бит (lowest bit) на едно двоично число. В темата за побитови операции показахме един начин за това, като само споменахме друг, по-кратък такъв, а именно:int lowestBit(int num) {
return num & -num;
}
X = ~X
), след което прибавяме 1 (X = X + 1
). С други думи, следните два реда са еквивалентни:
X = -X;
X = ~X + 1;
X = 42
. Двоичното представяне на 42(10) е 00101010(2), докато неговото отрицателно, -42(10) е 11010101(2) (за простота тук ползваме 8-битово представяне). Наистина:
00101010(2) = 42(10)
11010101(2) = ~42(10)
11010110(2) = ~42(10) + 1
Какво би станало, ако AND
-нем побитово 42 с неговото отрицателно? Ще получим число с един единствен сетнат бит - а имено, най-младшият бит на 42!Можем да се уверим, че това работи и за числа, където най-младшият бит е на по-далечна позиция, както и когато представянето е 32-битово:
00000000000000011110001001000000(2) = 123456(10)
11111111111111100001110110111111(2) = ~123456(10)
11111111111111100001110111000000(2) = ~123456(10) + 1 = -123456(10)
00000000000000011110001001000000(2) = 123456(10)
& 11111111111111100001110111000000(2) = -123456(10)
= 00000000000000000000000001000000(2) = 64(10)
Поддържани операции и сложности
Индексните дървета поддържат както бързо търсене, така и бързо променяне на елементите. "Търсенето" може да е както за сума, така и за произволна друга комутативна функция, която има обратна такава - например произведение или XOR. Забележете, че произведението по модул, например, не задължително има "обратна" функция - например ако модулът не е прост, не бихме могли да ползваме индексни дървета тъй като не можем да делим. В този случай вместо това бихме могли да ползваме сегментни дървета, които имат малко по-висока константа, но пък позволяват повече неща.Строене
Строенето на индексно дърво от N елемента става със сложностO(N∙log(N))
.Добавяне, Триене, Променяне
И трите операции се правят с една и съща функция, която е със сложностO(log(N))
.Търсене
Търсенето в индексно дърво е със сложностO(log(N))
. Можете да забележите, че е малко по-бавно отколкото при префиксните масиви (които позволяваха сложност O(1)
), но пък бързината на ъпдейта много често компенсира това.Идея и имплементация
Първо ще отбележим, че както и при префиксния масив, не можем да "добавяме" и "изтриваме" елемент между два други.! | Индексните дървета работят за произволна комутативна функция (сума, произведение, xor, …), която има обратна функция. За простота ще покажем как работи за сума, като относително лесно се променя за която и да е друга. |
Цел
Имаме списък с N елемента и искаме бързо да можем да ги променяме и отговаряме на въпроси "Колко е сумата в интервала[left, right]
?". Представяне
Индексното дърво представлява масив с N + 1 елемента (ще оставим нулевия елемент празен за простота по-нататък):int tree[MAX + 1];
Например елементът с индекс 24(10) = 11000(2) от индексното дърво има три най-младши нули преди първата си единица, следователно пази сумата на 23 = 8 елемента в интервала [17, 24], както можете да видите на следната схема:
32 | |||||||||||||||||||||||||||||||
16 | |||||||||||||||||||||||||||||||
8 | 24 | ||||||||||||||||||||||||||||||
4 | 12 | 20 | 28 | ||||||||||||||||||||||||||||
2 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | ||||||||||||||||||||||||
1 | 3 | 5 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 23 | 25 | 27 | 29 | 31 | ||||||||||||||||
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
В схемата горе всеки индекс се среща точно по веднъж и е показано кои елементи сумира.
Нека разгледаме следния списък с числа: (42, 13, 17, 1, -18, 3, -2, 9, 22, 4, 19, -23, 12, 5, 7, 0, -10, -8, 6). Индексното дърво, пазещо този списък с числа, би изглеждало по следния начин:
111 | 16 | ||||||||||||||||||||||||||||||||||||||
65 | 8 | ||||||||||||||||||||||||||||||||||||||
73 | 4 | 22 | 12 | ||||||||||||||||||||||||||||||||||||
55 | 2 | -15 | 6 | 26 | 10 | 17 | 14 | -18 | 18 | ||||||||||||||||||||||||||||||
42 | 1 | 17 | 3 | -18 | 5 | -2 | 7 | 22 | 9 | 19 | 11 | 12 | 13 | 7 | 15 | -10 | 17 | 6 | 19 | ||||||||||||||||||||
42 | 13 | 17 | 1 | -18 | 3 | -2 | 9 | 22 | 4 | 19 | -23 | 12 | 5 | 7 | 0 | -10 | -8 | 6 | |||||||||||||||||||||
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | |||||||||||||||||||||
Променяне на елемент
За да променим елемент на даден индекс, трябва да променим всички "покриващи" го интервали. Например, за да променим с delta стойността на елемента, на позиция 6, трябва да променим елементите 6, 8, и 16. За да променим пък 9, трябва да променим 9, 10, 12, и 16. За 13 бихме променили 13, 14, и 16.Какво е общото между тях? Нека разгледаме двоичните им представяния:
6(10) = 00000110(2) 9(10) = 00001001(2) 13(10) = 00001101(2)
8(10) = 00001000(2) 10(10) = 00001010(2) 14(10) = 00001110(2)
16(10) = 00010000(2) 12(10) = 00001100(2) 16(10) = 00010000(2)
16(10) = 00010000(2)
Забелязахте ли каква е логиката? Всеки път най-дясната единица се движи наляво, докато стигне до нула, затривайки всички единици по пътя си. Но това е просто добавяне на най-младшия бит на числото към самото число! Следователно, можем да правим ъпдейт на индексното дърво със следната функция:
void update(int idx, int delta) {
for (; idx <= MAX; idx += (idx & -idx))
tree[idx] += delta;
}
Търсене
Как можем да питаме за сумата на числата в интервал[left, right]
от дадения масив?
! | Точно това изваждане е причината да искаме да има "обратна" функция - в случая изваждане за сума. Ако индексното дърво пазеше произведение, щяхме да ползваме деление; ако пазеше XOR щяхме да ползваме отново XOR (тя е обратна на себе си); и т.н. |
query(int idx)
, то лесно можем да намерим сумата в [left, right]
: това е просто query(right) - query(left - 1)
.Индексните дървета правят именно това - позволяват бързо да се намери сумата (или някаква друга функция) в интервал от нула до даден индекс.
int query(int left, int right) {
return query(right) - query(left - 1);
}
left == 0
.Нека, отново, разгледаме как можем да намерим сумата на всички числа до някакви индекси. За 6, това би била сумата на числата, пазени в 6 и 4. За 9, бихме взели 9 и 8. За 13, бихме сумирали 13, 12, и 8.
Разглеждайки двоичните им записи, този път е по-лесно да видим какво се случва:
6(10) = 00000110(2) 9(10) = 00001001(2) 13(10) = 00001101(2)
4(10) = 00000100(2) 8(10) = 00001000(2) 12(10) = 00001100(2)
8(10) = 00001000(2)
Тук е очевидно, че просто махаме битовете един по един, започвайки от най-младшите. Това можем да направим със следната функция:
int query(int idx) {
int sum = 0;
for (; idx > 0; idx -= (idx & -idx))
sum += tree[idx];
return sum;
}
Строене
За да построим дърво от N елемента, можем да ползваме N на брой пъти функциятаupdate()
(за сложност O(N∙log(N))
).Разменяне на ъпдейт и куери
С малка хитрост можем да направим структурата така, че да ъпдейтваме цял интервал, като можем да питаме само за един елемент. Ако например искаме да добавим стойността delta към всеки елемент в интервала[left, right]
, бихме могли да постигнем това като добавим delta на позиция left (и покриващите я интервали), и после я извадим от позиция right + 1 (и покриващите я интервали).
int values[MAX];
int tree[MAX + 2];
int query(int idx) {
int res = values[idx - 1];
for (; idx > 0; idx -= (idx & -idx))
res += tree[idx];
return res;
}
void update(int idx, int delta) {
for (; idx <= MAX; idx += (idx & -idx))
tree[idx] += delta;
}
void update(int left, int right, int delta) {
update(left, delta);
update(right + 1, -delta);
}
Куерито всъщност представлява следното. Казваме, че резултатът res е равен на първоначалната стойност на съответния индекс. След това:
- Ако даден интервал е изцяло наляво от индекса, то неговата стойност ще бъде прибавена и извадена от res, тоест няма да влияе на неговата стойност.
- Ако левия край на даден интервал е наляво от idx, а десният му е надясно, то ще прибавим към res стойността на интервала, без да я вадим.
- Ако даден интервал е изцяло надясно от индекса, то нито ще добавим, нито ще извадим стойността му, и res отново няма да се промени.
Двумерни и многомерни индексни дървета
Не е много очевидно, че същата идея може да се приложи за двумерни, а и многомерни масиви. Тук ще покажем как става за двумерни масиви, като разширението за повече измерения е аналогично.В стандартния вариант за двумерно индексно дърво ъпдейтът ще е за една клетка
(row, col)
, докато куерито ще е за под-правоъгълник от масива, дефиниран от {(row1, col1), (row2, col2)}
. Тъй като ъпдейтът за всяко от измеренията е по O(log(N))
, то за да направим ъпдейт на двумерно индексно дърво ще ни е нужно O(log(N)∙log(N))
= O(log2(N))
. По-общо, за K-мерно дърво сложността ще бъде O(logK(N))
.Реално същото нещо имахме и при префиксния масив. Тъй като там сложността на куерито беше
O(1)
, то за двумерния вариант ставаше O(1∙1)
, което остава O(1)
.Идеята е, че всеки елемент от двумерното индексно дърво ще бъде отделно (едномерно) индексно дърво. Първото измерение обхождаме както бихме обходили и едномерно индексно дърво. Вместо да вземаме стойностите директно от масива, обаче, в двумерния вариант пускаме куери в под-дърветата.
Това като код изглежда по следния начин:
int tree[MAX + 1][MAX + 1];
void update(int x1, int y1, int val) {
for (int x = x1; x <= MAX; x += (x & -x))
for (int y = y1; y <= MAX; y += (y & -y))
tree[x][y] += val;
}
int query(int x1, int y1, int x2, int y2) {
int ret = 0;
for (int x = x2; x > 0; x -= (x & -x)) {
for (int y = y2; y > 0; y -= (y & -y))
ret += tree[x][y];
for (int y = y1 - 1; y > 0; y -= (y & -y))
ret -= tree[x][y];
}
for (int x = x1 - 1; x > 0; x -= (x & -x)) {
for (int y = y2; y > 0; y -= (y & -y))
ret -= tree[x][y];
for (int y = y1 - 1; y > 0; y -= (y & -y))
ret += tree[x][y];
}
return ret;
}
Интервални дървета
Друго надграждане на тази структура позволява както куери, така и ъпдейт на интервал, заO(log(N))
и за двете операции. Тази структура се нарича интервално дърво и ще разгледаме по-нататък. Забележете, че и тя може да бъде имплементирана както чрез индексни, така и чрез сегментни дървета.Пълни имплементации
Тук сме дали пълни имплементации на показаните неща в темата:- Индексно дърво, куери за интервал
- Индексно дърво, ъпдейт на интервал
- 2D Индексно дърво, куери за правоъгълник
- 3D Индексно дърво, куери за паралелепипед
Задачи за тренировка
Тази структура данни стана популярна в най-чистия си вид чрез задачата Mobiles от IOI 2001, в която се искаше да се напише просто двумерно индексно дърво. След това има редица задачи, които искат малко или много същото, например UFOs в Timus, която пък е тримерно индексно, или Ambitious Experiment, която е едномерно такова (но трябва да се сетите как да го ползвате).Преди тях можете да напишете чистата форма на задачата в тренировъчната задача
IndexTrees
Training problems, informatika.bg
Author: Alexander GeorgievTags: Medium, Index Trees
Statement | Archive | Online Judge
По-нататък ще разгледаме няколко задачи, комбиниращи друг алгоритъм с индексно дърво като оптимизация - например някои по-сложни динамични или пък задачи, решавани с метода на замитащата права.
Представяне чрез две дървета
Потребител на сайта ми изпрати интересен линк, който показва как можем да си представим индексните дървета като две преплетени дървета - едното служещо за ъпдейт, а другото за куери - върху един и същ набор от върхове. Ако искате да видите интересен поглед над индексните дървета (а и яки картинки, които го визуализират :)), можете да погледнете този блог.Референции
- Префиксни Масиви (www.informatika.bg)
- Сегментни Дървета (www.informatika.bg)
- Интервални Дървета (www.informatika.bg)
- Original Paper by Fenwick (psu.edu)
- Range updates with Fenwick Tree (wordpress.com)
- Range Queries and Fenwick Trees (tumblr.com)
За да предложите корекция, селектирайте думата или текста, който искате да бъде променен,
натиснете Enter и изпратете Вашето предложение.
натиснете Enter и изпратете Вашето предложение.
Страницата е посетена 7563 пъти.