百日算法修炼 · Day 02

0 阅读15分钟

百日算法修炼 · 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随机数概率变换用事件关系推导分布
31~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 相加时溢出。

思考问题:

  1. 每次查询都从 left 累加到 right,一次和多次查询分别需要多少时间?
  2. 能否预先保存“前 i 个元素的和”,让一次查询只做一次减法?
  3. 为什么前缀和数组设计成 n + 1 个元素,会比 n 个元素更自然?
  4. 如果输入下标不合法,应该返回特殊值,还是立即抛出异常?
提示 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。独立调用若干次后,只允许使用 minmax 组合结果。

请实现并推导:

  1. 两次随机数取最小值;
  2. 两次随机数取最大值;
  3. 三次随机数取最大值。

我们不只关心函数“返回什么”,还关心对任意 x0 <= x <= 1):

P(返回值<x)P(返回值 < x)

思考问题:

  1. max(a, b) < x 时,ab 分别要满足什么条件?
  2. min(a, b) < x 的反面事件是什么?
  3. 为什么三次调用必须相互独立,乘法公式才能成立?
  4. 如何用一百万次实验,验证推导出的理论概率?
提示 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;
}

思考问题:

  1. 能否先把 f() 加工成等概率返回 01 的函数?
  2. 1~5 有奇数个结果,怎样把它们公平地分成两组?
  3. 三个独立的随机二进制位能等概率表示哪些整数?
  4. 得到 0~7 后,为什么不能直接把 0 改成 7
  5. 若抽到不需要的结果,怎样处理才能不破坏其他结果的等概率性?
提示 1:先丢掉无法配对的中间值

抽到 1、2 时返回 0,抽到 4、5 时返回 1;抽到 3 就重新抽。两组各有两个等概率结果。

提示 2:三个比特可以表示八种状态

把三个独立比特依次放到二进制的第 2、1、0 位,可等概率得到 000~111,也就是 0~7。

提示 3:拒绝采样

如果抽到 0,就放弃整次结果并重新生成。剩余 1~7 的条件概率仍然相同。

写完后再看:查看练习 3 参考答案


第二部分:参考答案与图解

到这里再对照答案。重点检查变量含义、事件等价关系和边界处理,而不只是比较代码是否相同。

答案 1:多次查询数组区间和

返回练习 1

问题说明

直接遍历能正确回答一次查询,但当查询很多时,相同位置会被重复累加。前缀和的核心是:先花 O(n) 保存累计结果,再让每次查询降到 O(1)

核心思路

定义:

prefix[i]=arr[0]+arr[1]++arr[i1]prefix[i] = arr[0] + arr[1] + \cdots + arr[i-1]

因此 prefix 的长度是 arr.length + 1,且 prefix[0] = 0。闭区间 [left, right] 的和为:

sum(left,right)=prefix[right+1]prefix[left]sum(left,right)=prefix[right+1]-prefix[left]

多出来的 prefix[0] 是“空前缀”,它让 left = 0 时仍能使用同一个公式。

过程图解

arr = [-2, 4, 1, 3, -1] 为例:

下标012345
prefix0-22365
含义空前缀前 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 长度为 nprefix 长度为 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:用随机数的最值改变概率

返回练习 2

问题说明

这里的“概率变换”不是修改 Math.random() 本身,而是组合多次独立采样,让新随机变量的累计概率 P(Y < x) 呈现不同曲线。

先推导,再写代码

设每次采样得到的随机变量为 U。由于 U[0, 1) 上均匀分布:

P(U<x)=x,P(Ux)=1xP(U<x)=x, \qquad P(U\ge x)=1-x
两次取最大值

max(U1, U2) < x 要求两次结果都小于 x

P(max(U1,U2)<x)=x×x=x2P(\max(U_1,U_2)<x)=x \times x=x^2
三次取最大值

同理,三个结果必须全部小于 x

