1. 程式人生 > 實用技巧 >線段樹和樹狀陣列學習筆記

線段樹和樹狀陣列學習筆記

    學習了一週的線段樹和樹狀陣列,深深地體會到了這每種操作幾乎都是 \(O(logN)\) 級別的資料結構的美,但是做起題來還是相當痛苦的(特別是一開始只會模板的時候,很難靈活運用線段樹的性質)。還好有雨巨大神帶入門,視訊講解十分直觀(b站上也有很多介紹線段樹的視訊),不用像以前一樣看各種部落格題解入門。但是我現在就是在寫部落格了,希望能儘可能將我目前理解的知識整理出來,畢竟能讓別人看懂(網上已經這麼多關於線段樹和樹狀陣列的文章你還能找到我,相信我,你沒選錯),才說明自己也是真的懂了(雖然我還有好多不懂 \(QAQ\))。全文篇幅較長,細心理解一定會有收穫的♪(^∇^*)。

線段樹

一些概念

    線段樹是一種二叉搜尋樹,每一個結點都是一個區間(也可以叫作線段,可以有單點的葉子結點),有一張比較形象的圖如下(侵刪):

    可以看出,線段樹除根結點外的其他節點,都由其父節點二分長度得到,這種優秀的性質使得我們可以把它近似看成是一棵完全二叉樹。而完全二叉樹可以用一個數組表示:設根節點下標為 \(now\) (在程式碼中我習慣用 \(now\) 表示當前節點, \(ls(now)\) 表示左孩子結點, \(rs(now)\) 表示右孩子結點),則:

\[ls(now) = now*2,rs(now) = now*2+1 \]

    這樣就可以快速得到孩子的下標,根節點的下標為1,從上到下,從左往右編號既可將一顆線段樹存入小巧的數組裡了,不用煩人的指標。一般我會把左孩子和右孩子寫到巨集定義去,讓程式碼更簡潔,並且使用位運算,即:

\[ls(now) = now<<1,rs(now) = now<<1|1 \]

   這是等效的寫法,同時我們要獲得中間值,來二分 \(now\) 結點,同樣我用了巨集定義:

\[mid = (l+r)/2 \]

    \(l\)\(r\)\(now\) 的左右邊界,即它所能管理(覆蓋)的範圍,明確這一點非常重要。線段樹有很多種寫法,我看的很多程式碼都是把 \(l\)\(r\) 寫線上段樹節點的結構體裡面,我習慣是用傳引數的方法(因為帶我入門的雨巨就是這樣寫的。其實兩種寫法都是可以的,看個人習慣,最後熟練一種即可,但是另一種也要看得懂)。左孩子的左邊界還是

\(l\) , 右邊界變成了 \(mid\)右孩子的左邊界變成 \(mid+1\) ,右邊界是 \(r\) 。這樣就可以遞迴建樹了。
   這個線段樹陣列的大小也是要注意(經常 \(RE\) 的地方),要開4倍的陣列,就是說一個長度為10的區間,得開到40的陣列。一般題目資料的範圍都是在 \(10^5\) 的量級。有時資料範圍過大,還可以用離散化解決它,這在後面的部落格中會講到。

能解決什麼問題

   線段樹能解決超多有關區間的問題,還有一些不這麼明顯的區間問題(廢話)。像什麼單點修改,單點查詢,區間修改,區間查詢都不在話下,應用範圍比樹狀陣列廣,變通性極強(樹狀陣列能解決的問題線段樹都能解決,但是後者能解決的一些問題樹狀陣列還是搞不了的,但是樹狀陣列時空常數小,程式碼量少,還不容易寫錯)。線段樹可以區間維護區間和、區間乘,區間根號,區間最大公因數,連續的串長度等、區間最值操作等。這裡我給出一個洛谷題單和一個牛客題單,我也是剛剛刷完了這些題,質量都很高。

   我是先做牛客的再去做洛谷的,順序沒什麼。牛客題解比較少,需要自行百度理解(摘自牛客的一些比賽的比較多),洛谷質量就很高,題解豐富,做法也很多。可以先去做掉洛谷的四道模板題(線段樹兩道,樹狀陣列兩道,你還會發現線段樹其實有三道模板題,模板三理解起來比較困難,建議先刷完前面的題再去攻克,不然很可能會自閉(我坦白了,我已經自閉了))。寫完這篇總結後我會挑一些比較好的題再寫一篇部落格,算是題解總結吧。

