倍增與ST演算法 --演算法競賽專題解析(28)

羅勇軍發表於2020-11-04

本系列文章將於2021年整理出版。前驅教材:《演算法競賽入門到進階》 清華大學出版社
網購:京東 噹噹   作者簽名書:點我
有建議請加QQ 群:567554289

1. 倍增

   倍增就是“成倍增長”,每次遞增2倍。
   倍增法的題目都利用了數的二進位制表示(二進位制劃分):
      N = a 0 2 0 + a 1 2 1 + a 2 2 2 + a 3 2 3 + a 4 2 4 + … N = a_02^0 + a_12^1 + a_22^2+ a_32^3 + a_42^4 + … N=a020+a121+a222+a323+a424+
   例如35,它的二進位制是100011,第5、1、0位是1,即 a 5 = a 1 = a 0 = 1 a_5 = a_1 = a_0 = 1 a5=a1=a0=1,把這幾位的權值相加,有 35 = 2 5 + 2 1 + 2 0 = 32 + 2 + 1 35 = 2^5 + 2^1 + 2^0 = 32 + 2 + 1 35=25+21+20=32+2+1
   數的二進位制劃分反映了一種快速增長的特性,第i位的權值2i等於前面所有權值的和加1:
      2 i = 2 i − 1 + 2 i − 2 + … + 2 1 + 2 0 + 1 2^i = 2^{i-1} + 2^{i-2} + … + 2^1 + 2^0 + 1 2i=2i1+2i2++21+20+1
   一個整數n,它的二進位制表示只有logn位。如果要從0增長到n,可以以1、2、4、…、 2 k 2^k 2k為“跳板”,快速跳到n,這些跳板只有k = logn個。
   倍增法的特點是需要提前計算出第1、2、4、…、 2 k 2^k 2k個跳板,這要求資料是靜態不變的,不是動態變化的。如果資料發生了變化,所有跳板要重新計算,跳板就失去了意義。
   倍增法的經典應用有:矩陣快速冪、字尾陣列、ST演算法,LCA(最近公共祖先)。本節介紹相對簡單的ST演算法,另外也給出了一個應用倍增的經典題。

2. ST(Sparse Table)演算法

   ST演算法是求解RMQ問題的優秀演算法,它適用於靜態空間的RMQ查詢。
   靜態空間的RMQ問題(Range Minimum Query,區間最大最小值問題):給定長度為n的靜態數列,做m次詢問,每次給定L, R ≤ n,查詢區間[L, R]內的最值。
   下面都以最小值為例。
   用暴力搜區間[L, R]的最小值,即逐一比較區間內的每個數,複雜度是O(n)的;m次查詢,複雜度O(mn)。暴力法的效率很低。
   ST演算法源於這樣一個原理:一個大區間若能被兩個小區間覆蓋,那麼大區間的最值等於兩個小區間的最值。例如下圖中,大區間{4, 7, 9, 6, 3, 6, 4, 8, 7, 5}被兩個小區間{4, 7, 9, 6, 3, 6, 4, 8}、{4, 8, 7, 5}覆蓋,大區間的最小值3,等於兩個小區間的最小值,min{3, 4}=3。這個例子特意讓兩個小區間有部分重合,因為重合不影響結果。

倍增與ST演算法 --演算法競賽專題解析(28)
圖1.大區間被兩個小區間覆蓋

   從以上原理得到ST演算法的基本思路,包括兩個步驟:
   (1)把整個數列分為很多小區間,並提前計算出每個小區間的最值;
   (2)對任意一個區間最值查詢,找到覆蓋它的兩個小區間,用兩個小區間的最值算出答案。
   如何設計出這2個步驟的高效演算法?
   對於(1),簡單的方法是把數列分為固定大小的小區間,即“分塊”,在“分塊與莫隊演算法”中有介紹。它把數列分為 n \sqrt{n} n 塊,每塊有 n \sqrt{n} n 個數,提前計算這 n \sqrt{n} n 個小區間的最值,複雜度是 O ( n n ) O(n\sqrt{n}) O(nn )。然後對於(2)的最值查詢,每次計算量約為O(1)。這種演算法的效率比暴力法強很多,但是還不夠好。
   下面用“倍增”的方法來分塊,它的效率非常高:(1)的複雜度是O(nlogn),(2)的複雜度是O(1)。
(1)把數列按倍增分成小區間
   對數列的每個元素,把從它開始的數列分成長度為1、2、4、8、…的小區間。
   下圖給出了一個分割槽的例子,它按小區間的長度分成了很多組。
   第1組是長度為1的小區間,有n個小區間,每個小區間有1個元素;
   第2組是長度為2的小區間,有n個小區間,每個小區間有2個元素;
   第3組是長度為4的小區間,有n個小區間,每個小區間有4個元素;
   …
   共有logn組。

