[面试算法]Python实现二叉树三种遍历的递归与非递归形式

二叉树及其数据结构定义

二叉树是计算机当中最重要的数据结构之一,其应用非常广泛,例如数据库的索引使用的B+树是一种特殊的二叉树,堆排序所使用的堆是一种特殊的二叉树,Java当中HashMap使用的红黑树是一种特殊的二叉树。可见,二叉树在计算机编程当中有着重要地位。二叉树的遍历是二叉树的基本操作,不仅是面试的常考考点,也是程序员用来锻炼思维的小把戏。

二叉树的定义是递归的,即满足如下条件的树是二叉树:

  1. 一棵树当中的每个节点,最多有2棵子树;
  2. 如果一个节点有子树,那么子树必须是二叉树。

我们可以看到,二叉树的定义是递归的,递归的边界是一个节点没有子树。如下图所示,是一棵二叉树:
[面试算法]Python实现二叉树三种遍历的递归与非递归形式_第1张图片
可以看到,二叉树当中的每个节点,有值,也可能有左子节点,也可能有右子节点,因此我们经常用二叉链表的形式存储。上述二叉树在内存当中将以以下形式存储。
[面试算法]Python实现二叉树三种遍历的递归与非递归形式_第2张图片
图中,每个节点对应三个字段,或者叫域(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的后面,右子树的遍历结果存储在左子树遍历结果的后面,如下图所示。
[面试算法]Python实现二叉树三种遍历的递归与非递归形式_第3张图片
上图中,绿色长条表示我们编程时候使用的结果数组。遍历分为三大步骤:

  1. 先遍历根节点,把根节点1,存储在数组最开始的位置;
  2. 然后遍历左子树,把左子树进行前序遍历(具体怎么遍历先不考虑),将结果存储在1的后面;
  3. 最后遍历右子树,将右子树进行前序遍历(具体怎么遍历先不考虑),将结果存储在左子树遍历结果的后面。

那么左子树、右子树该怎么遍历呢,采用和上述步骤相同的步骤。拿左子树举例,如下图所示:
[面试算法]Python实现二叉树三种遍历的递归与非递归形式_第4张图片

左子树也是一棵二叉树,根节点是2,新的根节点有左右两棵子树。类似地,这棵树的遍历分为三大步骤:

  1. 遍历根节点2,写到结果数组的最开始,注意这里的最开始依然是在上图中上半部分的数组中的节点1的后面;
  2. 遍历2的左子树,将结果写到节点2的位置的后面;
  3. 遍历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)

下面再来看中序遍历和后序遍历。方便起见,二叉树的图再贴到下面:
[面试算法]Python实现二叉树三种遍历的递归与非递归形式_第5张图片

中序遍历

上面我们已经得到了前序遍历的结果是1 (2 4 5 7) (3 6),其中(2 4 5 7)是左子树的前序遍历的结果,(3 6)是右子树前序遍历的结果。中序遍历的结果就是先遍历左子树,再遍历根节点,最后遍历右子树。那么是不是可以根据前序遍历直接得到中序遍历的结果呢:(2 4 5 7) 1 (3 6)?不能!因为这里要求左子树和右子树也必须是中序遍历的,而(2 4 5 7)是前序遍历的结果。

中序遍历的结果,可以分为三大步骤:

  1. 中序遍历左子树:先遍历4,再遍历2,最后遍历5和75相对7是根节点,7相对5是左子树,应该先遍历7,再遍历5,所以是(7 5))。因此左子树中序遍历的结果是:(4) 2 (7 5)
  2. 遍历根节点:即1
  3. 中序遍历右子树:右子树是36,发现这颗子树当中,根节点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个栈帧,容易造成栈内存溢出。

所以,在大规模场景下,为了程序能够稳定运行,一般不允许使用递归,甚至在工程代码中写递归代码的程序员可能面临着失业。所以,非递归的方法是有必要掌握的。非递归算法中,一个函数就对应一个栈帧,没有嵌套回溯等过程,不会占据大量的栈内存空间。而为了将递归的算法写成非递归形式,有两种办法,第一种是用循环就可以实现,例如斐波那契数列的非递归实现只需要循环就可以改成非递归形式。第二种是使用栈,这里的栈是用户定义的对象,不会存放在线程栈当中,而是存放在堆当中,堆的空间一般比较大,比栈大很多,而且可调节,可以放心地使用。

前序遍历的非递归实现

下面拿前序遍历举例实现非递归的遍历。

