一文学会排列组合解法

前言

上一篇「一文学会递归解题」一文颇受你们好评,各大号纷纷转载,让笔者颇感欣慰,不过笔者注意到后台有读者有以下反馈java

确实,相信不少人(包括我本身)都有相似的感慨,对某个知识点,看确实是看懂了,但若是真的再用一样的套路再去解一些带有一样解题思路,但稍加变形的题,每每会一筹莫展。对这种状况有啥好的解决办法吗?mysql

除了勤加练习,还有一良策!面试

鲁迅先生说:若是学习算法,最好一段时间内只刷某种算法思想某种数据结构的题,啥意思呢?好比说你上次学了递归,那就持续找递归的题来刷,学了链表,这段时间就专门刷链表的题,千万不可今天刷递归,明天刷动态规划,后天又开始学习贪心算法。。。新手最怕的就是觉得本身懂了,浅尝辄止,这是新手的大忌!必定要对同一类型的题穷追猛打,造成肌肉记忆,这样以后再碰到同一类型的题就会条件反射地一看:哦,这题用 xxx 思想应该能够靠谱。算法

言归正转,排列组合是面试中的热门考点
由于看似简单的排列组合能够有挺多的变形,根据变形,难度能够逐渐递增,并且排列组合自己有挺多的解法,能很好地区分一个侯选者的算法水平,排列组合若是用递归挺不容易理解的(反正笔者一开始看了好几遍代码愣是没看懂),以后我会教你们如何用一种很是简单地方式来理解排列组合的递归,这也是写本文的根本目的sql

接下来咱们看看如何用 「递归四步曲」来解排列组合,本文会从如下几个方面来说解排列组合数组

  1. 什么是排列
  2. 排列的经常使用解法
  3. 什么是组合
  4. 组合递归解法
  5. 面试中排列组合的一些变形

什么是排列

排列的定义:从n个不一样元素中,任取 m (m≤n,m与n均为天然数,下同)个不一样的元素按照必定的顺序排成一列,叫作从n个不一样元素中取出m个元素的一个排列;从n个不一样元素中取出m(m≤n)个元素的全部排列的个数,叫作从n个不一样元素中取出m个元素的排列数,当 n = m 时,咱们称这样的排列为全排列性能优化

看到这个公式,你们是否是回忆起了高中的排列公式啦
数据结构

咱们从新温习一下,以 1, 2, 3 这三个数字的全排列有多少种呢。分布式

第一位咱们能够选择 3 个数字,因为第二位不能与第一位相等,因此第二位只能选 2 个数字,第一,第二位既然选完了,那么第三位就只有 1 个数字可选了,因此总共有 3 x 2 x 1 = 6 种排列。
函数

既然知道了什么是全排列,那咱们来看看怎么用程序来打印全排列的全部状况:
求 数字 1 到 n (n < 10) 的全排列

排列的经常使用解法

这道题若是暂时没什么头绪,咱们看看可否用最简单的方式来实现全排列,什么是最简单的方式,暴力穷举法!

暴力穷举法

你们仔细看上文中 1,2 ,3 的全排列,就是把全部状况所有列举出来了,因此咱们用暴力穷举法怎么解呢,对每一位的每种状况都遍历出来组成全部的排列,再剔除重复的排列,就是咱们要的全排列了

/**
 * 求数字第 1 到 n 的全排列
 */
public void permutation(int n) {
    for(int i = 1; i < n + 1; i ++) {
        for(int j = 1; j < n + 1; j ++) {
            for(int k = 1; k < n + 1; k ++) {
                if (i != j && i != k && j != k) {
                    System.out.println(i + j + k);
                }
            }
        }
    }
}

时间复杂度是多少呢,作了三次循环,很显然是
$$
O(n^3)
$$
不少人一看时间复杂度这么高,多数都会嗤之以鼻,可是要我说,得看场景,就这题来讲用暴力穷举法彻底没问题,n 最大才 9 啊,总共也才循环了 9^3 = 729 次,这对如今的计算机性能来讲简单不值一提,就这种场景来讲,其实用暴力穷举法彻底可行!

