迷人的算法-排列组合

需求


最近工做中碰到一个需求:咱们的数据表有多个维度,任意多个维度组合后进行 group by 可能会产生一些”奇妙”的反应,因为不肯定怎么组合,就须要将全部的组合都列出来进行尝试。html

抽象一下就是从一个集合中取出任意元素,造成惟一的组合。如 [a,b,c] 可组合为 [a]、[b]、[c]、[ab]、[bc]、[ac]、[abc]java

要求以下:git

  • 组合内的元素数大于 0 小于等于 数组大小;
  • 组合内不能有重复元素,如 [aab] 是不符合要求的组合;
  • 组合内元素的位置随意,即 [ab] 和 [ba] 视为同一种组合;

看到这里,就应该想到高中所学习的排列组合了,一样是从集合中取出元素造成一个另外一个集合,若是集合内元素位置随意,就是组合,从 b 个元素中取 a 个元素的组合有 种。而若是要求元素顺序不一样也视为不一样集合的话,就是排列,从 m 个元素取 n 个元素的排列有 种。github

我遇到的这个需求就是典型的组合,用公式来表示就是从元素个数为 n 的集合中列出 种组合。算法

转载随意,文章会持续修订,请注明来源地址:https://zhenbianshu.github.io 。数组

文中算法用 Java 实现。ide

从排列到组合-穷举


对于这种需求,首先想到的固然是穷举。因为排列的要求较少,实现更简单一些,若是我先找出全部排列,再剔除因为位置不一样而重复的元素,便可实现需求。假设须要从 [A B C D E] 五个元素中取出全部组合,那么咱们先找出全部元素的全排列,而后再将相似 [A B] 和 [B A] 两种集合去重便可。学习

咱们又知道 ,那么咱们先考虑一种状况 ,假设是 ,从 5 个元素中选出三个进行全排列。fetch

被选取的三个元素,每个均可以是 ABCDE 之一,而后再排除掉造成的集合中有重复元素的,就是 5 选 3 的全排列了。编码

代码是这样:

