Stack和Queue
Stack
Stack是什么
Stack 也是 Java 集合框架中的一个工具类, 它的底层是一个叫做栈的数据结构. 栈则是一种特殊的线性表, 它强制要求其元素只能从一头出一头进, 此时就形成了其的一种特性, 叫做先入后出
栈的插入操作也被叫做压栈, 指的就是在栈顶把元素放进去. 而删除操作则指的是出栈, 就是把栈顶的元素推出去
Stack的使用
由于Stack的使用非常简单, 没有几个方法. 并且其结构还是比较容易理解的, 因此我们这里就先使用, 后讲解
构造方法
栈的构造方法非常的简洁, 就只有一个默认的构造方法
方法名, 参数 | 说明 |
---|---|
Stack() | 创建一个栈 |
常用方法
返回值, 方法名, 参数 | 说明 |
---|---|
E push(E e) | 将e入栈,并返回e |
E pop() | 将栈顶元素出栈并返回 |
E peek() | 获取栈顶元素 |
int size() | 获取栈中有效元素个数 |
boolean empty() | 检测栈是否为空 |
下面是一些测试代码
public class Main {
public static void main(String[] args) {
// 测试Stack
Stack<Integer> stack = new Stack<>();
// 推入 1 2 3
stack.push(1);
stack.push(2);
stack.push(3);
// 打印查看栈
System.out.println(stack);
// 查看栈顶元素
System.out.println(stack.peek());
// 推出栈顶元素
stack.pop();
// 打印查看栈
System.out.println(stack);
// 出栈所有元素
while (!stack.isEmpty()) {
System.out.println(stack.pop());
}
}
}
下面是一个图示展示各个过程
栈的模拟实现
初始化和基本方法
依旧是老套路初始化和基本方法的书写, 我们这里对于栈的实现就采用 Java 中 Stack 采用的方式, 使用数组来存储元素.
此时可能有人就要问了: 为什么你这个栈还需要用到数组? 为什么它和链表之类的不一样? 不是一个新的结构?
实际上我们学习的顺序表和链表, 都是一种物理结构, 就是其真正体现了其底层的存储方法. 但是我们现在学习的栈, 就单单只是通过限制其元素的出入方式, 而形成的一种数据结构, 其底层具体是什么存储的方法, 并不影响它就是一个栈, 类似于栈这样的数据结构, 就被称作为是逻辑结构. 它在逻辑上体现为一种数据结构
而我们如何去看 Java 中是否是采用数组的方式来进行存储的, 我们进去一看便知
此时可能有人进到 Stack 中, 发现根本没有什么数组. 实际上这个数组就藏在其父类Vector
中
可以看到, 其中就是有一个名为elementData
的数组
此时可能有人要问了, 这个 Vector类又是一个什么东西, 它的底层怎么也藏着一个数组. 实际上, 他也是一个顺序表, 但是它是一个线程安全的线性表, 具体什么是线程安全问题我们这里不多谈及, 反正我们只需要知道, Stack类的底层是一个数组存储的即可.
那么最后我们得到的初始化代码如下
public class MyStack {
private int[] elemData;
private int size;
private static final int DEFAULT_CAPACITY = 10;
public MyStack() {
elemData = new int[DEFAULT_CAPACITY];
}
}
基本方法就是size()
和isEmpty()
, 有了size
成员后, 实现是非常简单的, 简单判断即可
public boolean isEmpty(){
return size == 0;
}
public int size(){
return size;
}
然后就是重写一下toString()
方法, 便于打印. 具体的书写方法我们就当作遍历数组即可
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[");
for (int i = 0; i < size; i++) {
sb.append(elemData[i]);
if (i != size - 1) sb.append(", ");
}
sb.append("]");
return sb.toString();
}
入栈
要实现入栈操作, 首先我们需要规定一下栈顶, 由于对于数组来说, 在尾部进行增删更为合适, 因此我们就规定数组尾部是栈顶.
那么入栈就是一个简简单单的尾插, 同时也需要判断容量, 并且扩容
public void push(int elem){
// 满了, 扩容
if(elemData.length == size){
elemData = Arrays.copyOf(elemData, elemData.length * 2);
}
// 放入
elemData[size++] = elem;
}
出栈
我们规定了栈顶是数组尾部, 出栈自然就是返回数组的最后一个元素, 也非常简单.
同时不要忘记判断站是否为空, 以及减少 size
public int pop(){
if (isEmpty()) throw new NoSuchElementException("栈为空");
return elemData[--size];
}
查看栈顶
查看栈顶元素则类似于出栈, 只不过不用让 size 减少
public int peek(){
if (isEmpty()) throw new NoSuchElementException("栈为空");
return elemData[size - 1];
}
栈的应用
很明显, 栈的实现实在是太简单了, 因此我们这里来做一题有关栈的题目. 这个题目就是经典的栈的一种应用, 括号匹配. 题目链接: 有效的括号
这一题的要求就是, 左括号需要和右括号对应, 例如下面的这些情况都是对应的
而下面这些则不是对应的
那么如何通过栈来实现这一题呢?
我们就可以采用, 遇到左边括号, 入栈, 然后遇到右边括号就看看栈顶是否对应即可. 下面是一个示例
当最后遍历完了, 如果栈还为空, 那么此时就代表匹配了.
另外还有一个细节问题, 如果我们的栈一直是空呢? 也就是全都是右括号的情况. 那么此时我们就可以直接返回 false. 一部分原因是防止在栈为空的时候查找栈, 另一部分则是, 当出现右括号后, 左括号如果还没出现过, 则一定不是对应的了.
那么最终我们实现的代码如下
class Solution {
public boolean isValid(String s) {
// 创建栈
Stack<Character> stack = new Stack<>();
// 这个是将字符串转换为字符数组, 便于访问, 也可以直接使用charAt()方法
char[] str = s.toCharArray();
for(int i = 0; i < str.length; i++){
if(str[i] == '(' || str[i] == '[' || str[i] == '{'){
// 如果是左括号, 入栈
stack.push(str[i]);
}else{
// 检查栈是否为空
if(stack.isEmpty()) return false;
// 右括号, 查看栈顶是否对应
// 对应则弹出, 不对应返回false
if(stack.peek() == '(' && str[i] == ')') stack.pop();
else if(stack.peek() == '[' && str[i] == ']') stack.pop();
else if(stack.peek() == '{' && str[i] == '}') stack.pop();
else return false;
}
}
// 此时检查栈是否为空
return stack.isEmpty();
}
}
链栈的简单介绍
我们上面的实现是以数组/顺序表的形式模拟实现的栈, 同时 Java 提供的 Stack类也是基于这种方式实现的, 那么此时可能有人想了, 我能否基于链表的结构来实现一个栈呢?
答案是当然是可行的, 但是对于不同种类的链表, 其效率也会有所不同, 例如我假如使用了一个单链表来维护一个栈. 那么很明显, 入栈和出栈既可以在链表头实现, 也可以在链表尾实现, 但是效率有所不同.
当我们在链表头进行操作的时候, 实际上就相当于头插和删除头节点, 此时时间复杂度肯定是O(1)的. 但是如果是在尾巴进行操作, 就是尾插和删除尾巴节点, 此时很明显, 对于一个单链表来说, 若想要操作尾巴, 肯定需要先遍历到尾巴位置才行, 此时时间复杂度就是 O(n) 了.
但是如果是一个双向链表, 那么此时则头插尾插都是一样的了, 时间复杂度则都是 O(1)
也就是说, 采用链表结构来实现栈, 是一种可行的行为, 并且如果要使用链表来实现栈, 则推荐使用双向链表来实现.
此时可能有人就要问了, 那 Java 的集合类中有没有基于链表的栈呢?
实际上是有的, 它就是我们之前了解过的 LinkedList类, 它不仅仅是基于双向链表实现的一个类, 同时其中也是提供了对应的push()
, pop()
等方法来供我们使用的
public class Main {
public static void main(String[] args) {
// 测试LinkedList
LinkedList<Integer> stack = new LinkedList<>();
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println(stack);
System.out.println(stack.peek());
stack.pop();
System.out.println(stack);
}
}
使用也是比较简单的, 我们这里就不详细介绍了
Queue
Queue是什么
Queue 是 Java 集合类中的一个类, 其底层的数据结构称作为队列. 而队列则类似于栈, 也是一种特殊的数据结构, 不讲究底层的实现, 只根据数据的出入逻辑去定义的一个结构.
栈的出入逻辑是先进先出, 而队列的出入逻辑则是先进后出. 就很类似于我们生活中的队列, 比如我们排队在超市算钱, 都是先进入队列的先去算完钱, 先走人, 还是比较好理解的.
那么既然队列是一个逻辑结构, 那它应该也是可以通过链表和数组实现的吧, 那 Java 中的 Queue 又是如何实现的呢?
实际上 Java 中的 Queue 是一个接口, 它有着多种实现, 其中最基本的队列的实现是在 LinkedList类中实现的.
此时可能有人看到下面还有一个 Deque, 这个则是中特殊的队列叫做双端队列. 这个队列的两端都是可以进行进出操作的.
Queue的使用
由于 Queue 和 Stack一样, 都是比较简单的, 因此我们也先进行使用, 再去模拟实现, 实际上它核心的也就下面这几个方法
其中add()
和offer()
是用于添加元素的, remove()
和poll()
是用来移出元素的, 剩下的element()
和peek()
则是用于查看队头元素的.
下面是一个简单的使用例子
public class Main {
public static void main(String[] args) {
// 测试Queue
Queue<Integer> queue = new LinkedList<>();
queue.offer(1);
queue.offer(2);
queue.offer(3);
System.out.println(queue);
System.out.println(queue.peek());
System.out.println(queue.poll());
System.out.println(queue);
}
}
然后这是另一组的方法使用
public class Main {
public static void main(String[] args) {
// 测试Queue
Queue<Integer> queue = new LinkedList<>();
queue.add(1);
queue.add(2);
queue.add(3);
System.out.println(queue);
System.out.println(queue.element());
System.out.println(queue.remove());
System.out.println(queue);
}
}
两者的打印结果都是一致的, 如下图所示
至于它为什么会打印出这样的结果, 我们可以看下图的演示
那此时可能有人要问了, 你这两个方法怎么效果一样的啊, 那这两个方法既然功能都一样为什么要提供两个呢? 此时我们可以阅读一下源码中的注释来看看它是怎么说的
可以看到, 上面的注释是关于add()
方法的注释, 下面的则是关于offer()
方法的注释. 其中框出的部分则是区分它们不同的重点信息.
首先两个前面的注释都说明了下面的内容
Inserts the specified element into this queue if it is possible to do so immediately without violating capacity restrictions
即在不违反容量限制的情况下可以立即将元素插入到队列中. 那此时自然我们就会提出一个问题: 那如果违反了容量限制呢?
此时后面就有所解释, 在add()
方法中, 其直接说插入成功会返回true
, 而如果没有容量导致插入失败后, 则会直接抛出一个异常. 而在offer()
方法中, 其则说的是, 如果你使用的是一个由容量限制的队列, 则推荐使用这个方法, 因为add()
方法插入失败会直接抛出异常.
同时我们可以看到offer()
方法的返回值是不仅仅有true
的, 同时会在失败的时候返回false
.
那么此时我们就可以得出结论, add()
方法会在没有容量的时候直接抛出异常, 而offer()
方法则是会返回一个 false. 因此在队列可能会满的情况下, 则推荐去使用offer()
方法, 因为可以避免队列满而抛出异常.
剩余的remove()
和poll()
, element()
和peek()
都是类似的道理, 其中remove()
在队列为空的时候会抛出异常, poll()
则是直接返回一个空指针. element()
也是在队列空的时候抛出异常, peek()
也是只会返回一个空指针.
队列的模拟实现
上面简单介绍并且了解了队列之后, 我们现在就要来模拟实现一个队列了. 我们这里就采用双向链表的形式, 来模拟一个简单的队列.
初始化
这里的初始化就和双向链表的一致, 我们这里不多介绍
public class MyQueue {
// 节点
private static class Node {
int val;
Node next;
Node prev;
Node(int val) {
this.val = val;
}
Node(int val, Node next, Node prev) {
this.val = val;
this.next = next;
this.prev = prev;
}
}
// 队头和队尾
private Node front;
private Node rear;
private int size;
}
入队
实际上这里的入队就是双向链表的尾插, 并没有什么新的知识点, 这里就不多阐述
// 入队, 使用尾插法
public void offer(int val) {
if (front == null) {
front = new Node(val);
rear = front;
} else {
rear.next = new Node(val, null, rear);
rear = rear.next;
}
size++;
}
当然, 你也可以在队头插入和队尾删除, 两个都是一样的. 但是这样听起来会比较别扭, 因为按照我们日常来说, 一个队伍的头应该是用于离开的, 一个队伍的尾巴应该是用于加入队列的
出队
上面的入队我们是在对尾巴进行插入, 那么这里的出队自然就是在队头进行的, 代码如下
// 出队, 删除头节点
public int poll() {
if (rear == null) {
throw new NoSuchElementException("队列为空");
}
int val = front.val;
if (rear == front) {
// 只有一个元素, 特殊处理
front = null;
rear = null;
} else {
front = front.next;
front.prev = null;
}
size--;
return val;
}
查看队头元素
就是简单的返回队头的值, 非常简单
// 查看队头元素
public int peek() {
if (front == null) {
throw new NoSuchElementException("队列为空");
}
return front.val;
}
然后我们可以运行测试代码来测试一下
public class Main {
public static void main(String[] args) {
// 测试MyQueue
MyQueue queue = new MyQueue();
queue.offer(1);
queue.offer(2);
queue.offer(3);
System.out.println(queue);
System.out.println(queue.peek());
System.out.println(queue.poll());
System.out.println(queue);
}
}
循环队列
循环队列的定义及其注意点
在上面使用链表模拟实现了一个队列以后, 可能有人就想问了: 如果我想要使用数组来实现一个队列, 那么如何去实现呢?
那么我们首先先考虑一个问题, 如果要在数组中实现队列, 除了存储空间以外, 还需要提供什么才能够使得这个数组能够完成队列的入队出队操作?
很明显, 答案就是两个标志位置, 一个用于标志队头, 一个用于标志队尾. 如下图所示
但是此时我们就会发现一个问题, 就是这个数组前面的空间在被废弃, 最后可能会到达这种情况
那么此时请问如果要入队, rear 应该如何走? 是扩容后往后走吗?
实际上这里有一种更加精妙的做法, 就是当 rear 到达数组外的时候, 让其重回数组的 0 下标处. front也是同理, 那么此时我们就可以循环的利用这部分的空间来形成一个队列, 此时这个队列就被称作是循环队列
但是此时又产生了新的问题: 我们应该如何判定这个循环队列是满的呢? 假如有下面的情况
很明显, 此时 front 和 rear 位于同一个位置, 和初始的状况一样, 那么此时我们如何去区分空的情况和满的情况呢?
此时我们就可以采用一些标记来标识这个队列是否是满的, 最经典的做法就是通过一个 size 来记录队列的大小, 如果 size 等于数组的长度, 那么自然就说明队列是满的了. 反之, 当 size 为 0 的时候, 则说明队列是空的.
此外, 实际上还有一种可以用于标识队列是否为满的方法, 就是留出一个空位, 当 rear 的下一个位置是 front 的时候, 就代表队列已经满了, 如下图所示
总而言之, 我们这里用于标识循环队列是否为满的方法一般有两个, 一个就是用 size 记录队列大小, 另一个则是空一格位置用于判断.
此时我们虽然解决了一个关键的问题, 但是对于一个循环队列, 实际上还有一个更加关键的问题没有解决, 这个问题就是: 如何实现循环?
实际上循环的实现也非常简单, 同时也有两种方式提供实现.
首先是一个非常简单易懂的形式, 我们的循环, 是不是在标志到达数组外一个位置的时候, 就需要回到数组的开头? 那么我们在每一次移动的时候, 就直接判断一下当前这个标志位是否越界, 如果越界了就让其回到开头即可. 如下代码
public void test(){
// 其他代码...
// front 后移
front++;
// 如果 front 越界, 手动回到下标 0 位置
if(front == 数组长度){
front = 0;
}
}
同时, 如果对于我们空一个位置来判断队列是否未满, 则可能有下面图片的这种情况需要特殊处理
public void test(){
// 一个是正常情况, 一个是上面的特殊情况
if(rear + 1 == front || (rear + 1 == 数组长度 && front == 0)){
System.out.println("队列为满");
return;
}
}
可以看到, 如果要使用这样的方法, 采用 size 的方式来记录大小会更加简单一些.
第二种方法, 则是借助模运算来实现指针的循环移动.
首先我们需要知道的是, 假设有一个数字 a 一直在自增, 同时我如果让这个数字 a 一直取余一个数字 b, 也就是进行一直进行 a = (a + 1) % b
的操作, 那么此时这个 a 就会在 [0, b - 1] 区间里面一直循环移动, 从 0 出发 到达 b, 然后又会回到 0, 再到达 b.
举一个例子, 例如 a 刚开始为 0, b 为 5. 那么刚开始就是a = (0 + 1) % 5
, 此时自然 a 就是 1, 然后直到 a为 4 的时候, 就会有a = (4 + 1) % 5
, 此时可以发现 a 又会回到 0.
那么此时我们就可以让这个 a 是标志位, b 是数组的长度, 这样标志位就永远不会超出数组, 并且在自增中可以在 [0, 数组长度 - 1] 的位置间循环移动, 非常符合我们的需要.
那么它是否能够解决上面我们提到的, 在使用空位置确认数组是否为满时, 检测数组为满的代码稍微有点繁琐的问题呢?
很明显我们就可以直接通过一条语句完成, 如下所示
public void test(){
// 检测是否为满
if((rear + 1) % 数组长度 == front){
System.out.println("队列为满");
return;
}
}
当然, 直接使用 size 也是可以的, 也同样的非常便利.
现在我们已经介绍了循环队列中的两个主要的问题, 接下来我们就可以来实现一个循环队列.
循环队列的实现
题目链接: 设计循环队列
初始化和基本方法
首先是创建数组成员和标识, 同时我们这里采用 size 来记录队列长度, 比较简单.
public class MyCircularQueue {
// 数组
private int[] queue;
// 队列头指针
private int front;
// 队列尾指针
private int rear;
// 队列大小
private int size;
}
接下来就是按照要求实现构造方法, 很明显根据题目要求, 我们的构造方法需要能够设置队列的长度
// 构造函数,设置队列长度为 k
public MyCircularQueue(int k) {
queue = new int[k];
}
接下来来书写两个简单的方法, 分别是isFull()
和isEmpty()
. 由于我们使用了 size 来标识队列大小, 因此这两个方法非常好写
public boolean isEmpty() {
return size == 0;
}
public boolean isFull() {
return size == queue.length;
}
获取元素
获取元素指的就是上面的这两个方法, 其中获取队首元素是是非常简单的. 我们直接判断一下队列是否为空, 然后返回对应元素即可
public int Front() {
if(size == 0){
return -1;
}
return queue[front];
}
但是这个返回队尾元素, 则稍微的有一点不一样, 因为我们的队尾指针, 指向的是实际队尾的前面一个格子. 同时我们还需要注意 rear - 1 后变成 -1, 也就是 rear 刚经历了一次循环, 到达下标 0 位置的情况.
下面是一个使用条件操作符的写法
public int Rear() {
if(size == 0){
return -1;
}
return queue[rear == 0 ? queue.length - 1 : rear - 1];
}
当然不使用条件操作符也是可以的, 如下所示
public int Rear() {
if(size == 0){
return -1;
}
// 确认队尾元素位置
int index = -1;
if(rear == 0){
index = queue.length - 1;
}else {
index = rear - 1;
}
return queue[index];
}
入队
入队实际上也没有很大的难度, 我们首先先查看队列是否为满. 如果不为满, 就往队尾插入一个元素, 同时 rear 后移, 后移的方式我们就采用比较易懂的手动移动的方式来进行, 因此我们还需要检测一下 rear 是否越界, 如果越界则需要归 0. 同时不能忘记修改 size
public boolean enQueue(int value) {
if(size == queue.length){
return false;
}
queue[rear++] = value;
if(rear == queue.length){
rear = 0;
}
size++;
return true;
}
可以看到, 在我们上面介绍了所有注意点后, 这些方法的实现都是非常简单的
出队
出队和入队也是非常相似的, 检测队列是否为空, 然后front 后移即可, 当然也不能忘记了修改 size. 这里元素的删除和我们之前说的一样, 将其索引删除后, 自然成为了一个无效元素, 后面自然会被替换掉.
public boolean deQueue() {
if(size == 0){
return false;
}
front++;
if(front == queue.length){
front = 0;
}
size--;
return true;
}
了解双端队列
前面我们也提到过, 在集合框架中有一个接口, 叫做 Deque, 其代表的是一个双端队列. 它的全称为Double ended queue
, 它支持在队列的两端进行入队和出队
在 Java 的集合框架中, 也提供了双端队列的链式实现和数组实现. 分别如下所示
public class Main {
public static void main(String[] args) {
// 双端队列的数组实现
ArrayDeque<Integer> deque1 = new ArrayDeque<>();
// 双端队列的链式实现
Deque<Integer> deque2 = new LinkedList<>();
}
}
双端队列使用的还是比较多的, 主要是由于其的两端既可以出也可以入, 因此其也可以只在一段进行出入, 即作为栈来进行使用.