1. 程式人生 > 遊戲 >國產修仙卡牌遊戲《弈仙牌》Steam發售 未來將持續更新

國產修仙卡牌遊戲《弈仙牌》Steam發售 未來將持續更新

線段樹專題

線段樹與樹狀陣列的視訊教程,非常清晰,強烈推薦

一、線段樹基礎

1. 線段樹簡介

線段樹是演算法競賽中常用的用來維護區間資訊的資料結構。
線段樹可以在很小的時間複雜度內實現 單點修改、區間修改、區間查詢(即區間求和,求區間 \(max\) ,求區間 \(min\) ,區間 \(gcd\) 等)操作。

但是,線段樹所維護的資訊,需要滿足區間加法

區間加法:如果一個區間 \([l,r]\)(線段樹中一個點表示一個區間)滿足區間加法的意思是一個區間 \([l,r]\) 的線段樹維護的資訊(即區間最大值,區間最小值,區間和,區間 \(gcd\) 等),可以由兩個區間 \([l,mid]\)

\([mid+1,r]\)合併而來。

2. 線段樹的基本概念

線段樹,是一種基於分治思想的二叉搜尋樹。它支援的所有操作都可以 \(O(logn)\) 的時間複雜度完成。
但是線段樹有個很大的特點:線段樹的題目可以調上一整天

線段樹的 基本特點

  • 線段樹的每一個節點表示一個區間
  • 線段樹有唯一根,這個根表示的所有會被線段樹統計的總區間,一般情況下,根表示的區間就是 \([1,n]\)
  • 線段樹的葉子節點表示的區間為 \([x,x]\) ,且長度為 \(1\)
  • 線段樹中如果一個節點表示的區間是 \([l,r]\) ,且這個點不為葉子節點,即 \(l≠r\),那麼這個節點的左子樹的根表示的區間就是 \([l,mid]\)
    這個節點的右子樹的根表示的區間就是 \([mid+1,r]\),其中 \(\large mid=⌊\frac{l+r}{2}⌋\)

3.線段樹的儲存方式

直接採用堆儲存方式,即一顆線段樹的跟的編號是 \(1\) ,設一個不為根的節點編號 \(x\) ,則這個點的父節點是 \(⌊\frac{x}{2}⌋\) ,他的兩個子節點的編號分別是 \(2×x\)\(2×x+1\) 。為了線段樹的節點不超過儲存範圍,一般線段樹都要開 \(4×n\) 的空間,即區間總長度的 \(4\) 倍。

因為一顆線段樹最多是一顆滿二叉樹,而滿二叉樹的最後一層是\(n\) 個點,前面的點數是 \(n−1\) ,所以一共要 \(2×n−1\)

的空間,但由於線段樹有可能最後一層節點還有子節點,比如說 \(n=10\) 的時候,如圖:

這裡就是一個例子,最後一層是多出來的,而最後一層節點最多 \(2×n\) 個節點,最壞情況下就最右邊兩個節點,最右下角的一個節點的編號是 \(2×n−1+2×n=4×n−1\) ,所以線段樹一般開 \(4×n\)

4.建立線段樹

思路
我們遞迴遍歷初始區間,把遍歷到的所有節點表示的區間記錄下來,如果這個節點不是葉子節點(即區間長度大於\(1\)),那麼就分別遍歷左子樹和右子樹,否則就是葉子節點,不僅要把表示的區間記錄下來,還要把線段樹維護的資訊也記錄下來,維護的資訊在葉子節點上基本上就是這個數本身。
時間複雜度 \(O(logn)\)

程式碼

struct Node {
    int l,r;
    LL sum;   //這裡可以維護任何滿足區間加法的資訊,這裡就用區間求和了
}tr[N << 2];   //要開四倍空間

void pushup (int u) {  //這裡只有區間和,區間和就是由一個點的左右子節點的和相加
    tr[u].sum = tr[u << 1].sum + tr[u << 1 | 1].sum;
}

