前言
本节分享数据结构中的基础数据结构栈和队列。他们都是操作受限的线性表数据结构
栈
什么是栈?栈的最大特点就是先进后出(LIFO),对于栈中的数据来说,所有的操作都是在栈顶完成的,举个例子:叠盘子,盘子都是一个叠在一个上面,我们想要拿盘子,也只能从最上面的一个一个拿起来!
栈实现:我们可以利用数组来实现栈结构,也可以利用一个单链表实现栈结构,用数组实现的栈结构叫做:顺序栈,用单链表实现的栈结构叫做:链式栈。因为我们只能操作栈顶元素,所以不管是链式栈还是顺序栈,入栈、出栈、只涉及栈顶的数据操作,时间复杂度都是O(1)。
基于数组实现:
/**
* 基于数组实现栈结构,入栈出栈时间复杂度都是O(1)
* **注意**:这里的空间复杂度并不是O(n),因为空间复杂度指的是除了原本数据存储空间外,
* 算法运行还需要的额外存储空间。所以这里的数组长度size,并不能说明空间复杂度就是O(n)。
* 因为存储空间只需要额外的变量来存储出栈、入栈的值,所以空间复杂度O(1)
*/
public class ArrayStack {
private String[] values;//数组
private int count;//定义栈中元素的个数
private int size;//栈的大小
//初始化数组,申请一个大小为size的数组空间
public ArrayStack(int size){
this.values=new String[size];
this.size=size;
this.count=0;
}
//入栈操作
public boolean push(String value){
if(count == size){
return false;//栈满了,入栈失败
}
//将元素直接放到当前的count位置
values[count]=value;
++count;
return true;
}
//出栈操作
public String pop(){
if(count == 0){
return null;//栈为空
}
//返回下标是count-1的元素,因为数组排序从0开始排的,
// 比如三个数据对应的索引是0、1、2,而不是1、2、3
String value=values[count-1];
//元素个数减少一
--count;
return value;
}
}
这里有一点我们需要注意,上面的代码中我们定义的栈依赖的数组长度是固定的,但是如果我们如果我们定义的栈依赖的数组的长度是支持动态扩展的呢?比如数组长度是4,当数组满了后,支持动态扩展,数组扩展长度为8,就需要将原来的数据复制到新的数组中,这个时候的入栈时间复杂就是O(n),因为出栈不涉及数组扩容,所以时间复杂度还是O(1)。
基于链表实现:
/**
* 基于链表实现栈结构
*/
public class LinkedStack {
//创建栈顶指针
Node top=null;
//入栈操作
public void push(String value){
Node newNode=new Node(value,null);
if(top == null){
top=newNode;//栈为空的时候
}else {
//将新节点放到栈顶,通过改变新节点的指针指向top节点,
//top节点指向新节点
newNode.next=top;
top=newNode;
}
}
//出栈操作
public String pop(){
if(top == null){
return null;//栈空返回null
}else {
//获取栈顶元素的值,通知将top指针指向top的next节点
String value=top.getData();
top=top.next;
return value;
}
}
//输出栈中的所有元素
public void printAll(){
Node node=top;
while (node != null){
System.out.println(node.getData());
node=node.next;
}
}
//定义单链表结构
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;
}
}
}
应用场景:当某个业务满足数据在一端插入和删除。并且满足后进先出、先进后出的特性,我们就应该首先选择栈这种结构。
习题练习:
题目描述:leetCode第20题:给定一个只包括'(', ')', '{', '}', '[', ']'的字符串,判断字符串是否有效?有效字符串必须满足:1.左括号必须拥有相同的右括号闭合。左括号必须以正确的顺序闭合
例如:
示例1:
输入:“()”
输出:true
示例2:
输入:" (] "
输出:false
代码展示:
示例3
输入:" (])] "
输出:false
代码展示:
public class Solution {
//预先将左括号作为key,右括号作为value存储在map集合中
private static final Map<Character,Character> map=new HashMap<Character,Character>(){
{
put('{','}'); put('[',']'); put('(',')'); put('?','?');
}
};
public static void main(String[] args) {
//结果打印true
System.out.println(isValid("{([]{()}[])}"));
//结果打印false
System.out.println(isValid("{("));
//结果打印false
System.out.println(isValid("{(]}"));
}
public static boolean isValid(String str){
//如果存在首字符不在map中直接返回false
if(str.length()>0 && !map.containsKey(str.charAt(0))){
return false;
}
//用我们定义的链式栈,不过这里我把栈底层的Node节点的data改为了Character方便测试
LinkedStack linkedStack=new LinkedStack();
for(Character c:str.toCharArray()){
if(map.containsKey(c)){
//如果是左括号就入栈
linkedStack.push(c);
} else {
//如果遇见右括号,就出栈,通过去map中获取对应的右括号,
//如果获取的右括号不等于c,就说明不符合.
if((map.get(linkedStack.pop()) != c)){
return false;
}
}
}
//如果遍历完栈中元素个数不为0,则说明字符无效可能都是左括号
return linkedStack.size() == 0;
}
}
队列
顺序队列和链式队列
队列特点:先进先出,基本操作:入队:放一个数据到队尾,出队:从队列头取出一个数据,可以理解为一个管道,管道中的水就是数据,那么就是管道的一段进水(入队),另一端出水(出队)。
队列实现:同样也有顺序队列和链式队列,顺序队列基于数组,链式队列基于链表,相比于栈结构,只需要一个栈顶指针,然后队列结构需要两个指针,一个是head指针,指向对头,一个是队尾tail,指针指向队尾,顺序队列。代码如下:
public class ArrayQueue {
//定义数组arr和数组长度
private String[] arr;
private int size;
//定义头指针和尾指针
private int head=0;
private int tail=0;
//初始化数组大小
public ArrayQueue(int capacity){
this.arr=new String[capacity];
this.size=capacity;
}
//入队
public boolean enQueue(String value){
//判断队列是否已满
if(tail == size){
return false;
}
arr[tail]=value;
++tail;
return true;
}
//出队
public String deQueue(){
//对列空
if(head == tail){
return null;
}
String value =arr[head];
++head;
return value;
}
}
以上代码是基于数组队列的基本实现,但是有个小问题,就是队列的入队和出队执行次数到一定数量的时候,会出现当指针tail移动到队尾后,队列并没有全满的情况下,不能继续执行入队操作了,如图:
我们执行几次入队操作后的图
我们执行几次出队和入队的操作后
此时,tail指针已经到达队尾了,但是队列不为空,却不可以继续执行入队操作了,所以当tail指针移动到队尾的时候,我们需要数据迁搬迁,为了保证性能,我们不需要每次入队都进行数据迁搬迁,只需要当数据无法执行入队操作的时候执行一次集中的数据迁搬迁操作,所以上述代码中,enQueu()方法可修改为:
//入队 public boolean enQueue(String value){ //tail == size意味tail已经到了队尾 if(tail == size){ //判断当tail == size && head == 0说明队列满了 if(head == 0){ return false; } //数据迁移 for(int i=head;i<tail;i++){ arr[i-head] = arr[head]; } //充值head和tail指针 tail=tail-head; head=0; } arr[tail]=value; ++tail; return true; }
基于链表的队列同样需要两个指针,head和tail一个直指向头节点,一个指向尾节点,链式队列代码如下:
图示:
代码如下:
public class LinkQueue {
//定义基础链表节点结构
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 enQueue(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.getData();
head=head.next;
if(head == null){
tail=null;
}
return value;
}
}
循环队列
循环队列就像是一个环,原本的数组是有头有尾的,像是一条直线,我们把它的收尾相连就成了一个环形的循环队列,循环队列如下图:
循环队列的好处是避免了数据迁搬迁,实现循环队列需要注意确定好判断队列空和队列满的条件!
循环队列判断空的条件仍然是tail==head,判断队列满的情况如图:
通过规律可以发现,判断满的条件是(tail+1)%n=head,公式推导:(在一般情况下,我们可以看出来,当队列满时,tail+1=head。但是,有个特殊情况,就是tail=size -1而head=0时,这时候,tail+1=size ,而head=0,所以用(tail+1)%size == size%size == 0。而且,tail+1最大的情况就是 size ,不会大于 size ,这样,tail+1 除了最大情况,不然怎么余取余size都是 tail+1 本身,也就是 head。这样,表达式就出现了。)我们只需要记住就行了,当队列满时,图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间。
基于数组的实现:
/**
* 基于数组实现循环队列
* 判断队列为空tail==head
* 判断队列满head=(tail+1)%size
*/
public class CircularQueue {
private String [] arr;
private int size;
private int head=0;
private int tail=0;
//初始化数组
public CircularQueue(int capacity){
this.arr=new String[capacity];
this.size=capacity;
}
//入队
public boolean enQueue(String value){
if(head == (tail+1)%size){
return false;//队列满了
}
arr[tail]=value;
tail=(tail+1)%size;
return true;
}
//出队
public String deQueue(){
if(head == 0){
return null;
}
String value = arr[head];
head=(head+1)%size;
return value;
}
}
阻塞队列和并发队列
阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队列头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回,例如java concurrent并发包用ArrayBlockingQueue来实现公平锁。
阻塞队列可用于生产者消费者模式,可以通过协调生产者和消费者的个数来提高数据的处理效。如图:
线程安全的队列我们叫并发队列,最简单的就是在enQueue()方法上加synchronized锁,但是锁的粒度比较大并发度会比较低,还可以利用CAS原子操作,可以实现非常高效的并发队列这也是循环队列比链式队列应用更加广泛的原因,java中的ConcurrentLinkedQueue中的offer方法就有用到CAS操作。
总结
本节主要分享了栈和队列的一些概念和基本特性,并且讲解了如何用数组或者链表去实现栈和队列的结构,已经一些扩展概念,对于如果想更好的掌握和运用这些结构,建议多去leetCode刷刷题,这样可以不仅可以锻炼思维逻辑,也能更加好的理解,掌握这些基本数据和使用场景。