力扣第71题"简化路径"是我刷题时反复看了好几遍的一道中等题。说它简单吧,核心逻辑确实就十几行;说它难吧,它在一堆边界条件上藏了不少坑,稍不注意就是连续三个提交翻车。我当时先写了 Python 版,觉得稳了,结果/...这个用例直接让我清醒;换 Java 实现时又因为栈的遍历顺序问题输出反了。这篇文章把这道题的完整思路、双语言实现方式、以及我实测过的所有边界场景都整理出来。如果你想准备算法面试,或者刚学完栈想找一道实战题练手,这篇笔记值得从头看到尾。
1. 题目拆解:三个规则读懂"简化路径"
1.1 一上来先搞清楚:路径到底怎么走
题目输入是一个 Unix 风格绝对路径,比如/a/./b/../../c/,要求输出简化后的结果。要理解这题,得先建立"目录栈"的直觉。你在终端里执行cd /a/b,实际上就是从根目录往下走两级;执行cd ..就是往回退一级。题目里的.表示当前目录,..表示上一级目录,连在一起的//只算一个分隔符。
官方几个示例很能说明问题:
/home/简化后是/home,末尾多余的斜杠去掉。/../简化后是/,根目录的上一级还是根目录。/home//foo/简化后是/home/foo,连续斜杠被合并。
简化后的路径必须满足四个约束:以单个/开头;目录之间只保留一个/;不以/结尾(除非整个路径就是根目录/);路径里不允许出现.和..这样的成分。
我一开始以为这就是字符串清洗,把多余的斜杠删掉、把.段删掉、把..段连同前面的目录一起删掉就行了。后来发现,真正的难点在于"删除"这个动作怎么组织才不出错,尤其是连续多个..的情况。这题本质上是在考察你对"层级关系"的建模能力,而不是字符串处理能力。
1.2 最容易被忽略的规则:什么才是合法目录名
题目里有一句很多人会扫过去的关键描述:任何其他形式的点(比如...)都要当作合法目录名处理。这句话直接决定了一个经典用例:输入/...,输出依然是/...,而不是/或者报错。
为什么这是坑?因为很多人在写判断时习惯用if part == ".."和if part == "."两个分支,这没问题。但如果你用的是"包含点就跳过"这种笼统逻辑,比如if "." in part: continue,那...、.hidden、a.b这些合法目录名就全被误杀了。
更隐蔽的是,.hidden这种以点开头的文件在 Linux 里是隐藏文件,作为目录名完全合法;a.b这种中间带点的目录名也合法。所以正确的判断逻辑必须是精确相等,而不是包含关系。
还有一个容易忽略的点:..在栈为空时应该怎么处理?比如输入/../,栈里什么都没有,这时候..想往上跳,总不能跳出一个不存在的位置。正确做法是直接忽略它,什么都不做。这一点也对应了"根目录的父目录还是根目录"这条隐含规则。
2. 思路对比:正则替换、手动扫描与"切分+栈"
2.1 我一开始想用正则,后来放弃了
拿到题目时,我脑子里冒出的第一个方案是用正则或者字符串替换:先把//替换成/,再处理.和..。但这个思路在实操中很快暴露问题。
第一个问题:替换顺序很难定。如果你先把//替换成/,再去掉.段,那/a/./b/变成/a/b/之后,还得再处理结尾斜杠。如果是/a/././b,连续多个.段也还好,但一旦混进..,比如/a/b/../c,你需要"向前删一个目录",这用纯字符串替换根本没法表达。
第二个问题:..是连续的、有状态的。/a/b/c/../../..对应的是先删c,再删b,最后删a,一直退到根。这种"根据之前入过的目录决定删除目标"的逻辑,只有用栈或者类似的线性数据结构才能自然表达。正则是一种无状态匹配工具,用它做有状态操作,代码只会越写越扭曲。
所以我最终放弃了正则路线。这个试错过程也有价值:它让我明白,刷题时先判断题目背后的数据结构需求,比先想字符串技巧更重要。
2.2 用栈模拟目录栈的完整推演
栈方案的核心思想非常直白:把路径按/切分成若干段,然后遍历每一段,用栈记录"当前所在位置"。遇到普通目录名就压栈;遇到..就弹栈(如果栈非空);遇到.或者空字符串就跳过。最后把栈里的目录名用/拼起来。
拿官方示例/a/./b/../../c/手动推演一遍。按/切分后得到["", "a", ".", "b", "..", "..", "c", ""],然后逐个处理:
| 当前段 | 动作 | 栈内容 |
|---|---|---|
| "" | 空串,跳过 | [] |
| "a" | 普通目录,入栈 | ["a"] |
| "." | 当前目录,跳过 | ["a"] |
| "b" | 普通目录,入栈 | ["a", "b"] |
| ".." | 上一级,弹出 b | ["a"] |
| ".." | 上一级,弹出 a | [] |
| "c" | 普通目录,入栈 | ["c"] |
| "" | 空串,跳过 | ["c"] |
遍历结束后栈里只剩c,拼接结果是/c,和题目输出完全一致。整个过程不需要回头看,也不需要预判后续段,每一段只根据当前栈的状态做一次入栈或出栈操作。这就是栈结构最典型的应用场景:匹配、回溯、撤销这类"反向操作"。
2.3 时间与空间复杂度为什么是最优
分析复杂度时,首先要明确 n 的定义。这里 n 是输入路径字符串的长度。split操作本身要遍历整个字符串,时间复杂度 O(n);切分后得到的每一段只被处理一次,入栈、出栈都是 O(1) 的数组操作。所以总时间复杂度是 O(n)。空间上,栈里最多存储所有目录段,即切分结果的规模同样不超过 O(n),所以空间复杂度是 O(n)。
可能有人会问:能不能做到 O(1) 空间?如果你允许原地修改字符串并且不需要保存中间目录,理论上可以,但那样代码复杂度会大幅上升,而且面试时没必要。对于这道题,O(n) 时间和 O(n) 空间就是最优、也最简洁的平衡点。面试官问我为什么不用手动指针逐个字符扫描时,我的回答是:切分加栈已经覆盖了所有边界情况,手动扫描反而要自己处理连续斜杠、段边界、空串这些细节,更容易出错。代码的可读性和正确性,比省一个循环更重要。
3. 双语言实现:Python三行核心逻辑,Java注意输出顺序
3.1 Python版本:短而清晰的参考写法
Python 的写法非常简洁,核心逻辑不超过五步:
class Solution: def simplifyPath(self, path: str) -> str: stack = [] for part in path.split('/'): if part == '' or part == '.': continue if part == '..': if stack: stack.pop() else: stack.append(part) return '/' + '/'.join(stack)这里最关键的两个点:一是判断必须用精确相等,不能把...误判成..或.;二是if stack的判断不能省,它保证栈为空时遇到..不会抛异常,而是静默忽略。
'/'.join(stack)是 Python 里非常顺手的拼接方式。如果栈为空,join结果是空字符串,前面加一个/就成了根目录/,刚好满足"根目录单独返回/"的要求。
我自己做这题时的习惯是先在本地跑一遍官方五个示例,再跑几个自己想出来的怪用例,比如"/..."和"/a//b////c/d//././/.."。这套流程走完,Python 版基本就不会翻车了。
3.2 Java版本:ArrayDeque和Stack怎么选
Java 版本的思路和 Python 完全一致,但有两个实现上的坑。第一个是容器选择。虽然题解里经常写Stack,但它是早期遗留类,继承了 Vector,所有方法都加锁,性能不占优势,官方文档也建议优先用ArrayDeque或Deque接口。所以我的参考写法用的是Deque:
class Solution { public String simplifyPath(String path) { Deque<String> stack = new ArrayDeque<>(); for (String part : path.split("/")) { if (part.isEmpty() || part.equals(".")) { continue; } if (part.equals("..")) { if (!stack.isEmpty()) { stack.pop(); } } else { stack.push(part); } } StringBuilder sb = new StringBuilder(); while (!stack.isEmpty()) { sb.append('/').append(stack.removeLast()); } return sb.length() == 0 ? "/" : sb.toString(); } }第二个坑就是输出顺序。我一开始用while (!stack.isEmpty()) { sb.append('/').append(stack.pop()); },想着把元素从头弹出来拼就行。但push是往头部插入,pop也是从头部弹出,这样一来栈里存的顺序是反的。比如处理完a、b两个目录,栈里从头部到尾部是b -> a,用pop拼出来的结果是/b/a,正好和正确答案/a/b相反。
正确做法是用removeLast()从尾部取元素,或者在入栈时就用addLast()、出栈时用removeLast()来模拟"按顺序入队"的效果。我的建议是统一记住一个模式:如果用的是push,拼结果时就必须用removeLast。
3.3 不同语言split行为的坑点
同样一句path.split("/"),Python 和 Java 的行为细节有差异,虽然最终不影响这份代码的正确性,但理解清楚能避免不少困惑。
Python 的split('/')会保留所有空串。比如"/".split('/')结果是['', ''],"/a/".split('/')结果是['', 'a', '']。这意味着切分结果里可能包含多个空字符串,所以代码里必须处理空串的情况。
Java 的split("/")默认会丢弃末尾的空字符串。"/a/".split("/")结果是["", "a"],最后那个空串被丢弃了。更特殊的是"/".split("/")在 Java 中返回一个长度为零的数组,循环压根不会执行。我们的代码对空串和.都做跳过处理,所以不管 split 返回的是['', 'a', '']还是["", "a"],最终结果一致。但如果你是新手,第一次看 Java 的 split 返回结果,可能以为代码写错了,这里提前打个预防针。
如果你的面试语言是 Java,建议在写完后主动提一句"这里 split 会丢掉尾部的空串,我们对空串做了防御性判断,所以不受影响",面试官会觉得你对语言细节很敏感。
4. 边界测试实录:这几个用例让很多提交翻车
4.1 三个点"..."不是"..":最经典的误判陷阱
这个坑我专门拿出来说,因为它在讨论区出现的频率极高。很多人的第一版代码长这样:
for part in path.split('/'): if part == '': continue if '..' in part: if stack: stack.pop() else: stack.append(part)看起来没问题,但'..' in '...'在 Python 中是 True,所以/...会被错误地当成..处理,最终输出/,而正确答案应该是/...。...是一个合法的目录名,文件名里出现多个点非常正常。判断..必须用精确相等,这一点无论如何强调都不为过。
顺带一提,.hidden这种目录也不能被误杀。有些写法会用part[0] == '.'来判断是不是特殊目录,这在测试用例/.hidden上会失败。正确逻辑只有一个标准:遇到.跳过,遇到..出栈,遇到其他任何字符串都入栈,包括以点开头、中间带点的字符串。
4.2 连续斜杠与结尾斜杠:split帮你处理了大半
连续斜杠的问题,比如/home//foo/,如果你手动扫描字符,需要自己维护"当前是否在一个空段中"的状态。但用 split 就简单了://切分后会产生一个空串,空串被跳过,等价于把多个连续斜杠压缩成一个。
结尾斜杠同理。/home/切分后最后一个空串被跳过,栈里只剩home,拼出来是/home。看到没有,你不需要额外写"去掉末尾斜杠"的逻辑,切分和跳过空串两步组合起来就解决掉了。
不过有一个细节值得留意:如果结尾是..或者普通目录名,比如/home/foo/..,切分后最后一段是..,它会把foo弹出去,最终结果/home。这是符合题意的,因为foo目录其实已经被退出了。
4.3 根目录、空路径与栈空时的".."
根目录是最容易出错的边界。输入/时,Python 切分出两个空串,Java 切分出零段,无论哪种情况,栈都是空的,最终返回'/' + ''.join或sb.length() == 0 ? "/",结果都是/。这里的实现细节是:拼接结果是空字符串时要兜底返回/。
栈空时遇到..也必须忽略。比如输入/../,切分后只有空串和..,栈始终为空,..什么都不做,最后返回/。如果代码写成stack.pop()而不加空栈判断,Python 会抛IndexError,Java 会抛NoSuchElementException,提交直接报运行时错误。
关于空路径,题目保证绝对路径以/开头,所以严格来说不会输入空字符串。但作为防御性编程,处理空串返回/也不会有问题。面试时可以稍稍提一句,显得你考虑边界比题目要求的更全面。
4.4 一份可直接复用的测试用例速查表
我自己写题时会把下面这些用例存成一个测试清单,做任何栈相关题目都能拿来参考。这里直接分享出来:
| 输入 | 期望输出 | 验证点 |
|---|---|---|
/home/ | /home | 去掉末尾斜杠 |
/../ | / | 栈空时..被忽略 |
/home//foo/ | /home/foo | 连续斜杠合并 |
/a/./b/../../c/ | /c | 综合入栈出栈 |
/a/../../b/../c//.// | /c | 多次..加混合场景 |
/a//b////c/d//././/.. | /a/b/c | 官方复杂示例 |
/... | /... | 三点是合法目录名 |
/.hidden | /.hidden | 点开头的隐藏目录合法 |
/ | / | 根目录 |
/a/../.. | / | ..溢出到根后忽略 |
这套用例跑完,代码的正确性基本就有保障了。我在实际提交前会把这十组用例全部过一遍,宁可多花两分钟,也不要反复提交浪费等待时间。
5. 套路迁移:从简化路径看栈类题目的通用解法
5.1 栈类题目的识别信号
做完第71题后,我回头总结了一下,发现很多栈类题目都有相似的特征。识别信号有三个:一是题目里出现"撤销""回退""取消上一步"等动作,比如括号匹配的右括号要撤销左括号、..要撤销上一级目录;二是出现嵌套或层级结构,比如路径的目录层级、表达式中的括号层级;三是操作顺序是从左到右,且当前的决策依赖历史状态。
第71题完美命中前两条:..是明确的回退操作,路径是明确的层级结构。所以当你读完题发现这两个信号,第一反应就应该是"这题大概率用栈"。
这个识别能力比记住某一道题的解法更重要。我后来又做了一系列堆栈相关的题目,包括括号匹配、逆波兰表达式求值、基本计算器、最长绝对路径等等,用的都是同一套思维框架。刷题刷到后面,你会发现所谓"套路",就是对数据结构适用场景的敏感度。
5.2 相似题目与变体扩展
如果想把栈的套路练透,建议按这个顺序做几道题:先做括号匹配,理解栈的"配对"作用;再做逆波兰表达式求值,理解栈的"延迟计算"作用;然后回到这题,理解栈的"层级回退"作用;最后可以做最长绝对路径,那题会用一个栈保存每一层的长度,算是一道综合应用题。
第71题本身也有不少变体值得思考。比如输入不是以/开头的相对路径,而是./a/../b这种,处理逻辑基本不变,只需要考虑开头是否要补一个/。再比如 Windows 风格路径用反斜杠\分隔,那你只需要把分隔符换掉,栈的逻辑完全一样。还有的变体会要求保留路径中重复的斜杠,这种情况下切分方案就不适用了,得改用逐字符扫描,不过实际面试中很少这么考。
我个人做这类题目时有个习惯:先不看题解,自己把边界用例列出来,再写核心逻辑。第71题这个经验我后面一直在用,因为大多数翻车都不是算法不会,而是边界没想全。先列用例、再写代码、最后对照用例自查,这十分钟的流程能帮你省下多次提交错误的时间。如果你也卡在某个用例上过不去,拿上面那张速查表逐条跑一遍,基本就能定位到问题出在哪一类分支里了。