算法知识图谱总览 / 搜索算法族 / 搜索剪枝优化

搜索剪枝优化

P1intermediatestandardG-A-14

What · 是什么

可行性剪枝/最优性剪枝/搜索顺序优化,减少搜索量

搜索剪枝是减少 DFS 搜索量的系列技术。DFS 时间复杂度过高,直接爆搜常超时;剪枝通过提前判断子树的'前途',在扩展前砍掉无望分支。三大类:①可行性剪枝——当前部分解已违反约束(超容量、无法到达),整棵子树非法;②最优性剪枝——当前代价已差于已得最优解(或乐观上界也追不上),继续搜无意义;③搜索顺序优化——先搜更有希望的分支,让最优解更早出现,间接提升剪枝效率。此外还有记忆化(相同状态只算一次)、等效性剪枝(对称/重复分支只搜一个)。

Why · 为什么

直觉 · Intuition

剪枝像'识途的老马':走岔路之前就闻出前面是死胡同(可行性),或者就算走到头也不会比现有最好成绩好(最优性),干脆掉头。先搜最有可能成功的路(顺序优化),能更早拿到好成绩,让最优性剪枝更有力。

原理 · Principle

DFS 框架中,可行性剪枝在循环内判断 cur+next 是否超约束;最优性剪枝在进入递归前比较 now 与当前最优 ans,若 now 已不可能优于 ans 则 return。顺序优化通过按代价/密度排序候选,使最优解尽快出现——这本质是利用'找到好下界越快,剪枝越多'。等效性剪枝(如相同长度木棍只尝试第一个未用的)避免重复探索对称分支。极端法剪枝:若最理想情况(乐观估计)都无法满足,则实际更不可能。

历史 · History

剪枝思想伴随回溯法出现(见 backtracking 节点),并在 0/1 背包分支限界(Land & Doig, 1960)、N皇后优化中得到系统化。OI Wiki 以工作分配问题(n≤15)与 P1120 小木棍为经典教学例,总结出'极端法/调整法/数学方法'三类剪枝思路。

How · 实现

伪代码

def dfs(状态, 当前代价 now):
    # 最优性剪枝
    if now 已差于/不可能优于 ans: return
    # 可行性剪枝
    if 违反约束: return
    if 到达目标: ans = min(ans, now); return
    for 候选 c in 按优化顺序排列的候选集:
        if 可行:
            标记; dfs(下一状态); 撤销
    # 等效性剪枝: 相同候选只试第一个

# 优化顺序: 按代价/密度/影响力降序

Python 实现

def stick_break(lengths, k):
    """小木棍问题: 能否把 lengths 分成 k 组, 每组和相同
    返回目标单根长度(存在解)或 -1
    集中演示: 从长到短排序 + 可行性/最优性/等效性剪枝
    """
    total = sum(lengths)
    if total % k != 0:
        return -1
    target = total // k
    lengths = sorted(lengths, reverse=True)  # 搜索顺序优化: 先长后短
    n = len(lengths)
    used = [False] * n

    def dfs(remaining_groups, cur_sum, start):
        if remaining_groups == 0:
            return True
        if cur_sum == target:
            return dfs(remaining_groups - 1, 0, 0)
        for i in range(start, n):
            if used[i]:
                continue
            if cur_sum + lengths[i] > target:
                continue  # 可行性剪枝
            if i > 0 and not used[i - 1] and lengths[i] == lengths[i - 1]:
                continue  # 等效性剪枝: 相同长度只试第一个
            used[i] = True
            if dfs(remaining_groups, cur_sum + lengths[i], i + 1):
                return True
            used[i] = False
            if cur_sum == 0:
                return False  # 最长未用都放不下, 无解
            if cur_sum + lengths[i] == target:
                return False  # 恰好拼满却失败, 无解
        return False

    return target if dfs(k, 0, 0) else -1


def assignment_min_time(cost, n):
    """工作分配问题: n个人n份工作, 求最小总时间
    cost[i][j] = 第i人做第j份工作的时间
    最优性剪枝: 当前总时间超过已有最优即返回
    """
    used = [False] * n
    best = [float('inf')]

    def dfs(person, cur):
        if cur >= best[0]:
            return  # 最优性剪枝
        if person == n:
            best[0] = min(best[0], cur)
            return
        for j in range(n):
            if not used[j]:
                used[j] = True
                dfs(person + 1, cur + cost[person][j])
                used[j] = False

    dfs(0, 0)
    return best[0]


if __name__ == "__main__":
    # 小木棍
    assert stick_break([5, 5, 5, 5], 4) == 5
    assert stick_break([3, 5, 2, 8, 1, 4], 1) == 23  # 一根全拼
    assert stick_break([7, 7, 3, 3, 10, 10], 2) == 20
    assert stick_break([1, 1, 1], 2) == -1

    # 工作分配
    cost = [[9, 2, 7, 8],
            [6, 4, 3, 7],
            [5, 8, 1, 8],
            [7, 6, 9, 4]]
    assert assignment_min_time(cost, 4) == 13

    # 随机对拍小木棍
    import random
    random.seed(3)
    def brute_ok(lengths, k):
        total = sum(lengths)
        if total % k != 0:
            return False
        target = total // k
        n = len(lengths)
        used = set()
        def rec(groups):
            if all(g == target for g in groups):
                return True
            idx = min(range(k), key=lambda i: groups[i])
            for i, x in enumerate(lengths):
                if i in used:
                    continue
                if groups[idx] + x <= target:
                    used.add(i)
                    groups[idx] += x
                    if rec(groups):
                        return True
                    groups[idx] -= x
                    used.discard(i)
            return False
        return rec([0] * k)

    for _ in range(300):
        n = random.randint(1, 8)
        lens = [random.randint(1, 8) for _ in range(n)]
        k = random.randint(1, 3)
        total = sum(lens)
        if total % k == 0 and brute_ok(lens, k):
            assert stick_break(lens, k) == total // k, (lens, k)
        else:
            assert stick_break(lens, k) == -1, (lens, k)
    print("search-pruning all tests passed (300 random checks)")
PyPy 提示剪枝搜索常用递归+全局最优,PyPy 注意 setrecursionlimit。剪枝顺序影响巨大:可行性剪枝最廉价先做,最优性剪枝次之;排序预处理(从大到小/密度降序)能显著加速。相同长度候选用等效性剪枝避免指数浪费。
时间复杂度最坏仍指数级;良好剪枝下可达实际多项式/近线性,但无法理论保证
空间复杂度O(搜索深度 + 资源标记)
注意 · 局限剪枝本质是经验优化,无法保证 worst-case 提升;剪枝条件写错可能漏解;问题规模太大(深度/分支大)时即使剪枝仍超时;记忆化需状态可哈希且无后效性。

易错点

例题

OJ题号难度考点链接
LuoguP11204小木棍:可行性+最优性+等效性剪枝综合题,剪枝极致训练https://www.luogu.com.cn/problem/P1120
OI Wikiopt-example3工作分配问题:最优性剪枝入门(n≤15)https://oi-wiki.org/search/opt/
步骤迷你图(节点 = 算法步骤 · 边 = 流程 · 绿色 = 起始步骤)
上游 · 本知识点依赖(前置/变体/组合/共现)

信源

✓ = 已核验 · △ = 未核验