数据结构--树

说明

关于数据结构中树的算法,经过最近一段时间的做题经验,在我看来,无非就是两种情况

  • 将所有的树都遍历一遍,得出最后的解。
  • 树的分解思路,通过树的子树推导出原问题的解。

关于第一种 (将所有的树都遍历一遍,得出最后的解。) ,最直观的理解就是从根节点开始,遍历每一个节点,对这些节点进行一定的处理,最后获得解。

树遍历

让我们来看以下四道题:

前序遍历

我们已知前序遍历的方式是 "根->左->右" ,所以通过它我们能够很明确的知道应该从根节点开始,依次遍历左子节点直到null,再回溯回去遍历右子节点,因此可以写下如下代码(伪)

scss 复制代码
traverse(root) {

  !root && return 

  // 遍历根
  console.log(root.val)

  // 遍历左
  traverse(root.left)

  // 回溯---遍历右

  traverse(root.right)
}

// 或者

traverse(root) {
  // 利用 栈 先进后出进行遍历
  stack = [root]
  
  while(stack.length) {
    cur = stack.pop()
    // 遍历
    console.log(cur.val)
    // 放入右入栈
    cur.right && stack.push(cur.right)
    // 放入左入栈
  	cur.left && stack.push(cur.left)
  }
}

中序遍历

同理,对比中序遍历("左->根->右"),后序遍历("左->右->根")也是如此, 层序遍历我们后面说

scss 复制代码
traverse(root){

  !root && return 

  // 遍历左
  traverse(root.left)

  // 遍历根
  console.log(root.val)

  // 遍历右
  traverse(root.right)
  
}

// 或者

traverse(root) {
  let cur = root
  let stack = []

  while( cur || stack.length) {
    
    // 定位到左叶子节点
    while(cur) {
      stack.push(cur)
      cur = cur.left
    }

    // 遍历
    cur = stack.pop()
    console.log(cur.val)

    cur = cur.right
  }
}

后序遍历

scss 复制代码
traverse(root) {

  !root && return 

  // 遍历左
  traverse(root.left)

  // 回溯---遍历右

  traverse(root.right)

  // 遍历根
  console.log(root.val)
}

// 或者

traverse(root) {
  // 利用 栈 先进后出进行遍历
  stack = [root]
  
  while(stack.length) {
    cur = stack.pop()
    // 放入右入栈
    cur.right && stack.push(cur.right)
    // 放入左入栈
  	cur.left && stack.push(cur.left)
    // 遍历
    console.log(cur.val)
  }
}

最后来说一下层序遍历,层序遍历的过程是针对树的每一层,从左到右依次遍历,这个时候我们只需要关注当前层的节点,并将他们依次输出就可以了,所以我们需要用一个空间来记录当前层有哪些节点,由此就想到了队列结构> (思考🤔:为什么不能用栈结构,队列结构和栈结构有哪些区别)

先直接上代码:

scss 复制代码
traverse(root) {
  !root && return []

  queue = [root]

  // 判断当前层是否为空
  while(queue.length) {
    // 记录len
    len = queue.length
    // 遍历当前层所有节点
    for(i =0; i < len; i++) {
      top = queue.shift()
      console.log(top.val)

      top.left && queue.push(top.left)

      top.right && queue.push(top.right)
    }
    
  }
}

那为什么使用栈结构不行,而队列结构可以呢?我相信很多人应该已经看出来了,如果使用栈结构(先进后出),要达到从左至右遍历当前层, 如果树只有两层【1,2,null,null,3,null,null】,我们交换push进入左右子树的顺序即可了。但是当树超过2层,结构为【1,2,3,4,5】的时候,会发现栈结构一直不断push新节点,当我们取出栈顶时,栈顶节点的层级数已经发生了改变,所以会导致输出的树节点错乱。

而队列结构(先进先出),在我们记录的len长度下,都是该root节点的下一级子节点,所以不会导致层级错乱,自然在一个for循环里输出的节点都是同一层级的节点。

树的构造(经典遍历方式)

上面4道题就是经典的树的四种遍历方式,当下已经完全明白了它的遍历过程以及编码实现,下面我们再来看下它的逆序操作,树是如何构造的。当然,树的构造是树的组装过程,基本思路就是构造节点,再构造它的子节点(左,右),这样一看,这不就是树的分解嘛。同样,我们来看以下四道题:

  • 构造最大二叉树
  • 通过前序和中序遍历序列构造二叉树
  • 通过中序和后序遍历序列构造二叉树
  • 通过前序和后序遍历序列构造二叉树

构造最大二叉树

