前端學數據結構與算法(六):二叉樹的四種遍歷方式及其應用

前言

上一章咱們從01的實現了一顆二叉搜索樹,以及理解了二叉搜索樹的特性與基本操做,這一章介紹關於二叉樹的更多操做,也就是樹的遍歷,對樹的每一個節點進行訪問。主要包括前序遍歷、中序遍歷、後序遍歷、層序遍歷,前面三種也叫深度優先遍歷(DFS),最後的層序遍歷也叫廣度優先遍歷(BFS),理解這四種遍歷方式的不一樣,再遇到樹相關的算法問題時,也就能更加遊刃有餘。這裏不單指二叉搜索樹,遍歷思想一樣適用於多叉樹。node

深度優先遍歷(DFS)

深度優先顧名思義,首先從樹的深度開始,也就是優先訪問完其中一棵子樹,而後再去訪問另外一顆子樹。樹的深度優先裏又分爲前/中/後序遍歷,它們的區別僅僅是當訪問到具體的節點時,它們前後順序的不一樣。深度優先通常都是採用遞歸的方式實現,由於好理解,固然也可使用遍歷實現,不過那樣會增長代碼複雜性以及代碼的語義化。(LeetCode上後序遍歷的非遞歸實現但是困難難度)git

前序遍歷

也就是靠前訪問到樹的節點,首先貼出代碼,給上一章實現的二叉搜索樹增長前序遍歷的方法:github

class BST {
  constructor() {
    this.root = null // 根節點
  }
  ...
  
  preorder(fn) { // 前序遍歷
  	const _helper = node => {
      if (!node) {
        return
      }
      fn(node.val) // 先訪問根節點
      _helper(node.left) // 再訪問左節點
      _helper(node.right) // 最後訪問右節點
    }
    _helper(root)
  }
}

不管使用哪一種遍歷方式都是爲了訪問到樹的每一個節點,注意上面代碼裏函數fn的位置,它位於兩個遞歸函數的前面,因此叫它前序遍歷;而中序遍歷fn就是在兩個遞歸函數的中間;後序遍歷fn就是在兩個遞歸函數的後面,它們叫法的由來,也是僅此而已。雖然是這麼一點點的區別,然而結果相差挺大且應用方式的區別也很大,首先來看下前序遍歷:

當使用前序遍歷時,它的訪問順序是1五、十、七、十二、2六、2二、37。它的訪問特色是首先訪問這棵樹的根節點,而後訪問它的左子樹,最後是訪問右子樹,這也是看起來比較符合直覺的一種遍歷方式。若是仍是不太好理解的話,能夠將一整顆樹分解來理解:

由於是前序遍歷,首先遇到子樹A,此時它的根節點是15,被訪問到。而後去它的左子樹B,節點10又是子樹B的根節點,因此被訪問到。再去節點7與兩個null構成的子樹,節點7被訪問到,由於7的左右孩子都是null,因此返回到父節點10,最後去訪問右節點12,整棵樹的左子樹就訪問完了。而後再右子樹實行一樣的規則進行訪問。以此類推也就造成了剛纔看到的訪問順序結果,知道它的訪問順序有什麼用?那就以一道算法題來看其應用。算法

前序遍歷應用 - 108-將有序數組轉換爲二叉搜索樹

將一個按照升序排列的有序數組,轉換爲一棵高度平衡二叉搜索樹。
一個高度平衡二叉樹是指一個二叉樹每一個節點的左右兩個子樹的高度差的絕對值不超過 1。
給定有序數組: [-10,-3,0,5,9],一個可能的答案是:[0,-3,9,-10,null,5],
它能夠表示下面這個高度平衡二叉搜索樹:
      0
     / \
   -3   9
   /   /
 -10  5

必需要高度平衡,因此不能出現一邊高一點低的狀況。該題有個條件是在一個有序的數組集合裏,因此再重構這顆樹時,根節點就能夠選擇數組的中間位置,由於剩下左側部分所有是小於根節點的,而右側部分所有是大於根節點的,正好符合二叉搜索樹的定義。以此類推剩下部分的根節點依然中間位置,從而進行左右的分割,直到最後不能進行分割便可。數組

