深入解析 lo 库it.Samples:基于 Go 1.23 迭代器的随机不重复采样
【免费下载链接】lo💥 A Lodash-style Go library based on Go 1.18+ Generics (map, filter, contains, find...)项目地址: https://gitcode.com/GitHub_Trending/lo/lo
导读
it.Samples是 lo 库迭代器子包(it)中用于随机不重复采样的泛型函数:从任意iter.Seq[T]序列中取出 N 个互不重复的随机元素,并保持原始迭代器类型返回(I ~func(func(T) bool))。本文以 docs/data/it-samples.md 为主线,结合 it/find.go 的源码实现与 it/find_test.go 的测试用例,系统讲解其签名、行为边界、底层算法(小采样率下的“置换映射”优化与大采样率下的“交换-截断”洗牌)、性能注意点,并给出可直接运行的完整示例。读完你将能在一行代码内完成任意 Go 序列的随机抽样,并清楚何时该改用it.Sample/it.SamplesBy。
一、函数签名与核心语义
func SamplesT any, I ~func(func(T) bool) I- 入参
collection I:Go 1.23 引入的迭代器类型func(func(T) bool)及其定义类型(~表示底层类型相同即可),这正是it包与核心lo包(接收 slice)的最大区别——输入输出都是惰性序列; - 入参
count int:希望抽取的元素个数; - 返回值
I:与入参相同类型的迭代器,即func(func(T) bool),由I(slices.Values(seq))包装转换而来(it/find.go)。
文档明确定义其语义为"Returns N random unique items from collection"——返回 N 个随机且不重复的元素。
从源码结构看调用链
it/find.go 中Samples只是薄封装,真正的逻辑委托给SamplesBy:
func SamplesT any, I ~func(func(T) bool) I { return SamplesBy(collection, count, xrand.IntN) }而SamplesBy的内部实现分为两步(it/find.go):
func SamplesByT any, I ~func(func(T) bool) int) I { slice := slices.Collect(iter.SeqT) seq := lo.SamplesBy(slice, count, randomIntGenerator) return I(slices.Values(seq)) }slices.Collect立即完整遍历输入序列并物化为切片(因此序列的惰性仅体现在“采样是最后一步”上,输入侧已被完全消费);- 复用核心包
lo.SamplesBy(find.go)完成采样,再将结果切片用slices.Values还原成与入参同类型的迭代器。
类型约束I ~func(func(T) bool)意味着自定义的迭代器定义类型也会被完整保留,测试 it/find_test.go 用type myStrings iter.Seq[string]验证了这一行为(is.IsType(nonempty, allStrings, "type preserved"))。
二、行为边界:六种典型场景
原文档给出了完整的行为矩阵,可归纳为以下六类:
| 场景 | 输入 | 结果 |
|---|---|---|
| 正常抽样 | []int{1..10},count=3 | 3 个随机不重复数字 |
count等于集合大小 | []int{1..5},count=5 | 全部 5 个元素、随机顺序 |
count大于集合大小 | []int{1,2,3},count=10 | 全部 3 个元素、随机顺序 |
count为 0 | []int{1..5},count=0 | 空序列 |
count为负数 | []int{1..5},count=-1 | 空序列 |
| 空集合 | []int{}, 任意count | 空序列 |
前两类场景的源码级依据在核心实现 find.go:
if count <= 0 { return Slice{} // 0 或负数 → 空切片 } size := len(collection) if size < count { count = size // count 超过集合大小 → 收敛为 size(即全部元素) } results := make(Slice, count)测试 it/find_test.go 与文档一一对应:zero count期望空结果、negative count with nil keyFn期望空结果、random selection(n=3)通过ElementsMatch断言与输入元素集合完全一致(不重复、数量正确),并额外覆盖了空输入与自定义随机生成器。
三、底层算法:两种采样策略与渐进复杂度
lo.SamplesBy是本文主题最值得深挖的实现细节,它在 find.go 中针对不同采样率采用了两条路径,且注释明确说明两条路径"consume the random generator and select elements identically"。
3.1 小采样率路径:置换映射(count <= size/16)
当只需抽取一小部分元素时(count <= size/16),实现避免物化整个索引排列,仅维护一张大小与count成正比的displaced map[int]int(find.go):
displaced := make(map[int]int, count) for i, n := 0, size; i < count; i, n = i+1, n-1 { index := randomIntGenerator(n) j, ok := displaced[index] if !ok { j = index } results[i] = collection[j] // Removes index: swap it with the last (virtual) element. last, ok := displaced[n-1] if !ok { last = n - 1 } displaced[index] = last }这是经典的partial Fisher–Yates技巧:每次从[0, n)随机取一个索引,取走后把"最后一个虚拟索引"置换到该位置,保证下次抽取绝不重复。时间和内存都与count成正比,而非size——对超大序列抽少量样本时内存占用从 O(size) 降到 O(count)。
3.2 大采样率路径:完整索引排列 + 交换截断
当采样比例较高(count > size/16)时,实现物化完整索引数组并执行交换-截断(find.go):
indexes := Range(size) for i := range results { n := len(indexes) index := randomIntGenerator(n) results[i] = collection[indexes[index]] // Removes index. // It is faster to swap with last element and remove it. indexes[index] = indexes[n-1] indexes = indexes[:n-1] }标准 Fisher–Yates 洗牌:抽中元素后与末尾交换并缩短切片,保证后续抽样不重复。时间和内存为 O(size),与count无关,适合抽样比例较高的场景。
3.3 阈值size/16的含义
从源码结构看,size/16是两条路径的分水岭:低于该比例时,displaced映射(平均负载较低)比维护完整indexes切片更省内存;高于该比例时,map 的哈希开销反而不如直接洗牌索引数组。这是典型的以抽样率为依据的自适应算法,也是it.Samples与朴素"循环随机去重"实现的关键性能差异。
四、时间复杂度与内存注意点(务必阅读)
原文档用两句话强调了该函数最重要的工程特性:
Will iterate through the entire sequence and allocate a slice large enough to hold all elements. Long input sequences can cause excessive memory usage.
- 必须完整遍历输入序列:
slices.Collect会一次性消费全部元素,无论count多小,输入序列都会被完全物化; - 内存峰值取决于序列大小:
slice := slices.Collect(...)这一行(it/find.go)就持有整个集合的副本,之后核心采样再分配results(大采样率路径还额外持有indexes)。因此长输入序列可能造成过度内存占用,无限/无界生成器(如it.Range无限版本)绝不能直接传给Samples; - 适用前提:输入必须是有限序列且可整体放入内存。若集合巨大,建议优先考虑基于流的抽样(如蓄水池算法),本项目未提供对应的流式变体。
五、完整可运行示例
以下示例来自原文档并补齐了it.Slice的包装细节(注意it.Slice是 it/seq.go 中返回迭代器的切片转换函数,签名是Slice(collection, start, end),因此文中均为it.Slice(xs)形式):
package main import ( "fmt" "github.com/samber/lo/it" ) func main() { // 1. 从 1-10 中随机取 3 个不重复元素 numbers := it.Slice([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) samples := it.Samples(numbers, 3) for v := range samples { fmt.Println(v) // 3 个 1-10 之间的随机不重复数字 } // 2. count 等于集合大小 → 全部元素随机排序 numbers2 := it.Slice([]int{1, 2, 3, 4, 5}) for v := range it.Samples(numbers2, 5) { fmt.Println(v) // 5 个数字的随机排列 } // 3. 字符串序列 words := it.Slice([]string{"apple", "banana", "cherry", "date", "elderberry"}) for w := range it.Samples(words, 2) { fmt.Println(w) // 2 个随机不重复单词 } // 4. 结构体序列 type Person struct { Name string Age int } people := it.Slice([]Person{ {Name: "Alice", Age: 30}, {Name: "Bob", Age: 25}, {Name: "Charlie", Age: 35}, {Name: "Diana", Age: 28}, {Name: "Eve", Age: 32}, }) for p := range it.Samples(people, 3) { fmt.Println(p) // 3 个随机不重复的 Person } // 5. count 大于集合大小 → 返回全部元素(随机顺序) for v := range it.Samples(it.Slice([]int{1, 2, 3}), 10) { fmt.Println(v) // 1、2、3 的随机顺序 } // 6. count 为 0 或负数 → 空序列 empty1 := it.Samples(it.Slice([]int{1, 2, 3, 4, 5}), 0) empty2 := it.Samples(it.Slice([]int{1, 2, 3, 4, 5}), -1) fmt.Println(it.ToSlice(empty1)) // [] fmt.Println(it.ToSlice(empty2)) // [] }说明:
it.ToSlice用于将返回的迭代器物化为切片便于打印;it.Samples本身返回惰性序列,逐元素range消费即可。
六、与相关 helper 的选型对比
文档 frontmatter 的similarHelpers字段给出了完整的对比维度:
| 函数 | 签名要点 | 适用场景 |
|---|---|---|
it.Samples(本文) | (collection I, count int) I,随机不重复、N 个 | 需要多个不重复随机元素 |
it.Sample(it/find.go) | (collection iter.Seq[T]) T,随机取1 个 | 只需单个随机元素 |
it.SampleBy(it/find.go) | 接受randomIntGenerator func(int) int | 需要可控/可注入的随机源(测试、确定性抽样) |
it.SamplesBy(it/find.go) | (collection I, count int, randomIntGenerator func(int) int) I | 多元素随机不重复 + 自定义随机源 |
lo.Samples(核心包,find.go) | 直接操作sliceSlice ~[]T | 输入本就是切片、无需迭代器包装 |
SamplesBy家族的价值在测试 it/find_test.go 中体现得淋漓尽致:测试注入func(n int) int { return n - 1 }(反向选择,期望得到["c","b","a"])和func(int) int { return 0 }(恒定选择索引 0,期望得到["a","c","b"]),从而完全确定性地验证置换算法的正确性,无需依赖随机种子。若你的业务需要可复现的抽样,请使用SampleBy/SamplesBy注入你自己的随机生成器。
七、验证与调试
- 单元测试:运行
go test ./it/ -run 'TestSamples|TestSamplesBy'可复现本文涉及的全部行为断言(元素匹配、空输入、越界 key 触发 panic 等,见 it/find_test.go); - 类型保留:
TestSamples中的preserves iterator type子测试确认自定义迭代器定义类型在返回时不变; - 源码导航:
it层封装在 it/find.go,核心算法在 find.go,随机源xrand.IntN定义于 internal/xrand;相邻的it.Shuffle(it/seq.go)与采样共享相同的"物化-洗牌-回写"模式,可对照阅读。
八、小结
it.Samples是 lo 库"核心 slice 函数 → 迭代器适配"设计哲学的典型样本:一次完整遍历 + 物化,再用 O(count) 或 O(size) 的自适应随机算法抽取,最后恢复为原始迭代器类型。使用时务必牢记它的内存特征——它适合有限、可整体入内存的序列;对超大集合,优先考虑SamplesBy注入可控随机源,或评估其他流式抽样方案。
【免费下载链接】lo💥 A Lodash-style Go library based on Go 1.18+ Generics (map, filter, contains, find...)项目地址: https://gitcode.com/GitHub_Trending/lo/lo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考