相信大家都清楚,栈是先进后出,队列是先进先出。具体该怎么实现这两种数据结构呢?
我们可以基于双向链表,或者数组实现:
基于链表
基于双向链表也可以实现队列:
使用链表的方式,可以实现队列的增强,不仅限于队列头添加节点,队尾弹出节点,在队列的任意节点都可以很方便的实现节点的添加和删除,进需要调整下当前节点的pre和next指针。
栈的实现也是一样,只是栈从头部进,从头部弹出,队列需要从头部进,从尾部弹出。两者底层都可以使用链表来实现。
下面我们简单的来看下基于链表怎么实现:
以队列为例,我们需要一个head和一个tail,同时我们需要一个从头部添加数据,和从尾部弹出数据的方法:
@Data
@Builder
public class MyQueue<T> {
private Node<T> head;
private Node<T> tail;
/**
* 从头部添加数据
* @param value
*/
public void push(T value) {
// 将当前要添加的数据,构建为链表的节点
Node cur = Node.builder().value(value).build();
// 队列没有任何数据,将当前节点设置为头结点
if (this.head == null) {
this.head = cur;
this.tail = cur;
} else {
// 队列已经有数据
// 原头结点变成next节点
cur.setNext(this.head);
// 设置原头结点的前节点,为当前节点
this.head.setPre(cur);
// 则将当前节点置为头节点
this.head = cur;
}
}
/**
* 从队尾弹出数据
* @return
*/
public T pop() {
// 尾结点没有数据,没数据可以弹出
if (this.tail == null) {
return null;
}
// 将原tail的前节点设置为最终的尾结点
Node cur = tail;
tail = tail.getPre();
tail.setNext(null);
return (T) cur.getValue();
}
}
基于数组
假设我们现在基于常规数组来实现队列(或者栈):
以队列为例:
现在有一个大小为5的数组,已经从头部依次放入了数据1,2,3,4。现在我们需要将1进行弹出队列:
此时如果5,6均需要入队列:
当5入列之后,发现6已经没有空间了。此时要么移动数组数据,将2,3,4,5依次在数组中向后移动一位:
要么6不让添加,不过显然这种方式是不可行的。
这样会带来一个问题,如果使用数组作为栈/队列的实现,随着每次的数据弹出,必然需要将已存在的数据进行一次数据迁移,这样做很明显是不理想的。
为此我们可以将数组抽象成一个环形:
定义两个指针,一个是记录插入位置,一个记录弹出位置。当数组还有空间可以插入数据时,插入数据后,插入指针指向到新插入的后一个位置。当有数据弹出之后,弹出位置的指针,指向弹出数据的前一个数据。这样就可以避免数据的频繁挪动了。
数组平铺开来示意图如下:
@Data
@Builder
public class MyQueueArray {
private Object[] arr;
/**
* 已经插入了数据的位置
*/
private int pushIndex;
/**
* 已经弹出了数据的位置
*/
private int popIndex;
/**
* 数组中已经存有数据的总大小
*/
private int size;
/**
* 初始化
*
* @param limit
*/
public MyQueueArray(int limit) {
arr = new Object[limit];
pushIndex = 0;
popIndex = 0;
size = 0;
}
public void push(Object value) {
if (this.size == arr.length) {
throw new RuntimeException("数据已经存满,不能再添加数据");
}
size++;
arr[pushIndex] = value;
// 移动插入指针位置
pushIndex = nextIndex(pushIndex);
}
public Object pop() {
if (size == 0) {
throw new RuntimeException("无数据可以弹出");
}
size--;
Object obj = arr[popIndex];
// 移动弹出指针位置
popIndex = nextIndex(popIndex);
return obj;
}
/**
* 返回下一个位置
*
* @param currentIndex
* @return
*/
private int nextIndex(int currentIndex) {
return currentIndex < arr.length - 1 ? currentIndex + 1 : 0;
}
}
设计栈(队列),push,pop,getMin时间复杂度为O(1)
分析下这道算法题,要求我们对栈的操作,入栈,出栈,以及获取栈中所有数据的最小值,其时间复杂度均为O(1)。
现在push,pop的操作肯定是O(1),要做的就是将getMin的复杂度也能支持O(1)。
如果我们在getMIn的时候,采用遍历栈内所有元素的方式来实现,肯定是不行,时间复杂度达不到O(1)。在这样的情况下,我们需要借助额外空间来存储栈内最小元素的信息。
在我们自定义的栈中,需要有两块存储区域,data部分用来存储当前栈元素信息,min部分,只存储当前栈内最小元素信息。
当7入栈时,data内没有数据,min内也没有数据,直接在data和min中压入
当3入栈时,data内压入3,min中栈顶元素是7,此时3<7,在min中将3压入
当2入栈时,data内压入2,min中此时栈顶元素是3,2<3,在min中将2压入
当5入栈时,data内压入5,min中此时栈顶元素是2,5>2,在min中再次将2压入。
也就是说,min中随着data的入栈操作,同步入栈。但是需要经历一步比较,如果data新入栈的数据,比min的栈顶元素要小,得将该数据直接压入min栈,如果data新入栈的元素比min栈顶元素大,则将min的栈顶元素再次压入min栈。
这样,min的栈顶元素,永远是当前data栈中最小的那条数据。如图所示,data中入栈7,3,2,5。此时min的栈顶元素是2,即data栈中最小元素为2。
ok,那现在还有一个问题,一旦data中有数据出栈了怎么办呢?很简单,min栈随着data同步出栈。
data栈中栈顶元素出栈,min中栈顶元素也出栈
data和min的同步出栈操作,可以保证min的栈顶永远是data中的最小元素。
@Data
@Builder
public class MyQueueArrayTwo {
private MyQueueArray data;
private MyQueueArray min;
public void push(Object value) {
// data栈中始终入栈
data.push(value);
// min中进行比较之后再入栈
Object minObj = min.peek();
if (minObj == null) {
// min栈中还没有元素,直接入栈
min.push(value);
}
// 进行比较
if (value > minObj) {
min.push(value);
} else {
min.push(minObj);
}
}
public Object pop() {
// data 和min 同步出栈
min.pop();
return data.pop();
}
public Object getMin() {
// 直接返回当前min的栈顶元素
return min.peek();
}
}
在此种方案中,min中存储的数据量和data是一致的。还有另一种,min中只存储最小值:
data中7入栈,min中7也入栈
data中3入栈,min中栈顶元素7,由于3<=7 ,所以min中3也入栈
data中2入栈,min中栈顶元素3,由于2<=3,所以min中2也入栈
data中2入栈,min中栈顶元素2,由于2<=2,所以min中2也入栈
data中5入栈,min中栈顶元素2,由于5>2,此时5不再入栈
也就说说,当data中新入栈的数据,小于或者等于 min的栈顶元素时,min中才进行入栈操作。
在此方案下,data中数据出栈时,与前面方案会稍有不同。
data中5出栈,此时min的栈顶是2,说明5并不是data中最小的元素,min中无元素出栈
data中2出栈,此时min的栈顶是2,说明2已结是data中的最小元素,data中最小元素已结出栈,那min中的元素也需要同步出栈
data中2出栈,此时min的栈顶是2,说明2已结是data中的最小元素,data中最小元素已结出栈,那min中的元素也需要同步出栈
也就是,当data中出栈的元素,等于min的栈顶元素时,min栈顶元素才出栈:
在此方案下,min栈中会存储相应较少的数据,节省空间
如何仅使用队列实现栈的功能
如何仅使用队列实现栈的功能呢?
比如数据入列顺序是1,2,3,4,5,6,那数据的出队顺序也是1,2,3,4,5,6
如何让调用端收到的数据顺序是6,5,4,3,2,1呢?
此时我们对外提供的栈MyStack中,底层实现只能是队列。
我们可以在MyStack中定义两个队列,queue1和queue2。
当执行入栈操作时,仅操作queue1:
当调用端将数据1,2,3进行入栈,实际底层是将1,2,3压入队列queue1。此时queue1中有数据1,2,3,其出队列的顺序也是1,2,3。queue2中无数据。
当调用端进行出栈pop操作时,预期弹出数据3,在MySatack的pop方法中:
1. queue1先进行一次出队列操作,数据为1,将数据1入队列queue2,此时queue1还有数据2,3;queue2中有数据1
2. queue1再进行出队列操作,数据为2,将数据2入队列queue2,此时queue1还有数据3,queue2中有数据1,2
3. queue1再进行出队列操作,数据为3,直接返回给客户端。
当调用端进行出栈pop操作时,预期弹出数据2:
此时queue1中已经没有数据,queue2中有数据1,2。
方法和前面类似,只是queue1和queue2的角色进行对换
1. queue2先进行一次出队列操作,数据为1,将数据1入队列queue1,此时queue2还有数据2;queue2中有数据2
2. queue1再进行出队列操作,数据为2,直接返回给客户端。
算法核心:在MyStack内部,不是直接将queue的出队列结果返回给调用端,而是先将queue中前面的所有数据进行出队列,然后将这些数据暂存在一个辅助队列中,queue中仅存的最后一条数据出队列返回给调用端,模拟出栈先进后出的效果。
如何仅使用栈实现队列功能
和仅使用队列实现栈类型,在MyQueue中需要定义两个stack,在push stack出栈时,需要先将数据入栈到pop stack,push stack仅返回最后一次的出栈结果给调用端。
但是在此方案下,需要注意一点:
调用端先将1,2,3push如队列,然后pop出一条数据。按照我们的算法逻辑,此时push stack先开始入栈1,2,3,然后将3,2出栈,再将3,2入栈pop stack,push stack再次将1进行出栈。走完流程之后,push stack里面没有数据了,pop stack里面还有数据3,2。
此时调用端再将4进行push如队列,然后再次调用pop进行出队列。这个过程我们来分析下,如果我们对push stack -> pop stack不加限制等的话,其执行过程将会是这样:
1. 调用端push 4,5
2. push stack 先将4,5入栈,此时push stack中有数据4,5
3. 调用端pop
4. push stack 将5出栈放入pop stack,然后再将4进行出栈返回调用端。此时pop stack中有数据1,2,5。
调用端的执行过程是:push 1,2,3, pop
push 4,5,pop
其第一次pop的返回值应该是1,这个没有问题,而第二次pop的返回值应该是2,而我们返回的是4。原因是我们在pop stack还有数据的情况下,又手动往里面添加了数据,这样其实就打破了前一次数据迁移后的数据顺序。
当调用端第二次pop时,如果我们的pop stack中有数据,应该直接从pop stack中弹出数据返回,依次弹出数据3,2。
当调用端再次pop时,pop stack已经没有数据了,此时又需要将push stack中的5出栈,然后压入pop stack ,重复执行前面的步骤。
所以当我们需要将push stack中的数据压入pop stack时,需要遵循两个原则:
- 只有pop stack里面没有数据的时候,才能进行入栈操作,这样可以保证已经入栈了的数据,顺序不会错乱
- 一旦发生从push stack 入栈到pop stack,一定要将数据全部压入pop stack,这样保证本次的入栈数据,顺序不会错乱。