function TreeNode(val) { // 樹節點類
  this.val = val;
  this.left = this.right = null;
}

var sortedArrayToBST = function (nums) {
  const _helper = (arr, l, r) => { // l爲左邊界、r爲右邊界
    if (l > r) { // 遞歸終止條件
      return null
    }
    const mid = l + (r - l) / 2 | 0 // 取數組中間值,並向下取整
    const node = new TreeNode(arr[mid]) // 前序遍歷,實例化爲樹節點
    node.left = _helper(arr, l, mid - 1) // 將分割的左側構建爲二叉搜索樹
    node.right = _helper(arr, mid + 1, r) // 將分割的右側構建爲二叉搜索樹
    return node // 將構建好的樹返回
  }
  return _helper(nums, 0, nums.length - 1) // 注意前閉後閉的區間方式
};

爲何不採用(l + r) / 2,而採用l + (r - l) / 2的書寫方式,是爲了防止出現整型溢出的狀況。由於數組裏每一項的值首先須要實例化爲樹的節點TreeNode,才能建立它的左孩子和右孩子,因此採用前序遍歷的方式。整棵樹的構建順序也是先根節點,而後左子樹,最後右子樹的順序完成。緩存

中序遍歷

僅僅只是改變了訪問節點的順序,首先訪問左子樹,而後訪問當前根節點,最後訪問右子樹。仍是貼出代碼:數據結構

class BST {
  constructor() {
    this.root = null // 根節點
  }
  ...
  
  inorder(fn) { // 中序遍歷
  	const _helper = node => {
      if (!node) {
        return
      }
      _helper(node.left) // 先訪問左孩子
      fn(node.val) // 再訪問根節點
      _helper(node.right) // 最後訪問右孩子
    }
    _helper(root)
  }
}

就是簡單的更改了訪問節點的位置,順序也頗有意思:
結果是七、十、十二、1五、2二、2六、37,正好是一個升序排列方式,這也是二叉搜索樹使用中序遍歷的一個特色,若是理解了以前前序遍歷遞歸函數的運行過程,中序遍歷理解起來就不難了。由於首先是訪問完左子樹,只要左孩子不是null,遞歸函數就會一直往左側訪問,而二叉搜索樹最小的節點正好最左側的葉子節點,到了底部以後則開始採用左中右的遞歸順序往回走,正好是一個升序排列。它有啥用?仍是使用一個算法題來看其應用。函數

中序遍歷應用 - 530-二叉搜索樹的最小絕對差

給你一棵全部節點爲非負值的二叉搜索樹,請你計算樹中任意兩節點的差的絕對值的最小值。

看題目不是太好理解,就以咱們示例的二叉搜索樹爲例:
按照該題的算法,解爲2,由於任意兩個之間的絕對值,這是最小的。一下沒有想到解題的思路?沒事,假如咱們把這顆二叉搜索樹當作是一個升序數組[7, 10, 12, 15, 22, 26, 37],求解該數組任意兩個值之間差的最小絕對值。不難發現其實"任意"是個幌子,你只能去比較兩個相連數字它們的絕對值。再去看二叉搜索樹,也就明白了,使用中序遍歷,比較兩個先後訪問的節點便可獲得"任意"兩個節點的最小絕對值差。 post

var getMinimumDifference = function (root) {
  let prev = null // 保存以前訪問的節點
  let minimum = Infinity // 取最大值
  const _helper = node => {
    if (!node) {
      return
    }
    _helper(node.left)
    if (prev !== null) { // 第一次訪問沒有prev節點
      minimum = Math.min(minimum, node.val - prev.val) // 始終保存最小的值
    }
    prev = node // 將本次循環的節點緩存爲上一個節點
    _helper(node.right)
  }
  _helper(root)
  return minimum // 返回最小差
};

