全排列算法(Full Permutation)

2415 字
12 分钟
全排列算法(Full Permutation)

定义#

给定无重复元素序列 {an}\{a_n\},即满足:

1i,jnijaiaj,1 \leq i,j \leq n \land i \neq j \Rightarrow a_i \neq a_j,

从中任取 mmmnm \leq n)个元素并按顺序排成一列,得到的所有结果称为排列(Permutation / Arrangement)。当 m<nm < n 时,排列数为

Anm=n!(nm)!A_n^m = \frac{n!}{(n-m)!}

m=nm = n 时称为全排列(Full Permutation),排列数为

Ann=n!A_n^n = n!

全排列的实现可分为递归非递归两类。下文示例默认序列元素为 1,,n1, \cdots, n 的整数;换成字符等其它可比较类型亦可。

递归实现#

1. 交换法#

Ann=nAn1n1A_n^n = n \cdot A_{n-1}^{n-1}:将 {an}\{a_n\} 中每个元素依次固定到某一位置(通常是当前位置 kk),再对剩余 n1n-1 个元素求全排列,直到只剩一个元素。

{1,2,3}\{1,2,3\} 为例,先固定首位:

交换结果
a0a0a_0 \leftrightarrow a_01,2,31,2,3
a0a1a_0 \leftrightarrow a_12,1,32,1,3
a0a2a_0 \leftrightarrow a_23,2,13,2,1

再对每个结果的后两位继续交换,最终得到全部 3!=63! = 6 种排列。

步骤:

  1. 固定位索引 k=0k = 0
  2. 对所有 i[k,n)i \in [k, n),交换 aka_kaia_i,再递归处理 k+1k+1
  3. 递归返回后交换回来(回溯);
  4. k=n1k = n-1 时输出一种排列。
#include <iostream>
using namespace std;
int n;
void swap(int *a, int *b) {
*a ^= *b;
*b ^= *a;
*a ^= *b;
}
void permutation(int array[], int k) {
if (k == n - 1) {
for (int i = 0; i < n; i++) {
cout << array[i] << ' ';
}
cout << endl;
} else {
for (int i = k; i < n; i++) {
swap(&array[i], &array[k]);
permutation(array, k + 1);
swap(&array[i], &array[k]);
}
}
}
int main() {
cin >> n;
int a[n];
for (int i = 0; i < n; ++i) {
a[i] = i + 1;
}
permutation(a, 0);
return 0;
}

注意 即便输入已是字典序,交换法得到的输出顺序一般也不是字典序

复杂度: 递归会到达 n!n! 个叶结点;若每次输出花费 O(n)O(n),生成全部全排列的时间为 O(nn!)O(n \cdot n!)。递归深度为 O(n)O(n),除输入数组外额外栈空间为 O(n)O(n)

2. 递归生成树法#

另一视角:Ann=k=1nCk1A_n^n = \prod_{k=1}^{n} C_k^1。维护一个栈:每次从剩余元素中取一个入栈,递归;返回后出栈并把元素放回。对序列 {a3}=\{a_3\} = {1,2,3}$ 可画出递归生成树,根到叶的每条路径对应一种排列:

S

1

2

3

2

3

1

3

1

2

3

2

3

1

2

1

123

132

213

231

312

321

S

1

2

3

2

3

1

3

1

2

3

2

3

1

2

1

123

132

213

231

312

321

vector 容器同时表示剩余序列结果栈,即可实现递归生成树法:

#include <iostream>
#include <vector>
using namespace std;
vector<int> res;
vector<int> a;
int n;
void permutation(int k) {
if (k == 0) {
for (int i = 0; i < n; ++i) {
cout << res[i] << ' ';
}
cout << endl;
} else {
for (int i = 0; i < k; ++i) {
res.push_back(a[i]);
a.erase(a.begin() + i);
permutation(k - 1);
a.insert(a.begin() + i, res.back());
res.pop_back();
}
}
}
int main() {
cin >> n;
for (int i = 0; i < n; ++i) {
a.push_back(i + 1);
}
permutation(n);
return 0;
}

vectorerase / insert 会使后续元素整体移动,常数较大;可用链表降低移动开销,但实现更繁琐。生成全部排列并输出时,时间仍至少是 O(nn!)O(n \cdot n!) 量级,额外空间 O(n)O(n)

非递归实现#

3. 穷举法#

穷举法的思路与递归生成树类似:用 nn 层嵌套循环依次选位置,并用条件排除重复。但其缺陷也很明显:代码的循环层数绑死 nn,一般只适用于 nn 固定的情况。

