1.栈
1.1栈的概念和结构
栈是一种特殊的线性表,其只允许在固定的一端进行插入和删除元素的操作。进行数据插入和删除的一端称为栈顶,另一端称为栈底。栈中数据元素遵循先进后出原则。
压栈:栈的插入操作叫做入栈,入数据在栈顶。
出栈:栈的删除操作叫做出栈。出数据也在栈顶。
示例1:基于数组的顺序栈
public class ArrayStack {
private String[] items;// 数组
private int count;// 栈中元素个数
private int n;// 栈的大小
// 初始化数组,申请一个大小为n的数组空间
public ArrayStack(int n) {
this.items = new String[n];
this.count = 0;
this.count = n;
}
// 入栈操作
public boolean push(String item) {
// 数组空间不够用了直接返回false
if (count == n) {
return false;
}
// 将item下标放到count位置,并且count加1
items[count] = item;
count++;
return true;
}
// 出栈操作
public String pop() {
// 栈为空,则直接返回null
if (count == 0) {
return null;
}
// 返回下标为count-1的数组元素,并且栈中元素个数count-1
String temp = items[count - 1];
count--;
return temp;
}
}
示例2:基于链表的链式栈
public class StackBasedLinkedList {
private static class Node {
private int data;
private Node next;
public Node(int data, Node next) {
this.data = data;
this.next = next;
}
}
private Node top = null;
//入栈
public void push(int value) {
Node newNode = new Node(value, null);
// 判断栈是否空
if (top == null) {
top = newNode;
}
newNode.next = top;
top = newNode;
}
//出栈,用-1表示栈中没有数据
public int pop() {
if (top == null) {
return -1;
}
int value = top.data;
top = top.next;
return value;
}
//打印
public void printAll() {
Node p=top;
while(p!=null) {
System.out.println(p.data+" ");
p=p.next;
}
System.out.println();
}
}
1.2支持动态扩容的顺序栈
上述基于数组实现的栈,是一个固定大小的栈,也就是说,在初始化栈时需要事先指定栈的大小。当栈满之后,就无法再往栈里添加数据了。尽管链式栈的大小不受限,但要存储next指针,内存消耗相对较多。要想实现一个支持动态扩容的栈,我们只需要底层依赖一个支持动态扩容的数组就可以了。当栈满之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中。
import java.util.Arrays;
public class ArrayStack2 {
private Object[] items;// 数组
private int count;// 栈中元素个数
private int n;// 栈的大小
//初始化数组,申请一个大小为n的数组空间
public ArrayStack2(int n) {
this.items = new Object[n];
this.count = 0;
this.count = n;
}
//入栈操作
public boolean push(Object item) {
if (count == n) {
int oldCount = n;
int newCount = oldCount << 1;
// 栈大小已经超过int的最大值
if ((newCount + 8) - Integer.MAX_VALUE > 0) {
return false;
}
// 数组扩容
n = newCount;
// 将item放到下标为count的位置上,并且count+1
items = Arrays.copyOf(items, newCount);
}
items[count] = item;
count++;
return true;
}
//出栈操作
public Object pop() {
// 栈为空,直接返回null
if (count == 0) {
return null;
}
// 返回下标count-1的数组元素,并且栈中元素个数count-1
Object temp = items[count - 1];
--count;
return temp;
}
public static void main(String[] args) {
ArrayStack2 stack = new ArrayStack2(1);
stack.push(9);
stack.push(6);
stack.push(8);
System.out.println(stack.pop());
}
}
1.3栈的应用
1.4.1栈在函数调用中的应用
其中,比较经典的一个应用就是函数调用栈。
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
从代码中我们可以看出,main() 函数调⽤了 add() 函数,获取计算结果,并且与临时变量 a 相加,最后打印 res 的值。为了让你清晰地看到这个过程对应的函数栈⾥出栈、⼊栈的操作,我画了⼀张图。图中显示的是,在执⾏到 add() 函数时,函数调⽤栈的情况。
1.4.2栈在表达式求值中的应用
为了⽅便解释,我将算术表达式简化为只包含加减乘除四则运算,⽐如:34+139+44-12/3。对于这个四则运算,我们⼈脑可以很快求解出答案,但是对于计算机来说,理解这个表达式本身就是个挺难的事⼉。如果换作你,让你来实现这样⼀个表达式求值的功能,你会怎么做呢?
实际上,编译器就是通过两个栈来实现的。其中⼀个保存操作数的栈,另⼀个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压⼊操作数栈;当遇到运算符,就与运算符栈的栈顶元素进⾏⽐较。如果⽐运算符栈顶元素的优先级⾼,就将当前运算符压⼊栈;如果⽐运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进⾏计算,再把计算完的结果压⼊操作数栈,继续⽐较。
我将 3+58-6 这个表达式的计算过程画成了⼀张图,你可以结合图来理解我刚讲的计算过程。
1.3.3栈在括号匹配中的应用
除了⽤栈来实现表达式求值,我们还可以借助栈来检查表达式中的括号是否匹配。
我们同样简化⼀下背景。我们假设表达式中只包含三种括号,圆括号 ()、⽅括号 [] 和花括号{},并且它们可以任意嵌套。⽐如,{[{}]}或 [{()}([])] 等都为合法格式,⽽{[}()] 或 [({)] 为不合法的格式。那我现在给你⼀个包含三种括号的表达式字符串,如何检查它是否合法呢?
这⾥也可以⽤栈来解决。我们⽤栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压⼊栈中;当扫描到右括号时,从栈顶取出⼀个左括号。如果能够匹配,⽐如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为⾮法格式。当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为⾮法格式。
2.队列
队列这个概念⾮常好理解。你可以把它想象成排队买票,先来的先买,后来的⼈只能站末尾,不允许插队。先进者先出,这就是典型的“队列”。
我们知道,栈只⽀持两个基本操作:⼊栈 push()和出栈 pop()。队列跟栈⾮常相似,⽀持的操作也很有限,最基本的操作也是两个:⼊队 enqueue(),放⼀个数据到队列尾部;出队 dequeue(),从队列头部取⼀个元素。
所以,队列跟栈⼀样,也是⼀种操作受限的线性表数据结构。
队列的概念很好理解,基本操作也很容易掌握。作为⼀种⾮常基础的数据结构,队列的应⽤也⾮常⼴泛,特别是⼀些具有某些额外特性的队列,⽐如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作⽤。⽐如⾼性能队列 Disruptor、Linux 环形缓存,都⽤
到了循环并发队列;Java concurrent 并发包利⽤ ArrayBlockingQueue 来实现公平锁等。
2.队列的实现
2.1队列的概念和结构
跟栈⼀样,队列可以⽤数组来实现,也可以⽤链表来实现。⽤数组实现的栈叫作顺序栈,⽤链表实现的栈叫作链式栈。同样,⽤数组实现的队列叫作顺序队列,⽤链表实现的队列叫作链式队列。
示例1:基于数组的顺序队列
import java.util.Queue;
public class ArrayQueue<T> implements Queue<T> {
//存放具体数据
private T[] elementData;
// 队列头
private int head;
// 队列尾部
private int tail;
// 队列容量
private int capacity;
public ArrayQueue(int capacity) {
this.capacity = (T[]) new Object[capacity];
}
// 元素入队
public void enqueue(T t) {
if (tail == capacity) {
System.out.println("队列已满");
throw new ArrayIndexOutOfBoundsException();
}
elementData[tail++] = t;
}
// 元素出队
public T dequeue() {
if (head == tail) {
System.out.println("队列为空");
throw new NullPointerException();
}
T result = elementData[head++];
return result;
}
// 返回队首元素,但不出队
public T peek() {
if (head == tail) {
System.out.println("队列为空");
throw new NullPointerException();
}
T result = elementData[head];
return result;
}
public int getSize() {
return tail - head;
}
public boolean isEmpty() {
return head == tail;
}
}
上述入队函数enqueue实现有优化方式,解决随着不停地进⾏⼊队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也⽆法继续往队列中添加数据了的问题。
改造如下:
public void enqueue(T t) {
if (tail == capacity) {
// tail == capacity && head == 0 表示队列已满
if (head == 0) {
System.err.println("队列已满");
throw new ArrayIndexOutOfBoundsException();
}
else {
// 数据搬移
for (int i = head;i < tail;i++) {
elementData[i - head] = elementData[i];
}
// 数据搬移后更新两个指针位置
tail -= head;
head = 0;
}
}
elementData[tail++] = t;
}
这样可以看到:
当队列的 tail 指针移动到数组的最右边后,如果有新的数据⼊队,我们可以将
head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置。
示例2:基于链表的链式队列
基于链表的实现,我们同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第⼀个结点和最后⼀个结点。如图所示,⼊队时,tail->next= new_node, tail = tail->next;出队时,head = head->next。
public class QueueBasedOnLinkedList {
private static class Node {
private String data;
private Node next;
public Node(String data, Node next) {
this.data = data;
this.next = next;
}
public String getData() {
return data;
}
}
//队列的队首和队尾
private Node head = null;
private Node tail = null;
//入队
public void equeue(String value) {
if (tail == null) {
Node newNode = new Node(value, null);
head = newNode;
tail = newNode;
} else {
tail.next = new Node(value, null);
tail = tail.next;
}
}
//出队
public String dequeue() {
if (head == null) {
return null;
}
String value = head.data;
head = head.next;
if (head == null) {
tail = null;
}
return value;
}
public void printAll() {
Node p = head;
while (p != null) {
System.out.println(p.data + " ");
p = p.next;
}
System.out.println();
}
}
2.2支持动态扩容的顺序队列(有界)
package bittech.queue.impl;
import bittech.queue.Queue;
import java.util.Objects;
public class ArrayQueue<T> implements Queue<T> {
// 存放具体数据
private T[] elementData;
// 队列头
private int head;
// 队列尾部
private int tail;
// 队列容量
private int capacity;
public ArrayQueue(int capacity) {
this.capacity = capacity;
elementData = (T[]) new Object[capacity];
}
/**
* 元素⼊队
* @param t 要⼊队元素
*/
@Override
public void enqueue(T t) {
if (tail == capacity) {
// tail == capacity && head == 0 表示队列已满
if (head == 0) {
System.err.println("队列已满");
throw new ArrayIndexOutOfBoundsException();
}
else {
// 数据搬移
for (int i = head;i < tail;i++) {
elementData[i - head] = elementData[i];
}
// 数据搬移后更新两个指针位置
tail -= head;
head = 0;
}
}
elementData[tail++] = t;
}
/**
* 元素出队
* @return 出队元素
*/
@Override
public T dequeue() {
if (head == tail) {
System.err.println("队列为空");
throw new NullPointerException();
}
T result = elementData[head++];
return result;
}
/**
* 返回队⾸元素但不出队
* @return
*/
@Override
public T peek() {
if (head == tail) {
System.err.println("队列为空");
throw new NullPointerException();
}
T result = elementData[head];
return result;
}
@Override
public int getSize() {
return tail - head;
}
@Override
public boolean isEmpty() {
return head == tail;
}
}
2.3循环队列
我们刚才⽤数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样⼊队操作性能就会受到影响。那有没有办法能够避免数据搬移呢?我们来看看循环队列的解决思路。
循环队列,顾名思义,它⻓得像⼀个环。原本数组是有头有尾的,是⼀条直线。现在我们把⾸尾相连,扳成了⼀个环。如下图所示
我们可以看到,图中这个队列的⼤⼩为 8,当前 head=4,tail=7。当有⼀个新的元素 a ⼊队时,我们放⼊下标为 7 的位置。但这个时候,我们并不把 tail 更新为 8,⽽是将其在环中后移⼀位,到下标为 0 的位置。当再有⼀个元素 b ⼊队时,我们将 b 放⼊下标为 0 的位置,然后 tail 加 1 更新为 1。所以,在a,b 依次⼊队之后,循环队列中的元素就变成了下⾯的样⼦:
通过这样的⽅法,我们成功避免了数据搬移操作。看起来不难理解,但是循环队列的代码实现难度要⽐前⾯讲的⾮循环队列难多了。要想写出没有 bug 的循环队列的实现代码,我个⼈觉得,最关键的是,确
定好队空和队满的判定条件。
在⽤数组实现的⾮循环队列中,队满的判断条件是 tail == n,队空的判断条件是 head == tail。那针对循环队列,如何判断队空和队满呢?
队列为空的判断条件仍然是 head == tail。但队列满的判断条件就稍微有点复杂了。来看下⾯这张队列满的图,⼤家可以看⼀下,试着总结⼀下规律。
就像我图中画的队满的情况,tail=3,head=4,n=8,所以总结⼀下规律就是:(3+1)%8=4。多画⼏张队满的图,你就会发现,当队满时,
(tail+1)%n=head
你有没有发现,当队列满时,图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费⼀个数组的存储空间。
示例:用数组实现的循环链表
public class ArrayLoopQueue<T> implements Queue<T> {
// 存放数据的泛型数组
private T[] elementData;
// 队⾸索引
private int head;
// 队尾索引
private int tail;
// 队列当前元素个数
private int size;
public ArrayLoopQueue(int capSize) {
// 因为循环队列会浪费⼀个空间来判断当前队列是否已满,因此多开辟⼀块空间
elementData = (T[]) new Object[capSize + 1];
}
public ArrayLoopQueue() {
this(8);
}
@Override
public void enqueue(T t) {
// 判断队列是否已满
if ((tail + 1) % elementData.length == head) {
System.err.println("队列已满");
throw new ArrayIndexOutOfBoundsException();
}
elementData[tail] = t;
tail = (tail + 1) % elementData.length;
size++;
}
@Override
public T dequeue() {
if (isEmpty()) {
System.err.println("队列为空");
throw new NullPointerException();
}
T result = elementData[head];
elementData[head] = null;
head = (head + 1) % elementData.length;
size--;
return result;
}
@Override
public T peek() {
if (isEmpty()) {
System.err.println("队列为空");
throw new NullPointerException();
}
T result = elementData[head];
return result;
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return head == tail;
}
}