栈和队列
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了栈和队列相关的知识,希望对你有一定的参考价值。
栈
栈 Stack:
- 栈是一种线性结构
- 相比数组,栈对应的操作是数组的子集,所以我们完全可以基于动态数组去实现它
- 栈只能从一端添加元素,也只能从同一端取出元素,这一端称为栈顶
- 栈是一种后进先出的数据结构(Last In First Out 简称为LIFO)
举个不太恰当的比喻,栈就像一个直径比乒乓球大点的水杯,而元素就像是乒乓球,现在我们要把几个乒乓球放入杯子里。因为杯子底部是实的,所以我们只能从杯口放入兵乓球,我们把乒乓球放入这个水杯的过程就是入栈,把兵乓球从杯子中取出的过程就是出栈。这个杯子的杯口就是栈顶,而在最上面的那个乒乓球就是栈顶元素。当我们想从水杯里拿乒乓球的时候,只能从最上面的开始拿,无法从底部或中间开始拿,符合后进先出的特性:
栈最常见的应用场景:
- 括号匹配-编译器
- 无处不在的Undo操作(撤销),将我们每次的操作放入栈中,执行撤销操作时只需要把放入的元素出栈即可
- 程序调用的系统栈,方法调用时所展现的调用层级,就是栈的结构,如下图:
栈的基本实现
在这一小节中,我们将基于上一章所实现的动态数组的基础上实现一个栈的数据结构,上一章的地址如下:
由于栈的底层实现有多种方式,所以为了隔离实现,我们定义一个接口,来面向接口编程,该接口仅定义栈这个数据结构必要的方法:
/**
* @program: Data-Structure
* @description: 栈数据结构接口
* @author: 01
* @create: 2018-11-07 12:21
**/
public interface Stack<E> {
/**
* 获取栈中的元素个数
*
* @return 元素个数
*/
int getSize();
/**
* 栈是否为空
*
* @return 为空返回true,否则返回false
*/
boolean isEmpty();
/**
* 将一个元素入栈
*
* @param e 新元素
*/
void push(E e);
/**
* 将一个元素出栈
*
* @return 栈顶的元素
*/
E pop();
/**
* 查看栈顶的元素
*
* @return 栈顶的元素
*/
E peek();
}
然后创建一个实现类实现这个接口,代码如下:
/**
* @program: Data-Structure
* @description: 基于动态数组实现的栈数据结构
* @author: 01
* @create: 2018-11-07 12:22
**/
public class ArrayStack<E> implements Stack<E> {
private Array<E> array;
public ArrayStack() {
this.array = new Array<>();
}
public ArrayStack(int capacity) {
this.array = new Array<>(capacity);
}
@Override
public int getSize() {
return array.getSize();
}
@Override
public boolean isEmpty() {
return array.isEmpty();
}
@Override
public void push(E e) {
array.addLast(e);
}
@Override
public E pop() {
return array.removeLast();
}
@Override
public E peek() {
return array.getLast();
}
/**
* 获取栈的容量
*
* @return capacity
*/
public int getCapacity(){
return array.getCapacity();
}
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("Stack: size = %d, capacity = %d
", getSize(), getCapacity()));
sb.append("[");
for (int i = 0; i < getSize(); i++) {
sb.append(array.get(i));
if (i != getSize() - 1) {
sb.append(", ");
}
}
return sb.append("] top").toString();
}
}
从实现代码可以看出,基于上章我们所实现的动态数组的基础上,实现一个栈数据结构是非常简单的。
ArrayStack主要方法的时间复杂度:
void push(E) // O(1) 均摊
E pop() // O(1) 均摊
E peek() // O(1)
int getSize() // O(1)
boolean isEmpty() // O(1)
使用栈来实现括号匹配
实现括号匹配可以说是栈的一个经典应用了,很多公司也出过这个面试题,所以我们本小节来看看如何基于栈实现括号匹配。
实现括号匹配的思路也很简单,大概就是先遍历字符串中的字符,遇到左括号就将其入栈,遇到右括号则将栈顶元素出栈与其进行匹配,若匹配则继续循环,不匹配则返回false结束。正常执行完循环后,还需验证栈是否为空,因为进行括号匹配的时候是将栈顶元素出栈进行匹配的,所以循环内逻辑正确的话所有元素都会出栈,此时的栈必需为空。
具体的实现代码如下:
/**
* @program: Data-Structure
* @description: 使用栈实现括号匹配
* @author: 01
* @create: 2018-11-07 13:36
**/
public class Solution {
public boolean isValid(String s) {
Stack<Character> stack = new ArrayStack<>();
// 遍历字符串中的字符
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == ‘(‘ || c == ‘[‘ || c == ‘{‘) {
// 只要是左边的括号就入栈
stack.push(c);
} else {
// 如果栈中没有元素代表没有左括号
if (stack.isEmpty()) {
return false;
}
// 取出栈顶元素进行匹配,只要有一个不匹配就返回false
char topChar = stack.pop();
if (c == ‘)‘ && topChar != ‘(‘) {
return false;
}
if (c == ‘]‘ && topChar != ‘[‘) {
return false;
}
if (c == ‘}‘ && topChar != ‘{‘) {
return false;
}
}
}
// 最后需要验证栈是否为空
return stack.isEmpty();
}
// 测试
public static void main(String[] args) {
System.out.println((new Solution()).isValid("()[]{}")); // true
System.out.println((new Solution()).isValid("{[()]}")); // true
System.out.println((new Solution()).isValid("([{}]")); // false
}
}
数组队列
队列Queue:
- 队列也是一种线性结构
- 相比数组,队列对应的操作是数组的子集
- 队列只能从队尾添加元素,并且只能从队首取出元素
- 队列是一种先进先出的数据结构(先到先得)
- 通常谈到队列总是会涉及FIFO,实际上FIFO就是First In First Out的缩写
数据结构中的队列与我们现实生活中的队列是一样的,例如我们在排队到柜台办理业务的时候,就是一个队列结构,先排队的先办理业务,后排队的后办理业务,符合先进先出的特性:
同样的,队列这种结构的底层实现也有多种方式,常见的就有数组队列、循环队列以及链表队列等,所以我们得定义一个Queue接口,来面向接口编程,该接口仅定义队列这个数据结构必要的方法:
/**
* @program: Data-Structure
* @description: 队列数据结构接口
* @author: 01
* @create: 2018-11-07 15:34
**/
public interface Queue<E> {
/**
* 新元素入队
*
* @param e 新元素
*/
void enqueue(E e);
/**
* 元素出队
*
* @return 元素
*/
E dequeue();
/**
* 获取位于队首的元素
*
* @return 队首的元素
*/
E getFront();
/**
* 获取队列中的元素个数
*
* @return 元素个数
*/
int getSize();
/**
* 队列是否为空
*
* @return 为空返回true,否则返回false
*/
boolean isEmpty();
}
本小节我们来实现的是数组队列,既然是数组队列自然是基于数组实现的,所以我们同样基于上一章所实现的Array类来实现数组队列,代码如下:
/**
* @program: Data-Structure
* @description: 基于动态数组实现的数组队列
* @author: 01
* @create: 2018-11-07 15:39
**/
public class ArrayQueue<E> implements Queue<E> {
private Array<E> array;
public ArrayQueue() {
this.array = new Array<>();
}
public ArrayQueue(int capacity) {
this.array = new Array<>(capacity);
}
@Override
public void enqueue(E e) {
array.addLast(e);
}
@Override
public E dequeue() {
return array.removeFirst();
}
@Override
public E getFront() {
return array.getFirst();
}
@Override
public int getSize() {
return array.getSize();
}
@Override
public boolean isEmpty() {
return array.isEmpty();
}
/**
* 获取队列的容量
*
* @return capacity
*/
public int getCapacity() {
return array.getCapacity();
}
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("Queue: size = %d, capacity = %d
", getSize(), getCapacity()));
sb.append("front [");
for (int i = 0; i < getSize(); i++) {
sb.append(array.get(i));
if (i != getSize() - 1) {
sb.append(", ");
}
}
return sb.append("] tail").toString();
}
// 测试
public static void main(String[] args) {
Queue<Integer> queue = new ArrayQueue<>();
for (int i = 0; i < 10; i++) {
queue.enqueue(i);
System.out.println(queue);
if (i % 3 == 2) {
// 每入队三个元素就出队一个元素
queue.dequeue();
System.out.println(queue);
}
}
}
}
ArrayQueue主要方法的时间复杂度:
void enqueue(E) // O(1) 均摊
E dequeue() // O(n) 每出队一个元素,底层数组内所有的元素都需要移动位置,所以是O(n)的复杂度
E getFront() // O(1)
int getSize() // O(1)
boolean isEmpty() // O(1)
循环队列
基于数组来实现队列是有一定局限性的,在出队的操作,复杂度是O(n),如果队列中有大量元素的话,出队一个元素都是很耗时的,例如数组中有10w个元素,那么每出队一个元素就要移动10w个元素。
所以我们就需要采用另一种方式来实现队列这个数据结构,通常我们会使用循环队列或链表队列,本小节主要介绍循环队列。在循环队列中,我们会在队列里设置两个变量,分别是front和tail,其中front始终指向的是位于队首的元素,而tail则始终指向位于队尾的元素+1的索引位置,当front等于tail时代表队列为空:
当我们将队首元素出队时,front移动一下指向下一个元素,数组内的其他元素都不移动,这样出队操作的复杂度就是O(1)。同理,当元素入队时,tail移动一下即可:
当元素继续入队,直到数组后面的空间都填满了怎么办?如下图:
首先我们从图中可以看到,数组的前面还有可利用的空间,我们可以想办法将tail移动到可利用的空间上。在上文中提到当新元素入队后tail就移动一下,那么这个具体移动的数值是怎么计算的呢?实际上tail移动的具体数值是通过(tail + 1) % capacity
得出的,例如这里就是(7 + 1) % 8 = 0
,所以此时tail就会指向数组索引为0的位置,而front也是同理:
将其想象成一个环,可能会更好理解,这也是为什么叫循环队列的原因,如下图:
当队列满了之后,自然就需要扩容,怎么判断队列满了呢?答案是判断(tail + 1) % capacity
的结果是否等于front的值:
循环队列的实现
在本小节中,我们将实现一个循环队列,与之前的数组队列实现不同的是,我们不再基于Array类进行实现,因为具体的实现逻辑有许多不一样的地方,我们要将数组当成一个环去用,所以无法再复用Array这个数据结构,我们需要从底层完成这个循环队列数据结构。
具体的实现代码如下:
/**
* @program: Data-Structure
* @description: 循环队列数据结构
* @author: 01
* @create: 2018-11-07 23:26
**/
public class LoopQueue<E> implements Queue<E> {
/**
* 实际存储元素的数组
*/
private E[] data;
/**
* 指向队首元素
*/
private int front;
/**
* 指向队尾元素+1的索引位置
*/
private int tail;
/**
* 元素的个数
*/
private int size;
/**
* 带有队列初始容量参数的构造器
*
* @param capacity 队列初始容量
*/
public LoopQueue(int capacity) {
// 因为循环队列的结构会浪费一个索引空间,所以这里需要+1
this.data = (E[]) new Object[capacity + 1];
this.front = 0;
this.tail = 0;
this.size = 0;
}
/**
* 无参构造器,默认队列初始容量为10
*/
public LoopQueue() {
this(10);
}
/**
* 获取队列的容量
*
* @return 队列的容量
*/
public int getCapacity() {
// 因为会浪费一个索引空间,所以实际的容量是数组的长度-1
return data.length - 1;
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return front == tail;
}
@Override
public E getFront() {
checkIfEmpty();
return data[front];
}
@Override
public void enqueue(E e) {
// 队列是否已满
if ((tail + 1) % data.length == front) {
// 扩容
resize(getCapacity() * 2);
}
data[tail] = e;
tail = (tail + 1) % data.length;
size++;
}
@Override
public E dequeue() {
checkIfEmpty();
E ret = data[front];
// 释放出队元素
data[front] = null;
// 移动front
front = (front + 1) % data.length;
size--;
// 判断是否需要缩容
if (size == getCapacity() / 4 && getCapacity() / 2 > 0) {
// 缩容
resize(getCapacity() / 2);
}
return ret;
}
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("Queue: size = %d, capacity = %d
", size, getCapacity()));
sb.append("front [");
// 第一种遍历循环队列的方式
for (int i = front; i != tail; i = (i + 1) % data.length) {
sb.append(data[i]);
if ((i + 1) % data.length != tail) {
sb.append(", ");
}
}
return sb.append("] tail").toString();
}
/**
* 队列容量重置
*
* @param newCapacity 新的队列容量
*/
private void resize(int newCapacity) {
E[] newData = (E[]) new Object[newCapacity + 1];
// 第二种遍历循环队列的方式
for (int i = 0; i < size; i++) {
// 因为是循环队列,元素的位置需要通过特定方式计算
newData[i] = data[(front + i) % data.length];
}
data = newData;
front = 0;
tail = size;
}
/**
* 检查是否是对空队列进行操作
*/
private void checkIfEmpty() {
if (isEmpty()) {
throw new IllegalArgumentException("Can‘t operation an empty queue.");
}
}
// 测试
public static void main(String[] args) {
Queue<Integer> queue = new LoopQueue<>();
for (int i = 0; i < 10; i++) {
queue.enqueue(i);
System.out.println(queue);
if (i % 3 == 2) {
// 每入队三个元素就出队一个元素
queue.dequeue();
System.out.println(queue);
}
}
}
}
LoopQueue主要方法的时间复杂度:
void enqueue(E) // O(1) 均摊
E dequeue() // O(1) 均摊
E getFront() // O(1)
int getSize() // O(1)
boolean isEmpty() // O(1)
数组队列和循环队列的性能比较
最后,我们来写一个简单的测试用例,使用10w数据量测试一下数组队列和循环队列的性能,代码如下:
public class Main {
/**
* 测试使用queue运行opCount个enqueue和dequeue操作所需要的时间,单位:毫秒
*
* @param queue queue
* @param opCount opCount
* @return 耗时
*/
private static long testQueue(Queue<Integer> queue, int opCount) {
long startTime = System.currentTimeMillis();
Random random = new Random();
for (int i = 0; i < opCount; i++) {
queue.enqueue(random.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < opCount; i++) {
queue.dequeue();
}
return System.currentTimeMillis() - startTime;
}
public static void main(String[] args) {
long time1 = testQueue(new ArrayQueue<>(), 100000);
System.out.println("ArrayQueue, time: " + time1 + "/ms");
long time2 = testQueue(new LoopQueue<>(), 100000);
System.out.println("LoopQueue, time: " + time2 + "/ms");
}
}
控制台输出如下:
ArrayQueue, time: 16531/ms
LoopQueue, time: 15/ms
项目源码地址如下:
以上是关于栈和队列的主要内容,如果未能解决你的问题,请参考以下文章