一、排列
排列的定义:从 n 个不同的元素中取出 m(1≤m≤n)个不同的元素,按照一定的顺序排成一列,这个过程就叫排列(Permutation)。
排列:包含顺序
- 对于 n 个元素的全排列,所有可能的排列数量就是 nx(n-1)x(n-2)x…x2x1,也就是 n!;
- 对于 n 个元素里取出 m(0<m≤n) 个元素的不重复排列数量是 nx(n-1)x(n-2)x…x(n - m + 1),也就是 n!/(n-m)!
组合:不含顺序
- 对于 n 个元素里取出 m(0<m≤n) 个元素的组合数量 n!/(m! * (n-m)!)
1.1 田忌赛马
田忌是齐国有名的将领,他常常和齐王赛马,可是总是败下阵来,心中非常不悦。孙膑想帮田忌一把。他把这些马分为上、中、下三等。他让田忌用自己的下等马来应战齐王的上等马,用上等马应战齐王的中等马,用中等马应战齐王的下等马。三场比赛结束后,田忌只输了第一场,赢了后面两场,最终赢得与齐王的整场比赛。
孙膑每次都从田忌的马匹中挑选出一匹,一共进行三次,排列出战的顺序。是不是感觉这个过程很熟悉?这其实就是数学中的排列过程。
这其实是一个树状结构。从树的根结点到叶子结点,每种路径都是一种排列。有多少个叶子结点就有多少种全排列。从图中我们可以看出,最终叶子结点的数量是 3x2x1=6,所以最终排列的数量为 6。如下图:
{
上等,中等,下等}
{
上等,下等,中等}
{
中等,上等,下等}
{
中等,下等,上等}
{
下等,上等,中等}
{
下等,中等,上等}
代码实现如下:
public class Main {
// 用 t1,t2 和 t3 分别表示田忌的上、中、下等马跑完全程所需的时间,用 q1,q2 和 q3 分别表示齐王的上、中、下等马跑全程所需的时间,因此,q1<t1<q2<t2<q3<t3。
// 如果你将这些可能的排列,仔细地和齐王的上等、中等和下等马进行对比,只有{下等,上等,中等}这一种可能战胜齐王,也就是 t3>q1,t1<q2,t2<q3。
// 孙膑的方法之所以奏效,是因为他看到每一等马中,田忌的马只比齐王的差一点点。如果相差太多,可能就会有不同的胜负结局。所以,在设置马匹跑完全程的时间上,我特意设置为 q1<t1<q2<t2<q3<t3,只有这样才能保证计算机得出和孙膑相同的结论
public static HashMap<String, Double> q_horses_time = new HashMap<String, Double>() {
{
put("q1", 1.0); put("q2", 2.0); put("q3", 3.0);} // 设置齐王的马跑完所需时间
};
public static HashMap<String, Double> t_horses_time = new HashMap<String, Double>() {
{
put("t1", 1.5); put("t2", 2.5); put("t3", 3.5);} // 设置田忌的马跑完所需时间
};
public static ArrayList<String> q_horses = new ArrayList<String>(Arrays.asList("q1", "q2", "q3"));
/**
* @param horses- 目前还剩多少马没有出战,result- 保存当前已经出战的马匹及顺序
* @return void
* @Description: 使用函数的递归(嵌套)调用,找出所有可能的马匹出战顺序
*/
public static void permutate(ArrayList<String> horses, ArrayList<String> result) {
if (horses.size() == 0) {
// 所有马匹都已经出战,判断哪方获胜,输出结果
compare(result, q_horses);
return;
}
for (int i = 0; i < horses.size(); i++) {
// 从剩下的未出战马匹中,选择一匹,加入结果
ArrayList<String> new_result = (ArrayList<String>) (result.clone());
new_result.add(horses.get(i));
// 将已选择的马匹从未出战的列表中移出
ArrayList<String> rest_horses = ((ArrayList<String>) horses.clone());
rest_horses.remove(i);
permutate(rest_horses, new_result); // 递归调用,对于剩余的马匹继续生成排列
}
}
public static void compare(ArrayList<String> t, ArrayList<String> q) {
int t_won_cnt = 0;
for (int i = 0; i < t.size(); i++) {
System.out.println(t_horses_time.get(t.get(i)) + " " + q_horses_time.get(q.get(i)));
if (t_horses_time.get(t.get(i)) < q_horses_time.get(q.get(i))) t_won_cnt++;
}
if (t_won_cnt > (t.size() / 2)) System.out.println(" 田忌获胜!");
else System.out.println(" 齐王获胜!");
}
public static void main(String[] args) {
ArrayList<String> horses = new ArrayList<String>(Arrays.asList("t1", "t2", "t3"));
permutate(horses, new ArrayList<String>());
}
}
从最终输出的结果看,6种排列中只有如下1种田忌会获胜:
[t3, t1, t2]
3.5 1.0
1.5 2.0
2.5 3.0
田忌获胜!
而如果齐王也是随机安排他的马匹出战顺序,代码实现如下:这个交叉对比的过程也是个排列的问题,田忌这边有 6 种顺序,而齐王也是 6 种顺序,所以一共的可能性是 6x6=36 种。
public static void main(String[] args) {
ArrayList<String> t_horses = new ArrayList<String>(Arrays.asList("t1", "t2", "t3"));
permutate(t_horses, new ArrayList<String>(), t_results); // 为田忌生成他马匹的全排序
ArrayList<String> q_horses = new ArrayList<String>(Arrays.asList("q1", "q2", "q3"));
permutate(q_horses, new ArrayList<String>(), q_results); // 为齐王生成他马匹的全排序
for (int i = 0; i < t_results.size(); i++) {
for (int j = 0; j < q_results.size(); j++) {
compare(t_results.get(i), q_results.get(j)); // 交叉对比,看哪方获胜
}
}
}
// 由于交叉对比时只需要选择 2 个元素,分别是田忌的出战顺序和齐王的出战顺序,所以这里使用 2 层循环的嵌套来实现。从最后的结果可以看出,田忌获胜的概率仍然是 1/6。
1.2 暴力破解密码
如果密码每位有 m 种选择,共 n 位,则这是一个排列问题,共 n m n^m nm 种密码的可能排列。如果你遍历并尝试所有的可能性,就能破解密码了。
不过,即使存在这种暴力法,你也不用担心自己的密码很容易被人破解。我们平时需要使用密码登录的网站或者移动端 App 程序,基本上都限定了一定时间内尝试密码的次数,例如 1 天之内只能尝试 5 次等等。这些次数一定远远小于密码排列的可能性。
这也是为什么有些网站或 App 需要你一定使用多种类型的字符来创建密码,比如字母加数字加特殊符号。因为类型越多, n m n^m nm 中的 n 越大,可能性就越多。
- 如果使用英文字母的 4 位密码,就有 5 2 4 = 7311616 52^4=7311616 524=7311616 种(即每位密码有 52 种选择,也就是大小写字母加在一起的数量),超过了 700 万种。
- 如果我们在密码中再加入 0~9 这 10 个阿拉伯数字,那么可能性就是 6 2 4 = 14776336 62^4=14776336 624=14776336 种,超过了 1400 万。
同理,我们也可以增加密码长度,也就是用 n m n^m nm 中的 m 来实现这一点。如果在英文和阿拉伯数字的基础上,我们把密码的长度增加到 6 位,那么就是 6 2 6 = 56800235584 62^6=56800235584 626=56800235584 种,已经超过了 568 亿了!这还没有考虑键盘上的各种特殊符号。有人估算了一下,如果用上全部 256 个 ASCII 码字符,设置长度为 8 的密码,那么一般的黑客需要 10 年左右的时间才能暴力破解这种密码。
如果每位密码都是 a ~ e 的小写字母,共 4 位密码,则通过排列所有4位密码即可暴力破解,代码如下:
func main() {
f("abcde", "")
}
func f(candidates, result string) {
if len(candidates) == 0 {
// 递归终止条件
fmt.Println(result)
return
}
for _, c := range candidates {
f(remove(candidates, c), result+string(c))
}
}
func remove(str string, x rune) (ans string) {
for _, c := range str {
if c != x {
ans += string(c)
}
}
return
}
// 共120种密码, 如下:
abcde
abced
abdce
abdec
abecd
abedc
acbde
acbed
...
二、组合
世界杯总共32个球队,需要多少场比赛呢?
- 如果区分主客场则需 32 ∗ 31 = 992 32 * 31 = 992 32∗31=992 场(排列),这实在是太多了
- 如果不区分主客场则需 32 ∗ 31 / 2 = 496 32 * 31 / 2 = 496 32∗31/2=496 场,还是太多了
- 如果改成小组赛+淘汰赛的形式呢:
- 小组赛:8个小组,每个小组4个队共需 4 ∗ 3 / 2 = 6 4 * 3 / 2 = 6 4∗3/2=6 场,共 8 * 6 = 48 场
- 淘汰赛:因每个小组晋级两队则共16强,共需 8(16强得8强) + 4(8强得4强) + 2(4强得2强) + 2(冠亚1场、三四名1场) = 16 场淘汰赛
- 则共需 48 + 16 = 64 场比赛,就是一个合理的场次了
组合是指,从 n 个不同元素中取出 m(1≤m≤n)个不同的元素。
- 例如,我们前面说到的世界杯足球赛的例子,从 32 支球队里找出任意 2 支球队进行比赛,就是从 32 个元素中取出 2 个元素的组合。
- 如果上文田忌赛马的规则改一下,改为从 10 匹马里挑出 3 匹比赛,但是并不关心这 3 匹马的出战顺序,那么也是一个组合的问题。
- 对于所有 m 取值的组合之全集合,我们可以叫作全组合(All Combination)。例如对于集合{1, 2, 3}而言,全组合就是{空集, {1}, {2}, {3}, {1, 2}, {1,3} {2, 3}, {1, 2, 3}}。
假设某种运动需要 3 支球队一起比赛,那么 32 支球队就有 32x31x30 种排列,如果三支球队在一起只要比一场,那么我们要抹除多余的比赛。三支球队按照任意顺序的比赛有 3x2x1=6 场,所以从 32 支队伍里取出 3 支队伍的组合是 (32x31x30)/(3x2x1)。基于此,我们可以扩展成以下两种情况。
- n 个元素里取出 m 个的组合,可能性数量就是 n 个里取 m 个的排列数量,除以 m 个全排列的数量,也就是 (n! / (n-m)!) / m!。
- 对于全组合而言,可能性为 2 n 2^n 2n 种。例如,当 n=3 的时候,全组合包括了 8 种情况。
2.1 递归实现
例题:从 3 个元素中选取 2 个元素的组合。假设有 3 个队伍,t1,t2 和 t3。从图中我们可以看出,对于组合而言,由于{t1, t2}已经出现了,因此就无需{t2, t1}。同理,出现{t1, t3},就无需{t3, t1}等等。对于重复的,我用叉划掉了。这样,最终只有 3 种组合了。
如何用代码来实现呢?一种最简单粗暴的做法是:
- 先实现排列的代码,输出所有的排列。例如{t1, t2}, {t2, t1};
- 针对每种排列,对其中的元素按照一定的规则排序。那么上述两种排列经过排序后,就是{t1, t2}, {t1, t2};
- 对排序后的排列,去掉重复的那些。上述两种排列最终只保留一个{t1, t2}。
这样做效率就会比较低,很多排列生成之后,最终还是要被当做重复的结果去掉。
显然,还有更好的做法。从图中我们可以看出被划掉的那些,都是那些出现顺序和原有顺序颠倒的元素。
例如,在原有集合中,t1 在 t2 的前面,所以我们划掉了{t2, t1}的组合。这是因为,我们知道 t1 出现在 t2 之前,t1 的组合中一定已经包含了 t2,所以 t2 的组合就无需再考虑 t1 了。因此,我只需要在原有的排列代码中,稍作修改,即每次传入嵌套函数的剩余元素,不再是所有的未选择元素,而是出现在当前被选元素之后的那些。代码如下:
public class Main {
// @Description: 使用函数的递归(嵌套)调用,找出所有可能的队伍组合
// @param teams- 目前还剩多少队伍没有参与组合,result- 保存当前已经组合的队伍
public static void combine(ArrayList<String> teams, ArrayList<String> result, int m) {
if (result.size() == m) {
// 挑选完了 m 个元素,输出结果
System.out.println(result);
return;
}
for (int i = 0; i < teams.size(); i++) {
ArrayList<String> newResult = (ArrayList<String>)(result.clone());
newResult.add(teams.get(i));// 从剩下的队伍中,选择一队,加入结果
ArrayList<String> rest_teams = new ArrayList<String>(teams.subList(i + 1, teams.size()));// 只考虑当前选择之后的所有队伍
combine(rest_teams, newResult, m); // 递归调用,对于剩余的队伍继续生成组合
}
}
public static void main(String[] args) {
ArrayList<String> teams = new ArrayList<String>(Arrays.asList("t1", "t2", "t3"));
combine(teams, new ArrayList<String>(), 2);
}
}
2.2 应用
2.2.1 乱序搜索词组(多元文法)
组合在计算机领域中也有很多的应用场景。比如大型比赛中赛程的自动安排、多维度的数据分析以及自然语言处理的优化等等。
在搜索领域的一个应用如下:需求是把每篇很长的文章,分隔成一个个的单词,然后对每个单词进行索引,便于日后的查询。但是很多时候,光有单个的单词是不够的,还要考虑多个单词所组成的词组。例如,“red bluetooth mouse”这样的词组。
处理词组最常见的一种方式是多元文法。什么是多元文法呢?这词看起来很复杂,其实就是把临近的几个单词合并起来,组合一个新的词组。比如我可以把“red”和“bluetooth”合并为“red bluetooth”,还可以把“bluetooth”和“mouse”合并为“bluetooth mouse”。
设计多元文法只是为了方便计算机的处理,而不考虑组合后的词组是不是有正确的语法和语义。例如“red bluetooth”,从人类的角度来看,这个词就很奇怪。但是毕竟它还会生成很多合理的词组,例如“bluetooth mouse”。所以,如果不进行任何深入的语法分析,我们其实没办法区分哪些多元词组是有意义的,哪些是没有意义的,因此最简单的做法就是保留所有词组。
普通的多元文法本身存在一个问题,那就是定死了每个元组内单词出现的顺序。例如,原文中可能出现的是“red bluetooth mouse”,可是用户在查询的时候可能输入的是“bluetooth mouse red”。这么输入肯定不符合语法,但实际上互联网上的用户经常会这么干。
那么,在这种情况下,如果我们只保留原文的“red bluetooth mouse”,就无法将其和用户输入的“bluetooth red mouse”匹配了。所以,如果我们并不要求查询词组中单词所出现的顺序和原文一致,那该怎么办呢?
我当时就在想,可以把每个二元或三元组进行全排列,得到所有的可能。但是这样的话,二元组的数量就会增加 1 倍,三元组的数量就会增加 5 倍,一篇文章的数据保存量就会增加 3 倍左右。我也试过对用户查询做全排列,把原有的二元组查询变为 2 个不同的二元组查询,把原有的三元组查询变为 6 个不同的三元组查询,但是事实是,这样会增加实时查询的耗时。
于是,我就想到了组合。多个单词出现时,我并不关心它们的顺序(也就是不关心排列),而只关心它们的组合。因为无需关心顺序,就意味着我可以对多元组内的单词进行某种形式的标准化。即使原来的单词出现顺序有所不同,经过这个标准化过程之后,都会变成唯一的顺序。
例如,“red bluetooth mouse”,这三个词排序后就是“bluetooth,mouse,red”,而“bluetooth red mouse”排序后也是“bluetooth,mouse,red”,自然两者就能匹配上了。我需要做的事情就是在保存文章多元组和处理用户查询这两个阶段分别进行这种排序。这样既可以减少保存的数据量,同时可以减少查询的耗时。这个问题很容易就解决了。怎么样,组合是不是非常神奇?
此外,组合思想还广泛应用在多维度的数据分析中。比如,我们要设计一个连锁店的销售业绩报表。这张报表有若干个属性,包括分店名称、所在城市、销售品类等等。那么最基本的总结数据包括每个分店的销售额、每个城市的销售额、每个品类的销售额。除了这些最基本的数据,我们还可以利用组合的思想,生成更多的筛选条件。
2.2.2 抽奖
设计一个抽奖系统。从 100 个人中,抽取三等奖 10 名,二等奖 3 名,一等奖 1 名。请列出所有可能的组合,注意每人最多只能被抽中 1 次。
这道题是用到了组合及排列,先看100个人里取1人的数量是C100,1 (格式问题,C100,1表示从100人里取1人的组合数量),剩下99人里取3人为C99,3,再剩下96人里取10人为C96,10,然后再利用排列,总共可能为C100,1 x C99,3 x C96,10
思路1:
先运行combine(100, 1),将所有结果保存。
然后用一层迭代对每个结果运行combine(99, 3),将所有结果append进去。
然后再来一层迭代对上一结果运行combine(96, 10),同样依次append进去。
此处的关键点在于每个迭代下得将上一结果中的数拿掉,以及得保存临时结果。
此思路也等价于直接上三个嵌套循环+运行递归程序。
combine()函数见上文
思路2:
先运行combine(100, 14),对每个结果运行combine(14, 10),再对每个更新的结果运行combine(4, 3)。其实就是思路1逆过来。