递归解题指南:LeetCode经典题全解析

递归、搜索与回溯知识点整理

一、递归(Recursion)

  1. 什么是递归?

递归的核心定义:函数自己调用自己的过程,是C语言与数据结构中的核心思想,典型应用场景包括:

二叉树的遍历(前/中/后序),快速排序,归并排序

  1. 为什么会用到递归?

递归的本质是问题的自相似性:

主问题可以被拆解为多个和原问题结构完全相同的子问题

子问题又可以继续拆解为更小规模的、结构相同的子问题

最终拆解到"足够简单、可直接解决"的边界情况(递归出口)

例如:

二叉树遍历:每个节点的遍历逻辑,与它的左、右子树的遍历逻辑完全相同

快排/归并排序:每个子数组的排序逻辑,与原数组的排序逻辑完全相同

  1. 如何理解递归?(三层递进理解法)

1) 误区:过度纠结递归展开的每一层调用栈细节,容易陷入混乱。

2) 进阶:把递归函数当成一个"黑盒",只关注它的输入和输出,不用关心内部执行细节。

3) 终极心法:不要在意递归的细节展开图,把递归函数当成一个能完成特定任务的黑盒,相信这个黑盒一定能完成任务(基于数学归纳法的正确性)

示例:二叉树前序遍历

cpp 复制代码
void dfs(TreeNode* root) {
    // 递归出口:空树直接返回
    if(root == NULL) return;
    
    printf(root->val);       // 处理当前节点
    dfs(root->left);        // 相信dfs能遍历左子树
    dfs(root->right);       // 相信dfs能遍历右子树
}

示例:归并排序

cpp 复制代码
void merge(int* nums, int left, int right) {
    // 递归出口:区间内只有一个元素,无需排序
    if(left >= right) return;
    
    int mid = (left + right) / 2;
    merge(nums, left, mid);        // 相信merge能排好左半部分
    merge(nums, mid + 1, right);   // 相信merge能排好右半部分
    mergeTwoSortedArrays(nums, left, mid, right); // 合并有序数组
}
  1. 如何写好一个递归?(三步走)

1) 设计函数头:先找到相同的子问题,明确函数的输入参数和功能(例如汉诺塔问题中,函数功能是"将x柱上的n个盘子,借助y柱,移到z柱")。

2) 编写函数体:只关心当前子问题的解决逻辑,直接调用递归函数处理更小的子问题。

3) 设置递归出口:当问题规模足够小时,直接解决问题,避免无限递归导致栈溢出。

二、搜索相关概念(DFS vs BFS)

  1. 核心概念区分

遍历:是一种访问所有节点的形式;搜索:是遍历的目的,用于寻找特定解。

深度优先遍历/深度优先搜索(DFS):沿着一条路径尽可能深地搜索,直到无法前进再回溯,通常用递归或栈实现。

广度优先遍历/广度优先搜索(BFS):按层遍历,先访问当前层所有节点,再访问下一层,通常用队列实现。

  1. 关系梳理

暴力枚举(穷举所有情况)的实现方式主要有两种:DFS 和 BFS,二者都是"搜索"的具体实现。

例如全排列问题,可以用DFS构建决策树,遍历所有可能的路径得到所有排列结果。

三、回溯与剪枝

  1. 回溯的本质

回溯算法的本质是深度优先搜索(DFS),是一种暴力搜索的优化形式。

它通过"尝试-失败-回退"的过程,遍历所有可能的解空间,找到满足条件的解。

例如迷宫问题中,遇到死路时回退到上一个节点,尝试其他路径,这就是回溯的过程。

  1. 剪枝的作用

剪枝是回溯算法的关键优化手段:在搜索过程中,提前判断某些路径不可能得到有效解,直接跳过这些路径,避免无意义的搜索,大幅提高效率。

例如在迷宫中,提前标记已经走过的路径,避免重复访问;或者根据问题规则,直接排除不符合条件的分支。

四、递归的定义与适用条件

在解决一个大规模的问题时,如果满足以下条件,就可以使用递归解决:

  1. 可拆分性:问题可以被划分为规模更小的子问题,且这些子问题与原问题的解决方法完全相同。

  2. 递推关系:当知道规模为 n-1 的子问题的解时,可以直接计算出规模为 n 的问题的解。

  3. 边界条件:存在一个"简单情况",当问题规模足够小时,可以直接求解,无需继续递归。

