GESP2026年9月认证C++六级( 第三部分编程题(2、分树规划))精讲



GESP C++ 六级编程题第二题:《分树规划》------用 DFS 给一棵树做一次公平分配!

今天的任务是:

老师有一棵树,想把它分给两位同学。老师只能剪断一条树枝,让树变成两部分。怎样剪,才能让两位同学分到的结点数量尽可能接近呢?

这道题的核心知识是:

  • 树的基本概念。

  • 深度优先搜索 DFS。

  • 子树结点数量统计。

  • 枚举每条边对应的划分方案。

  • abs() 计算两个部分的结点数之差。


一、故事时间:魔法森林的公平分配

想象一下,魔法森林里有一棵由许多结点组成的树。

例如:

复制代码
        1
        |
        2
        |
        3
        |
        4

这棵树有 4 个结点。

老师想把这棵树分给小明和小红。

规则很简单:

  1. 选择树上的一条边。

  2. 把这条边剪断。

  3. 树就会变成两个互不连接的部分。

  4. 两位同学各拿一部分。

老师希望两个人拿到的结点数量尽可能接近。

比如剪断 2---3 这条边:

复制代码
    1
    |
    2

    3
    |
    4

小明得到 2 个结点,小红也得到 2 个结点。

两个人的结点数之差为:

∣2−2∣= 0

差值为 0,说明两个人分得完全一样!

因此,这个例子的答案就是:

但是,如果树的形状更复杂,我们该怎么找到最合适的剪法呢?

我们总不能拿着剪刀,把每条边都剪一遍,再把所有树重新拼起来吧?

其实,我们有更聪明的方法!


二、认识树:树和普通图有什么不同?

在学习算法之前,我们先认识一下这道题中的树。

在 C++ 信奥中,树是一种特殊的图。

这道题中的树有几个重要特点:

  1. 一共有 n 个结点。

  2. 结点之间通过边连接。

  3. 任意两个结点之间都存在一条唯一的简单路径。

  4. 一棵有 n 个结点的树,恰好有 n-1 条边。

例如:

复制代码
        1
       / \
      2   3
     / \
    4   5

这棵树有 5 个结点、4 条边。

注意:树上的边没有规定必须从父亲指向孩子。

例如,结点 1 和结点 2 相连,那么从 1 可以走到 2,从 2 也可以走回 1。

这意味着,如果我们直接在树上进行 DFS,就需要小心处理一个问题:

不能因为刚刚走到一个结点,就又沿着原来的边走回去!


三、核心思路:把树暂时变成一棵有根树

题目没有规定树的根结点在哪里。

为了方便计算,我们可以暂时选择结点 1 作为根。

例如:

复制代码
        1
       / \
      2   3
     / \
    4   5

这样一来,我们就能把结点之间的关系理解为:

  • 1 是根结点。

  • 2 和 3 是 1 的孩子。

  • 4 和 5 是 2 的孩子。

于是,我们可以使用一个非常重要的概念:

什么是子树?

以结点 2 为根,它下面包含:

复制代码
      2
     / \
    4   5

这部分就是结点 2 的子树。

它一共有 3 个结点。

我们定义:

szu = 以结点 u 为根的子树结点数量

例如:

结点 u 子树包含的结点 szu
1 1、2、3、4、5 5
2 2、4、5 3
3 3 1
4 4 1
5 5 1

这里有一个重要规律:

每个结点自己的子树,至少包含它自己。

所以:

szu= 1 + 所有孩子的子树结点数之和

这正是 DFS 可以帮我们完成的工作。


四、最重要的观察:剪断一条边,究竟会发生什么?

假设我们已经把树以结点 1 为根。

现在考虑结点 u 与它父亲之间的边。

如果我们剪断这条边,会发生什么?

以刚才的树为例:

复制代码
        1
       / \
      2   3
     / \
    4   5

如果剪断 1---2

复制代码
    1       2
   /       / \
  3       4   5

两部分的结点数分别是:

  • 以 2 为根的子树:3 个结点。

  • 剩下的部分:5 - 3 = 2 个结点。


如果剪断 2---4

复制代码
        1
       / \
      2   3
     / \
    5   4

这次两部分的结点数分别是:

  • 结点 4 的子树:1 个结点。

  • 剩下的部分:5 - 1 = 4 个结点。


