淺談差分約束系統

Poetic_Rain發表於2020-07-04

差分約束系統是個啥呢?可能看名字非常地難理解,其實它要求的就是一個n元一次不等式組的解,形式如下:

\(\begin{cases}x-y \leq 10\\y-z \leq 5\\\end{cases}\)

那麼求解這一組資料的解,就是差分約束系統的目的

那麼對於以上這一個用數學方式來求解的不等陣列,我們如何用一個程式來實現呢?我們將以上式子做一個移項的處理得到:

\(\begin{cases}x\leq y+10\\y\leq z+5\\\end{cases}\)

仔細觀察以上式子,然後再來觀察一下最短路中的鬆弛操作

\(d[y] \leq d[x]+z\)

當所有的\(x\)\(y\)滿足以上式子之後,我們就求出了最短路,再對比上面的不等式組,不難發現他們有相似之處,我們就可以將不等式組的每一個式子進行移項得到一個類似鬆弛操作的式子,然後轉化為最短路求解

對於小於等於的情況跑最短路,當然也會出現大於等於的情況,這個時候我們應該跑最長路。但是我學習的時候,看了很多部落格都只是寫了這個結論的東西,如果你無法分清楚這一種情況,我們可以結合數學的知識來理解,如下:

\(\begin{cases}x\leq10\\x\leq5\\\end{cases}\)\(\begin{cases}y\geq10\\y\geq5\\\end{cases}\)

我們得出的答案應該是 \(x \leq 5\)\(y \geq10\) (初中數學基本知識),我們應該的正解應該是範圍更小的那一個。對於小於等於,最短路會使得答案範圍更小;相反,大於等於的話,最長路使得答案範圍更小,滿足不等式組的求解

然後就是對於不同符號的轉換:

如果是 \(x-y=z\) ,我們可以轉化為 \(x-y\geq z\)\(x-y \leq z\)

如果是 \(x-y \geq z\) ,我們可以轉化為 \(y \leq x-z\)

如果是 \(x-y \leq z\) ,我們可以轉化為 \(y \geq x-z\)

不難發現大於等於和小於等於的情況其實是可以互相轉換的,但是在你做題的時候你會發現,轉化之後求最長路或是最短路和轉化之前求解的答案不相同,是因為你的最短路的\(d[ ]\)的初始值的原因,這樣求出來的解是符合該不等式組的,但是我們人為的會對初始值進行賦值,才會導致答案不同,因為不等式組的解是無數的

講解了如何轉換一類的基礎知識,我們就要開始落實程式了,就是關於如何將不等式組轉化為圖然後跑最短(長)路的問題,以及判斷無解的情況

對於建圖(這裡只會講解用鏈式前向星),就像圖示一樣建圖就可以了(都是有向邊,第二張圖有點問題),不理解的建議自己手推一下:

但是如何判斷無解的情況呢?其實就是判斷有環的情況:

\(\begin{cases}x \leq y\\y \leq z \\ z \leq x\\\end{cases}\)

這一個不等式組建圖之後就會出現環的情況,我們只需要再最短路中判斷是否有環的情況就可以了

知道以上的東西之後,我們就可以確定最短路的演算法了,又有負邊權,又有判斷環,那麼可以選擇就是Ford演算法和SPFA演算法,至於用哪一個演算法,我個人推薦SPFA,畢竟大部分時候會快一點

那麼對於差分約束系統的核心思想就講到這裡了,我們通過一些例題來加深理解和落實程式碼就可以了

P3385 【模板】負環

從基礎的開始,先做判斷負環的情況,我這裡就直接貼程式碼了,不知道在最短路中如何判斷環的情況的可以直接看程式碼,如果對最短路不太熟悉的可以參考我的另一篇部落格

