1. 程式人生 > >[學習筆記] CDQ分治&整體二分

[學習筆記] CDQ分治&整體二分

這兩個東西好像都是離線騙分大法...

不過其實這兩個東西並不是一樣的...

雖然程式碼長得比較像

CDQ分治

基本思想

其實CDQ分治的基本思想挺簡單的...

大概思路就是長這樣的:

  1. 程式得到一個有序的操作/查詢序列$[l,r)$ (於是就不能線上了QAQ)
  2. 將這些操作分成兩部分$[l,mid)$和$[mid,r)$遞迴下去處理. 顯然直接分下去一定還是有序的於是我們不用管它
  3. 計算$[l,mid)$中的操作對$[mid,r)$的查詢的貢獻. 也就是用左半部分的子問題輔助解決右半部分的子問題

和普通分治的區別其實就在於普通分治不會讓$[l,mid)$對$[mid,r)$產生貢獻

個人感覺左閉右開寫起來比較方便還能減少奇奇怪怪的$\pm 1$計算

不過有一點需要注意, 根據主定理, 為了保證複雜度, 計算貢獻的過程的時間複雜度必須只和當前分治區間長度有關, 如果和總長有關的話就GG了...

正確性

其實這個CDQ的過程就像線段樹分治

我們考慮CDQ分治中產生的分治線段樹. 由於這是個樹所以每對葉子結點(操作/查詢)都有一個唯一的LCA. 而一個葉子對$(o,q)$會對最終輸出答案產生影響, 當且僅當$o$是一個操作且$o$在LCA的左子樹, $q$是一個查詢且$q$在LCA的右子樹. 所以每一對貢獻我們只會在LCA處計算一次, 正確性得證.

當然上述過程要基於貢獻的可加性 (即相關運算滿足交換律/結合律, 如$\min$, $\max$, $+$, $\oplus$...啥的)

裸的應用舉例

二維偏序

為啥先講二維偏序呢?

因為講了3維偏序之後就沒了捂臉熊

襠燃是讓大家一步一步理解辣~(一本正經地胡說八道中)

回憶一下歸併排序求逆序對的過程, 我們在合併兩個子區間的時候, 要考慮到左邊區間的對右邊區間的影響. 即, 我們每次從右邊區間的有序序列中取出一個元素的時候, 要把"以這個元素結尾的逆序對的個數"加上"左邊區間有多少個元素比他大". 其實這是一個典型的CDQ分治的過程.

現在我們把這個問題拓展到二維偏序問題. 在歸併排序求逆序對的過程中, 每個元素可以用一個有序對$(a,b)$表示, 其中a表示陣列中的位置, b表示該位置對應的值. 我們求的就是"對於每個有序對$(a,b)$, 有多少個有序對$(a',b')$滿足$a'<a$且$b'>b$ ", 這就是一個二維偏序問題.

注意到一開始我們預設$a$是有序的, 於是我們可以直接忽略$a$帶來的影響而計算, 因為$[l,mid)$區間中的所有$a$均要小於$[mid,r)$區間內的所有$a$. 其實我們可以理解為"這一維被CDQ分治掉了"

二維偏序Ex

考慮這麼一個問題:

給定一個$n$個元素的序列$a$, 初始值全部為$0$, 對這個序列進行以下兩種操作: 操作1: 格式為1 x k,把位置$x$的元素加上$k$ (位置從$1$標號到$n$). 操作2: 格式為2 x y,求出區間$[x,y]$內所有元素的和.

大家肯定會想"啊這不樹狀陣列沙比提嘛看我$5\texttt{min}$就切掉"壞笑熊

然而我們為了教學就是要沒事找事用CDQ來寫

顯然我們可以把它轉化為二維偏序問題, 第一維為操作時間, 第二維表示操作位置, 貢獻就是加和.

於是我們就可以按照離線題的套路定義一個結構Event表示廣義操作(或者事件), 把型別($0/1$, 代表這是操作還是查詢)/各個維度資訊/貢獻相關資訊都存進去, 按照維度資訊排好序就可以了

