题目:把n个骰子扔在地上,所有骰子朝上一面的点数之和为s,输入n,打印出s的所有可能的值出现的概率。
解法一:基于递归求骰子的点数,时间效率不够高
现在我们考虑如何统计每一个点数出现的次数。要想求出n个骰子的点数和,可以先把n个骰子分为两堆:第一堆只有1个,另一个有n-1个。单独的那一个有可能出现从1到6的点数。我们需要计算从1到6的每一种点数和剩下的n-1个骰子来计算点数和。接下来把剩下的n-1个骰子还是分成两堆,第一堆只有一个,第二堆有n-2个。我们把上一轮那个单独骰子的点数和这一轮单独骰子的点数相加,再和n-2个骰子来计算点数和。分析到这里,我们不难发现这是一种递归的思路,递归结束的条件就是最后只剩下一个骰子。
解法二:基于循环求骰子的点数,时间性能好
可以换一个思路来解决这个问题,我们可以考虑用两个数组来存储骰子点数的每一个总数出现的次数。在一次循环中,每一个数组中的第n个数组表示骰子和为n出现的次数。在下一轮循环中,我们加上一个新的骰子,此时和为n出现的次数应该等于上一次循环中骰子点数和为n-1,n-2,n-3,n-4,n-5,n-6的次数之和,所以我们把另一个数组的第n个数字设为前一个数组对应的第n-1,n-2,n-3,n-4,n-5,n-6。
分析问题:
如果用笨办法,一般人最开始都会想到笨方法,那就是枚举法。
举个例子,比如两个骰子,第一个骰子的结果为1,2,3,4,5,6,两个骰子的结果是2,3,4,5,6,7;3,4,5,6,7,8;4,5,6,7,8,9;……;7,8,9,10,11,12,共36种,用2的平方size的数组记录着36个结果。
仔细分析可以发现其实这其中有很多是重复的,所以去除重复,考虑最小的应该是2,也就是n,最大的应该是12,也就是6n,所以所有的结果应该只有6n-n+1=5n+1种,如果我们开辟了一个最大index=6n的数组,也就是size为6n+1的数组就可以放下这所有的结果,但是其中index为0~(n-1)的位置上没有数放,这里我们有两种解决方案,一种就是让它空着,这样的好处是,结果为s的就可以直接放在index为s的位上,不过如果我们想节省这部分的空间,可以将所有数据往前移一下,也就是把和为s的放在s-n上即可,这样我们就只需要size为5n+1的数组。
所以我们在声明一个结果数组,5n+1的大小,通过遍历前面的n平方大小的数组,出现和为s就在5n+1大小的s-n位上加1即可。
这样的方式,时间复杂度为n平方,可见并不理想,我们可以降低时间复杂度。
首先想到的是否能退化问题,比如n个骰子与n-1个骰子之间的关系,比如n个骰子的结果是n-1个骰子的结果分别加上1~6而得,于是n-1个骰子的结果又是n-2个骰子的结果分别再加上1~6而得。
但递归的方法并不是很好,很多重复计算,重复计算的问题可以考虑斐波那契计算过程,我们最后提出一种空间换时间的方法,也是传统的记录中间结果的方法,跟斐波那契的优化很像,将某些中间结果存起来以减少递归过程的重复计算问题,主体计算次数,将次数存到数组中,由于要用到递归,我们最好单独写一个probabilityOfDice函数,probabilityOfDice函数中的参数要包括递归的时候要用到的那些变量,比如总的n,现在的n,以及现在的sum,以及贯穿始终的次数数组:
public static void probabilityOfDice(int n, int curDiceValue, int numOfDices, int curSum, int[] times) {
if(numOfDices == 1) {//如果只有一个骰子
int sum = curSum + curDiceValue;
times[sum-n]++;//n*1 to n*MAX <---> 0 to len
} else {
int sum = curSum + curDiceValue;
for(int i = 1; i <= MAX; i++) {
probabilityOfDice(n, i, numOfDices-1, sum, times);
}
}
}
而计算次数的时候就是去调用这个probabilityOfDice的函数:
for(int i = 1; i <= MAX; i++) {//initial the first dice
probabilityOfDice(n, i, n, 0, times);//count the times of each possible sum
}
考虑n=2时的递归过程,首先n=2,numOfDices=2,curSum=1,表明第一个骰子甩出了1,由于numOfDices=2表明现在有两个骰子,所以进入else部分,i又从1到6循环,表明这是进入到第二个骰子在甩了,首先i为1,表明又甩出了一个1,这时候nC=1,就将2-n的位置上加1,表明结果为2的次数加1,然后退到上一层,i++,此时还是第二个骰子在甩,甩出了一个2,此时curSum=3,numOfDices=1,所以在和为3的位置上加1,一直这样,到了和为7的位置上加1的时候,会退到在上一次循环,这时候表明第一个骰子甩出了一个2,此时进入第2个骰子,依次会出现和为3,4,5,6,7,8的结果,然后再在相应位置上加1即可。
基于这个思路实现代码如下:
public class Dice {
private static int MAX = 6;
public static void printProbabilityOfDice(int n) {
if(n < 1)
return;
double total = Math.pow(MAX, n);
int len = n*MAX - n*1 + 1;//the sum of n dices is from n*1 to n*MAX
int[] times = new int[len];
for(int i = 1; i <= MAX; i++) {//initial the first dice
probabilityOfDice(n, i, n, 0, times);//count the times of each possible sum
}
for(int i = 0; i < len; i++) {
System.out.println((i+n) + "," + times[i] + "/" + total);
}
}
public static void probabilityOfDice(int n, int curDiceValue, int numOfDices, int curSum, int[] times) {
if(numOfDices == 1) {//如果只有一个骰子
int sum = curSum + curDiceValue;
times[sum-n]++;//n*1 to n*MAX <---> 0 to len
} else {
int sum = curSum + curDiceValue;
for(int i = 1; i <= MAX; i++) {
probabilityOfDice(n, i, numOfDices-1, sum, times);
}
}
}
}
优化方法:
我们需要将中间值存起来以减少递归过程中的重复计算问题,可以考虑我们用两个数组AB,A在B之上得到,B又在A之上再次得到,这样AB互相作为对方的中间值,其实这个思想跟斐波那契迭代算法中用中间变量保存n-1,n-2的值有异曲同工之妙。
我们用一个flag来实现数组AB的转换,由于要轮转,我们最好声明一个二维数组,这样的话,如果flag=0,1-flag用的就是数组1,如果flag=1时,1-flag用的就是数组0:
int[][] probabilities = new int[2][];
probabilities[0] = new int[g_maxValue*number+1];
probabilities[1] = new int[g_maxValue*number+1];
我们以probabilities[0]作为初始的数组,那么我们对这个数组进行初始化是要将1~6都赋值为1,说明第一个骰子投完的结果存到了probabilities[0]:
for(int i = 1; i <= g_maxValue; i++)
probabilities[0][i] = 1;
然后就是第二个骰子,第二个骰子的结果存到probabilities[1],是以probabilities[0]为基础的,此时和为s的次数就是把probabilities[0]中和为s-1,s-2,s-3,s-4,s-5,s-6的次数加起来即可,而第k次用k个骰子那么要更新的结果范围就是k到maxValue*k,所以连起来就是:
for(int i = 1; i <= g_maxValue; i++)
probabilities[0][i] = 1;
for(int k = 2; k <= number; k++) {
for(int i = 0; i < k; i++)
probabilities[1-flag][i] = 0;
for(int i = k; i <= g_maxValue*k; i++) {
probabilities[1-flag][i] = 0;
for(int j = 1; j <= i && j <= g_maxValue; j++)
probabilities[1-flag][i] += probabilities[flag][i-j];
}
}
然后就需要把probabilities[1]作为中间值数组,这里我们把flag赋值为1-flag即可:
flag = 1-flag;
如下代码:
public class DicesSum {
/**
* n个骰子的点数
* 把n个骰子扔在地上,所有骰子朝上一面的点数之和为S。输入n,打印出S的所有可能的值出现的概率。
* 在以下求解过程中,我们把骰子看做是有序的。
* 例如当n=2时,我们认为(1, 2)和(2, 1)是两种不同的情况。
*/
private static int g_maxValue = 6;
public static void printProbabilities(int number) {
if(number < 1)
return;
int[][] probabilities = new int[2][];
probabilities[0] = new int[g_maxValue * number + 1];
probabilities[1] = new int[g_maxValue * number + 1];
int flag = 0;
for(int i = 1; i <= g_maxValue; i++)
probabilities[flag][i] = 1;
for(int k = 2; k <= number; k++) {
for(int i = 0; i < k; i++)
probabilities[1-flag][i] = 0;
for(int i = k; i <= g_maxValue*k; i++) {
probabilities[1-flag][i] = 0;
for(int j = 1; j <= i && j <= g_maxValue; j++)
probabilities[1-flag][i] += probabilities[flag][i-j];
}
flag = 1-flag;
}
double total = Math.pow(g_maxValue, number);
for(int i = number; i <= g_maxValue*number; i++) {
double ratio = (double) probabilities[flag][i] / total;
System.out.println(i + "\t" + ratio);
}
}
}