剑指Offer_#60_n个骰子的点数

剑指Offer_#60_n个骰子的点数

Contents

题目

把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率。
你需要用一个浮点数数组返回答案,其中第 i 个元素代表这 n 个骰子所能掷出的点数集合中第 i 小的那个的概率。
示例 1:

输入: 1
输出: [0.16667,0.16667,0.16667,0.16667,0.16667,0.16667]

示例 2:

输入: 2
输出: [0.02778,0.05556,0.08333,0.11111,0.13889,0.16667,0.13889,0.11111,0.08333,0.05556,0.02778]

限制:

1 <= n <= 11

思路分析

这一题根本不是简单题...感觉是一道比较复杂的动态规划问题,书上的代码我也没有看懂,主要参考了题解:java 递归+记忆化递归+动态规划

可以观察到的规律

首先我们可以考虑比较最简单的几个例子,即n=1,2,3时的情况,可以得出一些结论:

  • 骰子数是n,那么点数和的范围是[n,6n]
  • 计算点数和为k的概率时(k∈[n,6n]),分母是所有骰子点数的全排列,即6n;分子是所有点数和为k的排列数。

分母是固定的,那么问题的关键就在于分子的计算,即如何统计出点数和为k的所有排列个数。
分析这个问题,比较自然的思路是递归的思路,然后对递归算法进行优化,可以得出动态规划的解法。

方法1:递归

我们可以将问题抽象出来,设定一个函数f(n,k),表示有n个骰子,出现的点数之和是k的所有排列数。
如何计算f(n,k)的值?我们可以这么考虑,n个骰子的点数和可以拆分为两部分,n-1个骰子的点数和与1个骰子的点数和,两部分相加结果一定是f(n,k)

  • 1个骰子出现的点数和最简单,一定是1~6当中的一个。
  • 根据那1个骰子的点数,我们可以推断出另外n-1个骰子的点数和,这两部分是互补的,有6种可能性。
    • 1 + (k-1)
    • 2 + (k-2)
    • 3 + (k-3)
    • 4 + (k-4)
    • 5 + (k-5)
    • 6 + (k-6)

也就是说,f(n,k)是骰子数为n,点数和为k的情况,是由骰子数为n-1的情况转变过来的。但骰子数为n-1时,点数和是不固定的,有6种可能性,即k-1,k-2...k-6。
得出递推关系:

f(n,k) = f(n-1,k-1)+f(n-1,k-2)+...+f(n-1,k-6)

即每个递归问题的解决要依赖于6个递归子问题。
然后考虑终止条件:
终止条件应该是f(1,x) = 1,即当骰子数为1时,组合数是1。
但这么写的话,无法处理x计算出负数的情况。所以代码中换了一种写法,是等效的,且避免了x为负数的情况,见代码注释。
最后是递归返回值:
返回f(n,k)
递归的思路是从上到下的,从f(n,k)开始,递归调用f(n-1,x),f(n-2,x)...直到满足终止条件,开始回溯,最后回到f(n,k)。

方法2:动态规划

递归的问题在于这里边有很多的重复计算,很多递归子问题是相同的,其实只需要计算一次。
动态规划是从下到上解决问题,先计算子问题的结果,将子问题的结果保存起来,那么后续计算时避免了重复计算。保存子问题结果的就是dp数组。
状态定义
dp(n,k),表示有n个骰子,出现的点数之和是k的所有组合数。

初始状态

dp(1,x) = 1

状态转移方程

dp(n,k) = dp(n-1,k-1)+dp(n-1,k-2)+...+dp(n-1,k-6)

其实思路是和递归几乎完全相同的,仅仅是修改了解决问题的顺序,就优化了时间复杂度。
更多细节请参考代码,写了很多注释。

解答

解法1:递归

class Solution {
    public double[] twoSum(int n) {
        //点数和总共有6n-n+1种情况,即[n...6n]
        double[] res = new double[5*n + 1];
        int curSum = n;
        for(int i = 0;i < res.length;i++){
            //计算每种点数和的概率,分母6^n是总的排列数,分母是出现各种点数和的排列数
            res[i] = countSum(curSum, n) / Math.pow(6, n);
            //res[0]对应的点数和是n,i增加时对应的点数和同步增加
            ++curSum;
        }
        return res;
    }

    private int countSum(int curSum, int n){
        if(n < 0 || curSum < 0) return 0;
        //任何一个curSum调用总是会分解到f(0,0),f(1,0),f(2,0)...
        //其中只有f(0,0)是1,其他的进一步分解必然出现负数,即得到0
        if(n == 0 && curSum == 0) return 1;
        int sum = 0;
        for(int i = 1;i <= 6;i++){
            sum += countSum(curSum-i, n-1);
        }
        return sum;
    }
}

递归写法对于n比较小的情况可以通过,n=9开始,时间超出限制。

解法2:动态规划

基本的dp解法,即用一个二维数组保存dp状态。

