整體二分算法完整總結

整體二分概述

一、適用問題

整體二分,即對所有的查詢進行一個整體的二分答案,需要數據結構題滿足以下性質。

  1. 詢問的答案具有可二分性
  2. 修改對判定答案的貢獻相對獨立,修改之間互不影響效果
  3. 修改如果對判定答案有貢獻,則貢獻爲一確定的與判定標準無關的值
  4. 貢獻滿足交換律、結合律,具有可加性
  5. 題目允許離線操作

(來自《淺談數據結構題的幾個非經典解法》)

上面的性質看上去複雜,其實只要滿足詢問答案具有可二分性,且題目允許離線操作,就可以考慮一下是否可以利用整體二分算法進行求解。

二、算法介紹

上面的文字可能有些過於理論,我們現在用淺顯一點的方式來理解這個算法。

假設你現在有 qq 次查詢,查詢區間第 kk 大的值。首先考慮如果只有 11 個查詢,是否可以直接二分解決。

顯然是可以的,我們只需要定位到具體區間,數一下小於等於當前二分值的數個數是否大於等於 kk 即可。於是問題就變成了如何從單次二分演變到整體二分。

我們首先維護一個操作序列,即每個點的賦值和查詢,共 n+qn+q 個操作。然後實現一個 solve(l,r,L,R)solve(l,r,L,R) 函數,表示當前的操作序列在 [L,R][L,R] 範圍內,而該操作序列中所有的查詢操作的答案都在 [l,r][l,r] 中。

於是我們二分一個值 mid=(l+r)/2mid=(l+r)/2,然後將 [L,R][L,R] 中所有的賦值操作中數值小於等於 midmid 的數加入到對應位置,比如 a[x]mida[x]\leq mid,則 sum[x]=sum[x]+1sum[x]=sum[x]+1,對於所有的查詢操作,判斷其查詢區間 [x,y][x,y] 的值是否大於等於 kk,如果是則將其遞歸到 solve(l,mid)solve(l,mid) 中,否則遞歸到 solve(mid+1,r)solve(mid+1,r) 中,具體內容看一下下面的例題就可以理解。

最後分析一下時間複雜度,最多分了 lognlogn 層,每一層的時間複雜度爲 O(nlogn)O(nlogn),因此總時間複雜度爲 O(nlog2n)O(nlog^2n)

最後附上《淺談數據結構題的幾個非經典解法》中對該算法的理論概述。

詢問的答案可二分且修改對判定標準的貢獻相對獨立,且貢獻的值與判定標準無關。因此如果我們已經計算過某一些修改對詢問的貢獻,那麼這個貢獻永遠不會改變,我們沒有必要當判定標準改變時再次計算這部分修改的貢獻,只要記錄下當前的總貢獻,再進一步二分時,直接加上新的貢獻即可。


整體二分系列習題

1. K-th Number

題意: 無修改的區間第 kk 大數問題。(1n105,1m5000)(1\leq n\leq 10^5,1\leq m\leq 5000)

思路: 主席樹模板題,但此處我們要用整體二分的方法來解決此題。

首先我們將所有賦值操作和查詢操作都放到一個數組中,形成了此題的操作序列。然後就是代碼中的核心關鍵點 solve(l,r,L,R)solve(l,r,L,R) 函數,該函數表示區間 [L,R][L,R] 中的操作序列中的查詢操作的答案一定在 [l,r][l,r] 範圍內。

因此問題就變成了如何將 [L,R][L,R] 中的序列進行分組,再遞歸到 solve(l,mid)solve(l,mid)solve(mid+1,r)solve(mid+1,r) 中。我們只需遍歷 [L,R][L,R] 中的所有操作,如果是賦值操作,則判斷數值 xx 是否大於 midmid,如果小於等於 midmid,則將該操作丟到 q1q_1 數組中,並在樹狀數組的 xx 位置加 11;否則將操作丟到 q2q_2 數組中。

如果是查詢操作,則在樹狀數組中查詢區間 [q[i].x,q[i].y][q[i].x,q[i].y] 的值 tmptmp,如果 ktmpk\leq tmp,則將該操作丟到 q1q_1 中;否則將 k=ktmpk=k-tmp,然後丟到 q2q_2 中。

