排列组合问题的头大?有这一篇就够了

不管是在面试、算法竞赛还是实际的开发中,我们都会遇到排列组合的问题,如果没有合适的算法去解决,时间复杂度会相当的大,毕竟阶乘的时间复杂度不仅让人头大,也让他计算机欲罢不能。所以非常有必要掌握排列组合相关的算法,碰到很多问题,我们心里就有些底气了。我这里例举十余种算法,其中想要特别强调二进制的相关解法,非常有趣。

写在前面

排列组合的问题,如果没有合适的算法去解决,时间复杂度会相当的大,毕竟阶乘的时间复杂度不仅让人头大,也让他计算机欲罢不能,而且我们遇到排列组合的相关问题的概率相当的大,所以非常有必要掌握排列组合相关的算法,碰到很多问题,我们心里就有些底气了。我这里例举几种算法,其中想要特别强调二进制的相关解法,非常有趣。

问题引入

我们把实际问题抽象一下,就是从一个集合中取出任意元素,形成唯一的组合。如 [a,b,c] 可组合为 [a]、[b]、[c]、[ab]、[bc]、[ac]、[abc]。我们既然说的是排列组合,当然就有区分这个集合里面,是否存在重复的元素的问题,如 [a,a,b,c] 可组合为 [a]、[b]、[c]、[ab]、[aa]、[bc]、[ac]、[abc]、[aab]等等。针对是否存在重复的元素问题,我们自然需要不同的解决方案,不过有些方法思路存在共性,接下来我们以不同算法为基础,结合不同的问题进行讲解,完成思路的理解(我们的重点是思路,有了思路,实现代码的时候就简单多了)。这里我们抛出一个问题,即如下

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。说明:解集不能包含重复的子集。输入: nums = [1,2,3]输出:[  [3],  [1],  [2],  [1,2,3],  [1,3],  [2,3],  [1,2],  []]

我们先按照不含重复元素来进行思考,然后附上含重复元素的思路。

暴力枚举

对于这种排列组合的问题,首先想到的当然是穷举。由于排列的要求较少,实现更简单一些,如果我先找出所有排列,再剔除由于位置不同而重复的元素,即可实现需求。假设需要从 [A B C D E] 五个元素中取出所有组合,那么我们先找出所有元素的全排列,然后再将类似 [A B] 和 [B A] 两种集合去重即可。当然,我们可以在枚举的时候进行一些剪枝,即在枚举个过程中进行一些判断,也可以当做一种优化。总结就是,逐个枚举,空集的幂集只有空集,每增加一个元素,让之前幂集中的每个集合,追加这个元素,就是新增的子集。还是不知道啥意思?没关系,我么来看思路和代码实现,这里我们分为两种枚举方式。

循环枚举

循环枚举的思路其实特别简单(暴力的思路就是人类的直觉,思路当然简单啦,哈哈哈),我们先创建结果集(res),然后往结果集里面添加一个空集,现在完成结果集的初始化之后(初始化的结果集中只有一个空集),我们开始在集合(nums)中开始循环遍历所有的元素,每次遍历一个元素,我们就把这个元素添加在当前结果集的每一个子集后,并形成新的子集,添加到结果集中,思路非常的简单暴力,代码吐下:

public static List<List<Integer>> enumerate(int[] nums) {    List<List<Integer>> res = new ArrayList<List<Integer>>();    res.add(new ArrayList<Integer>());    for (Integer n : nums) {        int size = res.size();        for (int i = 0; i < size; i++) {            List<Integer> newSub = new ArrayList<Integer>(res.get(i));            newSub.add(n);            res.add(newSub);        }    }    return res;}

递归枚举

递归枚举的总体思路和循环枚举一致,只不过我们的实现方式是使用递归,代码的变化其实就是把循环枚举里面的最外面那层遍历集合(nums)的循环,使用递归来代替,很简单的,看代码你就明白了。

