wqs二分


我们通过一道例题引入。

引入

CF1279F New Year and Handle Change

题目大意:给出长度为 \(n\) 一个01串(表现为大小写),可以最多选择 \(m\)\(s\) 长度的子串,全部变为0或1。求操作后的 \(\min(cnt_0 , cnt_1)\) 的最小值

\(1 \le n,m,s ≤10^6\)

题解

我们可以简单地想到可以分开考虑全变为 \(1\) 和全变为 \(0\) 。然后对于这两种情况我们可以很快的想到一个 \(O(n^2)\) 的DP。暂且只考虑全变 \(1\),全变 \(0\) 是一样的,我们设 \(dp_{i,j}\) 表示我们在区间 \([i-s+1,i]\) 执行第 \(j\) 次操作的最大的能够抹除的 \(0\) 的个数。那么我们很快可以得到DP的转移式:

\[dp_{i,j}=max\{dp_{i-1,j},dp_{max\{i-s,0\},j-1}+sum_i-sum_{max\{i-s,0\} }\} \]

其中 \(sum_i\) 表示 \(0\) 的个数的前缀和。统计答案就直接 \(sum_n-dp_{n,m}\) 即可。

但是看看数据范围就发现不行,要考虑优化。发现随着 \(m\) 的增大,我们可以删去的 \(0\) 必然单调不减,且由于 \(m\) 越大,之后操作能消去的 \(0\) 逐渐变少,所以若我们记 \(g(x)\) 表示 \(m\) 次操作能够最多消去的 \(0\) 的个数,这个函数虽然显然具有单调性,但是更为重要的是其具有凸性,即我们用一条切线去切这个函数图象,它上面的每个点都可以被切到。数学化的说,求增长速率逐渐减缓,下降速度增快(或者反过来也可以),数字化的说,就是其差分数组具有单调性。

有了这个性质,我们就可以进行 wqs二分 。这个性质是 wqs二分 进行的前提。实在不行可以打表去发现凸性。

它具体是怎么做的呢?它的思想就是,我们通过一些操作除去限制,然后我们可以容易地用DP求出。之后再通过一些操作转换到我们的带限制的最优解。(我这里采用的是几何化+意义化理解,而并非形象化理解,喜欢形象化理解的可以去看其他人的博客)

具体来说,我们先把 \(m\) 放在 \(g(x)\) 上,现在我们的目标就是 \(g(m)\) 。但是 \((m,g(m)\ )\) 具体在哪我们不得而知,因此我们考虑通过一些转换来求解它。

由于 \(g(x)\) 具有凸性,显然我们可以使用直线去切它,而此时直线的纵截距和切点的横坐标都关于斜率大小具有单调性,所以我们想到要去二分斜率。尽管我们二分完也暂且不知道这个切点和纵截距在哪,但是我们继续考虑转换。我们记切在横坐标为 \(x\) 的点上的直线的纵截距为 \(f(x)\) ,考虑纵截距 \(f(x)\) 的在几何上得到的代数意义: \(f(x)=g(x)-kx\) 。然后我们观察这个式子,然后思考 \(g(x),f(x)\) 的现实意义。

由于我们知道 \(g(x)\) 表示 \(m\) 次操作能够最多消去的 \(0\) 的个数,那么可以发现 \(f(x)\) 的现实意义为:每次消去的 \(0\) 的个数 \(-k\) 作为贡献,在 \(x\) 次操作后得到的贡献的最大值。这个看着是有一个 \(x\) 的限制,但是由于这个 \(x\) 是未知数,我们现在反而是要求 \(x\) ,所以 \(f(x)\) 作为截距的意义就是:每次消去的 \(0\) 的个数 \(-k\) 作为贡献得到的贡献的最大值。这个东西我们可以通过上面的DP删去操作次数的第二维求出,复杂度 \(O(n)\)。 然后反推一下可以求出 \(g(x)\)

你以为结束了?没有。我们还不知道那个位置是 \(m\) ,怎么算出 \(g(m)\) ,所以我们DP \(f(x)\) 时,我们按照决策及其意义统计操作次数即可。然后我们二分的时候检查操作次数和 \(m\) 的大小关系,然后按照这个调整斜率,以此移动切点的位置即可。复杂度易证 \(O(n\log n)\)

但是要注意的是,我们统计出的操作次数一定要最小化,不然会出现问题。原因我会同上面讨论的问题的图解一同给出。下面给出图解:

参考代码

