← 中文资源

简体中文版本

加权随机选择

用一次随机抽样按权重选择元素,拒绝负数与全零权重,并统计一万次抽样的实际分布。

阅读英文原文

功能说明

灰度发布、A/B 实验、负载分配常需要按比例选择:百分之一的流量进入金丝雀,百分之三十走稳定版本,其余走旧版本。加权随机只需要生成一个零到总权重之间的随机数,再顺序累减各选项权重,落在哪个区间就选中哪个,时间复杂度与选项数成正比。

示例用权重一、三、六的三项做一万次抽样,期望比例分别是百分之十、百分之三十和百分之六十,打印实际命中次数与百分比,便于确认实现正确。

关键代码

Pick 先校验选项非空、权重非负且总和为正,避免非法配置被静默接受。随机数用传入的 rand.Rand,便于固定种子复现。循环中若目标值小于当前权重即命中;否则减去当前权重继续。由于浮点误差,循环末尾兜底返回最后一项,保证函数总有结果。main 固定种子四十二,因此每次运行输出完全一致。

使用注意

math/rand 不适合安全场景,抽奖或令牌应使用 crypto/rand。权重为浮点数时可能出现极小概率的偏差,要求严格比例时可用整数权重。零权重选项永远不会被选中,但仍参与遍历。选项很多且调用频繁时,可以预计算累积权重并二分查找,把每次选择的复杂度降为对数级。修改切片后需要重新计算总权重。

验证方式

一万次抽样后三项的命中次数应接近一千、三千、六千,打印的百分比约为 10.0、30.0、60.0。负数权重会返回错误,输出提示为 true。把种子换成其他值,计数会变化但比例保持稳定;把某一项权重改为零,它的计数应保持为零。

代码示例

package main

import (
	"errors"
	"fmt"
	"math/rand"
)

type Choice[T any] struct {
	Item   T
	Weight float64
}

func Pick[T any](r *rand.Rand, choices []Choice[T]) (T, error) {
	var zero T
	if len(choices) == 0 {
		return zero, errors.New("no choices")
	}
	total := 0.0
	for _, choice := range choices {
		if choice.Weight < 0 {
			return zero, fmt.Errorf("negative weight for %v", choice.Item)
		}
		total += choice.Weight
	}
	if total <= 0 {
		return zero, errors.New("total weight must be positive")
	}
	target := r.Float64() * total
	for _, choice := range choices {
		if target < choice.Weight {
			return choice.Item, nil
		}
		target -= choice.Weight
	}
	return choices[len(choices)-1].Item, nil
}

func main() {
	rng := rand.New(rand.NewSource(42))
	choices := []Choice[string]{
		{Item: "canary", Weight: 1},
		{Item: "stable", Weight: 3},
		{Item: "legacy", Weight: 6},
	}
	counts := map[string]int{}
	for i := 0; i < 10000; i++ {
		item, err := Pick(rng, choices)
		if err != nil {
			panic(err)
		}
		counts[item]++
	}
	for _, choice := range choices {
		fmt.Printf("%s weight=%.0f picked=%d (%.1f%%)\n", choice.Item, choice.Weight, counts[choice.Item], float64(counts[choice.Item])/100)
	}
	_, err := Pick(rng, []Choice[string]{{Item: "bad", Weight: -1}})
	fmt.Println("negative weight rejected:", err != nil)
}