数据结构与算法 | 【分治策略 || 排列树 & 子集树】——全排列、求子集问题...

全排列问题

R = { r 1 , r 2 , . . .   r n } R=\{r_1,r_2,... \ r_n\} 是要进行排列的n个元素, R i = R { r i } R_i=R-\{r_i\} 。集合X中元素的全排列记为 p e r m ( X ) perm(X) ( r 1 ) p e r m ( X ) (r_1)perm(X) 表示在全排列 p e r m ( X ) perm(X) 的每一个排列前加上前缀 r i r_i ,得到的排列。 R R 的全排列可归纳定义如下:

  • n = l n=l 时, p e r m ( R ) = ( r ) perm(R)=(r) ,其中 r 是集合 R 中唯一的元素;
  • n > 1 n>1 时, p e r m ( R ) perm(R) ( r 1 ) p e r m ( R 1 ) , ( r 2 ) p e r m ( R 2 ) , . . . , ( r n ) p e r m ( R n ) (r_1)perm(R_1) , (r_2)perm(R_2) , ... , (r_n)perm(R_n) 构成。

分析:

对于 Ri = R-{ri}分析:
设 R = {1,2,3}   n = 3, 则有:
R1 = r-{r1} = {2,3}   R2 = R-{r2} = {1,3}   R3 = R-{r3} = {1,2}

对于 {1,2,3}的全排列有:
	1 2 3 
	1 3 2
	2 1 3
	2 3 1
	3 2 1
	3 1 2

依此递归定义,可设计产生 perm(R) 的递归过程:

						{ 1 , 2 , 3 }						初始集合 {1,2,3}
					   /      |      \
					 /        |        \
			 (1)p{2,3}    (2)p{1,3}   (3)p{1,2}				每次从中取一个数据
	       /      |        |     |       |      \
	      /       |        |     |       |       \  
	  (2)p{3}  (3)p{2} (1)p{3} (3)p{1}  (1)p{2}  (2)p{1} 	再次在前一次的基础上取一个数据
	     |        |        |     |       |         |
	     |        |        |     |       |         |
	     3        2        3     1       2         1		直至该集合只剩一个元素
	     ↓        ↓        ↓     ↓       ↓         ↓
	     ↓        ↓        ↓     ↓       ↓         ↓
	【1,2,3】 【1,3,2】【2,1,3】【2,3,1】【3,1,2】【3,2,1】	按照每次取出的数据顺序,形成排列

递归算法设计:

设有 ar = {1,2,3} ,设计递归函数 Perm(ar,i,m) ,其中 i 待提取元素的下标,m 为集合下标的最大值max_index。

  • 第一层递归,提取 (ri)Perm{Ri} 。格式为 (ar[0])Perm(ar,0,2)
  • 第二层递归,提取 (ri)Perm{Ri} 。格式为 (ar[1])Perm(ar,1,2)
  • 第三层递归,提取 (ri)Perm{Ri} 。格式为 (ar[2])Perm(ar,2,2)
  • 得到序列 { a r [ 0 ] , a r [ 1 ] , a r [ 2 ] } \{ar[0],ar[1],ar[2]\}

其中,我们规定, a r [ i ] ar[i] 为每次递归提取的数, ( i , m ] (i, m] 区间内为集合剩余元素 。核心算法:在递归内使用 循环+交换 的方式,在每次递归时分别把每个元素提取到 a r [ i ] ar[i] 位置,使 ( i , m ] (i, m] 区间内的元素继续下一次递归,直至集合内只剩一个元素。

#include<iostream>
using namespace std;

void Swap(int& a, int& b)
{
	int c = a;
	a = b;
	b = c;
}

void Perm(int *ar,int i,int m)
{
	if (i == m)	// 只剩一个元素,打印{ar[0],ar[1],ar[2]}
	{
		for (int k = 0; k <= m; ++k)
		{
			cout << ar[k] << " ";
		}
		cout << endl;
	}
	else
	{
		for (int k = i; k <= m; ++k)	// 使用循环,保证 1,2,3 都被提取一次
		{
			/*
				ar[i] 的位置是被提取的位置
				在第一次递归时,提取ar[0],第二次ar[1],第 ...
				因此,分别把集合中的每个元素放在提取位,使之被提取出集合
			*/
			Swap(ar[i], ar[k]);
			Perm(ar, i + 1, m);	// 提取 i~m 之间的元素
			Swap(ar[i], ar[k]);
		}

	}
}


int main()
{
	int ar[] = { 1,2,3 };
	int n = sizeof(ar) / sizeof(ar[0]);
	Perm(ar,0,n-1);
	return 0;
}
求子集问题

基本性质:
非空集合A中含有n个元素, A = { 1 , 2 , 3 , . . .   . . . n } A=\{1,2,3, ...\ ... n\} ,则

  • A的子集个数为 2 n 2^n
  • A的真子集的个数为 2 n 1 2^n-1
  • A的非空子集的个数为 2 n 1 2^n-1
  • A的非空真子集的个数为 2 n 2 2^n-2

举个栗子:
A={1,2,3},则他的子集有:

  • 特殊元素:φ
  • 一位元素:{1}、{2}、{3}
  • 二位元素:{1,2}、{1,3}、{2,3}
  • 三位元素:{1,2,3}

子集数: 2 3 = 8 2^3=8
真子集数: 2 3 1 = 7 2^3-1=7 ,没有 {1,2,3}
非空子集数: 2 3 1 = 7 2^3-1=7 ,没有 φ
非空真子集数: 2 3 2 = 6 2^3-2=6 ,没有 {1,2,3} 和 φ

算法分析:
通过观察子集与集合本身的特点,我们发现子集其实是集合本身某一元素的缺失。

如:

  • 集合{1,2,3}==> 子集{1,2},缺失 3,或者说只存在 1,2
  • 集合{1,2,3}==> 子集{1},缺失 2,3,或者说只存在 1

因此,我们发现集合中每个元素的属性只用两种,要么出现,要么不出现。

类比我们学过的一种数据结构——二叉树。二叉树只有左右结点,其中满二叉树除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树。并且,满二叉树的最后一层节点个数为 2 n 2^n 个,其中 n 为树的深度。

结合以上两者的特点,做出如下分析:

1表示出现,0表示隐藏
0 0 0   		 	    φ
0 0 1					3
0 1 0					2
0 1 1					2 3
1 0 0					1
1 0 1					1 3
1 1 0					1 2
1 1 1					1 2 3

满二叉树:

0
1
0
1
0
1
0
1
0
1
0
1
0
1
A
A
B
B
B
B
C
C
C
C
C
C
C
C
000
001
010
011
100
101
110
111

算法设计:

生成满二叉树算法。代码分析请看:【递归调用陷阱】

void fun(int i, int n)
{
	if (i >= n)
	{
	}
	else
	{
		fun1(i + 1, n);	// 左子树
		fun1(i + 1, n);	// 右子树
	}

}

使用数组 br[] 标记二叉树的左右的编码。

代码实现如下:

#include <iostream>
using namespace std;

void subset(int *ar,int *br,int i, int n)
{
	if (i >= n)
	{
		int i = 0;
		while (i < n)
		{
			if(br[i] == 1)
				cout << ar[i] << " ";
			i++;
		}
		cout << endl;
	}
	else
	{
		br[i] = 0;		/* 左边记为0  */
		subset(ar, br, i + 1, n);	/* 进入左孩子 */
		br[i] = 1;		/* 右边记为1 */
		subset(ar, br, i + 1, n);	/* 进入右孩子 */

	}
}

int main()
{
	int ar[] = { 1,2,3 };	
	int br[] = { 0,0,0 };
	subset(ar, br, 0, 3);
	return 0;
}

本次我们使用递归的方式完成了全排列,和求子集的问题。如果,为了追求效率我们还可以使用循环的方式去设计算法。

在设计全排列递归实现时,我们使用了排列树进行实现。在设计子集问题的递归实现时,我们使用了子集树进行实现。其中排列树和子集树正如他们的命名一般,前者是对不同元素的排列组合,后者是对不同元素的取舍

排列树和子集树在很多金典算法中都有涉及。如,排列数可以用来解决图的最短路径问题,子集树可以用来解决如01背包的n个物品中若干取值的最优解问题。

本章通过全排列问题和求子集问题粗浅的了解了排列树和子集树,后续我将继续分享两种问题的非递归实现方法,以及 01 背包等经典算法。

最后,如果觉得我的文章对你有帮助的话请帮忙点个赞,你的鼓励就是我学习的动力。如果文章中有错误的地方欢迎指正,有不同意见的同学也欢迎在评论区留言,互相学习。

——学习路上,你我共勉
原创文章 131 获赞 128 访问量 3万+

猜你喜欢

转载自blog.csdn.net/weixin_43919932/article/details/105240646