因此,我们发现:

对于一条连接父结点和孩子结点 u 的边,剪断它之后,其中一部分恰好是 u 的整棵子树。

那么:

  • 一部分有 sz[u] 个结点。

  • 另一部分有 n-sz[u] 个结点。


两部分的结点数之差的绝对值就是:

化简得到:

这就是本题的关键公式!

为什么这个公式特别有用?

因为我们不需要真的把边剪断。

只要知道 sz[u],就能立即计算剪断这条边后的差值。

所以,问题就变成了:

计算每个结点的子树大小,然后枚举所有非根结点,找出公式值最小的那个。


五、DFS 登场:帮每个结点数一数自己的子孙

我们已经知道,最关键的信息是 sz[u]

那么,如何计算它呢?

答案是:深度优先搜索 DFS。

1. DFS 的计算顺序

假设树是:

复制代码
        1
       / \
      2   3
     / \
    4   5

当 DFS 走到结点 1 时:

  1. 先把结点 1 自己计算进去。

  2. 进入结点 2。

  3. 计算结点 2 的子树大小。

  4. 再进入结点 3。

  5. 计算结点 3 的子树大小。

  6. 把孩子们的子树大小加起来。

可以把它想象成一场森林探险:

每个结点都要先派探险队去访问自己的孩子。等孩子们全部探险回来,报告各自发现了多少个结点,自己再把这些数量加起来。


2. DFS 的核心代码

复制代码
int dfs(int u, int p = 0) {
    int sz = 1;

    for (int i = h[u]; i; i = nx[i]) {
        if (to[i] != p) {
            sz += dfs(to[i], u);
        }
    }

    ans = min(ans, abs(n - 2 * sz));

    return sz;
}

我们逐句理解。

第一句:先把自己算进去
复制代码
int sz = 1;

每个结点至少包含自己。

所以子树大小从 1 开始。


第二句:枚举与当前结点相连的边
复制代码
for (int i = h[u]; i; i = nx[i])

这段代码使用的是链式前向星存图。

它的作用是依次访问与结点 u 相连的所有边。

如果你还没有学过链式前向星,可以先把它理解为:

找出当前结点所有能够直接到达的邻居。


第三句:不要走回父亲
复制代码
if (to[i] != p)

这里的 p 表示当前结点 u 的父结点。

因为树上的边是双向的,如果不加判断,就可能出现:

复制代码
1 → 2 → 1 → 2 → 1 → ...

这样 DFS 就会不停地来回走。

所以我们规定:

如果邻居是刚才的父结点,就跳过它。


第四句:把孩子的子树大小加起来
复制代码
sz += dfs(to[i], u);

这句话做了两件事:

  1. 递归进入孩子结点 to[i]

  2. 把孩子返回的子树大小加到当前的 sz 中。

例如:

复制代码
        1
       / \
      2   3
     / \
    4   5

结点 2 的计算过程是:

复制代码
sz = 1       // 先计算自己

访问结点4:
sz = 1 + 1 = 2

访问结点5:
sz = 2 + 1 = 3

最终:

复制代码
sz[2] = 3

第五句:计算当前结点对应的划分差值
复制代码
ans = min(ans, abs(n - 2 * sz));

这句代码是本题的核心。

对于当前结点 u,剪断它与父亲之间的边,就会产生两个部分。

差值为:

∣n−2×szu∣|n-2\times szu|

abs() 用来计算绝对值。

min() 用来保留目前找到的最小差值。

每访问一个非根结点,我们就计算一次。


第六句:返回子树大小
复制代码
return sz;

当前结点已经统计完成。

把它的子树大小返回给父结点,让父结点继续完成自己的统计。


六、参考程序

下面我们把整个算法组合起来。

本程序按照试卷中的参考程序思路编写,使用链式前向星存图,并通过 DFS 统计子树大小。

复制代码
#include <cstdio>
#include <algorithm>
#include <cstdlib>
using namespace std;

const int N = 20005;
const int E = N << 1;

int n;

// 链式前向星
int h[N], to[E], nx[E];
int et = 0;

// 当前找到的最小差值
int ans;

// 添加一条有向边 u -> v
void ae(int u, int v) {
    et++;

    to[et] = v;
    nx[et] = h[u];
    h[u] = et;
}

