LeetCode【学习计划】:【算法】
77. 组合
LeetCode: 77. 组合
中 等 \color{#FFB800}{中等} 中等
给定两个整数
n
和k
,返回范围[1, n]
中所有可能的k
个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
提示:
1 <= n <= 20
1 <= k <= n
方法1:回溯(递归)
回溯算法:把问题的解空间转化成树或图的结构表示,然后使用深度优先搜索(BFS)进行遍历,遍历的过程中寻找解。
当一个问题的每一步有很多选项时,我们可以使用回溯算法。
从例1入手,n=4, k=2
,我们可以得到如下的树:
第二层我们有4个结点,对应着选择[1,2,3,4]
中的1个数字。然后由于k=2
,所以我们要再选一个数字,于是到达第3层。
第一层我们决定第一个数是什么,第二层决定第二个数是什么,以此类推,我们就能用深度优先算法来列出所有的可能。
由于中间过程,和最终的答案都是原先数组的一部分,因此每次递归都至少要传入当前数组的起始位置和结束位置。对于第一次调用dfs函数时,我们要传入1
和n
,也就是一开始我们要从[1..n]
中选择:backtrack(1, n)
void backtrack(int cur, int n);
由于我们最终要返回所有组合,因此需要一个二维的答案数组ans
;每次深度遍历到最底层的过程中只能确定一个组合,因此我们需要一个单独的一维数组reserve
来留存单次的结果。
vector<vector<int>> ans;
vector<int> reserve;
我们每一层只能确定一个数,每层往reserve
中添加那个数。因此当reserve
的长度等于k
时,我们就完成了一次组合的查找,这时我们将reserve
中的内容存到ans
中。
if (reserve.size() == k)
ans.emplace_back(reserve);
接下来就到了最难的时刻了:如何安排递归?
我们不妨先从头开始过一遍流程。对于[1,2,3,4]
,我们首先要选择1
,然后选择2
、3
、4
;其次选择2
,然后选择3
、4
。我们将每轮选取的结果画成这样的图:
图中每一条路径均为一个解。
首先我们可以发现,同一父结点下的并排结点的值是递增的,这代表着以某一父结点下的所有并排结点将为起始点。蓝色一排各自对应的reserve
为:[1],[2],[3],[4]。同理,蓝色1
下的3个绿色结点将会是下一次的起点,它们各自对应的reserve
为:[1,2],[1,3],[1,4]。只不过本题的k
值为2,我们到第3层就结束了,所以看不出来绿色结点其实是下一次的起点。
我们设当前递归内起点为cur
。同一父结点下的并排结点的值是递增的,这个事情告诉我们,在递归内部,前往并排结点的函数调用起码是这个形式的:
dfs(cur+1, n);
我们先称这一部分递归叫做并排递归,也就是假设当前的cur
到n
已经遍历完了,接下来要在cur+1
到n
之中进行查找。假设图中蓝色1
的所有部分完成,下一个应该前往蓝色2
,然后是蓝色3
。前往并排结点的递归——并排递归。
我们还可以发现,在父节点下面的子节点也是递增的。例如蓝色1
下面的绿色2
,3
,4
。然后问题就来了,不管从蓝色1
前往蓝色的234,还是从蓝色1
前往绿色的234,它们实际上都是从1到2,再到3和4的数值递增,在代码中它们都没有颜色。也就是说在代码中,前往同层结点和前往下层的同层结点的代码都是backtrack(cur+1, n)
。我们先叫前往下层的同层结点这一部分递归为子结点递归。那么我们怎么区分蓝色1
在什么时候是往哪边前进的呢?也就是什么时候是并排递归,什么时候是子结点递归?
关键点就在于reserve
数组的变化。我们从蓝色1
前往绿色234的含义是:我选中了1
,然后去选234组成[1,2],[1,3]的样子。而从蓝色1
前往蓝色2
的含义是:我已经选完1
了,然后要从2
开始选34组成[2,3],[2,4]。既然如此,我们往下走的时候,reserve
数组必然是有当前结点的值的;我们往右走的时候,reserve
数组是没有当前结点的值的,换而言之,当前结点的值刚刚从reserve
数组里出去。
有出去必然有进来。所以:
- 我们在递归函数内部,先把当前结点的值放入
reserve
数组中,然后往后面进发,调用dfs(cur+1, n)
,这一步就是向子结点进发——子结点递归; - 子结点递归结束后,
reserve
数组中只剩当前结点的值(以及前面几层的值),我们再把当前结点的值从数组中删去,再调用dfs(cur+1, n)
,这一步就是向同排结点进发——并排递归。
我们基于之前那张图,把递归的调用情况用箭头重新画一遍表示出来:
图中可以很清晰的看出来,蓝色1
递归前往子结点的并排结点,也递归前往自身的并排结点。
小伙伴们或许能发现,这和【LeetCode学习计划】《算法-入门-C++》第8天 广度优先搜索 / 深度优先搜索中116. 填充每个节点的下一个右侧节点指针的最终结果几乎一模一样。
在116题中,我们将同层的所有结点都链接了起来。而本题中,只是将相同父节点下的子节点连起来而已。
而学习过数据结构课程的小伙伴或许有印象,思考一下:把父节点下的所有子节点全部横向连起来是为了什么?
…
…
…
…
…
答案是:将非二叉树转化为二叉树,或者将森林转化为二叉树。将上图的所有结点都排好位置,就可以得到下图:
注:下图不是传统的将树转为二叉树的结果,只是根据子节点递归和并排递归的区别而分的左子树和右子树,为了看起来方便一点。
这张图就能更清晰的看到递归的调用情况了。我们从1
开始兵分两路,左侧前往子节点递归,右侧并排递归。这也能对应上我们的代码结构,在一次递归函数内会有两次的dfs(cur+1, n)
调用。
为了避免小伙伴们误会上层只有蓝色层,或者说想要看正确的树转换为二叉树的结果,以下是n=4, k=3
的递归情况:
之前图中左侧均为同色,是为了让大家更好理解两侧的不同。而这张图中,颜色不同即代表不同的递归深度了。图中,每个结点的右孩子都是和自己并排的兄弟结点,左孩子均是比自己深一层的孩子结点。学习过数据结构的小伙伴应该能反应过来,我们最后画出来的图还归结到了普通树的孩子兄弟表示法,而这个转换方法就是我们之前提到的将树转化为二叉树的方法。
到此为止,我们的dfs函数应该是这样的:
void dfs(int cur, int n)
{
if (reserve.size() == k)
{
ans.emplace_back(reserve);
return;
}
reserve.push_back(cur);
dfs(cur + 1, n);
reserve.pop_back();
dfs(cur + 1, n);
}
递归的问题解决了,我们来讲一下这道题的一个非常简单的优化思路。我们仍旧以n=4, k=2
为例:
我们将目光集中在最右侧的蓝色4
上,可以发现它没有绿色孩子,这是因为从4
开始已经找不到能够组合的数了,数组只有[1,2,3,4]
。也就是说从cur
开始数,数到n
了之后,即为剩余的元素数量。如果当前reserve
中的数量,加上这个剩余数量小于k
的话,那么必然是不能构成一共需要k
个数的组合的。这样一来,我们就可以对树进行剪枝来减少运算量和时间:
// dfs
if (reserve.size() + (n - cur + 1) < k)
return;
最终的代码:
#include <vector>
using namespace std;
class Solution
{
private:
vector<vector<int>> ans;
vector<int> reserve;
int k;
public:
vector<vector<int>> combine(int n, int k)
{
this->k = k;
this->reserve.reserve(this->k);
backtrack(1, n);
return ans;
}
void backtrack(int cur, int n)
{
if (this->reserve.size() + (n - cur + 1) < this->k)
return;
if (this->reserve.size() == this->k)
{
ans.emplace_back(this->reserve);
return;
}
this->reserve.push_back(cur);
backtrack(cur + 1, n);
this->reserve.pop_back();
backtrack(cur + 1, n);
}
};
复杂度分析
-
时间复杂度: O ( ( k n ) × k ) O(\binom{k}{n} \times k) O((nk)×k)。每次组合枚举的时间为 O ( ( k n ) ) O(\binom{k}{n}) O((nk)),而每次将
reserve
数组的内容存入到答案数组ans
的时间为 O ( k ) O(k) O(k)。 -
空间复杂度: O ( n + k ) = O ( n ) O(n+k)=O(n) O(n+k)=O(n)。保留数组的
reserve
长度最大为n,递归调用的栈空间为k。
参考结果
Accepted
27/27 cases passed (4 ms)
Your runtime beats 99.14 % of cpp submissions
Your memory usage beats 19.43 % of cpp submissions (18.4 MB)
46. 全排列
LeetCode: 46. 全排列
中 等 \color{#FFB800}{中等} 中等
给定一个不含重复数字的数组
nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums
中的所有整数 互不相同
方法1:回溯
我们可以将给定的数组nums
分为两部分,左侧是随着递归的进展而被确定的数,右侧是未被填入的数。在回溯时,我们只需要动态维护这个数组即可。
假设我们已经填到了cur
的位置,那么nums
数组中[0, cur-1]
位置均是确定的数,我们要尝试用[cur, n-1]
里的数去填到cur
位置上。于是我们单独开启一个循环,设变量i
从cur
遍历至n-1
。每次交换第i
和第cur
个数,也就是swap(nums[i], nums[cur])
,然后递归调用dfs(cur+1, n)
。这样一来,我们在填第cur+1
个数的时候,nums
数组的[0, cur]
部分就是已经填过的数。回溯的时候我们再交换回来撤销操作。
#include <vector>
using namespace std;
class Solution
{
private:
vector<vector<int>> ans;
vector<int> nums;
public:
vector<vector<int>> permute(vector<int> &nums)
{
this->nums = nums;
backtrack(0, nums.size());
return ans;
}
void backtrack(int cur, int len)
{
// 所有数都填完了
if (cur == len)
{
ans.emplace_back(this->nums);
return;
}
for (int i = cur ; i < len; i++)
{
// 动态维护数组
swap(this->nums[i], this->nums[cur]);
// 继续递归填下一个数
backtrack(cur + 1, len);
// 撤销操作
swap(this->nums[i], this->nums[cur]);
}
}
};
复杂度分析
-
时间复杂度: O ( n × n ! ) O(n \times n!) O(n×n!)。推导过程详见官方题解
-
空间复杂度: O ( n ) O(n) O(n)。主要为递归的栈空间消耗。
参考结果
Accepted
26/26 cases passed (0 ms)
Your runtime beats 100 % of cpp submissions
Your memory usage beats 28.03 % of cpp submissions (7.8 MB)
784. 字母大小写全排列
LeetCode: 784. 字母大小写全排列
中 等 \color{#FFB800}{中等} 中等
给定一个字符串
S
,通过将字符串S
中的每个字母转变大小写,我们可以获得一个新的字符串。返回所有可能得到的字符串集合。
示例:
输入:S = "a1b2"
输出:["a1b2", "a1B2", "A1b2", "A1B2"]
输入:S = "3z4"
输出:["3z4", "3Z4"]
输入:S = "12345"
输出:["12345"]
提示:
S
的长度不超过12
。S
仅由数字和字母组成。
方法1:迭代
先定义一个存放答案的数组ans
,从字符串的开头进行遍历,遍历的过程中一共会有2中情况:
- 遍历到了字母。记录当前答案数组中元素的个数
size
。将数组ans
中的所有字符串复制一遍,然后前[0, size-1]
个字符串添加当前的字母,后[size, 2*size-1]
个字符串添加当前字母的大写或小写。 - 遍历到了数字。给当前答案数组的所有字符串都添加数字。
由于我们在遍历过程中要给字符串的末尾直接添加字符,所以一开始我们的答案数组中必须要有一个空字符串,这样代码会简洁一点。
以S="a1b2"
为例:
ans:
[""]
=> ["a", "A"]
=> ["a1", "A1"]
=> ["a1b", "A1b", "a1B", "A1B"]
=> ["a1b2", "A1b2", "a1B2", "A1B2"]
#include <vector>
#include <string>
using namespace std;
class Solution
{
public:
vector<string> letterCasePermutation(string s)
{
const int n = s.length();
vector<string> ans;
ans.emplace_back();
ans[0].reserve(n);
for (const auto &ch : s)
{
const int size = ans.size();
if ('0' <= ch && ch <= '9')
{
for (int i = 0; i < size; i++)
{
ans[i].push_back(ch);
}
}
else
{
for (int i = 0; i < size; i++)
{
ans.emplace_back(ans[i]);
}
char ch2 = ('A' <= ch && ch <= 'Z') ? ch - 'A' + 'a' : ch - 'a' + 'A';
for (int i = 0; i < size; i++)
{
ans[i].push_back(ch);
ans[i + size].push_back(ch2);
}
}
}
return ans;
}
};
复杂度分析
-
时间复杂度: O ( n × 2 n ) O(n \times 2^{n}) O(n×2n)。其中
n
是字符串的长度。最坏情况下字符串中每个字符都是英文字母,也就是说最终答案数组中会有 2 n 2^{n} 2n个字符串。每次遍历给每一个字符串添加一个字符,因此时间复杂度是 O ( n × 2 n ) O(n \times 2^{n}) O(n×2n)。 -
空间复杂度: O ( 1 ) O(1) O(1)。答案数组不计入空间复杂度。
参考结果
Accepted
63/63 cases passed (8 ms)
Your runtime beats 53.95 % of cpp submissions
Your memory usage beats 94.21 % of cpp submissions (9.2 MB)