#include
#define ll long long
#define db double
#define filein(a) freopen(#a".in","r",stdin)
#define fileot(a) freopen(#a".out","w",stdout)
#define sky fflush(stdout)
#define gc getchar
#define pc putchar
namespace IO{
	template
	inline void read(T &s){
		s=0;char ch=gc();bool f=0;
		while(ch<'0'||'9'
	inline void read(T &s,A &...a){
		read(s);read(a...);
	}
	inline bool blank(char c){
		return c==' ' or c=='\t' or c=='\n' or c=='\r' or c==EOF;
	}
	inline void gs(std::string &s){
		s+='#';char c=gc();
		while(blank(c) ) c=gc();
		while(!blank(c) ){
			s+=c;c=gc();
		}
	}
	inline void gs(char *s){
		char ch=gc();
		while(blank(ch) ) {ch=gc();}
		while(!blank(ch) ){
			*s++=ch;ch=gc();
		}
	}
};
using IO::read;
using IO::gs;
const int N=1e6+3;
const int inf=1e9;
int n,m,s;
char c[N];
int sum[N];
int f[N],g[N];
int ans=inf;
inline int check(int p,bool op){
	for(int i=1;i<=n;++i){
		int x=f[std::max(i-s,0)]+sum[i]-sum[std::max(i-s,0)]-p;
		f[i]=std::max(f[i-1],x);
		if(xf[i-1]) g[i]=g[std::max(i-s,0)]+1;
		if(x==f[i-1]) g[i]=std::min(g[i-1],g[std::max(i-s,0)]+1);
	}
	if(op) ans=std::min(ans,sum[n]-f[n]-m*p);
	return g[n];
}
int main(){
	filein(a);fileot(a);
	read(n,m,s);
	gs(c+1);
	for(int i=1;i<=n;++i){
		sum[i]=sum[i-1]+('A'<=c[i] and c[i]<='Z');
	}
	int l=0,r=n,res=0;
	while(l<=r){
		int mid=(l+r)>>1;
		if(check(mid,0)<=m){
			r=mid-1;res=mid;
		}else{
			l=mid+1;
		}
	}
	check(res,1);
	for(int i=1;i<=n;++i){
		sum[i]=sum[i-1]+('a'<=c[i] and c[i]<='z');
	}
	l=0,r=n,res=0;
	while(l<=r){
		int mid=(l+r)>>1;
		if(check(mid,0)<=m){
			r=mid-1;res=mid;
		}else{
			l=mid+1;
		}
	}
	check(res,1);
	printf("%d\n",ans);
	return 0;
}

例题分析

[国家集训队]Tree I

题目大意:给你一个 \(n\) 个点 \(m\) 条边的的无向带权连通图,每条边是黑色或白色。让你求一棵最小权的恰好有 \(d\) 条白色边的生成树。

\(n\le5e4,m\le 1e5,w\le 100\)

题解

这个题虽然不需要DP,但是用到了wqs二分。

我们设 \(g(x)\) 表示选择 \(x\) 条白边的最小生成树大小。这个函数具有凸性,我们假设原图的最小生成树中包含 \(z\) 条白边,那么 \(x\) 越接近 \(z\) ,就越接近这个最小生成树的形态,由于我们 \(x\)\(z\) 越远,就越必须现在那些能够被称为骤增的白边。说这么多,最主要的是 \(x\) 远离 \(z\) 的过程中,这个凸壳的走势绝对不会减缓,因为增加量大的都被 Kruskal 放到放到后面去了。

那么截距 \(f(x)\) 意义可得:白边边权减少 \(k\) 的情况下,生成树边权最大值。这个可以 Kruskal 求,横坐标也可顺便统计。然后就没什么好说的了。然后提一句二分范围,只有在比右端点最大的单次操作变化两大,左端点比最大减少量小即可。复杂度 \(O(n\log n\log w_{max})\)

参考代码

#include
#define ll long long
#define db double
#define filein(a) freopen(#a".in","r",stdin)
#define fileot(a) freopen(#a".out","w",stdout)
#define sky fflush(stdout)
#define gc getchar
#define pc putchar
namespace IO{
	template
	inline void read(T &s){
		s=0;char ch=gc();bool f=0;
		while(ch<'0'||'9'
	inline void read(T &s,A &...a){
		read(s);read(a...);
	}
	inline bool blank(char c){
		return c==' ' or c=='\t' or c=='\n' or c=='\r' or c==EOF;
	}
	inline void gs(std::string &s){
		s+='#';char c=gc();
		while(blank(c) ) c=gc();
		while(!blank(c) ){
			s+=c;c=gc();
		}
	}
	inline void gs(char *s){
		char ch=gc();
		while(blank(ch) ) {ch=gc();}
		while(!blank(ch) ){
			*s++=ch;ch=gc();
		}
		*s=0;
	}
};
using IO::read;
using IO::gs;
const int N=5e4+3;
const int M=1e5+3;
int n,m,d;
struct Edge{
	int u,v,w,c;
}e[M];
int fa[N];
int find(int x){
	return x==fa[x]?x:fa[x]=find(fa[x]);
}
int ans=0;
inline int check(int k,bool op){
	static int tmpk;
	tmpk=k;
	std::sort(e+1,e+1+m,[](Edge x,Edge y){
		int vx=x.w+(x.c?-tmpk:0),vy=y.w+(y.c?-tmpk:0);
		if(vx==vy) return x.c>1;
		if(check(mid,0)<=d){
			l=mid+1;res=mid;
		}else{
			r=mid-1;
		}
	}
	check(res,1);
	printf("%d\n",ans);
	return 0;
}