LeetCode 1487. 保证文件名唯一
题目描述
题意分析
按顺序创建
names里的每个文件夹。如果名字没被占用,直接用;如果已被占用,系统会自动加后缀(k),k取使得name(k)尚未被占用的最小正整数。返回每次实际创建出来的名字数组。
三个细节决定成败。第一,「最小的
k」是从 1 开始试的,不是从「这个名字出现过几次」开始。第二,带后缀生成出来的名字本身也会占用命名空间——["pes","pes(1)"]里第二个pes(1)是用户直接给的,它必须占住pes(1),导致后面再来一个pes时只能拿pes(2)。第三,输入里本来就可能出现带括号的名字,它们不是我们生成的,但同样参与占用,不需要也不应该去解析括号里的数字。
约束里
names.length到 $5 \times 10^4$,名字长度到 20。这个规模明确排除了「每次都从k = 1重头试」的朴素写法——最坏情况下 $5 \times 10^4$ 个相同的名字会让总试探次数达到 $O(n^2)$,即 $1.25 \times 10^9$ 次字符串拼接与哈希查询,必然超时。能不能把重复试探压掉,是这道题从「简单模拟」升级为「中等」的唯一原因,也是面试官想看的东西。
边界:全部名字互不相同(无需任何后缀);全部名字相同(依次得到
a、a(1)、a(2)…);用户提前占用了将来会被生成的名字(如["gta","gta(1)","gta"],第三个只能是gta(2));甚至提前占用了更靠后的号(如["a","a(3)","a","a","a"],依次得到a(1)、a(2)、a(4)——a(3)被跳过)。
解法:哈希表记录下一个可用后缀
核心思路
只用“已占用集合”时,每次重名都从后缀 1 开始试,会在大量同名输入上重复扫描。用一张哈希表同时保存占用状态和下一次探测起点,可以把重复工作摊平。
定义
nextSuffix[name]:名字 name 已被占用,且下次发生冲突时从该正整数后缀开始尝试。键集合就是全部已占用名字;新名字首次出现时记为 1。遇到冲突时,从记录值 k 开始检查
name(k)。用户可能提前创建过某个带后缀名字,所以仍需循环跳过已占用候选。找到后要同时更新nextSuffix[name] = k + 1,并登记生成名nextSuffix[candidate] = 1。不变量是:处理完任意输入前缀后,表中的键恰好等于已经输出的名字,且每个基础名的探测指针不会回退。正确性说明:未占用分支直接使用原名,显然取到最小选择;冲突分支从所有已证明不可用的较小后缀之后开始,并逐个检查到首个空位,因此得到最小合法 k。每个输出随即登记,所以结果始终唯一。
解题步骤
- 创建哈希表
nextSuffix和等长结果数组。- 名字未占用时原样输出,并将其探测起点设为 1。
- 名字已占用时,从表中记录的 k 开始生成候选,循环跳过已有名字。
- 找到空位后推进基础名的 k,并登记新生成名字。
- 按输入顺序返回结果。
["gta","gta(1)","gta","avalon"]会输出["gta","gta(1)","gta(2)","avalon"]。["a","a(3)","a","a","a"]中最后三个 a 依次得到a(1)、a(2)、a(4),验证了候选仍需循环探测。
代码实现
import java.util.HashMap;
import java.util.Map;
class Solution {
public String[] getFolderNames(String[] names) {
Map<String, Integer> nextSuffix = new HashMap<>();
String[] answer = new String[names.length];
for (int i = 0; i < names.length; i++) {
String name = names[i];
if (!nextSuffix.containsKey(name)) {
answer[i] = name;
nextSuffix.put(name, 1);
continue;
}
int suffix = nextSuffix.get(name);
String candidate = name + "(" + suffix + ")";
while (nextSuffix.containsKey(candidate)) {
suffix++;
candidate = name + "(" + suffix + ")";
}
answer[i] = candidate;
nextSuffix.put(name, suffix + 1);
nextSuffix.put(candidate, 1);
}
return answer;
}
}
import "strconv"
func getFolderNames(names []string) []string {
nextSuffix := make(map[string]int)
answer := make([]string, len(names))
for i, name := range names {
suffix, exists := nextSuffix[name]
if !exists {
answer[i] = name
nextSuffix[name] = 1
continue
}
candidate := name + "(" + strconv.Itoa(suffix) + ")"
for {
if _, occupied := nextSuffix[candidate]; !occupied {
break
}
suffix++
candidate = name + "(" + strconv.Itoa(suffix) + ")"
}
answer[i] = candidate
nextSuffix[name] = suffix + 1
nextSuffix[candidate] = 1
}
return answer
}
复杂度分析
- 时间复杂度:均摊 $O(nL)$。每个基础名的后缀指针只前进,所有探测总次数为线性级;L 为生成名字的最大长度。
- 空间复杂度:$O(nL)$。哈希表保存所有已占用名字;结果数组属于输出。
关键点总结
- 一张表同时承担“是否占用”和“从哪里继续试”两个职责。
- 记录值只是探测下界,用户可能提前占用后缀,因此
while不能省。- 生成名也必须登记,因为它同样占用命名空间。
- 输入中的括号只是普通字符,不要解析或改写其含义。
易错点总结
- 每次从后缀 1 重试:大量相同名字会退化为平方级探测。
- 把一次冲突检查写成
if:["a","a(1)","a"]会再次生成已占用的a(1);必须循环到a(2)。- 忘记登记生成名:后续出现同名字面量时会重复输出。
- 新名字的初始后缀设为 0:
["a","a"]会生成非法的a(0)。- 解析输入中的括号:
"a(1)"本身就是完整基础名,重复时应生成"a(1)(1)"。- 更新基础名为 k 而不是
k + 1:结果仍可能正确,但下次会重复检查刚占用的候选。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1166. 设计文件系统 | 中等 | 同为命名空间管理,但要校验父路径存在,键是层级路径而非扁平名字 |
| 535. TinyURL 的加密与解密 | 中等 | 也要保证生成的标识不冲突,但用自增 ID 或随机串,考察双向映射的维护 |
| 609. 在系统中查找重复文件 | 中等 | 按文件内容而非文件名分组,重点在字符串解析与多值哈希表 |
| 217. 存在重复元素 | 简单 | 只问是否有重复,哈希集合的最裸用法,没有「冲突后如何改名」的后续逻辑 |
| 1512. 好数对的数目 | 简单 | 同为边扫边查历史表,但表里存的是计数并用于累加贡献而非判定占用 |
| 187. 重复的DNA序列 | 中等 | 用哈希表统计定长子串出现次数,考察如何避免重复输出同一个子串 |