[Java]Leetcode 20.ValidParentheses 深度解析:从小白🤪到大师🔥!

3 阅读6分钟

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 分钟,老脸一红 🤣。我知道这版代码离“优雅”还差得远,大家轻喷!🙏…

复杂度分析

  • 时间复杂度:O(nk)O(n \cdot k) (实际表现为 O(n)O(n))

    • 对于 nn 个字符中的每一个,我们都执行了 op.contains(c)ed.indexOf(c)
    • 虽然括号种类 kk 是常数 (k=3k=3),但在数学上虽是 O(n)O(n),实际运行中频繁的线性搜索开销不小。
  • 空间复杂度:O(n)O(n)

    • 使用了 ArrayDeque<Character>,最坏情况下会存储 nn 个引用。
    • 内存占用: 较高。每个 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() 为了内存效率,优先使用 charAttoCharArray() 会在内存中创建字符串的完整副本,在大输入量下会导致内存占用翻倍。
  • 使用 switch 对于少量固定的分支,JVM 会将 switch 编译为跳转表 (Jump table),执行效率极高。

性能分析

  • 时间复杂度:O(n)O(n)

    • 改进:消除了方案 1 中的 O(k)O(k) 线性搜索。
  • 空间复杂度:O(n)O(n)

    • 依然需要存储最多 nn 个字符,且仍在使用对象包装类。
  • 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。

性能分析

  • 时间复杂度:O(n)O(n)

    • 极致优化: 常数项降到了最低。除了 charAt 外没有任何方法调用,循环内没有任何对象创建。
  • 空间复杂度:O(n)O(n)

    • 内存占用极小: 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! 🚀


如果你觉得这篇文章有帮助,请点赞支持一下,谢谢大家!☺️