ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

栈的应用:力扣71题简化路径全解析(Python+Java)

栈的应用:力扣71题简化路径全解析(Python+Java)

力扣第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题这个经验我后面一直在用,因为大多数翻车都不是算法不会,而是边界没想全。先列用例、再写代码、最后对照用例自查,这十分钟的流程能帮你省下多次提交错误的时间。如果你也卡在某个用例上过不去,拿上面那张速查表逐条跑一遍,基本就能定位到问题出在哪一类分支里了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进