图算法趣味学——团、独立集与顶点覆盖

112 阅读34分钟

在上一章中,我们看到看似简单的颜色分配问题会迅速膨胀为代价高昂的搜索任务。在本章中,我们将探讨类似复杂的节点集合问题:最大团、最大独立集和最小顶点覆盖。

对于这些问题,我们希望找到满足某些基于邻居或相邻边条件的最大或最小节点集合。虽然检查单个候选解是否满足约束条件很容易,但找到最优解往往计算代价很高。与图着色问题类似,这些问题也被归类为 NP-困难问题。我们同样可以通过启发式方法或穷举方法来解决这些问题。

本章将从上一章介绍的带剪枝的回溯穷举搜索出发,对其进行改造,用于穷尽搜索本章涉及的三类问题的解。此外,我们还会探讨各种贪心或启发式方法,并讨论这些问题在现实世界中的应用:从用团选择办公室位置,到用独立集避免宿怨,再到用顶点覆盖建设警卫塔。

节点集合的回溯搜索

对于本章中的每个问题,我们都希望找到满足给定约束的节点集合。我们使用上一章第16章介绍的带剪枝回溯搜索的改进版本,通过探索每个节点是否包含在集合中的不同可能性来寻找潜在解。正如图着色问题中的应用一样,这些回溯搜索会枚举所有有效解。虽然它们会检查每一个可能的有效分配,但效率通常很低。

这种搜索的基本思想是通过逐一考虑节点,并在每个决策点分支搜索来探索所有可能的节点集合。在第一条分支中,搜索探索不包含当前节点的可能集合。在第二条分支中,搜索探索包含当前节点的可能集合。

图17-1展示了这种方法,其中每个节点是否包含在集合中用 True(包含)或 False(不包含)标记。列表中的空项表示尚未决定是否包含的节点。在每一层,搜索都会考虑下一个未分配的节点,并对两种可能的分配进行分支。

image.png

因为在图17-1中我们将每条分支拆分为两个子分支,所以每一层的可能选项数量都会翻倍。对于一个有 NN 个决策的树,我们将探索 2N2^N 种完整分配。在图节点子集的情况下,我们将每个节点视为一个独立的决策,因此 N=VN = |V|,需要探索 2V2^{|V|} 种可能。虽然剪枝无效路径可以帮助剔除一些显而易见的不可能结果,但它无法避免搜索带来的复杂度全面爆炸。

我们可以把这种搜索想象成在一个魔法地牢中解谜的方法。进入冰冷的石室时,我们沿墙发现五个巨大的开关。根据之前对魔法地牢的研究,我们知道只有一种正确的开关配置才能打开通往宝藏的门。不幸的是,地牢设计者不仅仅想设计一个有趣的谜题;他们要保护宝藏,因此完全没有提供任何提示。虽然检查单个猜测所需时间不长,但我们可能需要尝试所有组合才能找到正确答案。

为了获得宝藏,我们从最左边的开关猜测(关闭),然后是第二个开关(关闭),依此类推,直到所有开关都处于关闭状态。当金库门不可避免地没有打开时,我们回溯到最后一个决策点(将最右边的开关设为关闭),尝试开启它。当这仍然不起作用时,我们进一步回溯(到第二个最右边的开关),将其改为开启,再次探索最后一个开关的每个可能设置。我们可以庆幸地牢设计者的预算只有五个开关,因此我们只需测试 25=322^5 = 32 种设置。但随着一次又一次的回溯,我们很难保持这样的积极想法。

对于本章中的所有算法,我们都描述了寻找解的两种算法方法。首先,我们通过描述近似的贪心搜索来建立问题基础以及影响解的因素。然后,我们展示如何将回溯搜索应用到该问题,并说明如何添加剪枝。

团(Cliques)

团是无向图中完全连接的节点子集。形式上,我们定义一个团为节点集合 VVV′ ⊆ V,使得:

(u,v)E对所有 uV′和 vV′成立(u, v) ∈ E \quad \text{对所有 } u ∈ V′ \text{和 } v ∈ V′ \text{成立}

在社交网络中,一个团可以理解为一群彼此都是朋友的人。

图17-2显示了一个图,其中有两个带阴影的节点子集。图17-2(a)中带阴影的节点 {1, 2, 5} 形成了一个团,因为子集中的每一对节点之间都有边。相比之下,图17-2(b)中带阴影的节点 {0, 1, 4} 并不构成团,因为节点 0 和 4 之间,以及节点 1 和 4 之间没有边。

