全排列問題
設 是要進行排列的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 揹包等經典算法。
最後,如果覺得我的文章對你有幫助的話請幫忙點個贊,你的鼓勵就是我學習的動力。如果文章中有錯誤的地方歡迎指正,有不同意見的同學也歡迎在評論區留言,互相學習。