可能說起來比較複雜,但是代碼比較清晰,推薦直接對代碼進行理解。

代碼:

#include <iostream>
#include <algorithm>
#include <cstdio>
#define mem(a,b) memset(a,b,sizeof a);
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define per(i,a,b) for(int i = a; i >= b; i--)
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
typedef long long ll;
typedef double db;
const int N = 1e5+100;
const int inf = 1e9+10;
const db EPS = 1e-9;
using namespace std;

void dbg() {cout << "\n";}
template<typename T, typename... A> void dbg(T a, A... x) {cout << a << ' '; dbg(x...);}
#define logs(x...) {cout << #x << " -> "; dbg(x);}

int n,m,ans[N],c[N];
struct Node{int x,y,k,id;}q[2*N],q1[2*N],q2[2*N];
inline int lowbit(int x) {return x&(~x+1);}
inline void update(int x,int v) {for(; x<=n; x+=lowbit(x)) c[x] += v;}
inline int ask(int x){
	int res = 0;
	while(x) res += c[x], x -= lowbit(x);
	return res;
}

void solve(int l,int r,int L,int R){
	if(l > r || L > R) return;
	if(l == r){
		rep(i,L,R) if(q[i].id) ans[q[i].id] = l;
		return;
	}
	int cnt1 = 0, cnt2 = 0, mid = (l+r)>>1;
	rep(i,L,R){
		if(q[i].id){ //查詢
			int tmp = ask(q[i].y)-ask(q[i].x-1);
			if(q[i].k <= tmp) q1[++cnt1] = q[i];
			else q[i].k -= tmp, q2[++cnt2] = q[i];
		}
		else{ //賦值
			if(q[i].x <= mid) update(q[i].y,1), q1[++cnt1] = q[i];
			else q2[++cnt2] = q[i];
		}
	}
	rep(i,1,cnt1) if(!q1[i].id) update(q1[i].y,-1);
	rep(i,1,cnt1) q[L+i-1] = q1[i];
	rep(i,1,cnt2) q[L+cnt1+i-1] = q2[i];
	solve(l,mid,L,L+cnt1-1); solve(mid+1,r,L+cnt1,R);
}

int main()
{
	scanf("%d%d",&n,&m);
	rep(i,1,n) {scanf("%d",&q[i].x); q[i].id = 0; q[i].y = i;}
	rep(i,1,m) {scanf("%d%d%d",&q[i+n].x,&q[i+n].y,&q[i+n].k); q[i+n].id = i;}
	solve(-inf,inf,1,n+m);
	rep(i,1,m) printf("%d\n",ans[i]);
	return 0;
}
2. Dynamic Rankings

題意: 帶修改的區間第 kk 大數問題。(1n5104,1m104)(1\leq n\leq 5*10^4,1\leq m\leq 10^4)

思路: 帶修改第 kk 大問題,如果要用主席樹來解決的話,則需要再加上一層樹狀數組來維護修改信息,即用樹套樹解決該問題。

但是如果用整體二分來處理這個問題的話,難度則會瞬間驟降。其實此題與上題唯一的區別就是這題多了一個修改操作,而修改操作無非就是刪除原來的數,加上新的數。

因此對於 a[x]=ya[x]=y 的修改操作,我們將其拆成兩部分,第一部分爲刪除 a[x]a[x],然後令 a[x]=ya[x]=y,第二部分是加上 a[x]a[x],具體細節可以參考下面的代碼實現。

代碼:

#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cstring>
#define mem(a,b) memset(a,b,sizeof a);
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define per(i,a,b) for(int i = a; i >= b; i--)
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
typedef long long ll;
typedef double db;
const int N = 1e5+100;
const int inf = 1e9+10;
const db EPS = 1e-9;
using namespace std;

void dbg() {cout << "\n";}
template<typename T, typename... A> void dbg(T a, A... x) {cout << a << ' '; dbg(x...);}
#define logs(x...) {cout << #x << " -> "; dbg(x);}

