目录

题目描述

LCR 071. 按权重随机选择

题意分析

给一个正整数权重数组 w,实现 pickIndex(),让它返回下标 i 的概率恰好是 w[i] / sum(w)

这是一道把概率问题转成区间问题的设计题。要求的是「按权重成比例地抽样」,而语言内置的随机源只能提供「在一段整数区间上均匀抽样」。两者之间的桥梁就是:把长度为 sum(w) 的整数区间按权重切成 $n$ 段,第 i 段的长度是 w[i],那么均匀落点落进第 i 段的概率天然就是 w[i] / sum(w)

于是 pickIndex 的实际任务变成:均匀抽一个落点,然后回答「它落在第几段」。后者是一个查找问题,而段的边界由前缀和给出且天然有序,正好用二分。

约束里 w 的长度可达 $10^4$、pickIndex 的调用次数可达 $10^4$,权重之和可达 $10^8$。这说明:预处理可以是 $O(n)$,但单次查询必须做到 $O(\log n)$;而按权重把下标展开成一个长度为 sum(w) 的大数组则会占用 $10^8$ 级内存,不可行。

边界上要注意:权重全为正,不存在长度为 0 的段,所以每个下标都有非零概率;数组长度可能为 1,此时永远返回 0;随机落点的取值范围必须严格覆盖 [1, sum][0, sum-1],多一个少一个都会让首尾两段的概率失真。

解法:二分查找判定答案

核心思路

最朴素的做法是把下标按权重展开:w = [2, 3] 就构造数组 [0, 0, 1, 1, 1],然后均匀随机取一个位置返回其中的值。概率完全正确,pickIndex 也只要 $O(1)$,但空间是 $O(\sum w)$,在权重和达到 $10^8$ 时直接爆内存。

瓶颈在于它把「每一份权重」都物化成了一个数组元素,而实际上同一个下标占据的是一段连续区间,完全不必逐格存储,只需记住每段的端点。

观察到:若令 presum[i] 表示前 i 个权重之和(presum[0] = 0),那么下标 i 对应的区间就是 (presum[i], presum[i+1]],长度恰好是 w[i]。这些区间首尾相接、覆盖 [1, sum] 且互不重叠——这正是一个完整的划分。

于是抽样过程分两步:先在 [1, sum] 上均匀取一个整数 x,再找出唯一满足 presum[i] < x <= presum[i+1]i。由于 presum 严格递增(权重全正),这个 i 等价于「第一个满足 presum[i+1] >= xi」,也就是一个标准的左边界二分。

维持的不变量是:答案始终落在 [left, right] 内;left 左侧的所有下标其区间上界都小于 x(落点在它们右边),right 及其右侧的下标其区间上界都不小于 x。判定为真时保留 midright = mid),为假时排除(left = mid + 1),收敛后的 left 即为落点所属的段。

概率的正确性由此闭合:x 均匀落在 [1, sum]sum 个整数上,其中恰有 w[i] 个会被二分归到下标 i,故 P(i) = w[i] / sum

解题步骤

  • 构造函数中一次性算好前缀和数组,长度取 $n + 1$,presum[0] = 0presum[i+1] = presum[i] + w[i]。多留一个 0 作为哨兵,可以让每段的表达式统一成 (presum[i], presum[i+1]],省掉对第 0 段的特判。
  • 预处理放在构造函数而不是 pickIndex 里。pickIndex 会被调用上万次,把 $O(n)$ 的累加摊到每次查询上就变成了 $O(n)$ 单次代价,白白浪费了「一次建表多次查询」的结构。
  • 每次 pickIndex 先取随机落点 x,范围是 [1, sum],其中 sum = presum[n-1](数组末元素)。取 [1, sum] 的闭区间是为了与「段的右端点」对齐,落点为 presum[i+1] 时应当归属下标 i
  • 在下标区间 [0, n-2] 上二分(此处 n 是前缀和数组的长度,故实际下标上界是 w.length - 1)。判定条件是 presum[mid + 1] >= x,即「第 mid 段的右端点是否已经够到落点」。
  • 判定为真时 right = mid,因为 mid 自己就可能是答案;为假时 left = mid + 1,因为第 mid 段整段都在落点左侧,绝无可能。
  • 循环条件 left < right,退出时 left == right,直接返回 left,就是落点所在的段号,也就是要抽中的下标。

w = [1, 3] 走一遍:构造时 presum = [0, 1, 4],总权重 4,二分范围是下标 [0, 1]。若随机落点 x = 1mid = 0,判定 presum[1] = 1 >= 1 为真,right = 0left == right == 0,返回 0。若 x = 2mid = 0presum[1] = 1 >= 2 为假,第 0 段整段在落点左侧,left = 1,返回 1。若 x = 3x = 4:同理均返回 1。四个等概率的落点里,只有 x = 1 归到下标 0,另外三个归到下标 1,于是 P(0) = 1/4P(1) = 3/4,与权重比 1 : 3 完全一致。

代码实现

class Solution {
    private int[] presum;

    public Solution(int[] w) {
        int n = w.length;
        presum = new int[n + 1];
        for (int i = 0; i < n; ++i) {
            presum[i + 1] = presum[i] + w[i];
        }
    }

    public int pickIndex() {
        int n = presum.length;
        int x = (int) (Math.random() * presum[n - 1]) + 1;
        int left = 0, right = n - 2;
        while (left < right) {
            int mid = (left + right) >> 1;
            if (presum[mid + 1] >= x) {
                right = mid;
            } else {
                left = mid + 1;
            }
        }
        return left;
    }
}
type Solution struct {
    presum []int
}

