搜尋選講、分塊初步、莫隊簡介

kokowork發表於2024-08-24

省流:本篇專供衝擊NOIP一等的人使用,座標HN

本文的洛谷連結

1:搜尋選講

因為純靠搜尋能AC的題目少得離譜,所以初學階段,我們不講很多高深的搜尋,以下四個演算法應該足以應付我們的目標了。

1.1 深度優先搜尋DFS

深度優先搜尋指的是用遞迴完成的遍歷列舉,遍歷請轉移至圖論

其思想很簡單,就是一條路走到~~
WA~~
\(\color{#52C41A}{黑}\),沿著起始位置一直往下走,不撞南牆不回頭

DFS列舉一般使用遞迴實現,至於圖論裡的非遞迴+棧,到時候再講

它有一個小最佳化,就是當列舉的位置/元素不符合要求的時候,可以透過撤銷操作往回跑,叫恢復現場也可以。其實,它的真正名字叫\(\color{#EE0000}{回溯}\)

回溯怎麼實現呢?你賦了哪些值、改變了哪些變數,改回去不就是了

就這樣,你已經瞭解了這個相當簡潔的演算法

試一試!

習題1.1.1

P1219 八皇后

明明是n皇后

對於這道題,我們可以學習圖的行-列-對角線標記法

即對於行、列、左/右對角線,我們分別開一個陣列標記

對於對角線儲存的原理,我們觀察可得:
\(對於每一條左對角線上的格子,有x+y為定值\)

\(對於每一條右對角線上的格子,有y-x為定值\)

\(其中y為列數,x為行數\)

但右對角線的定值可能\(\le 0\),考慮極端情況:

\[y=1,x=n \]

此時定值為\(1-n\),取得最小值

故我們加一個\(n\),就可以保證座標\(>0\)

然後是核心的dfs部分,

我們將dfs的引數設為行數,及對每一行進行dfs,

一旦這個格子可以放,就將這個格子所在的行、列、左/右對角線全部打上標記(示例程式碼記為1)

顯然回溯時全部定為0就可以了

如何判斷可不可以放呢?當且僅當格子所在的行、列、左/右對角線都未標記時,這裡是可以放的。

然後輸出就可以了

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=30;

//int line[maxn]; legacy
int column[maxn];
int lefts[maxn];
int rights[maxn];
int way[maxn];//三種方案
int ans=0;//最終輸出的次數
int cnt=1;//為了讓列印答案更直觀,新增一個輔助變數

void printarr(int len)
{
	if(cnt<=3)
	{
		for(int i=1;i<=len;i++)
		{
			cout<<way[i];
			if(i<len)cout<<" ";
		}
		cout<<endl;
	}
	ans++;
	cnt++;
}

void dfs(int k,int n)//這裡的k是列數,如果行和列都枚一遍,那就迴圈了,還遞迴什麼
{
	if(k>n)//行數枚完了
	{
		printarr(n);
		return;
	}
	for(int i=1;i<=n;i++)
	{
		if(column[i]==0&&lefts[i+k]==0&&rights[i-k+n]==0)
		{
			way[k]=i;//別忘了儲存答案,第k行的答案(列數)是i
			column[i]=1;
			lefts[i+k]=1;
			rights[i-k+n]=1;
			dfs(k+1,n);//下一行,啟動!
			//if 要不得,回溯,撤!
			column[i]=0;
			lefts[i+k]=0;
			rights[i-k+n]=0;
		}
	}
}

int main()
{
	int n=0;
	cin>>n;
	dfs(1,n);
	cout<<ans;
	return 0;
}

習題1.1.2

P1135 奇怪的電梯

我一看,哦,很簡單的題目,dfs的二叉搜尋就夠了

遞迴時先判斷合不合法,然後再呼叫

然後你就收穫了\(\color{#E74C3C}{Unaccepted}\)

#include<bits/stdc++.h>
using namespace std;
const int maxn=205;

int arr[maxn];
int cnt=0;

void dfs(int s,int t,int n)
{
	if(s==t)
	{
		return;
	}
	else
	{
		if(s+arr[s]<=n)
		{
			cnt++;
			dfs(s+arr[s],t,n);
		}
		else if(s-arr[s]>=1)
		{
			cnt++;
			dfs(s-arr[s],t,n);
		}
		else
		{
			cout<<"No roads"<<endl;
			return;
		}
	}
}

int main()
{
	int n=0,a=0,b=0;
	cin>>n>>a>>b;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	dfs(a,b,n);
	cout<<cnt;
	return 0;
}

值得一提的是,dfs不做任何最佳化的情況下時間複雜度為\(O(2^n)\)。雖然最佳化後可降至\(O(n^2)\),但它想卡還是完全可以卡你的。面對這種情況,我們可以使用下面這種演算法↓

1.2 廣度優先搜尋BFS

這個演算法基本是為圖論服務的,網上單獨對它進行講述的資料很少,有的甚至只有兩行,點名批評某wiki

但以後的圖論最短路演算法都是從它的基礎上衍生出去的,寫法看上去還差不多,所以我們講講。

廣度優先搜尋基本上以迴圈實現,用佇列來儲存所有與當前元素相鄰的元素,再一個個從佇列中取出,以它為當前元素,再入隊相鄰元素,如此往復。

值得一提的是,雖然裸的不加最佳化的bfs似乎也是\(O(2^n)\),但在一些具體題目中,它很可能達到\(O(n+m)\)的優秀時間複雜度,其中\(n\)為元素數,\(m\)為元素之間的關係數

習題1.2.1

P1135 奇怪的電梯 again

好,我們用bfs來處理一下這個題目

我們使用\(vis\)陣列來記錄每個點有沒有被訪問過,如果是,那就跳過它,這屬於\(“剪枝”最佳化\)的一部分(其實這裡挺謎的,為什麼一個樓層不能搭兩次電梯?)

其餘的寫法如上文所述。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=205;

int arr[maxn];
int vis[maxn];
int dis[maxn];
int cnt=0;

void bfs(int s,int t,int n)
{
	queue<int> q;
	memset(vis,0,sizeof(vis));
	memset(dis,0x3f,sizeof(dis));
	vis[s]=1;
	dis[s]=0;
	if(s+arr[s]<=n&&vis[s+arr[s]]==0)
	{
		q.push(s+arr[s]);
		vis[s+arr[s]]=1;
		dis[s+arr[s]]=min(dis[s+arr[s]],dis[s]+1);
	}
	if(s-arr[s]>=1&&vis[s-arr[s]]==0)
	{
		q.push(s-arr[s]);
		vis[s-arr[s]]=1;
		dis[s-arr[s]]=min(dis[s-arr[s]],dis[s]+1);
	}
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		if(u==t)return;
		if(u+arr[u]<=n&&vis[u+arr[u]]==0)
		{
			q.push(u+arr[u]);
			vis[u+arr[u]]=1;
			dis[u+arr[u]]=min(dis[u+arr[u]],dis[u]+1);
		}
		if(u-arr[u]>=1&&vis[u-arr[u]]==0)
		{
			q.push(u-arr[u]);
			vis[u-arr[u]]=1;
			dis[u-arr[u]]=min(dis[u-arr[u]],dis[u]+1);
		}
	}
}

int main()
{
	int n=0,a=0,b=0;
	cin>>n>>a>>b;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	bfs(a,b,n);
	if(dis[b]==0x3f3f3f3f)
	{
		cout<<-1;
		return 0;
	}
	cout<<dis[b];
	return 0;
}

其實這題的搜尋更適合用圖論建模的思想,把能到的樓層都連上邊,然後跑圖論版的\(dfs\)\(bfs\),留作思考題。

習題1.2.2

P1443 馬的遍歷

這個就是bfs的板子題了,一點技術含量都沒有

我們直接把馬的八個運動方向分解到棋盤的\(x\)\(y\)軸上(\(\color{#66CCFF}{夢迴物理whk}\)),然後把八個方向存在一個所謂的常量陣列裡,這樣呼叫時可以寫在一個迴圈裡面,防止一大坨的bfs

本題bfs的入隊條件是運動到目的地後不越界,即

\[1\le x_{移動後}\le n \]

\[1\le y_{移動後}\le m \]

為了避免一個格子被覆蓋多個路徑,我們使用二維陣列\(vis\)記錄,\(1\)已訪問\(0\)未訪問。這樣,在滿足第三個條件後,才可以入隊:

\[vis[x_{移動後}][y_{移動後}]==0 \]

其餘照常bfs即可,注意本題輸出陣列好像是用水平製表符分隔的,不知道空格會不會錯。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=405;

int dis[maxn][maxn];
int vis[maxn][maxn];

int xs[]={0,-1,-2,-2,-1,1,2,2,1};
int ys[]={0,2,1,-1,-2,-2,-1,1,2};

struct xy
{
	int x;
	int y;
};

void bfs(int n,int m,int x,int y)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	queue<xy> q;
	q.push({x,y});
	dis[x][y]=0;
	vis[x][y]=1;
	while(!q.empty())
	{
		xy u=q.front();
		q.pop();
		for(int i=1;i<=8;i++)
		{
			if(u.x+xs[i]<=n&&u.x+xs[i]>=1&&u.y+ys[i]<=m&&u.y+ys[i]>=1&&vis[u.x+xs[i]][u.y+ys[i]]==0)
			{
				dis[u.x+xs[i]][u.y+ys[i]]=dis[u.x][u.y]+1;
				vis[u.x+xs[i]][u.y+ys[i]]=1;
				q.push({u.x+xs[i],u.y+ys[i]});
			}
		}
	}
}

int main()
{
	int n=0,m=0,x=0,y=0;
	cin>>n>>m>>x>>y;
	bfs(n,m,x,y);
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=m;j++)
		{
			
			if(dis[i][j]==0x3f3f3f3f)cout<<-1;
			else cout<<dis[i][j];
			if(j<m)cout<<'\t';
		}
		if(i<n)cout<<endl;
	}
	return 0;
}

1.3 雙向搜尋/meeting in the middle

很快啊,這裡已經只剩\(\color{#52C41A}{普及+/提高及以上}\)

這個譯名真的很亂啊,而且這兩個真不是一個演算法!

雙向搜尋就是同時從起點\(s\)和終點\(t\)同時dfs或bfs,那麼問題有可行解的充要條件是兩條搜尋路徑能夠相遇

感覺寫起來的話bfs實現比較方便,dfs可能頭緒會有點亂。

meeting in the middle 常譯作“折半搜尋”,適用於以下情況:

\[O(n^2)<<O(資料規模)<<O(2^n) \]

資料規模較小,但不至於直接dfs或bfs

其核心思想是將搜尋路徑分成兩部分進行,最終使用時再合併。

分治了屬於是

非常抽象是吧,看兩道題吧

習題1.3.1

P4799 世界冰球錦標賽

經典meeting in the middle,這裡最多有40場比賽,暴力列舉為\(O(2^{40})\),請你算算,\(2^{40}\)\(10^9\)哪個大?\(2^{40}\)\(10^{18}\)呢?

所以顯然不能樸素dfs。而所謂“揹包”演算法也無法\(\color{52C41A}{accepted}\),所以這裡採用折半搜尋。

事實上,折半搜尋常用於\(O(2^{30})\)\(O(2^{40})\)的搜尋問題

我們考慮將比賽分成兩半,分開dfs求出可行方案數,並分別存在\(a,b\)兩個\(vector\)中。

分別進行兩次dfs後,我們就應該考慮合併答案,那如何合併呢?

首先,我們必須讓一個陣列有序(這裡選擇\(a\)),因為方便後面統計時直接跳出。

然後對於\(b\)中的第\(j\)個元素,所有滿足下列關係的\(a_{i}\)都是合法答案。我們將其累加到\(ans\)中:

\[a_{i}+b_{j}\le m \]

\[其中i \in [1,\frac{mid}{2}],j \in [\frac{mid}{2}+1,n] \]

這裡對於\(a\)\(b\)稍作解釋。\(a\)\(b\)中的每一個元素代表一種可行的方案的錢數(\(sum\)),比如樣例中我們將\(1、2\)\(3、4、5\)分開,對於前組,很明顯有兩種方案:不買買第一場,那麼,我們就往\(a\)\(pushback\)兩個答案,很明顯,\(a\)\(b\)組合時,答案遵循乘法原理,所以如此合併。

這真是道麻煩至極的\(\color{#52C41A}綠\)題,isn't it?

它是從\(\color{#9D3DCF}{紫}\)掉下來的

千萬要認真吸收,吃透

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=50;
#define ll long long

ll price[maxn];//每場價錢
vector<ll> a;//前半段的搜尋結果
vector<ll> b;//後半段的搜尋結果
ll ans;//最終答案

void dfs(ll l,ll r,ll sum,vector<ll> &q,ll m)
{
	if(sum>m)return;//買不起了,撤!
	if(l>r)//到達目的地了,撤!
	{
		q.push_back(sum);//把答案扔進去
		return;
	}
	dfs(l+1,r,sum+price[l],q,m);//這一場買了,處理下一場比賽
	dfs(l+1,r,sum,q,m);//這一場沒買,處理下一場比賽
}

int main()
{
	ll n=0,m=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>price[i];
	}
	ll mid=n/2;//折半邊界
	dfs(1,mid,0,a,m);//前半段的搜尋
	dfs(mid+1,n,0,b,m);//後半段的搜尋
	sort(a.begin(),a.end());//我們選擇使前半段有序
	for(int i=0;i<b.size();i++)
	{
		ans+=upper_bound(a.begin(),a.end(),m-b[i])-a.begin();//對於後半段結果中的每一個,它對ans的貢獻為前半段中與它相加小於m的數個數
	}
	cout<<ans;
	return 0;
}

習題1.3.2

P1379 八數碼難題

顯然每個九位數的數列都是個狀態,我們只要簡單地寫個單向bfs就可以了。

AC Code:

#include<bits/stdc++.h>
using namespace std;

int matrix[4][4];//模擬的地圖
int xs[]={0,0,0,-1,1};//x方向的移動的常量陣列
int ys[]={0,1,-1,0,0};//y方向的移動的常量陣列
int res=123804765;//目的狀態
int sx=0,sy=0;//0在地圖中的位置
queue<int> q;//bfs佇列
map<int,int> ans;//每個狀態(一個九位數)的答案

void inttoma(int n)//數列轉矩陣
{
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			matrix[i][j]=n%10;
			n/=10;
			if(matrix[i][j]==0)
			{
				sx=i;
				sy=j;
			}
		}
	}
}

int matoint()//矩陣轉數列
{
	int res=0;
	int cnt=1;
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			res+=matrix[i][j]*cnt;
			cnt*=10;
		}
	}
	return res;
}

void dbfs(int s)//雙向搜尋
{
	q.push(s);//起點入隊
	ans[s]=0;//起點距離為0
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		if(u==res)break;//到達目的狀態直接退出
		inttoma(u);//數字轉矩陣
		for(int i=1;i<=4;i++)
		{
			int ux=sx+xs[i];//嘗試挪動x
			int uy=sy+ys[i];//嘗試挪動y
			int n=0;
			if(ux<1||ux>3||uy<1||uy>3)continue;//越界就不動
			swap(matrix[ux][uy],matrix[sx][sy]);//嘗試挪動
			n=matoint();//挪動結果轉化成數列
			if(!ans.count(n))//在這裡,ans[n]==0可以起到vis[n]==0的作用,所以不需要額外的vis陣列
			{
				ans[n]=ans[u]+1;//更新距離
				q.push(n);//入隊吧
			}
			swap(matrix[ux][uy],matrix[sx][sy]);//恢復現場,以便下次挪動
		}
	}
}

int main()
{
	int s=0;
	cin>>s;
	dbfs(s);
	cout<<ans[res];//res的距離
	return 0;
}

但很明顯它出在了雙向bfs裡面,事實上,這是我能找到的雙向bfs的幾乎唯一一道例題。還有一道是\(雙向A^{*}\),那太難了,有緣再講。

現在讓我們思考本題的雙向bfs寫法……要不我留作思考題吧

鑑於維護兩個bfs佇列較為複雜,我們考慮還是隻維護一個佇列\(q\)和一個\(vis\)陣列。你一開始可能會覺得這也差不多,但是 巧妙的處理 在後面。

我們在\(dbfs()\)的開頭將兩點分別入隊,初始化距離,並打標記(本處\(1\)為正向,\(2\)為反向)。然後進入\(while(!q.empty())\)部分。大的要來了。

我們先取隊頭\(u\),然後出個隊,然後——我們設定一個\(buf\),並賦值為\(u\),顯然,\(buf\)\(u\)備份

然後,我們將\(u\)轉化為矩陣(在此獲得\(0\)的位置),嘗試一次挪動,並換回來(用於呼叫標記),此時,\(u\)\(buf\)的關係有且只有三種:

  1. \[u已訪問,且與buf同向:vis[u]==vis[buf] \]

  2. \[u已訪問,且與buf反向:vis[u]+vis[buf]==3 \]

  3. \[u未訪問 \]

對於Case 1,我們搜尋下去完全沒有意義了(因為有可能繼續搜到同向訪問過的點),所以恢復現場(方便下次移動),然後直接跳過其他情況。

對於Case 2,顯然再走一步,兩條搜尋路徑就會相交,此時起點和終點就有了一條通路,易證此時是符合題意的答案。

對於Case 3,是預設答案,,既然\(u\)未訪問,那就讓它的\(vis\)\(buf\)保持一致(表示是\(buf\)的方向搜過來的),距離自然是\(buf\)的加1,最後恢復現場,the end。

另外,如果開始就碰到了終點,記得開頭就特判,不然\(testcase 31\)就炸了。

AC Code:

#include<bits/stdc++.h>
using namespace std;

int matrix[4][4];//模擬的地圖
int xs[]={0,0,0,-1,1};//x方向的移動的常量陣列
int ys[]={0,1,-1,0,0};//y方向的移動的常量陣列
int res=123804765;//目的狀態
int sx=0,sy=0;//0在地圖中的位置
queue<int> q;//bfs佇列
map<int,int> ans;//每個狀態(一個九位數)的答案
map<int,int> vis;//標記陣列,正向碰到了打1,逆向碰到了打2

void inttoma(int n)//數列轉矩陣
{
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			matrix[i][j]=n%10;
			n/=10;
			if(matrix[i][j]==0)
			{
				sx=i;
				sy=j;
			}
		}
	}
}

int matoint()//矩陣轉數列
{
	int res=0;
	int cnt=1;
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			res+=matrix[i][j]*cnt;
			cnt*=10;
		}
	}
	return res;
}

void dbfs(int s)//雙向搜尋
{
	if(s==res)//很快啊,直接就到達目的地了
	{
		cout<<0;
		return;
	}
	q.push(s);//起點入隊
	q.push(res);//終點入隊
	ans[s]=0;//起點距離為0
	ans[res]=0;//終點距離也為0
	vis[s]=1;//起點方向搜尋打1標記
	vis[res]=2;//終點方向搜尋打2標記
	while(!q.empty())
	{
		int u=q.front();
		int buf=u;//存一份副本,buf是“上一步操作”
		q.pop();
		inttoma(u);//數字轉矩陣
		for(int i=1;i<=4;i++)
		{
			int ux=sx+xs[i];//嘗試挪動x
			int uy=sy+ys[i];//嘗試挪動y
			if(ux<1||ux>3||uy<1||uy>3)continue;//越界就不動
			swap(matrix[ux][uy],matrix[sx][sy]);//嘗試挪動
			u=matoint();//挪動結果轉化成數列
			if(vis[u]==vis[buf])//如果u和buf已經是一個方向搜尋到的,那就算了
			{
				swap(matrix[ux][uy],matrix[sx][sy]);//挪回來
				continue;//下一位!!
			}
			else if(vis[u]+vis[buf]==3)//因為一個打了1標記,一個打了2標記,如果它們相加為3,下一步必然相遇,再加1就可以輸出了
			{
				cout<<ans[u]+ans[buf]+1;//因為二者是相反方向搜尋過來的,所以二者到各自起始節點的距離相加,再加最後交融的1步,就是起點到終點的路徑
				return;
			}
			else
			{
				ans[u]=ans[buf]+1;//預設情況,u是新開發地帶,buf已被訪問,自然加1
				vis[u]=vis[buf];//保證u和buf是一個方向搜尋的
				q.push(u);//vis都打了,入隊吧
				swap(matrix[ux][uy],matrix[sx][sy]);//恢復現場,以便下次挪動
			}
		}
	}
}

int main()
{
	int s=0;
	cin>>s;
	dbfs(s);
	return 0;
}

這道題同時是單向bfs雙向bfs的經典例題,且數字/字串←→二維陣列的思想是狀態壓縮型別動態規劃的基礎,一定要掌握牢實。

關於雙佇列\(version\),那就是真正的思考題了(逃)

2.分塊初步

網上面將分塊稱之為“優雅的暴力”優雅個鬼

常用來解決\(10^5\)這一級資料規模的區間查詢與修改問題,所以是線段樹樹套樹的同類演算法

分塊碼量跟線段樹差不多,其實思維也不比線段樹容易理解多少,但是奇葩的事實是,分塊作為平均\(O(n\sqrt{n})\)的演算法,居然能夠透過卡平均\(O(nlogn)\)的線段樹的資料。這就是我們學它的意義吧。

有一個重要前提: 分塊的具體操作(查詢題目需要的資料)必須是\(O(1)\)的,如果不是,莫隊可以二次離線,分塊暫時沒轍

分塊就三個操作,初始化、區間修改、區間查詢

初始化:我們一般將塊的大小設計為\(\sqrt{n}\)\(n\)無法整除\(n\),則統計塊的數目時記得加1。 我們設陣列\(l、r、belong\)分別表示每個塊的左右端點號和每個陣列元素歸屬塊的編號,我們在此不加證明的給出三個陣列元素的初始化公式。

\[l[i]=(i-1)*塊的長度+1 \]

\[r[i]=i*塊的長度 \]

\[block[i]=\frac{i-1}{塊的長度}+1 \]

最後,如果有需要,將每個塊的內部進行排序。

我們再設原資料儲存在\(arr\)陣列,\(cpy\)陣列用於備份,在 將資料讀入到\(arr\)初始化 之間,應將\(arr\)的內容轉移到\(cpy\)裡,使用 \(for\)逐個賦值和\(memcpy()\)均可。

區間修改:我們將待修改區間\([l,r]\)分為三個部分

  1. \[[x,r[belong[x]] \]

  2. \[[r[belong[x]+1,l[belong[y]]-1 \]

  3. \[l[belong[y]],y] \]

對於Case 1&&3,我們直接暴力修改即可。

對於Case 2,我們顯然可以偷懶,因為這是整塊整塊的區間,顯然設個\(lazy\)陣列存放懶標記就可以了。夢迴線段樹

在每種修改後,你都需要\(arr\)的內容及時更新到\(cpy\),並且\(x\)\(y\)所在的塊進行排序。(中間的塊是整體改的,還在\(lazy\)裡當懶標記,沒必要重新整一遍)

區間查詢:類似的,我們可以將區間\([x,y]\)分作相同的三部分。Case 1&&3還是一樣的暴力,對於Case2,我們可以在\(belong[x]+1\)\(belong[y]-1\)兩塊之間內部查詢。內部查詢的實現依賴於具體問題,本處略去。三部分的答案相加即為查詢解。

設詢問數\(q\),資料大小\(n\),由於暴力修改的時間基本為常數,我們可以考慮忽略。(這是基本前提)所以在塊大小為\(\sqrt{n}\)的情況下,區間修改的時間複雜度為\(O(1)\)區間查詢的時間複雜度為\(O(q\sqrt{n})\)

分塊將大小設為\(\sqrt{n}\)被卡的情況,你設成\(\sqrt{n}\pm 1\)差不多就得了,設成\(\sqrt{ \frac{n}{lgn}}\)的極為少見

習題2.1.1

P2801 教主的魔法

竊以為這才是真的板子題

請留意討論區提示,不要嘗試使用線段樹透過此題

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5;

int arr[maxn];
int block=0;
int tot=0;
int l[maxn];
int r[maxn];
int belong[maxn];
int lazy[maxn];
int cpy[maxn];

void init(int n)
{
	block=(int)sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=tot;i++)
	{
		l[i]=(i-1)*block+1;
		r[i]=i*block;
	}
	r[tot]=n;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
	for(int i=1;i<=tot;i++)
	{
		sort(cpy+l[i],cpy+r[i]+1);
	}
}

void add(int x,int y,int k)
{
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			arr[i]+=k;
		}
		for(int i=l[belong[x]];i<=r[belong[y]];i++)
		{
			cpy[i]=arr[i];
		}
		sort(cpy+l[belong[x]],cpy+r[belong[x]]+1);
		return;
	}
	else
	{
		for(int i=x;i<=r[belong[x]];i++)
		{
			arr[i]+=k;
		}
		for(int i=l[belong[x]];i<=r[belong[x]];i++)
		{
			cpy[i]=arr[i];
		}
		sort(cpy+l[belong[x]],cpy+r[belong[x]]+1);
		for(int i=l[belong[y]];i<=y;i++)
		{
			arr[i]+=k;
		}
		for(int i=l[belong[y]];i<=r[belong[y]];i++)
		{
			cpy[i]=arr[i];
		}
		sort(cpy+l[belong[y]],cpy+r[belong[y]]+1);
		for(int i=belong[x]+1;i<=belong[y]-1;i++)
		{
			lazy[i]+=k;
		}
	}
}

