最近有粉丝和我交流面试遇到的算法题。其中有一道题比较有意思,分享给你们。前端
ta 说本身面试了一家某大型区块链的公司的前端岗位,被问到了一道算法题。这道题也是一个很是常见的题目了,力扣中也有原题 110. 平衡二叉树,难度为简单。java
不过面试官作了一点点小的扩展,难度瞬间升级了。咱们来看下面试官作了什么扩展。node
题目是《判断一棵树是否为平衡二叉树》,所谓平衡二叉树指的是二叉树中全部节点的左右子树的深度之差不超过 1。输入参数是二叉树的根节点 root,输出是一个 bool 值。git
代码会被以以下的方式调用:github
console.log(isBalance([3, 9, 2, null, null, 5, 5])); console.log(isBalance([1, 1, 2, 3, 4, null, null, 4, 4]));
求解的思路就是围绕着二叉树的定义来进行便可。面试
对于二叉树中的每个节点都:算法
能够看出咱们的算法就是死扣定义。数组
计算节点深度比较容易,既可使用前序遍历 + 参考扩展
的方式,也可以使用后序遍历
的方式,这里我用的是前序遍历 + 参数扩展。网络
对此不熟悉的强烈建议看一下这篇文章 几乎刷完了力扣全部的树题,我发现了这些东西。。。
因而你能够写出以下的代码。数据结构
function getDepth(root, d = 0) { if (!root) return 0; return max(getDepth(root.left, d + 1), getDepth(root.right, d + 1)); } function dfs(root) { if (!root) return true; if (abs(getDepth(root.left), getDepth(root.right)) > 1) return false; return dfs(root.left) && dfs(root.right); } function isBalance(root) { return dfs(root); }
不难发现,这道题的结果和节点(TreeNode) 的 val 没有任何关系,val 是多少彻底不影响结果。
能够仔细观察题目给的使用示例,会发现题目给的是 nodes 数组,并非二叉树的根节点 root。
所以咱们须要先构建二叉树。 构建二叉树本质上是一个反序列的过程。要想知道如何反序列化,确定要先知道序列化。
而二叉树序列的方法有不少啊?题目给的是哪一种呢?这须要你和面试官沟通。颇有可能面试官在等着你问他呢!!!
咱们先来看下什么是序列化,如下定义来自维基百科:
序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另外一台计算机环境中,能恢复原先状态的过程。依照序列化格式从新获取字节的结果时,能够利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不归纳以前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操做,是反序列化(也称为解编组、deserialization、unmarshalling)。
可见,序列化和反序列化在计算机科学中的应用仍是很是普遍的。就拿 LeetCode 平台来讲,其容许用户输入形如:
[1,2,3,null,null,4,5]
这样的数据结构来描述一颗树:
([1,2,3,null,null,4,5] 对应的二叉树)
其实序列化和反序列化只是一个概念,不是一种具体的算法,而是不少的算法。而且针对不一样的数据结构,算法也会不同。
阅读本文以前,须要你对树的遍历以及 BFS 和 DFS 比较熟悉。若是你还不熟悉,推荐阅读一下相关文章以后再来看。或者我这边也写了一个总结性的文章二叉树的遍历,你也能够看看。
咱们知道:二叉树的深度优先遍历,根据访问根节点的顺序不一样,能够将其分为前序遍历
,中序遍历
, 后序遍历
。即若是先访问根节点就是前序遍历,最后访问根节点就是后序遍历,其它则是中序遍历。而左右节点的相对顺序是不会变的,必定是先左后右。
固然也能够设定为先右后左。
而且知道了三种遍历结果中的任意两种便可还原出原有的树结构。这不就是序列化和反序列化么?若是对这个比较陌生的同窗建议看看我以前写的《构造二叉树系列》
有了这样一个前提以后算法就天然而然了。即先对二叉树进行两次不一样的遍历,不妨假设按照前序和中序进行两次遍历。而后将两次遍历结果序列化,好比将两次遍历结果以逗号“,” join 成一个字符串。 以后将字符串反序列便可,好比将其以逗号“,” split 成一个数组。
序列化:
class Solution: def preorder(self, root: TreeNode): if not root: return [] return [str(root.val)] +self. preorder(root.left) + self.preorder(root.right) def inorder(self, root: TreeNode): if not root: return [] return self.inorder(root.left) + [str(root.val)] + self.inorder(root.right) def serialize(self, root): ans = '' ans += ','.join(self.preorder(root)) ans += '$' ans += ','.join(self.inorder(root)) return ans
反序列化:
这里我直接用了力扣 105. 从前序与中序遍历序列构造二叉树
的解法,一行代码都不改。
class Solution: def deserialize(self, data: str): preorder, inorder = data.split('$') if not preorder: return None return self.buildTree(preorder.split(','), inorder.split(',')) def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: # 实际上inorder 和 preorder 必定是同时为空的,所以你不管判断哪一个都行 if not preorder: return None root = TreeNode(preorder[0]) i = inorder.index(root.val) root.left = self.buildTree(preorder[1:i + 1], inorder[:i]) root.right = self.buildTree(preorder[i + 1:], inorder[i+1:]) return root
实际上这个算法是不必定成立的,缘由在于树的节点可能存在重复元素。也就是说我前面说的知道了三种遍历结果中的任意两种便可还原出原有的树结构
是不对的,严格来讲应该是若是树中不存在重复的元素,那么知道了三种遍历结果中的任意两种便可还原出原有的树结构。
聪明的你应该发现了,上面个人代码用了 i = inorder.index(root.val)
,若是存在重复元素,那么获得的索引 i 就可能不是准确的。可是,若是题目限定了没有重复元素则能够用这种算法。可是现实中不出现重复元素不太现实,所以须要考虑其余方法。那到底是什么样的方法呢?
答案是记录空节点。接下来进入正题。
咱们来模仿一下力扣的记法。 好比:[1,2,3,null,null,4,5]
(本质上是 BFS 层次遍历),对应的树以下:
选择这种记法,而不是 DFS 的记法的缘由是看起来比较直观。并不表明咱们这里是要讲 BFS 的序列化和反序列化。
序列化的代码很是简单, 咱们只须要在普通的遍历基础上,增长对空节点的输出便可(普通的遍历是不处理空节点的)。
好比咱们都树进行一次前序遍历的同时增长空节点的处理。选择前序遍历的缘由是容易知道根节点的位置,而且代码好写,不信你能够试试。
所以序列化就仅仅是普通的 DFS 而已,直接给你们看看代码。
Python 代码:
class Codec: def serialize_dfs(self, root, ans): # 空节点也须要序列化,不然没法惟一肯定一棵树,后不赘述。 if not root: return ans + '#,' # 节点之间经过逗号(,)分割 ans += str(root.val) + ',' ans = self.serialize_dfs(root.left, ans) ans = self.serialize_dfs(root.right, ans) return ans def serialize(self, root): # 因为最后会添加一个额外的逗号,所以须要去除最后一个字符,后不赘述。 return self.serialize_dfs(root, '')[:-1]
Java 代码:
public class Codec { public String serialize_dfs(TreeNode root, String str) { if (root == null) { str += "None,"; } else { str += str.valueOf(root.val) + ","; str = serialize_dfs(root.left, str); str = serialize_dfs(root.right, str); } return str; } public String serialize(TreeNode root) { return serialize_dfs(root, ""); } }
[1,2,3,null,null,4,5]
会被处理为1,2,#,#,3,4,#,#,5,#,#
咱们先看一个短视频:
(动画来自力扣)
反序列化的第一步就是将其展开。以上面的例子来讲,则会变成数组:[1,2,#,#,3,4,#,#,5,#,#]
,而后咱们一样执行一次前序遍历,每次处理一个元素,重建便可。因为咱们采用的前序遍历,所以第一个是根元素,下一个是其左子节点,下下一个是其右子节点。
Python 代码:
def deserialize_dfs(self, nodes): if nodes: if nodes[0] == '#': nodes.pop(0) return None root = TreeNode(nodes.pop(0)) root.left = self.deserialize_dfs(nodes) root.right = self.deserialize_dfs(nodes) return root return None def deserialize(self, data: str): nodes = data.split(',') return self.deserialize_dfs(nodes)
Java 代码:
public TreeNode deserialize_dfs(List<String> l) { if (l.get(0).equals("None")) { l.remove(0); return null; } TreeNode root = new TreeNode(Integer.valueOf(l.get(0))); l.remove(0); root.left = deserialize_dfs(l); root.right = deserialize_dfs(l); return root; } public TreeNode deserialize(String data) { String[] data_array = data.split(","); List<String> data_list = new LinkedList<String>(Arrays.asList(data_array)); return deserialize_dfs(data_list); }
复杂度分析
实际上咱们也可使用 BFS 的方式来表示一棵树。在这一点上其实就和力扣的记法是一致的了。
咱们知道层次遍历的时候其实是有层次的。只不过有的题目须要你记录每个节点的层次信息,有些则不须要。
这其实就是一个朴实无华的 BFS,惟一不一样则是增长了空节点。
Python 代码:
class Codec: def serialize(self, root): ans = '' queue = [root] while queue: node = queue.pop(0) if node: ans += str(node.val) + ',' queue.append(node.left) queue.append(node.right) else: ans += '#,' return ans[:-1]
如图有这样一棵树:
那么其层次遍历为 [1,2,3,#,#, 4, 5]。咱们根据此层次遍历的结果来看下如何还原二叉树,以下是我画的一个示意图:
动画演示:
容易看出:
即第 1 个节点的左右子节点对应第 1 个和第 2 个节点,第 2 个节点的左右子节点对应第 3 个和第 4 个节点。。。
(注意,没了下一层三个字)所以咱们的思路也是一样的 BFS,并依次链接左右节点。
Python 代码:
def deserialize(self, data: str): if data == '#': return None # 数据准备 nodes = data.split(',') if not nodes: return None # BFS root = TreeNode(nodes[0]) queue = collections.deque([root]) # 已经有 root 了,所以从 1 开始 i = 1 while i < len(nodes) - 1: node = queue.popleft() lv = nodes[i] rv = nodes[i + 1] i += 2 # 对于给的的 level x,从左到右依次对应 level x + 1 的节点 # node 是 level x 的节点,l 和 r 则是 level x + 1 的节点 if lv != '#': l = TreeNode(lv) node.left = l queue.append(l) if rv != '#': r = TreeNode(rv) node.right = r queue.append(r) return root
复杂度分析
有了上面的序列化的知识。
咱们就能够问面试官是哪一种序列化的手段。 并针对性选择反序列化方案构造出二叉树。最后再使用本文开头的方法解决便可。
觉得这里就结束了吗?
并无!面试官让他说出本身的复杂度。
读到这里,不妨本身暂停一下,思考这个解法的复杂度是多少?
1
2
3
4
5
ok,咱们来揭秘。
时间复杂度是 $O(n) + O(n^2)$,其中 $O(n)$ 是生成树的时间,$O(n^2)$ 是判断是不是平衡二叉树的时间。
为何判断平衡二叉树的时间复杂度是 $O(n^2)$? 这是由于咱们对每个节点都计算其深度,所以总的时间为全部节点深度之和,最差状况是退化到链表的状况,此时的高度之和为 $1 + 2 + ... n$ ,根据等差数列求和公式可知,时间复杂度是 $O(n^2)$。
空间复杂度很明显是 $O(n)$。这其中包括了构建二叉树的 n 以及递归栈的开销。
面试官又追问:能够优化么?
读到这里,不妨本身暂停一下,思考这个解法的复杂度是多少?
1
2
3
4
5
ok,咱们来揭秘。
优化的手段有两种。第一种是:
我在上一篇文章 读者:西法,记忆化递归究竟怎么改为动态规划啊? 详细讲述了记忆化递归和动态规划的互相转换。若是你看了的话,会发现这里就是记忆化递归。
第一种方法代码比较简单,就不写了。这里给一下第二种方法的代码。
定义函数 getDepth(root) 返回 root 的深度。 须要注意的是,若是子节点不平衡,直接返回 -1。 这样上面的两个函数功能(getDepth 和 isBalance)就能够放到一个函数中执行了。
class Solution: def isBalanced(self, root: TreeNode) -> bool: def getDepth(root: TreeNode) -> int: if not root: return 0 lh = getDepth(root.left) rh = getDepth(root.right) # lh == -1 表示左子树不平衡 # rh == -1 表示右子树不平衡 if lh == -1 or rh == -1 or abs(rh - lh) > 1: return -1 return max(lh, rh) + 1 return getDepth(root) != -1
虽然这道面试题目是一个常见的常规题。不过参数改了一下,瞬间难度就上来了。若是面试官没有直接给你说 nodes 是怎么序列化来的,他多是故意的。二叉树序列的方法有不少啊?题目给的是哪一种呢?这须要你和面试官沟通。颇有可能面试官在等着你问他呢!!! 这正是这道题的难点所在。
构造二叉树本质就是一个二叉树反序列的过程。 而如何反序列化须要结合序列化算法。
序列化方法根据是否存储空节点能够分为:存储空节点和不存储空节点。
存储空节点会形成空间的浪费,不存储空节点会形成没法惟一肯定一个包含重复值的树。
而关于序列化,本文主要讲述的是二叉树的序列化和反序列化。看完本文以后,你就能够放心大胆地去 AC 如下两道题:
另外仅仅是暴力作出来还不够,你们要对本身提出更高的要求。
最起码你要会分析本身的算法,经常使用的就是复杂度分析。进一步若是你能够对算法进行优化会很加分。好比这里我就经过两种优化方法将时间优化到了 $O(n)$。
以上就是本文的所有内容了, 你们对此有何见解,欢迎给我留言,我有时间都会一一查看回答。我是 lucifer,维护西湖区最好的算法题解,Github 超 40K star 。你们也能够关注个人公众号《力扣加加》带你啃下算法这块硬骨头。另外我整理的 1000 多页的电子书已限时免费下载,你们能够去个人公众号《力扣加加》后台回复电子书获取。