目录
栈
栈的基本概念
栈是什么
线性表增加和删除操作限制在一端进行,就被称为栈。
那为什么要使用栈?其实,单纯从功能上讲,数组或者链表可以替代栈。然而问题是,数组或者链表的操作过于灵活,这意味着,它们过多暴露了可操作的接口。这些没有意义的接口过多,当数据量很大的时候就会出现一些隐藏的风险。一旦发生代码 bug 或者受到攻击,就会给系统带来不可预知的风险。虽然栈限定降低了操作的灵活性,但这也使得栈在处理只涉及一端新增和删除数据的问题时效率更高。
具体而言,栈的数据结点必须后进先出。后进的意思是,栈的数据新增操作只能在末端进行,不允许在栈的中间某个结点后新增数据。先出的意思是,栈的数据删除操作也只能在末端进行,不允许在栈的中间某个结点后删除数据。
增加和删除操作的一端称为栈顶(top),另一端称为栈底(bottom)。增加数据称为压栈(push),删除数据称为出栈(pop)。栈中没有数据称为空栈。
和线性表类似,栈分为顺序栈和链栈。
顺序栈
空栈:栈顶=栈底 或 top = -1 ;
增加数据:top + 1 ;
删除数据:top - 1 ;
top < StackSize,有一条数据时 top = 0;
链栈
栈顶放在单链表头部,即不需要头指针,使用top指针代替。增加与删除操作在头部即top指向处进行。
增加数据
在链式栈中进行删除操作时,只能在栈顶进行操作。因此,将栈顶的 top 指针指向栈顶元素的 next 指针即可完成删除。
栈的案例
符号匹配
范例:给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。有效字符串需满足:左括号必须与相同类型的右括号匹配,左括号必须以正确的顺序匹配。例如,{ [ ( ) ( ) ] } 是合法的,而 { ( [ ) ] } 是非法的。
浏览器前进后退功能
范例,浏览器的页面访问都包含了后退和前进功能,利用栈如何实现?
总结
栈继承了线性表的优点与不足,是个限制版的线性表。限制的功能是,只允许数据从栈顶进出,这也就是栈后进先出的性质。不管是顺序栈还是链式栈,它们对于数据的新增操作和删除操作的时间复杂度都是 O(1)。而在查找操作中,栈和线性表一样只能通过全局遍历的方式进行,也就是需要 O(n) 的时间复杂度。
什么时候选择栈?栈具有后进先出的特性,当你面对的问题需要高频使用新增、删除操作,且新增和删除操作的数据执行顺序具备后来居上的相反关系时,栈就是个不错的选择。例如,浏览器的前进和后退,括号匹配等问题。栈在代码的编写中有着很广泛的应用,例如,大多数程序运行环境都有的子程序的调用,函数的递归调用等。这些问题都具有后进先出的特性。
队列
队列的基本概念
队列是什么
线性表限制一端进行删除,则另一端进行增加。即具有先进先出的特点。
与线性表、栈一样,队列也存在这两种存储方式,即顺序队列和链式队列:
- 顺序队列,依赖数组来实现,其中的数据在内存中也是顺序存储。
- 而链式队列,则依赖链表来实现,其中的数据依赖每个结点的指针互联,在内存中并不是顺序存储。链式队列,实际上就是只能尾进头出的线性表的单链表。
一个队列依赖头指针(front)和尾指针(rear)进行唯一确定。队列从队头(front)删除元素,从队尾(rear)插入元素。
队列为空时:
基本操作
顺序队列的数据操作
为了实现一个有 k 个元素的顺序存储的队列,我们需要建立一个长度比 k 大的数组,以便把所有的队列元素存储在数组中。
入队:
当 A 出队列时,front 指针指向下标为 1 的位置,rear 保持不变。其后 E 加入队列,front 保持不变,rear 则移动到了数组以外,如下图所示:
这就是数组越界的问题。通过循环队列来解决。
循环队列
如果是循环队列,rear 指针就可以重新指向下标为 0 的位置,如下图所示:
如果这时再新增了 F 进入队列,就可以放入在下标为 0 的位置,rear 指针指向下标为 1 的位置。这时的 rear 和 front 指针就会重合,指向下标为 1 的位置,如下图所示:
此时,又会产生新的问题,即当队列为空时,有 front 指针和 rear 指针相等。而现在的队列是满的,同样有 front 指针和 rear 指针相等。那么怎样判断队列到底是空还是满呢?常用的方法是,设置一个标志变量 flag 来区别队列是空还是满。
链式队列的数据操作
链式队列就是一个单链表,同时增加了 front 指针和 rear 指针。链式队列和单链表一样,通常会增加一个头结点,并另 front 指针指向头结点。头结点不存储数据,只是用来辅助标识。
新增操作,链式队列进行新增数据操作时,将拥有数值 X 的新结点 s 赋值给原队尾结点的后继,即 rear.next。然后把当前的 s 设置为队尾结点,指针 rear 指向 s。如下图所示:
删除操作,实际删除的是头结点的后继结点。这是因为头结点仅仅用来标识队列,并不存储数据。因此,出队列的操作,就需要找到头结点的后继,这就是要删除的结点。接着,让头结点指向要删除结点的后继。
头节点干啥用的?为了防止删除最后一个有效数据结点后, front 指针和 rear 指针变成野指针,导致队列没有意义了。有了头结点后,哪怕队列为空,头结点依然存在,能让 front 指针和 rear 指针依然有意义。如图:
应用
约瑟夫环
约瑟夫环是一个数学的应用问题,具体为,已知 n 个人(以编号 1,2,3...n 分别表示)围坐在一张圆桌周围。从编号为 k 的人开始报数,数到 m 的那个人出列;他的下一个人又从 1 开始报数,数到 m 的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。这个问题的输入变量就是 n 和 m,即 n 个人和数到 m 的出列的人。输出的结果,就是 n 个人出列的顺序。
这个问题,用队列的方法实现是个不错的选择。它的结果就是出列的顺序,恰好满足队列对处理顺序敏感的前提。因此,求解方式也是基于队列的先进先出原则。解法如下:
- 先把所有人都放入循环队列中。注意这个循环队列的长度要大于或者等于 n。
- 从第一个人开始依次出队列,出队列一次则计数变量 i 自增。如果 i 比 m 小,则还需要再入队列。
- 直到i等于 m 的人出队列时,就不用再让这个人进队列了。而是放入一个用来记录出队列顺序的数组中。
- 直到数完 n 个人为止。当队列为空时,则表示队列中的 n 个人都出队列了,这时结束队列循环,输出数组内记录的元素。
至此,我们就通过循环队列解决了约瑟夫环问题。
总结
队列与栈的特性非常相似,队列也继承了线性表的优点与不足,是加了限制的线性表,队列的增和删的操作只能在这个线性表的头和尾进行。
在时间复杂度上,循环队列和链式队列的新增、删除操作都为 O(1)。而在查找操作中,队列和线性表一样只能通过全局遍历的方式进行,也就是需要 O(n) 的时间复杂度。在空间性能方面,循环队列必须有一个固定的长度,因此存在存储元素数量和空间的浪费问题,而链式队列不存在这种问题,所以在空间上,链式队列更为灵活一些。
怎么选择链式队列还是循环队列?在可以确定队列长度最大值时,建议使用循环队列。无法确定队列长度时,应考虑使用链式队列。队列具有先进先出的特点,很像现实中人们排队买票的场景。在面对数据处理顺序非常敏感的问题时,队列一定是个不错的技术选型。
LetCode真题
20. 有效的括号
package cn.ren.demo;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;
public class Solution {
public static void main(String[] args) {
String s = "";
System.out.println(isValid(s));
}
public static boolean isValid(String s) {
if (s.length() == 0) {
return true;
} else if (s.length() % 2 == 1) {
return false;
}
Map<Character, Character> match = new HashMap<Character, Character>();
match.put('(', ')');
match.put('[', ']');
match.put('{', '}');
Stack<Character> stack = new Stack<Character>();
for (int i = 0; i < s.length(); i++) {
char temp = s.charAt(i);
if (match.containsKey(temp)) { // 左括号,入栈
stack.add(temp);
} else if (match.containsValue(temp)) { // 右括号
if (!stack.isEmpty()) {
char popElement = stack.pop(); // 栈顶
if (match.get(popElement) != temp) { // 不匹配
return false;
}
} else { // 栈空,不匹配:有右括号,无左括号
return false;
}
}
}
if (stack.isEmpty()) {
return true;
}
return false;
}
}
总结:应用到数据结构:HashMap、Stack、String中charAt(index)
739. 每日温度
class Solution {
public int[] dailyTemperatures(int[] T) {
int[] result = new int[T.length];
Stack<Integer> stack = new Stack<Integer>();
for (int i = 0; i < T.length; i++) {
int temp = T[i];
while ( !stack.isEmpty() && temp > T[stack.peek()]) { // 温度比它大,出栈,直到栈空
result[stack.peek()] = i - stack.peek();
stack.pop();
}
stack.push(i);
}
return result;
}
}
239. 滑动窗口最大值
class Solution {
public static int[] maxSlidingWindow(int[] nums, int k) {
int[] result = new int[nums.length - k + 1] ;
Deque<Integer> deque = new LinkedList<Integer>() ; // 保存的是索引
for(int i = 0; i < nums.length; i++) {
// 头:移出头部,保证窗口长度的长度范围
if(!deque.isEmpty() && deque.getFirst() < (i - k + 1)) { // 头部没有缩短时,第二个条件会不满足,要去除头部
deque.poll() ;
}
// 尾:移出尾部元素小于当前值的元素
while(!deque.isEmpty() && nums[i] >= nums[deque.getLast()]) {
deque.removeLast() ;
}
// 尾:尾部入队,滑动窗口向右扩充
deque.addLast(i);
// 头:从头部返回最大值
if(i >= k-1) {// 开始时窗口要够大
result[i - k + 1] = nums[deque.getFirst()] ;
}
}
return result ;
}
}