Перейти до основного вмісту

Дерево Фенвіка

Нехай ff — деяка групова операція (бінарна асоціативна функція над множиною з нейтральним елементом та оберненими елементами), а AA — масив цілих чисел довжини NN. Позначимо інфіксний запис ff через *; тобто f(x,y)=xyf(x,y) = x*y для довільних цілих x,yx,y. (Оскільки операція асоціативна, при інфіксному записі ми опускатимемо дужки, що задають порядок застосування ff.)

Дерево Фенвіка — це структура даних, яка:

  • обчислює значення функції ff на заданому відрізку [l,r][l, r] (тобто AlAl+1ArA_l * A_{l+1} * \dots * A_r) за O(logN)O(\log N) часу
  • оновлює значення елемента масиву AA за O(logN)O(\log N) часу
  • потребує O(N)O(N) пам'яті (стільки ж, скільки й сам масив AA)
  • проста у використанні та програмуванні, особливо у випадку багатовимірних масивів

Найпоширеніше застосування дерева Фенвіка — обчислення суми на відрізку. Наприклад, якщо взяти за групову операцію додавання над множиною цілих чисел, тобто f(x,y)=x+yf(x,y) = x + y: тоді бінарна операція * — це ++, тож AlAl+1Ar=Al+Al+1++ArA_l * A_{l+1} * \dots * A_r = A_l + A_{l+1} + \dots + A_{r}.

Дерево Фенвіка також називають двійковим індексованим деревом (BIT). Уперше його описали в роботі під назвою "A new data structure for cumulative frequency tables" (Peter M. Fenwick, 1994).

Коли підходить цей алгоритм?
  • Чи є операція на відрізку оборотною (наприклад, сума за наявності віднімання), тобто утворює групу? (якщо операція лише асоціативна, як min\min/max\max без обернення → дерево відрізків)
  • Чи потрібні оновлення елементів між запитами? (якщо масив незмінний, а потрібен лише min\min/max\max за O(1)O(1)розріджена таблиця)
  • Чи важлива компактність і простота коду (особливо для багатовимірних запитів) порівняно з гнучкішим, але громіздкішим деревом відрізків?

Опис

Огляд

Для простоти вважатимемо, що функція ff задана як f(x,y)=x+yf(x,y) = x + y над цілими числами.

Нехай нам дано масив цілих чисел A[0N1]A[0 \dots N-1]. (Зауважимо, що ми використовуємо нумерацію з нуля.) Дерево Фенвіка — це просто масив T[0N1]T[0 \dots N-1], де кожен елемент дорівнює сумі елементів масиву AA на деякому відрізку [g(i),i][g(i), i]:

Ti=j=g(i)iAjT_i = \sum_{j = g(i)}^{i}{A_j}

де gg — деяка функція, що задовольняє умову 0g(i)i0 \le g(i) \le i. Функцію gg ми означимо в наступних кількох абзацах.

Цю структуру даних називають деревом, бо її можна гарно зобразити у вигляді дерева, хоча нам і не потрібно будувати справжнє дерево з вершинами та ребрами. Для обробки всіх запитів нам достатньо підтримувати лише масив TT.

Зауваження: Подане тут дерево Фенвіка використовує нумерацію з нуля. Багато хто користується варіантом дерева Фенвіка з нумерацією з одиниці. Тому в розділі про реалізацію ви також знайдете альтернативну реалізацію з нумерацією з одиниці. Обидві версії еквівалентні за часовою та просторовою складністю.

Тепер ми можемо записати псевдокод для двох згаданих вище операцій. Нижче ми отримуємо суму елементів масиву AA на відрізку [0,r][0, r] та оновлюємо (збільшуємо) деякий елемент AiA_i:

def sum(int r):
res = 0
while (r >= 0):
res += t[r]
r = g(r) - 1
return res

def increase(int i, int delta):
for all j with g(j) <= i <= j:
t[j] += delta