image.png

我们可以通过检查每对节点是否存在边来判断一个节点集合是否构成团,如清单17-1所示。

def is_clique(g: Graph, nodes: list) -> bool: 
    num_nodes: int = len(nodes)
    for i in range(num_nodes):
        for j in range(i + 1, num_nodes):
            if not g.is_edge(nodes[i], nodes[j]):
                return False
    return True

清单17-1:检查一个节点集合是否形成有效团

代码使用一对 for 循环遍历列表中的每一对节点,并检查相应的边是否存在。如果缺少边,代码会立即返回 False。如果成功检查了列表中所有节点对而未发现缺失的边,则返回 True。

我们可以将这种检查形象化为一个好奇的旁观者在社交网络中的行为。听闻某高中中有一个“伟大好友团”,怀疑的旁观者宣称:“他们不可能真的都互相喜欢”,于是开始试图揭露这个群体的隐藏分歧。身处初级侦探模式,他们逐个找到每个人,询问他们与团体其他成员的关系:“你真的和乔尼是朋友吗?苏茜呢?”直到确认每对关系都真实存在,他们才最终放弃怀疑。

虽然判断给定节点集合是否形成团相对简单,但在图中构建可能的最大团则困难得多。寻找最大团的问题在于找到图中最大的节点子集 V′⊆VV′ ⊆ V 构成有效团。这个问题比找到任意团更困难,因为一个节点的有效性取决于团中其他节点。如果逐个添加节点,早期选择可能导致我们走向次优方向并排除后续节点。

用例

我们可以通过考虑需要通过交通路线(边)直接连接的位置(节点)来理解团的重要性。王国的冒险者、探险家与制图师公会希望在拥有魔法地牢的位置设立地区总部。经过数小时对各类标准(如地牢难度、获取新鲜食材的便利性)的讨论,他们得出结论:最优先考虑的是办公室之间的便捷交通。毕竟,公会各办公室共享成员的任务清单。如果老墨尔本城的冒险者得知“犹豫悬崖”有一条有前景的任务,他们会希望直接前往。公会领导聘请高级制图师寻找最大的一组城市,使得每个城市都通过道路直接连接。制图师熟悉最大团问题,开始列举所有可能性。

在一个不那么奇幻的场景中,我们可能希望利用最大团检测来选择有直接交通连接的商业选址或有直接连接的计算节点。每个问题都涉及在图中寻找完全连接的子集。

贪心搜索

我们可以通过贪心算法构建团:从任意节点开始作为初始团,不断添加兼容节点。我们总是选择那些能保持团有效的新节点,即与团中每个成员都有边相连的节点。

清单17-2展示了如何检查哪些节点可以扩展当前团。

def clique_expansion_options(g: Graph, clique: list) -> list: 
    options: list = []
    for i in range(g.num_nodes):
       if i not in clique:
            valid: bool = True
            for j in clique:
               valid = valid and g.is_edge(i, j)
            if valid:
                options.append(i)
    return options

清单17-2:检查哪些节点可以加入团

代码遍历图中每个节点,并测试该节点是否可以加入当前团,首先检查节点是否已经在团中 ❶。如果不在团中,代码通过检查它是否与当前团的每个节点都有边相连来判断有效性 ❷。如果通过测试,则将该节点加入扩展选项列表。

这个函数就像帮助“伟大好友团”挑选潜在成员。学校里的每个学生都是候选人。对于每个不在团内的学生,团体代表会询问现有成员:“你们是朋友吗?”如果潜在成员已经与团内所有成员成为朋友(新节点与团中每个节点都有边),现有成员会迅速欢迎这个新朋友加入。

在清单17-3中,我们构建了一个贪心算法,每次添加一个节点来逐步构建团。

def clique_greedy(g: Graph) -> list: 
    clique: list = []
    to_add: list = clique_expansion_options(g, clique)
    while len(to_add) > 0:
      ❶ clique.append(to_add[0])
        to_add = clique_expansion_options(g, clique)
    return clique

清单17-3:一个贪心算法来寻找团

代码从空列表开始表示正在构建的团。它使用 while 循环,不断调用 clique_expansion_options() 找到潜在扩展选项列表,并将返回列表的第一个选项添加到团中 ❶。当当前团无法再添加新节点(len(to_add) == 0)时,停止并返回团列表。

在逐个添加节点时,我们会遇到一个问题:“接下来添加哪个节点?” 在清单17-3的代码中,我们选择了第一个选项,但这可能是糟糕的选择。考虑如果将该贪心算法应用于图17-3,算法会先选择节点 0,最终返回团 {0, 1},而不是更大的团 {1, 2, 4, 5}。