func Constructor(w []int) Solution {
    n := len(w)
    pre := make([]int, n+1)
    for i := 0; i < n; i++ {
        pre[i+1] = pre[i] + w[i]
    }
    return Solution{pre}
}

func (this *Solution) PickIndex() int {
    n := len(this.presum)
    x := rand.Intn(this.presum[n-1]) + 1
    left, right := 0, n-2
    for left < right {
        mid := (left + right) >> 1
        if this.presum[mid+1] >= x {
            right = mid
        } else {
            left = mid + 1
        }
    }
    return left
}

复杂度分析

  • 时间复杂度:构造函数 $O(n)$,一次线性累加得到前缀和;pickIndex 单次 $O(\log n)$,一次随机取值加一轮左边界二分,与权重的绝对大小无关。
  • 空间复杂度:$O(n)$,只存了长度为 $n + 1$ 的前缀和数组,避免了按权重展开所需的 $O(\sum w)$ 空间。

关键点总结

  • 「按权重抽样」的通用转化是「把权重铺成连续区间,再对均匀落点做区间定位」。这一步转化把概率问题变成纯粹的查找问题,之后就没有随机性可言了。
  • 前缀和把「每份权重占一格」压缩成「每段只记两个端点」,是从 $O(\sum w)$ 空间降到 $O(n)$ 的关键;同时它天然递增,直接为二分提供了有序性。
  • 落点区间与段区间必须严格对齐。用左开右闭的段配 [1, sum] 的落点,或用左闭右开的段配 [0, sum-1] 的落点,两套都对,但不能混用,否则首尾两段的概率会偏移。
  • 一次预处理、多次查询是设计类题目的常见结构。判断哪些计算该放构造函数、哪些放查询接口,依据是「查询次数量级」。
  • 二分求的是「第一个右端点不小于落点的段」,仍然是标准左边界模板:满足条件保留 mid,不满足排除 mid。判据换了,规则不变。
  • 面试视角:正确性论证要主动说出来——「xsum 个整数上均匀分布,落进第 i 段的整数恰好有 w[i] 个,故概率为 w[i]/sum」。随机化题目里,能把概率算清楚比写出代码更能体现水平。
  • 面试视角:常见追问是「能不能做到 $O(1)$ 查询」。答 Alias Method(别名采样)可以在 $O(n)$ 预处理后做到 $O(1)$ 抽样,思路是把所有段重排成 $n$ 个等长的桶、每桶最多两种下标;点到这个名字并说清桶的构造思想即可,通常不要求手写。

易错点总结

  • 错误写法:随机落点取 Math.random() * sum 后不加 1,即范围为 [0, sum-1],却仍用左开右闭的段划分。用例 w = [1, 3]x = 0 时二分找第一个 presum[i+1] >= 0,恒为下标 0,下标 0 的概率变成 2/4,正确应为 1/4。
  • 错误写法:把前缀和累加放进 pickIndex,每次调用重算一遍。用例 n = 10000 且调用 $10^4$ 次 → 总代价 $10^8$,超时;预处理必须只做一次。
  • 错误写法:二分右端取 n - 1(前缀和数组的末下标)。用例 w = [1, 3] → 候选包含哨兵之后的非法段号,判定式访问 presum[n] 越界,或返回等于 w.length 的非法下标。
  • 错误写法:判定条件写成 presum[mid] >= x,忘了偏移。用例 w = [1, 3]x = 1presum[0] = 0 >= 1 为假、presum[1] = 1 >= 1 为真,收敛到下标 1,正确答案是 0;段 i 的右端点是 presum[i+1] 而不是 presum[i]
  • 错误写法:判定写成严格大于 presum[mid + 1] > x。用例 w = [1, 3]x = 1 → 第 0 段的右端点 1 不满足严格大于,落点被推给下标 1,下标 0 的概率变成 0。
  • 错误写法:满足条件时写 right = mid - 1。用例 w = [1, 3]x = 1 → 正确答案下标 0 被排除,返回值恒为 1,下标 0 永远抽不到。
  • 错误写法:不满足条件时写 left = mid。用例 w = [1, 3]x = 4mid 恒等于 left,区间不收缩,死循环。
  • 错误写法:前缀和用 int 存但先乘后除做归一化,或按浮点比例比较。用例 权重和达 $10^8$ 且用浮点比较 → 浮点精度误差让边界附近的落点归错段,概率出现肉眼不可见但统计上显著的偏差;整数落点配整数前缀和才是精确的。
  • 错误写法:直接把下标按权重展开成数组再随机取。用例 w = [100000000] → 需要 $10^8$ 个元素的数组,内存溢出;正是前缀和要规避的那条路。

相似题目

题目 难度 考察点
528. 按权重随机选择 中等 与本题同题,可用来对照前缀和二分与别名采样两种抽样方案
497. 非重叠矩形中的随机点 中等 权重变成矩形面积,选中矩形后还要在矩形内做二维均匀抽样
398. 随机数索引 中等 目标是在等概率的重复下标中抽样,考察蓄水池而非区间划分
35. 搜索插入位置 简单 剥离随机性后剩下的正是同一个左边界二分模板
34. 在排序数组中查找元素的第一个和最后一个位置 中等 同样在有序数组上定位区间端点,但要同时求下界与上界
704. 二分查找 简单 最基础的有序查找,可用来对照闭区间与左闭右开两套写法
1011. 在 D 天内送达包裹的能力 中等 二分的对象从下标换成答案值域,判定条件需要自己构造