演算法與資料結構--空間複雜度O(1)遍歷樹

夕小瑤發表於2019-11-20

大家好~我叫「小鹿鹿鹿」,是本賣萌小屋的第二位簽約作(萌)者(貨)。和小夕一樣現在在從事NLP相關工作,希望和大家分享NLP相關的、不限於NLP的各種小想法,新技術。這是我的第一篇試水文章,初來乍到,希望大噶多多滋辭(●'◡'●)。

冬天已經來了,秋招早已悄無聲息的結束。作為一個已經工作了兩年的老人,校招面試感覺就像高考一樣遙遠。但是呢,雖然工作了,還是要時刻保持危機感的呀,萬一哪天就要跳槽了呢( ̄∇ ̄)。

遙想當年面試的時候,由於沒有學過資料結構,在面試官出演算法題之前就老實交待家底:“我的演算法和資料結構不太行,樹呀圖呀都不太會(✿◡‿◡)"。但是經過兩年斷斷續續的學習,發現其實樹是一個套路非常明顯的一類演算法題,而遍歷樹是解決絕大多數樹問題的基礎(很多題目都是在樹的遍歷上擴充套件),下面小鹿就以樹的遍歷為例,解剖樹裡面深深的套路吧o(* ̄▽ ̄*)o。

樹的基礎回顧



二叉樹長什麼樣子,小鹿這裡就不上圖啦,所謂的根節點、葉子節點也不介紹啦。我們知道,二叉樹的遍歷分為三種:前序、中序和後序。這三種序的不同主要就是在於什麼時候訪問根節點。以前序為例,在遍歷一顆樹的時候先訪問根節點,再遍歷其根節點的左子樹,最後訪問根節點的右子樹。而中序遍歷就是先遍歷左子樹,再訪問根節點,最後遍歷右子樹;後序遍歷小鹿就不重複啦。


樹的遞迴


樹有一個很好的特性,就是一棵樹的根節點的左右子樹仍然是一顆樹

這好像是一句正確的廢話╮( ̄▽ ̄"")╭

所以我們可以把一棵複雜的大樹,分解成兩顆稍微小一點的樹,依次類推,最後變成最小的單元(只有一個節點的樹)。不管怎麼講,處理只有一個節點的樹是不是超級容易!這個呢就是遞迴的思想,所以說到這裡,我們以後只要遇到關於樹的問題,誰還不會用遞迴走一波呢!

下面就來開始實踐!用遞迴的思想實現二叉樹的前序、中序、後序遍歷(遍歷結果記錄在self.ans的向量裡)。小鹿這裡就用python寫啦(真的不要糾結程式語言噢)。遍歷一個有n個節點的樹,其時間複雜度和空間複雜度都是O(n)。

class Solution(object):   
 def __init__(self):      
  self.ans = []       
 def preorderRecursive(self, root):  
      if root:           
 self.ans.append(root.val)      
      self.preorderRecursive(root.left)       
     self.perorderRecursive(root.right)       
   def inorderRecursive(self, root):      
  if root:          
  self.inorderRecursive(root.left)          
  self.ans.append(root.val)     
       self.inorderRecursive(root.right)
    def postorderRecursive(self, root):    
   if root:        
    self.postorderRecursive(root.left)       
     self.postorderRecursive(root.right)       
     self.ans.append(root.val)

樹和棧


用遞迴的思路解決樹的遍歷或者其他樹的問題,思路非常清晰,程式碼也會非常的簡單清楚。當我們在使用遞迴(函式的巢狀)的時候,其本質是在呼叫棧。我們可以用棧來實現樹的遍歷,進一步理解遞迴的思想。

class Solution(object):   
 def __init__(self):    
    self.ans = []    
def preorderStack(self, root):     
   aStack = []     
   if root:        
    aStack.append(root)  
      while aStack:           
 p = aStack.pop()           
 self.ans.append(p.val)          
  if p.right:            
    aStack.append(p.right)         
   if p.left:             
   aStack.append(p.left)    
    return self.ans
    def inorderStack(self, root):    
    stack = []     
   p = root       
 while p:      
      stack.append(p)           
 p = p.left     
   while stack:     
       p = stack.pop()        
    self.ans.append(p.val)        
    p = p.right           
 while p:             
   stack.append(p)           
     p = p.left       
 return self.ans
    def postorderStack(self, root)     
   aStack = []    
    prev = None     
   p = root       
 while aStack or p:   
         if p:        
        aStack.append(p)       
         p = p.left          
  else:           
     p = aStack[-1]          
      if p.right == prev or p.right is None:      
              self.ans.append(p.val)                
    prev = p                  
  aStack.pop()                 
   p = None         
       else:               
     p = p.right        
 return self.ans

用棧來實現樹的遍歷就稍微複雜一點啦。首先前序是最簡單的,我們用一個棧(first-in-last-out)來維護樹遍歷的順序。初始狀態是把非空的根節點push到棧中,逐個從棧中取出節點,訪問其值,然後先push右節點再push左節點到棧中,就保證訪問順序是 root->left->right 啦。超級簡單有木有!

中序遍歷就稍微難一些了,在訪問一個節點之前需要訪問其 left-most 節點,將其左節點逐個加入到棧中,直到當前節點為None。然後從棧中取出節點,由於棧的特性,在取出某個節點之前,其左節點已經被訪問,所以就可以安心的訪問該節點,然後把當前節點置為其右節點,依次類推。

後序遍歷和中序遍歷差不多,在中序遍歷的基礎上需要增加一個prev記錄上一個訪問的節點,確認在訪問某個節點之前其右節點是否已經被訪問。

