数据结构与算法:栈及其应用
栈的介绍
栈是一种特殊的线性表。栈与线性表的不同,体现在增和删的操作。具体而言,栈的数据结点必须后进先出。后进的意思是,栈的数据新增操作只能在末端进行,不允许在栈的中间某个结点后新增数据。先出的意思是,栈的数据删除操作也只能在末端进行,不允许在栈的中间某个结点后删除数据。
也就是说,栈的数据新增和删除操作只能在这个线性表的表尾进行,即在线性表的基础上加了限制。如下图所示:
栈的基本操作
如何通过栈这个后进先出的线性表,来实现增删查呢?初始时,栈内没有数据,即空栈。此时栈顶就是栈底。当存入数据时,最先放入的数据会进入栈底。接着加入的数据都会放入到栈顶的位置。如果要删除数据,也只能通过访问栈顶的数据并删除。对于栈的新增操作,通常也叫作 push 或压栈。对于栈的删除操作,通常也叫作 pop 或出栈。对于压栈和出栈,我们分别基于顺序栈和链栈进行讨论。
顺序栈
栈的顺序存储可以借助数组来实现。一般来说,会把数组的首元素存在栈底,最后一个元素放在栈顶。然后定义一个 top 指针来指示栈顶元素在数组中的位置。假设栈中只有一个数据元素,则 top = 0。一般以 top 是否为 -1 来判定是否为空栈。当定义了栈的最大容量为 StackSize 时,则栈顶 top 必须小于 StackSize。
当需要新增数据元素,即入栈操作时,就需要将新插入元素放在栈顶,并将栈顶指针增加 1。如下图所示:
删除数据元素,即出栈操作,只需要 top - 1 就可以了。
对于查找操作,栈没有额外的改变,跟线性表一样,它也需要遍历整个栈来完成基于某些条件的数值查找。
链式栈
关于链式栈,就是用链表的方式对栈的表示。通常,可以把栈顶放在单链表的头部,如下图所示。由于链栈的后进先出,原来的头指针就显得毫无作用了。因此,对于链栈来说,是不需要头指针的。相反,它需要增加指向栈顶的 top 指针,这是压栈和出栈操作的重要支持。
对于链栈,新增数据的压栈操作,与链表最后插入的新数据基本相同。需要额外处理的,就是栈的 top 指针。如下图所示,插入新的数据,则需要让新的结点指向原栈顶,即 top 指针指向的对象,再让 top 指针指向新的结点。
在链式栈中进行删除操作时,只能在栈顶进行操作。因此,将栈顶的 top 指针指向栈顶元素的 next 指针即可完成删除。对于链式栈来说,新增删除数据元素没有任何循环操作,其时间复杂度均为 O(1)。
对于查找操作,相对链表而言,链栈没有额外的改变,它也需要遍历整个栈来完成基于某些条件的数值查找。
通过分析你会发现,不管是顺序栈还是链栈,数据的新增、删除、查找与线性表的操作原理极为相似,时间复杂度完全一样,都依赖当前位置的指针来进行数据对象的操作。区别仅仅在于新增和删除的对象,只能是栈顶的数据结点。
栈在表达式求值中的应用
看栈的另一个常见的应用场景,编译器如何利用栈来实现表达式求值。
为了方便解释,我将算术表达式简化为只包含加减乘除四则运算,比如:34+13*9+44-12/3。对于这个四则运算,我们人脑可以很快求解出答案,但是对于计算机来说,理解这个表达式本身就是个挺难的事儿。如果换作你,让你来实现这样一个表达式求值的功能,你会怎么做呢?
实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;
如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。将 3+5*8-6 这个表达式的计算过程画成了一张图,你可以结合图来理解计算过程。
对计算器而言,哪怕是1+1=2 计算都最少需要6个指令集,而通过20>>1=10, 的位移运算,只需要一个指令集,计算效率不在一个级别上。
栈的应用案例
案例1
给定一个只包括 ‘(’,’)’,’{’,’}’,’[’,’]’ 的字符串,判断字符串是否有效。有效字符串需满足:左括号必须与相同类型的右括号匹配,左括号必须以正确的顺序匹配。例如,{ [ ( ) ( ) ] } 是合法的,而 { ( [ ) ] } 是非法的。
这个问题很显然是栈发挥价值的地方。原因是,在匹配括号是否合法时,左括号是从左到右依次出现,而右括号则需要按照“后进先出”的顺序依次与左括号匹配。因此,实现方案就是通过栈的进出来完成。
具体为,从左到右顺序遍历字符串。当出现左括号时,压栈。当出现右括号时,出栈。并且判断当前右括号,和被出栈的左括号是否是互相匹配的一对。如果不是,则字符串非法。当遍历完成之后,如果栈为空。则合法。如下图所示:
代码如下:
public static void main(String[] args) {
String s = "{[()()]}";
System.out.println(isLegal(s));
}
private static int isLeft(char c) {
if (c == '{' || c == '(' || c == '[') {
return 1;
} else {
return 2;
}
}
private static int isPair(char p, char curr) {
if ((p == '{' && curr == '}') || (p == '[' && curr == ']') || (p == '(' && curr == ')')) {
return 1;
} else {
return 0;
}
}
private static String isLegal(String s) {
Stack stack = new Stack();
for (int i = 0; i < s.length(); i++) {
char curr = s.charAt(i);
if (isLeft(curr) == 1) {
stack.push(curr);
} else {
if (stack.empty()) {
return "非法";
}
char p = (char) stack.pop();
if (isPair(p, curr) == 0) {
return "非法";
}
}
}
if (stack.empty()) {
return "合法";
} else {
return "非法";
}
}
案例2
浏览器的页面访问都包含了后退和前进功能,利用栈如何实现?我们利用浏览器上网时,都会高频使用后退和前进的功能。比如,你按照顺序先后访问了 5 个页面,分别标记为 1、2、3、4、5。现在你不确定网页 5 是不是你要看的网页,需要回退到网页 3,则需要使用到两次后退的功能。假设回退后,你发现网页 4 有你需要的信息,那么就还需要再执行一次前进的操作。
为了支持前进、后退的功能,利用栈来记录用户历史访问网页的顺序信息是一个不错的选择。此时需要维护两个栈,分别用来支持后退和前进。当用户访问了一个新的页面,则对后退栈进行压栈操作。当用户后退了一个页面,则后退栈进行出栈,同时前进栈执行压栈。当用户前进了一个页面,则前进栈出栈,同时后退栈压栈。我们以用户按照 1、2、3、4、5、4、3、4 的浏览顺序为例,两个栈的数据存储过程,如下图所示:
总结
栈继承了线性表的优点与不足,是个限制版的线性表。限制的功能是,只允许数据从栈顶进出,这也就是栈后进先出的性质。不管是顺序栈还是链式栈,它们对于数据的新增操作和删除操作的时间复杂度都是 O(1)。而在查找操作中,栈和线性表一样只能通过全局遍历的方式进行,也就是需要 O(n) 的时间复杂度。
栈具有后进先出的特性,当你面对的问题需要高频使用新增、删除操作,且新增和删除操作的数据执行顺序具备后来居上的相反关系时,栈就是个不错的选择。例如,浏览器的前进和后退,括号匹配等问题。栈在代码的编写中有着很广泛的应用,例如,大多数程序运行环境都有的子程序的调用,函数的递归调用等。这些问题都具有后进先出的特性。
上一篇: 数据结构_栈及其应用
下一篇: 数据结构—栈、队列的实现