void build (int u,int l,int r) {  //當前正在下標為u的點,這個點表示的區間是[l,r]
    if (l == r) {
        tr[u] = {l,r,a[l]};
        return ;
    }
    tr[u] = {l,r};  //記得儲存當前點表示的區間,否則你會調上一整天

    int mid = l + r >> 1;
    build (u << 1,l,mid),build (u << 1 | 1,mid + 1,r);  //u << 1就是u * 2,u << 1 | 1就是u * 2 + 1
    pushup (u);  //pushup函式的意思是把某一個節點的資訊有他的子節點算出來
}

5.單點修改

思路
我們通過二分查詢的形式找到要修改的點,然後把找的過程上的鏈都修改一下。
時間複雜度 \(O(logn)\)

程式碼

void modify (int u,int x,int v) {   //當前這個點是下標為u的點,要把第x個數修改成d
    if (tr[u].l == tr[u].r) {
        tr[u].sum = v;   //如果當前區間只有一個數,那麼這個數一定是要修改的。
        return ;
    }
    int mid = tr[u].l + tr[u].r >> 1;
    if (x <= mid) modify (u << 1 , x , v);  //如果在左邊就遞迴修改左半邊區間
    else modify (u << 1 | 1 , x , v);    //如果在右邊就遞迴修改右半邊區間
    pushup (u)   //記得更新資訊
}

6.區間修改

思路1
線段樹的區間修改大體分為兩步

  • 找到區間中全部都是要修改的點的線段樹中的區間
  • 修改這一段區間的所有點

我們先解決第 \(1\) 步:
我們從根節點(根節點一定包含所有點,既包含修改區間)出發,一直往下走,直到當前區間中的元素全部都是被修改元素。
當左區間包含整個被修改區間時,我們就遞迴到左區間;
當右區間包含整個被修改區間時,我們就遞迴到右區間;
否則區間的樣子就如下圖所示:

此時該怎麼辦呢?
不過,通過思考,我們可以發現,被修改區間中的元素間,兩兩之間都不會產生影響。
所以,我們可以把被修改區間分解成兩段,使得其中的一段完全在左區間,另一端完全在右區間。
很明顯,直接在 \(mid\) 的位置將該區間切開是最好的。如下圖所示:

如果當前區間包含了修改區間的話就直接修改,把區間裡的每一個數遍歷一遍。
時間複雜度 \(O(nlogn)\)

程式碼

void modify (int u,int l,int r,int d) { //當前遍歷到的點下標是u,要將區間[l,r]增加d
    if (tr[u].l == tr[u].r) {   //葉子節點
        tr[u].sum += d;
        return ;
    }
    int mid = tr[u].l + tr[u].r >> 1;  //注意是tr[u].l和tr[u].r
    if (l <= mid) modify(u << 1,l,r,d);  //左邊有修改區間,就遞迴左半邊
    if (r >= mid + 1) modify(u << 1 | 1,l,r,d);  //右邊有修改區間,就遞迴右半邊
    pushup (u);  //要記得要把這個點的資訊更新一下
}

思路2
欸,你不是說線段樹所有操作都是 \(O(logn)\) 的嗎?現在怎麼來了一個 \(O(n)\) 的?
這不就是思路 \(2\) 啊。。。
為提高線段樹的修改效率,就要引入一個好東西:懶標記

懶標記就是我們在做修改操作時能用來偷懶的標記

來自封禁的一個故事:

\(A\) 有兩個兒子,一個是 \(B\),一個是 \(C\)
有一天 \(A\) 要建一個新房子,沒錢。剛好過年嘛,有人要給 \(B\)\(C\) 紅包,兩個紅包的錢數相同都是 \(1\) 元,然而因為 \(A\) 是父親所以紅包肯定是先塞給 \(A\) 咯~
理論上來講 \(A\) 應該把兩個紅包分別給 \(B\)\(C\),但是……缺錢嘛,\(A\) 就把紅包偷偷收到自己口袋裡了。
\(A\) 高興地說:「我現在有 \(2\) 份紅包了!我又多了 \(2×1=2\) 元了!哈哈哈~」
但是 \(A\) 知道,如果他不把紅包給 \(B\)\(C\),那 \(B\)\(C\) 肯定會不爽然後導致家庭矛盾最後崩潰,所以 \(A\) 對兒子 \(B\)\(C\) 說:「我欠你們每人 \(1\)\(1\) 元的紅包,下次有新紅包給過來的時候再給你們!這裡我先做下記錄……嗯……我欠你們各 \(1\) 元……」
兒子 \(B\)\(C\) 有點惱怒:「可是如果有同學問起我們我們收到了多少紅包咋辦?你把我們的紅包都收了,我們還怎麼裝?」
父親 \(A\) 趕忙說:「有同學問起來我就會給你們的!我欠條都寫好了不會不算話的!」

