線段樹(毒瘤)總結

HISKrrr發表於2020-06-27

我們在這篇部落格裡將具體介紹一種超級毒瘤超級高效的演算法
線段樹


概念引入

首先來認識一下線段樹
什麼是線段樹呢:
線段樹是一種二叉樹,也就是對於一個線段,我們會用一個二叉樹來表示。比如說一個長度為6的線段,我們可以表示成這樣

這個圖是什麼意思呢?

  • 將這個做成一個樹的結構 每個根節點儲存左右兩個節點的權值之和
    舉個例子:最上邊的線段表示1~6的和 而他的左兒子表示1~3的和 右兒子表示4~6的和
  • 然後他左兒子的左兒子又表示1~2的和 左兒子的右兒子表示3的權值
  • 因此 節點i的權值=i左兒子的權值+i右兒子的權值
  • 所以我們可以得到 tree[rt].sum = tree[l].sum + tree[r].sum
  • 根據這個原理 我們就可以進行遞迴建樹了
struct node{
      int l,r,sum;//l表示左兒子 r表示右兒子  sum表示當前節點儲存的權值
}tree[maxn*4];

void build(int i,int l,int r){
	tree[i].l = l;tree[i].r = r;
	if(l == r){
		tree[i].sum = a[l];//a陣列儲存給出的陣列初始值
		return;
	}
	int mid = (l+r)/2;
	build(i*2,l,mid);
	build(i*2+1,mid+1,r);
	tree[i].sum = tree[i*2].sum+tree[i*2+1].sum;
	return;
}

這就是線段樹的建樹方法 如果你要問為什麼我們要花好幾倍的記憶體去建樹來完成一個陣列就能完成的事情 那就是因為我們需要讓這個超級大的陣列去幹一些比較困難的事情
那什麼是比較困難的事情呢 讓我們進入下個專題

簡單的操作

單點修改 區間查詢

  • 舉個例子 我們要求出區間1~5的和
  • 顯然可以 for(int i = 1;i<=5;++i){ans+=a[i]};
  • 但是我們仍然要使用線段樹來進行操作
  • 首先看區間的位置
    當前處在根節點 儲存的左邊界是1 右邊界是6
    根節點的左兒子的左邊界是1 右邊界是3 右兒子的左邊界是4 右邊界是6
    左兒子的區間完全被該區間包括 所以我們直接返回左兒子的權值
    右兒子的左邊界在目標區間右邊界的左邊 所以我們繼續遞迴搜尋右邊界
    現在最新的左兒子為4~5 完全包括在目標區間之中 直接返回權值 右兒子6與該區間毫無關係 返回0
    現在我們就可以把返回的值加起來了 3+2=5
  • 有人可能會吐槽了 用一個陣列能解決的問題 為什麼要搞的這麼複雜
  • 但是有的時候雖然陣列很方便 但是他並不能滿足我們的需求 ,O(n)的效率 ,有時候是無法令出題人快樂的, 這個時候就需要用到線段樹了 O(\(log_n\))

因此用程式碼怎麼實現呢 也很簡單
先讓我們總結一下線段樹的查詢方式:

  • 如果當前區間完全被包括在目標區間之中,直接返回當前區間的權值
  • 如果當前區間與目標區間毫無關係 直接返回 0
  • 如果當前區間與目標區間有交叉 繼續遞迴搜尋左兒子和右兒子
    那我們就可以有這樣的程式碼實現形式
int search(int rt,int l,int r){
	if(tree[rt].r < l ||tree[rt].l > r)return 0;
	if(tree[rt].l >= l && tree[rt].r <= r)return tree[rt].sum;
	int ans = 0;
	if(tree[rt*2].r >= l)ans += search(2*rt,l,r);
	if(tree[rt*2+1].l <= r)ans += search(2*rt+1,l,r);
	return ans;
}

 那單點修改呢
 這個相對就簡單許多了

  • 給出一個位置x 一個值k
  • 如果我們要修改x位置的數 讓他加上一個數k 我們就讓樹去遞迴尋找這個位置
void add(int rt,int x,int k){
	if(tree[rt].l == tree[rt].r){//到達葉子節點 說明找到該位置
		tree[rt].sum += k;
		return;
	}
	if(x <= tree[rt*2].r)add(rt*2,x,k); // 遞迴搜尋左兒子
	else add(rt*2+1,x,k);//遞迴搜尋右兒子
	tree[rt].sum = tree[rt*2].sum + tree[rt*2+1].sum;//重新將權值加和
	return;
}

區間修改單點查詢

區間修改和單點查詢的方法有很多
為了一會對pushdown的講解 我們這裡說一種比較便於下面理解的方法

區間修改和區間查詢很像

  • 不過區間查詢的 ”如果當前區間完全包括在目標區間就返回當前區間的值“要改為將當前區間打上k標記
  • 舉個例子: 我們要把一個區間所有的數加上k
  • 那就去遞迴搜尋線段樹 如果發現某個線段樹的區間完全包括在目標區間中 那就將這個區間打上k標記
  • 但是我們這裡的建樹就要有所不同了
  • 因為我們的所有節點的初始值都會為0(為了便於記錄標記k)
void build(int l,int r,int rt){
    tree[rt].num=0;
    tree[rt].l=l;
    tree[rt].r=r;
    if(l==r)
        return ;
    int mid=(r+l)/2;
    build(l,mid,rt*2);
    build(mid+1,r,rt*2+1);
}

