前两篇文章我们学习了第一个数据结构,数组,且从底层通过java实现了数组的构建和增删改查的操作功能,并且通过resize操作使我们的数组可以动态的扩容或者缩容。且我们知道数组最大的优点就是在索引有语义的情况下,查询和修改操作会非常的快,反之我们就得遍历寻找元素。我们还了解了时间复杂度,渐进复杂度,分摊复杂度和复杂度震荡等知识,分析动态数组相关操作的时间复杂度并且优化了resize操作。那么从这一章我们要学习另一种线性数据结构-栈。
当我们这样限定我们的数组形成了栈这样一个数据结构之后,它却可以在我们计算机组成逻辑有着非常非常重要的作用。
然后我们只能往栈顶这一端放入数据,这个放数据的过程通常我们称之为入栈:
需要注意的是,3只能放在2的上面,不能放在1和2的中间或者1的下面,这就是我们只能往栈顶这一端放入数据这个的体现。
如果我们现在想从栈取出元素,我们只能取出先取出3这个元素,因为3目前在栈顶,我们拿不到2和1,甚至从用户角度根本看不到2和1这两个元素。用户只能看到栈顶的元素,我们将元素从栈顶取出的过程叫作出栈。且出栈也体现了只能从同一端(栈顶)取出元素。
我们从刚才的例子可以看到,3是我们最后放入栈的元素,但却是第一个从栈被取出的元素
原理就是栈的应用。
例如我现在在文本里输入“沉迷”,对于这个动作,我们编辑器就会记录下来,这个记录动作其实就是把动作放进栈中,然后打入“学习”,接着本来打入“无法自拔”结果打成了“不法自拔”,由于打错也算一个动作这个时候就会把这些入栈过程体现为:
然后你意识到打错了,这个时候撤销,你执行撤销操作的过程就是从栈中拿出栈顶的元素,通过栈顶的元素来确认你最近的操作是什么,所以这次撤销操作就是把“不法自拔”从栈拿出然后删掉:
然后就可以继续重复之前的操作从而打入正确的文本。
这就是我们编程的时候进行子过程调用的时候,当一个子过程完成后可以自动的回到上层函数调用中断的位置,继续执行下去的原因,因为背后有一个系统栈,它可以记录每一次调用过程中中断的那个点。
基于这个原理的算法例如递归就是类似这样实现的。
上一节说了栈的定义和应用,其实大家会发现栈的应用非常重要,但是它实现起来其实非常简单,我们只需要实现以下方法即可:
Stack
从用户的角度看,支持这些操作就好,和数组差不多,用户不想担心怎样resize的,他只需要知道数组是动态的,我可以通过数组添加删除元素是正常的就好了。对于栈亦是如此
对于具体底层实现,用户不关心,实际底层有多种实现方式,但是这里我们只采用基于动态数组的方式实现的栈。由于栈本身主要就是上面说的那5种操作,我们完全可以将Stack本身定义为一个接口,接口定义上面5个操作,然后写一个具体的实现类叫作ArrayStack通过数组的方式具体实现这5个操作。
ps:此次数组栈的实现方式需要前面文章动态数组的实现的基础,所以最好需要去查看动态数组的实现这篇博客,有了动态数组的实现基础,我们会发现数组栈的实现就非常简单:
1、首先我们需要编写Stack接口,定义5个基础操作,代码如下:
public interface Stack<T> {
int getSize();
boolean isEmpty();
void push(T t);
T pop();
T peek();
}
2、编写ArrayStack类实现Stack接口,实现5个操作的方法,代码如下:
import com.mbw.array.DynamicArray;
public class ArrayStack<T> implements Stack<T> {
private DynamicArray<T> dynamicArray;
public ArrayStack(int capacity){
this.dynamicArray = new DynamicArray<>(capacity);
}
public ArrayStack(){
this.dynamicArray = new DynamicArray<>();
}
@Override
public int getSize() {
return dynamicArray.gitSize();
}
@Override
public boolean isEmpty() {
return dynamicArray.isEmpty();
}
@Override
public void push(T t) {
dynamicArray.addLast(t);
}
@Override
public T pop() {
return dynamicArray.removeLast();
}
@Override
public T peek() {
return dynamicArray.getLast();
}
@Override
public String toString() {
return "ArrayStack{" +
"top = " + peek() +
'}';
}
}
其实这里说一下我一开始实现的误解的点,我认为栈顶等于数组第一个位置,所以我认为push和pop对应的不应该是addLast和removeLast,而是addFirst和removeFirst.peek也是getFirst()。但是这样说明我们对栈的定义是有错误的,其实我们只需要保证放入元素和去除元素是同一端即可,此时这个"同一端"不管是从数组头还是数组尾,其实都是可以的。那么我们当然选择addLast和removeLast啊,addFirst和removeFirst时间复杂度铁打的O(n),而addLast和removeLast从均摊复杂度角度分析其实是O(1),效率更高。
然后我们可以试一下我们编写的栈:
public static void main(String[] args) {
ArrayStack<String> stringArrayStack = new ArrayStack<>();
stringArrayStack.push("沉迷");
stringArrayStack.push("学习");
stringArrayStack.push("不法");
String pop = stringArrayStack.pop();
System.out.println(pop);
stringArrayStack.push("无法自拔");
System.out.println(stringArrayStack.peek());
System.out.println(stringArrayStack.toString());
}
对于toString很多小伙伴可能会有疑惑为什么不把栈的所有元素打出来,其实从用户角度而言,栈这个数据结构用户只需关注栈顶的元素,我们从原则上也不应该把栈的中间元素给展示出来。不过自己学习的话可以将成员变量array的详情打印出来便于我们查看分析问题。这里就不做过多讲述。
而对于复杂度分析,除了之前说的push和pop从均摊复杂度角度分析都是O(1),而peek(),getSize(),isEmpty()这几个操作很明显都是O(1)的,所以栈的性能是非常良好的。
想必很多干开发的朋友会很熟悉,例如我们编程的时候使用小括号,中括号的逻辑,或者我们写块逻辑,无论是for,if,while或者定义一个函数或者类都需要使用大括号,经常会有这种括号套括号的情况,在这种情况下,如果我们的括号匹配不成功的话,那我们的编译器就会报错,而这个原理就是应用栈。
本章我们会通过leetcode的一个题目通过编程的方式完成这个应用—20. 有效的括号
那么这题我们来看看如何通过栈来解决该问题:
首先假设字符串为([{}])
我们先从头开始,将字符串为左括号的入栈。如下图:
直到遍历字符串到第一个右括号的时候,这个时候出栈,将栈的第一个元素和当前字符做匹配,以此类推,如果最后都匹配上了,栈一定为空。如果栈不为空,说明有多的左括号。
而一旦没匹配上,则直接返回false。例如下图大括号和栈顶的中括号不匹配:
总结一句话就是:
栈顶元素反映了在嵌套的层次关系中,最近的需要匹配的元素
接着我们掌握了原理,就可以编程解决了,这里我们不使用我们自定义的ArrayStack解决,而是通过java.util.Stack解决,其实熟悉的小伙伴会发现我们自己编写的ArrayStack和Stack的方法是一致的。
那么逻辑如下
class Solution {
public boolean isValid(String s) {
//如果字符串长度不是偶数,那么肯定不匹配,返回false
if (s.length() % 2 == 1){
return false;
}
Stack<Character> stack = new Stack<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
//如果是左括号则入栈
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
} else {
//如果此时栈是空的,那么说明没有左括号只有右括号,那肯定是不匹配的,直接返回false
if (stack.isEmpty()) {
return false;
}
if (!validatePopAndChar(stack, c)) {
return false;
}
}
}
//for循环结束后如果栈还有元素那么说明存在未匹配多余的左括号,返回false,否则返回true
return stack.isEmpty();
}
private boolean validatePopAndChar(Stack<Character> stack, char c) {
//不是左括号,出栈和左括号匹配
Character pop = stack.pop();
if (c == ')' && pop != '(') {
return false;
}
if (c == ']' && pop != '[') {
return false;
}
return c != '}' || pop == '{';
}
}