這題裡面因為第一維是操作時間所以按順序構造出來之後不用排序就可以直接上CDQ

太簡單了不放程式碼了

三維偏序

其實我們會發現普通的聯賽資料結構(樹狀陣列/線段樹)完全就能解決二維偏序問題, 完全用不上CDQ分治

然而三維就比較GG了

一般操作是樹套樹來解決這個問題(其實也相當於是"一層解決一維")

然而當你遇到了如下情況:

  • 時間不多了不夠寫樹套樹/k-D樹了
  • 偏序關係限制下的的貢獻很複雜
  • 題目本身並沒有強制線上/題目需要求貢獻總和

你就需要CDQ分治了

然後我們來看最基本的題目: 三維偏序(多數CDQ問題都是轉化為三維偏序之後解決的)

有$n$個元素, 每個元素有 $(a_i,b_i,c_i)$ 三個屬性, 設 $f(i)$ 表示滿足 $a_i \leq a_j$, $b_i\leq b_j$, $c_i\leq c_j$ 的 $j$ 的數量.

對於 $d \in [0,n)$, 求滿足 $f(i)=d$ 的 $i$ 的數量

在這道題目中, 我們其實完全可以將操作和查詢合併, 每個Event既是操作又是查詢.

我們類似二維偏序, 首先把按照第一維排序把它CDQ掉, 然後我們就可以專注於 $b$ 和 $c$ 兩維產生的貢獻了

接著我們按照 $b$ 元素升序歸併排序 (降常數用的...用std::sort也沒人攔你) , 這樣我們就在歸併的過程中處理掉了$b$ 維的偏序 (歸併過程中先訪問的會對後訪問的產生影響, 因為你排序了). 最後剩下 $c$ 維的偏序, 我們就可以使用一些普通資料結構 (比如樹狀陣列) 來解決了.

需要注意的一點是當前CDQ部分執行完後要把樹狀陣列清空, 而且不能使用 $O(n)$ 的清空方法, 需要懶惰刪除來保證複雜度.

具體實現:

void CDQ(int l,int r){
    if(r-l==1)
        return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    CDQ(mid,r);
    int lp=l,rp=mid,p=l;
    while(lp<mid||rp<r){
        if(lp<mid&&rp<r){
            if(N[lp].b<=N[rp].b){
                Add(N[lp].c,N[lp].w);
                buf[p++]=N[lp++];
            }
            else{
                N[rp].cnt+=Query(N[rp].c);
                buf[p++]=N[rp++];
            }
        }
        else if(lp<mid){
            Add(N[lp].c,N[lp].w);
            buf[p++]=N[lp++];
        }
        else{
            N[rp].cnt+=Query(N[rp].c);
            buf[p++]=N[rp++];
        }
    }
    for(int i=l;i<mid;i++)
        Add(N[i].c,-N[i].w);
    for(int i=l;i<r;i++)
        N[i]=buf[i];
}

因為帶重而且條件還是 $\leq$ 所以就去重然後把對應個數放在N[i].w裡了

非常容易理解(吧)

時間複雜度的話, 遞迴式是 $T(n)=2T(n/2)+O(n\log(n))$, 解出來是 $O(n\log^2(n))$ 的級別.

三維偏序Ex

考慮一個二維樹狀陣列題:

平面上有 $n$ 個點,要求 $q$ 個詢問,每個詢問為查詢在指定矩形 $(x_1,y_1)$ 到 $(x_2,y_2)$ 的矩形之間有多少個點

煞筆二維字首和?

然而...

黑惡勢力登場

$x,y \in [1,1\times 10^7]$, $n,q \leq 1\times 10^5$

等等我們是不是可以離散化一下呢?

然而每一維還是需要至少 $1\times 10^5$ 個點, 依然GG

於是人群中鑽出一個k-D TreeCDQ分治

首先我們按照二維樹狀陣列解法的套路把一個查詢拆成四個來差分求和

接著我們就非常偷稅地發現我們只要求個二維字首和就星了

