線段樹和樹狀數組筆記

線段樹和樹狀數組

一、線段樹

線段樹是什麼

首先線段樹是一棵平衡二叉樹,平常我們所指的線段樹都是指一維線段樹。故名思義, 線段樹能解決的是線段上的問題, 這個線段也可指區間。

線段樹支持三個操作:

build(start,end,vals) -> O(n)
update(index,value) -> O(logn)
query(start,end) -> O(logn)

線段樹的結構

一顆線段樹的構造就是根據區間的性質的來構造的, 如下是一棵區間[0, 4]的線段樹,每個[start, end]都是一個二叉樹中的節點。

            [0,4]
         /         \
      [0,2]       [3,4]
      /   \       /   \
   [0,1] [2,2] [3,3] [4,4]
   /   \      
[0,0] [1,1]

區間劃分大概就是上述的區間劃分。可以看出每次都將區間的長度一分爲二,數列長度爲n,所以線段樹的高度是log(n),這是很多高效操作的基礎。
上述的區間存儲的只是區間的左右邊界。我們可以將區間的最大值加入進來,也就是樹中的Node需要存儲left,right左右子節點外,還需要存儲start, end, val區間的範圍和區間內表示的值。

            [0,3]
           (val=4)
         /         \
     [0,1]         [2,3]
    (val=4)       (val=3)
    /    \         /    \
 [0,0]  [1,1]   [2,2]  [3,3]
(val=1)(val=4) (val=2)(val=3)

區間的第三維就是區間的最大值。加這一維的時候只需要在建完了左右區間之後,根據左右區間的最大值來更新當前區間的最大值即可,即當前子樹的最大值是左子樹的最大和右子樹的最大值裏面選出來的最大值。

// 節點區間定義
// [start, end] 代表節點的區間範圍
// max 是節點在(start,end)區間上的最大值
// left, right 是當前節點區間劃分之後的左右節點區間
class SegmentTreeNode {
public:
     int start, end, max;
     SegmentTreeNode *left, *right;
     SegmentTreeNode(int start, int end, int max, SegmentTreeNode *left, SegmentTreeNode *right) {
         this->start = start;
         this->end = end;
         this->max = max;
         this->left = left;
         this->right = right;
     }
};

因爲每次將區間的長度一分爲二,所有創造的節點個數,即底層有n個節點,那麼倒數第二次約n/2個節點,倒數第三次約n/4個節點,依次類推:

    n + 1/2 * n + 1/4 * n + 1/8 * n + ...
=   (1 + 1/2 + 1/4 + 1/8 + ...) * n
=   2n

所以構造線段樹的時間複雜度和空間複雜度都爲O(n)

線段樹的構建

給定一個區間,我們要維護線段樹中存在的區間中最大的值。這將有利於我們高效的查詢任何區間的最大值。給出A數組,基於A數組在線性時間內構建一棵維護最大值的線段樹。

segmentTreeNode* buildTree(int start, int end, vector<int> nums){
    if(start == end){
        return new segmentTreeNode(start,end,nums[start],NULL,NULL);
    }
    int mid = start + (end-start)/2;
    segmentTreeNode* left = buildTree(start,mid,nums);
    segmentTreeNode* right = buildTree(mid+1,end,nums);
    return new segmentTreeNode(start,end, max(left->sum,right->sum), left, right);
}

線段樹的區間查詢

1. 如何更好的查詢Query

構造線段樹的目的就是爲了更快的查詢。

給定一個區間,要求區間中最大的值。線段樹的區間查詢操作就是將當前區間分解爲較小的子區間,然後由子區間的最大值就可以快速得到需要查詢區間的最大值。

            [0,3]
           (val=4)
         /         \
     [0,1]         [2,3]
    (val=4)       (val=3)
    /    \         /    \
 [0,0]  [1,1]   [2,2]  [3,3]
(val=1)(val=4) (val=2)(val=3)

query(1,3) = max(query(1,1),query(2,3)) = max(4,3) = 4

上述例子將[1, 3]區間分爲了[1, 1][2, 3]兩個區間,因爲[1, 1]和[2, 3]存在於線段樹上,所以區間的最大值已經記錄好了,所以直接拿來用就可以了。所以拆分區間的目的是劃分成爲線段樹上已經存在的小線段

2. 如何拆分區間變成線段樹上有的小區間:

在線段樹的層數上考慮查詢 考慮長度爲8的序列構造成的線段樹區間[1, 8], 現在我們查詢區間[1, 7]。
圖片