// DFS:返回以 u 为根的子树大小
// p 表示 u 的父结点
int dfs(int u, int p = 0) {
    // 子树至少包含自己
    int sz = 1;

    // 枚举 u 的所有邻居
    for (int i = h[u]; i; i = nx[i]) {
        int v = to[i];

        // 不走回父结点
        if (v != p) {
            // 加上孩子的子树大小
            sz += dfs(v, u);
        }
    }

    // 尝试剪断 u 与父亲之间的边
    ans = min(ans, abs(n - 2 * sz));

    // 把子树大小返回给父结点
    return sz;
}

int main() {
    scanf("%d", &n);

    // 一棵 n 个结点的树有 n-1 条边
    for (int i = 1; i < n; i++) {
        int u, v;
        scanf("%d%d", &u, &v);

        // 无向边需要添加两个方向
        ae(u, v);
        ae(v, u);
    }

    // 初始化答案
    ans = n;

    // 以结点1为根进行DFS
    dfs(1);

    // 输出最小差值
    printf("%d\n", ans);

    return 0;
}

七、手动模拟:看看 DFS 是怎样完成任务的

我们使用试卷中的第二个样例。

输入

复制代码
6
1 2
1 3
1 4
1 5
5 6

这棵树的形状是:

复制代码
        1
      / | \ \
     2  3  4  5
                  \
                   6

一共有 6 个结点。

我们以结点 1 为根,开始 DFS。

第一步:计算结点 2、3、4、6 的子树大小

结点 2 没有孩子:

sz2 = 1

结点 3 没有孩子:

sz3 = 1

结点 4 没有孩子:

sz4 = 1

结点 6 没有孩子:

sz6 = 1


第二步:计算结点 5 的子树大小

结点 5 有一个孩子 6。

所以:

sz5=1+sz6 = 1+1 = 2

剪断 1---5 这条边后:

  • 一部分包含结点 5、6,共 2 个结点。

  • 另一部分包含 1、2、3、4,共 4 个结点。

差值为:

目前最小差值是 2。


第三步:计算根结点 1 的子树大小

结点 1 有四个孩子:2、3、4、5。

所以:

sz1 = 1+1+1+1+2 = 6

根结点没有父亲,因此它本身并不对应一条需要剪断的父子边。

参考程序也会计算根结点对应的公式值:

∣6−2×6∣= 6

这个值不会影响已经找到的最小值 2。

最终答案

2

这与试卷样例的输出一致。


八、为什么不需要真的剪断每一条边?

这是一个非常值得思考的问题。

如果使用最直接的方法:

  1. 找到一条边。

  2. 剪断它。

  3. 重新遍历整棵树,计算两部分的结点数。

  4. 恢复这条边。

  5. 再尝试下一条边。

这样做会产生很多重复工作。

但我们发现:

只要计算一次每个结点的子树大小,就能得到剪断它与父亲之间的边所产生的两部分大小。

所以我们不必真的剪断边。

只需要计算:

再取所有结果的最小值即可。

这就是本题巧妙的地方:

不必真的动手改变树的结构,而是通过子树大小,直接计算每一种剪法的结果。


九、算法复杂度分析

1. 时间复杂度

一棵树有 n-1 条无向边。

程序把每条无向边存储为两个有向边,因此一共存储 2(n-1) 条有向边。

DFS 从根结点出发,每个结点访问一次,每条存储的边也只会被检查常数次。

因此,时间复杂度为:

这意味着即使树有很多结点,我们也不需要对每条边都重新遍历整棵树。


2. 空间复杂度

程序使用了链式前向星数组:

复制代码
int h[N], to[E], nx[E];

这些数组的大小都与结点数和边数成正比。

此外,DFS 的递归调用也需要栈空间。

因此,整体空间复杂度为:


十、同学们容易犯的错误

错误1:只统计孩子,不统计自己

错误写法:

复制代码
int sz = 0;

如果这样写,结点自己的数量就没有被计算进去。

正确写法:

复制代码
int sz = 1;

记住:子树一定包含它的根结点自己!


错误2:DFS 时忘记跳过父结点

错误写法:

复制代码
for (int i = h[u]; i; i = nx[i]) {
    sz += dfs(to[i], u);
}

