普通视图

发现新文章,点击刷新页面。
昨天 — 2025年7月20日LeetCode 每日一题题解

每日一题-删除系统中的重复文件夹🔴

2025年7月20日 00:00

由于一个漏洞,文件系统中存在许多重复文件夹。给你一个二维数组 paths,其中 paths[i] 是一个表示文件系统中第 i 个文件夹的绝对路径的数组。

  • 例如,["one", "two", "three"] 表示路径 "/one/two/three"

如果两个文件夹(不需要在同一层级)包含 非空且相同的 子文件夹 集合 并具有相同的子文件夹结构,则认为这两个文件夹是相同文件夹。相同文件夹的根层级 需要相同。如果存在两个(或两个以上)相同 文件夹,则需要将这些文件夹和所有它们的子文件夹 标记 为待删除。

  • 例如,下面文件结构中的文件夹 "/a""/b" 相同。它们(以及它们的子文件夹)应该被 全部 标记为待删除:
    • /a
    • /a/x
    • /a/x/y
    • /a/z
    • /b
    • /b/x
    • /b/x/y
    • /b/z
  • 然而,如果文件结构中还包含路径 "/b/w" ,那么文件夹 "/a""/b" 就不相同。注意,即便添加了新的文件夹 "/b/w" ,仍然认为 "/a/x""/b/x" 相同。

一旦所有的相同文件夹和它们的子文件夹都被标记为待删除,文件系统将会 删除 所有上述文件夹。文件系统只会执行一次删除操作。执行完这一次删除操作后,不会删除新出现的相同文件夹。

返回二维数组 ans ,该数组包含删除所有标记文件夹之后剩余文件夹的路径。路径可以按 任意顺序 返回。

 

示例 1:

输入:paths = [["a"],["c"],["d"],["a","b"],["c","b"],["d","a"]]
输出:[["d"],["d","a"]]
解释:文件结构如上所示。
文件夹 "/a" 和 "/c"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含名为 "b" 的空文件夹。

示例 2:

输入:paths = [["a"],["c"],["a","b"],["c","b"],["a","b","x"],["a","b","x","y"],["w"],["w","y"]]
输出:[["c"],["c","b"],["a"],["a","b"]]
解释:文件结构如上所示。
文件夹 "/a/b/x" 和 "/w"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含名为 "y" 的空文件夹。
注意,文件夹 "/a" 和 "/c" 在删除后变为相同文件夹,但这两个文件夹不会被删除,因为删除只会进行一次,且它们没有在删除前被标记。

示例 3:

输入:paths = [["a","b"],["c","d"],["c"],["a"]]
输出:[["c"],["c","d"],["a"],["a","b"]]
解释:文件系统中所有文件夹互不相同。
注意,返回的数组可以按不同顺序返回文件夹路径,因为题目对顺序没有要求。

示例 4:

输入:paths = [["a"],["a","x"],["a","x","y"],["a","z"],["b"],["b","x"],["b","x","y"],["b","z"]]
输出:[]
解释:文件结构如上所示。
文件夹 "/a/x" 和 "/b/x"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含名为 "y" 的空文件夹。
文件夹 "/a" 和 "/b"(以及它们的子文件夹)都会被标记为待删除,因为它们都包含一个名为 "z" 的空文件夹以及上面提到的文件夹 "x" 。

示例 5:

输入:paths = [["a"],["a","x"],["a","x","y"],["a","z"],["b"],["b","x"],["b","x","y"],["b","z"],["b","w"]]
输出:[["b"],["b","w"],["b","z"],["a"],["a","z"]]
解释:本例与上例的结构基本相同,除了新增 "/b/w" 文件夹。
文件夹 "/a/x" 和 "/b/x" 仍然会被标记,但 "/a" 和 "/b" 不再被标记,因为 "/b" 中有名为 "w" 的空文件夹而 "/a" 没有。
注意,"/a/z" 和 "/b/z" 不会被标记,因为相同子文件夹的集合必须是非空集合,但这两个文件夹都是空的。

 

提示:

  • 1 <= paths.length <= 2 * 104
  • 1 <= paths[i].length <= 500
  • 1 <= paths[i][j].length <= 10
  • 1 <= sum(paths[i][j].length) <= 2 * 105
  • path[i][j] 由小写英文字母组成
  • 不会存在两个路径都指向同一个文件夹的情况
  • 对于不在根层级的任意文件夹,其父文件夹也会包含在输入中

【合集】一些有漏洞的哈希函数(更新中)

作者 hqztrue
2021年7月30日 11:09

这里介绍一些常见的错误哈希函数,并给出反例。这些哈希函数可能易于实现,并对于随机数据效果不错,所以广受人民群众喜爱(包括我),但对于某些确定的树结构会出问题。
(“正确”的哈希函数应当对于任何树结构都可以保证极小的出错概率,出错与否是无法由测试数据设计者控制的。)
记号:以下用 $x$ 表示字母树上的一个结点,$y_1,\dots,y_d$ 表示结点 $x$ 的孩子集合,$s(x)$ 表示结点 $x$ 的文件夹名,$h(x)$ 表示结点 $x$ 的哈希值,$h(s)$ 表示字符串 $s$ 的哈希值。令 $M$ 代表随便选的一个数,$P$ 代表大质数。