Функція sum працює так:

  1. Спершу вона додає до result суму на відрізку [g(r),r][g(r), r] (тобто T[r]T[r]).
  2. Потім вона «перестрибує» до відрізка [g(g(r)1),g(r)1][g(g(r)-1), g(r)-1] і додає до result суму цього відрізка.
  3. Це триває доти, доки вона не «перестрибне» з [0,g(g(g(r)11)1)][0, g(g( \dots g(r)-1 \dots -1)-1)] до [g(1),1][g(-1), -1]; на цьому функція sum припиняє стрибати.

Функція increase працює за тією самою аналогією, але «стрибає» в напрямку зростання індексів:

  1. Сума для кожного відрізка вигляду [g(j),j][g(j), j], що задовольняє умову g(j)ijg(j) \le i \le j, збільшується на delta; тобто t[j] += delta. Отже, оновлюються всі елементи TT, що відповідають відрізкам, у яких лежить AiA_i.

Складність обох функцій sum та increase залежить від функції gg. Існує багато способів обрати функцію gg так, щоб 0g(i)i0 \le g(i) \le i для всіх ii. Наприклад, підходить функція g(i)=ig(i) = i, що дає T=AT = A (у такому разі запити на суму повільні). Можна було б також узяти функцію g(i)=0g(i) = 0. Це відповідало б масивам префіксних сум (у такому разі знаходження суми на відрізку [0,i][0, i] потребуватиме лише сталого часу; проте оновлення будуть повільними). Хитрість алгоритму дерев Фенвіка полягає в тому, що він використовує особливе означення функції gg, яке дозволяє виконувати обидві операції за O(logN)O(\log N) часу.

Означення g(i)g(i)

Обчислення g(i)g(i) задається такою простою операцією: ми замінюємо всі завершальні біти 11 у двійковому записі числа ii на біти 00.

Іншими словами, якщо наймолодший двійковий розряд числа ii дорівнює 00, то g(i)=ig(i) = i. А інакше наймолодший розряд дорівнює 11, і тоді ми беремо цю 11 та всі інші завершальні одиниці й перевертаємо їх.

Наприклад, ми отримуємо

g(11)=g(10112)=10002=8g(12)=g(11002)=11002=12g(13)=g(11012)=11002=12g(14)=g(11102)=11102=14g(15)=g(11112)=00002=0\begin{align} g(11) = g(1011_2) = 1000_2 &= 8 \\\\ g(12) = g(1100_2) = 1100_2 &= 12 \\\\ g(13) = g(1101_2) = 1100_2 &= 12 \\\\ g(14) = g(1110_2) = 1110_2 &= 14 \\\\ g(15) = g(1111_2) = 0000_2 &= 0 \\\\ \end{align}

Для описаної вище нетривіальної операції існує проста реалізація через бітові операції:

g(i)=i & (i+1),g(i) = i ~\&~ (i+1),

де &\& — оператор побітового І. Неважко переконатися, що це рішення робить те саме, що й описана вище операція.

Тепер нам лише потрібно знайти спосіб перебрати всі jj такі, що g(j)ijg(j) \le i \le j.

Легко бачити, що ми можемо знайти всі такі jj, починаючи з ii та перевертаючи останній невстановлений біт. Цю операцію ми назвемо h(j)h(j). Наприклад, для i=10i = 10 маємо:

10=00010102h(10)=11=00010112h(11)=15=00011112h(15)=31=00111112h(31)=63=01111112\begin{align} 10 &= 0001010_2 \\\\ h(10) = 11 &= 0001011_2 \\\\ h(11) = 15 &= 0001111_2 \\\\ h(15) = 31 &= 0011111_2 \\\\ h(31) = 63 &= 0111111_2 \\\\ \vdots & \end{align}

Як і слід було очікувати, операцію hh теж можна просто виконати через бітові операції:

h(j)=j  (j+1),h(j) = j ~|~ (j+1),

де | — оператор побітового АБО.

На наступному зображенні показано один із можливих способів інтерпретації дерева Фенвіка як дерева. Вершини дерева показують відрізки, які вони покривають.

