1. 程式人生 > 其它 >樹狀陣列(一) 基本結構及其基本運用

樹狀陣列(一) 基本結構及其基本運用

引入

首先我們來看一張表:

單點修改 區間修改
單點查詢 傳統陣列
區間查詢

當我們使用普通的陣列時,如果我們要對一個元素進行修改,時間複雜度為 \(O(1)\) ,如果我們要進行區間查詢,時間複雜度則為 \(O(n)\) ,我們知道,一個演算法的時間複雜度是由瓶頸(時間複雜度最高的那一項)決定的,所以我們要實現一種較為快速的資料結構,他的單點修改為 \(O(log \ n)\) ,區間查詢為 \(O(log \ n)\) 。這樣,就引出了今天的主題——樹狀陣列

原理

在樹狀陣列中,顧名思義,我們要以陣列的形式建樹,具體怎麼建呢?請看下面這個例子:

其中藍色部分表示原始陣列(A),黃色部分表示樹狀陣列(C),紅線表示每個節點所管理的元素。
發現:
C[1] = A[1];
C[2] = A[1] + A[2];
C[3] = A[3];
C[4] = A[1] + A[2] + A[3] + A[4];
C[5] = A[5];
C[6] = A[5] + A[6];
C[7] = A[7];
C[8] = A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7] + A[8];


轉換成二進位制得到:
C[1] = A[1];
C[10] = A[1] + A[10];
C[11] = A[11];
C[100] = A[1] + A[10] + A[11] + A[100];
C[101] = A[101];
C[110] = A[101] + A[110];
C[111] = A[111];
C[1000] = A[1] + A[10] + A[11] + A[100] + A[101] + A[110] + A[111] + A[1000];

