问题描述

给定3个参数,blood,hurt,hitTime 。怪兽有 blood 滴血,等着英雄来砍自己。英雄每一次打击,都会让怪兽流失 [0~hurt] 的血量(此区间上等概率选定一个整形值)。求经过 hitTime 次打击之后,英雄把怪兽砍死的概率。


暴力递归

每次击打会扣除 [0~hurt] 范围上任意整形值的血量,一共击打 hitTime 次,所以最后剩余血量有 (hurt+1)^{hitTime} 种可能(包含负值)。注意,如果剩余击打次数还没有到 0 怪兽就已经死亡,死后仍需要继续鞭打(俗称鞭尸,哈哈),直到剩余击打次数 0 时才能停止,然后检查怪兽是否死亡,如果已死亡则返回 1 ,否则返回 0 。代码很简单:

double process(int hurt, int restHit, int restBlood)
{
    if(restHit <= 0)
    {
        if(restBlood <= 0)
            return 1;
        else
            return 0;
    }
    double die = 0;
    for(int h = 0; h <= hurt; h++)
        die += process(hurt, restHit - 1, restBlood - h);
    return die;
}
double killMonster(int blood, int hurt, int hitTime)
{
    double all = pow(hurt + 1, hitTime);
    double die = process(hurt, hitTime, blood);
    return die / all;
}

动态规划

对比暴力递归,很容易得到动态规划:

//help专门处理边界条件
double help(int restHit, int restBlood, int hurt, const vector<vector<double>> &dp)
{
    if(restHit <= 0)//此处==需要改为<=
    {
        if(restBlood <= 0)
            return 1;
        else
            return 0;
    }
    return dp[restHit][restBlood];
}
double killMonster(int blood, int hurt, int hitTime)
{
    vector<vector<double>> dp(hitTime + 1, vector<double>(blood + 1, 0));
    for(int r = 0; r <= hitTime; r++)
    {
        for(int c = 0; c <= blood; c++)
        {
            double die = 0;
            for(int h = 0; h <= hurt; h++)
            {
                die += help(r-1, c-h, hurt, dp);
            }
            dp[r][c] = die;
        }
    }
    return dp[hitTime][blood] / pow(hurt + 1, hitTime);
}

其实上面的代码还存在一个较为隐蔽的问题:在暴力递归中 restBlood 可以为负数,但改为动态规划时,dp 的列序号(blood)只能为正数 。所以这里的动态规划忽略了 blood 为负数的情况。由于最小负数为 blood-hurt×hitTime ,其绝对值可能很大,如果还为负数建表,空间复杂度则较大。思考后可以发现一个事实:restBlood 已经小于 0 时,无论后续扣血的方式如何,最终一定会 return 1,即后续每一种扣血方式最终都会被算进死亡数容易知道后续的扣血方式一共有 (hurt+1)^{restHit} ,所以改进暴力递归如下:

double process(int hurt, int restHit, int restBlood)
{
    if(restHit <= 0)
    {
        if(restBlood <= 0)
            return 1;
        else
            return 0;
    }
    if(restBlood <= 0)//也算一种剪枝优化
        return pow(hurt + 1, restHit);
    double die = 0;
    for(int h = 0; h <= hurt; h++)
    {
        die += process(hurt, restHit - 1, restBlood - h);
    }
    return die;
}

进而得到正确的动态规划:

double help(int restHit, int restBlood, int hurt, const vector<vector<double>> &dp)
{
    if(restHit <= 0)
    {
        if(restBlood <= 0)
            return 1;
        else
            return 0;
    }
    if(restBlood <= 0)
        return pow(hurt + 1, restHit);
    return dp[restHit][restBlood];
}
//killMonster函数同上

斜率优化

以上动态规划的表依赖关系 die += help(r-1, c-h, hurt, dp) 如下图:

容易发现:(x,y) 依赖的是第 x-1 行中 [y-hurt, y] 范围上的值,类似的,(x,y+1) 依赖的是第 x-1 行中 [y+1-hurt, y+1] 范围上的值。 从上图中能看到这两者的依赖有很大的重叠部分,这就是可以优化的地方。具体来说, (x,y) 的依赖可以转换为:

(x,y-1) + (x-1,y) - (x-1,y-hurt-1)

这样我们就成功省去了 for 循环。于是得到以下代码:

//help同上
double killMonster(int blood, int hurt, int hitTime)
{
    vector<vector<double>> dp(hitTime + 1, vector<double>(blood + 1, 0));
    for(int r = 0; r <= hitTime; r++)
    {
        for(int c = 0; c <= blood; c++)
        {
            dp[r][c] = help(r, c-1, hurt, dp) + help(r-1, c, hurt, dp) - help(r-1 ,c-hurt-1, hurt, dp);
        }
    }
    return dp[hitTime][blood] / pow(hurt + 1, hitTime);
}

死亡率=1-生存率

上面的方法中,最麻烦的是处理 restBlood 为负数时的情况。实际上,将求死亡率转换为求生存率就可以绕过这个步骤。

double help(int restHit, int restBlood, int hurt, const vector<vector<double>> &dp)
{
    if(restHit <= 0)
    {
        if(restBlood > 0)//砍完发现还有血,则生存数+1
            return 1;
        else
            return 0;
    }
    if(restBlood <= 0)//由于统计的是生存数,已经砍死就不需要继续了
        return 0;
    return dp[restHit][restBlood];
}
double killMonster(int blood, int hurt, int hitTime)
{
    vector<vector<double>> dp(hitTime + 1, vector<double>(blood + 1, 0));
    for(int r = 0; r <= hitTime; r++)
    {
        for(int c = 0; c <= blood; c++)
        {
            dp[r][c] = help(r, c-1, hurt, dp) + help(r-1, c, hurt, dp) - help(r-1 ,c-hurt-1, hurt, dp);
        }
    }
    return 1 - dp[hitTime][blood] / pow(hurt + 1, hitTime);//死亡率=1-生存率
}

总结

本题易错点:

  1. 当怪兽死亡后,就返回 1,不再继续鞭尸。而实际上需要继续鞭尸,鞭到 T = 0 时才能停下来,然后检查怪兽是否死亡。为什么要鞭尸,这个问题在醉汉死亡率中更能清楚体现。
  2. 暴力递归的状态变量 restBlood 可以为负,而动态规划的 dp 表却忽略了负数的情况,所以 help 中要处理 restBlood <= 0 的情况。
  3. process 的返回值应为 double ,int 和 long 都容易溢出。
  4. killMonster 为什么不能用“要或不要”模型(对 index 指向的伤害值要或不要)?因为有 restHit 限制,导致可能出现这样的情况:只砍了怪兽 3 刀,怪兽还没死,但 index 却来到了末尾。
  5. 当暴力递归中出现 for 循环时,可以在动态规划中尝试斜率优化,须仔细观察位置依赖,一定要举例并画图!
  6. 遇到这种概率 odds ,不妨从 odds 和 1 - odds 两个方向试试。
文章作者: 极简
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
数据结构与算法
喜欢就支持一下吧