坦白说,LeetCode 71 这道"简化路径"题目,我刚刷第一遍时根本没放在心上——以为就是字符串split一下再处理几个点号。直到某次面试官顺着这道题一路追问到"如果路径里有....怎么处理""栈空了遇到..到底该不该报错",我才意识到这题考察的边界远比表面复杂。它看着像一道字符串题,实际上是典型的栈应用,还要附带严谨的语义约定。这篇文章就把我从头到尾的思考过程、代码演进和实测踩坑全部分享出来,给正在刷 LeetCode 71 的朋友做一份完整的参考。
1. 先搞清楚"简化路径"到底简化了什么
1.1 题目给出的路径规则是什么
输入是一个 Unix 风格绝对路径,例如/home//foo/,要求输出规范化后的路径。所谓规范化,通常意味着满足这么几条规则:
- 路径必须以单个
/开头; - 目录名之间用单个
/分隔,多个连续/要合并成一个; - 路径中的
.表示当前目录,可以忽略; - 路径中的
..表示返回上一层目录; - 如果已经是根目录
/,..不应该继续往上跳; - 最终路径末尾不能有
/(除非整个路径就是根目录/)。
拿几个典型样例来说:
| 输入 | 期望输出 |
|---|---|
/home/ | /home |
/a/./b/../../c/ | /c |
/../ | / |
/home//foo/ | /home/foo |
这里面最容易忽略的是:..是一个完整目录段。也就是说/a/.../b中的...不是..,它只是名字里带三个点的普通目录,必须原样保留。这个点我在后文会专门拎出来讲,因为很多解法错就错在用字符串包含判断误伤了...和....。
1.2 读懂题目背后的真实意图
如果把路径看成一条"从根目录出发的导航轨迹",你会发现在这个 Unix 路径模型里,目录之间的移动只有三种动作:进入一个子目录、停留在当前目录、返回上一层目录。敲命令时习惯的cd a、cd .、cd ..,本质上就是对一棵目录树的指针移动。
题目要求输出规范路径,本质上就是对这条"轨迹"做规整:中间可能有原地打转的.,可能有连续斜杠造成的无意义空段,也可能有一连串的..把前面的目录撤销掉。你越早意识到这是一个层级状态的转移问题,就越接近正确解法。单纯用正则去替换//和/./是治标不治本,因为..需要回溯到已记录的某个目录层级,这必须引入"历史状态"。
1.3 哪些输入情况需要特别警惕
我整理过一份容易让第一次写的人翻车的输入集合,先列在这里,后面用它们走查代码:
/../:根目录返回上级仍是根目录;/a/../../b:越级返回时栈已经空了;/a//b////c/d:连续斜杠出现在中间;/a/./b/../c:.和..交错出现;/.和/..这类极短路径;/...:三个点的目录名;/a/..最终回到根目录,需要输出单个/。
2. 为什么这题的标准解法是维护一个栈
2.1 栈与路径天然同构
路径是有层级关系的:"进入子目录"就是往深处走一层,"返回上级目录"就是往浅处回一层。这个"后来者先撤销"的过程,和栈的 LIFO 逻辑完全一致。你访问的目录顺序是一串入栈动作,..就是把栈顶弹出一个目录。最后栈里剩下的元素,按从栈底到栈顶拼接起来,就是规范化后的路径。
用一个生活化的例子理解:你在一栋楼里逐层爬楼梯,每进入一个房间,就在本子上记一笔房间号;每上一级电梯返回大厅,就划掉最近记的房号。等所有动作结束,本子上没被划掉的房号按顺序念出来,就是最终位置。栈做的就是这本"记录本",而..就是"划掉最近一条"。
2.2 解题步骤推演:split 之后的三个分支
完整思路可以拆成以下四步:
- 用
/对原始路径做分割,得到一个字符串数组; - 遍历数组,对每个元素
s判断:- 如果
s是空字符串(来自连续斜杠,因为/a//b分割后中间会有一个空串)或者s是.,直接跳过; - 如果
s是..,执行弹栈操作(栈为空则跳过); - 其他情况说明
s是一个真正的目录名,入栈;
- 如果
- 遍历结束后,如果栈为空,说明路径已经回到根目录,返回
/; - 如果不为空,用
/把所有目录名拼接,并在最前面加上/。
第三步容易漏掉。很多人最后直接return '/' + '/'.join(stack),当栈为空时会得到/,好像也对。但如果你使用的语言里join返回空串,就必须要显式处理根目录情况。Python 中''.join([])是空字符串,'/' + ''就是/,恰好正确;但 Java 中手动拼接时很容易拼出多余斜杠,必须单独判断。
2.3 能不能不用栈?替代思路对比
表单遍历一眼就能看出..需要"追溯历史",但也有几种替代方案:
| 方案 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| 栈/数组模拟 | 用列表模拟栈,只用append和pop | 直观、可控、内存可预知 | 需要一个显式的栈结构 |
| 双向队列 | 换成队列存储路径段 | 便于队首队尾操作 | 多此一举,没有额外需求 |
| 递归解析 | 从左到右扫描并用起始位置记录层级 | 空间上能省略部分临时数组 | 边界情况极易出错,代码可读性差 |
| 正则替换循环 | 反复替换//、/./、/x/../模式 | 不写循环也能过部分样例 | 遇到..连续栈空或嵌套路径时容易误替换 |
我个人的建议是做题时就用列表模拟栈,因为这是面试时最容易解释、也最不容易在边界上翻车的写法。等把栈版本写熟之后,再去研究正则替换,你会发现正则版本的问题正是因为没有状态管理,很难干净地表达"遇到..时上一步入栈了什么"。
3. 完整实现:Python 为主,附递进式代码走查
3.1 最直接的栈版代码
先给一个我自己最终采用的 Python 实现:
def simplifyPath(path: str) -> str: parts = path.split("/") stack = [] for part in parts: if part in ("", "."): # 空串来自连续斜杠,"." 表示当前目录 continue if part == "..": # 返回上级目录:若已经在根目录则忽略 if stack: stack.pop() else: # 普通目录名(也包括 "..."/"...." 等带点的名字) stack.append(part) return "/" + "/".join(stack)如果你担心return "/" + "/".join(stack)在栈空时不符合直觉,也可以写成:
if not stack: return "/" return "/" + "/".join(stack)两种写法最终结果一致。不过显式分支可读性更强,也便于在面试时向面试官强调"你考虑了根目录"。
3.2 逐行走查一个混合样例
拿/a/./b/../../c/这个标准样例来走一遍:
path.split("/")得到["", "a", ".", "b", "..", "..", "c", ""];- 第一元素是空字符串,跳过;
- 遇到
"a",不是空、不是.、不是..,入栈,栈为["a"]; - 遇到
".",跳过; - 遇到
"b",入栈,栈为["a", "b"]; - 遇到第一个
"..",弹出"b",栈为["a"]; - 遇到第二个
"..",弹出"a",栈为[]; - 遇到
"c",入栈,栈为["c"]; - 最后一个空字符串,跳过;
- 最终
/+"c",输出/c。
再走查容易出错的/a/../../b:
- 分割后
["", "a", "..", "..", "b", ""]; "a"入栈;- 第一个
..弹出"a",栈空; - 第二个
..时栈为空,不操作,继续; "b"入栈;- 输出
/b。
这里的关键就在于栈空时遇到..必须静默跳过,而不是抛异常或把..本身当作目录名入栈。
3.3 用另一种语言写时要注意什么
如果面试要求用 Java 写,实现思路完全相同:
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); } } if (stack.isEmpty()) { return "/"; } StringBuilder sb = new StringBuilder(); while (!stack.isEmpty()) { sb.append("/").append(stack.pollLast()); } return sb.toString(); }Java 版本有两个坑。第一,ArrayDeque的push/pop操作的是队首,如果直接while (!stack.isEmpty()) sb.append("/").append(stack.pollFirst()),那路径段顺序会反过来,必须用pollLast()从栈底向栈顶取。第二,path.split("/")在 Java 中会丢弃尾部的空字符串,但不会丢弃开头的,所以"/a/"分割后是["", "a"],第一个空字符串正好能被跳过,逻辑没影响;但如果用 Kotlin,split还带一个limit参数,默认行为可能不同,建议先打日志打印分割结果再继续。
4. 实测边界:我把各种奇怪输入都喂了一遍
4.1 连续斜杠与根目录的混合
我写完第一版后,立刻用一批奇怪的输入做了本地测试。其中几个非常值得注意:
输入: "/home//foo/" 输出: "/home/foo"///被split("/")拆成了多个空字符串,所以不会被当成一个名为空字符串的目录入栈。这也是我不建议直接写正则去replace("//", "/")的原因——一旦路径中出现/.//..这种组合,替换顺序会产生不可控结果。
输入: "/../" 输出: "/"这个输入验证的是栈空时..的行为。注意,某些设计不当的解法会把/..处理成"/..",通过样例测试时看似正常,但一旦输入"/../.."就会输出错误。根目录的判断必须来自"栈是否为空",而不是"路径字符串中..前还有没有字符"。
输入: "/a//b////c/d" 输出: "/a/b/c/d"这组输入专门用来确认空字符串分支是否正确。
4.2 "…" 不是 "..",这是我见过最多的误判
我在调试时发现一个极其隐蔽的坑:目录名...或....在 Unix 系统中是合法名称,题目并没有规定目录名不能包含点号。如果解法写成:
if "." in part or ".." in part: # 错误处理那么/...会被错误地当成.或..处理。正确做法一定是精确匹配part == "."和part == "..",而不是用in或startswith判断。
我自己就用"/.../"和"/a/..../b"这两组输入测试过,错误版本会把...过滤掉或者当..再弹一层栈,输出变成/或/b,全是错的。这里也反映出一类面试题的考察点:判断逻辑必须是语义级别的,而不是字符级别的。
4.3 使用 Python 标准库做对照验证
在本地做实验时,我习惯用 Python 的os.path.normpath来对照检查,但刷题环境下不要调用它,因为面试官通常想考验你手动处理边界的能力。normpath的行为和本题目存在细微差异,例如:
import os os.path.normpath("/a/./b/../../c/") # '/c' os.path.normpath("/../") # '/' os.path.normpath("/...") # '/...'看起来和栈版解法结果一致。可以把它当成测试标尺来验证自己的代码逻辑,但提交答案时一定要自己实现。
4.4 一个容易漏掉的边界:路径尾部的连续斜杠
路径/a/b/需要输出/a/b,而不是/a/b/。也就是规范化之后除了根目录,不保留结尾斜杠。栈版解法天然满足这个条件,因为末尾的空字符串被跳过,最后只是把目录序列拼接。但如果你在遍历后使用rstrip("/"),就要额外小心:对根目录/执行rstrip会得到空字符串,导致错误。
5. 这题背后能延伸出的面试话术与变体
5.1 复杂度分析要怎么说
空间复杂度是O(n),因为最多需要把路径长度n个字符对应的目录段都存进栈。时间复杂度同样是O(n),每个字符和每个目录段最多被扫描一次、入栈一次、出栈一次。虽然字符串拼接在 Python 中可能引入额外复制,但就本题规模而言可以忽略;如果要严谨讨论,应该说明join操作本身是O(n),因此总体仍是O(n)。
面试时有一个加分的说法:把..看作"弹栈"、把.和空目录段看作"跳过",相当于对导航轨迹做一次状态机转换。状态集合是"栈的深度",输入符号是path分割后的每个目录段。
5.2 常见变体一:给定当前目录和相对路径
有的题目不会直接给你绝对路径,而是给你currentDir和relativePath,要你输出最终绝对路径。解法几乎一样,只需要先把当前目录切分入栈,再处理相对路径中的目录段。例如当前目录/a/b,相对路径../../c,先让栈里有["a", "b"],然后遍历["..", "..", "c"],前两个把栈弹空,最后入栈"c",输出/c。
这种变体更贴近真实开发中cd命令的实现,也更能考察逆向推导能力。
5.3 常见变体二:输出可以作为pwd命令的结果
如果你需要写一个简化版的pwd命令,恰好就是这道题的产物。区别在于真实文件系统中还要考虑权限、符号链接、挂载点等,而算法题把这些全部抽象掉了。我在实际项目里见过有人用正则解析路径,遇到复杂嵌套就出事,后来我推荐他用栈模型一步到位,后续维护成本立刻降了下来。
5.4 面试中如何避开"背题"标签
如果只是把代码默写出来,面试官很容易怀疑你在背题。我建议在讲思路时主动画一条轨迹线:把路径想象成一系列命令,cd subdir就是入栈,cd ..就是出栈,cd .就是原地不动。然后明确说出"栈为空时..无效"这一条约定。最高级的表达是讲出这个约定背后的原因:Unix 根目录的父目录就是它自己,所以没有更高层可返回。
6. 刷题之外的几点个人体会
我在实际刷这道题的过程中,最深刻的感受是:LeetCode 71 看起来简单,但它的坑全藏在"语义精确"上。你只要把判断从"包含点号"改成"等于点号",把操作从"无脑 pop"改成"栈空则跳过",基本就过关了。另外,测试用例一定要自己补充一组包含...目录名的输入,因为题目默认你会做精确匹配,但如果你只过了官方样例,十有八九会忽略。
还有一个小技巧:写快速解法时先拆解输出目标。你想得到的是"目录序列按顺序用/拼接",那么"目录序列"就是最终需要维护的核心数据结构。这个思维模式以后迁移到别的问题时很好用——先把输出结构抽象出来,再倒推需要哪些中间状态。
最后建议想做扩展练习的朋友,可以尝试把这道题的逻辑封装成一个通用函数,输入一个路径列表,输出每条路径规范化后的结果,并统计其中路径长度最长的那一条。这个变体与其说考算法,不如说考代码组织能力,但用来检验你对栈的理解是否透彻,效果非常好。