關於 wqs 二分部分可以參考 跳蛙的博客 或者 原論文,基礎部分這里略過。
wqs 二分的構造解
wqs 二分的本質是二分斜率,尋找切點。假設希望求出值的橫坐標為 \(X\)。但是事實上由於三點共線情況的存在,切點橫坐標不一定恰好等於 \(X\)。
紅線是切線,黃線是 \(x=X\),而 wqs 二分求出的切點可能是綠點。
不過對於一般的 wqs 二分問題,只要知道切線的截距和斜率,可以很容易求出任何切線上的點的坐標。但是有些情況下需要進行構造,上述做法就不太行了。
一種方法是隨機擾動,這種方法在出題人精心構造的情況下復雜度會退化為指數級。
我們需要一種不導致復雜度退化的方法。顯然的一點是,任意一條切線切在凸包上的點,其橫坐標一定構成一個區間。而我們知道,只要最終 \(X\) 落在這個區間內,那么一定存在至少一組解。
顯然對於 wqs 二分能處理的大部分問題,其 dp 過程中的答案仍然是凸的。實際上對於序列問題,我們 dp 的就是每一個前綴的切點,每個切點的坐標是由前面的切點轉移過來的。
那么現在所以對於每一個 dp 的子問題,我們都記錄切線所切的區間。這樣我們可以從最后開始,每次嘗試找到上一個可行的轉移點(可行指的是 \(X\) 要落在其切點的區間內)。
當然,我們大可以不必轉移整個區間。既然我們已經知道了它是一個區間,那么我們直接轉移其最大值與最小值即可:
如上圖,紅點對應的轉移可以直接求出,由此可以推出綠點(要求的解)的轉移。
不妨用 \(L_i,R_i\) 記錄第 \(i\) 個凸包的切線區間,那么我們只需要找到 \(X\in[L_j,R_j]\) 並且可以轉移當前位置的 \(j\) 即可。
Gym102268J Jealous Split
轉化后題意:構造一組方案,將長度為 \(n\) 的序列 \(a_i\) 分割為恰好 \(k\) 段非空的子區間,最小化 \(\displaystyle\sum_{i\leq k}\left(\sum_{j=l_i}^{r_i}a_j\right)^2\)。
使用上述做法就可以構造出一組可行解。
當然這道題還有另一種構造的方法,但是與本文無關。
wqs 二分與費用流
事實上要嚴格證明一個函數是凸的需要不少時間,比如經典的 wqs 二分問題:
給定長度為 \(n\) 序列 \(a_i\),要求選擇恰好 \(k\) 個不相交的非空子區間,使得選中元素的價值和最大。
其實直接證明它的凸性並不容易,或者說並沒有那么顯然。
但是我們知道的一點是,費用流模型的函數一定是凸的。這里費用流函數 \(F(x)\) 指的是欽定源點 \(S\) 向匯點 \(T\) 流恰好 \(x\) 的流量下的最小費用。
也就是說,如果我們能夠將上述問題建出流量為 \(k\) 費用流模型,那么自然證明了答案關於 \(k\) 是凸的。
而上述問題很容易建出費用流模型:
自然就證明了答案是一個凸性的函數。
事實上,大部分 wqs 二分的問題都可以建出費用流模型(當然復雜度不一定正確)。
舉個例子:
[八省聯考2018]林克卡特樹
事實上這題相當於點有限流,每次可以流任意一條簡單路徑。
那費用流模型也很顯然,直接將每個點拆成入點和出點,一條樹邊對應出點向入點連邊。類似於上圖的形式(不過擴展到了樹上而已)
然后源點向每個點,每個點向匯點分別連 \((\inf,0)\) 表示可以任選兩個點作為起點終點。
然后就可以套上 wqs 二分了,之后 dp 內容不是本文重點。
閔可夫斯基和
標准定義:
似乎有 \(|C|=|A||B|\) ?但是實際上我們需要的只是 \(C\) 點集的外層凸包,可以證明一定有 \(|C|\leq |A|+|B|\)。
舉個例子:
需要將上述兩個凸包做閔可夫斯基和。可以直接將其中一個凸包的每個頂點換成另一個凸包,然后保留外層凸包:
可以發現凸包上只有 \(8\) 個點(不算共線的情況)。
事實上可以證明的是,從一個凸包跳到另一個凸包的線(即綠色部分)至多只有 \(|A|\) 條,凸包內部轉移的線(灰色部分)至多只有 \(|B|\) 條。
下凸殼閔可夫斯基和
下凸殼就是凸包留下下半部分的點集,可以看成被下方平行光照射到的部分。
下凸殼閔可夫斯基和當然可以仿照凸包的寫法。但是這里有一個更方便的寫法:
考慮下凸殼的一個性質:即任意 \(x\) 坐標至多對應一個點。
把這些點全部描述出來,我們完全可以用一個 \(O(\text{值域})\) 的數組記錄這些點值,這樣就不必描述邊的信息。比如上述的凸包可以用 \(\{2,1,0,\frac 14,\frac 12,\frac 34,1,3\}\) 表示。
如果我們求出它的差分數組,由於原數組是一個凸包,所以是單調不降的。
考慮求閔可夫斯基和的過程,本質上就是將兩個差分數組歸並排序一下。
演示:
當然我們並不需要真的求出差分數組,可以直接用兩個指針指向需要合並的凸包,每次將斜率較大的點加入新的凸包即可。復雜度 \(O(|A|+|B|)\)。
代碼十分簡潔:
#define S(a) ((int)a.size())
typedef long long ll;
typedef vector<ll> vec;
vec operator +(const vec &a,const vec &b)
{
if(!S(a) || !S(b)) return vec();
vec c;c.reserve(S(a)+S(b));
c.push_back(a[0]+b[0]);
int p=1,q=1;ll w=a[0]+b[0];
while(p<S(a) || q<S(b))
{
if(q>=S(b) || (p<S(a) && a[p-1]-a[p]>b[q-1]-b[q])) c.push_back(w+=a[p]-a[p-1]),p++;
else c.push_back(w+=b[q]-b[q-1]),q++;
}
return c;
}
同樣,通過這種記錄的方式也可以很容易處理下凸殼的和(即兩個下凸殼的並)。由於我們已經證明了取並后結果一定是一個下凸殼,故直接將每個 \(x\) 軸對應的位置取 \(\min\) 即可。
閔可夫斯基和與 wqs 二分
可以觀察到,wqs 二分的函數圖像一定是一個上凸殼/下凸殼,而橫坐標的值域 \(k\) 通常等於點數,故可以用上述記錄每個 \(x\) 對應點值的方式記錄。
觀察朴素的 dp 轉移方程,其中 \(j\)(即記錄選了多少條邊)一維必然直接相加,即有 \(H(i+j)=\max(F(i)+G(j))\),可以發現這恰好對應上述的閔可夫斯基和與上/下凸包求並。與 wqs 二分不同的是,這樣維護出來的凸包實際上對於每一個 \(x\in[0,n]\) 都求出了答案。
處理多次詢問
如果將上面那道 wqs 二分的題目改成多組詢問,即:
給定長度為 \(n\) 序列 \(a_i\),要求對於每個 \(k\in[1,n]\) ,求出恰好選 \(k\) 個不相交的非空子區間,最大化選中元素的價值和。
這樣 wqs 二分就不太可做了,考慮用閔可夫斯基和處理。
具體來說,先考慮一種 naive 的 dp:令 \(f _ {l,r,k,0/1,0/1}\) 表示區間 \(l,r\) 選 \(k\) 個,其中左端點有/無區間覆蓋,右端點有/無區間覆蓋。
為什么要加上后兩維呢?因為我們處理區間合並的時候其實是可以合並中間的區間的,即:
直接這樣做當然是 \(O(n^3)\) 的。但是如果我們把上述 dp 寫成 \(f _ {l,r,0/1,0/1}(k)\) 的函數形式,可以發現這一定是一個凸函數。
所以我們任取一個 \(p\in[l,r)\),都可以在 \(O(r-l)\) 的時間用閔可夫斯基和求出函數。這恰好就是歸並排序的復雜度分析。
所以仿照歸並排序的思想,我們取 \(p=\lfloor\frac{l+r}2\rfloor\),就可以在 \(O(n\log n)\) 內對所有 \(k\in[0,n]\) 求出答案。
Gym102331J Jiry Matchings
題意
給定一顆大小為 \(n\) 的樹,邊有邊權表示匹配的價值。問對於 \(k\in[1,n]\) 求出恰好 \(k\) 匹配的價值之和最大值。無解輸出 \(\text{?}\)。
題解
既然是匹配,顯然可以轉化為費用流模型,故答案關於 \(k\) 的凸性顯然。
考慮如果只對某一個 \(k\) 求答案怎么做。由於是答案是凸的,故可以套用 wqs 二分,二分每多選一個匹配的額外價值 \(w\),然后用 \(f_{i,0/1}\) 表示 \(i\) 子樹中任意匹配的最大價值,其中 \(i\) 選/不選。轉移顯然。
那么對於所有 \(k\) 求,顯然的想法就是閔可夫斯基和,但是有一個問題是,閔可夫斯基和是 \(O(|A|+|B|)\) 而不是被合並集合大小,意味着它並不能套用啟發式合並的思路。
考慮仿照 樹上背包分治NTT 做法,我們對原樹輕重鏈剖分,然后對於輕鏈直接暴力合並,復雜度 \(O(siz)\)。
對於重鏈,先合並所有輕鏈的結果,然后做同上面那道題的分治處理,復雜度 \(O(siz\log siz)\)。其中在每次重鏈處理中,子樹內的點會被合並 \(O(\log n)\) 次,一個點到根有 \(O(\log n)\) 條輕鏈,故總復雜度 \(O(n\log^2 n)\)。
事實上仿照 樹上背包分治NTT 的思路按權分治做到 \(O(n\log n)\)。具體來說合並的時候找到一個分治點使得兩邊凸包大小之和盡量接近,這樣每次重鏈合並凸包大小至少 \(\times 1.5\),復雜度 \(O(n\log_{1.5} n)\)。
關鍵代碼:
void dfs1(int u,int p)
{
fa[u]=p;siz[u]=1;
for(int i=head[u];i;i=nxt[i])
{
int v=to[i];
if(v==p) continue;
fw[v]=w[i];
dfs1(v,u);siz[u]+=siz[v];
if(siz[v]>siz[son[u]]) son[u]=v;
}
}
struct matr{
vec g[2][2];//g[0/1][0/1]:左邊/右邊 是否強制不匹配
//g[0][x]>=g[1][x] , g[x][0]>=g[x][1]
vec* operator [](int a){return g[a];}
matr(){}
matr(int u){g[0][0]=f[u][0];g[0][1]=g[1][0]=f[u][1];}
};
matr merge(const matr &a,const matr &b,int w)
{
matr c;
For01(X) For01(x) For01(y) For01(Y)
if(x && y) c[X][Y]=max(c[X][Y],inc(a.g[X][x]+b.g[y][Y],1,w));
else c[X][Y]=max(c[X][Y],a.g[X][x]+b.g[y][Y]);
return c;
}
matr solve(int l,int r,vec &g)
{
if(l==r) return matr(g[l]);
int s=0,mid=l;
for(int i=l;i<=r;i++) s+=f[g[i]][0].size();
int w=f[g[l]][0].size();
while(mid<r-1 && w*2<=s) w+=f[g[++mid]][0].size();
return merge(solve(l,mid,g),solve(mid+1,r,g),fw[g[mid]]);
}
vec g[N];
void dfs2(int u,int top)
{
f[u][0].pb(0);f[u][1].pb(0);
for(int i=head[u];i;i=nxt[i])
{
int v=to[i];
if(v==son[u] || v==fa[u]) continue;
dfs2(v,v);
}
if(u==1) return;
if(son[u]) dfs2(son[u],top);
g[top].push_back(u);
if(u!=top) return;
auto s=solve(0,S(g[top])-1,g[top]);
int p=fa[u];
f[p][0]=max(f[p][0]+s[0][0],inc(f[p][1]+s[0][1],1,fw[u]));
f[p][1]=f[p][1]+s[0][0];
g[top].clear();f[u][0].clear();f[u][1].clear();
}
處理區間詢問
Gym102331H Honorable Mention
題意
給定長度為 \(n\) 序列 \(a_i\),\(q\) 次詢問 \([l_i,r_i,k_i]\),求出 \([l_i,r_i]\) 中選恰好選 \(k_i\) 個不相交的非空子區間,選中元素的價值和的最大值。
題解
沒想到吧居然是同一場比賽的。
考慮線段樹分治,然后保留分治過程每一個子區間的凸包。
一個顯然的想法是得到每次詢問的 \(O(\log n)\) 個區間,將每個區間當成一個物品,即在上面花費 \(k\) 個區間會得到 \(w_k\) 的價值。
由於最后的答案是凸的,故可以 wqs 二分額外價值 \(w\),然后在每個區間上二分切點。復雜度 \(O(q\log^2 n\log V)\)(二分答案+線段樹分治+二分切點),由於區間左右端點的覆蓋狀態還需要枚舉,算法自帶 \(16\) 倍大常數,難以通過。
可以發現的是,線段樹上總點數只有 \(O(n\log n)\) 個,而總詢問次數卻有 \(O(q\log^2 n)\) 次,可以發現二分答案十分浪費。
假如每次詢問的斜率 \(w\) 是單調遞增的,那么可以直接在每個凸包上維護一個指針,暴力移動到切點的位置,這樣總復雜度等於總點數即 \(O(n\log n)\)。
那么一個優化就是詢問離線,然后找到每個詢問需要查詢的斜率,按該斜率排序,每一次 reset 凸包的指針位置,每次查詢仿照上述均攤的做法。
總二分次數是 \(O(\log V)\),故指針移動總次數 \(O(n\log n\log V)\),總詢問次數 \(O(q\log n\log V)\),排序復雜度 \(O(q\log q\log V)\)。
總時間復雜度 \(O((q+n)\log n\log V)\)。
關鍵代碼:
void solve(int u,int l,int r,int L,int R,int o)
{
if(L<=l && r<=R)
{
node h[2]={node(),node()};
For01(x) For01(y)
{
int &p=pos[u][x][y];
for(;p && f[u][x][y][p]-f[u][x][y][p-1]<o;p--);
ll v=f[u][x][y][p];
h[y]=max(h[y],g[0]+node(v-1ll*o*(p+(x||y)),p+(x||y)));
h[y]=max(h[y],g[1]+node(v-1ll*o*(p+(x||y)-x),p+(x||y)-x));
}
g[0]=h[0];g[1]=h[1];
return;
}
int mid=(l+r)>>1;
if(L<=mid) solve(u<<1,l,mid,L,R,o);
if(R>mid) solve(u<<1|1,mid+1,r,L,R,o);
}
for(int _=50;_;_--)
{
clear(1,1,n);
sort(q+1,q+m+1,[&](ques a,ques b){return (a.l+a.r)/2<(b.l+b.r)/2;});
for(int i=1;i<=m;i++)
if(q[i].l<=q[i].r)
{
int mid=(q[i].l+q[i].r)>>1;
g[0]=node(0,0);g[1]=node();
solve(1,1,n,l[q[i].u],r[q[i].u],mid);
node t=max(g[0],g[1]);
if(t.u>=w[q[i].u]) q[i].l=mid+1,res[q[i].u]=t.v+1ll*mid*w[q[i].u];
else q[i].r=mid-1;
if(t.u==w[q[i].u]) q[i].r=mid;
}
}