最近又重新开始刷算法题,这次刷题和之前不太一样, 之前刷题的时候,由于对整体宏观没有把握住,所以大多是以框架思维为主,按照从基本的数据结构到算法这样的专题思路进行刷题, 每学一个地方,就要先整理相关理论,然后对应的一些题目刷一遍。 这样的好处是长期下来会形成一个知识框架,后面复刷的时候会很容易梳理总结。 缺点就是太费时间, 我花了很长的一段时间(从上年9月开始吧),到现在刚整理到一半, 而现在转眼就到了新的一年的快二月份了, 感觉这个思路进行的太慢了, 这样搞下去根本看不到全貌了,所以这次重新一种思路重新刷, 根据第一遍,大体上知道了重点和常考题型, 所以这一次的话就先以重点切入,从牛客和剑指offer开始(面向面试刷题), 这个过程(只要框架有的)就不梳理,直接刷相关题目,如果框架没有的再反推到我的第一遍框架进行补充,这个好处是速度会提上来,但是缺点是不好成体系,即使基于第一遍的框架也不太容易记住思路, 所以我觉得比较好的方式就是这两种方式结合起来,进行思想提炼。 所以在第二遍的过程中,通过写博客的方式提炼出各个部分最关键的一些思路,代码来应对面试,这次没有第一遍的那么全(各种思路解法会过一遍),重点是分类梳理最底层的思维逻辑代码,形成类似于我自己的一套模板性的东西。
这次开刷从树结构开始,因为树结构的重要性不言而喻,以树为中心刷,既能带动链表,又能开启图,算法涉及递归,回溯,动规,贪心,分治等,所以这个结构巨重要。 而树里面二叉树又是重中之重, 所以这篇文章从二叉树的遍历开始 - 复习二叉树的前中后序的递归和非递归代码, 层序遍历之BFS, 这几个代码非常非常重要。牢牢的掌握这7个框架, 就可以搞掉二叉树这里大部分的题目(大约30道)。
这个是牛客上的二个题目
用这两个中规中矩题目来走一遍核心代码, 如果打通了这个逻辑,以不变应万变,可以解决树这块的很多内容。下面开始默写
前中后的递归写法都比较简单,但不能光记这个,非递归也需要会默写,非递归可以更好的帮助我们走遍历过程,另一方面一些变形题目可能涉及到一些中间过程,非递归处理起来会容易些。
不解释,直接上代码。
pre_res = []
def PreOrder(root, pre_res): # 根 和存储前序遍历结果的
if not root:
return
# 访问或处理根的逻辑
pre_res.append(root.val)
# 左节点存在,访问
if root.left:
PreOrder(root.left, pre_res)
# 右节点存在,访问
if root.right:
PreOrder(root.right, pre_res)
# 不用返回结果,因为上面的pre_res是全局变量
# 递归前序遍历
PreOrder(root, pre_res)
这里写的时候,脑海中也要这样的流程, 首先既然是递归实现的,我们就需要手动维护个栈, 这样按照前序遍历: 根 -> 左节点 -> 右节点的访问顺序, 当我们拿到根节点的时候, 需要先访问,然后入栈(为了将来访问右孩子), 然后去他的左孩子, 进行同样的逻辑操作。 当到了最左边没有左孩子的时候, 开始出栈顶部元素,然后去访问右孩子。 有了这样一个流程,我们转换成代码:
pre_res = []
def PreOrder(root, pre_res):
# 栈来
stack = []
# 开始判断
while root or stack: # 这个写法比较简单,前中后都可以用
# 如果当前节点不空, 访问,入栈,找左
while root:
# 访问或者处理根的逻辑
pre_res.append(root.val)
stack.append(pre_res)
root = root.left
# 上面循环退出,说明root此时是空,即到了最左边,此时栈顶就是最左边的叶子节点,出栈,访问右孩子
root = stack.pop()
root = root.right
# 不用返回结果,因为上面的pre_res是全局变量
# 非递归前序遍历
PreOrder(root, pre_res)
上面这两个模板虽然很简单,但是功能很强大, 一些变体,都可以基于这两个模板处理一些中间结果的。这次刷题如果遇到了,会统一整理上来。
不解释,直接上代码
in_res = []
def inorder(root, in_res):
if not root:
return
# 先访问左子树
if root.left:
inorder(root.left, in_res)
# 访问根 或者处理根逻辑
in_res.append(root.val)
# 访问右子树
if root.right:
inorder(root.right, in_res)
# 中序递归遍历
inorder(root, in_res)
这里脑海中要有这样的定式:
把上面的流程,翻译成代码:
in_res = []
def in_order(root, in_res):
# 栈来
stack = []
while root or stack:
# 去找他左孩子
while root:
stack.append(root)
root = root.left
# 但root为空,说明到了最左边了, 栈顶元素就是最左边的叶子,出栈访问
root = stack.pop()
# 访问或处理逻辑
in_res.append(root.val)
# 去右孩子
root = root.right
# 中序遍历非递归
inorder(root, in_res)
不解释,直接上代码
post_res = []
def postorder(root, post_res):
if not root:
return
if root.left:
postorder(root.left, post_res)
if root.right:
postorder(root.right, post_res)
# 访问根逻辑
post_res.append(root.val)
# 后序递归遍历
postorder(root, post_res)
这个相比上面两个来说稍微麻烦一点, 但只要判别出当我们到了某个根节点之后,我们是从左边回去的,还是从右边回去的,其实就可以了。 所以下面的一个定式:
上面这个题目阿里曾经有个面试题考过,就是仅仅用栈来实现后序的非递归遍历,不能借助其他结构。下面我们写代码吧:
post_res = []
def post_order(root, post_res):
# 栈来
stack = []
# 判断
while root or stack:
# 找第一个叶子节点,这里看小技巧
while root:
stack.append(root)
root = root.left if root.left else root.right # 关键就在这里
# 这样root为空的时候,栈顶就不是最左边了,而是第一个应该访问的叶子,出栈访问它
root = stack.pop()
post_res.append(root.val)
# 下面判断是它父亲的左节点还是右节点
if stack and stack[-1].left == root: # 是它父亲的左孩子
root = stack[-1].right # 直接访问它父亲右孩子
else:
root = None # 否则,说明这个分支完事了,下轮直接访问它父亲即可
# 后序非递归
postorder(root, post_res)
这样,前中后的递归和非递归梳理完毕, 这里的非递归算法中规中矩,虽然这三个有点小小的不统一,但完全是模拟了遍历过程。 这样写可以真正的去掌握思想, 虽然看到题解里面有很多写法都把前中后的非递归给统一了起来,类似递归的形式(我第一轮整理过),但这次发现那种写法虽然好记一点,但已经迷失了遍历的过程(尤其是后序,没有这种最后访问根的感觉了), 这样可能不利于迁移到别的题目上去,也就是仅仅停留在这样的遍历过程,一旦遇到变形,处理中间的某个逻辑的时候,可能就乱了套。所以这里我不打算采用那种方式了,中规中矩,掌握核心思想才是王道。
树的层序遍历属于bfs的范畴了,bfs两个重大应用场景:层序遍历和最短路径。
这里首先定式, 提到bfs先想到队列, dfs先想到栈。关于层次遍历是怎么回事,这里不解释, 这里整理两个模板:
baseline模板, 这个就是传统的bfs遍历模板,巨重要:
def bfs(root):
if not root:
return
d = collections.deque([])
d.append(root)
while d:
root = d.popleft()
if root.left:
d.append(root.left)
if root.right:
d.append(root.right)
但这个无法区分队列中的节点来自哪一层。所以这个还不能直接拿来做二叉树的层序遍历。
层序遍历要求的输入结果和 BFS 是不同的。层序遍历要求我们区分每一层,也就是返回一个二维数组。而 BFS 的遍历结果是一个一维数组,无法区分每一层。
所以层序遍历的话, 我们需要在每一层遍历开始前,先记录队列中的结点数量 n n n(也就是这一层的结点数量),然后一口气处理完这一层的 n n n个结点。上代码:
from collections import deque
level_res = []
def level_order(root, level_res):
if not root:
return level_res
# 队列来
d = dequne([root])
while d:
# 先统计当前层的节点个数
size = len(d)
# 记录当前层的几点结果
level_tmp = []
# 遍历当前层所有节点
for _ in range(size):
root = d.popleft()
# 节点处理逻辑
level_tmp.append(root.val)
# 左节点存在,入队
if root.left:
d.append(root.left)
# 右节点存在,入队
if root.right:
d.append(root.right)
# 把当前层结果加入总结果
level_res.append(level_tmp)
# 层序遍历
level_order(root, level_res)
有了这个模板, 像后面的层序遍历题目统统一网打尽。
下面开始展示这7个框架的威力。
single_sum
来统计每条路径上的和,当然不是简单的加和,每下去一层,要乘10,处理逻辑的核心代码single_sum = single_sum * 10 + root.val
, 如果是叶子的时候, 加完之后更新全局的和即可。sum
,更新结果即可。当然,每层的处理逻辑那里还需要保存每一步的路径值, 再加一个path
参数, 这个当然有点回溯的味道了, 因为path那个地方不能加在当前层处理逻辑哪里, 要加到下一层的参数那,否则就需要回溯中序遍历这里能够体现其强大的就是结合着二叉搜索树来看, 因为二叉搜索树的中序遍历是递增的, 这里通过LeetCode98: 验证二叉搜索树这个题目,就能看到中序遍历的强大之处。
中序遍历一遍二叉树, 如果遍历途中发现当前节点的值比前面节点的值小, 那么就不是一棵二叉搜索树。递归框架拿过来, 然后修改当前层的逻辑
class Solution:
def __init__(self):
self.pre = float('-inf')
def isValidBST(self, root: TreeNode) -> bool:
if not root:
return True
# 判断左子树是否二叉搜索树
if not self.isValidBST(root.left):
return False
# 当前层的处理逻辑,如果前面那个数大于当前数,不是
if root.val <= self.pre:
return False
self.pre = root.val
return self.isValidBST(root.right)
这个题目用中序遍历的非递归框架感觉更好看一些,完全非递归框架, 修改当前节点的处理逻辑:
def isValidBST(self, root: TreeNode) -> bool:
stack = []
pre = float('-inf')
while root or stack:
# 去找最左边的节点
while root:
stack.append(root)
root = root.left
# 出栈栈顶元素,看看是否小于它前面的节点值,然后更新前面的节点值
root = stack.pop()
# 当前节点的处理逻辑
if root.val <= pre:
return False
pre = root.val
root = root.right
return True
这两个框架不仅能判断是否是二叉搜索树, 还能在二叉搜索中, 直接找最小的第K个元素LeetCode230. 二叉搜索树中第K小的元素,也是只需要修改当前节点的处理逻辑
class Solution:
def isValidBST(self, root: TreeNode, k:int) -> int:
stack = []
while root or stack:
# 去找最左边的节点
while root:
stack.append(root)
root = root.left
root = stack.pop()
k -= 1
if k == 0:
return root.val
# 去右边
root = root.right
递归更简洁, 也是只需要修改当前逻辑
class Solution:
def __init__(self):
self.res = None
self.k = None
def kthSmallest(self, root: TreeNode, k: int) -> int:
self.k = k
def inorder(root):
if not root: return
inorder(root.left)
if self.k == 0: return # 这里需要剪枝
self.k -= 1
if self.k == 0:
self.res = root.val
inorder(root.right)
inorder(root)
return self.res
不过这种需要一个全局的变量来控制。
类似的变式题目: 剑指 Offer 54. 二叉搜索树的第k大节点, 这个题目也是中序遍历的非递归框架,只不过这个比较巧妙的是反着用了,也就是先遍历右,再去遍历左,这时候才能找第k大。
class Solution:
def kthLargest(self, root: TreeNode, k: int) -> int:
stack = []
while root or stack:
while root: # 先去右
stack.append(root)
root = root.right
root = stack.pop()
k -= 1
if k == 0:
return root.val
# 这时候去左
root = root.left
当然二叉搜索中的这种第K大和第K小还有种比较清晰的思路,就是先中序遍历存到数组,这是个递增序列,然后直接返回相应的数也行,只不过时间会花费的多一些。
可以看到, 底层思维框架的重要性, 一套框架,瞬间搞定了二叉搜索树三个题目, 还没爽完? 下面继续爽,哈哈。
defaultdict(int)
字典, 如果键没在字典里会自动添加且默认0, 在collections包里面。但如何体现中序递增的优势呢? 这个之前也想过,就是用边统计边更新的思路统计,但这样只能找出一个众数,找不出集合来。 于是看了波题解,发现了新思路,依然是完全的中序遍历非递归框架,通过修改处理逻辑,可以遍历一次找出所有的众数。 这种思路学习一下:leetcode 236, 剑指offer 68, 牛客: 二叉树的最近公共祖先:完全后序遍历的非递归模板,处理当前逻辑那块保存p和q的祖先
Leetcode110. 判断是否平衡二叉树:后序遍历的递归方式, 自底向上, 这里还应该明白的一点是二叉树深度和二叉树高度其实不是一个东西的, 看了人家的题解才发现这个问题, 可以参考这个题解, 求深度适合用前序遍历, 而求高度适合用后序遍历
LeetCode 226: 翻转二叉树: 后序遍历的递归方式, 修改当前层的处理逻辑即可
LeetCode 104: 二叉树的最大深度: 求一棵树高度的代码, 后序遍历的递归模板,每棵树需要返回高度值。有了这个题,再去拓展,就是N叉树的最大高度思路, 需要修改中间这个,毕竟N叉树要遍历分叉。最大深度就是求根节点的高度
LeetCode559: N叉树的最大深度: 这个和上面这个基本上一模一样, 把上面的这个子树改成遍历节点孩子的操作即可。
LeetCode111: 二叉树的最小深度: 这个题目后序遍历也可以搞定, 需要改返回结果时的逻辑, 由于这里是最小, 那么返回结果的时候,如果两个子树都不是空, 那么最小深度为左右子树的最小深度+1, 但是这里要注意如果其中有一个为空, 最小深度为不为空的那个深度+1, 这是和求最大深度不一样的地方
可以发现,这种框架式的底层思维,只需要改一小部分代码就能解决很多问题。
Leetcode222: 完全二叉树的节点个数: 完全的后序遍历模板, 和最大深度的一样,也需要返回值。
从上面发现了一个类似规律的小问题, 发现绝大多数后序遍历的题目都得需要返回值, 也就是先左右,后中, 然后跟着中的处理逻辑的时候有返回值。 而前序遍历那里一般递归函数里面不用返回值,一般都是全局遍历进行保存。当然,也不一定,这里也拿代码随想录里面总结的一句话作为参考:
如果需要搜索整颗二叉树,那么递归函数就不要返回值,如果要搜索其中一条符合条件的路径,递归函数就需要返回值,因为遇到符合条件的路径了就要及时返回。
感觉还是看具体的需求吧,这句话说的也不是那么绝对。