使用一個prev變量緩存上一次訪問的節點,每一次讓當前訪問到的節點值減去以前節點的值,由於是中序遍歷,因此當前節點的值必定是大於以前節點的,將整顆樹遍歷完,返回兩兩相減最小的值便可。this

後序遍歷

也是僅僅改變訪問節點的位置便可,先訪問左子樹,再訪問右子樹,最後訪問自身根節點,貼代碼:

class BST {
  constructor() {
    this.root = null // 根節點
  }
  ...
  
  postorder(fn) { // 後序遍歷
  	const _helper = node => {
      if (!node) {
        return
      }
      _helper(node.left) // 先訪問左孩子
      _helper(node.right) // 而後訪問右孩子
	  fn(node.val) // 最後訪問根節點      
    }
    _helper(root)
  }
}

先訪問完左子樹,而後是右子樹,最後是自身節點,訪問順序以下:

後序遍歷應用 - 563-二叉樹的坡度

給定一個二叉樹,計算整個樹的坡度。
一個樹的節點的坡度定義即爲,該節點左子樹的結點之和和右子樹結點之和的差的絕對值。空結點的的坡度是0。
整個樹的坡度就是其全部節點的坡度之和。

簡單來講就是每一個節點的坡度等於它左右子樹和的絕對差,因此葉子節點的坡度就是0,由於左右孩子都是空節點,返回的就是0-0的值。進行子問題拆解的話,能夠理解爲整顆樹的坡度就是它的左右子樹坡度之和,因此須要先求出左右孩子的坡度才能計算出當前節點的坡度,後序遍歷很是適合。
解題代碼以下:

var findTilt = function (root) {
  let tilt = 0
  const _helper = (node) => {
    if (!node) {
      return 0 // 葉子節點的坡度爲0
    }
    const l = _helper(node.left) // 先求出左子樹的和
    const r = _helper(node.right) // 再求出右子樹的和
    tilt += Math.abs(l - r) // 當前節點的坡度等於左右子樹和的絕對差
    return l + r + node.val // 返回子樹和
  }
  _helper(root)
  return tilt
};

深度優先遍歷(DFS)應用進階

以上三道算法題,分別展現了樹的前/中/後序遍歷的實際應用,這些還遠遠不夠。有的算法題常規的遍歷方式並不能太好解決問題,這個時候就須要在深入理解了樹的(DFS)遍歷特性後,進行額外靈活的處理來解決。

反常規中序遍歷 - 538-把二叉搜索樹轉換爲累加樹

給定一個二叉搜索樹,把它轉換成爲累加樹,使得每一個節點的值是原來的節點值加上全部大於它的節點值之和。


題目很差懂,不過從轉換的示例能夠看出,從右子樹最右葉子節點開始,進行兩兩的節點值累加,最終從右到左的累加完整棵樹。若是把這顆二叉搜索樹當作是一個數組[7,10,12,15,22,26,37],那麼它的操做就是數組從後往前的進行兩兩相加並覆蓋前一個值。對於樹的話,咱們就須要進行反常規的中序遍歷,首先遍歷右子樹,而後遍歷根節點,最後遍歷左子樹,也就是進行一次降序遍歷。

var convertBST = function (root) {
  let sum = 0
  const _helper = (node) => {
    if (!node) {
      return null
    }
    _helper(node.right) // 先遍歷右子樹
    node.val += sum // 右子樹到底後逐個節點進行值的累加與覆蓋
    sum = node.val // 記錄的就是上個被覆蓋節點的值
    _helper(node.left) // 最後遍歷左子樹
  }
  _helper(root)
  return root // 返回新的累加樹
};

前序加後序遍歷 - 257-二叉樹的全部路徑

給定一個二叉樹,返回全部從根節點到葉子節點的路徑。