递归的一般求解过程

  1. 验证简单情况:先处理递归的终止条件(边界)。

  2. 假设与递推:假设较小规模的子问题已经解决,基于此解决当前问题。

注:上述过程可以通过数学归纳法来证明其正确性。


题目1:汉诺塔问题(LeetCode 面试题 08.06)

  1. 题目描述
  1. 核心递归思路

汉诺塔是递归的经典案例,核心思想是"分治":把大规模问题拆成小规模子问题,递归解决子问题后再处理原问题。

基础情况分析

当 n=1(只有1个盘子):直接把A柱上的盘子移到C柱即可。

当 n=2(2个盘子):需要借助B柱中转,共3步:把A柱上的小盘子(1号)移到B柱;把A柱上的大盘子(2号)移到C柱;把B柱上的小盘子(1号)移到C柱。

当 n>2(n个盘子):可以复用 n=2 的策略,将问题拆分为3步:把A柱上的n-1个盘子,借助C柱中转,全部移到B柱上;把A柱上剩下的最大的1个盘子,直接移到C柱上;把B柱上的n-1个盘子,借助A柱中转,全部移到C柱上。

关键逻辑:移动过程中,A柱上的最大盘子始终在最底部,不会被其他盘子压住,因此不会违反"大盘不压小盘"的规则。

  1. 递归函数设计与流程

函数定义 void dfs(vector<int>& a, vector<int>& b, vector<int>& c, int n)

功能:将 a 柱上的 n 个盘子,借助 b 柱中转,移动到 c 柱上。

参数:a(源柱)、b(辅助柱)、c(目标柱)、n(当前需要移动的盘子数量)。

递归流程

  1. 边界条件(n == 1):直接将 a 柱最顶端的盘子移到 c 柱:
cpp 复制代码
if (n == 1) {
    c.push_back(a.back());
    a.pop_back();
    return;
}
  1. 第一步:移动n-1个盘子到辅助柱:将 a 柱上的 n-1 个盘子,借助 c 柱中转,移动到 b 柱:
    dfs(a, c, b, n - 1);

  2. 第二步:移动最大盘子到目标柱:将 a 柱上剩下的最大盘子直接移到 c 柱:
    c.push_back(a.back());
    a.pop_back();

  3. 第三步:移动n-1个盘子到目标柱:将 b 柱上的 n-1 个盘子,借助 a 柱中转,移动到 c 柱:
    dfs(b, a, c, n - 1);

cpp 复制代码
#include <vector>
using namespace std;

class Solution {
public:
    void hanota(vector<int>& a, vector<int>& b, vector<int>& c) {
        // 调用递归函数,将a柱的所有盘子(a.size()个)借助b柱移到c柱
        dfs(a, b, c, a.size());
    }

private:
    // 递归函数:将a柱的n个盘子,借助b柱,移动到c柱
    void dfs(vector<int>& a, vector<int>& b, vector<int>& c, int n) {
        // 边界条件:只有1个盘子时,直接移动
        if (n == 1) {
            c.push_back(a.back());
            a.pop_back();
            return;
        }

        // 1. 把a柱上的n-1个盘子,借助c柱,移到b柱
        dfs(a, c, b, n - 1);

        // 2. 把a柱剩下的最大盘子,直接移到c柱
        c.push_back(a.back());
        a.pop_back();

        // 3. 把b柱上的n-1个盘子,借助a柱,移到c柱
        dfs(b, a, c, n - 1);
    }
};
  1. 关键知识点总结

1) 递归的核心思想:把复杂的大问题,拆成和原问题解法相同、规模更小的子问题,通过"递推"+"回归"解决。

2) 汉诺塔的递推公式:移动n个盘子需要的步数为 2ⁿ - 1(例如n=3时需要7步,n=14时需要16383步)。

3) 栈的特性:用 vector 模拟栈,back() 取栈顶元素,pop_back() 弹出栈顶元素,push_back() 压入栈顶元素,完全符合题目中"顶端移动"的规则。