public static void recursion(int[] nums, int i,                                List<List<Integer>> res) {    if (i >= nums.length) return;    int size = res.size();    for (int j = 0; j < size; j++) {        List<Integer> newSub = new ArrayList<Integer>(res.get(j));        newSub.add(nums[i]);        res.add(newSub);    }    recursion(nums, i + 1, res);}

回溯枚举

回溯枚举就和上面的两种枚举思路有点不一样了,这种枚举思路就好像是我们人的思路,进行一个一个拼凑,只要符合我们子集的条件,就将这个子集添加进结果集,代码如下:

public static void backtrack(int[] nums, int i, List<Integer> sub,                                  List<List<Integer>> res) {    for (int j = i; j < nums.length; j++) {        if (j > i && nums[j] == nums[j - 1]) continue;        sub.add(nums[j]);        res.add(new ArrayList<Integer>(sub));        backtrack(nums, j + 1, sub, res);        sub.remove(sub.size() - 1);    }}

深度优先搜索

集合中每个元素其实就两种状态,就是选和不选,我们通过这两种状态,可以构成了一个满二叉状态树,比如,左子树是不选,右子树是选,从根节点、到叶子节点的所有路径,构成了所有子集。可以有前序、中序、后序的不同写法,结果的顺序不一样。本质上,其实是比较完整的中序遍历。我这样光说,可能比较抽象,我这里通过画的一张中序遍历的图进行讲解,如下。中序遍历图解

前序遍历

public static void preOrder(int[] nums, int i,               ArrayList<Integer> subset, List<List<Integer>> res) {    if (i >= nums.length) return;    subset = new ArrayList<Integer>(subset);    res.add(subset);    preOrder(nums, i + 1, subset, res);    subset.add(nums[i]);    preOrder(nums, i + 1, subset, res);}

中序遍历

public static void inOrder(int[] nums, int i,               ArrayList<Integer> subset, List<List<Integer>> res) {    if (i >= nums.length) return;    subset = new ArrayList<Integer>(subset);    inOrder(nums, i + 1, subset, res);    subset.add(nums[i]);    res.add(subset);    inOrder(nums, i + 1, subset, res);}

后序遍历

public static void postOrder(int[] nums, int i,          ArrayList<Integer> subset, List<List<Integer>> res) {    if (i >= nums.length) return;    subset = new ArrayList<Integer>(subset);    postOrder(nums, i + 1, subset, res);    subset.add(nums[i]);    postOrder(nums, i + 1, subset, res);    res.add(subset);}

字典序

如果你足够理解了前面的思路,其实你应该会发现前面本质思路可以分为两类,暴力、递归、回溯一类,以及深度优先搜索一类,分类的依据是什么呢?其实就是思考的角度不一样,前面的那一类,我们思考的对象是每个子集,我们对每个子集进行相关算法的实现,而深度优先搜索开始,我们将关注的点放在集合(nums)中的每个元素的状态,我们集合中的每个元素在子集中只有两个状态,也就是存在和不存在。现在我们将关注的点转到了元素的状态上,即 01 状态,上面的深度优先搜索的实现只是一个过渡,本质上和递归等效率差不了多少,因为 01 状态是二进制的天下,我们自然使用二进制来代替,效率很高。利用二进制的思想去解决这个问题,就很简单了,值得一提的是,我们在使用二进制思想解决问题的时候,并不一定说使用位运算,而这里要讲的字典序,就不适用位运算来实现二进制思路。为了更好的理解思路,我们暂时先将问题简化为:

给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。输入: n = 4, k = 2输出:[  [2,4],  [3,4],  [2,3],  [1,2],  [1,3],  [1,4],]

算法其实非常的直观,不过有一个值得注意的是,我们在子集的最后需要放一个标志位(也就是所说的哨兵),在每次循环的时候,我们只要找到 nums 中的第一个满足 nums[j] + 1 != nums[j + 1]的元素,并将其加一 nums[j]++ 以转到下一个组合。这种思想代替了位运算,完成了二进制的实现。字典序图解