由于边是双向的,这样可能导致 DFS 不断返回父结点,形成无限递归。

正确写法:

复制代码
if (to[i] != p) {
    sz += dfs(to[i], u);
}

错误3:只考虑一边的结点数

假设剪断一条边后,两部分大小分别是 2 和 8。

如果只记录 2,就无法直接得到题目要求的差值。

正确计算方式是:

∣2−8∣ = 6

或者直接使用:


错误4:忘记取最小值

题目不是要求某一种剪法的差值,而是要求所有剪法中最小的差值。

因此,需要不断更新:

复制代码
ans = min(ans, abs(n - 2 * sz));

十一、举一反三:这道题还告诉了我们什么?

本题有一个重要的算法思想:

当我们把树固定一个根之后,树上的每一条边,都可以对应一个孩子结点的子树。

因此,我们可以利用子树信息来研究删边之后的结果。

类似的思路还可以帮助我们解决其他树上问题,例如:

  • 统计每个结点的子树大小。

  • 计算删除某条边后两个连通块的大小。

  • 判断一棵树能否被划分成满足条件的多个部分。

  • 在树上统计某些结点数量或计算子树信息。


十二、最后总结:把这道题装进脑海里的魔法口诀

同学们,今天我们学会了怎样利用 DFS 完成一棵树的公平分配。

请记住下面这段口诀:

树有结点也有边,选个根来理关系;

DFS 先数自己,再把孩子加一起;

剪断父子连接边,一边子树一边外;

总数减去子树数,两边差值算出来;

所有结点试一遍,最小差值就是答案!

最后,再把本题的核心公式记牢:

差值= ∣n−2×szu

其中:

  • n 是整棵树的结点数量。

  • sz[u] 是以 u 为根的子树结点数量。

  • u 是根结点以外的任意结点时,这个公式对应剪断它与父亲之间的边。

这道题最重要的收获,不只是学会写 DFS,而是学会先分析剪断一条边会产生什么结构,再利用子树信息直接计算答案。


附:《分树规划》进阶:不使用链式前向星,还能怎样存图?

对于初学 C++ 的同学来说,链式前向星并不是存储树的唯一方法。

对于《分树规划》这道题,真正重要的是 DFS 统计子树大小,而不是使用哪一种存图方式。

只要能够找到每个结点的邻居,并且避免 DFS 沿着边走回父结点,就可以完成任务。

本题的树有 n 个结点、n-1 条边。我们可以考虑下面几种存图方法:

  1. vector 邻接表。

  2. 二维数组邻接矩阵。

  3. 固定数组模拟邻接表,也就是原程序使用的链式前向星。

下面我们分别看看。


一、方法一:使用 vector 邻接表

同学们已经学习过 vector,可以使用 vector 邻接表。

它的思想非常简单:

每个结点准备一个可以变长的盒子,里面存放与它直接相连的所有结点编号。

例如:

复制代码
        1
       / \
      2   3
     / \
    4   5

邻接表可以表示为:

复制代码
结点1:2,3
结点2:1,4,5
结点3:1
结点4:2
结点5:2

在 C++ 中,可以这样定义:

复制代码
vector<int> tree[N];

其中:

复制代码
tree[u]

就代表结点 u 的邻居列表。


1. 添加边

假设输入一条边:

复制代码
u v

因为树是无向的,所以要添加两个方向:

复制代码
tree[u].push_back(v);
tree[v].push_back(u);

例如,输入:

复制代码
1 2

就相当于:

复制代码
tree[1] 中加入 2
tree[2] 中加入 1

2. DFS 统计子树大小

有了邻接表,DFS 就会变得非常直观:

复制代码
int dfs(int u, int p = 0) {
    int sz = 1;

    for (int v : tree[u]) {
        if (v != p) {
            sz += dfs(v, u);
        }
    }

    ans = min(ans, abs(n - 2 * sz));

    return sz;
}

注意这一句:

复制代码
for (int v : tree[u])

它是 C++11 支持的范围 for 循环。

可以把它理解为:

把结点 u 的邻居一个一个拿出来,放进变量 v 中。

这样就不用自己维护边的编号了。


3. 完整程序:vector 邻接表版本