發現:
\(C[x] = \sum _{i =x - lowbit(x) + 1} ^ x A[i]\)
\(C[x]\) 維護的是 \((x - lowbit(x), x]\)
這個區間(注意左邊是閉區間
\(lowbit(x)\) 表示的是 x 在二進位制下最右邊的1 (例如 \(lowbit(10010_2)=10_2\) )。
至於為什麼這麼設計,是因為他有著非常神奇的性質,我們後面會講到。

lowbit

在講實現之前,我們先了解一下這個所謂的 "lowbit" 怎麼來用程式碼寫出。如果從左往右一位一位地算的話,複雜度為 \(O(log\ n)\) ,有沒有更快速的方法呢?答案肯定是有的:
inline int lowbit(int &x) { return x & -x; }
感性理解一下,負數是由補碼儲存的,-x 等於 x的取反加一,那麼原先x末尾的1000...0,在-x中就會變為0111...1(取反)-> 1000...0(加一),而剩餘的高位剛好相反

,下面是三個例子:

x x取反 x取反加一(-x) x & -x
10100010 01011101 01011110 00000010
11111111 00000000 00000001 00000001
11101000 00010111 00011000 00001000

瞭解了 lowbit 以及基本的原理之後,我們就可以開始寫基本的操作了。

單點修改

當我們進行單點修改的時候,我們需要找到的就是 包含原始陣列A[i]的所有樹狀陣列C[j],並進行修改操作,具體什麼意思呢,舉個例子。
當我們修改5號位置時,綠色為要更新的節點:

從上圖可以發現,當我們更新A[5]的時候,C[5]、C[6]、C[8]都被更新了。
觀察他們的二進位制: \(5 (101)_2 \ \ \ \ \ 6(110)_2 \ \ \ \ 8(1000) _2\) ,可以得知
$5 + lowbit(5) = (101)_2 + (1)_2 = (110)_2 = 6 $
$6 + lowbit(6) = (110)_2 + (10)_2 = (1000)_2 = 8 (x - lowbit(x), x] $
.......(後面會一直更新到整棵樹的大小)

於是可以發現當更新A[i]時,會更新
$ C[i],C[i_1],C[i_2]...C[i_n] $
其中 $i_k = i_{k-1} + lowbit(i_k), i_k \leq n $

理解一下這個結論:有哪些節點會包含A[i]這個元素呢?首先C[i]肯定是包含的(原因上面已經講過),再看看有沒有其他的節點 \(x\) 使得 \(i \in (x - lowbit(x), x]\) ,很容易得出這樣一個結論:如果C[x]包含A[i],那麼C[x + lowbit(x)]也一定包含A[i]
我們來證明這個結論:我們設原來的 x 為 x1,x + lowbit(x) 為 x2。
那麼 x1 所代表的區間為 \((x_1 - lowbit(x_1), x_1]\)
x2所代表的區間為 \((x_2 - lowbit(x_2), x_2]\)
\(\because x_1 < x_2\)
$\therefore x_1 + lowbit(x1) < x_2 + lowbit(x_2) $
也就是說** x2 所代表的區間完全包含 x1 所代表的區間** 。那麼更新 C[x1] 時,就要更新 C[x2] 了。

於是程式碼就自然而然地寫出來了(將位置為 x 的元素加上 v ,這裡用 f 表示樹狀陣列 C )
inline void updata(int x, int v) { while(x <= n) { f[x] += v; x += lowbit(x); } }

區間查詢

假設我們現在要查詢區間 \([l, r]\) ,那麼就將問題轉化成查詢區間 \([1, l)\)\([1, r]\) ,通過第二項減去第一項就可以得到區間 \([l, r]\) 了。
我們還是從特殊到一般:假設現在查詢區間 \([1, 7]\) 。其中紫色部分為需要累加的和.

與之前相似地,觀察他們的二進位制 \(7(111) \ \ \ 6(110) \ \ \ 4(100)\) ,易得
$7 - lowbit(7) = (111)_2 - (1)_2 = (110)_2 = 6 $
$6 - lowbit(6) = (110)_2 - (10)_2 = (100)_2 = 4 $
$4 - lowbit(4) = (100)_2 - (100)_2 = (0)_2 = 0 $
總結後可以發現紫色節點有以下規律( \(sum_x\) 表示區間 \([1, x]\) 的和)
$sum_x = C[x] + C[x_1] + C[x_2]+..+C[x_n] $
其中 \(x_k = x_{k - 1} - lowbit(x_{k - 1})\)

首先 \(sum_x\) 中一定包含 C[x], 而C[x]所代表的範圍是 \((x - lowbit(x), x]\) ,之後我們要找到一個節點 y ,他所代表的區間為: \((y - lowbit(y), y]\) , 而我們要完整包含 \([1, x]\) 這整個區間, 所以 y 一定要滿足: \(y =x - lowbit(x)\) (x的左界等於y的右界),於是得證。

程式碼就比較好寫了:

inline int pre(int x) { int res = 0; while(x) { res += f[x]; x -= lowbit(x); } return res; }
inline int query(int l, int r) { return pre(r) - pre(l - 1); }

例題

樹狀陣列1[模板]-洛谷
這裡維護的操作,之前我們都講過了,這裡只把程式碼放出來

#include <bits/stdc++.h>
using namespace std;

inline int read() {//快讀,可忽略
    int x = 0; char c = getchar();bool f = 1;
    while(c < '0' || c > '9') { if(c == '-') f = 0; c = getchar(); }
    while(c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); }
    return (f ? x : -x);
}

int n, m, opt, x, y, f[500005];

inline int lowbit(int &x) { return x & -x; }
inline void updata(int x, int v) { while(x <= n) { f[x] += v; x += lowbit(x); } }
inline int pre(int x) { int res = 0; while(x) { res += f[x]; x -= lowbit(x); } return res; }
inline int query(int l, int r) { return pre(r) - pre(l - 1); }

int main() {
    n = read(); m = read();
    for(int i = 1 ;i <= n ;++i) updata(i, read());

    while(m--) {
        opt = read(); x = read(); y = read();

        if(opt == 1) updata(x, y);
        else printf("%d\n", query(x, y));
    }

    return 0;
}

尾聲

讓我們回到開始的那張表:

單點修改 區間修改
單點查詢 傳統陣列 ?
區間查詢 樹狀陣列 ?

現在我們已經掌握了 單點修改 + 單點查詢 以及 單點修改 + 區間查詢,那麼後兩種 區間修改 + 單點查詢區間修改 + 區間查詢 暫時還沒有掌握,這就是我們下幾章要講述的內容了。