image.png

贪心搜索并不能保证找到最大团,因为贪心搜索每次迭代的决策并非相互独立。每次算法将节点 uu 加入团时,就会阻止它将未来不与 uu 相连的节点加入团。如果在早期加入了错误的节点,很容易陷入局部最优。我们可以通过改进选择启发式策略(例如优先选择拥有最多边的节点)来有所帮助,但效果有限。要构建最大团,我们需要更全面(且开销更大的)搜索。

回溯搜索

最大团的回溯搜索通过递归尝试将图中的某个节点设为团成员或非团成员,如清单17-4所示。在递归的每一层,搜索函数会获取当前构建的团(clique)和下一个待测试节点(index),递归测试所有未分配节点的组合,并返回该分支搜索中找到的最大团。该分支操作有效地测试了图中 2∣V∣2^{|V|} 种可能的节点子集,同时利用剪枝排除无效选项。

def maximum_clique_recursive(g: Graph, clique: list, index: int) -> list: 
  ❶ if index >= g.num_nodes:
        return copy.copy(clique)

  ❷ best: list = maximum_clique_recursive(g, clique, index + 1)

  ❸ can_add: bool = True
    for n in clique:
        can_add = can_add and g.is_edge(n, index)

    if can_add:
        clique.append(index)
        candidate: list = maximum_clique_recursive(g, clique, index + 1)
      ❹ clique.pop()

      ❺ if len(candidate) > len(best):
            best = candidate

    return best

清单17-4:递归探索可能的团

回溯搜索的代码首先检查是否达到了终止条件(已经遍历过图中最后一个节点)❶。如果是,则没有节点需要检查,该分支下的 clique 即为当前最大的节点子集。代码返回当前团的副本,以便快照当前状态,同时与后续递归过程中修改的 clique 对象分离。

如果尚未到达递归终点,代码会尝试构建包含或不包含当前节点的团。它首先通过调用 maximum_clique_recursive(),使用当前团和下一个节点的索引测试不包含当前节点的子集 ❷,并保存该分支的最佳结果以便比较。

在测试包含当前节点的子集之前,代码会检查该节点是否与当前团兼容,从而避免探索无效子树 ❸。与清单17-2中的 clique_expansion_options() 类似,maximum_clique_recursive() 会检查候选节点是否与团中所有节点相连。如果缺少任意一条边,加入该节点将产生无效团,则代码跳过该递归分支。

如果当前节点与当前团兼容,代码将尝试将其加入团中,并递归测试剩余节点。随后通过移除该节点清理 clique 数据,以便在其他分支继续使用 ❹。代码比较两条分支的结果,并保留更大的有效节点子集 ❺。

我们可以用初始 clique=[]index=0 调用清单17-4中的函数,或者使用包装函数:

def maximum_clique_backtracking(g: Graph) -> list: 
    return maximum_clique_recursive(g, [], 0)

图17-4展示了该搜索的可视化。每一层显示算法基于单个节点是否加入团的分支情况。第一层考虑是否加入节点 0,第二层考虑是否加入节点 1。加入团的节点用阴影表示,排除的节点用白色表示,未分配节点用虚线圆表示。

image.png

图17-4中的子图显示了每次函数调用开始时 clique 列表的状态。可以看到,函数只沿着包含有效团的分支进行搜索。例如,在评估 clique=[0]index=2 时,搜索无法沿右分支继续,因为 {0, 2} 并不是有效团。因此,搜索只测试了16种可能的完整组合中的10种,如最后一行所示。

独立集

独立集在本质上是团的“反面”。我们将无向图中的独立集定义为一个节点子集,使得集合中的任意两个节点都不是邻居。形式化表示为:独立集是节点集合 VVV' \subseteq V,满足:

(u,v)E对所有 uV且 vV(u, v) \notin E \quad \text{对所有 } u \in V' \text{且 } v \in V'

可以把选择独立集想象成策划世界上最尴尬的派对:我们邀请一群来自学校或办公室的人参加派对,但派对上没有任何人喜欢其他任何人。

图17-5显示了一个图及两个节点子集。图17-5(a)中阴影节点 {0, 2, 4} 形成一个独立集,因为这些节点之间没有任何边连接。相比之下,图17-5(b)中阴影节点 {0, 1, 4} 并不构成独立集,因为节点0和节点1之间存在一条边。

image.png

