Skip to content

D. Vertical Paths - 题解

比赛与标签

比赛: Codeforces Round 787 (Div. 3) 标签: graphs, implementation, trees 难度: *1300

题目大意喵~

喵哈~!各位算法大师们好呀!今天我们来解决一个关于树的路径划分问题,很有趣的哦~

题目会给我们一棵有根树,它由一个父节点数组 p 来表示。p[i] 代表节点 i 的父节点是谁。特别地,如果 p[i] = i,那么 i 就是这棵树的根节点啦。

我们的任务是,把这棵树里的所有节点,划分成若干条路径。这些路径需要满足以下条件:

  1. 每个节点都必须属于 且仅属于 一条路径。
  2. 每条路径都必须是 从上到下 的,也就是说,路径中的下一个节点必须是当前节点的儿子。
  3. 我们需要找到一种划分方案,使得路径的总数量 最少

最后,我们要输出最少的路径数量,以及每一条路径的具体内容。

寻找路径的猫爪印🐾

要让路径数量最少,我们应该怎么做呢喵?让我们一起动动脑筋!

首先,我们来观察一下路径的特点。题目要求路径必须是从上到下(从父到子)的。那么,一条路径的终点会是什么样的节点呢?它肯定是一个没有子节点的节点,否则路径就可以继续向下延伸了嘛。在树里,这种没有子节点的节点,我们亲切地称它为 叶子节点 的说!

既然每条路径都必须有一个终点,而且这个终点很自然地就是一个叶子节点,那么我们可以得出一个超级重要的结论:每一个叶子节点,都必须作为某一条路径的终点。因为如果一个叶子节点不是任何路径的终点,那它又该如何被覆盖呢?它没有孩子,无法成为路径的中间节点;如果它是起点,那这条路径只有一个节点,它自己就是终点。所以,每个叶子都必须是某条路径的终点。

每条路径只有一个终点,而我们有 k 个叶子节点都需要被当做终点。所以,我们至少需要 k 条路径才能覆盖所有的叶子节点,对不对呀?

哇!这真是一个惊人的发现!最小路径数就等于叶子节点的数量

找到了最小路径数,我们该如何具体地构造出这些路径呢?

策略其实非常直接喵!既然我们确定了要以每个叶子节点作为终点来构建路径,那我们就这么做:

  1. 首先,通过父节点数组 p,我们可以轻松地构建出每个节点的子节点列表。这样就能快速找到谁是叶子节点(也就是子节点列表为空的节点)。
  2. 我们从任意一个叶子节点开始,比如说 leaf_A
  3. 我们从 leaf_A 出发,沿着父节点指针 向上回溯,一步步走向根节点。这条回溯的轨迹 ... -> parent_of_parent -> parent -> leaf_A 就是一条合法的从上到下的路径,只是我们是反着找到它的。
  4. 我们将这条路径上的所有节点都标记为“已访问”,表示它们已经被分配了归宿。
  5. 接着,我们再找下一个 还没有被访问过 的叶子节点,重复上面的过程,构建出第二条路径。
  6. 我们不断重复这个过程,直到所有的叶子节点都被作为起点(从下往上看是起点)处理过。

这个方法为什么是正确的呢?因为我们从所有的叶子节点出发,向上延伸路径,直到碰到一个已经被其他路径“预定”的节点。这样可以保证,树上的任何一个节点 u,都会被它子树中某个叶子节点延伸上来的路径所覆盖,而且由于有“已访问”标记,它只会被覆盖一次。完美地满足了题目的所有要求呐!

所以,算法步骤就是:

  1. 建树(子节点列表)。
  2. 找到所有叶子节点。
  3. 输出叶子节点的数量。
  4. 对每个叶子节点,向上回溯生成路径,直到遇到已访问节点或根,然后输出这条路径。

代码魔法时间✨

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>