建樹、修改和查詢

   題目一:P3372 【模板】線段樹 1
   我們從模板題入手,題目中的兩個操作正是線段樹很經典的操作:區間修改和區間查詢。我們思考以下幾種做法:
   ① 如果讓我們暴力地修改區間每一個值,再暴力查詢區間的每一個值,修改和暴力都是 \(O(n)\) ,加上 \(m\) 次操作,總的時間複雜度就是 \(O(nm)\) 的,必定 \(TLE\)
   ② 預處理字首和,進行離線操作。每次修改為 \(O(n)\) ,查詢為 \(O(1)\) ,時間複雜度依舊是 \(O(nm)\) ,還是會 \(TLE\)
   ③ 以上操作的瓶頸都每次操作時間複雜度都是 \(O(n)\) ,這時我們想起了每次操作都是 \(O(logn)\) 線段樹, 總的時間複雜度就是 \(O(mlogn)\) , \(AC\) 了。
   開始介紹之前,先交代一下線段樹結構體:

const int maxn = 1e5+5;
struct node{
    ll sum,lazy; //sum為區間和,lazy為懶標記
}t[maxn<<2];   //開四倍空間

   區間和就不用說了,重點講一下線段樹 \(O(logn)\) 操作的關鍵:懶標記 \(lazy\) 。懶標記就是懶,將對區間操作的命令不立刻執行,而是到萬不得已的時候才執行下去,否則就繼續“偷懶”,將命令繼續壓在自己手上,不往自己孩子傳。什麼時候可以不往孩子節點傳下去(即偷懶)呢?如果此時要修改的區間範圍已經囊括了當前節點能管理的範圍,那我就把這個命令直接在當前節點消化掉,不必再通知孩子也要執行這個命令了
   舉個栗子,比如我命令 \([1,10]\) 區間全部給我加 \(10\) ,到 \([1,10]\) 這個節點的時候,\([1,10]\) 正好包含住我管理的區間,那我直接給它的懶標記加上 \(10\) ,給區間和加上 \(100\) ,美滋滋地結束了任務,孩子們甚至不用知道這件事。下次如果要查詢 \([1,10]\) 的區間和的話,我直接在 \([1,10]\) 這個節點返回就好了,因為它已經修改正確了。但是很多時候命令的區間是多個節點的並集區間。如果接下來我要 \([4,7]\) 這個區間加 \(5\) ,這個區間是 \([4,5]\)\([6,7]\) 這兩個節點的並,你可能會說這不就是在這兩個節點打上標記就完事了嗎?可事實上你之前在 \([1,10]\) 打上了懶標記,這個會影響 \([4,5]\)\([6,7]\) 的區間和,而它的影響因為上次偷懶還沒傳下去呢,實際上 \([4,5]\)\([6,7]\) 的懶標記應該打上 \(15\) 才對。那我們怎麼亡羊補牢呢?誒,這需要 \(pushdown\) 函式幫忙啦,先賣個關子,先來講講怎麼建樹和修改。

建樹build

   先給出程式碼,四行建樹。

void build(int now,int l,int r){
    if(l == r) { cin>> t[now].sum ; return;}
    build(ls,l,mid);
    build(rs,mid+1,r);
    t[now].sum = t[ls].sum + t[rs].sum;
}

    \(now\)是當前節點的陣列下標,\(l\)\(r\) 是它所管轄的範圍,如果 \(l\)\(r\) 相等,說明到了葉子節點,也就是單點的情況,這時就直接讀入資料好了,只有一個元素,區間和肯定就是它本身了,注意之後要返回,因為它不可再細分了;否則,將管轄範圍一刀兩半,利用類似完全二叉樹的性質,遞迴建立左子樹 \(ls\) 和右子樹 \(rs\),這是我的巨集定義(你可以修改各個變數名,很多人習慣用 \(p\) 代表當前節點,我比較直接就用 \(now\) 了):

