【精选】一篇文章带你搞懂排列算法题【面向面试】

一说起排列与组合,很多人会想到回溯法,但回溯法的适用状态空间大小很小,即使是剪枝高手在面对n>20时的题目也会望洋兴叹,另觅它径。
不过这篇文章还是先从回溯讲起,再慢慢转入数学的天堂。

排列型题目的回溯法都有一个板子:

void backtrace(int n) {
	递归出口或中间处理部分
	for (int i = 0; i < n; ++i) {
		if (未选) {
			标记选中
			backtrace(n);
			取消标记选中;
		}
	}
}

借着这个板子可以轻松搞定本文的第一题:

例1:

给定一个 没有重复 数字的序列,返回其所有可能的全排列。

这个问题可以看作有 n n n 个排列成一行的空格,我们需要从左往右依此填入题目给定的 n n n 个数,每个数只能使用一次。那么很直接的可以想到一种穷举的算法,即从左往右每一个位置都依此尝试填入一个数,看能不能填完这 n n n 个空格,在程序中我们可以用「回溯法」来模拟这个过程。

我们定义递归函数 b a c k t r a c k ( f i r s t , o u t p u t ) backtrack(first, output) backtrack(first,output) 表示从左往右填到第 f i r s t first first 个位置,当前排列为 o u t p u t output output。 那么整个递归函数分为两个情况:

如果 f i r s t = = n first==n first==n,说明我们已经填完了 n n n 个位置(注意下标从 0 0 0 开始),找到了一个可行的解,我们将 o u t p u t output output 放入答案数组中,递归结束。
如果 f i r s t < n first<n first<n,我们要考虑这第 f i r s t first first 个位置我们要填哪个数。根据题目要求我们肯定不能填已经填过的数,因此很容易想到的一个处理手段是我们定义一个标记数组 v i s [ ] vis[] vis[] 来标记已经填过的数,那么在填第 f i r s t first first 个数的时候我们遍历题目给定的 n n n个数,如果这个数没有被标记过,我们就尝试填入,并将其标记,继续尝试填下一个位置,即调用函数 b a c k t r a c k ( f i r s t + 1 , o u t p u t ) backtrack(first + 1, output) backtrack(first+1,output)。搜索回溯的时候要撤销这一个位置填的数以及标记,并继续尝试其他没被标记过的数。

void backtrack(vector<vector<int>>& res, vector<int>&nums, vector<int>& output, vector<bool>&vis, int first, int len) {
		if (first == len) {
			res.emplace_back(output);
			return;
		}
		for (int i = 0; i < len; ++i) {
			if (!vis[i]) {
				vis[i] = true;
				output.emplace_back(nums[i]);
				backtrack(res, nums, output, vis, first + 1, len);
				vis[i] = false;
				output.pop_back();
			}
		}
	}
	vector<vector<int>> permute(vector<int>& nums) {
		vector<vector<int> > res;
		vector<int> output;
		vector<bool> vis(nums.size(), false);
		backtrack(res, nums, output, vis, 0, (int)nums.size());
		return res;
	}

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!)
空间复杂度: O ( n ) O(n) O(n)

使用标记数组来处理填过的数是一个很直观的思路,但是可不可以去掉这个标记数组呢?毕竟标记数组也增加了我们算法的空间复杂度。

答案是可以的,我们可以将题目给定的 n n n 个数的数组 n u m s [ ] nums[] nums[] 划分成左右两个部分,左边的表示已经填过的数,右边表示待填的数,我们在递归搜索的时候只要动态维护这个数组即可。

具体来说,假设我们已经填到第 f i r s t first first 个位置,那么 n u m s [ ] nums[] nums[] 数组中 [ 0 , f i r s t − 1 ] [0,first−1] [0,first1] 是已填过的数的集合, [ f i r s t , n − 1 ] [first,n−1] [first,n1] 是待填的数的集合。我们肯定是尝试用 [ f i r s t , n − 1 ] [first,n−1] [first,n1] 里的数去填第 f i r s t first first 个数,假设待填的数的下标为 i i i ,那么填完以后我们将第 i i i 个数和第 f i r s t first first 个数交换,即能使得在填第 f i r s t + 1 first+1 first+1个数的时候 n u m s [ ] nums[] nums[] 数组的 [ 0 , f i r s t ] [0,first] [0,first] 部分为已填过的数, [ f i r s t + 1 , n − 1 ] [first+1,n−1] [first+1,n1] 为待填的数,回溯的时候交换回来即能完成撤销操作。

举个简单的例子,假设我们有 [ 2 , 5 , 8 , 9 , 10 ] [2, 5, 8, 9, 10] [2,5,8,9,10] 5 5 5 个数要填入,已经填到第 3 3 3 个位置,已经填了 [ 8 , 9 ] [8,9] [8,9] 两个数,那么这个数组目前为 [ 8 , 9 ∣ 2 , 5 , 10 ] [8, 9 | 2, 5, 10] [8,92,5,10] 这样的状态,分隔符区分了左右两个部分。假设这个位置我们要填 10 10 10 这个数,为了维护数组,我们将 2 2 2 10 10 10 交换,即能使得数组继续保持分隔符左边的数已经填过,右边的待填 [ 8 , 9 , 10 ∣ 2 , 5 ] [8, 9, 10 | 2, 5] [8,9,102,5]

