1. 程式人生 > 實用技巧 >#筆記 線段樹

#筆記 線段樹

線段樹與樹狀陣列 3

一、簡單複習

線段樹

線段樹是一個二叉樹結構。其核心函式為 FindRange 函式,基本寫法如下:

function FindRange(L, R):
  if (InRange(L, R)):
    do sth.
    return
  else if (OutofRange(L, R)):
    return
  else:
    ls->FindRange(L, R)
    rs->FindRange(L, R)

do sth 的時間複雜度為 \(O(1)\),則上述函式的時間複雜度為 \(O(\log n)\),其中 \(n\) 是序列長度。在初等階段,線段樹的所有操作的本質都是 FindRange 函式,操作的複雜度正確性也都來源於上述結論。

線段樹的總結點數不超過 \(2n - 1\)。即如果單點資訊量為 \(O(1)\),則其空間複雜度為 \(O(n)\)

樹狀陣列

樹狀陣列是一個用來維護序列字首和,並支援進行單點加的資料結構。其主要寫法為:

function lowbit(x):
  return x & -x

function upd(p, w):
  while p <= n:
    a[p] += w
    p += lowbit(p)

function qry(p):
  ret = 0
  while p:
    ret += a[p]
    p -= lowbit(p)
  return ret

對於高維度的字首和,樹狀陣列只需要在外面多套幾層迴圈即可。

動態開點線段樹

動態開點線段樹的思想在於,當序列長度過長,但是有效操作過少時,線段樹上實際被用到/修改到的結點個數是比較少的,如果把樹完整的建出來,會消耗大量的空間,並且產生大量無用的空結點。為了避免這個問題,可以初始時只建立樹根。在 FindRange 執行過程中,如果需要用到子節點,並且子節點不存在,再新建子節點。這樣的空間複雜度為 \(O(\min(n, m \log n))\),其中 \(m\) 是操作個數,\(n\) 是序列長度,\(\min\) 的第一項帶有二倍常數。可以發現,動態開點線段樹的時空複雜度在任何情況下必定不劣於普通線段樹。但是因為判定較多並且記憶體不連續,在 \(m \log n \geq n\)

時,其時間常數可能略大於普通線段樹。

如果有 pushdown 函式的話,開子節點的操作可以在 pushdown 中完成,否則需要在遞迴孩子時進行判斷。

可持久化線段樹

對於一個數列,有若干次操作,每次操作會對數列進行單點修改,稱為新建一個版本。可以用可持久化線段樹來維護數列的每個版本。具體來說,注意到每個版本相對於上一個版本都只有一個位置不同,對應到線段樹上,則一個版本的線段樹只會有一個葉節點到根的鏈上的資訊與上個版本不一致,其他版本完全相同,因此每次新建一個版本時,對於需要被修改的子樹,遞迴新建,對於另一側的子樹則直接用指標指到上一個版本的對應孩子。因為任何一個葉節點到根的路徑長度都是 \(O(\log n)\),所以每次只會新建 \(O(\log n)\) 個結點。因此其時空複雜度均為 \(O(m \log n + n)\),其中 \(m\) 是操作個數,\(n\) 是序列長度。注意第二項有兩倍常數,實際記憶體池大小應該開 \(m \log n + 2n\)

二、有關 FindRange 時間複雜度的證明

注意到線上段樹上的任何一層的所有結點執行 FindRange 函式,最多僅有兩個節點同時不滿足 InRange 和 OutofRange,即兩個包括了操作左右端點 \(L, R\) 的結點。剩下的結點顯然要麼滿足 InRange,要麼滿足 OutofRange,

因此對於線段樹上的每一層,最多有兩個結點向下新建遞迴函式,而線段樹上的總層數為 \(O(\log n)\),因此只會新建 \(O(\log n)\) 個函式。因此 FindRange 函式的時間複雜度為 \(O(\log n)\)。通過上述分析可以發現,因為每個節點向下新建的是兩個遞迴函式,所以 FindRange 操作實際上帶有兩倍常數。

三、樹狀陣列與線段樹的對比

樹狀陣列的功能被線段樹完全包含。在我所知道的範圍內,樹狀陣列能夠完成的操作,線段樹一定可以以不劣於樹狀陣列的時空複雜度完成。

簡單起見,我們這裡分析的線段樹是進行動態開點的一般線段樹。

