【LeetCode学习计划】《算法-入门-C++》第11天 递归 / 回溯

LeetCode【学习计划】:【算法】



77. 组合

LeetCode: 77. 组合

中 等 \color{#FFB800}{中等}

给定两个整数 nk,返回范围 [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函数时,我们要传入1n,也就是一开始我们要从[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,然后选择234;其次选择2,然后选择34。我们将每轮选取的结果画成这样的图:
在这里插入图片描述
图中每一条路径均为一个解。

首先我们可以发现,同一父结点下的并排结点的值是递增的,这代表着以某一父结点下的所有并排结点将为起始点。蓝色一排各自对应的reserve为:[1],[2],[3],[4]。同理,蓝色1下的3个绿色结点将会是下一次的起点,它们各自对应的reserve为:[1,2],[1,3],[1,4]。只不过本题的k值为2,我们到第3层就结束了,所以看不出来绿色结点其实是下一次的起点。

我们设当前递归内起点为cur同一父结点下的并排结点的值是递增的,这个事情告诉我们,在递归内部,前往并排结点的函数调用起码是这个形式的:

dfs(cur+1, n);

我们先称这一部分递归叫做并排递归,也就是假设当前的curn已经遍历完了,接下来要在cur+1n之中进行查找。假设图中蓝色1的所有部分完成,下一个应该前往蓝色2,然后是蓝色3。前往并排结点的递归——并排递归

我们还可以发现,在父节点下面的子节点也是递增的。例如蓝色1下面的绿色234。然后问题就来了,不管从蓝色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数组里出去

出去必然有进来。所以:

  1. 我们在递归函数内部,先把当前结点的值放入reserve数组中,然后往后面进发,调用dfs(cur+1, n),这一步就是向子结点进发——子结点递归
  2. 子结点递归结束后,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位置上。于是我们单独开启一个循环,设变量icur遍历至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中情况:

  1. 遍历到了字母。记录当前答案数组中元素的个数size。将数组ans中的所有字符串复制一遍,然后前[0, size-1]个字符串添加当前的字母,后[size, 2*size-1]个字符串添加当前字母的大写或小写。
  2. 遍历到了数字。给当前答案数组的所有字符串都添加数字。

由于我们在遍历过程中要给字符串的末尾直接添加字符,所以一开始我们的答案数组中必须要有一个空字符串,这样代码会简洁一点。

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)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

亡心灵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值