讀了我的上篇文章“一個 Lua 的湊24程式”的讀者可能會產生這樣的疑問:
- 為什麼由 4 個數字和 3 個運算子組成的合法的逆波蘭式就只有那 5 種?你是怎麼窮舉的?
- 為什麼不寫程式自動求出所有的合法逆波蘭式,而非要手算?
為了討論這兩個問題,我們先來看下面的問題:
問題1(出棧序列問題):數 1 ~ n 按順序入棧(棧是後進先出的),任何時刻你可以選擇讓下一個數入棧,或者讓當前棧頂元素出棧(若棧非空)。求所有可能的出棧序列的個數。
例:
若 n = 2,有兩種:
- 進進出出,得到出棧序列:2 1
- 進出進出,得到出棧序列:1 2
若 n = 3,有五種:(下面以 1 表示進棧,-1 表示出棧)
- 1 -1 1 -1 1 -1,得到:1 2 3
- 1 -1 1 1 -1 -1,得到:1 3 2
- 1 1 -1 -1 1 -1,得到:2 1 3
- 1 1 -1 1 -1 -1,得到:2 3 1
- 1 1 1 -1 -1 -1,得到:3 2 1
我們注意到:n = 3 時的出棧序列有 5 種,正好跟 4 個數字和 3 個運算子構成的逆波蘭式個數相等!
於是我們猜想:這兩個問題是等價的。
為了證實這個猜想,我們把出棧序列問題改寫成下面的形式:
問題2(1,-1排列問題):將 n 個 1 和 n 個 -1 排成序列 S2n,使得對任意的 k ∈[1, 2n],滿足,。求所有排列的個數。
注意到在問題1中,若出棧入棧操作的排列順序不同,則最後得到的數字序列也一定不同,因此出棧序列數就等於操作的排列個數,於是問題1與問題2等價。
回顧逆波蘭式的計算過程:每當遇到一個數字,就入棧,相當於棧中元素個數 +1,而每遇到一個運算子就出棧兩個數字,運算後結果入棧,相當於棧中元素個數 -1。我們還要求中間任何一步時棧中元素個數≥1。
如果把入棧操作(或 1)對應於逆波蘭式中的數字,出棧操作(或 -1)對應於逆波蘭式中的運算子,我們立即得到:
n + 1 個數字和 n 個運算子組成的合法逆波蘭式的個數問題,與 n 個數的出棧序列問題等價。不同之處在於逆波蘭式問題中,棧裡始終有一個數字。
再做進一步聯想,逆波蘭式相當於對錶達式樹做後序遍曆,於是我們不禁要問:逆波蘭式的個數問題與運算式樹狀架構,或者說二叉樹的個數問題是否等價?
問題3(二叉樹計數):求 n 個結點的二叉樹的個數。
例:當 n = 3 時,有 5 種:
為了進一步討論這個問題,我們先來看一下如何求 n 個數的出棧序列的個數 Cn。
為此我們將 1,-1 排列問題改寫成下面的 x ≥ y 格路問題。
問題4(x≥y格路問題)在 n * n 的方格中每次只能要麼向右走,要麼向上走。在 x ≥ y 的地區中,從 A 到 B 有多少種走法?
將 1 與向右對應,-1 與向上對應,不難看出這兩個問題的等價性。
下面就來求解這個問題。
設走法數為 Cn,考慮尋找 Cn 的遞推公式。
以下將 A 到 B 的那條斜線稱為“大斜線”,斜線上的點記為 A0(= A), A1, A2, ... , An(= B)。
有如下推論:從 A 到 B,且不經過 A1, A2, ... , An-1 中任何一點的走法有 Cn-1 種。
證:從 A 到 B,且不經過 A1, A2, ... , An-1 中任何一點相當於從 D 走到 E 的 x≥y格路問題,故有 Cn-1 種走法。證畢。
對於從 A 到 B 的任意一條路徑,記這條路徑中,第一次碰到大斜線的那個點為 F。則我們可以把 A 到 B 的所有路徑分成如下 n 類:
F = A1
F = A2
...
F = An = B
注意前面 F 的定義是第一次碰到大斜線,故 F 之前的路徑都沒碰到。易證對於任意一條 A 到 B 的路徑 L,它一定屬於某一個分類,而且不可能同時屬於兩個不同的分類。可見這些分類是互不相交的,而且正好完全覆蓋了 A 到 B 的所有路徑。
於是我們可以分別計算每一類的數量。
對 F = Ai,顯然 A 到 F 以及 F 到 B 可以看成 i 階和 n - i 階 x≥y格路問題,又由於 A 到 F 時沒有碰大斜線,故有 Ci-1 種,而 F 到 B 有 Cn-i 種,故由乘法原理,第 i 類有:Ci-1Cn-i 種。加起來得:
Cn = C0Cn-1 + C1Cn-2 + ... + Cn-1C0 , C0 = 1 (1)
這樣我們就得到了 Cn 的遞推公式。
下面再來看二叉樹計數問題。設 n 個結點的二叉樹有 Cn 種,除去根之後還有 n-1 個結點,給左子樹分 i 個結點,則右子樹有 n-1-i 個結點。顯然左右子樹分別化為兩個低階的二叉樹計數問題。於是令 i 從 0 取到 n-1,我們得到:
Cn = C0Cn-1 + C1Cn-2 + ... + Cn-1C0 , C0 = 1
與上面的遞推公式一樣。故:二叉樹計數問題與逆波蘭式計數問題等價。
問題5(多邊形劃分):將 n+2 邊凸多邊形劃分成三角形,只能在兩個頂點之間連線,每個頂點看作是不同的。有多少種劃分方法?
例:n = 3 時,五邊形顯然有五種劃分,即從每個頂點連出兩條線。
這個問題與前面幾個問題的等價性留給讀者自證。
下面我們就來求解遞推關係 (1) 式。
問題6:已知:C0 = 1,Cn = C0Cn-1 + C1Cn-2 + ... + Cn-1C0。求:Cn。
解:(母函數法)
將數列 {Cn} 作為形式冪級數的係數,構造出數列 {Cn} 的母函數:
f(x) = C0 + C1x + C2x2 + ... + Cnxn + ...
於是:
f2(x) = (C0 + C1x + C2x2 + ... + Cnxn + ...) * (C0 + C1x + C2x2 + ... + Cnxn + ...)
= C0C0 + (C0C1 + C1C0)x + (C0C2 + C1C1 + C2C0)x2 + ...
= C1 + C2x + C3x2 + ... + Cn+1xn + ...
將上式兩邊同乘以 x,再加 C0(= 1),得:
1 + xf2(x) = C0 + C1x + C2x2 + ... + Cn+1xn+1 + ...
= f(x)
於是我們得到關於 f(x) 的方程:
1 + xf2(x) = f(x)
解之,得:
(2)
對上式作泰勒展開(準確說是形式冪級數展開,反正就那個東西):
回顧泰勒公式:
令 x = -4x,a = 1/2 代入,得:
其通項可化為:
由於 Cn > 0,故可知 (2) 式中應取負號。上面的通項取負後,再除以 2x,得到 f(x) 的通項:
故:
這個數在組合數學中被稱為卡塔蘭(Catalan)數。
由此我們看到,從一個看似簡單的逆波蘭式計數問題,可以挖掘出這麼多東西,其實寫程式枚舉所有合法的逆波蘭式是可以的,相當於枚舉所有滿足要求的 1,-1 序列。只不過對於一個湊24程式來說太複雜,就省了。
下面是我的枚舉 1,-1 序列程式:
#include <stdbool.h><br />#include <stdio.h></p><p>/**<br /> * 根據當前的序列和 n,得到下一個序列。<br /> * 對這個函數來說,第一個序列是 1,-1 交錯狀態,<br /> * 最後一個序列是前面全是 1,後面全是 -1。<br /> *<br /> * @param ar 當前序列值,必須有 2n 項<br /> * @param n<br /> * @return true若下一個序列存在,false若當前已經是最後一個序列<br /> */<br />bool nextSeq(int ar[], int n)<br />{<br /> int sm1, s1;<br /> int i = 2 * n - 1;<br /> while (ar[i] == -1) // 尋找末尾連續的 -1<br /> {<br /> i--;<br /> }<br /> sm1 = 2 * n - i;<br /> while ((ar[i] == 1)&&(i >= 0)) // 尋找 -1 之後連續的 1<br /> {<br /> i--;<br /> }<br /> if (i < 0)<br /> {<br /> return false;<br /> }<br /> s1 = 2 * n - 1 - i - sm1;<br /> ar[i] = 1; // 將這個 -1 改成 1<br /> i++;<br /> int j;<br /> // 填充剩下的<br /> for (j = sm1 - s1; j > 0; j--)<br /> {<br /> ar[i] = -1;<br /> i++;<br /> }<br /> for (j = s1; j > 0; j--)<br /> {<br /> ar[i] = 1;<br /> ar[i + 1] = -1;<br /> i += 2;<br /> }<br /> return true;<br />}</p><p>int main(int argc, char *argv[])<br />{<br /> int n = 4;<br /> int ar[2 * n];<br /> int i;<br /> int count = 0;<br /> for (i = 0; i < n; i++)<br /> {<br /> ar[2 * i] = 1;<br /> ar[2 * i + 1] = -1;<br /> }<br /> do<br /> {<br /> for (i = 0; i < 2 * n; i++)<br /> {<br /> printf("%d ", ar[i]);<br /> }<br /> printf("/n");<br /> count++;<br /> } while(nextSeq(ar, n));<br /> printf("count: %d/n", count);<br /> return 0;<br />}<br />
這個程式用 C 語言編寫(C 不是 C++ !),用到了部分 C99 特性,比如 bool 型,棧上的動態大小數組等,編譯器用的是 GCC 4.5。所以如果你用 VC++ 可能必須改代碼才能編譯通過,但用 codepad(http://codepad.org/)可以編譯通過並運行。codepad 的運行結果:http://codepad.org/3u8eLcar
修改 main 函數中 n 的值可以輸出不同的 n 的值的結果。nextSeq() 函數根據當前的序列計算下一個序列,時間複雜度 O(n)。