递归、搜索、回溯的基础应用

本文介绍了递归在编程中的四个实际应用场景:汉诺塔问题的递归解决方案,链表反转的递归和迭代方法,二叉树的所有路径和剪枝问题,以及全排列和子集的递归算法。通过深度优先和回溯策略展示了递归在解决这些问题中的关键作用。
摘要由CSDN通过智能技术生成

递归

定义

在解决一个规模为n的问题时,如果满足以下条件,我们可以使用递归来解决:
1、问题可以被划分为规模更小的子问题,并且这些子问题具有与原问题相同的解决方法。
2、当我们知道规模更小的子问题(规模为 n - 1)的解时,我们可以直接计算出规模为 n 的问题
的解。
3、存在⼀种简单情况,或者说当问题的规模足够小时,我们可以直接求解问题。
⼀般的递归求解过程如下:
1、验证是否满⾜简单情况。
2、假设较小规模的问题已经解决,解决当前问题。
      上述步骤可以通过数学归纳法来证明。

题目一:汉诺塔

面试题 08.06. 汉诺塔问题 - 力扣(LeetCode)

在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:
(1) 每次只能移动一个盘子;
(2) 盘子只能从柱子顶端滑出移到下一根柱子;
(3) 盘子只能叠在比它大的盘子上。

请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。

你需要原地修改栈。

示例1:

 输入:A = [2, 1, 0], B = [], C = []
 输出:C = [2, 1, 0]

示例2:

 输入:A = [1, 0], B = [], C = []
 输出:C = [1, 0]

题目解析

1、对于规模为 n 的问题,我们需要将 A 柱上的 n 个盘子移动到C柱上。
2、规模为 n 的问题可以被拆分为规模为 n-1 的子问题:
      a、将 A 柱上的上面 n-1 个盘子移动到B柱上。
      b.、将 A 柱上的最大盘子移动到 C 柱上,然后将 B 柱上的 n-1 个盘子移动到C柱上。
3、当问题的规模变为 n=1 时,即只有一个盘子时,我们可以直接将其从 A 柱移动到 C 柱。
class Solution 
{
public:
    void hanota(vector<int>& a, vector<int>& b, vector<int>& c) 
    {
        hanotadfs(a,b,c,a.size());
    }

    //这个表示的意思是,将a中count个盘子,通过b的帮助,转移到c身上
    void hanotadfs(vector<int>& a, vector<int>& b, vector<int>& c,int count)
    {
        if(count==1)
        {
            c.push_back(a.back());
            a.pop_back();
            return;
        }
        //将a的盘子通过c的帮助将上层n-1个盘子转移到b;
        hanotadfs(a,c,b,count-1);
        //将a中剩余的最大那个盘子直接转移到c;
        c.push_back(a.back());
        a.pop_back();
        //将b中的n-1个盘子,通过a的帮助,全部转移到c;从而实现盘子的全部转移
        hanotadfs(b,a,c,count-1);
    }
};

题目二:反转链表

206. 反转链表 - 力扣(LeetCode)

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

示例 1:

输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]

示例 2:

输入:head = [1,2]
输出:[2,1]

示例 3:

输入:head = []
输出:[]

提示:

  • 链表中节点的数目范围是 [0, 5000]
  • -5000 <= Node.val <= 5000

题目解析

递归思想

先递归到尽头,找到反转后的链表的新的头节点,也就是当前链表的尾节点,进行标记,并返回,然后不断回溯修改链表节点指向;

class Solution 
{
public:
    ListNode* reverseList(ListNode* head) 
    {
        //返回要反转后的链表的头节点
        if(head==nullptr||head->next==nullptr)
        {
            return head;
        }
        //记录反转后的头节点
        ListNode* newhead=reverseList(head->next);
        head->next->next=head;
        head->next=nullptr;
        return newhead;
    }
};
迭代思想

运用双指针解决问题:

ListNode* reverseList(ListNode* head) 
    {
        ListNode* prev=nullptr;
        ListNode* cur=head;
        while(cur)
        {
            ListNode* next=cur->next;
            cur->next=prev;
            prev=cur;
            cur=next;
        }
        return prev;
    }

