二叉树是计算机当中最重要的数据结构之一,其应用非常广泛,例如数据库的索引使用的B+树是一种特殊的二叉树,堆排序所使用的堆是一种特殊的二叉树,Java
当中HashMap
使用的红黑树是一种特殊的二叉树。可见,二叉树在计算机编程当中有着重要地位。二叉树的遍历是二叉树的基本操作,不仅是面试的常考考点,也是程序员用来锻炼思维的小把戏。
二叉树的定义是递归的,即满足如下条件的树是二叉树:
我们可以看到,二叉树的定义是递归的,递归的边界是一个节点没有子树。如下图所示,是一棵二叉树:
可以看到,二叉树当中的每个节点,有值,也可能有左子节点,也可能有右子节点,因此我们经常用二叉链表的形式存储。上述二叉树在内存当中将以以下形式存储。
图中,每个节点对应三个字段,或者叫域(field),包括:值(val)、左子节点的地址(left)、右子节点的地址(right),地址用ax
表示,表示节点x
的内存地址(address)。如果一个节点没有左子节点,那么对应的字段为null
。
二叉树的节点对应的Python
类可以写成如下形式:
# 节点的数据结构
# val表示值、left表示当前节点的左子节点地址、right表示右子节点地址
class Node(object):
def __init__(self, val, left, right):
self.val = val
self.left = left
self.right = right
既然二叉树用上面这种形式存储起来了,那么给定一个根节点(上图当中的第1个节点,值为1的节点),我们就可以知道这个二叉树的所有信息了,这和链表类似:对于一个单链表,只给一个头指针节点,就可以知道这个单链表的所有信息。因此,当我们说给定一个二叉树的时候,其实就是给定一个二叉树的根节点,因为知道根节点就知道这棵二叉树的全部了。
二叉树的遍历方式可以分为两类:深度优先遍历(Depth First Search, DFS
)和广度优先遍历(Breadth First Search, BFS
)。广度优先遍历BFS会在后续的博客中再谈,而深度优先遍历又包含前序、中序、后序遍历,是今天的重点。
前序、中序、后序遍历中的“序”指的是在遍历过程中,根节点遍历的时间,相对左子树和右子树的次序。三者的定义分别如下:
说明:我们规定,无论是哪种顺序,左子树和右子树的顺序是,先左子树后右子树。这两个的顺序不是很关键,不重要。
可以看到,上面三种遍历方式的定义,也是递归的,“遍历左子树”、“遍历右子树”,我们知道二叉树的某个节点的子树,也是二叉树,这就是递归,但是问题规模减小了,所以这个递归是可解的。那么,递归边界是什么?边界是:当一棵二叉树的左子节点和右子节点,都不存在,都为null
的时候,就不继续往下遍历了。
针对上图的例子,前序遍历的过程如下:
首先遍历根节点1,如下图,将1存储在结果数组的最开始位置。然后发现有两棵子树,先不考虑两棵子树怎么遍历,我们只需要知道,左子树遍历的结果排在数组当中根节点1的后面,右子树的遍历结果存储在左子树遍历结果的后面,如下图所示。
上图中,绿色长条表示我们编程时候使用的结果数组。遍历分为三大步骤:
那么左子树、右子树该怎么遍历呢,采用和上述步骤相同的步骤。拿左子树举例,如下图所示:
左子树也是一棵二叉树,根节点是2,新的根节点有左右两棵子树。类似地,这棵树的遍历分为三大步骤:
因此,这棵树的遍历结果是:2 (4) (5 7),括号表示左子树和右子树,展开就是2 4 5 7
。
而原二叉树的右子树的遍历结果是:3 () (6)
,其中括号()
表示左子树是空的,没有元素,展开就是3 6
。
所以将(2 4 5 7)
和(3 6)
追加到根节点1
的后面,就得到了原二叉树的前序遍历结果:1 (2 4 5 7) (3 6)
。
下面再来看中序遍历和后序遍历。方便起见,二叉树的图再贴到下面:
上面我们已经得到了前序遍历的结果是1 (2 4 5 7) (3 6)
,其中(2 4 5 7)
是左子树的前序遍历的结果,(3 6)
是右子树前序遍历的结果。中序遍历的结果就是先遍历左子树,再遍历根节点,最后遍历右子树。那么是不是可以根据前序遍历直接得到中序遍历的结果呢:(2 4 5 7) 1 (3 6)
?不能!因为这里要求左子树和右子树也必须是中序遍历的,而(2 4 5 7)
是前序遍历的结果。
中序遍历的结果,可以分为三大步骤:
4
,再遍历2
,最后遍历5和7
(5
相对7
是根节点,7
相对5
是左子树,应该先遍历7
,再遍历5
,所以是(7 5)
)。因此左子树中序遍历的结果是:(4) 2 (7 5)
。1
。3
和6
,发现这颗子树当中,根节点3
的左子树为空,那么就遍历根节点3
,再遍历右子树6
,而右子节点6
没有子树了,结束了。所以这颗右子树的中序遍历结果是:3 (6)
所以,上述二叉树的中序遍历结果是:(4 2 7 5) 1 (3 6)
。打括号知识为了标记谁是子树,最终结果不需要括号。
后序遍历,将根节点放到最后,那么大致的顺序是(2 4 5 7) (3 6 ) 1
,根节点1的位置确定放到最后了。
左子树(2 4 5 7)
,根节点是2
放到最后,剩下左子树4
(遍历左子树,发现为空,遍历右子树,发现为空,最后遍历根节点4
)和右子树(5 7)
(遍历根节点5
的左子树7
,遍历右子树为空,最后遍历根节点5
),因此右子树(5 7)
的后续遍历结果是(7 5)
。因此这整个左子树的结果是(4 7 5 2)
,
右子树(3 6)
先遍历6,最后遍历3,因此是(6 3)
。
所以,上述二叉树的后序遍历结果是:(4 7 5 2) (6 3) 1
。打括号知识为了标记谁是子树,最终结果不需要括号。
根据上面的讨论,我们已经了解了三种遍历方式。三种遍历的定义本身就是递归的,因此用递归的方式编写相对更简单,三种遍历方式的递归实现Python代码如下(经测试有效):
# 全局变量记录遍历的结果
result = []
# 前序
def dfs_before(root):
if root == None: # 遇到None,说明不用继续搜索下去
return
result.append(root)
dfs_before(root.left)
dfs_before(root.right)
# 中序遍历
def dfs_middle(root):
if root == None:
return
dfs_middle(root.left)
result.append(root)
dfs_middle(root.right)
# 后序遍历
def dfs_after(root):
if root == None:
return
dfs_after(root.left)
dfs_after(root.right)
result.append(root)
# 节点的数据结构
# val表示值、left表示当前节点的左子节点地址、right表示右子节点地址
class Node(object):
def __init__(self, val, left, right):
self.val = val
self.left = left
self.right = right
if __name__ == "__main__":
node7 = Node(7, None, None)
node5 = Node(5, node7, None)
node4 = Node(4, None, None)
node2 = Node(2, node4, node5)
node6 = Node(6, None, None)
node3 = Node(3, None, node6)
node1 = Node(1, node2, node3)
# dfs_before(node1)
# dfs_middle(node1)
dfs_after(node1)
for e in result:
print(e.val, end=' ')
print()
既然已经有了递归方式的实现,为什么还要使用非递归形式实现遍历呢?拿Java
举例说明(Java
当中的方法相当于函数),函数存储在栈当中,每个函数对应一个栈帧,这个栈帧存储着函数签名、局部变量和引用、前驱函数签名、后继函数签名,前驱和后继函数签名记录着函数嵌套的关系,当一个函数执行完毕,其对应栈帧立即出栈,继续执行下一个函数。
然而,栈的大小是有限的,而且一般被设置成较小的值,因为里面存储的是临时信息。递归就是函数自己调用自己,如果递归深度为10,就有10个栈帧存储在栈当中,虽然这些栈帧对应同一个函数。因此,如果递归深度太深,容易造成栈溢出。例如数据库索引使用的B+树的深度是100,再递归搜索的时候需可能要存储100个栈帧,容易造成栈内存溢出。
所以,在大规模场景下,为了程序能够稳定运行,一般不允许使用递归,甚至在工程代码中写递归代码的程序员可能面临着失业。所以,非递归的方法是有必要掌握的。非递归算法中,一个函数就对应一个栈帧,没有嵌套回溯等过程,不会占据大量的栈内存空间。而为了将递归的算法写成非递归形式,有两种办法,第一种是用循环就可以实现,例如斐波那契数列的非递归实现只需要循环就可以改成非递归形式。第二种是使用栈,这里的栈是用户定义的对象,不会存放在线程栈当中,而是存放在堆当中,堆的空间一般比较大,比栈大很多,而且可调节,可以放心地使用。
下面拿前序遍历举例实现非递归的遍历。
我们需要一个栈记录遍历的状态,还需要一个结果数组用于记录遍历的结果。方便起见,不再用数字表示节点,以字母表示节点,二叉树示意图再次贴在下面。
这是初始时刻状态,进行了初始化操作:将根节点A入栈,并追加到结果数组当中。
top=A
,top.left=B
,判断B
不在结果数组当中,没有被遍历过,因此入栈并追加到结果数组当中,如下图右侧所示。top=B
,top.left=D
,判断D
不在结果数组当中,即D
没有被遍历过,因此入栈并追加到结果数组当中,如下图右侧所示。top=D
,top.left=Null
为空,继续判断top.right=Null
为空,说明对 以当前节点为根节点的子树 的遍历已经结束,当前这个节点D
出栈,如下图右侧所示。top=B
,判断左子树top.left=D
,发现D
已经在结果数组当中,即说明D
已经被遍历过。左子树已经被遍历过,判断右子树top.right=E
,发现E
没有被遍历过,入栈并追加到结果数组当中,如下图右侧所示。top=E
,判断左子树top.left=G
发现G
不在结果数组当中,即没有被遍历过,应该入栈,并追加到结果数组当中,如下图右侧所示。top=G
,判断左子树top.left=Null
为空,继续判断右子树top.right=Null
为空,说明对 以G
为根节点的子树 的遍历已经结束,应当将G
出栈,如下图右侧所示。top=E
,判断左子树top.left=G
已经被遍历过了,继续判断右子树top.right=Null
为空,说明对以E
为根节点的子树 的遍历已经结束(看到了吗,是不是对E
的判断有点多余,早该出栈了,这里先按下不表,在后记
中会进行讨论),应当将E
出栈,如下图右侧所示。top=B
,判断左子树top.left=D
已经被遍历过了,继续判断右子树top.right=E
已经被遍历过了,说明对以B
为根节点的子树 的遍历已经结束,应当将B
出栈,如下图右侧所示。(看到了吗,是不是觉得对B
的判断有点多余,早该出栈了,这里先按下不表,在后记
中会进行讨论)top=A
,判断左子树top.left=B
已经被遍历过了,继续判断右子树top.right=C
没有被遍历过,将C
加入到栈当中,并追加到结果数组当中,如下图右侧所示。top=C
,判断左子树top.left=Null
为空,继续判断右子树top.right=F
发现F
不在结果数组当中,没有被遍历过,应当将F
入栈并追加到结果数组当中,如下图右侧所示。top=F
,判断左子树top.left=Null
为空,继续判断右子树top.right=Null
为空,说明对 以F
为根节点的子树 的遍历已经结束,应当将F
出栈,如下图右侧所示。top=C
,判断左子树top.left=Null
为空,继续判断右子树top.right=F
已经遍历过了,说明对 以C
为根节点的子树 的遍历已经结束,应当将C
出栈,如下图右侧所示。top=A
,判断左子树top.left=B
已经遍历过了,继续判断右子树top.right=F
已经遍历过了,说明对 以A
为根节点的子树(其实就是整棵树) 的遍历已经结束,应当将A
出栈,如下图右侧所示。上述过程对二叉树的前序遍历可以总结为如下几条规则:
初始化:如果根节点为空,结束遍历。如果根节点不空,将根节点入栈,追加到结果数组当中。
循环获取栈顶元素top
,不同情况采取下面不同的动作,直到栈为空,退出即可。
top.left
为空或者top.left
不空但被遍历过,判断top.right
:top.right
为空或不为空但已经遍历过了,则将top
出栈。top.right
不空且没有被遍历过,则将top.right
入栈,并追加到结果数组当中。top.left
不为空且没有被遍历过,则将top.left
入栈,并追加到结果数组当中。1、 如果一个节点没有被遍历过,应该入栈,并且被追加到结果数组当中。入栈是为了下次循环遍历 以这个节点为根节点的子树,因为我们每次循环总是获取栈顶元素;追加到结果数组当中是因为这是前序遍历,遍历这棵子树就要最先遍历这个节点(即这棵子树的根节点)
2、如果栈顶元素的左、右节点为空或者已经都被遍历过了,总之就是如果栈顶元素的左、右子树不用再管了,说明栈顶元素应该出栈了。(这里有个小优化在后记
中提到)
前序遍历的非递归实现Python代码如下所示(测试通过):
# 前序遍历非递归实现:完全按照上述规则进行编写
def dfs_before_nonrecursive(root):
s = []
result = []
if root==None:
return []
s.append(root)
result.append(root)
# 循环获取栈顶元素
while len(s) > 0:
top = s[-1] # 获取栈顶元素
if top.left == None or (top.left!=None and top.left in result):
if top.right==None or (top.right!=None and top.right in result):
s.pop(len(s)-1) # 只有左右子树都完事了,才可以将当前元素出栈。
else: # 如果左子树完事,但是右子树不空且没有被遍历过,那么就入栈并加入结果数组
# s.pop(len(s)-1) # 这就是后记当中所说的优化:右子树遍历之前就将根节点出栈
s.append(top.right) # 入栈
result.append(top.right) # 遍历它
else: # 如果左子树不空且没有被遍历过,那么就入栈并加入结果数组
s.append(top.left) # 入栈
result.append(top.left) # 遍历它
return result
# 节点的数据结构
# val表示值、left表示当前节点的左子节点地址、right表示右子节点地址
class Node(object):
def __init__(self, val, left, right):
self.val = val
self.left = left
self.right = right
if __name__ == "__main__":
node7 = Node('G', None, None)
node5 = Node('E', node7, None)
node4 = Node('D', None, None)
node2 = Node('B', node4, node5)
node6 = Node('F', None, None)
node3 = Node('C', None, node6)
node1 = Node('A', node2, node3)
result = dfs_before_nonrecursive(node1)
for e in result:
print(e.val, end=' ')
print()
结果是:
A B D E G C F
Process finished with exit code 0
中序遍历和前序遍历有什么不同呢?就是栈当中的元素,我们获取栈顶元素后,栈顶的元素不一定被遍历过了。在前序遍历当中,栈当中的元素一定是被变过的,因为是前序遍历,而中序遍历中,栈当中的元素需要等到其左子树完事了,才可以被遍历,然后再遍历右子树。
总结一下:
初始化:如果根节点为空,结束遍历。如果根节点不空,见根节点入栈,暂时不要追加到结果数组当中。
循环获取栈顶元素top
,判断:
top.left
为空或top.left
不空但top.left
不空且已经遍历过了,那么就判断栈顶元素top
是否已经遍历,如果没有则追加到结果数组当中,如果有则进行下一步:判断右子树的情况。如果右子节点为空或已经遍历过了,则将栈顶元素top
出栈;如果右子节点top.right
不空且没有被遍历过,则将top.right
入栈,暂时不要追加到结果数组当中,因为是中序遍历。top.left
不空且没有被遍历过,那么就将top.left
入栈,暂时不要追加到结果数组当中,因为是中序遍历。中序遍历的非递归实现Python代码如下所示(测试通过):
# 中序遍历非递归实现
def dfs_middle_nonrecursive(root):
s = []
result = []
if root == None:
return []
s.append(root)
while len(s) > 0:
top = s[-1]
if top.left == None or (top.left!=None and top.left in result):
if top not in result:
result.append(top)
if top.right == None or (top.right!=None and top.right in result):
s.pop(len(s)-1)
else:
s.pop(len(s)-1) # 这就是后记当中所说的优化:右子树遍历之前就将根节点出栈
s.append(top.right)
else:
s.append(top.left)
return result
# 节点的数据结构
# val表示值、left表示当前节点的左子节点地址、right表示右子节点地址
class Node(object):
def __init__(self, val, left, right):
self.val = val
self.left = left
self.right = right
if __name__ == "__main__":
node7 = Node('G', None, None)
node5 = Node('E', node7, None)
node4 = Node('D', None, None)
node2 = Node('B', node4, node5)
node6 = Node('F', None, None)
node3 = Node('C', None, node6)
node1 = Node('A', node2, node3)
result = dfs_middle_nonrecursive(node1)
for e in result:
print(e.val, end=' ')
print()
结果是:
D B G E A C F
Process finished with exit code 0
后续遍历,类似的道理,总结一下规律:
初始化:如果根节点为空,结束遍历。如果根节点不空,见根节点入栈,暂时不要追加到结果数组当中。
循环获取栈顶元素top
,判断:
后序遍历的非递归实现Python代码如下所示(测试通过):
# 后续遍历的非递归实现
def dfs_after_nonrecursive(root):
result = []
s = []
if root == None:
return []
s.append(root)
while len(s) > 0:
top = s[-1]
if top.left == None or (top.left!=None and top.left in result):
if top.right !=None and top.right not in result:
s.append(top.right)
else:
if top not in result:
result.append(top)
s.pop(len(s)-1)
else:
s.pop(len(s)-1)
else:
s.append(top.left)
return result
if __name__ == "__main__":
node7 = Node('G', None, None)
node5 = Node('E', node7, None)
node4 = Node('D', None, None)
node2 = Node('B', node4, node5)
node6 = Node('F', None, None)
node3 = Node('C', None, node6)
node1 = Node('A', node2, node3)
result = dfs_after_nonrecursive(node1)
for e in result:
print(e.val, end=' ')
print()
结果是:
D G E B F C A
Process finished with exit code 0
top
左子节点完事了,之后需要遍历右子节点,如果是前序遍历的话,此时可以直接让top
出栈,然后再遍历右子树,不然后面遍历完右子树之后,在右子节点出栈之后,还会面临着栈顶元素为top
,还需要再继续判断,那做法肯定让top
出栈,这么做逻辑没问题但这就多了一次循环,没这个必要,不如在遍历右子树之前就将top
出栈,反正总要出栈的而且不用判断。有兴趣的朋友可以对比一下循环次数,如果不提前出栈,上述这个例子要循环13次;如果提前出栈,要循环10次:无论前序还是中序。Python
的list
数据类型作为栈和数组使用,Python
的这个数据类型功能的确很丰富,还能做队列,在后面讲BFS
的博客当中会看到。