int n,m,ans[N],c[N],a[N];
struct Node{int x,y,k,id;}q[2*N],q1[2*N],q2[2*N];
inline int lowbit(int x) {return x&(~x+1);}
inline void update(int x,int v) {for(; x<=n; x+=lowbit(x)) c[x] += v;}
inline int ask(int x){
	int res = 0;
	while(x) res += c[x], x -= lowbit(x);
	return res;
}

void solve(int l,int r,int L,int R){
	if(l > r || L > R) return;
	if(l == r){
		rep(i,L,R) if(q[i].k) ans[q[i].id] = l;
		return;
	}
	int cnt1 = 0, cnt2 = 0, mid = (l+r)>>1;
	rep(i,L,R){
		if(q[i].k){ //查詢
			int tmp = ask(q[i].y)-ask(q[i].x-1);
			if(q[i].k <= tmp) q1[++cnt1] = q[i];
			else q[i].k -= tmp, q2[++cnt2] = q[i];
		}
		else{ //賦值
			if(q[i].x <= mid) update(q[i].id,q[i].y), q1[++cnt1] = q[i];
			else q2[++cnt2] = q[i];
		}
	}
	rep(i,1,cnt1) if(!q1[i].k) update(q1[i].id,-q1[i].y);
	rep(i,1,cnt1) q[L+i-1] = q1[i];
	rep(i,1,cnt2) q[L+cnt1+i-1] = q2[i];
	solve(l,mid,L,L+cnt1-1); solve(mid+1,r,L+cnt1,R);
}

int main()
{
	int _; scanf("%d",&_);
	while(_--){
		scanf("%d%d",&n,&m);
		int cnt = 0, tot = 0;
		memset(c,0,sizeof c);
		rep(i,1,n) {
			scanf("%d",&a[i]);
			q[++cnt] = {a[i],1,0,i};
		}
		rep(i,1,m){
			char op[5]; scanf("%s",op);
			int x,y,k;
			if(op[0] == 'Q'){
				scanf("%d%d%d",&x,&y,&k);
				q[++cnt] = {x,y,k,++tot};
			}
			else{
				scanf("%d%d",&x,&y);
				q[++cnt] = {a[x],-1,0,x};
				a[x] = y;
				q[++cnt] = {a[x],1,0,x};
			}
		}
		solve(-inf,inf,1,cnt);
		rep(i,1,tot) printf("%d\n",ans[i]);
	}
	return 0;
}
3. K大數查詢

題意: nn 個位置,mm 個操作。操作有兩種,1 a b c1\ a\ b\ c 表示在第 aa 個位置到第 bb 個位置,每個位置加入一個數 cc2 a b c2\ a\ b\ c 表示詢問從第 aa 個位置到第 bb 個位置,第 cc 大的數是多少。(1n,m5104)(1\leq n,m\leq 5*10^4)

思路: 其實和上面第二個問題沒有太大的差別,只不過上一個問題是單點修改,而這題變成了區間修改。因此我們用線段樹維護一下整體二分即可解決。

代碼:

#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cstring>
#define mem(a,b) memset(a,b,sizeof a);
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define per(i,a,b) for(int i = a; i >= b; i--)
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
typedef long long ll;
typedef double db;
const int N = 1e5+100;
const int inf = 1e9+10;
const db EPS = 1e-9;
using namespace std;

void dbg() {cout << "\n";}
template<typename T, typename... A> void dbg(T a, A... x) {cout << a << ' '; dbg(x...);}
#define logs(x...) {cout << #x << " -> "; dbg(x);}

int n,m;
ll ans[N],sum[2*N],lazy[2*N];
struct Node{ll x,y,k,id;}q[2*N],q1[2*N],q2[2*N];