Binary Indexed Tree

Реалізація

Знаходження суми в одновимірному масиві

Тут ми наводимо реалізацію дерева Фенвіка для запитів на суму та оновлень одного елемента.

Звичайне дерево Фенвіка вміє відповідати лише на запити на суму вигляду [0,r][0, r] за допомогою sum(int r), однак ми можемо відповідати й на інші запити вигляду [l,r][l, r], обчисливши дві суми [0,r][0, r] та [0,l1][0, l-1] і віднявши їх. Це реалізовано в методі sum(int l, int r).

Також ця реалізація підтримує два конструктори. Ви можете створити дерево Фенвіка, ініціалізоване нулями, або перетворити наявний масив на форму дерева Фенвіка.

struct FenwickTree {
vector<int> bit; // двійкове індексоване дерево
int n;

FenwickTree(int n) {
this->n = n;
bit.assign(n, 0);
}

FenwickTree(vector<int> const &a) : FenwickTree(a.size()) {
for (size_t i = 0; i < a.size(); i++)
add(i, a[i]);
}

int sum(int r) {
int ret = 0;
for (; r >= 0; r = (r & (r + 1)) - 1)
ret += bit[r];
return ret;
}

int sum(int l, int r) {
return sum(r) - sum(l - 1);
}

void add(int idx, int delta) {
for (; idx < n; idx = idx | (idx + 1))
bit[idx] += delta;
}
};

Лінійна побудова

Наведена вище реалізація потребує O(NlogN)O(N \log N) часу. Її можна покращити до O(N)O(N) часу.

Ідея полягає в тому, що число a[i]a[i] за індексом ii робить внесок у відрізок, що зберігається в bit[i]bit[i], та в усі відрізки, у які робить внесок індекс i(i+1)i | (i + 1). Тож, додаючи числа по порядку, вам потрібно лише проштовхнути поточну суму далі до наступного відрізка, де вона потім буде проштовхнута далі до наступного відрізка, і так далі.

FenwickTree(vector<int> const &a) : FenwickTree(a.size()){
for (int i = 0; i < n; i++) {
bit[i] += a[i];
int r = i | (i + 1);
if (r < n) bit[r] += bit[i];
}
}

Знаходження мінімуму на [0,r][0, r] в одновимірному масиві

Очевидно, що не існує простого способу знайти мінімум на відрізку [l,r][l, r] за допомогою дерева Фенвіка, оскільки дерево Фенвіка вміє відповідати лише на запити вигляду [0,r][0, r]. Крім того, щоразу під час оновлення (update) значення нове значення має бути меншим за поточне. Обидва ці суттєві обмеження виникають тому, що операція minmin разом із множиною цілих чисел не утворює групи, бо немає обернених елементів.

struct FenwickTreeMin {
vector<int> bit;
int n;
const int INF = (int)1e9;

FenwickTreeMin(int n) {
this->n = n;
bit.assign(n, INF);
}

FenwickTreeMin(vector<int> a) : FenwickTreeMin(a.size()) {
for (size_t i = 0; i < a.size(); i++)
update(i, a[i]);
}

int getmin(int r) {
int ret = INF;
for (; r >= 0; r = (r & (r + 1)) - 1)
ret = min(ret, bit[r]);
return ret;
}

void update(int idx, int val) {
for (; idx < n; idx = idx | (idx + 1))
bit[idx] = min(bit[idx], val);
}
};

Зауваження: можна реалізувати дерево Фенвіка, яке вміє обробляти довільні запити на мінімум на відрізку та довільні оновлення. У роботі Efficient Range Minimum Queries using Binary Indexed Trees описано такий підхід. Однак за цього підходу вам потрібно підтримувати над даними друге двійкове індексоване дерево зі трохи іншою структурою, оскільки одного дерева недостатньо, щоб зберегти значення всіх елементів масиву. Реалізація також значно складніша порівняно зі звичайною реалізацією для сум.

