按权重随机选择 图解题解
这道题到底在问什么
- w
- [1,3,2]
- 总权重
- 1+3+2 = 6
- 各下标概率
- 1/6, 3/6, 2/6
最优解:为什么这么做
一句话答案:LeetCode 528 按权重随机选择用前缀和 + 二分:把权重累加成前缀和当区间右端,掷一个随机数用 bisect_left 找它落进的区间下标,构造 O(n)、每次抽样 O(log n)、空间 O(n)。
按权重随机返回下标,pickIndex 要做到什么
给正整数数组 w,w[i] 是下标 i 的权重。pickIndex() 每次随机返回一个下标,返回 i 的概率等于 w[i] 除以权重之和。w=[1,3,2] 时概率是 1/6、3/6、2/6,权重 3 的下标 1 最常被抽中。
把权重摊成一个大数组,问题出在哪
直觉是按权重摊开:1 个 0、3 个 1、2 个 2,成 [0,1,1,1,2,2],均匀挑下标,抽样 O(1),但长度等于权重之和。权重上到几万,空间 O(总权重) 就把内存吃爆。得找不真摊开又按权重的办法。
不摊开数组,怎么还能按权重抽
看成一条长度 6 的数轴:下标 0 占 (0,1]、1 占 (1,4]、2 占 (4,6],每段长度正是那下标的权重,权重越大区间越宽。均匀掷一点,落进哪段就选哪个下标,等于按权重选。
边界用前缀和记(累加权重,pre[i] 为前 i+1 个的和):pre=[1,4,6],pre[i] 是下标 i 那段右端。合法的点是 1 到 6 这 6 个整数,掷出 target 就问它落进哪段。pre 升序,找落进的段即找第一个 ≥ target 的位置(bisect_left)。右端是闭区间,target 等于某 pre[i] 时该归下标 i。
bisect_left 在 pre 上怎么一步步夹出下标
bisect_left 找下界:lo、hi 圈候选,起初 lo=0、hi=n,答案在 [lo,hi)。取 mid=(lo+hi)//2,pre[mid] < target 就 lo=mid+1,否则 hi=mid(保留 mid、不减一,因为左边或许还有更小的也满足)。lo、hi 相撞即得下标。
手算 w=[1,3,2]:target=4、5 各夹几轮
前缀和 pre[0]=1,pre[1]=1+3=4,pre[2]=4+2=6,即 pre=[1,4,6],total=6。掷 target=4,lo=0、hi=3。第一轮 mid=(0+3)//2=1,pre[1]=4,4 < 4 不成立,hi=1,[0,1)。第二轮 mid=0,pre[0]=1 < 4,lo=1。lo=hi=1 停,返回 1,target=4 落在下标 1 的 (1,4]。
再掷 target=5,lo=0、hi=3。第一轮 mid=1,pre[1]=4 < 5,lo=2。第二轮 [2,3),mid=2,pre[2]=6 ≥ 5,hi=2,返回 2,target=5 落在 (4,6]。把 1 到 6 全掷一遍:1 点归下标 0、3 点归下标 1、2 点归下标 2,比例 1:3:2,正好是权重比。
随机数从 0 起、二分找成 >,概率就偏了
坑不在崩,而在悄悄算偏概率。target 要掷 [1,total],从 0 起会让多出的 0 挤进第一段,把下标 0 的概率抬高一个点;且必须用 bisect_left 找「第一个 ≥」,换成 bisect_right 找「第一个 >」,target 压在某 pre[i] 上时会算到下标 i+1,边界整段错位。两处都编译得过、跑得动,抽样几万次才看出偏了。
▶ 动画逐步走查(共 25 步)——想跟着动画一帧帧对照就展开
- 3最直觉的做法是「按权重摊开」:1 个 0、3 个 1、2 个 2 →[0,1,1,1,2,2],再均匀随机挑一个。思路对,但权重一大(几万)就会撑爆内存——得想个不真摊开的办法。
- 4不真摊开,而是想象一条长度为 6 的数轴:[1] 占 (0,1]、[1] 占 (1,4]、[2] 占 (4,6]。区间越长越容易被命中——这正好等于按权重选。找「点落在哪段」用前缀和记区间右端、用二分定位。
- 5w = [1, 3, 2]先把权重数组摆出来:[1, 3, 2]。接下来要把它累加成前缀和数组——也就是每个下标在数轴上的右端坐标。
- 6pre[0] = w[0] = 1新建一个前缀和数组 pre。第 0 格直接抄 w[0]:pre[0]=1。它表示下标 0 的区间右端 = 1,即区间 (0,1]。
- 7pre[1] = pre[0] + w[1] = 1+3第 1 格 = 前一格 pre[0]=1 加上 w[1]=3,得 pre[1]=4。下标 1 的区间右端 = 4,即区间 (1,4],长度正好是它的权重 3。
- 8pre[2] = pre[1] + w[2] = 4+2第 2 格 = pre[1]=4 加上 w[2]=2,得 pre[2]=6。这也是总权重。下标 2 占区间 (4,6],长度 2。前缀和数组建好了:[1,4,6]。
- 9pre = [1,4,6],总长 6现在 pre=[1,4,6] 就是三段区间的右端坐标。把整条数轴想成 1 到 6 的 6 个整数点,每个点落在哪段、就选那个下标——点越多的段(权重越大)越容易中。
- 10每次抽样:先掷一个 1~6 的随机数 target,然后在有序的 pre 里二分找第一个 ≥ target 的位置——这就是 target 这个点落进的区间下标。下面拿 target=4 演一遍二分。
- 11target = 4(随机落在区间 (1,4])假设这次随机掷出 target=4。它落在数轴的哪一段?用二分在 pre=[1,4,6] 上找第一个 ≥ 4 的格子。
- 12lo=0, hi=n=3二分找下界:lo 指 0、hi 指 3(= n,数组外一格)。答案下标就夹在 [lo, hi) 之间,慢慢往中间收。
- 13mid = (0+3)/2 = 1mid 落在下标 1。看 pre[mid]=pre[1]=4 与 target=4 比一比,决定往哪边收。
- 14pre[1]=4 ≥ target=4 → 收右界pre[1]=4 ≥ target=4,说明下标 1 够大、可能就是答案,但还要看左边有没有更小的也满足,所以 hi=mid=1(保留 mid 自己,故不是 mid−1)。
- 15hi → 1,排除下标 1 右边hi 收到 1,下标 2 变灰出局——它的右端 6 比 4 大没错,但更靠右、不会是第一个满足的。范围缩成 [0,1)。
- 16mid = (0+1)/2 = 0新范围 [0,1) 里取中点,mid 落到下标 0。再比一次 pre[0] 和 target。
- 17pre[0]=1 < target=4 → 收左界pre[0]=1 < target=4,下标 0 的区间右端才到 1,装不下 target=4。它出局(变灰),lo=mid+1=1。
- 18lo == hi,停!下标 = 1lo 和 hi 撞到一起停下,命中下标 1。target=4 确实落在区间 (1,4] 里——pickIndex() 这次返回 1。
- 19target=4 ∈ (pre[0]=1, pre[1]=4]复核一眼:4 比 pre[0]=1 大、又 ≤ pre[1]=4,正好卡在下标 1 的区间 (1,4] 里。返回 1 无误。
- 20target=5 → 重新二分换个随机数 target=5 再演一遍,体会二分每次怎么收。lo=0, hi=3 重新开局。
- 21mid=(0+3)/2=1, pre[1]=4 < 5mid 落下标 1,pre[1]=4 < target=5,下标 1 的区间右端才到 4、装不下 5。下标 1 出局,lo 跳到 2。
- 22lo=2,hi=3 → mid=2, pre[2]=6 ≥ 5范围缩到 [2,3),mid 落下标 2,pre[2]=6 ≥ 5,下标 2 够大,hi 收到 2。
- 23lo == hi == 2,停!lo、hi 撞在下标 2 停下,命中下标 2。target=5 落在区间 (4,6],返回 2。
- 24target=1 → 第一个 pre[i]≥1 是下标 0再掷一个 target=1:第一个 ≥ 1 的是 pre[0]=1,命中下标 0。6 个点里只有 1 落进 0 的区间——所以下标 0 概率 1/6,和权重对上。
- 25target=6 → 第一个 pre[i]≥6 是下标 2再掷 target=6:第一个 ≥ 6 的是 pre[2]=6,命中下标 2。点 5、6 都落进 2 的区间(长度 2),概率 2/6——区间越长越容易中,正是按权重。
- 261→0 ┆ 2,3,4→1 ┆ 5,6→2把 1~6 全掷一遍:1 个点归下标 0、3 个点归下标 1、2 个点归下标 2,比例 1:3:2 正好是权重比。前缀和把权重变成了区间长度,二分让每次抽样只花 O(log n)。
- 31「带权随机」「按频率采样」这类问题都能往这个模板靠:前缀和把离散权重摊成连续数轴,二分把一次采样压到对数级。是离线采样的经典套路。
⚠️ 容易写错的地方
✗ 错:target 取 [0, total]
✓ 对:target ∈ [1, total]
前缀和是区间右端(闭);取 0 会让第一段被算多一个点,概率失真
✗ 错:二分用 bisect_right / 找 >
✓ 对:找第一个 ≥(lower_bound / bisect_left)
区间右端是闭的,target 等于某个 pre[i] 时应归到下标 i,不是 i+1
完整代码(Python / C++ / Java)
Python
import random, bisect
class Solution:
def __init__(self, w):
self.pre = []
s = 0
for x in w: # 构建前缀和
s += x
self.pre.append(s)
self.total = s
def pickIndex(self):
target = random.randint(1, self.total) # [1, total]
return bisect.bisect_left(self.pre, target) # 第一个 ≥C++
class Solution {
vector<int> pre;
public:
Solution(vector<int>& w) {
int s = 0;
for (int x : w) { s += x; pre.push_back(s); } // 前缀和
}
int pickIndex() {
int target = rand() % pre.back() + 1; // [1, total]
return lower_bound(pre.begin(), pre.end(), target) - pre.begin();
}
};Java
class Solution {
private int[] pre;
private java.util.Random rnd = new java.util.Random();
public Solution(int[] w) {
pre = new int[w.length];
int s = 0;
for (int i = 0; i < w.length; i++) { // 前缀和
s += w[i];
pre[i] = s;
}
}
public int pickIndex() {
int target = rnd.nextInt(pre[pre.length - 1]) + 1; // [1, total]
int lo = 0, hi = pre.length;
while (lo < hi) { // 找第一个 ≥
int mid = lo + (hi - lo) / 2;
if (pre[mid] >= target) hi = mid;
else lo = mid + 1;
}
return lo;
}
}复杂度
构建时间
O(n)
一遍遍历累加出前缀和数组
每次抽样
O(log n)
在有序前缀和上二分找下界
空间复杂度
O(n)
存一份前缀和数组
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 按权重随机选择 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么用前缀和 + 二分,而不是直接摊开数组?+
摊开成 [0,1,1,1,2,2] 这种数组,长度等于所有权重之和,抽样确实 O(1),但权重上到几万几十万时空间 O(总权重) 会爆。前缀和只存 n 个数,空间 O(n),每次抽样在有序数组上二分 O(log n),时间空间都省。代价只是抽样从一次直接取数变成一次二分,对频繁调用完全划算。
二分到底在哪个数组上找、找什么?+
找的对象是有序的前缀和 pre,不是原权重数组。掷出随机数 target 后,要的是「第一个 ≥ target 的 pre 下标」,也就是 target 这个点落进的区间。因为 pre 是权重累加出来的、天然升序,才能二分;找的是下界(lower_bound / bisect_left),不是随便一个 ≥ target 的位置。
随机数为什么掷 [1, total],从 0 开始不行吗?+
前缀和记的是每段区间的右端,而且右端是闭的,所以数轴上合法的点是 1 到 total 这 total 个整数,每个下标占的点数正好是它的权重。从 0 开始会多出一个点 0,它会被 bisect_left 算进第一段,让下标 0 白多一份概率,整体比例就不再是 w[i]/total 了。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 按权重随机选择 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。