面试算法:删除子文件夹(1)

104 阅读4分钟

移除子文件夹的三种解法

问题描述

你是一位系统管理员,手里有一份文件夹列表 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
    }
}

算法描述

哈希集合法的核心思想是:对于每个文件夹路径,检查它是否有任何父文件夹存在于原始列表中。如果有,则它是子文件夹;如果没有,则它是非子文件夹。

具体实现步骤:

  1. 将所有文件夹路径存入哈希集合,便于O(1)时间复杂度的查找
  2. 对于每个路径,逐级向上查找其父文件夹:
    • 通过截取最后一个 '/' 之前的字符串来获取父文件夹路径
    • 检查父文件夹是否存在于集合中
    • 如果找到任何存在的父文件夹,则当前路径是子文件夹
  3. 收集所有非子文件夹路径作为结果

这种方法的优点是不需要排序,直接通过哈希集合进行快速查找,实现也相对简单。

时间复杂度:O(n * L²),其中n是文件夹数量,L是文件夹路径的最大长度。对于每个路径,最坏情况下需要检查O(L)个父文件夹,每个父文件夹的截取和查找操作需要O(L)时间。

空间复杂度:O(n * L),用于存储哈希集合和结果列表。

以上三种方法都能有效地解决移除子文件夹的问题,各有其优缺点,可以根据具体情况选择使用。