倍增與ST演算法 --演算法競賽專題解析(28)
圖2.按倍增分為小區間

   可以發現,每組的小區間的最值,可以從前一組遞推而來。例如第3組{4, 7, 9, 6}的最值,從第2組{4, 7}、{9, 6}的最值遞推得到。
   定義dp[s][k],表示左端點是s,區間長度為2k的區間最值。遞推關係是:
      dp[s][k] = min{dp[s][k-1], dp[s + 1<<(k-1)][k-1]}
   其中1<<(k-1)等於 2 k − 1 2^{k-1} 2k1
   用dp[][]來命名,是因為遞推關係是一個動態規劃的過程。
   計算所有小區間的最值,即計算出所有的dp[][],複雜度是多少?圖中的每一組都需計算n次,共logn組,總計算量是O(nlogn)。
(2)查詢任意區間的最值
   根據上面的分割槽方法,有以下結論:以任意元素為起點,有長度為1、2、4、…的小區;以任意元素為終點,它前面也有長度為1、2、4、…的小區。
   根據這個結論,可以把需要查詢的區間[L, R]分為2個小區間:以L為起點的小區間、以R為終點的小區間,讓這兩個小區間首尾相接覆蓋[L, R],區間最值從兩個小區間的最值求得。一次查詢的計算複雜度是O(1)。
   區間[L, R]的長度是len = R-L+1。兩個小區間的長度是x,令x是比len小的最大2的倍數,有2*x ≥ len,這樣保證能覆蓋。另外需要計算dp[][],根據dp[s][k]的定義,有 2 k 2^k 2k =x。例如len = 19,x = 16, 2 k 2^k 2k = 16,k = 4。
   已知len如何求k?計算公式是k = log2(len) = log(len)/log(2),向下取整。以下兩種程式碼都行:

int k=(int)(log(double(R-L+1)) / log(2.0));    //以10為底的庫函式log()
int k=log2(R-L+1);                              //以2為底的庫函式log2()

   如果覺得庫函式log2()比較慢,可以自己提前算出LOG2,LOG2[i]的值與向下取整的log2(i)相等:

	LOG2[0] = -1;
    for(int i=1;i<=maxn;i++)  
        LOG2[i] = LOG[i>>1]+1;

   最後給出區間[L, R]最小值的計算公式,等於覆蓋它的兩個小區間的最小值:
      min(dp[L][k],dp[R-(1<<k)+1][k]);
   用這個公式做一次最值查詢,計算複雜度是O(1)。
   下面用一個模板題給出程式碼。


洛谷P2880
題目描述:給定一個包含n個整數的數列,和q個區間詢問,詢問區間內最大值和最小值的差。
輸入:第一行是2個整數,n和q。接下來n行,每行一個整數hi。再後面q行,每行2個整數a、b,表示一個區間詢問。
輸出:對每個區間詢問,返回區間內最大值和最小值的差。
資料範圍:1≤n≤5× 1 0 4 10^4 104,1≤q≤1.8× 1 0 5 10^5 105,1≤a≤b≤n。


   程式碼:

#include<bits/stdc++.h>
using namespace std;
const int maxn=50005;
int a[maxn],dp_max[maxn][22],dp_min[maxn][21],n,m;
int LOG2[maxn];             //自己計算以2為底的對數,向下取整
int st_init(){
    LOG2[0]=-1;
    for(int i = 1;i<=maxn;i++)  //不用系統的log()函式,自己算
        LOG2[i] = LOG2[i>>1]+1;

    for(int i=1;i<=n;i++){ //初始化區間長度為1時的值
        dp_min[i][0]=a[i];
        dp_max[i][0]=a[i];
    }
int p=log2(n);                             //可倍增區間的最大次方: 2^p <= n
//int p= (int)(log(double(n)) / log(2.0));  //兩者寫法都行
	for(int k=1;k<=p;k++)          //倍增計算小區間。先算小區間,再算大區間,逐步遞推
        for(int s=1;s+(1<<k)<=n+1;s++){
            dp_max[s][k]=max(dp_max[s][k-1], dp_max[s+(1<<(k-1))][k-1]);
            dp_min[s][k]=min(dp_min[s][k-1], dp_min[s+(1<<(k-1))][k-1]);
	}
}
int st_query(int L,int R){
//int k=log2(R-L+1);                        //3種方法求k
//int k=(int)(log(double(R-L+1)) / log(2.0));
    int k=LOG2[R-L+1];                           //用自己算的LOG2
    int x=max(dp_max[L][k],dp_max[R-(1<<k)+1][k]);//區間最大
    int y=min(dp_min[L][k],dp_min[R-(1<<k)+1][k]);//區間最小
    return x-y;  //返回差值
}
int main(){
    scanf("%d%d",&n,&m);//輸入
    for(int i=1;i<=n;i++)    scanf("%d",&a[i]);
    st_init();
	for(int i=1;i<=m;i++){
		int L,R; scanf("%d%d",&L,&R);
		printf("%d\n",st_query(L,R));
	}
	return 0;
}