public List<List<Integer>> combine(int n, int k) {  LinkedList<Integer> nums = new LinkedList<Integer>();  for(int i = 1; i < k + 1; ++i)    nums.add(i);  nums.add(n + 1);  List<List<Integer>> output = new ArrayList<List<Integer>>();  int j = 0;  while (j < k) {    output.add(new LinkedList(nums.subList(0, k)));    j = 0;    while ((j < k) && (nums.get(j + 1) == nums.get(j) + 1))      nums.set(j, j++ + 1);    nums.set(j, nums.get(j) + 1);  }  return output;}

二进制位运算

很多算法都能通过位运算巧秒地解决,其优势主要有两点:一者位运算在计算机中执行效率超高,再者由于位运算语义简单,算法大多直指本质。组合算法也能通过位运算实现,而且代码实现之后,简直令人回味无穷。我们将问题回归到最开始的问题,现在我们假设,从 M 个元素中取任意个元素形成组合,组合内元素不能重复、元素位置无关。我们使用状态的思想,对于每个元素来说,要么被放进组合,要么不放进组合。每个元素都有这么两种状态。我们这里举个例子,如果从 5 个元素中任意取 N 个元素形成组合的话,用二进制位来表示每个元素是否被放到组合里,就是:在这里插入图片描述每种组合都可以拆解为 N 个二进制位的表达形式,而每个二进制组合同时代表着一个十进制数字,所以每个十进制数字都就能代表着一种组合。十进制数字的数目我们很简单就能算出来,从 00000... 到 11111... 一共有种,排除掉全都不被放进组合这种可能,结果有几种。