P(max(U1,U2,U3)<x)=x3P(\max(U_1,U_2,U_3)<x)=x^3
两次取最小值

直接数“至少一个小于 x”容易遗漏重叠情况,因此先计算反面事件:两次都不小于 x

P(min(U1,U2)<x)=1P(U1x,U2x)=1(1x)2P(\min(U_1,U_2)<x) =1-P(U_1\ge x,U_2\ge x) =1-(1-x)^2

事件关系图

flowchart TD
    A[组合两个独立随机数] --> B{取最大值还是最小值?}
    B -- 最大值小于 x --> C[两次都小于 x]
    B -- 最小值小于 x --> D[排除两次都不小于 x]
    C --> E[概率 x 的平方]
    D --> F[概率 1 减去 1-x 的平方]

x = 0.6 为例:

新随机变量 YP(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));
    }
}

为什么这样改

  • minOfTwomaxOfTwomaxOfThree 代替含义不清的 specialxToXPower2 等命名;
  • 注释写成精确的 P(返回值 < x),避免把“随机数本身”和“累计概率”混为一谈;
  • 把重复的百万次实验提取为 verify,同时输出理论值、实验值和误差;
  • 使用 DoubleSupplier,让同一套验证逻辑可以测试不同随机生成函数。

随机实验的结果不会与理论值完全相等。样本数量越大,通常越接近理论值,但实验不能替代数学证明。


答案 3:把等概率 1~5 加工成等概率 1~7

返回练习 3

问题说明

直接对 f() 的结果做取模不能完成目标:输入只有 5 种状态,无法在一次调用中公平映射到 7 种状态。可行路径是先制造公平比特,再用三个比特得到 8 种等概率状态,最后拒绝其中一种。

第一步:制造等概率比特

f() 的五种结果等概率。把它们分为:

f() 的结果处理方式输出
1、2左组0
3无法配对,重新抽取
4、5右组1

在已经接受一次结果的前提下,输出 01 都对应两个原始结果,因此概率各为 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

二进制十进制二进制十进制
00001004
00111015
01021106
01131117

位运算表达式:

(randomBit() << 2) | (randomBit() << 1) | randomBit()

使用按位或 | 比加法更贴近“把比特放到不同位置”的真实含义;由于三个位互不重叠,两种写法的数值结果相同。

第三步:拒绝 0,保留 1~7

若把 0 直接改成 7,那么 7 会同时接收 000111,概率变为 2/8,其他数字仍为 1/8,不再等概率。

正确做法是拒绝 0,整次重新生成。对任意 k1 <= k <= 7):

P(结果=k结果0)=1/87/8=17P(结果=k \mid 结果\ne0) =\frac{1/8}{7/8} =\frac17
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)”不表示每次调用都有固定次数上限。理论上可能连续多次抽到被拒绝的值,但这种情况的概率会指数下降。


第三部分:统一验证与复盘

随机算法应该怎样验证

随机算法不能用“某一次输出等于期望值”的方式测试。更合适的方法分两层:

  1. 数学证明:证明每个目标结果的概率相同;
  2. 大量采样:检查实现是否明显偏离理论分布,帮助发现编码错误。
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 附近,并以概率证明作为正确性的核心依据。


加餐挑战

  1. 设计 rangeSum(left, right) 的批量查询接口,一次接收多组区间;
  2. 推导 n 次随机数取最大值时的 P(结果 < x)
  3. 推导 n 次随机数取最小值时的 P(结果 < x)
  4. 已有等概率返回 a~b 的函数,设计通用方法生成等概率 c~d
  5. 查阅卡方检验,思考如何用统计方法判断采样结果是否“异常偏离”均匀分布。
挑战 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 仍然等概率
  • 我知道随机实验用于排错,但不能替代数学证明

一句话总结: 今天真正要修炼的,是用预处理消除重复计算,用事件关系看懂概率,再用拒绝采样把“已有随机性”加工成“目标随机性”。