void solve() {
    int n;
    std::cin >> n;
    // p[i] 存的是节点 i 的父亲喵~ 我们用从1开始的下标哦
    std::vector<int> p(n + 1);
    // children[i] 用来存节点 i 的所有孩子,方便我们找叶子节点
    std::vector<std::vector<int>> children(n + 1);
    for (int i = 1; i <= n; ++i) {
        std::cin >> p[i];
        // 如果 p[i] != i,说明 i 不是根节点,它有父亲
        // 我们就把 i 添加到它父亲 p[i] 的孩子列表里
        if (p[i] != i) {
            children[p[i]].push_back(i);
        }
    }

    // 一个节点如果没有孩子,它就是可爱的叶子节点啦!
    std::vector<int> leaves;
    for (int i = 1; i <= n; ++i) {
        if (children[i].empty()) {
            leaves.push_back(i);
        }
    }

    // 最少路径数就是叶子节点的数量,直接输出!
    std::cout << leaves.size() << "\n";

    // visited数组用来标记一个节点是否已经被分配到某条路径里了
    std::vector<bool> visited(n + 1, false);

    // 从每个叶子开始,向上回溯构造路径
    for (int leaf : leaves) {
        std::vector<int> current_path;
        int curr = leaf;
        
        // 向上回溯,直到碰到根或者已经被访问过的节点
        while (true) {
            // 如果当前节点已经被其他路径占领了,就停下来
            if (visited[curr]) {
                break;
            }
            // 把当前节点加入路径,并标记为已访问
            current_path.push_back(curr);
            visited[curr] = true;
            
            // 如果到达了根节点(它的父亲是它自己),路径就到头了
            if (p[curr] == curr) {
                break;
            }
            
            // 继续向上走,去找父亲
            curr = p[curr];
        }
        
        // 因为我们是从叶子->根这样从下往上找的,所以要反转一下
        // 才能得到题目要求的从上到下的路径顺序哦
        std::reverse(current_path.begin(), current_path.end());
        
        // 按格式输出这条路径
        std::cout << current_path.size() << "\n";
        for (size_t i = 0; i < current_path.size(); ++i) {
            std::cout << current_path[i] << (i == current_path.size() - 1 ? "" : " ");
        }
        std::cout << "\n";
    }
}

int main() {
    // 加上这个可以让输入输出快一点,是个好习惯喵~
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);
    
    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }
    
    return 0;
}

复杂度分析

  • 时间复杂度: O(N) 的说。其中 N 是所有测试用例中节点数的总和。对于每个测试用例,我们遍历所有节点来建树(O(n)),遍历所有节点来找叶子(O(n)),然后从每个叶子出发向上回溯。因为每个节点只会被访问并标记一次,所以所有回溯操作的总和也是 O(n)。因此,总体是线性的!
  • 空间复杂度: O(N) 的说。我们主要使用了 p 数组、children 邻接表和 visited 数组来存储信息,它们的大小都和节点数 n 成正比。

猫娘的小课堂🎓

这道题虽然是图论题,但思路非常巧妙,让我们来总结一下学到了什么吧!

  1. 核心洞察: 这道题最关键的就是发现 “最小路径数 = 叶子节点数” 这个性质。在处理树或图的路径覆盖问题时,分析路径端点的性质常常是找到突破口的关键!抓住这个性质,问题就迎刃而解了喵~

  2. 图的表示转换: 题目给了父节点数组,这是一种表示树的方式。但为了方便找到叶子节点(即出度为0的节点),我们把它转换成了子节点邻接表。根据问题的需要,灵活选择或转换图的表示方法,是每个算法高手的基本功哦。

  3. 逆向思维: 我们没有尝试从根节点开始向下搜索如何划分路径(这会非常复杂),而是采用了从叶子节点 向上回溯 的策略。这种“反过来想”的思路在很多算法问题中都超级有用,能化繁为简,值得我们学习和模仿呐!

  4. 实现细节: 在实现时,使用 visited 数组来确保每个节点只属于一条路径是至关重要的。还有,别忘了我们是从下往上构建的路径,所以在输出前要 reverse 一下,变成从上到下的顺序。

好啦,这次的题解就到这里啦!希望对大家有帮助喵~ 继续加油,下一个AC就是你的!🐾

Released under the MIT License.