欢迎访问我的博客首页。
下文提到的排列组合问题都可以使用回溯算法解决。《数学中的排列》还可以用非递归算法解决,非递归算法的关键是找到下一个排列。《LeetCode 31.下一个排列》专门考察怎么找下一个排列。
1. 数学中的排列
求字符串、字符数组等序列中的给定字符的全排列。
相关题目有《LeetCode 46.全排列》、《LeetCode 47.全排列Ⅱ》、《LeetCode 31.下一个排列》。
1. 递归算法
使用递归算法实现全排列很简单:
循环0: 把str[0:n-1]中的某个字符str[i0]与str[0]交换,使str[i0]作为str[0]。
循环1: 把str[1:n-1]中的某个字符str[i1]与str[1]交换,使str[i1]作为str[1]。
...
循环n-1: 把str[n-1:n-1]中的某个字符str[in-1]与str[n-1]交换,使str[in-1]作为str[n-1]。至此确定一个排列。
回溯n-1: 交换回str[in-1]与str[n-1]。继续执行循环n-1。
....
回溯1: 交换回str[i1]与str[1]。继续执行循环1。
回溯0: 交换回str[i0]与str[0]。继续执行循环0。
由此看出,共有 n 层循环,循环 i 处理子串 str[i:n-1]。所以可以使用递归算法,每个递归执行一个循环体。
重复的排列:假如原字符串是 “abcdc”,循环 1 处理 “bcdc”。当 i1 = 4,即循环 1 处理到第 2 个 ‘c’ 时,不应该再把这第 2 个 ‘c’ 与 str[1] 交换,因为 str[1] 这个位置已经放置过第 1 个 ‘c’ 了。所以处理重复的方法就是检查 str[1:i1-1] 中是否有字符与 str[i1] 相同。
bool repeat(string& str, int begin, int end) {
for (int i = begin; i < end; i++)
if (str[i] == str[end])
return true;
return false;
}
void permutation(string& str, int index, vector<string>& res) {
if (index == str.size()) {
res.push_back(str);
return;
}
for (int i = index; i < str.size(); i++) {
if (repeat(str, index, i) == true)
continue;
swap(str[index], str[i]);
permutation(str, index + 1, res);
swap(str[index], str[i]);
}
}
vector<string> permutation(string str) {
if (str.size() == 0)
return{};
vector<string> res;
permutation(str, 0, res);
return res;
}
第 14、15 行的判断语句使全排列中不含重复排列,当然使用 set 存放结果也可以使全排列中不含重复排列。
2. 非递归算法
假如我们把 n 个字符的全排列按字典顺序排序,则最小的排列是递增的,最大的排列是递减的。在按字典顺序排序的全排列中,假如我们知道了其中一个排列,怎么确定下一个排列呢?这是求全排列的非递归算法的关键,《LeetCode 31.下一个排列》就是考察这一点的。
比如求 “xzycba” 的下一个排列。这和"求 2199 的下一个数字"有相似之处,求 2199 的下一个数字,要对它的最低位加 1。那么,怎么对 “xzycba” 加 1 呢?只能通过交换两个字符。先来看两个简单的例子:
- “abc” 的下一个排列是 “acb”:‘c’ 不能变大,‘b’ 可以通过与 ‘c’ 交换变大。
- “abdc” 的下一个排列是 “acbd”。‘d’ 与 ‘c’ 交换会变小;‘b’ 与 ‘d’ 和 ‘c’ 交换都会变大,但我们只加 1,所以应该与较小的 ‘c’ 交换变成 “acdb”。‘b’ 变大了,后面的部分 “db” 应该按最小值排列成 “bd”。这和 2199 的 1 变成 2 时两个 9 变成 0 是同样道理。所以最终结果是 “acbd”。
回到 “xzycba”,求它的下一个排列应该:
- 先确定要交换 ‘x’。因为它后面的 “zycba” 是递减的,任何一个字符与它后面的字符交换只会变小不会变大。
- 再确定 ‘x’ 与谁交换。可选的只有 ‘z’ 和 ‘y’,选较小的 ‘y’,交换后变成 “yzxcba”。
- ‘x’ 变大后,后面的部分应该按最小值排列成递增的,所以最终结果是 “yabcxz”。
至此我们总结出求全排列的非递归算法:
- 先把字符串从小到大排序。
- 从字符串末尾开始寻找长度为 2 的递增子串,假设这个子串是 str[p-1:p]。注意此时 str[p:n-1] 是递减的。如果找不到,程序结束。
- 从递减子串 str[p:n-1] 中找出大于 str[p-1] 的最小字符,假设它的下标是 min_index。
- 交换 str[p-1] 与 str[min_index]。注意交换后str[p:n-1] 仍是递减的。
- 反转递减子串 str[p:n-1] 成递增子串,得到一个排列 str。
- 执行第 2 步。
下面是算法实现。
void reverse(string& str, int begin) {
int end = str.size() - 1;
while (begin < end) {
swap(str[begin++], str[end--]);
}
}
void permutation(string str, vector<string>& res) {
int p = str.size() - 1;
while (p > 0) {
// 如果有递增子串。
if (str[p - 1] < str[p]) {
// 1.str[p:n-1]是递减序列,从小端开始找第一个大于str[p-1]的字符,并交换它们。
int min_index = str.size() - 1;
while (str[min_index] <= str[p - 1])
min_index--;
swap(str[p - 1], str[min_index]);
// 2.交换后str[p:n-1]仍是递减序列,需要把它调整成递增序列。至此确定一个排列。
reverse(str, p);
res.push_back(str);
// 3.继续从尾部开始检查是否有递增子串。
p = str.size() - 1;
}
else
p--;
}
}
vector<string> permutation(string str) {
if (str.size() == 0)
return {};
sort(str.begin(), str.end());
vector<string> res = { str };
permutation(str, res);
return res;
}
因为这种方法逐个生成更大的排列,所以求得的结果不会有重复。
2. 数学中的组合
问题:给定一个字符串,输出所含字符的组合。注意,字符可能有重复。
void combination(string& str, int need_len, vector<string>& res, int start = 0, string& yi = string{}) {
// 从 str[start:n-1] 中找出长度为 need_len 的子序列。
if (need_len == 0) {
res.push_back(yi);
return;
}
if (start >= str.size())
return;
// 1.取字符 str[start],继续从 str[start+1:n-1] 中找出长度为 need_len-1 的子序列。
yi.push_back(str[start]);
combination(str, need_len - 1, res, start + 1, yi);
// 2.回溯到不取 str[start],继续从 str[start+1:n-1] 中找出长度为 need_len 的子序列。
yi.pop_back();
combination(str, need_len, res, start + 1, yi);
}
vector<string> combination(string str) {
if (str.size() == 0)
return{};
vector<string> res;
// 找出长度从 1 到 n 的所有子序列。
for (int need_len = 1; need_len <= str.size(); need_len++)
combination(str, need_len, res);
return res;
}
子序列和子串不一样,子序列不要求连续,但子序列中元素的相对位置和在原序列中是一样的。
如果给定的字符中有相同的字符,该算法求得的组合中也会有相同的组合。
3. 算法题中的排列组合
数学问题中,n 个字符的组合的长度从 1 到 n 不等,算法题一般不考察这样的组合。算法题中说的“排列”和“组合”不一定是数学中的排列和组合,它们可能是一类问题,需要具体分析。
算法题中的排列组合问题一般是考察怎么枚举 n 个在指定范围内取值的变量,取值的所有组合。这样的组合长度都是 n。这类问题的解决方法也是回溯算法,利用递归实现 n 重循环枚举所有取值。下面看几个例子。
题目:输出 n 个骰子向上一面的点的全排列。
分析:类似于 《剑指 Offer 第二版 17.打印从 1 到最大的 n 位数》,这个题是打印从 n 个 1 到 n 个 6 的所有整数。假如有 5 个骰子,使用 5 层从 1 到 6 的循环就可遍历所有情况。因为 n 是变量,所以下面使用递归的方法实现 n 重循环的枚举。
void permutation(int n, vector<vector<int>>& res, int index = 0, vector<int>& item = vector<int>{}) {
if (index == n) {
res.push_back(item);
return;
}
for (int i = 1; i <= 6; i++) {
item.push_back(i);
permutation(n, res, index + 1, item);
item.pop_back();
}
}
如上所说,算法题中的排列和组合可能是一类问题,比如使用这个求全排列的例题的解决方法,同样可以解决力扣 17 《电话号码的字母组合》。