刷 LeetCode 的人迟早会在字符串和栈的分水岭碰到这道leetcode71:简化路径。说实话我第一次做这道题的时候,觉得它不过是个字符串处理的小题,结果用正则、replace 一顿操作,最后被连续斜杠和..的组合拳打蒙了。这道题表面是路径字符串规范化,实际考的是对栈的理解,以及把自然语言规则翻译成代码的严谨度。无论你是准备面试、在学数据结构,还是在项目中写文件路径处理,这题都值得花半小时彻底吃透。今天我就以刷透这道题的视角,把题目规则、解题思路、完整实现和坑位都拆开说清楚。
1. 题目到底在问什么:先看懂“简化路径”的规则
1.1 从 Unix 路径说起:为什么要做“简化”
在 Unix 系统里,路径就是定位文件或目录的一串地址。比如/home/project/app,意思是从根目录/出发,依次进入home、project、app这三个目录。用户在实际操作中很少会打得那么规矩,经常会多敲几个斜杠,或者用.、..表示“当前目录”和“上一级目录”。
这时候操作系统就需要把用户输入的路径“规范化”,也就是把多余的、绕路的部分去掉,得到一个干净、没有歧义的标准路径。这道算法题模拟的正是这个规范化过程。所以题目里的“简化”不是简单压缩字符串长度,而是把语义上等价的路径统一成同一种写法。
我把这类题目当作“带着规则的字符串解析题”,它和单纯的反转字符串、子串匹配不一样,因为每个字符(尤其是.)的意义是由上下文决定的。你光看单个字符看不出结果,必须结合前面的目录层级才能判断,这天然就适合用栈来处理。
1.2 规则拆解:四个特殊符号的语义
原题给的规则其实可以收紧成四条:
- 连续多个斜杠,比如
//或///,全部等价于一个斜杠。 .表示当前目录,在路径里可以整体删掉。..表示退回上一级目录,需要把当前路径的最后一层目录删掉。- 普通目录名(包括
...、..hidden这类奇怪的字符串)都当成正常目录保留下来。
举个例子,输入/a/./b/../../c/,按照语义走一遍:/a进入 a,.留在 a,/b进入 b,..回到 a,另一个..回到根目录,最后/c进入 c。所以结果就是/c。
这里我特别提醒一下:...不是返回上级,它是合法目录名。很多人会下意识把一切带点的都按“特殊符号”处理,这是这道题最大的误判点。...就是三个点组成的普通名字,和abc没有区别。同理..hidden、.git也都是普通目录名,不能因为开头有点就当成.处理。
1.3 边界条件:比想象中多
我一开始以为边界就是“多几个斜杠”,后来做测试才发现坑不少:
- 输入是
"/"或者纯斜杠如"///",结果应该是"/"。 - 在根目录还遇到
..,比如/../,没有上一级可退,结果仍然是/。 - 路径末尾是
.或..,比如/a/.结果/a,/a/..结果/。 - 路径中间或末尾有多余斜杠,比如
/home//foo/,结果/home/foo。
这些边界条件看似细碎,却决定了解法稳不稳。面试或者笔试的时候,能把边界情况主动说出来,比上来闷头写完代码更加分。
2. 解题思路对比:为什么“用栈”是最优解
2.1 暴力字符串处理为什么容易翻车
拿到题最容易想到的就是字符串替换:遇到./删掉,遇到/../就删除前面的目录。逻辑听起来简单,但你真去实现会发现事情没那么美好。
举个例子,如果无脑用replace("./", ""),输入/a/./b确实能变成/a/b,看起来没问题。可要是目录名本身是./或者包含类似模式呢?比如/a/..hidden/b,..hidden是一个普通目录,但一个不够严谨的工作人员可能把它当成..加hidden然后乱处理。
更麻烦的是..需要知道“上一级目录是谁”。字符串替换是独立处理每一处的,它没法理解上下文。比如/a/b/..,你需要知道..前面是b,然后把b删掉。如果路径很长,每遇到一个..都要重新往前找最近的有效目录,很容易出现删错、删多、删少的情况。用正则虽然能写,但表达式又长又难读,面试时也不好解释清楚。
所以暴力处理不是“不能做”,而是“不容易做对”。它的本质问题在于:你试图用“局部替换”来解决一个需要“前后文状态”的问题。
2.2 栈模型:目录切换本来就是后进先出
我个人的理解是:当你沿着路径往下走,其实是在维护一个“当前目录栈”。进入home,栈底是/,栈顶是home;再进入project,栈里就多了一个project。当你遇到..,就是从project退回home,对应 “把栈顶弹出去”。
这种“最后进入的目录最先被回退”的顺序,正是栈(Stack)的核心特征。所以这道题与其说是在考路径处理,不如说是在考你能不能把一个现实场景抽象成数据结构。
举个例子:路径/a/b/c对应栈[a, b, c]。如果某个位置出现..,就弹出c;如果栈已经空了,说明已经在根目录,此时..不产生任何效果。这么一想,实现起来就非常有画面感,也不需要再去死记硬背规则。
2.3 复杂度与为什么 O(n) 已经到极限
用栈的解法只需要从头到尾遍历一遍路径字符串,每个字符至多被处理几次,时间复杂度是 O(n)。空间上,栈里最多存放 n 个目录名,空间复杂度也是 O(n)。对于这种字符串处理题,O(n) 已经是复杂度下限,因为至少要看一遍输入才能决定结果。
可能有人会问:能不能做到 O(1) 额外空间?理论上可以,但需要原地修改字符串,还得处理不同语言里字符串不可变的问题,代码会复杂很多,而且没有必要。在面试中,O(n) 时间、O(n) 空间的解法足够清晰,也最容易向面试官讲明白。真正值得花时间的是把边界情况考虑全面,而不是去扣那一点常数空间。
3. 核心实现:手把手写出干净解法
3.1 先切分再判断:处理连续斜杠最稳妥
我的习惯做法是:先按/把整个路径切分成一个个“部件”,再逐个判断每个部件应该做什么。切分是把复杂问题变简单的重要手段。
比如"/home//foo/"用/切分,会得到["", "home", "", "foo", ""]。注意这里是 Python 的split('/')结果,里面会有空字符串。连续斜杠导致空串,开头和结尾的斜杠也会导致空串。空串对应的是“没有内容”,也就是分隔符本身,所以在后续逻辑里可以跳过。
现在每个非空部件就只有四种情况:.、..、普通目录名、以及那些带点但不是特殊符号的普通名。处理规则很清晰:
- 如果部件是
""或者".",忽略。 - 如果部件是
"..",尝试弹出栈顶目录。 - 其他所有情况,直接入栈。
先切分再判断的好处是,你不用逐个字符去判断当前是不是在目录名中间、斜杠后面是什么,这些麻烦事都被split挡掉了。你只需要关注切出来的“词法单元”。
3.2 用栈模拟目录切换:Python 和 Java 对照
先看 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)这段代码的思路和你刚才看的规则完全对应。path.split('/')把所有部件切出来;循环里先处理空串和.;遇到..就判断栈非空再弹出;其余的都当作目录名加入栈。最后用'/'拼接,并在最前面补一个根目录斜杠。
再看 Java 版本,逻辑一样,只是字符串处理方式不同:
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.pollLast(); } } else { stack.offerLast(part); } } return "/" + String.join("/", stack); } }这里用Deque而不是Stack,是我个人一直推荐的写法。Java 的Stack类继承自Vector,带锁而且是不太被推荐的遗留集合。用ArrayDeque做栈,性能更好,语义也更清楚。offerLast和pollLast分别表示从尾部加入和从尾部取出。
3.3 拼接结果:顺序、斜杠和根目录
最后一步是把栈里的目录拼回路径。很多第一次写的人会在拼接时翻车,核心有两个点。
第一,顺序不能反。栈底是最上层的目录,栈顶是当前目录,所以要从栈底到栈顶拼接。用'/'.join(stack)就是按列表的自然顺序,正好是从底到顶。如果自己用for循环从末尾往前拼,顺序很容易反过来。
第二,根目录的表示。如果栈是空的,说明最终结果就是根目录,返回值应该是"/"。按照'/' + '/'.join([])这个写法,空列表拼接后是空字符串,加上/正好得到"/",所以统一处理即可。
有时候我也会用StringBuilder来拼接,但要注意顺序:
StringBuilder sb = new StringBuilder(); for (String dir : stack) { sb.append("/").append(dir); } return sb.length() == 0 ? "/" : sb.toString();这种写法更直观,但需要额外处理空栈的情况。两种方式都可以,关键是你得清楚每一步在干什么。
4. 踩坑实录:这些细节让代码从“能跑”到“正确”
4.1 连续斜杠和末尾斜杠:split 出的空串不能乱丢
我在第一次实现时犯过非常典型的错误:直接用path.split("/"),然后对每个非空部分做入栈出栈,看似没问题,但我在判断时写成了if part != ""就入栈,结果把.和..混进去,导致结果完全不对。后来才意识到,切分出来的空串代表“多余的分隔符”,它们本身不是目录名,必须在业务逻辑里过滤掉。
同样值得警惕的是不同语言split的行为差异。Java 的" /a/ ".trim()先不谈,单说/home//foo/这个字符串,用 Java 默认的split("/")会得到["", "home", "foo"],末尾空串被丢弃了;Python 的split('/')则会保留末尾空串。如果你在 Java 里写part.isEmpty()过滤,那没问题。如果你用 C++ 的getline按/分割,也能自然跳过空的部分。理解语言差异是避免玄学报错的关键。
我建议你写完之后,专门用一个"/a//b///c/"这样的用例跑一遍,看看切分结果到底是什么,然后再确认过滤逻辑。这样你就不依赖“我猜 split 会怎样”这种模糊认知。
4.2 根目录上回退..:栈空时不能乱 pop
题目里有一个很经典的输入:"/../"。你从根目录出发,结果上来就是一个..,意思是退到上一级,可是根目录的上一级还是根目录。这个..应该被忽略,所以最终结果不是"/..",也不是"//",而是干净的"/"。
代码里对应的是:执行stack.pop()之前必须先判断stack是否为空。如果不判断,在栈空时调用pop,Python 会直接抛IndexError,Java 会抛NoSuchElementException,结果就是运行时崩溃。
我见过一些同学在面试时写出“先弹出,如果异常就忽略”的方式,这当然可行,但不够优雅。更好的做法是显式判断非空再弹出,逻辑一目了然。
4.3 合法目录名...:别一刀切
这道题最容易错、也最容易被面试官追问的地方,就是...应该怎么处理。先说结论:...是一个普通的目录名,不是..的特殊形态。
为什么?因为文件系统里的目录名可以是任何不以/结尾的合法字符串,包括...。如果我们简单地把“包含两个点”的都当作..,就会把/a/.../b错误地改成/b。正确结果应该是/a/.../b。
我在写代码时用了一个很直白的策略:除了""、"."、".."这三个精确匹配之外,其余所有字符串都按普通目录名入栈。也就是说,我不去判断“是不是包含点”,只做精确匹配。这样"..."、"..foo"、".bar"都会老老实实进栈。这个策略简单又不会出错。
5. 变种题与面试加分点
5.1 相关题目:栈的套路是一通百通
简化路径不是孤立题目。LeetCode 上很多经典题目都用到了“后进先出”这个模型。比如:
- 有效的括号:左括号入栈,右括号出栈并匹配。
- 逆波兰表达式求值:数字入栈,遇到运算符弹两个出来算完再压回去。
- 最长有效括号:用栈记录括号的下标,从而计算长度。
- 文件的最长绝对路径:模拟目录深度,也需要栈维护层级关系。
把这些题目放到一起看,你会发现核心都是“遇到某种信号就入栈,遇到另一种信号就出栈,最后从栈里整理出答案”。所以刷题时不要只背代码,要提炼出这种“信号-动作”的对应关系。这道题里的信号就是目录名和.、..,动作就是入栈和出栈。
5.2 面试现场怎么讲这道题更加分
如果面试官让你写这道题,我建议按下面顺序来:
先问清楚输入约束。比如路径是否一定以/开头?空字符串会不会出现?目录名是否包含空格?这一步既能体现严谨性,也避免写完后才发现假设错了。
然后讲思路。我会说:路径本身有层级关系,进入目录相当于入栈,..相当于出栈,.和连续斜杠需要过滤。这个模型很简单,也很容易让面试官跟上。
接着写代码。写的时候我会边写边解释:为什么跳过空串,为什么..前要判断栈非空,为什么不把...当特殊符号处理。解释动作比默写代码更重要。
最后主动跑几个测试用例,比如/home/、/../、/a/./b/../../c/。不是机械地过一遍,而是在过的时候说明每个用例验证了什么边界。
如果你能做到这个完整闭环,哪怕最终代码有个别小瑕疵,面试官给你的评价也会明显不一样。
5.3 一套可复用的测试用例清单
我在本地维护了一个测试用例表,每次改完代码就跑一遍。你可以直接拿去用:
| 输入路径 | 期望输出 | 主要验证点 |
|---|---|---|
/home/ | /home | 去掉末尾斜杠 |
/../ | / | 根目录上的..被忽略 |
/home//foo/ | /home/foo | 连续斜杠的处理 |
/a/./b/../../c/ | /c | .、..混合处理 |
/a/.../b | /a/.../b | ...是普通目录名 |
/// | / | 全是斜杠时返回根目录 |
/..hidden | /..hidden | 开头带点的普通目录名 |
/a/b/../../.. | / | 多次回退到根目录 |
我第一次用这套用例跑的时候,还真抓到了一个 bug:我在处理..的时候没有判断栈空,导致"/../"直接报错。现在每次刷题或写路径处理相关功能,都会先把这些用例过一遍。
6. 我刷这道题的真实体会
6.1 当初是怎么翻车的
我第一次做这道题时,完全没意识到要切分,而是想用正则替换来解决。写了半天,大概长这样:先替换重复斜杠,再处理./,再处理..,结果一到/a/b/../..这种需要连续回退的场景就崩了。因为正则替换是“一次性扫描”,它没法记住当前位置深度。
后来我看了别人的栈解法,恍然大悟:原来不需要在字符串里找来找去,只需要把目录名按顺序放进栈里,..弹出栈顶即可。从此之后,我再遇到任何带层级关系的字符串处理,都会下意识地想“能不能用栈建模”。这个思维转换,比背住代码重要得多。
6.2 给后来者的建议
如果你现在正被这道题折磨,我的建议是:先不要立刻看题解,拿张草稿纸把一个复杂样例比如/a/./b/../../c/手工走一遍,把每一步的“当前路径”写出来。等你走完一遍,你自己就能感觉到栈的存在。然后再打开编辑器自己写,写错也没关系,对照测试用例调试一遍,收获会非常大。
另外,不要只满足于通过这道题。把它和“有效的括号”这类题放一起总结,提炼出“入栈信号”和“出栈信号”的通用框架。刷题到最后,拼的就是这些抽象能力。
最后分享一个小技巧:做字符串路径类题目时,先打印出切分后的列表,看看每个语言到底把/切成了什么。你只要亲眼看过一次空串和普通目录的分布,以后就再也不会对split行为产生误解了。