当然善于思考的读者肯定已经发现这样生成的全排列并不是按字典序存储在答案数组中的,如果题目要求按字典序输出,那么请还是用标记数组或者其他方法。

class Solution {
public:
    void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len){
        if (first == len) {
            res.emplace_back(output);
            return;
        }
        for (int i = first; i < len; ++i) {
            swap(output[i], output[first]);
            backtrack(res, output, first + 1, len);
            swap(output[i], output[first]);
        }
    }
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int> > res;
        backtrack(res, nums, 0, (int)nums.size());
        return res;
    }
};

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!)
空间复杂度: O ( n ) O(n) O(n)(纯递归栈开销)

例2:

给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

这道题是例1的进阶,序列中包含了重复的数字,要求我们返回不重复的全排列,那么我们依然可以选择使用搜索回溯的方法来做。

要解决重复问题,我们只要设定一个规则,保证在填数的时候重复数字只会被填入一次即可。我们可以选择对原数组排序,保证相同的数字都相邻,然后每次填入的数一定是这个数所在重复数集合中「从左往右第一个未被填过的数字」,即如下的判断条件:

if (i > 0 && nums[i] == nums[i - 1] && !vis[i - 1]) {
    continue;
}

这个判断条件保证了对于重复数的集合,一定是从左往右逐个填入的。

假设我们有 3 3 3 个重复数排完序后相邻,那么我们一定保证每次都是拿从左往右第一个未被填过的数字,即整个数组的状态其实是保证了 [ 未 填 入 , 未 填 入 , 未 填 入 ] [未填入,未填入,未填入] [] [ 填 入 , 未 填 入 , 未 填 入 ] [填入,未填入,未填入] [],再到 [ 填 入 , 填 入 , 未 填 入 ] [填入,填入,未填入] [],最后到 [ 填 入 , 填 入 , 填 入 ] [填入,填入,填入] [] 的过程的,因此可以达到去重的目标。

 void backtrack(vector<int>& nums, vector<vector<int>>& ans, int idx, vector<int>& perm) {
        if (idx == nums.size()) {
            ans.emplace_back(perm);
            return;
        }
        for (int i = 0; i < (int)nums.size(); ++i) {
            if (vis[i] || (i > 0 && nums[i] == nums[i - 1] && !vis[i - 1])) {
                continue;
            }
            perm.emplace_back(nums[i]);
            vis[i] = 1;
            backtrack(nums, ans, idx + 1, perm);
            vis[i] = 0;
            perm.pop_back();
        }
    }

    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<vector<int>> ans;
        vector<int> perm;
        vis.resize(nums.size());
        sort(nums.begin(), nums.end());
        backtrack(nums, ans, 0, perm);
        return ans;
    }

时间复杂度: O ( n ∗ n ! ) O(n*n!) O(nn!)
空间复杂度: O ( n ) O(n) O(n)

例3:

给出集合 [1,2,3,…,n],其所有元素共有 n! 种排列。

按大小顺序列出所有排列情况,并一一标记,当 n = 3 时, 所有排列如下:

“123” “132” “213” “231” “312” “321” 给定 n 和 k,返回第 k 个排列。

这道题也可以通过回溯法解决,但当n过大时运行效率十分差。
还有一点,回溯法进行了大量的无用计算,无法直接得到目标。

那么能不能直接得到第k个排列呢,基于排列的数学性质,我们需要从数学分析开始。

先来个简单的结论:

对于 n 个不同的元素,它们可以组成的排列总数目为 n!。

对于给定的 n n n k k k,我们不妨从左往右确定第 k k k 个排列中的每一个位置上的元素到底是什么。

我们首先确定排列中的首个元素 a 1 a_1 a1 。根据上述的结论,我们可以知道:

  • 以 1 为 a 1 a_1 a1的排列一共有 (n−1)! 个;
  • 以 2 为 a 1 a_1 a1的排列一共有 (n−1)! 个;
  • 以 n 为 a 1 a_1 a1的排列一共有 (n−1)! 个;

由于我们需要求出从小到大的第 k k k个排列,因此:
如果 k ≤ ( n − 1 ) ! k≤(n−1)! k(n1)!,我们就可以确定排列的首个元素为 1 1 1
如果 ( n − 1 ) ! < k ≤ 2 ⋅ ( n − 1 ) ! (n−1)!<k≤2⋅(n−1)! (n1)!<k2(n1)!,我们就可以确定排列的首个元素为 2 2 2
如果 ( n − 1 ) ! < k ≤ n ⋅ ( n − 1 ) ! (n−1)!<k≤n⋅(n−1)! (n1)!<kn(n1)!,我们就可以确定排列的首个元素为 n n n
因此,第 k k k 个排列的首个元素就是:
a 1 = ⌊ k − 1 ( n − 1 ) ! ⌋ + 1 a_1=\lfloor \frac{k-1}{(n-1)!}\rfloor+1 a1=(n1)!k1+1

