轻松理解全排列算法的递归解法

笔试面试算法经典–全排列算法-递归&字典序实现(Java)
全排列算法的全面解析

看了上面两篇对全排列算法应该就有些感觉了,对递归的解法我一开始看得还是挺蒙的,看了好一会才明白。

首先,递归问题一定先演算出其递推公式,找到终止条件。

我一开始在学习递归的时候,在纸上画出递归的过程,在脑子中像计算机一样回放一个个递归步骤,我发现这样反而进入了思维误区,给自己制造了理解障碍。递归就是将大问题分解为子问题,将最终的子问题的解作为终止条件,而这些子问题和大问题的解法都是一样的,只需要思考大问题和子问题之间的关系就行,不需要一层层往下去思考子问题与子子问题之间的关系。这样大脑一下就轻松了,原来这才是本质。

摘自极客时间王争老师的《数据结构与算法之美》专栏:

写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

下面来一步步解析全排列算法的递归解法。

确定递推公式

假设有 {1, 2, 3, ... n} 这样一个序列,现在要找出这个序列的全排列,第一位有 n 种可能性,确定了第一位后就是求解剩下 n - 1 个数据的排列问题,这样就可以往下一直分解问题,直到序列结尾处,也就是终止条件。这样递推公式就可以表示成:

f(1, 2, ... n) = {第一位是 1, f(n-1)} + {第一位是 2, f(n-1)} +...+{第一位是 n, f(n-1)}
第一位的排列情况

数组 {1, 2, 3, 4},第一位有 4 种可能性:

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

就是将第一位和后面的数依次交换,交换之前的序列是 {1, 2, 3, 4}:

public class PermutationAdvanced {
    public static void main(String[] args) {
        int[] a = {1, 2, 3, 4};
        allPermutation(a, 0,a.length - 1);

    }

    private static void allPermutation(int[] a, int cursor, int k) {
        for (int i = cursor; i <= k; i++) {
            swap(a, cursor, i);
            System.out.println(Arrays.toString(a));
            // 保证交换之前的序列还是 {1, 2, 3, 4}
            swap(a, cursor, i);
        }
    }

    private static void swap(int[] a, int cursor, int i) {
        int temp = a[cursor];
        a[cursor] = a[i];
        a[i] = temp;
    }
}

输出:

[1, 2, 3, 4]
[2, 1, 3, 4]
[3, 2, 1, 4]
[4, 2, 3, 1]
重复元素的序列

序列 {3, 3, 2, 3, 4} 在上面的代码中输出是:

[3, 3, 2, 3, 4]
[3, 3, 2, 3, 4]
[2, 3, 3, 3, 4]
[3, 3, 2, 3, 4]
[4, 3, 2, 3, 3]

这个序列第一位只有 3 种情况:3、2、4。所以还得针对存在重复元素的序列改进代码:

public class PermutationAdvanced {
    public static void main(String[] args) {
//        int[] a = {1, 2, 3, 4};
        int[] a = {3, 3, 2, 3, 4};
        allPermutation(a, 0, a.length - 1);

    }

    private static void allPermutation(int[] a, int cursor, int k) {
        for (int i = cursor; i <= k; i++) {
            if (!judgeSwap(a, cursor, i)) {
                continue;
            }
            swap(a, cursor, i);
            System.out.println(Arrays.toString(a));
            // 保证交换之前的序列还是 {1, 2, 3, 4}
            swap(a, cursor, i);
        }
    }

    private static void swap(int[] a, int cursor, int i) {
        int temp = a[cursor];
        a[cursor] = a[i];
        a[i] = temp;
    }

    /**
     * 判断是否需要进行交换
     *
     * @param a
     * @param cursor
     * @param i
     * @return
     */
    private static boolean judgeSwap(int[] a, int cursor, int i) {
        for (int j = cursor; j < i; j++) {
            /**
             * a[i] 是等待被交换的元素
             * 如果 在 [cursor, i) 范围里存在和 a[i] 相同的元素则不进行交换,说明这种情况已经存在了。
             * 比如当 cursor 为 0 时,{3, 3, 2, 3, 4} 这个序列在第一个位置只有三种情况:3、2、4,如果等待被交换的元素 a[i] 在之前出现过,说明这种情况已经存在了
             * 当 cursor 和 i 相等时表示同一位置的交换,不能跳过,所以这里是「j < i」
             */
            if (a[j] == a[i]) {
                return false;
            }
        }
        return true;
    }
}

