二叉树遍历的总结

二叉树是一棵树,其中每个节点都不能有多于两个的孩子。

理解二叉树的遍历可以帮助我们很好地理解递归以及栈。在前端的应用中也或多或少地会接触到树的遍历和操作(DOM树以及当前热门框架的各种diff算法),

除此之外,在数据库中建立索引,高效的查找也是得益于二叉搜索树(B+树)这种特殊的数据结构。其基本思想都是大同小异。

因此,掌握二叉搜索树的相关的遍历算法是一个合格工程师基本的技能。

对于二叉树的遍历,根据根节点的访问顺序,可以被分为先序遍历、中序遍历以及后序遍历。

作为广度优先搜索思想的体现,层次遍历(补充在最后)也需要掌握🤗🤗🤗。

首先需要声明一下相应的数据结构:

class TreeNode {
	constructer(val, left=null, right=null) {
		this.val = val;
		this.left = left;
		this.right = right;
	}
}

先序遍历

先序遍历:根节点->左子树->右子树

应用:可以用来实现目录结构的显示(不局限于二叉树)。

如果是文件夹,先输出文件夹名,然后再依次输出该文件夹下的所有文件(包括子文件夹),如果有子文件夹,则再进入该子文件夹,输出该子文件夹下的所有文件名。这是一个典型的先序遍历过程。

方法一:利用递归的思想

let res = ''
const preOrder_recur = function (root) {
	if (root === null) return ''
	res += root.val + ' '
	preOrder_recur(root.left)
	preOrder_recur(root.right)
}

方法二:非递归

先序遍历的特征就是先考虑根节点,如果根节点有值,则将其保存到结果集中,并继续考察其左孩子,直到左孩子为空,再转而去考察该节点的右孩子,

因此为了找到遍历过的节点,需要一个栈来保存访问过的节点。具体的算法步骤如下:

  1. 对于当前非空节点,将其值保存到result,然后置为该节点左孩子
  2. 如果该节点为空,则从栈顶取出节点,并令右孩子为当前节点。之后重复以上两个步骤,直到当前节点和栈都为空,就说明遍历完成。
const preOrder_stack1 = function (root) {
	if (root === null)	return
	const stack = []
	let curNode: TreeNode = root
	while (curNode !== null || stack.length > 0) {
		if (curNode !== null) {
			res += curNode.val + ' '
			stack.push(curNode)
			curNode = curNode.left
		} else {
			let node = stack.pop()
			curNode = node.right
		}
	}
	return res
}

另外,对于非递归前序遍历的实现,还有一种更容易理解的方法。

从递归的思路出发,我们打印了根节点的值以后,首先递归左孩子节点,然后递归右孩子节点,从而完成前序的遍历。

在通过栈来模拟递归之前,我们需要知道栈的存储满足FILO原则,因此为了首先能访问到左节点,则应该先将右节点压入栈,之后再将左节点压入,从而保证了访问的顺序为先左后右

const preOrder_stack2 = function (root) {
	if (root === null)	return
	const stack = []
	stack.push(root)
	while (stack.length > 0) {
		let node = stack.pop()
		if (node.right) stack.push(node.right)
		if (node.left) stack.push(node.left)
		res += node.val + ' '
	}
	return res
}

其实上面的所谓的非递归版本的本质也是利用了递归的思想,通过手动对栈的控制模拟了递归的过程。

方法三:Morris遍历

这里还有一种通过修改二叉树结构来达到常数级别的空间复杂度实现的二叉树的遍历,不可谓不强。

Morris 先序遍历步骤:

  1. 如果当前节点没有左子树,输出当前节点并当前节点为其右孩子
  2. 如果当前节点有左子树,找到当前节点的左子树的最右节点,记为mostRight
    • 如果mostRight节点的右指针为空,则将其指向当前节点并打印该节点,然后当前节点指针左移
    • 如果mostRight节点的右指针指向当前,则将其指向空,当前节点指针右移
  3. 重复以上步骤,直到当前节点为空
    在这里插入图片描述
    稍微具体的注释写进代码里:
const preOrder_morris = function (root) {
	if (root === null)	return ''
	
	let cur: TreeNode = root
	let mostRight: TreeNode = null
	
	while (cur !== null) {
		mostRight = root.left
		if (mostRight !== null) {
			// 找到左子树最右节点 且避免因为回环而返回到 cur 节点
			while (mostRight !== null && mostRight !== cur) {
				mostRight = mostRight.right
			}
			if (mostRight === null) {
				// 打印第一次遍历到的 cur 节点
				res += cur.val + ' '
				// 指向 cur
				mostRight.right = cur
				cur = cur.left
				continue
			} else {
				// 右指针指向 cur 将其还原
				mostRight.right = null
			}
		} else {
			// 没有左子树
			res += cur.val + ' '
		}
		// 不管怎样 都指向右指针
		cur = cur.right
	}
	return res
}

中序遍历

中序遍历:左子树->根节点->右子树

应用:可以用来做表达式树,在编译器底层实现的时候用户可以实现基本的加减乘除,比如 a * b + c