下面的程序保留了原题的 DFS 核心逻辑,只把链式前向星换成了 vector 邻接表。

复制代码
#include <cstdio>
#include <algorithm>
#include <cstdlib>
#include <vector>
using namespace std;

const int N = 20005;

int n;
vector<int> tree[N];

int ans;

// DFS 返回以 u 为根的子树大小
int dfs(int u, int p = 0) {
    int sz = 1;

    // 遍历 u 的所有邻居
    for (int v : tree[u]) {
        // 不走回父结点
        if (v != p) {
            sz += dfs(v, u);
        }
    }

    // 计算剪断 u 与父亲之间的边后的差值
    ans = min(ans, abs(n - 2 * sz));

    return sz;
}

int main() {
    scanf("%d", &n);

    // 读入 n-1 条无向边
    for (int i = 1; i < n; i++) {
        int u, v;
        scanf("%d%d", &u, &v);

        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    ans = n;

    dfs(1);

    printf("%d\n", ans);

    return 0;
}

4. 这种方法有什么特点?

特点 说明
容易理解 每个结点直接保存自己的邻居
容易编写 添加边只需要 push_back()
遍历方便 使用范围 for 循环
时间复杂度 DFS 为 O(n)O(n)
空间复杂度 O(n)O(n)

教学建议:如果学生已经学过 vector,我会优先用这种方法讲解本题。

它把存图细节隐藏起来,让学生把注意力集中在 DFS 和子树大小的计算上。


二、方法二:使用二维数组邻接矩阵

如果学生还没有学习 vector,甚至可以使用二维数组来存图。

不过,这种方法虽然容易理解,却有明显的空间和时间代价。

1. 什么是邻接矩阵?

定义:

复制代码
int g[N][N];

如果结点 u 和结点 v 之间有边,就令:

复制代码
g[u][v] = 1;
g[v][u] = 1;

例如:

复制代码
        1
       / \
      2   3

可以用下面的矩阵表示:

复制代码
      1  2  3
    ----------
1 |   0  1  1
2 |   1  0  0
3 |   1  0  0

g[u][v] == 1 表示结点 u 和结点 v 之间有边。


2. 如何遍历邻居?

如果当前在结点 u,就从结点 1 一直检查到结点 n

复制代码
for (int v = 1; v <= n; v++) {
    if (g[u][v] == 1 && v != p) {
        sz += dfs(v, u);
    }
}

即使结点 u 只有一个邻居,也需要检查所有可能的结点编号。


3. DFS 的矩阵版本

复制代码
int dfs(int u, int p = 0) {
    int sz = 1;

    for (int v = 1; v <= n; v++) {
        if (g[u][v] == 1 && v != p) {
            sz += dfs(v, u);
        }
    }

    ans = min(ans, abs(n - 2 * sz));

    return sz;
}

这段代码的子树统计逻辑,与前面的 vector 版本完全一样。

区别仅仅在于:寻找邻居的方式不同。


4. 为什么本题不适合使用邻接矩阵?

题目中最多有约 20000 个结点。

如果使用:

复制代码
int g[20005][20005];

需要存储大约 4 亿个 int

假设一个 int 占 4 字节,那么仅这个矩阵就需要约:

这已经远远超过题目给出的 512 MB 内存限制。

而且,DFS 每访问一个结点,都要扫描所有 n 个可能的邻居。

因此,时间复杂度会达到:

结论:邻接矩阵适合结点数量较少、需要频繁判断两点之间是否有边的图,但不适合本题这种结点较多、边数很少的树。


三、方法三:链式前向星------原题采用的存图方式

我们再回头看看原程序。

复制代码
int h[N], to[E], nx[E];
int et;

它没有给每个结点单独准备一个 vector,而是把所有边放在几个普通数组里管理。

可以把它想象成:

  • h[u]:结点 u 的邻居名单从哪里开始。

  • to[i]:第 i 条有向边通向哪个结点。

  • nx[i]:同一个结点的下一条边在哪里。

  • et:目前已经存了多少条有向边。

添加边时:

复制代码
void ae(int u, int v) {
    et++;

    to[et] = v;
    nx[et] = h[u];
    h[u] = et;
}

遍历邻居时:

复制代码
for (int i = h[u]; i; i = nx[i]) {
    int v = to[i];

    if (v != p) {
        sz += dfs(v, u);
    }
}

链式前向星的主要优点是:

  1. 使用连续数组存储边,内存开销可预测。

  2. 不需要为每个结点分别管理动态数组。

  3. 遍历所有边的效率很高。

  4. 对于边数较多、需要精细控制存储方式的图问题,它是一种常见的实现方法。

它的缺点也很明显:

代码比 vector 邻接表更难理解。

初学者需要同时理解边编号、下一条边、表头等概念,容易把注意力从 DFS 本身转移到存图细节上。


四、三种方法放在一起比较

比较项目 vector 邻接表 邻接矩阵 链式前向星
存储方式 每个结点一个邻居列表 二维数组记录两点是否相连 普通数组保存边及链接关系
添加边 简单 简单 需要维护边编号和链接
遍历邻居 只遍历实际邻居 扫描所有结点 只遍历实际邻居
空间复杂度 O(n) O(n^2) O(n)
DFS 时间复杂度 O(n) O(n^2) O(n)
初学者理解难度 较低 较低 较高
本题是否适合 适合 不适合 适合

这里的复杂度分析针对本题的树结构,假设使用邻接表或链式前向星存储,DFS 正常访问每个结点及其邻接边。


五、那么,链式前向星是本题的最优方法吗?

需要区分两个不同的问题。

1. 从算法复杂度来看

对于本题,vector 邻接表和链式前向星都可以做到:

O(n) 时间复杂度

以及:

O(n) 空间复杂度

所以,它们在渐进复杂度上是相同的。


2. 从教学角度来看

对于初学 C++ 的小学生,我更建议:

优先讲解 vector 邻接表版本。

原因是学生能够把主要精力放在:

  1. 为什么要以结点 1 为根?

  2. 为什么 sz 从 1 开始?

  3. 为什么要跳过父结点?

  4. 为什么剪断边后的差值是 abs(n - 2 * sz)

  5. 为什么 DFS 返回子树大小?

这些才是本题最值得掌握的核心知识。

等学生熟悉邻接表、DFS 和树的基本概念后,再介绍链式前向星,会更容易理解。


3. 从实际编程角度来看

使用 C++11,vector 邻接表是一种完全合理的选择。

链式前向星则适合希望使用固定数组、精细控制存储,或者已经熟悉这种写法的同学。

两者都能有效解决本题。


六、最后送给同学们一个记忆口诀

树上存图有方法,邻接表和前向星;

邻接矩阵也能存,结点太多要小心;

初学先把邻接表学明白,DFS 才是本题的重头戏!

如果这道题是给初学 C++ 的小学生上课,我建议采用下面的顺序:

第一步:vector 邻接表讲清楚树和 DFS。

第二步: 用手动画图理解子树大小与删边后的两个连通块。

第三步: 推导公式:

第四步: 最后再把 vector 邻接表替换为链式前向星,让学生理解不同存图方式的区别。

这样既能完成本题,也能帮助学生建立更扎实的图论基础。


相关推荐
学生小羊1 小时前
C++ 初阶 学习博客
c语言·c++·c++与c语言的区别·c++基础学习
辛苦才能2 小时前
C++多态原理:虚函数表的内存布局与动态绑定的汇编真相
开发语言·c++
西西弗Sisyphus2 小时前
Qt 实现一个 水波进度球
c++·qt·c
无忧.芙桃2 小时前
数据结构之排序算法(中):冒泡排序与快速排序,从相邻交换到工程级优化
c语言·c++·排序算法
Hhy_11072 小时前
《C++深度解构04》类和对象(下)——类型转换、static、友元与编译器优化
c语言·c++·学习·类和对象·visual studio
qq_199886872 小时前
第7板块·第1节:通用算子分类与设计模式
c++·人工智能·gpu算力·cuda
6Hzlia4 小时前
【Classic 150 刷题计划】 LeetCode 205. 同构字符串 | C++ 双向绑定与哈希表映射
c++·算法·leetcode
神仙别闹10 小时前
基于 C++ 实现(控制台)学生成绩管理系统
开发语言·c++
会周易的程序员10 小时前
5 节点边缘冗余方案(上):基于 aiRaft 的物联网高可用控制面设计
c++·分布式·物联网·raft·iot·共识