排列组合

欢迎访问我的博客首页


  下文提到的排列组合问题都可以使用回溯算法解决。《数学中的排列》还可以用非递归算法解决,非递归算法的关键是找到下一个排列。《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 呢?只能通过交换两个字符。先来看两个简单的例子:

  1. “abc” 的下一个排列是 “acb”:‘c’ 不能变大,‘b’ 可以通过与 ‘c’ 交换变大。
  2. “abdc” 的下一个排列是 “acbd”。‘d’ 与 ‘c’ 交换会变小;‘b’ 与 ‘d’ 和 ‘c’ 交换都会变大,但我们只加 1,所以应该与较小的 ‘c’ 交换变成 “acdb”。‘b’ 变大了,后面的部分 “db” 应该按最小值排列成 “bd”。这和 2199 的 1 变成 2 时两个 9 变成 0 是同样道理。所以最终结果是 “acbd”。

  回到 “xzycba”,求它的下一个排列应该:

  1. 先确定要交换 ‘x’。因为它后面的 “zycba” 是递减的,任何一个字符与它后面的字符交换只会变小不会变大。
  2. 再确定 ‘x’ 与谁交换。可选的只有 ‘z’ 和 ‘y’,选较小的 ‘y’,交换后变成 “yzxcba”。
  3. ‘x’ 变大后,后面的部分应该按最小值排列成递增的,所以最终结果是 “yabcxz”。

  至此我们总结出求全排列的非递归算法:

  1. 先把字符串从小到大排序。
  2. 从字符串末尾开始寻找长度为 2 的递增子串,假设这个子串是 str[p-1:p]。注意此时 str[p:n-1] 是递减的。如果找不到,程序结束。
  3. 从递减子串 str[p:n-1] 中找出大于 str[p-1] 的最小字符,假设它的下标是 min_index。
  4. 交换 str[p-1] 与 str[min_index]。注意交换后str[p:n-1] 仍是递减的。
  5. 反转递减子串 str[p:n-1] 成递增子串,得到一个排列 str。
  6. 执行第 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 《电话号码的字母组合》。

4. 参考


  1. 排列组合
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值