void query(int x,int y,int k)
{
	int ans=0;
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			if(lazy[belong[i]]+arr[i]>=k)ans++;
		}
		cout<<ans<<endl;
		return;
	}
	for(int i=x;i<=r[belong[x]];i++)
	{
		if(lazy[belong[x]]+arr[i]>=k)ans++;
	}
	for(int i=l[belong[y]];i<=y;i++)
	{
		if(lazy[belong[y]]+arr[i]>=k)ans++;
	}
	for(int i=belong[x]+1;i<=belong[y]-1;i++)
	{
		int ll=l[i],rr=r[i],mid=0,res=0;
		while(ll<=rr)
		{
			mid=(ll+rr)/2;
			if(cpy[mid]+lazy[i]>=k)
			{
				rr=mid-1;
				res=r[i]-mid+1;
			}
			else
			{
				ll=mid+1;
			}
		}
		ans+=res;
	}
	cout<<ans<<endl;
}

int main()
{
	int n=0,q=0;
	cin>>n>>q;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	memcpy(cpy,arr,sizeof(arr));
	init(n);
	char op;
	int a,b,c;
	for(int i=1;i<=q;i++)
	{
		cin>>op>>a>>b>>c;
		if(op=='M')add(a,b,c);
		if(op=='A')query2(a,b,c);
	}
	return 0;
}

