bigywater.tech
算法与数据结构 · LeetCode 71

LeetCode 71. 简化路径

给定一个 Unix 风格的绝对路径,把它简化成规范路径:. 表示当前目录,忽略;.. 表示上一级目录;连续的多个 / 视为一个;结果以 / 开头,目录之间用单个 / 连接,末尾不带 /(根目录本身除外)。三个点 ... 或者 ..a 这样的名字是合法的普通目录名。做法是栈:按 / 把路径切成段,逐段处理:空段和 . 忽略,.. 弹出栈顶(栈空则忽略),其他入栈;最后用 / 连接栈里的目录。本文用真实运行展示两个常见错误,给出五种语言的实现,并把每一段的处理做成分步演示。

30,000
组随机对拍,与暴力解的结果全部一致
10 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。路径只包含字母、数字、.、/、_。基准解是 Python 标准库的 posixpath.normpath:先在路径前面补上 / 并去掉开头多余的斜杠(normpath 对开头恰好两个斜杠有特殊处理),与手写栈是两种独立的实现。一共 15 组用例,覆盖:官方示例、根目录、只有斜杠、.. 越过根目录、... 和 ..a 这样的普通名字、连续斜杠:

真实实测 · 本机执行输出
输入 ["/home/"]
输出 "/home"
输入 ["/home//foo/"]
输出 "/home/foo"
输入 ["/home/user/Documents/../Pictures"]
输出 "/home/user/Pictures"
输入 ["/../"]
输出 "/"
输入 ["/.../a/../b/c/../d/./"]
输出 "/.../b/d"
输入 ["/"]
输出 "/"

陷阱:空段没忽略,或者在根目录弹出空栈

按 / 切分之后,连续的斜杠会产生空字符串段,末尾的斜杠也会产生一个空段,这些都要忽略;而 .. 出现在根目录时(栈是空的),只能忽略,不能报错。下面是两种错误写法的真实结果:

真实实测 · 本机执行输出
# 两种常见的错误写法
'/home//foo/': 不忽略空段 -> '/home//foo/';在根目录弹出空栈 -> '/home/foo';solve -> '/home/foo'
'/../': 不忽略空段 -> '/';在根目录弹出空栈 -> 'IndexError: pop from empty list';solve -> '/'
'/a/../../b': 不忽略空段 -> '/b';在根目录弹出空栈 -> 'IndexError: pop from empty list';solve -> '/b'
'/home/': 不忽略空段 -> '/home/';在根目录弹出空栈 -> '/home';solve -> '/home'

不忽略空段时,/home//foo/ 得到 /home//foo/,多出了斜杠,/home/ 得到 /home/,末尾带着斜杠。在栈为空时弹出,/../ 和 /a/../../b 直接抛出 IndexError: pop from empty list。

栈:五种语言的实现

按 / 把路径切成段,逐段处理:空段和 . 直接忽略;.. 时如果栈非空就弹出栈顶,否则什么都不做;其他情况(包括 ...、..a)入栈。最后用 / 连接栈里的所有目录,前面加一个 /;栈为空时结果就是单独一个 /。Python、Java、Go、JavaScript、C++ 都是真实编译运行的;C++ 用 stringstream 的 getline 按 / 切分。