4) 参数的传递逻辑:递归调用时,辅助柱和目标柱会动态切换,以实现"中转"的效果,这是汉诺塔递归实现的关键。


题目2:合并两个有序链表(LeetCode 21)

  1. 题目描述

提示:

  • 两个链表的节点数目范围是 [0, 50]
  • -100 <= Node.val <= 100
  • l1l2 均按 非递减顺序 排列
  1. 递归解法核心知识点

1) 递归函数的含义

递归函数的定义是:传入两个链表的头结点,返回合并后链表的头结点。

它的作用是:帮你把两个有序链表合并成一个有序链表,并返回合并后的头。

2) 算法核心思路

递归的核心是分而治之,每次解决"当前一步"的问题,剩下的交给递归处理:

  1. 选头结点:比较两个链表的头结点值,选择值较小的节点,作为合并后链表的当前头结点。

  2. 递归处理剩余部分:将"值较小节点的下一个节点"和"另一个链表的头结点"作为新的参数,继续递归合并。

  3. 拼接结果:将步骤2中递归返回的结果,接到步骤1中选出的头结点后面。

  4. 返回头结点:将当前选出的头结点作为结果返回,供上一层调用。

3) 递归出口(终止条件)

当某一个链表为空(nullptr)时,直接返回另一个链表。

若 l1 == nullptr,说明 l1 已经遍历完,直接返回 l2 剩余部分即可。

若 l2 == nullptr,同理,直接返回 l1 剩余部分。

4) 关键注意事项

链表题必须画图理解指针操作,明确:谁是当前节点?谁是下一个节点?递归返回的结果要接在谁的后面?

cpp 复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution
{
public:
    ListNode* mergeTwoLists(ListNode* l1, ListNode* l2)
    {
        // 递归出口:某一个链表为空,直接返回另一个
        if(l1 == nullptr) return l2;
        if(l2 == nullptr) return l1;

        // 选值较小的节点作为当前头结点
        if(l1->val <= l2->val)
        {
            // 递归合并 l1->next 和 l2,结果接在 l1 后面
            l1->next = mergeTwoLists(l1->next, l2);
            return l1;
        }
        else
        {
            // 递归合并 l1 和 l2->next,结果接在 l2 后面
            l2->next = mergeTwoLists(l1, l2->next);
            return l2;
        }
    }
};
  1. 知识点拓展

1) 时间复杂度

递归的次数等于两个链表的总节点数,每次递归只做一次比较和一次指针赋值,因此时间复杂度为:O(n + m),其中 n、m 分别为两个链表的长度。

2) 空间复杂度

递归调用栈的深度等于两个链表的总节点数,因此空间复杂度为:O(n + m)(递归栈开销)。

3) 易错点总结

空指针判断:必须先判断链表是否为空,否则访问 l1->val 会导致程序崩溃。

指针赋值方向:l1->next = mergeTwoLists(...) 是关键,不要搞反两个链表的参数顺序。

非递减顺序:题目中是"非递减",因此判断条件用 <= 即可,无需严格 <。


题目3:反转链表(LeetCode 206)

  1. 题目描述

示例 3:

复制代码
输入:head = []
输出:[]

提示:

  • 链表中节点的数目范围是 [0, 5000]
  • -5000 <= Node.val <= 5000
  1. 递归解法核心知识点

1) 递归函数的含义

递归函数的作用:传入一个链表的头指针,返回反转后链表的头结点。

2) 算法核心思路

递归的本质是"分而治之",核心步骤分为三步:

  1. 递归处理后半段:先把当前结点之后的链表反转,得到反转后链表的头结点 newHead。

  2. 调整指针指向:将当前结点的下一个结点的 next 指向当前结点(实现反转)。

  3. 处理尾结点:将当前结点的 next 置为 nullptr,避免链表出现环。

  4. 返回结果:返回反转后链表的头结点 newHead。

3) 递归出口(终止条件)

当链表为空 head == nullptr,或链表只有一个结点 head->next == nullptr 时,无需反转,直接返回 head 即可。

4) 关键注意事项

链表题必须画图理解指针操作,明确每个结点的 next 指向变化。

必须将原链表的头结点(反转后的尾结点)的 next 置为 nullptr,否则会形成环形链表。

