1. 程式人生 > 實用技巧 >JS樹結構操作:查詢、遍歷、篩選、樹結構和列表結構相互轉換

JS樹結構操作:查詢、遍歷、篩選、樹結構和列表結構相互轉換

經常有同學問樹結構的相關操作,在這裡總結一下JS樹形結構一些操作的實現思路,並給出了簡潔易懂的程式碼實現。
本文內容結構大概如下:

一、遍歷樹結構

1. 樹結構介紹

JS中樹結構一般是類似於這樣的結構:

let tree = [
  {
    id: '1',
    title: '節點1',
    children: [
      {
        id: '1-1',
        title: '節點1-1'
      },
      {
        id: '1-2',
        title: '節點1-2'
      }
    ]
  },
  {
    id: '2',
    title: '節點2',
    children: [
      {
        id: '2-1',
        title: '節點2-1'
      }
    ]
  }
]

為了更通用,可以用儲存了樹根節點的列表表示一個樹形結構,每個節點的children屬性(如果有)是一顆子樹,如果沒有children屬性或者children長度為0,則表示該節點為葉子節點。

2. 樹結構遍歷方法介紹

樹結構的常用場景之一就是遍歷,而遍歷又分為廣度優先遍歷、深度優先遍歷。其中深度優先遍歷是可遞迴的,而廣度優先遍歷是非遞迴的,通常用迴圈來實現。深度優先遍歷又分為先序遍歷、後序遍歷,二叉樹還有中序遍歷,實現方法可以是遞迴,也可以是迴圈。

廣度優先和深度優先的概念很簡單,區別如下:

  • 深度優先,訪問完一顆子樹再去訪問後面的子樹,而訪問子樹的時候,先訪問根再訪問根的子樹,稱為先序遍歷;先訪問子樹再訪問根,稱為後序遍歷。
  • 廣度優先,即訪問樹結構的第n+1層前必須先訪問完第n層

3. 廣度優先遍歷的實現

廣度優先的思路是,維護一個佇列,佇列的初始值為樹結構根節點組成的列表,重複執行以下步驟直到佇列為空:

  • 取出佇列中的第一個元素,進行訪問相關操作,然後將其後代元素(如果有)全部追加到佇列最後。

下面是程式碼實現,類似於陣列的forEach遍歷,我們將陣列的訪問操作交給呼叫者自定義,即一個回撥函式:

// 廣度優先
function treeForeach (tree, func) {
  let node, list = [...tree]
  while (node = list.shift()) {
    func(node)
    node.children && list.push(...node.children)
  }
}

很簡單吧,~,~
用上述資料測試一下看看:

treeForeach(tree, node => { console.log(node.title) })

輸出,可以看到第一層所有元素都在第二層元素前輸出:

> 節點1
> 節點2
> 節點1-1
> 節點1-2
> 節點2-1

4. 深度優先遍歷的遞迴實現

先序遍歷,三五行程式碼,太簡單,不過多描述了:

function treeForeach (tree, func) {
  tree.forEach(data => {
    func(data)
    data.children && treeForeach(data.children, func) // 遍歷子樹
  })
}

後序遍歷,與先序遍歷思想一致,程式碼也及其相似,只不過調換一下節點遍歷和子樹遍歷的順序:

function treeForeach (tree, func) {
  tree.forEach(data => {
    data.children && treeForeach(data.children, func) // 遍歷子樹
    func(data)
  })
}

測試:

treeForeach(tree, node => { console.log(node.title) })

輸出:

// 先序遍歷
> 節點1
> 節點1-1
> 節點1-2
> 節點2
> 節點2-1

// 後序遍歷
> 節點1-1
> 節點1-2
> 節點1
> 節點2-1
> 節點2

5. 深度優先迴圈實現

先序遍歷與廣度優先迴圈實現類似,要維護一個佇列,不同的是子節點不追加到佇列最後,而是加到佇列最前面:

function treeForeach (tree, func) {
  let node, list = [...tree]
  while (node = list.shift()) {
    func(node)
    node.children && list.unshift(...node.children)
  }
}

後序遍歷就略微複雜一點,我們需要不斷將子樹擴充套件到根節點前面去,(艱難地)執行列表遍歷,遍歷到某個節點如果它沒有子節點或者它的子節點已經擴充套件到它前面了,則執行訪問操作,否則擴充套件子節點到當前節點前面:

function treeForeach (tree, func) {
  let node, list = [...tree], i =  0
  while (node = list[i]) {
    let childCount = node.children ? node.children.length : 0
    if (!childCount || node.children[childCount - 1] === list[i - 1]) {
      func(node)
      i++
    } else {
      list.splice(i, 0, ...node.children)
    }
  }
}

