wqs 二分学习笔记

wqs 二分好难懂啊,终于懂了的 Oken 决定写一篇尽量通俗易懂的学习笔记来帮助和我一样学不懂的人。

1

我们先看一道例题:Tree I,尝试由这道题推出 wqs 二分的性质。

给定 \(V\) 个点 \(E\) 条边的无向连通图 \(G\),第 \(i\) 条边的边权为 \(c_i\),颜色为 \(col_i\),其中 \(0\) 表示白色,\(1\) 表示黑色。求 \(G\) 的一棵生成树,恰好包含 \(need\) 条白边,最小化生成树中边权和。

\(V \leq 5 \times 10^4\)\(E \leq 10^5\)\(c_i \in [1,100]\)\(col_i \in \{0,1\}\)

我们先抛开 \(need\) 的限制,显然地,我们可以使用 kruskal 求出最小生成树。

接下来考虑加上 \(need\) 的限制。我们定义函数 \(f(x)\) 表示恰好包含 \(x\) 条白边的生成树最小边权和,原问题即为求解 \(f(need)\)

接下来是使用 wqs 二分需要满足的重要性质:\(f(x)\) 具有凸性。我们先假设 \(f(x)\) 具有凸性,接下来需要考虑在此前提下如何求解答案。

我们考虑在凸函数 \(f(x)\) 上进行二分,不过我们二分的是斜率。具体地,我们考虑用直线去切 \(f\)。容易发现,因为 \(f(x)\) 具有凸性,当切线的斜率单调变化时,切点的横坐标 \(x\) 也是单调变化的。我们需要求出 \(f(need)\),此时,我们考虑找到一条斜率为 \(k_0\) 的直线,使得其切 \(f\) 的切点为 \((need,f(need))\)

考虑若这条直线的斜率为 \(k\),我们能求出哪些量。此时我们引出 wqs 二分的关键思想:带权二分。具体地,对于凸函数,其切线的截距一定取到极值,我们考虑截距对应的点,不妨令其为 \((0,g(k))\)。我们考虑如何计算出 \(g(k)\)。不妨令此时的切点为 \((x_0,f(x_0))\),我们可以得出 \(f(x_0)=g(k)+k \times x_0\)。接下来我们考虑将 \(x_0\) 相关的项移到一边,得到 \(g(k)=f(x_0)-k \times x_0\)。因为 \(g(k)\) 一定取到极值,所以 \(g(k)\)\(f(x)-k \times x\) 的极值。在本题最小生成树的背景下,显然 \(f(x)\) 是下凸的,\(g(k)\) 应取到最小值,即 \(f(x)-k \times x\) 的最小值。

我们考虑 \(f(x)-k \times x\) 的实际意义。我们发现,若生成树中包含 \(x\) 条白边,则生成树的权值要减去 \(k \times x\)。自然地,我们考虑将 \(G\) 中所有白边边权减去 \(k\),容易发现这样求出的最小生成树一定为 \(f(x)-k \times x=g(k)\),同时我们也可以求出使用的白边条数,即 \(x_0\),此时我们通过 \(f(x_0)=g(k)+k \times x_0\) 可以求出 \(f(x_0)\),至此我们已经知道了切点为 \((x_0,f(x_0))\),就可以继续二分了。

等等,我们貌似还没证明 \(f\) 的凸性?不过在此题的背景下,显然白边取太少或太多答案显然不优,感性理解可知答案具有凸性。实际比赛时,作为 OIer,没有必要严格证明,如果你感性理解是对的,或者你打了表发现具有凸性,这就够了。

至此,wqs 二分的第一道例题已经结束,接下来是代码环节。

咦,不是还有不少细节吗,你怎么给漏了?

比如二分上下界。容易发现,我们最终关心的只有切点,即只关心选取白边的数量,考虑所有白边的边权都比黑边的边权大或小时,选取的方案是固定的,所以我们二分的上下界只要超过值域大小即可。