對於分塊預處理陣列的構建,我覺得還是很難的,要多練一下。

分塊的模板好打的鋰譜,但有很多時候,你會卡在不知道維護什麼陣列上

但是那種題我也不會做,所以再來做道簡單題吧

習題2.1.2

P2357 守墓人

模板題\(See\) \(you\) \(again\),相信你不看程式碼就能\(\color{#52C41A}{Accepted!!!}\)

但是呢,這題有所提示,輸入不超過64位整數

十年OI一場空,不開\(longlong\)見祖宗

rp++

而且這題十分玄學,你甚至可以不初始化,直接就處理詢問,只要\(update()\)裡不給區間和加上應有的\(k\),你甚至可以拿\(\color{#52C41A}{80分的高分!!!}\) \(\color{#052242}{with}\) \(\color{#052242}{testcase21}\) \(\color{#052242}{TLE}\),可能是資料出鍋了 😦

你可以觀察到,我們在讀入每個數後立馬處理區間和,這樣可以在\(init()\)內省去二重迴圈,卡常小trick get

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e5+7;
#define int long long

int arr[maxn];
int l[maxn];
int r[maxn];
int belong[maxn];
int cntb[maxn];
int lazy[maxn];
int block=0;
int tot=0;

void init(int n)
{
	block=sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=tot;i++)
	{
		l[i]=(i-1)*block+1;
		r[i]=i*block;
	}
	r[tot]=n;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
}

void update(int x,int y,int k)
{
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			arr[i]+=k;
			cntb[belong[i]]+=k;
		}
		return;
	}
	for(int i=x;i<=r[belong[x]];i++)
	{
		arr[i]+=k;
		cntb[belong[i]]+=k;
	}
	for(int i=l[belong[y]];i<=y;i++)
	{
		arr[i]+=k;
		cntb[belong[i]]+=k;
	}
	for(int i=belong[x]+1;i<=belong[y]-1;i++)
	{
		lazy[i]+=k;
		cntb[i]+=k*(r[i]-l[i]+1);
	}
}

int query(int x,int y)
{
	int res=0;
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			res+=lazy[belong[i]]+arr[i];
		}
		return res;
	}
	for(int i=x;i<=r[belong[x]];i++)
	{
		res+=lazy[belong[i]]+arr[i];
	}
	for(int i=l[belong[y]];i<=y;i++)
	{
		res+=lazy[belong[i]]+arr[i];
	}
	for(int i=belong[x]+1;i<=belong[y]-1;i++)
	{
		res+=cntb[i];
	}
	return res;
}

signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n=0,m=0,op=0,x=0,y=0,k=0;
	cin>>n>>m;
	init(n);
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
		cntb[belong[i]]+=arr[i];
	}
	for(int i=1;i<=m;i++)
	{
		cin>>op;
		if(op==1)
		{
			cin>>x>>y>>k;
			update(x,y,k);
		}
		else if(op==2)
		{
			cin>>k;
			update(1,1,k);
		}
		else if(op==3)
		{
			cin>>k;
			update(1,1,-k);
		}
		else if(op==4)
		{
			cin>>x>>y;
			cout<<query(x,y);
			if(i<m)cout<<endl;
		}
		else if(op==5)
		{
			cout<<query(1,1);
			if(i<m)cout<<endl;
		}
	}
	return 0;
}

但是我們不能將思維停留在這種簡單題上面,接下來來看一道之前說過的,怎麼建立陣列分塊的題目。

習題2.1.3

P4135 作詩

這道題真的不是分塊模板題!就算是,也有點難度,起碼對你的字首和思想考查要求很高。

一個很麻煩的事情是本題要維護的資訊(詢問區間內出現次數為偶數的數的個數)並不滿足區間可加性,舉個例子:

至於本題,對於一個長度為\(10\)的原陣列,我們查詢\([4,9]\)的答案,按\(\sqrt n\)分塊的話,是兩個整區間\([4,6]\)\([7,9]\),沒有散塊,最簡單的情況。