第一層會查詢試圖查詢[1, 7], 發現區間不存在,然後根據mid位置拆分[1, 4]和[5, 7]
第二層會查詢[1, 4],[5, 7], 發現[1, 4]已經存在,返回即可,[5, 7]仍舊需要繼續拆分
第三層會查詢[5, 6],[7, 7], 發現[5, 6]已經存在,返回即可,[7, 7]仍舊需要繼續拆分
第四層會查詢[7, 7]

任意長度的線段,最多被拆分成logn條線段樹上存在的線段,所以查詢的時間複雜度爲O(log(n))

// 區間查詢的代碼及註釋
int query(TreeNode *root, int start, int end) {
    if (start == root->start && root->end == end) {
        return root->max;
    }
    int mid = (root->start + root->end) / 2;
    
    if (end <= mid) {   
        return query(root->left,start, end);
    }else if (start >= mid + 1) { 
        return query(root->right,start, end);
    }else{
        return max(query(root->right,start, mid),query(root->right,mid+1, end));
    }
}

 

線段樹的單點更新 

更新序列中的一個點

            [0,3]
           (val=4)
         /         \
     [0,1]         [2,3]
    (val=4)       (val=3)
    /    \         /    \
 [0,0]  [1,1]   [2,2]  [3,3]
(val=1)(val=4) (val=2)(val=3)

更新序列中的一個節點,如何把這種變化體現到線段樹中去,例如,將序列中的第4個點A[3]更新爲5, 要變動3個區間中的值,分別爲[3,3],[2,3],[0,3]

提問:爲什麼需要更新這三個區間?:因爲只有這三個在線段樹中的區間,覆蓋了3這個點。

            [0,3]
           (val=5)
         /         \
     [0,1]         [2,3]
    (val=4)       (val=5)
    /    \         /    \
 [0,0]  [1,1]   [2,2]  [3,3]
(val=1)(val=4) (val=2)(val=5)

可以這樣想,改動一個節點,與這個節點對應的葉子節點需要變動。因爲葉子節點的值的改變可能影響到父親節點,然後葉子節點的父親節點也可能需要變動。

更新所以需要從葉子節點一路走到根節點, 去更新線段樹上的值。因爲線段樹的高度爲log(n),所以更新序列中一個節點的複雜度爲log(n)。

因爲每次從父節點走到子節點的時候,區間都是一分爲二,那麼我們要修改index的時候,我們從root出發,判斷index會落在左邊還是右邊,然後繼續遞歸,這樣就可以很容易從根節點走到葉子節點,然後更新葉子節點的值,遞歸返回前不斷更新每個節點的最大值即可。具體代碼實現如下:

// 單點更新的代碼及註釋
void update(SegmentTreeNode *root, int index, int value) {
    // write your code here
    if(root->start == root->end && root->start == index) { // 找到被改動的葉子節點
        root->max = value; // 改變value值
        return;
    }
    int mid = (root->start + root->end) / 2; // 將當前節點區間分割爲2個區間的分割線
    if(index <= mid){ // 如果index在當前節點的左邊
        modify(root->left, index, value); // 遞歸操作
    }else{            // 如果index在當前節點的右邊
        modify(root->right, index, value); // 遞歸操作
    }
    root->max = max(root->right->max, root->left->max); // 可能對當前節點的影響
    return;
}

如果需要區間的最小值或者區間的和,構造的時候同理。

 

總結 - 線段樹問題解決的框架

通過前面問題的分析,我們對線段樹問題可以做如下總結:

  • 如果問題帶有區間操作,或者可以轉化成區間操作,可以嘗試往線段樹方向考慮
  • 從面試官給的題目中抽象問題,將問題轉化成一列區間操作,注意這步很關鍵

當我們分析出問題是一些列區間操作的時候

  • 對區間的一個點的值進行修改
  • 對區間的一段值進行統一的修改
  • 詢問區間的和
  • 詢問區間的最大值、最小值

什麼情況下,無法使用線段樹?

  • 如果我們刪除或者增加區間中的元素,那麼區間的大小將發生變化,此時是無法使用線段樹解決這種問題的。

 

二、樹狀數組

數組在物理空間上是連續的,而樹是通過父子關係關聯起來的,而樹狀數組正是這兩種關係的結合,首先在存儲空間上它是以數組的形式存儲的,即下標連續;其次,對於兩個數組下標x,y(x < y),如果x + 2^k = y (k等於x的二進制表示中末尾0的個數),那麼定義(y, x)爲一組樹上的父子關係,其中y爲父結點,x爲子結點。

