组合数计算
计算组合数的题目及其变体还是比较常见的,这里记录一下几种经典解法。
我们以一个典型问题为例进行讨论: LeetCode 62. Unique Paths,问的是 mxn 的矩阵从左上走到右下有几种走法,每次只能向下走或者向右走。
动态规划
第一种解法是DP,采用递推公式 (C(m, n)=C(m-1, n) + C(m-1, n-1))(即,杨辉三角)来计算。
很显然,到达每一个格子的走法,是他的左边和上边邻居的走法之和。这样我们可以使用一个二维数组来对应记录每一个位置的走法数,空间开销 O(m*n)
。进一步,由于我们是一行一行进行计算,我们可以把空间压缩到 O(min(m,n))
。
最终,时间开销 O(m*n)
,空间开销 O(min(m,n))
。
参考解法:
/*
* @lc app=leetcode id=62 lang=cpp
*
* [62] Unique Paths
*/
// @lc code=start
class Solution {
public:
int uniquePaths(int m, int n) {
if (m < n) swap(m, n);
vector<int> dp(n+1, 0);
// dp[1][1] = 1;
dp[1] = 1;
for (int i=1; i<=m; i++) {
for (int j=1; j<=n; j++) {
// dp[i][j] = dp[i-1][j] + dp[i][j-1];
dp[j] += dp[j-1];
}
}
return dp[n];
} // AC, DP
};
// @lc code=end
组合数公式
我们注意到,由于题目限制,实际上路径总长是 m-1+n-1,需要从中选出 m-1 步向下。
第二种解法是直接使用组合数公式 (C(n, k) = n! / (k! * (n-k)!)) 进行计算,但是这里有一个陷阱是整数溢出问题。
一种解决溢出的办法是使用浮点数,甚至有人提出了使用对数降低计算复杂度的方案;但是这是一个整数问题,我们想使用纯整数来解决。
注意到计算公式的分子分母各自都是 n
个数的乘积,所需乘法次数相同。我们把两遍的计算分为两段,分别长为 k
和 n-k
,其中一段是完全相同的 1*2*3*...
可以直接消除,我们选择消较长的一段。不妨设剩下的较短一段是长度为k的那一段,则 (C(n, k) = (n*(n-1)*(n-2)*...*(n-k+1)) / (1*2*3*...*k))。这里的问题仍然是溢出如何解决,但是进展是分子分母都已经是单个自然数序列了。
我们可以在一轮循环中解决这个计算,即变形为 (C(n, k) = n/1 * (n-1)/2 * (n-2)/3 * ... * (n-k+1)/k)。也就是乘法和除法交叉计算,保证不会出现小数。
据 这篇博客 所言,有数论中的定理可以保证这一点,但是博客本身并没有提供这个定理。
我们这里简单说明一下为什么每次都能整除,不会出现小数。考虑数学归纳,对于第一次操作 n/1 肯定是能整除的;然后我们假设前面 k-1 次都整除了,考察第 k 次能不能整除。当然能整除,因为分母是连续的 k 个自然数,必然有一个恰好是 k 的倍数,所以其乘积也能被 k 整除。(这里我们的说明其实不够充分,因为我们没有证明,k的时候分母乘积中用到的因子没有被前面k-1个数用掉。)
这种解法,时间复杂度 O(min(m,n))
,空间复杂度 O(1)
。
参考代码:
/*
* @lc app=leetcode id=62 lang=cpp
*
* [62] Unique Paths
*/
// @lc code=start
class Solution {
public:
int64_t C(int n, int k) {
int64_t res = 1;
if (k > n - k) k = n - k;
for (int i = n, j = 1; j <= k; i--, j++) {
res *= i;
res /= j;
}
return res;
}
int uniquePaths(int m, int n) {
return (int) C(m-1 + n-1, m-1);
} // AC, 组合数C(m+n, m)
};
// @lc code=end
其他解法
其他方案包括 质因数分解;
以及进一步降低了复杂度的 Lucas定理;
还有人提出了 二进制辅助法 的解决思路。
拓展阅读
OI Wiki | 排列组合
OI Wiki | 卡特兰数
OI Wiki | 卢卡斯定理
Morning-Glory | 卡特兰(Catalan)数入门详解