Try   HackMD

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

連結

二元索引樹是一個提供高效前綴和查詢,並且支援率支援單點或區間修改的資料結構。

他有以下稱呼:

  • [中] 二元索引樹
  • [中] 樹狀數組
  • [英] Binary Indexed Tree
  • [英] Fenwick Tree

時常可以在程式題目中,遇到需求得一陣列之前綴和或區間和之情況。

為了方便說明,我們首先定義一些符號。令

{an}n=1N 為一長度為
NN
之有限數列,並令
Sn=i=1nai
為此數列之前
n
項和;令
a,bN
1ab
,則
S[a,b]=n=aban
為此數列之第
a
至第
b
項 (首尾皆包含) 和。

求得前

n 項和最 Naive 的算法就是從第一項走訪 (遍歷) 至第
n
項,並把經過的所有元素加總。

然而,以上的算法有著

O(n) 的時間複雜度,耗費大量時間與資源,
所以我們應該追求更高效率的算法。在這學期的課程中,我們學到動態規劃 (Dynamic Programming) ,此時就可以派上用場。

n 項和可以被拆解成兩個子問題:前
n1
項和以及第
n
項元素。或者,也就是說:

Sn=Sn1+annNn2

因此,我們可以透過事前花費線性時間 (

O(n)) 建構前綴和數列
{pn}n=1N
,此數列的第
n
項即代表數列
{an}
的前
n
項和。也就是說:

pn=Sn=i=1nainN

當前綴和數列

{pn} 建構好後,我們就可以達成常數時間 (
O(1)
) 的任意前綴之查詢。

而當我們可以做到常數時間下的前綴和查詢,我們也就可以做到常數時間的任意區間和查詢,因為任意區間和皆可拆解為兩個前綴和之差。例如,數列的第

a 項至第
b
項之和,可以透過前
b
項和減去前
a1
項和求得。也就是說

S[a,b]=SbSa1=pbpa1a,bN1<ab

pb
pa1
的取得都可以在常數時間做到,因此求得數列任意區間和的時間複雜度仍然是常數時間。

發現問題

前綴和陣列看似完美的解決了這個問題,但實際上他有個致命傷。當原數列

{an} 發生變動時,前綴和數列
{pn}
的一部分就會立刻失效,需要更新。

更精確地說,當原數列

{an} 的第
i
項元素 (
ai
) 發生更動時,前綴和數列的第
i
項之後 (包含) ,也就是第
i
到第
N
項都將失效,需要重新建構。

在最糟糕的情況下 (worst case) ,也就是原數列

{an} 的第
1
項發生更動,則整個前綴和數列
{pn}
都會失效,需要從頭到尾重新建構,因此將花費線性時間 (
O(n)
) 。

然而,這也就是二元索引樹所想要解決的問題。我們希望能在時間複雜度小於

O(n) 下對原數列
{an}
進行修改,並維持前綴和的正確性。

二元索引樹的誕生,使我們可在做到

O(logn) 的前綴和查詢,並且支援
O(logn)
的原數列單點或區間修改。詳細原理會在下個部分說明。

此外,二元索引樹的空間只要

n,比線段樹的
4n
小。並且和前綴和陣列一樣,只需要用一個一維陣列就可儲存一棵二元索引樹。

在這個部分中,我會介紹以下二元索引樹的重要操作:

  • 前綴和查詢 Prefix Sum Quries
  • 單點修改 Point Updates
  • 建構 Construction

前綴和查詢
Prefix Sum Queries

關於如何建構一棵二元索引樹,我會在後面介紹。
我會以倒敘法的方式說明,使你可以更好的理解二元索引樹。

以下是一棵建構好的二元索引樹

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

[圖片來源:師大附中競程國手 WiwiHo 樹狀數組資料結構筆記]

令其為

BIT ,且令
BITi
BIT
之第
i
個節點;令
bi
BITi
之權重。

則求得前

n 項和的方法為:
「將第
n
號節點至根節點 (第
0
號節點;不包含) 之最短路徑上所有節點之權重加總」

也就是說,令

{Pin} 為此二元索引樹第
n
號節點至其根節點之路徑上的所有節點之有序集合、
Pin
為此集合中第
i
個節點之權重,則

Sn={Pin}

舉例來說,若想求數列

{an} 的前
7
項和,則我們只要將
BIT
之第
7
號節點至根節點之最短路徑上所有節點之權重加總,就會是所求。更嚴謹地表示:

P7={BIT7,BIT6,BIT4}

因此

S7={Pi7}=b7+b6+b4

也就是說,數列

{an} 的前
7
項和
S7
就會是其二元索引樹的第
7
號、第
6
號及第
4
號節點的權重總和。