3. 幾個討論

(1)ST演算法與線段樹
   求解RMQ問題更常用的是線段樹,它求RMQ的時間複雜度與ST差不多,但是兩者有很大區別:線段樹用於動態陣列,ST用於靜態陣列。
   ST演算法用O(nlogn)時間來預處理陣列,預處理之後每次區間查詢是O(1)的。如果陣列是動態改變的,改變一次就需要用O(nlogn)預處理一次,導致效率很低。線段樹適用於動態維護(新增或刪除)的陣列,它每次維護陣列和每次查詢都是O(logn)的。從陣列是否能動態維護這個角度來說,ST是離線演算法,線段樹是線上演算法。ST的優勢是編碼簡單,如果陣列是靜態的,就用ST。
(2)ST演算法的適用場合
   從ST演算法的原理看,它的核心思想是“大區間被兩個小區間覆蓋、小區間的重複覆蓋不影響結果”,然後用倍增法劃分小區間和計算小區間的最值。最大值和最小值符合這種場景,類似的有RGQ問題(Range GCD Query,區間最大公約數問題):查詢給定區間的GCD。

4. 倍增例題

   下面給出另一個應用倍增的經典題目。


洛谷 P4155 國旗計劃
題目描述:邊境上有m個邊防站圍成一圈,順時針編號1到m。有n個戰士,每個戰士常駐2個站,能在2個站之間移動。局長有個國旗計劃,讓邊防戰士舉著國旗環繞一圈。局長想知道至少需要多少戰士才能完成國旗計劃,並且他想知道,在某個戰士必須參加的情況下,至少需要多少邊防戰士。
輸入:第一行是兩個正整數n,m,表示戰士數量和邊防站數量。後面n行,每行有兩個正整數,第i行的ci、di表示i號邊防戰士常駐的兩個邊防站編號,沿順時針從ci邊防站到di是他的移動區間。資料保證整個邊境線是可被覆蓋的。所有戰士的移動區間互相不包含。
輸出:輸出一行,包含n個正整數,其中第j個正整數表示j號戰士必須參加的前提下至少需要多少邊防戰士才能順利完成國旗計劃。
資料範圍:n≤2× 1 0 5 10^5 105, m< 1 0 9 10^9 109, 1≤ci,di≤m


   題目的要求很清晰:計算能覆蓋整個圓環的最少區間(戰士)。
   題目給定的所有區間互相不包含,那麼按區間的左端點排序後,區間的右端點也是單調增加的。這樣情況下能用貪心來選擇區間。
   解題用到的技術有:斷環成鏈、貪心、倍增。
   (1)斷環成鏈路。把題目給的環斷開變成一條鏈,更方便處理。注意環是首尾相接的,斷開後為了保持原來的首尾的關係,需要把原來的環複製再相接。
   (2)貪心。首先考慮從一個區間出發,如何選擇所有的區間。選擇一個區間i後,下一個區間只能從左端點小於等於i的右端點的那些區間中選,在這些區間中選右端點最大的那個區間,是最優的。例如下圖選擇區間i後,下一個區間可以從A、B、C中選,它們的左端點都在i內部。C是最優的,因為它的右端點最遠。選定C之後,再用貪心策略找下一個區間。這樣找下去,就得到了所需的最少區間。

倍增與ST演算法 --演算法競賽專題解析(28)
圖3.用貪心選下一個最優區間

   以i為起點,用貪心查詢一次,要遍歷所有的區間,複雜度O(n)。題目要求以每個區間為起點,共做n次查詢,總複雜度是 O ( n 2 ) O(n^2) O(n2),超時。
   (3)倍增。為了進行高效的n次查詢,可以用類似ST演算法中的倍增,預計算出一些“跳板”,快速找到後面的區間。
   定義go[s][i]:表示從第s個區間出發,走 2 i 2^i 2i個最優區間後到達的區間。例如 go [s][4],是從s出發到達的第 2 i 2^i 2i =16個最優的區間,s和go[s][4]之間的區間也都是最優的。
   預計算出從所有的區間出發的go[][],以它們為“跳板”,就能快速跳到目的地。
   注意,跳的時候先用大數再用小數。以從s跳到後面第27個區間為例:1)從s跳16步,到達s後的第16個區間f1;2)從f1跳8步,到達f1後的第8個區間f2;3)從f2跳2步到達f3;4)從f3跳1步到達終點f4。共跳了16+8+2+1=27步。這個方法利用了二進位制的特徵,例如27的二進位制是11011,其中的4個“1”的權值就是16、8、2、1。把一個數轉換為二進位制數時,是從最高位往最低位轉換的,這就是為什麼要先用大數再用小數的原因。

