读了我的上篇文章“一个 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 排成序列 S 2n,使得对任意的 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 种。加起来得:
C n = C 0C n-1 + C 1C n-2 + ... + C n-1C 0 , C 0 = 1 (1)
这样我们就得到了 Cn 的递推公式。
下面再来看二叉树计数问题。设 n 个结点的二叉树有 Cn 种,除去根之后还有 n-1 个结点,给左子树分 i 个结点,则右子树有 n-1-i 个结点。显然左右子树分别化为两个低阶的二叉树计数问题。于是令 i 从 0 取到 n-1,我们得到:
C n = C 0C n-1 + C 1C n-2 + ... + C n-1C 0 , C 0 = 1
与上面的递推公式一样。故:二叉树计数问题与逆波兰式计数问题等价。
问题5(多边形划分):将 n+2 边凸多边形划分成三角形,只能在两个顶点之间连线,每个顶点看作是不同的。有多少种划分方法?
例:n = 3 时,五边形显然有五种划分,即从每个顶点连出两条线。
这个问题与前面几个问题的等价性留给读者自证。
下面我们就来求解递推关系 (1) 式。
问题6:已知:C 0 = 1,C n = C 0C n-1 + C 1C n-2 + ... + C n-1C 0。求:C n。
解:(母函数法)
将数列 {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 序列程序:
这个程序用 C 语言编写(C 不是 C++ !),用到了部分 C99 特性,比如 bool 型,栈上的动态大小数组等,编译器用的是 GCC 4.5。所以如果你用 VC++ 可能必须改代码才能编译通过,但用 codepad(http://codepad.org/)可以编译通过并运行。codepad 的运行结果:http://codepad.org/3u8eLcar
修改 main 函数中 n 的值可以输出不同的 n 的值的结果。nextSeq() 函数根据当前的序列计算下一个序列,时间复杂度 O(n)。