迭代的方法与递归方法不同的是,一个是往后去一个一个改变节点的指向,而递归是从后往前去改变节点指向。

深搜

定义

        深度优先遍历,是我们树或者图这样的数据结构中常用的一种遍历算法。这个算法会尽可能深的搜索树或者图的分支,直到一条路径上的所有节点都被遍历完毕,然后再回溯到上一层,继续找一条路遍历。
        在二叉树中,常见的深度优先遍历为:前序遍历、中序遍历以及后序遍历。 因为树的定义本身就是递归定义,因此采用递归的方法去实现树的三种遍历不仅容易理解而且代码很简洁。并且前中后序三种遍历的唯一区别就是访问根节点的时机不同,在做题的时候,选择一个适当的遍历序,对于算法的理解是非常有帮助的。

题目一:二叉树的所有路径

257. 二叉树的所有路径 - 力扣(LeetCode)

给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。

叶子节点 是指没有子节点的节点。

 

示例 1:

输入:root = [1,2,3,null,5]
输出:["1->2->5","1->3"]

示例 2:

输入:root = [1]
输出:["1"]

题目解析

使用深度优先遍历(DFS)求解。
路径以字符串形式存储,从根节点开始遍历,每次遍历时将当前节点的值加入到路径中,如果该节点为叶子节点,将路径存储到结果中。否则,将 "->" 加入到路径中并递归遍历该节点的左右子树。
class Solution 
{
    //为记录路径结果
    vector<string> allpath;
public:
    vector<string> binaryTreePaths(TreeNode* root) 
    {
        binaryTreePathsdfs(root,string());
        return allpath;
    }
    //path:为记录当前路径
    void binaryTreePathsdfs(TreeNode* root,string path)
    {
        path+=to_string(root->val);
        if(root->left==nullptr&&root->right==nullptr)
        {
            allpath.push_back(path);
            return;
        }
        path+="->";
        if(root->left)
        {
            binaryTreePathsdfs(root->left,path);
        }
        if(root->right)
        {
            binaryTreePathsdfs(root->right,path);
        }
    }
};

注意:这道题path尽量设置为全局变量不要传参,因为需要将int->字符串的形式,恢复现场时无法确定需要几次删除。

题目二:二叉树的剪枝

814. 二叉树剪枝 - 力扣(LeetCode)

题目解析

后序遍历按照左子树、右子树、根节点的顺序遍历⼆叉树的所有节点,通常用于父节点的状态依赖于子节点状态的题目。 如果我们选择从上往下删除,我们需要收集左右子树的信息,这可能导致代码编写相对困难。然而, 通过观察我们可以发现,如果我们先删除最底部的叶子节点,然后再处理删除后的节点,最终的结果 并不会受到影响。 因此,我们可以采用后序遍历的方法来解决这个问题。在后序遍历中,我们先处理左子树,然后处理右子树,最后再处理当前节点。在处理当前节点时,我们可以判断其是否为叶子节点且其值是否为 0, 如果满足条件,我们可以删除当前节点。
需要注意的是,在删除叶子节点时,其父节点很可能会成为新的叶子节点。因此,在处理完子节点后,我们仍然需要处理当前节点。这也是为什么选择后序遍历的原因(后序遍历首先遍历到的⼀定是叶子节点)。
通过使用后序遍历,我们可以逐步删除叶子节点,并且保证删除后的节点仍然满足删除操作的要
求。这样,我们可以较为方便地实现删除操作,而不会影响最终的结果。
若在处理结束后所有叶子节点的值均为 1,则所有子树均包含 1,此时可以返回。

class Solution 
{
public:
    TreeNode* pruneTree(TreeNode* root) 
    {
        if(root==nullptr)
        {
            return nullptr;
        }
        if(root->left)
        {
            root->left=pruneTree(root->left);
        }
        if(root->right)
        {
            root->right=pruneTree(root->right);
        }
        if(root->left==nullptr&&root->right==nullptr&&root->val==0)
        {
            delete root;
            return nullptr;
        }
        return root;
    }
};

