樹狀陣列(Binary Indexed Tree),看這一篇就夠了
定義
根據維基百科的定義:
A Fenwick tree or binary indexed tree is a data structure that can efficiently update elements and calculate prefix sums in a table of numbers.
也就是說,所謂樹狀陣列,或稱Binary Indexed Tree, Fenwick Tree,是一種用於高效處理對一個儲存數字的列表進行更新及求字首和的資料結構。
舉例來說,樹狀陣列所能解決的典型問題就是存在一個長度為n
的陣列,我們如何高效進行如下操作:
1. update(idx, delta)
num
加到位置idx
的數字上。 2.
prefixSum(idx)
:求從陣列第一個位置到第idx
(含idx
)個位置所有數字的和。 3.
rangeSum(from_idx, to_idx)
:求從陣列第from_idx
個位置到第to_idx
個位置的所有數字的和
對於上述問題,除去每次求和都對原陣列相關數字暴力相加求和的解法外,另一種較簡單解法為使用O(n)
時間構造一個字首和陣列(cumulative sum),即該陣列中的第i
個位置儲存原陣列中前i
個元素的和,則對於上述每一個操作,我們有:
1. update(idx, delta)
:更新操作需要更新cumulative sum陣列中每一個受此更新影響的字首和,即從idx
O(n)
時間複雜度。 2.
prefixSum(idx)
:直接返回cumulativeSum[idx + 1]
即可。該操作為O(1)
時間複雜度。 3.
rangeSum(from_idx, to_idx)
:直接返回cumulativeSum[to_idx + 1] - cumulativeSum[from_idx]
即可。該操作為O(1)
操作。
可以看出,該簡單解法的求和操作非常高效,而單個更新操作為線性時間。如果所需的更新操作的數量遠少於求和操作的話,該解法非常合適。反之,如果更新操作較多,我們就需要思考優化的方法。
那麼使用樹狀陣列解決該問題的目的就是為了在保證求和操作依然高效的前提下優化update(idx, delta)
填坑法構造Binary Indexed Tree
所謂的Binary Indexed Tree,首先需要明確它其實並不是一棵樹。Binary Indexed Tree事實上是將根據數字的二進位制表示來對陣列中的元素進行邏輯上的分層儲存。
Binary Indexed Tree求和的基本思想在於,給定需要求和的位置i
,例如13,我們可以利用其二進位制表示法來進行分段(或者說分層)求和:13 = 2^3 + 2^2 + 2^0
,則prefixSum(13) = RANGE(1, 8) + RANGE(9, 12) + RANGE(13, 13)
(注意此處的RANGE(x, y)
表示陣列中第x
個位置到第y
個位置的所有數字求和)。如下面例子中所示:
arr = [1, 7, 3, 0, 5, 8, 3, 2, 6, 2, 1, 1, 4, 5]
prefixSum(13) = RANGE(1, 8) + RANGE(9, 12) + RANGE(13, 13)
= 29 + 10 + 4 = 43
那麼如果我們將上述的range sum提前計算好的話,prefixSum(13)
可以直接由它們相加得到。那麼我們所需要解決的問題就是,根據何種規則來計算和儲存這樣的二進位制表示後所需的range sum呢?規則如下圖中所示。
圖中第一行為原陣列,第二到第四行為依次按層填坑的過程。我們需要從左到右,從上到下依次將相應的值填入對應的位置中。最後一行中即為最終所形成的樹狀陣列。
以圖中第二行,也就是構造樹狀陣列第一層的過程為例,我們首先需要填充的是陣列中第一個數字開始,長度為2的指數個數字的區間內的數字的累加和。所以圖中分別填充了從第一個數字開始,長度為2^0, 2^1, 2^2, 2^3
的區間的區間和。到此為止這一步就結束了。因為2^4
超過了我們原陣列的長度範圍。
下一步我們構造陣列的第二層。與上一層類似,我們依然填充餘下的空白中從第空白處一個位置算起長度為2的指數的區間的區間和。例如3-3
空白,我們只需填充從位置3開始,長度為1的區間的和。再如9-14
空白,我們需要填充從9開始,長度為2^0
(9-9),2^1
(9-10),2^2
(9-12)的區間和。
類似地,第三層我們填充7-7
,11-11
和13-14
區間的空白。
到此為止,我們已經完全的構造了對應於輸入陣列的一個樹狀陣列。將該陣列即為BIT
(方便起見,此處對此陣列的索引為從1開始).
利用圖中已構造好的樹狀陣列,則:
prefixSum(13) = prefixSum(0b00001101)
= BIT[13] + BIT[12] + BIT[8]
= BIT[0b00001101] + BIT[0b00001100] + BIT[0b00001000]
如下圖所示。這樣一來,我們也就解決了上面提出的如何記錄range sum以方便求和的問題。
利用Binary Indexed Tree求prefix sum或range sum
通過上面的例子我們得知求字首和的過程事實上是在樹狀陣列所代表的抽象的樹形結構中不斷移動尋找上一層母結點並求和的過程。上面例子中樹狀陣列所表示的樹如下圖所示:
那麼我們應該如何用程式碼實現這一向上尋找母結點的過程呢?
觀察這個求和的過程:
prefixSum(13) = prefixSum(0b00001101)
= BIT[13] + BIT[12] + BIT[8]
= BIT[0b00001101] + BIT[0b00001100] + BIT[0b00001000]
可以發現,在這棵抽象的樹種向上移動的過程其實就是不斷將當前數字的最後一個1
翻轉為0
的過程。基於這一事實,實現在Binary Indexed Tree中向上(在陣列中向前)尋找母結點的程式碼就非常容易了。例如給定一個int x = 13
,這個過程可以用如下運算實現:
x = 13 = 0b00001101
-x = -13 = 0b11110011
x & (-x) = 0b00000001
x - (x & (-x)) = 0b00001100
更新陣列中的元素
當我們呼叫update(idx, delta)
更新了原陣列中的某一個數字後,顯然我們也需要更新Binary Indexed Tree中相應的區間和來應對這一改變。
以update(5, 2)
為例,我們想要給原陣列中第5個位置的數字加2,基於之前構造好的Binary Indexed Tree,更新的過程如下圖中所示:
從圖中我們發現,從5開始,應當被更新的位置的座標為原座標加上原座標二進位制表示中最後一個1所代表的數字。這一過程和上面求和的過程剛好相反。以int x = 5
為例,我們可以用如下運算實現:
x = 5 = 0b00000101
-x = -5 = 0b11111011
x & (-x) = 0b00000001
x + (x & (-x)) = 0b00000110
Binary Indexed Tree的建立
Binary Indexed Tree的建立非常簡單。我們只需初始化一個全為0的陣列,並對原陣列中的每一個位置對應的數字呼叫一次update(i, delta)
操作即可。這是一個O(nlogn)
的建立過程。
此外,還存在一個O(n)
時間簡歷Binary Indexed Tree的演算法,其步驟如下(陣列下標從0開始):
給定一個長度為n
的輸入陣列list
。
1. 初始化長度為n + 1
的Binary Indexed Tree陣列bit
,並將list
中的數字對應地放在bit[1]
到bit[n]
的各個位置。
2. 對於1
到n
的每一個i
,進行如下操作:
- 令j = i + (i & -i)
,若j < n + 1
,則bit[j] = bit[j] + bit[i]
複雜度分析
根據上面的分析,我們可以看出,對於長度為n
的陣列,單個update
和prefixSum
操作最多需要訪問logn
的元素,也就是說單個update
和prefixSum
操作的時間複雜度均為O(logn)
。
構建Binary Indexed Tree的時間複雜度為O(nlogn)
或者O(n)
,取決於我們使用哪種演算法。
程式碼實現
public class BinaryIndexedTree {
private int[] bitArr;
// O(nlogn) initialization
// public BinaryIndexedTree(int[] list) {
// this.bitArr = new int[list.length + 1];
// for (int i = 0; i < list.length; i++) {
// this.update(i, list[i]);
// }
// }
public BinaryIndexedTree(int[] list) {
// O(n) initialization
this.bitArr = new int[list.length + 1];
for (int i = 0; i < list.length; i++) {
this.bitArr[i + 1] = list[i];
}
for (int i = 1; i < this.bitArr.length; i++) {
int j = i + (i & -i);
if (j < this.bitArr.length) {
this.bitArr[j] += this.bitArr[i];
}
}
}
/**
* Add `delta` to elements in `idx` of original array
* @param idx index of the element in original array that is going to be updated
* @param delta number that will be added to the original element.
*/
public void update(int idx, int delta) {
idx += 1;
while (idx < this.bitArr.length) {
this.bitArr[idx] += delta;
idx = idx + (idx & -idx);
}
}
/**
* Get the sum of elements in the original array up to index `idx`
* @param idx index of the last element that should be summed.
* @return sum of elements from index 0 to `idx`.
*/
public int prefixSum(int idx) {
idx += 1;
int result = 0;
while (idx > 0) {
result += this.bitArr[idx];
idx = idx - (idx & -idx);
}
return result;
}
/**
* Get the range sum of elements from original array from index `from_idx` to `to_idx`
* @param from_idx start index of element in original array
* @param to_idx end index of element in original array
* @return range sum of elements from index `from_idx` to `to_idx`
*/
public int rangeSum(int from_idx, int to_idx) {
return prefixSum(to_idx) - prefixSum(from_idx - 1);
}
}