排列枚举
问题引入¶
给定 $n$ 个不同元素,需要枚举它们的所有排列顺序,并对每一种顺序执行某些操作(如计算路径长度、统计方案等)。
举例:有 $3$ 个城市 A
、B
、C
,想要计算访问顺序的成本,需要枚举以下 $6$ 种路线:
排列枚举¶
全排列的数量¶
根据乘法原理,第 $1$ 个位置有 $n$ 种选择,第 $2$ 个位置有 $n-1$ 种选择,$\cdots$,第 $n$ 个位置有 $1$ 种选择, 因此总的排列数为:
例如 $n=3$ 时共有 $3! = 6$ 种排列。
这揭示了枚举所有排列的复杂度非常之高。
STL 专属函数:next_permutation¶
在 C++ 的 <algorithm>
头文件中,提供了 next_permutation
函数,用于生成当前序列的下一个字典序排列。
bool next_permutation(BidirIt first, BidirIt last);
- 参数
[first, last)
:双向迭代器区间 $[first, last)$,表示待排列的范围。- 注意是左闭右开,用法类似于
sort
函数。
- 返回值
- 若存在 下一个 字典序排列,函数生成下一个排列并返回
true
。 - 若已是最大字典序排列,则函数会将序列重置为最小排列,并返回
false
。
- 若存在 下一个 字典序排列,函数生成下一个排列并返回
使用示例
时间复杂度:$O(n!\times n)$
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n;
cin >> n;
int a[15];
for (int i = 1; i <= n; i++) a[i] = i; // 初始化为字典序最小的排列
do
{
for (int i = 1; i <= n; i++)
cout << a[i] << " ";
cout << "\n";
} while (next_permutation(a + 1, a + n + 1));
return 0;
}
- 首次执行循环时处理最小排列 $1\sim n$。
- 每次调用
next_permutation
生成下一个排列,并决定是否继续循环。
do - while
循环要点
do {...} while(condition);
保证循环体至少执行一次。- 结合
next_permutation
,可确保最初排列(最小字典序)也被处理。
例题¶
例题一:吃奶酪¶
题意:一只老鼠从原点 $(0,0)$ 出发,依次吃完 $n$ 块奶酪。每块奶酪位于 $(x_i, y_i)$,要求最小化移动总距离。
思路:
- 将奶酪编号为 $1\sim n$,起点编号为 $0$(坐标 $(0,0)$)。
- 初始化序列
a = [1,2,…,n]
,使用do { … } while(next_permutation(a + 1, a + n + 1));
枚举所有 $n!$ 种访问顺序。 - 定义
double dis(int i, int j)
的函数,用于计算两个奶酪 $(x_i,y_i)$ 和 $(x_j,y_j)$ 的距离。
double dis(int i, int j)
{
double dx = x[i] - x[j], dy = y[i] - y[j];
return sqrt(dx * dx + dy * dy);
}
- 对当前顺序 ($a[1]..a[n]$):
- 初始化
sum = 0
。 - 遍历奶酪顺序 $a$,更新 $sum$:
- 执行 $sum\leftarrow sum+dis(a[i-1],a[i])$。
- 对移动总距离 $sum$ 取 $\min$ 即可。
- 初始化
- 时间复杂度:$O(n!\times n)$
例题二:火星人¶
题意:给定 $N$ 和 $M$,以及一个长度为 $N$ 的初始排列,代表火星人当前的 数。按字典序向后移动 $M$ 步(调用 next_permutation
$M$ 次),输出最终排列。
思路:直接执行 M
次 next_permutation(a + 1, a + N + 1)
。
例题三:环游城市¶
题意:$N$ 座城市,旅行时间矩阵 T[N][N]
,从城市 $1$ 出发恰好访问所有其他城市一次再返回 $1$,问有多少条路径总时间恰好是 $K$。
思路:
定义 $p[N]$ 维护城市编号,初始化 $p[i]=i$。由于最后还要回到城市 $1$,因此可以设置 $p[n + 1]=1$。
- 枚举城市 $2\sim N$ 的全排列 $p$。
- 只需要
next_permutation(p + 2, p + n + 1)
。注意城市 $1$ 不参与全排列。
- 只需要
- 对每个排列 $p$ 计算:
- 初始化 $sum=0$ 用来计算移动总距离。
-
累加 $T[p[i]][p[i +1]]$ 移动距离。
-
若 $sum = K$,则
ans++
。
例题四:缆车¶
题意:$n$ 名学生排队乘缆车下山,缆车最大承重 $W$,每辆车费用 $1$ 元。第 $i$ 名学生体重 $a_i$。问最少需要支付多少钱。
定义 $p[i]$ 记录学生编号,初始化 $p[i] = i$。
- 枚举学生的所有可能顺序(全排列)。
- 对每一种顺序,按顺序依次尝试上车:
- 用变量 $\texttt{sum}$ 累计当前缆车的载重。
- 初始化 $\texttt{sum} = a[\texttt{p}[1]]$,已租车辆数 $\texttt{cnt}=1$。
- 对余下每位学生 $\texttt{p}[i]$:
- 如果 $\texttt{sum} + a[\texttt{p}[i]] \le W$,则加到当前缆车:$\texttt{sum} \mathrel{+}= a[\texttt{p}[i]]$。
- 否则另租一辆:$\texttt{cnt} \mathrel{+}= 1,\;\texttt{sum} = a[\texttt{p}[i]]$.
- 记录所有顺序中最小的 $\texttt{cnt}$。
- 时间复杂度:枚举 $n!$ 种顺序,每种顺序做 $O(n)$ 装车操作,适合 $n\le10$。
设 $n=3$, $W=100$,体重数组 ${30, 80, 50}$。
枚举顺序 $(30,50,80)$:
- 第一位 $30$:新车,$\texttt{sum}=30$,$\texttt{cnt}=1$。
- 第二位 $50$:$30+50\le100$,同车,$\texttt{sum}=80$。
- 第三位 $80$:$80+80>100$,新车,$\texttt{cnt}=2$,$\texttt{sum}=80$。
- 最终需要 $2$ 辆缆车。
对所有排列都可做同样计算,取最小 $\texttt{cnt}$ 即为答案。
例题五:[ABC363C] Avoid K Palindrome 2¶
题目需要排列字符串 $s$,且由于 $|s|\leq 10$,因此考虑使用全排列来做。
注意本题样例 $2$ 的字符串 zzyyx
,这个字符串一共只有 $30$ 个排列而不是 $5!$ 个。原因在于这里有两个重复的字符 $z,y$。因此正确的全排列个数为 $\dfrac{5!}{2!\times 2!}$。
如果还是沿用之前的做法(先初始化顺序数组 $a$)则会生成 $120$ 个排列,会导致某些重复的排列被计算多次,从而使得答案增多。
因此本题要对字符串本身进行全排列,它的用法类似于对字符串直接排序。
即 next_permutation(s.begin(), s.end())
。
注意使用之前需要先保证 $s$ 初始是字典序最小的情况,即调用 sort(s.begin(), s.end())
先初始化为字典序最小的情况。
接下来即对于一个字符串 $s$,验证是否具有长度为 $k$ 得回文子串,可以用打标记得思想实现。
- 定义
bool check(l, r)
的函数用于判断字符串 $s$ 的区间 $$l\sim r$ 是否回文。 - 接下来就是枚举所有长度为 $k$ 的区间,由于区间长度固定且是子串,枚举左端点 $i$ 即可,即可同步计算出右端点。
- 若存在一个区间是回文的,则当前排列是不合法的,打上标记。
- 最后根据标记变量的值来判定这个字符串是否符合要求即可。
时间复杂度:$O(n!\times n^2)$
例题六:最少操作数¶
交换相邻两行或列可以类比做冒泡排序,因此行或列的交换实际就是在对行或列重新排列。
因此本题需要全排列所有的可能,一共 $n!\times m!$ 种(嵌套的关系)。
因此需要两个编号数组 $a$ 和 $b$ 分别记录行和列的编号,初始化分别为 $1\sim n$ 与 $1\sim m$。
如何计算交换次数?
- 对于给定的排列 $a$,它初始是 $1\sim n$,变化为其它排列后,只需要计算出这个排列的逆序对个数即可,逆序对个数就是它的交换次数。
- 逆序对:当满足 $i
a_j$ 就称 $(i,j)$ 是一个逆序对。 - 因为冒泡排序本质就是消除逆序对的过程。
- 逆序对:当满足 $i
- 总交换次数就等于行的交换次数加上列的交换次数。注意是加不是乘。
- 时间复杂度为:$O(n!\times m!\times n^2\times m^2)$。
例题七:[ABC150C] Count Order¶
简单题,对排列 $1\sim n$ 进行全排列,同时设置一个变量记录目前到了第几个排列,然后分别检验当前排列是否分别等于 $p$ 或 $q$。
例题八:[ABC198D] Send More Money¶
题意:给你三个字符串 $a,b,c$,将每个字符看作是一个 $0\sim 9$ 之间的数字,可以得到新的三个数字 $N_1,N_2,N_3$。要求:
- 每个字符对应的数字唯一,即若字母 $a$ 看作是
1
,其他字母则不能用 $1$。 - 新的三个数字无前导 $0$ 且都是正整数。
- 满足以上条件后给定一个构造方案。
首先可以统计三个字符串中一共出现多少个不同的字符,若不同的个数大于 $10$ 则无解。
接下来可以对每一个字符重新映射成一个数字,例如把 $a$ 看作 $1$,以此类推。注意这里出现的字母不一定是按照 $a,b,c$ 这样连续的,可能是将 $a,d,z$ 重新编号为 $1,2,3$。具体可以用 map
实现。
string a, b, c;
cin >> a >> b >> c;
string s = a + b + c; // 拼到一起
for (auto x : s) vis[x - 'a'] = 1;
int num = 0;
for (int i = 0; i < 26; i++) num += (vis[i] == 1);
if (num > 10)
{
cout << "UNSOLVABLE";
return 0;
}
map<char, int> id;
int cnt = 0;
for (int i = 0; i < 26; i++)
{
if (vis[i]) id[i + 'a'] = ++cnt; // 每个字母重新给个数值
}
接下来枚举 $0\sim 9$ 的排列,来重新检验是否生成的数值符合要求。
for (int i = 0; i < 10; i++) x[i] = i;
do
{
if (!x[id[a[0]]] || !x[id[b[0]]]) continue;
long long suma = 0;
for (auto v : a) suma = suma * 10 + (x[id[v]]);
long long sumb = 0;
for (auto v : b) sumb = sumb * 10 + (x[id[v]]);
long long sumc = 0;
for (auto v : c) sumc = sumc * 10 + (x[id[v]]);
if (suma && sumb && suma + sumb == sumc)
{
cout << suma << "\n" << sumb << "\n" << sumc;
return 0;
}
} while (next_permutation(x, x + 10));
cout << "UNSOLVABLE";