#define ls now<<1
#define rs now<<1|1
#define mid (l+r)/2

   建樹程式碼最後一行是關鍵,也是線段樹建樹的精髓,我們一般將這句話寫在一個函式 \(pushup\) 裡面,與 \(pushdown\) 正好對應。在這裡,它在遞迴返回時,左右子樹已經建好了,要將它們的區間和資訊整合到根節點,這裡直接累加即可,不必成單獨一個函式。但是當要維護的資訊量很多時,因為這個 \(pushup\) 後面還會呼叫,我們會將它單獨寫成一個函式以減少程式碼量,降低維護成本。
這裡的 \(pushup\) 程式碼可以寫成:

void pushup(int now){
    t[now].sum = t[ls].sum + t[rs].sum;
}

   建樹完畢,總結一下:
   1.先寫遞迴返回條件 \(l == r\)
   2.遞迴左右子樹 。
   3.合併兩子樹 \(pushup\)

修改update

   同樣先給出程式碼:

void update(int now, int l, int r, int x, int y, int value){
    if(x <= l && r <= y) {
       t[now].lazy += value;
       t[now].sum += (r - l + 1) * value;
       return;
    }
    if(t[now].lazy)  pushdown(now,r-l+1);  
    //懶標記下傳,與本次修改的資訊無關,只是清算之前修改積壓的懶標記
    if(mid >= x) update(ls, l, mid, x, y, value);
    if(mid < y) update(rs, mid + 1, r, x, y, value);
    pushup(now);
}

   前三個引數是固定引數,只與線段樹本身有關,可以無腦打上,後三個引數在本題的意義為將 \(x\)\(y\) 區間上每個值加上 \(value\) (可正可負)。
   首先我們還是得先寫遞迴返回條件:如果要修改的區間滿足 \([l,r]\in[x,y]\) ,也就是說已經涵蓋了本區間了,那我就沒有必要再將修改資訊往下傳了,在我這裡修改就可以了嘛。所以我們偷個懶,打上標記,給區間和加上增量,搞定,返回。但是如果要修改的區間是 \([l,r]\) 的一部分,就要像之前我們說的,要執行神祕的 \(pushdown\) 操作了,即將懶標記下傳。這裡特別要注意的是,本次下傳懶標記和這次修改沒有任何關係,從傳遞的引數也可以看出來與後三個引數無關,它的作用就是清算之前偷懶造成的影響。來看看這個重要的 \(pushdown\) 程式碼

void pushdown(int now,int tot){
    t[ls].lazy += t[now].lazy;        //懶標記給左孩子
    t[rs].lazy += t[now].lazy;        //懶標記給右孩子
    t[ls].sum += (tot - tot/2) * t[now].lazy;  //區間和加上懶標記的影響,注意範圍
    t[rs].sum += (tot/2) * t[now].lazy;
    t[now].lazy = 0;  //記得懶標記下傳後清0
}

  新引數 \(tot\) 表示當前節點管轄區間的範圍大小,注意左孩子管轄範圍為 \(tot-tot/2\)右孩子是 \(tot/2\) ,在加區間和的時候要小心。把之前偷懶的部分傳給孩子後,偷懶記錄就清零啦(摸魚成功)。這時不要不放心繼續 \(pushdown\) 左孩子和右孩子,這是沒有必要的,因為之後我們還會繼續 \(update\) 左子樹和右子樹(看上上個程式碼),如果有需要就會進行 \(pushdown\),沒有需要就繼續偷懶。這樣就能保證完整的 \(update\) 操作是 \(O(logn)\) 的。注意,遞迴左右子樹之前先判斷需不需要遞迴。分兩種情況,如果你要修改的部分完全在左子樹,就沒有必要修改右子樹;同理亦是如此。這樣可以防止無限遞迴了(如果在測試樣例的時候發現不出結果,大概率就是這裡沒寫對)。
   修改完畢,總結一下:
   1.先寫遞迴返回條件 \(x <= l ~\&\&~ r <= y\) ,執行偷懶操作。
   2.如果當前節點有懶標記積壓,執行 \(pushdown\) 操作,先清算之前的賬。
   3.根據條件判斷遞迴哪棵子樹(可能兩棵都會修改)進行修改。
   4.合併兩子樹 \(pushup\)