void add(int rt,int l,int r,int k){
    if(tree[rt].l>=l && tree[rt].r<=r){
        tree[rt].num+=k;
        return ;
    }
    if(tree[rt*2].r>=l)
       add(rt*2,l,r,k);
    if(tree[rt*2+1].l<=r)
       add(rt*2+1,l,r,k);
}

單點查詢可以去尋找這個節點 路上遇到的所有標記都要累加起來 最後再加上這個節點的初始值
用程式碼實現大概是這個樣子

void search(int rt,int dis){
    ans+=tree[rt].num;
    if(tree[rt].l==tree[rt].r)
        return ;
    if(dis<=tree[rt*2].r)
        search(rt*2,dis);
    if(dis>=tree[rt*2+1].l)
        search(rt*2+1,dis);
}
    //主函式中
    printf("%d\n",ans+a[x]);//a[x]為目標位置的初始值

建議將上面的板子打熟再向下繼續觀看

區間修改與區間查詢(pushdown and lazy)

前方高難
看到這樣的題你或許會想 不就是上邊那兩種放在一起嗎
但是如果你真的這樣寫完了程式碼你會發現 WA
為什麼呢


先來回想一下剛才的操作:將區間加上標記 最終查詢的時候去從上往下找 將標記累加最後再加上初始值


但是這樣真的可以嗎?


答案是否定的 原因很簡單:如果你要求1~3區間的和 而你剛剛將3~5的區間加上標記 因為1~3並不包含3~5的標記 所以我們計算後的結果並不是加k之後的和 而是初始值的和


那如何解決這個問題呢? 也很簡單:只要將我們的標記k下放到i的兒子不就好了嗎


所以我們的演算法雛形就出來了(這也是線段樹最毒瘤而且難調最具有魅力的地方)

  • 首先我們在結構體中多定義一個變數lazy 用於記錄標記 每次有加的操作的時候我們就加到lazy上
  • 然後就是下放操作pushdown 用於將lazy下放到i的兒子節點中
  • 所以通過簡單的推理和歸納我們仍然有以下性質:
      1. 如果當前區間完全被包含在目標區間中 則這個區間的權值 tree[rt].sum += k*(tree[rt].r - tree[rt].l + 1)
      2. 如果當前區間與目標區間有交集但是並沒有被完全覆蓋 就下放懶惰標記
      3. 下放之後分別對左兒子和右兒子進行相同的操作
  • 最後仍然是按照tree[rt].sum = tree[rt2].sum + tree[rt2+1].sum向上更新
    因此程式碼實現就是
void pushdown(long long rt){
	if(tree[rt].lazy != 0){//如果當前區間已經被標記
		tree[rt*2].lazy += tree[rt].lazy;//下放到左兒子
		tree[rt*2+1].lazy += tree[rt].lazy;//下放到右兒子
		long long mid = (tree[rt].l + tree[rt].r)/2;
		tree[rt*2].sum += tree[rt].lazy*(mid - tree[rt*2].l + 1);//更新左兒子的值
		tree[rt*2+1].sum += tree[rt].lazy*(tree[rt*2+1].r - mid);//更新右兒子的值
		tree[rt].lazy = 0;//清空當前節點的懶惰標記
	}
	return;
}

void add(long long rt,long long l,long long r,long long k){
	if(tree[rt].l >= l && tree[rt].r <= r){//如果當前區間完全包含在目標區間直接更新並且標記懶惰標記
		tree[rt].sum += k*(tree[rt].r-tree[rt].l+1);//更新當前區間的權值
		tree[rt].lazy += k;//增加懶惰標記
		return;
	}
	pushdown(rt);//下放懶惰標記
	if(tree[rt*2].r >= l)add(rt*2,l,r,k);//遞迴更新左兒子
	if(tree[rt*2+1].l <= r)add(rt*2+1,l,r,k);//遞迴更新右兒子
	tree[rt].sum = tree[rt*2].sum+tree[rt*2+1].sum;//更新當前節點的權值
	return;
}

區間查詢的時候和之前幾乎一樣 不同的是要進行懶惰標記的下放之後在累加

long long search(long long rt,long long l,long long r){
	if(tree[rt].l >= l && tree[rt].r <= r)return tree[rt].sum;//如果當前區間完全包含在目標區間內 直接返回當前區間的權值
	if(tree[rt].r < l || tree[rt].l > r)return 0;//如果當前區間和目標區間完全沒有關係 直接返回0
	pushdown(rt);//下放懶惰標記
	long long s = 0;
	if(tree[rt*2].r >= l)s += search(rt*2,l,r);
	if(tree[rt*2+1].l <= r)s += search(rt*2+1,l,r);
	return s;//最後返回這個區間的和
}

線段樹模型大概就是這個樣子(線段樹還是比較受出題人青睞的難道是因為難調??)
附上練習攻略:
簡單線段樹建議用洛谷P3374【模板】樹狀陣列1
        洛谷P3368【模板】樹狀陣列2練習板子
如果簡單線段樹沒有問題了
可以去嘗試一下:洛谷P3372【模板】線段樹1
        洛谷P3373【模板】線段樹2
        洛谷P6242【模板】線段樹3

謝謝觀看
點個關注>_<

相關文章