原本今天想给大家讲讲快速选择算法的,但是发现一连写了好几篇排序相关了,所以临时改了题目,今天聊点数据结构,来看看经典并且简单的数据结构——栈。
栈这个结构我想大家应该都耳熟能详,尤其是在很多地方将和堆并列在一起,称作“堆栈”就更广为人知了。但其实堆和栈本质上是两种不同的数据结构,我们不能简单地混为一谈。让我们先从比较简单的栈开始。
栈和队列的本质其实都是数组(严格地说是线性表)。只不过我们在数组上增加了一些限制,使得它满足一定的条件而已,所以很多对数据结构畏首畏尾的同学可以放宽心,栈没什么特别的花样,就是一种特殊的数组。
和其他广义上的线性表数据结构比起来,栈的特殊性只有两条,一条是先进后出,另一条是只能从数组的一侧读写。但本质上来说这两条是一样的,由于我们只能从一侧读写元素,所以进的越早出的越晚,当然是先进后出。从下面这张图应该很容易能看明白。
栈规定了我们只能从一侧进行读写,常规上我们将能够读写的一侧称作是栈顶。不能读写的另一侧称为是栈底。从上面的图可以看到,只有栈顶的元素出栈了之后,才能访问到栈底的元素。
我们用Python的数组来实现栈这个数据结构,去掉注释真的只有30行不到,可以说是非常简单,我们先来看代码。
class Stack(object):
def __init__(self, size_limit=None):
self._size_limit = size_limit
self.elements = []
self._size = 0
# 进栈,判断是否越界
def push(self, value):
if self._size_limit is not None and len(self.elements) > self._size_limit:
raise IndexError("Stack is full")
else:
self.elements.append(value)
self._size += 1
# 判断栈是否为空
def is_empty(self):
return self._size == 0
# 栈清空
def clear(self):
self.elements = []
self._size = 0
# 访问元素数量
def size(self):
return self._size
# 查询栈顶元素
def top(self):
return self.elements[-1]
# 弹出栈顶元素
def pop(self):
val = self.elements.pop()
self._size -= 1
return val
复制代码本质上来说,一般的栈实现只有以上这么几个方法,可能会更少。因为有些语言当中的栈,top和弹出是合并的。意味着访问必须要弹出,不支持非弹出访问。所以栈的实现逻辑是非常简单的,甚至可以说是毫无技术含量,非常适合入门数据结构。
当然,从另一个方面也可以说栈的实现原理并不太重要,相比之下更重要的是栈一般会用在什么地方。
栈的应用
栈最广泛的应用就是在操作系统当中,比如在程序执行调用方法的时候,在编译器内部,其实是记录了一个当前调用的方法栈。举个例子,比如当前调用到的方法是A,如果在A方法中又去调用了方法B,那么计算机就会在系统方法栈当中存储一个指向B方法的指针,如果B方法又调用到了C方法,那么又会新增一个C的指针。当C方法执行结束,那么C就会弹出,计算机会将C的结果带入B,继续执行之前的B,以此类推,直到栈空为止。
那么,问题来了,如果一个方法A自己调用自己会怎么样?
答案是计算机会创建一个新的A的指针填入栈中,如果A继续递归,那么系统再创建一个新的指针入栈……
从上面这个过程,我们可以确定两个事情。第一,我们写程序时候的递归,在编译器内部其实是以栈的形式执行的。第二,如果我们用一个死循环去不停地递归,由于栈存在大小限制,所以当栈的深度超过限制的时候,就会出现SystemStackExceed的错误。也就是说递归并不是无限的,因为除了操作系统对于运行内存的限制之外,编译器还会有最大递归深度的限制,防止递归中死循环导致系统崩溃。虽然各个语言实现机制不完全一样,但是有一点是肯定的,递归深度是有限的,我们不能无限制递归。
那问题来了,如果我们系统就是会存在大规模的递归怎么办?难道还要手动给机器加内存吗?
这是ACM玩家在赛场上经常遇到的问题之一,有经验的选手在第一天的热身赛时一定会做的事情除了配置vim或者其他IDE之外,就是会测试一下电脑的最大递归深度。在C++当中,是支持通过汇编语言强行打开递归深度限制的,但是即使如此也是有限的,并且据我所知只有C++可以这么干,对于其他语言,以及开大了递归深度还是不够用的情况,就只有一种办法,就是手动建栈模拟递归。
手动递归
许多同学可能觉得递归痛苦,但是如果他们试着手动建栈来模拟递归的话,会发现要更加痛苦。不仅要额外增加变量存储中间状态,并且对于编程也是一个巨大的挑战。
我们来看一个例子:
class Node:
def init(self, val):
self.val = val
# 左孩子
self.lchild = None
# 右孩子
self.rchild = None
if name == “main”:
# 建树
root = Node(0)
node1 = Node(1)
root.lchild = node1
node2 = Node(2)
root.rchild = node2
node3 = Node(3)
node1.lchild = node3
node4 = Node(4)
node1.rchild = node4
node5 = Node(5)
node2.rchild = node5
复制代码这是一棵简单的二叉树,画出来是这个样子:
0
/
1 2
/ \
3 4 5
复制代码下面我们要通过栈在不使用递归的情况下来中序遍历它,中序遍历我们都知道,就是先遍历左子树,然后输出当前节点,再遍历右子树。写成递归非常方便,只有几行:
def dfs(node):
if node is None:
return
dfs(node.lchild)
print(node.val)
dfs(node.rchild)
复制代码大家想想,如果不使用递归应该怎么办?如果你真的试着去写,就会发现看起来很简单的问题好像变得非常复杂。我们很容易可以想到,我们把节点存储在栈当中,但是存储数据只是表象。本质问题是当我们从栈当中拿到了一个节点之后,我们怎么判断它究竟应该做什么?应该遍历左节点吗,应该输出吗,还是应该遍历右节点?
对这些问题仔细分析和思考,我们可以发现它们都和递归的回溯有关。
在递归当中,当我们遍历完了当前节点的某棵子树之后,随着栈的弹出,还会回到这个节点。比如上面这棵树当中,在递归过程当中,我们会两次碰到1这个节点。第一次时它不会输出1,而是先去遍历了它的左子树,也就是3,之后再次回到1,由于它的左子树已经遍历过,所以会输出1。这个离开又回来的过程称为回溯。如果你把树结构想象成瀑布的话,这个过程有点像是顺流而下,又逆流而上,翻译成回溯还是蛮合理的。
我们回到之前的问题,所有的搞不清楚的本质都来源于我们无法判断当前遇到的节点究竟是初次见面,还是回溯之后的久别重逢。而这关系到我们要对它做什么。原本在递归当中,由于程序会记录递归时的状态和代码运行的位置,递归回溯之后会回到上次调用的位置,所以我们可以忽略这个问题。而现在我们由于不再使用递归,所以需要我们自己来判断节点的状态。
想通了其实很简单,我们只需要在节点当中加一个状态的字段,表示这个节点是否会发生回溯。显然在一开始的时候,所有的节点状态都是True。
class Node:
def init(self, val):
self.val = val
self.lchild = None
self.rchild = None
self.flag = True
复制代码我们在Node类中加一个flag作为记录,初始化时我们默认它为True。接着就很简单了,我们就按照左中右的顺序遍历节点,只要左子树存在就往左边遍历,在一路往左的过程中遇到的这些节点的flag全部置为False,因为它们的回溯已经开始,以后不会再发生回溯了。由于往右遍历不会存在回溯的问题,所以可以忽略,想明白了,代码也就顺理成章。
使用我们自己刚刚创建的数据结构
stack = Stack()
插入根节点
stack.push(root)
while not stack.is_empty():
# 获取栈顶元素,也就是当前遍历的节点
tmp = stack.top()
# 如果不曾回溯过,并且左子树存在
while tmp.flag and tmp.lchild is not None:
# 回溯标记置为False
tmp.flag = False
# 栈顶push左孩子
stack.push(tmp.lchild)
# 往左遍历
tmp = tmp.lchild
# 弹出栈顶
tmp = stack.pop()
# 此时说明左节点已经遍历完了,输出
print(tmp.val)
# 往右遍历
if tmp.rchild is not None:
stack.push(tmp.rchild)
复制代码这段代码虽然短,但其实不简单,想要完全看懂需要对递归和循环有深入的理解。属于典型的看着简单实际不容易的题,我个人比较喜欢这类问题,除了锻炼思维之外也很适合用来面试,候选人的思维能力、代码驾驭能力基本上都一清二楚了。没有看懂的同学也不用担心,因为在实际场景当中并不会遇到这样的场景,以后还会推出其他关于递归和搜索算法的文章,只要你坚持阅读,我相信一定会看懂的。