【数据结构】栈(Stack)、队列(Queue)、双端队列(Deque) —— 有码有图有真相
目录
栈和队列
1. 栈(Stack)
1.1 概念
1.2 栈的使用(原始方法)
1.3 栈的模拟实现
【小结】
2. 栈的应用场景
1、改变元素的序列
2、将递归转化为循环
3、逆波兰表达式求值
4、括号匹配
5、出栈入栈次序匹配
6、最小栈
【概念区分】栈、虚拟机栈、栈帧有什么区别呢?
3. 队列(Queue)
3.1 概念
3.2 队列的使用(原生方法)
3.3 队列的模拟实现
3.4 循环队列
4. 双端队列 (Deque)
【面试题】
1、用队列模拟实现栈 (普通队列和普通栈)
2、用栈模拟实现队列(普通栈和普通队列)
【总结】
栈和队列
Vector基本上过时了,这里我们不进行讲解。
- LinkedList 不仅能当做链表(用List接口引用),也能当做队列(用Deque接口和Queue接口引用)
- 主要看你用哪种接口调用LinkedList(一定是LinkedList实现的接口),LinkedList 就表现哪种形式。在使用方法的时候必须满足接口中组织数据的形式。
- 主要看用哪种方式维护LinkedList的
1. 栈(Stack)
栈是一种数据结构,实体类,特点先进后出。如果数据要支持先进后出这种特点,就要选用数据结构栈这种方式来存储或组织数据。
- 数据结构中,结构就是来描述组织数据的方式的,之所以会有很多数据结构,是因为我们有不同的需求,组织的数据方式不同,有了很多种存储或组织数据的方式,所以就有了很多的数据结构。
- 栈只是一种数据结构,我们可以用数组(顺序表),单链表,双链表来实现栈(维护栈),但是在使用方法的时候必须满足先进后出的形式。
- 栈的底层本来就是一个数组,因为stack类继承了Vector类,而Vector类底层就是一个数组在存储数据。
1.1 概念
栈:一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。
进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则
- 压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。
- 出栈:栈的删除操作叫做出栈。出数据在栈顶。
栈在现实生活中的例子:
子弹压弹以及发射。羽毛球放入桶中以及拿出来。
1.2 栈的使用(原始方法)
Stack类中的方法原生方法比较少。size方法继承于父类
方法 | 功能 |
Stack() | 构造一个空的栈 |
E push(E e) | 将e入栈,并返回e (入栈) |
E pop() | 将栈顶元素出栈并返回 (出栈) |
E peek() | 获取栈顶元素 (查看栈顶元素) |
int size() | 获取栈中有效元素个数 |
boolean empty() | 检测栈是否为空 |
public static void main(String[] args) {
Stack<Integer> s = new Stack();
s.push(1);
s.push(2);
s.push(3);
s.push(4);
System.out.println(s.size()); // 获取栈中有效元素个数---> 4
System.out.println(s.peek()); // 获取栈顶元素---> 4
s.pop(); // 4出栈,栈中剩余1 2 3,栈顶元素为3
System.out.println(s.pop()); // 3出栈,栈中剩余1 2 栈顶元素为2
if(s.empty()){
System.out.println("栈空");
}else{
System.out.println(s.size());
}
}
1.3 栈的模拟实现
栈的底层本就是一个数组,可以用数组(顺序表),单链表,双链表模拟实现栈,但是在使用方法的时候必须满足先进后出的形式。
从上图中可以看到,Stack继承了Vector,Vector和ArrayList类似都是动态的顺序表,不同的是Vector是线程安全的。
1、顺序栈(用数组实现栈)
- 里面的数据是拿数组(顺序表,动态扩容的数组)来组织实现的。
- 栈的底层其实就是一个数组,但是我们提供的方法必须满足先进后出的形式。
数组长度与数组中有效元素个数一定要分清楚。
这里usedSize,不仅可以记录有效元素个数,还能标记当前存储数据元素下标。
public class MyStack implements IStack {
private int[] elem;
private int usedSize;//1. 记录有效元素个数
//2. 存储数据元素下标
private static final int DEFAULT_CAPACITY = 10;
public MyStack() {
elem = new int[DEFAULT_CAPACITY];
}
//入栈
@Override
public void push(int x) {
if (full()) {
elem = Arrays.copyOf(elem, elem.length * 2);
}
this.elem[usedSize] = x;
usedSize++;
}
//出栈
@Override
public int pop() throws StackEmptyException {
if (empty()) {
throw new StackEmptyException("栈为空!");
}
int old = elem[usedSize - 1];
usedSize--;//相当于是删除
// 如果里面为引用类型
// elem[usedSize] = null;
return old;
}
//获取栈顶元素,只查看不删除
@Override
public int peek() {
if (empty()) {
throw new StackEmptyException("栈为空!");
}
return this.elem[usedSize -1];
}
//栈中有效元素个数
@Override
public int size() {
return this.usedSize;
}
//栈是否为空
@Override
public boolean empty() {
return usedSize == 0;
}
//栈中元素是否满了
@Override
public boolean full() {
return usedSize == elem.length;
}
//遍历打印
//注意:该方法并不是栈中的方法,为了方便看测试结果给出的
public void display() {
for (int i = 0; i < usedSize; i++) {
System.out.print(elem[i] + " ");
}
System.out.println();
}
}
栈为空异常类:
public class StackEmptyException extends RuntimeException{
public StackEmptyException(String message) {
super(message);
}
}
2、用链表实现栈(链式栈)
- 用单向链表实现栈:其中入栈用头插,出栈也是在头部,时间复杂度为O(1)
- 用双向链表实现栈:头尾都可以作为出栈和入栈。时间复杂度为O(1)
【小结】
数组维护(实现)栈
- 栈的底层本质就是数组(动态扩容的数组)
- 出栈和入栈时间复杂度都是为O(1)
链表实现栈 (链式栈):
单链表维护栈
- 需要满足时间复杂度为O(1),所以从头部插入(入栈),从头部删除(出栈)
- 如果从尾部插入和删除,非常的麻烦(插入的时候需要找尾节点,删除的时候还需要找尾结点)时间复杂度都为O(n)
双向链表维护栈
- 从头部插入(入栈),从头部删除(出栈)
- 从尾部插入(入栈),从尾部删除(出栈)
- 不管从哪里入栈和出栈时间复杂度都是O(1)
栈的底层本就是一个数组,可以用数组(顺序表),单链表,双链表模拟实现栈,但是在使用方法的时候必须满足先进后出的形式。
2. 栈的应用场景
1、改变元素的序列
2、将递归转化为循环
比如:逆序打印链表
用递归方法实现逆序打印链表
递归找两个条件:1、终止条件。2、找自己本身
//递归打印链表
public void display3(LinkedList pHead) {
if (pHead == null) {
return;
}
if (head.next == null) {
System.out.println(pHead.val + " ");
}
display3(pHead.next);
System.out.println(pHead.val + " ");
}
递归其实就是栈的形式,每次递归都要在栈上开辟栈帧,用栈模拟递归的形式。
利用栈(链式栈,用链表实现栈)去打印逆序链表
思路: 栈是先进后出的,所以把链表中的节点放进栈中,然后出栈打印,就能逆序打印出链表了
//循环方法 利用栈
public void display4() {
Stack<ListNode> stack = new Stack<>();
ListNode cur = head;
//将链表中的节点保存到栈中
while (cur != null) {
stack.push(cur);
cur = cur.next;
}
//遍历栈
while (!stack.empty()) {
System.out.print(stack.pop() + " ");
}
System.out.println();
}
3、逆波兰表达式求值
给你一个字符串数组 tokens
,表示一个根据 逆波兰表示法 表示的算术表达式。
请你计算该表达式。返回一个表示表达式值的整数。OJ链接
什么是逆波兰表达式:
- 将中缀表达式" 1 + ( ( 2 + 3 ) * 4 ) - 5 "转换为后缀表达式(逆波兰表达式)为:" 1 2 3 + 4 * + 5 - "
- 计算器怎么知道字符( + - * / )的优先级的高低呢(怎么识别括号的),一般在计算器中运算会把中缀表达式转换为后缀表达式,再通过后缀表达式计算这个表达式的值。
- 中缀表达式转换为后缀表达式(逆波兰表达式)步骤,选择题或填空题小技巧
第一步:从左到右,先乘除后加减,都填上括号
第二步:对应的运算符,挪倒右边括号的外面,只挪一次
第三步:把所有的括号都去掉,就得到了后缀表达式(逆波兰表达式)
拿到后缀表达式了,怎么去计算这个后缀表达式结果呢?
思路:
- 遍历后缀表达式,把后缀表达式中的元素依次放进栈中,如果遇到了运算符,则弹出栈顶的两个元素,
- 第一个元素作为运算符的右操作数,第二个元素作为运算符的左操作数,进行计算,只要遇到运算符就弹出栈顶两个元素进行运算,运算后的结果再次入栈,
- 然后接着让后缀表达式中的元素入栈,直到后缀表达式的元素全部计算完。
遍历后缀表达式,只要是数字就入栈,是字符就弹出栈顶的两个元素,参与运算,最后把运算之后的结果,再次入栈。
这里给了字符串数组,避免二位数的数字被拆分开。
public int evalRPN(String[] tokens) {
//创建一个栈
Stack<Integer> stack = new Stack<>();
//遍历后缀表达式
for (String x : tokens) {
//如果是操作数,放到栈中
if (!isOperation(x)) {
//这里将String类类型拆行为int基本类型并压栈
//压栈时自动装箱
stack.push(Integer.parseInt(x));
} else {
//如果是运算符则需要弹出栈顶两个元素,作为操作数进行运算
//第一个为右边操作数,第二个左边操作数
int num2 = stack.pop();
int num1 = stack.pop();
/*if (x.equals("+")) {
stack.push(num1 + num2);
}
if (x.equals("-")) {
stack.push(num1 - num2);
}
if (x.equals("*")) {
stack.push(num1 * num2);
}
if (x.equals("/")) {
stack.push(num1 / num2);
}*/
//这里用switch语句更简洁,如果用if语句形式太多太复杂
//判断是哪种运算符,并进行运行
switch (x) {
case "+":
stack.push(num1 + num2);
break;
case "-":
stack.push(num1 - num2);
break;
case "*":
stack.push(num1 * num2);
break;
case "/":
stack.push(num1 / num2);
break;
}
}
}
//走完for循环,栈内就剩一个元素了 即计算后的结果,直接返回
return stack.pop();
}
//判断是否是运算符
private boolean isOperation(String s) {
if (s.equals("+") || s.equals("-") || s.equals("*") || s.equals("/")) {
return true;
} else {
return false;
}
}
4、括号匹配
给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。OJ链接
有效字符串需满足:左括号必须用相同类型的右括号闭合。左括号必须以正确的顺序闭合。每个右括号都有一个对应的相同类型的左括号。
分为几种情况:
- 匹配。字符串遍历完成且栈为空。
- 不匹配。
- 一部分匹配:
- 左括号多:字符串遍历完成,栈不空
- 右括号多:字符串没有遍历完,栈空了
思路:
- 遍历字符串,让左括号入栈,遇到右括号从栈中弹出元素,
- 看该元素是否是与右括号匹配的左括号(如果是有效的字符串,则一定是第一个右括号与最后一个左括号先匹配),
- 字符串遍历完了同时栈也空了,说明匹配的。
//括号匹配
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
//1.遍历字符串
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
//2.判断是否是左括号
if (ch == '(' || ch == '{' || ch == '[') {
stack.push(ch);
} else {
//3.遇到了右括号
//判断是否是第一次遇见右括号 同时判断了字符串没有遍历完,栈就为空的情况
if (stack.empty()) {
return false;
}
char ch2 = stack.peek();//ch2是左括号,获取栈顶元素不删除 此时ch是右括号
//出栈的左括号是否跟此时右括号匹配
if ((ch2 == '(' && ch == ')') || (ch2 == '{' && ch == '}') || (ch2 == '[' && ch == ']')) {
stack.pop();
} else {
return false;
}
}
}
if(!stack.empty()) {
return false;
}
return true;
}
5、出栈入栈次序匹配
输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否可能为该栈的弹出顺序。假设压入栈的所有数字均不相等。OJ链接
例如序列1,2,3,4,5是某栈的压入顺序,序列4,5,3,2,1是该压栈序列对应的一个弹出序列,但4,3,5,1,2就不可能是该压栈序列的弹出序列。
思路:
- 遍历第一个序列进行压栈,每次压栈后栈顶的元素与第二个序列中的元素进行匹配,
- 如果一样,第二个序列 j 往后走继续比较,如果不同,第一个序列继续压栈然后再比较,直到第一个序列遍历完,
- 如果栈中没有元素,则出栈入栈次序匹配,如果栈中还有元素没有匹配,则出栈入栈次序不匹配。
//出栈入栈次序匹配
public boolean IsPopOrder (int[] pushV, int[] popV) {
Stack<Integer> stack = new Stack<>();
int j = 0;
//遍历第一个序列并压栈
for (int i = 0; i < popV.length; i++) {
stack.push(pushV[i]);
//判断栈顶元素是否与第二序列j位置的元素是否相同
while (!stack.empty() && j < popV.length && stack.peek() == popV[j]) {
stack.pop();
j++;
}
}
//栈中是否还有元素
return stack.empty();
/*if (!stack.empty()) {
return false;
}
return true;*/
}
- 这里注意stack.peek() == popV[j] ,我们需要对stack是否为空和popV[j]是否越界进行判断。
- 有可能栈空了,j 还没走完
- 有可能 j 走完了,栈还没为空
- 还有一点:stack.peek() == popV[j] 比较的时候尽量用 equals 进行比较。
- Integer的取值范围是 [-128,127]之间,如果弹出的元素在这个范围之内用 == 比较没问题,如果超过了这个范围 这个语句表达的结果就是false
6、最小栈
设计一个支持 push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈。即时间复杂度为O(1) OJ链接
实现 MinStack 类:
MinStack() 初始化堆栈对象。
void push(int val) 将元素val推入堆栈。
void pop() 删除堆栈顶部的元素。
int top() 获取堆栈顶部的元素。
int getMin() 获取堆栈中的最小元素。
pop
、top
和getMin
操作总是在 非空栈 上调用。
问题分析:
- 如果只有一个栈,把元素全部压进去,而最小的元素在栈底,那我们必须把栈底上面的元素都出栈,才能得到这个最小的元素,
- 如果有n个元素,那么检索到最小元素就不是常数时间了(即要在O(1)的时间内拿到最小值)。且在出栈的过程中最小值是在变化的。
思路:
- 所以必须有两个栈,一个普通栈Stack,一个最小栈minStack。 在第一次往Stack中压栈的时候,无论放值多大或多小,我们都要维护这个值,把它压入minStack栈中,
- 再次往Stack中压栈时,压栈的元素要与minStack栈顶元素比较,如果小于或等于minStack栈顶元素,我们也要维护这个元素,在Stack中压栈的同时也要把该元素压入minStack栈中,如果大于只在Stack中压栈,
- 当压栈序列遍历完后,最小栈minStack栈顶元素就是这个压栈序列的最小元素了,能在常数时间内检索到最小元素的栈。
- 出任何元素的时候如果这个元素在最小栈minStack中有维护的话,minStack栈中也需要出栈。且在出栈的过程中最小值是在变化的。
应题目要求,pop,top和getMin 操作总是在 非空栈 上调用,所以这些方法不用再判断栈是否为空。
import java.util.Stack;
class MinStack {
public Stack<Integer> stack;
public Stack<Integer> minStack;
public MinStack() {
stack = new Stack<>();
minStack = new Stack<>();
}
//入栈
public void push(int val) {
stack.push(val);
if (minStack.empty()) {
minStack.push(val);
return;
}
int peekVal = minStack.peek();
//-1 < 3
if (val <= peekVal) {
minStack.push(val);
}
}
//出栈
public void pop() {
int val = stack.pop();
if (val == minStack.peek()) {
minStack.pop();
}
}
// peek 获得当前普通栈的栈顶元素,不删除
public int top() {
return stack.peek();
}
//最小栈的peek,每次通过这个方法获取 最小值
public int getMin() {
return minStack.peek();
}
}
当写的代码出错了,一定记得调试:
- 看哪个测试用例错了
- 拿着这个测试用例去调试画图
- 一定记住不能光看代码!!!看代码是看不出来的
- 如果通过肉眼就能看到代码的问题那为什么我们要有编译器呢?直接记事本写代码不就好了。
【概念区分】栈、虚拟机栈、栈帧有什么区别呢?
- 栈是一种先进后出的数据结构;
- 虚拟机栈是JVM划分的一块内存;
- 栈帧:运行程序调用方法时会在虚拟机当中,给这个方法开辟一块内存(空间),开辟的空间就叫作栈帧。
每个函数(方法)在运行时,jvm都会创建一个栈帧,然后将栈帧压入到虚拟机栈中,当函数调用结束时,该函数对应的栈帧会从虚拟机栈中出栈。
注意:每个方法在栈帧中结构都是一样,大小可能不同,栈帧的大小在程序编译时已经确定好的。
3. 队列(Queue)
3.1 概念
队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出FIFO(FirstIn First Out)
- 入队列:进行插入操作的一端称为队尾(Tail/Rear)
- 出队列:进行删除操作的一端称为队头 (Head/Front)
3.2 队列的使用(原生方法)
在Java中,Queue是个接口,底层是通过链表实现的。
Queue接口里有两组方法,即add,remove,element与offer,poll,peek是对应的,但它们是有区别的,下面会讲。常用的为后者。size和isEmpty方法是继承父类的。
方法 | 功能 |
boolean offer(E e) | 入队列 |
E poll() | 出队列 |
peek() | 获取队头元素(查看元素,不删除) |
int size() | 获取队列中有效元素个数 |
boolean isEmpty() | 检测队列是否为空 |
注意:Queue是个接口,在实例化时必须实例化LinkedList的对象,因为LinkedList实现了Queue接口。
public static void main(String[] args) {
Queue<Integer> q = new LinkedList<>();
q.offer(1);
q.offer(2);
q.offer(3);
q.offer(4);
q.offer(5); // 从队尾入队列
System.out.println(q.size());
System.out.println(q.peek()); // 获取队头元素
q.poll();
System.out.println(q.poll()); // 从队头出队列,并将删除的元素返回
if(q.isEmpty()){
System.out.println("队列空");
}else{
System.out.println(q.size());
}
}
//执行结果
5
1
2
3
3.3 队列的模拟实现
队列中既然可以存储元素,那底层肯定要有能够保存元素的空间,通过前面线性表的学习了解到常见的空间类型有两种:顺序结构 和 链式结构。同学们思考下:队列的实现使用顺序结构还是链式结构好?链式实现(使用较多),数组(顺序表)实现。
队列的底层本就是一个双向链表,可以用数组(顺序表),单链表,双链表模拟实现队列,但是在使用方法的时候必须满足先进先出的形式。
1、用链式实现队列(链式队列)
单链表实现队列:
- 入:头插法 -> O(1) 出:删除链表最后一个(需要找尾巴) O(n)
- 入:尾插法 -> O(N)(需要找尾巴) 出:删除链表第一个 O(n)
- 需要满足时间复杂度为O(1),队列的特性。
双链表实现队列(用的最多)
- 入:头插法 -> O(1) 出:删除链表最后一个 O(1)
- 入:尾插法 -> O(1) 出:删除链表第一个 O(1)
单链表实现队列哪种形式 时间复杂度都是O(n),有局限性;而双链表实现队列哪种形式 时间复杂度都是O(1),而且队列的底层本来就是双链表实现的,所以使用双链表实现较多。
双向链表模拟实现队列:
//双向链表模拟实现队列
public class MyLinkedListQueue {
//节点,内部类
public static class ListNode {
public int val;
public ListNode prev;
public ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public ListNode head;
public ListNode last;
public int usedSize;
//入队列,尾插
public boolean offer(int val) {
ListNode node = new ListNode(val);
if (head == null) {
head = node;
last = node;
} else {
last.prev.next = node;
node.prev = last;
last = node;
}
usedSize++;
return true;
}
//出队列,头删
public int poll() throws QueueEmptyException {
if (head == null) {
throw new QueueEmptyException("队列为空!");
}
int retVal = head.val;
if (head.next == null) {
head = null;
last = null;
} else {
head = head.next;
head.prev = null;
}
usedSize--;
return retVal;
}
//获取队头元素,不删除
public int peek() throws QueueEmptyException {
if (head == null) {
throw new QueueEmptyException("队列为空!");
}
return head.val;
}
public int size() {
return usedSize;
}
public boolean empty() {
return head == null;
}
}
队列为空的自定义异常类
public class QueueEmptyException extends RuntimeException {
public QueueEmptyException(String message) {
super(message);
}
}
单向链表模拟实现队列:
- 这里我们为了更方便模拟实现,在单链表中定义了head指向头节点,last 指向尾节点,usedSize记录节点个数。
- 需要满足时间复杂度为O(1),队列的特性。只能从尾入,从头删,时间复杂度就都是O(1)了;如果从头进,从尾删,时间复杂度仍为O(n),因为尾结点删除你找不到上一个节点是谁了(单向的)。
- 如果是双向链表实现就没有局限,所以双向链表才是最通用的链表。
//单向链表模拟实现队列
public class MySingleListQueue {
//节点,内部类
public static class ListNode {
public int val;
public ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public ListNode head;
public ListNode last;
public int usedSize;
//入队列,尾插
public boolean offer(int val) {
ListNode node = new ListNode(val);
if (head == null) {
head = node;
last = node;
} else {
last.next = node;
last = node;
}
usedSize++;
return true;
}
//出队列,头删
public int poll() throws QueueEmptyException {
if (head == null) {
throw new QueueEmptyException("队列为空!");
}
int retVal = head.val;
if (head.next == null) {
head = null;
last = null;
} else {
head = head.next;
}
usedSize--;
return retVal;
}
//获取队头元素,不删除
public int peek() throws QueueEmptyException {
if (head == null) {
throw new QueueEmptyException("队列为空!");
}
return head.val;
}
public int size() {
return usedSize;
}
public boolean empty() {
return head == null;
}
}
2、用数组实现队列(顺序队列)
数组(顺序表)实现队列会出现下面情况,从reat位置入(队尾),从front位置出(队头),如果数组后面已经满了,而前面因为删除了元素空出了,怎么才能利用起来前面的空间呢,
rear是当前可以存放数据元素下标。
如果说让元素整体往前挪,也需要消耗时间;我们能不能让reat再回去,所以我们引出了环形队列(循环队列)。
下面的循环队列,就是用数组(顺序表)模拟实现的队列。
3.4 循环队列
实际中我们有时还会使用一种队列叫循环队列。如操作系统课程讲解生产者消费者模型时可以就会使用循环队列。环形队列通常使用数组实现。
rear是当前可以存放数据元素的下标,与数组模拟实现栈中useSize异曲同工之妙。
rear可能放元素放满了,与front相遇;或者front出元素数组为空了,与rear相遇。
问题1、rear和front相遇了,如何区分空与满
解决空还是满,有很多种方案:
- 使用usedSize进行记录
- 浪费一个空间来表示满
- 使用标记
这里我们主要讲解第二种方案:
例如:这里要放89我们可以先让rear判断下一个是否是front,如果下一个是front证明满了不能放了。否则没满。即每次放元素之先rear判断一下。
- 如果front与rear相遇,就认为是空的。
- 如果reat下一个是front,就认为是满的。
问题2、rear怎么从7下标,来到0下标
- rear从7下标到0下标,同时兼顾1下标到2下标这种普通情况。
- rear = (rear + 1) % len
- front = (front + 1) % len
设计一个循环队列。OJ链接
class MyCircularQueue {
public int[] elem;
//public int usedSize;
public int front;//表示队头
public int rear;//表示队尾
public MyCircularQueue(int k) {
elem = new int[k + 1];
}
//入队列,数组尾入
public boolean enQueue(int value) {
if (isFull()) {
return false;
}
elem[rear] = value;
rear = (rear + 1) % elem.length;
return true;
}
//出队列,数组头出
public boolean deQueue() {
if (isEmpty()) {
return false;
}
front = (front + 1) % elem.length;
return true;
}
//从队首获取元素
public int Front() {
if (isEmpty()) {
return -1;//或抛出自定义异常
}
return elem[front];
}
//获取队尾元素
public int Rear() {
if (isEmpty()) {
return -1;//或抛出自定义异常
}
int index = (rear == 0) ? elem.length - 1 : rear - 1;
return elem[index];
}
//检查循环队列是否为空
public boolean isEmpty() {
return front == rear;
}
//检查循环队列是否已满
public boolean isFull() {
return (rear + 1) % elem.length == front;
}
}
这里有两点要注意的:
- rear是当前可以存放数据元素的下标,获取队尾元素时会有一种极端情况,如果此时rear是0下标位置,找队尾下标就是下标为7的位置,不能直接通过rear-1 来获得队尾下标。
- 传入参数k空间容量大小,但是我们是通过浪费一个空间来表示满的情况,需要数组多申请一个,即new k+1个空间的数组。或者定义usedSize,通过数据的个数来表示front与rear相遇时是满的还是空的情况,就不存在浪费空间的说法了。
4. 双端队列 (Deque)
双端队列(deque)是指允许两端都可以进行入队和出队操作的队列,deque 是 “double ended queue” 的简称。那就说明元素可以从队头 出队和入队,也可以从队尾 出队和入队。
Deque是一个接口,使用时必须创建LinkedList的对象。底层是双向链表实现的。
我们发现,Deque中包含了很多方法:
- Deque底层有 offer(),poll(),peek() 和 add(),remove(),element()
- Queue底层也有 offer(),poll(),peek() 和 add(),remove(),element()
- 它们属于两组方法,功能互相对应。
- 区别:底层代码注解为,add 插不了元素会抛异常,对于offer 来说不会抛异常,认为offer 方法优于add
在实际工程中,使用Deque接口是比较多的,栈和队列均可以使用该接口:
Deque<Integer> stack = new ArrayDeque<>();//双端队列的线性实现 Deque<Integer> queue/deque = new LinkedList<>();//双端队列的链式实现
- 不仅可以当做队列,也可以当做栈。
- 双端队列线性实现 -》 常用当做栈使用,因为栈底层本身就是数组。
- 双端队列链式实现 -》 常用当做队列或双端队列使用,因为队列或双端队列底层本身就是链表(双向链表)
【面试题】
他实现了我: 必须是他类中的方法,遵循我的形式,来模拟实现我的形式。
1、用队列模拟实现栈 (普通队列和普通栈)
请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push
、top
、pop
和 empty
)。OJ链接
问题分析:
- 队列是先进先出的,栈是先进后出的,这两个矛盾的,所以要用队列实现栈,一个队列是无法实现栈的。需要两个队列实现栈。
- 用队列实现栈 ,那拿什么实现队列呢,用Linkedlist(双向链表)实现队列。
- 为什么不用ArrayDeque (数组)实现链表呢,这就把问题拉回到了用数组和链表区别的问题了。因为数组实现队列有一个缺点就是扩容问题,可能会浪费空间;用Linkedlist实现队列比较多。
- 用链表也可以实现栈,用数组也可以实现栈,但是用数组实现的比较多,用链表实现栈每次需要维护它的指向,所以具体使用看场景。
思路:
- 一个队列是无法实现栈的,需要两个队列实现栈。实例化两个队列,一个为qu1,一个为qu2。
- ”入栈“操作让元素进入非空的队列,如果非空的队列为qu1 ,则让元素入队列(qu1),就是用队列实现了"入栈"的操作,此时”出栈“操作 则是让qu1中的size-1个元素出队列,然后依次人队列(qu2)中,此时qu1中还剩一个元素,出队列,就是用队列实现了"出栈"的操作;
- 如果非空的队列为qu2,则让元素入队列(qu2),就是用队列实现了"入栈"的操作,此时”出栈“操作 则是让qu2中的size-1个元素出队列,然后依次人队列(qu1)中,此时qu2中还剩一个元素,出队列,就是用队列实现了"出栈"的操作;
- 如果两个队列都是空的话,则让元素入队列(qu1),就是用队列实现了"入栈"的操作。
- ”入栈“:元素放到不为空的队列中 , 如果两个队列都为空那么就放到第一个队列当中。
- ”出栈“:元素不为空的队列,出size-1个元素到另一个队列当中
- 当两个队列都为空的时候,那么说明模拟的栈是空的
class MyStack {
private Queue<Integer> qu1;
private Queue<Integer> qu2;
public MyStack() {
qu1 = new LinkedList<>();
qu2 = new LinkedList<>();
}
//队列中模拟实现栈的入栈操作
public void push(int x) {
//哪个队列不为空入哪个队列
if (!qu1.isEmpty()) {
qu1.offer(x);
} else if (!qu2.isEmpty()){
qu2.offer(x);
} else {
//两个队列都为空的 指定存放到第一个队列当中
qu1.offer(x);
}
}
//队列中模拟实现栈的出栈操作
public int pop() {
//两个队列都为空
if (empty()) {
return -1;//或者抛出自定义异常
}
//判断哪个队列不为空
//出size - 1个元素到另一队列中
if (!qu1.isEmpty()) {
int size = qu1.size();
//进来后,que1队列出size - 1个元素到qu2中
for (int i = 0; i < size - 1; i++) {
//for (int i = 0; i < qu1.size()-1; i++) {
//这里的for循环不能这样写,因为qu1中的size一直在变,
//每次qu1中元素出栈后底层的size就会减减,会导致循环次数减少
int x = qu1.poll();
qu2.offer(x);
}
return qu1.poll();
} else {
int size = qu2.size();
//进来后,que2队列出size - 1个元素到qu1中
for (int i = 0; i < size - 1; i++) {
int x = qu2.poll();
qu1.offer(x);
}
return qu2.poll();
}
}
//队列中模拟实现栈的获得栈顶元素操作
public int top() {
//两个队列都为空
if (empty()) {
return -1;//或者抛出自定义异常
}
//判断哪个队列不为空
//出size个元素到另一队列中
if (!qu1.isEmpty()) {
int size = qu1.size();
int x = -1;
//进来后,que1队列出size个元素到qu2中
for (int i = 0; i < size; i++) {
x = qu1.poll();
qu2.offer(x);
}
return x;
} else {
int size = qu2.size();
int x = -1;
//进来后,que2队列出size个元素到qu1中
for (int i = 0; i < size; i++) {
x = qu2.poll();
qu1.offer(x);
}
return x;
}
}
//队列中模拟实现栈的是否为空操作
public boolean empty() {
//两个队列同时为空时,说明模拟实现的栈是空的
return qu1.isEmpty() && qu2.isEmpty();
}
}
这里代码要注意两点:
- 队列中模拟实现栈的出栈操作和获得栈顶元素时,for遍历一个队列size - 1个元素出队列到另一队列时,size() - 1 不能作为for循环的判断条件。因为每次元素从该队列出去,size()在不断减少(变化),会导致循环次数减少。定义一个变量记录一下size()的值。
- 队列中模拟实现栈的获得栈顶元素操作时,我们需要一个队列中的size个元素全部到另一个队列中,定义一个变量,每次出队列到另一个队列的元素都记录一下,最后一次记录的就是需要的元素。
2、用栈模拟实现队列(普通栈和普通队列)
请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push
、pop
、peek
、empty
) OJ链接
思路:
- 一个栈是无法实现队列的,需要两个栈实现队列。实例化两个栈,一个是s1,一个是s2。
- “入队列”操作让元素进入s1栈中,就是用栈实现了“入队列”操作。
- “出队列”操作让 s1栈中的元素 全部(size()个)依次出栈,然后进入s2栈中,此时s2栈中的元素顺序与之前s1栈中的元素顺序是相反的,然后出s2栈 栈顶的元素,就用栈实现了“出队列”的操作。
- “入队”:入到s1中
- “出队”:s2栈为空,s1中的元素 , 一个一个全部倒放到第二个队列当中。
//用栈模拟实现队列(普通栈和普通队列)
class MyQueue {
private Deque<Integer> s1;
private Deque<Integer> s2;
public MyQueue() {
s1 = new ArrayDeque<>();
s2 = new ArrayDeque<>();
}
//栈中模拟实现队列的入队列操作
public void push(int x) {
s1.push(x);
}
//栈中模拟实现队列的出队列操作
public int pop() {
if (empty()) {
return -1;//或抛出自定义异常,但力扣上认为-1是正确的
}
if (s2.isEmpty()) {
int size = s1.size();
for (int i = 0; i < size; i++) {
int x = s1.pop();
s2.push(x);
}
}
return s2.pop();
}
//栈中模拟实现队列的获得队头元素操作
public int peek() {
if (empty()) {
return -1;//或抛出自定义异常,但力扣上认为-1是正确的
}
if (s2.isEmpty()) {
int size = s1.size();
for (int i = 0; i < size; i++) {
int x = s1.pop();
s2.push(x);
}
}
return s2.peek();
}
//栈中模拟实现队列的是否为空操作
public boolean empty() {
return s1.isEmpty() && s2.isEmpty();
}
}
这里同样也要注意:栈中模拟实现队列的出栈操作和获得队头元素时,for遍历一个栈size个元素出栈到另一栈时,size()不能作为for循环的判断条件。因为每次元素从该栈出去,size()在不断减少(变化),会导致循环次数减少。定义一个变量记录一下size()的值。
【总结】
分清楚一点
栈(Stack)是普通类 先进后出
- 栈可以实例化,可以被数组(顺序表)、单链表、双链表实现;栈继承了Vector类,而Vector类底层就是一个数组在存储数据,所以栈的底层其实就是数组(顺序表)。
- 通过数组(顺序表)、单链表、双链表实现的栈调用方法必须遵先进后出的形式。 拿数组(顺序表)实现的栈最常用。
- 用Stack 去引用自己的对象其实不常用。Stack<Integer> stack = new Stack<>(); 不常用
- 用ArrayDeque双端队列的线性实现,来代替实现栈。Deque<Integer> stack = new ArrayDeque<>(); 常用
- ArrayDeque类 底层也是数组(顺序表结构)
队列(Queue)是一个接口 先进先出
- 队列不能实例化,但可以被数组(顺序表)、单链表、双链表实现;队列的底层是被LinkedList类实现的,LinkedList底层又是双向链表,所以队列的底层其实就是双向链表。
- 通过数组(顺序表)、单链表、双链表实现的队列调用方法必须遵循先进先出的形式。
- 拿LinkedList(双向链表)来实现队列最常用。Queue<Integer> queue = new LinkedList<>();
双端队列(Deque)是一个接口 双端进出都可以
- 双端队列不能实例化,但可以被数组(顺序表)、单链表、双链表实现;双端队列的底层是被LinkedList类实现的,LinkedList底层又是双向链表,所以双端队列的底层其实就是双向链表。
- 通过数组(顺序表)、单链表、双链表实现的队列调用方法必须遵循的双端进出都可以形式。
- 拿LinkedList(双向链表)来实现双端队列最常用。Deque<Integer> deque/queue = new LinkedList<>();
拿接口去引用对象 与 拿对象本身来引用自己对象有什么区别?
- 区别:拿对象本身来引用自己对象包含的方法多,能调用的方法多。
- 一般拿接口去实现具体类使用较多,因为可读性比较高,你知道要调用的是谁的方法;而如果拿实体类自己去实体化对象,不知道以什么方式去用的,方法过多
好啦Y(^o^)Y,本节内容到此就结束了。下一篇内容一定会火速更新!!!
后续还会持续更新数据结构与算法方面的内容,还请大家多多关注本up,第一时间获取新鲜的知识。
如果觉得文章不错,别忘了一键三连哟!