cpp 复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution
{
public:
    ListNode* reverseList(ListNode* head)
    {
        // 递归出口:链表为空或只有一个结点,直接返回
        if (head == nullptr || head->next == nullptr) 
            return head;

        // 1. 递归处理 head->next 之后的链表,得到反转后的头结点
        ListNode* newHead = reverseList(head->next);
        
        // 2. 调整指针:让 head->next 的 next 指向 head
        head->next->next = head;
        
        // 3. 处理尾结点:将当前 head 的 next 置为 nullptr,避免成环
        head->next = nullptr;
        
        // 4. 返回反转后链表的头结点
        return newHead;
    }
};
  1. 知识点拓展

1) 复杂度分析

时间复杂度:O(n),每个结点仅被访问一次,其中 n 为链表长度。

空间复杂度:O(n),递归调用栈的深度等于链表长度,最坏情况下(链表长度为 n)需要 n 层栈空间。

2) 易错点总结

  1. 指针指向错误:忘记设置 head->next = nullptr,会导致反转后的链表形成环,程序运行时死循环。

  2. 递归出口缺失:没有处理链表为空的情况,会导致空指针访问异常。

  3. 返回值错误:误将 head 作为返回值,而不是递归返回的 newHead,导致反转后的链表头结点丢失。

  4. 迭代解法补充(对比学习)

如果需要空间复杂度为 O(1) 的解法,可以使用迭代法:

cpp 复制代码
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        ListNode* prev = nullptr; // 前一个结点
        ListNode* curr = head;    // 当前结点
        while (curr != nullptr) {
            ListNode* nextTemp = curr->next; // 保存下一个结点
            curr->next = prev;               // 反转指针
            prev = curr;                     // prev 后移
            curr = nextTemp;                 // curr 后移
        }
        return prev;
    }
};

时间复杂度:O(n); 空间复杂度:O(1),仅使用常数额外空间。


题目4:两两交换链表中的节点(LeetCode 24)

  1. 题目描述

提示:

  • 链表中节点的数目在范围 [0, 100]
  • 0 <= Node.val <= 100
  1. 递归解法核心解析

1) 递归函数的含义

递归函数的作用:传入一个链表的头指针,返回两两交换完成后的链表头结点。

2) 算法核心思路

递归的本质是分而治之,核心步骤分为三步:

  1. 递归处理后半段:先处理从第三个节点开始的子链表,返回其交换后的头结点 tmp。

  2. 交换当前两个节点:

原顺序:head → head->next → 子链表

交换后:head->next → head → tmp

  1. 返回新头结点:head->next 会成为当前层的新头结点,将其返回给上一层。

3) 递归出口(终止条件)

当链表为空 head == nullptr 时,直接返回 nullptr。

当链表只有一个节点 head->next == nullptr 时,无需交换,直接返回 head。

cpp 复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode() : val(0), next(nullptr) {}
 *     ListNode(int x) : val(x), next(nullptr) {}
 *     ListNode(int x, ListNode *next) : val(x), next(next) {}
 * };
 */
class Solution
{
public:
    ListNode* swapPairs(ListNode* head)
    {
        // 递归出口:链表为空或只有一个节点,直接返回
        if (head == nullptr || head->next == nullptr) 
            return head;

        // 1. 递归处理后续子链表(从第3个节点开始)
        auto tmp = swapPairs(head->next->next);
        
        // 2. 保存当前层交换后的新头结点(原第二个节点)
        auto ret = head->next;
        
        // 3. 调整指针:将当前节点的next指向tmp,完成交换
        head->next->next = head; // 原第二个节点的next指向原第一个节点
        head->next = tmp;        // 原第一个节点的next指向递归返回的子链表头
        
        // 4. 返回当前层交换后的新头结点
        return ret;
    }
};
  1. 关键细节与易错点

1) 必须画图理解指针操作:链表题的核心是指针的指向变化,画图能帮你清晰看到每个步骤的节点连接关系,避免逻辑混乱。

2) 交换顺序不能错:必须先处理子链表,再调整当前节点的指针,否则会丢失后续节点的引用。

