一、栈
1、特点及应用
先进后出。(如果会和队列先进先出记混的话,就记场景吧:弹栈弹栈,就是把最上面的最新进来的弹出去;而队列就像我们火车站排队检票出站一样,谁排在前面谁就先出去。)
应用的话,其实我们经常接触呀。比如Undo操作(就是撤销操作)就是使用的栈的思想,以及程序调用的系统栈。下面我们举一个经典的括号匹配的例子:
题目要求:
给定一个只包括
'(',')','{','}','[',']'的字符串,判断字符串是否有效。有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
注意空字符串可被认为是有效字符串。
解题思路:第一想到的应该就是用栈来做。如果是 { ( [ 之中的,就直接压入栈,如果有出现了 ) } ] 之间的,就开始匹配。先从栈顶的元素开始匹配,如果不同则直接返回false;如果相同则继续匹配,直到栈里ianshi空的。
源码如下:
import java.util.Stack;
class Solution {
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
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;
char top = stack.pop();
if (c == ')' && top != '(')
return false;
if (c == '}' && top != '{')
return false;
if (c == ']' && top != '[')
return false;
}
}
return stack.isEmpty();
}
}
二、队列
先进先出。
1、循环队列
循环队列是很常见的数据结构,在大学计算机课程上也会遇到,被应用到很多场景,比如管道的实现(固定的容量64K)、MapReduce中数据溢写入磁盘(默认100M,超过80M溢写)、网络库中常见的IO buffer等等。
循环队列需要两个索引front和tail,分别指向队列头和尾(下次要放入的下标),假设队列长度为length:
- 初始,head = tail = 0;
- 入队列,tail = (tail + 1) % length;
- 出队列,head = (head + 1) % length;
- 判断队列满,(tail + 1) % length == head;
判断队列满时需要注意,这种判断会少用队列中的一个元素,相当于队列的可用长度只有length - 1。而且这种循环队列是不能够扩展长度的,如果需要可变长的循环队列,需要加入额外的机制,当队列满时,重新申请空间,将原先队列上的数据拷贝至新队列。
2、可变长队列
既然提到了队列,我也想讲讲关于可变长队列,主要来源于evbuffer的设计,它用于IO的缓冲队列,它不是完全的循环队列,更像是循环队列与移位结构的综合。(IO buffer的作用是只允许select、poll、epoll的阻塞,不允许readn、writen的阻塞)。
off表示有效数据长度,misalign表示orig_buffer到buffer的长度,为无效数据,队列总长度为totallen。当入队列时,假设数据长度为datlen:
- 如果 (misalign + off + datlen) <= totallen,那么数据往off后面放。
off += datlen;
- 如果(misalign + off + datlen) > totallen && misalign >= datlen,那么将有效数据off向左对齐,然后数据往off后面放。
memmove(orig_buffer, buffer, off); //直接调用memmove
buffer = orig_buffer;
misalign = 0;
off += datlen;
- 如果(misalign + off + datlen) > totallen && misalign < datlen,那么需要开辟新内存扩容,在扩容前,需要先将有效数据向左对齐。扩容策略是成倍增长。
memmove(orig_buffer, buffer, off); //直接调用memmove
buffer = orig_buffer;
misalign = 0;
newbuf = realloc(buffer, length)); //开辟另外的内存空间
orig_buffer = buffer = newbuf; //将两个buffer指针更新到新的内存地址
totallen = length; //更新总大
仔细阅读evbuffer的源码,会发现一些可以优化的地方,比如有些拷贝不是必要的,可以适当减少。后来muduo出现了,是某大神用C++写的网络库,借鉴了一些libevent的思想。
muduo的IO buffer与libevent这种纯C写的evbuffer不同,它巧妙的运用了容器vector自动扩容的特点,替代了手动扩容,而且相比evbuffer,它增加了prepend头部,用来记录字节流的长度,方便数据解析,效率得到提高。
虽然开源的网络库有很多,但不一定适用于公司的业务,所以公司都会实现自己的网络库,核心思想不会变,只是对于一些细节的处理会更加优化。
三、栈与队列的异同
1、相同点
(1)都是线性结构。
(1)插入操作都是限定在表尾进行。
(3)都可以通过顺序结构和链式结构实现。、
(4)插入与删除的时间复杂度都是O(1),在空间复杂度上两者也一样。
(5)多链栈和多链队列的管理模式可以相同。
2、异同点
(1)队列先进先出,栈先进后出。
(2)删除数据元素的位置不同,栈的删除操作在表尾进行,队列的删除操作在表头进行。
(3)遍历数据速度不同。栈只能从头部取数据 也就最先放入的需要遍历整个栈最后才能取出来,而且在遍历数据的时候还得为数据开辟临时空间,保持数据在遍历前的一致性。队列不同,他基于地址指针进行遍历,而且可以从头或尾部开始遍历,但不能同时遍历,无需开辟临时空间,因为在遍历的过程中不影数据结构,速度要快的多。(4)应用场景不同。常见栈的应用场景包括括号问题的求解,表达式的转换和求值,函数调用和递归实现,深度优先搜索遍历等;常见的队列的应用场景包括计算机系统中各种资源的管理,消息缓冲器的管理和广度优先搜索遍历等。
(5)顺序栈能够实现多栈空间共享,而顺序队列不能。
四、假溢出
由于队列拥有固定的长度,并且每当元素出队都是从front中移出,并且fornt的脚标向后移,这时我们看到前面的空位置理应可以插入元素,但是当我们执行插入操作的时候却被抛出异常,队列溢出,这种溢出被称为假溢出。
我们可以通过循环队列实现:通过把队列的首尾连接起来,并且在插入元素的时候先通过判断当前尾脚标(tail)的位置,或者在出队的时候判断前脚标(front)的位置是否在(队列的长度-1)上,因为这里从0开始。如果要插入元素,如果前脚标在队列最后面的时候,即队列最大长度-1的位置因为从0开始,把前脚标front赋值为0,这样相当于把前脚标放到队头。如果元素出队的时候判断当前脚标(front)是否在队尾,即最大队列长度-1的位置,如果是,把前脚标对应的元素移出之后,再赋值为队头,即front=0,但是这种方法判断队列是否满就要再定义一个变量来记录当前队列有多少个元素,这样就需要每次进队/出队的时候都要进行自增(++/自减(–)操作。
另外一种大众的方法:取模。元素入队的时候,让尾脚标(tail)+1再跟队列的长度取余,得到的数就是要插入元素将存储的位置。在元素出队的时候,用前脚标(front)+当前队列元素个数再跟队列长度取余。在java的集合框架中,也有关于queue(jdk 1.5更新)跟stack的实现,其中queue是接口,但是stack是类。(即循环队列可以通过(tail+1)%data.length==front来判断队列是否满了)
五、两个栈怎样实现一个队列
第一次看到这个问题的时候,我就把一个栈作为存储栈,另外一个栈作为缓存栈。
当要往里面添加元素的时候,直接添加到存储栈。我之前想过要不要考虑哪个栈有元素,哪个栈没元素,但是后来发现想多了,因为在移出时,先判断缓存栈有没有元素,如果有元素直接弹栈;若缓存栈没有元素,则把存储栈的全部元素放到缓存栈中,然后再弹栈,这时元素弹栈的顺序跟元素进栈一样,这种实现的时间效率是线性的,即O(n)
有些人考虑到优化问题,说把存储栈中的n-1个元素放到缓存栈中,存储栈的最后一个元素可以不用放到缓存栈,可以直接弹出,这样就避免了把存储栈中的最后一个元素先压到缓存栈,然后再弹栈的行为。
其实这种解决方法不妥。虽然说可以减少一次压栈的操作,但是如果要按照这样做,那么每遍历一次,都要判断存储栈是否只剩下一个元素……那还不如直接扔过去算了。
下面是线性时间效率的实现:
import java.util.Stack;
public class Stack2QueueInO1 {
// use s1 to keep the value and s2 to cache
private Stack<Integer> s1 = new Stack<Integer>();
private Stack<Integer> s2 = new Stack<Integer>();
// 插入时直接放到存储栈
public boolean enQueue(Integer integer) {
s1.push(integer);
return true;
}
// 弹栈时,先判断缓存栈是否为空,不为空直接返回,如果为空,先倒入缓存栈再弹栈
public Integer deQueue() {
if (s2.isEmpty()) {
while (!s1.isEmpty()) {
s2.push(s1.pop());
}
}
return s2.pop();
}
// 判断是否为空
public boolean isEmpty() {
return (s1.size() + s2.size()) == 0;
}
}
六、两个队列如何实现一个栈
由于队列是先进先出,在java中的实现最简单的方法就是借用LinkedList类,这个类实现了DeQue接口,而DeQue接口是Double Ended Queue的缩写,双端队列可以在两段进行插入和删除操作,但是同时DeQue接口继承了Queue接口,所以也具有队列的基本实现。要在java中实现,直接用一个LinkedList插入元素,然后返回最后一个元素即可。
如果使用两个队列来实现的话,(图解见下面)首先把一堆元素放到队列1中,比如1,2,3,4,由于队列插入元素在队尾插入,但是移出元素在队头,每次要保证两个队列中有一个队列是空队列,这样就可以把有元素的队列中n-1个元素移动到另外一个队列中,这样,刚刚的队列就剩下最后插入的元素,这时,把最后一个元素出队,这样,刚刚出队元素所在的队列就为空了,就这样反复的找空队列,每次移动n-1个元素,当然这里的n是会变化的。这样可以确保每一次出队的都是后面加进来的元素。
import java.util.LinkedList;
public class Queue2Stack {
private LinkedList<Integer> l1 = new LinkedList<Integer>();
private LinkedList<Integer> l2 = new LinkedList<Integer>();
// 添加元素,直接添加到存储队列
public void push(Integer integer) {
l1.add(integer);
}
// 实现弹栈
public Integer pop() {
// 首先判断两个队列中有没有元素,若无,抛异常
if ((l1.size() + l2.size()) == 0) {
throw new RuntimeException(
"the stack is null that can't pop the element in it");
} else {
// 有元素:找空队列,只要一个为空队列,另外一个就往空队列倒n-1个元素
if (l1.isEmpty()) {// 存储队列为空的情况
while (l2.size() > 1) {// 倒元素
l1.add(l2.poll());
}
return l2.poll();
} else {// 缓存队列为空的情况
while (l1.size() > 1) {// 倒元素
l2.add(l1.poll());
}
return l1.poll();
}
}
}
public boolean isEmpty() {
return (l1.size() + l2.size()) == 0;
}
}
最后,不要为了使用数据结构而是用数据结构,比如说字符串的反转,没有必要使用栈/队列倒入找出,可以使用两边交换的方法。当然如果你要使用LinkedList类的话也可以,直接得到反序元素输出。
七、元素出栈、入栈顺序的合法性。(如入栈的序列(1,2,3,4,5),出栈序列为 (4,5,3,2,1) )
我相信上面的这种思路是绝大数人首先想到的,并且很有可能会在自己脑海保存这个答案,认为这是这个题目的标准答案,因为这种思路对绝大数出栈序列的判断是没有问题的,如果你不做详细的测试,或者说测试用例不够的话的确是这样。
下面我们说下这里面存在的一个坑,当入栈顺序为:1 2 3 4 5 时 3 2 4 1 5 也是一个合法的出序列,你按上面思路走走,判断出来却是不合法。
多理解理解实质吧!
int CheckInvaliOutStackOrder(int* a1,int* a2,int n)
{
int i = 0,index = 0, outdex = 0;
Stack s;
StackInit(&s);
while (index < n) {
if (a1[index] == a2[outdex])
{
++index;
++outdex;
}
else
{
if (StackEmpty(&s) != 0 && StackTop(&s) == a2[outdex])
{
StackPop(&s);
++outdex;
}
else
{
StackPush(&s, a1[index]);
++index;
}
}
}
int j = outdex;
while(j < n){
if (StackTop(&s) == a2[outdex])
{
++outdex;
StackPop(&s);
}
else
return 0; //出栈序列不合适
++j;
}
return 1; //出战序列合适
}
八、一个数组实现两个栈(共享栈)
这个题比较简单,了解清楚用一个数组实现栈的结构特点就可以,这里我提供将一个数组分配给两个栈用的三种结构。
说明:
方法一:采用交叉索引的方式,数组奇数位为一个栈,偶数位为一个栈;每次push,pop操作找到对应的下标进行操作;
方法二:可计算出数组中间位置,左半部分为栈1,右半部分为栈2;
方法三:相对于前两种比较优的解决方法,栈1从数组下标为0开始不断增大,栈2从数组元素个数-1的下标开始不断减小。
相比三种方法,效率不相上下,第一二种考虑增容时会比较麻烦,第三种较简单;第一种和第二种方法在其中一个栈压入比较多的数据而另外一个栈数据很少时,就存在非常大的空间浪费,但方法三就可以很好的避免这一情况,空间利用率比较高,而且这种方案在一个栈pop的空间另一个栈可以使用,可以在一些情况下减少开辟空间的次数。