時間常數

顯然樹狀陣列的單次查詢時間複雜度為 \(O(p_1)\),修改的時間複雜度為 \(O(p_0)\) ,其中 \(p\) 是修改/查詢的位置,\(p_0, p_1\) 分別表示二進位制下 \(0,1\) 的個數。當 \(p\)\([1, n]\) 範圍內均勻隨機時,可以認為 \(p_0 = p_1 = \frac 1 2 \log n\)。因此雖然樹狀陣列的時間複雜度仍然為 \(O(\log n)\),不過隨機意義下其常數為 \(\frac 1 2\)。當 \(p\) 不是隨機值而是構造值時,可以給 \(p\) 加上一個恆定偏移量。即自行取一個與 \(n\) 同階的常數 \(x\),將所有操作的 \(p\) 都改為 \(p + x\)。可以證明,此時期望 \(p_0 = p_1 = \frac 1 2 \log n\)。需要指出的是,如果對於所有的操作位置,所加的偏移量相同,雖然其期望常數為 \(\frac 1 2\),但是顯然極差極大(因為如果 \(x\) 選崩了可能毫無效果),因此可以對於每個操作位置,獨立選擇一個偏移量 \(x\)。只需要保證操作位置加上偏移量後大小關係不變即可。

需要指出的是,上述分析都是理論情況,一般情況下,樹狀陣列不需要實現上述優化。

因此,樹狀陣列總存在 \(\frac 1 2\) 的常數。與之相比,線段樹無論是建樹還是 FindRange 函式,都帶有兩倍常數。因此在不考慮其他操作的情況下,線段樹的常數是樹狀陣列的四倍。事實上在實現時,由於線段樹存在大量的遞迴呼叫,並且記憶體訪問不連續,其時間常數會更大。而樹狀陣列因為記憶體訪問連續,對 cache 比較友好,其時間常數會更小。

當樹狀陣列進行區間查詢時,由於需要進行一次字首相減操作,也即一次查詢進行兩次操作,看起來二分之一常數會被抵消,但是事實上有一些奇技淫巧可以降低該操作的常數。具體可以看這篇日報

綜上分析,樹狀陣列的時間常數優於線段樹,且優勢比較明顯。

空間常數

線段樹的節點數為 \(2n - 1\),而樹狀陣列的節點數為 \(n\)。在節點數上,樹狀陣列優於線段樹。

由於線段樹每個節點還要儲存當前節點的左右端點,左右孩子等資訊,其實際空間常數更大。而樹狀陣列對於每個節點只需要維護結點對應的權值和,空間常數小於線段樹。

綜上分析,樹狀陣列的空間常數優於線段樹。

題外話:如果線段樹被卡空間了,可以考慮不在節點裡面維護區間左右端點,而是遞迴時作為引數進行遞迴。這樣雖然會增大時間常數,但是可以節約 \(4n\) 個資訊。

程式碼複雜度

肉眼可見,樹狀陣列的碼量遠小於線段樹。

侷限性

在一般情況下,只進行字首查詢與單點修改的樹狀陣列必須滿足資訊具有可減性。例如,字首最大值無法使用樹狀陣列維護。因為把一個結點的值改小以後,無法撤銷原值對後面結點造成的貢獻。特殊情況是,如果對每個位置的修改都保證新值不小於原值,則可以使用樹狀陣列維護最大值,因為原值不會再對後面節點產生貢獻。

進行區間查詢與單點加(這裡的加是廣義加,指無需撤銷原資訊對後面節點造成的貢獻的情況,例如在保證新值不小於原值時用樹狀陣列維護最大值)的樹狀陣列必須滿足資訊的字首可減性。例如,即使每個位置的修改都越改越大,也無法使用樹狀陣列維護區間最大值。因為樹狀陣列維護的是字首和(這裡和指 \(\max\)),但是 \([l, r]\) 的最大值無法通過 \([1, r]\) 最大值與 \([1, l)\) 的最大值得到。樹狀陣列能處理的比如進行單點修改和區間求和。因為 \(sum_{l, r} = sum_{1, r} - sum_{1, l - 1}\)