一些错误的哈希函数:

  1. $h(x)=M\cdot \sum_{1\leq i\leq d}h(y_i)+h'(s(x))~~(\bmod~P)$.
    这个函数的问题是太线性了。当我们把不同儿子的哈希值加起来时,如果对一个儿子的哈希值加1,对另一个儿子的哈希值减1,那么结果不变。对于比较简单的 $h'(\cdot)$ 这很容易做到。
    实现举例1 举例2 变种3(周赛CN第8名的代码 @JTJL)
    反例:
    [["a"],["b"],["a","b"],["a","d"],["b","a"],["b","e"]]
  2. $h(x)=1+M\cdot \sum_{1\leq i\leq d}h(y_i)\cdot h'(s(y_i))~~(\bmod~P)$.
    这是上一个函数的变种,因为有 $h'$ 的存在,看起来更复杂了。但即使 $h'$ 很复杂,如果我们把所有的 $h'(s(y_i))$ 看成变量,那么最终结果是关于它们的多项式。我们可以用多种方法(对应多棵不同的树)凑出同一个多项式,例如 $1+M((1+Ma)\cdot b+1\cdot a)=1+M((1+Mb)\cdot a+1\cdot b)$。此时即使并行使用多个模数也不能带来任何帮助。
    实现举例1(更新后) 举例2(这个例子实现时有个小错误,反而卡不掉了。修正之后可以卡掉。)
    反例:
    [["y"],["y","a"],["y","a","b"],["y","b"],["z"],["z","b"],["z","b","a"],["z","a"]]
    即使混合使用 $\bmod P$ 与自然溢出,当$P$较大时我们也可以用类似的办法构造数据使得出错概率较大(这可能有点反直觉,$P$大了反而容易出错):
    实现举例3 反例:
    [["xtiiadgbdw"], ["xtiiadgbdw", "monetnwzju"], ["xtiiadgbdw", "monetnwzju", "uqqiqmeoqw"], ["xtiiadgbdw", "uqqiqmeoqw"], ["djjdtgpgmf"], ["djjdtgpgmf", "uqqiqmeoqw"], ["djjdtgpgmf", "uqqiqmeoqw", "monetnwzju"], ["djjdtgpgmf", "monetnwzju"]]
    实现举例4 反例:
    [["ztnorzuybq"], ["ztnorzuybq", "pyjmbyiqtv"], ["ztnorzuybq", "pyjmbyiqtv", "vfsicaayya"], ["ztnorzuybq", "vfsicaayya"], ["omvuigvvjx"], ["omvuigvvjx", "vfsicaayya"], ["omvuigvvjx", "vfsicaayya", "pyjmbyiqtv"], ["omvuigvvjx", "pyjmbyiqtv"]]
    它们跟前一个反例的树结构是相同的,但字符串随机选取。
  3. 令 $h_i(x)=(h_{i-1}(x)\cdot M+h(y_i)\cdot M+h'(s(y_i)))\bmod P$ 为前 $i$ 个儿子的 hash 值, 而 $h(x)=h_d(x)$.
    这个哈希函数类似Rabin-Karp,但有个重要的漏洞:如果我们把取模前的最终结果看成一个 $M$ 进制大数,则多个结点可能共享同一位。如果选取的 $h'(s)$ 函数过于简单,例如使用线性函数,则我们可以容易地对一个后代结点的哈希值加1,对相应的另一个后代结点的哈希值减1,并保持结果不变。
    实现举例
    反例:
    [["y"],["z"],["a"],["i"],["j"],["e"],["y","a"],["y","a","b"],["y","d"],["y","d","e"],["z","i"],["z","i","b"],["z","d"],["z","d","j"]]
    正确的做法应当记录子树大小$\mathrm{size}(y_i)$,合并一个子结点时乘上 $M^{\mathrm{size}(y_i)}$ 而不是 $M$。
  4. 在例三的基础上,即使选取较为复杂的函数 $h'(s)$,例如std::hash(),我们也可以换一种攻击方式:采用类似例二的方法,把所有的 $h'(s(y_i))$ 看成变量,并用多种方法凑出同一个多项式。常见的办法是找到两个共享答案同一位的点,并交换两个点在树中的位置。
    以下例子使用的是 $h_i(x)=(h_{i-1}(x)\cdot M^2+h(y_i)\cdot M+h'(s(y_i)))\bmod P$,原理是一样的。
    实现举例
    反例:
    [["y"],["y","b"],["y","b","a"],["y","d"],["y","d","c"],["z"],["z","d"],["z","d","c"],["z","d","c","b"],["z","d","c","b","a"]]
  5. 在例四的基础上,令 $h(x)=(h_d(x)\cdot M+B)\bmod P$,即在尾巴上添加一个随便选的常数 $B$. 注意如果在头上添加常数 $B$,即令 $h_0(x)=B$,则反例4已经能卡掉了。这个反例的构造方式是类似的。
    实现举例1 变种2(周赛CN第4名的代码 @wifiii)
    反例:
    [["y"],["y","b"],["y","b","a"],["y","d"],["y","d","c"],["z"],["z","c"],["z","c","a"],["z","d"],["z","d","b"]]

删除系统中的重复文件夹

2021年7月25日 15:46

方法一:子树的序列化表示

思路

我们可以想出这道题在抽象层面(也就是省去了所有实现细节)的解决方法,即:

  • 第一步,我们通过给定的 $\textit{paths}$,简历出文件系统的型表示。这棵树是一棵多叉树,根节点为 $\texttt{/}$,每个非根节点表示一个文件夹。

  • 第二步,我们对整棵树从根节点开始进行一次遍历。根据题目中的描述,如果两个节点 $x$ 和 $y$ 包含的子文件夹的「结构」(即这些子文件夹、子文件夹的子文件夹等等,递归直到空文件夹为止)完全相同,我们就需要将 $x$ 和 $y$ 都进行删除。那么,为了得到某一个节点的子文件夹的「结构」,我们应当首先遍历完成该节点的所有子节点,再回溯遍历该节点本身。这就对应着多叉树的后序遍历

    在回溯到某节点时,我们需要将该节点的「结构」存储下来,记录在某一「数据结构」中,以便于与其它节点的「结构」进行比较。

  • 第三步,我们再次对整棵树从根节点开始进行一次遍历。当我们遍历到节点 $x$ 时,如果 $x$ 的「结构」在「数据结构」中出现了超过 $1$ 次,那就说明存在于 $x$ 相同的文件夹,我们就需要将 $x$ 删除并回溯,否则 $x$ 是唯一的,我们将从根节点开始到 $x$ 的路径计入答案,并继续向下遍历 $x$ 的子节点。

    在遍历完成后,我们就删除了所有重复的文件夹,并且得到了最终的答案。

算法

对于上面的三个步骤,我们依次尝试进行解决。

对于第一步而言,我们只需要定义一个表示树结构的类,建立一个根节点,随后遍历 $\textit{paths}$ 中的每一条表示文件夹的路径,将路径上的所有节点加入树中即可。如果读者已经掌握了字典树(Trie)这一数据结构,就可以较快地实现这一步。

对于第二步而言,难点不在于对树进行后序遍历,而在于如何表示一个节点的「结构」。我们可以参考「297. 二叉树的序列化与反序列化」,实现一个多叉树的序列化表示。我们用 $\text{serial}(x)$ 记录节点 $x$ 的序列化表示,具体地:

  • 如果节点 $x$ 是子节点,那么 $\text{serial}(x)$ 为空字符串,这是因为节点 $x$ 中不包含任何文件夹,它没有「结构」。例如示例 $1$ 中,三个叶节点 $b, a, a$ 对应的序列化表示均为空字符串;

  • 如果节点 $x$ 不是子节点,它的子节点分别为 $y_1, y_2, \cdots, y_k$ 那么 $\text{serial}(x)$ 递归定义为:

    $$
    \text{serial}(x) = y_1(\text{serial}(y_1))y_2(\text{serial}(y_2))\cdots y_k(\text{serial}(y_k))
    $$

    也就是说,我们首先递归地求出 $y_1, y_2, \cdots, y_k$ 的序列化表示,随后将它们连通本身的文件夹名拼接在一起,并在外层使用括号 $()$ 将它们之间进行区分(或者说隔离)。但如果只是随意地进行拼接,会产生顺序的问题,即如果有节点 $x_1$ 和 $x_2$,它们有相同的子节点 $y_1$ 和 $y_2$,但在 $x_1$ 的子节点中 $y_1$ 先出现 $y_2$ 后出现,而在 $x_2$ 的子节点中 $y_2$ 先出现而 $y_1$ 后出现,这样尽管 $x_1$ 和 $x_2$ 的「结构」是完全相同的,但会因为子节点的出现顺序不同,导致序列化的字符串不同。

    因此,在将 $y_1, y_2, \cdots, y_k$ 的序列化表示进行拼接之前,我们可以对它们进行排序(字典序顺序),再将排序后的结果进行拼接,就可以保证具有相同「结构」的节点的序列化表示是完全相同的了。例如示例 $4$ 中,根节点下方的两个子节点 $a, b$,它们的序列化表示均为 $\texttt{x(y())z()}$。

这样一来,通过一次树的后序遍历,我们就可以求出每一个节点「结构」的序列化表示。由于序列化表示都是字符串,因此我们可以使用一个哈希映射,记录每一种序列化表示以及其对应的出现次数。

对于第三步而言,我们从根节点开始对树进行深度优先遍历,并使用一个数组 $\textit{path}$ 记录从根节点到当前遍历到的节点 $x$ 的路径。如果 $x$ 的序列化表示在哈希映射中出现了超过 $1$ 次,就进行回溯,否则将 $\textit{path}$ 加入答案,并向下递归遍历 $x$ 的所有子节点。

代码

下面的 $\texttt{C++}$ 代码没有析构树的空间。如果在面试中遇到了本题,可以和面试官进行沟通,询问是否需要析构对应的空间。

###C++

struct Trie {
    // 当前节点结构的序列化表示
    string serial;
    // 当前节点的子节点
    unordered_map<string, Trie*> children;
};

class Solution {
public:
    vector<vector<string>> deleteDuplicateFolder(vector<vector<string>>& paths) {
        // 根节点
        Trie* root = new Trie();

        for (const vector<string>& path: paths) {
            Trie* cur = root;
            for (const string& node: path) {
                if (!cur->children.count(node)) {
                    cur->children[node] = new Trie();
                }
                cur = cur->children[node];
            }
        }

        // 哈希表记录每一种序列化表示的出现次数
        unordered_map<string, int> freq;
        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        function<void(Trie*)> construct = [&](Trie* node) {
            // 如果是叶节点,那么序列化表示为空字符串,无需进行任何操作
            if (node->children.empty()) {
                return;
            }

            vector<string> v;
            // 如果不是叶节点,需要先计算子节点结构的序列化表示
            for (const auto& [folder, child]: node->children) {
                construct(child);
                v.push_back(folder + "(" + child->serial + ")");
            }
            // 防止顺序的问题,需要进行排序
            sort(v.begin(), v.end());
            for (string& s: v) {
                node->serial += move(s);
            }
            // 计入哈希表
            ++freq[node->serial];
        };

        construct(root);

        vector<vector<string>> ans;
        // 记录根节点到当前节点的路径
        vector<string> path;

        function<void(Trie*)> operate = [&](Trie* node) {
            // 如果序列化表示在哈希表中出现了超过 1 次,就需要删除
            if (freq[node->serial] > 1) {
                return;
            }
            // 否则将路径加入答案
            if (!path.empty()) {
                ans.push_back(path);
            }
            for (const auto& [folder, child]: node->children) {
                path.push_back(folder);
                operate(child);
                path.pop_back();
            }
        };

        operate(root);
        return ans;
    }
};

###Python

class Trie:
    # 当前节点结构的序列化表示
    serial: str = ""
    # 当前节点的子节点
    children: dict

    def __init__(self):
        self.children = dict()

class Solution:
    def deleteDuplicateFolder(self, paths: List[List[str]]) -> List[List[str]]:
        # 根节点
        root = Trie()

        for path in paths:
            cur = root
            for node in path:
                if node not in cur.children:
                    cur.children[node] = Trie()
                cur = cur.children[node]

        # 哈希表记录每一种序列化表示的出现次数
        freq = Counter()
        # 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        def construct(node: Trie) -> None:
            # 如果是叶节点,那么序列化表示为空字符串,无需进行任何操作
            if not node.children:
                return

            v = list()
            # 如果不是叶节点,需要先计算子节点结构的序列化表示
            for folder, child in node.children.items():
                construct(child)
                v.append(folder + "(" + child.serial + ")")
            
            # 防止顺序的问题,需要进行排序
            v.sort()
            node.serial = "".join(v)
            # 计入哈希表
            freq[node.serial] += 1

        construct(root)

        ans = list()
        # 记录根节点到当前节点的路径
        path = list()

        def operate(node: Trie) -> None:
            # 如果序列化表示在哈希表中出现了超过 1 次,就需要删除
            if freq[node.serial] > 1:
                return
            # 否则将路径加入答案
            if path:
                ans.append(path[:])

            for folder, child in node.children.items():
                path.append(folder)
                operate(child)
                path.pop()

        operate(root)
        return ans

###Java

class Solution {
    class Trie {
        String serial; // 当前节点结构的序列化表示
        Map<String, Trie> children = new HashMap<>(); // 当前节点的子节点
    }

    public List<List<String>> deleteDuplicateFolder(List<List<String>> paths) {
        Trie root = new Trie(); // 根节点

        // 构建字典树
        for (List<String> path : paths) {
            Trie cur = root;
            for (String node : path) {
                cur.children.putIfAbsent(node, new Trie());
                cur = cur.children.get(node);
            }
        }

        Map<String, Integer> freq = new HashMap<>(); // 哈希表记录每一种序列化表示的出现次数
        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        construct(root, freq);
        List<List<String>> ans = new ArrayList<>();
        List<String> path = new ArrayList<>();
        // 操作字典树,删除重复文件夹
        operate(root, freq, path, ans);
        return ans;
    }

    private void construct(Trie node, Map<String, Integer> freq) {
        if (node.children.isEmpty()) return; // 如果是叶节点,无需操作

        List<String> v = new ArrayList<>();
        for (Map.Entry<String, Trie> entry : node.children.entrySet()) {
            construct(entry.getValue(), freq);
            v.add(entry.getKey() + "(" + entry.getValue().serial + ")");
        }

        Collections.sort(v);
        StringBuilder sb = new StringBuilder();
        for (String s : v) {
            sb.append(s);
        }
        node.serial = sb.toString();
        freq.put(node.serial, freq.getOrDefault(node.serial, 0) + 1);
    }

    private void operate(Trie node, Map<String, Integer> freq, List<String> path, List<List<String>> ans) {
        if (freq.getOrDefault(node.serial, 0) > 1) return; // 如果序列化表示出现超过1次,需要删除

        if (!path.isEmpty()) {
            ans.add(new ArrayList<>(path));
        }

        for (Map.Entry<String, Trie> entry : node.children.entrySet()) {
            path.add(entry.getKey());
            operate(entry.getValue(), freq, path, ans);
            path.remove(path.size() - 1);
        }
    }
}

###C#

public class Solution {
    class Trie {
        public string Serial { get; set; } = ""; // 当前节点结构的序列化表示
        public Dictionary<string, Trie> Children { get; } = new Dictionary<string, Trie>(); // 当前节点的子节点
    }

    public IList<IList<string>> DeleteDuplicateFolder(IList<IList<string>> paths) {
        // 根节点
        Trie root = new Trie();
        // 构建字典树
        foreach (var p in paths) {
            Trie current = root;
            foreach (var node in p) {
                if (!current.Children.ContainsKey(node)) {
                    current.Children[node] = new Trie();
                }
                current = current.Children[node];
            }
        }

        // 哈希表记录每一种序列化表示的出现次数
        var freq = new Dictionary<string, int>();
        
        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        void Construct(Trie node) {
            // 如果是叶节点,那么序列化表示为空字符串,无需进行任何操作
            if (node.Children.Count == 0) {
                return;
            }
            var v = new List<string>();
            // 如果不是叶节点,需要先计算子节点结构的序列化表示
            foreach (var entry in node.Children) {
                Construct(entry.Value);
                v.Add($"{entry.Key}({entry.Value.Serial})");
            }
            // 防止顺序的问题,需要进行排序
            v.Sort();
            node.Serial = string.Join("", v);
            // 计入哈希表
            if (!freq.ContainsKey(node.Serial)) {
                freq[node.Serial] = 0;
            }
            freq[node.Serial]++;
        }

        Construct(root);
        var ans = new List<IList<string>>();
        // 记录根节点到当前节点的路径
        var path = new List<string>();

        void Operate(Trie node) {
            // 如果序列化表示在哈希表中出现了超过 1 次,就需要删除
            if (freq.TryGetValue(node.Serial, out int count) && count > 1) {
                return;
            }
            // 否则将路径加入答案
            if (path.Count > 0) {
                ans.Add(new List<string>(path));
            }

            foreach (var entry in node.Children) {
                path.Add(entry.Key);
                Operate(entry.Value);
                path.RemoveAt(path.Count - 1);
            }
        }

        Operate(root);
        return ans;
    }
}

###Go

type Trie struct {
serial   string             // 当前节点结构的序列化表示
children map[string]*Trie   // 当前节点的子节点
}

func deleteDuplicateFolder(paths [][]string) [][]string {
root := &Trie{children: make(map[string]*Trie)} // 根节点
// 构建字典树
for _, path := range paths {
cur := root
for _, node := range path {
if _, ok := cur.children[node]; !ok {
cur.children[node] = &Trie{children: make(map[string]*Trie)}
}
cur = cur.children[node]
}
}

freq := make(map[string]int) // 哈希表记录每一种序列化表示的出现次数
// 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
var construct func(*Trie)
construct = func(node *Trie) {
if len(node.children) == 0 {
return // 如果是叶节点,无需操作
}
v := make([]string, 0, len(node.children))
for folder, child := range node.children {
construct(child)
v = append(v, folder + "(" + child.serial + ")")
}
sort.Strings(v)
node.serial = strings.Join(v, "")
freq[node.serial]++
}
construct(root)
ans := make([][]string, 0)
path := make([]string, 0)
// 操作字典树,删除重复文件夹
var operate func(*Trie)
operate = func(node *Trie) {
if freq[node.serial] > 1 {
return // 如果序列化表示出现超过1次,需要删除
}

if len(path) > 0 {
tmp := make([]string, len(path))
copy(tmp, path)
ans = append(ans, tmp)
}

for folder, child := range node.children {
path = append(path, folder)
operate(child)
path = path[:len(path) - 1]
}
}
operate(root)

return ans
}

###JavaScript

var deleteDuplicateFolder = function(paths) {
    class Trie {
        constructor() {
            this.serial = ""; // 当前节点结构的序列化表示
            this.children = new Map(); // 当前节点的子节点
        }
    }

    const root = new Trie(); // 根节点
    // 构建字典树
    for (const path of paths) {
        let cur = root;
        for (const node of path) {
            if (!cur.children.has(node)) {
                cur.children.set(node, new Trie());
            }
            cur = cur.children.get(node);
        }
    }

    const freq = new Map(); // 哈希表记录每一种序列化表示的出现次数
    // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
    function construct(node) {
        if (node.children.size === 0) return; // 如果是叶节点,无需操作
        const v = [];
        for (const [folder, child] of node.children) {
            construct(child);
            v.push(`${folder}(${child.serial})`);
        }
        v.sort();
        node.serial = v.join("");
        freq.set(node.serial, (freq.get(node.serial) || 0) + 1);
    }
    construct(root);

    const ans = [];
    const path = [];
    // 操作字典树,删除重复文件夹
    function operate(node) {
        if ((freq.get(node.serial) || 0) > 1) return; // 如果序列化表示出现超过1次,需要删除
        if (path.length > 0) {
            ans.push([...path]);
        }
        for (const [folder, child] of node.children) {
            path.push(folder);
            operate(child);
            path.pop();
        }
    }
    operate(root);

    return ans;
}

###TypeScript

function deleteDuplicateFolder(paths: string[][]): string[][] {
    class Trie {
        serial: string = ""; // 当前节点结构的序列化表示
        children: Map<string, Trie> = new Map(); // 当前节点的子节点
    }

    const root = new Trie(); // 根节点

    // 构建字典树
    for (const path of paths) {
        let cur = root;
        for (const node of path) {
            if (!cur.children.has(node)) {
                cur.children.set(node, new Trie());
            }
            cur = cur.children.get(node)!;
        }
    }

    const freq = new Map<string, number>(); // 哈希表记录每一种序列化表示的出现次数
    // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
    function construct(node: Trie) {
        if (node.children.size === 0) return; // 如果是叶节点,无需操作

        const v: string[] = [];
        for (const [folder, child] of node.children) {
            construct(child);
            v.push(`${folder}(${child.serial})`);
        }

        v.sort();
        node.serial = v.join("");
        freq.set(node.serial, (freq.get(node.serial) || 0) + 1);
    }
    construct(root);
    const ans: string[][] = [];
    const path: string[] = [];

    // 操作字典树,删除重复文件夹
    function operate(node: Trie) {
        if ((freq.get(node.serial) || 0) > 1) return; // 如果序列化表示出现超过1次,需要删除

        if (path.length > 0) {
            ans.push([...path]);
        }

        for (const [folder, child] of node.children) {
            path.push(folder);
            operate(child);
            path.pop();
        }
    }
    operate(root);
    return ans;
}

###Rust

use std::collections::HashMap;

#[derive(Default)]
struct Trie {
    serial: String, // 当前节点结构的序列化表示
    children: HashMap<String, Trie>, // 当前节点的子节点
}

impl Solution {
    pub fn delete_duplicate_folder(paths: Vec<Vec<String>>) -> Vec<Vec<String>> {
        let mut root = Trie::default(); // 根节点
        // 构建字典树
        for path in paths {
            let mut cur = &mut root;
            for node in path {
                cur = cur.children.entry(node.clone()).or_default();
            }
        }

        let mut freq = HashMap::new(); // 哈希表记录每一种序列化表示的出现次数

        // 基于深度优先搜索的后序遍历,计算每一个节点结构的序列化表示
        fn construct(node: &mut Trie, freq: &mut HashMap<String, usize>) {
            if node.children.is_empty() {
                return; // 如果是叶节点,无需操作
            }

            let mut v = Vec::new();
            for (folder, child) in node.children.iter_mut() {
                construct(child, freq);
                v.push(format!("{}({})", folder, child.serial));
            }

            v.sort();
            node.serial = v.join("");
            *freq.entry(node.serial.clone()).or_default() += 1;
        }
        construct(&mut root, &mut freq);
        let mut ans = Vec::new();
        let mut path = Vec::new();

        // 操作字典树,删除重复文件夹
        fn operate(node: &Trie, freq: &HashMap<String, usize>, path: &mut Vec<String>, ans: &mut Vec<Vec<String>>) {
            if freq.get(&node.serial).unwrap_or(&0) > &1 {
                return; // 如果序列化表示出现超过1次,需要删除
            }

            if !path.is_empty() {
                ans.push(path.clone());
            }

            for (folder, child) in &node.children {
                path.push(folder.clone());
                operate(child, freq, path, ans);
                path.pop();
            }
        }
        operate(&root, &freq, &mut path, &mut ans);

        ans
    }
}

复杂度分析

这里我们只考虑计算所有节点结构的序列化表示需要的时间,以及哈希映射需要使用的空间。对于其它的项(无论是时间项还是空间项),它们在渐近意义下一定都小于计算以及存储序列化表示的部分,因此可以忽略。

在最坏情况下,节点结构的序列化表示的字符串两两不同,那么需要的时间和空间级别均为「所有节点结构的序列化表示的字符串的长度之和」。如何求出这个长度之和的上界呢?

这里我们需要用到一个很重要的结论:

设 $T$ 为无权多叉树。对于 $T$ 中的节点 $x$,记 $\textit{dist}[x]$ 为从根节点到 $x$ 经过的节点个数,$\textit{size}[x]$ 为以 $x$ 为根的子树的大小,那么有:

$$
\sum_{x \in T} \textit{dist}[x] = \sum_{x \in T} \textit{size}[x]
$$

证明也较为直观。对于任意的节点 $x'$,在右侧的 $\sum_{x \in T} \textit{size}[x]$ 中,$x'$ 被包含在 $\textit{size}[x]$ 中的次数就等于 $x'$ 的祖先节点的数目(也包括 $x'$ 本身),其等于根节点到 $x'$ 的经过的节点个数,因此得证。

回到本题,$\textit{path}$ 中给出了根节点到所有节点的路径,其中最多包含 $2\times 10^5$ 个字符,那么 $\sum_{x \in T} \textit{dist}[x]$ 不超过 $2\times 10^5$,$\sum_{x \in T} \textit{size}[x]$ 同样也不超过 $2\times 10^5$。

对于任意的节点 $x$,$x$ 结构的序列化表示的字符串长度包含两部分,第一部分为其中所有子文件夹名的长度之和,其不超过 $10 \cdot \textit{size}[x]$,第二部分为额外添加的用来区分的括号,由于一个子文件夹会恰好被添加一对括号,因此其不超过 $2 \cdot \textit{size}[x]$。这样一来,「所有节点结构的序列化表示的字符串的长度之和」的上界为:

$$
12 \cdot \sum_{x \in T} \textit{size}[x] = 2.4 \times 10^6
$$

即空间的数量级为 $10^6$。而对于时间,即使算上排序的额外 $\log$ 的时间复杂度,也在 $10^7$ 的数量级,可以在规定的时间内通过本题。并且需要指出的是,在上述估算上界的过程中,我们作出的许多假设是非常极端的,因此实际上该方法的运行时间很快。

括号表示法 + 字典树(Python/Java/C++/Go)

作者 endlesscheng
2021年7月25日 12:06

核心思路:把相同的子树映射为相同的字符串,就能用哈希表去重了。

如何把子树转化成字符串?为了准确判断两棵子树的结构是否相同,需要做到两点:

  1. 字符串需要包含子树所有节点的文件夹名
  2. 字符串要能够表达节点的父子关系

考察树的递归过程,把向下「递」的动作用一个非字母字符表示,向上「归」的动作用另一个非字母字符表示,就可以描述一棵树的形状了(用非字母字符是为了与文件夹名区分开)。比如说,用左括号表示递,用右括号表示归。从节点 $\texttt{x}$ 向下递到节点 $\texttt{y}$,再归回 $\texttt{x}$,就可以表示为 $\texttt{x(y)}$。如果 $\texttt{x}$ 有两个儿子 $\texttt{y}$ 和 $\texttt{z}$(并且这两个儿子都是叶子),那么子树 $\texttt{x}$ 就可以表示为 $\texttt{x(y)(z)}$。

一般地,定义如下括号表达式:

  • 对于叶子节点,设其文件夹名为 $S$,则其括号表达式就是 $S$。
  • 对于任意子树 $x$,设 $x$ 的儿子为 $y_1,y_2,\ldots,y_k$,则子树 $x$ 的括号表达式为
    $$
    x 的文件夹名 + (子树 y_1 的表达式) + (子树 y_2 的表达式) + \cdots + (子树 y_k 的表达式)
    $$

image.png{:width=300}

看示例 4,子树 $\texttt{x}$ 的括号表达式为 $\texttt{x(y)}$,子树 $\texttt{a}$ 的括号表达式为 $\texttt{a(x(y))(z)}$,子树 $\texttt{b}$ 的括号表达式为 $\texttt{b(x(y))(z)}$。

根据题意,我们不关心子树根节点的文件夹名,在去掉子树根节点后,子树 $\texttt{a}$ 和子树 $\texttt{b}$ 的括号表达式都是 $\texttt{(x(y))(z)}$,所以这两个文件夹「包含非空且相同的子文件夹集合,并具有相同的子文件夹结构」。

括号表达式既包含了文件夹名,又通过括号的嵌套关系表达了父子关系,因此可用于判断两个文件夹是否为相同文件夹。

你可能会问:如果子树 $\texttt{b}$ 的两棵子树是 $\texttt{z}$ 在左,$\texttt{x(y)}$ 在右呢?得到的表达式为 $\texttt{(z)(x(y))}$,这样没法判断两棵子树相同呀?

解决办法:在构造括号表达式时,先把子树 $y_1,y_2,\ldots,y_k$ 的表达式按照字典序排序,再把表达式依次拼接,就可以避免出现上述情况了。

代码实现时,用 字典树 表示这个文件系统,节点保存文件夹名称。注意这与一般的字典树不同,不是二十六叉树那种用单个字母对应节点,而是用一整个字符串(文件夹名)对应节点。这棵字典树一个节点(文件夹)最多能有 $20000$ 个儿子(子文件夹)。

用 $\textit{paths}$ 构建完字典树后,DFS 这棵树,按照前文的规则生成括号表达式 $\textit{subTreeExpr}$:

  • 如果首次遇到 $\textit{subTreeExpr}$,那么把 $\textit{subTreeExpr}$ 及其对应的子树根节点保存到哈希表中。
  • 否则我们找到了重复的文件夹,把哈希表中 $\textit{subTreeExpr}$ 对应的节点,以及当前节点,都标记为待删除。

最后,再次 DFS(回溯)这棵字典树,仅访问未被删除的节点,同时用一个列表 $\textit{path}$ 记录路径上的文件夹名。每次递归到一个节点,就把 $\textit{path}$ 的一个拷贝加到答案中。做法类似 257. 二叉树的所有路径

class TrieNode:
    __slots__ = 'son', 'name', 'deleted'

    def __init__(self):
        self.son = {}
        self.name = ''  # 文件夹名称
        self.deleted = False  # 删除标记


class Solution:
    def deleteDuplicateFolder(self, paths: List[List[str]]) -> List[List[str]]:
        root = TrieNode()
        for path in paths:
            # 把 path 插到字典树中,见 208. 实现 Trie
            cur = root
            for s in path:
                if s not in cur.son:
                    cur.son[s] = TrieNode()
                cur = cur.son[s]
                cur.name = s

        expr_to_node = {}  # 子树括号表达式 -> 子树根节点

        def gen_expr(node: TrieNode) -> str:
            if not node.son:  # 叶子
                return node.name  # 表达式就是文件夹名

            # 每个子树的表达式外面套一层括号
            expr = sorted('(' + gen_expr(son) + ')' for son in node.son.values())
            sub_tree_expr = ''.join(expr)  # 按字典序拼接所有子树的表达式
            if sub_tree_expr in expr_to_node:  # 哈希表中有 sub_tree_expr,说明有重复的文件夹
                expr_to_node[sub_tree_expr].deleted = True  # 哈希表中记录的节点标记为删除
                node.deleted = True  # 当前节点标记为删除
            else:
                expr_to_node[sub_tree_expr] = node

            return node.name + sub_tree_expr

        for son in root.son.values():
            gen_expr(son)

        ans = []
        path = []

        # 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
        # 类似 257. 二叉树的所有路径
        def dfs(node: TrieNode) -> None:
            if node.deleted:
                return
            path.append(node.name)
            ans.append(path.copy())  # path[:]
            for child in node.son.values():
                dfs(child)
            path.pop()  # 恢复现场

        for son in root.son.values():
            dfs(son)

        return ans
class Solution {
    private static class TrieNode {
        Map<String, TrieNode> son = new HashMap<>();
        String name; // 文件夹名称
        boolean deleted = false; // 删除标记
    }

    public List<List<String>> deleteDuplicateFolder(List<List<String>> paths) {
        TrieNode root = new TrieNode();
        for (List<String> path : paths) {
            // 把 path 插到字典树中,见 208. 实现 Trie
            TrieNode cur = root;
            for (String s : path) {
                if (!cur.son.containsKey(s)) {
                    cur.son.put(s, new TrieNode());
                }
                cur = cur.son.get(s);
                cur.name = s;
            }
        }

        Map<String, TrieNode> exprToNode = new HashMap<>(); // 子树括号表达式 -> 子树根节点
        for (TrieNode son : root.son.values()) {
            genExpr(son, exprToNode);
        }

        List<List<String>> ans = new ArrayList<>();
        List<String> path = new ArrayList<>();
        for (TrieNode son : root.son.values()) {
            dfs(son, path, ans);
        }
        return ans;
    }

    private String genExpr(TrieNode node, Map<String, TrieNode> exprToNode) {
        if (node.son.isEmpty()) { // 叶子
            return node.name; // 表达式就是文件夹名
        }

        List<String> expr = new ArrayList<>();
        for (TrieNode son : node.son.values()) {
            // 每个子树的表达式外面套一层括号
            expr.add("(" + genExpr(son, exprToNode) + ")");
        }
        Collections.sort(expr);

        String subTreeExpr = String.join("", expr); // 按字典序拼接所有子树的表达式
        TrieNode n = exprToNode.get(subTreeExpr);
        if (n != null) { // 哈希表中有 subTreeExpr,说明有重复的文件夹
            n.deleted = true; // 哈希表中记录的节点标记为删除
            node.deleted = true; // 当前节点标记为删除
        } else {
            exprToNode.put(subTreeExpr, node);
        }

        return node.name + subTreeExpr;
    }

    // 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
    // 类似 257. 二叉树的所有路径
    private void dfs(TrieNode node, List<String> path, List<List<String>> ans) {
        if (node.deleted) {
            return;
        }
        path.add(node.name);
        ans.add(new ArrayList<>(path)); // 记录路径
        for (TrieNode son : node.son.values()) {
            dfs(son, path, ans);
        }
        path.removeLast(); // 恢复现场
    }
}
struct TrieNode {
    unordered_map<string, TrieNode*> son;
    string name; // 文件夹名称
    bool deleted = false; // 删除标记
};

class Solution {
public:
    vector<vector<string>> deleteDuplicateFolder(vector<vector<string>>& paths) {
        TrieNode* root = new TrieNode();
        for (auto& path : paths) {
            // 把 path 插到字典树中,见 208. 实现 Trie
            TrieNode* cur = root;
            for (auto& s : path) {
                if (!cur->son.contains(s)) {
                    cur->son[s] = new TrieNode();
                }
                cur = cur->son[s];
                cur->name = s;
            }
        }

        unordered_map<string, TrieNode*> expr_to_node; // 子树括号表达式 -> 子树根节点

        auto gen_expr = [&](this auto&& gen_expr, TrieNode* node) -> string {
            if (node->son.empty()) { // 叶子
                return node->name; // 表达式就是文件夹名
            }

            vector<string> expr;
            for (auto& [_, son] : node->son) {
                // 每个子树的表达式外面套一层括号
                expr.emplace_back("(" + gen_expr(son) + ")");
            }
            ranges::sort(expr);

            string sub_tree_expr;
            for (auto& e : expr) {
                sub_tree_expr += e; // 按字典序拼接所有子树的表达式
            }

            if (expr_to_node.contains(sub_tree_expr)) { // 哈希表中有 sub_tree_expr,说明有重复的文件夹
                expr_to_node[sub_tree_expr]->deleted = true; // 哈希表中记录的节点标记为删除
                node->deleted = true; // 当前节点标记为删除
            } else {
                expr_to_node[sub_tree_expr] = node;
            }

            return node->name + sub_tree_expr;
        };

        for (auto& [_, son] : root->son) {
            gen_expr(son);
        }

        vector<vector<string>> ans;
        vector<string> path;

        // 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
        // 类似 257. 二叉树的所有路径
        auto dfs = [&](this auto&& dfs, TrieNode* node) -> void {
            if (node->deleted) {
                return;
            }
            path.push_back(node->name);
            ans.push_back(path);
            for (auto& [_, son] : node->son) {
                dfs(son);
            }
            path.pop_back(); // 恢复现场
        };

        for (auto& [_, son] : root->son) {
            dfs(son);
        }

        return ans;
    }
};
type trieNode struct {
son     map[string]*trieNode
name    string // 文件夹名称
deleted bool   // 删除标记
}

func deleteDuplicateFolder(paths [][]string) (ans [][]string) {
root := &trieNode{}
for _, path := range paths {
// 把 path 插到字典树中,见 208. 实现 Trie
cur := root
for _, s := range path {
if cur.son == nil {
cur.son = map[string]*trieNode{}
}
if cur.son[s] == nil {
cur.son[s] = &trieNode{}
}
cur = cur.son[s]
cur.name = s
}
}

exprToNode := map[string]*trieNode{} // 子树括号表达式 -> 子树根节点
var genExpr func(*trieNode) string
genExpr = func(node *trieNode) string {
if node.son == nil { // 叶子
return node.name // 表达式就是文件夹名
}

expr := make([]string, 0, len(node.son)) // 预分配空间
for _, son := range node.son {
// 每个子树的表达式外面套一层括号
expr = append(expr, "("+genExpr(son)+")")
}
slices.Sort(expr)

subTreeExpr := strings.Join(expr, "") // 按字典序拼接所有子树的表达式
n := exprToNode[subTreeExpr]
if n != nil { // 哈希表中有 subTreeExpr,说明有重复的文件夹
n.deleted = true    // 哈希表中记录的节点标记为删除
node.deleted = true // 当前节点标记为删除
} else {
exprToNode[subTreeExpr] = node
}

return node.name + subTreeExpr
}
for _, son := range root.son {
genExpr(son)
}

// 在字典树上回溯,仅访问未被删除的节点,并将路径记录到答案中
// 类似 257. 二叉树的所有路径
path := []string{}
var dfs func(*trieNode)
dfs = func(node *trieNode) {
if node.deleted {
return
}
path = append(path, node.name)
ans = append(ans, slices.Clone(path))
for _, son := range node.son {
dfs(son)
}
path = path[:len(path)-1] // 恢复现场
}
for _, son := range root.son {
dfs(son)
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\ell\cdot m\log m)$,其中 $m$ 是字符串的总个数,$\ell\le 10$ 是单个字符串的长度,题目保证 $\ell\cdot m\le 2\cdot 10^5$。瓶颈在排序上。字符串拼接的复杂度是 $\mathcal{O}(\ell\cdot m)$。
    • 排序:最坏情况下一个节点有 $\mathcal{O}(m)$ 个儿子,我们会对 $\mathcal{O}(m)$ 个字符串排序。会发生 $\mathcal{O}(m\log m)$ 次字符串比较,每次 $\mathcal{O}(\ell)$ 时间,所以排序的时间复杂度为 $\mathcal{O}(\ell\cdot m\log m)$。
    • 字符串拼接:可能读者会认为当树退化成链时,代码会跑到 $\mathcal{O}((\ell\cdot m)^2)$ 时间,但这是不可能的。注意题目的这句话:「对于不在根层级的任意文件夹,其父文件夹也会包含在输入中。」这意味着如果一棵树的高度是 $d$,那么至少要 $1+2+3+\cdots+d = \mathcal{O}(d^2)$ 个字符串才能生成这棵树(可以参考示例 2),所以树的高度只有 $\mathcal{O}(\sqrt m)$。相应地,代码中的 node.name + subTreeExpr 会对长为 $\mathcal{O}(\ell\sqrt m)$ 的字符串复制拼接 $\mathcal{O}(\sqrt m)$ 次,所以时间复杂度为 $\mathcal{O}(\ell(\sqrt m)^2) = \mathcal{O}(\ell\cdot m)$。
  • 空间复杂度:$\mathcal{O}(\ell\cdot m)$。

专题训练

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

昨天以前LeetCode 每日一题题解

排序(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2025年7月19日 08:58

如果 $B$ 是 $A$ 的子文件夹,那么:

  • $A + \texttt{/}$ 是 $B$ 的前缀。

比如 $\texttt{/a/b}$ 是 $\texttt{/a}$ 的子文件夹,其中 $\texttt{/a/}$ 是 $\texttt{/a/b}$ 的前缀。(当然,$\texttt{/a}$ 也是 $\texttt{/a/b}$ 的前缀。)

暴力的想法是,枚举路径 $B$,枚举其他路径 $A$,判断是否满足上述要求,这需要 $\mathcal{O}(\ell\cdot n^2)$ 时间,其中 $\ell\le 100$ 为单个字符串的长度。这太慢了。

可以不枚举 $A$ 吗?

如果 $\textit{folder}$ 列表是有序的(按照字典序从小到大排序),那么当 $B$ 是 $A$ 的子文件夹时,$A$ 是 $B$ 的前缀,所以 $A$ 一定排在 $B$ 的左边。

比如 $\textit{folder} = [\texttt{/a},\texttt{/a/b},\texttt{/c},\texttt{/d}]$:

  1. $\texttt{/a}$ 不可能是任何文件夹 $A$ 的子文件夹(否则 $A$ 会排在 $\texttt{/a}$ 前面)。
  2. $\texttt{/a/b}$ 是 $\texttt{/a}$ 的子文件夹,删除。
  3. $\texttt{/c}$ 不是 $\texttt{/a}$ 的子文件夹。
  4. $\texttt{/d}$ 不是 $\texttt{/c}$ 的子文件夹。注意,不需要判断 $\texttt{/d}$ 是否为 $\texttt{/a}$ 的子文件夹。因为一定不是,为什么?如果 $\texttt{/d}$ 是 $\texttt{/a}$ 的子文件夹,由于列表是有序的,$\texttt{/a}$ 的下一个字符串到 $\texttt{/d}$ 之间的字符串都是以 $\texttt{/a/}$ 开头的,所以上一个文件夹 $\texttt{/c}$ 也是以 $\texttt{/a/}$ 开头的,一定是 $\texttt{/a}$ 的子文件夹,一定会被删除,但这与实际矛盾。

因此,排序后,只需比较当前路径与上一个未被删除的路径

代码实现时,为避免 $A + \texttt{/}$ 生成新字符串花费额外时间和空间,判断 $A + \texttt{/}$ 是否为 $B$ 的前缀,可以分解为:

  1. 判断 $A$ 是否为 $B$ 的前缀。
  2. 判断 $B[m]$ 是否为 $\texttt{/}$,其中 $m$ 是 $A$ 的长度。注意题目保证所有字符串互不相同,所以如果 $A$ 是 $B$ 的前缀,那么 $B$ 的长度一定大于 $A$ 的长度。所以 $B[m]$ 不会下标越界。

###py

class Solution:
    def removeSubfolders(self, folder: List[str]) -> List[str]:
        folder.sort()
        ans = [folder[0]]
        for s in folder[1:]:  # 切片可以用 for i in range(1, n) 代替,做到 O(1) 空间
            last = ans[-1]
            if not s.startswith(last) or s[len(last)] != '/':
                ans.append(s)
        return ans

###java

class Solution {
    public List<String> removeSubfolders(String[] folder) {
        Arrays.sort(folder);
        List<String> ans = new ArrayList<>();
        ans.add(folder[0]);
        for (int i = 1; i < folder.length; i++) {
            String s = folder[i];
            String last = ans.getLast();
            if (!s.startsWith(last) || s.charAt(last.length()) != '/') {
                ans.add(s);
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    vector<string> removeSubfolders(vector<string>& folder) {
        ranges::sort(folder);
        vector<string> ans = {folder[0]};
        for (int i = 1; i < folder.size(); i++) {
            string& s = folder[i];
            string& last = ans.back();
            if (!s.starts_with(last) || s[last.size()] != '/') {
                ans.emplace_back(s);
            }
        }
        return ans;
    }
};

###c

int cmp(const void* a, const void* b) {
    return strcmp(*(char**)a, *(char**)b);
}

char** removeSubfolders(char** folder, int folderSize, int* returnSize) {
    qsort(folder, folderSize, sizeof(char*), cmp);

    *returnSize = 1;
    for (int i = 1; i < folderSize; i++) {
        char* s = folder[i];
        char* last = folder[*returnSize - 1];
        int len = strlen(last);
        if (strncmp(last, s, len) != 0 || s[len] != '/') {
            folder[(*returnSize)++] = s; // 原地保存
        }
    }

    return folder;
}

###go

func removeSubfolders(folder []string) []string {
    slices.Sort(folder)
    ans := folder[:1]
    for _, s := range folder[1:] {
        last := ans[len(ans)-1]
        if !strings.HasPrefix(s, last) || s[len(last)] != '/' {
            ans = append(ans, s)
        }
    }
    return ans
}

###js

var removeSubfolders = function(folder) {
    folder.sort();
    const ans = [folder[0]];
    for (let i = 1; i < folder.length; i++) {
        const s = folder[i];
        const last = ans[ans.length - 1];
        if (!s.startsWith(last) || s[last.length] !== '/') {
            ans.push(s);
        }
    }
    return ans;
};

###rust

impl Solution {
    pub fn remove_subfolders(mut folder: Vec<String>) -> Vec<String> {
        folder.sort_unstable();
        let mut ans = vec![folder[0].clone()];
        for s in folder.into_iter().skip(1) {
            let last = ans.last().unwrap();
            if !s.starts_with(last) || s.as_bytes()[last.len()] != b'/' {
                ans.push(s);
            }
        }
        ans
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(\ell\cdot n\log n)$ 时间,$n$ 是 $\textit{folder}$ 的长度,$\ell\le 100$ 为单个字符串的长度。排序有 $\mathcal{O}(n\log n)$ 次比较,每次 $\mathcal{O}(\ell)$ 时间。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。忽略排序的栈开销。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

每日一题-删除子文件夹🟡

2025年7月19日 00:00

你是一位系统管理员,手里有一份文件夹列表 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 每个元素都是 唯一

【爪哇缪斯】图解LeetCode

作者 muse-77
2023年2月8日 13:03

感慨万千,伟大的king!!!

image.png

解题思路

根据题目描述,我们要删除所有的子目录,然后将文件夹列表 folder中剩余的目录输出即可。那么假设我们有一个目录/a,那么所有以/a开头的路径都是它的子目录,如下所示:

主目录/a
子目录/a/a/a/b/a/b/c/a/b/d/e/f/g,……

那么针对如上规则,我们首先需要做的就是对无序的文件夹列表 folder执行排序操作,当排序完毕后,相关的主目录和子目录就会被排列在一起。遍历排序后的文件列表folder,只将主目录保存到result中即可。但是,再描述具体操作之前,我们先来说明一下流程介绍所涉及的变量:

folder[i]】表示遍历的每个folder元素;
result】用于存储最终结果集合;
result(last) 】表示result中存储的最后一个元素;

具体处理逻辑,如下所示:

case1】如果result为空,则直接将folder[0]保存到result中;
case2】如果result(last)满足folder[i]的前缀,则说明folder[i]属于子目录,i执行加1,遍历下一个目录;
case3】如果result(last)不满足folder[i]的前缀,则说明folder[i]属于主目录,将folder[i]保存到result中,然后i执行加1,遍历下一个目录;
结果】当遍历完所有folder列表后,result中存储的就是所有主目录。

以上就是这道题的解题思路,下面我们以输入folder是["/c/d","/a/ab","/a","/c/d/e","/a/b","/c/f/d","/c/f"]为例,看一下具体的处理过程:

image.png

代码实现

###java

public class Solution {
    public List<String> removeSubfolders(String[] folder) {
        Arrays.sort(folder);
        List<String> result = new ArrayList<>();
        result.add(folder[0]);
        for (int i = 1; i < folder.length; i++) {
            StringBuilder sb = new StringBuilder(result.get(result.size() - 1)).append("/");
            if (!folder[i].startsWith(sb.toString())) result.add(folder[i]);
        }
        return result;
    }
}

image.png

今天的文章内容就这些了:

写作不易,笔者几个小时甚至数天完成的一篇文章,只愿换来您几秒钟的 点赞 & 分享

[Python3/Java/C++/Go] 一题双解:排序 & 字典树

作者 lcbin
2023年2月8日 08:51

方法一:排序

我们先将数组 folder 按照字典序排序,然后遍历数组,对于当前遍历到的文件夹 $f$,如果它的长度大于等于答案数组中最后一个文件夹的长度,并且它的前缀包含答案数组的最后一个文件夹再加上一个 /,则说明 $f$ 是答案数组中最后一个文件夹的子文件夹,我们不需要将其加入答案数组中。否则,我们将 $f$ 加入答案数组中。

遍历结束后,答案数组中的文件夹即为题目要求的答案。

class Solution:
    def removeSubfolders(self, folder: List[str]) -> List[str]:
        folder.sort()
        ans = [folder[0]]
        for f in folder[1:]:
            m, n = len(ans[-1]), len(f)
            if m >= n or not (ans[-1] == f[:m] and f[m] == '/'):
                ans.append(f)
        return ans
class Solution {
    public List<String> removeSubfolders(String[] folder) {
        Arrays.sort(folder);
        List<String> ans = new ArrayList<>();
        ans.add(folder[0]);
        for (int i = 1; i < folder.length; ++i) {
            int m = ans.get(ans.size() - 1).length();
            int n = folder[i].length();
            if (m >= n || !(ans.get(ans.size() - 1).equals(folder[i].substring(0, m)) && folder[i].charAt(m) == '/')) {
                ans.add(folder[i]);
            }
        }
        return ans;
    }
}
class Solution {
public:
    vector<string> removeSubfolders(vector<string>& folder) {
        sort(folder.begin(), folder.end());
        vector<string> ans = {folder[0]};
        for (int i = 1; i < folder.size(); ++i) {
            int m = ans.back().size();
            int n = folder[i].size();
            if (m >= n || !(ans.back() == folder[i].substr(0, m) && folder[i][m] == '/')) {
                ans.emplace_back(folder[i]);
            }
        }
        return ans;
    }
};
func removeSubfolders(folder []string) []string {
sort.Strings(folder)
ans := []string{folder[0]}
for _, f := range folder[1:] {
m, n := len(ans[len(ans)-1]), len(f)
if m >= n || !(ans[len(ans)-1] == f[:m] && f[m] == '/') {
ans = append(ans, f)
}
}
return ans
}

时间复杂度 $O(n \times \log n \times m)$,空间复杂度 $O(m)$。其中 $n$ 和 $m$ 分别为数组 folder 的长度和数组 folder 中字符串的最大长度。


方法二:字典树

我们可以使用字典树存储数组 folder 中的所有文件夹。字典树的每个节点包含 children 字段,用于存储当前节点的子节点,以及 fid 字段,用于存储当前节点对应的文件夹在数组 folder 中的下标。

对于数组 folder 中的每个文件夹 $f$,我们先将 $f$ 按照 / 分割成若干个子串,然后从根节点开始,依次将子串加入字典树中。接下来,我们从根节点开始搜索字典树,如果当前节点的 fid 字段不为 -1,则说明当前节点对应的文件夹是答案数组中的一个文件夹,我们将其加入答案数组并且返回。否则,我们递归地搜索当前节点的所有子节点,最终返回答案数组。

class Trie:
    def __init__(self):
        self.children = {}
        self.fid = -1

    def insert(self, i, f):
        node = self
        ps = f.split('/')
        for p in ps[1:]:
            if p not in node.children:
                node.children[p] = Trie()
            node = node.children[p]
        node.fid = i

    def search(self):
        def dfs(root):
            if root.fid != -1:
                ans.append(root.fid)
                return
            for child in root.children.values():
                dfs(child)

        ans = []
        dfs(self)
        return ans

class Solution:
    def removeSubfolders(self, folder: List[str]) -> List[str]:
        trie = Trie()
        for i, f in enumerate(folder):
            trie.insert(i, f)
        return [folder[i] for i in trie.search()]
class Trie {
    private Map<String, Trie> children = new HashMap<>();
    private int fid = -1;

    public void insert(int fid, String f) {
        Trie node = this;
        String[] ps = f.split("/");
        for (int i = 1; i < ps.length; ++i) {
            String p = ps[i];
            if (!node.children.containsKey(p)) {
                node.children.put(p, new Trie());
            }
            node = node.children.get(p);
        }
        node.fid = fid;
    }

    public List<Integer> search() {
        List<Integer> ans = new ArrayList<>();
        dfs(this, ans);
        return ans;
    }

    private void dfs(Trie root, List<Integer> ans) {
        if (root.fid != -1) {
            ans.add(root.fid);
            return;
        }
        for (var child : root.children.values()) {
            dfs(child, ans);
        }
    }
}

class Solution {
    public List<String> removeSubfolders(String[] folder) {
        Trie trie = new Trie();
        for (int i = 0; i < folder.length; ++i) {
            trie.insert(i, folder[i]);
        }
        List<String> ans = new ArrayList<>();
        for (int i : trie.search()) {
            ans.add(folder[i]);
        }
        return ans;
    }
}
class Trie {
public:
    void insert(int fid, string& f) {
        Trie* node = this;
        vector<string> ps = split(f, '/');
        for (int i = 1; i < ps.size(); ++i) {
            auto& p = ps[i];
            if (!node->children.count(p)) {
                node->children[p] = new Trie();
            }
            node = node->children[p];
        }
        node->fid = fid;
    }

    vector<int> search() {
        vector<int> ans;
        function<void(Trie*)> dfs = [&](Trie* root) {
            if (root->fid != -1) {
                ans.push_back(root->fid);
                return;
            }
            for (auto& [_, child] : root->children) {
                dfs(child);
            }
        };
        dfs(this);
        return ans;
    }

    vector<string> split(string& s, char delim) {
        stringstream ss(s);
        string item;
        vector<string> res;
        while (getline(ss, item, delim)) {
            res.emplace_back(item);
        }
        return res;
    }

private:
    unordered_map<string, Trie*> children;
    int fid = -1;
};

class Solution {
public:
    vector<string> removeSubfolders(vector<string>& folder) {
        Trie* trie = new Trie();
        for (int i = 0; i < folder.size(); ++i) {
            trie->insert(i, folder[i]);
        }
        vector<string> ans;
        for (int i : trie->search()) {
            ans.emplace_back(folder[i]);
        }
        return ans;
    }
};
type Trie struct {
children map[string]*Trie
fid      int
}

func newTrie() *Trie {
return &Trie{map[string]*Trie{}, -1}
}

func (this *Trie) insert(fid int, f string) {
node := this
ps := strings.Split(f, "/")
for _, p := range ps[1:] {
if _, ok := node.children[p]; !ok {
node.children[p] = newTrie()
}
node = node.children[p]
}
node.fid = fid
}

func (this *Trie) search() (ans []int) {
var dfs func(*Trie)
dfs = func(root *Trie) {
if root.fid != -1 {
ans = append(ans, root.fid)
return
}
for _, child := range root.children {
dfs(child)
}
}
dfs(this)
return
}

func removeSubfolders(folder []string) (ans []string) {
trie := newTrie()
for i, f := range folder {
trie.insert(i, f)
}
for _, i := range trie.search() {
ans = append(ans, folder[i])
}
return
}

时间复杂度 $O(n \times m)$,空间复杂度 $O(n \times m)$。其中 $n$ 和 $m$ 分别为数组 folder 的长度和数组 folder 中字符串的最大长度。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

删除子文件夹

2023年2月7日 10:15

方法一:排序

思路与算法

我们可以将字符串数组 $\textit{folder}$ 按照字典序进行排序。在排序完成后,对于每一个 $\textit{folder}[i]$,如果 $\textit{folder}[i-1]$ 恰好是它的前缀,并且 $\textit{folder}[i]$ 第一个多出的字符是 $\text{/}$,那么我们就可以把 $\textit{folder}[i]$ 删除。

注意当 $\textit{folder}[i]$ 被删除后,后续的所有字符串都需要向前移动一个位置。例如 $\text{[``/a'',``/a/b'',``/a/c'']}$ 中,$\text{``/a/b''}$ 被删除后,数组会变为 $\text{[``/a'',``/a/c'']}$,$\text{``/a/c''}$ 也会被删除。

这样做的必要性是显然的,因为如果上述条件满足,就说明 $\textit{folder}[i]$ 是 $\textit{folder}[i-1]$ 的子文件夹。对于充分性,我们可以使用反证法:

假设 $\textit{folder}[i]$ 是某个 $\textit{folder}[j]~(j \neq i-1)$ 的子文件夹但不是 $\textit{folder}[i-1]$ 的子文件夹,那么在排序后,$\textit{folder}[j]$ 一定出现在 $\textit{folder}[i]$ 的前面,也就是有 $j < i$。如果有多个满足要求的 $j$,我们选择最早出现的那个。这样就保证了 $\textit{folder}[j]$ 本身不会是其它文件夹的子文件夹。

由于 $\text{/''}$ 的字典序小于所有的小写字母,并且 $\textit{folder}[i]$ 是由 $\textit{folder}[j]$ 加上 $\text{/''}$ 再加上后续字符组成,因此在 $\textit{folder}[i]$ 和 $\textit{folder}[j]$ 之间的所有字符串也都一定是由 $\textit{folder}[j]$ 加上 $\text{``/''}$ 再加上后续字符组成。这些字符串都是 $\textit{folder}[i]$ 的子文件夹,它们会依次被删除。当遍历到 $\textit{folder}[i]$ 时,它的上一个元素恰好是 $\textit{folder}[j]$,因此它一定会被删除。

代码

###C++

class Solution {
public:
    vector<string> removeSubfolders(vector<string>& folder) {
        sort(folder.begin(), folder.end());
        vector<string> ans = {folder[0]};
        for (int i = 1; i < folder.size(); ++i) {
            if (int pre = ans.end()[-1].size(); !(pre < folder[i].size() && ans.end()[-1] == folder[i].substr(0, pre) && folder[i][pre] == '/')) {
                ans.push_back(folder[i]);
            }
        }
        return ans;
    }
};

###Java

class Solution {
    public List<String> removeSubfolders(String[] folder) {
        Arrays.sort(folder);
        List<String> ans = new ArrayList<String>();
        ans.add(folder[0]);
        for (int i = 1; i < folder.length; ++i) {
            int pre = ans.get(ans.size() - 1).length();
            if (!(pre < folder[i].length() && ans.get(ans.size() - 1).equals(folder[i].substring(0, pre)) && folder[i].charAt(pre) == '/')) {
                ans.add(folder[i]);
            }
        }
        return ans;
    }
}

###C#

public class Solution {
    public IList<string> RemoveSubfolders(string[] folder) {
        Array.Sort(folder);
        IList<string> ans = new List<string>();
        ans.Add(folder[0]);
        for (int i = 1; i < folder.Length; ++i) {
            int pre = ans[ans.Count - 1].Length;
            if (!(pre < folder[i].Length && ans[ans.Count - 1].Equals(folder[i].Substring(0, pre)) && folder[i][pre] == '/')) {
                ans.Add(folder[i]);
            }
        }
        return ans;
    }
}

###Python

class Solution:
    def removeSubfolders(self, folder: List[str]) -> List[str]:
        folder.sort()
        ans = [folder[0]]
        for i in range(1, len(folder)):
            if not ((pre := len(ans[-1])) < len(folder[i]) and ans[-1] == folder[i][:pre] and folder[i][pre] == "/"):
                ans.append(folder[i])
        return ans

###C

static int cmp(const int *pa, const int *pb) {
    return strcmp(*(char **)pa, *(char **)pb);
}

char ** removeSubfolders(char ** folder, int folderSize, int* returnSize) {
    qsort(folder, folderSize, sizeof(char *), cmp);
    char **ans = (char **)malloc(sizeof(char *) * folderSize);
    int pos = 0;
    ans[pos++] = folder[0];
    for (int i = 1; i < folderSize; ++i) {
        int pre = strlen(ans[pos - 1]);
        if (!(pre < strlen(folder[i]) && !strncmp(ans[pos - 1], folder[i], pre) && folder[i][pre] == '/')) {
            ans[pos++] = folder[i];
        }
    }
    *returnSize = pos;
    return ans;
}

###go

func removeSubfolders(folder []string) (ans []string) {
    sort.Strings(folder)
    ans = append(ans, folder[0])
    for _, f := range folder[1:] {
        last := ans[len(ans)-1]
        if !strings.HasPrefix(f, last) || f[len(last)] != '/' {
            ans = append(ans, f)
        }
    }
    return
}

###JavaScript

var removeSubfolders = function(folder) {
    folder.sort();
    const ans = [folder[0]];
    for (let i = 1; i < folder.length; ++i) {
        const pre = ans[ans.length - 1].length;
        if (!(pre < folder[i].length && ans[ans.length - 1] === (folder[i].substring(0, pre)) && folder[i].charAt(pre) === '/')) {
            ans.push(folder[i]);
        }
    }
    return ans;
};

复杂度分析

  • 时间复杂度:$O(nl \cdot \log n)$,其中 $n$ 和 $l$ 分别是数组 $\textit{folder}$ 的长度和文件夹的平均长度。$O(nl \cdot \log n)$ 为排序需要的时间,后续构造答案需要的时间为 $O(nl)$,在渐进意义下小于前者。

  • 空间复杂度:$O(l)$。在构造答案比较前缀时,我们使用了字符串的截取子串操作,因此需要 $O(l)$ 的临时空间。我们也可以使用一个递增的指针依次对两个字符串的每个相同位置进行比较,省去这一部分的空间,使得空间复杂度降低至排序需要的栈空间 $O(\log n)$。但空间优化并不是本题的重点,因此上述的代码中仍然采用空间复杂度为 $O(l)$ 的写法。注意这里不计入返回值占用的空间。

方法二:字典树

思路与算法

我们也可以使用字典树来解决本题。文件夹的拓扑结构正好是树形结构,即字典树上的每一个节点就是一个文件夹。

对于字典树中的每一个节点,我们仅需要存储一个变量 $\textit{ref}$,如果 $\textit{ref} \geq 0$,说明该节点对应着 $\textit{folder}[\textit{ref}~]$,否则($\textit{ref} = -1$)说明该节点只是一个中间节点。

我们首先将每一个文件夹按照 $\text{``/''}$ 进行分割,作为一条路径加入字典树中。随后我们对字典树进行一次深度优先搜索,搜索的过程中,如果我们走到了一个 $\textit{ref} \geq 0$ 的节点,就将其加入答案,并且可以直接回溯,因为后续(更深的)所有节点都是该节点的子文件夹。

代码

###C++

struct Trie {
    Trie(): ref(-1) {}

    unordered_map<string, Trie*> children;
    int ref;
};

class Solution {
public:
    vector<string> removeSubfolders(vector<string>& folder) {
        auto split = [](const string& s) -> vector<string> {
            vector<string> ret;
            string cur;
            for (char ch: s) {
                if (ch == '/') {
                    ret.push_back(move(cur));
                    cur.clear();
                }
                else {
                    cur.push_back(ch);
                }
            }
            ret.push_back(move(cur));
            return ret;
        };

        Trie* root = new Trie();
        for (int i = 0; i < folder.size(); ++i) {
            vector<string> path = split(folder[i]);
            Trie* cur = root;
            for (const string& name: path) {
                if (!cur->children.count(name)) {
                    cur->children[name] = new Trie();
                }
                cur = cur->children[name];
            }
            cur->ref = i;
        }

        vector<string> ans;

        function<void(Trie*)> dfs = [&](Trie* cur) {
            if (cur->ref != -1) {
                ans.push_back(folder[cur->ref]);
                return;
            }
            for (auto&& [_, child]: cur->children) {
                dfs(child);
            }
        };

        dfs(root);
        return ans;
    }
};

###Java

class Solution {
    public List<String> removeSubfolders(String[] folder) {
        Trie root = new Trie();
        for (int i = 0; i < folder.length; ++i) {
            List<String> path = split(folder[i]);
            Trie cur = root;
            for (String name : path) {
                cur.children.putIfAbsent(name, new Trie());
                cur = cur.children.get(name);
            }
            cur.ref = i;
        }

        List<String> ans = new ArrayList<String>();
        dfs(folder, ans, root);
        return ans;
    }

    public List<String> split(String s) {
        List<String> ret = new ArrayList<String>();
        StringBuilder cur = new StringBuilder();
        for (int i = 0; i < s.length(); ++i) {
            char ch = s.charAt(i);
            if (ch == '/') {
                ret.add(cur.toString());
                cur.setLength(0);
            } else {
                cur.append(ch);
            }
        }
        ret.add(cur.toString());
        return ret;
    }

    public void dfs(String[] folder, List<String> ans, Trie cur) {
        if (cur.ref != -1) {
            ans.add(folder[cur.ref]);
            return;
        }
        for (Trie child : cur.children.values()) {
            dfs(folder, ans, child);
        }
    }
}

class Trie {
    int ref;
    Map<String, Trie> children;

    public Trie() {
        ref = -1;
        children = new HashMap<String, Trie>();
    }
}

###C#

public class Solution {
    public IList<string> RemoveSubfolders(string[] folder) {
        Trie root = new Trie();
        for (int i = 0; i < folder.Length; ++i) {
            IList<string> path = Split(folder[i]);
            Trie cur = root;
            foreach (string name in path) {
                cur.children.TryAdd(name, new Trie());
                cur = cur.children[name];
            }
            cur.reference = i;
        }

        IList<string> ans = new List<string>();
        DFS(folder, ans, root);
        return ans;
    }

    public IList<string> Split(string s) {
        IList<string> ret = new List<string>();
        StringBuilder cur = new StringBuilder();
        foreach (char ch in s) {
            if (ch == '/') {
                ret.Add(cur.ToString());
                cur.Length = 0;
            } else {
                cur.Append(ch);
            }
        }
        ret.Add(cur.ToString());
        return ret;
    }

    public void DFS(string[] folder, IList<string> ans, Trie cur) {
        if (cur.reference != -1) {
            ans.Add(folder[cur.reference]);
            return;
        }
        foreach (Trie child in cur.children.Values) {
            DFS(folder, ans, child);
        }
    }
}

public class Trie {
    public int reference;
    public IDictionary<string, Trie> children;

    public Trie() {
        reference = -1;
        children = new Dictionary<string, Trie>();
    }
}

###Python

class Trie:
    def __init__(self):
        self.children = dict()
        self.ref = -1

class Solution:
    def removeSubfolders(self, folder: List[str]) -> List[str]:
        root = Trie()
        for i, path in enumerate(folder):
            path = path.split("/")
            cur = root
            for name in path:
                if name not in cur.children:
                    cur.children[name] = Trie()
                cur = cur.children[name]
            cur.ref = i
        
        ans = list()

        def dfs(cur: Trie):
            if cur.ref != -1:
                ans.append(folder[cur.ref])
                return
            for child in cur.children.values():
                dfs(child)

        dfs(root)
        return ans

###C

typedef struct {
    char *key;
    struct Trie *val;
    UT_hash_handle hh;
} HashItem; 

typedef struct Trie {
    HashItem *children;
    int ref;
} Trie;

Trie *creatTrie() {
    Trie *obj = (Trie *)malloc(sizeof(Trie));
    obj->children = NULL;
    obj->ref = -1;
    return obj;
}

HashItem *hashFindItem(HashItem **obj, char *key) {
    HashItem *pEntry = NULL;
    HASH_FIND_STR(*obj, key, pEntry);
    return pEntry;
}

bool hashAddItem(HashItem **obj, char *key, Trie *val) {
    if (hashFindItem(obj, key)) {
        return false;
    }
    HashItem *pEntry = (HashItem *)malloc(sizeof(HashItem));
    pEntry->key = key;
    pEntry->val = val;
    HASH_ADD_STR(*obj, key, pEntry);
    return true;
}

void hashFree(HashItem **obj) {
    HashItem *curr = NULL, *tmp = NULL;
    HASH_ITER(hh, *obj, curr, tmp) {
        HASH_DEL(*obj, curr);  
        free(curr);             
    }
}

char **split(char *str, int *returnSize) {
    int len = strlen(str);
    char **ret = (char *)malloc(sizeof(char *) * len);
    char *p = strtok(str, "/");
    int pos = 0;
    while (p != NULL) {
        ret[pos++] = p;
        p = strtok(NULL, "/");
    }
    *returnSize = pos;
    return ret;
}

void dfs(Trie* cur, char **res, int *pos, char **folder) {
    if (cur->ref != -1) {
        res[(*pos)++] = folder[cur->ref];
        return;
    }

    for (HashItem *pEntry = cur->children; pEntry != NULL; pEntry = pEntry->hh.next) {
        dfs(pEntry->val, res, pos, folder);
    }
};

void freeTrie(Trie* root) {
    for (HashItem *pEntry = root->children; pEntry != NULL; pEntry = pEntry->hh.next) {
        freeTrie(pEntry->val);
    }
}

char ** removeSubfolders(char ** folder, int folderSize, int* returnSize) {
    Trie *root = creatTrie();
    char **copy = (char **)malloc(sizeof(char *) * folderSize); 
    for (int i = 0; i < folderSize; ++i) {
        copy[i] = (char *)malloc(sizeof(char) * (strlen(folder[i]) + 1));
        strcpy(copy[i], folder[i]);
        int pathSize = 0;
        char **path = split(copy[i], &pathSize);
        Trie *cur = root;
        for (int j = 0; j < pathSize; j++) {
            char *name = path[j];
            HashItem *pEntry = hashFindItem(&cur->children, name);
            Trie *node = NULL;
            if (pEntry == NULL) {
                node = creatTrie();
                hashAddItem(&cur->children, name, node);
            } else {
                node = pEntry->val;
            }
            cur = node;
        }
        free(path);
        cur->ref = i;
    }
    char **ans = (char **)malloc(sizeof(char *) * folderSize);
    int pos = 0;
    dfs(root, ans, &pos, folder);
    freeTrie(root);
    for (int i = 0; i < folderSize; i++) {
        free(copy[i]);
    }
    free(copy);
    *returnSize = pos;
    return ans;
}

###JavaScript

var removeSubfolders = function(folder) {
    const root = new Trie();
    for (let i = 0; i < folder.length; ++i) {
        const path = split(folder[i]);
        let cur = root;
        for (const name of path) {
            if (!cur.children.has(name)) {
                cur.children.set(name, new Trie());
            }
            cur = cur.children.get(name);
        }
        cur.ref = i;
    }

    const ans = [];

    const dfs = (folder, ans, cur) => {
        if (cur.ref !== -1) {
            ans.push(folder[cur.ref]);
            return;
        }
        for (const child of cur.children.values()) {
            dfs(folder, ans, child);
        }
    }

    dfs(folder, ans, root);
    return ans;
}

const split = (s) => {
    const ret = [];
    let cur = '';
    for (let i = 0; i < s.length; ++i) {
        const ch = s[i];
        if (ch === '/') {
            ret.push(cur);
            cur = ''
        } else {
            cur += ch;
        }
    }
    ret.push(cur);
    return ret;
}

class Trie {
    constructor() {
        this.ref = -1;
        this.children = new Map();
    }
}

复杂度分析

  • 时间复杂度:$O(nl)$,其中 $n$ 和 $l$ 分别是数组 $\textit{folder}$ 的长度和文件夹的平均长度。即为构造字典树和答案需要的时间。

  • 空间复杂度:$O(nl)$,即为字典树需要使用的空间。注意这里不计入返回值占用的空间。

[Python3/Java/C++/Go/TypeScript] 一题一解:优先队列 + 前后缀和 + 枚举(清晰题解)

作者 lcbin
2025年7月18日 07:12

方法一:优先队列(大小根堆)+ 前后缀和 + 枚举分割点

题目实际上等价于在 $nums$ 中找到一个分割点,将数组分成左右两部分,在前一部分中选取最小的 $n$ 个元素,在后一部分中选取最大的 $n$ 个元素,使得两部分和的差值最小。

我们可以用一个大根堆维护前缀中最小的 $n$ 个元素,用一个小根堆维护后缀中最大的 $n$ 个元素。我们定义 $pre[i]$ 表示在数组 $nums$ 的前 $i$ 个元素中选择最小的 $n$ 个元素的和,定义 $suf[i]$ 表示从数组第 $i$ 个元素到最后一个元素中选择最大的 $n$ 个元素的和。在维护大小根堆的过程中,更新 $pre[i]$ 和 $suf[i]$ 的值。

最后,我们在 $i \in [n, 2n]$ 的范围内枚举分割点,计算 $pre[i] - suf[i + 1]$ 的值,取最小值即可。

###python

class Solution:
    def minimumDifference(self, nums: List[int]) -> int:
        m = len(nums)
        n = m // 3

        s = 0
        pre = [0] * (m + 1)
        q1 = []
        for i, x in enumerate(nums[: n * 2], 1):
            s += x
            heappush(q1, -x)
            if len(q1) > n:
                s -= -heappop(q1)
            pre[i] = s

        s = 0
        suf = [0] * (m + 1)
        q2 = []
        for i in range(m, n, -1):
            x = nums[i - 1]
            s += x
            heappush(q2, x)
            if len(q2) > n:
                s -= heappop(q2)
            suf[i] = s

        return min(pre[i] - suf[i + 1] for i in range(n, n * 2 + 1))

###java

class Solution {
    public long minimumDifference(int[] nums) {
        int m = nums.length;
        int n = m / 3;
        long s = 0;
        long[] pre = new long[m + 1];
        PriorityQueue<Integer> pq = new PriorityQueue<>((a, b) -> b - a);
        for (int i = 1; i <= n * 2; ++i) {
            int x = nums[i - 1];
            s += x;
            pq.offer(x);
            if (pq.size() > n) {
                s -= pq.poll();
            }
            pre[i] = s;
        }
        s = 0;
        long[] suf = new long[m + 1];
        pq = new PriorityQueue<>();
        for (int i = m; i > n; --i) {
            int x = nums[i - 1];
            s += x;
            pq.offer(x);
            if (pq.size() > n) {
                s -= pq.poll();
            }
            suf[i] = s;
        }
        long ans = 1L << 60;
        for (int i = n; i <= n * 2; ++i) {
            ans = Math.min(ans, pre[i] - suf[i + 1]);
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    long long minimumDifference(vector<int>& nums) {
        int m = nums.size();
        int n = m / 3;

        using ll = long long;
        ll s = 0;
        ll pre[m + 1];
        priority_queue<int> q1;
        for (int i = 1; i <= n * 2; ++i) {
            int x = nums[i - 1];
            s += x;
            q1.push(x);
            if (q1.size() > n) {
                s -= q1.top();
                q1.pop();
            }
            pre[i] = s;
        }
        s = 0;
        ll suf[m + 1];
        priority_queue<int, vector<int>, greater<int>> q2;
        for (int i = m; i > n; --i) {
            int x = nums[i - 1];
            s += x;
            q2.push(x);
            if (q2.size() > n) {
                s -= q2.top();
                q2.pop();
            }
            suf[i] = s;
        }
        ll ans = 1e18;
        for (int i = n; i <= n * 2; ++i) {
            ans = min(ans, pre[i] - suf[i + 1]);
        }
        return ans;
    }
};

###go

func minimumDifference(nums []int) int64 {
m := len(nums)
n := m / 3
s := 0
pre := make([]int, m+1)
q1 := hp{}
for i := 1; i <= n*2; i++ {
x := nums[i-1]
s += x
heap.Push(&q1, -x)
if q1.Len() > n {
s -= -heap.Pop(&q1).(int)
}
pre[i] = s
}
s = 0
suf := make([]int, m+1)
q2 := hp{}
for i := m; i > n; i-- {
x := nums[i-1]
s += x
heap.Push(&q2, x)
if q2.Len() > n {
s -= heap.Pop(&q2).(int)
}
suf[i] = s
}
ans := int64(1e18)
for i := n; i <= n*2; i++ {
ans = min(ans, int64(pre[i]-suf[i+1]))
}
return ans
}

type hp struct{ sort.IntSlice }

func (h hp) Less(i, j int) bool { return h.IntSlice[i] < h.IntSlice[j] }
func (h *hp) Push(v any)        { h.IntSlice = append(h.IntSlice, v.(int)) }
func (h *hp) Pop() any {
a := h.IntSlice
v := a[len(a)-1]
h.IntSlice = a[:len(a)-1]
return v
}

###ts

function minimumDifference(nums: number[]): number {
    const m = nums.length;
    const n = Math.floor(m / 3);
    let s = 0;
    const pre: number[] = Array(m + 1);
    const q1 = new MaxPriorityQueue<number>();
    for (let i = 1; i <= n * 2; ++i) {
        const x = nums[i - 1];
        s += x;
        q1.enqueue(x);
        if (q1.size() > n) {
            s -= q1.dequeue();
        }
        pre[i] = s;
    }
    s = 0;
    const suf: number[] = Array(m + 1);
    const q2 = new MinPriorityQueue<number>();
    for (let i = m; i > n; --i) {
        const x = nums[i - 1];
        s += x;
        q2.enqueue(x);
        if (q2.size() > n) {
            s -= q2.dequeue();
        }
        suf[i] = s;
    }
    let ans = Number.MAX_SAFE_INTEGER;
    for (let i = n; i <= n * 2; ++i) {
        ans = Math.min(ans, pre[i] - suf[i + 1]);
    }
    return ans;
}

###rust

use std::collections::BinaryHeap;
use std::cmp::Reverse;

impl Solution {
    pub fn minimum_difference(nums: Vec<i32>) -> i64 {
        let m = nums.len();
        let n = m / 3;
        let mut s = 0i64;
        let mut pre = vec![0i64; m + 1];
        let mut pq = BinaryHeap::new(); // max-heap

        for i in 1..=2 * n {
            let x = nums[i - 1] as i64;
            s += x;
            pq.push(x);
            if pq.len() > n {
                if let Some(top) = pq.pop() {
                    s -= top;
                }
            }
            pre[i] = s;
        }

        s = 0;
        let mut suf = vec![0i64; m + 1];
        let mut pq = BinaryHeap::new();

        for i in (n + 1..=m).rev() {
            let x = nums[i - 1] as i64;
            s += x;
            pq.push(Reverse(x));
            if pq.len() > n {
                if let Some(Reverse(top)) = pq.pop() {
                    s -= top;
                }
            }
            suf[i] = s;
        }

        let mut ans = i64::MAX;
        for i in n..=2 * n {
            ans = ans.min(pre[i] - suf[i + 1]);
        }

        ans
    }
}

时间复杂度 $O(n \times \log n)$,空间复杂度 $O(n)$。其中 $n$ 为数组 $nums$ 的长度。


有任何问题,欢迎评论区交流,欢迎评论区提供其它解题思路(代码),也可以点个赞支持一下作者哈😄~

每日一题-删除元素后和的最小差值🔴

2025年7月18日 00:00

给你一个下标从 0 开始的整数数组 nums ,它包含 3 * n 个元素。

你可以从 nums 中删除 恰好 n 个元素,剩下的 2 * n 个元素将会被分成两个 相同大小 的部分。

  • 前面 n 个元素属于第一部分,它们的和记为 sumfirst 。
  • 后面 n 个元素属于第二部分,它们的和记为 sumsecond 。

两部分和的 差值 记为 sumfirst - sumsecond 。

  • 比方说,sumfirst = 3 且 sumsecond = 2 ,它们的差值为 1 。
  • 再比方,sumfirst = 2 且 sumsecond = 3 ,它们的差值为 -1 。

请你返回删除 n 个元素之后,剩下两部分和的 差值的最小值 是多少。

 

示例 1:

输入:nums = [3,1,2]
输出:-1
解释:nums 有 3 个元素,所以 n = 1 。
所以我们需要从 nums 中删除 1 个元素,并将剩下的元素分成两部分。
- 如果我们删除 nums[0] = 3 ,数组变为 [1,2] 。两部分和的差值为 1 - 2 = -1 。
- 如果我们删除 nums[1] = 1 ,数组变为 [3,2] 。两部分和的差值为 3 - 2 = 1 。
- 如果我们删除 nums[2] = 2 ,数组变为 [3,1] 。两部分和的差值为 3 - 1 = 2 。
两部分和的最小差值为 min(-1,1,2) = -1 。

示例 2:

输入:nums = [7,9,5,8,1,3]
输出:1
解释:n = 2 。所以我们需要删除 2 个元素,并将剩下元素分为 2 部分。
如果我们删除元素 nums[2] = 5 和 nums[3] = 8 ,剩下元素为 [7,9,1,3] 。和的差值为 (7+9) - (1+3) = 12 。
为了得到最小差值,我们应该删除 nums[1] = 9 和 nums[4] = 1 ,剩下的元素为 [7,5,8,3] 。和的差值为 (7+5) - (8+3) = 1 。
观察可知,最优答案为 1 。

 

提示:

  • nums.length == 3 * n
  • 1 <= n <= 105
  • 1 <= nums[i] <= 105

【什码情况】两个优先级队列,双百

作者 smqk
2022年2月6日 19:58

image.png

解题思路

1、前部分元素和求最小值,后部分元素和求最大值;
2、循环遍历找到最小差值即可;

代码

###java

class Solution {
    public long minimumDifference(int[] nums) {
        int n = nums.length / 3;
        // min[i] 表示 nums[0] ~ nums[i] 个元素中选择 n 个元素和最小
        long[] min = new long[3 * n];

        // max[i] 表示 nums[i] ~ nums[3*n-1] 个元素中选择 n 个元素和最大
        long[] max = new long[3 * n];

        // 升序队列、 降序队列
        PriorityQueue<Integer> asc = new PriorityQueue<>();
        PriorityQueue<Integer> desc = new PriorityQueue<>((a, b) -> b - a);
        
        long sum_first = 0, sum_second = 0;
        for (int i = 0; i < 3 * n; i++) {
            int l = i, r = 3 * n - 1 - i;
            int left = nums[l], right = nums[r];
            sum_first += left;
            desc.add(left);

            sum_second += right;
            asc.add(right);

            if (i >= n) {
                sum_first -= desc.poll();
                sum_second -= asc.poll();
            }
            min[l] = sum_first;
            max[r] = sum_second;
        }

        // 遍历所有情况找到最小差值
        long minAns = Long.MAX_VALUE;
        for (int i = n - 1; i < 2 * n; i++) {
            minAns = Math.min(minAns, min[i] - max[i + 1]);
        }

        return minAns;
    }
}

最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎加我微信『 code5bug 』和 加入我们的「组队打卡」小群。

前后缀分解 + 堆(Python/Java/C++/Go)

作者 endlesscheng
2022年2月6日 00:12

题意:把 $\textit{nums}$ 分割成两部分,第一部分选 $n$ 个数求和,记作 $s_1$,第二部分选 $n$ 个数求和,记作 $s_2$。计算 $s_1-s_2$ 的最小值。

由于 $s_1$ 和 $s_2$ 互相独立,为了让 $s_1-s_2$ 尽量小,那么 $s_1$ 越小越好,$s_2$ 越大越好。

枚举分割位置,保证两部分都至少有 $n$ 个数。对于每个分割位置,计算最小 $s_1$ 和最大 $s_2$,所有 $s_1-s_2$ 的最小值就是答案。

一个 $n=4$ 的例子:

具体地,我们需要计算出 $\textit{nums}$ 的前缀最小和 $\textit{preMin}[i]$,即 $\textit{nums}[0]$ 到 $\textit{nums}[i]$ 中最小的 $n$ 个元素之和;以及后缀最大和 $\textit{sufMax}[i]$,即 $\textit{nums}[i]$ 到 $\textit{nums}[3n-1]$ 中最大的 $n$ 个元素之和。答案为 $\textit{preMin}[i]-\textit{sufMax}[i+1]$ 中的最小值。

对于后缀最大和,可以从右往左遍历 $\textit{nums}$ 计算,思路同经典题 703. 数据流中的第 K 大元素(Top K),维护一个包含 $n$ 个元素的最小堆(及其元素和)。不断向左遍历 $\textit{nums}$ 中的元素 $v$,若 $v$ 比堆顶元素大,则弹出堆顶,并将 $v$ 入堆,这可以让堆中元素和更大。

同理,对于前缀最小和,则需要维护一个包含 $n$ 个元素的最大堆(及其元素和)。不断向右遍历 $\textit{nums}$ 中的元素 $v$,若 $v$ 比堆顶元素小,则弹出堆顶,并将 $v$ 入堆,这可以让堆中元素和更小。

代码实现时,可以在计算前缀最小和的同时计算答案。

class Solution:
    def minimumDifference(self, nums: List[int]) -> int:
        m = len(nums)
        n = m // 3
        min_h = nums[-n:]
        heapify(min_h)

        suf_max = [0] * (m - n + 1)  # 后缀最大和
        suf_max[-1] = sum(min_h)
        for i in range(m - n - 1, n - 1, -1):
            suf_max[i] = suf_max[i + 1] + nums[i] - heappushpop(min_h, nums[i])

        max_h = [-x for x in nums[:n]]  # 所有元素取反,表示最大堆
        heapify(max_h)

        pre_min = -sum(max_h)  # 前缀最小和
        ans = pre_min - suf_max[n]  # i=n-1 时的答案
        for i in range(n, m - n):
            pre_min += nums[i] + heappushpop(max_h, -nums[i])
            ans = min(ans, pre_min - suf_max[i + 1])
        return ans
class Solution {
    public long minimumDifference(int[] nums) {
        int m = nums.length;
        int n = m / 3;
        PriorityQueue<Integer> minPQ = new PriorityQueue<>();
        long sum = 0;
        for (int i = m - 1; i >= m - n; i--) {
            minPQ.offer(nums[i]);
            sum += nums[i];
        }

        long[] sufMax = new long[m - n + 1]; // 后缀最大和
        sufMax[m - n] = sum;
        for (int i = m - n - 1; i >= n; i--) {
            int v = nums[i];
            if (v > minPQ.peek()) {
                sum += v - minPQ.poll();
                minPQ.offer(v);
            }
            sufMax[i] = sum;
        }

        PriorityQueue<Integer> maxPQ = new PriorityQueue<>((a, b) -> b - a);
        long preMin = 0; // 前缀最小和
        for (int i = 0; i < n; ++i) {
            maxPQ.offer(nums[i]);
            preMin += nums[i];
        }

        long ans = preMin - sufMax[n]; // i=n-1 时的答案
        for (int i = n; i < m - n; i++) {
            int v = nums[i];
            if (v < maxPQ.peek()) {
                preMin += v - maxPQ.poll();
                maxPQ.offer(v);
            }
            ans = Math.min(ans, preMin - sufMax[i + 1]);
        }
        return ans;
    }
}
class Solution {
public:
    long long minimumDifference(vector<int>& nums) {
        int m = nums.size(), n = m / 3;

        priority_queue<int, vector<int>, greater<>> min_pq(nums.end() - n, nums.end());
        long long sum = reduce(nums.end() - n, nums.end(), 0LL);

        vector<long long> suf_max(m - n + 1); // 后缀最大和
        suf_max[m - n] = sum;
        for (int i = m - n - 1; i >= n; i--) {
            int v = nums[i];
            if (v > min_pq.top()) {
                sum += v - min_pq.top();
                min_pq.pop();
                min_pq.push(v);
            }
            suf_max[i] = sum;
        }

        priority_queue<int> max_pq(nums.begin(), nums.begin() + n);
        long long pre_min = reduce(nums.begin(), nums.begin() + n, 0LL); // 前缀最小和

        long long ans = pre_min - suf_max[n]; // i=n-1 时的答案
        for (int i = n; i < m - n; i++) {
            int v = nums[i];
            if (v < max_pq.top()) {
                pre_min += v - max_pq.top();
                max_pq.pop();
                max_pq.push(v);
            }
            ans = min(ans, pre_min - suf_max[i + 1]);
        }
        return ans;
    }
};
func minimumDifference(nums []int) int64 {
m := len(nums)
n := m / 3
minH := minHeap{nums[m-n:]}
heap.Init(&minH)
sum := 0
for _, v := range nums[m-n:] {
sum += v
}

sufMax := make([]int, m-n+1) // 后缀最大和
sufMax[m-n] = sum
for i := m - n - 1; i >= n; i-- {
if v := nums[i]; v > minH.IntSlice[0] {
sum += v - minH.IntSlice[0]
minH.IntSlice[0] = v
heap.Fix(&minH, 0)
}
sufMax[i] = sum
}

maxH := maxHeap{nums[:n]}
heap.Init(&maxH)
preMin := 0 // 前缀最小和
for _, v := range nums[:n] {
preMin += v
}

ans := preMin - sufMax[n]
for i := n; i < m-n; i++ { // i=n-1 时的答案
if v := nums[i]; v < maxH.IntSlice[0] {
preMin += v - maxH.IntSlice[0]
maxH.IntSlice[0] = v
heap.Fix(&maxH, 0)
}
ans = min(ans, preMin-sufMax[i+1])
}
return int64(ans)
}

type minHeap struct{ sort.IntSlice }
func (minHeap) Push(any) {}
func (minHeap) Pop() (_ any) { return }

type maxHeap struct{ sort.IntSlice }
func (h maxHeap) Less(i, j int) bool { return h.IntSlice[i] > h.IntSlice[j] }
func (maxHeap) Push(any) {}
func (maxHeap) Pop() (_ any) { return }

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$。其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

专题训练

下面动态规划题单的「专题:前后缀分解」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

(C++/python3)枚举分割点 + 优先队列

作者 newhar
2022年2月6日 00:11

解法:枚举分割点 + 堆

问题等价于:枚举数组的分割点,将数组分成两半,在左侧取最小的 $n/3$ 个元素,在右侧取最大的 $n/3$ 个元素,然后计算左侧的和与右侧的和,取它们的差的最小值。

我们定义

  • $s_1[i]$ 为从数组 $nums[0\sim i]$ 中,取最小的 $n/3$ 个元素(如果有)的和
  • $s_2[i]$ 为从数组 $nums[i+1\sim n-1]$ 中,取最大的 $n/3$ 个元素(如果有)的和

如何计算 $s_1[i]$ 呢?我们只需从左到右遍历,维护一个大顶堆,里面存放数组左侧的最小的 $n/3$ 个元素即可;添加元素后,如果堆中的元素数量 $> n/3$,则从堆中移除最大的元素。同时维护最小的 $n/3$ 个元素的和也很方便。

如何计算 $s_2[i]$ 呢?类似地,我们只需从右到左遍历,维护一个小顶堆,里面存放数组左侧的最大的 $n/3$ 个元素即可;添加元素后,如果堆中的元素数量 $> n/3$,则从堆中移除最小的元素。

最后根据 $s_1[i]$ 和 $s_2[i]$ 的定义,针对每个 $i$,计算 $s_1[i] - s_2[i]$ 最小值即可。注意,只有当 $n/3-1 \le i \le 2\times n/3-1$ 时,才有可能从数组的左侧、右侧均取到 $n/3$ 个元素,在这个区间里统计答案才是有意义的。

class Solution {
public:
    long long minimumDifference(vector<int>& nums) {
        int n = nums.size(), k = n/3;
        
        // s1[i]: 数组从 0 ~ i 的最小的 n / 3 个元素的和;
        // s2[i]: 数组从 i+1 ~ n-1 的最大的 n/3 个元素的和;
        vector<long long> s1(n, 0), s2(n, 0);
        
        // 左侧最小的 n / 3 个元素,用大顶堆
        priority_queue<int> small;
        
        // 从左到右动态维护左侧最小的 n/3 个元素的和
        for(int i = 0; i < 2*k; ++i) {
            s1[i] = (i > 0)? s1[i-1] : 0;
            small.push(nums[i]);
            s1[i] += nums[i];
            if(small.size() > k) {
                s1[i] -= small.top(), small.pop();
            }
        }
        
        // 右侧最大的 n / 3 个元素,用小顶堆
        priority_queue<int, vector<int>, greater<int>> big;
        
        // 从右到左动态维护右侧最大的 n/3 个元素的和
        for(int i = n-2; i >= k-1; --i) {
            s2[i] = s2[i+1];
            big.push(nums[i+1]);
            s2[i] += nums[i+1]; 
            if(big.size() > k) {
                s2[i] -= big.top(), big.pop();
            }
        }
        
        // 统计答案
        long long res = 1e15;
        for(int i = k-1; i < 2*k; ++i) {
            res = min(res, s1[i] - s2[i]);
        }
        
        return res;
    }
};
class Solution:
    def minimumDifference(self, nums: List[int]) -> int:
        n, k = len(nums), len(nums) // 3
        s1, s2 = [0] * n, [0] * n
        q = []

        for i in range(2*k):
            if i: s1[i] = s1[i-1]
            heapq.heappush(q, -nums[i])
            s1[i] += nums[i]
            if len(q) > k:
                s1[i] -= -heapq.heappop(q)
        
        q.clear()

        for i in range(n-2, k-2, -1):
            s2[i] = s2[i+1]
            heapq.heappush(q, nums[i+1])
            s2[i] += nums[i+1]
            if len(q) > k:
                s2[i] -= heapq.heappop(q)

        res = 10 ** 13
        for i in range(k-1, 2*k):
            res = min(res, s1[i] - s2[i])

        return res

每日一题-找出有效子序列的最大长度 II🟡

2025年7月17日 00:00
给你一个整数数组 nums 和一个  整数 k 。

nums 的一个 子序列 sub 的长度为 x ,如果其满足以下条件,则称其为 有效子序列 :

  • (sub[0] + sub[1]) % k == (sub[1] + sub[2]) % k == ... == (sub[x - 2] + sub[x - 1]) % k
返回 nums 的 最长有效子序列 的长度。

 

示例 1:

输入:nums = [1,2,3,4,5], k = 2

输出:5

解释:

最长有效子序列是 [1, 2, 3, 4, 5] 。

示例 2:

输入:nums = [1,4,2,3,1,4], k = 3

输出:4

解释:

最长有效子序列是 [1, 4, 1, 4] 。

 

提示:

  • 2 <= nums.length <= 103
  • 1 <= nums[i] <= 107
  • 1 <= k <= 103

3202. 找出有效子序列的最大长度 II

作者 stormsunshine
2024年6月30日 14:24

解法

思路和算法

长度为 $x$ 的有效子序列 $\textit{sub}$ 满足对于任意 $1 \le i \le x - 2$ 都有 $(\textit{sub}[i - 1] + \textit{sub}[i]) \bmod k = (\textit{sub}[i] + \textit{sub}[i + 1]) \bmod k$,等价于 $(\textit{sub}[i - 1] + \textit{sub}[i] - \textit{sub}[i] - \textit{sub}[i + 1]) \bmod k = 0$,整理得 $(\textit{sub}[i - 1] - \textit{sub}[i + 1]) \bmod k = 0$,即 $\textit{sub}[i - 1] \bmod k = \textit{sub}[i + 1] \bmod k$。因此子序列 $\textit{sub}$ 满足子序列中的下标相差 $2$ 的两项除以 $k$ 的余数相等,进一步可以得到子序列 $\textit{sub}$ 满足所有偶数下标项除以 $k$ 的余数相等且所有奇数下标项除以 $k$ 的余数相等。

为了计算最长有效子序列的长度,对于所有满足 $0 \le i, j < k$ 的整数 $i$ 和 $j$ 需要计算最后两项除以 $k$ 的余数分别是 $i$ 和 $j$ 的子序列的最大长度。

创建 $k \times k$ 的二维数组 $\textit{dp}$,其中 $\textit{dp}[i][j]$ 表示最后两项除以 $k$ 的余数分别是 $i$ 和 $j$ 的子序列的最大长度。

动态规划的边界情况是:如果当前没有最后两项除以 $k$ 的余数分别是 $i$ 和 $j$ 的子序列,则 $\textit{dp}[i][j] = 0$。初始时,$\textit{dp}$ 中的所有状态值都是 $0$。

遍历数组 $\textit{nums}$,对于遍历到的每个元素 $\textit{num}$,执行如下操作。

  1. 计算当前余数 $\textit{curr} = \textit{num} \bmod k$。

  2. 遍历 $0 \le \textit{prev} < k$ 的每个余数 $\textit{prev}$,为了得到最后两项除以 $k$ 的余数分别是 $\textit{prev}$ 和 $\textit{curr}$ 的最长有效子序列,需要首先得到最后两项除以 $k$ 的余数分别是 $\textit{curr}$ 和 $\textit{prev}$ 的最长有效子序列,然后在末尾添加 $\textit{num}$ 得到更长的有效子序列,因此将 $\textit{dp}[\textit{prev}][\textit{curr}]$ 的值更新为 $\textit{dp}[\textit{curr}][\textit{prev}] + 1$,然后使用 $\textit{dp}[\textit{prev}][\textit{curr}]$ 更新最长有效子序列的长度。

上述计算过程即为动态规划的状态转移方程。

遍历结束之后,即可得到最长有效子序列的长度。

代码

###Java

class Solution {
    public int maximumLength(int[] nums, int k) {
        int maxLength = 0;
        int[][] dp = new int[k][k];
        for (int num : nums) {
            int curr = num % k;
            for (int prev = 0; prev < k; prev++) {
                dp[prev][curr] = dp[curr][prev] + 1;
                maxLength = Math.max(maxLength, dp[prev][curr]);
            }
        }
        return maxLength;
    }
}

###C#

public class Solution {
    public int MaximumLength(int[] nums, int k) {
        int maxLength = 0;
        int[][] dp = new int[k][];
        for (int i = 0; i < k; i++) {
            dp[i] = new int[k];
        }
        foreach (int num in nums) {
            int curr = num % k;
            for (int prev = 0; prev < k; prev++) {
                dp[prev][curr] = dp[curr][prev] + 1;
                maxLength = Math.Max(maxLength, dp[prev][curr]);
            }
        }
        return maxLength;
    }
}

复杂度分析

  • 时间复杂度:$O(k^2 + nk)$,其中 $k$ 是给定的整数,$n$ 是数组 $\textit{nums}$ 的长度。创建大小为 $k \times k$ 的二维数组 $\textit{dp}$ 的时间是 $O(k^2)$,遍历数组 $\textit{nums}$ 时对于每个元素的计算时间是 $O(k)$,因此时间复杂度是 $O(k^2 + nk)$。

  • 空间复杂度:$O(k^2)$,其中 $k$ 是给定的整数。需要创建大小为 $k \times k$ 的二维数组。

时间O(n^2) 空间O(n*k) 的动态规划

作者 yi-lu-o
2024年6月30日 13:10

思路

  1. 本题是选与不选的子序列问题,可以尝试给出这样的状态定义f[i]:以nums[i]结尾的符合条件的最长子序列的长度
  2. 那么什么是符合条件的子序列呢?数组中任意两个数相加模k都符合条件,为了表示模k后的值,可以再增加一个维度的数组f[i][j]:以nums[i]结尾模k后值为j的最长子序列的长度
  3. 那么状态转移方程是怎样的呢?对于每一个i,遍历j(0<=j<i)f[i][(nums[i] + nums[j]) % k] = f[j][(nums[i] + nums[j]) % k] + 1,保证模k后的值相同。

代码

###Python3

class Solution:
    def maximumLength(self, nums: List[int], k: int) -> int:
        n = len(nums)
        f = [[1] * k for _ in range(n)]
        ans = 1
        for i in range(1, n):
            for j in range(i):
                f[i][(nums[i] + nums[j]) % k] = f[j][(nums[i] + nums[j]) % k] + 1
                ans = max(ans, f[i][(nums[i] + nums[j]) % k])
        return ans

复杂度分析

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n*k)

分析式子+动态规划,两种方法(Python/Java/C++/Go)

作者 endlesscheng
2024年6月30日 12:08

分析

对于等式

$$
(a+b)\bmod k = (b+c)\bmod k
$$

根据 模运算的世界:当加减乘除遇上取模,可以移项,得

$$
(a+b-(b+c)) \bmod k = 0
$$

化简得

$$
(a-c)\bmod k = 0
$$

这意味着 $a$ 与 $c$ 关于模 $k$ 同余。即题目式子中的 $\textit{sub}[i]$ 与 $\textit{sub}[i+2]$ 关于模 $k$ 同余。换句话说,有效子序列的偶数项 $\textit{sub}[0],\textit{sub}[2],\textit{sub}[4],\ldots$ 都关于模 $k$ 同余,奇数项 $\textit{sub}[1],\textit{sub}[3],\textit{sub}[5],\ldots$ 都关于模 $k$ 同余。

如果把每个 $\textit{nums}[i]$ 都改成 $\textit{nums}[i]\bmod k$,问题等价于:

  • 求最长子序列的长度,该子序列的奇数项都相同,偶数项都相同。

方法一:考察子序列的最后两项

比如 $\textit{nums}=[1,2,1,2,1,2]$:

  • 遍历到 $1$ 的时候,在「末尾为 $1,2$ 的子序列」的末尾添加一个 $1$,得到「末尾为 $2,1$ 的子序列」。
  • 遍历到 $2$ 的时候,在「末尾为 $2,1$ 的子序列」的末尾添加一个 $2$,得到「末尾为 $1,2$ 的子序列」。

从左到右遍历 $\textit{nums}$,遍历的同时,维护一个二维数组 $f[y][x]$,表示最后两项模 $k$ 分别为 $y$ 和 $x$ 的子序列的长度。

对于 $x=\textit{nums}[i]\bmod k$,我们可以在「最后两项模 $k$ 分别为 $x$ 和 $y$ 的子序列」的末尾添加 $\textit{nums}[i]$,那么「最后两项模 $k$ 分别为 $y$ 和 $x$ 的子序列」的长度会增加 $1$,即

$$
f[y][x] = f[x][y] + 1
$$

最后答案为 $f[i][j]$ 的最大值。

答疑

:如何理解这个递推?它和记忆化搜索的区别是什么?

:对比二者的计算顺序。如果用记忆化搜索来做,需要单独计算「最左(或者最右)两项模 $k$ 分别为 $x$ 和 $y$ 的子序列」的长度,这是「单线程」,必须查找下一个元素的位置。而递推的计算顺序是,(假设我们先遍历到了元素 $2$,然后遍历到了元素 $4$,两个元素属于不同的子序列)一会计算一下「最后两项模 $k$ 分别为 $y$ 和 $2$ 的子序列」,一会又计算一下「最后两项模 $k$ 分别为 $y$ 和 $4$ 的子序列」,这是「多线程」,没有查找元素位置的过程,遇到谁就处理谁

具体请看 视频讲解 第三题,欢迎点赞关注~

###py

class Solution:
    def maximumLength(self, nums: List[int], k: int) -> int:
        f = [[0] * k for _ in range(k)]
        for x in nums:
            x %= k
            for y, fxy in enumerate(f[x]):
                f[y][x] = fxy + 1
        return max(map(max, f))

###py

class Solution:
    def maximumLength(self, nums: List[int], k: int) -> int:
        f = [0] * (k * k)
        for x in nums:
            x %= k
            # f[x * k: (x + 1) * k] 是二维写法的第 x 行
            # f[x::k] 是二维写法的第 x 列
            f[x::k] = [v + 1 for v in f[x * k: (x + 1) * k]]
        return max(f)

###java

class Solution {
    public int maximumLength(int[] nums, int k) {
        int ans = 0;
        int[][] f = new int[k][k];
        for (int x : nums) {
            x %= k;
            for (int y = 0; y < k; y++) {
                f[y][x] = f[x][y] + 1;
                ans = Math.max(ans, f[y][x]);
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int maximumLength(vector<int>& nums, int k) {
        int ans = 0;
        vector f(k, vector<int>(k));
        for (int x : nums) {
            x %= k;
            for (int y = 0; y < k; y++) {
                f[y][x] = f[x][y] + 1;
                ans = max(ans, f[y][x]);
            }
        }
        return ans;
    }
};

###go

func maximumLength(nums []int, k int) (ans int) {
f := make([][]int, k)
for i := range f {
f[i] = make([]int, k)
}
for _, x := range nums {
x %= k
for y, fxy := range f[x] {
f[y][x] = fxy + 1
ans = max(ans, f[y][x])
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(k^2 + nk)$,其中 $n$ 是 $\textit{nums}$ 的长度。注意创建大小为 $k^2$ 的二维数组需要 $\mathcal{O}(k^2)$ 的时间。
  • 空间复杂度:$\mathcal{O}(k^2)$。

方法二:枚举余数,考察子序列的最后一项

枚举子序列相邻两项之和模 $k$ 的结果为 $m=0,1,2,\ldots, k-1$。

如果知道了子序列的最后一项(假设是 $x$),那么子序列的倒数第二项也就确定了,根据 模运算的世界:当加减乘除遇上取模,倒数第二项为

$$
(m - x\bmod k + k) \bmod k
$$

加 $k$ 再模 $k$ 是为了在 $m < x\bmod k$ 时,保证计算结果非负。

类似方法一,从左到右遍历 $\textit{nums}$ 的同时,维护一个数组 $f[x]$,表示最后一项模 $k$ 为 $x$ 的子序列的长度。

对于 $x=\textit{nums}[i]\bmod k$,我们可以在「最后一项模 $k$ 为 $(m - x\bmod k + k) \bmod k$ 的子序列」的末尾添加 $\textit{nums}[i]$,那么「最后一项模 $k$ 为 $x$ 的子序列」的长度会增加 $1$,即

$$
f[x] = f[(m - x\bmod k + k) \bmod k] + 1
$$

Python 取模更简单,由于允许负数下标,可以直接用 $f[m-x\bmod k]$ 作为转移来源。

遍历结束后(或者遍历中),用 $f[i]$ 更新答案的最大值。

###py

class Solution:
    def maximumLength(self, nums: List[int], k: int) -> int:
        ans = 0
        for m in range(k):
            f = [0] * k
            for x in nums:
                x %= k
                f[x] = f[m - x] + 1
            ans = max(ans, max(f))
        return ans

###java

class Solution {
    public int maximumLength(int[] nums, int k) {
        int ans = 0;
        for (int m = 0; m < k; m++) {
            int[] f = new int[k];
            for (int x : nums) {
                x %= k;
                f[x] = f[(m - x + k) % k] + 1;
                ans = Math.max(ans, f[x]);
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int maximumLength(vector<int>& nums, int k) {
        int ans = 0;
        for (int m = 0; m < k; m++) {
            vector<int> f(k);
            for (int x : nums) {
                x %= k;
                f[x] = f[(m - x + k) % k] + 1;
                ans = max(ans, f[x]);
            }
        }
        return ans;
    }
};

###go

func maximumLength(nums []int, k int) (ans int) {
f := make([]int, k)
for m := 0; m < k; m++ {
clear(f)
for _, x := range nums {
x %= k
f[x] = f[(m-x+k)%k] + 1
ans = max(ans, f[x])
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(k(k+n))$,其中 $n$ 是 $\textit{nums}$ 的长度。注意创建大小为 $k$ 的数组需要 $\mathcal{O}(k)$ 的时间。
  • 空间复杂度:$\mathcal{O}(k)$。

专题训练

见动态规划题单的「§7.4 合法子序列 DP」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

每日一题-找出有效子序列的最大长度 I🟡

2025年7月16日 00:00

给你一个整数数组 nums

nums 的子序列 sub 的长度为 x ,如果其满足以下条件,则称其为 有效子序列

  • (sub[0] + sub[1]) % 2 == (sub[1] + sub[2]) % 2 == ... == (sub[x - 2] + sub[x - 1]) % 2

返回 nums最长的有效子序列 的长度。

一个 子序列 指的是从原数组中删除一些元素(也可以不删除任何元素),剩余元素保持原来顺序组成的新数组。

 

示例 1:

输入: nums = [1,2,3,4]

输出: 4

解释:

最长的有效子序列是 [1, 2, 3, 4]

示例 2:

输入: nums = [1,2,1,1,2,1,2]

输出: 6

解释:

最长的有效子序列是 [1, 2, 1, 2, 1, 2]

示例 3:

输入: nums = [1,3]

输出: 2

解释:

最长的有效子序列是 [1, 3]

 

提示:

  • 2 <= nums.length <= 2 * 105
  • 1 <= nums[i] <= 107
❌
❌