PS: 本文章原文是英文(在此),这个版本是直译过来的,可能有些地方会比较生硬,请见谅。有任何建议和问题都欢迎在评论区交流哦~
TL;DR: 我是一个算法初学者。在这篇文章中,我将分享我是如何从一个平庸的初版方案,一步步优化到击败 100% 的极致性能方案的。我提供了 3 个版本的代码以及详细的演进思路,希望能对你有所启发!
原题目 (链接)
Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid.
An input string is valid if:
- Open brackets must be closed by the same type of brackets.
- Open brackets must be closed in the correct order.
- Every close bracket has a corresponding open bracket of the same type.
方案 1 — 朴素直觉版 (Naïve approach)
class Solution {
public static boolean isValid(String s) {
ArrayDeque<Character> stack = new ArrayDeque<>();
List<Character> op = List.of('(', '{', '[');
List<Character> ed = List.of(')', '}', ']');
for (int i = 0; i < s.length(); i++) {
Character c = s.charAt(i);
if (op.contains(c)) {
stack.addFirst(c);
} else if (ed.contains(c)) {
if (stack.isEmpty()) { return false; }
Character top = stack.getFirst();
if (top != op.get(ed.indexOf(c))) {
return false;
} else {
stack.removeFirst();
}
}
}
return stack.isEmpty();
}
}
思路解析
这是一个非常标准且符合直觉的方案,核心是利用栈的后进先出 (LIFO) 特性:
-
遍历: 逐个检查字符串中的每个字符。
-
入栈 (Push): 遇到左括号时,将其存入栈中,记录下预期的闭合顺序。
-
匹配与出栈 (Match & Pop): 遇到右括号时,验证两个条件:
- 栈不能为空(防止下溢 Underflow)。
- 当前字符必须与栈顶元素匹配。
PS: 快一年没写 Java 了,光是这第一个版本的语法就卡了我 30 分钟,老脸一红 🤣。我知道这版代码离“优雅”还差得远,大家轻喷!🙏…
复杂度分析
-
时间复杂度: (实际表现为 )
- 对于 个字符中的每一个,我们都执行了
op.contains(c)和ed.indexOf(c)。 - 虽然括号种类 是常数 (),但在数学上虽是 ,实际运行中频繁的线性搜索开销不小。
- 对于 个字符中的每一个,我们都执行了
-
空间复杂度:
- 使用了
ArrayDeque<Character>,最坏情况下会存储 个引用。 - 内存占用: 较高。每个
Character都是堆上的对象。在 64 位 JVM 中,对象头(Object Headers)的开销让这个版本比存储原始数据消耗多得多的 RAM。
- 使用了
-
LeetCode 排名:
- Runtime: 4ms (击败 42.05% 😭)
- Memory: 43.50 MB (击败 24.58%)
存在的缺陷 (Drawbacks)
- 自动装箱 (Autoboxing) 开销: 基本类型
char和包装类Character之间的频繁转换产生了不必要的堆内存压力,拖慢了速度。 - 隐藏的线性搜索: 在循环内部使用
List.contains(c)导致每次都要遍历列表。 - 内存足迹:
ArrayDeque<Character>存储的是对象引用。在 Java 中,一个Character对象占用的空间(最高 24 字节)远比一个 2 字节的原始char多。 - 低效映射: 通过
op.get(ed.indexOf(c))来匹配括号的逻辑略显臃肿,增加了方法调用开销。
方案 2 — 逻辑精简版 (Logic Cleanup)
class Solution {
public boolean isValid(String s) {
ArrayDeque<Character> stack = new ArrayDeque<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '(':
stack.push(')');
break;
case '{':
stack.push('}');
break;
case '[':
stack.push(']');
break;
default:
if (stack.isEmpty() || stack.pop() != c) {
return false;
}
}
}
return stack.isEmpty();
}
}
思路解析
在翻阅社区讨论时,我被 phoenix13steve 分享的这个巧妙思路惊艳到了:
这个版本采用了一种预期校验逻辑:
- 反向推导: 遇到左括号时,不再存入左括号本身,而是直接把对应的右括号压入栈。
- 直接对比: 这样当后续遇到右括号时,只需判断
pop()出来的字符是否等于当前字符即可。逻辑瞬间变得清晰无比。 - 语义化: 使用了标准的
push()和pop(),比方案 1 的addFirst更符合栈的语义。
现代化适配 (Modern Adjustments)
考虑到原贴是 2015 年的,我针对 2026 年的标准做了一些优化:
- 用
ArrayDeque替代Stack:Stack是遗留类(Legacy class),其方法带有synchronized同步锁,在单线程环境下会产生不必要的性能损耗,且它继承自Vector并不符合栈的纯粹定义。 - 用
charAt(i)替代toCharArray(): 为了内存效率,优先使用charAt。toCharArray()会在内存中创建字符串的完整副本,在大输入量下会导致内存占用翻倍。 - 使用
switch: 对于少量固定的分支,JVM 会将switch编译为跳转表 (Jump table),执行效率极高。
性能分析
-
时间复杂度:
- 改进:消除了方案 1 中的 线性搜索。
-
空间复杂度:
- 依然需要存储最多 个字符,且仍在使用对象包装类。
-
LeetCode 排名:
- Runtime: 3ms (击败 87.62% 😧)
- Memory: 43.15 MB (击败 84.20%)
存在的缺陷
- 持续的自动装箱: 虽然逻辑变强了,但
Character对象依然存在,垃圾回收 (GC) 压力仍在。 - 缺乏提前剪枝: 如果字符串长度是奇数,显然无效。当前代码仍会处理完整个字符串。
方案 3 — 栈模拟极致性能版 (Stack Simulation)
class Solution {
public boolean isValid(String s) {
int n = s.length();
// 1. 奇数长度提前剪枝
if (n % 2 != 0) return false;
// 2. 使用原生数组模拟栈
char[] stack = new char[n];
int top = -1;
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
switch (c) {
case '(' -> stack[++top] = ')';
case '{' -> stack[++top] = '}';
case '[' -> stack[++top] = ']';
default -> {
// 3. 直接通过指针对比,零方法调用开销
if (top == -1 || stack[top--] != c) return false;
}
}
}
return top == -1;
}
}
思路解析
这个版本是关于性能工程 (Performance Engineering) 的。我完全抛弃了 Java 集合框架,直接与内存对话。
- 原生数组作栈: 用简单的
char[]代替ArrayDeque<Character>。这彻底消除了自动装箱(Auto Boxing)。我们现在存储的是原始的 2 字节char。 - 手动指针 (
top): 不再调用push()或pop()方法,而是通过一个简单的整数指针来追踪栈顶。 - 增强型 Switch (Java 17+): 使用
->语法让代码更简洁,并防止“击穿 (Fall-through)”导致的 Bug。
性能分析
-
时间复杂度:
- 极致优化: 常数项降到了最低。除了
charAt外没有任何方法调用,循环内没有任何对象创建。
- 极致优化: 常数项降到了最低。除了
-
空间复杂度:
- 内存占用极小:
char[]是最紧凑的存储方式,比方案 1 节省了约 10 倍的实际堆内存空间。
- 内存占用极小:
-
LeetCode 排名:
- Runtime: 1ms (击败 99.78% 😎)
- Memory: 40.80 MB (击败 98.06%)
权衡与未来考量 (Trade-offs)
-
可读性 vs 性能: 虽然方案 3 最快,但它属于底层优化。在不追求极致性能的企业级代码中,方案 2 可能是更好的选择,因为它更易读。
-
HashMap 的诱惑: 很多人建议用
HashMap存储映射。- 优点: 扩展性好,增加新括号类型很方便。
- 缺点: 会引入显著的对象开销和哈希计算时间,排名会掉到 2-5ms 左右。在高性能 Java 中,
switch永远是首选。
-
安全性: 手动管理数组指针需要格外小心,确保不会出现越界(虽然本题中
n是安全的上界)。
下一步挑战
如果你也想练习这种“栈模拟”技巧,推荐尝试以下题目:
最后的思考: 永远不要害怕一开始写出“烂代码”。每一个 0ms 的神作都是从 3ms 的方案不断打磨而来的。保持迭代,保持好奇,Happy Coding! 🚀
如果你觉得这篇文章有帮助,请点赞支持一下,谢谢大家!☺️