百日算法修炼 · Day 02
主题:前缀和、随机数概率变换、等概率随机数范围转换
语言:Java | 建议用时:60~90 分钟
阅读方式
本文分为两个区域:
- 上半篇是练习区:只包含题目、示例、思考问题和可折叠提示;
- 下半篇是答案区:包含优化代码、图解、正确性分析、复杂度与易错点。
建议先独立完成全部题目,再通过“查看参考答案”链接跳转。
今日任务地图
flowchart TD
A[开始 Day 02] --> B[前缀和]
B --> C[随机变量的最值]
C --> D[等概率 1 到 5]
D --> E[等概率比特]
E --> F[等概率 1 到 7]
F --> G[随机实验与复盘]
| 题号 | 主题 | 核心能力 |
|---|---|---|
| 1 | 前缀和 | 用预处理换取快速查询 |
| 2 | 随机数概率变换 | 用事件关系推导分布 |
| 3 | 1~5 转 1~7 | 等概率比特与拒绝采样 |
第一部分:独立练习区
先不要向下翻到答案区。真正的收获,往往发生在“还没看到答案”的几分钟里。
练习 1:多次查询数组区间和
给定一个整数数组 arr,需要多次回答闭区间 [left, right] 内所有元素的累加和。
数组:[-2, 4, 1, 3, -1]
查询:[1, 3]
答案:4 + 1 + 3 = 8
约束:
arr非空,元素允许为负数;0 <= left <= right < arr.length;- 查询次数可能很多;
- 使用
long保存累加结果,避免多个int相加时溢出。
思考问题:
- 每次查询都从
left累加到right,一次和多次查询分别需要多少时间? - 能否预先保存“前
i个元素的和”,让一次查询只做一次减法? - 为什么前缀和数组设计成
n + 1个元素,会比n个元素更自然? - 如果输入下标不合法,应该返回特殊值,还是立即抛出异常?
提示 1:多保留一个空前缀
令 prefix[0] = 0,prefix[i + 1] 表示 arr[0] 到 arr[i] 的和。这样 left = 0 时也不需要特殊分支。
提示 2:把目标区间从大区间中减掉
[left, right] 的和,等于前 right + 1 个元素的和,减去前 left 个元素的和。
写完后再看:查看练习 1 参考答案
练习 2:用随机数的最值改变概率
假设 Math.random() 等概率返回 [0, 1) 内的 double。独立调用若干次后,只允许使用 min 或 max 组合结果。
请实现并推导:
- 两次随机数取最小值;
- 两次随机数取最大值;
- 三次随机数取最大值。
我们不只关心函数“返回什么”,还关心对任意 x(0 <= x <= 1):
思考问题:
max(a, b) < x时,a和b分别要满足什么条件?min(a, b) < x的反面事件是什么?- 为什么三次调用必须相互独立,乘法公式才能成立?
- 如何用一百万次实验,验证推导出的理论概率?
提示 1:先研究最大值
最大值小于 x,等价于每一次随机结果都小于 x。独立事件同时发生时,概率相乘。
提示 2:最小值适合用反面事件
最小值不小于 x,等价于每一次随机结果都不小于 x。先算这个概率,再用 1 减去它。
写完后再看:查看练习 2 参考答案
练习 3:把等概率 1~5 加工成等概率 1~7
现有函数 f(),它只能等概率返回 1、2、3、4、5。不能查看或修改 f() 的内部实现,请加工出一个新函数,使它等概率返回 1~7。
// 唯一可用的随机源
public static int f() {
return (int) (Math.random() * 5) + 1;
}
思考问题:
- 能否先把
f()加工成等概率返回0或1的函数? 1~5有奇数个结果,怎样把它们公平地分成两组?- 三个独立的随机二进制位能等概率表示哪些整数?
- 得到
0~7后,为什么不能直接把0改成7? - 若抽到不需要的结果,怎样处理才能不破坏其他结果的等概率性?
提示 1:先丢掉无法配对的中间值
抽到 1、2 时返回 0,抽到 4、5 时返回 1;抽到 3 就重新抽。两组各有两个等概率结果。
提示 2:三个比特可以表示八种状态
把三个独立比特依次放到二进制的第 2、1、0 位,可等概率得到 000~111,也就是 0~7。
提示 3:拒绝采样
如果抽到 0,就放弃整次结果并重新生成。剩余 1~7 的条件概率仍然相同。
写完后再看:查看练习 3 参考答案
第二部分:参考答案与图解
到这里再对照答案。重点检查变量含义、事件等价关系和边界处理,而不只是比较代码是否相同。
答案 1:多次查询数组区间和
问题说明
直接遍历能正确回答一次查询,但当查询很多时,相同位置会被重复累加。前缀和的核心是:先花 O(n) 保存累计结果,再让每次查询降到 O(1)。
核心思路
定义:
因此 prefix 的长度是 arr.length + 1,且 prefix[0] = 0。闭区间 [left, right] 的和为:
多出来的 prefix[0] 是“空前缀”,它让 left = 0 时仍能使用同一个公式。
过程图解
以 arr = [-2, 4, 1, 3, -1] 为例:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
prefix | 0 | -2 | 2 | 3 | 6 | 5 |
| 含义 | 空前缀 | 前 1 个 | 前 2 个 | 前 3 个 | 前 4 个 | 前 5 个 |
查询 [1, 3]:
prefix[4] = -2 + 4 + 1 + 3 = 6
prefix[1] = -2
区间和 = 6 - (-2) = 8
flowchart LR
A[完整前缀 0 到 right] --> B[减去 0 到 left - 1]
B --> C[得到 left 到 right]
完整代码
package class02;
import java.util.Arrays;
import java.util.Random;
/**
* 前缀和:预处理一次后,以 O(1) 时间回答闭区间和查询。
*/
public class Code01_PreSum {
private static final Random RANDOM = new Random();
public static void main(String[] args) {
final int testTimes = 10_000;
for (int test = 0; test < testTimes; test++) {
int[] arr = randomArray(1 + RANDOM.nextInt(100), 1_000);
// 先确定两个下标,再统一为 left <= right。
int index1 = RANDOM.nextInt(arr.length);
int index2 = RANDOM.nextInt(arr.length);
int left = Math.min(index1, index2);
int right = Math.max(index1, index2);
PrefixSum prefixSum = new PrefixSum(arr);
long expected = rangeSumBruteForce(arr, left, right);
long actual = prefixSum.rangeSum(left, right);
if (expected != actual) {
throw new AssertionError(
"arr=" + Arrays.toString(arr)
+ ", range=[" + left + ", " + right + "]"
+ ", expected=" + expected
+ ", actual=" + actual);
}
}
System.out.println("Accepted: " + testTimes + " tests");
}
/** 朴素实现只作为验证基准,不用于高频查询。 */
private static long rangeSumBruteForce(int[] arr, int left, int right) {
checkRange(arr.length, left, right);
long sum = 0L;
for (int i = left; i <= right; i++) {
sum += arr[i];
}
return sum;
}
/** 前缀和查询器。构造后不依赖原数组,避免外部修改影响结果。 */
public static final class PrefixSum {
private final long[] prefix;
public PrefixSum(int[] arr) {
if (arr == null) {
throw new IllegalArgumentException("arr 不能为 null");
}
// prefix[i] 保存 arr 中前 i 个元素的和;prefix[0] 是空前缀。
prefix = new long[arr.length + 1];
for (int i = 0; i < arr.length; i++) {
prefix[i + 1] = prefix[i] + arr[i];
}
}
/** 返回闭区间 [left, right] 的元素和。 */
public long rangeSum(int left, int right) {
checkRange(prefix.length - 1, left, right);
return prefix[right + 1] - prefix[left];
}
}
/** 发现非法下标时尽早失败,避免用特殊返回值掩盖错误。 */
private static void checkRange(int length, int left, int right) {
if (left < 0 || right < left || right >= length) {
throw new IndexOutOfBoundsException(
"非法区间 [" + left + ", " + right + "],数组长度=" + length);
}
}
/** 生成范围在 [-maxAbsValue, maxAbsValue] 内的随机数组。 */
private static int[] randomArray(int length, int maxAbsValue) {
int[] arr = new int[length];
for (int i = 0; i < length; i++) {
arr[i] = RANDOM.nextInt(2 * maxAbsValue + 1) - maxAbsValue;
}
return arr;
}
}
为什么这样改
| 原实现 | 优化后 | 收益 |
|---|---|---|
help 长度为 n | prefix 长度为 n + 1 | 查询无需为 left == 0 单独分支 |
保存 source 副本 | 只保存 prefix | 少占一份数组空间 |
left > right 返回 Long.MIN_VALUE | 统一校验并抛出异常 | 非法输入不会伪装成正常答案 |
循环内反复创建 Random | 复用一个 Random | 验证代码更清晰,减少对象创建 |
| 随机区间可能反向 | 用 min/max 生成合法区间 | 测试真正覆盖有效查询 |
| 阶段 | 时间复杂度 | 额外空间 |
|---|---|---|
| 构造前缀和 | O(n) | O(n) |
| 单次区间查询 | O(1) | O(1) |
q 次查询总计 | O(n + q) | O(n) |
若数组会频繁修改,普通前缀和并不合适,因为一次修改可能影响后面所有前缀值。那类问题通常需要树状数组或线段树。
答案 2:用随机数的最值改变概率
问题说明
这里的“概率变换”不是修改 Math.random() 本身,而是组合多次独立采样,让新随机变量的累计概率 P(Y < x) 呈现不同曲线。
先推导,再写代码
设每次采样得到的随机变量为 U。由于 U 在 [0, 1) 上均匀分布:
两次取最大值
max(U1, U2) < x 要求两次结果都小于 x:
三次取最大值
同理,三个结果必须全部小于 x:
两次取最小值
直接数“至少一个小于 x”容易遗漏重叠情况,因此先计算反面事件:两次都不小于 x。
事件关系图
flowchart TD
A[组合两个独立随机数] --> B{取最大值还是最小值?}
B -- 最大值小于 x --> C[两次都小于 x]
B -- 最小值小于 x --> D[排除两次都不小于 x]
C --> E[概率 x 的平方]
D --> F[概率 1 减去 1-x 的平方]
以 x = 0.6 为例:
新随机变量 Y | P(Y < 0.6) 理论值 | 直观效果 |
|---|---|---|
| 原始随机数 | 0.6000 | 均匀 |
| 两次取最小值 | 1 - 0.4^2 = 0.8400 | 更容易得到较小值 |
| 两次取最大值 | 0.6^2 = 0.3600 | 更容易得到较大值 |
| 三次取最大值 | 0.6^3 = 0.2160 | 向较大值偏移得更明显 |
完整代码
package class02;
import java.util.function.DoubleSupplier;
/** 使用独立均匀随机数的最值,构造新的概率分布。 */
public class Code02_RandToRand {
public static void main(String[] args) {
final double x = 0.6;
final int testTimes = 1_000_000;
verify("原始随机数", Math::random, x, x, testTimes);
verify("两次取最小值", Code02_RandToRand::minOfTwo,
x, 1.0 - Math.pow(1.0 - x, 2), testTimes);
verify("两次取最大值", Code02_RandToRand::maxOfTwo,
x, Math.pow(x, 2), testTimes);
verify("三次取最大值", Code02_RandToRand::maxOfThree,
x, Math.pow(x, 3), testTimes);
}
/** P(返回值 < x) = 1 - (1 - x)^2。 */
public static double minOfTwo() {
return Math.min(Math.random(), Math.random());
}
/** P(返回值 < x) = x^2。 */
public static double maxOfTwo() {
return Math.max(Math.random(), Math.random());
}
/** P(返回值 < x) = x^3。 */
public static double maxOfThree() {
double first = Math.random();
double second = Math.random();
double third = Math.random();
return Math.max(first, Math.max(second, third));
}
/** 比较实验频率与理论概率;随机实验会有微小波动。 */
private static void verify(
String name,
DoubleSupplier generator,
double x,
double expectedProbability,
int testTimes) {
if (x < 0.0 || x > 1.0) {
throw new IllegalArgumentException("x 必须在 [0, 1] 范围内");
}
int count = 0;
for (int i = 0; i < testTimes; i++) {
if (generator.getAsDouble() < x) {
count++;
}
}
double actualProbability = (double) count / testTimes;
System.out.printf(
"%s:理论值=%.6f,实验值=%.6f,误差=%.6f%n",
name,
expectedProbability,
actualProbability,
Math.abs(expectedProbability - actualProbability));
}
}
为什么这样改
- 用
minOfTwo、maxOfTwo、maxOfThree代替含义不清的special、xToXPower2等命名; - 注释写成精确的
P(返回值 < x),避免把“随机数本身”和“累计概率”混为一谈; - 把重复的百万次实验提取为
verify,同时输出理论值、实验值和误差; - 使用
DoubleSupplier,让同一套验证逻辑可以测试不同随机生成函数。
随机实验的结果不会与理论值完全相等。样本数量越大,通常越接近理论值,但实验不能替代数学证明。
答案 3:把等概率 1~5 加工成等概率 1~7
问题说明
直接对 f() 的结果做取模不能完成目标:输入只有 5 种状态,无法在一次调用中公平映射到 7 种状态。可行路径是先制造公平比特,再用三个比特得到 8 种等概率状态,最后拒绝其中一种。
第一步:制造等概率比特
f() 的五种结果等概率。把它们分为:
f() 的结果 | 处理方式 | 输出 |
|---|---|---|
| 1、2 | 左组 | 0 |
| 3 | 无法配对,重新抽取 | — |
| 4、5 | 右组 | 1 |
在已经接受一次结果的前提下,输出 0 和 1 都对应两个原始结果,因此概率各为 1/2。
flowchart TD
A[调用 f] --> B{结果是什么?}
B -- 1 或 2 --> C[返回 0]
B -- 3 --> A
B -- 4 或 5 --> D[返回 1]
第二步:三个比特组成 0~7
三个独立比特共有 2^3 = 8 种组合,每种概率都是 1/8:
| 二进制 | 十进制 | 二进制 | 十进制 |
|---|---|---|---|
000 | 0 | 100 | 4 |
001 | 1 | 101 | 5 |
010 | 2 | 110 | 6 |
011 | 3 | 111 | 7 |
位运算表达式:
(randomBit() << 2) | (randomBit() << 1) | randomBit()
使用按位或 | 比加法更贴近“把比特放到不同位置”的真实含义;由于三个位互不重叠,两种写法的数值结果相同。
第三步:拒绝 0,保留 1~7
若把 0 直接改成 7,那么 7 会同时接收 000 和 111,概率变为 2/8,其他数字仍为 1/8,不再等概率。
正确做法是拒绝 0,整次重新生成。对任意 k(1 <= k <= 7):
flowchart LR
A[三个随机比特] --> B[得到 0 到 7]
B --> C{结果为 0?}
C -- 是 --> A
C -- 否 --> D[返回 1 到 7]
完整代码
package class02;
import java.util.Arrays;
/** 把等概率返回 1~5 的随机函数,加工成等概率返回 1~7。 */
public class Code03_EqualProbabilityRandom {
public static void main(String[] args) {
final int testTimes = 1_000_000;
int[] counts = new int[7];
for (int i = 0; i < testTimes; i++) {
counts[randomOneToSeven() - 1]++;
}
System.out.println("各数字出现次数:" + Arrays.toString(counts));
for (int i = 0; i < counts.length; i++) {
double frequency = (double) counts[i] / testTimes;
System.out.printf("%d -> %.6f%n", i + 1, frequency);
}
System.out.printf("理论概率 -> %.6f%n", 1.0 / 7.0);
}
/** 唯一的基础随机源:等概率返回 1、2、3、4、5。 */
public static int randomOneToFive() {
return (int) (Math.random() * 5) + 1;
}
/**
* 等概率返回 0 或 1。
* 1、2 映射为 0;4、5 映射为 1;遇到 3 时拒绝并重新抽取。
*/
public static int randomBit() {
int value;
do {
value = randomOneToFive();
} while (value == 3);
return value < 3 ? 0 : 1;
}
/** 使用三个独立随机比特,等概率返回 0~7。 */
public static int randomZeroToSeven() {
return (randomBit() << 2)
| (randomBit() << 1)
| randomBit();
}
/** 拒绝 0,等概率返回 1~7。 */
public static int randomOneToSeven() {
int value;
do {
value = randomZeroToSeven();
} while (value == 0);
return value;
}
}
正确性与效率
randomBit()每轮接受结果的概率为4/5,所以一定能结束的概率趋近于1;randomZeroToSeven()的八种结果均由唯一的三比特组合产生,所以各为1/8;randomOneToSeven()每轮接受概率为7/8,拒绝后不会改变剩余七种结果的相对概率;- 期望循环次数都是常数,因此生成一个结果的期望时间复杂度为 O(1),额外空间为 O(1)。
“期望 O(1)”不表示每次调用都有固定次数上限。理论上可能连续多次抽到被拒绝的值,但这种情况的概率会指数下降。
第三部分:统一验证与复盘
随机算法应该怎样验证
随机算法不能用“某一次输出等于期望值”的方式测试。更合适的方法分两层:
- 数学证明:证明每个目标结果的概率相同;
- 大量采样:检查实现是否明显偏离理论分布,帮助发现编码错误。
flowchart LR
A[先做概率推导] --> B[写随机生成器]
B --> C[大量重复采样]
C --> D[比较频率与理论值]
D --> E[检查偏差是否合理]
采样频率有波动是正常现象。不要写出“七个计数必须完全相等”这样的断言;在随机实验中,它几乎不会成立。
三道题的共同思想
| 题目 | 预处理或加工 | 单次结果 | 核心代价 |
|---|---|---|---|
| 前缀和 | 保存累计和 | 一次减法得到区间和 | O(n) 预处理空间 |
| 概率变换 | 组合独立采样 | 得到新的分布 | 更多随机调用 |
| 1~5 转 1~7 | 公平比特 + 拒绝采样 | 得到目标均匀分布 | 偶尔重新采样 |
它们看似不同,背后都在做同一件事:利用已经掌握的结构,换取后续操作更简单或更符合目标。
今日自测
1. 为什么 prefix 使用 n + 1 个元素?
查看答案
prefix[0] 表示空前缀。这样任意闭区间 [left, right] 都能统一写成 prefix[right + 1] - prefix[left],left = 0 时也无需特殊判断。
2. 两次取最大值后,为什么 P(结果 < x) = x^2?
查看答案
最大值小于 x,当且仅当两个独立随机数都小于 x。单次概率为 x,独立事件同时发生的概率为 x × x。
3. 两次取最小值后,为什么不是 x^2?
查看答案
最小值小于 x 只要求至少一个结果小于 x。用反面事件计算更简单:两次都不小于 x 的概率是 (1 - x)^2,所以答案是 1 - (1 - x)^2。
4. randomBit() 为什么必须拒绝数字 3?
查看答案
五个等概率结果无法无损分成数量相同的两组。拒绝 3 后,1、2 映射到 0,4、5 映射到 1,两边各包含两个等概率结果。
5. 为什么不能把生成的 0 直接映射成 7?
查看答案
这样 000 和 111 都会生成 7,使 7 的概率变为 2/8,而 1~6 仍是 1/8。拒绝 0 并重新采样,才能保持 1~7 的条件概率相同。
6. 随机实验中,七个计数不完全相等是否说明代码错误?
查看答案
不是。有限次随机采样必然存在波动。应检查频率是否在理论值 1/7 附近,并以概率证明作为正确性的核心依据。
加餐挑战
- 设计
rangeSum(left, right)的批量查询接口,一次接收多组区间; - 推导
n次随机数取最大值时的P(结果 < x); - 推导
n次随机数取最小值时的P(结果 < x); - 已有等概率返回
a~b的函数,设计通用方法生成等概率c~d; - 查阅卡方检验,思考如何用统计方法判断采样结果是否“异常偏离”均匀分布。
挑战 2、3 提示
最大值小于 x 要求 n 次都小于 x;最小值小于 x 可以先排除 n 次都不小于 x 的情况。
挑战 4 提示
先从原随机源制造等概率 0/1,再用足够多的随机比特覆盖目标范围,最后拒绝超出目标范围的状态。
Day 02 复盘清单
- 我能解释
prefix[right + 1] - prefix[left]的由来 - 我能说明前缀和适合“少修改、多查询”的场景
- 我能推导两次取最小值和最大值后的概率
- 我不会把随机变量的值与累计概率混为一谈
- 我能用拒绝采样制造等概率
0/1 - 我能把三个随机比特组合成
0~7 - 我能解释为什么拒绝
0后,1~7仍然等概率 - 我知道随机实验用于排错,但不能替代数学证明
一句话总结: 今天真正要修炼的,是用预处理消除重复计算,用事件关系看懂概率,再用拒绝采样把“已有随机性”加工成“目标随机性”。