输出:

[3, 3, 2, 3, 4]
[2, 3, 3, 3, 4]
[4, 3, 2, 3, 3]
加入递归

通过代码将序列第一位的所有情况的罗列出来了,解决了第一个子问题,剩下问题解决方案还是一样的。序列剩下的 n - 1 或 n - 2 或 n - 3… 排列情况和第一位是一样的:

public class PermutationAdvanced {
    public static void main(String[] args) {
        int[] a = {1, 2, 3};
//        int[] a = {3, 3, 2, 3, 4};
        allPermutation(a, 0, a.length - 1);

    }

    private static void allPermutation(int[] a, int cursor, int k) {
        // 已经到序列结尾了 
        if (cursor == k) {
            System.out.println(Arrays.toString(a));
        }
        for (int i = cursor; i <= k; i++) {
            if (!judgeSwap(a, cursor, i)) {
                continue;
            }
            swap(a, cursor, i);
            allPermutation(a, cursor + 1, k);
            // 保证交换之前的序列还是 {1, 2, 3, 4}
            swap(a, cursor, i);
        }
    }

    private static void swap(int[] a, int cursor, int i) {
        int temp = a[cursor];
        a[cursor] = a[i];
        a[i] = temp;
    }

    /**
     * 判断是否需要进行交换
     *
     * @param a
     * @param cursor
     * @param i
     * @return
     */
    private static boolean judgeSwap(int[] a, int cursor, int i) {
        for (int j = cursor; j < i; j++) {
            /**
             * a[i] 是等待被交换的元素
             * 如果 cursor == i 需要进行交换
             * 如果 在 [cursor, i) 范围里存在和 a[i] 相同的元素则不进行交换,说明这种情况已经存在了
             */
            if (a[j] == a[i]) {
                return false;
            }
        }
        return true;
    }
}
时间复杂度

借助递归树来进行时间复杂度的分析,

f(n) ->  n 个 f(n-1) -> n * (n-1) 个 f(n-2) -> n * (n-1) * (n-2) 个 f(n-3)

第一层 f(n) 分解有 n 次交换操作(cursor 为 0 的那个 for 循环);
第二层 n 个 f(n-1)n 个节点,每个节点 n - 1 次交换(cursor 等于 1 的时候),第二层交换次数为 n * (n - 1);
第三层 n * (n-1) 个 f(n-2) 的节点数就是上一层的交换次数 n * (n - 1),每个结点交换 n - 2 次,第三层的交换次数为 n * (n - 1) * (n - 2)

那第 k 层交换次数可以 表示成:n * (n - 1) * (n - 2) * ... * (n - k + 1)
那总的交换次数就是各层加起来:

n + n * (n - 1) + n * (n - 1) * (n - 2) + ... + n * (n - 1) * (n - 2) * ... * 2 * 1

最后一个数 n * (n - 1) * (n - 2) * ... * 2 * 1 等于 n!,前面的每个数肯定都是小于 n!,那这个公式的和小于 n * n!,可以看出全排列递归的时间复杂度是大于 O (n!),小于O(n * n!)的,时间复杂度还是挺高的哦。

总结

如果要通过人脑去过一遍递归的过程,那是很困难的,当然也没这个必要。求解的递归的关键点是:

  1. 一个问题是否可以分解为多个子问题,然后子问题又可以继续划分;
  2. 分解后的子问题除了数据规模不一样,但具体解法还是一样。在全排列的求解过程中,每个子问题的解法一样,那就先解出一个子问题(求第一位有多少种情况),然后再加入递归代码,大脑中不用去模拟递归的一个过程,你就想子问题的解法都是一样的,计算机只不过是在通过栈做重复的事情而已;
  3. 找到子问题终止条件。
  • 40
    点赞
  • 96
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值