详细总结组合排列的十余种算法实现

 

目录

•写在前面

•问题引入

•暴力枚举

循环枚举

递归枚举

回溯枚举

•深度优先搜索

前序遍历

中序遍历

后序遍历

•字典序

•二进制位运算

•带重复数字

•总结


•写在前面

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

•问题引入

我们把实际问题抽象一下,就是从一个集合中取出任意元素,形成唯一的组合。如 [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 3
0 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 3
0 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;
}

•总结

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

发布了93 篇原创文章 · 获赞 474 · 访问量 93万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章