确定一组节点是否构成独立集,需要检查每对节点,确认它们之间没有边连接:

def is_independent_set(g: Graph, nodes: list) -> bool: 
    num_nodes: int = len(nodes)
    for i in range(num_nodes):
        for j in range(i + 1, num_nodes):
            if g.is_edge(nodes[i], nodes[j]):
                return False
    return True

这段代码与17-1节中检查团的算法几乎相同。它遍历列表中的每对节点,检查它们是否违反独立集的定义。

在尴尬派对的例子中,is_independent_set() 函数就像另一个怀疑的局外人。他无法忍受沉默,坚持认为:“这里肯定有人是朋友。”于是他询问派对上每个人与其他人的关系:“你确定你们不是朋友吗?那他们呢?”只有当每一对都不是朋友时,他才承认这种尴尬氛围是可以理解的,主持人可能确实有点刻薄。

与团类似,生成大的独立集也很困难,因为向独立集中添加一个节点可能会影响其他节点的有效性。寻找最大独立集的问题,就是在图中找到最大节点子集 VVV' \subseteq V,使其构成有效独立集。

使用场景

我们可以通过选择没有负面关系(边)的人员(节点)来理解独立集的重要性。想象在一个高度功能失调的组织中组建项目团队。每个员工都对同事怀有各种怨恨,例如“吃错午餐”或“忘记生日”。HR甚至建立了一个图来表示员工之间的成对怨恨,每个节点代表一名员工,边表示相互敌意。问题就是在这个图中找到一组员工,使得没有两人互相敌视。

另一种场景是设计魔法迷宫。为了给冒险者提供合适但不至于不可能的挑战,邪恶巫师决定不在相邻的房间中放置Boss级怪物。他将迷宫建模为一个图,节点是房间,边是房间之间的通道,然后寻找能放置Boss级怪物的最大独立房间集,其余房间放置低级史莱姆,让冒险者休息。

贪心搜索

与团算法类似,我们可以定义一个贪心算法,通过逐个添加兼容节点来构建独立集。通过检查每个节点是否有效,我们列出独立集扩展的选项,如下所示:

def independent_set_expansion_options(g: Graph, current: list) -> list: 
    options: list = []
    for i in range(g.num_nodes):
        if i not in current:
            valid: bool = True
            for j in current:
                valid = valid and not g.is_edge(i, j)
            if valid:
                options.append(i)
    return options

代码遍历图中的每个节点,测试该节点是否可以加入独立集。它检查待选节点是否与当前集合中的任何节点共享边,只有每个节点检查通过(valid 仍为 True),才将该节点加入扩展选项列表。

贪心搜索可通过启发式方法选择下一个节点,从而引导独立集构建的方向。启发式方法不能保证 100% 正确,但能提高效果。对于独立集问题,一个合理的启发式是选择边最少的节点,这些节点与其他节点冲突较少,更兼容需求。在功能失调组织中,这意味着选择怨恨最少的员工。

def independent_set_lowest_expansion(g: Graph, current: list) -> int: 
    best_option: int = -1
    best_num_edges: int = g.num_nodes + 1

    for i in range(g.num_nodes):
        if i not in current and g.nodes[i].num_edges() < best_num_edges:
            valid: bool = True
            for j in current:
                valid = valid and not g.is_edge(i, j)
            if valid:
                best_num_edges = g.nodes[i].num_edges()
                best_option = i
    return best_option

这段代码与17-5节类似,但跟踪当前最佳节点 best_option 及其边数 best_num_edges。首先将最佳节点设为无效值 -1,并将最佳边数设为大于任意节点可能的边数。然后循环检查每个节点是否可行,如果节点的边数多于当前最佳节点,则直接跳过可行性检查。

通过不断添加最佳候选节点,我们可以构建贪心搜索:

def independent_set_greedy(g: Graph) -> list: 
    i_set: list = []
    to_add: int = independent_set_lowest_expansion(g, i_set)
    while to_add != -1:
        i_set.append(to_add)
        to_add = independent_set_lowest_expansion(g, i_set)
    return i_set

代码从空列表 i_set 开始,使用 independent_set_lowest_expansion() 找到最佳节点并不断添加,直到没有节点可以加入。

在组织实例中,这意味着每次选取与当前团队兼容且怨恨最少的员工,逐步建立团队。首先选择没有冲突的员工,再选择冲突最少的员工,依此类推,总是跳过与当前团队不兼容的员工。

