文章

栈·队列·字符串高频算法面试题整理(Kotlin 题解,含最优解与其他解法)

承接数组链表哈希二叉树专题,继续整理栈、队列、字符串三类力扣/牛客高频算法面试题。每题用 Kotlin 给出完整题解,对比单调栈/单调队列/滑动窗口等最优解与暴力解的复杂度差异。

栈·队列·字符串高频算法面试题整理(Kotlin 题解,含最优解与其他解法)

这是算法面试题整理的第二篇,承接上一篇 数组·链表·哈希·二叉树高频算法面试题整理,本篇聚焦栈、队列、字符串三类。它们背后有几个特别高频的”套路武器”:单调栈、单调队列、滑动窗口——掌握了它们,一大批看起来很难的题会瞬间变简单。

每题依旧遵循同一结构:题意与思路 → Kotlin 实现 → 对比非最优解 → 时空复杂度分析。原理层面还没打牢的同学,建议先读姊妹篇 一文通俗读懂常见数据结构

栈(Stack)是”后进先出 LIFO”,队列(Queue)是”先进先出 FIFO”。Kotlin 里常用 ArrayDeque 同时充当栈和队列:addLast/removeLast 当栈用,addLast/removeFirst 当队列用。

一、栈

栈最擅长处理“就近匹配 / 就近比较”的问题:括号匹配、表达式求值、”找下一个更大的元素”(单调栈)都是它的主场。

1. 有效的括号(简单)

判断字符串是否是有效括号序列,如 "()[]{}" 有效,"(]" 无效。

