你以为栈只是个数学概念?错!它就在你身边!
大家好,欢迎来到我的博客!今天咱们来聊一聊栈(Stack)。栈听起来像是某个数学课上的抽象概念,但其实它无处不在,特别是在我们日常开发中,真的是让人又爱又恨的好伙伴。别担心,我不会让你听一堆枯燥的理论,而是通过实际的例子,让你明白栈到底有多酷。
栈的最经典特性就是 FILO ,也就是 First In, Last Out(先进后出)。你可以想象自己去超市买东西,拿了购物车放了一堆东西进去。结果到结账时,你发现最早放进去的那些货物反而被放到了最底下,只有最后拿的那瓶牛奶可以最先结账。所以,如果你想要买那个最早放进购物车的苹果,得先拿到牛奶。明白了吗?栈就是这么个顺序------最早放进去的最后拿出来。
好啦,开个玩笑,接下来我们用一些实际的 JavaScript 示例,带你真正理解栈是什么。
栈与类:一场面向对象的邂逅
在 JavaScript 的世界里,栈可以用数组(Array)或者链表(LinkedList)来实现。今天我们先聊聊 数组实现栈 ,然后再看看 链表实现栈 的优缺点。
数组版栈:简单、直接、快速
栈的实现不复杂,最简单的方式就是用 JavaScript 的数组。数组本身就提供了 push
和 pop
方法,所以你可以很方便地模拟栈的行为。看看下面的实现:
javascript
const MinStack = function () {
this.stack = []; // 主栈
this.stack2 = [] // 辅助栈,用来保存最小值
};
MinStack.prototype.push = function (x) {
this.stack.push(x);
// 如果栈2为空,或者当前值小于等于栈2顶端的最小值,压入栈2
if (this.stack2.length === 0 || this.stack2[this.stack2.length - 1] >= x) {
this.stack2.push(x);
}
};
MinStack.prototype.pop = function () {
if (this.stack.pop() === this.stack2[this.stack2.length - 1]) {
this.stack2.pop();
}
};
MinStack.prototype.top = function () {
return this.stack[this.stack.length - 1];
};
MinStack.prototype.getMin = function () {
return this.stack2[this.stack2.length - 1];
};
如何理解上面的代码呢?
push(x)
:每当我们将一个新元素压入栈时,我们不仅会将其加入主栈(stack
),还会检查它是否应该成为当前的最小值。如果是,它就被压入辅助栈(stack2
)中。这样,辅助栈就始终保持了最小元素。pop()
:当我们弹出元素时,如果弹出的元素是当前的最小值(即和辅助栈的顶端元素一样),我们也从辅助栈中弹出该元素。top()
:返回主栈的顶部元素,简单吧。getMin()
:返回当前栈中的最小值,它就是辅助栈的顶部元素。这样,我们就可以在 O(1) 的时间内获取栈中的最小值!
数组实现栈的优缺点:
-
优点:
- O(1) 时间复杂度的
push()
和pop()
操作; - 可以直接通过
stack
和stack2
访问栈顶元素和最小值。
- O(1) 时间复杂度的
-
缺点:
- 数组会在达到一定大小时进行扩容,但扩容操作是 O(n) 的,因此在大规模数据的情况下可能会变慢。不过,这种情况出现得比较少。
链表版栈:动态的魔法!
不过,你可能会想,数组的实现虽然方便,但如果栈的大小一直增长怎么办?会不会浪费很多内存呢?这时候链表就显得格外有用。链表是一种动态的数据结构,内存不会浪费,而且每次只需分配存储当前节点所需的内存。
这里有一个基于链表实现的栈的例子:
javascript
class LinkedListStack {
#stackPeek; // 将头节点作为栈顶
#stkSize = 0; // 栈的长度
constructor() {
this.#stackPeek = null;
}
get size() {
return this.#stkSize;
}
isEmpty() {
return this.size === 0;
}
push(num) {
const node = new ListNode(num);
node.next = this.#stackPeek;
this.#stackPeek = node;
this.#stkSize++;
}
pop() {
const num = this.peek();
this.#stackPeek = this.#stackPeek.next;
this.#stkSize--;
return num;
}
peek() {
if (!this.#stackPeek) throw new Error('栈为空');
return this.#stackPeek.val;
}
toArray() {
let node = this.#stackPeek;
const res = new Array(this.size);
for (let i = res.length - 1; i >= 0; i--) {
res[i] = node.val;
node = node.next;
}
return res;
}
}
链表实现栈的优势:
- 链表的 动态内存分配 让我们避免了数组扩容时可能遇到的性能瓶颈。
- 每次
push()
和pop()
只需操作栈顶节点,不会像数组那样发生大规模数据的移动。
不过,链表实现栈的效率稍低一些,因为每次入栈都需要为节点分配内存,且操作的指针有一些额外开销。
栈的神奇应用:括号匹配问题
说到栈,你可能会好奇它到底能做什么有趣的事情。其实,栈在解决一些经典的算法问题中非常有用,譬如 括号匹配问题。
想象一下,你有一个字符串,里面包含了各种括号:"()"
,"[]"
,"{}"
。你需要判断这个字符串中的括号是否匹配,栈正是解决这个问题的利器。
括号匹配问题的解法:
javascript
const leftToRight = {
"(": ")",
"[": "]",
"{": "}"
};
const isValid = function (s) {
if (!s) return true;
const stack = [];
const len = s.length;
for (let i = 0; i < len; i++) {
const ch = s[i];
if (ch === "(" || ch === "{" || ch === "[") {
stack.push(leftToRight[ch]);
} else {
if (!stack.length || stack.pop() !== ch) {
return false;
}
}
}
return !stack.length;
};
这个算法怎么用栈解决呢?
-
我们先定义一个
leftToRight
映射,用来记录左括号和右括号的对应关系。 -
遍历字符串:
- 如果遇到左括号(
"("
,"{"
,"["
),就把它对应的右括号压入栈中; - 如果遇到右括号,栈顶元素应该是它对应的左括号,如果不匹配,就返回
false
;
- 如果遇到左括号(
-
如果所有括号都匹配完了,栈应该是空的,如果栈里还有剩余的括号,说明匹配失败。
栈在括号匹配中的优势:
- O(n) 时间复杂度:只需要遍历字符串一次,栈的操作是常数时间的;
- O(n) 空间复杂度:最坏情况下,所有字符都是左括号,栈的大小为
n
。
总结:栈,这个看似简单却又极具魔力的数据结构
栈,不仅仅是一个基础的数据结构,它在很多经典的算法问题中发挥着巨大的作用。无论是通过数组实现栈,还是链表实现栈,栈都能帮助我们高效地解决问题。而且,它的 FILO 特性使得栈在括号匹配、递归调用、内存管理等领域都能大显身手。
所以,下次你遇到需要 "后进先出" 的问题时,记得召唤栈来帮忙!是不是感觉栈突然就变得高大上了?😎
如果你对栈有任何疑问,或者想了解更多的栈应用,欢迎留言,我会第一时间为你解答!