【搜索】其一 排列、组合、子集生成

搜索是基础的编程技术,是基础中的基础,非常非常重要,而且也有难度。需要认真学习。
搜索体现了暴力法的思想,有很多用途:

  1. 很多问题只能用暴力搜索解决,如猜密码。
  2. 对小规模的数据,完全足够,而且简单不易出错。
  3. 暴力法往往用于参照,可以从它出发,逐步思考更高级的算法,而且在生成测试数据进行对拍上面很有用。
  4. 暴力法往往可以进行优化,使用剪枝跳过不符合要求的情况,减少搜索空间。

搜索的主要技术是枚举、排列组合、DFS和BFS,搭配上栈、队列、树、图等数据结构,相应的有递归的实现。

这是【搜索技术】的第一篇文章,可能会有第零篇是枚举。第二篇是BFS,第三篇是DFS、回溯和剪枝。之后会介绍高级搜索技术——如A*,双向广搜,迭代加深搜索,IDA*等。

一、递归生成排列

1. next_permutation 【字典序】

首先,我们要掌握的是排列的生成。排列有两种情况:

  1. 打印n个数据的全排列,排列总数n*(n-1)*...*1=n!
  2. 从n个数据中任意选出m个数据进行全排列,有n*(n-1)*...*(n-m+1)=n!/(n-m)!种情况。

由于暴力法总是需要考虑到所有的情况,而用递归编程往往可以轻松实现这一点,因此我们考虑用递归实现上面的两种函数。

先用STL的next_permutation实现全排列的打印,事先用sort排序得到最小排列,然后用next_permutation可以从小到大得到所有排列,优点在于是现成的函数,按照字典序

关于next_permutation的实现解析,见STL next_permutation和prev_permutation的原理和具体实现

#include <iostream>
#include <algorithm>
using namespace std;

int main() {
	int data[] = {5, 2, 1, 4};
	sort(data, data + 4);
	do {
		for (int i = 0; i < 4; ++i) 	
		    cout << data[i] << " ";
		cout << endl;
	} while (next_permutation(data, data + 4));
	return 0;
}

2. 递归-交换法

(a). 全排列

但是重点在于下面的★★★ 递归求全排列

思路如下:设数字为a, b, c, d, e, ..., z共有\(N\)个数字,那么就是\(N!\)种情况,第一个数字有N种情况,第二个有N-1种情况,依次类推,那我们如何表达所有可能的数据呢?

(1). 让第一个位置的数变换出N种情况,下面的实现方法是将第一个数a和自己及其后的所有数字进行交换。由于第一个数不同,所以这些N个排列毫无疑问都是不同的!

a b c d e ... z  层1
b a c d e ... z  层2
c b a d e ... z
...
z b c d e ... a

(2). 让第二个位置的数变化出N-1种情况,即将第二个数和自己及其后的所有数字进行交换,这样第二个数就不同了,由此产生的N-1个排列都不同。

// 从层1递归进入,因此第一个数字都是a
a b c d e ... z 
a c b d e ... z
a d c b e ... z 
a e c d b ... z
...
a z c d e ... b
// 从层2递归进入,因此第一个数字都是b
b a c d e ... z
b c a d e ... z
b d c a e ... z
b e c d a ... z
...
b z c d e ... a

如此继续重复以上步骤,每次都会减少数字的规模,直到用完所有数字。

#include <iostream>
using namespace std;
// 排列
int data[] = {1, 2, 3, 4, 5, 6, 8, 9, 10, 32, 15, 18, 33}; // 求排列的数据 

#define Swap(a, b) { int temp = a; a = b; b = temp; } 
int num = 0; // 排列的个数
/* 全排列 */
void fullPermutation(int begin, int end) { 
	//之所以begain==end表示结束,是因为对最后一层,只有一个数据,不用进行交换
	if (begin == end) { // 这个排列包含了所有数据
		++num;  // 排列计数 
	    for (int i = 0; i <= 9; ++i)  // 输出这个排列 
			cout << data[i] << " ";
		cout << endl;
	} else {
		for (int i = begin; i <= end; ++i) {
			Swap(data[begin], data[i]); // 第1个数n种情况,与后面交换形成这n种情况...以此类推 
			fullPermutation(begin + 1, end);
			Swap(data[begin], data[i]);
		}
	}
}
int main() {
	fullPermutation(0, 9); // 3628800种情况 10*9*8*7...*1  
	cout << num << endl;
	return 0;
} 

(b). 部分数的全排列

如果只用★★★ 打印n个数中任意m个数的全排列,就改一下上面的代码就可以了:

为什么这么改?重点是任意m个数,由于我们采用交换的方式,因此数组data中前面m个数的排列就会表现出我们需要的所有结果

当然,这样得到的部分数的排列不是按字典序的。

#include <iostream>
using namespace std; 
int data[] = {1, 2, 3, 4, 5, 6, 8, 9, 10, 32, 15, 18, 33}; // 求排列的数据 

#define Swap(a, b) { int temp = a; a = b; b = temp; } 
/* 打印n个数中任意m个数的全排列, 共n!/(n-m)!个 */
int m = 3;
void permutation(int begin, int end) {
	if (begin == m) {
		++num;
		for (int i = 0; i < m; ++i) 
			cout << data[i] << " ";
		cout << endl;
	} else {
		for (int i = begin; i <= end; ++i) {
			Swap(data[begin], data[i]);
			permutation(begin + 1, end);
			Swap(data[begin], data[i]);
		}
	}
}
int main() { 
	permutation(0, 9);       // 720种情况 10!/(10-3)!=10*9*8=720 
	cout << num << endl;
	return 0;
} 

★★★ 3. 递归-选择法【字典序】

我们很容易注意到一件事,上面的递归-交换法固然可以求出全排列,但是它得到的结果不是按照字典序的,即使输入的数据是最小的排列(即已经从小到大排序):
\(e.g.\) 用上面的例子,a b c ... z最后一类是以\(z\)开头的排列,上述的方法将\(a\)\(z\)交换,递归下去得到以\(z\)开始的第一个排列——z b c ... a;而以\(z\)开头的最小的排列应该是z a b c ... y。因此使得整个全排列不符合字典序。

(a). 全排列

那么我们有什么方法呢?下面的是我写的代码,通过选择进行。如果输入的是最小的排列,那么它会按照字典序输出全排列;如果输入的是随意的一个排列,它得到的全排列不按照字典序。

思想:假设输入的是最小的排列。每次函数调用都有一个\(for\)循环,\(i=0\),从\([i, end]\)的全部数据中,从0开始选择一个以前没选择的数据(这就是\([i, end]\)区间没有被选择的数据中最小的数),进入下一个递归。这样第一个得到的就是输入的排列。
\(e.g.\) 第一个先是选择了\(1\),得到了\(1\)开头的所有排列;然后选择\(2\),第二层又选择\(1\)......,得到\(2\)开头的所有排列;...

如果对\(n\)个数据每个数据选择\(n\)次,最后判断得到的数列是否符合条件,那么应该是\(O(n^n) \ ?\)的复杂度;如果是每次暴力判断这个数据使用过了没有,那复杂度减少了一些,但还是爆表。

所以下面使用了一个\(vis\)的bool数组,判断这个位序上的数据是否已被使用。...

#include <iostream>
using namespace std;
// 排列
int data[] = {1, 2, 3, 4, 5, 6, 8, 9, 10, 32, 15, 18, 33}; // 求排列的数据 

#define Swap(a, b) { int temp = a; a = b; b = temp; } 
int num = 0; // 排列的个数
/* 全排列 */
bool vis[10] = {0};
vector<int> vi;
void allPermutation(int k, int end) { //这里的k表示该选择排列中位序为k的数
	if (k > end) {
		++num;
		for (int i = 0; i < vi.size(); ++i)
		 	cout << vi[i] << " ";
		cout << endl;
		return;
	}
	for (int i = 0; i <= end; ++i) {
		if (vis[i] == true) continue; //之前已经在该排列使用过了
		
		vi.push_back(data[i]);
		vis[i] = true; //标记这个位序的元素被使用了
		allPermutation(k + 1, end);
		vi.pop_back();
		vis[i] = false;
	}
}

int main() {
	allPermutation(0, 9); // 3628800种情况 10*9*8*7...*1  
	cout << num << endl; 
	return 0;
} 

使用allPermutation(0, 2)结果如下:

(b). 部分数的全排列

只打印n个数中任意m个数的全排列,改一下上面的代码就可以了,结果也是按照字典序的:

#include <iostream>
using namespace std;
// 排列
int data[] = {1, 2, 3, 4, 5, 6, 8, 9, 10, 32, 15, 18, 33}; // 求排列的数据 
#define Swap(a, b) { int temp = a; a = b; b = temp; } 
int num = 0; // 排列的个数
 
bool vis[10] = {0};
vector<int> vi;
int M = 3;
void Permutation(int k, int end) {
	if (k == M) {
		++num;
		for (int i = 0; i < vi.size(); ++i)
		 	cout << vi[i] << " ";
		cout << endl;
		return;
	}
	for (int i = 0; i <= end; ++i) {
		if (vis[i] == true) continue;
		
		vi.push_back(data[i]);
		vis[i] = true;
		Permutation(k + 1, end);
		vi.pop_back();
		vis[i] = false;
	}
}

int main() {  
	Permutation(0, 9); // 720种
	cout << num << endl; 
	return 0;
} 

二、子集生成

不需要输出全排列,只需要输出组合,即子集(集合内部的元素是没有顺序的)。

一个包含n个元素的集合{a, b, c, d ... z},子集共有2n个,为{∮},{a},{b},... ,{a, b, c, d, ... z}。

那么,如何求子集呢?

1. 递归求子集

用递归的写法,就是对{a, b, c, d, ... , z}每个元素都有选和不选两种选择,因此对n个元素有2*2*...*2=\(2^n\)种可能,算法复杂度同上。

#include <iostream>
#include <vector> 
using namespace std;

vector<int> subset;  
void recurSubset(int begin, int end) {
	if (begin > end) {
		for (auto i : subset) 
			cout << i << " ";
		cout << endl;
		return;
	} 
	subset.push_back(begin); // 选择这一元素
	recurSubset(begin + 1, end);
	
	subset.pop_back(); // 不选
	recurSubset(begin + 1, end);  
}
 	
int main() { 
	recurSubset(0, 3); // 16种情况 
	return 0;
} 

★★★ 2. 迭代求子集

迭代的写法,用n和二进制对应就很直观了。如n=3和{a, b, c}:

子集
二进制数 0 0 0 0 0 1 0 1 0 0 1 1 1 0 0 1 0 1 1 1 0 1 1 1

因此,每个子集对应一个二进制数,二进制数的每个1都对应这个子集中的一个元素,而且子集中的元素没有什么顺序。通过检查每个二进制数中的1打印相关的元素可以得到所有的子集。由于有\(2^n\)个子集,因此算法的复杂度为\(O(2^n)\)

无疑,我更喜欢★★★迭代生成子集的写法,简单。·

#include <iostream>
#include <vector> 
using namespace std;
	
void printSubset(int n) {
	int total = (1 << n);
	for (int i = 0; i < total; ++i) { //2^n个子集 
		for (int j = 0; j < n; ++j)  // 每个集合要遍历n个元素, 看是否含有这个元素 
			if (i & (1 << j)) cout << j << " ";
		cout << endl;
	} 
}

int main() { 
	printSubset(4); // 16种情况 
	return 0;
} 


三、组合生成

从n个数据中任意选出m个数据进行组合,有n*(n-1)*...*(n-m+1)/m!=n!/[(n-m)!*m!]种情况。从全排列到任意m个数的排列,再到任意m个数的组合,解的数量越来越小。组合的总数之所以要除以m!,是因为这m个数会形成\(m!\)个排列,这些排列都是一样的组合。

我们思考一下该怎么做。从排列的两种代码改是肯定不可能的...emm,也不是不可能,在Permutation的基础上用set<vector<int>>保存每一个大小为m的排列,当然,这些排列都先进行排序,...,最后剩下的就是需要的组合了。这也可以用剪枝保持每m个数组合有序,从而选出来,当然,需要原始数据事先进行排序。

1. 递归求组合【DFS+剪枝】

第一种代码就是这样了。DFS+剪枝,剪去无序的组合。

#include <iostream>
#include <vector> 
using namespace std;

int info[] = {2, 4, 5, 7, 9, 12}; // 15种情况 
vector<int> c;
int t = 4;
void Combination(int begin, int end) {
	if (c.size() == t) {
		for (auto i : c) 
			cout << i << " ";
		cout << endl;
		return;
	}
	for (int i = begin; i <= end; ++i) {
		if (!c.empty() && info[i] < c[c.size() - 1])
	        continue;
		c.push_back(info[i]);
		combination(i + 1, end); // i + 1, 这样不会重复自己
		c.pop_back();
	}
}

2. 递归求组合【递归求子集基础上】

第二种方法,和上面的DFS版本是同样的递归思想,但是出发点不同。

我们发现子集中元素是无序的,而且n个数中任意k个数的组合就是n个数的一个大小为k的子集

为了看清楚做个示例:对于int info[] = {2, 4, 5, 7, 9, 12};,其子集如下,标红的就是要求的组合。

因此我们在上面打印子集的代码上面修改一下就可以了,从递归求子集的代码修改,只输出大小为k的子集。

#include <iostream>
#include <vector> 
using namespace std;
int info[] = {2, 4, 5, 7, 9, 12}; // 15种情况 

vector<int> vi;  
int k = 4;
void recurCombine(int begin, int end) {
	if (vi.size() == k) { //子集大小为k
		for (auto i : vi) 
			cout << i << " ";
		cout << endl;
		return;
	} else if (begin > end) return; //数组边界
	
	vi.push_back(info[begin]); // 选择这一元素
	recurCombine(begin + 1, end); 
	vi.pop_back(); // 不选
	recurCombine(begin + 1, end);  
}
 	
int main() { 
	recurCombine(0, 5);   
	return 0;
} 

★★★ 3. 迭代求组合【迭代求子集基础上】

这里是从迭代子集的基础上进行修改。

由于每一个子集对应一个二进制数,要找还有k个元素的子集,就转化为查找1的个数为k的二进制数。像上面一样数,就是初级版本的代码。

int k = 4;
void Combination(int n) {
	int tot = (1 << n);
	for (int i = 0; i < tot; ++i) {
		int knum = 0;
		for (int j = 0; j < n; ++j) 
			if (i & (1 << j)) ++knum;
		if (knum == k) {
			for (int j = 0; j < n; ++j) 
				if (i & (1 << j)) cout << info[j] << " ";
			cout << endl;
		}
	}
}

当然,有点麻烦,必须数过一次才知道1的个数,即n位二进制数必须数n次,可以减少一些复杂度。用i = i & (i - 1)可以直接定位二进制数中最低位的1的位置,跳过中间的0,并把这一位1消掉,每次消除一个1,操作次数是1的个数。如i = 7,三次操作就等于0:

1011 & (1011 - 1) = 1011 & 1010 = 1010
1010 & (1010 - 1) = 1010 & 1001 = 1000
1000 & (1000 - 1) = 1000 & 0111 = 0000

改写一下上面的代码,★★★求组合的模板:

void Combination(int n, int k) {
	int tot = (1 << n);
	for (int i = 0; i < tot; ++i) {
		int num = 0, kk = i; //num统计i中1的个数;kk用于处理i 
		while (kk) {
			kk = kk & (kk - 1); //清除kk中最低位的1 
			++num;              //统计1的个数 
		}
		if (num == k) {         //二进制的1有k个, 符合要求 
			for (int j = 0; j < n; ++j) 
				if (i & (1 << j)) cout << info[j] << " ";
			cout << endl;
		}
	}
}

四、总结

这篇文章花了我不少时间,总结了我写过的多种排列组合的代码。

总之,排列组合的代码是很多搜索问题的基础,也是模板。同时,排列组合也会涉及到计数问题,在数学类题目中有广泛的运用,必须掌握!

posted @ 2020-04-24 15:44  LETHARGY  阅读(168)  评论(0)    收藏  举报