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

全排列问题

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

  • n = l n=l n=l 时, p e r m ( R ) = ( r ) perm(R)=(r) perm(R)=(r),其中 r 是集合 R 中唯一的元素;
  • n > 1 n>1 n>1 时, p e r m ( R ) perm(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) (r1)perm(R1),(r2)perm(R2),...,(rn)perm(Rn) 构成。

分析:

对于 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]\} {ar[0],ar[1],ar[2]}

其中,我们规定, a r [ i ] ar[i] ar[i] 为每次递归提取的数, ( i , m ] (i, m] (i,m]区间内为集合剩余元素 。核心算法:在递归内使用 循环+交换 的方式,在每次递归时分别把每个元素提取到 a r [ i ] ar[i] ar[i] 位置,使 ( i , m ] (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;
}

2021/8/20更新…
STL库中的全排列函数:

    string S("cba");
    sort(S.begin(), S.end(), less<char>());  // 注这里需要升序排序 --> abc
    do {
        cout << S << " ";
    } while (next_permutation(S.begin(), S.end()));
    cout << endl;
    /*
    *   输出:abc acb bac bca cab cba
    */

需要注意的是这里如果 S=“cba”,那么只会输出一个结果,所以需要排序。

next_permutation的函数声明:#include <algorithm>
bool next_permutation( iterator start, iterator end);

  • next_permutation()函数功能是输出所有比当前排列大的排列,顺序是从小到大。
  • prev_permutation()函数功能是输出所有比当前排列小的排列,顺序是从大到小。

求子集问题

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

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

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

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

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

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

如:

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

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

类比我们学过的一种数据结构——二叉树。二叉树只有左右结点,其中满二叉树除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树。并且,满二叉树的最后一层节点个数为 2 n 2^n 2n 个,其中 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 背包等经典算法。

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

——学习路上,你我共勉



2021/12/5更新…

上述代码中都使用的是指针传参,如果我们想使用容器也是可以的,我们只需要将函数头部的参数类型修改一下即可。

void Perm(vector<int>& ar, int i, int m);
void subset(const vector<int>& ar, vector<bool>& br, int i, int n)

另外,在C++中的 <algorithm> 库中,有个next_permutation函数,我们可以借助它简化我们的全排列。

template< class BidirIt >
bool next_permutation( BidirIt first, BidirIt last );// 按照operator<
template< class BidirIt, class Compare >
bool next_permutation( BidirIt first, BidirIt last, Compare comp );

Permutes the range [first, last) into the next permutation, where the set of all permutations is ordered lexicographically with respect to operator< or comp. Returns true if such a “next permutation” exists; otherwise transforms the range into the lexicographically first permutation (as if by std::sort(first, last, comp)) and returns false.
——https://en.cppreference.com/w/cpp/algorithm/next_permutation

next_permutation函数会在给定的范围 [first, last) 中,将所有的元素按照 operator< 或 comp 的比较方式以字典序进行重新排列。

  • 若新排列按字典序大于旧者则返回 true 。
    例如 abc 重排列后为 bac ,bac 的字典序大于 abc
  • 若抵达最后,重排并重置范围为首个排列则为 false 。
    例如 cba 为重排列后字典序最大的,再进行一次next_permutation后,会被重置为初始状态,即 abc。

示例:对字符串进行全排列。
需要注意的是,next_permutation会进行全排列的条件是,存在排列后的字典序大于当前字典序的情况、因此,我们需要保证排列前,我们的字典序最小。

#include <algorithm>
#include <string>
#include <iostream>

int main()
{
    std::string s = "cab";
    std::sort(s.begin(), s.end(), less<char>());
    do {
        std::cout << s << '\n';
    } while (std::next_permutation(s.begin(), s.end()));
}

/* 输出
abc
acb
bac
bca
cab
cba*/

示例2:如果不存在可能的大于当前字典序的情况,则返回初始状态。

#include <algorithm>
#include <string>
#include <iostream>

int main()
{
    std::string s = "cab";
    std::sort(s.begin(), s.end(), greater<char>());
    do {
        std::cout << s << '\n';
    } while (std::next_permutation(s.begin(), s.end()));
}
/* 输出
cba*/

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我叫RT

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值