比如二分的细节处理。我们在二分时可能会出现几个点切点的斜率相同的情况,此时我们不妨钦定优先取白边,这样切点就仍是单调的,二分就不用处理过多的细节了。注意最后的切点可能不是 \(need\),需要找到直线后计算。

至此,wqs 二分的第一道例题已经结束,接下来是代码环节。

#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
int n,m,need;
long long ans;
struct Edge{
	int u,v,w,col;
}edge[100000];
bool operator <(const Edge &lhs,const Edge &rhs){
	if(lhs.w!=rhs.w){
		return lhs.w<rhs.w;
	}
	else{
		return lhs.col<rhs.col;
	}
}
int p[50010];
int Div_2(int num){
	if(num%2==0  ||  num>=0) return num/2;
	else return num/2-1;
}
int Find(int pos){
	if(p[pos]!=pos) p[pos]=Find(p[pos]);
	return p[pos];
}
int calc_id(int mid){
	for(int i=0;i<n;i++){
		p[i]=i;
	}
	for(int i=0;i<m;i++){
		if(edge[i].col==0){
			edge[i].w-=mid;
		}
	}
	sort(edge,edge+m);
	int tot_white=0,sum=0;
	for(int i=0;i<m;i++){
		int u=Find(edge[i].u),v=Find(edge[i].v),w=edge[i].w,col=edge[i].col;
		if(u!=v){
			sum+=w;
			if(col==0) tot_white++;
			p[u]=v;
		}
	}
	ans=sum+mid*need;
	for(int i=0;i<m;i++){
		if(edge[i].col==0){
			edge[i].w+=mid;
		}
	}
	return tot_white;
}
int main(){
	scanf("%d %d %d",&n,&m,&need);
	for(int i=0;i<m;i++){
		scanf("%d %d %d %d",&edge[i].u,&edge[i].v,&edge[i].w,&edge[i].col);
	}
	int L=-114,R=114;
	while(L<R){
		int mid=Div_2(L+R);
		if(calc_id(mid)>=need){
			R=mid;
		}
		else{
			L=mid+1;
		}
	}
	calc_id(L);
	printf("%lld",ans);
	return 0;
}

2

接下来再看一道简单题:种树

给定长度为 \(n\) 的序列 \(a\),求在 \(a\) 中选出最多 \(k\) 个不相邻的数之和的最大值。

\(1 \leq n \leq 3 \times 10^5\)\(1 \leq k \leq \dfrac{n}{2}\)\(|a_i| \leq 10^6\)

仿照上面的例子,我们先考虑去除 \(k\) 的限制,容易发现这是经典的 dp 问题。我们令 \(dp_{i,j}\) 表示考虑到了前 \(i\) 个数,第 \(i\) 个数选或不选的方案数,则此时有:

\[\begin{cases} dp_{i,0}=\max(dp_{i-1,0},dp_{i-1,1}) \\ dp_{i,1}=a_i+dp_{i-1,0} \end{cases} \]

现在加上 \(k\) 的限制。我们当然可以在 dp 时再加一维,但这显然会超时。此时我们考虑 wqs 二分。令 \(f(x)\) 表示在 \(a\) 中选出恰好 \(x\) 个不相邻的数之和的最大值,容易发现 \(f\) 是上凸的。我们二分出切线的斜率,仍然考虑截距 \(g(k)=f(x)-k \times x\),发现其实际意义为将选出的每个数减去 \(k\)。我们当然可以先对 \(a\) 中的所有数减 \(k\),然后再做普通的 dp。于是这道题就结束了。

等等,好像还没有结束喵。我们考虑一件事,在 \(f(x)\) 的定义中,其对应的是恰好\(x\) 个数,而原问题中为最多\(x\) 个数。我们考虑这样会产生什么错误。不妨令 \(x=x_0\)\(f(x)\) 取到最大值,则在 \(x>x_0\) 时,\(f(x) \leq f(x_0)\),此时我们可以恰好\(x_0\) 个数,这样答案一定不劣。对应到 wqs 二分中,我们只需要在二分时将 \(k\) 的斜率下界设为 \(0\) 即可。对应的,我们可以发现,在 wqs 二分时,将斜率设为 \(0\) 可以求出 \(f\) 的极值点。