假如在\([4,6]\)裡,\(3\)出現了\(1\)次,在\([7,9]\)裡則出現了\(3\)次,合到一起就是\(4\)次,對答案產生了貢獻,加進去是吧?模板分塊下,你根本預判不到這種情況,除非願意退化為暴力掃。

這就叫做不滿足區間可加性,那讓我們想想辦法,把分塊改造一下。

首先把整塊的情況處理出來,開一個二維陣列\(ans[i][j]\),表示第\(i\)塊到第\(j\)塊的答案。這個部分在初始化裡就可以處理出來,設個\(bufcnt[maxn]\),表示一個特定區間內\(i\)的出現次數。

  • 首先搞個\(i\)\(j\)的二重迴圈,因為設定原因,\(j\)直接從\(i\)開始就可以了,沒問題吧。

  • 然後顯然初始時\(ans[i][j]=ans[i][j-1]\)

  • 然後再開始統計這個塊的答案,直接在\(ans[i][j]\)上修改,就根據\(bufcnt\)的結果修改就可以。

  • 為什麼設個\(bufcnt\)就可以了呢,我們主要是初始化答案時要做到塊內資料可合併,只要\(bufcnt\)不清空,我們就可以實現這個目的。你搞過工程的話,你可能會覺得這是一個給塊統計答案的資料池,我覺得也是的。

  • 由於我們為了共享資料,又不是多測\(bufcnt\)\(j\)迴圈內不用清空,只要在\(i\)迴圈內清空就行了,起點/左邊界變了嘛,難道你又從最左邊刪起?編碼複雜度角度考慮肯定不會這樣的。

然後查詢\([x,y]\)時初值就是

\[ans[belong[x]+1][belong[y]-1] \]

現在再來考慮散塊,我們之前的一個痛點就是區間合併時無法預測答案是否受到額外貢獻,為此,我們再設立一個\(cnt[i][j]\)陣列,用來統計\(1到i\)塊中\(j\)的出現個數,那麼,我們掃描散塊時,每個數的出現次數就從迴圈內統計的次數變成還要加上整塊內的次數,剩餘的技巧就和初始化\(ans\)差不多了。同理可得兩個散塊的統計之間也不需要清空。

容易發現其實\(ans\)(如果你改成一維的話,可能寫個inline查詢函式可以改善馬蜂,就像下面的\(cnt\)一樣)和\(cnt\)都是可差分的,所以整塊內的資料就很好查詢了,最後,輸入輸出是加密的,小心一點。

這就是不好直接分塊的題目的一個常用辦法:將每塊答案用字首和和差分處理。再用資料共享陣列輔助更新答案。很牛逼吧。

但是線段樹的話,這個技巧的實現就有點複雜了,這就是為什麼討論區裡沒有人喊線段樹怎麼做的原因吧(如果線段樹能打破我們的痛點,那這題就太板了,不能評紫了QAQ

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;
const int sqrtn=5e3+5;

int arr[maxn];//原陣列
int cnt[sqrtn][maxn];//區間1到i中x出現次數
int ans[sqrtn][sqrtn];//區間i到j的答案
int l[sqrtn];
int r[sqrtn];
int belong[maxn];
int block=0;
int tot=0;
int bufcnt[maxn];//暴力掃輔助

inline int getnum(int l,int r,int x)//差分求出塊l到塊r的x個數
{
	return cnt[r][x]-cnt[l-1][x];
}

void init(int n,int c)
{
	block=sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=tot;i++)
	{
		l[i]=(i-1)*block+1;
		r[i]=(i==tot)?n:(i*block);
	}
	for(int i=1;i<=tot;i++)
	{
		for(int j=l[i];j<=r[i];j++)
		{
			belong[j]=i;
			cnt[i][arr[j]]++;
		}
		for(int j=0;j<=c;j++)
		{
			cnt[i][j]+=cnt[i-1][j];
		}
	}
	for(int i=1;i<=tot;i++)
	{
		for(int j=i;j<=tot;j++)
		{
			ans[i][j]=ans[i][j-1];
			for(int k=l[j];k<=r[j];k++)
			{
				bufcnt[arr[k]]++;
				int buf=bufcnt[arr[k]];
				if(buf%2==0)ans[i][j]++;
				else if(buf!=1)ans[i][j]--;
			}
		}
		for(int j=0;j<=c;j++)
		{
			bufcnt[j]=0;
		}
	}
}