inline int get_id(int l,int r) {return (l+r)|(l!=r);}
inline void pushDown(int l,int r){
	int mid = (l+r)>>1, now = get_id(l,r), ls = get_id(l,mid), rs = get_id(mid+1,r);
	sum[ls] += lazy[now]*(ll)(mid-l+1); sum[rs] += lazy[now]*(ll)(r-mid);
	lazy[ls] += lazy[now]; lazy[rs] += lazy[now];
	lazy[now] = 0;
}
inline void update(int l,int r,int L,int R,int v){
	int now = get_id(l,r);
	if(L <= l && r <= R){
		sum[now] += (ll)v*(ll)(r-l+1);
		lazy[now] += v;
		return;
	}
	if(lazy[now]) pushDown(l,r);
	int mid = (l+r)>>1;
	if(L <= mid) update(l,mid,L,R,v);
	if(R > mid) update(mid+1,r,L,R,v);
	sum[now] = sum[get_id(l,mid)]+sum[get_id(mid+1,r)];
}
inline ll query(int l,int r,int L,int R){
	int now = get_id(l,r);
	if(L <= l && r <= R) return sum[now];
	if(lazy[now]) pushDown(l,r);
	int mid = (l+r)>>1;
	ll thp = 0;
	if(L <= mid) thp += query(l,mid,L,R);
	if(R > mid) thp += query(mid+1,r,L,R);
	return thp; 
}

void solve(int l,int r,int L,int R){
	if(l > r || L > R) return;
	if(l == r){
		rep(i,L,R) if(q[i].id) ans[q[i].id] = l;
		return;
	}
	int cnt1 = 0, cnt2 = 0, mid = (l+r)>>1;
	rep(i,L,R){
		if(q[i].id){ //查詢
			ll tmp = query(1,n,q[i].x,q[i].y);
			if(q[i].k <= tmp) q2[++cnt2] = q[i];
			else q[i].k -= tmp, q1[++cnt1] = q[i];
		}
		else{ //賦值
			//由於右區間的起始點爲mid+1, 因此此處爲 >= mid+1
			if(q[i].k >= mid+1) update(1,n,q[i].x,q[i].y,1), q2[++cnt2] = q[i];
			else q1[++cnt1] = q[i];
		}
	}
	rep(i,1,cnt2) if(!q2[i].id) update(1,n,q2[i].x,q2[i].y,-1);
	rep(i,1,cnt1) q[L+i-1] = q1[i];
	rep(i,1,cnt2) q[L+cnt1+i-1] = q2[i];
	solve(l,mid,L,L+cnt1-1); solve(mid+1,r,L+cnt1,R);
}

int main()
{
	scanf("%d%d",&n,&m);
	int tot = 0;
	rep(i,1,m){
		ll op,x,y,k;
		scanf("%lld%lld%lld%lld",&op,&x,&y,&k);
		if(op == 1) q[i] = {x,y,k,0};
		else q[i] = {x,y,k,++tot};
	}
	solve(-inf,inf,1,m);
	rep(i,1,tot) printf("%lld\n",ans[i]);
	return 0;
}
4. Stamp Rally

題意: 一個 nn 個點,mm 條邊的圖,第 ii 條邊連接 aia_ibib_i,保證圖是連通的。

現在有 qq 次詢問,每次詢問給出一個三元組 x y zx\ y\ z,表示詢問從 x yx\ y 兩個點出發,一共擴展 zz 個不同的點(包括起始點),求所經過的邊中最大編號的最小值。(3n105,1q105)(3\leq n\leq 10^5,1\leq q\leq 10^5)

思路: 此題較之上面三題,沒有那麼套路,因此我們先從只有一個詢問開始找思路。

首先考慮能不能把圖變成樹,因爲圖上問題往往都很複雜,而變成樹上問題後我們的可操作空間會大很多。繼續思考不難發現,如果我們按邊的編號爲權值構建一棵最小生成樹,每次詢問的答案也一定會落在最小生成樹上的邊上。

轉到樹上問題之後,我們考慮能不能二分答案然後 checkcheck,如果只有一個詢問的話,顯然是可以的。只需要維護一個可加邊可刪邊的按秩合併的並查集即可。

既然單個查詢可以二分,那一定可以用整體二分的方法對所有查詢進行二分。我們在二分值爲 midmid 時,將所有編號小於等於 midmid 的邊連接起來,然後再遞歸到 [mid+1,r][mid+1,r] 區間。右區間遞歸結束後,再撤銷二分值爲 midmid 時連接的邊,然後遞歸 [l,mid][l,mid]

如果連接操作是 fa[x]=y,sz[y]=sz[y]+sz[x]fa[x]=y,sz[y]=sz[y]+sz[x],那麼撤銷操作的時候,如果只有 fa[x]=x,sz[y]=sz[y]sz[x]fa[x]=x,sz[y]=sz[y]-sz[x] 是不夠的,需要從 yy 開始不斷向上訪問,對於訪問到的每一個節點都減去 sz[x]sz[x],如此才能保證撤銷操作的正確性。

