一、递归
递归:在定义一个过程或函数时,出现本过程或本函数的成分称为递归。
递归条件:可以用递归解决的问题应该满足以下三个条件:
- 这个问题可以转化为一个或多个子问题来求解,而且这些子问题的求解方法与原问题完全相同
- 递归调用的次数是有限的
- 必须有终止条件
递归的底层原理: 函数调用操作包括从一块代码到另一块代码之间的双向数据传递和执行控制转移,大多数CPU使用栈来支持函数调用。单个函数调用操作所使用的的函数调用栈被称为栈帧。每次函数调用都会相应地创建一帧,返回函数地址、函数实参和局部变量值等,并将该帧压入调用栈。若该函数在返回之前又发生了新的调用,则将新函数对于的帧压入栈,成为栈顶。函数一旦执行完,对应的帧边出栈,控制权交给该函数的上层调用函数,并按照该帧保存的返回地址,确定程序中继续执行的位置。
递归问题一般解决步骤:
- 对问题 f ( s n ) f(s_{n}) f(sn)进行分析,假设出合理的小问题 f ( s n − 1 ) f(s_{n-1}) f(sn−1)
- 假设小问题 f ( s n − 1 ) f(s_{n-1}) f(sn−1)是可解的,在此基础上确定大问题 f ( s n ) f(s_{n}) f(sn)的解,即给出 f ( s n ) f(s_{n}) f(sn)与 f ( s n − 1 ) f(s_{n-1}) f(sn−1)之间的关系
- 确定一个特殊情况(如 f ( s 1 ) f(s_{1}) f(s1)或 f ( s 0 ) f(s_{0}) f(s0)的解),由此作为递归出口
时间复杂度: 时间复杂度
O
(
T
)
O(T)
O(T)通常是递归调用的数量(记作
R
R
R) 和计算的时间复杂度的乘积(表示为
O
(
s
)
O(s)
O(s))的乘积:
O
(
T
)
=
R
∗
O
(
s
)
O(T)=R*O(s)
O(T)=R∗O(s)
空间复杂度: 在计算递归算法的空间复杂度时,应该考虑造成空间消耗的两个部分:递归相关空间(recursion related space)和非递归相关空间(non-recursion related space)
代码模板:
def recursion(level, param1, param2, ...):
# 递归终止条件
if level > MAX_LEVEL:
return result
# 当前level的逻辑
process_data(level, data)
# 递归
self.recurison(level, param1, param2, ...)
# 如果必要的话,还原此层的状态
reverse_state(level)
二、例题
1、两两交换链表中的节点
此题为leetcode第24题。示例:给定 1->2->3->4, 你应该返回 2->1->4->3
。我们假设有一个链表,已经走到了中间某个节点,当前节点为head,这个一般情况可以表示如下:
我们希望head
和next
能够互换,希望达到如下效果:
- 我们希望
head
和next
互换,互换后head
指向后面,next
变为当前子链表的头结点 - 交换后,
head
应该指向后面的子链表,而后面的子链表应该是交换完成了的,也就是说,后面的子链表两两交换是个子问题,解决方式和当前的方式一样,所以这个子链表的头结点(即head.next.nex
应该传入递归函数中) - 因为交换后
next
为当前子链表的头结点,我们用一个指针指向它,即res=head.next
,交换完后我们返回res
即可,因为当前子链表也是上一层递归调用的子问题,我们要返回当前链表的头结点 - 交换的过程:首先我们需要将
head.next
指向后面子问题返回的头结点,然后next
指向head
,即res.next=head
- 交换完后,返回此时当前子链表的头结点
res
即可 - 终止条件:当
head=None
(无节点)或head.next=None
(此时为最后一个节点)时,直接返回head
class Solution:
def swapPairs(self, head: ListNode) -> ListNode:
# 终止条件
if head == None or head.next == None:
return head
# 交换后的头结点
res = head.next
# 子问题递归,head指向子问题返回来的节点
head.next = self.swapPairs(head.next.next)
# next指向head
res.next = head
return res
- 时间复杂度: O ( N ) O(N) O(N),其中 N N N 指的是链表的节点数量
- 空间复杂度: O ( N ) O(N) O(N),递归过程中使用的堆栈空间
2、反转链表
此题为leetcode第206题。
示例:输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL
假设我们有如下链表:
当前节点为head
,假设子问题返回的链表已经两两反转,我们希望节点2能指向节点1,节点1指向None
。递归过程如下图所示:
- 当前节点为
head
,我们期望后续的子链表指向head
,而head
指向None
。那么后续子链表的两两交换问题为子问题。 - 子问题里的子链表节点两两交换后,原来的尾节点(图中的节点4)变成了头结点,并且要返回这个头结点;原来的头结点(图中的节点2)变为了尾节点,并且要指向
None
,因为尾节点最后都要指向None
。注意此时head
节点依然是指向节点2的,因为子问题并没有改变head
的指向 - 交换过程:需要将节点2指向节点1,即
head.next.next=head
,然后head
指向None
- 终止条件:
head=None
(无节点)或head.next=None
(最后一个节点)时,直接返回head
此题也可以用迭代来解决,设置两个指针prev和curr分别指向当前节点和前一个节点,我们需要将curr.next指向前一个节点prev,然后prev变为curr,curr指向curr.next。不断重复迭代这个过程,最后返回curr。
# 递归
class Solution:
def reverseList(self, head: ListNode) -> ListNode:
# 终止条件
if not head or not head.next:
return head
# 子问题递归,返回交换完后的子链表的头结点
res = self.reverseList(head.next)
# 交换
head.next.next = head
head.next = None
return res
- 时间复杂度: O ( N ) O(N) O(N),其中 N N N 指的是链表的节点数量
- 空间复杂度: O ( N ) O(N) O(N),递归过程中使用的堆栈空间
# 迭代
class Solution:
def reverseList(self, head: ListNode) -> ListNode:
prev, cur = None, head
while cur:
cur.next, prev, cur = prev, cur, cur.next
return prev
3、反转链表II
此题为leetcode第92题
思路:我们先考虑反转前n个节点的方法,过程如下:
base case为n == 1,反转一个元素,就是它本身,同时要记录后驱节点successor。上一个题我们直接把 head.next 设置为none,因为整个链表反转后原来的head 变成了整个链表的最后一个节点。但现在head节点在递归反转之后不一定是最后一个节点了,所以要记录后驱 successor(第 n + 1 个节点),反转之后将head 连接上。
如果m == 1时直接调用上面的过程。当m != 1时,我们把 head 的索引视为 1,那么我们是想从第 m 个元素开始反转;如果把 head.next 的索引视为1,那么相对于head.next,反转的区间应该是从第 m - 1 个元素开始的;那么对于 head.next.next就是m – 2,直到等于1,可以递归实现。
class Solution:
def reverseBetween(self, head: ListNode, m: int, n: int) -> ListNode:
def reverseN(head, n):
if n == 1:
self.successor = head.next
return head
if not head or not head.next:
return head
last = reverseN(head.next, n - 1)
head.next.next = head
head.next = self.successor
return last
if m == 1:
return reverseN(head, n)
head.next = self.reverseBetween(head.next, m - 1, n - 1)
return head
4、斐波那契数列
记忆化
此题为leetcode第509题。斐波那契数列有如下递归关系:
f
(
0
)
=
0
,
f
(
1
)
=
1
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
2
)
,
n
>
1
f(0)=0, f(1)=1 \\ f(n)=f(n-1) + f(n-2),n>1
f(0)=0,f(1)=1f(n)=f(n−1)+f(n−2),n>1
给定
n
n
n计算
f
(
n
)
f(n)
f(n)。以计算
f
(
4
)
f(4)
f(4)为例,我们可以得到:
f
(
4
)
=
f
(
3
)
+
f
(
2
)
=
f
(
2
)
+
f
(
1
)
+
f
(
1
)
+
f
(
0
)
=
f
(
1
)
+
f
(
0
)
+
f
(
1
)
+
f
(
1
)
+
f
(
0
)
f(4)=f(3)+f(2)=f(2)+f(1)+f(1)+f(0)=f(1)+f(0)+f(1)+f(1)+f(0)
f(4)=f(3)+f(2)=f(2)+f(1)+f(1)+f(0)=f(1)+f(0)+f(1)+f(1)+f(0),把这个递归过程画出来如下图所示:
按说这个很容易找到子问题,并且终止条件也明确,但是我们观察发现,这其中有很多重复的计算,比如
f
(
2
)
f(2)
f(2)重复了两次,这会导致内存占用较多。为此,我们可以将中间结果暂存起来,到时候直接用就可以。
class Solution:
def fib(self, N: int) -> int:
# 缓存字典
catch = {}
def rec(N):
# 若N在缓存中,直接返回对应值
if N in catch:
return catch[N]
# N小于2时直接返回自己
if N < 2:
res = N
else:
res = rec(N-1) + rec(N-2)
# 放入缓存中
catch[N] = res
return res
return rec(N)
- 时间复杂度: O ( N ) O(N) O(N)
- 空间复杂度: O ( N ) O(N) O(N),缓存空间大小
注意这里我们使用了一个额外的函数rec(N)
,然后直接在fib
函数中直接返回rec(N)
,我们称这样的递归为尾递归:尾递归函数是递归函数的一种,其中递归调用是递归函数中的最后一条指令。并且在函数中应该只有一次递归调用。 尾递归的好处是,它可以避免递归调用期间栈空间开销的累积,因为系统可以为每个递归调用重用栈中的固定空间。
5、合并两个有序链表
此题为leetcode第21题。示例:输入:1->2->4, 1->3->4 输出:1->1->2->3->4->4
。我们假设有如下两个有序链表:
设l1
指向了第一个链表中的某个位置,l2
指向了第二个链表中的某个位置,我们比较这两个节点的大小。如果l1<l2
,那么l1
应该在l2
之后,l2
应该和l1
之后的子链表继续比较,由此形成子问题。如果l1>l2
,那么l2
应该在l1
之后,l1
继续和l2
之后的子链表比较。整个过程如下所示:
- 若
l1<l2
,则将l1.next
和l2
传入递归函数中,将l1
指向递归函数返回的节点;反之将l2.next
和l1
传入递归函数中,l2
指向递归函数返回的节点。 - 当
l1=None
时,说明l1
已提前遍历完(或l1
本来为空),l2
剩下的也不用比较了,直接返回l2
;同理l2=None
时,直接返回l1
。
当然此题也可以用迭代法解决。
# 递归解法
class Solution:
def mergeTwoLists(self, l1, l2):
if l1 is None:
return l2
if l2 is None:
return l1
if l1.val <= l2.val:
l1.next = self.mergeTwoLists(l1.next, l2)
return l1
if l1.val > l2.val:
l2.next = self.mergeTwoLists(l1, l2.next)
return l2
# 迭代解法
class Solution:
def mergeTwoLists(self, l1: ListNode, l2: ListNode) -> ListNode:
head = res = ListNode(0)
while l1 and l2:
if l1.val > l2.val:
head.next = l2
l2 = l2.next
elif l1.val <= l2.val:
head.next = l1
l1 = l1.next
head = head.next
if l1:
head.next = l1
if l2:
head.next = l2
return res.next
- 时间复杂度:
O
(
n
+
m
)
O(n + m)
O(n+m)。因为每次递归调用都会将指向
l1
或l2
的指针递增一次(逐渐接近每个列表末尾的 null),所以每个列表中的每个元素都会对mergeTwoLists
进行一次调用。 因此,时间复杂度与两个列表的大小之和是线性相关的。 - 空间复杂度:
O
(
n
+
m
)
O(n + m)
O(n+m)。一旦调用
mergetwolist
,直到到达l1
或l2
的末尾时才会返回,因此 n + m n + m n+m的栈将会消耗 O ( n + m ) O(n + m) O(n+m) 的空间。
6、Pow(x, n)
此题为leetcode第50题
思路:这道题要考虑两点:n为负数和n为奇偶数的情况。如果n为负数,那么要先将x取倒数,再将n变为正数。如果n为偶数,那么直接将x * x和n / 2传入下层递归。如果n为奇数,那么将x和n – 1传入下层递归,返回的数值再乘以x。此题也可以写成迭代的形式。
# 递归
class Solution:
def myPow(self, x: float, n: int) -> float:
if n == 0:
return 1
if n < 0:
return 1. / self.myPow(x, -n)
if n % 2 == 1: # n为奇数
return x * self.myPow(x, n - 1)
if n % 2 == 0: # n为偶数
return self.myPow(x * x, n / 2)
# 迭代
class Solution:
def myPow(self, x: float, n: int) -> float:
if n < 0:
x = 1. / x
n = -n
res = 1
while n:
if n % 2 == 1:
res = res * x
x = x * x
n = n // 2
return res
7、从前序与中序遍历构造二叉树
此题为leetcode第105题
思路:对于前序遍历来说,第一个元素即为根节点,记为pre_root。因为题中没有重复元素,我们可以在中序遍历中找到这个根节点,记为in_root。根据中序遍历的特点,in_root的左边都为根节点的左子树,in_root的右边都为根节点的右子树。同时根据前序遍历的特点,根节点的右边为左子树和右子树的组合。如果能找到前序遍历中关于左子树的子序列和中序遍历中左子树的子序列,将它们送入下层递归,即可得到左子树的根节点。同理,找到前序遍历中右子树的子序列和中序遍历中的右子树子序列,将它们送入下层递归,即可得到右子树的根节点。我们设每次递归时传入前序后序遍历的左右边界,即pre_left、pre_right、in_left和in_right。根据pre_left可以找到根节点在中序遍历中的位置in_root,那么左子树子序列的长度为size_left = in_root – in_left。根据这个size_left可以得到前序、中序遍历中左子树的右子树左右边界,然后将它们送入下层循环。
class Solution:
def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
def helper(pre_left, pre_right, in_left, in_right):
# 叶子节点
if pre_left > pre_right:
return None
# 前序遍历的pre_left就是根节点
pre_root = pre_left
# 在中序遍历中定位根节点
in_root = index_map[preorder[pre_root]]
# 建立根节点
root = TreeNode(preorder[pre_root])
# 得到左子树节点个数
size_left = in_root - in_left
# 递归地构造左子树,并连接到根节点的left
root.left = helper(pre_left + 1, pre_left + size_left, in_left, in_root - 1)
# 递归地构造右子树,并连接到根节点的right
root.right = helper(pre_root + size_left + 1, pre_right, in_root + 1, in_right)
return root
# 构建哈希表,快速定位根节点在中序遍历中的index
index_map = {val: i for i, val in enumerate(inorder)}
n = len(preorder)
return helper(0, n - 1, 0, n - 1)
8、正则表达式匹配
此题为leetcode第10题
思路:定义self.isMatch(s, p)方法返回字符串s和字符串p是否匹配,根据不同的条件递归地调用这个方法,具体条件和动态规划解法的条件几乎一样,动态规划题解点这里
class Solution:
def isMatch(self, s: str, p: str) -> bool:
if not p:
return not s
if len(p) > 1 and p[1] == '*':
if s and (s[0] == p[0] or p[0] == '.'):
return self.isMatch(s[1:], p) or self.isMatch(s, p[2:])
else:
return self.isMatch(s, p[2:])
elif s and (s[0] == p[0] or p[0] == '.'):
return self.isMatch(s[1:], p[1:])
return False
9、二叉树剪枝
此题为leetcode第814题
思路:我们将当前节点root的左右子树分别作为子问题递归地传入下一层,返回的是剪枝后的子树pruned_left和pruned_right。如果哪个返回的子树为None,说明它被剪枝了,那么修改对应的root的子树为None。当返回的子树都至少有一个不为空且root.val == 1时,直接返回root。否则说明以当前节点为根节点的子树都不含1,应该被剪枝,返回None。
class Solution:
def pruneTree(self, root: TreeNode) -> TreeNode:
if root is None:
return None
pruned_left = self.pruneTree(root.left)
pruned_right = self.pruneTree(root.right)
if pruned_left is None:
root.left = None
if pruned_right is None:
root.right = None
if root.val == 1 or pruned_left is not None or pruned_right is not None:
return root
else:
return None