演算法專題——SPFA拓展
SPFA演算法概念
SPFA演算法可以歸結為兩個特點:
- 可以鬆弛操作進行更新的點進行更新,並將更新的點加入佇列(如果佇列沒有該點的話)以更新其他的點.
- while迴圈結束的條件,所有邊均滿足鬆弛定理不能再更新.
當圖中路徑存在負環時,最短路是求不出來的,對應到圖中即: 每一個節點不存在一個固定的解使得所有邊對應的鬆弛操作不可能同時滿足.
路徑存在最短路是一個重要的性質, 與此同時路徑中的負環同樣具有十分重要的性質.
求負環
求負環的幾種判斷方式優化方式以及對應程式碼:
//一個點的入隊次數不超過n次, 超過n次就說明存在負環. if (++ cnt[i] > n) return false; //最短路包含的邊數不超過n次, 超過n次就說明存在負環. 更加推薦這種方法 cnt[i] = cnt[j] + 1; if (cnt[i] > n) return false; //不一定正確, 當所有點的入隊次數超過一定閾值時說明存在負環 if (++cnt > 2 * MAXN) return false; //使用堆疊進行儲存 堆疊的特點更適合找負環的需求
求負環的重點, SPFA演算法必須要可以遍歷到所有的邊. 因此要根據需求選擇是檢測圖中所有的負環還是路徑上的負環.
如果需要檢測圖中所有的負環, 且不能使起點到達所有點的情況, 就需要將所有的點入隊, 並設定好dist值的大小.
例題
wormhole
題面:
分析:
判斷是否存在一個農場使得從該農場出發, 最終可以回到該農場, 並完成時光倒流.
即判斷圖中是否存在負環. 由於並沒有說該圖是一個連通圖, 所以需要將所有點提前壓入佇列. 下面給出核心程式碼:
const int MAXN = 510, MAXM = 6010; int h[MAXN], e[MAXM], val[MAXM], ne[MAXM], idx; int dist[MAXN], cnt[MAXN]; bool vis[MAXN]; queue<int> que; bool SPFA(int start) { memset(dist, 0x3f, sizeof dist); memset(cnt, 0, sizeof cnt); memset(vis, false, sizeof vis); dist[0] = 0; vis[0] = true; while (!que.empty()) que.pop(); que.push(0); while (!que.empty()) { int u = que.front(); que.pop(); vis[u] = false; for (int i = h[u]; ~i; i = ne[i]) { int v = e[i]; if (dist[v] > dist[u] + val[i]) { dist[v] = dist[u] + val[i]; cnt[v] = cnt[u] + 1; if (cnt[v] > n) return true; if (!vis[v]) { que.push(v); vis[v] = true; } } } } return false; }
零一分數規劃
求負環的特殊情況, 所有形如求$\frac{\sum邊權1}{\sum邊權2}$最大/小值的題目我們稱之為零一分數規劃問題, 該問題一般通過SPFA配合二分解決問題. 直接通過例題進行分析.
例題
sightseeing cows
題面:
分析:
該題就是一道典型的零一分數規劃問題. 由於奶牛最終要回到起點, 所以奶牛最終應是在一個環上進行觀光. 又所謂幸福度不會因為疊加計算是為了防止重複繞圈計算遊覽一圈可以得到的最大值. 即問題轉化為要找到一個環, 使得滿足公式$\frac{點權}{邊權} > 比例$ 的同時, 比例最大. 而在一個環中, 點權可以轉換為邊權(出邊或者入邊都可以), 所以和$\frac{\sum邊權1}{\sum邊權2}$的形式差不多, 是一道求零一分數規劃的問題.
將公式進行一個變形得到: 點權 - 邊權 * 比例 > 0
, 可以發現此時就變成了一個求一個最大的比例, 使得圖中存在存在一個正環即可. 如果得到一個比例, 我們可以通過最長路求正環的方法驗證得到的比例是否滿足條件, 不難發現比例在數值範圍內是滿足單調性的, 所以可以通過二分比例, 然後通過最長路求正環的方法進行檢測, 從而得到比例的最大值.
下面和核心程式碼:
int h[MAXN], e[MAXM], val[MAXM], ne[MAXM], idx;
int pval[MAXN]; //點權
double dist[MAXN];
queue<int> que;
int cnt[MAXN];
bool vis[MAXN];
bool Check(double mid) {
memset(vis, true, sizeof vis);
memset(cnt, 0, sizeof cnt);
while (!que.empty()) que.pop();
for (int i = 1; i <= n; i++) que.push(i);
while (!que.empty()) {
int u = que.front(); que.pop();
vis[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (dist[v] < dist[u] - val[i] * mid + pval[u]) {
dist[v] = dist[u] - val[i] * mid + pval[u];
if (++cnt[v] > n) return true; //通過記錄入隊次數的判斷正環的方法
if (!vis[v]) {
vis[v] = true;
que.push(v);
}
}
}
}
return false;
}
int main {
double l = 0, r= 1010; //注意邊界情況, 奶牛至少要去兩個景點回到起點, 即一定要找到環, 由於資料保證一定存在環, 且比例總是部位負數, 所以可以讓l = 0
while (r - l > 1e-4) { //題目要求的精度再小兩位
double mid = (l + r) / 2;
if (Check(mid)) l = mid;
else r = mid;
}
cout << r << endl;
}
Word Rings
題面:
分析:
這道題的難點在於建圖的方式, 一般很容易直接將單詞看作一個節點, 在單詞與單詞之間建圖, 但是不難發現, 這樣無論是需要建的點的數量還是邊的數量都是一個十分大的數量級, 空間顯然不夠用, 就算空間夠用, 光是建圖的時間也會超時, 所以顯然這種直接了當的建圖方式是不可取的.
我們可以考慮在詞綴與詞綴之間進行建圖, 詞綴1→詞綴2
表示的是以詞綴1開頭, 詞綴2結尾的一個單詞, 邊權為單詞的長度. 基於次我們可以發現這種建圖的方式所需要的點只有不到300個, 所建成的邊也只有O(n)級別. 所以在這類圖論題中, 需要留意圖的建立方式.
然後就是很普通的求解答案了, 下面貼出全部程式碼:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int MAXM = 1e5 + 10, MAXN = 700;
int n;
int h[MAXN], e[MAXM], val[MAXM], ne[MAXM], idx;
double dist[MAXN];
queue<int> que;
int cnt[MAXN];
bool vis[MAXN];
char tmp[MAXM];
void AddEdge(int a, int b, int c) {
e[idx] = b, val[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
bool Check(double mid) {
int count = 0;
memset(cnt, 0, sizeof cnt);
memset(vis, true, sizeof vis);
while (!que.empty()) que.pop();
for (int i = 0; i < 676; i++) que.push(i);
while (!que.empty()) {
int u = que.front(); que.pop();
vis[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (dist[v] < dist[u] + val[i] - mid) {
dist[v] = dist[u] + val[i] - mid;
cnt[v] = cnt[u] + 1;
if (++count >= 10000) return true;
if (cnt[v] >= MAXN) return true;
if (!vis[v]) {
que.push(v);
vis[v] = true;
}
}
}
}
return false;
}
int main() {
while (scanf("%d", &n), n) {
memset(h, -1, sizeof h);
idx = 0;
for (int i = 0; i < n; i++) {
scanf("%s", tmp);
int len = strlen(tmp);
if (len >= 2) { //注意邊界情況!
int left = (tmp[0] - 'a') * 26 + tmp[1] - 'a'; //通過26進位制的方法進行節點儲存
int right = (tmp[len - 2] - 'a') * 26 + tmp[len - 1] - 'a';
AddEdge(left, right, len);
}
}
if (!Check(0)) puts("No solution.");
else {
double l = 0, r = 1010;
while (r - l > 1e-4) {
double mid = (l + r) / 2;
if (Check(mid)) l = mid;
else r = mid;
}
printf("%lf\n", r);
}
}
return 0;
}
差分約束
對一個不含負環的連通圖求最短路, 當SPFA函式執行完之後, 可以發現任意取一個點, 所有的入邊都滿足三角不等式dist[u] + val[i] >= dist[v]
, 一個入邊就對應著一個這樣的三角不等式, 而該點的取值dist[v]
為了使所有的不等式成立, 應取所有不等式裡的最小值, 這個值是到節點v的最短路, 同時也是dist[v]
可以取到的最大值. 即如果我們將視角從求到v的最短路轉移到求v的最大值上, 將圖論的知識點遷移到數學表示式上, 我們便得到了一種新的知識點----差分約束.
差分約束就是利用圖論中的三角不等式以求解在滿足所有的限制(以不等式組的形勢給出)的同時, 變數可以取到的最大/小值.
**差分約束的易錯點: **
- 要注意這裡取到的最大/小值是相對於起點(起點表示的變數)而言的, 不難發現, 當得到一組可行解的時候, 我們往往(除了一些特殊的不等式)可以通過所有變數加減一個數得到另一組可行解. 這裡求出的最大值最小值是相對於起點而言的, 就如用SPFA求最短路一樣, 求出的解是單源最短路, 只能確定起點
start
到目標節點v
的差值是最大/小值, 不能確定一個非起點節點u
到非起點目標節點v
的最大/小值. - 差分約束一個十分重要的點, 需要將節點之間所有可能包含的關係都用不等式表達出來, 比如下面的例題----Intervals和cashier employment
差分約束的幾個重點:
-
差分約束存在負環: 即沒有一個固定的
dist
值滿足給定的三角不等式, 即無解. 求解不等式是否有解, 可以參照求負環的步驟, 建立虛擬原點 -
dist[v] == INF
: 即從地點start
到目標節點v
之間不存在最大/小值,v
不受start
控制, 最大值為正無窮. -
求最小值使用最長路,求最大值使用最短路: 從起點到目標節點可以得到n條途徑, 目標節點的取值應該滿足所有的不等式, 最長路三角不等式為
dist[u] + val[i] <= dist[v]
, 可以發現當滿足所有不等式時,dist[v]
會取得可以取得的值裡邊的最小值. 最短路同理. -
一些特殊條件的轉換:
u > v
可以轉化為u >= v + 1
;u == v
可以轉化為u >= v, v >= u
;u == c
可以設立一個節點dist[0] == c
, 然後仿照第二個式子的轉換.
例題
Candies
題面:
分析:
模板題, 要求最大值, 使用最短路, 注意建邊的方式, 下面給出程式碼, 以及在註釋中給出注意事項.
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
#include<cmath>
#define debug(x) cout << #x << " = " << x << endl
using namespace std;
const int MAXN = 3e4 + 10, MAXM = 15e4 + 10;
int n, m;
int h[MAXN], e[MAXM], val[MAXM], ne[MAXM], idx;
int dist[MAXN];
bool vis[MAXN];
struct Node {
int v, val;
Node(int _v = 0, int _val = 0) : v(_v), val(_val) {}
bool operator< (const Node& a) const {return val > a.val;}
}tmp;
void AddEdge(int a, int b, int c) {
e[idx] = b, val[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
//求最大值→最短路
void dijkstra() { //由於SPFA會超時, 加上不存在不邊, 所以這裡使用dijkstra演算法求最短路
memset(dist, 0x3f, sizeof dist);
memset(vis, false, sizeof vis);
priority_queue<Node> que;
dist[1] = 0; //起點設為0, 之後輸出答案可以直接輸出dist[n]
que.push(Node(1, 0));
while (!que.empty()) {
tmp = que.top(), que.pop();
int u = tmp.v;;
if (vis[u]) continue;
vis[u] = true;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (!vis[v] && dist[v] > dist[u] + val[i]) {
dist[v] = dist[u] + val[i];
que.push(Node(v, dist[v]));
}
}
}
}
int main() {
scanf("%d%d", &n, &m);
int a, b, c;
memset(h, -1, sizeof h);
for (int i = 0; i < m; i++) {
scanf("%d%d%d", &a, &b, &c);
AddEdge(a, b, c);
}
dijkstra();
cout << dist[n] << endl;
}
Intervals
題面:
分析:
這道題中, 仍可以較為容易的找到關係, 得到陣列dist[i]
表示從1~i
之間, 該集合中總共有多少個元素, 這樣我們就可以用不等式來表示區間的含義了, 即dist[r] - dist[l - 1] >= interval[i]
, 在此基礎上, 將節點與節點所有可能包含的條件都塞進去, 便可以得到我們最終的表示式(求最小值, 用最長路):
1. dist[r] >= dist[l - 1] + interval[i]
2. dist[i] >= dist[i - 1]
3. dist[i - 1] >= dist[i] - 1 //2 3表示i - 1與i之間的差距只能在1之間
建圖程式碼如下, 求最長路程式碼略:
memset(h, -1, sizeof h);
for (int i = 0; i < n; i++) {
scanf("%d%d%d", &a, &b, &c);
MAX = max(MAX, b);
AddEdge(a - 1, b, c);
}
for (int i = 1; i <= MAX; i++) {
AddEdge(i - 1, i, 0);
AddEdge(i, i - 1, -1);
}
Layout
題面:
分析:
這道題題目需要判斷是否有解, 以及判斷是否距離可以無限, 其餘方面倒沒什麼特殊的, 見下面的關鍵程式碼:
bool SPFA(int size) {
memset(dist, 0x3f, sizeof dist);
memset(vis, false, sizeof vis);
memset(cnt, 0, sizeof cnt);
queue<int> que;
for (int i = 1; i <= size; i++) { //通過傳遞一個size引數, 判斷是否要將所有點壓入佇列, 從而在求最短路和求負環之間進行切換
que.push(i); vis[i] = true;
dist[i] = 0;
}
while (!que.empty()) {
int u = que.front(); que.pop();
vis[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (dist[v] > dist[u] + val[i]) {
dist[v] = dist[u] + val[i];
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) return false;
if (!vis[v]) {
que.push(v); vis[v] = true;
}
}
}
}
return true;
}
int main() {
建圖();
//建圖完畢後, 求最短路
if (!SPFA(n)) puts("-1");
else {
SPFA(1);
if (dist[n] == INF) puts("-2");
else cout << dist[n] << endl;
}
}
Cashier Employment
題面:
分析:
差分約束型別的問題中, 還有要一個難點在於知道這道題是一道差分約束的題, 而確定一道題時差分約束的題, 則需要找到題目中的不等式, 有些題目比較隱藏差分約束的特點, 這時候就可以通過先不管差分約束的一般形式, 有限找到不等式為主.
這道題而言, 我們設從i - 1
點開始工作的收銀員有x[i]
個, i-1 ~ i
點需要的收銀員需要num[i]
個, 那麼就可以得到x[i - 7] + x[i - 6] + ... + x[i] >= num[i]
, 這是一個不等式, 雖然不是我們熟悉的兩個變數的不等式, 不過是一個好兆頭, 接下來我們可以發現, 可以通過求字首和的方法進行化簡, 於是我們設sum[i] = Σx[i]
, 這樣就得到了不等式sum[i] - sum[i - 8] >= num[i]
, 當然還要考慮一些邊界情況, 於是我們可以得到所有的不等式(求最小值, 用最長路):
1. sum[i - 1] >= sum[i] - cash[i] //cash[i]表示i - 1時間點開始工作的收銀員, 最多可以招聘的個數
2. sum[i] >= sum[i - 1]
3. sum[i] >= sum[i - 8] + num[i]
4. sum[i] >= sum[i + 16] + num[i] - sum[24] //不難發現, 這裡有一個額外的變數, sum[24], 即最終的答案不過好在資料的範圍並不大, 我們可以列舉sum[24]的值, 將其視為一個常量
5. sum[24] >= sum[0] + ans, sum[0] >= sum[24] - ans, sum[0] = 0 //建立一個虛擬原點, 用於限定sum[24]的值, 是基於4而新增的條件
程式碼:
#include<iostream>
#include<cstdio>
#include<algorithm>
#include<cstring>
#include<cmath>
#include<queue>
#define debug(x) cout << #x << " = " << x << endl
using namespace std;
const int MAXN = 25, MAXM = 200 + 10;
int t, n;
int num[MAXN], cash[MAXN], sum[MAXN];
int h[MAXN], e[MAXM], val[MAXM], ne[MAXM], idx;
int cnt[MAXN];
bool vis[MAXN];
void AddEdge(int a, int b, int c) {
e[idx] = b, val[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void Build(int s24) {
memset(h, -1, sizeof h);
idx = 0;
for (int i = 1; i <= 24; i++) AddEdge(i - 1, i, 0), AddEdge(i, i - 1, -cash[i]);
for (int i = 8; i <= 24; i++) AddEdge(i - 8, i, num[i]);
for (int i = 1; i < 8; i++) AddEdge(16 + i, i, num[i] - s24);
AddEdge(0, 24, s24); AddEdge(24, 0, -s24);
}
bool SPFA(int s24) {
Build(s24); //由於每一次列舉的sum[24]都不一樣, 所以每一次列舉都要重新建圖
memset(cnt, 0, sizeof cnt);
memset(sum, -0x3f, sizeof sum);
memset(vis, false, sizeof vis);
queue<int> que;
que.push(0); vis[0] = true;
sum[0] = 0;
while (!que.empty()) {
int u = que.front(); que.pop();
vis[u] = false;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (sum[v] < sum[u] + val[i]) {
sum[v] = sum[u] + val[i];
cnt[v] = cnt[u] + 1;
if (cnt[v] >= 25) return false;
if (!vis[v]) {
que.push(v); vis[v] = true;
}
}
}
}
return true;
}
int main() {
scanf("%d", &t);
while (t--) {
for (int i = 1; i <= 24; i++) scanf("%d", &num[i]);
scanf("%d", &n);
int tmp;
memset(cash, 0, sizeof cash);
for (int i = 0; i < n; i++) {
scanf("%d", &tmp);
cash[tmp + 1]++;
}
int flag = 0;
for (int i = 0; i <= 1000; i++) { //列舉答案
if (SPFA(i)) {
cout << i << endl;
flag = 1;
break;
}
}
if (!flag) puts("No Solution");
}
return 0;
}