01分数规划
问题介绍
01分数规划是大家学习之路上较为经典的一类问题,首先对于这个名字,01的含义一般来说就是“选与不选”,问题原型如下
当前有n个物品,每个物品都有其价值 \(v_i\) 与成本 \(w_i\),要求我们从中选出k(k<=n)个物品,使得总价值与总成本的比例最大(最小),也就是对于选出的这k个物品
\(1 <= j <= k\)
有 \(\sum_1^k{v_j} / \sum_1^k{w_j}\) 最大(最小),求这个最值
- PS:对于这个问题,网上有些介绍可能少了其中的“k个”,而是描述为“选一些”,当然这样可能更加宽泛,但是容易出现误解,我一开始就误以为“一些”是”任意个“的意思,结果发现这样想的话,只用选性价比最高的那个物品就行了,显然这样是不行的
解决方法/算法过程
- 1.式子变形
一开始要对这个式子进行变形,以最大值为例,设答案为 \(ans\) 则在选出的k个中有:
即
得
合并为
可以看出这是个一次不等式,不等式左边可以看成一个以 \(ans\) 为变量的一次函数,当\(\sum_1^k{(v_j - ans * w_j)} < 0\)时,说明ans太大了,当\(\sum_1^k{(v_j - ans * w_j)} > 0\)时,说明有比ans更大的解,当式子两边相等时,说明我们找到了真正的ans
-
2.枚举答案
上面这个不等式性质很明显了,单调函数求零点,二分答案就可以了
-
1.设左边界 \(l = 0\), 右边界 \(r = \max_{(1 \le i \le n)}{v_i/w_i}\),进行二分枚举
-
对于每次的 \(mid\),我们处理出所有的
\[d_i = v_i - ans * w_i \]然后对 \(d_i\) 进行从大到小的排序
- 把前k个 \(d_i\) 相加,得到的结果如果大于0,则答案在左半区间,反之在右半区间
-
例题/模板题
poj2976 题目链接
是的,这是很与原型非常接近的一道题,不过要注意精度,还有就是如果用了double,则在printf输出时用%.0f才能过,虽然不能归咎于玄学,但是确实网上还没有找到对此的解释,大家都是用的这种方法
c++代码
#include
#include
#include
#include
using namespace std;
const double cc = 1e-7;
int n, k;
double aa[1005], bb[1005];
double dd[1005];
bool cmp(double a, double b)
{
return a > b;
}
int main()
{
while (scanf("%d%d", &n, &k) == 2)
{
if (!n && !k)
{
break;
}
for (int i = 1; i <= n; ++i)
{
scanf("%lf", &aa[i]);
aa[i] *= 100;
}
double l = 0, r = 0;
double ans = -1;
for (int i = 1; i <= n; ++i)
{
scanf("%lf", &bb[i]);
r = max(r, aa[i] / bb[i]);
}
while (l + cc < r)
{
double mid = (l + r) / 2;
for (int i = 1; i <= n; ++i)
{
dd[i] = aa[i] - bb[i] * mid;
}
sort(dd + 1, dd + n + 1, cmp);
double acc = 0;
for (int i = 1; i <= n - k; ++i)
{
acc += dd[i];
}
if (acc > 0)
{
l = mid;
}
else
{
r = mid;
}
}
ans = (l + r) / 2;
printf("%.0f\n", ans);
}
return 0;
}
拓展问题:最优比率生成树与Dinkelbach算法
最优比率生成树,不是指树的形状如何,而是指生成树的边的某种比例(如边长和/花费和)最小或者最大,本质上还是01分数规划问题
- 例题:poj2728
题中要求得出具有最小的“花费和/边长和”的生成树,我们可以用kruskal算法,每次也就是从m条边中选n-1条边,那么类比上面的01分数规划原问题,我们kruskal排序用来比较的值也应该是
每次二分后,排序优先选择di最小的边建立生成树就可以了
但这样是过不了的,因为这是个完全图,导致kruskal算法比较慢,\(O(2*n^2*log^2(n))\) 复杂度太大,解决方案之一是使用prim,不过更为方便的方法是使用Dinkelbach算法
-
Dinkelbach算法
Dinkelbach算法是对二分法的优化,我们可以认为它相比二分,更新ans时换了一种迭代方式
- 1.我们去掉二分所需要的l, r, mid
- 2.我们初始化ans==0
- 3.每次按照\(cost_i - ans * dis_i\) 对边进行排序并选出生成树
- 4.同时记录这n-1条边所形成的的一次函数
\[\]\[\]\[\]\[\]
Dinkelbach算法每次的ans收敛方向与二分法是一致的,但是收敛速度比二分法要快不少,事实上,此题在使用kruskal算法时,正好卡了二分法,若使用Dinkelbach算法则刚好可以过
- 注意:通过多次提交与验证,算法中有两个需要注意的地方,一是代码中r变量(即上述ans)一开始最好置为0,否则可能会WA,二是与之前的poj2976类似,最终答案的占位符最好用%f而不要用%lf,原因还未找到,但是既然已经重复遇到了这种问题,以后可以优先考虑%f。
c++代码:
#include
#include
#include
#include
#include
using namespace std;
const double cc = 1e-4; //精度
int n;
struct edges
{
int u;
int v;
double d;
double w;
double dv;
} ee[2000005];
double r;
bool cmp(struct edges a, struct edges b)
{
return a.w - r * a.d < b.w - r * b.d;
}
int ff[1005] = {0};
double xs[1005], ys[1005], zs[1005];
int find(int xx)
{
if (ff[xx] != xx)
{
ff[xx] = find(ff[xx]);
}
return ff[xx];
}
int main()
{
while (scanf("%d", &n) == 1)
{
if (!n)
{
break;
}
for (int i = 1; i <= n; ++i)
{
scanf("%lf%lf%lf", &xs[i], &ys[i], &zs[i]);
}
int co = 0; //边的数量 n * (n - 1) / 2
for (int i = 1; i <= n; ++i)
{
for (int j = i + 1; j <= n; ++j)
{
ee[++co].u = i;
ee[co].v = j;
ee[co].d = sqrt((xs[i] - xs[j]) * (xs[i] - xs[j]) + (ys[i] - ys[j]) * (ys[i] - ys[j]));
ee[co].w = fabs(zs[i] - zs[j]);
}
}
r = 0; //如果不置为0可能会WA
while (1)
{
sort(ee + 1, ee + co + 1, cmp);
int cnt = 0;
for (int i = 1; i <= n; ++i)
{
ff[i] = i;
}
double A = 0, B = 0;
for (int i = 1; i <= co; ++i)
{
int fu = find(ee[i].u);
int fv = find(ee[i].v);
if (fu != fv)
{
ff[fu] = fv;
A += ee[i].w;
B += ee[i].d;
++cnt;
if (cnt == n - 1)
{
break;
}
}
}
double nex = A / B;
if (fabs(r - nex) <= cc)
{
break;
}
else
{
r = nex;
}
}
printf("%.3f\n", r); //如果写lf可能会WA
}
return 0;
}