至於懶標記:
如果父親 \(A\) 把錢給 \(B\)\(C\) ,接著。。。 \(A\) 就沒錢了。沒錯!懶標記就是故事中的記錄父親 \(A\) 沒給的錢賬本!

懶標記就是區間修改時偷懶用的,所以叫懶標記。
他主要解決了一個問題:如修改區間包含當前區間,就在這個節點上打個標記,表示這個點在以後的程式碼中要修改,現在先不修改。

懶標記的具體意思:我覺得 \(y\) 總的懶標記意思比較好:如果一個點上打的一個懶標記,那麼表示這個點的所有子節點都要變化這個懶標記(可以是加減乘除,\(max\),\(min\),\(gcd\)等等),注意!懶標記沒有包含當前點!

具體見圖:

我們給區間 \([1,2]\) 加上 \(DATA=1e18\) (圖左上角)
因為第一個節點包含了所有修改區間,所以我們把加的數掛在 第一個節點 上,區間修改就完成了。
當然,還有一個細節問題,就是如果一個點有懶標記,要先把他的懶標記下傳,就是把懶標記給他的兩個兒子,這就是我們要實現的 \(pushdown\) 函式。下面幾張圖片就展示了懶標記下傳的過程。

程式碼2

void pushdown (int u) { //下傳標記函式
    auto &root = tr[u],&left = tr[u << 1],&right = tr[u << 1 | 1];  //加了引用符號只要修改這個變數就會修改他被賦值的變數
    if (root.tag) {  //有懶標記才下傳
        left.tag += root.tag,left.sum += (LL)(left.r - left.l + 1) * root.tag;  //這裡的子節點要加上懶標記,因為這裡懶標記的意思是不包括當前節點
        right.tag += root.tag,right.sum += (LL)(right.r - right.l + 1) * root.tag;  //同理
        root.tag = 0;  //懶標記記得清零
    }
}
void modify (int u,int l,int r,int d) { //當前遍歷到的點下標是u,要將區間[l,r]增加d
    if (l <= tr[u].l && tr[u].r <= r) {
        tr[u].sum += (LL)(tr[u].r - tr[u].l + 1) * d;
        tr[u].tag += d;  //這裡我才用了y總的懶標記的意思,一個點所有的子節點都加上d,而前一行時加上根節點的數,因為不包括根節點。
        return ;
    }
    
    pushdown (u);  //一定要分裂,只要記牢在遞迴左右區間之前,就要分裂
    
    int mid = tr[u].l + tr[u].r >> 1;  //注意時tr[u].l和tr[u].r
    if (l <= mid) modify (u << 1,l,r,d);  //左邊有修改區間,就遞迴左半邊
    if (r >= mid + 1) modify (u << 1 | 1,l,r,d);  //右邊有修改區間,就遞迴右半邊
    
    pushup (u);  //要記得要把這個點的資訊更新一下
}

7.區間查詢

思路
區間修改類似區間修改,只不過變為查詢了。

程式碼

LL query(int u,int l,int r) {
    if (l <= tr[u].l && tr[u].r <= r) return tr[u].sum;
    pushdown (u);  //在遞迴之前一定要分裂

    int mid = l + r >> 1;
    LL sum = 0;
    if (l <= mid) sum += query_sum (u << 1,l,r);  //左半邊有被查詢到的資料,就遞迴左半邊
    if (r >= mid + 1) sum += query_sum (u << 1 | 1,l,r);  //右半邊有被查詢到的資料,就遞迴右半邊
    return sum;
}