我們定義C[i]的值爲它的所有葉子結點的權值的總和。根據上述邏輯結構和思想,可以寫出C[i]的表達式,C[i]=A[i]+A[i-1]+....+A[i-2^k+1]。k代表i的二進制的最後連續0的個數。

其實C[i]還有一種更加普適的定義,它表示的其實是一段原數組A的連續區間和。區間的左端點爲i - 2^k + 1,右端點爲i。

構建樹狀數組,實則就是初始化C數組。對於C數組,我們知道,下標爲i的Ci,在樹形邏輯結構中,它的父親是i + 2^k = y,而它父親的父親則是y + 2^ k' = m 一直到超出數據範圍爲止。也就是說,原本的Ai,只會影響Ci及Ci的祖先。

基於“前綴和”信息來實現:

  • Log(n)修改任意位置值
  • Log(n)查詢任意區間和

功能特性:

對於一個有N個數的數組,支持如下功能:

update(index, delta) -> O(logN) 
query(k) -> O(logK) 

實現特性:

雖然名字叫做Tree,但是是用數組(Array)存儲的

BIT是一棵多叉樹,父子關係代表包含關係

BIT的第0位空出來,沒有用上

如何求前綴和?

由前面可知,C[i] = A[i - 2^k + 1] + A[i - 2^k + 2] +... + A[i],所以

sum(i) = A[1] + A[2] + ... + A[i] 
       = A[1] + A[2] + ... + A[i - 2^k] + A[i - 2^k + 1] + ... + A[i]
       = A[1] + A[2] + ... + A[i - 2^k] + C[i]
       = sum(i - 2^k) + C[i]
       = sum(i - lowbit(i)) + C[i]

 lowbit operation:

// lowbit opeation returns the first 1 bit from the right in binary representation
// which is equivalent to doing 2^k where k is number of tailing 0s
lowbit(x) = x & (-x);

如何更新?

當A[i]被更新了,那些BIT中的數會受到影響?

 

 

BIT的兩個操作總結

update(x,delta)

  • delta = val - A[x] 也就是增量
  • 從x開始,不斷的將 BIT[x] += delta,然後 x = x + lowbit(x) while x <= n

query(x)

  • 不斷的做 x = x - lowbit(x) while x > 0
// binary index tree array index start from 1
// the index passed from the outside function call should +1
class BIT{
private:
    vector<int> sums;
    int lowbit(int x)
        return x & (-x);

public:
    BIT(n):sums(n+1,0){}

    void update(int index, int delta){  
        for(int i = index; i <= sums.size(); i += lowbit(i))
            sums[i] += delta; 
    }

    int query(int index){
        int sum = 0;
        for(int i = index; i > 0; i -= lowbit(i))
            sum += sums[i];
        return sum;
    }
}

樹狀數組總結

Binary Indexed Tree 事實上就是一個有部分區段累加和數組

首先我們必須明確的事情是,樹狀數組只能維護前綴(前綴和,前綴積,前綴最大最小),而線段樹可以維護區間。我們求區間和,是用兩個前綴和相減得到的,而區間最大最小值是無法用樹狀數組求得的。(經過一些修改處理,也可以處理)

所以樹狀數組可以針對的題目是:
1.如果問題帶有區間和,或者可以轉化成區間和問題,可以嘗試往樹狀數組方向考慮

  • 從面試官給的題目中抽象問題,將問題轉化成一列區間操作,注意這步很關鍵

2.當我們分析出問題是一個區間和的問題時,如果有以下特徵:

  • 對區間的單個元素進行修改操作
  • 對區間進行求和操作

3.我們就可以套用經典的樹狀數組模型進行求解

樹狀數組劣勢

  1. 線段樹無法處理的問題樹狀數組也無法處理。
  2. 對於區間最大值這類無法通過兩個區間相減操作得到解答的問題,樹狀數組一般也無法處理。(即經過一些修改處理,也可以處理)

樹狀數組優勢

  1. 更快(樹狀數組運用位運算、不遞歸)
  2. 更少空間(樹狀數組只有O(n)的空間消耗,線段樹根據寫法和實現不同空間消耗也有所不同,但都大於樹狀數組的消耗)

 

樹狀數組和線段樹處理問題比較
  樹狀數組 線段樹
區間和 O(logN) O(logN)
區間最大值/最小值 N/A O(logN)
所有數最大值/最小值 O(logN) 取值O(1) 更新O(logN)
比某個數大的最小值 N/A O(logN)
比某個數小的最大值 N/A O(logN)

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章