查詢query

  最後一部分就是查詢了,與修改操作其實比較類似:

ll query(int now, int l, int r, int x, int y){
    if(x <= l && r <= y) return t[now].sum;
    if(t[now].lazy) pushdown(now,r-l+1);
    ll ans = 0;
    if(mid >= x)  ans += query(ls, l, mid, x, y);
    if(mid < y)  ans += query(rs, mid + 1, r, x, y);
    return ans;
}

  你可能也發現查詢操作比較好理解,最不容易寫錯,也是寫的比較開心的一部分了。要注意的還是記得 \(pushdown\) ,因為要查詢的區間可能是當前節點的某個孩子,如果不把之前的懶標記下傳,查詢會出錯。遞迴返回區間和就行,並不用 \(pushup\) (查詢不會修改當前節點的值)。
   查詢完畢,總結一下:
   1.先寫遞迴返回條件 \(x <= l ~\&\&~ r <= y\) ,返回區間和資訊即可。
   2.如果當前節點有懶標記積壓,執行 \(pushdown\) 操作,先清算之前的賬。
   3.根據條件判斷遞迴子樹進行查詢。
   4.合併兩子樹查詢結果並返回。

完整程式碼:

#include<bits/stdc++.h>
using namespace std;
#define For(i,sta,en) for(int i = sta;i <= en;i++)
#define speedUp_cin_cout ios::sync_with_stdio(false);cin.tie(0); cout.tie(0);
#define ls now<<1
#define rs now<<1|1
#define mid (l+r)/2
typedef long long ll;
const int maxn = 1e5+5;
int n,m;
struct node{
    ll sum,lazy; //sum為區間和,lazy為懶標記
}t[maxn<<2];

void pushup(int now){
    t[now].sum = t[ls].sum + t[rs].sum;
}

void build(int now,int l,int r){
    if(l == r) { cin>> t[now].sum ; return;}
    build(ls,l,mid);
    build(rs,mid+1,r);
    pushup(now);
}

void pushdown(int now,int tot){
    t[ls].lazy += t[now].lazy;        //懶標記給左孩子
    t[rs].lazy += t[now].lazy;        //懶標記給右孩子
    t[ls].sum += (tot - tot/2) * t[now].lazy;  //區間和加上懶標記的影響,注意範圍
    t[rs].sum += (tot/2) * t[now].lazy;
    t[now].lazy = 0;  //記得懶標記下傳後清0
}

void update(int now, int l, int r, int x, int y, int value){
    if(x <= l && r <= y) {t[now].lazy += value; t[now].sum += (r - l + 1) * value;return;}
    if(t[now].lazy)  pushdown(now,r-l+1);  //懶標記下傳,與本次修改的資訊無關,只是清算之前修改積壓的懶標記
    if(mid >= x) update(ls, l, mid, x, y, value);
    if(mid < y) update(rs, mid + 1, r, x, y, value);
    pushup(now);
}

ll query(int now, int l, int r, int x, int y){
    if(x <= l && r <= y) return t[now].sum;
    if(t[now].lazy) pushdown(now,r-l+1);
    ll ans = 0;
    if(mid >= x)  ans += query(ls, l, mid, x, y);
    if(mid < y)  ans += query(rs, mid + 1, r, x, y);
    return ans;
}

int main(){
    speedUp_cin_cout//加速讀寫
    cin>>n>>m;
    build(1,1,n);  //建樹順便讀入,省一個數組
    int op,l,r,d;
    For(i,1,m){
        cin>>op;
        if(op == 1) {  // l 到 r 加上 d
            cin>>l>>r>>d;
            update(1, 1, n, l, r, d);
        }else {
            cin>>l>>r;     //查詢 l 到 r 的值
            cout << query(1, 1, n, l, r) << endl;
        }
    }
    return 0;
}

  幹掉這題就可以去做P3373 【模板】線段樹 2了,這題會提升你對懶標記的理解,\(pushdown\) 的懶標記下傳處理變得有些複雜。因為它要同時維護區間加標記和區間乘標記,區間乘標記會同時影響區間加標記和區間乘標記,想要 \(AC\) 還是得細心理解乘和加的關係。

