引言
在繁忙的学业之余,终于能挤出时间来磨砺代码技艺了。今天,我将分享自己对一道经典LeetCode题目的学习心得:155. 最小栈。
栈是什么?
在完成这道LeetCode题前,我们可以先来了解一下什么是栈:
栈它是一种运算受限的线性表,限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。
以下是动画演示,希望能够帮助你理解栈的基本概念。
栈的操作特性?
- Push (入栈) : 将一个元素添加到栈顶。
- Pop (出栈) : 移除并返回栈顶元素。
- Peek (查看栈顶元素) : 返回栈顶元素但不移除它。
- Size (栈的大小) : 返回栈中元素的数量。
- IsEmpty (栈是否为空) : 检查栈是否没有元素。
- Clear (清空栈) : 移除所有元素,使栈变为空。
这些操作构成了栈的基本行为模式,确保了数据按照"后进先出"的原则被有序地处理。每一个操作都扮演着不可或缺的角色,共同保障了栈的稳定性和效率。无论是构建复杂的算法还是解决实际问题,掌握栈及其操作都是程序员必备的技能之一。
栈的实现方式
对于栈的实现,我们可以选择数组或链表两种主要形式。每种方式都有其特点:
数组
使用数组实现栈具有简单直接的优势。由于数组在内存中是连续存储的,因此push和pop操作都非常高效,可以在常数时间内完成。然而,数组的容量是固定的,当达到上限时需要进行扩容操作,这虽然不是频繁发生,但在扩容过程中会涉及到创建新数组和复制现有元素的过程,导致时间复杂度升高至O(n)。此外,预分配的空间可能会造成一定的浪费。
链表
相比之下,链表提供了更加灵活的存储方案。每个节点不仅包含数据部分,还包括指向下一个节点的指针,使得链表可以动态地增长或缩减。这意味着在执行入栈或出栈操作时,不需要考虑空间限制的问题。不过,每次入栈都需要初始化一个新的节点,并更新指针,这比数组操作稍微复杂一些,但从整体来看,链表在处理未知大小的数据集方面表现得更为稳定。
启动
先来看看一个普通的栈是如何实现的
js
/**
* 初始化栈结构
*/
const MinStack = function () {
this.stack = []
};
// 栈的入栈操作,其实就是数组的 push 方法
MinStack.prototype.push = function (x) {
this.stack.push(x)
};
// 栈的入栈操作,其实就是数组的 pop 方法
MinStack.prototype.pop = function () {
this.stack.pop()
};
// 取栈顶元素,这里我本能地给它一个边界条件判断(不给也能通过)
MinStack.prototype.top = function () {
if (!this.stack || !this.stack.length) {
return
}
return this.stack[this.stack.length - 1]
};
// 按照一次遍历的思路取最小值
MinStack.prototype.getMin = function () {
let minValue = Infinity
const { stack } = this
for (let i = 0; i < stack.length; i++) {
if (stack[i] < minValue) {
minValue = stack[i]
}
}
return minValue
};
首先,我们构建了一个基本的栈结构,利用JavaScript中的数组作为底层容器来模拟栈的行为。这里实现了四个基本操作:push
, pop
, top
, 和 getMin
。其中,getMin
方法通过遍历整个栈来找寻最小值,尽管直观,但却不是最优解法。
辅助栈 空间换时间
在这时候,我们就应该仔细想想,时间复杂度还可以优化吗?当然有,那就是空间换时间,我们可以使用一个新的栈来存取原来栈中的数据,
js
const MinStack = function () {
this.stack = []; //原栈
this.stack2 = [] //辅助栈
}
MinStack.prototype.push = function (num) {
this.stack.push(num)
// 为什么要等于 出栈
if (this.stack2.length === 0 || this.stack2[this.stack2.length - 1] >= num) {
this.stack2.push(num)
}
}
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]
}
这段代码与上述代码的区别就是调用了一个新的栈,在这个栈中实现了一种规则:入栈时 :当两栈为空时,原栈入栈,辅助栈也入栈。而在这之后,原栈入栈不受影响,辅助栈只有当前入栈元素小于辅助栈栈顶元素时,这个元素才会在辅助栈中实现入栈,以此类推,实现了辅助栈中元素由上往下从小到大的排序;出栈时:原栈不受影响,而辅助栈则需要判断,只有出栈元素等于栈顶元素时,辅助栈才可以实现元素出栈,这样,辅助栈的栈顶元素始终代表着当前栈中的最小值,从而实现了O(1)时间复杂度的最小值查询。
注意:其中入栈中等于的情况,是为了保证出栈时不会因为两个相同的数导致出栈后最小元素的错误判断。
试一试用链表实现这个功能
除了上述基于数组的实现外,我们还可以考虑使用链表来模拟栈的行为。链表的一个显著优势在于它能够动态地增加或减少节点,这使得它非常适合用来处理未知大小的数据集。通过定义一个链表节点类ListNode
,我们可以方便且高效地完成链表栈的入栈和出栈操作。
在链表栈中,每个节点不仅保存了数据本身,还包含了一个指向下一个节点的引用(指针)。这样的结构允许我们快速地添加或移除元素,而无需担心空间分配的问题。为了更好地理解这一过程,我们可以将入栈和出栈的操作分解为几个简单的步骤:
入栈操作
入栈可以视为一个四步的过程:
- 创建新节点 :首先,我们需要创建一个新的头节点元素
node
,用于存储即将入栈的数据。 - 链接节点 :然后,我们将新的头节点
node
指向当前的栈顶元素,即原来的头节点。这样做是为了保持链表的连接性,确保所有节点都能被正确访问。 - 更新栈顶 :接着,我们将
node
设置为新的栈顶(stackPeek
),这意味着它现在成为了栈的新入口。 - 更新长度:最后,我们增加栈的长度计数器,以反映栈中元素数量的变化。
出栈操作
同样地,出栈也可以分解为四个步骤:
- 保存栈顶值:首先,我们需要保存当前栈顶元素的值,以便稍后返回给调用者。
- 移动栈顶 :接下来,我们将栈顶(
stackPeek
)更新为它的下一个节点(stackPeek.next
),从而有效地移除了之前的栈顶元素。 - 更新长度:相应地,我们减少栈的长度计数器,以反映元素的移除。
- 返回结果:最后,我们返回之前保存的栈顶元素值,完成整个出栈过程。
通过这种方式,我们不仅实现了栈的基本功能,而且利用链表的特性,保证了操作的高效性和灵活性。这种设计特别适用于那些需要频繁进行插入和删除操作的场景,因为链表不需要像数组那样预先分配固定大小的空间,也不必在每次扩容时重新分配内存和复制现有元素。
解决了这些基础概念之后,我们现在就可以着手实现具体的代码了。下面展示的是一个完整的链表栈实现,包括入栈、出栈、获取栈顶元素以及检查栈是否为空等常用方法。
js
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;
}
/* 将链表转化为 Array 并返回 */
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;
}
}
以上我们通过采用链表实现栈,我们不仅获得了更加灵活的数据结构,还能更优雅地处理各种动态变化的数据集。
思路的扩展
在深入探讨栈的应用时,一个经典的例子便是验证括号的有效性。这个问题来源于力扣(LeetCode)上的题目 20. 有效的括号,接下来让我们看看如何解决这道题吧!
js
// 用一个 map 来维护左括号和右括号的对应关系
const leftToRight = {
"(": ")",
"[": "]",
"{": "}"
};
const isValid = function (s) {
// 结合题意,空字符串无条件判断为 true
if (!s) {
return true;
}
// 初始化 stack 数组
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;
};
这段代码简洁而优雅,充分利用了栈后进先出(LIFO)的特点,实现了对括号匹配性的快速验证。具体来说:我们创建了一个map
对象leftToRight
,用于存储每种左括号及其对应的右括号。这样做不仅简化了后续逻辑,而且提高了查找效率。然后进行了处理空字符串的操作,空字符串被视为有效的括号组合,因此直接返回true
,然后使用一个循环遍历输入字符串中的每个字符,对于每个字符,根据它是左括号还是右括号采取不同的行动,每当遇到左括号时,我们将其对应的右括号压入栈中;当遇到右括号时,则尝试从栈中弹出一个元素并检查它们是否匹配。如果不匹配或栈为空,立即返回false
,最后遍历结束后,如果栈为空,表示所有括号都成功配对,返回true
;否则,返回false
。
结语
以上就是我今天的代码学习历程了,栈对于我们来说还是一定要掌握的内容,继续加油,让我们一起探索更多有趣的问题。