栈
栈(Stack)又名堆栈,它是一种运算受限的 线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为 栈顶,相对地,把另一端称为 栈底。向一个栈插入新元素又称作进栈、入栈 或 压栈 ,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作 出栈 或 退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。
栈是一种具有 「先入后出」 特点的抽象数据结构,可使用数组或链表实现。使用数组实现的栈也成为 顺序栈,使用链表实现的栈成为 链栈
「先入后出」 可以用手枪弹夹比喻,弹夹中最先放入的子弹最后才会打出去,而最后放入的子弹第一枪就打出去了。
压栈 方法名一般叫做 push ,出栈 方法名一般叫做 pop。
具体使用什么方法名完全取决于你自己,但是为了规范,我们都会统一使用
push和pop
我们先来画图看看入栈和出栈的过程
stack.push(1); // 元素 1 入栈
stack.push(2); // 元素 2 入栈
stack.pop(); // 出栈 -> 元素 2
stack.pop(); // 出栈 -> 元素 1
没什么问题,这看起来非常好理解。所谓先入后出就像你上班坐公交车一样,先上车的乘客司机总会让你往里走走,因为里面 “还有位置”,但是下车时总是后上车的乘客先下车,因为离车门近,反而先上车的乘客因为在里面的“位置上坐着”,离车门远,总是后下车。
顺序栈和链栈的优缺点
上面有提到过,栈可使用数组或链表实现。使用数组实现的栈也称为 循序栈,使用链表实现的栈称为 链栈
但是这两种栈的优缺点是什么,我们应该如何选择呢?首先我们需要知道数组和链表的优缺点
对数组和链表不了解的请看:
数组的优缺点:
优点: 数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。
缺点: 数组的劣势体现在插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动或者重新开辟内存扩容,影响效率。
总结: 数组所适合的是读操作多、写操作少的场景!
链表的优缺点:
优点:插入和删除速度快,保留原有的逻辑顺序,在插入或者删除一个元素的时候,只需要改变指针指向即可。没有空间限制,存储元素无上限,只与内存空间大小有关.
缺点:查找速度比较慢,因为在查找时,需要循环遍历链表。
总结:链表适合的是读操作少、写操作多的场景!
栈的特点:
- 元素数量不固定
- 对线性表尾部有频繁的插入和删除操作(push和pop)
无论从那个方面看,使用链表来实现栈都是一个更好的选择,但是如果我们能避开数组的缺点来使用栈的话,数组的顺序存储也带来不少好处。
js数组也已经包含了符合栈的 push 和 pop 方法,所以js中也经常使用数组来当做栈使用,这样就不用自己实现了。但是因为v8数组的扩容收缩机制,性能可能不是十分完美
对v8数组扩容收缩机制不了解的同学可以看看我之前的文章:图解数据结构js篇-数组结构(Array) 和 深入V8 - js数组的内存是如何分配的
顺序栈
顺序栈使用数组来实现,将数组的第一项作为栈低,然后维护一个指针来表示栈顶。由于数组的缺点我们应该尽量使用固定的栈大小,也就是限制栈用元素的数量。下图是一个大小为5的栈。
当栈顶指针为 -1 时表示栈中没有元素,即 空栈
我们来看一下简单的模拟实现
// stack.js
export class Stack {
constructor(length) {
this._stack = Array(length);
this._maxLength = length;
this._stackTop = -1;
}
push(data){
// 判断是否栈满
if(this._stackTop === this._maxLength-1){
return false;
}
this._stack[++this._stackTop] = data
return true
}
pop(){
// 判断是否栈空
if(this.isEmpty()){
return null;
}
// 可以不必清空出栈的元素,栈顶指针移动即可。下次入栈会自动覆盖
return this._stack[this._stackTop--];
}
isEmpty(){
return this._stackTop === -1;
}
toString(){
return `Stack(${this._stack.slice(0, this.stackTop+1).toString()})`
}
}
拓展-共享栈
我们先来看一个场景
现在有两个容量为5的栈,其中 栈1 已经满栈,栈2 只有一个元素。现在想要向 栈1 中 push 新的元素2,由于 栈1 已经满栈会导致入栈操作失败,但是 栈2 却还有很多空位没有元素使用。
这就很气人,内存空间得不到很好的利用。所以人们就想出来一种方法,那就是让两个栈共享一个内存空间,从而让空间得到更好的利用。这种栈也称为 共享栈
共享栈是将数组两两端分别作为两个栈的栈低,然后分别使用两个指针指向两个栈的栈顶
共享栈一般在算法中用得比较少,但是对于操作系统或者硬件底层很常见,因为他们的资源非常有限,需要共享栈来优化资源的利用率
链栈
使用链表实现栈有一个问题: 使用链表的头指针还是尾指针作为栈顶?
因为栈操作都是从栈顶操作,栈底就不需要考虑指针了,现在我们只需要在头指针和尾指针中选择一个作为栈顶即可。
选择尾指针:
如果选择尾指针作为栈顶,那么当我们入栈时相当于尾插,只需要将 rear.next = node; rear=node 即可。借助尾指针我们可以很方便的在链表尾部添加节点。
但是我们出栈时需要执行尾删操作,对于单向链表来说,尾删需要把尾指针指向前一个元素,而获取前一个元素的唯一方法就是从头指针开始遍历,因为单向链表无法逆向查找。但是遍历会带来较大的时间消耗。
选择头指针:
入栈相当于头插,只需要 node.next = head; head = node;,出栈相当于头删,只需要 head = head.next;。可以看出单向链表的头插和头删都是非常快速的。
相比之下,我们可以看出使用头指针作为栈顶更好。所以我们选择使用头指针作为栈顶,借助头指针我们可以很快很方便的完成头插和头删操作。
// stack.js
import { LinkedListNode, LinkedList } from './linkedList.js'
// 链表实现的栈
export class LinkedStack {
constructor(){
this._linkedList = new LinkedList();
}
// 入栈
push(data){
return this._linkedList.insertInHead(data);
}
// 出栈
pop(){
return this._linkedList.remove(0);
}
isEmpty() {
return this._linkedList.isEmpty();
}
top() {
return this._linkedList.get(0);
}
toString(){
let reslut = []
let cur = this._linkedList.head;
while (cur = cur.next){
reslut.push(cur.toString())
}
return `Stack(${reslut.toString()})`;
}
}
栈的实现很简单,如果对链表的操作和原理不熟悉,可以看我的另一篇文章图解数据结构js篇-链表结构(Linked-list)
常用的实现方法
如果你的项目对数据结构没有特别高的要求,一般我们会借助js数组已有的一些Api方法来编写一个栈。但是其逻辑还是数组。
export class Stack {
constructor() {
this._arr = []
}
push(value) {
return this._arr.push(value);
}
pop() {
return this._arr.pop();
}
isEmpty() {
return this._arr.length === 0;
}
top() {
return this._arr[this._arr.length - 1];
}
toString() {
return `Stack(${this._arr.toString()})`
}
}
leetcode 实战
弄明白了栈的实现和原理,我们还要知道什么时候改使用栈,如何用栈结构来解决问题。
这里举例两 leetcode 上几个典型的题目
难度:一个简单一个中等(为什么没有困难的?因为困难的我也搞不定)。
以下题目都是面试出现记录很高的题
20. 有效的括号
给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
示例 1: 输入:s = "()" 输出:true 示例 2: 输入:s = "()[]{}" 输出:true 示例 3: 输入:s = "(]" 输出:false 示例 4: 输入:s = "([)]" 输出:false 示例 5: 输入:s = "{[]}" 输出:true提示:
1 <= s.length <= 104, s 仅由括号 '()[]{}' 组成
符号成对匹配和后缀表达式之类的题型可以首先考虑栈,例如本题,每当遇到 ({[ 就入栈,遇到 )}] 就入栈顶出栈的元素,如果成对则继续,不成对则返回失败;
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function(s) {
// js中未提供Stack结构,需要自己将上面的Stack实现复制过来。篇幅原因,这里就不复制了
let stack = new Stack()
for(let char of s){
// 左括号入栈
if('({['.includes(char)){
stack.push(char)
}else {
// 出栈左括号与右括号匹配,如果不匹配或者为空则说明括号无效
switch (stack.pop()){
case '(':
if(char !== ')') return false;
break;
case '{':
if(char !== '}') return false;
break;
case '[':
if(char !== ']') return false;
break;
// 为空
default: return false;
}
}
}
// 如果栈为空,则说明所有括号都匹配,返回true,否则返回false
return !stack.pop()
};
143. 字符串解码
给定一个经过编码的字符串,返回它解码后的字符串。
编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。
你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。
此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k ,例如不会出现像 3a 或 2[4] 的输入。
示例 1: 输入:s = "3[a]2[bc]" 输出:"aaabcbc" 示例 2: 输入:s = "3[a2[c]]" 输出:"accaccacc" 示例 3: 输入:s = "2[abc]3[cd]ef" 输出:"abcabccdcdcdef" 示例 4: 输入:s = "abc3[cd]xyz" 输出:"abccdcdcdxyz"
- 本题核心思路是在栈里面每次存储两个信息, (左括号前的字符串, 左括号前的数字), 比如
3[a2[c]], 当遇到第一个左括号的时候,压入栈中的是("", 3), 然后遍历括号里面的字符串a2[c], 遇到第二个左括号,压入栈中的是("a", 2),然后继续遍历后面,遇到右括号则出栈,得到新字符串str = t[0] + str.repeat(t[1])也就是acc,然后遇到第二个右括号继续出栈,得到accaccacc - 凡是遇到左括号就进行压栈处理,遇到右括号就弹出栈,栈中记录的元素很重要。
function decodeString(s){
let stack = new Stack()
//记录字符
let str = '';
// 记录数字
let num = '';
for(let char of s){
// 遇到[,入栈
if(char === '['){
stack.push([str, num]);
// 重置
str = ''
num = ''
}
else if(char === ']'){
// 遇到右括号出栈,然后拼接结果
let t = stack.pop();
str = t[0] + str.repeat(+t[1]);
}
else if(char >= '0' && char <= '9'){
// 遇到数字,记录
num += char;
}else {
// 遇到字符,记录
str += char;
}
}
return str;
}