ini 复制代码
- 假设一颗不含重复元素的二叉树为[3, 2, 1, 6, 0, 5], 我们现在要构造最大二叉树。
- 则根据最大二叉树的定义:二叉树的根是数组中的最大值
- 分析可知如下:
  1. 根节点:是数组中的最大值
  2. 左子树:是根左边的数组中的最大值
  3. 右子树:是根右边的数组中的最大值
 

- 基本思路如下:
```javascript
function findMaxIndex(nums) {
  let maxIndex = 0
  for(let i =0; i < nums.length; i++) {
    if(nums[i] > nums[maxIndex]) {
      maxIndex = i
    }
  }
  return maxIndex
}

function build(nums) {
  let MaxIndex = findMaxIndex(nums)

  let node = new TreeNode(nums[MaxIndex])
  node.left = build(nums.slice(0, MaxIndex))
  node.right = build(nums.slice(MaxIndex + 1))

  return node
}
```


- 具体代码
```javascript
var findMaxIndex = function(nums, l, r) {
  let maxIndex = l
  for (let i = l; i <= r; i++) {
      if(nums[i] > nums[maxIndex]) {
          maxIndex = i
      }
  }
  return maxIndex
}

var build = function(nums, l, r) {
  if(l > r || !nums.length) return null
  let maxIndex = findMaxIndex(nums, l, r)
  let node = new TreeNode(nums[maxIndex])
  node.left = build(nums,l, maxIndex - 1)
  node.right = build(nums, maxIndex + 1, r)
  return node
}

var constructMaximumBinaryTree = function(nums) {
  return build(nums, 0, nums.length - 1)
};
```

上述代码就完整的展示了如何去构建一颗最大二叉树,其实从代码不难看出,只要理解了构造二叉树的过程,就很容易写出代码块,一开始可能只能写出来"基本思路代码",但是没关系,我们可以通过增增补补,来完善出最终的结果。但是整体来说,就是【树的分解】,也可以称为【分治】,上述代码的构建过程,是不是和归并排序也很像?

通过前序和中序遍历序列构造二叉树

scss 复制代码
- 前序[3, 9, 20, 15, 7], 中序[9, 3, 15, 20, 7]

- 思路分析
  首先,做这种类型的题目,应该先搞清楚前序/中序遍历的过程,以及它们遍历输出的结果有什么特点。我们先来回顾
  上文中的遍历过程,前序(根->左->右),所以pre[0]一定是根节点,而中序遍历的过程是(左->根->右),
  根据pre[0],找到中序当中处于根节点的位置,其左就应该是它左子树,其右则是右子树。这样我们就将一颗树
  分解成了两颗,然后再重复上述过程,就能够构建出完整的树了。

- 基本思路如下:
```javascript

function buildTree(preTree, ordTree) {
  return build(preTree, ordTree, 0, preTree.length - 1, 0, ordTree.length - 1)
}

function findIndexFromOrdTree(ordTree, root) {
  return ordTree.findIndex(root)
}

function build(preTree, ordTree, lStart, lEnd, rStart, rEnd) {
  let rootVal = preTree[lStart]
  let rootIndex = findIndexFromOrdTree(ordTree, root)

  let node = new TreeNode(rootVal)

  node.left = build(preTree, ordTree, lStart + 1, lStart + index - rStart, rStart, index - 1)

  node.right = build(preTree, ordTree, lStart + l + index - rStart, lEnd, index + 1, rEnd)

  return node
}
```


- 完整代码+优化
```javascript
// Map存储值映射到索引
let valToIndex = new Map()

var build = function(preorder, inorder, preStart, preEnd, inStart, inEnd) {
    if(preStart > preEnd || inStart > inEnd) return null
    // 根值
    let rootVal = preorder[preStart]
    // 根索引
    let rootIndex = valToIndex.get(rootVal)
    // 左子节点长度
    let leftSize = rootIndex - inStart
    let root = new TreeNode(rootVal)
    // 构建左子树
    root.left = build(preorder, inorder, preStart + 1,leftSize + preStart,  inStart, rootIndex - 1)
    // 构建右子树
    root.right = build(preorder, inorder,  preStart + 1 + leftSize, preEnd, rootIndex + 1, inEnd)

    return root
}

var buildTree = function(preorder, inorder) {
    for(let i = 0; i < inorder.length; i++) {
        valToIndex.set(inorder[i], i)
    }
    return build(preorder, inorder, 0, preorder.length - 1, 0, inorder.length - 1)
};
```

分析这道题,其实思路很简单,难点其实在于找到分割左右子树的点,即:

var build = function(preorder, inorder, preStart, preEnd, inStart, inEnd) { }

左右子树的preStart, preEnd, inStart, inEnd该怎么找出来呢?看如下图(截取至labuladong的算法小抄)