#include<bits/stdc++.h>
using namespace std;
const int MAXN=9*1e5+51;
int T;
int n,m;
struct node{
	int net,to,w;
}e[MAXN];
int head[MAXN],tot;
void add(int x,int y,int z){
	e[++tot].net=head[x];
	e[tot].to=y;
	e[tot].w=z;
	head[x]=tot;
} //鏈式前向星 
int d[MAXN]; //記錄距離 
bool v[MAXN];  //是否入隊 
int cnt[MAXN]; //入隊次數 
bool spfa(int s){
	queue<int>q;
	for(register int i=1;i<=n;i++){
		d[i]=20050206,v[i]=false,cnt[i]=0;
	}//初始化 
	d[s]=0;
	cnt[s]=1;
	v[s]=true;
	q.push(s);
	while(!q.empty()){
		int x=q.front();
		q.pop();
		v[x]=false;
		for(register int i=head[x];i;i=e[i].net){
			int y=e[i].to,z=e[i].w;
			if(d[y]>d[x]+z){
				d[y]=d[x]+z;
				cnt[y]++; //入隊次數++ 
				if(cnt[y]>=n) return false; //如果入隊次數超過n,說明有負環 
				if(v[y]==false){
					v[y]=true;
					q.push(y);//入隊 
				}
			}
		}
	}
	return true; //記得return true,預設的是return false 
}
int main(){
	scanf("%d",&T);
	while(T--){
		memset(head,0,sizeof head);
		tot=0; //記得每一次初始化 
		scanf("%d%d",&n,&m);
		for(register int i=1;i<=m;i++){
			int x,y,z;
			scanf("%d%d%d",&x,&y,&z);
			add(x,y,z); 
			if(z>=0) add(y,x,z); //按照題目來建邊 
		}
		if(spfa(1)==false) puts("YES");
		else puts("NO");
	}
	return 0;
} 

P5960 【模板】差分約束演算法

#include<bits/stdc++.h>
using namespace std;
const int MAXN=2*1e5+51;
int n,m;
struct node{
	int net,to,w;
}e[MAXN];
int head[MAXN],tot;
void add(int x,int y,int z){
	e[++tot].net=head[x];
	e[tot].to=y;
	e[tot].w=z;
	head[x]=tot;
}
int d[MAXN],vis[MAXN];
bool v[MAXN];
bool spfa(int s){
	queue<int>q;
	for(register int i=1;i<=n;i++){
		d[i]=20040915,v[i]=false,vis[i]=0;
	}
	v[s]=true;
	d[s]=0;
	vis[s]=1;
	q.push(s);
	while(!q.empty()){
		int x=q.front();
		q.pop();
		v[x]=false;
		for(register int i=head[x];i;i=e[i].net){
			int y=e[i].to,z=e[i].w;
			if(d[y]>d[x]+z){
				d[y]=d[x]+z;
				vis[y]++;
				if(vis[y]>=n) return false;
				if(v[y]==false){
					q.push(y);
					v[y]=true;
				}
			}
		}
	}
	return true;
}
int main(){
	scanf("%d%d",&n,&m);
	for(register int i=1;i<=m;i++){
		int x,y,z;
		scanf("%d%d%d",&x,&y,&z);
		add(y,x,z);
	}
	for(register int i=1;i<=n;i++){
		add(0,i,0);
	} //因為你不知道哪一個點是起點,我們建立一個超級源點來遍歷所有點 
	if(spfa(0)==true){
		for(register int i=1;i<=n;i++){
			cout<<d[i]<<" ";
		}
	}else puts("NO");
	return 0;
}

做完模板題之後,我會給出幾道我自己認為還不錯的可以加深理解的題,也會給出一些解題方法和注意要點

P4878 [USACO05DEC]Layout G

為什麼會推薦這道題呢?因為這道題初看其實就是一道很純的模板題,沒有任何區別,只是將大於等於和小於等於轉化一下就可以了,但是對於後三組資料你並不能過,因為它有一些特殊的情況

題目中表面說了,求1到N的答案,所以很多人就會直接從1開始跑最短路,然而這是錯誤的,因為我們沒有判斷圖並不是連通的,什麼意思,就是1這個點是孤兒點或者1根本不可能走向n,這個時候我們就需要建立一個超級源點來做了

