说明
关于数据结构中树的算法,经过最近一段时间的做题经验,在我看来,无非就是两种情况
- 将所有的树都遍历一遍,得出最后的解。
- 树的分解思路,通过树的子树推导出原问题的解。
关于第一种 (将所有的树都遍历一遍,得出最后的解。) ,最直观的理解就是从根节点开始,遍历每一个节点,对这些节点进行一定的处理,最后获得解。
树遍历
让我们来看以下四道题:
前序遍历
我们已知前序遍历的方式是 "根->左->右" ,所以通过它我们能够很明确的知道应该从根节点开始,依次遍历左子节点直到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)
}