前序遍历
递归版本
function preorder(root: TreeNode | null, res: number[] = []): number[] {
if (root === null) {
return [];
}
res.push(root.val);
preorder(root.left, res);
preorder(root.right, res);
return res;
}
不做过多赘述。大家都很熟悉,也很简单。
递归版本
前序遍历的特点:
- 先遍历根节点
- 再遍历左子树,左子树不是叶子结点,就按照前序遍历特点,一直遍历下去。直到遍历到叶子结点
- 在遍历右子树。右子树同子树同理。
注意:2和3是可以反着来的,只是平常我们习惯2再3。
那么,我们打印一下图中二叉树的打印顺序**[10,7,4,5,11,10,12,13]**。
我们从前序遍历的特点,可以总结出一个很大的特点前序遍历的左子树,总是最先遍历,遇到一个遍历一个。右子树总是最后遍历,且父节点的右子树总是晚于子节点的右子树遍历。
从我们总结的就可以知道:
- 只要遇到一棵树的左子树,直接遍历出来就可以。
- 遇到一棵树的右子树,我们先存起来。
- 怎么存呢?
- 答:用栈。
- 因为:从我们模拟的过程中,我们总是从根节点出发,那么肯定是先得到父节点的右子树,再得到子节点的右子树。打印的时候,又是子节点的右子树先遍历完,再遍历父节点的右子树。这完美的符合栈的特点。
那么我们再用栈来进行模拟一遍遍历的过程。
- 得到根节点10,直接打印:[10]。10有右节点,入栈。stack=>[11]
- 10有左节点,直接打印:[10,7]。7没有右结点。不入栈。
- 7有左节点,打印:[10,7,4]。4没有右节点。不入栈。
- 4没有有左节点,不打印,4有右节点,入栈stack=>[11,5]
- 这个时候,就开始进行出栈。将一路存储的右节点出栈。
- 5出栈,打印:[10,7,4,5]。5没有左结点,没有右节点。[11]。
- 11出栈。打印:[10,7,4,5,11]。11有左节点。有右节点。入栈:stack[12]
- 11有左节点,打印[10,7,4,5,11,10]。10没有左节点,没有右节点。开始出栈。
- 出栈。12。打印[10,7,4,5,11,10,12],有右子树,入栈。stack[13]。
- 12没有左子树,出栈13。
- 打印[10,7,4,5,11,10,13]。栈为空,完毕。
我们用代码来翻译一下上面的话。
function preorderTraversal1(root: TreeNode | null): number[] {
if (root === null) {
return [];
}
const stack = [root];
const res: number[] = [];
while (stack.length !== null) {
while (root !== null) {
res.push(root.val);
if (root.right !== null) {
stack.push(root.right);
}
root = root.left;
}
root = stack.pop()!;
}
return res;
}
一开始一直遍历结点的left,中间遇到right就入栈。直到left为空。开始出栈,对右子树也是进行同样的操作。
我们一定要根据遍历的顺序,总结出用什么数据结构比符合整体的操作。剩下的,就是什么时候对数据结构进行增,删,查。
试着用这种思路试试,中序遍历和后序遍历。
中序遍历
递归版本
依旧不用多说
function inorder(root: TreeNode | null, res: number[] = []): number[] {
if (root === null) {
return [];
}
inorder(root.left, res);
res.push(root.val);
inorder(root.right, res);
return res;
}
迭代版本
我们用前序遍历的思路。发现中序遍历=》[4,5,7,10,10,11,12,13]是这样。4是最先遍历,然后是5,然后是7...一开始肯定不是10。但是我们一开始得到是10,这种先得到,后遍历的,就符合先进后出的栈。所有,哪些看起来是先得到,后遍历的,就直接入栈。找到某个时候,就出栈。
- 由于最开始遍历的是最左边的结点。那么,我们可以写出如下的代码:保证栈顶一定是最左边的。
while(root) { stack.push(root) root = root.left; } - 在判断栈顶元素是否有右节点。有的话就继续上面的操作。将一棵树的最左边的结点先打印。
const node = stack.pop()!
if(node.right !== null) {
root = node.right;
}
我们一结合就得到了最后的代码。
function inorderTraversal(root: TreeNode | null): number[] {
if (root === null) {
return [];
}
const res: number[] = [];
const stack: TreeNode[] = [];
// 这里的栈可能为空,root可能不为空。比如:遍历到根节点。根节点出栈。栈为空,root为11,这个时候需要再次进行入栈出栈操作。
// 前序遍历则不会出现栈为空,还没有遍历完成的情况。
while (stack.length !== 0 || root !== null) {
while (root) {
stack.push(root);
root = root.left;
}
const node = stack.pop()!;
res.push(node.val);
if (node.right !== null) {
root = node.right;
}
}
return res;
}
后序遍历
递归版本
不赘述
function poster(root: TreeNode | null, res: number[]): number[] {
if (root === null) {
return [];
}
poster(root.left, res);
poster(root.right, res);
res.push(root.val);
return res;
}
迭代版本
有了前序和中序迭代的经验。我们直接看打印结果:[5,4,7,10,13,12,11,10] 不用说5,4,7,10,这种都是满足先进后出的,肯定是栈。
与中序遍历不同,这次先打印的是5,而不是4.但是4,也是入栈,所以,第一步操作都是一样的。先入,再考虑什么时候出来。
whil(root) {
stack.push(root);
root = root.left;
}
上一步操作完成后,root肯定是4。但是我们不能打印4。我们需要先判断一个结点是否还有右节点,不可能存在左节点,不然的话,肯定进入while循环。如果有说明不是叶子结点,需要先把右子树的打印完成后,才能打印当前结点。并且,由于4还在栈里,它再次出栈的时候,不能再把它的右子树入栈。不然就是死循环。
const node = stack.pop()!
if(node.right !== null && preNode !== node.right){
stack.push(node)
root = node.right;
}else {
res.push(node.val)
preNode = node;
}
我们再把上面代码一结合。
function posterTraversal(root: TreeNode | null): number[] {
if (root === null) {
return [];
}
const res = [];
const stack: TreeNode[] = [];
let preNode: TreeNode | null = null;
// 因为栈可能为空。当最后一个出栈后,需要判断是否还有东西需要入栈,比如10.
// 我们也可以先判断是否有右节点,在进行是否出栈。只是我们需要获取栈顶元素。这样只是首次的需要特殊处理。
while (stack.length !== 0 || root !== null) {
while (root) {
stack.push(root);
root = root.left;
}
const node = stack.pop()!;
// 存在右结点,并且右结点没有被访问过
// 把当前出栈的结点再次入栈
// 继续入栈右结点
if (node.right !== null && node.right !== preNode) {
stack.push(node);
root = node.right;
} else {
res.push(node.val);
preNode = node;
}
}
return res;
}
到此完成。里面的思想很重要,过程可以自己推导,这样下次忘记了也会自己推导出来。