组合算法
〇、题外总结
n个元素的全组合是一个比较特殊的存在,虽然也叫“组合”却有本质上的区别,但本文主要总结的是 *Cnm*类型的组合。 全组合的列举可以直接按(二)进制位来对应每个元素,那么n元素的全组合就一一对应于0~2n所表示的二进制位。
一、组合描述
从n个元素中任选m个元素进行组合,组合结果数量为(特注:符号 C 的上标要始终不大于下标才有意义,后续不再重复声明类似问题)
C
n
m
=
C
n
n
−
m
=
C
n
−
1
m
−
1
+
C
n
−
1
m
(
恒
等
式
)
C^{m}_{n} = C^{n-m}_n = C^{m-1}_{n-1} + C^{m}_{n-1} (恒等式)
Cnm=Cnn−m=Cn−1m−1+Cn−1m(恒等式)
- 基础理论回顾:
A n n = n ! ( 基 础 的 全 排 列 , 从 1 − n 每 个 位 置 的 可 选 元 素 为 n − 1 , 即 n ∗ ( n − 1 ) ∗ . . . ∗ 3 ∗ 2 ∗ 1 ) A^n_n = n! (基础的全排列,从1-n每个位置的可选元素为n-1,即n*(n-1)*...*3*2*1) Ann=n!(基础的全排列,从1−n每个位置的可选元素为n−1,即n∗(n−1)∗...∗3∗2∗1)
A n m = A n n A n − m n − m ( 在 全 排 列 的 基 础 上 , 只 取 前 m 个 元 素 , 则 每 个 都 有 后 n − m 个 元 素 在 重 复 排 列 ) A_n^m = \frac{A_n^n}{A_{n-m}^{n-m}} (在全排列的基础上,只取前m个元素,则每个都有后n-m个元素在重复排列) Anm=An−mn−mAnn(在全排列的基础上,只取前m个元素,则每个都有后n−m个元素在重复排列)
C n m = A n m A m m ( 在 一 般 排 列 基 础 上 , 剔 除 排 列 顺 序 因 素 , 则 每 个 组 合 都 有 m 个 元 素 在 重 复 排 列 ) C_n^m = \frac{A_n^m}{A_m^m} (在一般排列基础上,剔除排列顺序因素,则每个组合都有m个元素在重复排列) Cnm=AmmAnm(在一般排列基础上,剔除排列顺序因素,则每个组合都有m个元素在重复排列)
二、01
转换法
-
算法描述
该算法的原理就是利用了开篇中恒等式的逆向推导过程。具体规则如下:- 定义一个一维数组,长度为n,其中前m个元素为
1
(代表选中),后n-m个元素为0
(代表未选中) - 初始数组即表示第一个选中的组合,此后开始遍历数组:
- 从前往后开始,找到数组中首次出现
1
0
两个连续元素的位置,将此时的1
0
互换(相当于1
向后移1位) - 上一步完成后,将上一步中的
1
前边的所有1
都移到数组的最前端 - 完成之后,此时数组即表示新的一种组合
- 继续重复上述循环遍历,直到所有的
1
都被移到数组最后端
- 从前往后开始,找到数组中首次出现
那么为什么
01
转换法可以正确地遍历出所有的组合情况呢? - 定义一个一维数组,长度为n,其中前m个元素为
-
先分析恒等式
C n m = C n n − m = C n − 1 m − 1 + C n − 1 m ( 证 明 过 程 自 行 查 找 ) C^{m}_{n} = C^{n-m}_n = C^{m-1}_{n-1} + C^{m}_{n-1} (证明过程自行查找) Cnm=Cnn−m=Cn−1m−1+Cn−1m(证明过程自行查找)
该等式可以将 Cnm 问题逐步简化,例如:
C n m = C n − 1 m − 1 + C n − 1 m = C n − 1 m − 1 + C n − 2 m − 1 + C n − 2 m − 1 = . . . = C n − 1 m − 1 + C n − 2 m − 1 + C n − 3 m − 1 + . . . + C m + 1 m − 1 + C m m − 1 + C m m C_n^m = C^{m-1}_{n-1} + C^{m}_{n-1} = C^{m-1}_{n-1} + C^{m-1}_{n-2} + C_{n-2}^{m-1} =...=C^{m-1}_{n-1} + C^{m-1}_{n-2} +C^{m-1}_{n-3} +...+ C^{m-1}_{m+1} + C^{m-1}_{m} + C^{m}_{m} Cnm=Cn−1m−1+Cn−1m=Cn−1m−1+Cn−2m−1+Cn−2m−1=...=Cn−1m−1+Cn−2m−1+Cn−3m−1+...+Cm+1m−1+Cmm−1+Cmm
C 6 4 = C 6 2 = C 5 1 + C 5 2 = C 5 1 + C 4 1 + C 4 2 = . . . = C 5 1 + C 4 1 + C 3 1 + C 2 1 + C 2 2 = 15 C_6^4 = C_6^2 = C_5^1 + C_5^2 = C_5^1 + C_4^1 + C_4^2 =...= C_5^1 + C_4^1 + C_3^1 + C_2^1 + C_2^2 = 15 C64=C62=C51+C52=C51+C41+C42=...=C51+C41+C31+C21+C22=15 -
分析算法
该算法就是利用了上述恒等式的逆向推导过程。- 先看算法第一步,数组中前m个为
1
表示选中,可以看为仅前m个元素参与了组合,后n-m个暂时闲置,即 Cmm - 之后的第一次“移位”操作,相当于是将参与组合的m个元素再加一个,并且选中新加进入的元素(不选则会跟上一步重复),而在下一个新元素“加入”前,组合的数量相当于Cmm-1。另外此时的前m个元素依然可能发生“移位”操作,这可以看作为 Cmm-1 的递归子问题,子问题的结果即是 Cmm-1。
- 上述两点完成后,先看结果:Cmm-1 + Cmm,此时的所有
1
都是连在一起的,下一步将会把最后的一位1
继续向后“移位”,从而在当前组合元素池中再加一个新元素,新的组合结果为 Cm+1m-1 ,总的组合结果为 Cm+1m-1 + Cmm-1 + Cmm。 - 到这里该算法的逻辑原理已经很清晰了,随着上述过程继续进行,直到结束,最终可以得到完整的 Cnm 个组合结果。
- 最后一点,也是该算法最后缺少的一块,就是虽然最后得到了Cnm 个组合结果,但为什么不可能有重复的组合出现?这个问题很简单,算法之所以限制从前往后单向操作,就是为了去重。因为在上述描述中每次“新加入”的元素都是必然被选中的,而这一点也保证了新的组合一定不会与之前的组合重复。解决了这个问题,那么对
01
转换法的理解才真正完美。
- 先看算法第一步,数组中前m个为
-
快速实现版(刚开始用python简单实现的算法版本)
srclist = [854, 631, 546, 682, 670, 493]
def combination(srclist, num) :
c = [0] * len(srclist)
c[:num] = [1] * num
print(list(zip(c, srclist)))
while shift10(c):
print(list(zip(c, srclist)))
def shift10(clist):
"""[summary]
Args:
clist ([type]): [description]
Returns:
[type]: [description]
"""
i = 0
max = len(clist) - 1
while i < max:
if clist[i] == 1:
if clist[i + 1] == 0:
clist[i] = 0
clist[i + 1] = 1
return True
zero = 0
while zero < i:
if clist[zero] == 0:
clist[zero] = 1
clist[i] = 0
break
zero += 1
i += 1
return False
combination(srclist, 3)
[(1, 854), (1, 631), (1, 546), (0, 682), (0, 670), (0, 493)]
[(1, 854), (1, 631), (0, 546), (1, 682), (0, 670), (0, 493)]
[(1, 854), (0, 631), (1, 546), (1, 682), (0, 670), (0, 493)]
[(0, 854), (1, 631), (1, 546), (1, 682), (0, 670), (0, 493)]
[(1, 854), (1, 631), (0, 546), (0, 682), (1, 670), (0, 493)]
[(1, 854), (0, 631), (1, 546), (0, 682), (1, 670), (0, 493)]
[(0, 854), (1, 631), (1, 546), (0, 682), (1, 670), (0, 493)]
[(1, 854), (0, 631), (0, 546), (1, 682), (1, 670), (0, 493)]
[(0, 854), (1, 631), (0, 546), (1, 682), (1, 670), (0, 493)]
[(0, 854), (0, 631), (1, 546), (1, 682), (1, 670), (0, 493)]
[(1, 854), (1, 631), (0, 546), (0, 682), (0, 670), (1, 493)]
[(1, 854), (0, 631), (1, 546), (0, 682), (0, 670), (1, 493)]
[(0, 854), (1, 631), (1, 546), (0, 682), (0, 670), (1, 493)]
[(1, 854), (0, 631), (0, 546), (1, 682), (0, 670), (1, 493)]
[(0, 854), (1, 631), (0, 546), (1, 682), (0, 670), (1, 493)]
[(0, 854), (0, 631), (1, 546), (1, 682), (0, 670), (1, 493)]
[(1, 854), (0, 631), (0, 546), (0, 682), (1, 670), (1, 493)]
[(0, 854), (1, 631), (0, 546), (0, 682), (1, 670), (1, 493)]
[(0, 854), (0, 631), (1, 546), (0, 682), (1, 670), (1, 493)]
[(0, 854), (0, 631), (0, 546), (1, 682), (1, 670), (1, 493)]
- 优化版
class Combination:
def __init__(self, srclist) -> None:
if not isinstance(srclist, list):
raise TypeError("the argument 'srclist' needs a <list> type, but %s provided" % type(srclist).__name__)
self._srclist = srclist
def combine(self, count) -> Generator:
if count <= 0:
return []
srclen = len(self._srclist)
if count >= srclen:
return list(self._srclist)
slist = [0] * srclen
for _ in range(count):
slist[_] = 1
yield [item for item, flag in zip(self._srclist, slist) if flag]
while self.__shift10_(slist):
yield [item for item, flag in zip(self._srclist, slist) if flag]
def __shift10_(self, slist) -> bool:
i = 0
max = len(slist) - 1
zero = 0 # 记录可能为0的位置
while i < max:
if slist[i] == 1:
if slist[i + 1] == 0: # 遇到第一个10
slist[i] = 0
slist[i + 1] = 1
return True
if zero == i: # 没出现过0,找下一位
zero += 1
else: # zero记录有首位0的位置,把1移到最左端
slist[zero] = 1
slist[i] = 0
zero += 1 # 指向下一个0
i += 1
return False
c = Combination(srclist)
r = c.combine(4)
for i in r:
print(i)
输出:
[851, 631, 546, 682]
[851, 631, 546, 670]
[851, 631, 682, 670]
[851, 546, 682, 670]
[631, 546, 682, 670]
[851, 631, 546, 522]
[851, 631, 682, 522]
[851, 546, 682, 522]
[631, 546, 682, 522]
[851, 631, 670, 522]
[851, 546, 670, 522]
[631, 546, 670, 522]
[851, 682, 670, 522]
[631, 682, 670, 522]
[546, 682, 670, 522]
在该优化版的实现算法中,算法的时间复杂度可以近似为
O
(
n
C
n
m
)
O(nC_n^m )
O(nCnm)