倍增與ST演算法 --演算法競賽專題解析(28)
圖4.用倍增跳到終點

   複雜度是多少?查詢一次,用倍增法從s跳到終點複雜度是O(logn)的。共有n次查詢,總複雜度O(nlogn)。
   剩下的問題是如何快速預計算出go[][]。有以下非常巧妙的遞推關係:
      go[s][i] = go[go[s][i-1]][i-1]
   遞推式的右邊這樣理解:
   1)go[s][i-1]。從s起跳,先跳 2 i − 1 2^{i-1} 2i1步到了區間z = go[s][i-1];
   2)go[go[s][i-1]][i-1] = go[z][i-1]。再從z跳 2 i − 1 2^{i-1} 2i1步到了區間go[z][i-1]。
   一共跳了 2 i − 1 + 2 i − 1 = 2 i 2^{i-1} + 2^{i-1} = 2^i 2i1+2i1=2i步。公式右邊實現了從s起跳,跳到了s的第 2 i 2^i 2i個區間,這就是遞推式左邊的go[s][i]。
  特別地,go[s][0]是x後第 2 0 2^0 20 = 1個區間(用貪心算出的下一個最優區間),go[s][0]是遞推式的初始條件,從它遞推出了所有的go[][]。遞推的計算量有多大?從任意一個s到末尾,最多隻有logn個go[s][],所以只需要遞推O(logn)次。計算n個結點的go[][],共計算O(nlogn)次。
   以上所有的計算,包括預計算go[][]和n次查詢,總複雜度是O(nlogn) + O(nlogn)。
   下面是程式碼。

//改寫自:https://blog.csdn.net/qq_35923186/article/details/83066690
#include<bits/stdc++.h>
using namespace std;
const int maxn = 4e5+1;
int n, m;

struct warrior{   
    int id, L, R;      //id:戰士的編號;L、R,戰士的左右區間
    bool operator < (const warrior b) const{return L < b.L;}
}w[maxn*2];
int n2;
int go[maxn][20];    
void init(){         //貪心 + 預計算倍增
    int nxt = 1;
    for(int i=1;i<=n2;i++){ //用貪心求每個區間的下一個區間
        while(nxt<=n2 && w[nxt].L<=w[i].R)  //每個區間的下一個是右端點最大的那個區間
            nxt++;
        go[i][0]=nxt-1;    //區間i的下一個區間
    }
    for(int i=1;(1<<i)<=n;++i)   //倍增:i=1,2,4,8,... 共log(n)次
        for(int s=1;s<=n2;s++)   //每個區間後的第2^i個區間
            go[s][i] = go[go[s][i-1]][i-1];
}
int res[maxn];
void getans(int x){ //從第x個戰士出發
    int len=w[x].L+m, cur=x, ans=1;
    for(int i=log2(maxn);i>=0;i--){   //從最大的i開始找:2^i = maxn
        int pos = go[cur][i];
        if(pos && w[pos].R < len){
            ans += 1<<i;               //累加跳過的區
            cur = pos;                 //從新位置繼續開始
        }
    }
    res[w[x].id] = ans+1;
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=1;i<=n;i++){
        w[i].id = i;  //記錄戰士的順序
        scanf("%d%d",&w[i].L, &w[i].R);
        if(w[i].R < w[i].L) //把環變成鏈
            w[i].R += m;
    }
    sort(w+1, w+n+1);  //按左端點排序
    n2 = n;
    for(int i=1;i<=n;i++){  //拆環加倍成一條鏈
        n2++;  w[n2]=w[i];  w[n2].L=w[i].L+m;  w[n2].R=w[i].R+m;
    }
    init();
    for(int i=1;i<=n;i++)  getans(i);  //逐個計算每個戰士
    for(int i=1;i<=n;i++)  printf("%d ",res[i]);
    return 0;
}

5. 倍增習題

洛谷P2048 超級鋼琴
洛谷5465 星際穿越
Hdu 6107 Typesetting
洛谷3295 萌萌噠

相關文章