#include<bits/stdc++.h>
using namespace std;
const int MAXN=1e5+10;
const int INF=2004091500;
int n,l,D;
struct node{
	int net,to,w;
}e[MAXN];
int head[MAXN],tot;
void add(int x,int y,int z){
	e[++tot].net=head[x];
	e[tot].to=y;
	e[tot].w=z;
	head[x]=tot;
}
int d[MAXN],vis[MAXN];
bool v[MAXN];
bool spfa(int s){
	queue<int>q;
	for(register int i=1;i<=n;i++){
		d[i]=INF,vis[i]=0,v[i]=false;
	}
	d[s]=0;
	vis[s]=1;
	v[s]=true;
	q.push(s);
	while(!q.empty()){
		int x=q.front();
		q.pop();
		v[x]=false;
		for(register int i=head[x];i;i=e[i].net){
			int y=e[i].to,z=e[i].w;
			if(d[y]>d[x]+z){
				d[y]=d[x]+z;
				vis[y]++;
				if(vis[y]>=n) return false;
				if(v[y]==false){
					v[y]=true;
					q.push(y);
				}
			}
		}
	}
	return true;
}
int main(){
	scanf("%d%d%d",&n,&l,&D);
	for(register int i=1;i<=n;i++) add(0,i,0); //建立超級源點 
	for(register int i=1;i<=l;i++){
		int x,y,z;
		scanf("%d%d%d",&x,&y,&z);
		add(x,y,z);
	}
	for(register int i=1;i<=D;i++){
		int x,y,z;
		scanf("%d%d%d",&x,&y,&z);
		add(y,x,-z);
	} //分清楚兩種建邊的情況 
	if(spfa(0)==false) puts("-1"); //先跑0判斷是否連通
	else if(spfa(1)==false) puts("-1"); //再跑1看是否負環 
	else{
		if(d[n]==INF) puts("-2"); //是否不連通 
		else printf("%d",d[n]);
	}
	return 0;
}

P3275 [SCOI2011]糖果

給這道題的原因就是對於給你一個題目,判斷到底應該跑最長路還是最短路,用小於等於還是大於等於,很明顯,求至少跑多少,是大於等於,跑最長路

然後就是對於不同情況應該如何建邊

#include <bits/stdc++.h>
using namespace std;
queue<long long> q;
long long n,m,tot;
long long ans;
long long dis[2000050],cnt[2000050],head[2000050];
bool vis[2000050];
struct node {
	long long to,net,val;
} e[2000050];

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

bool spfa(int s) {
	vis[s]=true;
	cnt[s]=1;
	q.push(s);
	while(!q.empty()) {
		long long x=q.front();
		q.pop();
		vis[x]=false;
		for(register long long i=head[x];i;i=e[i].net) {
			long long v=e[i].to;
			if(dis[v]<dis[x]+e[i].val) {
				dis[v]=dis[x]+e[i].val;
				cnt[v]++;
				if(cnt[v]>=n) return false;
				if(vis[v]==false) {
					vis[v]=true;
					q.push(v);
				}
			}
		}
	}
	return true;
}

int main() {
	scanf("%lld%lld",&n,&m);
	for(register long long i=1;i<=m;i++) {
		long long x,u,v;
		scanf("%lld%lld%lld",&x,&u,&v);
		if(u==v&&x%2==0){
			puts("-1");
			return 0;
		}
		if(x==1) {
			add(u,v,0);
			add(v,u,0);
		}
		else if(x==2) add(u,v,1);
		else if(x==3) add(v,u,0);
		else if(x==4) add(v,u,1);
		else add(u,v,0);
	}
	for(register long long i=1;i<=n;i++) add(0,i,1); 
	if(spfa(0)==false) puts("-1");
	else {
		for(register long long i=1;i<=n;i++) {
			ans+=(dis[i]);
		}
		printf("%lld",ans);
	}
	return 0;
}

然後你會發現你自己超時了,之後我會將如何優化這個程式,先放在這裡

P2294 [HNOI2005]狡猾的商人