这里说句题外话,你们在学习的过程当中必定要视场景选择合适的技术方案,有句话说:过早的性能优化是万恶之源,说的就是这个道理,这就比如,一个初创公司,dau 不过千,却要搞分布式,中间件,一个 mysql 表,记录不过一千,却要搞分库分表。。。这就搞笑了,记住没有最牛逼的技术,只有最合适的技术!能解决当前实际问题的技术,就是好技术!

递归解题

这是笔者写此文的根本目的!就是为了讲清楚怎么用递归来更好地理解排列组合!由于我发现不少网友都以为排列组合的递归解法实在不能 Get 到点上, 当初笔者也是看了好几遍代码才勉强理解,不过过了一段时间再看又忘了,后来根据笔者悟出的一套递归四步曲来理解,容易多了,现与各位分享!仔细看好啦

咱们先来观察一下规律,看下怎样才能找出排列是否符合递归的条件,由于如前文 所述,必需要找出题目是否能用递归才能再用递归四步曲来解题

乍一看确实看不出什么因此然出来,那咱们假设第一个数字已经选中了(假定为1),问题是否是转化为只求后面三位数的全排列了,发现没有,此时全排列从前面 n 位数的全排列转化成了求以后 n-1 位数的全排列了,问题从 n 变成了 n-1,规模变小了!并且变小的子问题与原问题具备相同的解决思路,都是从求某位开始的全排列!符合递归的条件!

既然咱们发现排列符合递归条件,那咱们就能够用递归四步曲来解了

一、定义函数的功能
要求数字 1 到 n 的全排列,咱们定义如下函数的功能为求从 k 位开始的全排列,数组 arr 存的是参与全排列的 1 到 n 这些数字

public void permutation(int arr[], k) {
}

二、寻找递推公式

注意上面造成递归的条件:第一个数字已经选中了!那第一位被选中有哪些状况呢,显然有如下几种状况

即在第一位上把全部的数字都选一遍,怎么作才能把全部的数字都在第一位上都选一遍呢,把第一位与其余 n-1 位数分别交换便可(注意每一次交换前都要保证是原始顺序),以下

画外音:第一步交换本身其实就是保持不变,由于咱们要保证在第一位全部数字都能取到,若是移除了这一步,则第一位少了数字 1 ,全排列就漏了

这样咱们就把第一位的全部数字都选了遍,以后只要对剩余的 n-1 位数作全排列便可(即调用第一步的函数),切忌再对 n-1 再作展开,只要咱们发现递推关系就好了,千万不要陷入层层展开子问题的陷阱当中去!注意要从函数的功能来理解,由于问题与子问题具备相同的解决思路,因此第 1 步定义的函数对子问题(求 n-1 ,n-2 ... 的全排列)一样适用!

那递归的终止条件是什么呢 ,显然是从 n 缩小到对最后一位的全排列(此时 k 指向 arr 的最后一个元素)

因而咱们能够得出递推关系为:
permutation(int arr[], k) = 选中第k位(将第k位与以后的 n- k 位分别交换) + permutation(int arr[], k+1)

三、将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中,补充后的函数以下

/**
 *
 * @param arr  表明全排列数字组成的数组
 * @param k 表明第几位
 */
public void permutation(int[] arr, int k) {
    // 当 k 指向最后一个元素时,递归终止,打印此时的排列排列
    if (k == arr.length - 1) {
            System.out.println(Arrays.toString(arr));
    } else {
        for (int i = k; i < arr.length; i++) {
            // 将 k 与以后的元素 i 依次交换,而后能够认为选中了第 k 位
            swap(arr, k, i);
            // 第 k 位选择完成后,求剩余元素的全排列
            permutation(arr, k+1);
            // 这一步很关键:将 k 与 i 换回来,保证是初始的顺序
            swap(arr, k, i);
        }
    }
}