public List<List<String>> combination(String[] m) {    List<List<String>> result = new ArrayList<>();    for (int i = 1; i < Math.pow(2, m.length) - 1; i++) {        List<String> eligibleCollections = new ArrayList<>();        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;}

当然,还想更秀操作一点,我们在第一和二层循环那里,使用位移运算,来完成与运算,本质是一样的,代码如下

public static List<List<Integer>> binaryBit(int[] nums) {    List<List<Integer>> res = new ArrayList<List<Integer>>();    for (int i = 0; i < (1 << nums.length); i++) {        List<Integer> sub = new ArrayList<Integer>();        for (int j = 0; j < nums.length; j++)            if (((i >> j) & 1) == 1) sub.add(nums[j]);        res.add(sub);    }    return res;}

当然,如果我们还有一种稍微绕一点点的二进制实现方式,就是数 1 的个数,这种思路有点绕,不过不管怎么样都还是二进制的一种实现方式,带着二进制的特色,我这里就大概的提一下数二进制中 1 的个数的实现方式,具体的问题解决代码,感兴趣的可以自己去实现以下,数二进制中 1 的个数的代码如下,想要关于数二进制的个数的其他算法,可以看我另一篇文章

int BitCount1(unsigned int n){    unsigned int c =0 ; // 计数器    for (c =0; n; n >>=1) // 循环移位        c += n &1 ; // 如果当前位是 1,则计数器加 1    return c ;}

带重复数字

我们在集合中带有重复数字,这样的集合和不带重复数字有什么区别呢?我们按照这个思路,其实可以使用不重复数字的求解方式,在求解带重复数字集合的过程中,进行去重即可。有了这种思路,我们去设计算法其实就不难了,在暴力、递归、回溯、深搜的方法中,我们很容易的将算法进行改进,我在这里就不多提了,这里要说的是二进制的算法改进。你仔细想一下我们怎么样才能在二进制的算法中进行相应的去重,这其实不容易想到的。我们来假设 nums 中有[1,2,3],那么它的结果集以及对应的二进制形如下面这这样

1 2 30 0 0 -> [     ]0 0 1 -> [    3]0 1 0 -> [  2  ]   0 1 1 -> [  2 3]  1 0 0 -> [1    ]1 0 1 -> [1   3] 1 1 0 -> [1 2  ]1 1 1 -> [1 2 3]

但是如果有了重复数字,很明显就行不通了。例如对于 nums = [ 1 2 2 2 3 ]。

1 2 2 2 30 1 1 0 0  -> [  2 2  ]0 1 0 1 0  -> [  2 2  ]0 0 1 1 0  -> [  2 2  ]

上边三个数产生的数组重复的了。三个中我们只取其中 1 个,取哪个呢?我们仔细想一下应该好想到,就是我们只要去重复数字的开头的序列就可以了,比如重复了三个 2,那么我们只要分别取一个 2 开头即“2”,两个 2 开头即“22”以及三个 2 开头即“222”就可以了,这样就可以避免重复,更形象的解释,我们举个五个 2 的例子,然后例举出他们不重复的序列,像下面这样

2 2 2 2 2 1 0 0 0 0 -> [  2         ]1 1 0 0 0 -> [  2 2       ]1 1 1 0 0 -> [  2 2 2     ]1 1 1 1 0 -> [  2 2 2 2   ]1 1 1 1 1 -> [  2 2 2 2 2 ]

那么这个时候,我们就可以将问题转成,我们怎么去取不同个数的 2 在一起,其实仔细思考也不难理解,我们只要对二进制稍微研究一下就知道了,我们先拿两个 2 来举例子,在五位二进制中,有两个 2 开头的二进制序列有哪些?

2 2 2 2 2 1 1 0 0 0 -> [  2 2       ]1 0 1 0 0 -> [  2 2       ]0 1 1 0 0 -> [  2 2       ]0 1 0 1 0 -> [  2 2       ]0 0 0 1 1 -> [  2 2       ]

上面所有两个 2 的情况,我们只需要去其中一种,那么我们应该取哪种?怎么取呢?这个时候我们观察一下他们是否存在不同点,而且其中存在一个和其他情况都不同的形式,我们来看一下出现了重复数字,并且当前是 1 的前一个的二进位。

对于 1 1 0 0 0 ,是 1。对于 1 0 1 0 0 , 是 0。对于 0 1 1 0 0 ,是 0。对于 0 1 0 1 0 ,是 0。对于 0 0 0 1 1 ,是 0。

可以看到只有第一种情况对应的是 1 ,其他情况都是 0。其实除去从开头是连续的 1 的话,就是两种情况。第一种就是,占据了开头,类似于这种 10...1....。第二种就是,没有占据开头,类似于这种 0...1...。这两种情况,除了第一位,其他位的 1 的前边一定是 0。所以的话,我们的条件是看出现了重复数字,并且当前位是 1 的前一个的二进位。有了这种思路,我们只需要在不重复的代码中,进行一个判断就可以了,代码如下

public static List<List<Integer>> binaryBit(int[] nums) {    List<List<Integer>> res = new ArrayList<List<Integer>>();    for (int i = 0; i < (1 << nums.length); i++) {        List<Integer> sub = new ArrayList<Integer>();        boolean illegal=false;        for (int j = 0; j < nums.length; j++)            if (((i >> j) & 1) == 1) {                if(j>0&&nums[j]==nums[j-1]&&(i>>(j-1)&1)==0){                    illegal=true;                    break;                }else{                    sub.add(nums[j]);                }            }        if(!illegal){            res.add(sub);        }    }    return res;}

总结

二进制是真的很美妙,我们的很多问题都可以通过二进制来解决,所以我们需要慢慢适应并融入二进制的世界,思考问题的角度和 思路也将变得更加开阔。

阅读全文: http://gitbook.cn/gitchat/activity/5e44b6b033d4c163c63a6313

您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。

FtooAtPSkEJwnW-9xkCLqSTRpBKX

发布了3634 篇原创文章 · 获赞 3487 · 访问量 325万+

猜你喜欢

转载自blog.csdn.net/valada/article/details/104304918