引言
软件世界的一些概念大多都不是凭空创造,这些概念大多都是从现实世界抽象而来。就像我们本文所讨论的"栈"一样,日常生活中也有这样的模型,比如叠成一摞的碗,最先放置的反而放在最下面,像下面这样:这与排队相反。排队对应的一种数据结构,我们称之为队列。事实上栈这种数据结构在日常使用中还是非常常用的,就比如说撤销操作,这是软件内普遍内置的操作,就像我们在日常编码中做了一些修改,想回到上一步,我们一般就是使用ctrl + z。软件按时间记录下了你的操作或者是输入,像下面这样:时间最靠前的反而在最下面,这样我们就能吃后悔药了。在比如我们写代码中的括号匹配,如果少写了一个,编译器是如何知道的呢?我们来分析一下这个问题:编号为括号的进入顺序, 每个右括号将与最近遇到的那个未匹配的左括号相匹配,即"后进的先出, 或者先进的后出"。体会一下上面的话,每个右括号将与最近遇到的那个未匹配的左括号相匹配。我们用p记录左括号的最新位置,用q指向当前新进入的括号,将p与q相比对,如果相匹配,则删除左括号,p后退一位。如果匹配到最后还是有剩余的左括号那么这个括号就是不匹配的。
再比如我们常用的函数互相调用,Java总是从一个main函数开始,如果我们的代码是main方法调用a方法,a方法里面又调用c方法,c方法里面又调用d方法,那么显而易见的是d方法执行完毕之后再执行c方法,c方法执行完才执行a方法。这种执行模型也很像栈:仔细体会上面的用例,这些问题的共同的特点就是处理过程中都具有"后进先出"的特性,这是一类典型的问题,我们将其抽象出来也就是数据结构中的"栈"模型。下面开始讲解一些栈的概念和一些基本应用。
栈
概述
下面是栈的一种示意图:你可以将示意图中的长方体理解为一个竹筒,最上面开口,最下面封闭,里面放的是写有编号的小球,放入顺序依据小球顺序,从小到大。如果我们想从这个竹筒中取球,可以发现一种规律: 先放进去的只能后拿出来; 反之,后放进去的小球能够先拿出来,也就是先进后出,这是栈的典型特点。
当我们把上面结构中的小球抽象为数据结构中的结点时,因为结点间的关系是线性的,因此它也属于线性表,又由于它的操作只能在同一端进行,因此它是一个运算受限的线性表,我们将这种类型线性表称之为栈。
现在我们给出栈的定义: 栈是一种特殊的线性表,它所有的插入和删除都限制在表的同一端进行。栈中允许允许进行插入、删除操作的一段叫栈顶(top),另一端则叫栈底(bottom)。当栈中没有元素时,称之为空栈
栈中的插入运算我们通常称之为压栈、进栈或入栈(PUSH),栈的删除运算通常被称为弹栈(push)出栈(pop). 第一个进栈的元素在栈底,最后一个进栈的元素在栈顶,第一个出栈的元素为栈顶元素,最后一个出栈的元素为栈底元素。
可能会有人觉得为什么要引入栈,或者直接用数组和链表实现栈不就行了吗?为什么要专门划出一种数据结构来? 原则上我们后面讲的队列、树、图也都是用数组和链表来实现,每一种数据结构都对应了一类专门的数据处理问题,像栈专门处理"先进后出",同时也简化了程序设计思路,缩小了我们的思考范围。就算是用数组来实现栈,我们也得让外部调用者无需担心下标越界以及增减的问题,那么这就是一种新的数组,只不过我们习惯上用栈来称呼而已。
同线性表基本类似,栈的基本运算如下:
- 栈初始化操作: 建立一个空栈表
- 栈判空: 判断栈是否为空
- 取栈顶元素: 取栈顶元素的值
- 压栈: 在栈S中插入元素e, 使其成为新的栈顶元素
- 弹栈: 删除栈S的元素。
现在的许多高级语言,比如说Java、C#都内置了对栈数据结构的封装,我们可以不用关注栈的实现细节,而直接使用对栈的push和Pop的操作。同时也可以借鉴设计思路。
用数组实现的栈我们称之为顺序栈,用链表实现的栈我们一般就称之为链栈。关于顺序栈其实也可以再进行细分,一种是一开始就给定栈的大小,入栈超出大小的时候我们可以选择将栈底的上一个元素称为栈底元素。第二种我们称之为动态栈,栈的大小是动态的,也就是动态扩容,超出栈的上限的时候,自动扩容,栈的大小基本取决于内存的大小。
顺序栈
我们尝试用数组来实现以下顺序栈,其实顺序栈相关的设计也可以参考Java中的相关的设计思路,我们来看下Java中的Stack:操作也与我们上面讨论的一致。那peek和pop有什么区别:
- peek 取栈顶的元素不删除
- pop 取栈顶的元素删除并返回栈顶元素 Java中Stack的设计思路:
- 栈判空 看数组的实际容量
- push操作 将元素添加到数组的末尾
- peek 操作 返回数组最后一个元素
- pop 操作 返回数组的最后一个元素, 同时栈顶元素向后移动一个位置 我们也可以让我们建的顺序栈继承我们在数据结构与算法分析(三) 线性表SequenceList,这样扩容操作我们就不用在重写一遍,只用实现栈的方法就可以了。关于方法的设计我们可以完全借鉴Java中的Stack:
public class SequenceStack extends SequenceList { public SequenceStack() { } public int push(int data){ add(data); return data; } /** * 返回数组的最后一个元素, 同时栈顶元素向后移动一个位置 * @return */ public int pop(){ if (size() == 0){ // 抛出异常 } int data = peek(); remove(size() - 1); return data; } public int peek(){ if (size() == 0){ // 抛出异常 } return get(size() - 1); } public boolean empty(){ return size() == 0; } }
链式栈
采用链式存储方式的栈我们称之为链栈或链式栈。链栈有一个个结点构成,每个结点包含数据数据域和指针域两部分。在链式栈中,利用每一个结点的存储域存储栈中的每一个元素,利用指针域来表示元素之间的关系。像下面这样:
public class LinkedStack { private Node top; //指向栈顶元素 private class Node{ private int data; private Node next; public Node(int data , Node next){ this.data = data; this.next = next; } public int getData() { return data; } public Node getNext() { return next; } } public LinkedStack() { } /** * @param data * @return */ public int push(int data){ Node node = new Node(data,top); top = node; return data; } public int pop(){ return peek(); } public int peek(){ int data = top.data; top = top.next; return data; } public boolean empty() { return top == null; } }
栈的应用举例
数制转换
十进制数到R进制数的转换,我们一般采用的方法是十进制数和进制取余,最高位往往是最后才出现,刚好符合后入先出的栈。例如1024 转8进制:最高位最后出现, 但栈刚好是后进先出。进制转换示例:
/** * 进制转换 * @param input * @param decimal */ private static void binaryConversion(int input, int decimal) { if(decimal == 0){ return; } Stack<Integer> stack = new Stack<>(); while (input != 0){ stack.push(input % decimal); input = input / decimal; } String result = ""; while (!stack.empty()){ result = result + stack.pop(); } System.out.println(result); }