然而,贪心搜索并不总能找到最大独立集。即便使用启发式方法,仍可能做出次优选择,将解限制在局部最优。例如,将该贪心算法应用到图17-6时可能出现这种情况。

image.png

如图17-6(a)所示,贪心搜索会先选择节点0,然后选择节点1,从而将自己锁定在局部最优解。如果搜索在第二步选择节点3,如图17-6(b)所示,则可以找到独立集 {0, 3, 5}。

回溯搜索

构建最大独立集的回溯搜索同样尝试将每个节点标记为集合成员或非成员。通过这种分支,函数可以测试节点的所有组合,并返回每条搜索分支中找到的最大独立集。在每一层递归中,17-7节中的函数接收当前已构建的独立集(current)和待测试的下一个节点(index)。

def maximum_independent_set_rec(g: Graph, current: list, index: int) -> list: 
    if index >= g.num_nodes:
        return copy.copy(current)

    best: list = maximum_independent_set_rec(g, current, index + 1)

    can_add: bool = True
    for n in current:
        can_add = can_add and not g.is_edge(n, index)

    if can_add:
        current.append(index)
        candidate: list = maximum_independent_set_rec(g, current, index + 1)
        current.pop()

        if len(candidate) > len(best):
            best = candidate

    return best

列表17-7:递归探索可能的独立集

沿用17-4节 maximum_clique_recursive() 的模式,maximum_independent_set_rec() 函数首先检查是否已到递归终点,即没有剩余节点可检查 ❶。如果是,则返回当前独立集的副本,作为该分支下找到的最大集合。

如果尚未到达递归终点,函数尝试构建包含或不包含当前节点 index 的独立集。先测试不包含当前节点的子集,通过递归调用函数,传入当前独立集和下一个节点索引 ❷。这相当于跳过当前节点,继续考虑后续节点。代码将该分支找到的最大结果保存,用作其他分支的基准。

接着,代码检查当前节点 index 是否与当前独立集兼容 ❸。如果当前节点与 current 中的任意节点存在边,添加它将导致独立集无效。代码仅探索结果有效的路径(can_add == True)。

如果当前节点兼容当前集合,代码尝试将其加入 current 并递归测试剩余选项 ❹。随后,通过 pop() 移除该节点,以便在其他分支继续使用该列表 ❺。代码比较两条分支的结果,并保留较大的有效节点子集。

我们可以用初始值 current=[]index=0 调用17-7节的函数,或者使用包装函数:

def maximum_independent_set_backtracking(g: Graph) -> list: 
    return maximum_independent_set_rec(g, [], 0)

图17-7展示了搜索的可视化,每一层表示算法在单个节点的包含与否上进行分支。分配到独立集的节点为阴影节点,被排除的节点为白色,未分配的节点为虚线圆圈。

image.png

图17-7中的子图展示了每次函数调用开始时 current 的状态。第一层考虑是否包含节点0;第二层考虑是否包含节点1。由于函数只探索包含有效独立集的分支,它最终只遍历了16种可能完整分配中的7种。

顶点覆盖(Vertex Cover)

与寻找团(clique)和独立集的问题都关注节点对是否相邻不同,顶点覆盖问题关注的是每条边所连接的节点。我们将无向图中的顶点覆盖定义为节点的一个子集,使得每条边至少有一个端点在该集合中。换句话说,每条边至少被一个顶点(节点)覆盖。形式化定义如下:顶点覆盖是节点集合 VVV' \subseteq V,满足:

对每条边 (u,v)E(u, v) \in E,至少有 uVvVu \in V'、v \in V' 或两者都在集合中。

可以将顶点覆盖想象成一个由群岛(节点)组成的王国,这些岛屿由桥梁(边)连接。为了维护安全,王国在岛屿上建造高塔以监视桥梁。每座岛上的高塔可以看到与该岛相连的所有桥梁,从而王国可以战略性地选择塔的位置。然而,每条桥(边)必须至少有一个端点所在的岛屿上建有高塔(即被选中的节点)。

图17-8展示了一个图及两个阴影节点子集。图17-8(a)中的阴影节点 {1, 3, 5} 构成顶点覆盖,因为每条边至少与一个阴影节点相连。而图17-8(b)中的阴影节点 {0, 1, 4} 并不构成顶点覆盖,因为边 (2, 5) 没有被集合中的任何节点覆盖。

image.png

确定一组节点是否构成顶点覆盖,需要检查图中每条边是否至少被集合中的一个节点覆盖:

def is_vertex_cover(g: Graph, nodes: list) -> bool: 
  ❶ node_set: set = set(nodes)
    for edge in g.make_edge_list():
      ❷ if edge.from_node not in node_set and edge.to_node not in node_set:
            return False
    return True

这段代码首先创建了一个包含已选节点的集合 node_set,利用集合的数据结构加快查找速度,而不是在列表中逐一搜索 ❶。接着,它遍历图中的每条边,检查该边的起点和终点是否都不在集合中 ❷。如果边的两个端点都不在集合中,则该边未被覆盖,函数立即返回 False。如果所有边都通过检查,函数返回 True。

最小顶点覆盖问题是指找到图中构成顶点覆盖的最小节点子集 V′⊆VV' \subseteq V。这一问题在成本节约方面有直接类比。在观察塔的例子中,王国希望建造尽可能少的瞭望塔来监控每座桥梁。

使用场景

顶点覆盖问题自然出现在维护管理的场景中。想象一个既邪恶又整洁的巫师建造了一个魔法地下城。他们知道不能让通道无人打扫,也不能让火把熄灭,因此需要在每条通道附近安置紧急维修小队。当冒险者在地下通道中与怪物战斗时,打落的石块需要小队及时修复。为了效率,巫师需要在每条通道两端的至少一个房间中安置小队。为了降低成本,巫师会精确计算所需的最少小队数量。

在非魔法场景中,我们也可能希望为交通网络配置维修队或收费亭。为了降低成本,我们计划设置一套收费亭,使所有进出岛屿的交通都必须经过。

贪心搜索

我们可以基于独立集的贪心算法,设计一个用于顶点覆盖的贪心方法,选取节点子集,如清单17-8所示。这次我们使用的启发式是选择覆盖未覆盖边数最多的节点。

def vertex_cover_greedy_choice(g: Graph, nodes: list) -> int: 
  ❶ edges_covered: set = set([])
    for index in nodes:
        for edge in g.nodes[index].get_edge_list():
            edges_covered.add((edge.from_node, edge.to_node))
            edges_covered.add((edge.to_node, edge.from_node))

    best_option: int = -1
    best_num_edges: int = 0
    for i in range(g.num_nodes):
        new_covered: int = 0
        for edge in g.nodes[i].get_edge_list():
          ❷ if (edge.from_node, edge.to_node) not in edges_covered:
                new_covered = new_covered + 1

        if new_covered > best_num_edges:
            best_num_edges = new_covered
            best_option = i

    return best_option

与清单17-6相比,这段代码增加了对已覆盖边的记录 edges_covered。首先创建一个空集合 ❶,然后由于 Graph 类实现了无向边,每条边都以双向方式加入 edges_covered

主循环类似独立集的启发式算法,遍历图中的每个节点并计算启发值。这段代码统计当前节点的新覆盖边数 ❷,记录当前最佳节点并返回。如果没有节点能增加覆盖边数(即节点已形成有效顶点覆盖),返回 -1。

通过在清单17-8的选择逻辑外层加循环,可以得到完整的贪心算法:

def vertex_cover_greedy(g: Graph) -> list: 
    nodes: list = []
    to_add: int = vertex_cover_greedy_choice(g, nodes)
    while to_add != -1:
        nodes.append(to_add)
        to_add = vertex_cover_greedy_choice(g, nodes)
    return nodes

代码以空节点列表开始,使用 vertex_cover_greedy_choice() 逐一添加节点,直到构建出有效的顶点覆盖,且没有节点能增加覆盖数为止。

注意,我们可以通过在外层循环维护 edges_covered 集合并传入函数,提高贪心算法的效率,避免每次迭代重新计算。为了保持选择函数的独立性,此处特意每次重新计算 edges_covered

正如本章其他贪心算法一样,最小顶点覆盖的贪心算法并不保证最优。看似良好的初始选择,在整个图的上下文中可能是次优的。

想象观察塔例子中的规划者在图17-9所示的岛屿上工作。为了降低成本,规划者首先选择桥梁最多的岛屿(节点0)建塔。这通常是合理策略,因为该节点覆盖的边最多。然而,在此情况下,这会导致次优解,如图17-9(a)所示。选择节点0后,规划者需要再选择三个岛屿来覆盖最右侧的边,更糟的是,这个错误会不断重复。只要贪心算法使用确定性选择,就总会得到相同的结果。

image.png

相比之下,图17-9(b)展示了一个使用更少节点的顶点覆盖。一旦我们包含了节点1、2和3,就不再需要包含节点0。

回溯搜索