3) 返回值易错:ret(原第二个节点)才是当前层交换后的新头结点,不能直接返回 head。

4) 边界处理:注意空链表、单节点链表的情况,避免空指针访问异常。

  1. 复杂度分析

时间复杂度:O(n),每个节点仅被访问一次,其中 n 为链表长度。

空间复杂度:O(n),递归调用栈的深度为链表长度的一半,最坏情况下需要 n/2 层栈空间。

  1. 拓展:迭代解法(空间复杂度O(1))

如果需要常数级空间复杂度,可以使用迭代法:

cpp 复制代码
class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        ListNode dummy(0);
        dummy.next = head;
        ListNode* prev = &dummy;
        
        while (prev->next != nullptr && prev->next->next != nullptr) {
            ListNode* first = prev->next;
            ListNode* second = prev->next->next;
            
            // 交换节点
            first->next = second->next;
            second->next = first;
            prev->next = second;
            
            // 移动指针
            prev = first;
        }
        return dummy.next;
    }
};

题目5:Pow(x, n)(LeetCode 50)

  1. 题目描述
  1. 递归快速幂解法核心解析

1) 核心思想:分治法(二分法)

cpp 复制代码
class Solution
{
public:
    double myPow(double x, int n)
    {
        // 处理负指数:注意n为-2^31时,直接取反会溢出,需转成long long
        return n < 0 ? 1.0 / pow(x, -(long long)n) : pow(x, (long long)n);
    }

private:
    // 递归快速幂,n为非负整数
    double pow(double x, long long n)
    {
        // 递归出口:n=0时返回1
        if (n == 0) return 1.0;
        
        // 分治:先计算x^(n/2)
        double tmp = pow(x, n / 2);
        
        // 根据n的奇偶性返回结果
        return n % 2 == 0 ? tmp * tmp : tmp * tmp * x;
    }
};
  1. 关键细节与易错点

1) 整数溢出问题:int 范围为 -2^{31} ~ 2^{31}-1,当 n = -2^31 时,直接写 -n 会溢出,必须先转成 long long 再取反。

2) 负指数处理:x^{-n} = 1 / x^n,不能直接在递归函数中处理负指数,否则 n/2 向下取整会出错。

3) 浮点数精度:题目允许一定精度误差,使用 double 计算即可满足要求。

4) 递归效率:递归深度为 O(log n),远优于暴力法的 O(n),时间复杂度为 O(log n),空间复杂度为 O(log n)(递归栈开销)。

  1. 复杂度分析

时间复杂度:O(log n),每次递归/迭代将指数折半,共需 log n 次计算。

空间复杂度:递归法为 O(log n)(递归栈),迭代法为 O(1)。

  1. 拓展:迭代快速幂(空间复杂度O(1))

如果需要常数级空间复杂度,可以用迭代法实现:

cpp 复制代码
class Solution {
public:
    double myPow(double x, int n) {
        long long N = n;
        if (N < 0) {
            x = 1 / x;
            N = -N;
        }
        double res = 1.0;
        while (N > 0) {
            if (N % 2 == 1) {
                res *= x;
            }
            x *= x;
            N /= 2;
        }
        return res;
    }
};
相关推荐
Kiling_07041 小时前
Java集合进阶:Set与Collections详解
算法·哈希算法
智者知已应修善业2 小时前
【51单片机89C51及74LS273、74LS244组成】2022-5-28
c++·经验分享·笔记·算法·51单片机
洛水水2 小时前
【力扣100题】33.验证二叉搜索树
算法·leetcode·职场和发展
SimpleLearingAI2 小时前
聚类算法详解
算法·数据挖掘·聚类
刀法如飞3 小时前
Go 字符串查找的 20 种实现方式,用不同思路解决问题
算法·面试·程序员
Dlrb12115 小时前
C语言-指针数组与数组指针
c语言·数据结构·算法·指针·数组指针·指针数组·二级指针
WL_Aurora5 小时前
Python 算法基础篇之集合
python·算法
平行侠5 小时前
A15 工业路由器IP前缀高速检索与内存压缩系统
网络·tcp/ip·算法
阿旭超级学得完6 小时前
C++11包装器(function和bind)
java·开发语言·c++·算法·哈希算法·散列表