public static void swap (int[] arr, int i, int j) {
    int t = arr[i];
    arr[i] = arr[j];
    arr[j] = t;
}

我看网上有很多人对最后一步(如图示)不理解

回过头去看上面的递归过程图中咱们特地强调了注意每一次交换时都要保证是原始顺序
因此最后一个 swap 要作的事情就是每次交换第一个数字与后面被选中的那个数,作完以后元素的全排列以后,要把数字交换回来,以保证接下来再用第一位与其余位的数字进行交换前是原始的序列,这样才能保证第一位数字与以后的 n-1 个元素依次交换以后都是不重复的。

注定必定要从函数的功能去理解递归,全排列的函数从功能上能够这么理解,选中第 k 位 + 计算以后的 n-k 位的全排序, 并且因为是递归,以后的 n-k 位也能够重复调用一样的函数持续求解!

四、求时间/空间复杂度
因为咱们只用了一个数组 arr,因此空间复杂度显然是 O(n),
那时间复杂度呢,仔细看上面的编码能够很明显地看出计算 n 的全排列须要作 n 次循环,循环里是要作 2 次交换(因为是固定数字,能够认为是常数 C ),还有一次对以后 n-1 次元素的全排列
因此 f(n) = n * (C + f(n-1)),C是常数能够忽略,因此

f(n) = n * f(n-1) = n * (n-1) * f(n-2) = n!,因此时间复杂度是 O(n!),注意不可能有比这个更好的时间复杂度了!由于全排列的组合自己就有 n! 次,再怎么优化都确定会有这么屡次

在 n 较大的状况下显然是不可接受的,因此咱们要想办法进行优化

字典序法

除了递归解法,还有一种经常使用的解法:字典排序法
啥叫字典排序法?

举个例子: 1 2 3 这三位数字的全排列以下

1 2 3 , 1 3 2 , 2 1 3 , 2 3 1 , 3 1 2 , 3 2 1

以上排列知足从小到大依次递增,按这种方式排列的算法就叫字典排序法。

因此咱们只要从排列的最小值开始,依次按从小到大依次递增的顺序找寻下一个全排列的数字便可,直到最大值!就能找到全部全排列。

假设咱们定义了一个叫 nextPermutation 的函数,根据字典排序法,则从最小值 123 开始,持续调用这个函数便可求出全部全排列的组合,如图示

那么这个函数该怎么实现呢

有 4 个步骤
一、从右到左(从个位数往高位数)寻找第一个左邻小于右邻的数,若是找不到说明此时的数字为全排列的最大值
二、再从右往左找第一个比第一步找出的数更大的数
三、交换上面两个步骤中的数
四、假设第一步寻找的数对应的位置为 i,则将 i+1至最后一个元素从小到大进行排序,排好序后,此时的数字就是咱们要找的那个排列

举个例子: 假设当前给的数字是 124653, 按这四个步骤来看如何寻找这个数按字典排序法的下一个全排列数字

一、从右到左(从个位数往高位数)寻找第一个左邻小于右邻的数,显然是 4

二、再从右往左找第一个比第一步找出的数(4)更大的数, 显然是 5

三、交换上面两个步骤中的数,即交换 4, 5,此时数字为 125643

四、 再对 643 从小到大进行排序,显然应该为 125346,,这一步的排序咱们用了快排

总体思路仍是很清晰的,若是不太清楚,建议你们多看几遍。

思路清楚了,代码写起来就快了,直接贴上按以上步骤来实现的代码吧,注释写得很详细了,你们能够对照着看

/**
 * 
 * @param arr   当前排列
 * @return boolean 若是还有下一个全排列数,则返回 true, 不然返回 false
 */
