集合元素全排列的生成(排列树+递归回溯)

1. 问题定义
  • 输入整数n,按照字典序从小到大输出前n个数得到所有排列。
    即若n=3,则有全排列 { 123 , 132 , 213 , 231 , 312 , 321 } \{123,132,213,231,312,321\} {123132213231312321}
  • 或者输入含有n个元素的集合,输出这n个元素的 A n n A_n^n Ann 种排列结果。
2. 递归生成1~n的排列

根据我们上面举的关于字典序排列生成的方式,我们可以用递归的方式来生成排列:
即对于 1 , 2 , 3 , . . . , n 1,2,3,...,n 1,2,3,...,n 的排列,我们先生成1开头的排列,再生成2开头的排列,接着是3开头的排列,…,依此类推,最后是n开头的排列。

子问题的定义:

  • 生成1开头的排列的时候,我们第一位是1,剩下是 2 , 3 , . . , n 2,3,..,n 2,3,..,n 组成的子排列,所以我们递归的时候主要看首元素和子排列的关系。
  • 即我们递归函数需要保留以下参数:
    • 已经确定的“前缀序列A”,便于输出;
    • 还需要进行排列的元素集合S,便于从中选取下一元素。
  • 所以我们有伪代码
void permutation(前缀A,序列S){
	if(序列S为空)  输出序列A;
	else{
		按照从小到大的顺序依次取出S中每个元素u{
			permutation(A + u, S - {u});
		}
	}
}

实现一:暴力实现

基于上面的伪代码,我们可以先写一个简单的实现:

void permutation(int n, int* A, int index) {
	/*
	** A表示生成的排列序列,n表示排列的元素个数,index表示当前生成元素的位置
	*/
	if (index == n) { // 一个排列生成结束
		for (int i = 0; i < n; i++) printf("%d ", A[i]);
		printf("\n"); return;
	}
	// 按照从小到大依次取出1-n生成排列
	for (int i = 1; i <= n; i++) {
		int ok = 1; // 没有被取过
		for (int j = 0; j < index; j++) {
			if (i == A[j]) { ok = 0; break; }
		}// for
		if (ok) {
			A[index] = i;
			permutation(n, A, index + 1);
		}
	}
}

分析:
每次我们要从未选区的元素集合 S S S 中选择元素时,我们都需要先遍历 i 从 1 到 n i从1到n i1n 选择我们需要的元素,然后再遍历 j 从 0 到 i n d e x j从0到index j0index 判断该元素是否被选择过,导致当n比较大时,越是到后面的选取,我们越是要遍历两边数组,即复杂度接近 O ( n 2 ) O(n^2) O(n2)。所以,我们可以再添加一个数组vis记录下已经被选取过的元素。

实现二:空间换时间优化

我们再使用一个 O ( n ) O(n) O(n) 的数组来标记已经访问过的元素,这样我们在选取新元素的时候就不需要再重复判断了。

void permutation2(int n, int* A, int* vis, int index) {
	if (index == n) {
		for (int i = 0; i < n; i++) printf("%d ", A[i]);
		printf("\n"); 
		return;
	}
	for (int i = 1; i <= n; i++) {
		if (vis[i] == 0) {
			vis[i] = 1;
			A[index] = i;
			permutation2(n, A, vis, index + 1);
			vis[i] = 0;
		}
	}
}

方法3:基于交换的全排列
方法2在 选择每个元素的时候是使用了一个O(n)的空间复杂度来将函数优化在时间复杂度O(n)左右, 但是我们也可以使用交换策略来在集合S中选择我们需要的元素加入序列A,即 我们在集合S中选择一个元素u然后将其交换到当前我们选择的位置即可,这样就不需要vis数组了。

void swap(int& a, int& b) {
	if (a == b) return;
	int t = a;
	a = b;
	b = t;
}
void permutation3(int n, int* A, int index) {
	if (index == n) {
		for (int i = 0; i < n; i++) printf("%d ", A[i]);
		printf("\n"); 
		return;
	}
	else {
		for (int i = index; i < n; i++) {
			swap(A[index], A[i]); // 当前需要排列index,相当于vis[i] = 1;
			permutation3(n, A, index + 1);
			swap(A[index], A[i]); // 相当于vis[i] = 0;
		}
	}
}

需要注意的是:这里的集合A需要初始化,或者原本有值才行。

3. 复杂度分析

上面所分析的复杂度均是在某一次执行函数时选择下一元素的复杂度,实际上,生成全排列的复杂度远远不止于此,我们一个写一个简单的程序来粗略的估算一下。