由于 a 1 a_1 a1开头的排序已有 ( n − 1 ) ! (n-1)! (n1)!个,所以我们需要在 a 2... a n a2...an a2...an的序列中找到第 ( k − 1 ) m o d ( n − 1 ) ! + 1 (k-1)mod(n-1)!+1 (k1)mod(n1)!+1个排列。
设第 k k k个排列为 a 1 , a 2 , . . . , a n a_1,a_2,...,a_n a1,a2,...,an,我们从左往右地确定每一个元素 a i a_i ai我们用数组 v a l i d valid valid记录每一个元素是否被使用过。
我们从小到大枚举:

  • 我们已经使用过了 i − 1 i-1 i1个元素,剩余 n − i + 1 n-i+1 ni+1个元素未使用过,每一个元素都对应着 ( n − 1 ) ! (n-1)! (n1)!个排列,总计(n-i+1)!个排列。
  • 因此在第k个排列中,a_i即剩余未使用的元素中第 ⌊ k − 1 ( n − i ) ! ⌋ + 1 \lfloor \frac{k-1}{(n-i)!}\rfloor+1 (ni)!k1+1小的元素
  • 在确定了 a i a_i ai后,这 n − i + 1 n-i+1 ni+1个元素的第 k k k个排列,就等于 a i a_i ai之后跟着剩余 n − i n-i ni个元素的第 ( k − 1 ) m o d ( n − i ) ! + 1 (k-1)mod(n-i)!+1 (k1)mod(ni)!+1个排列。

在实际的代码中,我们可以首先将 k k k 的值减少 1 1 1,这样可以减少运算,降低代码出错的概率。对应上述的后两步,即为

  • 因此在第 k 个排列中, a i a_i ai即为剩余未使用过的元素中第 ⌊ k ( n − i ) ! ⌋ + 1 \lfloor \frac{k}{(n-i)!} \rfloor + 1 (ni)!k+1 小的元素
  • 在确定了 a i a_i ai后,这 n − i + 1 n−i+1 ni+1 个元素的第 k k k 个排列,就等于 a i a_i ai之后跟着剩余 n − i n−i ni 个元素的第 k m o d ( n − i ) ! kmod(n−i)! kmod(ni)! 个排列。

实际上,这相当于我们将所有的排列从 0 0 0 开始进行编号

class Solution {
public:
    string getPermutation(int n, int k) {
        vector<int> factorial(n);
        factorial[0] = 1;
        for (int i = 1; i < n; ++i) {
            factorial[i] = factorial[i - 1] * i;
        }

        --k;
        string ans;
        vector<int> valid(n + 1, 1);
        for (int i = 1; i <= n; ++i) {
            int order = k / factorial[n - i] + 1;
            for (int j = 1; j <= n; ++j) {
                order -= valid[j];
                if (!order) {
                    ans += (j + '0');
                    valid[j] = 0;
                    break;
                }
            }
            k %= factorial[n - i];
        }   
        return ans;     
    }
};

实际上,这个问题还可以反过来问,对于给定的排列 a 1 a 2 . . . a n a_1a_2...a_n a1a2...an,你能求出 k k k吗?
事实上这个问题也由数学推导解决:
在这里插入图片描述

例4:

实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。

如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。

必须原地修改,只允许使用额外常数空间。

以下是一些例子,输入位于左侧列,其相应输出位于右侧列。 1,2,3 → 1,3,2 3,2,1 → 1,2,3 1,1,5 → 1,5,1

在这里插入图片描述
我们需要找到按字典顺序排列的下一个排列,观察图像,我们只需找到最后一个相邻递增数对,在图中为5,将其与其后的最后一个比其大的数交换,再将后面的递减序列改为递增序列即可。
即5与6交换,7,5,4逆置。
这其中的数学原理,篇幅有限,无法展开

void nextPermutation(vector<int>& nums) {
        int i = nums.size() - 2;
        while (i >= 0 && nums[i] >= nums[i + 1])  --i;
        if (i >= 0) {
            int j = nums.size() - 1;
            while (j >= 0 && nums[i] >= nums[j])  --j;
            swap(nums[i], nums[j]);
        }
        reverse(nums.begin() + i + 1, nums.end());
    }

例5:

给你一个正整数的数组 A(其中的元素不一定完全不同),请你返回可在 一次交换(交换两数字 A[i] 和 A[j]
的位置)后得到的、按字典序排列小于 A 的最大可能排列。

如果无法这么操作,就请返回原数组。

与例4相同的思路,其解决方法与例4对称
关键在于从右往左找到最后一个相邻递减数对。且只需交换操作

 vector<int> prevPermOpt1(vector<int>& A) {
        int i=A.size()-2;
        while(i>=0&&A[i]<=A[i+1]) --i;
        if(i>=0){
            int j=A.size()-1;
            while(j>=0&&A[i]<=A[j]) --j;
            while(j>=1&&A[j]==A[j-1]) --j; //思考,为什么?
            swap(A[i],A[j]);
        }
        return A;
    }

实际上,C++提供了排列的API: std::next_permutation,其用法点击这里

参考资料:leetcode诸题解

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值