最优解:栈。括号匹配的本质是”最近未匹配的左括号,必须最先被闭合“——比如 ([)] 就是错的,因为 ) 想闭合时,最近的左括号是 [ 而不是 (。这种”最近优先”的配对,正好是栈”后进先出”的拿手戏。

规则很简单:遇到左括号就入栈(记下”欠一个对应的右括号”);遇到右括号 c,就看栈顶是不是与它匹配的左括号——是则弹出(配对成功),否则立刻判非法。两种边界要一并处理:右括号来时栈已空(没有左括号可配),以及全部遍历完栈却非空(有左括号没被闭合),都属于无效。用一个 右→左 的映射表 pairs 查匹配,代码最简洁。

"([)]" 为例的栈变化:

字符动作栈(底→顶)
(左括号入栈(
[左括号入栈( [
)栈顶是 [,与 ) 不匹配 → 返回 false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
 * 判断括号序列是否有效。
 * Check whether the brackets are valid.
 * @param s 只含括号的字符串
 * @return Boolean 有效返回 true
 */
fun isValid(s: String): Boolean {
    val stack = ArrayDeque<Char>()
    val pairs = mapOf(')' to '(', ']' to '[', '}' to '{')
    for (c in s) {
        if (c in pairs) {
            // 右括号:栈顶必须是与之匹配的左括号
            if (stack.isEmpty() || stack.removeLast() != pairs[c]) return false
        } else {
            stack.addLast(c)  // 左括号入栈
        }
    }
    return stack.isEmpty()   // 全部匹配完栈应为空
}
  • 时间 O(n),空间 O(n)

非最优解:反复替换。循环把 ()[]{} 替换成空串,直到不能再替换,最后看是否为空串。逻辑直观,但每次替换要扫描整个字符串,最坏 O(n²),且依赖字符串替换 API,不如栈优雅。

2. 最小栈(简单)

设计一个栈,支持 pushpoptop,并能在 O(1) 时间内返回栈中最小元素。

最优解:辅助栈。难点在于:只用一个变量记录最小值是不够的——因为栈会 pop,一旦弹出的正好是那个最小值,就得知道”次小值”是谁,而单个变量无从回溯。

解法是让”历史最小值”也随栈的形态一起变化:开一个与数据栈同步压弹的最小值栈 mins,它的栈顶始终等于”当前数据栈里的最小值”。push(v) 时,新的最小值就是 min(v, 之前的最小值),一起压入;pop 时两个栈一起弹出,mins 的新栈顶自然就回到了”去掉刚弹出元素后”的最小值。这样 getMin 只需读 mins 栈顶,O(1)。核心思想是用一个与主栈同生共死的辅助结构,把”每个状态下的最小值”都缓存下来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * 最小栈:O(1) 获取栈中最小元素。
 * A stack that returns its minimum element in O(1).
 */
class MinStack {
    private val data = ArrayDeque<Int>()
    private val mins = ArrayDeque<Int>()  // 辅助栈,栈顶始终是当前最小值

    fun push(value: Int) {
        data.addLast(value)
        // 新的最小值 = min(当前值, 之前的最小值)
        mins.addLast(if (mins.isEmpty()) value else minOf(value, mins.last()))
    }

    fun pop() {
        data.removeLast()
        mins.removeLast()
    }

    fun top(): Int = data.last()

    fun getMin(): Int = mins.last()
}
  • 所有操作 O(1),空间 O(n)

非最优解:每次遍历求最小。只用一个栈,getMin 时遍历整个栈找最小值。空间省了,但 getMin 退化到 O(n),不满足题目”O(1) 取最小”的要求。(进阶:还可用”只存差值”的技巧把辅助栈省掉,可作为加分回答。)

3. 每日温度(中等)

给定每日温度数组,返回一个数组,第 i 项表示第 i 天之后还要等几天才会遇到更高温度,没有则为 0。这是单调栈的典型题。

最优解:单调栈。这题问的是”每一天右边第一个更高温度在哪”,属于经典的”找右边第一个更大元素“。暴力要为每天向右扫,重复比较很多。单调栈的思路是:把那些”还在等更高温度”的天先缓存进栈,一旦来了更高温度,就一次性结算它们

栈里存的是下标,且保证栈内对应温度从栈底到栈顶单调递减。为什么能保持递减?因为每来一天 i,只要它比栈顶那天温度高,就说明栈顶那天”等到了”——弹出它并记下天数差 i - prev;一直弹到栈顶温度 ≥ 当前,再把 i 压入。这样比当前低的天都被清走了,递减性自然维持。每个下标最多进栈一次、出栈一次,所以是 O(n)

[73,74,72,76] 为例(栈内存下标):

i温度触发弹出(结算)栈(底→顶,下标)res
0730[0,0,0,0]
174弹 0,res[0]=1-0=11[1,0,0,0]
272无(72<74)1 2[1,0,0,0]
376弹 2 res[2]=3-2=1;弹 1 res[1]=3-1=23[1,2,1,0]

遍历结束仍留在栈里的天(下标 3)右边没有更高温度,res 保持默认 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
 * 每日温度:求每天还要等多少天才有更高温度(单调栈)。
 * Daily temperatures via a monotonic stack.
 * @param temperatures 每日温度数组
 * @return IntArray 每天需要等待的天数
 */
fun dailyTemperatures(temperatures: IntArray): IntArray {
    val res = IntArray(temperatures.size)
    val stack = ArrayDeque<Int>()  // 存下标,对应温度单调递减
    for (i in temperatures.indices) {
        // 当前温度比栈顶那天高,就找到了栈顶那天的答案
        while (stack.isNotEmpty() && temperatures[i] > temperatures[stack.last()]) {
            val prev = stack.removeLast()
            res[prev] = i - prev
        }
        stack.addLast(i)
    }
    return res
}
  • 时间 O(n)(每个下标最多进栈出栈一次),空间 O(n)

非最优解:暴力双重循环。对每一天,往后逐天找第一个更高温度,O(n²)。单调栈的妙处在于”用一个递减栈把已经遍历过、还在等更高温度的天缓存起来”,避免重复回溯,把 O(n²) 降到 O(n)

4. 逆波兰表达式求值(中等)

计算逆波兰(后缀)表达式的值,如 ["2","1","+","3","*"] = (2+1)*3 = 9

最优解:栈。中缀表达式(我们平时写的 2+1*3)求值麻烦,是因为要处理运算符优先级和括号。而后缀(逆波兰)表达式已经把运算顺序编码进了排列里:运算符出现时,它需要的两个操作数一定就是紧邻它之前算好的两个值——这天然契合栈”最近的先取”。

于是规则很直接:遇到数字就入栈;遇到运算符就弹出栈顶两个数、运算、把结果压回。最容易写错的是减法和除法的操作数顺序:栈是后进先出,所以先弹出的是右操作数 b,后弹出的才是左操作数 a,必须算 a - ba / b 而不能反。

["2","1","+","3","*"] 为例:压 2、压 1 → 栈 [2,1];遇 + 弹出 122+1=3 压回 → [3];压 3[3,3];遇 * 弹出 333*3=9[9],结果 9。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * 逆波兰表达式求值。
 * Evaluate reverse Polish notation.
 * @param tokens 逆波兰表达式的 token 数组
 * @return Int 表达式的计算结果
 */
fun evalRPN(tokens: Array<String>): Int {
    val stack = ArrayDeque<Int>()
    for (t in tokens) {
        when (t) {
            "+", "-", "*", "/" -> {
                val b = stack.removeLast()   // 注意:后弹出的是左操作数
                val a = stack.removeLast()
                stack.addLast(
                    when (t) {
                        "+" -> a + b
                        "-" -> a - b
                        "*" -> a * b
                        else -> a / b
                    }
                )
            }
            else -> stack.addLast(t.toInt())  // 数字直接入栈
        }
    }
    return stack.last()
}
  • 时间 O(n),空间 O(n)

说明:本题栈解法就是标准最优解,几乎没有更差的常见解法可比——它正是”栈解决就近匹配”这一思想的直接体现。面试时要点明减法、除法的操作数顺序(先弹出的是右操作数),这是最容易写错的地方。

二、队列

队列的高频考点是用两个栈模拟队列(考察对两种结构的理解),以及单调队列(滑动窗口极值)。

1. 用两个栈实现队列(简单)

用两个栈实现一个队列,支持在队尾插入、从队首删除。

最优解:一个”入队栈” + 一个”出队栈”。栈是后进先出,队列要先进先出,两者顺序正好相反。而“反转一次顺序”恰好可以用第二个栈完成:把一个栈里的元素逐个弹出再压入另一个栈,顺序就颠倒了一次——从”后进先出”变回”先进先出”。

具体是:入队一律压进 inStack;出队时,如果 outStack 为空,就把 inStack 的元素全部倒进 outStack(此时 outStack 栈顶正是最早入队的元素),再从 outStack 弹出。

graph LR
  A["依次入队 1,2,3<br/>压入 inStack"] --> B["inStack 自顶向下<br/>3, 2, 1"]
  B -->|"outStack 为空时<br/>逐个弹出再压入(反转)"| C["outStack 自顶向下<br/>1, 2, 3"]
  C --> D["从 outStack 弹出<br/>1 → 2 → 3(FIFO ✓)"]

关键优化是 transfer 里那句 “只有 outStack 空了才倒”:只要 outStack 里还有货,就一直从它弹,不去打扰 inStack;等它彻底空了才一次性补充。这样每个元素一生只被”搬运”一次(进 in、倒进 out、出 out),所以虽然单次 pop 偶尔要搬一整批,但均摊到每个操作是 O(1)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * 用两个栈实现队列。
 * Implement a queue using two stacks.
 */
class MyQueue {
    private val inStack = ArrayDeque<Int>()   // 负责入队
    private val outStack = ArrayDeque<Int>()  // 负责出队

    fun push(x: Int) = inStack.addLast(x)

    fun pop(): Int {
        transfer()
        return outStack.removeLast()
    }

    fun peek(): Int {
        transfer()
        return outStack.last()
    }

    fun empty(): Boolean = inStack.isEmpty() && outStack.isEmpty()

    /**
     * 当出队栈为空时,把入队栈的元素全部倒过去(顺序反转成 FIFO)。
     * Move all elements from inStack to outStack when outStack is empty.
     */
    private fun transfer() {
        if (outStack.isEmpty()) {
            while (inStack.isNotEmpty()) outStack.addLast(inStack.removeLast())
        }
    }
}
  • 均摊 O(1):每个元素最多被”倒”一次,push/pop 均摊常数时间。

非最优解:每次出队都倒来倒去。出队时无脑把元素倒过去、弹出、再倒回来,每次 O(n)。关键优化是”只有 outStack 空了才倒“,让每个元素一生只搬一次,从而做到均摊 O(1)

2. 滑动窗口最大值(中等偏难)

给定数组和窗口大小 k,窗口从左向右滑动,返回每个窗口内的最大值。这是单调队列的经典题。

最优解:单调队列(双端队列)。求每个窗口最大值,暴力要在每个窗口里重新比一遍。单调队列的核心洞察是:当一个较大的新元素进来时,它左边所有比它小、且在它后面出场的元素,都永远没机会再当最大值了——因为只要新元素还在窗口里,它就压着那些小的;等新元素滑出窗口时,那些更早的小元素早就先滑出了。既然如此,它们可以被直接丢弃。

用一个双端队列存下标,维持”对应值从队首到队尾单调递减”。每步做三件事:

  1. 队尾清理:当前值比队尾大,就把队尾一路弹掉(它们失去了成为最大值的资格),再把当前下标入队——递减性得以维持;
  2. 队首过期:队首下标若已滑出窗口(≤ i - k),从队首移除;
  3. 取答案:窗口一旦形成(i ≥ k-1),队首下标对应的值就是当前窗口最大值。

注意”队尾”和”队首”两端各司其职:队尾负责维护单调性,队首负责淘汰过期元素并提供答案,这正是需要双端队列的原因。以 nums=[1,3,-1,-3,5]k=3 为例:

inums[i]队尾清理入队后 deque(下标)队首过期处理记录最大值
010
13弹 0(1<3)1
2-11 2res[0]=nums[1]=3
3-31 2 3res[1]=nums[1]=3
45弹 3、2、1(都 <5)4res[2]=nums[4]=5

得到 [3,3,5]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * 滑动窗口最大值(单调队列)。
 * Sliding window maximum via a monotonic deque.
 * @param nums 整型数组
 * @param k 窗口大小
 * @return IntArray 每个窗口的最大值
 */
fun maxSlidingWindow(nums: IntArray, k: Int): IntArray {
    val res = IntArray(nums.size - k + 1)
    val deque = ArrayDeque<Int>()  // 存下标,对应值单调递减,队首为最大值
    for (i in nums.indices) {
        // 队尾比当前值小的都弹掉(它们不可能再成为最大值)
        while (deque.isNotEmpty() && nums[deque.last()] < nums[i]) deque.removeLast()
        deque.addLast(i)
        // 队首下标已滑出窗口,移除
        if (deque.first() <= i - k) deque.removeFirst()
        // 窗口形成后记录队首(最大值)
        if (i >= k - 1) res[i - k + 1] = nums[deque.first()]
    }
    return res
}
  • 时间 O(n)(每个下标最多进出队一次),空间 O(k)

非最优解一:暴力。对每个窗口遍历求最大值,O(n·k),数据大时超时。

非最优解二:大顶堆。用堆维护窗口内元素,取堆顶为最大值,O(n log k)。比暴力好,但需要处理”过期元素”(懒删除),且不如单调队列的 O(n)。单调队列的核心思想是”一个新来的大元素,会让它前面所有更小的元素永远失去成为最大值的资格“,所以可以直接把它们丢弃。

三、字符串

字符串题的高频武器是双指针滑动窗口。很多”最长/最短子串”问题都能用滑动窗口在 O(n) 内解决。

1. 最长公共前缀(简单)

查找字符串数组中所有字符串的最长公共前缀,不存在返回 ""

最优解:纵向扫描。公共前缀的长度不可能超过最短的那个字符串,而且只要在某一列上出现分歧,前缀就到此为止。所以把所有字符串”竖着对齐”,一列一列地比:以第一个字符串 strs[0] 为基准,对第 i 列,检查其余每个字符串在第 i 位是否都等于 strs[0][i]

一旦遇到两种情况之一就立即返回 strs[0].substring(0, i):某个字符串已经走到末尾(i == str.length,它比公共前缀还短),或某个字符与基准不一致。这种”逐列比、早发现早退出”的方式,遇到明显分歧时能很快结束,不必把每个字符串都读完。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 查找字符串数组的最长公共前缀(纵向扫描)。
 * Find the longest common prefix among strings.
 * @param strs 字符串数组
 * @return String 最长公共前缀
 */
fun longestCommonPrefix(strs: Array<String>): String {
    if (strs.isEmpty()) return ""
    // 逐个字符列比较
    for (i in strs[0].indices) {
        val c = strs[0][i]
        for (str in strs) {
            // 某字符串到头了,或字符不一致,公共前缀到此为止
            if (i == str.length || str[i] != c) return strs[0].substring(0, i)
        }
    }
    return strs[0]
}
  • 时间 O(n·m)n 个字符串,m 为最短串长度),空间 O(1)

非最优解:横向扫描。先算前两个字符串的公共前缀,再拿它和第三个求公共前缀……逐个缩小。复杂度相同,但当数组里有个别很长的公共前缀、后面却出现短的不匹配串时,纵向扫描能更早终止,通常更优。

2. 无重复字符的最长子串(中等)

找出字符串中不含重复字符的最长子串的长度,如 "abcabcbb" 的答案是 3"abc")。

最优解:滑动窗口 + 哈希表。用一个 [left, right] 的窗口表示”当前这段无重复子串”。right 一路向右扩张吃进新字符,只要窗口内不出现重复,长度就一直更新;一旦 right 指向的字符在窗口内已经出现过,就必须收缩左边界把那个旧的重复字符排除出去。

关键在于左指针怎么跳:用哈希表记 字符 → 最近出现的下标。当新字符 c 的上次位置 lastIndex[c] 落在当前窗口内(>= left)时,直接把 left 跳到 lastIndex[c] + 1——一步到位越过那个重复字符,而不是一格格挪。之所以要判断 >= left,是因为哈希表里可能残留着早已滑出窗口的旧记录,那种情况不该触发收缩。

整个过程 left 只增不减、right 走一遍,两个指针各扫一趟,所以是 O(n),把暴力的两层循环压成了一次遍历。以 "abcabcbb" 为例,窗口从 "abc" 开始,right 到第二个 aleft 跳过第一个 a,窗口变成 "bca",长度始终维持在 3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * 无重复字符的最长子串长度(滑动窗口)。
 * Length of the longest substring without repeating characters.
 * @param s 输入字符串
 * @return Int 最长无重复子串的长度
 */
fun lengthOfLongestSubstring(s: String): Int {
    val lastIndex = HashMap<Char, Int>()  // 字符 -> 最近一次出现的下标
    var left = 0
    var best = 0
    for (right in s.indices) {
        val c = s[right]
        // 若字符在窗口内出现过,左边界跳到其上次位置的下一位
        if (lastIndex.containsKey(c) && lastIndex[c]!! >= left) {
            left = lastIndex[c]!! + 1
        }
        lastIndex[c] = right
        best = maxOf(best, right - left + 1)
    }
    return best
}
  • 时间 O(n),空间 O(min(n, 字符集大小))

非最优解:暴力枚举所有子串。枚举每个起点,向后延伸直到出现重复,O(n²) 甚至 O(n³)(若每次用 Set 重新判重)。滑动窗口的关键是”左指针只前进不后退“,把两层循环压成一次遍历。

3. 最长回文子串(中等)

找出字符串中最长的回文子串,如 "babad" 的答案是 "bab""aba"

最优解:中心扩展。回文串的定义就是”关于中心左右对称”。与其枚举所有子串再判断,不如反过来枚举中心、向两边扩张:从中心出发,只要左右字符相等就同时向外走一步,直到不等或越界,走过的这段就是以该中心为轴的最长回文。

难点是回文有奇偶两种"aba" 长度为奇,中心是正中间那个字符;"abba" 长度为偶,中心落在两个字符的间隙里。所以要枚举 n 个单字符中心(expand(i, i))和 n-1 个间隙中心(expand(i, i+1)),共 2n-1 个中心,才不会漏掉偶回文。

还有个易错点是扩展结束后长度的计算while 循环退出时,leftright 已经各自多走了一步(指向不相等或越界的位置),所以真实回文是 [left+1, right-1],长度为 right - left - 1、起点为 left + 1。每个中心最多扩张 O(n),共 2n-1 个中心,故整体 O(n²),但只用常数额外空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
 * 最长回文子串(中心扩展法)。
 * Longest palindromic substring by expanding around centers.
 * @param s 输入字符串
 * @return String 最长回文子串
 */
fun longestPalindrome(s: String): String {
    if (s.length < 2) return s
    var start = 0
    var maxLen = 1

    /**
     * 从中心 (l, r) 向两边扩展,返回该中心能得到的最长回文长度。
     * Expand around center (l, r) and return the longest palindrome length.
     */
    fun expand(l: Int, r: Int) {
        var left = l
        var right = r
        while (left >= 0 && right < s.length && s[left] == s[right]) {
            left--
            right++
        }
        // 循环结束时多走了一步,真实长度为 right - left - 1
        val len = right - left - 1
        if (len > maxLen) {
            maxLen = len
            start = left + 1
        }
    }

    for (i in s.indices) {
        expand(i, i)      // 奇数长度回文,中心是单个字符
        expand(i, i + 1)  // 偶数长度回文,中心是两个字符间隙
    }
    return s.substring(start, start + maxLen)
}
  • 时间 O(n²),空间 O(1)

非最优解一:暴力。枚举所有子串再逐个判断是否回文,O(n³)

非最优解二:动态规划dp[i][j] 表示 s[i..j] 是否回文,dp[i][j] = (s[i]==s[j]) && dp[i+1][j-1]。时间 O(n²) 但空间也要 O(n²),不如中心扩展省空间。(追求极致可用 O(n) 的 Manacher 算法,但实现复杂,面试能讲清中心扩展即可。)

4. 字符串相加(简单)

给定两个用字符串表示的非负整数 num1num2,返回它们的和(字符串形式),不能直接转成整数(大数会溢出)。牛客高频题。

最优解:双指针从末位模拟竖式加法。既然不能转成整数(大数溢出),就回到小学列竖式加法的做法:从最低位(字符串末尾)对齐,逐位相加,满十进一

两个指针 ij 分别从两个字符串末尾往前走,每一步取出各自当前位的数字(某个字符串先走完就用 0 补位),加上上一位的进位 carry,得到 sum:本位数字是 sum % 10,新的进位是 sum / 10。循环条件 i >= 0 || j >= 0 || carry > 0 里的 carry > 0 很关键——它负责处理最高位还有进位的情况(如 "99" + "1" = "100"),漏了会算错。

因为是从低位往高位算、结果也是从低位往高位拼的,所以最后要把 StringBuilder 整体反转才是正确顺序。用字符相减 num1[i] - '0' 把字符转成数字,是处理字符串数字的常用技巧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * 字符串形式的大数相加。
 * Add two non-negative integers represented as strings.
 * @param num1 第一个数字字符串
 * @param num2 第二个数字字符串
 * @return String 两数之和的字符串
 */
fun addStrings(num1: String, num2: String): String {
    val sb = StringBuilder()
    var i = num1.length - 1
    var j = num2.length - 1
    var carry = 0  // 进位
    while (i >= 0 || j >= 0 || carry > 0) {
        val a = if (i >= 0) num1[i--] - '0' else 0
        val b = if (j >= 0) num2[j--] - '0' else 0
        val sum = a + b + carry
        sb.append(sum % 10)   // 当前位
        carry = sum / 10      // 进位
    }
    return sb.reverse().toString()
}
  • 时间 O(max(m, n)),空间 O(max(m, n))(结果)。

非最优解:转成整数相加(num1.toLong() + num2.toLong()).toString()。看似简单,但大数会溢出(题目正是要考这个),只能用于长度很小的输入,是本题明确要避开的错误解法。

结语:三种结构的”套路武器”

结构高频武器一句话心法
单调栈“找下一个更大/更小元素”就用它,把 O(n²) 降到 O(n)
队列单调队列、双栈滑动窗口极值用单调队列;两个栈可互相模拟另一种结构
字符串双指针、滑动窗口“最长/最短无重复子串”优先想滑动窗口,左指针只进不退

💡 面试建议:单调栈和单调队列是很多人的盲区,但它们的思想其实一致——及时丢弃那些”永远不可能再成为答案”的元素(被更大的数挡住、或已滑出窗口)。想清楚”什么样的元素可以安全丢弃”,这两类题就通了。更基础的数组/链表/哈希/二叉树题,见上一篇 高频算法面试题整理

本文由作者按照 CC BY 4.0 进行授权