方法一:递归

const res = ''
const inOrder_recur = function (root) {
	if (root === null) return
	inOrder_recur(root.left)
	res += root.val + ' '
	inOrder_recur(root.right)
}

方法二:非递归

非递归的中序与上面的非递归前序的大体思路相同。只是中序遍历应该跳过打印当前节点的值,先考虑左孩子,一旦左孩子为空,就打印当前节点的值,并递归地考虑其右孩子。同样这里的递归也是利用栈手动实现,通过栈来保存遍历过的节点。

具体的算法步骤如下:

  1. 若当前节点不为空,则将当前节点入栈,并转向其左孩子。
  2. 如果当前节点为空,将栈顶节点弹出并访问,然后转向其右孩子,重复算法步骤,当且仅当栈和当前节点都为空的时候,说明遍历完成。

根据上面的步骤,不难写出如下代码:

const inOrder_stack1 = function (root) {
	if (root === null)	return
	const stack = []
	let curNode: TreeNode = root
	while (curNode || stack.length) {
		if (curNode !== null) {
			stack.push(curNode)
			curNode = curNode.left
		} else {
			let node = stack.pop()
			res += node.val + ' '
			curNode = node.right
		}
	}
	
	return res
}

可以看出其与preOrder_stack一模一样,只是打印值的位置不同而已。

根据同样的思路,也可以写出如下的代码:

const inOrder_stack2 = function (root) {
  let stack = []
  while (stack.length > 0 || root !== null) {
    // 只要root节点和栈不同时为空 就说明还存在未保存的值
    while (root) {
      // 按照栈的先入后出原则 使得左儿子节点能最先遍历
      stack.push(root)
      root = root.left
    }
    if (stack.length > 0) {
      root = stack.pop()
      res += root.val + ' '
      root = root.right
    }
  }
  return res
};

方法三:Morris遍历

同样也有Morris中序遍历的版本。其算法步骤如下:

  1. 如果当前节点的左孩子为空,输出当前节点并更新当前节点为其右孩子
  2. 如果当前节点的左孩子不为空,则在当前节点的左子树中找到最右的节点mostRight
    • 如果mostRight的右孩子为空,则将其右孩子指向当前节点,当前节点更新为其左孩子
    • 如果mostRight的右孩子就是当前节点,删除右孩子,输出当前节点,并将当前节点更新为其右孩子
  3. 重复以上步骤,直到当前节点为空
    在这里插入图片描述
    可以看到Morris中序遍历与先序遍历的区别就在输出节点的时机上:
const inOrder_morris = function (root) {
	if (root === null)	return ''

	let cur: TreeNode = root
	let mostRight: TreeNode = null
	
	while (cur !== null) {
		mostRight = cur.left
		if (mostRight !== null) {
			// 找到左子树最右节点 且避免因为回环而返回到 cur 节点
			while (mostRight && mostRight !== cur) {
				mostRight = mostRight.right
			}
			if (mostRight.right === null) {
				mostRight.right = cur
				cur = cur.left
				continue
			} else {
				// 右孩子为当前节点
				mostRight.right = null
				// 在第二次访问到该节点的时候再打印值
				res += cur.val + ' '
			}
		} else {
			// 没有左子树
			res += cur.val + ' '
		}
		cur = cur.right
	}
	return res
}

神级的Morris遍历的先序和中序同非递归(辅助栈)的先序和中序一样,都是一个模板,只是打印当前节点的时机不同。好好理解。

后序遍历

后序遍历:左子树->右子树->根节点

应用:可以用来实现计算目录内的文件占用的数据大小(不局限于二叉树)。

若要知道某文件夹的大小,必须先知道该文件夹下所有文件的大小,如果有子文件夹,若要知道该子文件夹大小,必须先知道子文件夹所有文件的大小。这是一个典型的后序遍历过程。

方法一:递归

还是轻松加随意地写出递归的后序遍历:

const postOrder_recur = function (root) {
	if (root === null)	return ''
	postOrder_recur(root.left)
	postOrder_recur(root.right)
	res += root.val + ' '
}

方法二:非递归(辅助栈+标志位)

思路:和中序遍历的非递归类似的是,由于左子树最先被访问,因此先将向左遍历的各个节点压入栈中。直到为空然后再考虑弹出栈顶节点并考虑其右孩子。为了避免这样的逻辑影响到访问根节点的时候再一次访问其右孩子,需要设立一个最近访问的标志位。

这里可能说的有点模糊,看代码会比较清晰:

const postOrder_stack1 = function (root) {
	const stack = [];
	const [p, recent] = [root, null];
	while (p || stack.length) {
		if (p) {
			// 遍历到最左边
			stack.push(p);
			p = p.left;
		} else {
			// 取出栈顶节点
			const node = stack.slice(-1);
			if (node.right && node.right !== rencent) {
				// 有右孩子且未被访问
				p = node.right;
			} else {
				// 弹出当前节点并访问
				p = stack.pop();
				res += p.val + ' ';
				// 记录访问标志位
				rencent = p;
				// 重置 p,下一次取栈中的节点
				p = null;
			}
		}
	}
	return res;
}