用棧的方式遍歷樹,更加顯式的告訴了大家樹遍歷的時候是怎麼回溯的,其時間空間複雜度仍然是O(n)

Morris遍歷


下面小鹿要給大家介紹一個神級樹遍歷方法Morris,使其空間複雜度從O(n)變成O(1)!

樹的遍歷一個難點就是確定什麼時候以及回溯如何回溯(好像是兩個點),第一種遞迴的方法通過函式的巢狀實現回溯,第二種方法通過棧儲存節點的順序實現回溯,而Morris則是利用樹中空節點記錄每個節點其回溯的節點,實現空間複雜度為O(1)的遍歷。

具體來說,當我們訪問了某個左子樹最右的節點後需要回溯到其左子樹的根節點,但是怎麼回去呢,我們需要在之前加一個連結,將該根節點連到其左子樹的最右節點的右節點上。是不是有點繞呢(╯﹏╰)b,不慌!小鹿帶你一起做一遍中序遍歷的例子就清楚啦~~


演算法與資料結構--空間複雜度O(1)遍歷樹


以上面的圖為例做中序遍歷,當前節點為6(curr=6)時,找到其左子樹的最右節點5,新增連結備用,這樣從節點5我們就可以回溯到節點6。更新當前節點,curr=2,重複相同的操作。當curr=1時,到達葉子節點,輸出(節點變藍),因為之前新增了連結,所以可以從節點1回溯到節點2,回溯刪除連結。當前節點變成4,以此類推下去

已經懵逼的小夥伴們請仔細品味下面的程式碼( ̄▽ ̄)~

class Solution(object):  
  def __init__(self):     
   self.ans = []       
     def inorderMorris(self, root):      
  p = root        while p:        
    if p.left is None:        
        #left-most node           
     self.ans.append(p.val)         
       p = p.right         
   else:          
      #find prev, the right-most node of the left tree         
       prev = p.left        
        while prev.right and prev.right != p:    
                prev = prev.right                       
             if prev.right is None:           
         #first time to visit p               
     prev.right = p #add link               
     p = p.left              
  else:               
     #second time to visit p               
     self.ans.append(p.val)          
          prev.right = None          
          p = p.right     
   return self.ans      
      def preorderMorris(self, root):   
     p = root      
  prev = None    
    while p:         
   if p.left is None:      
          #left-most node         
       self.ans.append(p.val)       
         p = p.right            else:     
           #find right-most node of the left tree      
          prev = p.left            
    while prev.right and prev.right != p:    
                prev = prev.right           
     if prev.right is None:          
          #first time to visit p                
    prev.right = p #add link              
      self.ans.append(p.val)                  
  p = p.left           
     else:                  
  #second time to visit p          
          p = p.right #back to root                   
 prev.right = None #delete the link       
 return self.ans                  

Morris前序遍歷和中序遍歷幾乎一模一樣,唯一的差別就是在第一次訪問節點的時候就輸出,還是第二次訪問節點的時候輸出。Morris後序遍歷在此基礎上還要稍微的複雜一丟丟。因為後續遍歷根節點最後輸出,需要增加一個dump節點作為假的根節點,使其的左子樹right-most 指向原來的根節點。話不多說,我們來看下程式碼吧!

# Definition for a binary tree node.# class TreeNode(object):#     
def __init__(self, x):#     
    self.val = x#        
 self.left = None#     
    self.right = None
class Solution(object):   
 def __init__(self):        self.ans = []
    def postorderMorris(self, root):     
   dump = TreeNode(0)     
   dump.left = root    
    p = dump      
  while p:         
   if p.left is None:  
              p = p.right    
        else:             
   prev = p.left             
   while prev.right and prev.right != p:       
             prev = prev.right        
        if prev.right is None:            
        #first time to visit p           
         prev.right = p              
      p = p.left                else:       
             #second time to visit p        
            self.singleLinkReverseTraversal(p.left, prev)           
         prev.right = None                
    p = p.right    
    return self.ans
    def singleLinkReverseTraversal(self, start, end):   
     #take the right branch from start to end as single link    
    #travel reversely   
     if start == end:      
      self.ans.append(start.val) 
           return
        self.reverse(start, end)     
   curr = end     
   while curr != start:      
      self.ans.append(curr.val)     
       curr = curr.right      
  self.ans.append(curr.val)  
      self.reverse(end, start)
    def reverse(self, start, end):    
    if start == end:       
     return        prev = None    
    curr = start        while curr != end:   
         tmp = curr.right      
      curr.right = prev        
    prev = curr         
   curr = tmp    
    curr.right = prev

眼尖的小夥伴看了程式碼之後就會發現,Morris後序遍歷怎麼多了兩個函式,怎麼和前序和中序不一樣了呢ヽ(`Д´)ノ!這個Morris後序遍歷確實比較挺難理解,我們還是用最簡單的圖示來走一遍程式碼的意思吧~~~

開始除了加了一個dump節點以外,都是一樣的一路向下,到達left-most節點3,不輸出(注意啦!不一樣啦!)然後回溯到節點2,逆序輸出從3到3的節點,刪除連結。從節點2一路往右回溯到節點1,逆序輸出從其左節點2到prev節點6,刪除節點。以此類推,就噢啦ヽ( ̄▽ ̄)ノ。


      演算法與資料結構--空間複雜度O(1)遍歷樹


現在大家都清楚了吧~~不清楚的地方歡迎在評論區提出噢,小鹿會盡最大努力為大家答疑解惑嗒( ̄▽ ̄)/後面小鹿鹿鹿還會持續推送關於演算法和資料結構的小文章,大家有興趣的話歡迎關注訂閱哦(´▽`)ノ 。

相關文章