偶尔无聊,生搬硬凑下组合序列生成的四种写法。
方法零 状态/序列生成法
组合,combination,或者说n choose k的问题。一般在命令式编程语言中,采用生成器的方式来完成。大体就是保存状态,按序列生成。这种方式的标准方式,参考C++的algorithm的库。在Python中,使用itertools生成出来。原理都是相似的。
在函数式编程语言中,则有着不同的思路。函数式编程语言中,不提倡使用状态这种方式。或者说通过递归调用+延迟计算的方式,综合起来的特性,得到数学结构上更为优美的解法。
我们约定组合序列生成的函数为comb :: Int -> [a] -> a, comb k xs,其中k为选择个数,xs为待选取列表。生成结果为一个列表的列表。在数学表达式中,我们按照中国约定俗称的C(n,k),表示从n个中选取k个。注意下面的代码示例,可以左右拨动。
我们知道关于组合有一些基本的公式。根据这些公式,其实组合问题,实在逐步递降的(待组合的列表更短,或者选择个数k变小)。而以下的种种写法,其实都是由背后的公式推演出来的。
方法一 C(n+1,k) = C(n,k-1) + C(n,k)
比如我们有公式C(n+1,k) = C(n,k-1) + C(n,k)
这个公式的含义为从n+1个中选取k个,等价于固定选择女朋友,从剩余n中选择k-1个;或者直接选择不带女朋友,从剩余n中选择k个。
也就是组合分成两种情况,选择了第一个,或者丢弃第一个的。所有结果,等于这二者之和。这种方式,两个分支中,都缩减了问题的规模。故而函数递归是可以穷尽的。
这个公式理解最为普遍。大名鼎鼎的杨辉三角,就是按照这个公式来进行推演的。
按照这种理解,我们有Haskell代码:
comb k (x:xs) = map (x:) (comb (k - 1) xs)) ++ (comb k xs)
(x:xs)为模式匹配,匹配第一个元素为x,剩余列表为xs。结果为两者之和,++,表示两部分结果合并。前半部分,给comb (k-1) xs的结果,每一个都添加了x作为头。后半部分,则在xs中选择k个。这行代码与上面的公式完美映射。
方法二 C(n+1,k+1) = C(n,k) + C(n-1,k) + ... + C(k,k)
这个公式的含义为在n+1个中选择k+1个,等于以下情况之和:
按序最先选择第一个为火车头,剩下的选择k个
按序最先选择第二个为火车头,剩下的选择k个
。。。
按序最先选择第k+1个为火车头,剩下的选择k个(注意,这个时候只剩下k个,所以就是这k个)
按照这个公式,我们利用haskell中的tails函数。这个函数生成序列的每个后缀。
tails [0..3][[0,1,2,3],[1,2,3],[2,3],[3],[]]
我们对每个后缀序列,都固定选择其头部的元素,剩余的选择k个。有haskell代码如下。f函数,就是固定选择头t,剩下的在递归到组合函数comb。这样得到的结果,再全部累积一起,通过concat函数。
comb k xs = concatMap f (tails xs) where f [] = [] f (t:ts) = map (t:) (comb (k - 1) ts)
方法三 C(m+n,k) = SUM[C(n,i)C(m,k-i)] 0<=i<=k
这个公式的意思为将待选列表分为两部分m和n,然后等于下列情况之和
在m个男生中选择0个,在n个女生中选择k个
在m个男生中选择1个,在n个女生中选择k-1个
。。。
在m个男生中选择k个,在个女生n中选择0个
这种方式的最大的好处是,直接将m+n的问题量级降低到其一半水平。用这种方式,理论上将问题从O(N)的方式,可以转换为O(logN)。因为我们生成的是全序列,这种差异体现不出来。
这里我们要注意列表相乘运算,也就是m中选择的i中情况,与n中选择k-i中情况,是可以交叉配对的。也就是第一个结果中的任一一个列表,可以和后一个结果中的任一个列表构成一组新的结果。更形象一点,就是我的选择i种情况出来的A结果,与你的选择k-i种情况出来的B结果,都可以组合在一起,然后形成选择为k个对象的结果。这个结果的个数为A*B=AB。
这等价于排列组合的乘法原理,也等价于笛卡尔积的过程。按照haskell中的实现,我们直接利用liftM2,这个monad辅助函数实现了。
prod :: a -> a -> aprod xs ys = liftM2 (++) xs ys
我们有最终的代码:
comb k xs = concatMap (\i -> prod (comb (k-i) hd) (comb i tl)) [0..k] where (hd, tl) = halve xs
从hd中选择k-i个,出来的结果,与tl中选择i个,乘在一起,最终相concat,构成新的结果。
完整代码如图,或者参考原文链接。
综上,我们可以看到在函数式语言中,可以按照数学公式写出非常优雅的代码。而且因为其中的逻辑清晰,不需要关心更多细节的状态问题,相对更容易做到bug-free。这种书写代码的方式,目前也在和其他编程范式在相互融合。了解下,就当一起玩下吧。
鸣谢:文中的很多图片来自于浣熊数学