移除子文件夹的三种解法
问题描述
你是一位系统管理员,手里有一份文件夹列表 folder,你的任务是要删除该列表中的所有 子文件夹,并以 任意顺序 返回剩下的文件夹。
如果文件夹 folder[i] 位于另一个文件夹 folder[j] 下,那么 folder[i] 就是 folder[j] 的 子文件夹 。folder[j] 的子文件夹必须以 folder[j] 开头,后跟一个 "/"。例如,"/a/b" 是 "/a" 的一个子文件夹,但 "/b" 不是 "/a/b/c" 的一个子文件夹。
文件夹的「路径」是由一个或多个按以下格式串联形成的字符串:'/' 后跟一个或者多个小写英文字母。
例如,"/leetcode" 和 "/leetcode/problems" 都是有效的路径,而空字符串和 "/" 不是。
示例 1:
输入:folder = ["/a","/a/b","/c/d","/c/d/e","/c/f"] 输出:["/a","/c/d","/c/f"] 解释:"/a/b" 是 "/a" 的子文件夹,而 "/c/d/e" 是 "/c/d" 的子文件夹。 示例 2:
输入:folder = ["/a","/a/b/c","/a/b/d"] 输出:["/a"] 解释:文件夹 "/a/b/c" 和 "/a/b/d" 都会被删除,因为它们都是 "/a" 的子文件夹。 示例 3:
输入: folder = ["/a/b/c","/a/b/ca","/a/b/d"] 输出: ["/a/b/c","/a/b/ca","/a/b/d"]
提示:
1 <= folder.length <= 4 * 104 2 <= folder[i].length <= 100 folder[i] 只包含小写字母和 '/' folder[i] 总是以字符 '/' 起始 folder 每个元素都是 唯一 的
解法一:排序法
代码实现
class Solution {
fun removeSubfolders(folder: Array<String>): List<String> {
// 排序文件夹路径
folder.sort()
val result = mutableListOf<String>()
for (path in folder) {
// 如果结果列表为空,或者当前路径不是结果列表最后一个路径的子文件夹
if (result.isEmpty() || !path.startsWith("${result.last()}/")) {
result.add(path)
}
}
return result
}
}
算法描述
排序法的核心思想是利用字典序排序的特性:当文件夹路径按字典序排序后,子文件夹一定会紧跟在其父文件夹后面。
排序后,我们只需要维护一个结果列表,遍历排序后的路径时,检查当前路径是否是结果列表中最后一个路径的子文件夹。判断方法是检查当前路径是否以"结果列表最后一个路径+/"开头。
如果是子文件夹,则跳过;如果不是,则加入结果列表。这样可以确保结果列表中只包含非子文件夹。
这种方法的优点是实现简单,利用了排序的特性来简化子文件夹的判断。
时间复杂度:O(n log n + n * L),其中n是文件夹数量,L是文件夹路径的平均长度。排序需要O(n log n),遍历检查需要O(n * L)。
空间复杂度:O(n * L),用于存储排序后的路径和结果列表。
解法二:前缀树(字典树)法
代码实现
class Solution {
// 前缀树节点
private class TrieNode {
val children = mutableMapOf<String, TrieNode>()
var isEnd = false // 标记是否为文件夹路径的终点
}
fun removeSubfolders(folder: Array<String>): List<String> {
val root = TrieNode()
// 构建前缀树
for (path in folder) {
var current = root
// 分割路径为各个部分(去掉空字符串)
val parts = path.split("/").filter { it.isNotEmpty() }
for (part in parts) {
// 如果当前节点已经是一个文件夹的终点,说明当前路径是子文件夹,无需继续添加
if (current.isEnd) {
break
}
current.children.putIfAbsent(part, TrieNode())
current = current.children[part]!!
}
// 标记路径终点
current.isEnd = true
}
// 从前缀树中收集所有非子文件夹路径
val result = mutableListOf<String>()
collectNonSubfolders(root, "", result)
return result
}
// 递归收集非子文件夹路径
private fun collectNonSubfolders(node: TrieNode, currentPath: String, result: MutableList<String>) {
if (node.isEnd) {
result.add(currentPath)
return // 如果是文件夹终点,不再继续深入(避免子文件夹)
}
for ((part, childNode) in node.children) {
val newPath = if (currentPath.isEmpty()) "/$part" else "$currentPath/$part"
collectNonSubfolders(childNode, newPath, result)
}
}
}
算法描述
前缀树法通过构建一个字典树来存储所有文件夹路径,每个节点代表路径中的一个部分(如"/a/b"中的"a"和"b")。
构建树的过程中,如果遇到已经标记为文件夹终点的节点,就停止深入,因为后续路径一定是子文件夹。
收集结果时,我们递归遍历前缀树,一旦遇到标记为文件夹终点的节点,就将其加入结果并停止深入该分支,这样就自然排除了所有子文件夹。
这种方法的优点是可以高效地判断和过滤子文件夹,特别适合处理大量路径的情况。
时间复杂度:O(n * L),其中n是文件夹数量,L是文件夹路径的平均长度(每个路径的平均部分数)。构建前缀树和收集结果都需要遍历所有路径的所有部分。
空间复杂度:O(n * L),用于存储前缀树的节点。
解法三:哈希集合法
代码实现
class Solution {
fun removeSubfolders(folder: Array<String>): List<String> {
val folderSet = folder.toSet()
val result = mutableListOf<String>()
for (path in folder) {
var isSubfolder = false
var current = path
// 循环检查当前路径的所有可能的父文件夹
while (true) {
// 找到最后一个 '/' 的位置
val lastSlashIndex = current.lastIndexOf('/')
// 如果已经是根目录下的一级文件夹,没有父文件夹了
if (lastSlashIndex == 0) {
break
}
// 获取父文件夹路径
current = current.substring(0, lastSlashIndex)
// 如果父文件夹存在于集合中,则当前路径是子文件夹
if (folderSet.contains(current)) {
isSubfolder = true
break
}
}
// 如果不是子文件夹,则加入结果
if (!isSubfolder) {
result.add(path)
}
}
return result
}
}
算法描述
哈希集合法的核心思想是:对于每个文件夹路径,检查它是否有任何父文件夹存在于原始列表中。如果有,则它是子文件夹;如果没有,则它是非子文件夹。
具体实现步骤:
- 将所有文件夹路径存入哈希集合,便于O(1)时间复杂度的查找
- 对于每个路径,逐级向上查找其父文件夹:
- 通过截取最后一个 '/' 之前的字符串来获取父文件夹路径
- 检查父文件夹是否存在于集合中
- 如果找到任何存在的父文件夹,则当前路径是子文件夹
- 收集所有非子文件夹路径作为结果
这种方法的优点是不需要排序,直接通过哈希集合进行快速查找,实现也相对简单。
时间复杂度:O(n * L²),其中n是文件夹数量,L是文件夹路径的最大长度。对于每个路径,最坏情况下需要检查O(L)个父文件夹,每个父文件夹的截取和查找操作需要O(L)时间。
空间复杂度:O(n * L),用于存储哈希集合和结果列表。
以上三种方法都能有效地解决移除子文件夹的问题,各有其优缺点,可以根据具体情况选择使用。