int query(int x,int y)
{
	int res=0;
	if(belong[y]-belong[x]<=1)
	{
		for(int i=x;i<=y;i++)
		{
			bufcnt[arr[i]]++;
			int buf=bufcnt[arr[i]];
			if(buf%2==0)res++;
			else if(buf!=1)res--;
		}
		for(int i=x;i<=y;i++)
		{
			bufcnt[arr[i]]--;
		}
		return res;
	}
	else
	{
		res=ans[belong[x]+1][belong[y]-1];
		for(int i=x;i<=r[belong[x]];i++)
		{
			bufcnt[arr[i]]++;
			int buf=bufcnt[arr[i]]+getnum(belong[x]+1,belong[y]-1,arr[i]);
			if(buf%2==0)res++;
			else if(buf!=1)res--;
		}
		for(int i=l[belong[y]];i<=y;i++)
		{
			bufcnt[arr[i]]++;
			int buf=bufcnt[arr[i]]+getnum(belong[x]+1,belong[y]-1,arr[i]);
			if(buf%2==0)res++;
			else if(buf!=1)res--;
		}
		for(int i=x;i<=r[belong[x]];i++)
		{
			bufcnt[arr[i]]--;
		}
		for(int i=l[belong[y]];i<=y;i++)
		{
			bufcnt[arr[i]]--;
		}
		return res;
	}
}

int main()
{
	int n=0,c=0,m=0,last=0,ll=0,rr=0;
	cin>>n>>c>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	init(n,c);
	for(int i=1;i<=m;i++)
	{
		cin>>ll>>rr;
		ll=(ll+last)%n+1;
		rr=(rr+last)%n+1;
		if(ll>rr)swap(ll,rr);
		//cout<<ll<<' '<<rr<<endl;
		last=query(ll,rr);
		cout<<last;
		if(i<m)cout<<endl;
	}
	return 0;
}

3.莫隊簡介

本篇不涉及莫隊的線上化改造