而線段樹不受上述限制。只要被維護的資訊滿足結合律(即可以通過兩個孩子的資訊合併成父節點的資訊),就可以進行區間查詢。如果只進行單點查詢的話,甚至不需要資訊滿足結合律(因為單點查詢一定會在葉結點處返回有效資訊,而不需要把資訊合併到非葉節點,一個典型的例子是可持久化線段樹的模板題只有葉子上維護了資訊,不需要寫 pushup)。

樹狀陣列只能處理單點修改和字首查詢的操作,在特殊的情況下(即其字首和有可減性時),才能支援單點修改與區間查詢。在另一種特殊的情況下(即序列可差分)在能進行區間加和單調查詢。在更加特殊的情況下,才能進行區間修改與區間查詢(這種情況本質上是討論一種修改對一個查詢的貢獻,沒有了解的必要。如果想要了解可以看這個連結)。

而線段樹不受上述限制。只要可以區間打標記就可以區間修改,只要資訊可合併就可以進行區間查詢,且二者獨立互不影響,在二者都成立時,可以同時進行區間修改和區間查詢。

綜上分析,線段樹的普適性高於樹狀陣列。樹狀陣列在使用時存在各種侷限。

動態開點

這裡的動態開點是指對於運算元 \(m\) 和序列長度 \(n\)\(m \log n \lt n\),即動態開點有意義時的討論

線段樹可以輕鬆做到動態開點,並且時間常數雖然略有增加,但是不會太大。

樹狀陣列的動態開點需要藉助 hash 進行。具體的方式將在下文討論。經過筆者親身測驗,使用 hash/std::unordered_map 維護的樹狀陣列,其常數遠大於動態開點線段樹。並且因為需要實現 hash,並且要對查詢修改進行更改,所以其碼量並不會比線段樹小多少。

但是在實現樹套樹時,線段樹需要實現兩顆線段樹(一般是外層普通線段樹,內層動態開點線段樹,外層序列樹,內層權值樹)。此時其碼量極其驚人(因為要實現兩棵樹之間相互影響的介面,其碼量遠大於分寫兩棵獨立線段樹),而樹狀陣列只需要多加一個 for 迴圈(即二維樹狀陣列),此時的碼量是遠小於線段樹套線段樹的。如果比賽時時間仍然不夠,可以直接用 std::map 代替陣列,這樣時間上又多了一個 \(\log\),但是碼量和一個普通樹狀陣列無異。

需要說明的是,在實現樹套樹時,動態開點二維樹狀陣列的常數表現奇差。通常只能跑 \(n = 3 \times 10^4\) 左右的資料範圍。而線段樹套線段樹可以輕鬆跑過 \(10^5\)

綜上分析,在一維情況下,動態開點線段樹優於樹狀陣列,但是在更高維度時,動態開點樹狀陣列的碼量佔有明顯優勢,但是得分偏低。

可持久化

因為線段樹的優秀結構,可以輕鬆進行可持久化。

樹狀陣列的本質還是一個數組,因此對樹狀陣列進行可持久化,相當於對樹狀陣列維護資訊的陣列進行可持久化,這樣還是要通過可持久化線段樹來維護可持久化陣列。並且因為一次操作新建了 \(O(\log n)\) 個版本,所以其時空複雜度都比直接使用可持久化線段樹維護序列多一個 \(\log n\)

綜上分析,線段樹的可持久化完全優越於樹狀陣列的可持久化。從某種意義上,樹狀陣列無法可持久化。

總結

對比二者,線段樹的普適性更高,但是對於樹狀陣列擅長維護的資訊,樹狀陣列在各方面的表現都優於線段樹。因此在資訊可以使用樹狀陣列維護時,應該優先選擇樹狀陣列。

四、樹狀陣列高維離散化(動態開點)

一個結論

一個顯然的結論是,如果我們保證只在需要對某個記憶體進行修改時,再動態開闢這塊記憶體,那麼一個程式的時間複雜度顯然不會低於空間複雜度,因為至少有開闢了所有空間的時間複雜度,光這部分複雜度就不低於空間複雜度。

反過來說,如果我們保證每次只開闢被修改的記憶體,那麼程式的空間複雜度就不會高於時間複雜度,在時間複雜度符合要求時,一般如果空間複雜度與之相等則也能符合要求。因此這樣的動態開點都是在避免開闢不被修改的記憶體。

一個複雜度比較劣的做法