n=5n = 5 时示意代码如下:

#include <iostream>
using namespace std;
int n = 5;
int main() {
int a[n];
for (int i = 0; i < n; ++i) {
a[i] = i + 1;
}
for (int i1 = 0; i1 < n; ++i1) {
for (int i2 = 0; i2 < n; ++i2) {
if (a[i1] == a[i2]) continue;
for (int i3 = 0; i3 < n; ++i3) {
if (a[i1] == a[i3] || a[i2] == a[i3]) continue;
for (int i4 = 0; i4 < n; ++i4) {
if (a[i1] == a[i4] || a[i2] == a[i4] || a[i3] == a[i4]) continue;
for (int i5 = 0; i5 < n; ++i5) {
if (a[i1] == a[i5] || a[i2] == a[i5] || a[i3] == a[i5] || a[i4] == a[i5]) continue;
cout << a[i1] << ' ' << a[i2] << ' ' << a[i3] << ' '
<< a[i4] << ' ' << a[i5] << endl;
}
}
}
}
}
return 0;
}

若输入无重复且按字典序给出,输出也会按字典序。嵌套循环的搜索规模约为 O(nn)O(n^n) 量级,额外空间 O(n)O(n)

4. 字典序法(next_permutation / prev_permutation#

n!n! 种全排列可排成字典序序列 {Pk}\{P_k\}

  1. P1P_1:原序列的字典序最小排列;
  2. Pn!P_{n!}:反字典序(字典序最大);
  3. PkP_k 可由 Pk1P_{k-1} 通过求下一个排列得到。

例如 {A,B,C,D,E,F}\{A,B,C,D,E,F\}P1=ABCDEFP_1 = ABCDEFP2=ABCDFEP_2 = ABCDFE,…,P720=FEDCBAP_{720} = FEDCBA

C++ <algorithm> 提供 next_permutation / prev_permutation。常见用法:

  • sort 成升序,再循环 next_permutation 直到返回 false
  • 或先变成降序,再循环 prev_permutation
  • 也可从任意排列双向扫完所有排列。
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;
void print(const string &s) {
for (char c : s) {
cout << c << ' ';
}
cout << endl;
}
void LFP(string str) { // Lexicographical Full Permutation
sort(str.begin(), str.end());
do {
print(str);
} while (next_permutation(str.begin(), str.end()));
}
void RLFP(string str) { // Reverse Lexicographical Full Permutation
sort(str.begin(), str.end());
reverse(str.begin(), str.end());
do {
print(str);
} while (prev_permutation(str.begin(), str.end()));
}
int main() {
string str;
cin >> str;
LFP(str);
RLFP(str);
return 0;
}

next_permutation 步骤(有重复元素时用 \leq / \geq 亦可):

  1. 从右往左找最大下标 kk,使 ak<ak+1a_k < a_{k+1}(找不到则已是最后一项);
  2. 从右往左找最大下标 j>kj > k,使 ak<aja_k < a_j
  3. 交换 aka_kaja_j
  4. 将区间 [k+1,n)[k+1, n) 反转,得到下一个字典序排列。

prev_permutation 将上述比较方向反过来即可。对应实现如下:

#include <iostream>
using namespace std;
void swap(int *a, int *b) {
*a ^= *b;
*b ^= *a;
*a ^= *b;
}
int main() {
int n;
cin >> n;
int a[n];
for (int i = 0; i < n; ++i) {
a[i] = i + 1;
}
while (true) {
for (int i = 0; i < n; i++) {
cout << a[i] << ' ';
}
cout << endl;
int k = n - 2;
int j = n - 1;
// prev_permutation:把 >= 改成 <=
for (; k >= 0 && a[k] >= a[k + 1]; --k) {}
if (k == -1) {
break;
}
for (; j > k && a[k] >= a[j]; --j) {}
swap(&a[j], &a[k]);
for (int i = 0; i < (n - 1 - k) / 2; ++i) {
swap(&a[k + 1 + i], &a[n - 1 - i]);
}
}
return 0;
}

单次 next_permutationO(n)O(n),枚举全部为 O(nn!)O(n \cdot n!),额外空间 O(1)O(1)(原地)。

5. Steinhaus–Johnson–Trotter(SJT)算法#

SJT 又称最小改动排列生成算法,其通过相邻交换依次得到下一个排列:为每个元素维护移动方向;每次找出可合法移动的最大元素,与其方向上的相邻元素交换,并翻转所有更大元素的方向,直到没有元素可移动。其具体实现如下:

#include <iostream>
using namespace std;
void swap(int &a, int &b) {
a ^= b;
b ^= a;
a ^= b;
}
void print(int a[], int n) {
for (int i = 0; i < n; ++i) {
cout << a[i] << ' ';
}
cout << endl;
}
int main() {
int n;
cin >> n;
int a[n], d[n];
for (int i = 0; i < n; ++i) {
a[i] = i + 1;
d[i] = -1; // -1 向左,+1 向右
}
bool flag = true;
while (flag) {
flag = false;
print(a, n);
int mx = 0;
int max_index = 0;
for (int i = 0; i < n; ++i) {
if ((d[i] < 0 && i > 0 && a[i] > a[i - 1]) ||
(d[i] > 0 && i < n - 1 && a[i] > a[i + 1])) {
if (a[i] > mx) {
mx = a[i];
max_index = i;
flag = true;
}
}
}
if (!flag) {
break;
}
swap(a[max_index], a[max_index + d[max_index]]);
swap(d[max_index], d[max_index + d[max_index]]);
for (int i = 0; i < n; ++i) {
if (a[i] > mx) {
d[i] = -d[i];
}
}
}
return 0;
}
IMPORTANT

初始为字典序时,SJT 的生成顺序通常也不是字典序。时间 O(nn!)O(n \cdot n!),空间 O(n)O(n)(含方向数组)。

应用:填数问题#

全排列本质是穷举。若必须枚举全部排列,时间至少 O(nn!)O(n \cdot n!),只适合规模较小的问题。在解题经验上,时限约 1000ms1000\,\mathrm{ms}O(n!)O(n!) 量级时,n11n \leq 11 是一般而言可行的规模上限;若剪枝得当有时 nn 可到 12121313

例题: 2016 年第七届蓝桥杯省赛 C 语言 A 组第 3 题——在如下十格填入 0099(各用一次),要求任意两个连续整数(差的绝对值为 11)所在格不能相邻(含斜向)。

十格填数示意图

该问题本质是图上的约束计数,一般用 DFS 来解决。但考虑到本题的规模,也可先求全排列,再通过特殊的判定条件筛选出满足条件的排列。下面用字典序法枚举所有排列并逐个判断是否满足条件。

#include <iostream>
#define n 10
#define abs(a) ((a) > 0 ? (a) : -(a))
using namespace std;
void swap(int *a, int *b) {
*a ^= *b;
*b ^= *a;
*a ^= *b;
}
int main() {
int *a = new int[n];
for (int i = 0; i < n; ++i) {
a[i] = i;
}
int count = 0;
while (true) {
if (abs(a[0] - a[1]) != 1 && abs(a[1] - a[2]) != 1 &&
abs(a[3] - a[4]) != 1 && abs(a[4] - a[5]) != 1 && abs(a[5] - a[6]) != 1 &&
abs(a[7] - a[8]) != 1 && abs(a[8] - a[9]) != 1 &&
abs(a[0] - a[4]) != 1 && abs(a[1] - a[5]) != 1 && abs(a[2] - a[6]) != 1 &&
abs(a[3] - a[7]) != 1 && abs(a[4] - a[8]) != 1 && abs(a[5] - a[9]) != 1 &&
abs(a[0] - a[5]) != 1 && abs(a[1] - a[6]) != 1 &&
abs(a[3] - a[8]) != 1 && abs(a[4] - a[9]) != 1 &&
abs(a[0] - a[3]) != 1 && abs(a[1] - a[4]) != 1 && abs(a[2] - a[5]) != 1 &&
abs(a[4] - a[7]) != 1 && abs(a[5] - a[8]) != 1 && abs(a[6] - a[9]) != 1) {
count++;
}
int k = n - 2, j = n - 1;
for (; k >= 0 && a[k] > a[k + 1]; --k) {}
if (k == -1) {
break;
}
for (; j > k && a[k] > a[j]; --j) {}
swap(&a[j], &a[k]);
for (int i = 0; i < (n - 1 - k) / 2; ++i) {
swap(&a[k + 1 + i], &a[n - 1 - i]);
}
}
cout << count << endl;
delete[] a;
return 0;
}

答案为 1580

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

全排列算法(Full Permutation)
https://blog.scxs-studio.com/posts/full-permutation/
作者
R. Z.
发布于
2019-05-06
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
R. Z.
Suffering from acute coke overdose
分类
标签
站点统计
文章
7
分类
4
标签
16
总字数
57,527
运行时长
0
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.3
文章许可
CC BY-NC-SA 4.0