class Solution {
    public double[] twoSum(int n) {
        //把第0个位置空出来,目的是将数组索引与实际的骰子数目及点数对应
        int[][] dp = new int[n + 1][6*n + 1];
        //初始状态,即矩阵的第一行
        //每一行都依赖于上一行进行计算
        //这里循环变量用j比较合适,因为表示处理的是一行当中的每个元素
        for(int j = 1;j <= 6;j++) dp[1][j] = 1;
        //状态转移,由第一行开始,计算后面的所有行
        for(int i= 2;i <= n;i++){
            //因为i代表骰子数目,则其点数和范围应该是[i...6i]
            //进到第二层循环,即计算某一行当中的某个特定元素
            for(int j = i;j <= 6*i;j++){
                //用for循环表示状态转移方程,看起来会比较抽象,但是实际上并没有很难,不要害怕
                //实际上就是dp[i][j] = dp[i-1][j-1]+dp[i-1][j-2]+...+dp[i-1][j-6]
                //抽象出来,就是dp[i][j - dice],dice作为循环变量,每次累加一个dp[i][j - dice]
                for(int dice = 1;dice <= 6;dice++){
                    //j - dice = 0时,矩阵中的数字是无意义的,因为所有赋值都是从dp[1][1]开始的
                    if(j - dice <= 0) break;
                    dp[i][j] += dp[i - 1][j - dice];
                }
            }
        }
        double[] res = new double[5*n + 1];
        //dp[][n]开始计算,因为不存在点数和小于n的情况
        int j = n;
        //所有的排列数,即计算概率的分母
        double sum = Math.pow(6,n);
        //这里用k是因为这里遍历的是res[],而不是dp[][],加以区分
        for(int k = 0;k <= res.length - 1;k++){
            //迭代到最后,其实只需要看矩阵dp[][]当中最后一行(即第n行)的结果即可,代表的就是n个骰子各种点数和出现的次数
            //除以总的排列数,即得到概率
            res[k] = dp[n][j++] / sum;
        }
        return res;
    }
}

这个代码的提交成绩是双百。但是还有优化余地,因为dp矩阵当中,其实每一次只用得到当前行(dp[i][])和上一行(dp[i-1][]),所以不必要保存整个矩阵,只需要保存两行即可。

解法3:进一步精简代码

参考K神的解法剑指 Offer 60. n 个骰子的点数
实际上原理和解法2也是相同的,但是代码上做了进一步的优化和精简。

  • 解法2当中dp数组是一个二维数组,dp[i]是一行数据,这行当中的每个数据dp[i][j]表示的是i个骰子之和为j的排列数。解法3则是直接维护了一个dp的一维数组,由于我们结果只需要最后一行,所以从始至终解法3只保留了一行数据。
  • 解法2的dp数组计算的是排列数,然后最后再通过除以6^n求出概率,而对于解法3,是直接用dp计算概率的。
class Solution {
    public double[] dicesProbability(int n) {
        double[] dp = new double[6];
        //1个骰子的情况,出现1-6的概率是1/6
        Arrays.fill(dp, 1.0 / 6.0);
        //每次增加1个骰子,逐步迭代计算出n个骰子的点数和的概率分布情况
        //每轮循环当中,i表示骰子的数目
        for(int i = 2; i <= n; i++){
            //i个骰子的点数和范围是[i, 6i],共有5i+1种情况
            double[] tmp = new double[5 * i + 1];
            //i个骰子的点数和是由i-1个骰子的点数和加上新骰子的点数得来的
            for(int j = 0; j < dp.length; j++){
                //新骰子的点数可能是1,2,3...6
                for(int k = 0; k < 6; k++){
                    //j是1,2,3...6的概率分别为1/6,所以点数和从j变化为j+k的概率需要乘以1/6
                    tmp[j + k] += dp[j] / 6.0;
                }
            }
            //dp和tmp的关系:dp是tmp的前一轮的值
            dp = tmp;
        }
        return dp;
    }
}

上面的代码感觉变量名,逻辑有点不清晰,重写了一个

class Solution {
    public double[] dicesProbability(int n) {
        int[] pre = new int[6];
        for(int i = 0; i < pre.length; i++) pre[i] = 1;
        //填表,cur是基于pre求出的下一行,
        for(int i = 2; i <= n; i++){//i表示骰子数量
            int[] cur = new int[5 * i + 1];
            for(int j = 0; j < cur.length; j++){//j表示点数和
                for(int k = 0; k < 6; k++){
                    //注意这里递推的时候的下标对应关系,可以画出骰子数量为1,2的情况来归纳一下数学表达式
                    if(j - k >= 0 && j - k < pre.length) cur[j] += pre[j - k];
                }
            }
            pre = cur;
        }
        double sum = Math.pow(6, n);
        double[] res = new double[5 * n + 1];
        for(int i = 0; i < res.length; i++){
            //这里不使用cur是因为cur是上面循环中的一个局部变量
            res[i] = pre[i] / sum;
        }
        return res;
    }
}
posted @ 2020-07-25 01:49  Howfar's  阅读(202)  评论(0编辑  收藏  举报