考慮一個長度為 \(n\) 的序列,初始時全為 \(0\),要求進行 \(m\) 次單點加和字首求和操作。其中 \(m \log n \lt n\),空間上不允許開出大小為 \(n\) 的陣列。

注意到對於一次修改操作,我們只修改了 \(O(\log n)\) 個位置。因此實際上,即使 \(m\) 次操作全是查詢,也只有 \(O(m \log n)\) 個位置的值被修改了,其餘位置都是 \(0\)

我們可以先空跑一邊樹狀陣列,即只進行 for 迴圈不進行修改,然後記錄下所有被訪問到的下標,把這些下標拿出來,進行離散化。設 \(T = m \log n\),則離散化的複雜度為 \(O(T \log T)\),確定一個下標離散化後的值可以通過 std::lower_bound 做到 \(O(\log T)\)。在樹狀陣列執行過程中,一共需要確定 \(T\) 個結點的離散化值。因此這樣做的時間複雜度為 \(O(T \log T)\),即 \((m \log n \log(m \log n))\)。因為保證了 \(m \log n \lt n\),因此時間複雜度為 \(O(m \log^2n)\)。其空間複雜度為 \(O(m \log n)\)

顯然如果不保證 \(m \log n \lt n\),不考慮離散化時,其空間複雜度為 \(O(\min(m \log n, n))\)

優化複雜度

考慮時間瓶頸在於離散化。我們進行離散化的本質是把一個數對映成了另一個數。考慮我們需要一個時間複雜度更優秀的能夠維護對映的演算法,這讓我們想到了 hash。對所有被訪問到的位置進行 hash,其期望複雜度即可降為 \(O(m \log n)\)。如果不會 hash 的話,可以讓 CYC 講一講。

需要指出的是,經過筆者實測,在為了保證效率而選擇較大的 hash 模數時,這個演算法的空間開銷極大。而如果為了節約空間而縮小模數規模,則其實際執行效率非常緩慢,且碼量與線段樹沒有明顯差別,使用需謹慎。

升維

我們發現,上面的演算法可以輕鬆擴充套件到高維樹狀陣列的情況。具體的,對於 \(k\) 維樹狀陣列,實際呼叫的空間只有 \(O(m \log^k n)\) 個位置,其中 \(n\) 為每一維的大小。同樣的把這些位置拿出來進行離散化,可以做到 \(O(m \log^k n)\) 的空間和 \(O(m \log^{k + 1} n)\) 的時間,而用 hash 代替離散化可以做到 \(O(m \log ^k n)\) 的時空。

需要指出的是,在演算法競賽的範圍內,當 \(k \geq 2\) 時,因為常數較大,\(O(m \log^{k + 1} n)\) 的實際效率可能不如直接暴力,因此期望得分甚至可能不如暴力。而 \(O(m \log^k n)\) 的演算法的實際執行時空效率也非常差,只適用於在比賽時間不夠用時 rush 一個好寫的樹套樹作為騙分,不適合作為正解。

說明

還需要指出的是,對於這一部分內容,結合目前各位的水平以及所接觸到的比賽,只需要各位大概瞭解思想(這裡包括只開闢有用空間的思想與用高速對映(hash)代替一般對映(離散化)的思想),不需要紮實掌握,更不需要做相關題目。

五、關於結構體建構函式的一些補充說明

所有結構體都有至少一個建構函式,當不顯式的實現任何建構函式時,編譯器會自動補全一個沒有任何引數的空函式作為其建構函式。對於直接宣告的結構體變數,都會呼叫這個沒有引數也沒有內容的空函式。

當你實現了任何一個建構函式時(比如 Node(const int L, const int R)),則編譯器不會再自動補全無引數的建構函式。在此時如果直接宣告一個結構體變數,會 CE 報錯,因為找不到對應的建構函式。此時需要再手動宣告一個引數為空的建構函式。

例如,下述程式碼會 CE:

struct Node {
  int OvO;
  
  Node(const int a) : Ovo(a) {}
};
Node Fusu[maxn];

下述兩個程式碼不會 CE:

struct Node {
  int OvO;
};
Node Fusu[maxn];
struct Node {
  int OvO;

  Node() {}  
  Node(const int a) : Ovo(a) {}
};
Node Fusu[maxn];

如果對於第五部分還有疑問,可以去詢問 jzk。