#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
const long long INF=1e9;
long long a[300010],dp[300010][2],ans;
int n,k,cnt[300010][2];
int calc_id(long long mid){
	for(int i=1;i<=n;i++){
		dp[i][0]=dp[i][1]=0;
		cnt[i][0]=cnt[i][1]=1;
	}
	for(int i=1;i<=n;i++){
		dp[i][0]=max(dp[i-1][0],dp[i-1][1]);
		if(dp[i-1][0]<dp[i-1][1]){
			cnt[i][0]=cnt[i-1][1];
		}
		else if(dp[i-1][0]>dp[i-1][1]){
			cnt[i][0]=cnt[i-1][0];
		}
		else{
			cnt[i][0]=min(cnt[i-1][0],cnt[i-1][1]);
		}
		dp[i][1]=dp[i-1][0]+a[i]-mid;
		cnt[i][1]=cnt[i-1][0]+1;
	}
	long long sum=max(dp[n][0],dp[n][1]),tot;
	if(dp[n][0]<dp[n][1]){
		tot=cnt[n][1];
	}
	else if(dp[n][0]>dp[n][1]){
		tot=cnt[n][0];
	}
	else{
		tot=min(cnt[n][0],cnt[n][1]);
	}
	ans=sum+k*mid;
	return tot;
}
int main(){
	scanf("%d %d",&n,&k);
	for(int i=1;i<=n;i++){
		scanf("%lld",&a[i]);
	}
	long long l=0,r=INF;
	while(l<r){
		long long mid=(l+r)>>1;
		if(calc_id(mid)<=k){
			r=mid;
		}
		else{
			l=mid+1;
		}
	}
	calc_id(l);
	printf("%lld",ans);
	return 0;
}

通过上面两个例子,我们发现,wqs 二分在使用时需要满足凸性。同时我们发现,wqs 二分可以解决恰好最多最少等问题。而且在上一道例题中,我们发现 wqs 二分可以优化 dp 的状态,减少维数。

3

接下来我们看一道经典的老题:[IOI 2000] 邮局[IOI 2000] 邮局[IOI 2000] 邮局

数轴上有 \(n\) 个点,第 \(i\) 个点的坐标为 \(x_i\)。你需要选出 \(m\) 个点作为关键点,最小化每个点到最近的关键点距离之和。

\(1 \leq m \leq n \leq 300\)\(1 \leq m \leq 30\)\(1 \leq x_i \leq 10000\)

\(1 \leq m \leq n \leq 3000\)\(1 \leq m \leq 300\)\(1 \leq x_i \leq 10000\)

\(1 \leq m \leq n \leq 5 \times 10^5\)\(0 \leq x_i \leq 2 \times 10^6\)

首先我们考虑原题,即第一个数据范围。容易发现,对于一个关键点,其产生贡献的点为一段连续的区间。我们考虑先将所有点从小到大排序。定义 \(dp_{i,j}\) 为在前 \(i\) 个点中选取 \(j\) 个关键点,最小的距离总和。可以发现转移式应为:

\[dp_{i,j}=\min\limits_{k=0}^{i-1} (dp_{k,j-1}+w(k+1,i)) \]

其中 \(w(l,r)\) 表示在编号属于 \([l,r]\) 的点中选出一个作为关键点,到 \([l,r]\) 中所有点的距离和最小值。根据初中数学,此时关键点一定是 \([l,r]\) 中所有点的中位数。通过 \(O(n)\) 的预处理,我们可以在 \(O(1)\) 的时间复杂度内算出 \(w(l,r)\)。此时 dp 的复杂度为 \(O(n^2m)\),可以通过原题。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=300,M=30;
long long num[N+10],sum[N+10],dp[N+10][M+10];
long long w(int l,int r){
	int lmid=(l+r)/2,rmid=(l+r)/2+(l+r)%2;
	return (sum[r]-sum[rmid-1])-(sum[lmid]-sum[l-1]);
}
int main(){
	int n,m;
	scanf("%d %d",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%lld",&num[i]);
		sum[i]=sum[i-1]+num[i];
	}
	memset(dp,0x3f,sizeof(dp));
	dp[0][0]=0;
	for(int i=1;i<=n;i++){
		for(int j=1;j<=m;j++){
			for(int k=0;k<i;k++){
				dp[i][j]=min(dp[i][j],dp[k][j-1]+w(k+1,i));
			}
		}
	}
	printf("%lld",dp[n][m]);
	return 0;
}

