算法知识图谱总览 / 离线与杂项技巧族 / 分数规划/01分数规划

分数规划/01分数规划

P1advancedstandardG-E-7

What · 是什么

求Σa/Σb极值:二分答案→Σw_i(a_i-mid·b_i)≥0判定;Dinkelbach迭代法

分数规划(Fractional Programming)用于求分式 Σ(a_i·w_i)/Σ(b_i·w_i) 的极值,其中 w_i∈{0,1} 表示选/不选第 i 个物品。核心方法:二分答案 mid,将分式判定转化为 Σw_i(a_i-mid·b_i)≥0 的线性判定问题,再用贪心/DP/MST/SPFA等求解判定。

Why · 为什么

直觉 · Intuition

要求 Σa/Σb 的最大值。如果猜答案为 mid,则 Σa/Σb≥mid 等价于 Σa-mid·Σb≥0 即 Σ(a_i-mid·b_i)≥0。这样分式判定就变成了线性判定:每个物品的'净权值'为 a_i-mid·b_i,选若干使净权和≥0。二分 mid 即可逼近最优比值。

原理 · Principle

二分法:对答案 mid,判定是否存在选法使 Σw_i(a_i-mid·b_i)≥0。判定函数的设计取决于限制条件:①无限制→贪心选正净权值 ②恰好选 k 个→选净权值前 k 大 ③分母≥W→01背包(b_i为重量,a_i-mid·b_i为价值) ④生成树→MST(a_i-mid·b_i为边权) ⑤环→判负环(a_i-mid为边权)。Dinkelbach 迭代法:用上一轮的比值作新 mid 迭代直至收敛,收敛快但不保证最劣情况。

历史 · History

分数规划是运筹学经典方法。Dinkelbach(1967)提出迭代法。竞赛中常见于最优比率生成树(POJ2728)、最优比率环(负环判定)、01分数规划(LOJ149)。OI Wiki 系统整理了二分法和 Dinkelbach 两种解法。

How · 实现

伪代码

# 01分数规划: 选恰好k个, 最大化 Σa/Σb
# 二分法
lo, hi = 0, max_ratio_upper_bound
while hi - lo > eps:
  mid = (lo + hi) / 2
  # 计算每个物品净权值 c_i = a_i - mid * b_i
  # 选净权值前k大, 求和
  c = [(a[i] - mid * b[i]) for i in range(n)]
  c.sort(reverse=True)
  s = sum(c[:k])
  if s >= 0:  # mid 可行
    lo = mid
  else:
    hi = mid
return lo

Python 实现

def fractional_programming_01(a, b, k):
    """01分数规划: 从n个物品中选恰好k个, 最大化 Σa_i/Σb_i。
    二分答案法。
    a, b: 每个物品的两个权值
    k: 必须选的个数
    返回最大比值。
    """
    n = len(a)
    eps = 1e-7
    lo, hi = 0.0, max(a[i] / b[i] for i in range(n))
    for _ in range(100):  # 二分100次足够精度
        mid = (lo + hi) / 2
        # 净权值 c_i = a_i - mid * b_i
        c = [a[i] - mid * b[i] for i in range(n)]
        c.sort(reverse=True)
        s = sum(c[:k])  # 选前k大
        if s >= 0:
            lo = mid
        else:
            hi = mid
    return lo


def fractional_programming_unlimited(a, b):
    """分数规划: 无限制选, 最大化 Σa/Σb。
    贪心: 选所有正净权值物品。
    """
    eps = 1e-7
    lo, hi = 0.0, max(a[i] / b[i] for i in range(len(a))) if a else 0.0
    for _ in range(100):
        mid = (lo + hi) / 2
        s = sum(max(0, a[i] - mid * b[i]) for i in range(len(a)))
        if s >= 0:
            lo = mid
        else:
            hi = mid
    return lo


def dinkelbach_method(a, b, k):
    """Dinkelbach迭代法: 01分数规划。
    用上一轮比值作新mid, 迭代至收敛。
    通常比二分法收敛更快。
    """
    n = len(a)
    # 初始猜测: 选前k个的比值
    L = sum(a[:k]) / sum(b[:k])
    eps = 1e-7
    while True:
        c = [(a[i] - L * b[i], i) for i in range(n)]
        c.sort(reverse=True)
        selected = [c[j][1] for j in range(k)]
        new_L = sum(a[i] for i in selected) / sum(b[i] for i in selected)
        if abs(new_L - L) < eps:
            return new_L
        L = new_L


if __name__ == '__main__':
    # 测试01分数规划(选恰好k个)
    a = [1, 2, 3, 4]
    b = [1, 1, 1, 1]
    k = 2
    # 最优: 选a最大的两个(3,4), 比值=(3+4)/(1+1)=3.5
    result = fractional_programming_01(a, b, k)
    print(f'01 fractional programming (k={k}): {result:.6f}')
    assert abs(result - 3.5) < 1e-4

    # 测试Dinkelbach迭代法
    result2 = dinkelbach_method(a, b, k)
    print(f'Dinkelbach method: {result2:.6f}')
    assert abs(result2 - 3.5) < 1e-4

    # 测试无限制选
    a2 = [3, 1, 4, 1, 5]
    b2 = [2, 1, 3, 1, 2]
    result3 = fractional_programming_unlimited(a2, b2)
    print(f'Unlimited selection: {result3:.6f}')
    # 暴力验证: 选所有a/b>0的部分
    best = 0
    for mask in range(1, 1 << len(a2)):
        sa = sum(a2[i] for i in range(len(a2)) if mask & (1 << i))
        sb = sum(b2[i] for i in range(len(a2)) if mask & (1 << i))
        best = max(best, sa / sb)
    print(f'Brute force: {best:.6f}')
    assert abs(result3 - best) < 1e-4

    print('All fractional programming tests passed!')
PyPy 提示PyPy 对浮点运算和排序优化尚可;但二分100次+每次排序 O(n log n) 的开销在 N>1e5 时可能偏慢;注意浮点精度 eps 设 1e-7 通常足够。
时间复杂度O(log(V/eps) * T) — T为单次判定复杂度; 贪心判定O(n log n), 背包判定O(n*W), MST判定O(m log m)
空间复杂度O(N) — 存净权值数组
注意 · 局限浮点精度问题: eps 太小可能死循环, 太大精度不够;Dinkelbach 法不保证最劣情况收敛速度(但实际通常快);判定函数设计是难点(需根据限制条件灵活变型)。

易错点

例题

OJ题号难度考点链接
LOJ149301分数规划模板: 选恰好k个最大化Σa/Σb, 贪心判定https://loj.ac/p/149
LuoguP43774Talent Show G: 分母≥W限制, 01背包判定https://www.luogu.com.cn/problem/P4377
POJ27284Desert King: 最优比率生成树, MST判定http://poj.org/problem?id=2728
步骤迷你图(节点 = 算法步骤 · 边 = 流程 · 绿色 = 起始步骤)

信源

✓ = 已核验 · △ = 未核验