好的,我们来看一个你列表中尚未出现过的经典图论算法题目。
最小顶点覆盖问题的精确解法(基于树形动态规划)
题目描述
给定一个 无向树(即一个无环且连通的无向图,有 n 个顶点和 n-1 条边),我们需要找到它的一个 最小顶点覆盖。
最小顶点覆盖 的定义是:图中的一组顶点构成的集合 C,使得图中的每一条边都至少有一个端点属于 C。而“最小”是指这个集合 C 的顶点数量是所有顶点覆盖中最少的。
要求我们设计一个算法,精确地(而非近似地)求出这棵树的最小顶点覆盖的大小,并能够重构出这个覆盖集合本身。
核心难点与解题思路
对于任意图,最小顶点覆盖是一个NP-hard问题。但题目特别指出图是一棵树。树的结构非常简单(无环),这使我们能够利用其递归性质。核心思路是使用树形动态规划。
我们可以任意选择一个根节点(例如节点0),把无根树转化为一棵有根树。然后从叶子节点向根节点自底向上进行状态计算。对于每个节点,我们需要考虑它是否被选入覆盖集合,而它的状态会受到其子节点状态的影响。
逐步解题过程
步骤1:定义动态规划状态
我们为树中的每个节点 u 定义两个DP状态值:
dp[u][0]: 表示在以节点u为根的子树中,当节点u自身不被选入顶点覆盖集合时,这棵子树所需的最小顶点覆盖大小。dp[u][1]: 表示在以节点u为根的子树中,当节点u自身被选入顶点覆盖集合时,这棵子树所需的最小顶点覆盖大小。
我们的最终目标是求出 min(dp[root][0], dp[root][1])。
步骤2:建立状态转移方程
我们以节点 u 为当前计算的节点,假设它有子节点 v1, v2, ..., vk。我们需要根据子节点的状态来计算 u 的状态。
-
情况
dp[u][1](u被选入覆盖):- 如果
u已经在覆盖集合里,那么所有与u相连的边(包括连接其子节点的边)都已经被覆盖。 - 因此,对于
u的每个子节点v,它可以自由选择是否进入覆盖集合,我们只需要选择对以v为根的子树最有利(即覆盖数最小)的那个选项。 - 状态转移方程为:
dp[u][1] = 1 + sum( min(dp[v][0], dp[v][1]) ) for each child v of u- 公式中的
1代表节点u本身被选中。 min(dp[v][0], dp[v][1])表示对于子节点v的子树,我们选择最优的覆盖方案。
- 公式中的
- 如果
-
情况
dp[u][0](u不被选入覆盖):- 如果
u不在覆盖集合里,那么为了覆盖连接u和其子节点v的边(u, v),子节点v必须被选入覆盖集合。 - 否则,边
(u, v)将没有被覆盖,违反了顶点覆盖的定义。 - 因此,状态转移方程为:
dp[u][0] = sum( dp[v][1] ) for each child v of u
- 如果
步骤3:确定初始条件(叶子节点)
对于叶子节点(没有子节点的节点):
dp[leaf][0] = 0:如果叶子不被选,它没有子节点需要覆盖,所以其子树覆盖大小为0。dp[leaf][1] = 1:如果叶子被选,它自身计入覆盖,所以大小为1。
步骤4:算法执行流程(自底向上计算)
- 建树与选根:将给定的无向树用邻接表存储。任意选择一个节点(如节点0)作为根节点。
- 执行DFS:从根节点开始进行深度优先搜索(DFS)。DFS会递归地访问所有子节点,直到叶子节点。
- 后序计算:在DFS的回溯阶段(即处理完所有子节点后),根据上述转移方程计算当前节点
u的dp[u][0]和dp[u][1]。 - 得到答案:当DFS从根节点完全返回时,答案就是
min(dp[root][0], dp[root][1])。
步骤5:重构最小顶点覆盖集合
为了不仅知道大小,还要知道具体哪些节点在覆盖中,我们需要在动态规划的过程中记录“选择”。
- 我们可以使用一个辅助的布尔数组
inCover[]。 - 在进行最终决策
min(dp[root][0], dp[root][1])时,我们可以决定根节点是否应被加入覆盖。 - 然后,我们进行第二次DFS来填充
inCover[]。从根节点开始:- 如果当前节点
u被标记为应在覆盖中(inCover[u] = true),那么对于它的每个子节点v,我们可以选择使得dp[v]更小的方案(即min(dp[v][0], dp[v][1])),并相应地标记v。 - 如果当前节点
u不在覆盖中(inCover[u] = false),那么根据转移方程,它的所有子节点v必须在覆盖中(inCover[v] = true)。
- 如果当前节点
复杂度分析
- 时间复杂度:O(n)。我们对树进行了两次DFS遍历,每个节点和每条边都被访问常数次。
- 空间复杂度:O(n)。用于存储邻接表、DP数组和记录选择的数组。
示例演示
考虑一棵简单的树:
0
/ \
1 2
/ \
3 4
-
以节点0为根。
-
自底向上计算:
- 节点1(叶子):
dp[1][0]=0,dp[1][1]=1 - 节点3(叶子):
dp[3][0]=0,dp[3][1]=1 - 节点4(叶子):
dp[4][0]=0,dp[4][1]=1 - 节点2:有子节点3和4。
dp[2][1] = 1 + min(dp[3][0],dp[3][1]) + min(dp[4][0],dp[4][1]) = 1 + 0 + 0 = 1dp[2][0] = dp[3][1] + dp[4][1] = 1 + 1 = 2
- 节点0:有子节点1和2。
dp[0][1] = 1 + min(dp[1][0],dp[1][1]) + min(dp[2][0],dp[2][1]) = 1 + 0 + 1 = 2dp[0][0] = dp[1][1] + dp[2][1] = 1 + 1 = 2
- 节点1(叶子):
-
最终答案:
min(dp[0][0], dp[0][1]) = 2。 -
重构覆盖(假设我们选择让根节点0进入覆盖,因为
dp[0][1]=2是可选方案之一):inCover[0] = true。- 对于子节点1:从
min(0, 1)中选择更小的方案,即dp[1][0]=0,所以inCover[1] = false。 - 对于子节点2:从
min(2, 1)中选择更小的方案,即dp[2][1]=1,所以inCover[2] = true。 - 对于节点2(在覆盖中),看其子节点:
- 子节点3:从
min(0, 1)中选,inCover[3] = false。 - 子节点4:从
min(0, 1)中选,inCover[4] = false。
- 子节点3:从
- 最终覆盖集合是
{0, 2},大小为2。验证:所有边(0,1),(0,2),(2,3),(2,4)都至少有一个端点在集合内。
通过这个树形DP方法,我们高效且准确地解决了树上的最小顶点覆盖问题。