這道題要仔細講一講,因為我們之後做的題肯定不是像上面一樣的純模板題,是需要一些轉換的。就拿這道題來說,我最開始沒想出什麼思路,打了一個錯誤的暴力11分,看著旁邊的大佬直接用所謂的“暴力”A了這道題,我還是選擇看題解,因為實在是太菜啊

因為這道題,它的題目意思是 這幾個月一共的收入 ,那麼我們就可以想到字首和 ,例如\((1,3,5)\) 表示1到3月份的收入是5塊錢,就可以轉化為\(sum[3]-sum[1-1]=5\) 的方式(字首和的基本操作),那麼對於一組資料\((u,v,w)\),就可以以\(u-1\)\(v\)為兩個端點,\(w\)就為邊權,連兩條邊,因為是等於的情況,那麼直接看程式

#include <bits/stdc++.h>
using namespace std;
int T,n,m,u,v,w,tot;
int dis[250010],cnt[250010],head[250010];
bool vis[250010];

struct node {
	int to,net,val;
} e[250010];

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

inline bool spfa() {
	queue<int> q;
	for(register int i=0;i<=n;i++) {
		cnt[i]=0;
		vis[i]=true;
		q.push(i);
	} 
	//這個地方可以仔細說一下,我前面幾個程式在不確定起始點的情況下,都是用的超級源點
	//其實也可以直接全部入隊,達到的目的是一樣的
	//但是貌似全部入隊會快一點,上面那道糖果用全部入隊的方式就可以直接A 
	while(!q.empty()) {
		int x=q.front();
		q.pop();
		vis[x]=false;
		for(register int i=head[x];i;i=e[i].net) {
			int v=e[i].to;
			if(dis[v]>dis[x]+e[i].val) {
				dis[v]=dis[x]+e[i].val;
				cnt[v]++;
				if(cnt[v]>=n) return false;
				if(vis[v]==false) {
					vis[v]=true;
					q.push(v);
				}
			}
		}
	}
	return true;
}

int main() {
	scanf("%d",&T);
	while(T--) {
		tot=0;
		memset(head,0,sizeof head);
		memset(vis,false,sizeof vis);
		memset(cnt,0,sizeof cnt);
		memset(dis,0,sizeof dis);//清空陣列 
		scanf("%d%d",&n,&m);
		for(register int i=1;i<=m;i++) {
			scanf("%d%d%d",&u,&v,&w);
			add(u-1,v,-w); //轉化為字首和的形式,本題核心 
			add(v,u-1,w);	 //等於的情況建兩條邊 
		}
		if(spfa()==false) puts("false");
		else puts("true");
	}
	return 0;
}

那麼題就講完了,剩下的就靠自己刷題領悟和加深理解了,在最後,我還是想講一講關於SPFA的簡單優化,只會涉及一點,其他的優化可以去參照一下其他dalao的部落格

SLF優化,就是將佇列改為雙端佇列,在插入佇列的時候判斷插入位置,起到一個很好的優化

deque<int>q
while(!q.empty()) {
	int x=q.front();
	q.pop_front();
	vis[x]=false;
	for(register int i=head[x]; i; i=e[i].net) {
		int v=e[i].to;
		if(dis[v]>dis[x]+e[i].val) {
			dis[v]=dis[x]+e[i].val;
			cnt[v]++;
			if(cnt[v]>=n) return false;
			if(vis[v]==false) {
				vis[v]=true;
				if(!q.empty()&&d[q.front()]<d[v]) q.push_back(v);
				else q.push_front(v);
				//如果比隊首的最優解要大,往隊尾放
				//如果比隊首優,往隊首放
				//這樣就可以是這個佇列的趨勢變為遞增的
				//並不是嚴格遞增,但是肯定可以起到一個優化的作用
				//我個人認為用這個優化就可以解決大部分問題了
				//因為如果出題人想卡SPFA,任何優化都沒用 
			}
		}
	}
}

感謝一下ZJY dalao,同桌和RHL dalao在學習時提供給我的幫助

本篇部落格就講到這裡了,如果有任何錯誤請在評論區指出,謝謝閱讀

相關文章