Знаходження суми у двовимірному масиві

Як уже зазначалося, дерево Фенвіка дуже легко реалізувати для багатовимірного масиву.

struct FenwickTree2D {
vector<vector<int>> bit;
int n, m;

// init(...) { ... }

int sum(int x, int y) {
int ret = 0;
for (int i = x; i >= 0; i = (i & (i + 1)) - 1)
for (int j = y; j >= 0; j = (j & (j + 1)) - 1)
ret += bit[i][j];
return ret;
}

void add(int x, int y, int delta) {
for (int i = x; i < n; i = i | (i + 1))
for (int j = y; j < m; j = j | (j + 1))
bit[i][j] += delta;
}
};

Підхід із нумерацією з одиниці

Для цього підходу ми трохи змінюємо вимоги та означення для T[]T[] і g()g(). Ми хочемо, щоб T[i]T[i] зберігало суму на [g(i)+1;i][g(i)+1; i]. Це трохи змінює реалізацію та дозволяє аналогічно гарно означити g(i)g(i):

def sum(int r):
res = 0
while (r > 0):
res += t[r]
r = g(r)
return res

def increase(int i, int delta):
for all j with g(j) < i <= j:
t[j] += delta

Обчислення g(i)g(i) задається так: скидання останнього встановленого біта 11 у двійковому записі числа ii.

g(7)=g(1112)=1102=6g(6)=g(1102)=1002=4g(4)=g(1002)=0002=0\begin{align} g(7) = g(111_2) = 110_2 &= 6 \\\\ g(6) = g(110_2) = 100_2 &= 4 \\\\ g(4) = g(100_2) = 000_2 &= 0 \\\\ \end{align}

Останній встановлений біт можна виділити за допомогою i & (i)i ~\&~ (-i), тож операцію можна виразити так:

g(i)=i(i & (i)).g(i) = i - (i ~\&~ (-i)).

І неважко бачити, що для оновлення A[j]A[j] вам потрібно змінити всі значення T[j]T[j] у послідовності i, h(i), h(h(i)), i,~ h(i),~ h(h(i)),~ \dots, де h(i)h(i) означено як:

h(i)=i+(i & (i)).h(i) = i + (i ~\&~ (-i)).

Як бачите, головна перевага цього підходу полягає в тому, що бітові операції дуже гарно доповнюють одна одну.

Наведену нижче реалізацію можна використовувати так само, як і інші реалізації, проте всередині вона використовує нумерацію з одиниці.

struct FenwickTreeOneBasedIndexing {
vector<int> bit; // двійкове індексоване дерево
int n;

FenwickTreeOneBasedIndexing(int n) {
this->n = n + 1;
bit.assign(n + 1, 0);
}

FenwickTreeOneBasedIndexing(vector<int> a)
: FenwickTreeOneBasedIndexing(a.size()) {
for (size_t i = 0; i < a.size(); i++)
add(i, a[i]);
}

int sum(int idx) {
int ret = 0;
for (++idx; idx > 0; idx -= idx & -idx)
ret += bit[idx];
return ret;
}

int sum(int l, int r) {
return sum(r) - sum(l - 1);
}

void add(int idx, int delta) {
for (++idx; idx < n; idx += idx & -idx)
bit[idx] += delta;
}
};

Операції на відрізку

Дерево Фенвіка може підтримувати такі операції на відрізку:

  1. Оновлення в точці та запит на відрізку
  2. Оновлення на відрізку та запит у точці
  3. Оновлення на відрізку та запит на відрізку

1. Оновлення в точці та запит на відрізку

Це просто звичайне дерево Фенвіка, описане вище.

2. Оновлення на відрізку та запит у точці

За допомогою простих прийомів ми можемо виконувати й обернені операції: збільшувати значення на відрізках та запитувати окремі значення.

Нехай дерево Фенвіка ініціалізоване нулями. Припустимо, що ми хочемо збільшити відрізок [l,r][l, r] на xx. Ми виконуємо дві операції оновлення в точці над деревом Фенвіка, а саме add(l, x) та add(r+1, -x).