回溯

定义

        回溯算法是⼀种经典的递归算法,通常用于解决组合问题、排列问题和搜索问题等。
        回溯算法的基本思想:从⼀个初始状态开始,按照⼀定的规则向前搜索,当搜索到某个状态无法前进时,回退到前⼀个状态,再按照其他的规则搜索。回溯算法在搜索过程中维护⼀个状态树,通过遍历 状态树来实现对所有可能解的搜索。
        回溯算法的核心思想:“试错”,即在搜索过程中不断地做出选择,如果选择正确,则继续向前搜索;否则,回退到上⼀个状态,重新做出选择。回溯算法通常用于解决具有多个解,且每个解都需要搜索才能找到的问题。

题目一:全排序

46. 全排列 - 力扣(LeetCode)

题目解析

我们需要在每⼀个位置上考虑所有的可能情况并且不能出现重复。通过深度优先搜索的方式,不断地枚举每个数在当前位置的可能性,并回溯到上⼀个状态,直到枚举完所有可能性,得到正确的结果。
每个数是否可以放入当前位置,只需要判断这个数在之前是否出现即可。具体地,在这道题目中,我们可以通过⼀个递归函数和标记数组来实现全排列。

class Solution 
{
    bool isused[6];
    vector<int> path;
    vector<vector<int>> allpath;
public:
    vector<vector<int>> permute(vector<int>& nums) 
    {
        permutedfs(nums);
        return allpath;
    }
    void permutedfs(vector<int>& nums)
    {
        if(path.size()==nums.size())
        {
            allpath.push_back(path);
            return;
        }
        for(size_t i=0;i<nums.size();i++)
        {
            if(!isused[i])
            {
                path.push_back(nums[i]);
                isused[i]=true;
                permute(nums);
                path.pop_back();
                isused[i]=false;
            }
        }
    }
};

题目二:子集

78. 子集 - 力扣(LeetCode)

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的

子集

(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:

输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入:nums = [0]
输出:[[],[0]]

题目解析

为了获得 nums 数组的所有子集,我们需要对数组中的每个元素进行选择或不选择的操作,即 nums 数组⼀定存在 2^(数组长度) 个子集。对于查找子集,具体可以定义⼀个数组,来记录当前的状态,并对其进行递归。
对于每个元素有两种选择:1. 不进行任何操作;2. 将其添加至当前状态的集合。在递归时我们需要保证递归结束时当前的状态与进行递归操作前的状态不变,而当我们在选择进行步骤 2 进行递归时,当前状态会发生变化,因此我们需要在递归结束时撤回添加操作,即进行回溯。
决策树一
通过决策树一可以得知:
使用该决策树完成这题,那么就是递归到尽头,决策树的叶子节点就是子集
class Solution 
{
    vector<vector<int>> allchild;
    vector<int> child;
    int n;
public:
    vector<vector<int>> subsets(vector<int>& nums) 
    {
        n=nums.size();
        subsetsdfs(nums,0);
        return allchild;
    }

    void subsetsdfs(vector<int>& nums,int pos)
    {
        if(pos==nums.size())
        {
            allchild.push_back(child);
            return;
        }
        //选进行递归
        child.push_back(nums[pos]);
        subsetsdfs(nums,pos+1);
        //恢复现场
        child.pop_back();
        //不选进行递归
        subsetsdfs(nums,pos+1);
    }
};
决策树二

通过决策树二的方法实现得知:决策树的节点都是子集

class Solution 
{
    vector<vector<int>> allchild;
    vector<int> child;
    int n;
public:
    vector<vector<int>> subsets(vector<int>& nums) 
    {
        n=nums.size();
        subsetsdfs(nums,0);
        return allchild;
    }

    void subsetsdfs(vector<int>& nums,int pos)
    {
        allchild.push_back(child);
        for(size_t i=pos;i<n;i++)
        {
            child.push_back(nums[i]);
            subsetsdfs(nums,i+1);
            child.pop_back();
        }
    }
};

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值