栈(Stack)
- 栈是一种线性结构。相比数组,栈对应的操作是数组的子集,所以我们完全可以基于动态数组去实现它
- 栈只能从一端添加元素,也只能从同一端取出元素,这一端称为栈顶
- 栈是一种后进先出的数据结构(Last In First Out 简称为LIFO)
栈最常见的应用场景:
- 括号匹配-编译器
- 无处不在的Undo操作(撤销),将我们每次的操作放入栈中,执行撤销操作时只需要把放入的元素出栈即可
- 程序调用的系统栈,方法调用时所展现的调用层级,就是栈的结构,如下图:
栈的基本结构
我们将基于前面所实现的动态数组的基础上实现一个栈的数据结构。由于栈的底层实现有多种(数组、链表)方式,所以为了隔离实现,我们定义一个接口,来面向接口编程,该接口仅定义栈这个数据结构必要的方法:
public interface Stack<E> {
/**
* 获取栈中的元素个数
*
* @return 元素个数
*/
int getSize();
/**
* 栈是否为空
*
* @return 为空返回true,否则返回false
*/
boolean isEmpty();
/**
* 将一个元素入栈
*
* @param e 新元素
*/
void push(E e);
/**
* 将一个元素出栈
*
* @return 栈顶的元素
*/
E pop();
/**
* 查看栈顶的元素
*
* @return 栈顶的元素
*/
E peek();
}
然后创建一个实现类实现这个接口,代码如下:
/**
* 基于动态数组实现的栈数据结构
**/
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\n", 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结束。正常执行完循环后,还需验证栈是否为空,因为进行括号匹配的时候是将栈顶元素出栈进行匹配的,所以循环内逻辑正确的话所有元素都会出栈,此时的栈必需为空。
/**
* @author li.pan
* <p>
* 给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。
* 有效字符串需满足:
* 1. 左括号必须用相同类型的右括号闭合。
* 2. 左括号必须以正确的顺序闭合。
* </p>
*/
public class Leetcode20 {
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 Leetcode20()).isValid("()[]{}")); // true
System.out.println((new Leetcode20()).isValid("{[()]}")); // true
System.out.println((new Leetcode20()).isValid("([{}]")); // false
}
}
队列(Queue)
- 队列也是一种线性结构,相比数组队列对应的操作是数组的子集
- 队列只能从队尾添加元素,并且只能从队首取出元素
- 队列是一种先进先出的数据结构(先进先出),实际上FIFO就是First In First Out的缩写
数据结构中的队列与我们现实生活中的队列是一样的,例如我们在排队到柜台办理业务的时候,就是一个队列结构,先排队的先办理业务,后排队的后办理业务,符合先进先出的特性。
队列的基本结构
同样的,队列这种结构的底层实现也有多种方式,常见的就有数组队列、循环队列以及链表队列等,所以我们得定义一个Queue接口,来面向接口编程,该接口仅定义队列这个数据结构必要的方法:
public interface Queue<E> {
/**
* 新元素入队
*
* @param e 新元素
*/
void enqueue(E e);
/**
* 元素出队
*
* @return 元素
*/
E dequeue();
/**
* 获取位于队首的元素
*
* @return 队首的元素
*/
E getFront();
/**
* 获取队列中的元素个数
*
* @return 元素个数
*/
int getSize();
/**
* 队列是否为空
*
* @return 为空返回true,否则返回false
*/
boolean isEmpty();
}
数组队列
同样的,我们基于前面所实现的动态数组的基础上来实现数组队列
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();
}
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\n", 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();
}
}
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的值:
front==tail队列为空,(tail + 1) % capacity==front队列为满,(tail + 1) % capacity队列移动
实现一个循环队列,与之前的数组队列实现不同的是,我们不再基于Array类进行实现,因为具体的实现逻辑有许多不一样的地方,我们要将数组当成一个环去用,所以无法再复用Array这个数据结构,我们需要从底层完成这个循环队列数据结构。
/**
* 循环队列数据结构
**/
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\n", 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.");
}
}
}
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
对于数组队列,每次出队需要移动后全部元素,故此时间消耗较大。
源代码地址:github.com/perkinls/ja…