Якщо ми хочемо отримати значення A[i]A[i], нам просто потрібно взяти префіксну суму звичайним методом суми на відрізку. Щоб зрозуміти, чому це справді так, знову зосередимося на попередній операції збільшення. Якщо i<li < l, то обидві операції оновлення не впливають на запит, і ми отримуємо суму 00. Якщо i[l,r]i \in [l, r], то ми отримуємо відповідь xx завдяки першій операції оновлення. А якщо i>ri > r, то друга операція оновлення скасує вплив першої.

Наведена нижче реалізація використовує нумерацію з одиниці.

void add(int idx, int val) {
for (++idx; idx < n; idx += idx & -idx)
bit[idx] += val;
}

void range_add(int l, int r, int val) {
add(l, val);
add(r + 1, -val);
}

int point_query(int idx) {
int ret = 0;
for (++idx; idx > 0; idx -= idx & -idx)
ret += bit[idx];
return ret;
}

Зауваження: звісно, можна також збільшити окрему точку A[i]A[i] за допомогою range_add(i, i, val).

3. Оновлення на відрізку та запит на відрізку

Щоб підтримувати і оновлення на відрізку, і запити на відрізку, ми використаємо два BIT, а саме B1[]B_1[] та B2[]B_2[], ініціалізовані нулями.

Припустимо, що ми хочемо збільшити відрізок [l,r][l, r] на значення xx. Аналогічно до попереднього методу, ми виконуємо два оновлення в точці над B1B_1: add(B1, l, x) та add(B1, r+1, -x). А також оновлюємо B2B_2. Подробиці будуть пояснені пізніше.

def range_add(l, r, x):
add(B1, l, x)
add(B1, r+1, -x)
add(B2, l, x*(l-1))
add(B2, r+1, -x*r))

Після оновлення на відрізку (l,r,x)(l, r, x) запит на суму на відрізку має повертати такі значення:

sum[0,i]={0i<lx(i(l1))lirx(rl+1)i>rsum[0, i]= \begin{cases} 0 & i < l \\\\ x \cdot (i-(l-1)) & l \le i \le r \\\\ x \cdot (r-l+1) & i > r \\\\ \end{cases}

Ми можемо записати суму на відрізку як різницю двох доданків, де для першого доданка використовуємо B1B_1, а для другого — B2B_2. Різниця запитів дасть нам префіксну суму на [0,i][0, i].

sum[0,i]=sum(B1,i)isum(B2,i)={0i0i<lxix(l1)lir0i(x(l1)xr)i>r\begin{align} sum[0, i] &= sum(B_1, i) \cdot i - sum(B_2, i) \\\\ &= \begin{cases} 0 \cdot i - 0 & i < l\\\\ x \cdot i - x \cdot (l-1) & l \le i \le r \\\\ 0 \cdot i - (x \cdot (l-1) - x \cdot r) & i > r \\\\ \end{cases} \end{align}

Останній вираз точно дорівнює потрібним доданкам. Отже, ми можемо використовувати B2B_2 для відсікання зайвих доданків, коли множимо B1[i]×iB_1[i]\times i.

Ми можемо знаходити довільні суми на відрізку, обчисливши префіксні суми для l1l-1 та rr і знову взявши їхню різницю.

def add(b, idx, x):
while idx <= N:
b[idx] += x
idx += idx & -idx

def range_add(l,r,x):
add(B1, l, x)
add(B1, r+1, -x)
add(B2, l, x*(l-1))
add(B2, r+1, -x*r)

def sum(b, idx):
total = 0
while idx > 0:
total += b[idx]
idx -= idx & -idx
return total

def prefix_sum(idx):
return sum(B1, idx)*idx - sum(B2, idx)

def range_sum(l, r):
return prefix_sum(r) - prefix_sum(l-1)

Задачі для практики

Інші джерела

Відеоматеріали