我们需要一个栈记录遍历的状态,还需要一个结果数组用于记录遍历的结果。方便起见,不再用数字表示节点,以字母表示节点,二叉树示意图再次贴在下面。
[面试算法]Python实现二叉树三种遍历的递归与非递归形式_第6张图片
这是初始时刻状态,进行了初始化操作:将根节点A入栈,并追加到结果数组当中。

  1. 如下图左侧。获取栈顶元素top=Atop.left=B,判断B不在结果数组当中,没有被遍历过,因此入栈并追加到结果数组当中,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第7张图片
  2. 如下图左侧。获取栈顶元素top=Btop.left=D,判断D不在结果数组当中,即D没有被遍历过,因此入栈并追加到结果数组当中,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第8张图片
  3. 如下图左侧。获取栈顶元素top=Dtop.left=Null为空,继续判断top.right=Null为空,说明对 以当前节点为根节点的子树 的遍历已经结束,当前这个节点D出栈,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第9张图片
  4. 如下图左侧。获取栈顶元素top=B,判断左子树top.left=D,发现D已经在结果数组当中,即说明D已经被遍历过。左子树已经被遍历过,判断右子树top.right=E,发现E没有被遍历过,入栈并追加到结果数组当中,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第10张图片
  5. 如下图左侧。获取栈顶元素为top=E,判断左子树top.left=G发现G不在结果数组当中,即没有被遍历过,应该入栈,并追加到结果数组当中,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第11张图片
  6. 如下图左侧。获取栈顶元素为top=G,判断左子树top.left=Null为空,继续判断右子树top.right=Null为空,说明对 以G为根节点的子树 的遍历已经结束,应当将G出栈,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第12张图片
  7. 如下图左侧。获取栈顶元素为top=E,判断左子树top.left=G已经被遍历过了,继续判断右子树top.right=Null为空,说明对以E为根节点的子树 的遍历已经结束(看到了吗,是不是对E的判断有点多余,早该出栈了,这里先按下不表,在后记中会进行讨论),应当将E出栈,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第13张图片
  8. 如下图左侧。获取栈顶元素为top=B,判断左子树top.left=D已经被遍历过了,继续判断右子树top.right=E已经被遍历过了,说明对以B为根节点的子树 的遍历已经结束,应当将B出栈,如下图右侧所示。(看到了吗,是不是觉得对B的判断有点多余,早该出栈了,这里先按下不表,在后记中会进行讨论)
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第14张图片
  9. 如下图左侧。获取栈顶元素为top=A,判断左子树top.left=B已经被遍历过了,继续判断右子树top.right=C没有被遍历过,将C加入到栈当中,并追加到结果数组当中,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第15张图片
  10. 如下图左侧。获取栈顶元素top=C,判断左子树top.left=Null为空,继续判断右子树top.right=F发现F不在结果数组当中,没有被遍历过,应当将F入栈并追加到结果数组当中,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第16张图片
  11. 如下图左侧。 获取栈顶元素为top=F,判断左子树top.left=Null为空,继续判断右子树top.right=Null为空,说明对 以F为根节点的子树 的遍历已经结束,应当将F出栈,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第17张图片
  12. 如下图左侧。获取栈顶元素为top=C,判断左子树top.left=Null为空,继续判断右子树top.right=F已经遍历过了,说明对 以C为根节点的子树 的遍历已经结束,应当将C出栈,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第18张图片
  13. 如下图左侧。获取栈顶元素为top=A,判断左子树top.left=B已经遍历过了,继续判断右子树top.right=F已经遍历过了,说明对 以A为根节点的子树(其实就是整棵树) 的遍历已经结束,应当将A出栈,如下图右侧所示。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第19张图片
  14. 如下图左侧。获取栈顶元素失败,因为栈空了,遍历结束。
    [面试算法]Python实现二叉树三种遍历的递归与非递归形式_第20张图片

上述过程对二叉树的前序遍历可以总结为如下几条规则:

初始化:如果根节点为空,结束遍历。如果根节点不空,将根节点入栈,追加到结果数组当中。

循环获取栈顶元素top,不同情况采取下面不同的动作,直到栈为空,退出即可。

  1. 如果top.left为空或者top.left不空但被遍历过,判断top.right
    1.1 如果top.right为空或不为空但已经遍历过了,则将top出栈。
    1.2 如果top.right不空且没有被遍历过,则将top.right入栈,并追加到结果数组当中。
  2. 如果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,判断:

  1. 如果左子节点top.left为空或top.left不空但top.left不空且已经遍历过了,那么就判断栈顶元素top是否已经遍历,如果没有则追加到结果数组当中,如果有则进行下一步:判断右子树的情况。如果右子节点为空或已经遍历过了,则将栈顶元素top出栈;如果右子节点top.right不空且没有被遍历过,则将top.right入栈,暂时不要追加到结果数组当中,因为是中序遍历。
  2. 如果左子节点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,判断:

  1. 如果左子节点为空或左子节点已经被遍历过了则判断:
    1.1 如果右子节点不空且没有被遍历过,则将右子节点入栈
    1.2 如果右子节点为空或已经遍历过,那么此时左右子树都完事了,判断当前节点是否已经被遍历过了,如果没有则追加到结果数组当中并出栈;如果有,则直接出栈。
  2. 如果左子节点不空且没有被遍历过则将左子节点入栈。

后序遍历的非递归实现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

后记

  1. 上述代码当中有一个值得思考的地方,在前序遍历和中序遍历当中都有这个问题。拿前序遍历举例,如果某个节点top左子节点完事了,之后需要遍历右子节点,如果是前序遍历的话,此时可以直接让top出栈,然后再遍历右子树,不然后面遍历完右子树之后,在右子节点出栈之后,还会面临着栈顶元素为top,还需要再继续判断,那做法肯定让top出栈,这么做逻辑没问题但这就多了一次循环,没这个必要,不如在遍历右子树之前就将top出栈,反正总要出栈的而且不用判断。有兴趣的朋友可以对比一下循环次数,如果不提前出栈,上述这个例子要循环13次;如果提前出栈,要循环10次:无论前序还是中序。
  2. 这篇博客的代码,我们使用Pythonlist数据类型作为栈和数组使用,Python的这个数据类型功能的确很丰富,还能做队列,在后面讲BFS的博客当中会看到。

你可能感兴趣的:(算法OJ,数据结构,二叉树,栈,递归算法,python)