實際上就相當於 $(t,x,y)$ 三維偏序, 貢獻為求和

直接套剛剛的闆闆就星了

然而拿來寫簡單題會MLE...這可真蠢...

正確闆闆題連結->園丁的煩惱 Mokia

不裸的應用舉例

天使玩偶

題意: 動態插入一些座標系上的點, 要求查詢這些插入的點到到指定查詢點的曼哈頓距離的最小值

一道k-D Tree裸題

曼哈頓距離的表示式是長這樣的:

$$ dis(A,B)=|A_x-B_x|+|A_y-B_y| $$

因為帶絕對值, 我們按照套路把絕對值拆開成$4$種情況來計算

這樣的話我們相當於翻轉$4$次座標系後計算下面這個式子的最小值:

$$ans(A)=\min\{A_x - B_x + A_y - B_y | B_x \leq A_x , B_y \leq A_y, B_t\leq A_t \}$$

其中$A_t$為插入/查詢時間, 問題轉化為一個三維偏序問題, 計算滿足偏序關係的點的$B_x+B_y$的最大值. 可以用CDQ分治和樹狀陣列來解決.

不過翻轉座標系之後的偏序關係是完全一樣的, 所以可以只寫一個CDQ函式然後在外面翻轉.

在這個題目中, 貢獻就變成了曼哈頓距離最小值

並不能摺疊程式碼於是就不在這裡貼實現了

因為複雜度是嚴格兩個 $\log$ 於是跑得並不如玄學複雜度的 k-D Tree 快...另一道題直接就TLE了...哇

動態逆序對

題意: 給定一個排列, 動態刪除其中的一些數字, 每次刪除之前輸出當前序列的逆序對數量

一道樹套樹裸題

我們首先把操作離線成倒序插入, 每次求出插入一個數字後的逆序對數量

然後把插入時間, 插入位置和插入的值 $(t,p,v)$ 做三維偏序, 每次求出插入當前值時貢獻的逆序對數量

發現 $t$ 的偏序關係都是 $t<t'$ 時對 $t'$ 有貢獻, 但 $p$ 和 $v$ 就要分成兩類:

  • $p < p'$ 且 $v>v'$
  • $p>p'$ 且 $v<v'$

咋辦呢?

兩遍CDQ唄

當然同樣可以寫在一個函式裡...然而好像比較容易翻炸...乖乖寫兩個好了...

或者...

void CDQ(int l,int r){
	if(r-l==1)
		return;
	int mid=(l+r)>>1;
	CDQ(l,mid);
	CDQ(mid,r);
	{
		int lp=l,rp=mid,p=l;
		while(lp<mid&&rp<r){
			if(A[lp].p>A[rp].p){
				Add(A[lp].v,1);
				T[p++]=A[lp++];
			}
			else{
				ans[A[rp].t]+=Query(A[rp].v);
				T[p++]=A[rp++];
			}
		}
		while(lp<mid){
			Add(A[lp].v,1);
			T[p++]=A[lp++];
		}
		while(rp<r){
			ans[A[rp].t]+=Query(A[rp].v);
			T[p++]=A[rp++];
		}
		for(int i=l;i<mid;i++)
			Add(A[i].v,-1);
		for(int i=l;i<r;i++)
			A[i]=T[i];
	}
	{
		int lp=l,rp=mid,p=l;
		while(lp<mid&&rp<r){
			if(B[lp].p<B[rp].p){
				Add(n+1-B[lp].v,1);
				T[p++]=B[lp++];
			}
			else{
				ans[B[rp].t]+=Query(n+1-B[rp].v);
				T[p++]=B[rp++];
			}
		}
		while(lp<mid){
			Add(n+1-B[lp].v,1);
			T[p++]=B[lp++];
		}
		while(rp<r){
			ans[B[rp].t]+=Query(n+1-B[rp].v);
			T[p++]=B[rp++];
		}
		for(int i=l;i<mid;i++)
			Add(n+1-B[i].v,-1);
		for(int i=l;i<r;i++)
			B[i]=T[i];
	}
}