考虑常见的 dp 优化方法。容易发现在本题的背景下,dp 的决策点具有决策单调性。具体的,我们发现若 \(dp_{i,j}\) 的决策点为 \(pos_{i,j}\),则在 \(i\) 增加时,对于相同的 \(j\),为了均摊距离的贡献,其决策点也是不断向右移动的,采用决策单调性分治即可。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const long long INF=0x3f3f3f3f3f3f3f3f;
const int N=3000,M=300;
long long num[N+10],sum[N+10],lst[N+10],dp[N+10];
long long w(int l,int r){
	int lmid=(l+r)/2,rmid=(l+r)/2+(l+r)%2;
	return (sum[r]-sum[rmid-1])-(sum[lmid]-sum[l-1]);
}
void solve(int l,int r,int l_p,int r_p){
	if(l>r){
		return ;
	}
	int mid=(l+r)>>1;
	int pos=-1;
	for(int i=l_p;i<=r_p;i++){
		if(i<mid){
			long long prenum=lst[i]+w(i+1,mid);
			if(pos==-1  ||  prenum<dp[mid]){
				dp[mid]=prenum;
				pos=i;
			}
		}
	}
	solve(l,mid-1,l_p,pos);
	solve(mid+1,r,pos,r_p);
}
int main(){
	int n,m;
	scanf("%d %d",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%lld",&num[i]);
		sum[i]=sum[i-1]+num[i];
		dp[i]=INF;
	}
	for(int i=1;i<=m;i++){
		for(int j=0;j<=n;j++){
			lst[j]=dp[j];
		}
		solve(1,n,0,n-1);
	}
	printf("%lld",dp[n]);
	return 0;
}

欸你前面写了这么多,和 wqs 二分有关系吗?

我们考虑 \(1 \leq m \leq n \leq 10^5\) 的部分怎么做。我们发现,在 dp 的过程中有两个维度。为了优化,我们必须考虑减少一维。而 \(n\) 这一维由于涉及到关键点的贡献计算,不能删除。于是考虑删除 \(m\) 的这一维。发现 \(m\) 为关键点个数的限制,考虑使用 wqs 二分技巧。容易证明,令 \(f(x)\) 表示选择 \(x\) 个关键点的最小距离之和,则对于 \(x_1 \leq x_2\),有 \(f(x_1) \geq f(x_2)\)。但是 wqs 二分需要的是凸性,在此处,我们还可以说明 \(f\) 是下凸的。感性理解一下,对于 \(x\) 较小的情况,每增加一个关键点能够将一个较大的区间拆成两个区间,对答案的贡献也就较大。仍然考虑截距 \(g(k)=f(x)-k \times x\),发现其实际意义为对每个关键点的贡献减 \(k\)。考虑此时的 dp 状态转移方程为:

\[dp_i=\min\limits_{k=0}^{i-1} (dp_k+w(k+1,i)-k) \]

