前言:前天吃晚饭的时候,在去食堂的路上,同学给我们出了一个括号组合的问题。虽然利用吃饭的时间,给出了合理的计算雏形,但是其中很多的细节,在没有用纸笔的情况下,是分析不全的。回到实验室后,他告诉我们这是一个求解卡特兰树的问题。可以直接用公式做。好吧,这是我第一次听到数学家-卡特兰的名字。虽然被告诉了用公式很快就能计算出结果,但是作为一个有些偏执的小白程序员,我还是决定用我晚饭间想到的方式实现,并解决这个问题。白天琐事太多,没得机会,晚上脑子又转的慢,所以磨磨唧唧的隔了两天才搞定。现在写点东西总结一下收获。 写了个我感觉有点糙的python代码放在个人空间了,暂时就不贴在这里了。
原问题:现在有左括号'(' 和右括号')' 若干,当然数量上左右匹配。问对于总数为N的情况下,共有多少种排列组合?
>>> 如果将单个括号当做处理对象的话,那么最简单直观的方法就是用二叉树了:
首先构造一个 节点数为2^N-1的完全二叉树, 这个树具有这样的特点,每个节点的数据只存一个整型数,int flag,并且flag的取值只为-1和1,而根节点的值为1。1代表'(' ,-1代表')'。
当构造完后,需要定义三个全局变量,int deep,count,threshold,分别代表当前遍历节点所在的深度,已经获得的有些结果的统计值,和一个遍历时判断当前路径是否有效的判别值。
按照根-左-右的顺序遍历这个树。
对于threshold这个判别值,从根节点开始,在遍历的时候,尝试和将当前节点的flag相加,如果结果为负,则说明当前节点无效,向上返回。
对于count,只有当deep的值为N,既遍历到叶子节点时(并且此时的threshold应该为0),说明找到了一条合理路径,即一个合理的组合,count+1。
但是这样的话,需要产生很多没有意义的节点。如果不考虑更巧妙的算法,只考虑在生成合法的结果(组合)的情况下计算组合数的话,那么可以考虑这样:
>>> 首先,我们以一个括号对 ‘()’ 当做一个处理对象的话,那么进一步可以将这个括号对视为一个树(森林)的节点。那么,对于题目,合理的转化就是:括号内嵌括号<-->父节点下面有子节点。那么题目就变成了给定M个节点,能构成多少个树(森林)。进一步的由于按照约定的方法,一个树(森林)可以与一个二叉树唯一对应,那么题目也可以变为给定M个节点,能够构成多少个二叉树。
OK,题目先拓展到这,下面接着说说操作细节。
首先,对于输入的N个节点,我们定义以一个N-1位的序列表示一种合法的组合,或者说是一种合法的树(森林)的结构。
例如 : 0 0 对于这个森林,N=8,我们从第0个树开始,
/ | \ | 按照先根的顺序遍历每个树的每个节点。
0 0 0 0 并记录当前节点的子节点数。
/ \ 则我们可以用 [ 3, 0, 2, 0, 0, 0, 1, 0 ] 表示这个森林。
0 0
说明了单个组合结果的表示方式,下面我们来讲如何生成有效的组合结果。
我们需要一个list,用这个list来存储我们生成的序列/组合。首先,根据输入的N,为list加入初始数据[0], [1],..[N-1]。因为我们最终的组合结果是N位的序列,所以我们需要进行N-1次循环,来为这些初始数据的后续位生成数据。
进行第n次循环时,逐个取下n-1次完成的序列。然后对n-1次生成的序列进行诸位遍历:遍历的过程中统计两个数据:已经使用的节点数 used(0),预约使用的节点数preUsed(0)。
以上面的例子为例, 假如我们要为序列mem = [3,0,2,0,0,0,1,0] 中索引为2的位置生成数据,假设现在mem = [ 3, 0 ] 。那么开始遍历mem:mem[0] = 3,我们已经用一个节点制造了第0个树的根节点,那么已使用的节点数 used += 1 ( 现在为1),并且这个节点预定了三个节点用来生成他的子节点,所以 preUsed += 3,所以在为序列的下一位生成数据时,即为 mem[1] 生成数据时,可用的节点数为 N-used-preUsed = 8 - 1 - 3 = 4。因为我们已经假定是在为mem[2] 生成数据,那么还得继续遍历。遍历 mem[1] = 0,我们又用了一个节点用来生成 mem[1] 所代表的节点,但是preUsed > 0,即说明 这个节点是被之前的某节点预约为子节点的,所以 used += 1 的同时, preUsed -= 1,mem[1] = 0,说明这个节点没有预定子节点,那么在为序列的下一位生成数据时,可用的节点数为 N - used - preUsed = 8 - 2 -2 = 4。遍历完了,现在为 mem[2] 生成数据,因为 preUsed > 0, 说明当前构造的这个节点已经被预约了,所以不用担心生成这一节点对于未使用的节点的开销。所以对于即将构造的这个节点,未使用的4个节点都是可用的。也即对于即将构造的这个节点,其子节点范围为 0,1,2,3,4 ,都是合法的取值,然后将这个五个可取值添加到mem上,生成五个新的mem,原来的mem取下来,在处理后舍弃,将新生成的mem加到用来存储组合的list上,代表处理完了一个位,本次循环结束。 其他例子就不多说了,值得注意的是:当 preUsed 为 0 时,说明当前没有被预约的节点,那么在为序列的下一位生成数据时,首先要preUsed += 1,即为即将构造的节点预约一个用来生成自己的节点。
上面的算法,按照我们先前的约定,可以计算出对于给定的一个N,可以生成的数(森林)的所有结构。进而统计出能构造出的树(森林)的数量,也就是括号的组合数量,二叉树的生成数量,出入栈的组合数量。
这里简答解释一下,如果上述序列如何表示一个出入栈的情况,首先,按照上面的那个森林的例子,按照给定的序列,先遍历/生成出数(森林)。例如有 ABCDE :[ 3, 0, 1, 0, 0]
A
/ | \ 还是按照 先根顺序遍历这个树,A入栈, B入栈,B出栈,C入栈,
B C D E入栈, E出栈,C出栈, D入栈,D出栈, A出栈。
| 即从根节点开始入栈,从叶节点返回时,叶节点出栈,
E 当访问完某节点的所有子节点时,该节点出栈。
OK,就这么点内容了,欢迎大家批评我的代码(没有贴在这里,在个人空间里),给出建设性的意见。
最后,赞一下大数学家-卡特兰!