最小顶点覆盖的回溯搜索虽然在逐节点处理上类似于最大团和独立集的构建,但通过添加节点构建顶点覆盖并不提供同样的剪枝机会。一般的剪枝方法要求从一个有效解开始,并跳过那些会使候选集无效的选择。然而,顶点覆盖的子集可能无法覆盖所有边,因此本身可能不是有效顶点覆盖。因此,我们不能从空集合开始逐步构建。

如果我们从全节点集合开始,逐一移除节点,而不是向集合中添加节点,就能重新获得剪枝机会。由于全节点集合本身就是有效顶点覆盖,我们再次满足“只沿着保持有效顶点覆盖的分支探索”的约束。

在每一层递归中,回溯搜索函数接受当前顶点覆盖(current)以及待测试移除的节点(index),并探索移除或不移除该节点的可能性,如清单17-9所示:

def minimum_vertex_cover_rec(g: Graph, current: set, index: int) -> set: 
  ❶ if index >= g.num_nodes:
        return copy.copy(current)

    best: set = minimum_vertex_cover_rec(g, current, index + 1)

    can_remove: bool = True
    for edge in g.nodes[index].get_edge_list():
      ❷ can_remove = can_remove and edge.to_node in current

    if can_remove:
        current.remove(index)
        candidate: set = minimum_vertex_cover_rec(g, current, index + 1)
      ❸ current.add(index)

        if len(candidate) < len(best):
            best = candidate

    return best

清单17-9:递归探索可能的顶点覆盖

代码首先检查是否已到达递归终点,即没有节点可检查 ❶。如果是,则返回当前顶点覆盖的副本,作为该分支找到的最佳解。

如果尚未结束递归,代码会尝试考虑移除或保留 index。与清单17-4和17-7的搜索不同,这里考虑的是是否 移除 节点。默认选项是保留节点,通过递归调用函数传入当前集合和下一个节点的索引,并将此分支的结果保存为基准最佳解。

在移除节点之前,代码检查移除是否会破坏顶点覆盖。若要在移除 index 后保持集合有效,该节点当前覆盖的所有边必须由 current 中的其他节点覆盖 ❷。代码通过遍历当前节点的每条边,并检查边的另一端节点(edge.to_node)是否在 current 中来实现这一点。

如果可以移除当前节点,代码会尝试从 current 中移除 index 并递归测试剩余选项。之后通过重新加入 index 来清理当前数据,以便在其他分支中使用 ❸。代码比较两个分支的结果,并保留较小的有效节点子集。

我们可以用一个包装函数调用清单17-9中的函数,初始 current 为所有节点索引的集合,index=0

def minimum_vertex_cover_backtracking(g: Graph) -> list: 
    current: set = set([i for i in range(g.num_nodes)])
    best: set = minimum_vertex_cover_rec(g, current, 0)
    return list(best)

图17-10展示了该搜索的可视化,每一层表示算法在单个节点的移除与否上分支。分配到顶点覆盖的节点为阴影,排除的节点为白色,尚未分配的节点为虚线圆圈(初始包含在顶点覆盖中)。

image.png

图17-10中的子图展示了每次函数调用开始时 current 的状态。由于函数只探索包含有效顶点覆盖的分支,它只达到了16种可能完整分配中的7种。

随机化算法

解决本章讨论的这些分配问题的另一种方法是使用随机化算法来评估解。此类算法通过随机数生成器选择下一个要加入或移除集合的节点。乍一看,这似乎不太可能奏效。习惯于确定性思维的用户可能会惊呼:“为什么要随机添加一个节点,而不是用贪心算法选择最佳节点?这样不是会浪费很多时间在糟糕的选择上吗?”虽然随机化算法可能会探索次优选择,但它们提供了几个重要优势值得考虑。

首先,随机化算法可以避免陷入贪心算法可能遇到的局部最优。正如我们在图17-9(a)中看到的,贪心搜索可能通过孤立地做每个选择而导致次优解。相比之下,随机化算法偶尔会猜出一个好的解,就像图17-9(b)中的例子。

其次,随机化算法非常适合并行化:我们可以同时运行多个随机搜索(无需大量协调),然后比较每个搜索找到的最佳结果。这就像让多个瞭望塔规划者各自进行随机搜索,并比较结果,也许作为王国范围内的竞赛,每个团队独立工作,无需全局协调。

在最简单的形式中,随机搜索可能会多次尝试同一个解。虽然我们可以通过额外的跟踪来避免或至少减少重复选项的评估,但这会增加复杂度,并且在并行搜索中需要协调。在本节中,我们关注随机化的基本工作原理,因此保持实现简单。

