重修算法(1)—以 O(n) 複雜度構建樹結構

曾經看過一部網絡小說,主角在輪迴中的第九世是個大反派。而全書都是主角在努力修煉改變第九世,算是圓滿自己的修行。因爲一些原因沒看完,只是記得書名好像叫做《重修第九世》,但是利用收索引擎卻沒有找到這本書,應該是我記錯了名字。不過就像這本書一樣,我相信每個人都有自己沒有圓滿的事情,有些可以彌補,而有些卻無法彌補。

我在大學時期並沒有把數據結構與算法學好,在步入工作的這一段時間中,屢次想要去拾起算法。書倒是買了不少,視頻也看過一些,但都半途而廢了。於是決定通過寫文章的形式來學習算法。一邊通過講解的方式加深自己的理解,同時幫助別人,另一方面也是希望通過 flag 的形式來保質保量的學習算法嘛,先定它一個小目標,一週至少兩篇關於算法的博客。

基本上,在開發任意一款 to B 應用,我們都不可避免的涉及到樹形結構的增刪改查。就個人而言,我接觸過所有的產品中,都不可避免的樹結構。個人也參考並且手寫過樹組件以及樹操作。對樹結構的方案也有一定思考。於是,第一篇我決定就從實際業務出發,從樹的構建開始:

這裏爲了簡化,就簡單設定。如果當前書節點不具有父節點,則 parentId 爲0。對於其他需求,請自行設定配置項。

interface TreeItem {
	id: number
    // 父節點的 id
	parentId: number
    // 當前樹的名稱
	name: string
}

for 循環使用

事實上,在業務層面構建一棵樹不算難。但是可能還是有一些算法基礎不太好的小夥伴不能很快的寫出來,此時我們可以用最簡單的方式。直接多層 for 循環。

function buildTree(treeItems) {
  /** 構建第一層 */  
  const treeRoots = treeItems.filter(x => x.parentId === 0)
  
  for (let first of treeRoots) {
    /** 第一層子節點 */  
    first.children = treeItems.filter(x => x.partner === first.id)
      /** 構建第二層 */
      for(let second of first.children) {
        // ...       
      }
  }
}

該方案在實際業務基本不可以用,除非在實際業務中限制樹的層級並且只有前幾層。而且層級越大,代碼量也就越大,性能也就越差。

但是基本上所有的樹操作在所有的節點中尋並插入父節點,所以該方案作爲樹結構的基本思路,我在此時列出以便大家可以循序漸進的思考和改進。

遞歸構建

通過上述代碼,很簡單就可以發現我們可以把當前問題分解爲多個子問題。而每個子問題都是在尋找該節點的子節點,並且插入父節點的 children 中。根據這一點,我們不難寫出如下遞歸代碼。

/** 構建樹 */
const buildTree = (treeItems, id = 0) =>
  treeItems
    // 找到當前節點所有的孩子
    .filter(item => item.parentId === id)
    // 繼續遞歸找
    .map(item => ({ ...item, children: buildTree(treeItems, item.id) }));

根據當前遞歸,我們減少了代碼的冗餘,並且可以“無限”的構建下去。不計算遞歸本身的時間複雜度(後面有機會再說遞歸本身耗費的時間複雜度)的情況下,每一次都要遍歷一次數組。而數組每一個數據都要便利一次,可以得出時間複雜度是 O(n<sup>2</sup>)。

對於大部分業務需求來說,現在可以結束了,因爲在大部分業務場景中樹結構本身不太會有很多的數據量。就算數據量很大的情況下,我們也可以通過組件延遲加載的方式解決。

利用對象引用構建樹

上述方案是常規方案,但是問題在於,性能還是低下。

性能低下的原因之一在於遞歸更加耗費性能而且可能會導致棧溢出錯誤(js 到目前沒有實現尾遞歸優化),這一點我們可以利用遞歸轉循環來做(後面再說,現在沒必要)。

同時在每次構建一個節點的孩子時,都需要遍歷整個數組一次,這個也是很大的損耗。事實上,優秀的算法應該是可以複用前面已經計算過的屬性。

那麼我們是否能夠通過一次循環解決子節點問題呢?答案也是肯定的。先上代碼:

function buildTreeOptimize (items) {
  // 由業務決定是否需要對 items 深拷貝一次。這裏暫時不做
  
  // 把每個子節點保存起來,以便後面插入父節點
  const treeDataByParentId = new Map()
  
  // 對每節點循環,找其父節點,並且放到數組中    
  items.forEach(item => {
    // map 中有父數據,插入,沒有,構建並插入   
    treeDataByParentId.has(item.parentId) ? treeDataByParentId.get(item.parentId).push(item) : treeDataByParentId.set(item.parentId, [item])
  })

  // 樹第一層  
  const treeRoots = []
  
  // 對每一個節點循環,找其子節點
  items.forEach(item => {
    // 子節點插入當前節點  
    item.children = (treeDataByParentId.get(item.id) || [])
    // 當前節點不具備父節點,插入第一層數組中
    if (!item.parentId) {
      treeRoots.push({item})
    }
  })
    
  // 返回樹結構
  return treeData
}

兩次 for 循環完成了樹的構建?該算法時間複雜度是O(n)!! 可以說相當快,畢竟對於之前的代碼,每個節點查詢一次都要 O(n) 一次。

在第一次循環中,我們幫助所有的節點尋找到了父節點。即都存儲到了 map 中去。在這一步中,所有的子節點按照服務端給予的數據順序依次插入。第二次循環中,我們直接在原 items 循環並插入第一布找到的子節點。插入節點.

其實這個算法的精妙之處在於第一步塞入 map 中的樹對象和第二步塞入父節點中的樹對象是是同一個對象!!!

表面上,第二步只是尋找每一個節點的子節點,但實際上在把當前節點修改的“同時”,map 中的對象節點也被改掉了,因爲他們都是同一個對象(每一層的父子關係都搞定了)。所以最終僅僅只通過兩次遍歷便拿到關於樹的數據。

大部分情況下上在業務層面做到這裏就沒什麼太大問題了。例如 Element Tree 樹形組件。以及 Ant Design 的 TreeSelect 組件。當然,同樣的代碼依然適合服務端開發。

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 博客下幫忙 star 一下。

博客地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章