容易发现此时的转移仍然具有决策单调性,但因为这里 dp 的转移是依赖前面的值的,所以不能使用分治技巧。我们考虑使用二分队列的手法。具体地,考虑每一个点作为决策点时所能贡献的区间。我们采用队列维护贡献区间,可以发现每新加一个点会更新一段后缀的贡献点,实现的具体细节见代码。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<deque>
using namespace std;
const long long INF=0x3f3f3f3f3f3f3f3f;
const int N=500000,M=500000;
int n,m;
long long num[N+10],sum[N+10],dp[N+10],cnt[N+10],ans;
long long w(int l,int r){
	int lmid=(l+r)/2,rmid=(l+r)/2+(l+r)%2;
	return (sum[r]-sum[rmid-1])-(sum[lmid]-sum[l-1]);
}
long long Div_2(long long num){
	if(num%2==0  ||  num>=0){
		return num/2;
	}
	else{
		return num/2-1;
	}
}
struct Node{
	int l,r,pos;
};
long long calc(int pos,int i,long long mid){
	return dp[pos]+w(pos+1,i)-mid;
}
int calc_id(long long mid){
	for(int i=1;i<=n;i++){
		dp[i]=INF;
	}
	deque<Node> p_list;
	p_list.push_back((Node){1,n,0});
	for(int i=1;i<=n;i++){
		Node pre=p_list.front();
		p_list.pop_front();
		dp[i]=calc(pre.pos,i,mid);
		cnt[i]=cnt[pre.pos]+1;
		if(pre.l<pre.r){
			p_list.push_front((Node){pre.l+1,pre.r,pre.pos});
		}
		int lst=n+1;
		while(!p_list.empty()){
			pre=p_list.back();
			p_list.pop_back();
			if(calc(i,pre.r,mid)<=calc(pre.pos,pre.r,mid)){
				if(calc(i,pre.l,mid)<=calc(pre.pos,pre.l,mid)){
					lst=pre.l;
				}
				else{
					int l=pre.l,r=pre.r+1;
					while(l<r){
						int id=(l+r)>>1;
						if(calc(i,id,mid)<=calc(pre.pos,id,mid)){
							r=id;
						}
						else{
							l=id+1;
						}
					}
					if(pre.l<l){
						p_list.push_back((Node){pre.l,l-1,pre.pos});
					}
					lst=l;
					break;
				}
			}
			else{
				p_list.push_back(pre);
				break;
			}
		}
		if(lst<=n) p_list.push_back((Node){lst,n,i});
	}
	ans=dp[n]+mid*m;
	return cnt[n];
}
int main(){
	scanf("%d %d",&n,&m);
	for(int i=1;i<=n;i++){
		scanf("%lld",&num[i]);
		sum[i]=sum[i-1]+num[i];
	}
	long long l=-1e9,r=0;
	while(l<r){
		long long mid=Div_2(l+r);
		if(calc_id(mid)>=m){
			r=mid;
		}
		else{
			l=mid+1;
		}
	}
	calc_id(l);
	printf("%lld",ans);
	return 0;
}

4

接下来是一道省选题:林克卡特树

有一棵 \(n\) 个节点的树,第 \(i\) 条边的边权为 \(v_i\)。你需要删除恰好 \(k\) 条边并连接恰好 \(k\) 条边权为 \(0\) 的边使其成为一棵新树,求新树的直径最大值。

\(0 \leq k < n \leq 3 \times 10^5\)\(|v_i| \leq 10^6\)

首先我们考虑转化题意。选择恰好 \(k\) 条边删去,此时我们发现原来的树被分成了 \(k+1\) 个连通块。新树的直径,应为每个连通块的直径之和。所以我们可以得知,原问题等价于从树上选择 \(k+1\) 条不相交的链的边权和最大值。

这个时候显然可以 dp 了,但是 \(k\) 的限制仍然很讨厌。能不能消去 \(k\) 呢?等等,怎么又是这个问题。同样地,令 \(f(x)\) 表示选择 \(x\) 条不相交的链的边权和最大值。发现链太少会有边覆盖不到,链太多就会有很多退化成点的链,合理猜测 \(f\) 是上凸的。那就可以 wqs 二分了。注意为了避免因斜率相同而出错的情况,我们应当选取尽量少的链。

仍然考虑截距 \(g(k)=f(x)-k \times x\),发现其实际意义为对每条链的边权和减 \(k\)。接下来我们就可以进行 dp 了。定义 \(dp_{i,j}\) 表示在以 \(i\) 为根的子树中 \(i\) 的状态为 \(j\),选出若干条链的边权和的最大值。其中 \(dp_{i,0}\) 表示 \(i\) 不选,\(dp_{i,1}\) 表示 \(i\) 单独作为一条链,\(dp_{i,2}\) 表示 \(i\) 为一条链的一个端点,\(dp_{i,3}\) 表示 \(i\) 为子树内一条链的一个中间点。

对于 \(dp_{i,0}\),我们发现其为 \(i\) 的所有儿子任意取的情况。对于 \(dp_{i,1}\),贡献方式和 \(dp_{i,0}\) 差不多。对于 \(dp_{i,2}\),需要选取一个儿子贡献。对于 \(dp_{i,3}\),选择两个儿子进行贡献即可。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
int n,m;
const long long INF=-0x3f3f3f3f3f3f3f3f;
const int N=300000;
struct Node{
	int v;
	long long w;
};
vector<Node> G[N+10];
long long dp[N+10][4],ans;
int cnt[N+10][4];
long long Div_2(long long num){
	if(num%2==0  ||  num>=0){
		return num/2;
	}
	else{
		return num/2-1;
	}
}
struct Vex{
	long long maxn;
	int mint;
};
bool operator <(const Vex &lhs,const Vex &rhs){
	if(lhs.maxn!=rhs.maxn) return lhs.maxn>rhs.maxn;
	else return lhs.mint<rhs.mint;
}
void dfs(int u,int fa,long long mid){
	int cnt_child=0;
	for(int i=0;i<G[u].size();i++){
		int v=G[u][i].v;
		long long w=G[u][i].w;
		if(v!=fa){
			cnt_child++;
			dfs(v,u,mid);
		}
	}
	if(cnt_child==0){
		dp[u][0]=0;
		cnt[u][0]=0;
		dp[u][1]=-mid;
		cnt[u][1]=1;
		dp[u][2]=INF;
		dp[u][3]=INF;
		return ;
	}
	dp[u][0]=0;
	cnt[u][0]=0;
	dp[u][1]=-mid;
	cnt[u][1]=1;
	dp[u][2]=0;
	cnt[u][2]=0;
	dp[u][3]=mid;
	cnt[u][3]=-1;
	vector<Vex> son; 
	for(int i=0;i<G[u].size();i++){
		int v=G[u][i].v;
		long long w=G[u][i].w;
		if(v!=fa){
			long long maxn=INF;
			int mint;
			long long tmp_maxn;
			int tmp_mint;
			if(dp[v][1]>maxn){
				maxn=dp[v][1];
				mint=cnt[v][1];
			} 
			else if(dp[v][1]==maxn){
				mint=min(mint,cnt[v][1]);
			}
			if(dp[v][2]>maxn){
				maxn=dp[v][2];
				mint=cnt[v][2];
			} 
			else if(dp[v][2]==maxn){
				mint=min(mint,cnt[v][2]);
			}
			tmp_maxn=maxn;
			tmp_mint=mint;
			if(dp[v][0]>maxn){
				maxn=dp[v][0];
				mint=cnt[v][0];
			}
			else if(dp[v][0]==maxn){
				mint=min(mint,cnt[v][0]);
			}
			if(dp[v][3]>maxn){
				maxn=dp[v][3];
				mint=cnt[v][3];
			}
			else if(dp[v][3]==maxn){
				mint=min(mint,cnt[v][3]);
			}
			dp[u][0]+=maxn;
			cnt[u][0]+=mint;
			dp[u][1]+=maxn;
			cnt[u][1]+=mint;
			dp[u][2]+=maxn;
			cnt[u][2]+=mint;
			dp[u][3]+=maxn;
			cnt[u][3]+=mint;
			son.push_back((Vex){tmp_maxn+w-maxn,tmp_mint-mint});
		}
	}
	sort(son.begin(),son.end());
	if(son.size()>=2){
		dp[u][2]+=son[0].maxn;
		cnt[u][2]+=son[0].mint;
		dp[u][3]+=son[0].maxn;
		cnt[u][3]+=son[0].mint;
		dp[u][3]+=son[1].maxn;
		cnt[u][3]+=son[1].mint;
	}
	else{
		dp[u][2]+=son[0].maxn;
		cnt[u][2]+=son[0].mint;
		dp[u][3]=INF;
	}
}
int calc_id(long long mid){
	dfs(1,-1,mid);
	long long maxn=INF;
	int mint;
	if(dp[1][1]>maxn){
		maxn=dp[1][1];
		mint=cnt[1][1];
	} 
	else if(dp[1][1]==maxn){
		mint=min(mint,cnt[1][1]);
	}
	if(dp[1][2]>maxn){
		maxn=dp[1][2];
		mint=cnt[1][2];
	} 
	else if(dp[1][2]==maxn){
		mint=min(mint,cnt[1][2]);
	}
	if(dp[1][0]>maxn){
		maxn=dp[1][0];
		mint=cnt[1][0];
	}
	else if(dp[1][0]==maxn){
		mint=min(mint,cnt[1][0]);
	}
	if(dp[1][3]>maxn){
		maxn=dp[1][3];
		mint=cnt[1][3];
	}
	else if(dp[1][3]==maxn){
		mint=min(mint,cnt[1][3]);
	}
	ans=maxn+mid*m;
	return mint;
}
int main(){
	scanf("%d %d",&n,&m);
	m++;
	for(int i=1;i<n;i++){
		int u,v;
		long long w;
		scanf("%d %d %lld",&u,&v,&w);
		G[u].push_back((Node){v,w});
		G[v].push_back((Node){u,w});
	}
	long long l=-1e9,r=1e9;
	while(l<r){
		long long mid=Div_2(l+r);
		if(calc_id(mid)<=m){
			r=mid;
		}
		else{
			l=mid+1;
		}
	}
	calc_id(l);
	printf("%lld",ans);
	return 0;
}

5

最后来看一道 IOI 题:[IOI 2016] aliens,这也是 wqs 二分在国外也被称作 aliens trick 的来源。

有一个 \(m \times m\) 的网格。你需要在网格中选择最多 \(t\) 个字正方形,使得每个正方形的主对角线都在网格的主对角线上,且选出的正方形可以覆盖给定的 \(n\) 个关键点。求选出的正方形面积并的最小值。

\(1 \leq t \leq n \leq 10^5\)\(1 \leq m \leq 10^6\)

首先我们有最基础的观察:网格主对角线两边用正方形覆盖的区域是对称的,此时我们不妨先将所有关键点对称到主对角线一边。

考虑关键点 \((x,y)\),下文中对于所有关键点,令 \(x \leq y\),设当前正方形的两个顶点为 \((a,a)\)\((b,b)\),且 \(a \leq b\),则正方形能覆盖 \((x,y)\) 的充分要条件为 \(a \leq x \leq b\)\(a \leq y \leq b\),整理得到 \(a \leq x\)\(b \geq y\)

这时我们能发现一个重要性质:若存在关键点 \((a,b)\)\((c,d)\),且 \(a \leq c\)\(b \geq d\),则点 \((c,d)\) 是无用的。若存在正方形能覆盖 \((a,b)\),则其必然能覆盖 \((c,d)\)。去掉这些点后,我们按横坐标递增将所有点排序,此时所有点的纵坐标必然递增。我们考虑 dp。

\(dp_{i,j}\) 为使用 \(j\) 个正方形覆盖前 \(i\) 个点,形成的最小面积并。容易发现,对于一个正方形,其覆盖的必然是连续的一段点,此时每个正方形的覆盖都要做到局部的最优。

\[dp_{i,j}=\min\limits_{k=0}^{i-1} (dp_{k,j-1}+w(k+1,i)) \]

对于 \(w(l,r)\) 的计算,具体细节可以见代码。

按照惯例,现在我们需要找到凸性。我们令 \(f(x)\) 为使用恰好 \(x\) 个正方形覆盖所有关键点的最小面积并。容易发现 \(f\) 一定是递减的。同时我们发现,大正方形浪费的面积会更多,不妨猜测 \(f\) 是下凸的。那就可以 wqs 二分了。

由于我不想再复制了,总之你考虑 \(g(k)\) 为每个正方形的贡献减 \(k\),容易发现此时 dp 方程:

\[dp_i=\min\limits_{j=0}^{j-1} (dp_j+w(j+1,i)-k) \]

发现此时的东西很像有决策单调性的样子啊!写一发试试,结果过了喵!

#include<iostream>
#include<cstdio>
#include<vector>
#include<algorithm>
#include<deque>
using namespace std;
const long long INF=0x3f3f3f3f3f3f3f3f;
int n,m,k,x[100010],y[100010],cnt[100010];
vector<int> G[1000010];
long long dp[100010],ans;
long long Div_2(long long num){
	if(num>=0  ||  num%2==0) return num/2;
	else return num/2-1;
}
long long w(int l,int r){
	long long len1=y[r]-x[l]+1;
	long long len2=max(0,y[l-1]-x[l]+1);
	return len1*len1-len2*len2;
}
long long calc(int pos,int i,long long mid){
	return dp[pos]+w(pos+1,i)-mid;
}
struct Node{
	int l,r,pos;
};
int calc_id(long long mid){
	for(int i=1;i<=n;i++){
		dp[i]=INF;
	}
	deque<Node> p_list;
	p_list.push_back((Node){1,n,0});
	for(int i=1;i<=n;i++){
		Node pre=p_list.front();
		p_list.pop_front();
		dp[i]=calc(pre.pos,i,mid);
		cnt[i]=cnt[pre.pos]+1;
		if(pre.l<pre.r){
			p_list.push_front((Node){pre.l+1,pre.r,pre.pos});
		}
		int lst=n+1;
		while(!p_list.empty()){
			pre=p_list.back();
			p_list.pop_back();
			if(calc(i,pre.r,mid)<=calc(pre.pos,pre.r,mid)){
				if(calc(i,pre.l,mid)<=calc(pre.pos,pre.l,mid)){
					lst=pre.l;
				}
				else{
					int l=pre.l,r=pre.r+1;
					while(l<r){
						int id=(l+r)>>1;
						if(calc(i,id,mid)<=calc(pre.pos,id,mid)){
							r=id;
						}
						else{
							l=id+1;
						}
					}
					if(pre.l<l){
						p_list.push_back((Node){pre.l,l-1,pre.pos});
					}
					lst=l;
					break;
				}
			}
			else{
				p_list.push_back(pre);
				break;
			}
		}
		if(lst<=n) p_list.push_back((Node){lst,n,i});
	}
	ans=dp[n]+mid*k;
	return cnt[n];
}
int main(){
	scanf("%d %d %d",&n,&m,&k);
	while(n--){
		int pre_x,pre_y;
		scanf("%d %d",&pre_x,&pre_y);
		pre_x++,pre_y++;
		G[min(pre_x,pre_y)].push_back(max(pre_x,pre_y));
	}
	n++;
	for(int i=1;i<=m;i++){
		if(G[i].size()){
			sort(G[i].begin(),G[i].end());
			int pre_x=i,pre_y=G[i][G[i].size()-1];
			if(pre_y>y[n]){
				n++;
				x[n]=pre_x;
				y[n]=pre_y;
			}
		}
	}
	long long l=-1e12,r=0;
	while(l<r){
		long long mid=Div_2(l+r);
		if(calc_id(mid)>=k){
			r=mid;
		}
		else{
			l=mid+1;
		}
	}
	calc_id(l);
	printf("%lld",ans);
	return 0;
}

总结一下,wqs 二分是一种处理具有凸性的信息的方法。我们通过二分斜率,给一些值加权的方法,去除某个维度的限制。

在 OI 中,你不必严格证明凸性,单调性等内容,如果能够感性理解,在考场上可以直接把结论当作对的。还可以通过打表等手段,观察数值的性质,进而解决问题。

›⩊‹

⪩. .⪨

•ω•

posted @ 2025-10-28 14:50  Oken喵~  阅读(11)  评论(0)    收藏  举报