算法 | 【分治策略 || 排列树 & 子集树】——全排列、求子集问题...
全排列问题
设 是要进行排列的n个元素, 。集合X中元素的全排列记为 。 表示在全排列 的每一个排列前加上前缀 ,得到的排列。 的全排列可归纳定义如下:
- 当 时,,其中 r 是集合 R 中唯一的元素;
- 当 时, 由 构成。
分析:
对于 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)
- 得到序列
其中,我们规定, 为每次递归提取的数,区间内为集合剩余元素 。核心算法:在递归内使用 循环+交换 的方式,在每次递归时分别把每个元素提取到 位置,使区间内的元素继续下一次递归,直至集合内只剩一个元素。
#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的子集个数为。
- A的真子集的个数为
- A的非空子集的个数为
- A的非空真子集的个数为
举个栗子:
A={1,2,3},则他的子集有:
- 特殊元素:φ
- 一位元素:{1}、{2}、{3}
- 二位元素:{1,2}、{1,3}、{2,3}
- 三位元素:{1,2,3}
子集数:
真子集数: ,没有 {1,2,3}
非空子集数:,没有 φ
非空真子集数:,没有 {1,2,3} 和 φ
算法分析:
通过观察子集与集合本身的特点,我们发现子集其实是集合本身某一元素的缺失。
如:
- 集合{1,2,3}==> 子集{1,2},缺失 3,或者说只存在 1,2
- 集合{1,2,3}==> 子集{1},缺失 2,3,或者说只存在 1
因此,我们发现集合中每个元素的属性只用两种,要么出现,要么不出现。
类比我们学过的一种数据结构——二叉树。二叉树只有左右结点,其中满二叉树除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树。并且,满二叉树的最后一层节点个数为 个,其中 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
满二叉树:
算法设计:
生成满二叉树算法。代码分析请看:【递归调用陷阱】
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 背包等经典算法。
最后,如果觉得我的文章对你有帮助的话请帮忙点个赞,你的鼓励就是我学习的动力。如果文章中有错误的地方欢迎指正,有不同意见的同学也欢迎在评论区留言,互相学习。