栈·队列·字符串高频算法面试题整理(Kotlin 题解,含最优解与其他解法)
承接数组链表哈希二叉树专题,继续整理栈、队列、字符串三类力扣/牛客高频算法面试题。每题用 Kotlin 给出完整题解,对比单调栈/单调队列/滑动窗口等最优解与暴力解的复杂度差异。
这是算法面试题整理的第二篇,承接上一篇 数组·链表·哈希·二叉树高频算法面试题整理,本篇聚焦栈、队列、字符串三类。它们背后有几个特别高频的”套路武器”:单调栈、单调队列、滑动窗口——掌握了它们,一大批看起来很难的题会瞬间变简单。
每题依旧遵循同一结构:题意与思路 → Kotlin 实现 → 对比非最优解 → 时空复杂度分析。原理层面还没打牢的同学,建议先读姊妹篇 一文通俗读懂常见数据结构。
栈(Stack)是”后进先出 LIFO”,队列(Queue)是”先进先出 FIFO”。Kotlin 里常用
ArrayDeque同时充当栈和队列:addLast/removeLast当栈用,addLast/removeFirst当队列用。
一、栈
栈最擅长处理“就近匹配 / 就近比较”的问题:括号匹配、表达式求值、”找下一个更大的元素”(单调栈)都是它的主场。
1. 有效的括号(简单)
判断字符串是否是有效括号序列,如
"()[]{}"有效,"(]"无效。
最优解:栈。遇到左括号就入栈,遇到右括号就看栈顶是否是与之匹配的左括号——是则出栈,否则无效。最后栈空才有效。
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. 最小栈(简单)
设计一个栈,支持
push、pop、top,并能在 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。这是单调栈的典型题。
最优解:单调栈。栈里存”还没等到更高温度的天的下标”,保持栈内对应温度单调递减。遇到更高温度时,把栈里所有比它低的天依次弹出并计算天数差。
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。
最优解:栈。遇到数字入栈,遇到运算符弹出栈顶两个数运算后把结果压回。后缀表达式天生适合用栈求值,无需处理运算符优先级。
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(顺序恰好反转成 FIFO),再从 outStack 弹出。
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
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. 最长公共前缀(简单)
查找字符串数组中所有字符串的最长公共前缀,不存在返回
""。
最优解:纵向扫描。以第一个字符串为基准,逐列比较所有字符串在该位置的字符,一旦发现不一致或某字符串已到末尾,就返回。
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")。
最优解:滑动窗口 + 哈希表。右指针不断扩张窗口,用哈希表记录每个字符最近出现的位置;一旦遇到窗口内已存在的重复字符,就把左指针跳到该重复字符上次位置的下一位。
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"。
最优解:中心扩展。回文串关于中心对称,所以枚举每个可能的中心(n 个单字符中心 + n-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
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. 字符串相加(简单)
给定两个用字符串表示的非负整数
num1、num2,返回它们的和(字符串形式),不能直接转成整数(大数会溢出)。牛客高频题。
最优解:双指针从末位模拟竖式加法。两个指针从两字符串末尾开始,逐位相加并处理进位,结果逆序拼接。
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) |
| 队列 | 单调队列、双栈 | 滑动窗口极值用单调队列;两个栈可互相模拟另一种结构 |
| 字符串 | 双指针、滑动窗口 | “最长/最短无重复子串”优先想滑动窗口,左指针只进不退 |
💡 面试建议:单调栈和单调队列是很多人的盲区,但它们的思想其实一致——及时丢弃那些”永远不可能再成为答案”的元素(被更大的数挡住、或已滑出窗口)。想清楚”什么样的元素可以安全丢弃”,这两类题就通了。更基础的数组/链表/哈希/二叉树题,见上一篇 高频算法面试题整理。