总结汇总一下递归技巧。
递归的核心理解
什么是递归
递归问题是指子问题和最终问题相似时,且子问题容易解决时,可以先递到子问题,然后归到原问题来求解。
更直白地说,递归就是"函数自己调用自己"。但是刷题里真正重要的不是这个定义,而是要能看出来:当前问题能不能拆成一个或多个和原问题形式相同、规模更小的问题。
比如求链表长度:
Python3
点击展开代码
展开代码
这个函数的意思不是"我脑子里一层一层模拟调用栈",而是:
当前链表长度 = 1 + 后面链表的长度所以写递归最重要的习惯是:相信递归函数已经能解决子问题。我们只需要想清楚当前层要做什么,以及什么时候停止。
递归三要素
递归一般可以拆成三个问题:
1. 递归函数的定义是什么?2. 递归出口是什么?3. 当前层如何利用子问题结果?第一点最重要。很多递归写乱,本质上不是不会写代码,而是一开始没有定义清楚函数到底返回什么、负责什么。
比如二叉树最大深度:
Python3
点击展开代码
展开代码
这里递归函数的定义是:
maxDepth(root) 返回以 root 为根节点的树的最大深度有了这个定义后,代码就自然了:
当前树最大深度 = 左子树最大深度 和 右子树最大深度 的较大值 + 1所以以后写递归时,可以先写一句中文定义:
这个函数接收什么?这个函数返回什么?这个函数处理的是哪一段/哪一棵树/哪一个状态?递归函数的定义方式
递归函数通常有几种定义方式。
第一种,定义为"处理某个结构":
Python3
代码示例
二叉树题最常见,比如最大深度、翻转二叉树、判断相同的树。
第二种,定义为"处理某个区间":
Python3
代码示例
分治题、构造树、归并排序、快排经常这样写。
第三种,定义为"从某个位置开始处理":
Python3
代码示例
组合、切割、字符串匹配、动态规划递归版经常这样写。
第四种,定义为"当前路径/当前选择状态":
Python3
代码示例
排列、N皇后、数独、分桶问题经常这样写。
第五种,定义为"两个对象之间的关系":
Python3
代码示例
比如对称二叉树、相同的树、最长公共子序列、编辑距离。
递归函数定义得越准,后面的出口和递推关系就越容易写。
递归出口
递归出口就是最小子问题,也就是不用再继续拆的问题。没有递归出口,就会无限递归。
常见出口有几类。
结构为空:
Python3
代码示例
区间非法:
Python3
代码示例
位置到头:
Python3
代码示例
目标达成:
Python3
点击展开代码
展开代码
状态已经算过:
Python3
代码示例
递归出口要和函数定义保持一致。比如函数定义是"返回链表长度",空链表就应该返回 0;如果函数定义是"返回是否存在路径",走到目标就应该返回 True。
这里最容易错的是出口返回值。出口不是随便 return,而是要返回一个能被上一层正确使用的值。
递归返回值
递归函数可以有返回值,也可以没有返回值。
有返回值时,通常是子问题的答案要交给上一层使用:
Python3
点击展开代码
展开代码
没有返回值时,通常是靠外部变量或者参数里的 path 收集答案:
Python3
点击展开代码
展开代码
所以可以这样判断:
如果当前层需要子问题结果,就让递归函数 return。如果只是枚举所有可能并收集答案,可以不 return,用 path 和 ans。当然,回溯也可以有返回值,比如搜索是否存在一条路径时,找到后直接返回 True,可以提前剪枝。
递归前序位置与后序位置
递归里经常说前序位置、后序位置,其实就是"递归调用前做事"还是"递归调用后做事"。
Python3
点击展开代码
展开代码
前序位置适合自顶向下传递信息,比如当前路径和、当前深度、当前选择。
Python3
点击展开代码
展开代码
后序位置适合自底向上汇总信息,比如树的高度、节点数量、是否平衡、最大路径和。
Python3
点击展开代码
展开代码
可以简单记:
要把信息带下去,用前序。要从子树收结果,用后序。递归调用栈
递归调用不是"魔法",它本质上是系统帮我们维护了一个调用栈。每调用一次函数,就会把当前函数的局部变量、执行位置、参数保存起来,等子函数返回后再继续执行。
比如:
Python3
点击展开代码
展开代码
调用 f(3) 的输出是:
before 3before 2before 1after 1after 2after 3这就是"递"和"归":
递:一路进入更小的问题归:从最小问题开始一层一层返回所以很多题不建议一开始就把每一层调用全部脑补完,这样很容易晕。更好的方式是先相信函数定义,再看当前层如何组合答案。
递归和循环的关系
递归和循环都能表达重复过程。
循环更适合线性、明确次数的问题:
Python3
代码示例
递归更适合天然有层级结构、分支结构的问题:
Python3
代码示例
比如遍历数组,用循环更自然;遍历二叉树,用递归更自然。
从本质上说,递归可以转成循环,只是需要我们自己维护栈:
Python3
点击展开代码
展开代码
而递归就是系统帮我们维护这件事。所以递归代码通常更短、更贴合问题结构,但代价是会消耗调用栈空间,数据规模太大时可能爆栈。
递归、分治、回溯、动态规划的区别
这几个概念很容易混在一起。我的理解是:递归是一种写法,分治、回溯、动态规划是几种不同的问题思想,它们都可以用递归来实现。
递归:
函数自己调用自己。分治:
把一个大问题拆成几个互相独立的小问题,分别解决后合并。典型例子是归并排序:
排序左半边 -> 排序右半边 -> 合并两个有序数组回溯:
在一棵选择树上做选择,走不通就撤销选择,换下一条路。典型例子是全排列、组合、N皇后。
动态规划:
有重复子问题,并且可以通过保存子问题结果避免重复计算。递归版动态规划通常叫记忆化搜索。
可以用一个判断方式区分:
子问题互相独立,最后合并:分治。需要枚举选择,选完还要撤销:回溯。子问题大量重复,需要缓存:动态规划。只是顺着结构自然往下走:普通递归。比如二叉树最大深度,是普通结构递归;归并排序是分治递归;全排列是回溯递归;斐波那契加 memo 是记忆化搜索。
结构递归:顺着数据结构往下走
链表递归
LC206 - 反转链表
正常而言,这一题可以使用简单的三指针翻转解决,但是既然本专栏是递归专题,肯定是用递归的方法来做。我们思考,如果我们需要翻转的链表,实际上就是需要一个函数每次返回 翻转后的链表的头结点 。这样,我们就能定义一个递归函数,自己调用自己来完成任务。
Python3
点击展开代码
展开代码
这道题的递归思路稍微有点绕,而且时空也不优秀,但是麻雀虽小,武藏巨拳。
LC24 - 两两交换链表中的节点
在双指针专题中,我们写的就是递归写法,也是这类题目最简单的写法:
Python3
点击展开代码
展开代码
LC25 - K个一组翻转链表
Python3
点击展开代码
展开代码
LC21 - 合并两个有序链表
双指针合并自然是最简单的方式,但是,我们也可以利用递归结构来代替指针移动。
Python3
点击展开代码
展开代码
双指针:自己维护 p,一步步接节点。 递归:每一层只决定当前头节点是谁,后面的合并交给递归。
不过这样做的话,空间复杂度更差了。一般两个链表的合并,我们还是直接用双指针。
LC23 - 合并K个升序链表
涉及到了k个升序链表合并,就必须要用递归去做了(其实也可以用堆)。为了让递归树尽量平衡,我们应该每次在中间断开拆解子问题。当只有两个链表的时候,自然而然使用合并两个升序链表。
Python3
点击展开代码
展开代码
LC234 - 回文链表
这一题最简单的做法是找中点、(断开)、翻转、判断。但是,也可以使用递归的方式来做。
我们知道,递归可以"反向"访问到链表的元素,因此我的思路是,先递归一路走到链表尾部,然后回来的时候,从后往前访问节点,同时用left从前往后走。如果这个过程left.val和right.val都相等,那就是回文链表。
Python3
点击展开代码
展开代码
这样写的话,空间上更差一点,但是好在不会破坏原本的链表的结构。
二叉树递归
LC104 - 二叉树的最大深度
树本身就是递归定义的,所以很多题目树的题目都可以回归本质用递归做:
Python3
点击展开代码
展开代码
LC111 - 二叉树的最小深度
这类问题代表的是多出口的递归,不一定要一直递归到底返回,以为本题要求的答案不一定会到叶子节点才结算。
Python3
点击展开代码
展开代码
LC226 - 翻转二叉树
递归返回的时候从下往上调转左右即可:
Python3
点击展开代码
展开代码
LC100 - 相同的树
代表两边同时进行递归判断的题目:
Python3
点击展开代码
展开代码
LC101 - 对称二叉树
两个指针同时进行递归判断,一个向左一个向右,值和形状应该始终保持一致。
Python3
点击展开代码
展开代码
LC543 - 二叉树的直径
本题属于递归维持、返回的量,和题目要求的量不一样。但是递归可以保证一定可以考虑到最优解的情况(实际上就是遍历了),所以我们维持一个全局量来更新,然后dfs即可。
放在本题,直接想二叉树直径很难,可以将问题降级为"从node出发的最大节点数",只要知道了这个量,任意节点都可以看自己的左右孩子的这个量,来判断需不需要更新最大路径。
Python3
点击展开代码
展开代码
LC110 - 平衡二叉树
平衡二叉树是左右高度之差之中不大于1,对所有子树都有这约束。这道题是通过递归传递多种信息的典型。我们通过传递的量不仅要表示子树高度为多少,还要表达子树是否已经不平衡,于是不平衡传递-1,告诉上层也不用继续算了。
Python3
点击展开代码
展开代码
LC236 - 二叉树的最近公共祖先
同样是通过递归传递信息,这题需要传递的信息是"是否找到p/q"。如果找到了,就要返回自身。如果没找到,就不要返回。
Python3
点击展开代码
展开代码
LC235 - 二叉搜索树的最近公共祖先
上一题的逻辑适用于所有树的情况,这一题既然已经是BST,就可以利用值的性质。即如果p、q都是左子树,则答案一定在左边;反之一定在右边;如果一左一右,则root就是答案。
Python3
点击展开代码
展开代码
二叉搜索树递归
LC98 - 验证二叉搜索树
需要注意的是,BST是全局性质,因此递归过程中要维持一个上下界。
Python3
点击展开代码
展开代码
LC700 - 二叉搜索树中的搜索
正常的思路,注意递归的分支和返回即可。
Python3
点击展开代码
展开代码
LC701 - 二叉搜索树中的插入操作
带条件的递归,我们根据 BST 的大小关系一路递归到空位置,在空位置创建新节点;回溯时把插入后的子树逐层接回原树,最后返回根节点。
说白了,这是一题修改子树的递归题,而不是以前那样的找结果的递归题,所以感觉上可能有点稍微的差距,倒是和链表有点相似,就是返回修改过的部分。
Python3
点击展开代码
展开代码
LC450 - 删除二叉搜索树中的节点
和上一题一样,需要调整子树。
Python3
点击展开代码
展开代码
LC230 - 二叉搜索树中第K小的元素
直接中序遍历即可:
Python3
点击展开代码
展开代码
N叉树递归
LC559 - N叉树的最大深度
N叉树的结局方法,和二叉树其实差不多,用一个循环解决即可。
Python3
点击展开代码
展开代码
LC589 - N叉树的前序遍历
公式递归。
Python3
点击展开代码
展开代码
LC590 - N叉树的后序遍历
公公又式式
Python3
点击展开代码
展开代码
分治递归:把问题拆成左右两半
分治的基本模板
分治递归的关键,不只是"拆开",更是"怎么合并"。很多题递归本身不难,难的是左右子问题的答案如何拼回当前问题。
一个标准的分治题,通常都可以先问自己四个问题:
1. 这个问题能不能拆成左右两半?2. 左半边要返回什么信息?3. 右半边要返回什么信息?4. 当前层如何利用左右信息合并答案?一个基础模板如下:
Python3
点击展开代码
展开代码
如果是排序类题目,merge 真的就是合并有序结果;如果是统计类题目,merge 还会顺手统计贡献;如果是区间最值类题目,merge 则是在左右子区间答案的基础上,再考虑是否存在"跨中点"的情况。
归并排序
LC912 - 排序数组
这一题就是大名鼎鼎的归并排序。每次切半,直到不能切为止,然后组装起来。
Python3
点击展开代码
展开代码
后面有很多题目会在同一个函数中解决问题,因此我们学着利用python的特性,把分解与归并排序操作放在函数体内的同一个函数merge_sort中,这样更清晰:
Python3
点击展开代码
展开代码
LC148 - 排序链表
同样的归并排序:
Python3
点击展开代码
展开代码
同理单函数版:
Python3
点击展开代码
展开代码
LC23 - 合并K个升序链表
跟上一题基本没啥区别,降到两两合并即可:
Python3
点击展开代码
展开代码
快速排序
快速排序是非常经典的板子,大部分自带排序函数底层都是快排实现的,因为其效率很高。
快排模板
快排的本质,就是通过一次 partition,把 pivot 放到最终位置;再递归处理 pivot 左右两边。
先选一个 pivot,常见写法是默认选最后一个元素; 用 i 标记"小于等于 pivot 区域"的下一个写入位置; 用 j 从左到右扫描; 遇到 <= pivot 的数,就和 nums[i] 交换,然后 i 往后走; 最后把 pivot 和 nums[i] 交换。
Python3
点击展开代码
展开代码
LC215 - 数组中的第K个最大元素
利用快速选择的性质,可以在不用完全排序前找到第k大的元素,这个方法也叫快速选择排序,可以在O(n)内解决这个问题。
Python3
点击展开代码
展开代码
然而,这样写,力扣上会时间超限。这就很难受了,因为快选平均才能On,最坏要On方。我们有时候会加入随机来保证枢纽随便选:
Python3
点击展开代码
展开代码
但是,这样还是会超时。这个写法在有大量重复元素的时候会发生退化。最好的方式,是把<=和else的两路划分,变成三路划分,如下:
Python3
点击展开代码
展开代码
没错,这就是经典的荷兰国旗问题啊,三路划分让快排可以有效处理很多重复元素,直接把lt到gt之间的元素忽略掉。
二分递归
LC704 - 二分查找
经典二分查找,我们先直接写二分查找的板子:
Python3
点击展开代码
展开代码
但是放在了二分递归的板子里,是为了告诉你,这题也是非常符合递归的题:
Python3
点击展开代码
展开代码
本质上和迭代写法等效,就是去两边找,但是迭代的写法更容易理解,且没有空间消耗,所以尽量写迭代吧。
LC35 - 搜索插入位置
Python3
点击展开代码
展开代码
LC69 - x的平方根
Python3
点击展开代码
展开代码
LC50 - Pow(x, n)
这一题,是运用了快速幂的思想,将时间复杂度从O(n)降低到了O(logn):
Python3
点击展开代码
展开代码
分治统计
LC169 - 多数元素
本题属于答案一定在左或右,需要二次判断左右给出的信息。
多数元素的最优解法是投票法,我们让相同加票数,不同减票数,最后剩下的候选人就是多数元素。
Python3
点击展开代码
展开代码
不过就当是锻炼思维,这一题也是可以用分治法来做的。分治的思路是递归左半边的多数元素、右半边的多数元素,如果结果相同就直接返回,否则就在当前区间内分别统计它们出现次数,返回次数更多的那个。
Python3
点击展开代码
展开代码
LC53 - 最大子数组和
本题属于答案可能在左、右或跨中点的归并题。
这一题,每个数字要么接着前面的结果继续,要么另起炉灶,所以很容易就能想到动态规划来做。
Python3
点击展开代码
展开代码
这就是最好的方法了,但是,这题同样可以使用分治统计来做。最大子数组只有三种可能,要么最大子数组完全在左半边,要么完全在右半边,要么跨过中点。
Python3
点击展开代码
展开代码
这个方法的空间复杂度logn,时间复杂度nlogn。
然后,这个递归仍然可以优化,让时间变成on。需要返回每个区间返回四个信息:区间总和sum、区间最大后缀和prefix、区间最大后缀和suffix、区间最大子数组和best。
Python3
点击展开代码
展开代码
剑指Offer 51 - 数组中的逆序对
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
本题是归并的时候统计贡献,难度适合入门。合并的过程中不断判断left是否超过right,如果超过就是逆序。这里的i、j本身也就是合并有序数组的意思(本身这题就是归并排序的同时去做统计),然后一旦发现left[i]大于right[j],那么left后面所有的数字都能和right[j]构成逆序对,所以可以直接加个数为len(left)-i。
Python3
点击展开代码
展开代码
LC315 - 计算右侧小于当前元素的个数
本题依然属于归并过程中统计贡献题,比上一题难度高一点,核心思想是,在归并排序合并两个有序区间时,统计右半边有多少个数已经被放到当前左半边元素前面。
因为右半边的元素原本就在当前元素右侧。 如果某些右半边元素比左半边当前元素小,并且已经先被合并走了,那它们就应该计入答案。
注意为了保留原始下标,我们不能只排序数字,而要排序(value,index),本题还是归并排序。
Python3
点击展开代码
展开代码
有人可能会疑惑,只在归并左侧元素时更新 ans 足够吗?其实是足够的。只有这种情况会对答案造成贡献:
- 当前元素在左半边;
- 更小的元素在右半边;
- 右半边元素先于它被合并。
而拆分过程中,元素最终会被切成一个一个的元素,不会经过这个判断的,也只有右侧最后一个元素,而这个元素右侧小于它的数字个数必定是0,所以是完全足够的。
选择递归:每一步做选择
子集问题
LC78 - 子集
子集,实际上是一个每个元素可选可不选的问题,我们可以直接进行按位置的dfs。
Python3
点击展开代码
展开代码
除了这种递归方法之外,我们还可以遍历数组递归,以开头元素为基准dfs出所有情况。为了不回头看,用start来调整对应的位置即可:
Python3
点击展开代码
展开代码
总结一下,子集问题有两种常见递归视角。第一种是选/不选,每个元素都做一次二选一,答案在叶子节点收集。第二种是枚举下一个选择,从 start 开始向后选择一个元素加入 path,答案在每个递归节点收集。对于没有重复元素的 LC78,两种都可以;对于有重复元素的 LC90,for + start 写法更适合同层去重。
LC90 - 子集II
子集II与子集问题只有轻微不同,给的数组nums可能包含重复元素,但解集不能包含重复的子集,所以一个简单的想法,先排序,然后开始暴力求子集,并去重:
Python3
点击展开代码
展开代码
但是,这样写的效率很低,有很多次无用递归。如果学到了NSum对于去重的方法,那么这一题就不会这么生硬去重,我们排序后,对于每次选择同样元素开始dfs的,直接跳过。
组合问题
LC77 - 组合
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
换句话说,不重复的1-n,让你选长度为k的子集。这么说就明白了,跟子集问题几乎没区别:
Python3
点击展开代码
展开代码
LC39 - 组合总和
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
抓住两点:无重复、可复选。其他的dfs的条件变一下就行。
Python3
点击展开代码
展开代码
这么写是对的,不过效率还是可以继续提升,参考剪枝的方案,我们依旧先排序,然后再判断目前是否已经超过target。如果超过了,都不用继续递归进去。
Python3
点击展开代码
展开代码
LC40 - 组合总和II
组合总和II和I相比,多了每个数字仅能使用一次,然后少了数字不重复。因此,我们要对重复元素进行剪枝:
Python3
点击展开代码
展开代码
这里进行了两种剪枝,continue那里是去重剪枝,虽然这个数不能选,但是后面的可能可以选;而break是超过目标剪枝,因为已经排序完成了,如果超过target,后面肯定不能选了,直接break掉就行。
LC216 - 组合总和III
找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次 返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
翻译一下,用1-9(没有重复元素),不能重复用。其实很简单:
Python3
点击展开代码
展开代码
排列问题
LC46 - 全排列
全排列和子集问题的差距在于,可以回头选元素了。所以也不用维护start,每次都从头找。但是要额外维持一个seen,防止复选。
Python3
点击展开代码
展开代码
LC47 - 全排列II
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
也就是说,我们依旧是要进行去重了。但是,每一层都要从头扫,因为任意没用过的数都可能被选,所以我们不能能用start去重了,要改一下思路。
于是我们利用used去重,如果i-1的位置没有被使用,且i位置和i-1位置数字一样,那么这个数字也不能先用 — 换句话说,就是相同数字之间必须保持按原本顺序使用!所以就不会出现颠倒的两种情况。
Python3
点击展开代码
展开代码
切割问题
LC131 - 分割回文串
本题可以通过dfs,考虑到所有区间切分的情况,从而将所有符合条件的子区间加入到答案当中。
Python3
点击展开代码
展开代码
看起来,挺像每个位置依次去试,保证能覆盖所有字串。(下一个从end+1开始查)。从 start 位置开始,枚举下一刀切在哪里。如果 s[start+1] 是回文,就把这一段加入 path,然后递归处理 i+1 后面的部分。当 start == len(s),说明整个字符串切完了,得到一个合法方案。
如果只用二重循环,得到的是"局部合法片段",只有dfs到底,才能得到全局合法方案。
LC93 - 复原IP地址
这题同样是切分问题,判断每个数字是否在0-255之间,在的话才能加入列表,然后用.连接加入答案即可。
Python3
点击展开代码
展开代码
但是其实细看代码,还是有许多需要注意的逻辑点。首先,我们长度控制在三位及以内,我们应该将这个条件写在end循环中比较好;另外直接转为int,可能会有前导0问题,所以遇到前导0多位数的情况就可以不用继续往后判断了,直接删掉break。
棋盘问题
LC51 - N皇后
N皇后也是在试验每个皇后能放在哪,最终能放完所有皇后。但是与之前相比,变成了二维实验。
当然,由于每一行最多只能放一个皇后,所以不用二重循环,用dfs(row)表示给第row行放皇后即可。
一个典型的想法是维持visited数组,每次回溯。但是这样会更复杂一些,不如另一种方法优雅:我们直接用坐标关系,来判断是不是一个竖行、正对角线、副对角线,然后用集合来判断某竖、正负对角是否被用过即可。
Python3
点击展开代码
展开代码
LC52 - N皇后II
N皇后2只要统计解决方案的个数即可,正好可以再练一次手。
Python3
点击展开代码
展开代码
LC37 - 解数独
和上一题类似,我们用两个集合,rows字典和cols字典。我们可以先将所有需要填写的位置用一个数组存起来,然后再dfs去填写:
Python3
点击展开代码
展开代码
搜索路径问题
LC79 - 单词搜索
搜索类问题通常需要向多个方向进行dfs,如果能搜到底就成立。单词搜索的开头不固定,所以我们就二重循环定起点,然后开始dfs。
Python3
点击展开代码
展开代码
LC200 - 岛屿数量
进入dfs的入口写在外面,dfs的次数就代表联通区域:
Python3
点击展开代码
展开代码
LC695 - 岛屿的最大面积
求面积要将所有方向的dfs都加起来,然后在求岛模版上加一个最大面积判断即可。
Python3
点击展开代码
展开代码
LC130 - 被围绕的区域
这题也是多源 DFS,但思路要反过来。不要从每个 O 出发判断它是否被 X 包围,而是从边界上的 O 出发,把所有与边界连通的 O 标记为安全。因为只要一个 O 能连到边界,它所在的整个连通块就不能被翻转。最后遍历全图,仍然是 O 的位置说明无法连到边界,改成 X;被标记过的安全位置再改回 O。
Python3
点击展开代码
展开代码
dp的结果就从起点开始一直到终止条件,但是上面几题你可以看出来dp的设计很灵活,需要仔细体会。
LC417 - 太平洋大西洋水流问题
分桶与划分问题
LC698 - 划分为K个相等的子集
这一题和数组连续划分的区别在于,这一题要考虑任意子集划分,所以要使用的方法是分桶划分。
Python3
点击展开代码
展开代码
这个思想也很直接,我们从0号桶开始放,放不下了尝试后面的。如果都能放满,就返回True,否则这个方案就不行。剪枝点在于空桶都放不下的话,那直接False。
另外桶划分中,最好让失败趁早发生,也就是说,我们用快排,先把最长的元素塞进桶,早失败早退出。
LC473 - 火柴拼正方形
写了上一题就知道,这一题实际上还是四桶问题。可以直接写写看。
Python3
点击展开代码
展开代码
状态递归:递归函数表示一个状态
斐波那契与爬楼梯
LC509 - 斐波那契数
斐波那切数和爬楼梯应该是很多人动态规划开始的地方。所以我们在这里也写一下dp的方案。
Python3
点击展开代码
展开代码
其实,dp就是打表的递归问题,从而避免计算重复问题。会写dp,自然也会写递归:dp初始状态就是递归出口,状态转移公式就是递归函数,直接变化如下:
Python3
点击展开代码
展开代码
递归解法由于会重复计算,效率小于dp。为了不进行裸递归,我们可以使用python的缓存装饰器@cache,从而自动进行记忆化搜索。
Python3
点击展开代码
展开代码
LC70 - 爬楼梯
跟斐波那契几乎一样。
Python3
点击展开代码
展开代码
LC746 - 使用最小花费爬楼梯
和上一题类似,但是这题是看上楼梯的价值。我们尝试递归。
Python3
点击展开代码
展开代码
dp写法如下:
Python3
点击展开代码
展开代码
关键在于,这题上完所有楼梯之后还要登顶,等跳出所有楼梯再结算而不是上到最后一级台阶。
网格路径
LC62 - 不同路径
这类题目,同样最先想到的是二维dp,是二维dp的启蒙题。
Python3
点击展开代码
展开代码
dp的题本质上是记忆化递归,所以肯定也能写成递归形式。
Python3
点击展开代码
展开代码
LC63 - 不同路径II
与 不同路径 对比,多了一个障碍物的设计,路径不能从障碍物来,同样也不能到障碍物。
Python3
点击展开代码
展开代码
效率一样的情况下,这一题记忆递归效率更高,注意递归的时候要先判断下标越界再访问下标。
Python3
点击展开代码
展开代码
LC64 - 最小路径和
同样往下或者往右走,我们用 dfs(i,j) 来求解位置i、j的最大小路径。
Python3
点击展开代码
展开代码
代码更简洁,但是要注意花费应该什么时候被计算,这是很重要的,跟收费楼梯又是不一样的逻辑,还有就是越界到底应该怎么返回、传入的下标。这些都是容易爆的地方。
背包递归
0-1背包
完全背包
LC416 - 分割等和子集
分割成K个子集,是多桶问题,而这里的分割成两个等和子集,其实就是找出是不是有等于和一半的子集,可以将其理解为0-1背包,每个元素选或不选,最终能否达到target。
Python3
点击展开代码
展开代码
一开始用nonlocal来做curr_sum,实际上可以直接写在函数体内。然后就是第一次写用了nonlocal的flag,来表示有没有找到,这样没办法及时剪枝,从而报了TLE。我们需要用dfs来判断当前位置、当前和开始,能不能找到和为target的划分 , 用一个or连接,失败的条件是target或i超限,这是最好最干净的思路。
LC494 - 目标和
其实就是用dfs判断每一位是加还是减。
Python3
点击展开代码
展开代码
字符串递归
LC72 - 编辑距离
本题是经典的二维dp题,我们用dp(i,j)表示处理到了word1/2的位置(前面已经相等),但是有三种操作,分别是插入、删除、替换。删除之前需要i-1对上j,所以对应的操作是 dp[i-1][j] + 1;插入需要j-1位置对应上i,也就是 dp[i][j-1] + 1;替换需要i-1和j-1都对应上,也就是操作数 dp[i-1][j-1] + 1。我们取三者中的最小值,就能解决了。
Python3
点击展开代码
展开代码
本题用dp比较好想。
LC1143 - 最长公共子序列
我们在一维中经常做到这样的题目,现在要求两个字符串的最长公共子序列,我们依旧可以直接二维dp。
Python3
点击展开代码
展开代码
递归解法:
Python3
点击展开代码
展开代码
LC115 - 不同的子序列
子序列问题,判断s子序列中出现t的个数。我们可以直接对s进行dfs,判断符合条件的子序列:
Python3
点击展开代码
展开代码
然而,无脑dfs,必然超时了。这一题其实也是二维dp做法,加一个cache。我们可以将 dp[i][j] 定义为 s 从下标 i 开始的子串,能够匹配出多少个 t 从下标 j 开始的子串。
现在,我们来思考一下转移,这种问题我们要只考虑一个字母。首先,如果两个字母对上了,即 s[i] == t[j],那么我们有两种选择,要么直接从这开始匹配,dp[i][j] = dp[i+1][j+1],要么还是不选他,因为后面可能还有,所以是 dp[i][j] = dp[i][j+1]。至于不匹配,那就直接 dp[i][j] = dp[i+1][j+1]。(不过填表的过程需要你用当前dp看之前的dp,写成减号)。
我们来试试按照递归的思路来做,从最后一位开始看。逻辑和上述一样,初始状态是j回退到了0则为1,然后s回退到0即为0
Python3
点击展开代码
展开代码
LC10 - 正则表达式匹配
本题依旧是基于双指针的dp或者记忆化搜索。基础的规则,如果遇到相同的元素,我们 dp[i][j] = dp[i+1][j+1] ,这是很容易想到的。但是这一题多了.和*。
Python3
点击展开代码
展开代码
简单来说,待匹配元素为0来初始化dp表,然后.是一定放行,*分为两种情况,消除前面一位或者让前面一位一直重复。
博弈递归
LC486 - 预测赢家
从现在我们进入了博弈论 DP(Minimax 极小化极大算法),看代码的时候经常很难理解,因为这套递归中,隐藏了无缝的"视角切换"。之前的题,dfs主视角永远是自己、当前,而博弈论中,dfs变成一个高级模型,两人会轮流使用它得出自己的最优解。
我们只用dfs关心自己的分减去对手分的正负,也就是自己的优势。打个比方,我方选择left的时候,对手采用最优解得到的相对我的分数是 dfs(left+1,right),那么我想对于对手的最优解得到的分数增加就是 nums[left] - dfs(left+1, right)。
所以,实际上我们在两种选择,都会考虑相对于对手的最优解我们的最优解,最终只要判断从这过区间开始,第一个开始选的人能不能让优势大于0,就可以解决这题了。
Python3
点击展开代码
展开代码
LC877 - 石子游戏
这一题其实跟预测赢家一样,我们用dfs来表示对手可能产生的优势,按照相同方式求解。
Python3
点击展开代码
展开代码
不过,这一题可以直接用石子游戏题目结论秒杀,结论就是先手必胜。。直接return True。
构造递归:递归地生成答案
生成括号
LC22 - 括号生成
dfs生成答案的过程中,我们还要保证括号有效,所以我们需要left和right来维持有效性,可以用nonlocal更改或者直接当参数传入。
Python3
点击展开代码
展开代码
构造二叉树
LC105 - 从前序与中序遍历序列构造二叉树
树是天然递归结构,我用左右指针对准dfs的结果就行,dfs定义为完成构建的二叉树。同时,我们传入目前的前序和中序数组。
Python3
点击展开代码
展开代码
LC106 - 从中序与后序遍历序列构造二叉树
跟上一题如出一辙,我们记得按照方式划分即可。
Python3
点击展开代码
展开代码
LC654 - 最大二叉树
这一题直接就把构造的过程告诉你了,照猫画虎即可。
Python3
点击展开代码
展开代码
LC108 - 将有序数组转换为二叉搜索树
既然已经是有序数组了,我们每次取中间部分即可。和前面的题没啥区别:
Python3
点击展开代码
展开代码
序列化与反序列化
LC297 - 二叉树的序列化与反序列化
这一题其实可以通过特判空的bfs来解决,这也是带null数组建树的过程和逆过程,我们来实操一下。
Python3
点击展开代码
展开代码
但是既然放在这里,那就说明可以通过递归构造法来构造。实际上,我们只要先序遍历就能得到树的,虽然顺序不一样,但是序列化本来就是布置一种顺序,所以用dfs也是可以的。
Python3
点击展开代码
展开代码
LC449 - 序列化和反序列化二叉搜索树
有左小右大规律,可以靠值范围恢复结构。这样,我们就不需要null了,直接按照前序建立。然后,反序列化的过程,用BST的上界和下界值域约束:
Python3
点击展开代码
展开代码
数学递归:按公式定义问题
阶乘
经典递归入门。
Python3
点击展开代码
展开代码
最大公约数
正常做法是从1找到一半(或者平方根)能整除n的数。最大公约数最经典的递归算法是 欧几里得算法 ,核心结论是 gcd(a, b) = gcd(b, a % b) 。所以我们辗转相除,最终一定能得到结果:
Python3
点击展开代码
展开代码
快速幂
本来x的n次幂要乘n次,时间复杂度是On。但是如果我们存中间的过程,每次把问题减半,就会降低复杂度到Onlogn
Python3
点击展开代码
展开代码
面试题 08.06. 汉诺塔问题
在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制: (1) 每次只能移动一个盘子; (2) 盘子只能从柱子顶端滑出移到下一根柱子; (3) 盘子只能叠在比它大的盘子上。
请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。
汉诺塔问题也非常经典
这题是递归最经典的数学模型之一。移动 n 个盘子,看起来很复杂,但如果把最大的盘子单独拿出来看,事情就清楚了:
1. 先把上面的 n-1 个盘子借助目标柱移到辅助柱2. 再把最大的那个盘子移到目标柱3. 最后把辅助柱上的 n-1 个盘子借助起始柱移到目标柱也就是说,汉诺塔的本质是:
移动 n 个盘子 = 先解决 n-1 个盘子 + 移动最大的盘子 + 再解决 n-1 个盘子递归出口就是只剩一个盘子时,直接移动即可:
Python3
点击展开代码
展开代码
本题最少移动次数是 2^n - 1,时间复杂度为 O(2^n),空间复杂度为递归栈深度 O(n)。它特别适合帮助理解"把大问题拆成相同形式的小问题"这件事。
约瑟夫环
卡特兰数
递归优化
记忆化搜索
记忆化搜索,就是在递归的基础上加一个缓存,把已经算过的状态保存起来,避免重复计算。
它最适合这种场景:
递归状态很多次重复出现。典型比如斐波那契数列,朴素递归会反复计算 f(n-1)、f(n-2)。加上记忆化之后,就能把指数级复杂度压下来。
一个常见模板:
Python3
点击展开代码
展开代码
记忆化搜索本质上就是"自顶向下的动态规划"。
尾递归
尾递归指的是:递归调用是函数中的最后一步,当前层在递归返回后不再做额外运算。
比如下面这种就更接近尾递归:
Python3
点击展开代码
展开代码
不过在 Python 里,尾递归并不会像某些语言那样自动优化掉栈空间,所以更多是一个概念上的了解,不用特别执着去写尾递归风格。
剪枝
剪枝就是:发现当前这条递归分支不可能成为答案时,提前停掉,不再继续往下搜。
常见剪枝方式有:
- 超过目标值直接返回,比如组合总和
- 同层重复元素直接跳过,比如子集II、组合总和II、全排列II
- 当前状态已经不合法,立即返回,比如括号生成、N皇后
- 当前最优值已经不可能超过已有答案,直接停掉
回溯题很多时候能不能通过,差别就在剪枝是否及时。
递归转迭代
递归本质上是系统帮我们维护调用栈,所以很多递归都能改写成"显式维护栈"的迭代。
最常见的例子就是树遍历:
Python3
点击展开代码
展开代码
一般来说:
- 如果题目天然是树、图、回溯结构,递归通常更直观
- 如果数据规模很大,担心爆栈,或者逻辑本身就是线性的,迭代通常更稳
能不能从递归切到迭代,本质上就是看你能不能把"当前层还没做完的信息"自己存在栈里。
递归爆栈问题
当递归层数太深时,就会爆栈。最常见的情况有:
- 链表递归,但链表很长
- 树退化成链表
- DFS 图/网格时搜索深度过大
- 递归出口写错,导致无限递归
所以写递归题时,要特别注意:
- 出口是否一定能到
- 每次递归规模是否真的变小
- 最坏情况下递归深度是多少
Python递归深度限制
Python 默认递归深度不高,通常在千级左右。刷题时,如果递归层数可能达到几万,就要警惕。
有时候可以临时调整:
Python3
代码示例
但这不是万能解法。真正更稳的方式还是:
- 改迭代
- 优化递归结构
- 减少不必要的深度
递归题目的分类判断
看数据结构
如果题目本身给的是链表、树、图、网格,那么先想递归往往没错,因为这些结构天然具有"一个部分里还嵌着更小的同类部分"的特点。
- 链表:当前节点 + 后续链表
- 二叉树:当前节点 + 左右子树
- 网格/图:当前点 + 四周可达状态
看是否可以拆成子问题
如果一个问题能自然地写成:
当前问题 = 更小规模的同类问题 + 当前层处理那它大概率就能用递归。
比如:
- 反转链表:反转后续链表,再接回当前节点
- Pow(x, n):先求
x^(n//2),再平方 - 汉诺塔:先移动
n-1个盘子,再移动最大的
看是否需要枚举选择
如果每一步都有多个选择,而你需要把所有可能都试一遍,那通常就是回溯递归。
比如:
- 子集:选或不选
- 组合:下一个选哪个数
- 排列:当前位置放谁
- N皇后:当前行放哪一列
看是否存在重复子问题
如果不同路径会反复进入同一个状态,那就不要只写朴素递归了,应该进一步考虑记忆化搜索。
比如:
- 斐波那契
- 爬楼梯
- 编辑距离
- 预测赢家
这些题如果只递归不缓存,复杂度通常会很难看。
看是否需要回溯撤销选择
如果你在递归过程中修改了某些状态,并且回到上一层后还要恢复原状,那这就是典型回溯。
比如:
path.append(...)之后要path.pop()- 棋盘上放皇后后,回来要撤销
- 数独填数字后,回来要删掉
- 网格搜索里标记访问后,回来要恢复
递归常见模板
链表递归模板
链表递归常见于"返回处理后的链表头":
Python3
点击展开代码
展开代码
这类题要特别注意:改指针之前,先想清楚当前层到底返回什么。
二叉树递归模板
二叉树递归最常见的是后序汇总:
Python3
点击展开代码
展开代码
如果题目需要自顶向下传信息,也可以写成前序:
Python3
点击展开代码
展开代码
分治递归模板
分治递归就是"先拆,再治,最后合并":
Python3
点击展开代码
展开代码
如果题目涉及跨中点答案,就把"跨中点"也写在 merge 里考虑进去。
回溯递归模板
组合、排列、切割、棋盘题大多长这样:
Python3
点击展开代码
展开代码
如果是排列问题,通常没有 start,而是用 used 数组控制。
记忆化搜索模板
Python3
点击展开代码
展开代码
一旦题目有"区间 + 最优策略""字符串前缀 + 最优值""当前位置 + 剩余容量"这类重复状态,都可以先想这个模板。
DFS网格搜索模板
Python3
点击展开代码
展开代码
如果题目要求统计面积、路径数、是否可达,就把 return 的值改成对应的量即可。
递归问题总结
递归专题走到这里,最重要的其实不是记住多少题,而是形成几个稳定的直觉。
第一,先定义函数,不要先写代码。
这个递归函数到底返回什么?它处理的是哪个子结构、哪个区间、哪个状态?第二,出口一定要和函数定义匹配。
空节点返回什么?空区间返回什么?目标达成时返回什么?第三,很多递归题真正难的不是"怎么递",而是"怎么归"。
普通树题往往递下去不难,真正要想清楚的是:
- 回来的时候要汇总什么信息
- 是否要维护全局答案
- 是否需要在当前层做合并
第四,回溯题的关键不是会不会写 path.append() 和 path.pop(),而是:
这一层的选择范围是什么?什么情况该 continue?什么情况该 break?哪些状态要撤销?第五,分治题不要先急着看代码,先问:
左边返回什么?右边返回什么?当前层怎么合并?最后给自己留一个非常实用的判断口诀:
树和链表,先想结构递归;要试所有可能,先想回溯;问题能切两半,先想分治;状态重复出现,先想记忆化;网格四向扩散,先想 DFS。递归不是一类题,而是一种看问题的方式。只要能把"当前问题"写成"更小的同类问题 + 当前层处理",递归就自然出来了。
专题阅读
算法
这篇文章属于同一条阅读链。你可以直接在这里切换,不用再回到列表页重新找。
部分信息可能已经过时
留言区
留言
欢迎纠错、补充、交流。昵称和评论内容必填;如果你愿意,也可以留下联系方式,仅站主可见。