最近有粉絲和我交流面試遇到的算法題。其中有一道題比較有意思,分享給你們。前端
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 多頁的電子書已限時免費下載,你們能夠去個人公衆號《力扣加加》後臺回覆電子書獲取。