public boolean next_permutation(int[] arr) {
    int beforeIndex = 0; //记录从右到左寻找第一个左邻小于右邻的数对应的索引
    int currentIndex;
    boolean isAllReverse = true;    // 是否存在从右到左第一个左邻小于右邻的数对应的索引
    // 1. 从右到左(从个位数往高位数)寻找第一个左邻小于右邻的数
    for(currentIndex = arr.length - 1; currentIndex > 0; --currentIndex){
        beforeIndex = currentIndex - 1;
        if(arr[beforeIndex] < arr[currentIndex]){
            isAllReverse = false;
            break;
        }
    }
    //若是不存在,说明这个数已是字典排序法里的最大值,此时已经找到全部的全排列了,直接打印便可
    if(isAllReverse){
        return  false;
    } else {
        // 2. 再从右往左找第一个比第一步找出的数更大的数的索引
        int firstLargeIndex = 0;
        for(firstLargeIndex = arr.length - 1; firstLargeIndex > beforeIndex; --firstLargeIndex) {
            if (arr[firstLargeIndex] > arr[beforeIndex]) {
                break;
            }
        }
        // 3. 交换 上述 1, 2 两个步骤中得出的两个数
        swap(arr, beforeIndex, firstLargeIndex);
        // 4. 对 beforeIndex 以后的数进行排序,这里用了快排
        quicksort(arr, beforeIndex + 1, arr.length);
        return true;
    }
}

public void swap (int[] arr, int i, int j) {
    int t = arr[i];
    arr[i] = arr[j];
    arr[j] = t;
}

注:以上第四步的排序用到了快排(quicksort),限于篇幅关系没有贴出快排的完整代码,若是不了解快排,建议你们网上查查看,这里不作详细展开

那 next_permutation 的时间复杂度是多少呢,从以上的步骤中其实能够看到是第四步作快排时的时间复杂度,即 O(nlogn)。

next_permutation 咱们写好了,接下来要寻找全排列就容易了,思路以下

一、 首先对参与全排列的数字数组做排序,保证初始的排列数字必定是最小的
即若是起始的 int[] arr = {4,3,2,1} 通过快排后会变成 {1,2,3,4}

二、持续调用定义好的 next_permutation 函数,直到最大值

public void permutation(int[] arr) {
    // 一、 快排,保证 arr 里的元素是从小到大的
    quicksort(arr);
    // 二、持续调用定义好的 next_permutation 函数,直到最大值
    while(next_permutation(arr)) {
        System.out.println(Arrays.toString(array));
    }
}

能够看到若是定义好了 next_permutation,在算全排列仍是很简单的,那用字典序法的时间和空间复杂度是多少呢
因为全程只用了arr 数组,空间复杂度显示是 O(n)
而时间复杂度显然是第一步快排的空间复杂度 + 持续作 next_permutation 计算的时间复杂度

快排的时间复杂度为 O(nlogn),而 next_permutation 因为要计算 n! 次, 且根据以上分析咱们已经知道了 next_permutation 的时间复杂度是 O(nlogn), 因此总体的时间复杂度是

O(nlog) + O(n! * nlogn) = O(n! * nlogn)。

看起来字典序法比递归的时间复杂度更高,因此咱们应该使用倾向于使用递归吗?
这里注意: 递归的实现是经过调用函数自己,函数调用的时候,每次调用时要作地址保存,参数传递等,这是经过一个递归工做栈实现的。具体是每次调用函数自己要保存的内容包括:局部变量、形参、调用函数地址、返回值。那么,若是递归调用N次,就要分配N局部变量、N形参、N调用函数地址、N返回值,这势必是影响效率的,同时,这也是内存溢出的缘由,由于积累了大量的中间变量没法释放。

因此在时间复杂度差很少的状况下,优化选择非递归的实现方式

什么是组合

看完了排列,咱们来看看组合,首先咱们仍是先看看组合的定义

组合(combination)是一个数学名词。通常地,从n个不一样的元素中,任取m(m≤n)个元素为一组,叫做从n个不一样元素中取出m个元素的一个组合。咱们把有关求组合的个数的问题叫做组合问题。

假设有数字1, 2, 3, 4, 要从中选择 2 个元素,共有多少种组合呢

共有 6 种

排列与组合最主要的区别就是排列是有序的,而组合是无序的,12 和 21 对组合来讲是同样的