莫隊演算法是一位叫莫濤的聚銠在\(2009\)年以\(\color{#52C41A}{577的高分}\)斬獲\(NOI\) \(\color{#FFD700}{Au}\)後在國家集訓隊作業論文中提出的。他本人也承認當時其實\(Codefoces\)上有很多神犇早就會寫了,但莫濤確實是第一個對該演算法進行系統整理的人,這在科學上極為重要。為了表彰他的卓越貢獻,我們現在都將其稱為莫隊演算法。

順便大聲喊一聲:莫濤聚銠高中就讀於長沙市長郡中學!

不喜歡郡系,但喜歡莫濤

我的名言

值得一提的是,還有一位聚銠,名叫範浩強,是中國人民大學附屬中學的選手,他在同年\(NOI\)斬獲二等,於次年斬獲一等,一路打到\(NOI2012\)都是\(\color{#FFD700}{Au}\)大滿貫。他是\(fhq-treap/無旋treap\)的發明者,這個資料結構有緣再講。

更值得一提的是,我們雅禮集團早在\(2007\)年就已經有\(插頭DP\)\(cdq分治\)的發明者陳丹琦學姐(長沙市雅禮中學)進隊了雖然Ag,郡系還是慢了一步啊。樂死了。

其實給莫隊演算法起個名字還是很重要的,你說他是暴力&&剪枝,它比\(dfs/bfs\)抽象多了;說它是雙指標、滑動視窗,都有點影子。所以乾脆叫莫隊,一了百了。

另外,雖然很奇葩,但:

莫隊演算法是主席樹(可持久化線段樹)的替代演算法

這句話很重要,它幾乎成為莫隊各種改進的指導思想。

3.1 普通莫隊

我插一句,只有這個version是莫隊本隊提出的

現在我們進入正題,莫隊怎麼寫?

一個典型的母題是:

  • 有個長度為\(n\)的陣列\(arr\),一般\(1 \le n\le 2\times10^{5}\)

  • 要你處理\(q\)\([l_{i},r_{i}]\)區間的查詢,範圍一般同\(n\)

  • 可以離線(提前獲取所有資料)。

我們當然可以寫暴力騙分對拍,時間複雜度顯然是\(O(q(l+r))\),最壞情況下直接打到\(O(nq)\),約\(4\times 10^{10}\),顯然\(\color{#E74C3C}{Unaccepted}\)

我們大多數情況下也可以寫主席樹,但我們假裝不行

我們此時可以構想,有兩個“指標”指著陣列下標,每次獲取一個查詢區間,然後不斷將“指標”一個位置一個位置挪至對應的左右端點,每挪動一個位置就計算該位置對答案的貢獻並累加或刪除。這裡的位置指陣列的“格子”。 我們可以將累加或刪除抽象到\(add()\)\(del()\)兩個函式里。

設左/右指標為\(l,r\),左右端點為\(l_{i},r_{i}\)

我們在這裡對指標與區間端點的位置關係及Solution進行討論,這四個情況的判斷程式碼的順序似乎關係不大……有點繞,請大家認真體會:

  1. 左指標在左端點左邊:\(l < l_{i}\)

    此時,左邊顯然對答案做了過多的貢獻,所以要減去多餘部分,可使用\(while\)來控制\(del(l++)\)

  2. 左指標在左端點右邊:\(l > l_{i}\)

    此時,左邊對答案的貢獻還有一部分沒被統計到,要加上這一部分:\(del(l++)\)

  3. 右指標在右端點右邊:\(r > r_{i}\)

    此時,右邊對答案造成了多餘貢獻,減去:\(del(r--)\)

  4. 右指標在右端點左邊:\(r < r_{i}\)

    此時,右邊的貢獻少了,加上:\(add(r++)\)

答案的輸出需要依照原順序,所以對於區間,我們建個結構體維護,其中還要記錄原始位置,存進答案陣列時按照原陣列的位置存。

你說,是不是很像滑動視窗或雙指標?

在這種演算法中,看不出對時間複雜度有什麼最佳化,但我們可以將前面分塊的\(init()\)搬過來,然後先按左端點大小,後按右端點大小來將查詢區間排序,這樣就可以一個塊一個塊的處理,減少“指標”挪動距離。

假設我們以\(\sqrt{n}\)的大小建塊,顯然此時單次查詢已經降到了最壞\(O(\sqrt{n})\)的地步,總時間複雜度為\(O(n\sqrt{n})\),關於排序的時間,因為\(O(nlogn)<<O(n\sqrt{n})\),所以可以忽略不計。

此處的排序必須要獲取所有資料,所以莫隊演算法是離線的。

關於奇偶化排序這一小trick的證明,我太菜,講不了一點,看普通莫隊的程式碼吧。

習題3.1.1

P2709 小B的詢問

應該算是現存的模板題,注意累加的是\(c_{i}^{2}\),不是整體累加再平方。

還有一個有趣的地方,關於答案輸出的部分,可以寫完後和前面分塊的程式碼對照一下,思考一下:為什麼莫隊只能離線,而分塊可以線上?

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e4+5;

int arr[maxn];
int cnt[maxn];
int belong[maxn];
int ans[maxn];
int block=0;
int tot=0;
int l=1;
int r=0;
int res=0;

struct interval
{
	int l;
	int r;
	int id;
};
interval querys[maxn];

bool operator <(interval a,interval b)
{
	return (belong[a.l]^belong[b.l])?belong[a.l]<belong[b.l]:(belong[a.l]&1)?a.r<b.r:a.r>b.r;
}

void init(int n,int k)
{
	block=sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
	sort(querys+1,querys+k+1);
}

void add(int pos)
{
	res-=cnt[arr[pos]]*cnt[arr[pos]];
	cnt[arr[pos]]++;
	res+=cnt[arr[pos]]*cnt[arr[pos]];
}

void del(int pos)
{
	res-=cnt[arr[pos]]*cnt[arr[pos]];
	cnt[arr[pos]]--;
	res+=cnt[arr[pos]]*cnt[arr[pos]];
}

int main()
{
	int n=0,m=0,k=0;
	cin>>n>>m>>k;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	for(int i=1;i<=m;i++)
	{
		cin>>querys[i].l>>querys[i].r;
		querys[i].id=i;
	}
	init(n,m);
	for(int i=1;i<=m;i++)
	{
		while(l<querys[i].l)del(l++);
		while(l>querys[i].l)add(--l);
		while(r<querys[i].r)add(++r);
		while(r>querys[i].r)del(r--);
		ans[querys[i].id]=res;
	}
	for(int i=1;i<=m;i++)
	{
		cout<<ans[i];
		if(i<m)cout<<endl;
	}
	return 0;
}

簡短好記,起碼比主席樹好寫多了。

這基本上就最簡短了,再壓行會不便於除錯的。

3.2 帶修莫隊

我們發現,莫隊將輸入的區間排好序後,無法再更改,事實上,也不好更改。因為莫隊本身只支援查詢。為什麼呢?你想,我們事先對所有查詢進行了\(sort()\),本來查詢和修改就是亂的,你還在這裡改資料就是添亂! 其實你當初寫\(add()\)\(del()\)時改的也是\(cnt\),並未“動真格”。

但人家主席樹可是帶修的,莫隊氣不過,所以也勉勉強強,支援一下單點修改。

你別說還真的可以

我們在\(l,r\)兩個指標的基礎上,再新增一個指標\(t\),它指向誰呢?就是儲存的查詢操作。

現在你可以將整個題目資料想象成一個二維座標系,資料沿橫軸展開,而縱軸是修改操作。

如果像其他文章裡寫的那樣,硬是把\(l\)\(r\)兩個指標看成一個二維座標系的話,那你加上\(t\)時間指標,就相當於是個三維座標系就行了。沒什麼大不了的

我們著重理解一下修改操作。為了保證形式一致,我們也把它抽象到一個\(travel()\)函式里,因為它有種說法叫“時間漫遊”。

我們首先考慮當前修改次數\(t\)在不在區間\([l,r]\)裡,如果在的話,那就減去要修改位置本來的值,再加上修改的目標值,這裡的增刪可使用\(add()、del()\).

然後,我們就交換一下陣列裡的要修改位置的值和修改操作裡的目標值。最後,別忘了移動\(t\),我們的時間顯然從\(t=0\)為起點,向未來是\(t++\),回憶往昔是\(t--\)。大功告成。

這裡為什麼要執行交換操作呢?注意到前面的\(add()\)\(del()\)都只修改了(也只能修改)\(cnt\)陣列,而沒有對\(arr\)陣列進行實質性的修改。這樣的話,如果不交換一下,若要撤回修改操作(當\(t\)所在的時間在本次查詢之前,別笑,你對查詢進行了排序就肯定有這樣的情況),你模擬一遍,是不是啥也沒做?

交換操作的用處就在這裡,它動了一下“真格”,看似打破了莫隊的理論基石,其實是保護了\(arr\)原來的值,以防止為效率對查詢進行排序使修改和查詢的時間順序錯亂,從而\(WA\),而新增交換以後,我們在前面一遍的啥也沒做之後,就可以安心把資料恢復回來。妙啊!!!

所以你會發現為什麼帶修莫隊只支援單點修改?因為它只有一個\(t\)指標在“時間”維度上跑來跑去,而區間修改別無他法,只能暴力掃一遍,直接給你的複雜度再套一個\(n\)變為\(O(n^2 \sqrt {n)}\)你比拿\(O(n^2)\)的暴力嘗試跑\(10^7\)更勇啊

習題3.2.1

P1903 [國家集訓隊]數顏色/維護序列

上面講解的差不多了,這裡注意一些細節,比如前四個\(while()\)裡傳的引數和普通莫隊的有些區別,主要是為了適配\(travel()\)

如果你發現前四個\(while()\)和普通莫隊不同時,It doesn't matter,是我調不過時看資料亂改的,現在懶得改回來了,汗。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e6;

int arr[maxn];
int cnt[maxn];
int belong[maxn];
int ans[maxn];
int block=0;
int tot=0;
int l=1;
int r=0;
int t=0;
int res=0;

struct interval
{
	int l;
	int r;
	int time;
	int id;
};
interval querys[maxn];

struct modify
{
	int pos;
	int val;
};
modify ops[maxn];

bool operator <(interval a,interval b)
{
	if(belong[a.l]!=belong[b.l])return belong[a.l]<belong[b.l];
	else if(belong[a.r]!=belong[b.r])return belong[a.r]<belong[b.r];
	else return a.time<b.time;
}

void add(int pos)
{
	if(cnt[pos]==0)res++;
	cnt[pos]++;
}

void del(int pos)
{
	cnt[pos]--;
	if(cnt[pos]==0)res--;
}

void travel(int poss,int i)
{
	if(ops[poss].pos>=querys[i].l&&ops[poss].pos<=querys[i].r)
	{
		del(arr[ops[poss].pos]);
		add(ops[poss].val);
	}
	swap(ops[poss].val,arr[ops[poss].pos]);
}

int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n=0,m=0,often=0,qs=0;
	char op;
	cin>>n>>m;
	block=pow(n,2.0/3.0);
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
		belong[i]=(i-1)/block+1;
	}
	for(int i=1;i<=m;i++)
	{
		cin>>op;
		if(op=='Q')
		{
			qs++;
			cin>>querys[qs].l>>querys[qs].r;
			querys[qs].id=qs;
			querys[qs].time=often;
		}
		if(op=='R')
		{
			often++;
			cin>>ops[often].pos>>ops[often].val;
		}
	}
	sort(querys+1,querys+qs+1);
	for(int i=1;i<=qs;i++)
	{
		while(l>querys[i].l)add(arr[--l]);
		while(r>querys[i].r)del(arr[r--]);
		while(r<querys[i].r)add(arr[++r]);
		while(l<querys[i].l)del(arr[l++]);
		while(t<querys[i].time)travel(++t,i);
		while(t>querys[i].time)travel(t--,i);
		ans[querys[i].id]=res;
	}
	for(int i=1;i<=qs;i++)
	{
		cout<<ans[i];
		if(i<qs)cout<<endl;
	}
	return 0;
}

關於這道題的花式問題,This may help

國集的題能給點面子嘛,就一個藍,砈

我想水紫題

3.3 回滾/不刪除/不增加莫隊

一般來說,回滾莫隊是針對如下情況的莫隊改進的:

\(add()\)\(del()\)函式中,有一個實現的複雜度非常大

有一個母題:求查詢區間的眾數

如果要用莫隊做這個題,\(add()\)非常好編寫,只要\(cnt++\),然後和當前的最大值打擂臺,就可以了

\(del()\)函式中,我們刪除了一個數後,與誰打擂臺顯然不清楚。要不然就暴力掃一遍\(cnt\),那顯然不現實,搞不好\(O(n)\)

在這種情況下,我們就使用回滾莫隊來回避\(del()\)的編寫,其實還是和帶修莫隊一樣“打假拳”

我們思考一下,怎麼做能夠最大程度減少複雜度大的那一個操作?

對於不刪除的情況,你想啊,如果我們保證同一個塊內,左端點不管,右端點是單調遞增的,那我們對於右指標的刪除操作是不是就可以免了?不錯啊,成功了一半。

接下來是左指標,我們想辦法讓詢問的左端點按分塊的順序排序(你應該熟悉分塊順序),使左端點所在塊編號小的就排在前面處理,那左端點在大部分情況下是不是也可以不用刪除操作了?好,基本成功了。所以這裡不能使用奇偶化排序的卡常技巧,因為有可能右端點會因為依照奇偶排序而不能單調遞增,讓你\(\color{#E74C3C}{WA}\)掉。

既然分了塊,那當我們移動指標時,就可以考慮一塊一塊的處理詢問(好像講過了qwq),然後新到每個塊時,就將\(l\)放在這個塊的右端點,而\(r\)是莫隊老慣例了,放在\(l-1\)的位置哦。

然後我們分三個階段搜尋:

  1. \(r\)向右搜尋到詢問右端點
  2. \(l\)向左搜尋到詢問左端點
  3. \(l\)回到原來初始化的位置,為下組同塊的詢問做準備(回滾)

琢磨一下,是不是隻有3中涉及到刪除操作了?而且告訴你吧,這個操作只要清空一個單獨開的陣列,刪一次是\(O(1)\)的,滿足莫隊聚銠的心意

我們開\(first、last、last2\)三個陣列維護,它們分別代表:

  • 每個元素出現的第一個位置
  • 每個元素出現的最後一個位置
  • 在詢問左端點到\(l\)初始化位置的\(last\)功能

對於Case 1,顯然我們移動時,對於每個位置的元素\(first\)只用修改一次(第一次嘛),但\(last\)要實時更新(最後一個位置),然後根據題目處理。

對於Case 2,雖然我們更改的也是\(last2\)陣列,但是我們必須和\(last\)裡相同位置的資料比較一下,根據題意取捨。因為同塊內的查詢資料顯然是可以共享的,所以懶得清空\(first\)\(last\),但這樣的話\(l\)走過的\(last2\)\(r\)也可能記錄在\(last\)裡了,這就不能不比較一下。

對於Case 3,\(l\)每滾一個位置,就把\(last2\)的該位置清空,因為同塊內查詢,\(last2\)的資料也有可能不同,所以不能等到換塊了再清。顯然這個“刪除”是\(O(1)\)的,good

對於詢問端點同塊的情況,那你還是暴力吧,\(l\)不用動,只動\(r\)\(first\)就行。\(last\)不動嗎,最後一個位置顯然是\(r\)所在的地方嘛。

注意\(r\)移動前後\(first\)都要清空,因為都在塊內,顯然不是整塊,不能再使用整塊的資料了。

不增加的題目也有,但構造增加不為\(O(1)\)且比刪除困難的情況太難了,所以現階段只要掌握不刪除的就行了,不增加的,除了使每個詢問右端點單調遞減以外,區別幾乎沒有。

習題3.3.1

P5906 【模板】回滾莫隊&不刪除莫隊

雖然你們很喜歡把另一道題作為回滾莫隊模板題,但我不喜歡RemoteJudge的題,它們編號不一樣,打亂了透過題號的隊形,所以用這道吧。qwq

正經模板題就是不一樣,實現細節我都沒什麼好講的了\((doge)\)

哦,你看這變數多的,給演算法發明者點贊!

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+5;

int arr[maxn];//原陣列、離散化後陣列
int belong[maxn];//每個元素儲存的塊
int rs[maxn];//每個塊的右端點
int ans[maxn];//每個詢問的答案
int first[maxn];//每個元素在每個詢問區間第一個出現的位置
int last[maxn];//每個元素在每個詢問區間出現的最後一個位置
int last2[maxn];//每個元素在(詢問區間-當前塊)範圍最後一個出現的值
int cpy[maxn];//離散化輔助陣列
int lastblock=0;//上一個塊編號
int block=0;//塊大小
int tot=0;//塊數量
int l=0;//左指標
int r=0;//右指標
int res=0;//每次詢問的結果

struct interval//詢問區間
{
	int l;
	int r;
	int id;
};
interval querys[maxn];//詢問區間陣列

bool operator <(interval a,interval b)
{
	if(belong[a.l]!=belong[b.l])return a.l<b.l;
	else return a.r<b.r;
}

int main()
{
	int n=0,m=0;
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
		cpy[i]=arr[i];//離散化
	}
	sort(cpy+1,cpy+n+1);
	int len=unique(cpy+1,cpy+n+1)-(cpy+1);
	for(int i=1;i<=n;i++)
	{
		arr[i]=lower_bound(cpy+1,cpy+len+1,arr[i])-cpy;
	}//離散化end
	block=sqrt(n);//分塊
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
	for(int i=1;i<=tot;i++)
	{
		rs[i]=i*block;
	}
	rs[tot]=n;//分塊end
	cin>>m;
	for(int i=1;i<=m;i++)
	{
		cin>>querys[i].l>>querys[i].r;
		querys[i].id=i;
	}
	sort(querys+1,querys+m+1);
	for(int i=1;i<=m;i++)
	{
		if(belong[querys[i].l]==belong[querys[i].r])
		{
			res=0;
			for(int j=querys[i].l;j<=querys[i].r;j++)
			{
				first[arr[j]]=0;
			}
			for(int j=querys[i].l;j<=querys[i].r;j++)
			{
				if(first[arr[j]]==0)first[arr[j]]=j;
				else
				{
					res=max(res,j-first[arr[j]]);
				}
			}
			for(int j=querys[i].l;j<=querys[i].r;j++)
			{
				first[arr[j]]=0;
			}
			ans[querys[i].id]=res;
			continue;
		}
		int nowblock=belong[querys[i].l];
		if(lastblock!=nowblock)
		{
			res=0;
			for(int j=l;j<=r;j++)
			{
				first[arr[j]]=last[arr[j]]=0;
			}
			l=rs[nowblock];
			r=l-1;
			lastblock=nowblock;
		}
		while(r<querys[i].r)
		{
			r++;
			if(first[arr[r]]==0)
			{
				first[arr[r]]=r;
			}
			last[arr[r]]=r;
			res=max(res,r-first[arr[r]]);
		}
		int p=l,tmp=0;
		while(p>querys[i].l)
		{
			p--;
			if(last2[arr[p]]==0)last2[arr[p]]=p;
			tmp=max(tmp,max(last[arr[p]],last2[arr[p]])-p);
		}
		while(p<l)
		{
			last2[arr[p]]=0;
			p++;
		}
		ans[querys[i].id]=max(res,tmp);
	}
	for(int i=1;i<=m;i++)
	{
		cout<<ans[i];
		if(i<m)cout<<endl;
	}
	return 0;
}

想水的紫題終於到手了

請讀者注意:莫隊演算法一般來說掌握普通普隊就夠了,如果本文3.2和3.3的知識學的吃力的話,先去學線段樹和平衡樹等同樣熱門的知識點可能有所幫助。因為莫隊是它們的替代演算法。

2024/8/20 01:00:00 初稿!完結撒花!!!(釋出在洛谷)

相關文章