樹狀陣列

一些概念

  樹狀陣列,顧名思義,就是像一棵樹的陣列。其實它和樹關係不太大,實際操作時有類似在樹上節點的跳躍的過程,但是在寫程式碼的時候它也不過是個一維陣列而已,和線段樹還是有一點點像的,不過是將線段樹的一些精華部分充分壓縮了。來,讓我們看看傳說中的樹狀陣列長啥樣(純手工,不要在意細節):

  綠油油的就是樹狀陣列啦,它頭上紅色的是這個下標對應的二進位制,最底下的就是原陣列了。直覺告訴你,他們之間隱約存在某些關係。你可能會覺得這裡一共有兩個陣列,還有一堆連線,要維護起來是不是很麻煩啊。其實他們可以只用一個一維陣列儲存所有資訊,而其中關鍵的紐帶就是二進位制
  我們設原陣列為 \(A\) ,樹狀陣列為 \(T\) ,定義連線的意義就是一個節點能管轄的範圍。例如 \(T_1\) 能直接管轄管轄到 \(A_1\) , \(T_2\) 能管轄到 \(T_1\) (間接管轄到 \(A_1\))和 \(A_2\), \(T_4\) 能管轄到 \(T_2\)\(T_3\)\(A_4\) ,亦即 \(T_4\) 能管轄到原陣列 \(A_1\)\(A_2\)\(A_3\)\(A_4\) ...以此類推,\(T_8\) 能管轄到原陣列所有值。所以,我們只要儲存 \(T_1\) , \(T_2\) 的值,原陣列中 \(A_2\) 可以由這兩者相減得到;同理, \(A_5\)\(A_6\)\(A_7\)\(A_8\) 的總和可以由 \(T_8\) 減去 \(T_4\) 得到。所以,我們只要保留樹狀陣列,原陣列的資訊完全可以由樹狀陣列維護出來,並且輕鬆知道任意一個區間的資訊和
  那麼新的問題出現了,我們如何知道誰管轄誰,他們之間有什麼聯絡嗎?這時,奇妙的二進位制出現了。觀察樹狀陣列頭上的二進位制,看出被管轄者與管轄著之間在二進位制上的聯絡了嗎?揭曉答案,被管轄者加上 \(2^k\)\(k\) 為被管轄者二進位制末尾零的個數,即可得到管轄著的二進位制!舉個栗子,\(T_2\) 的二進位制為 \(0010\) ,加上 \(2^1(0010)\) ,得到 \(0100\) ,即 \(T_4\) 。我們一般將 \(2^k\) 寫成一個函式叫 \(lowbit\) ,樹狀陣列下標 \(x\) 與它的 \(lowbit\) 如下關係:

\[lowbit = x\&(-x) \]

  證明其實沒必要,會用就行,這涉及到負數在計算機中儲存的形式,可以自己證一下。

修改update

  P3374 【模板】樹狀陣列 1
  樹狀陣列完全不用像線段樹一樣需要一個函式來建樹,聲明瞭一個一維陣列(陣列大小等於資料量即可,不用開多幾倍)直接就可以進行修改查詢等操作了。它的修改函式程式碼非常短,而且形式幾乎不變。

void update(int now,int value){
    while(now <= n){
        t[now] += value;
        now += lowbit(now);
    }
}

  三行迴圈就結束了,線段樹自愧不如。這個函式的意義是在原陣列的 \(now\) 的下標位置加上 \(value\) ,迴圈的終點是大於了樹狀陣列的下標範圍 \(n\) 。它是怎麼通過加上 \(lowbit\) 實現的呢?來看下面這張圖:

  假如我們要修改原陣列 \(5\) 這個位置的值,能管轄到它的只有 \(T_6\)\(T_8\) 。因為我們要求區間和,所以 \(T_6\)\(T_8\) 要都加上 \(T_5\) 修改後的值才行。這時我們用一個 \(lowbit\) 在迴圈中從 \(T_5\) 跳到 \(T_6\),再跳到 \(T_8\) ,一氣呵成。這樣,單點修改操作就完成啦。

