最近项目上遇到了一个需求就是在多个指定区间内生成总和恒定的随机数。
示例:在[1-3]、[4-20]、[24-100]区间上分别生成一个随机数且要求随机数总和为40。
输出:随机数 a = 2 、b = 5 、c = 33.
想了一整天,最后用一种不是太完美的方法解决了这个问题。
在多个指定区间生成随机数这个好弄,但是要求总和恒定就有点难办了。
下边的思路我会用用上面的示例来解释。
首先我们可以确定的是这个随机数的总和肯定是要>=1+4+24、<= 3+20+100的。即 >=所有区间最小值的和,<=所有区间最大值的和。
如果我们生成了一个随机数,我们用 总值 - 生成的数 = 剩下的总值 那么 剩下的总值 也必然满足 >=剩下区间的最小值总和,<=剩下区间最大值总和。
例如如我们生成 a=2 ,那么剩下总和为38,那么判断 38 是否满足 >=27 && <=123,如果满足则这个数符合规矩,我们只要在每次生成随机数后进行判断是否满足这个条件就可以了,如果不满足就继续生成。
如果前面的随机数都满足条件,那么最后一个数也必然满足条件。我们不用判断最后一个数,直接用 最后剩下的总和 即可。
问题
用我上面的思路去做必然会生成一组符合条件的随机数,但会出现一个问题。经过大量数据测试你会发现,我们设定的总和离 边界值 越近,生成的时间越长。这是为什么呢?
这是因为在我们的算法中,随机数每次都是在我们规定的区间内生成数据,前边生成的数据会对后边的值产生一定影响。如果前面生成的数过大,可能后边的数就要适当小一些才能满足要求。
当我们设定的总和离边界值远时,这时随机数可取的范围比较大,比如说此时我们设定随机数的总和为75,也就是随机数可取值的中位数。此时离边界是最远的,那么无论前边取何值,对后边的影响都会较小。一组随机数更可能达到要求。
但如果设定值正好为27或123那么此时随机数的取值已经是固定的。我们可以简单计算下 如果我们设置边界值为27那么随机数总和为27的概率就为 1/3 * 1/16 * 1/76
这还是只取三个随机数,如果我们要求的是10个、100个呢。那概率可就够小了。
解决方案
我的改进办法就是在每次判断后将随机数生成的范围缩小。
例如我第一次生成了5,第二次生成了20,我发现剩余总值为15,此时我们明白我们生成的数过大了,我们可以为15为第二个随机数的最高值来生成随机数,即可。即第二次在4-15上生成。这样的就会大大减少随机数生成的次数,从而减少算法消耗时间。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
/**
* @author lixiangxiang
* @description 随机数算法
* @date 2021/7/14 14:10
*/
public class RandomAlgorithm {
public static void main(String[] args) {
List<PveScoreRule> rules = new ArrayList<>();
int minTotal = 0;
int maxTotal = 0;
int total = 0;
//生成分数范围
for (int i = 0; i < 10; i++) {
//生成最大边界
int max = new Random().nextInt(109) % (109+1);
//生成最小边界
int min = new Random().nextInt(109) % (109+1);
//如果min>max交换两者顺序
if (max < min) {
int temp = min;
min = max;
max = temp;
}
//得出随机数总和最高范围
maxTotal += max;
//得出随机数总和最低范围
minTotal += min;
rules.add(new PveScoreRule(max,min));
}
total = new Random().nextInt(maxTotal) % (maxTotal - minTotal + 1) + minTotal;
//打印并验证随机数是否合法
int num = 10;
List<PveScoreRule> errRule = new ArrayList<>();
int[] errArr = new int[10];
//验证数据是否合法
for (int i = 0; i < 10000; i++) {
boolean flag = true;
int sum = 0;
int[] scores = createPveScore(rules,minTotal,maxTotal,total);
for (int j = 0; j < scores.length; j++) {
if (rules.get(j).max < scores[j]||rules.get(j).min>scores[j]) {
flag = false;
errRule = rules;
errArr = scores;
}
System.out.print(scores[j]+"-");
sum += scores[j];
}
}
System.out.println("-"+flag)
System.out.println("======"+Arrays.toString(errRule.toArray()));
}
/**
* description: 创建随机分数
*
* @author: lixiangxiang
* @param rules 每个分数的范围
* @param minTotal 总分最小下限
* @param maxTotal 总分最大上限
* @param total 总分
* @return int[]
* @date 2021/7/14 22:18
*/
public static int[] createPveScore(List<PveScoreRule> rules,int minTotal,int maxTotal,int total) {
//存放随机数的数组
int[] randoms = new int[rules.size()];
for (int i = 0; i < rules.size(); i++) {
//生成随机数 范围最小值到范围最大值
int max = rules.get(i).max;
int min = rules.get(i).min;
//生成到最后一个时,直接将剩余的总数赋给他
if(i == rules.size()-1){
randoms[i] = total;
return randoms;
}
int score = new Random().nextInt(max) % (max - min + 1) + min;
//计算剩余的总数、最大边界和最小边界
minTotal -= min;
maxTotal -= max;
int remandTotal = total - score;
//判断生成的数是否有问题
//1. 判断其剩余总数是否大于最大值 总数是否小于最小值如果有一个满足则进行循环,直到随机数可行时
while(remandTotal > maxTotal || remandTotal < minTotal) {
if (remandTotal > maxTotal) {
//如果比之大说明随机数需要取更大值
//通过改变随机数生成的下限,让其下限等于当前的随机数
min = score;
}
//1. 判断其剩余总数是否小于最小值
else {
//如果比之大说明随机数需要取更小值
//通过改变随机数生成的下限,让其上限等于当前的随机数
max = score;
}
//重新生成随机分数
score = new Random().nextInt(max) % (max - min + 1) + min;
//重新计算剩余的总数
remandTotal = total - score;
}
//如果上述条件都不成立,说明随机数合法存入到数组中
randoms[i] = score;
//计算真正剩余总值
total -= score;
}
return randoms;
}
}
class PveScoreRule {
int max;
int min;
public PveScoreRule(int max, int min) {
this.max = max;
this.min = min;
}
@Override
public String toString() {
return "PveScoreRule{" +
", max=" + max +
", min=" + min +
'}';
}
}
昨天知乎的一个大佬给了一个更好的思路,并指出我的算法中的不足。我顺着他的思路对这个算法做了一些改进。
上面的算法虽然能够生成满足的范围条件的数,但其实他并不是随机的。因为前边的数一旦固定,后面数的范围会逐渐缩小,已经不是我们所规定的范围了。我之前的算法只能说是生成满足条件的数,但说不上随机。
要生成n个数,他们的和为sum。我们可以先生成最大的数n0,然后问题就变成生成n-1个数,他们的和为sum-n0。
比如按照例子[1,3]、[4,20]、[24,100],sum=40可以发现,最大的那个数实被和所限制范围为 [40-20-3,40-1-4]即[17,36],再与其本身的范围取交集,就变成[24,36],然后随机数得到,假设是33然后问题就变成了[1,3]、[4,20]中各生成一个数,他们的和为7这样。那么第二个数的范围就是[4,6],随机数取到5,那么最后一个数就是2。
但是这样做有一个问题,就是不那么随机,后续算的数字更容易落在区间的边缘处。
上面是大佬的思路,同时他也告诉我可以用dp遍历所有情况进行随机取出,但由于我的业务量比较大,需要一次生成一百多个随机数,这种方案肯定不行,
知乎大佬的回答
如何生成多个随机数,要求随机数在不同范围内,且总和一定? - 一丝混乱的回答 - 知乎
之前我们知道生成的数越往后,他的范围就会越小,值就会越确定,这样肯定会导致后面的数更接近区间的边缘值。
在这个思路的基础上,我想到将随机数生成的顺序改变,就是将随机数的下标打乱,记录下来,按照这个打乱的下标顺序生成随机数。等生成结束后再将随机数顺序还原。这样的话,由于第一个生成的数是不确定的,随机数的最后一个值不一定是最后一个生成的,那么生成的概率也将更随机。
当然这个思路只能是加大这组数的随机性,但仍是达不到完全随机。我经过大量数据的测试发现,这种方法生成的数仍是靠近区间边缘的数生成的概率越高,但相差的概率已经缩小很多。
目前我的水平可能也只能做到这一步了,如果有更好思路的大佬请在下方留言,感激不尽。
package GenerateRandom;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.*;
/**
* @author lixiangxiang
* @description /
* @date 2021/7/20 13:18
*/
public class GenerateFloatScore {
public static void main(String[] args) {
List<FightPveScoreScopeDto> rules = generateRules();
// System.out.println(rules);
float maxTotal = 0;
float minTotal = 0;
for (FightPveScoreScopeDto scope : rules) {
maxTotal += scope.getMax();
minTotal += scope.getMin();
}
float total = nextFloat(minTotal,maxTotal);
List<Map<Float,Integer>> list = new ArrayList<>();
for (int i = 0; i < rules.size(); i++) {
list.add(new HashMap<>());
}
for (int i = 0; i < 1000000; i++) {
float[] scores = generatePveScore(rules, total);
for (int j = 0; j < scores.length; j++) {
Map<Float,Integer> map = list.get(j);
float score = scores[j];
if(map.containsKey(score)) {
//计算数值出现概率
int probability = map.get(score)+1;
map.put(score,probability);
} else {
map.put(score,1);
}
}
// judgeScores(floats, rules);
}
for (Map<Float,Integer> map : list) {
System.out.println("第"+(list.indexOf(map)+1)+"个数范围为"+rules.get(list.indexOf(map)));
for (Float key : map.keySet()) {
System.out.println(key+"概率"+(map.get(key)/1000000f)+"---");
}
System.out.println();
}
}
/**
* description: 生成各个数范围
*
* @author: lixiangxiang
* @return java.util.List
* @date 2021/7/20 15:37
*/
public static List<FightPveScoreScopeDto> generateRules() {
List<FightPveScoreScopeDto> rules = new ArrayList<>();
for (int i = 0; i < 10; i++) {
//生成最大边界
float max = nextFloat(0, 10.9f);
//生成最小边界
float min = nextFloat(0, 10.9f);
// System.out.println("交换前,max="+max+",min="+min);
//如果min>max交换两者顺序
if (max < min) {
float temp = min;
min = max;
max = temp;
}
FightPveScoreScopeDto fightPveScoreScopeDto = new FightPveScoreScopeDto();
fightPveScoreScopeDto.setMax(max);
fightPveScoreScopeDto.setMin(min);
rules.add(fightPveScoreScopeDto);
}
return rules;
}
/**
* description:
*
* @author: lixiangxiang
* @param scores 生成的随机数
* @param rules 每个数的范围
* @return boolean
* @date 2021/7/20 15:38
*/
public static boolean judgeScores(float[] scores,List<FightPveScoreScopeDto> rules) {
List errRule = new ArrayList();
float[] errArr = new float[10];
boolean flag = true;
int sum = 0;
for (int j = 0; j < scores.length; j++) {
if (rules.get(j).getMax() < scores[j]||rules.get(j).getMin()>scores[j]) {
flag = false;
errRule = rules;
errArr = scores;
}
System.out.print(scores[j]+"-");
sum += scores[j];
}
System.out.println("-"+flag);
return flag;
}
/**
* 生成max到min范围的浮点数
* */
private static float nextFloat( float min, float max) {
BigDecimal cha = new BigDecimal(Math.random() * (max-min) + min);
//保留 scale 位小数,并四舍五入
return cha.setScale(1,BigDecimal.ROUND_HALF_UP).floatValue();
}
/**
* description:
*
* @author: lixiangxiang
* @param scopes 每枪范围集合
* @param total 总分
* @return int[]
* @date 2021/7/18 21:38
*/
public static float[] generatePveScore(List<FightPveScoreScopeDto> scopes,float total) {
float maxTotal = 0;
float minTotal = 0;
for (FightPveScoreScopeDto scope : scopes) {
maxTotal += scope.getMax();
minTotal += scope.getMin();
}
//将scopes顺序打乱
List<FightPveScoreScopeDto> shuffleScopes = new ArrayList<>();
//生成打乱顺序后的下标
int[] randomIndex = generateRandomArr(scopes.size());
//按照生成的下标将集合打乱
for (int i = 0; i < scopes.size(); i++) {
shuffleScopes.add(scopes.get(randomIndex[i]));
}
//按照打乱的顺序生成分数
float[] shuffleScores = generatePveScore(shuffleScopes, minTotal, maxTotal, total);
float[] floatScores = new float[scopes.size()];
//恢复生成分数顺序
for (int i = 0; i < randomIndex.length; i++) {
floatScores[randomIndex[i]] = shuffleScores[i];
}
return floatScores;
}
/**
* description: 创建人机随机分数
*
* @author: lixiangxiang
* @param rules 所有枪数规则
* @param minTotal 总分最小下限
* @param maxTotal 总分最大上限
* @param total 总分
* @return int[]
* @date 2021/7/14 22:18
*/
private static float[] generatePveScore(List<FightPveScoreScopeDto> rules, float minTotal, float maxTotal, float total) {
//存放随机数的数组
float[] res = new float[rules.size()];
int length = rules.size();
for (int i = length - 1; i >= 0; i--) {
//从后往前生成
//计算最后一个范围的真实范围 [总数-其他数范围最大值之和,总数-其他数范围最小值之和]与其原本范围的交集
float max = rules.get(i).getMax();
float min = rules.get(i).getMin();
minTotal -= min;
maxTotal -= max;
//最后一个数不用值唯一,为剩下的total值
float scopeMax = Math.min((total - minTotal),max);
float scopeMin = Math.max((total - maxTotal),min);
//生成随机分数
//如果范围是0-0则不用算随机数,直接赋值
if(scopeMax == 0 && scopeMin == 0) {
res[i] = 0;
continue;
}
//用计算出的真实范围生成随机数
float score = nextFloat(scopeMin, scopeMax);
res[i] = score;
//重新对total赋值
total -= score;
}
return res;
}
/**
* description: 生成随机打乱顺序的下标
*
* @author: lixiangxiang
* @param size 、
* @return int[]
* @date 2021/7/20 15:35
*/
private static int[] generateRandomArr(Integer size) {
int[] arr = new int[size];
for (int i = 0; i < size; i++) {
arr[i] = i;
}
int[] randomArr = new int[size];
//索引
int cur = 0;
//位置
int position = 0;
int length = size;
while (length > 0) {
//生成随机位置
position = new Random().nextInt(length);
//将随机数生成位置的值记录到randomArr中
randomArr[cur++] = arr[position];
//将arr数组的最后一个值赋值给arr2中随机数所在的位置
arr[position] = arr[length - 1];
//数组长度-1
length--;
}
return randomArr;
}
}
class FightPveScoreScopeDto implements Serializable {
/**
* 分数最大值
*/
private Float max;
/**
* 分数最小值
*/
private Float min;
public Float getMax() {
return max;
}
public void setMax(Float max) {
this.max = max;
}
public Float getMin() {
return min;
}
public void setMin(Float min) {
this.min = min;
}
@Override
public String toString() {
return "FightPveScoreScopeDto{" +
"max=" + max +
", min=" + min +
'}';
}
}