GG

高階應用舉例

下面的都是些高階操作...

因為阿克業突然咕咕咕於是ddl纏身的rvalue只是嘴了嘴做法而並沒有親自去完整實現...

炸彈熊

趁早趕出來趁早搞完好了(咕咕咕

城市建設

題意: 給定一張無向圖, 要求支援修改邊權操作並在每次修改邊權之後求出當前最小生成樹大小

其實感覺這題只是普通分治而不是CDQ...然而思路比較清奇而且被老姚打了CDQ標籤就放出來好了

直觀思路是當分治到葉子的時候求一下當前的最小生成樹

然後上層非葉子結點的時候要做兩個操作:

  • construction求出所有一定在最小生成樹中的邊並加入答案並縮邊, 之後不再考慮. 計算方法是將所有當前區間修改掉的邊都加入最小生成樹後再求最小生成樹. 正確性顯然.
  • reduction 求出所有不可能加入最小生成樹的邊, 直接扔掉. 計算方法是禁止加入當前區間修改掉的邊然後儘量求最小生成樹, 此時扔掉的邊就可以徹底扔掉了

進行這兩個操作後圖的規模好像會縮小很多...

具體縮小多少好像並沒有找到證明...

複雜度是O(玄學)...

貨幣兌換

並不存在一句話題意(看原題吧)

感覺這個是最高階的操作了

首先每天一定只有兩種決策, 要麼買買買, 要麼賣賣賣 (有利可圖顯然多多益善, 可能虧損顯然一點都不碰是最優的)

那麼設 $f(i)$ 為前 $i$ 天的最大收益, $a_i$ 為第 $i$ 天全部買入時能得到的A券數目, $b_i$ 同理

則 $a_i =\frac{R_jf(j)}{R_jA_j+B_j}$, $b_i=\frac{f(j)}{R_jA_j+B_j}$. 如果在第 $i$ 天將第 $j$ 天買入的金券全部賣出, 則得到的價值是$A_ia_j+B_ib_j$

於是 $f(i)=\max\{f(i-1),A_ia_j+B_ib_j\}$.

於是直接就變成斜率優化了...因為顯然只有上凸殼上的 $(a_i,b_i)$ 才有可能做出貢獻

然而並不能直接斜率優化...因為這斜率™並不是單調的

標準做法是用平衡樹維護上凸殼並在查詢的時候二分

hzoi2017_jjm: 啊Treap維護上凸殼不是很好寫的麼...我寫過啊

rvalue: orz!

顯然大家都沒有hzoi2017_jjm強, 所以我們選擇更加好用的CDQ分治來寫這個題.

首先我們發現最大的問題在於決策點式中的 $a_j, b_j$ 與 $f(j)$ 有關, 所以我們必須要在 $f(j)$ 完全計算完成的情況下計算它對後面的貢獻. 於是我們調整CDQ分治順序, 先遞迴 $[l,mid)$ 將它完全計算完成.

接著對於當前CDQ分治區間 $[l,r)$:

因為 $[l,mid)$ 區間內的 $f(i)$ 均已計算完成, 我們將其中的決策點的上凸殼求出來. 然後將 $[mid,r)$ 區間內的點按斜率排序強行讓斜率單調, 最後掃一遍即可得到 $[l,mid)$ 區間對 $[mid,r)$ 區間內的斜率能產生的最大貢獻.

一直維護下去就好了...不過據說快排會T掉...還是老老實實歸併排序吧

整體二分

基本思想

整體二分主要是把所有查詢放在一起二分答案, 然後把操作也一起分治.

對沒錯, 放在一起. 計算的時候掃一遍當前處理的區間, 把答案在 $[l,mid)$ 的查詢放在一邊遞迴下去, $[mid,r)$ 的放在另一邊遞迴下去, 遞迴到葉子就有答案了.

其實有點像是二分在資料結構題目上的擴充套件, 因為資料結構題一般不僅有多組查詢還有其他的修改...

特徵性質

  • 查詢具有可二分性
  • 操作對判定結果的貢獻相互獨立(前面操作對後面操作的貢獻有不同影響的話會當場去世)
  • 如果操作對答案判定有影響, 則這個貢獻是一個確定的與判定標準無關的值
  • 貢獻滿足可加性(交換律/結合律)
  • 沒有強制線上

應用舉例

動態排名系統

題意: 給定一個序列, 支援單點修改和查詢區間 $k$ 小.

一道煮席樹裸題

不過如果記憶體開64MB的話主席樹就不可做了

首先我們發現這個答案肯定是有可二分性的

其次我們要二分的判定依據就是指定詢問區間中小於當前值的值的個數.

然後我們把所有值根據與當前二分的值域的 $mid$ 的比較結果轉化成 $0/1$ , 問題就變成了一個單點修改區間求和過程, 按照套路使用樹狀陣列即可解決.

我們發現一個值大於 $mid$ 的修改對於被判定為小於 $mid$ 的查詢不會再產生任何貢獻 (貢獻是確定值的用處) , 於是我們把這些修改劃分到另一邊. 一個值小於 $mid$ 的修改對於被判定為大於 $mid$ 的查詢一定會一直產生貢獻, 我們把它產生的貢獻累加到後面的查詢上 (貢獻滿足可加性的用處) 然後就可以把這些修改劃分到另一邊了.

而對答案造成影響的修改, 判定答案減小後仍可能造成影響. 所以我們將它們和對應的查詢劃到一起

劃分的時候因為是反歸併的過程所以一直保持著時間順序, 所以直接一邊跑修改一邊查詢問就星了

核心過程程式碼:

全域性陣列: int ans, int cnt, Event q, Event tmp, bool left

void Solve(int l,int r,int L,int R){
    if(R-L==1){
        for(int i=l;i<r;i++){
            if(q[i].op==0)
                ans[q[i].id]=L;
        }
        return;
    }
    int mid=(L+R)>>1;
    for(int i=l;i<r;i++){
        if(q[i].op==0)
            cnt[i]=Query(q[i].r)-Query(q[i].l-1);
        else if(q[i].v<=mid)
            Add(q[i].pos,q[i].op);
    }
    int lcnt=0;
    for(int i=l;i<r;i++){
        if(q[i].op==0){
            if(q[i].cur+cnt[i]>=q[i].v){
                left[i]=true;
                ++lcnt;
            }
            else{
                left[i]=false;
                q[i].cur+=cnt[i];
            }
        }
        else{
            if(q[i].v<=mid){
                left[i]=true;
                ++lcnt;
            }
            else
                left[i]=false;
        }
    }
    for(int i=l;i<r;i++)
        if(q[i].op!=0&&q[i].v<=mid)
            Add(q[i].pos,-q[i].op);
    int lp=l,rp=l+lcnt;
    for(int i=l;i<r;i++){
        if(left[i])
            tmp[lp++]=q[i];
        else
            tmp[rp++]=q[i];
    }
    for(int i=l;i<r;i++)
        q[i]=tmp[i];
    Solve(l,lp,L,mid);
    Solve(lp,rp,mid,R);
}

 

(以上程式碼由於時間緊迫未經測試炸彈熊...但是和網上程式碼對比並沒有很大差異於是我們假裝它是對的)

時間複雜度分析和三維偏序一樣

Meteors

題意: 給定一坨區間加的操作, 每個點都屬於一個點集, 而每個點集有一個需求量, 對於每個點集求什麼時候集合內的點的當前值之和達到需求量

顯然到達時間是可以二分的.

於是整體過程就變成了: 把第 $[l,mid)$ 區間內的修改全都執行掉, 然後判斷當前點集是否滿足要求. 若滿足要求則分在 $[l,mid)$ 一側, 否則將已經做出的貢獻累加並分到 $[mid,r)$ 一側.

而這個修改和查詢的過程就是區間修改單點查詢, 按照套路使用樹狀陣列字首和即可解決.

然後套剛剛那題的闆闆就可以辣~鼓掌熊