【講●解】超全面的線段樹:從入門到入墳


\(Pre\):其實線段樹已經學了很久了,突然想到線段樹這個數據結構比較重要吧,想寫篇全面的總結,幫助自己復習,同時造福廣大\(Oier\)雖然線段樹的思維難度並不高)。本篇立志做一篇最淺顯易懂,最全面的線段樹講解,采用\(lyd\)寫的《算法競賽進階指南》上的順序,從最基礎的線段樹到較深入的主席樹,本篇均會涉及,並且附有一定量的習題,以后可能會持續更新,那么現在開始吧!

關於作者,,,他咕了。。。。


目錄一覽

  • 更新日志
  • 線段樹想\(AC\)之基本原理(霧*1
  • 線段樹想偷懶之懶標記(霧*2
  • 線段樹想應用之掃描線(霧*3
  • 線段樹想瘦身之開點與合並(霧*4
  • 線段樹想持久之主席樹(霧*5
  • 線段樹想帶修之樹套樹(霧*6
  • 線段樹想...不,你不想

更新日志

5.19 update:懶標記20%完成。
5.12 update:添加題目鏈接,然后頹去了
5.11 update:修改部分字詞,基本原理基本完成,大綱完成。
5.4 update:基本原理20%完成。

線段樹想\(AC\)之基本原理

什么是線段樹啊?

首先,你得有的基本知識。

然后。

以下內容摘自百度百科

線段樹是一種二叉搜索樹,與區間樹相似,它將一個區間划分成一些單元區間,每個單元區間對應線段樹的一個結點。

很懵?沒關系,我們繼續。

其實,線段樹(\(Segment\) \(Tree\))是一種基於分治思想的二叉樹結構,(Q1:為什么一定是二叉?)如果你學過樹狀數組,你會清楚地知道兩者的差異性,並且隨着學習的深入,你會發現線段樹是一種更為通用的數據結構。

可以說,只要是能滿足區間可加性(也就是大區間的信息能由它的兩個子區間整理得到)的操作,大都可以用線段樹解決。有時可能會有一些奇奇怪怪的操作。。。

最基本的線段樹包含以下幾個概念:

  1. 線段樹每個節點表示一個區間
  2. 線段樹的唯一根節點表示整個區間統計范圍,如[\(1,N\)]。
  3. 線段樹的每個葉節點表示一個長度為\(1\)的元區間,如[\(x,x\)]。
  4. 線段樹上的每個節點[\(l,r\)],它的左子節點是[\(l,mid\)],右子節點是[\(mid+1,r\)],其中\(mid=(l+r)/2\)(這是線段樹的標准寫法,也有其他不同的寫法,但作為初學者,還是從標准入手好)。

如圖,這就是一棵線段樹。我們可以發現,當整個區間統計長度為\(2\)的整數次冪時,整棵線段樹一定是一棵完全二叉樹(Q2:為什么),那我們就可以用堆的編號方法來給線段樹來編號啊(其實圖中已經編好了)。

即:

  1. 根節點編號為\(1\)
  2. 編號為\(x\)的節點,它的左兒子編號為\(x*2\),右兒子編號為\(x*2+1\)

這樣,我們就可以用一個數組來存所有節點的編號了!
至於正確性,,,既然你都學到線段樹了,那就不用我說了吧。。。

誒等等,那萬一整個區間長度不是\(2\)的整數次冪呢?

看這張圖!

可以驚訝地發現,我們同樣可以使用父子二倍標記法。正確性顯然,只不過,正是因為這種情況,所以樹的最后一層節點編號在數組中的位置可能不是連續的。

如果區間長度為\(N\),在最理想的狀況下,即\(N\)\(2\)的整數次冪時,\(N\)個葉節點的滿二叉樹有\(N+N/2+N/4+...+1=2N-1\)個節點。只要不是這種情況,那就還有一層,所以我們保存線段樹節點編號的數組長度要大於等於\(4N\)

於是線段樹信息儲存如下:

struct SegmentTree {
    int l, r;//每個區間左右端點
    int dat;//區間數據
    //其他一些附加信息
}sak[4*MAX];

當然,線段樹的寫法多種多樣,這是最穩的一種,還有一種是記錄左右兒子編號的,后面我們再說,\(zkw\)線段樹就不介紹了吧。。。

建樹

我們需要從根節點“\(1\)”出發,向下遞歸建樹,並把每個節點所代表的區間賦給它。當到達了根節點,便傳值,再向上維護信息。

以維護區間和為例,我們可以這樣建樹:

inline void build(int p, int l, int r) {
    sak[p].l = l, sak[p].r = r;
    if (l == r) {//葉節點賦值
        sak[p].sum = a[l];
        return;
    }
    int mid = (l + r) / 2;
    build(2*p, l, mid);//遞歸建左兒子樹
    build(2*p + 1, mid + 1, r);//遞歸建右兒子樹
    sak[p].sum = sak[2*p].sum + sak[2*p + 1].sum;//向上傳遞區間和的信息
}

單點修改

顯然,每次操作,我們都需要從根節點開始遍歷,遞歸找到需要修改的葉子節點,然后修改,然后向上傳遞信息。(Q3:正確性)

inline void change(int p, int x, int val) {
    if (sak[p].l == sak[p].r) { sak[p].sum = val; return; }//找到x位置
    int mid = (l + r) / 2;
    if (x <= mid) change(p*2, x, val)
    else change(p*2+1, x, val);
    sak[p].sum = sak[2*p].sum + sak[2*p + 1].sum;//向上傳遞區間和的信息   

因為整棵樹的深度是\(logN\),所以單次修改的時間復雜度為\(O(logN)\)

區間查詢

這里直接給出算法過程,正確性顯然。

  1. 若當前節點所表示的區間已經被詢問區間所完全覆蓋,則立即回溯,並傳回該點的信息。
  2. 若當前節點的左兒子所表示的區間已經被詢問區間所完全覆蓋,就遞歸訪問它的左兒子。
  3. 若當前節點的右兒子所表示的區間已經被詢問區間所完全覆蓋,就遞歸訪問它的右兒子。

以返回區間和為例:

inline ll ask(int p, int l, int r) {
    if (l <= sak[p].l && r >= sak[p].r) {//對應1操作
        return sak[p].sum;
    }
    pushdown(p);
    ll val = 0;
    int mid = (sak[p].l + sak[p].r) / 2;
    if (l <= mid) val += ask(2*p, l, r);//對應2操作
    if (r > mid) val += ask(2*p + 1, l, r);//對應3操作
    return val;	
} 

【例題】Can you answer on these queries 3

需要你提供一種數據結構使之能夠查詢區間最大連續子段和,並且支持單點修改。

讓我們來分析一下,在區間上進行操作自然而然可以想到樹狀數組或者是線段樹。這里單點修改好辦,難就難在查詢上。

想一想怎么辦?或者說,我們如何整理子區間的信息?

仔細思考后,我們會發現,一個區間上的連續最大和只有幾種情況:

  1. 連續最大和的區間只在左兒子所對應的區間上。
  2. 連續最大和的區間只在右兒子所對應的區間上。
  3. 連續最大和的區間橫跨左右兒子的區間。

\(1\)\(2\)這兩種情況好弄,直接繼承取最值,可情況\(3\)呢?

除了維護區間和,區間最大連續子段和,我們還需維護緊靠左端的最大連續子段和,以及緊靠右端的最大連續子段和。

於是每次更新時,我們就可以用子區間的信息來更新當前區間了。

具體代碼其他的博客也有介紹,但最好自己想一想,我就不打了,因為懶,思維懂了,代碼就來了。

【習題】Interval GCD

題目大意:需要提供一種數據結構使之能夠查詢區間gcd,並且支持區間加法

題解:咕咕咕中。。。

Q&A:

  • A1:既然你都看到這里了,就不用我說了吧。
  • A2:畫個圖再\(YY\)一下,無需多說。
  • A3:修改的節點只包含在遞歸時經過的區間中,所以只會對遞歸時經過的區間產生影響。

線段樹想偷懶之懶標記

【引題】A Simple Problem with Intergers

就是叫你實現區間修改,區間查詢嘛。

考慮之前講到的線段樹。如果用線段樹的單點修改,我們需要先改變葉子節點的值,然后不斷地向上遞歸修改祖先節點直至到達根節點,時間復雜度最高可以到達\(O(nlogn)\)的級別,這還是單次操作,更別說有\(10^5\)次指令了。。。

現在思考,該怎么辦呢?

我們想,如果已經到達了屬於答案區間范圍內的節點,我們就直接對該節點進行一系列的操作,然后直接返回。這樣,一定能保證本次區間更新的正確性。(很顯然啊),可我們知道,區間更新不只一次,如果照剛剛那樣更新而不進行任何后處理的話,那么該節點的子節點都未更新,勢必會導致答案錯誤。於是,我們需要一種東西來記錄下節點的更新信息,以便下次更新時處理。

我們考慮引入一個名叫\(lazytag\)(懶標記)的東西——之所以稱其為\(lazytag\),是因為當我們引入懶標記后,我們不會去更新已經覆蓋答案區間的子節點,只有在接下來的操作中我們才可能會用到該區間的子區間。所以這次操作就無需更新。區間更新的期望復雜度就降到了\(O(logn)\)的級別。(感性理解下)

那如何實現呢?

我們思考前面大家做題遇到的\(pushup\),(沒用過的可以去刷刷前面的題了)它的實質是在線段樹中向上傳遞信息,放在遞歸之后,那我們要做到\(pushdown\),不就是在線段樹中從上往下傳遞信息嗎?那就把\(pushdown\)直接放在每次遞歸之前就行了啊。

於是,我們就可以如此標記。

inline void change(int p, int l, int r, int d) {
    if (l <= sak[p].l && r >= sak[p].r) {
        sak[p].sum += (ll)d*(sak[p].r - sak[p].l+1);
        sak[p].add += d;
        return;
    }
    pushdown(p);
    int mid = (sak[p].l + sak[p].r) / 2;
    if (l <= mid) change(2*p, l, r, d);
    if (r > mid) change(2*p + 1, l, r, d);
    sak[p].sum = sak[2*p].sum + sak[2*p + 1].sum;	
}

順着代碼理思路也是種不錯的選擇。

咕咕咕中。。。

【習題】[LG P3373] 線段樹 2

【習題】[雅禮集訓2017] 市場

Q&A:

線段樹想應用之掃描線

【引題】Atlantis

【例題】Stars in Your Window

Q&A:

線段樹想瘦身之開點與合並

【例題】Promotion Counting

Q&A:

線段樹想持久之主席樹

【例題】K-th Number

Q&A:

線段樹想帶修之樹套樹

【引題】【模板】二逼平衡樹

Q&A:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM