1. 程式人生 > 實用技巧 >wqs二分學習筆記

wqs二分學習筆記

怎麼總是因為一場模擬賽來填坑啊 /kel

Ubuntu 沒有幾何畫板(悲)

適用問題

題目型別:給定 \(n\) 個物品,要求剛好選擇 \(m\) 個,最大/小化權值。

特點:如果沒有限制,能夠較簡單地求出最優解

使用前提:設取 \(k\) 個物品的最優決策是 \(f(k)\) ,那麼函式 \(y=f(x)\) 必須具有凹凸性(即凹/凸函式,或者你願意說影象是個凸包也可以)

演算法主體

以下預設討論的是上凸包。

首先,來考慮對於一個固定的 \(m\) ,如何求出 \(f(m)\) .

對於一個上凸包,有一個顯然的性質(定義):隨著 \(x\) 軸座標增大,斜率單調遞減。

這樣一個具有單調性的東西,容易想到二分。我們可以二分一個斜率 \(k\)

,然後找到斜率為 \(k\) 的直線和這個凸包相切的切點。就像這樣:

大眼觀察得 隨著 \(k\) 的減小,切點會向右移動。於是可以二分 \(k\) 直到切點橫座標為 \(m\) ,那麼其縱座標就是答案。

現在的問題就是如何判定切點位置。

考慮多條斜率為 \(k\) 的直線,如下圖:

顯然,過切點的直線在所有直線中處於最上方,也就是說,直線在 \(y\) 軸上的截距最大。

設截距為 \(b\) ,那麼直線方程就是 \(y=kx+b\) . 顯然,這等價於給每個物品的權值加上 \(k\) 之後,選了 \(x\) 個物品的總權值。因此,我們可以直接將所有物品的權值加上 \(k\) ,在沒有個數限制的情形下計算得到最大的 \(x\)

,然後由於要求的是切點橫座標為 \(m\) 的情況,所以可以根據目前的 \(x\)\(k\) 進行調整,如果 \(x\)\(m\) 左邊,那麼斜率要減小;反之增大。(當然,這裡討論的是上凸包)

(由於有前提:如果沒有限制,能夠較簡單地求出最優解 ,所以這個 check 是很容易能夠在較低的複雜度內實現的)

思路還是很好理解的,結合影象更佳。但是口胡是不行的!我們要做題!程式碼說明一切

Tree I

給定一個無向帶權連通圖,每條邊是黑/白色,求一棵恰好有 \(need\) 的條白邊的最小生成樹。

Solution

這道題滿足了 “恰好 \(k\) 個” 的限制,且在沒有限制的情況下可以輕鬆地用 Kruskal 求解,因此滿足了使用 WQS 二分的前提。

模板題,直接二分然後把所有白邊加上這個權值就好了。

有一個細節:如果你跟我一樣,寫的是整數二分,那麼請注意,對於邊權相等的情況,需要強制一個 “優先選黑/白邊” 的條件,然後根據相應的情況在二分條件中寫 >= 或者 <= .

//白邊優先的情況
bool operator < ( const Edge &tmp ) const { return (val^tmp.val) ? val<tmp.val : typ<tmp.typ; }

if ( tmp>=k ) l=mid+1,ans=mid;
else r=mid-1;

//黑邊優先的情況
bool operator < ( const Edge &tmp ) const { return (val^tmp.val) ? val<tmp.val : typ>tmp.typ; }

if ( tmp<=k ) r=mid-1,ans=mid;
else l=mid+1;

原理的話就是儘可能多選黑/白邊,以避免邊權相等時出現奇怪的不可控情況,反正只要相等時有個順序就行了。

//Author: RingweEH
const int N=5e4+10,M=1e5+10;
struct Edge
{
	int fro,to,val,typ;
	bool operator < ( const Edge &tmp ) const { return (val^tmp.val) ? val<tmp.val : typ<tmp.typ; }
}e[M];
int n,m,k,fa[N];

int find( int x )
{
	return (x==fa[x]) ? x : fa[x]=find(fa[x]);
}

int check( int &funcx,int del )
{
	for ( int i=1; i<=m; i++ )
		if ( e[i].typ==0 ) e[i].val+=del;
	sort( e+1,e+1+m );
	for ( int i=1; i<=n; i++ )
		fa[i]=i;
	int tot=0,cnt=0,sum=0;
	for ( int i=1; i<=m; i++ )
	{
		int u=e[i].fro,v=e[i].to; u=find(u),v=find(v);
		if ( u==v ) continue;
		fa[u]=v; cnt++; tot+=(e[i].typ==0); sum+=e[i].val;
		if ( cnt==(n-1) ) break;
	}
	for ( int i=1; i<=m; i++ )
		if ( e[i].typ==0 ) e[i].val-=del;
	funcx=sum; return tot;
}

int main()
{
	n=read(); m=read(); k=read();
	for ( int i=1; i<=m; i++ )
		e[i].fro=read()+1,e[i].to=read()+1,e[i].val=read(),e[i].typ=read();

	int l=-100,r=100,ans=0; int res=0;
	while ( l<=r ) 
	{
		int mid=(l+r)>>1,tmp=check( res,mid );
		if ( tmp>=k ) l=mid+1,ans=mid;
		else r=mid-1;
	}

	check(res,ans); res=res-ans*k;
	printf( "%d\n",res );

    return 0;
}

最小度限制生成樹

給定一個 \(n\)\(m\) 邊帶權無向圖,求一棵點 \(s\) 正好連了 \(k\) 條邊的最小生成樹。

Solution

題面中滿足了 “正好 \(k\) 個” 的條件,且沒有限制的最小生成樹很容易求解,前提充分。

顯然這裡的物品就是和 \(s\) 相連的所有邊了。二分 \(k\) 給這些邊加上就行。

但是注意資料範圍:\(1\leq n \le 5\times 10^4,1\leq m \le 5\times 10^5.\) 如果是 \(\Omicron(m\log ^2 )\) 顯然非常的危。

所以可以加一點點 小優化

最開始把 \(s\) 連的邊和其他邊分開,排個序,然後跑最小生成樹的時候歸併排序即可。

顯然這樣只需要合併一次,而且所有相連的邊增加同一個值,順序不變。

程式碼需要注意一些細節和判斷無解,如:

  • 排序的時候相同權值,和 \(s\) 相連優先
  • 如果沒有改變權值也不能找出生成樹,無解
  • 跑出答案之後再找一遍生成樹,如果無解或者 \(s\) 的度數不等於 \(k\) 也是無解
  • 沒給邊權範圍就離譜,但是我也不知道 int 能不能過去
//Author: RingweEH
const int N=5e4+10,M=5e5+10,INF=1e9;
struct Edge
{
	int fro,to; ll val;
	bool operator < ( const Edge &tmp ) const { return val<tmp.val; }
}e1[M],e2[M],e[M];
int n,m,s,k,tot1=0,tot2=0,fa[N]; 
ll nowsum;

bool has( Edge x )
{
	if ( x.fro==s ) return 1;
	if ( x.to==s ) return 1;
	return 0;
}

void Merge_Sort()
{
	int i=0,j=0,tot=0;
	while ( (i<tot1) && (j<tot2) ) 
	{
		Edge t1=e1[i+1],t2=e2[j+1];
		if ( (t1.val<t2.val) || ((t1.val==t2.val) && (has(t1))) ) e[++tot]=t1,i++;
		else e[++tot]=t2,j++;
	}
	while ( i<tot1 ) e[++tot]=e1[++i];
	while ( j<tot2 ) e[++tot]=e2[++j];
}

int find( int x )
{
	return (x==fa[x]) ? x : fa[x]=find(fa[x]);
}

