理论基础:
二叉树是一种特殊的树形结构:每个节点最多有两个子节点,通常称为左子节点和右子节点,在计算机科学中广泛应用。
基本概念:
- 节点(Node): 二叉树的基本单位,包含数据和两个指向子节点的指针。
- 根节点(Root): 二叉树的顶部节点,没有父节点。
- 叶节点(Leaf): 没有子节点的节点。
- 子树(Subtree): 节点及其所有后代形成的树。
- 深度(Depth): 从根节点到指定节点的边的数量。
- 高度(Height): 从指定节点到最远叶节点的最长路径的边的数量。
- 层(Level): 树的一层包含所有深度相同的节点。
- 满二叉树: 所有非叶节点都有两个子节点。
- 完全二叉树: 所有的层都被完全填满,除了可能的最后一层,且最后一层的所有节点都尽可能地靠左排列。
- 平衡二叉树(AVL树): 任何节点的两个子树的高度差不超过1。
存储方式:
二叉树的存储方式主要有两种:链式存储和顺序存储。每种方式都有其特点和适用场景。
1. 链式存储
链式存储是二叉树最常见的存储方式。在这种方式中,每个节点包含三个部分:
- 数据域:存储节点的数据。
- 左指针域:指向左子节点。
- 右指针域:指向右子节点。
2. 顺序存储
顺序存储通常使用数组来实现。在这种方式中,树中的节点按照特定的顺序存储在数组中。对于二叉树,通常按照层次遍历的顺序存储节点。
假设根节点存储在数组的第 0 位,则对于数组中任意位置 i 的节点:
- 左子节点的位置是 2 * i + 1。
- 右子节点的位置是 2 * i + 2。
- 父节点的位置是 (i - 1) / 2。
遍历方式:
深度优先 Depth-First Search, DFS:
深度优先遍历有三种主要的遍历策略:
-
前序遍历(Pre-order Traversal):
- 访问根节点。
- 递归地对根节点的左子树进行前序遍历。
- 递归地对根节点的右子树进行前序遍历。
-
中序遍历(In-order Traversal):
- 递归地对根节点的左子树进行中序遍历。
- 访问根节点。
- 递归地对根节点的右子树进行中序遍历。
-
后序遍历(Post-order Traversal):
- 递归地对根节点的左子树进行后序遍历。
- 递归地对根节点的右子树进行后序遍历。
- 访问根节点。
广度优先 Breadth-First Search, BFS:
按照树的层次从上到下、从左到右访问每个节点。
- 创建一个队列:用于存储在遍历过程中遇到的节点。
- 将根节点入队:遍历的起点。
- 循环执行以下操作,直到队列为空:
- 从队列中取出一个节点。
- 访问该节点。
- 如果该节点有左子节点,将左子节点入队。
- 如果该节点有右子节点,将右子节点入队。
二叉树的实现:
python实现:
class treeNode:
def __init__(self, val = 0, left = None, right = None):
self.val = val
self.left = left
self.right = right
二叉树的递归遍历:
class treeNode:
def __init__(self, val = 0, left = None, right = None):
self.val = val
self.left = left
self.right = right
def preorderTraversal(self, root):
if root == None:
return []
return [root.val] + self.preorderTraversal(root.left) + self.preorderTraversal(root.right)
def inorderTraversal(self, root):
if root == None:
return []
return self.inorderTraversal(root.left) + [root.val] + self.inorderTraversal(root.right)
def postorderTraversal(self,root):
if root == None:
return []
return self.postorderTraversal(root.left) + self.postorderTraversal(root.right) + [root.val]
构建测试数据:
nodes = [treeNode(val) for val in [5,4,6,1,2,7,8]]
nodes[0].left = nodes[1]
nodes[0].right = nodes[2]
nodes[1].left = nodes[3]
nodes[1].right = nodes[4]
nodes[2].left = nodes[5]
nodes[2].right = nodes[6]
root = nodes[0]
测试:
print("前序遍历:", root.preorderTraversal(root))
print("中序遍历:", root.inorderTraversal(root))
print("后序遍历:", root.postorderTraversal(root))
输出:
前序遍历: [5, 4, 1, 2, 6, 7, 8] 中序遍历: [1, 4, 2, 5, 7, 6, 8] 后序遍历: [1, 2, 4, 7, 8, 6, 5]
二叉树的迭代遍历:
前序遍历:
迭代模拟栈帧压入系统调用栈的过程:前序遍历是最容易的,因为先处理了中间节点。那么第一步是处理第一个栈帧,也就是根节点先进栈。迭代逻辑是只要栈不空,每次先处理(弹出当前栈帧,随后记录value),然后将其右子节点(如果有)和左子节点(如果有)按顺序推入栈中,在下一轮迭代中按顺序处理这一轮的子节点。
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if root == None:
return []
stack, res = [root], []
while stack:
node = stack.pop()
res.append(node.val)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return res
中序遍历:
中序遍历的迭代实现更复杂,因为不能在遇到一个节点时立即处理。需要先遍历到它的最左子节点。过程中将遇到的所有节点都推入栈。当到达最左节点(没有左子节点的节点)时,回溯,从栈中弹出节点并处理,然后转移到弹出节点的右子节点。中序遍历的迭代法实现核心在于需要延迟处理节点直到它的左子树都被处理过。
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if root == None:
return []
stack, res, cur = [], [], root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
res.append(cur.val)
cur = cur.right
return res
这里详细记录一下自己刚开始不理解为什么while的循环条件是 cur or stack,为什么cur和stack同时为空是停止条件,为什么这两个条件共同表示了遍历过程已经访问了所有节点。
-
这个条件意味着当前的节点为空。在中序遍历中,一直向左走直到不能再走(即没有左子节点)。当我们到达一个没有左子节点的节点时,current
为空:current
会变成None
。但这并不意味着遍历已经完成,因为我们可能需要回溯(通过栈)到之前的节点并去访问右子树。 -
栈用来回溯到之前的节点。当我们从栈中弹出一个节点并处理它之后,我们会转到该节点的右子节点。如果栈为空而cur不为空,说明右子节点存在,栈又有节点被压进,会继续迭代;如果右子节点不存在(stack
为空:current
为None
),栈也空了,说明我们已经访问了所有节点的左子树和根节点,并且没有更多的右子树需要访问。
后序遍历:
前序遍历是中左右,后续遍历是左右中,那把前序遍历的代码改成中右左再反向输出即可。
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if root == None:
return []
stack, res = [root], []
while stack:
node = stack.pop()
res.append(node.val)
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return res[::-1]
尝试模拟递归过程,有点点复杂:
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
stack = []
result = []
last_visited = None
while stack or root:
# 尽可能地向左走
if root:
stack.append(root)
root = root.left
else:
peek = stack[-1]
# 如果右子节点存在且未被访问过,则向右走一步
if peek.right and last_visited != peek.right:
root = peek.right
else:
# 访问节点
result.append(peek.val)
last_visited = stack.pop()
return result
- 首先尽可能地向左走,并将沿途的节点推入栈中。
- 当不能再向左走时,我们查看栈顶的节点(但不弹出)。
- 如果栈顶节点有右子节点且这个右子节点还没有被访问过,我们转向该右子节点并继续执行步骤2。
- 如果栈顶节点没有右子节点或右子节点已经被访问过,我们访问栈顶节点并将其弹出栈,然后继续检查新的栈顶节点。
二叉树的统一迭代遍历:
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
stack = [root]
res = []
while stack:
node = stack.pop()
if node:
#右
if node.right: stack.append(node.right)
#左
if node.left: stack.append(node.left)
#中
stack.append(node)
stack.append(None)
else:
res.append(stack.pop().val)
return res
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
stack = [root]
res = []
while stack:
node = stack.pop()
if node:
#右
if node.right: stack.append(node.right)
#中
stack.append(node)
stack.append(None)
#左
if node.left: stack.append(node.left)
else:
res.append(stack.pop().val)
return res
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
stack = [root]
res = []
while stack:
node = stack.pop()
if node:
#中
stack.append(node)
stack.append(None)
#右
if node.right: stack.append(node.right)
#左
if node.left: stack.append(node.left)
else:
res.append(stack.pop().val)
return res
每个元素入栈两次,从根节点开始,每次弹出栈顶元素,如果是第一次访问该元素,将其与左右子树的根节点(如果存在)按从栈口看进去的方向顺成需要的DFS顺序,并且它二次进去的时候加入一个标记表明已经访问过了。
我们来考察这个迭代的循环不变式:
- 将res数组的末端连接栈顶,从左到右、从栈顶到栈底符合当前访问过的节点的DFS遍历顺序(前序、中序或后序)。
- 标记下方的元素是二次进入栈的元素。
循环开始时,只有root在栈中,res为空,那么从res的左到右,从栈的顶到底,符合遍历顺序。
循环中,有两种情况,
- 栈顶元素不是标记:考察栈顶元素的左右子树的根节点,如果不是空,将他们(连同自己和标记)按照从栈顶看的DFS顺序排进栈,res不变,栈顶元素被删除,他的左右孩子按顺序进入栈顶,整个序列还是满足DFS的顺序。
- 栈顶元素是标记:单纯把栈顶元素的值扔进了res队尾,整个序列还是满足DFS的顺序。
循环结束时,已经完成了遍历,只有res中有值,stack为空,结果也是满足DFS顺序的。
可以证明它的正确性。
今日总结:
很久很久之前学数据结构的时候接触二叉树,基本没有什么印象了。今天从头到尾详细的考察了二叉树的基础以及深度优先遍历的方法。
另外,系统学习了递归的写法,用作复习。并且学习使用迭代的方法去模拟递归过程,前序比较容易,因为处理顺序和访问顺序是一样的(其实只有“中”会被处理,所谓左右只是指针而已)。中序遍历和后序遍历模拟过程稍微复杂,后序遍历查阅很多资料才完成。
统一迭代写法使用标记二次进栈的方法统一了三种遍历的写法。考察了这种写法的循环不变式,理解了它的作用过程。