經典旅行商問題
題目描述
一個商品推銷員要去若干個城市推銷商品,該推銷員從一個城市出發,需要經過所有城市后,回到出發地。應如何選擇行進路線,以使總的行程最短?請輸出最短行程。節點個數\(N\)滿足\(2 \leq N \leq 20\),路的長度小於\(1000\)。
分析解答
冷靜分析
雖然我沒有證明過,但這是一個NP完全問題沒錯,也就是說不存在已知多項式時間的高效解法,具體可見百度百科的NP完全問題(注意,請最好不要學習模擬退火等算法,因為它無法解決類似的全排列問題)。按我的理解的話,就是其復雜度一般都是指數級別的(不能以偏概全嘛,但是我做的NP完全問題好像都是指數級時間復雜度的)。但是我們對其稍加優化又可以比純暴力快得多。
例如這個旅行商問題,可能的路線一共有\((n-1)!\)種,如果純暴力試遍每一種方案,那必然是會超過限定時間的。那么我們就需要一種優化方案了,它就是:狀態壓縮動態規划。
我們知道,動態規划是根據之前已經計算好的狀態推出后面一種狀態,這道題亦是如此。也許有人會說,跑一遍最短路不就好了嗎?但是最短路不保證走遍每一個點,而即使我們加上了點的計數,那也不能保證從\(A\)上不存在一個環(往回走的環),其節點個數恰好彌補了那些沒掃過的節點。而且計數的時候還沒到終點的點該如何更新呢?說不定一不小心就更沒了,而且如果把每種情況都記錄下來,是不亞於枚舉的。但是仍有一些弱數據可以被水過去(NOIP),而且這種求最小值的狀態壓縮題甚至可以被一些優秀的隨機化算法(模擬退火)等水過去。不過這些算法最怕的就是求所有狀態的題目了,而狀態壓縮DP就做到了求出所有狀態這一點。
由於博主水平有限,無法帶領大家一步一步推理,因此我們就針對遞推式進行講解吧。其遞推式為:
其中\(f[S][v]\)表示的是從\(v\)出發訪問所有不屬於集合\(S\)的頂點,最終回到\(0\)的路徑的權重總和的最小值。而集合\(V\)表示為所有城市的集合,不難理解從\(0\)到\(0\)就是路程為\(0\)對吧,並且再將其它所有情況均賦值為無窮大(當然不可能是無窮大啊,找一個達不到的值即可),以防計算不存在的情況。其他的話,遞推式已經表達得很明白了,大家也可以舉幾個例子幫助自己理解(注意,里面有些狀態是對答案沒用的,大家可以找一找,后面我會提到)。
注意這里出現了集合表示法,但是似乎不好怎么表示這個集合,難道用\(set\)表示嗎?那就慢了!別忘了二進制這個好東西。
注意到我們的\(int\)可以表示到\(2^{31}-1\),然而這里的\(N\)才這么大一點,那么我們就可以在二進制位中逐位保存某個點“存在”和“不存在”的情況。再者,這只需涉及到二進制的位運算,所以整體來說還是很快的。
那么我們現在來為一些稍缺知識的同學補充一些二進制表達集合的方法吧!
二進制表達想必學過一些二進制的人都知道吧,我們需要做的僅僅是摒棄它的十進制的模樣(別影響了自己的理性思維)。假設現在有一個集合\(S=\emptyset\),表示為二進制就是\(00000000\)(假裝只有\(8\)位),然后我們往里面加入\(0\),那么在二進制位的第\(0\)位就要改為\(1\),也就是\(S=S\cup \{ 0\} =S\vee 2^0=S|1<<0\)。同理,如果往里面加入\(6\),就是\(S|=1<<6\)(此處計算機表示法均用C++表達)。
如果是判斷\(u\)是否存在於集合\(S\)中的話,那么只需要判斷\(S\vee 2^u\)(即C++中的\(S\And (1<< u)\))是否為真就可以了。因為后者僅第\(u\)位是\(1\),其它部分都是\(0\),而與的性質是二者都真時才為真,故達到了判斷的目的。
於是我們就不難寫出程序了。這里以洛谷1171為例。
代碼一覽
注意,這道題十分卡常!仍然需要優化!但請一步一步看下來,不要急於求成!
下面這段代碼僅能得到\(80\)分,但是開\(O2\)可以險過。
#include <cstdio>
#include <algorithm>
#define INF 20000
using namespace std;
int f[(1<<20)][20], d[20][20];
int main()
{
int i, j, k, n, smax;
scanf("%d", &n);
smax = (1 << n) - 1;
for(i = 0; i < n; i += 1)
for(j = 0; j < n; j += 1)
scanf("%d", &d[i][j]);
for(i = 0; i <= smax; i += 1)
for(j = 0; j < n; j += 1)
f[i][j] = INF;
f[smax][0] = 0;
for(i = smax-1; i >= 0; i -= 1)
for(j = 0; j < n; j += 1)
for(k = 0; k < n; k += 1)
if(!(i >> k & 1) && j != k)
f[i][j] = min(f[i][j], f[1<<k|i][k] + d[j][k]);
printf("%d", f[0][0]);
return 0;
}
回過頭來我們再看一下這個遞推式,實際上還有優化的地步!
注意到\(min\)中的\(f[S\cup \{ u\}][u]\)保證了\({u}\)必然在集合\(S\cup \{ u\}\)里面,有人可能會說這是廢話,但是這說明了在前面的狀態中,\(v\)必然在集合\(S\)里面啊!因此對於\(v\)不在集合\(S\)中的情況就不必考慮了。但是,注意當\(S\)為空(程序中變為\(0\))的時候它就不會繼續走答案了,也就是說我們要特殊處理一下這種情況。
於是我們得到了\(90\)分代碼。
#include <cstdio>
#define INF 20000
int f[(1<<20)][20], d[20][20];
inline int cmin(int a, int b)
{
return a < b ? a : b;
}
int main()
{
int i, j, k, n, smax;
scanf("%d", &n);
smax = (1 << n) - 1;
for(i = 0; i < n; i += 1)
for(j = 0; j < n; j += 1)
scanf("%d", &d[i][j]);
for(i = 0; i <= smax; i += 1)
for(j = 0; j < n; j += 1)
f[i][j] = INF;
f[smax][0] = 0;
for(i = smax-1; i; i -= 1)
for(j = 0; j < n; j += 1)
if(i >> j & 1)/*添加1*/
for(k = 0; k < n; k += 1)
if(!(i >> k & 1))
f[i][j] = cmin(f[i][j], f[1<<k|i][k] + d[j][k]);
int ans = INF;
for(i = 1; i < n; i += 1)/*添加2*/
ans = cmin(ans, f[1 << i][i] + d[0][i]);
printf("%d", ans);
return 0;
}
那,不開\(O2\)怎么拿\(100\)分呢?答案是舍去多考慮的必然不存在的情況,也就是必然為\(INF\)的情況。
讓我們再從頭開始看,看那個遞推式(稍改)。
最開始\(S\)二進制位(位數小於\(N\))全部是\(1\)的時候,僅\(0\)號點為\(0\),其它都是\(INF\)。而有些\(INF\)是可以推到底層的!那什么樣的情況滿足其\(f\)恆為\(INF\)呢?首先注意到\(f[V][x],x\neq 0\)的情況恆為\(INF\),那這個狀態又會推到哪里呢?根據遞推式:
我們有:
容易得到\(f[S][v]\)必然為\(INF\)(因為\(u\)只有一種可能)。
同理,對於遞推式:
\(f[S][v]\)必然由一個比當前集合\(S\)(包含元素\(0\))多一個元素的集合\(S'\)得來,而\(S'\)又以類似方式得來,最終它們共同的來源均為:
故對於任何滿足\(\{ 0\} \in S\)的\(f[S][v]\),它們的值恆為\(INF\)。用二進制表示法來說,只要\(S\And 1\)為真,那么就無需考慮。
那么程序就可以“進化”了,從而拿到\(100\)分!
#include <cstdio>
#define INF 20000
int f[(1<<20)][20], d[20][20];
inline int cmin(int a, int b)
{
return a < b ? a : b;
}
int main()
{
int i, j, k, n, smax;
scanf("%d", &n);
smax = (1 << n) - 1;
for(i = 0; i < n; i += 1)
for(j = 0; j < n; j += 1)
scanf("%d", &d[i][j]);
for(i = 0; i <= smax; i += 1)
for(j = 0; j < n; j += 1)
f[i][j] = INF;
f[smax][0] = 0;
for(i = smax-1; i; i -= 2)
for(j = 0; j < n; j += 1)
if(i >> j & 1)
for(k = 0; k < n; k += 1)
if(!(i >> k & 1))
f[i][j] = cmin(f[i][j], f[1<<k|i][k] + d[j][k]);
int ans = INF;
for(i = 1; i < n; i += 1)
ans = cmin(ans, f[1 << i][i] + d[0][i]);
printf("%d", ans);
return 0;
}
另外,如果各位還追求更高的速度,那么可以將自定義取最小值函數換成標准模板庫中的\(min\)函數,然后開一下\(O2\)就能將最慢的900ms+的點提速到200ms+,神奇吧!
注意:以上數據均在洛谷測試,均為C++語言。
輪廓線優化——“棋盤”里的美妙操作
在狀態壓縮DP里面,我們也經常會碰到一些“棋盤”題。如果直接暴力搜索的話,會產生“狀態爆炸”的尷尬局面,於是我們就需要一個美妙的優化方案——輪廓線優化了。
代表題目 [USACO06NOV]Corn Fields
題目大意:有一塊\(M\times N\)(\(1\leq M,N\leq 12\))的長方形牧場,分割的每一塊土地都是正方形的(說白了就是棋盤),農夫要在某幾格種上牧草或者干脆不種。要求是:不能種在貧瘠的土地上;各個土地上的牧草不能有公共邊。土地狀況會在輸入中給出,求不同的種植方案的個數,答案對\(10^8取模\)。(看你們還怎么用模擬退火)
具體見洛谷1879。
分析解答
冷靜分析
也許有人會說:啊!這還怎么用狀態壓縮啊!\(M\times N\)早就爆炸了啊!(說好的冷靜呢)
其實這題有人就這么過了,還寫了題解,確實比別的好理解,不過代價就是太慢了。
額哼,不扯了,我們切入正題吧。
其實我們這里沒必要搞出這么多狀態,我們的方法其實就是逐行遞推(逐行動態規划)。轉移方程該怎么寫呢?也不難,就是根據當前考慮的格子的左邊和上面的狀態得到當前格的狀態。但是要注意不存在的情況及時過濾!於是我們得到遞推式:
第一個賦值式表示的是初值(經驗所得,下方有答疑);第二個遞推式表示的是這個點不被選取的情況;第三個遞推式表示這個點被選取的情況。但是需要注意的是,如果這個點被選了,那么第二第三條遞推式都要寫上,因為農夫可以選擇不種(別被細節坑了哦)!
哦,對了,對於某格上面存在草的情況,遞推下來的時候要遞推到這格不種草的情況,圖示:
不能種的情況:
- 上面或左邊有草。
- 該點土地不達標。
但是仍然要轉移過來,不然怎么遞推到最后一行去統計呢?如果不清楚的話,下文會提到為什么要在最后一行統計。
您可能會問的問題:
- 為什么\(f[0][0]=1\)啊?(因為第一行也是可以由第\(0\)行推來的,而考慮第一行的時候,第一個位置由於上面沒有東西,不會考慮,而它的左邊必然是\(0\)的,那么這一個點就可以由轉移方程推來)
- 不用考慮\(S\)中有沖突的情況嗎?(不需要,因為沖突情況都是\(0\)的,對答案沒有貢獻)
- 不存在的情況有哪些?(比如在第一行的時候考慮上面的情況是不存在的,在第一列的時候考慮左邊的情況是不存在的——但這種情況可以人為排除,看我程序就明白了)
- 可以具體表現(讓大家感性理解)一下這個動態規划的形式嗎?(如圖,包括輪廓線在這里的具體表現)
- 可以證明這些遞推式是正確的嗎?(很簡單,因為每次枚舉不同的點,而輪廓線集合是互異的,故可以全部加起來,不過注意最后統計的時候要統計最后一行的所有狀態)
- 為什么選擇輪廓線式遞推?(老師說這樣快一些,未親測,請各位酌情接受)
- 如果左、上都有草怎么轉移?(雖然圖中是在某一格有值的,但是實際操作中,我們可以將其統計到輪廓線上得到一個輪廓線值,那么就算左、上都有草,也只要按照遞推式加一下就好了)
還有什么問題也可以通過評論告訴我,或者用洛谷私信也行(洛谷ID:50871)。
為了追求完美,博主用了滾動數組(也就是數組重復利用,不浪費空間)。
下面是滿滿的注釋的代碼,博主滿滿的愛。
代碼一覽
舊洛谷機子上面必然為0ms的代碼,別問我為什么。因為新測評機沒有0ms了啊!沒趕上啊!不過在洛谷IDE(老測評機)測試全為可種草的土\(12\times 12\)的數據中,跑出來是0ms的。
#include <cstdio>
#define MAXN 15
#define p 100000000
int a[MAXN][MAXN], f[2][1<<MAXN], kmax;
inline void init(int x)//滾動數組初始化函數
{
for(register int i = 0; i < kmax; i += 1)
f[x][i] = 0;
}
int main()
{
int i, j, k, ans = 0;
int M, N, now = 0, up, left;
scanf("%d%d", &N, &M);
kmax = 1 << M;
for(i = 1; i <= N; i += 1)//正常的行
for(j = 0; j < M; j += 1)//方便二進制
scanf("%d", &a[i][j]);
f[0][0] = 1;
for(i = 1; i <= N; i += 1)
for(j = 0; j < M; j += 1)//枚舉點
{
now ^= 1;//滾動數組走起
init(now);//注意初始化
for(k = 0; k < kmax; k += 1)
{
up = (1 << j) & k;
//上面那格和現在這格處於同一列
//根據輪廓線,這格的左邊是同行的
left = j ? (1 << (j - 1)) & k : 0;
//看看前面一格有沒有草
//人為排除不存在的情況
if(i == 1 && up)//人為不好排除就if
continue;
if(up)
{
f[now][k ^ (1 << j)] = (f[now][k ^ (1 << j)] + f[now ^ 1][k]) % p;
//注意到同列,因此遞推下來的時候就要推到這里不種草的情況
continue;
}
if(left || !a[i][j])
{
f[now][k] = (f[now][k] + f[now ^ 1][k]) % p;
//這里照常推下來就行了
continue;
}
f[now][k] = (f[now][k] + f[now ^ 1][k]) % p;
f[now][k | 1 << j] = (f[now][k ^ (1 << j)] + f[now ^ 1][k]) % p;
//遞推式照搬
}
}
for(i = 0; i < kmax; i += 1)
ans = (ans + f[now][i]) % p;
//互異,統計,加起來即可,別忘記取模哦
printf("%d", ans);
return 0;
}
總結
狀態壓縮DP雖然說是指數級的DP,但是也涉及到一些細微的優化,甚至可以達到2倍級的優化。所以,在寫狀態壓縮DP的時候也要多想想,多分析分析復雜度。另外,畢克大佬告訴我們不要依賴模擬退火和一些奇技淫巧解這樣的題,因為如果出題人出了計數題,那么這些算法就沒辦法“水”過去了。
還有,這些狀態壓縮DP只是小部分,后續我可能還會繼續更新,比如插頭DP等。
參考文獻
- 秋葉拓哉, 岩田陽一, 北川宜稔. 挑戰程序設計競賽(第二版)[M]. 北京:人民郵電出版社, 2013. 191-198
- 龍_之_谷. 題解 [USACO06NOV]玉米田Corn Fields[EB/OL]. https://www.luogu.org/problemnew/solution/P1879, 2018-08-04
快樂一刻
請你將下列缺陷填好,使其成為完整的旅行商問題的程序:
#include <cstdio>
#define ________ 20000
int _[(1<<20)][20], __[20][20];
inline int __________(int ______, int _______)
{
return ______ < _______ ? ______ : _______;
}
int main()
{
int _____, _______, _________, ___________, ____;
scanf("%d", &___________);
____ = (1 << ___________) - 1;
for(_____ = 0; _____ < ___________; _____ += 1)
for(_______ = 0; _______ < ___________; _______ += 1)
scanf("%d", &__[_____][_______]);
for(_____ = 0; _____ <= ____; _____ += 1)
for(_______ = 0; _______ < ___________; _______ += 1)
_[_____][_______] = ________;
_[____][0] = 0;
for(_____ = ____-1; _____ ; _____ -= 2)
for(_______ = 0; _______ < ___________; _______ += 1)
if(_____ >> _______ & 1)
for(_________ = 0; _________ < ___________; _________ += 1)
if(!(_____ >> _________ & 1))
_[_____][_______] = __________(_[_____][_______], _[1<<_________|_____][_________] + __[_______][_________]);
int ___ = ________;
for(_____ = 1; _____ < ___________; _____ += 1)
___ = __________(___, _[1 << _____][_____] + __[0][_____]);
printf("%d", ___);
return 0;
}
別看了,這里沒有缺陷,交上去照樣AC。
寫在最后
推薦大家看看《挑戰程序設計競賽(第二版)》,講的也很好噢~
感謝參考文獻中提到的文獻的幫助。
除參考部分外,大多數內容(如常數優化,較為通俗的理解,圖片等)均為個人智力成果,如需轉載,請注明出處,禁止作商業用途傳播。
最后,感謝各位的閱讀。