首先說明一下,此博文來自我在CSDN上看到的一篇哈密頓迴路(有向圖中)的位運算算法,出自GDTZX大神之手,(侵刪),雖然剛從校園畢業,但腦子已經完全僵住了,花了許久纔看懂了這個算法。
哈密頓迴路,具體到本題之中即從某一個點開始經過所有的點一次後再回到該點的不同路徑數。對於這個不同需要注意兩點:
-
如果我們將路徑經過的點按順序寫下,比如當n=3時,若存在123和231。此時,我們認爲這兩條路徑是同一條哈密頓迴路。而123和213則是不同的哈密頓迴路。
-
若兩個點之間有多條邊,經過不同的邊的路徑仍然看作同一條哈密頓迴路。不同哈密頓迴路只和經過的點有關。因此對於多條邊的情況我們可以將其合併爲一條邊來考慮。
對於哈密頓迴路,一個簡單的想法就是枚舉所有可能的路徑,判定這個路徑是否存在。即時間複雜度爲O(n!)。而題目給定的數據範圍爲:n <= 12,所以最大可能的枚舉次數爲12! = 479,001,600。
極限的數據不到5億,所以我們可以考慮使用暴力來枚舉所有的哈密頓迴路。直接採用DFS枚舉我們的每一步,最後判定是否走回到起點。
僞代碼如下:
DFS(int nowVertex, bool visitedVertex, int path, int length)
visitedVertex[ nowVertex ] = True;
If (all Vertex is visited) Then
Count = Count + 1
Else
For (u is next vertex of nowVertex)
If (not visitedVertex[ u ]) Then
path[ length ] = u
DFS(u, visitedVertex, path, length + 1)
End If
End For
End If
visitedVertex[ nowVertex ] = False
由於哈密頓迴路始終會經過每一個點,所以我們只以1爲起點就一定可以找出所有的哈密頓迴路。
那麼這樣是否能夠解決這道題目呢?我只能說不一定能夠解決。
雖然僞代碼相同,但是根據實現的方法會有不同的運行效率,在某些實現方法下就能夠通過所有的數據點,在某些實現方法下就會超過時限。
這裏我們介紹一種利用位運算的實現,能夠使得整個程序的效率提高很多倍。
首先來看看代碼:
const int MAXN = 14;
int edge[ MAXN ];
int p[1 << MAXN];
int cnt;
void dfs(int nowVertex, int unused) {
if (!unused) {
cnt += (edge[nowVertex] & 1);
return ;
}
int rest = unused & edge[ nowVertex ];
while (rest) {
int tp = rest & (-rest);
dfs(p[ tp ], unused - tp);
rest -= tp;
}
return ;
}
int main()
{
int n, m;
scanf("%d %d", &n, &m);
for (int i = 0; i < n; ++i)
p[ 1 << i ] = i + 1;
while (m--) {
int u, v;
scanf("%d %d", &u, &v);
edge[u] |= 1 << (v - 1);
}
dfs(1, (1 << n) - 2);
printf("%d\n", cnt);
return 0;
}
我們一個一個來解釋每一個變量的含義:
edge[i]
表示點i
的next節點情況,我們用二進制表示edge[i]
,比如當edge[i]=01011
時:
+---+---+---+---+---+
| 5 | 4 | 3 | 2 | 1 | 右起第j位
+---+---+---+---+---+
| 0 | 1 | 0 | 1 | 1 | 二進制位的值
+---+---+---+---+---+
從右起第j
位,若爲1,則表示存在i到j的邊;若爲0,則表示不存在i到j的邊。所以edge[i]=01011
就表示節點i可以到達節點1,2,4。
p[i]
是爲了方便查找點的編號。在edge[i]
中若存在01000
,我們可以根據01000=8
, 而p[8]=4
來快速的通過二進制位來定位節點編號。
所以有初始化:
for (int i = 0; i < n; ++i)
p[ 1 << i ] = i + 1;
而通過節點編號來找到二進制位,則可以直接使用1 << (i - 1)
實現。
我們在讀入數據時,通過位運算邊可以記錄下所有點之間的連邊情況:
while (m--) {
int u, v;
scanf("%d %d", &u, &v);
edge[u] |= 1 << (v - 1); // 記錄u有後繼節點v
}
unused
該二進制數表示我們尚未訪問的節點集合,同樣的右起第i
位表示節點i,比如unused = 01100
:
+---+---+---+---+---+
| 5 | 4 | 3 | 2 | 1 | 右起第i位
+---+---+---+---+---+
| 0 | 1 | 1 | 0 | 0 | 二進制位的值
+---+---+---+---+---+
表示我們現在深度優先搜索已經經過了節點1,2,5,而節點3,4還尚未經過。
由於我們是以節點1作爲起點,初始化的unused
也就要去掉最右邊的1,所以代碼爲dfs(1, (1 << n) - 2)
。
接下來我們逐行解釋dfs
函數:
if (!unused) {
cnt += (edge[nowVertex] & 1);
return ;
}
當我們所有的節點都經過一次之後,unused
中一定不存在1,因此有!unused = true
。但是此時並不一定找到了哈密頓迴路,我們還需要判斷當前節點是否能回到起點,也就是節點1。若nowVertex
可以到達節點1,則edge[nowVertex]
最右邊1位一定是1,那麼就一定有edge[nowVertex] & 1 = 1
。
int rest = unused & edge[ nowVertex ];
rest
表示當前節點還可以走到的未訪問節點。由於&
運算的性質,只有當unused
和edge[ nowVertex ]
對應二進制位同時爲1時,rest
對應的二進制位纔會爲1。其含義就是該節點尚未訪問,且節點nowVertex
可以到達該節點。
while (rest) {
int tp = rest & (-rest);
dfs(p[ tp ], unused - tp);
rest -= tp;
}
該循環的作用是枚舉每一個可以到達的點。
int tp = rest & (-rest);
這裏利用了一個性質,即p & -p
等於取出p的最右邊的一個1。舉個例子p=10110
:
+---+---+---+---+---+
p | 1 | 0 | 1 | 1 | 0 |
+---+---+---+---+---+
-p | 0 | 1 | 0 | 1 | 0 |
+---+---+---+---+---+
& | 0 | 0 | 0 | 1 | 0 |
+---+---+---+---+---+
我們不斷的利用這個性質從rest
裏面取出可以使用的二進制位,進行dfs(p[ tp ], unused - tp);
。同時再枚舉完一個節點後,將其從rest
中刪除,即rest -= tp;
。
最後我們再使用printf("%d\n", cnt);
來輸出我們找到的方案數。
在上面DFS的基礎上,我們還可以進一步優化。
遞歸的過程中,unused
很有可能會出現重複的情況,比如說從1->3->2->4
和從1->2->3->4
,對於dfs(4, unused)
來說,此時的unused
值都是相同的。因此我們可以採用記憶化搜索的方式進一步降低複雜度。
定義數組f[n][unused]
表示當前節點爲n
,且節點訪問情況爲unused
時的方案數量。
那麼有:
f[n][unused] = sigma(f[p][unused + (1 << (n - 1))] | (unused & (1 << (n - 1)) == 0) and (p != n) and (edge[p] & (1 << (n - 1)) != 0))
這對應的是原dfs函數中下面這段代碼的逆過程。
while (rest) {
int tp = rest & (-rest);
dfs(p[ tp ], unused - tp);
rest -= tp;
}
三個條件分別爲:
(unused & (1 << (n - 1)) == 0)
表示當前狀態中右起第n位爲0(p != n)
表示前驅結點不等於n(edge[p] & (1 << (n - 1)) != 0)
表示節點p能夠到達節點n
在計算f[n][unused]
的過程中,假設unused
的二進制表示中有i
個1,則我們需要事先計算出所有i+1
個1的狀態才能夠保證unused + (1 << (p - 1))
是正確的結果。
因此我們在枚舉過程中,需要按照狀態中1的個數來枚舉。
其僞代碼:
For numberOfOnes = n-1 .. 0
For (the number of ones of unused equals numberOfOnes)
For nowVertex = 1 .. n
For prevVertex = 1 .. n
If (unused & (1 << (nowVertex - 1)) == 0) and (prevVertex != nowVertex) and (edge[ prevVertex ] & (1 << (nowVertex - 1)) != 0) Then
f[ nowVertex ][ unused ] = f[ nowVertex ][ unused ] + f[ prevVertex ][unused + (1 << (nowVertex - 1))]
End If
End For
End For
End For
End For
對於f[n][ unused ]
數組,其初始條件爲f[1][(1 << n) - 2] = 1
。
最後需要將所有的f[n][0]
中能夠到達節點1的節點n累加起來,即可得到所有的方案數。該算法的理論時間複雜度爲O(2^n*n^2)
。
結果分析
該題目一共只有7名選手通過,大部分提交過該題的選手也都有一定的部分分值。
本題直接採用搜索就能夠通過,拉開差距的主要原因是對於僞代碼實現方式的不同,而導致通過的測試點數量不同。
另外還有一個易錯點是走完所有節點之後一定要判斷是否可以回到起點。