題目的要求是從根節點到葉子節點,因此要記錄每一步的當前節點,而前序遍歷的順序正好能夠記錄從根到葉子節點的整條路徑。而這道題有意思的地方在於前序遍歷返回時,須要把最後一步的葉子節點從路徑裏移除掉,從新添加另外的節點路徑值,因此能夠在後序遍歷的順序裏,像貪吃蛇同樣一口口的去吃掉已經訪問過的路徑。有人把這種解法取了個挺厲害的名叫回溯。代碼以下:

var binaryTreePaths = function (root) {
  const ret = []
  const _helper = (node, path) => {
    if (!node) {
      return
    }
    path.push(node.val) // 前序遍歷訪問,記錄路徑的每一步
    _helper(node.left, path)
    _helper(node.right, path)
    if (!node.left && !node.right) { // 到達了葉子節點
      ret.push(path.join('->')) // 將路徑轉換爲字符串
    }
    path.pop() // 後序遍歷訪問,將訪問過的路徑節點彈出
  }
  _helper(root, [])
  return ret
};

廣度優先遍歷(BFS)

深度優先是先遍歷完一棵子樹,再遍歷完另外一顆子樹,而廣度優先的意思是按照樹的層級一層層的訪問每一個節點,圖示以下:
固然你也能夠從右往左的打印,打印順序就是1五、2六、十、3七、2二、十二、7,廣度優先的實現咱們須要藉助另一種數據結構《隊列》,由於普通的隊列理解起來並不複雜,因此以前的章節也沒有單獨介紹。正好到了樹的層序遍歷,在這裏能夠將隊列的定義及其應用一併介紹。

什麼是隊列

以前的章節咱們介紹了棧,這是一種一端堵死,後進先出的數據結構。而隊列則有不一樣,沒有哪頭堵死,從隊尾進入,隊首出隊的一種順序數據結構。
這也是一種受控的數據結構,提供的接口只能訪問到隊尾和隊首,中間的元素外部沒法訪問到。

樹的BFS實現

使用隊列,將每一個節點入隊,同時在出隊一個節點後,將它的兩個孩子節點入隊。首先入隊根節點,而後出隊根節點的同時,入隊它的兩個孩子節點,此時隊列不爲空,繼續採用一樣的方式入及出隊接下來的節點。
代碼實現以下:

class BST {
  constructor() {
    this.root = null // 根節點
  }
  ...
  
  levelOrder(fn) {
    if (!this.root) {
      return
    }
    const queue = [this.root] // 首先入隊根節點
    while (queue.length > 0) {
      const node = queue.shift() // 出隊隊首節點
      fn(node.val) // 訪問出隊節點值
      if (node.left) { // 入隊左孩子
        queue.push(node.left)
      }
      if (node.right) { // 入隊右孩子
        queue.push(node.right)
      }
    }
  }
}

廣度優先遍歷應用 - 637-二叉樹的層平均值

這個徹底就是爲了層序遍歷而量身定製的,不過和常規的遍歷有所不一樣,須要求出每一層的平均值,也就是說在遍歷完一層的時候,咱們須要知道這一層已經遍歷完了,這樣才能夠求出平均值。能夠在while循環內再加一個針對每一層的循環,代碼以下:

var averageOfLevels = function (root) {
  if (root === null) {
    return []
  }
  const queue = [root]
  const ret = [] // 最終返回的結果
  while (queue.length > 0) {
    let len = queue.length
    let sum = 0 // 記錄每一層的總和
    for (let i = 0; i < len; i++) { // 遍歷每一層
      const node = queue.shift()
      sum += node.val
      if (node.left) {
        queue.push(node.left)
      }
      if (node.right) {
        queue.push(node.right)
      }
    }
    ret.push(sum / len) // 求出該層的平均
  }
  return ret
};

最後

告訴你們一個很差的消息,以上全部題目均爲簡單難度。固然主要是爲了熟悉樹的遍歷方式,還有不少經典的樹的問題,如樹的公共祖先、翻轉、序列化等,再理解了樹的遍歷和遞歸後,再面對這些問題也會更好理解,本章內容只是一個拋磚引玉的做用。本章github源碼

相關文章
相關標籤/搜索