時間複雜度 \(O(logn)\)

二、題單

(1)、單點修改

\(AcWing\) \(1275\). 最大數[單點修改區間查詢]

\(AcWing\) \(245\). 你能回答這些問題嗎[單點修改區間查詢]

\(AcWing\) \(246\). 區間最大公約數[單點修改區間查詢+差分+更相減損數]

(2)、區間修改

\(HDU\) \(1698\) \(Just\) \(a\) \(Hook\)[黃海補充+區間修改統一值+區間查詢]

\(HDU\) \(3577\) \(Fast\) \(Arrangement\) [黃海補充+線段樹+區間修改+維護最大值]

\(POJ\) \(3264\) \(Balanced\) \(Lineup\)[黃海補充+線段樹+維護最大值、最小值]

\(AcWing\) \(243\). 一個簡單的整數問題[區間增加一個值+區間查詢]

\(HDU\) \(3333\) \(Turing\) \(Tree\) [黃海補充+線段樹(樹狀陣列)+離線操作+數字去重求區間和]

\(POJ\) \(2828\) \(Buy\) \(Tickets\)[黃海補充+線段樹+二分]

(3)、掃描線法

\(AcWing\) \(1228\). 油漆面積[掃描線+線段樹,黃海補充]

\(AcWing\) \(247\). 亞特蘭蒂斯

\(AcWing\) \(1277\). 維護序列

(4)、線段樹優化建圖

Legacy CodeForces - 787D

(5)、線段樹維護樹上資訊

POJ 3321 Apple Tree [\(dfs\)序求子樹節點和]

據說線段樹還可以應用bfs序,目前還沒有找到合適的練習題,挖坑待填吧~

dfs序基礎講解(小白版)

(6)、線段樹維護區間可合併資訊

\(POJ\) \(2777\) \(Count\) \(Color\)

(7)、線段樹維護區間不可合併資訊(暴力計算)

\(GSS4\) - \(Can\) \(you\) \(answer\) \(these\) \(queries\) \(IV\) [暴力開方]

\(P4145\) 上帝造題的七分鐘 \(2\) / 花神遊歷各國
這個其實就是上面那道題,一模一樣,輸出有點差別而已。

\(CF438D\) \(The\) \(Child\) \(and\) \(Sequence\)[暴力取模]

(8)、線段樹維護最大子段和

\(GSS1\) - \(Can\) \(you\) \(answer\) \(these\) \(queries\) \(I\) [區間最大子段和]
\(GSS3\) - \(Can\) \(you\) \(answer\) \(these\) \(queries\) \(III\)[區間最大子段和+單點修改]
\(GSS5\) - \(Can\) \(you\) \(answer\) \(these\) \(queries\) \(V\)[有範圍限制的區間最大子段和]

(9)、線段樹與思維

\(HDU\) \(5649\) \(DZY\) \(Loves\) \(Sorting\)

(10)、線段樹分裂與合併

\(P5494\) 【模板】線段樹分裂

\(P4556\) [\(Vani\)有約會]雨天的尾巴 /【模板】線段樹合併

(11)、可持久化線段樹(主席樹)

推薦視訊

\(AcWing\) \(255\). 第\(K\)小數

\(HDU\) \(5280\) \(Lights\)

codevs 1080 (單點修改+區間查詢)
codevs 1081 (區間修改+單點查詢)
codevs 1082 (區間修改+區間查詢)
codevs 3981 (區間最大子段和)
Bzoj 3813  (區間內某個值是否出現過)
Luogu P2894 (區間連續一段空的長度)
codevs 2000 (區間最長上升子序列)
codevs 3044 (矩陣面積求並)
Hdu 1698 (區間染色+單次統計)
Poj 2777 (區間染色+批量統計)
Hdu 4419 (多色矩形面積並)
Poj 2761 (區間第K大)
Hdu 2305 (最值維護)