如今咱们来看看若是从 n 个元素中选出 m 的组合共有几种,以前详细地讲解了如何用递归解排列,相信你们应该对组合怎么使用递归应该有一个比较清晰的思路。

咱们一块儿来看看,假设要从 n 选 m 的组合的解题思路

这里须要注意的是相对于全排列的每一个元素都能参与排列不一样,组合中的每一个元素有两种状态,选中或未选中,因此造成递归分两种状况。

  • 若是第一个元素选中,则要从以后的 n-1 个元素中选择 m-1 个元素

  • 若是第一个元素未被选中,则须要从以后的 n-1 个元素选择 m 个元素

递归条件既然找到了,接下来咱们就按递归四步曲来解下组合。

一、定义函数的功能
定义如下函数为从数组 arr 中第 k 个位置开始取 m 个元素(以下的 COMBINATION_CNT)

public static final int COMBINATION_CNT = 5;        // 组合中须要被选中的个数
public static void combination(int[] arr, int k, int[] select) {
}

这里咱们额外引入了一个 select 数组,这个数组里的元素若是为1,则表明相应位置的元素被选中了,若是为 0 表明未选中

如图示,以上表示 arr 的 第 2,3 元素被选中做为组合

二、寻找递推公式
显然递推公式为

combination(arr, k,m)  = (选中 k 位置的元素 +combination(arr, k+1) ) +  (不选中 k 位置的元素 +combination(arr, k+1) )

那么终止条件呢,有两个

  • 一个是被选中的元素已经等于咱们要选择的数量了
  • 一个是 k (开始选取的数组索引) 超出数组范围了。
    三、将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中,补充后的函数以下
public static final int COMBINATION_CNT = 5;        // 组合中须要被选中的个数
public static void combination(int[] arr, int k, int[] select) {
    // 终止条件1:开始选取的数组索引 超出数组范围了
    if (k >= arr.length) {
        return;
    }

    int selectNum = selectedNum(select);
    // 终止条件2:选中的元素已经等于咱们要选择的数量了
    if (selectNum == COMBINATION_CNT) {
        for (int j = 0; j < select.length; j++) {
            if (select[j] == 1) {
                System.out.print(arr[j]);
            }
        }
        System.out.print("\n");
    } else {
        // 第 k 位被选中
        select[k] = 1;
        combination(arr, k+1, select);

        // 第 k 位未被选中
        select[k] = 0;
        // 则从第 k+1 位选择 COMBINATION_CNT - selectNum 个元素
        combination(arr, k+1, select);
    }
}

public static void main(String[] args) {
    int[] arr = {1,2,3,4,5,6,7,8,9};
    int[] select = {0,0,0,0,0,0,0,0,0};
    // 一开始从 0 开始选 组合数
    combination(arr, 0, select);
}

四、求时间/空间复杂度
空间复杂度:因为咱们用了一个辅助数组 select, 因此空间复杂度是 O(n)
时间复杂度:能够看到 f(n) = 2f(n-1),因此时间复杂度是O(2^n),显然是指数级别的
画外音:你们能够考虑一下怎么优化,提示:每种元素只有选中和被选中的状态,是否是对应了二进制的 0 和 1,能够考虑用位运算

面试中排列组合的一些变形

通过以上的讲解,我相信你们对排列组合的递归解法应该是很明白了,不过面试中面试官可能还会对排列组合稍加变形,以进一步考察你的算法水平。

考虑如下状况

  1. 在全排列时参与排列的数字都是不相同的, 若是有相同的数字(好比参与排序的是 1, 1,2,3),在使用递归进行解题时,须要进行怎样的改造
  2. 在组合中 ,咱们的题目是从 n 中选出 m 个数,若是要选出全部组合呢,好比给定 1,2,3,全部的组合是
    1, 2, 3, 12, 13, 23, 123, 此时以上的递归解法又该怎么改造

期待你的回答!咱们下篇见

若有帮助,欢迎关注公众号「码海」

相关文章
相关标签/搜索