而若將此流程從圖的角度拉回二元索引樹的名字本身,其實向上追朔母節點的行為在二進制的世界中,就等同於移除目前節點索引值的「最低有效為 (Least Significant Bit) 」

再次使用上述的例子,若想求數列前

7 項和,則:

  1. 先將二元索引樹第
    7
    號節點之權重計入總和。
  2. 再將
    710011
    1
    2
    的最低有效為 (也就是
    1
    ) 移除。
  3. 得到
    6
    ,故將第
    6
    號節點之權重加入總和。
  4. 再將
    61001
    1
    02
    之最低有效為 (也就是
    2
    ) 移除。
  5. 得到
    4
    ,故將第
    4
    號節點之權重加入總和。
  6. 再將
    4100
    1
    002
    之最低有效為 (也就是
    4
    ) 移除。
  7. 得到
    0
    ,代表我們追朔到了根節點,因此操作結束。

最後所得總和即為數列之前

7 項和。

給定一棵建構好的二元索引樹,能透過以上方法求得前綴和之原因將在此部分解答。

與前綴和陣列類似的是,二元索引樹的每個節點都有其所付責儲存的數列區間和。每個節點負責的區間皆與其索引值之二進制表示法有關,具體規則如下:

  1. 各節點所負責的區間長度洽為其索引值之最小為元
  2. 各節點所負責區間為從自身索引值往下延伸最小為元長

更精確地敘述令

Ii
BITi
所負責之區間,則

Ii=[ilsb(i)+1,i]

其中,

lsb(i) 代表
i
的最低有效位。

因此,我們也得知

bi=SIi=S[ilsb(i)+1,i]

舉例來說:

  • 二元索引樹的第
    7
    號節點所負責的區間長度是
    1
    ,也就是其最低有效為 (
    710011
    1
    2
    )。因此其所負責儲存的數列區間和為
    S[7,7]
  • 二元索引樹的第
    6
    號節點所負責的區間長度是
    2
    ,也就是其最低有效為 (
    61001
    1
    02
    )。因此其所負責儲存的數列區間和為
    S[5,6]
  • 二元索引樹的第
    4
    號節點所負責的區間長度是
    4
    ,也就是其最低有效為 (
    4100
    1
    002
    )。因此其所負責儲存的數列區間和為
    S[1,4]

所以你可以觀察到,在上個部分的例子中,我們得到

S7=b7+b6+b4 ,而在這個部分我們又得到
b7=S[7,7],b6=S[5,6],b4=S[1,4]
,因此我們可以驗證

b7+b6+b4=S[7,7]+S[5,6]+S[1,4]=S[1,7]=S7

的正確性。

經簡單觀察我們就可以發現,從第

n 號節點持續移除節點索引值之最低有效位直到
0
,經過的所有節點負責的區間和聯集就恰會是
[1,n]
,而這就是加總所有路徑上節點權重就會是數列前
n
項和之原理。

 Point Updates

當原數列

{an} 的第
i
項元素
ai
發生更動,我們無需徹底重建二元索引樹。我們只需要更新所有負責區間覆蓋 (或包含) 到
i
的節點權重。

要找到所有被變動的

ai 所影響到的節點,方法與做前綴和查詢時相反,也就是要持續加上節點索引值之最低有效位。

舉例來說,當

a7 發生更動時,找到所有影響到的節點流程如下:

  1. BIT7
    必受影響,對其做與
    a7
    相同更新
  2. 7
    加上其最低有效位,也就是
    1
    (
    710011
    1
    2
    ) 。
  3. 得到
    8
    ,故
    BIT8
    受到影響,對其作相同更新。
  4. 8
    加上其最低有效位,也就是
    8
    (
    810
    1
    0002
    ) 。
  5. 得到
    16
    ,故
    BIT16
    受到影響,對其作相同更新。
  6. 16
    加上其最低有效位,也就是
    16
    (
    1610
    1
    00002
    ) 。

重複執行以上流程,直到索引值超出數列

{an} 的範圍。

 Construction

建構一棵基於數列

{an} 的二元索引樹非常簡單。

只需要先將二元索引樹所有節點權重初始化為零,意即當作數列

{an} 一開始全為
0
,隨後再對數列
{an}
的每個元素做單點更新。

使

這裡提供一題裸題,沒有額外包裝。但實際上二元索引樹常常可在求區間和但時間限制較高的情況下派上運場。

如果想嘗試解題,可以將程式碼繳交至 ZeroJudge d799

給你

N 個數據,不斷地改變這
N
個數據的同時,也不停地問你某個區間中所有元素總合。

只有一筆測試數據。

第一行有一個數

N (
0<N500000
) 。
第二行有
N
個數
ai
(
11N0ai32767
)。

第三行有一個數

Q (
0<Q500000
) 。

接下來有

Q 組要求和詢問。
每組要求或詢問中:

首先有一個數

v (
v{1,2}
)。

v=1 則表示是要求,
接下來會有三個數
x,y,k
(
0<xyN0k1000
),表示從第
x
個數據至第
y
個數據,每個都加上
k

v=2 則表示是詢問,
接下來有兩個數
x,y
(
0<xyN
),表示詢問從第
x
個數據至第
y
個數據的所有元素之總和。

如果是要求,則無須輸出;

如果是詢問,則需輸出所詢問的區間元素總和。

題目來源:ZeroJudge d799

二元索引樹和 堆積 (Heap) 有著異曲同工之妙。

堆積總是一棵完全樹。即除了最底層,其他層的節點都被元素填滿,且最底層儘可能地從左到右填入。

雖兩者運作方式不同,但相同的是,節點索引值連續 (沒有空洞) ,且子母節點關係明確,故都非常適合以陣列儲存

另外,值得注意的是,堆積名字中沒有「二元」等字詞,但他是一棵完全二元樹;相反地,二元索引樹雖然名字中有「二元」,但他明顯並不是二元樹,因為名字中的二元是指「二進制」。

 Storage

一個基於長度為

N 之數列所建構的
BIT
必恰有
N
個節點,且母節點與子節點之間編號關係明確,都是差一個最低有效位。

此外,二元索引樹的節點索引值是連續的,中間不會有空洞。

因此,

BIT 非常適合以「陣列」形式儲存。

Pseudo Code
N = 16
BIT = [ 0 ] * N

 Prefix Sum Quries

流程:
將索引值持續減去其本身的最低有效位 (Least Significant Bit) ,並將所有經過節點權重相加,直到索引值被減至

0 為止。

只需要一個簡單的 while 迴圈就可以實現這個流程。

Pseudo Code
def query(i):
    sum = 0
    while i > 0:
        sum += BIT[i]
        i -= lsb(i)
    return sum

 Point Updates

流程:
將索引值持續加上其本身的最低有效位 (Least Significant Bit) ,並將所有經過節點做相同更新,直到索引值被加至超過

N 為止。

Pseudo Code
def update(i, delta):
    while i <= N:
        BIT[i] += delta
        i += lsb(i)

 Least Significant Bit

取得最小位元最簡單的方法是取

x
x
bitwise and

因為
x
x
的補數
+1
(Two's Complement)。

def lsb(x):
    return x & -x

以下以 C++ 實作二元索引樹,作為範例程式碼。

#include <bits/stdc++.h> using namespace std; const int N = 16; int BIT[N] = {0}; int lsb(int x) { /* Returns the least significant bit of x */ return x & -x; } int prefix(int n) { /* Returns the n-th prefix sum */ int sum = 0, idx = n; while (idx > 0) { // cout << "i: " << idx << endl; sum += BIT[idx-1]; idx -= lsb(idx); } return sum; } int query(int l, int r) { /* Return the range sum of the interval [l, r] */ if (l == 1) return prefix(r); return prefix(r) - prefix(l-1); } void update(int idx, int delta) { /* Updates the idx-th element of the array */ while (idx <= N) { BIT[idx-1] += delta; idx += lsb(idx); } } int main() { srand(time(nullptr)); cout << "Random Array: " << endl; cout << "[ "; for (int i = 1; i <= N; ++i) { int a = (rand() % 21) * (rand() % 10 > 4 ? 1 : -1); cout << a << ' '; update(i, a); } cout << "]" << endl; int l, r; cout << "Enter Your Query [l, r] (-1 to quit): " << endl; while (true) { cout << "l: "; cin >> l; if (l == -1) break; cout << "r: "; cin >> r; if (r == -1) break; cout << "Range sum of [" << l << ", " << r << "] is " << query(l, r) << endl; } return 0; }

Source Code Gist

 Prefix Sum Queries

流程為持續移除最低有效位直至

0 ,而在範圍內的節點索引值最多可以有
log2N
個有效位元。最多也只可能移除
log2N
次才追朔到根節點。

因此,前綴和查詢的時間複雜度為

O(logn)

 Point Updates

流程為持續加上最低有效位直至超出範圍。在最糟的情況下,如果從

1 開始加,最多也只可能加上
log2N
才超出或剛好到範圍邊界。

因此,前單點修改的時間複雜度為

O(logn)

 Construction

流程為先將二元索引樹所有節點權重初始化為零,意即當作數列

{an} 一開始全為
0
,隨後再對數列
{an}
的每個元素做單點更新。

在以上方法中,共有

n 次更新,而每次更新複雜度是
O(logn)

因此,建構一棵二元索引樹的時間複雜度為
O(nlogn)

[註:事實上,二元索引樹的建構是可以在

O(n) 內完成的,請參考 Fenwick Tree construction]

 References