LeetCode 388. 文件的最长绝对路径
题目描述
题意分析
输入是一个用换行符和制表符编码的目录树:每一行是一个目录名或文件名,行首制表符的个数表示它在树中的深度,深度为 $d$ 的条目属于它上面最近那个深度为 $d-1$ 的目录。要求找出所有文件的绝对路径中最长的那条的长度;如果整棵树里一个文件都没有,返回 $0$。
绝对路径的长度按「各级名称长度之和,加上层与层之间的斜杠个数」计算,斜杠本身不出现在输入里,需要自己补上。深度为 $d$ 的文件,其路径里恰好有 $d$ 个斜杠。
有两个信号值得留意。第一,题目只要长度,不要路径字符串本身,这意味着完全不必拼接字符串,维护整数长度即可。第二,输入的行序天然就是目录树的先序遍历——处理到某一行时,它的所有祖先都已经在前面出现过。这条性质是把「树上求最长路径」降级成「一趟线性扫描」的关键。
边界方面:区分目录与文件的判据是名称里是否含有
.;根层条目的深度为 $0$,其路径前面不加斜杠;同一层可能有多个兄弟,后面的兄弟必须覆盖掉前一个兄弟留下的信息;文件名本身可能含有多个点,目录名则约定不含点。
解法:栈记录各层路径长度
核心思路
一种直白的做法是先按缩进把输入还原成一棵树,再对树做深度优先搜索求最长文件路径。这样是对的,但要额外建节点、连父子、再遍历一遍,代码量和出错面都不小。
瓶颈其实是「建树」这一步纯属多余。既然行序就是先序遍历,那么当扫到深度为 $d$ 的一行时,它的父目录必然是此前最后一个出现的深度为 $d-1$ 的目录,而这个信息可以在扫描过程中顺手记住。
于是把状态定义为一个按深度索引的数组:$stack[d]$ 表示「当前正在处理的这条分支上,深度为 $d$ 的位置之前的路径前缀长度,含该前缀末尾的斜杠」。换句话说,一个深度为 $d$ 的条目,它的完整路径长度就是 $stack[d] + \text{名称长度}$。根据这个定义,$stack[0] = 0$,因为根层条目前面什么都没有。
维持这个定义的不变量是:每当扫到一个深度为 $d$ 的目录,就立刻写入 $stack[d+1] = stack[d] + \text{名称长度} + 1$,那个 $+1$ 就是这个目录名之后的斜杠。由于行序是先序遍历,处理深度为 $d+1$ 的条目之前,最后一次写入 $stack[d+1]$ 的必定正是它真正的父目录,旧的兄弟分支留下的值被自然覆盖。这就是为什么用数组下标直接覆盖,而不需要真的弹栈。
遇到文件时不写 $stack$,而是用 $stack[d] + \text{名称长度}$ 算出候选答案并更新最大值——文件是路径的终点,后面不会再接斜杠。
解题步骤
- 按换行符把输入切成若干行。行数不会超过输入长度,据此为长度数组开一个
lines.length + 1的容量,$+1$ 是因为最深的目录还要写入下一层的槽位。- 对每一行,从行首开始数连续的制表符个数得到深度 $d$,剩下的部分就是名称。之所以能这样切分,是因为制表符只作为缩进出现,名称内部不会含有制表符。
- 判断名称中是否含有
.,据此区分文件与目录。这是题目给定的唯一判据,不能靠有没有扩展名之类的额外猜测。- 若是文件,计算 $stack[d] + \text{名称长度}$ 并与当前答案取较大者。这里不加 $1$,因为文件名之后不再有斜杠。
- 若是目录,写入 $stack[d+1] = stack[d] + \text{名称长度} + 1$。写的是下一层而不是当前层,因为这个值是给它的子条目用的前缀;$+1$ 计入分隔用的斜杠。
- 不需要任何显式的出栈操作。当扫描从深层回到浅层时,浅层的 $stack$ 值仍然有效(它由更早的祖先写入且从未被更深的条目改动),而深层的陈旧值会被新分支的目录在使用前重新写入。
- 全部扫完后返回记录的最大值。初值取 $0$ 恰好覆盖了「没有任何文件」的情形。
以
"dir\n\tsubdir1\n\t\tfile1.ext\n\t\tsubsubdir1\n\tsubdir2\n\t\tsubsubdir2\n\t\t\tfile2.ext"走一遍:切分后共七行,长度数组初始全为 $0$,答案best为 $0$。第一行dir,深度 $0$,名称长 $3$ 且不含点,写入 $stack[1] = 0 + 3 + 1 = 4$。第二行subdir1,深度 $1$,名称长 $7$ 不含点,写入 $stack[2] = 4 + 7 + 1 = 12$。第三行file1.ext,深度 $2$,名称长 $9$ 含点,候选长度为 $12 + 9 = 21$,best更新为 $21$,对应路径dir/subdir1/file1.ext确实是 $21$ 个字符。第四行subsubdir1,深度 $2$,名称长 $10$ 不含点,写入 $stack[3] = 12 + 10 + 1 = 23$。第五行subdir2,深度 $1$,名称长 $7$ 不含点,写入 $stack[2] = stack[1] + 7 + 1 = 4 + 8 = 12$,这一步把上一个兄弟分支留下的同值覆盖掉,之后 $stack[3]$ 的旧值 $23$ 虽然还在数组里,但下一次被读取前必定会被重写。第六行subsubdir2,深度 $2$,名称长 $10$ 不含点,写入 $stack[3] = 12 + 10 + 1 = 23$,正是这次重写让陈旧值失效。第七行file2.ext,深度 $3$,名称长 $9$ 含点,候选长度为 $23 + 9 = 32$,大于 $21$,best更新为 $32$。扫描结束返回 $32$,对应路径dir/subdir2/subsubdir2/file2.ext,逐字符数确实是 $32$。
代码实现
// 用数组 stack[depth] 记录到该层目录的累计长度(含末尾 /)。
class Solution {
public int lengthLongestPath(String input) {
String[] lines = input.split("\n");
int[] stack = new int[lines.length + 1];
int best = 0;
for (String line : lines) {
int depth = 0;
while (depth < line.length() && line.charAt(depth) == '\t') {
depth++;
}
String name = line.substring(depth);
if (name.indexOf('.') >= 0) {
int length = stack[depth] + name.length();
if (length > best) {
best = length;
}
} else {
stack[depth + 1] = stack[depth] + name.length() + 1;
}
}
return best;
}
}
// 用数组 stack[depth] 记录到该层目录的累计长度(含末尾 /)。
func lengthLongestPath(input string) int {
lines := strings.Split(input, "\n")
stack := make([]int, len(lines)+1)
best := 0
for _, line := range lines {
depth := 0
for depth < len(line) && line[depth] == '\t' {
depth++
}
name := line[depth:]
if strings.ContainsRune(name, '.') {
length := stack[depth] + len(name)
if length > best {
best = length
}
} else {
stack[depth+1] = stack[depth] + len(name) + 1
}
}
return best
}
复杂度分析
- 时间复杂度:$O(n)$,$n$ 为输入字符串的总长度。切分成行遍历了一遍输入;对每行数前导制表符和取子串的代价与该行长度成正比,所有行长度之和仍是 $n$;判断是否含点也是逐字符扫一遍该行。合计常数遍线性扫描。
- 空间复杂度:$O(n)$。切分产生的行数组总字符量为 $O(n)$,按深度索引的长度数组大小为行数加一,最坏情况下(每行只有一个字符)行数与 $n$ 同阶。
关键点总结
- 输入自带的先序遍历性质,往往能让「先建树再遍历」直接压缩成一趟扫描。看到缩进表示层级的格式,第一反应应该是找这条性质而不是动手建节点。
- 只要答案是标量(这里是长度),就不要拼接中间字符串。维护整数累计量既省内存又避免了大量子串复制。
- 用「按深度索引的数组 + 直接覆盖」代替真正的压栈弹栈,成立的前提是「陈旧值在被读取前必定被重写」。能把这个前提说清楚,才敢省掉出栈逻辑。
- 状态定义要精确到「含不含末尾分隔符」这种细节。本题把斜杠算进 $stack[d+1]$ 而不是在使用时再补,是让文件分支只写 $stack[d] + len$ 这一行的直接原因。
- 面试视角:面试官会关注你如何处理「从深层回到浅层」。主动解释为什么不需要弹栈,比默默写完更能体现对不变量的掌控。
- 面试视角:常见追问是「如果目录名允许含点怎么办」。此时判据要改成由输入格式另行给出(例如按是否有子行判断),能顺口指出这一点说明你意识到了判据的脆弱性。
易错点总结
- 错误写法:目录分支写成 $stack[d] = stack[d] + len + 1$ 而不是写入 $d+1$ → 层级整体错位一格,对样例中的
dir会把 $stack[0]$ 改成 $4$,之后所有同层兄弟的前缀被污染,结果偏大。- 错误写法:文件分支也加上 $1$,即 $stack[d] + len + 1$ → 每条路径都多算一个不存在的末尾斜杠,样例会返回 $33$ 而非 $32$。
- 错误写法:把制表符
\t当成四个空格或按字符数除以四来算深度 → 输入里缩进就是单个制表符,除以四会让所有深度归零,所有文件都被当成根层条目,样例返回 $9$。- 错误写法:用
name.endsWith(".ext")之类的后缀判断代替「是否含点」 → 文件扩展名不固定,含其他扩展名的文件会被当成目录,其路径长度从不参与比较,答案偏小甚至为 $0$。- 错误写法:数点时用
line.indexOf('.')而非在去掉缩进后的名称里找 → 本题制表符里没有点所以恰好不出错,但一旦路径前缀被误拼进line,判据就会失效;养成先切出名称再判断的习惯更稳。- 错误写法:为长度数组只开
lines.length的容量 → 当最后一行是最深处的目录时,写入 $stack[d+1]$ 会越界;容量必须是行数加一。- 错误写法:认为浅层回退时必须显式清空更深层的 $stack$ 值 → 清空本身无害,但若清空后忘记在新分支重写,深层文件会用 $0$ 当前缀,样例中
file2.ext会算成 $9$。- 错误写法:把答案初始化为某个负数或第一行的长度 → 输入中一个文件都没有时(例如只有
"dir"),应当返回 $0$,用负数初值会返回负值,用首行长度会返回 $3$。- 错误写法:先把输入整体按
\t也切一遍,试图一次性拿到深度和名称 → 连续制表符会切出空串,空串的个数与深度的对应关系依赖切分实现的细节,极易差一;逐字符数制表符更可靠。- 错误写法:为求最长路径而真的拼接出每条绝对路径再取长度 → 深度大时字符串复制的总代价升到 $O(n^2)$ 量级,且完全没有必要,题目只要长度。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 71. 简化路径 | 中等 | 路径规范化,栈里存的是目录名且需要真正处理 . 与 .. 出栈 |
| 1233. 删除子文件夹 | 中等 | 排序后用前缀关系判断包含,考察的是路径之间的比较而非解析 |
| 636. 函数的独占时间 | 中等 | 同样靠嵌套层级驱动,但需要真正的压栈弹栈来结算区间时长 |
| 394. 字符串解码 | 中等 | 嵌套结构的展开,栈中要同时保存计数与已构造的片段 |
| 385. 迷你语法分析器 | 中等 | 由字符串还原嵌套对象,考察分隔符与括号配对的解析细节 |