另外地,我们也可以通过手动栈的方式来模拟后序的递归。只是这里需要两个栈才能维持正确的顺序,算法的思想在于:设置两个栈,分别为S1,S2。其中S1用于处理遍历顺序,S2则用于保存最后打印的结果顺序。

考虑到栈的FILO原则,所以在后序遍历中,我们应该创造一个S2的入栈顺序为:根->右孩子->左孩子,这样才能保证其弹出的顺序为后序。

S1则用于中转,首先将根节点放入S2,然后将根节点的左右孩子依次入栈S1,方便之后的子树以右孩子->左孩子的顺序入栈S2。之后同样的逻辑,将根节点压入S2,然后再将该节点的左右孩子压入S1从而制造顺序。重复执行,直到栈S1全为空。

const postOrder_stack2 = function (root) {
	if (root === null) return ' '
	const [stack1, stack2] = [[root], []];
	while (stack1.length) {
		const node = stack1.pop();
		stack2.push(node);
		if (node.left)	stack1.push(node.left);
		if (node.right)	stack1.push(node.right);
	}
	while (stack2.length) {
		res += stack2.pop().val + ' ';
	}
	return res;
}

方法三:Morris遍历

后续遍历稍显复杂,需要建立一个临时节点dummy,令其左孩子是root。并且还需要一个子过程,就是倒序输出某两个节点之间路径上的各个节点。

步骤:
当前节点设置为临时节点dummy

  1. 如果当前节点的左孩子为空,则将其右孩子作为当前节点
  2. 如果当前节点的左孩子不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点
    • 如果前驱节点的右孩子为空,将它的右孩子设置为当前节点。当前节点更新为当前节点的左孩子
    • 如果前驱节点的右孩子为当前节点,将它的右孩子重新设为空。倒序输出从当前节点的左孩子到该前驱节点这条路径上的所有节点。当前节点更新为当前节点的右孩子
  3. 重复以上直到当前节点为空
    在这里插入图片描述
    后序Morris遍历的主逻辑:
const postOrder_morris = function (root) {
	if (root === null)	return '';

	let cur: TreeNode = root;
	let mostRight: TreeNode = null;
	
	while (cur !== null) {
		mostRight = cur.left;
		if (mostRight !== null) {
			// 找到左子树最右节点 且避免因为回环而返回到 cur 节点
			while (mostRight && mostRight !== cur) {
				mostRight = mostRight.right;
			}
			if (mostRight.right === null) {
				mostRight.right = cur;
				cur = cur.left;
				continue;
			} else {
				// 右孩子为当前节点
				mostRight.right = null;
				// 在第二次访问到该节点的时候逆序打印值
				printEdge(cur.left);
			}
		}
		cur = cur.right
	}
	return res
}

逆序函数reverseEdge和打印函数printEdge,其中reverseEdge函数的本质就是链表的逆序,只是这里right指针为链表的next指针,需要注意的是在printEdge函数中逆序调整之后,需要将其结构还原。

const reverseEdge = function (from) {
	const [pre, nxt] = [null, null];
	while (from !== null) {
		nxt = from.right;
		from.right = pre;
		pre = from;
		from = nxt;
	}
	return pre;
}

const printEdge = function (root) {
	const tail = reverseEdge(root);
	const cur = tail;
	while (cur !== null) {
		res += cur.val + ' ';
		cur = cur.right;
	}
	// 还原
	reverseEdge(tail);
}

其后序实现与中序遍历无差,区别在于:对能来到两次的节点,逆序打印其左子树的右边界。

虽然是神级方法,但实际用到的情形似乎并不是很多,作为一种眼界和思想的开阔也是极好的。

层次遍历

因为比较熟就不展开了,直接贴上以前写的TypeScript的关于层序遍历的代码,也有深度优先和广度优先两种不同的写法:

const levelOrderBFS = function (root: TreeNode) {
    // BFS
    if (root === null) {
      return [];
    }
    let res: number[][] = [];
    let cur: TreeNode[][] = [[root]];
    while (cur.length > 0) {
      let level = cur.shift();
      let nodeList = [];
      let valueList = [];
      while (level.length > 0) {
        let node = level.shift();
        valueList.push(node.val);
        if (node.left) {
          nodeList.push(node.left);
        }
        if (node.right) {
          nodeList.push(node.right);
        }
      }
      res.push(valueList);
      if (nodeList.length > 0) cur.push(nodeList);
    }
    return res;
  }

const levelOrderDFS = function (root: TreeNode) {
    // DFS
    let level: number = 0,
        res: number[][] = [];
    if (root === null) {
      return [];
    }
    const helper = function (root: TreeNode, level: number): void {
      if (res.length === level) {
        res.push([]);
      }
      res[level].push(root.val);
      if (root.left) {
        helper(root.left, level + 1);
      }
      if (root.right) {
        helper(root.right, level + 1);
      }
    };
    helper(root, level);
    return res;
  }

结束!!!撒花💐💐💐。。。

参考(图来源)

遍历二叉树的神级方法

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Key Board

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值