int Kruskal()
{
	for ( int i=1; i<=n; i++ )
		fa[i]=i;
	int cnt=0,cnts=0; ll sum=0;
	for ( int i=1; i<=m; i++ )
	{
		int u=e[i].fro,v=e[i].to; ll w=e[i].val; 
		u=find(u); v=find(v);
		if ( u==v ) continue;
		fa[u]=v; sum+=w; cnt++;
		if ( has(e[i])) cnts++;
		if ( cnt==(n-1) ) break;
	}
	if ( cnt<(n-1) ) return -1;
	nowsum=sum; return cnts;
}

int check( int x )
{
	for ( int i=1; i<=tot1; i++ )
		e1[i].val+=x;
	Merge_Sort();
	int res=Kruskal();
	for ( int i=1; i<=tot1; i++ )
		e1[i].val-=x;
	return res;
}

int main()
{
	n=read(); m=read(); s=read(); k=read();
	for ( int i=1; i<=m; i++ )
	{
		int u=read(),v=read(),w=read();
		if ( u==s ) { e1[++tot1].fro=u,e1[tot1].to=v; e1[tot1].val=w; }
		else if ( v==s ) { e1[++tot1].fro=v,e1[tot1].to=u; e1[tot1].val=w; }
		else { e2[++tot2].fro=u; e2[tot2].to=v; e2[tot2].val=w; }
	}

	sort( e1+1,e1+1+tot1 ); sort( e2+1,e2+1+tot2 );
	if ( check(0)==-1 ) { printf( "Impossible\n" ); return 0; }
	int l=-INF,r=INF,ans=-INF;
	while ( l<=r )
	{
		int mid=(l+r)>>1;
		if ( check(mid)>=k ) l=mid+1,ans=max(ans,mid);
		else r=mid-1;
	}

	int now=check(ans);
	if ( (now==-1) || (now^k) ) printf( "Impossible\n" );
	else 
	{
		ll ans_sum=nowsum-ans*k;
		printf( "%lld\n",ans_sum );
	}
    return 0;
}

April Fools' Problem (hard)

\(n\) 道題, 第 \(i\) 天可以花費 \(a_i\) 準備一道題, 花費 \(b_i\) 列印一道題, 每天最多準備一道, 最多列印一道, 準備的題可以留到以後列印, 求最少花費使得準備並列印 \(k\) 道題。\(k,n\leq 5e5\) .

Solution

看到 \(k\) 個東西,就能想到 wqs二分了。

顯然,斜率單調不降,因此這題是個下凸包,把每個物品的權值減去 \(mid\) 即可。然後來考慮怎麼寫 checker .

