☰
LeetCode 71“简化路径”详解:栈应用与边界全解析
2026/10/22 5:53:53 网站建设 项目流程

坦白说,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 之后的三个分支

完整思路可以拆成以下四步:

  1. 用/对原始路径做分割,得到一个字符串数组;
  2. 遍历数组,对每个元素s判断:
    • 如果s是空字符串(来自连续斜杠,因为/a//b分割后中间会有一个空串)或者s是.,直接跳过;
    • 如果s是..,执行弹栈操作(栈为空则跳过);
    • 其他情况说明s是一个真正的目录名,入栈;
  3. 遍历结束后,如果栈为空,说明路径已经回到根目录,返回/;
  4. 如果不为空,用/把所有目录名拼接,并在最前面加上/。

第三步容易漏掉。很多人最后直接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"改成"栈空则跳过",基本就过关了。另外,测试用例一定要自己补充一组包含...目录名的输入,因为题目默认你会做精确匹配,但如果你只过了官方样例,十有八九会忽略。

还有一个小技巧:写快速解法时先拆解输出目标。你想得到的是"目录序列按顺序用/拼接",那么"目录序列"就是最终需要维护的核心数据结构。这个思维模式以后迁移到别的问题时很好用——先把输出结构抽象出来,再倒推需要哪些中间状态。

最后建议想做扩展练习的朋友,可以尝试把这道题的逻辑封装成一个通用函数,输入一个路径列表,输出每条路径规范化后的结果,并统计其中路径长度最长的那一条。这个变体与其说考算法,不如说考代码组织能力,但用来检验你对栈的理解是否透彻,效果非常好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询