#include<iostream>
#include<string>
#include<algorithm>
#include<cstring>
#include<ctime>
using namespace std;

long long cnt = 0; // 所进行的操作数,这里我指的循环次数
// 生成所有排列
void permutation1(int n, int* A, int index) {
	/*
	** A表示生成的排列序列,n表示排列的元素个数,index表示当前生成元素的位置
	*/
	if (index == n)	return;
	// 按照从小到大依次取出1-n生成排列
	for (int i = 1; i <= n; i++) {
		int ok = 1; // 没有被取过
		for (int j = 0; j < index; j++) {
			cnt++;
			if (i == A[j]) { ok = 0; break; }
		}// for
		if (ok) {
			A[index] = i;
			permutation1(n, A, index + 1);
		}
	}
}

void permutation2(int n, int* A, int* vis, int index) {
	if (index == n) return;
	for (int i = 1; i <= n; i++) {
		cnt++;
		if (vis[i] == 0) {
			vis[i] = 1;
			A[index] = i;
			permutation2(n, A, vis, index + 1);
			vis[i] = 0;
		}
	}
}
void swap(int& a, int& b) {
	if (a == b) return;
	int t = a;
	a = b;
	b = t;
}
void permutation3(int n, int* A, int index) {
	if (index == n) return;
	for (int i = index; i < n; i++) {
		cnt++;
		swap(A[index], A[i]); // 当前需要排列index,相当于vis[i] = 1;
		permutation3(n, A, index + 1);
		swap(A[index], A[i]); // 相当于vis[i] = 0;
	}

}
int main() {
	int n;
	while (cin >> n) {
		int* A = new int[n];
		int* vis = new int[n + 1];
		fill_n(vis, n + 1, 0);
		for (int i = 1; i <= n; i++) A[i - 1] = i;
		
		int clock1 = clock();
		cnt = 0;
		permutation1(n, A, 0);
		long long cnt1 = cnt; cnt = 0;
		int clock2 = clock();
		permutation2(n, A, vis,0);
		long long cnt2 = cnt; cnt = 0;
		int clock3 = clock();

		permutation3(n, A, 0);
		long long cnt3 = cnt; cnt = 0;
		int clock4 = clock();

		printf("方法1的时间 %.4f s,操作数:%lld\n", double(clock2 - clock1) / CLOCKS_PER_SEC, cnt1);
		printf("方法2的时间 %.4f s,操作数:%lld\n", double(clock3 - clock2) / CLOCKS_PER_SEC, cnt2);
		printf("方法3的时间 %.4f s,操作数:%lld\n", double(clock4 - clock3) / CLOCKS_PER_SEC, cnt3);

		delete[] A;
		delete[] vis;
	}
	return 0;
}

输出结果:

1
方法1的时间 0.0000 s,操作数:0
方法2的时间 0.0000 s,操作数:1
方法3的时间 0.0000 s,操作数:1
2
方法1的时间 0.0000 s,操作数:4
方法2的时间 0.0000 s,操作数:6
方法3的时间 0.0000 s,操作数:4
3
方法1的时间 0.0000 s,操作数:39
方法2的时间 0.0000 s,操作数:30
方法3的时间 0.0000 s,操作数:15
4
方法1的时间 0.0000 s,操作数:316
方法2的时间 0.0000 s,操作数:164
方法3的时间 0.0000 s,操作数:64
5
方法1的时间 0.0000 s,操作数:2605
方法2的时间 0.0000 s,操作数:1030
方法3的时间 0.0000 s,操作数:325
6
方法1的时间 0.0000 s,操作数:23046
方法2的时间 0.0000 s,操作数:7422
方法3的时间 0.0010 s,操作数:1956
7
方法1的时间 0.0020 s,操作数:221935
方法2的时间 0.0000 s,操作数:60620
方法3的时间 0.0010 s,操作数:13699
8
方法1的时间 0.0150 s,操作数:2329720
方法2的时间 0.0040 s,操作数:554248
方法3的时间 0.0080 s,操作数:109600
9
方法1的时间 0.1480 s,操作数:26579241
方法2的时间 0.0410 s,操作数:5611770
方法3的时间 0.0660 s,操作数:986409
10
方法1的时间 1.5490 s,操作数:328145410
方法2的时间 0.4260 s,操作数:62353010
方法3的时间 0.6470 s,操作数:9864100
11
方法1的时间 19.2080 s,操作数:4364070931
方法2的时间 5.1980 s,操作数:754471432
方法3的时间 7.6710 s,操作数:108505111

从上面的结果来看:

  • 随着n的增大,每种方法的操作数增幅越来越快;n=12后就需要等待很长时间了。
  • 这里我们定义最里循环为一个基本操作数,实际上,它们所需要的时间是不一样的,例如方法1和2设计数字的赋值,方法3设计子函数的调用以及数组元素的交换。
  • 所以,方法3不一定比方法2快。
4. 排列树(解答树)

实际上,在生成全排列的时候实际上我们也隐含的生成了一棵全排列树。
例如当n=4时,S={1,2,3,4},则我们有以下全排列树:
在这里插入图片描述
上树第0层有n个子节点,第1层有n-1个子节点,…,第n层没有子节点,全是叶子结点,而每个叶子结点对应于一个排列,共有 n ! n! n! 个叶子。
而上树一共有多少个结点了?(包含内部子节点和叶子结点)
我么直到第 k k k 层有 n ( n − 1 ) . . . ( n − k ) = n ! / ( n − k − 1 ) ! n(n-1)...(n-k) = n! / (n-k-1)! n(n1)...(nk)=n!/(nk1)!个子节点,则所有结点之和为:
在这里插入图片描述
由泰勒展开公式:
lim ⁡ x → ∞ ∑ k = 0 n − 1 1 k = e \lim_{x \to \infty} \sum_{k=0}^{n-1} \frac{1}{k} = e xlimk=0n1k1=e 则有 T ( n ) ≤ n ! e = O ( n ! ) T(n) \leq n! e = O(n!) T(n)n!e=O(n!)
由于叶子结点有 n!个,倒数第二层也有 n! 个结点,因此上面各层全部加起来也不到 n!。

  • e ≈ 2.718
  • 倒数第二层到最后一层的递归只剩下一个元素可以选,所以结点数相同。

因此,我们能得到一个重要结论:
排列数的结点主要集中在最后两层,和它们相比,上面的结点数目可以忽略不计。

5.可重集的全排列

如果问题不是生成1-n的全排列,而是任意数组P的全排列,则我们需要对方法123的代码进行修改,大致上来说,我们也可以先生成1-n的全排列,然后当成数组索引来生成p的全排列。但是对于含有重复元素的集合可能会生成重复排列,例如对于集合{1,1,1},第一次我们枚举第一个1,第二次我们枚举第二个1,第三次我们枚举第三个1,实际上结果都是相同的。
所以,我们 先进行排序,然后再全排列,注意去重:

void permutation1(int n, int* A,int* P, int index) {
	/*
	** A表示生成的排列序列,n表示排列的元素个数,
	** P表示输入的需要排列的集合,index表示当前生成元素的位置
	*/
	if (index == n) {
		for (int i = 0; i < n; i++) cout << A[i] << " ";
		cout << endl;
		return;
	}
	for (int i = 0; i < n; i++) {
		if (!i || P[i] != P[i - 1]) {// 避免重复元素的冗余递归
			int c1 = 0, c2 = 0; // 统计该元素在A和P中出现的次数
			for (int j = 0; j < index; j++) if (A[j] == P[i]) c1++;
			for (int j = 0; j < n; j++) if (P[j] == P[i]) c2++;
			if (c1 < c2) {
				A[index] = P[i];
				permutation1(n, A, P, index + 1);
			}
		}// if
	}// for
}
void permutation2(int n, int* A, int* P, int* vis, int index) {
	if (index == n) {
		for (int i = 0; i < n; i++) cout << A[i] << " ";
		cout << endl;
		return;
	}
	int last = -1; // 表示上一次枚举的元素
	for (int i = 0; i < n; i++) {
		if (vis[i] == 0 && P[i] != last) {
			vis[i] = 1;
			A[index] = P[i];
			last = A[index];
			permutation2(n, A, P, vis, index + 1);
			vis[i] = 0;
		}
	}
}
6. STL中的下一个排列函数

在C++的STL的<algorithm>中也有一个取全排列的函数,其用法如下:

  • 先对集合进行排序;
  • 然后不断调用next_permutation(a.begin(),a.end())来取下一排列,当排列完成后,返回false;否则,就地修改数组A表示下一排列;
  • 适用于可重复集合。
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;

int main() {
	int num; // EOF表示结束
	vector<int> A;
	while (cin >> num) A.push_back(num);
	sort(A.begin(), A.end());
	do {
		for (int i = 0; i < A.size(); i++) cout << A[i] << " ";
		cout << endl;
	} while (next_permutation(A.begin(), A.end()));
	return 0;
}
  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值