我這里實現全排列的基本算法如下(C++):
1 #include <algorithm>
2 #include <iostream>
3 #include <vector>
4
5 void perm(std::vector<int>& v, int pos = 0)
6 {
7 if (pos == v.size()) {
8 for (int i = 0; i < v.size(); ++i) {
9 std::cout << v[i] << ',';
10 }
11 std::cout << std::endl;
12 }
13 for (int i = pos; i < v.size(); ++i) {
14 std::swap(v[i], v[pos]);
15 perm(v, pos + 1);
16 std::swap(v[i], v[pos]);
17 }
18 }
19
20 int main()
21 {
22 std::vector<int> v;
23 for (int i = 1; i <= 4; ++i) v.push_back(i);
24 perm(v);
25 }
這是一種最簡潔的算法,即,在每輪遞歸中,依次選擇一項數據放在當前參考位置,然后移動參考位置,並進入更深一級遞歸。
這里的C++實現,簡單,但是靈活性不夠。上面的例子,對全排列的每個狀態,只能夠打印輸出。當然,為perm函數添加一個函數指針,用不同的函數行為來替代打印操作,可以稍微提高靈活性。但,我這里所說的靈活性受限,主要是指,上面的算法只能一口氣處理所有的全排列狀態,不能中斷這個過程。是的,你也可以為上面的perm函數提供一個容器,收集所有排列狀態,待頂層perm函數返回后,再對搜集的結果進行進一步處理,但是,當排列狀態過多時,搜集狀態的容器會占用很大的內存。能不能把數組的所有排列狀態看作一個集合,然后用迭代器去訪問這整個集合?迭代器的靈活性能夠滿足我的需要,最好,迭代過程中,也沒有數據堆積,不會出現內存占用太多的情況。
C++的STL中有符合要求的算法:std::next_permutation。
使用如下:
1 #include <algorithm>
2 #include <iostream>
3 #include <vector>
4
5 int main()
6 {
7 std::vector<int> v;
8 for (int i = 1; i <= 4; ++i) v.push_back(i);
9 do {
10 for (int i = 0; i < v.size(); ++i) {
11 std::cout << v[i] << ',';
12 }
13 std::cout << std::endl;
14 } while (std::next_permutation(v.begin(), v.end()));
15 }
next_permutation這個函數,能夠滿足我的需求。它靈活,快速,並且沒有多余的內存占用。不過,我關注的焦點不在它,因為它采用了一種更復雜的算法。具體的算法實現,可以自行查看該函數的源碼,另外,侯捷《STL源碼剖析》一書中,對該算法的原理有清楚的解釋。
我希望的是,算法如第一例般簡明易懂,使用起來又有如迭代器般靈活。
為了這個目標,我想到協程。
C/C++的話,Windows下有CreateFiber等一組函數能夠創建和切換協程。由於Lua中的協程和Windows的Fiber使用大同小異,這里我就直接轉到Lua的協程-coroutine了。
1 function _perm(arr, pos)
2 pos = pos or 1
3 if pos == #arr then
4 coroutine.yield(arr)
5 end
6 for i = pos, #arr do
7 arr[i], arr[pos] = arr[pos], arr[i]
8 _perm(arr, pos + 1)
9 arr[i], arr[pos] = arr[pos], arr[i]
10 end
11 end
12
13 function perm(arr)
14 return coroutine.wrap(function()
15 _perm(arr)
16 end)
17 end
18
19 for i in perm({1, 2, 3, 4}) do
20 table.foreach(i, function(_, v) io.write(v, ',') end)
21 print()
22 end
可以看到,上面的_perm函數和C++版的perm幾乎相同,只是把打印數組的語句換成了coroutine.yield。
_perm實現了算法的主體部分,而perm則是將_perm包裝成協程迭代器后返回給外部使用。這個實現,既保持了算法的清晰性,又得到了極高的靈活度。
當然,協程不是沒有開銷的,協程其實是另外開辟了一個棧的空間,在上面返回的coroutine迭代其銷毀前,這份額外的棧內存一直存在。但是,肯定比收集所有狀態再逐一訪問的方案更優,因為,協程中的棧幀最多不過n層,而前者的內存占用是n!。
之前我還測試過,將STL中的next_permutation算法移植到Lua中,再和協程版本比效率,結果是協程版本輸了,但兩者的用時也較接近。畢竟協程是一種用空間/時間換清晰性,控制軟件復雜度的方案,不應該對它的性能苛求太多。當然,只針對全排列這個問題,采用next_permutation算法是最好了。
本來,說過Lua的協程版全排列算法后,我這篇文章也該結束了,但事實上,由於在不同的語言中,協程的用法不盡相同,一些語言中的協程在使用上會有一些局限,因此,部分語言的協程在解決特定問題時,可能會遇到點麻煩。
我在很長的時間里,都沒意識到,C#的yield return其實是協程設施,直到我最近學Python時,看到有人指出,Python的協程就是send和yield,我才恍然大悟。
在需要自動生成迭代器時,yield這貨,它表現得極為犀利,也因此,各種語言入門書,但凡講yield,都用迭代器的生成作為示例。事實上,yield在實現類似Linq to object這樣的多級延遲迭代的函數庫時,確實能夠發揮及其強大的威力。
和Lua/Win-API版本的協程相比,C#/Python中的協程更為高效,因為它的實現並沒有借助棧,內存占用更少,但是,也因此,后者的協程在用法上有了一個限制:yield應該放在同一個函數的函數體中,即是說,在Python中,不能在外層函數中yield后,又簡單的進入內層函數再yield。這是因為,外層函數對內層函數的調用,並不會執行內層函數函數體的代碼,這個調用的結果,只是一個表達式,一個迭代器對象。
這是一段Lua代碼:
1 function bar()
2 coroutine.yield(2)
3 end
4
5 function foo()
6 coroutine.yield(1)
7 bar()
8 coroutine.yield(3)
9 end
10
11 for i in coroutine.wrap(foo) do
12 print(i)
13 end
它輸出的是1,2,3.
這是一段Python代碼:
1 def bar():
2 yield 2
3
4 def foo():
5 yield 1
6 bar()
7 yield 3
8
9 for i in foo():
10 print i
它輸出1,3.
因為二者的協程實現方案是不同的!Python中的bar()調用,只會返回一個迭代器,而不會執行任何bar函數體中的語句!
其實說到這里,結論已經很明顯了:在C#/Python中,如果需要跨多個函數幀來yield的話,不能簡單的調用含yield的內層函數,而是應該代之以這種語句:
1 for i in foo(): yield i
這里的foo就是含yield的內層函數。不管foo內部的yield語句后面有沒有表達式,即,不管上面的i是否為None,都應該在含yield的外層函數中,將foo()調用替換乘上面的語句,這樣才能榨干foo,執行foo中應該被執行的每條語句。
在發現C#的yield return就是協程之前,我一度認為yield是不能用於解決全排列這個問題的,直到我認真思考,得出上面這個結論之后。這樣看來,yield這種協程方案,功能是和Lua的coroutine等效的了。
這是Python的全排列實現:
1 def perm(arr, pos = 0):
2 if pos == len(arr):
3 yield arr
4
5 for i in range(pos, len(arr)):
6 arr[pos], arr[i] = arr[i], arr[pos]
7 for _ in perm(arr, pos + 1): yield _
8 arr[pos], arr[i] = arr[i], arr[pos]
9
10 for i in perm([1,2,3,4]):
11 print i
當然,C#也一樣:
1 static void Swap<T>(ref T a, ref T b)
2 {
3 T t = a;
4 a = b;
5 b = t;
6 }
7
8 static IEnumerable<int[]> Perm(int[] arr, int pos)
9 {
10 if (pos == arr.Length)
11 {
12 yield return arr;
13 }
14 for (int i = pos; i < arr.Length; ++i)
15 {
16 Swap(ref arr[i], ref arr[pos]);
17 foreach (var j in Perm(arr, pos + 1)) yield return j;
18 Swap(ref arr[i], ref arr[pos]);
19 }
20 }
21
22 static void Main(string[] args)
23 {
24 foreach (var i in Perm(new int[] { 1, 2, 3, 4 }, 0))
25 {
26 Console.WriteLine(string.Join(",", i.Select(j=>j.ToString()).ToArray()));
27 }
28 }
盡管有不能利用多核心充分發揮硬件性能這個缺陷,但還是不得不說,協程實在是用線性代碼實現異步邏輯之居家旅行殺人越貨的必備神器啊。