代碼:

#include <bits/stdc++.h>
#define mem(a,b) memset(a,b,sizeof a);
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define per(i,a,b) for(int i = a; i >= b; i--)
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
typedef long long ll;
typedef double db;
const db EPS = 1e-9;
const int N = 1e6+100;
using namespace std;

void dbg() {cout << "\n";}
template<typename T, typename... A> void dbg(T a, A... x) {cout << a << ' '; dbg(x...);}
#define logs(x...) {cout << #x << " -> "; dbg(x);}

int n,m,Q,fa[N],sz[N],ans[N];
struct Node {int x,y,k,id,h1,h2;} q[N],q1[N],q2[N];

int find(int x) {return x == fa[x] ? x : find(fa[x]);}
int calc(int x,int y){
	int fx = find(x), fy = find(y);
	return fx == fy ? sz[fx] : (sz[fx] + sz[fy]);
}
pair<int,int> merge(int x,int y){
	int fx = find(x), fy = find(y);
	if(fx == fy) return make_pair(-1,-1);
	if(sz[fx] < sz[fy]){
		fa[fx] = fy, sz[fy] += sz[fx];
		return make_pair(fx,fy);
	}
	else{
		fa[fy] = fx, sz[fx] += sz[fy];
		return make_pair(fy,fx);
	}
}
void Delete(int x,int y) {
	fa[x] = x;
	while(y){
		sz[y] -= sz[x];
		if(y == fa[y]) break;
		y = fa[y];
	}
}

void solve(int l,int r,int L,int R){
	if(l > r || L > R) return;
	if(l == r){
		rep(i,L,R) if(q[i].k) ans[q[i].id] = l;
		return;
	}
	int mid = (l+r)>>1, cnt1 = 0, cnt2 = 0;
	// logs(mid,L,R);
	rep(i,L,R){
		if(q[i].k){ //查詢
			int tmp = calc(q[i].x,q[i].y);
			if(q[i].k <= tmp) q1[++cnt1] = q[i];
			else q2[++cnt2] = q[i];
		}
		else{
			if(q[i].id <= mid){
				pair<int,int> tmp = merge(q[i].x,q[i].y);
				q1[++cnt1] = q[i];
				q1[cnt1].h1 = tmp.first; q1[cnt1].h2 = tmp.second;
			}
			else q2[++cnt2] = q[i];
		}
	}
	rep(i,1,cnt1) q[L+i-1] = q1[i];
	rep(i,1,cnt2) q[L+cnt1+i-1] = q2[i];
	solve(mid+1,r,L+cnt1,R);
	rep(i,1,cnt1) if(!q[L+i-1].k) Delete(q[L+i-1].h1,q[L+i-1].h2);
	solve(l,mid,L,L+cnt1-1);
}

int main()
{
	int cnt = 0;
	scanf("%d%d",&n,&m);
	rep(i,1,n) fa[i] = i, sz[i] = 1;
	rep(i,1,m){
		int x,y; scanf("%d%d",&x,&y);
		if(merge(x,y).first == -1) continue;
		q[++cnt] = {x,y,0,i,0,0};
		// logs(i);
	}
	scanf("%d",&Q);
	rep(i,1,Q){
		int x,y,z; scanf("%d%d%d",&x,&y,&z);
		q[++cnt] = {x,y,z,i,0,0};
	}
	rep(i,1,n) fa[i] = i, sz[i] = 1;
	solve(0,m+1,1,cnt);
	rep(i,1,Q) printf("%d\n",ans[i]);
	return 0;
}

後記

整體二分的內容到這裏就結束了,總的來說,該算法應該屬於一種解題套路,所需要的學習時間不長,可以當作解決數據結構問題的一種套路。最後祝大家 ACAC 愉快,一起愛上二分把!(๑•̀ㅂ•́)و✧

ACM 的旅行雖然充滿荊棘但一擡頭便能看見無數束光,請務必堅持下去,負重前行終有云開霧散之日!💪💪💪

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