對於每一天,有三種選擇:

  1. 跳過這一天
  2. 準備一道題(將可選項中加入一個 \(a_i\)
  3. 打印出現過的最小的一個 \(a_i\) (用 \(b_i\) 和之前的 \(a_i\) 配對)

顯然這個東西可以用優先佇列維護。每個 \(b_i\) 有兩種選擇:

  1. 和某個新的 \(a_i\) 配對,取堆頂即可。
  2. 替換之前某個 \(a_i\) 所配的 \(b_i\) ,這個就直接類似反悔貪心一樣搞,往堆裡面加入一個 \(b_i-del\) 即可,這樣當你訪問到 \(b_j\) 的時候,\(b_j-del-val=b_j-del-b_i+del=b_j-b_i\) ,就相當於加入差值了。

那麼就做完了。寫WQS第一次一遍AC,我不行

奉送雙倍經驗:[PA2013]Raper

//Author: RingweEH
const int N=5e5+10;
struct Node
{
	ll val; int typ;
	Node ( ll _val=0,int _typ=0 ) { val=_val; typ=_typ; }
	bool operator < ( const Node &tmp ) const { return val<tmp.val; }
};
int n,k;
ll a[N],b[N],sav_sum=0;
priority_queue<Node> q;

int check( ll del )
{
	ll sum=0;
	for ( int i=1; i<=n; i++ )
	{
		Node t(-a[i],0); q.push(t);
		Node now=q.top();
		ll tmp=b[i]-del-now.val;
		if ( tmp<0 )
		{
			sum+=tmp; q.pop();
			q.push( Node(b[i]-del,1) );
		}
	}
	int cnt=0; sav_sum=sum;
	while ( !q.empty() ) { cnt+=(q.top().typ==1); q.pop(); }
	return cnt;
}

int main()
{
	n=read(); k=read();
	for ( int i=1; i<=n; i++ )
		a[i]=read();
	for ( int i=1; i<=n; i++ )
		b[i]=read();

	ll l=0,r=3e9,ans=0;
	while ( l<=r )
	{
		ll mid=(l+r)>>1; int now=check(mid);
		if ( now<=k ) l=mid+1,ans=mid;
		else r=mid-1;
	}

	check(ans);
	printf( "%lld\n",sav_sum+ans*k );

    return 0;
}

忘情

給定一個式子,表示序列的值:

\[\frac{\left((\sum\limits_{i=1}^{n}x_i×\bar x)+\bar x\right)^2}{\bar x^2} \]

給定一個長度為 \(n\) 的序列,要求分成 \(m\) 段且每段的值之和最小,求最小值。

\(m\leq n\leq 1e5,1\leq x_i\leq 1000\) .

Solution

這式子純粹是來噁心人的qwq

In fact , 上下除以 \(\bar x\) 就會變成:

\[\left(\sum\limits_{i=1}^{n}x_i+1\right)^2 \]

這樣就清新多了。而且顯然平方里面的東西可以字首和預處理出來。

那麼現在就是 DP 一眼題:

\[f[i]=\min\{f[j]+(S[i]-S[j]+1)^2\} \]

但是可惜的是,這是個 \(\Omicron(n^2)\) 的式子……考慮優化。

然後發現,這個式子幾乎跟 這道斜優板子 一模一樣!(不會斜優請自行前往)

來推個式子:

\[f[i]=f[j]+S[i]^2-2S[i]S[j]+S[j]^2+2S[i]-2S[j]+1=>y=kx+b\\\\ -f[j]-S[j]^2+2S[j]=-2S[i]\cdot S[j]+(S[i]^2-f[i]+2S[i]+1)\\\\ f[j]+S[j]^2-2S[j]=2S[i]S[j]+(f[i]-S[i]^2-2S[i]-1) \]

斜率 \(k=2S[i]\)\(x=S[j]\)\(b=f[i]-S[i]^2-2S[i]+1\)\(y=f[j]+S[j]^2-2S[j]\) .

顯然,斜率單增,因此最優決策點單增,可以決策單調性再優化,直接一個單調佇列維護就好了。

然後這個 WQS二分 也是個下凸包的板子。

板子套板子.jpg

我有問題 我一開始寫成上凸包了 然後又沒開 long long

//Author: RingweEH
const int N=1e5+10;
int n,m,cnt_block[N],q[N];
ll f[N],S[N];
ll X( ll num ) { return S[num]; }
ll Y( ll num ) { return f[num]+S[num]*S[num]-2*S[num]; }
db slope( ll t1,ll t2 ) { return (db)(Y(t2)-Y(t1))/(X(t2)-X(t1)); }

int check( ll del )
{
	memset( f,0x3f,sizeof(f) ); memset( cnt_block,0,sizeof(cnt_block) );
	int head=1,tail=0; q[++tail]=f[0]=0;
	for ( int i=1; i<=n; i++ )
	{
		while ( head<tail && slope(q[head],q[head+1])<2*S[i] ) head++;
		f[i]=f[q[head]]+(S[i]-S[q[head]]+1)*(S[i]-S[q[head]]+1)+del; 
		cnt_block[i]=cnt_block[q[head]]+1;
		while ( head<tail && slope(q[tail-1],q[tail])>slope(q[tail-1],i) ) tail--;
		q[++tail]=i;
	}
	return cnt_block[n];
}

int main()
{
	n=read(); m=read();
	for ( int i=1; i<=n; i++ )
		S[i]=read();

	for ( int i=2; i<=n; i++ )
		S[i]+=S[i-1];
	ll l=0,r=1e16,ans=0;
	while ( l<=r )
	{
		ll mid=(l+r)>>1;
		if ( check(mid)<=m ) r=mid-1,ans=f[n]-mid*m;
		else l=mid+1;
	}

	printf( "%lld\n",ans );
    return 0;
}

林克卡特樹

給定一棵 \(n\) 點帶權樹,去掉其中 \(k\) 條邊,再加上 \(k\) 條邊權為 \(0\) 的邊。可以任意選擇兩點 \(p,q\) ,求 \(p,q\) 樹上路徑的邊權和的最大值。求這個值。

\(1\leq n\leq 3e5,0\leq k\leq 3e5,k<n,|v_i|\leq 1e6\)

Solution

將一棵樹刪去 \(k\) 條邊,會出現 \(k+1\) 個連通塊,而新的邊邊權為 \(0\) ,對答案沒有貢獻,也就是說,我們只需要求出每個連通塊內的直徑即可。

放回原樹上,其實我們並不用真的刪去 \(k\) 條邊,而是轉化成選擇了 \(k+1\) 條不相鄰的鏈/點(也就是刪完之後每個連通塊的直徑)。

這個問題顯然可以用 DP 解決。設 \(f[u][i]\) 表示子樹 \(u\) 中選擇了 \(i\) 條鏈的最大價值。

然而這樣好像並不利於轉移 考慮一些特殊性質。

注意到最後的鏈是不相交的,因此每個點的度數至多為 \(2\) . 不妨再增設一維狀態:\(f[0/1/2][u][i]\) 表示點 \(u\) 的度數。設當前子樹為 \(v\) ,考慮三種情況:

  1. \(u\) 度數為 \(0\) . \(f[0][u][i]=\max(f[0][u][j]+\max(f[0/1/2][v][i-j]))\)
  2. \(u\) 度數為 \(1\),說明點 \(u\) 處是一條鏈的端點,\(f[1][u][i]=\max(f[1][u][j]+f[0][v][i-j],f[0][u][j]+f[1][v][i-j]+w(u,v))\)
  3. \(u\) 度數為 \(2\) ,說明點 \(u\) 處被一條鏈經過,\(f[2][u][i]=\max(f[2][u][j]+f[0][v][i-j],f[1][u][j]+f[1][v][i-j+1]+w(u,v))\)

這樣就完成了 DP 部分。顯然,這樣的時間複雜度是 \(\Omicron(nk)\) ,因為還要列舉一個 \(j\) .

那麼現在就可以往上套 WQS二分 了。此時的函式值 \(f(x)=\max(f[0/1/2][rt][x])\) .

注意到,每增加一條鏈,有兩種方式:

  1. 再找一條鏈
  2. 拆開一條鏈

顯然,每一次操作都是選擇當前的最優解,新的操作不優於上一個,因此函式影象是上凸包。

那麼在 DP 外套上一個 WQS二分,去掉關於每個點選了幾條鏈的次數限制,時間複雜度 \(\Omicron(n)\) ,總時間複雜度 \(\Omicron(n\log k)\) ,可以通過本題。

洛谷 O2 第一頁了w 然而 LOJ 上已經排不上號了

實現的時候有一些細節:

  1. 合併兩條鏈的時候要補上多減了一次的代價
  2. 最後要對“只選一個點”的情況取 \(\max\) .
  3. DFS 開頭注意清空,對於 \(f[0][u]\) 的情況是 \(0\) ,但是另外兩個是負權。
  4. 記錄選的鏈數挺麻煩的,建議直接寫結構體過載
//Author: RingweEH
const int N=3e5+10,INF=1e9;
struct Edge
{
	int to,nxt; ll val;
}e[N<<1];
struct Node
{
	ll x; int cnt;
	Node ( ll _x=0,int _cnt=0 ) : x(_x),cnt(_cnt) {}
	Node operator + ( const Node &tmp ) const { return Node(x+tmp.x,cnt+tmp.cnt); }
	bool operator < ( const Node &tmp ) const { return ( x<tmp.x || (x==tmp.x && cnt<tmp.cnt) ); }
	void clear() { x=-INF; cnt=-INF; }
}f[N][3];
int n,k,tot=0,head[N];

void add( int u,int v,ll w )
{
	e[++tot].to=v; e[tot].nxt=head[u]; head[u]=tot; e[tot].val=w;
}

void dfs( int u,int fa,ll del )
{
	f[u][0]=Node(0,0); f[u][1].clear(); f[u][2].clear();
	for ( int i=head[u]; i; i=e[i].nxt )
	{
		int v=e[i].to;
		if ( v==fa ) continue;
		dfs( v,u,del );
		f[u][2]=max( f[u][2]+f[v][0],f[u][1]+f[v][1]+Node(e[i].val+del,-1) );
		f[u][1]=max( f[u][0]+f[v][1]+Node(e[i].val,0),f[u][1]+f[v][0] );
		f[u][0]=f[u][0]+f[v][0];
	}
	f[u][1]=max( f[u][1],f[u][0]+Node(-del,1) );
	f[u][0]=max( f[u][0],max(f[u][1],f[u][2]) );
}


int main()
{
	n=read(); k=read(); k++;
	for ( int i=1; i<n; i++ )
	{
		int u=read(),v=read(); ll w=read();
		add( u,v,w ); add( v,u,w );
	}
	
	ll l=-INF,r=INF,ans=0;
	while ( l<=r )
	{
		ll mid=(l+r)>>1; dfs( 1,0,mid );
		if ( f[1][0].cnt>=k ) l=mid+1,ans=f[1][0].x+k*mid;
		else r=mid-1;
	}
	
	printf( "%lld\n",ans );
	return 0;
}

What's More

事實上,WQS二分是可以優化費用流的!沒想到吧

考慮這樣一個經典問題:

給定一個長度為 \(n\) 的序列 \(a\) ,要求超出恰好 \(k\) 個不相交的連續子序列,使得和最大。

複雜度要求:\(\Omicron(n\log n)\)\(n,k\) 同級。

事實上,這是個費用流模型。

對於序列中每個點,拆分成兩個點 \(i,i'\) ,連一條 \(i\to i'\) ,流量為 1,費用 \(a_i\) 的邊。

對於每個 \(i\) ,連 \(S\to i\) ,流量為 1,費用為 0 .

對於每個 \(i'\) ,連 \(i'\to T\) ,流量為 1,費用為 0 .

對於相鄰點 \(i,i+1\) ,連 \(i'\to i+1\) ,流量為 1,費用為 0.

顯然這樣每次沿著最大費用路徑單路增廣一次,就相當於選擇了原問題的一個最大連續子序列。

增廣 \(k\) 次就是答案,由於有反向邊,所以不會出現區間相交的情況。

然後就有兩種方法:

第一,資料結構優化。

把模型放到原問題上,每次增廣就是求全域性的最大連續子序列和,然後取反。

那麼可以用線段樹維護這個操作,複雜度 \(\Omicron(k\log n)\) .

第二,考慮特殊性質。

由於每次單路增廣的是最長路,那麼增廣之後的網路顯然是殘餘網路,每次得到的費用會比上一次少。

也就是說,增廣 \(x\) 次後的流量 \(f(x)\) 是個上凸包。

事實上 \(f(x)\) 就是選了 \(x\) 個不相交的連續子序列的最大和。

那麼到這裡就和上面的WQS二分重合了。

後記

其實難度主要是想到用這個東西,和 checker 裡面的東西吧,正經 WQS二分並不難寫。

WQS學習文章/圖源:wqs二分詳解

費用流部分來源於: wqs二分/dp凸優化

大部分題目來源:wqs二分學習筆記