private static Set<Set<String>> exhaustion() { List<String> m = Arrays.asList("a", "b", "c", "d", "e"); Set<Set<String>> result = new HashSet<>(); int count = 3; for (int a = 1; a < m.size(); a++) { for (int b = 0; b < m.size(); b++) { for (int c = 0; c < m.size(); c++) { Set<String> tempCollection = new HashSet<>(); tempCollection.add(m.get(a)); tempCollection.add(m.get(b)); tempCollection.add(m.get(c)); // 若是三个元素中有重复的会被 Set 排重,致使 Set 的大小不为 3 if (tempCollection.size() == count) { result.add(tempCollection); } } } } return result; } 

对于结果组合的排重,我借用了 Java 中 HashSet 的两个特性:

  • 元素惟一性,选取三个元素放到 Set 内,重复的会被过滤掉,那么就能够经过集合的大小来判断是否有重复元素了,
  • 元素无序性,Set[A B] 和 Set[B A] 都会被表示成 Set[A B]。
  • 另外又因为元素惟一性,被同时表示为 Set[A B] 的多个集合只会保留一个,这样就能够帮助将全排列转为组合。

能够注意获得,上面程序中 count 参数是写死的,若是须要取出 4 个元素的话就须要四层循环嵌套了,若是取的元素个取是可变的话,普通的编码方式就不适合了。

注: 可变层数的循环能够用 递归 来实现。

从排列到组合-分治


穷举毕竟太过暴力,咱们来经过分治思想来从新考虑一下这个问题:

分治思想

分治的思想总的来讲就是”大事化小,小事化了”,它将复杂的问题往简单划分,直到划分为可直接解决的问题,再从这个直接能够解决的问题向上聚合,最后解决问题。

从 M 个元素中取出 N 个元素整个问题很复杂,用分治思想就能够理解为:

  • 首先,若是咱们已经从 M 中元素取出了一个元素,那么集合中还剩下 M-1 个,须要取的元素就剩下 N-1 个。
  • 还很差解决的话,咱们假设又从 M-1 中取出了一个元素,集合中还剩下 M-2 个,须要取的元素只剩下 N-2 个。
  • 直到咱们可能取了有 M-N+1 次,须要取的元素只剩下一个了,再从剩余集合中取,就是一个简单问题了,很简单,取法有 M-N+1 种。
  • 若是咱们解决了这个问题,已经取完最后一次了产生了 M-N+1 种临时集合,再考虑从 M-N+2 个元素中取一个元素呢,又有 M-N+2 种可能。
  • 将这些可能聚合到一块,直到取到了 N 个元素,这个问题也就解决了。

仍是从 5 个元素中取 3 个元素的示例:

  • 从 5 个元素中取 3 个元素是一个复杂问题,为了简化它,咱们认为已经取出了一个元素,还要再从剩余的 4 个元素中取出 2 个,求解公式为:。
  • 从 4 个元素中取出 2 个依旧不易解决,那咱们再假设又取出了一个元素,接下来的问题是如何从 3 个元素中取一个,公式为 。
  • 从 3 个元素中取 1 个已是个简单问题了,有三种可能,再向上追溯,与四取1、五取一的可能性作乘,从而解决这个问题。

代码实现

用代码实现以下:

public class Combination { public static void main(String[] args) { List<String> m = Arrays.asList("a", "b", "c", "d", "e"); int n = 5; Set<Set<String>> combinationAll = new HashSet<>(); // 先将问题分解成 五取1、五取二... 等的全排列 for (int c = 1; c <= n; c++) { combinationAll.addAll(combination(m, new ArrayList<>(), c)); } System.out.println(combinationAll); } private static Set<Set<String>> combination(List<String> remainEle, List<String> tempCollection, int fetchCount) { if (fetchCount == 1) { Set<Set<String>> eligibleCollections = new HashSet<>(); // 在只差一个元素的状况下,遍历剩余元素为每一个临时集合生成多个知足条件的集合 for (String ele : remainEle) { Set<String> collection = new HashSet<>(tempCollection); collection.add(ele); eligibleCollections.add(collection); } return eligibleCollections; } fetchCount--; Set<Set<String>> result = new HashSet<>(); // 差多个元素时,从剩余元素中取出一个,产生多个临时集合,还须要取 count-- 个元素。 for (int i = 0; i < remainEle.size(); i++) { List<String> collection = new ArrayList<>(tempCollection); List<String> tempRemain = new ArrayList<>(remainEle); collection.add(tempRemain.remove(i)); result.addAll(combination(tempRemain, collection, fetchCount)); } return result; } } 

其实现就是递归,关于递归和分治,有兴趣能够看一下隐藏篇: 递归和分治

直击本质-位运算


从元素的全排列找全组合,比穷举略好,但还不是最好的方法,毕竟它”绕了一次道”。

不少算法都能经过位运算巧秒地解决,其优点主要有两点:一者位运算在计算机中执行效率超高,再者因为位运算语义简单,算法大多直指本质。

组合算法也能经过位运算实现。

思想

再次考虑全组合的需求,从 M 个元素中取任意个元素造成组合,组合内元素不能重复、元素位置无关。

以前的方法都是从结果组合是否知足要求来考虑问题,考虑组合是否有重复元素、是否已有一样的组合等条件。若是换种思路,从待选元素上来考虑呢?

对于每一个元素来讲,它的状态就简单得多了,要么被放进组合,要么不放进组合。每一个元素都有这么两种状态。若是从 5 个元素中任意取 N 个元素造成组合的话,用二进制位来表示每一个元素是否被放到组合里,就是:

A  B  C  D  E
0  0  0  0  1   [E] = 1

A  B  C  D  E
0  0  0  1  0   [D] = 2

A  B  C  D  E
0  0  0  1  1   [DE] = 3
...

看到这里,应该就很是清楚了吧,每种组合均可以拆解为 N 个二进制位的表达形式,而每一个二进制组合同时表明着一个十进制数字,因此每一个十进制数字都就能表明着一种组合。

十进制数字的数目咱们很简单就能算出来,从00000... 到 11111... 一共有 种,排除掉全都不被放进组合这种可能,结果有种。

代码实现

下面是 Java 代码的实现:

public class Combination { public static void main(String[] args) { String[] m = {"A", "B", "C", "D", "E"}; Set<Set<String>> combinationAll = combination(m); System.out.println(combinationAll); } private static Set<Set<String>> combination(String[] m) { Set<Set<String>> result = new HashSet<>(); for (int i = 1; i < Math.pow(2, m.length) - 1; i++) { Set<String> eligibleCollections = new HashSet<>(); // 依次将数字 i 与 2^n 按位与,判断第 n 位是否为 1 for (int j = 0; j < m.length; j++) { if ((i & (int) Math.pow(2, j)) == Math.pow(2, j)) { eligibleCollections.add(m[j]); } } result.add(eligibleCollections); } return result; } } 

小结


排列和组合算法在实际应用中很常见,并且他们的实现方法也很是具备参考意义。总的来讲:排列用递归、组合用位运算。

关于本文有什么疑问能够在下面留言交流,若是您以为本文对您有帮助,欢迎关注个人 微博 或 GitHub 。您也能够在个人 博客REPO 右上角点击 Watch 并选择 Releases only 项来 订阅 个人博客,有新文章发布会第一时间通知您。

相关文章
相关标签/搜索