二、列表和樹結構相互轉換

1. 列表轉為樹

列表結構通常是在節點資訊中給定了父級元素的id,然後通過這個依賴關係將列表轉換為樹形結構,列表結構是類似於:

let list = [
  {
    id: '1',
    title: '節點1',
    parentId: '',
  },
  {
    id: '1-1',
    title: '節點1-1',
    parentId: '1'
  },
  {
    id: '1-2',
    title: '節點1-2',
	  parentId: '1'
  },
  {
    id: '2',
    title: '節點2',
    parentId: ''
  },
  {
    id: '2-1',
    title: '節點2-1',
  	parentId: '2'
  }
]

列表結構轉為樹結構,就是把所有非根節點放到對應父節點的chilren陣列中,然後把根節點提取出來:

function listToTree (list) {
  let info = list.reduce((map, node) => (map[node.id] = node, node.children = [], map), {})
  return list.filter(node => {
    info[node.parentId] && info[node.parentId].children.push(node)
    return !node.parentId
  })
}

這裡首先通過info建立了id=>node的對映,因為物件取值的時間複雜度是O(1),這樣在接下來的找尋父元素就不需要再去遍歷一次list了,因為遍歷尋找父元素時間複雜度是O(n),並且是在迴圈中遍歷,則總體時間複雜度會變成O(n^2),而上述實現的總體複雜度是O(n)。

2. 樹結構轉列表結構

有了遍歷樹結構的經驗,樹結構轉為列表結構就很簡單了。不過有時候,我們希望轉出來的列表按照目錄展示一樣的順序放到一個列表裡的,並且包含層級資訊。使用先序遍歷將樹結構轉為列表結構是合適的,直接上程式碼:

//遞迴實現
function treeToList (tree, result = [], level = 0) {
  tree.forEach(node => {
    result.push(node)
    node.level = level + 1
    node.children && treeToList(node.children, result, level + 1)
  })
  return result
}

// 迴圈實現
function treeToList (tree) {
  let node, result = tree.map(node => (node.level = 1, node))
  for (let i = 0; i < result.length; i++) {
    if (!result[i].children) continue
    let list = result[i].children.map(node => (node.level = result[i].level + 1, node))
    result.splice(i+1, 0, ...list)
  }
  return result
}

三、樹結構篩選

樹結構過濾即保留某些符合條件的節點,剪裁掉其它節點。一個節點是否保留在過濾後的樹結構中,取決於它以及後代節點中是否有符合條件的節點。可以傳入一個函式描述符合條件的節點:

function treeFilter (tree, func) {
  // 使用map複製一下節點,避免修改到原樹
  return tree.map(node => ({ ...node })).filter(node => {
    node.children = node.children && treeFilter(node.children, func)
    return func(node) || (node.children && node.children.length)
  })
}

四、樹結構查詢

1. 查詢節點

查詢節點其實就是一個遍歷的過程,遍歷到滿足條件的節點則返回,遍歷完成未找到則返回null。類似陣列的find方法,傳入一個函式用於判斷節點是否符合條件,程式碼如下:

function treeFind (tree, func) {
  for (const data of tree) {
    if (func(data)) return data
    if (data.children) {
      const res = treeFind(data.children, func)
      if (res) return res
    }
  }
  return null
}

2. 查詢節點路徑

略微複雜一點,因為不知道符合條件的節點在哪個子樹,要用到回溯法的思想。查詢路徑要使用先序遍歷,維護一個佇列儲存路徑上每個節點的id,假設節點就在當前分支,如果當前分支查不到,則回溯。

function treeFindPath (tree, func, path = []) {
  if (!tree) return []
  for (const data of tree) {
    path.push(data.id)
    if (func(data)) return path
    if (data.children) {
      const findChildren = treeFindPath(data.children, func, path)
      if (findChildren.length) return findChildren
    }
    path.pop()
  }
  return []
}

用上面的樹結構測試:

let result = treeFindPath(tree, node => node.id === '2-1')
console.log(result)

輸出:

["2","2-1"]

3. 查詢多條節點路徑

思路與查詢節點路徑相似,不過程式碼卻更加簡單:

function treeFindPath (tree, func, path = [], result = []) {
  for (const data of tree) {
    path.push(data.id)
    func(data) && result.push([...path])
    data.children && treeFindPath(data.children, func, path, result)
    path.pop()
  }
  return result
}

五、結語

對於樹結構的操作,其實遞迴是最基礎,也是最容易理解的。遞迴本身就是迴圈的思想,所以可以用迴圈來改寫遞迴。熟練掌握了樹結構的查詢、遍歷,應對日常需求應該是綽綽有餘啦。