Python · 解法代码def solve(path): st = [] for part in path.split("/"): # 按 / 切开,逐段处理 if part == "" or part == ".": # 连续的 / 产生的空段,或者当前目录 ".":忽略 continue if part == "..": # 上一级:栈里有目录就弹出;已经在根目录就什么都不做 if st: st.pop() else: st.append(part) # 普通目录名(包括 "..." 这种三个点的名字):入栈 return "/" + "/".join(st)
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
["/home/"]  →  "/home"
["/home//foo/"]  →  "/home/foo"
["/home/user/Documents/../Pictures"]  →  "/home/user/Pictures"
["/../"]  →  "/"
["/.../a/../b/c/../d/./"]  →  "/.../b/d"
["/"]  →  "/"
Java · 解法代码class Solution { public String simplifyPath(String path) { Deque<String> st = new ArrayDeque<>(); for (String part : path.split("/")) { // 按 / 切开,逐段处理 if (part.isEmpty() || part.equals(".")) continue; // 连续的 / 产生的空段,或者当前目录 ".":忽略 if (part.equals("..")) { // 上一级:栈里有目录就弹出;已经在根目录就什么都不做 if (!st.isEmpty()) st.removeLast(); } else { st.addLast(part); // 普通目录名(包括 "..." 这种三个点的名字):入栈 } } return "/" + String.join("/", st); } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
["/home/"]  →  "/home"
["/home//foo/"]  →  "/home/foo"
["/home/user/Documents/../Pictures"]  →  "/home/user/Pictures"
["/../"]  →  "/"
["/.../a/../b/c/../d/./"]  →  "/.../b/d"
["/"]  →  "/"
Go · 解法代码func simplifyPath(path string) string { st := []string{} for _, part := range strings.Split(path, "/") { // 按 / 切开,逐段处理 if part == "" || part == "." { // 连续的 / 产生的空段,或者当前目录 ".":忽略 continue } if part == ".." { // 上一级:栈里有目录就弹出;已经在根目录就什么都不做 if len(st) > 0 { st = st[:len(st)-1] } } else { st = append(st, part) // 普通目录名(包括 "..." 这种三个点的名字):入栈 } } return "/" + strings.Join(st, "/") }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
["/home/"]  →  "/home"
["/home//foo/"]  →  "/home/foo"
["/home/user/Documents/../Pictures"]  →  "/home/user/Pictures"
["/../"]  →  "/"
["/.../a/../b/c/../d/./"]  →  "/.../b/d"
["/"]  →  "/"
JavaScript · 解法代码function simplifyPath(path) { const st = []; for (const part of path.split("/")) { // 按 / 切开,逐段处理 if (part === "" || part === ".") continue; // 连续的 / 产生的空段,或者当前目录 ".":忽略 if (part === "..") { // 上一级:栈里有目录就弹出;已经在根目录就什么都不做 if (st.length > 0) st.pop(); } else { st.push(part); // 普通目录名(包括 "..." 这种三个点的名字):入栈 } } return "/" + st.join("/"); }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
["/home/"]  →  "/home"
["/home//foo/"]  →  "/home/foo"
["/home/user/Documents/../Pictures"]  →  "/home/user/Pictures"
["/../"]  →  "/"
["/.../a/../b/c/../d/./"]  →  "/.../b/d"
["/"]  →  "/"
C++ · 解法代码#include <sstream> #include <string> #include <vector> using namespace std; class Solution { public: string simplifyPath(string path) { vector<string> st; stringstream ss(path); string part; while (getline(ss, part, '/')) { // 按 / 切开,逐段处理 if (part.empty() || part == ".") continue; // 连续的 / 产生的空段,或者当前目录 ".":忽略 if (part == "..") { // 上一级:栈里有目录就弹出;已经在根目录就什么都不做 if (!st.empty()) st.pop_back(); } else { st.push_back(part); // 普通目录名(包括 "..." 这种三个点的名字):入栈 } } string res; for (auto& d : st) res += "/" + d; return res.empty() ? "/" : res; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
["/home/"]  →  "/home"
["/home//foo/"]  →  "/home/foo"
["/home/user/Documents/../Pictures"]  →  "/home/user/Pictures"
["/../"]  →  "/"
["/.../a/../b/c/../d/./"]  →  "/.../b/d"
["/"]  →  "/"
真实实测 · 本机执行输出
共 15 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 15/15
  Java        通过 15/15
  Go          通过 15/15
  JavaScript  通过 15/15
  C++         通过 15/15

随机对拍

Python 版本另外和 posixpath.normpath 做了随机对拍:以 / 开头,由 0 到 8 段拼成,每段取自 a、b、c、..、.、空段、...、..a,结尾随机带或不带 /:

真实实测 · 本机执行输出
随机对拍 30000 组(以 / 开头,由 0 到 8 段(a、b、c、..、.、空段、...、..a)用 / 连接,结尾随机带或不带 /),solve 与 brute 的结果全部一致。

另外四种语言只在上面 15 组固定用例上验证过,没有做大规模随机对拍。这道题只扫描路径一遍,是线性的,所以没有做规模对比实验。

分步演示:/a/./b/../../c/

下面的演示用 /a/./b/../../c/:切开后是 ∅(开头的空段)、a、.、b、..、..、c、∅(结尾的空段)。a、b 入栈,. 忽略,第一个 .. 弹出 b,第二个 .. 弹出 a,c 入栈,两个空段忽略,最后结果是 /c。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

简化路径:"/a/./b/../../c/"
正在处理的一段已处理的段

    参考与说明

    • 官方题目页:LeetCode 71. 简化路径。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚。
    • 没有覆盖:符号链接和相对路径;Java 里 split("/") 对结尾空串的处理(本文的写法对结果没有影响,因为空段本来就被忽略)。
    京ICP备2026067502号-1京公网安备11010502063487号