我们先根据preorder找到根为1 ,以及其对应rootIndex: 5,

首先我们根据rootIndex的值求出rootLeft = rootIndex - inStart

所以left的preorder的preStart为:preStart + 1 ; preEnd为:preStart + rootLeft;

left的inorder的inStart为:inStart ; inEnd为:rootIndex - 1;

同理:

right的preorder的preStart为:preStart +1 + rootLeft ; preEnd为:preEnd;

right的inorder的inStart为:rootIndex + 1 ; inEnd为:inEnd

这道题看懂了,我相信针对通过后序和中序遍历构造二叉树 和针对前序和后序遍历构造二叉树 就很简单了,以下就直接上代码,先来看后序中序遍历构造二叉树

根据后序和中序遍历构造二叉树

ini 复制代码
let valToIndex = new Map()

var build = function(postorder, inorder, postStart, postEnd, inStart, inEnd) {
    if(postStart > postEnd || inStart > inEnd) return null
    let rootVal = postorder[postEnd]
    let rootIndex = valToIndex.get(rootVal)
    let leftLen = rootIndex - inStart
    let root = new TreeNode(rootVal)

    root.left = build(postorder, inorder, postStart, postStart + leftLen - 1, inStart, rootIndex - 1)
    root.right = build(postorder, inorder, postStart + leftLen, postEnd - 1, rootIndex + 1, inEnd)
    return root
}

var buildTree = function(inorder, postorder) {
    for(let i = 0; i < inorder.length; i++) {
        valToIndex.set(inorder[i], i)
    }

    return build(postorder, inorder, 0, postorder.length - 1, 0, inorder.length - 1)
};

是不是和前序和中序遍历序列构造二叉树如出一辙,区别只在于,找根节点从原来的preorder[preStart], 变成了现在的postorder[postEnd]以及遍历左右子树起始和终止索引位置的区别。

根据前序和后序遍历构造二叉树

通过前序和后序遍历构造二叉树,和前文的前序+中序,后序+中序就有所不同了,由前序+后序构造出来的二叉树可能并不是唯一的。但是构造的基本原理是一样的,依然是先构造根节点,然后去构造它的左右子树。我们来看一下下面这道题

diff 复制代码
- 题目描述
- preorder : [1, 2, 3]    postorder: [3, 2, 1]

经过上面的讲解,我们已知,preorder索引为0的元素和postorder索引为length - 1的元素都是构造的二叉树的root。 所以我们可以先去除preorder[0]或者postorder[len - 1]作为root, 然后将preorder的preorder[1]的元素作为左子树的根节点, 根据这个根节点去postorder中寻找索引边界,进而就可以确定右子树的索引边界,来递归的构造左右子树。

(🤔: 为什么这里我们可以找preorder[1]作为左子树根节点,并再postorder中找到它的索引就能分割出来左右子树呢?)

首先,再次强调一次,前序+后序遍历根本无法确定唯一的二叉树。所以我们这里只是假定preorder[1]为左子树的根节点(也许这颗树只有右子树的情况),所以后面的所有结果都是在此假定的基础上构造的。回到主题,我们以preorder[1]作为了左子树根节点leftRootVal,那么就可以从postorder中找到它的索引,从而索引index左边部分就是左子树的长度,index+1 ~ postEnd - 1 部分就是右子树的长度。

分析完成,现在让我们来写一写代码!

ini 复制代码
let ValToIndex = new map()

var build = function (preorder, postorder, preStart, preEnd, postStart, postEnd) {
  if(preStart > preEnd || postStart > postEnd) return null

  if(preStart === preEnd) {
     return new TreeNode(preorder[preStart])
  }

  let rootVal = preorder[preStart]
  let leftRootVal = preorder[preStart + 1]
  let leftRootIndex = ValToIndex.get(leftRootVal)
	let leftSize = leftRootIndex - postStart
  let root = new TreeNode(rootVal)

  root.left = build(preorder, postorder, preStart + 1, preStart + leftSize, postStart, leftRootIndex)
  root.right = build(preorder, postorder, preStart + leftSize + 1, preEnd, postStart + leftSize + 1, postEnd - 1)

  return root
}

var buildTree = function (preorder, postorder) {
  for(let i = 0; i < postorder.length; i++) {
    ValToIndex.set(postorder[i], i)
  }

  return build(preorder, postorder, 0, preorder.length - 1, postorder.length - 1)
}

未完待续...

相关推荐
PandaCave5 分钟前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟7 分钟前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾28 分钟前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧37 分钟前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm1 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒2 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript
gqkmiss2 小时前
Chrome 浏览器 131 版本开发者工具(DevTools)更新内容
前端·chrome·浏览器·chrome devtools