基本随机搜索

最简单的随机搜索是完全随机地选择可行选项。以寻找最大独立集为例,我们可以使用清单17-5中的 independent_set_expansion_options() 函数提供可行选项列表,如下:

def independent_set_random(g: Graph) -> list: 
    i_set: list = []
    options: list = independent_set_expansion_options(g, i_set)
    while len(options) > 0:
      ❶ index: int = random.randint(0, len(options)-1)
        i_set.append(options[index])
      ❷ options = independent_set_expansion_options(g, i_set)
    return i_set

代码从一个空的独立集 (i_set) 和所有节点的潜在选项列表 (options) 开始。通过循环,算法随机选择一个可行节点 ❶,加入独立集,并重新生成可扩展的可行选项 ❷。这里使用 Python random 库的 randint() 函数,需要在文件顶部 import random。循环持续进行,直到没有更多节点可加入,随后返回当前独立集。

尽管是随机的,该函数保证生成有效独立集。每次迭代中,算法仅从可行选项列表中选择扩展节点,因此每次添加后独立集仍然有效。我们可以用循环持续搜索更优解,直到达到最大迭代次数:

def build_independent_set_random(g: Graph, iterations: int) -> list: 
    best_iset: list = []
    for i in range(iterations):
        current_iset: list = independent_set_random(g)
        if len(current_iset) > len(best_iset):
            best_iset = current_iset
    return best_iset

代码从一个空独立集 (best_iset) 开始,表示目前见过的最佳结果。然后通过 for 循环生成并测试更多选项。每次迭代中,代码使用 independent_set_random() 生成一个随机独立集,并与目前最佳结果比较,记录最大的独立集并在迭代结束后返回。

可以将这一搜索想象成在一个运作混乱的组织中组建团队的例子。规划者想组建最大团队,但没有时间做穷举搜索。他们决定随机生成100个有效团队,并将最好的呈报给上司。每次生成团队时,随机选择保证尝试之前未考虑的选项。100次尝试后,选出最佳团队并提交。

与贪心搜索一样,随机搜索也不能保证找到最优解,但不同于贪心搜索的是,随机搜索可以避免重复犯同样的错误。

加权随机搜索

完全随机搜索的一个潜在缺点是,每个节点被选中的概率相同,无论是有前景的节点还是糟糕的节点。虽然为了完全探索解空间,每个节点必须有一定被选中的概率,但我们不必强制使用等概率。比如办公室外交官(无冲突)和办公室捣蛋鬼(与半数同事有争端),没有理由给予相同机会。

加权随机算法利用问题结构信息,为节点选择定义自定义概率分布。举个简单例子,在最大独立集问题中选择下一个节点。给定当前独立集节点子集 V′ ⊆ V,定义可行候选集合 C 为不在 V′ 且不与 V′ 中任何节点有边相连的节点集合。形式化表示为:

  • 对于每个 u ∈ Cv ∈ V′u ≠ v(u, v) ∉ E

对于候选集 C,定义选择节点 v ∈ V 的概率分布 p(v)

  • v ∉ C,则 p(v) = 0
  • 满足 ∑v p(v) = 1

例如,我们可以为每个节点分配一个权重,与相邻边数成反比,从而更可能选择邻居较少的节点。

为什么这很重要

对于本章和上一章涉及的所有问题来说,评估一个给定解很容易,但找到最优解却很困难。我们已经探讨了多种解决 NP 难图分配问题的方法,包括贪心搜索、随机搜索、穷举搜索以及定制(启发式)算法。然而,目前没有已知的方法能在所有情况下都高效。

这些问题仅是 NP 难图问题的一个子集。尽管它们没有已知的通用高效算法,但往往对应着现实世界中至关重要的问题。因此,理解问题的结构以及解决它们的实用技术都非常重要。

本章中,我们为每个问题都展示了两种方法——近似贪心解使用回溯搜索的穷举解——以说明问题及其计算困难的因素。这些方法仅仅触及了相关技术的表面。例如,有兴趣的读者可以在 Cormen 等人的《算法导论(第4版)》(MIT Press, 2022)中找到顶点覆盖的有界近似算法。Russell 和 Norvig 的《人工智能:现代方法(第4版)》(Pearson, 2020)则提供了约束满足算法的入门介绍,并讲解了如何将其应用于图着色等问题。

在下一章中,我们将不再是为集合选择节点,而是解决如何选择边以形成图中的遍历路径的问题。