查詢query

  查詢操作永遠是和修改操作配套的,一切修改的目的都是為了查詢的方便。既然修改程式碼這麼短小精悍,那麼查詢程式碼就更加小巧了,請看:

ll query(int now){
    ll ans = 0;  //long long 型別的答案
    while(now){
        ans += t[now];
        now -= lowbit(now);
    }return ans;
}

  程式碼的意義是查詢原陣列從 \(1\)\(now\) 的字首和,即從 \(A_1\)\(A_{now}\) 的和。注意這時我們的 \(lowbit\) 操作變成了減,而之前修改操作是加。原理也可以看圖說明:

  圖中我們查詢的是 \(1\) ~ \(7\) 的字首和,我們先加上 \(T_7\) 的答案,再減去它的 \(lowbit\) 跳到 \(T_6\) ,最後跳到 \(T_4\) ,因為 \(T_6\)\(T_4\) 在前面的修改操作中已經維護出了自己管轄區域的區間和,都加上就是 \(1\) ~ \(7\) 的字首和了。
  知道了字首和,區間和其實就很容易了,假如我們要求 \([x,y]\) 的區間和,其實就是 \(query(y)-query(x-1)\)注意是 \(x-1\) ,要自己想一想,這個地方總是容易被忽略

完整程式碼

#include<bits/stdc++.h>
using namespace std;
#define For(i,sta,en) for(int i = sta;i <= en;i++)
#define speedUp_cin_cout ios::sync_with_stdio(false);cin.tie(0); cout.tie(0);
#define lowbit(x) x&(-x)
typedef long long ll;
const int maxn = 5e5+5;
int t[maxn],n,m,num;

void update(int now,int value){
    while(now<=n){
        t[now]+=value;
        now += lowbit(now);
    }
}

ll query(int now){
    ll ans = 0;  //long long 型別的答案
    while(now){
        ans += t[now];
        now -= lowbit(now);
    }return ans;
}

int main(){
    speedUp_cin_cout
    cin>>n>>m;
    For(i,1,n) {
        cin>>num;
        update(i,num);
    }int op,x,y;
    For(i,1,m){
        cin>>op>>x>>y;
        if(op == 1) update(x,y);
        else cout<<query(y)-query(x-1)<<endl;
    }
    return 0;
}

  是不是比線段樹短多了。這是單點修改,區間查詢。洛谷還有道P3368 【模板】樹狀陣列 2,是區間修改,單點查詢。這就需要差分的思想了。所謂的差分,其實就是後一項與前一項的差,對於第一項而言,\(a[0] = 0\) 。設陣列 \(a[~]=\{1,9,3,5,2\}\) ,那麼差分陣列\(t[~]=\{1,8,-6,2,-3\}\) ,即 \(t[i]=a[i]-a[i-1]\) ,那麼 $$a[i]=t[1]+...+t[i]$$
這不就是字首和嗎?以對原陣列的區間修改,單點查詢就是在其差分陣列上單點修改,區間查詢。但是要注意的是,這裡的單點其實是要修改兩個點。例如我們如果要讓 \([2,3]\) 區間加上 \(4\) ,首先是要修改差分陣列上的 \(t[2] +4\), 然後還要修改 \(t[4]-4\) ,這也是很好理解的,畢竟 \([2,3]\) 區間比其他區間突出了一塊,整體提高了 \(4\) ,而其他的區間的差分關係並沒有被改變。這樣,我們也可以很愉快地 \(AC\) 這道題了。

還有一些話

  做模板題是快樂的(除了P6242 【模板】線段樹 3),但是實際應用起來是比較頭疼的。因為線段樹和樹狀陣列靈活性很高,可以解決很多看似無法下手的問題,但是要維護的資訊多得容易摸不著頭腦(不知道為什麼這樣做就可以了),邏輯關係環環相扣,時不時就得感嘆一下“妙”。這些都得做更多的題來體會了。還有不要死記模板,要清楚知道每一步的作用,很多時候一些順序會顛倒,來解決不同的問題,這是需要警惕的。
  如果覺得對你理解有幫助的希望給我點個贊哦,ο(=•ω<=)ρ⌒☆