当前位置: 首页 > news >正文

RocksDB 高效采样算法:水塘抽样和随机寻址

SkipListRep::UniqueRandomSample 中实现的这两种非常经典的采样算法。这个函数的设计体现了对算法特性和工程实践的深刻理解,根据不同的场景选择最高效的策略。

它根据采样密度(m 和 N 的关系,其中 m 是 target_sample_sizeN 是 num_entries)来选择不同的策略。

场景一:高密度采样 (m > sqrt(N)) - 算法 R (水塘抽样的变种)

当需要采样的数量 m 相对较多时(大于总数 N 的平方根),代码采用了一种非常高效的流式采样算法。

代码分析:

skiplistrep.cc

// ... existing code ...void UniqueRandomSample(const uint64_t num_entries,const uint64_t target_sample_size,std::unordered_set<const char*>* entries) override {
// ... existing code ...// We pick Option 2 when m<sqrt(N) and// Option 1 when m > sqrt(N).if (target_sample_size >static_cast<uint64_t>(std::sqrt(1.0 * num_entries))) {// 对应场景一:高密度采样Random* rnd = Random::GetTLSInstance();iter.SeekToFirst();uint64_t counter = 0, num_samples_left = target_sample_size;for (; iter.Valid() && (num_samples_left > 0); iter.Next(), counter++) {// 核心逻辑:以变化的概率决定是否采样// Add entry to sample set with probability// num_samples_left/(num_entries - counter).if (rnd->Next() % (num_entries - counter) < num_samples_left) {entries->insert(iter.key());num_samples_left--;}}} else {
// ... existing code ...

算法解析:

这个算法被称为算法 R (Algorithm R),由 Jeffrey Vitter 在其论文《An Efficient Algorithm for Sequential Random Sampling》中提出。它是一种非常巧妙的等概率无放回抽样方法。

  • 核心思想: 遍历整个数据集(从 i = 0 到 N-1),对于第 i 个元素,以 (m - |S|) / (N - i) 的概率决定是否将其放入样本集 S 中。其中 |S| 是当前样本集的大小。
  • 为什么是等概率的? 我们可以通过数学归纳法证明,当这个过程结束后,任何一个元素被选中的概率都是 m/N
    • 对于第一个元素,被选中的概率是 m/N
    • 对于第二个元素,它被选中的概率是 (第一个没被选中的概率 * 它被选中的概率) + (第一个被选中的概率 * 它被选中的概率) ... 最终计算结果也是 m/N
  • 优点:
    1. 只需一次遍历: 算法从头到尾扫描一遍数据即可,时间复杂度为 O(N)。
    2. 空间高效: 除了存储结果的 m 个样本外,不需要额外的巨大存储空间。
    3. 流式处理: 非常适合处理无法一次性载入内存的大型数据集或数据流。
  • 适用场景: 当 m 比较大时,随机寻址的方式碰撞概率会很高,导致多次无效寻址。而一次完整的线性遍历反而更划算。m > sqrt(N) 这个阈值是一个很好的工程启发式规则。

场景二:低密度采样 (m < sqrt(N)) - 随机寻址法

当需要采样的数量 m 相对较少时,遍历整个数据集 N 就显得非常浪费。此时,代码选择了更直接的方式。

代码分析:

// ... existing code ...} else {// 对应场景二:低密度采样// Option 2: pick m random elements with no duplicates.// If Option 2 is picked, then target_sample_size<sqrt(N)// Using a set spares the need to check for duplicates.for (uint64_t i = 0; i < target_sample_size; i++) {// We give it 5 attempts to find a non-duplicate// ... (概率分析注释) ...for (uint64_t j = 0; j < 5; j++) {iter.RandomSeek();// unordered_set::insert 返回一个 pair,其 .second 成员// 在插入成功时为 true,如果元素已存在则为 false。if ((entries->insert(iter.key())).second) {break;}}}}
// ... existing code ...

算法解析:

  • 核心思想: 进行 m 次独立的随机采样。每一次都通过 iter.RandomSeek() 在跳表中随机定位到一个节点,然后尝试将其插入 std::unordered_set
  • RandomSeek() 的实现InlineSkipList 中的 RandomSeek 是一个 O(logN) 的操作。它从最高层开始,在每一层的节点链表中随机选择一个节点,然后跳到下一层继续这个过程,最终定位到一个随机的元素。
  • unordered_set 去重std::unordered_set 的特性被巧妙地利用了。insert() 方法在元素不存在时会插入并返回 {iterator, true},如果元素已存在,则不插入并返回 {iterator, false}。代码正是通过检查返回的 bool 值来判断本次随机寻址是否命中了重复元素。
  • 重试机制: 代码中有一个 for (j < 5) 的循环,这是一个为了处理哈希碰撞的重试机制。如果 RandomSeek 连续几次都找到了已经存在于样本集中的元素,它会重试最多5次。注释中给出了详细的概率分析,证明对于 m < sqrt(N) 的场景,5次重试足以大概率(>99.9%)找到一个新元素。
  • 优点:
    1. 避免全量扫描: 当 m 远小于 N 时,其期望时间复杂度约为 O(m * logN),远优于 O(N)。
  • 适用场景: 当采样密度很低时,随机寻址碰到重复元素的概率也很低,这种方法的效率极高。

总结

UniqueRandomSample 函数是自适应算法选择的一个绝佳范例:

特性高密度采样 (算法 R)低密度采样 (随机寻址)
判断条件m > sqrt(N)m <= sqrt(N)
核心操作线性遍历 (iter.Next())随机跳转 (iter.RandomSeek())
去重方式算法本身保证(通过概率)std::unordered_set
时间复杂度O(N)约 O(m * logN)
优势m 较大时,避免了随机寻址的高碰撞率,一次遍历解决问题。m 较小时,避免了不必要的全量数据扫描,效率极高。

通过一个简单的 if-else,RocksDB 就在两种经典的采样算法之间做出了明智的选择,确保了在不同采样需求下都能获得优异的性能。

FindRandomEntry 

随机选一层 是一个非常自然的想法,但这种方法无法保证每个元素被选中的概率相等

高层级的节点本身就比较稀疏,如果随机选一层再随机选一个节点,那么高层级的节点被选中的概率会不成比例地远高于低层级的节点,这就不是一个公平的(均匀的)随机采样了。

FindRandomEntry 的目标是:确保跳表中的每一个元素(即 level 0 上的所有节点)都有完全相同的机会被选中。它通过一个非常巧妙的“分层随机缩放”(Hierarchical Random Zoom-in)策略来实现这一点。

我们来看代码,并用一个具体的例子来解释它的每一步。

inlineskiplist.h

// ... existing code ...
template <class Comparator>
typename InlineSkipList<Comparator>::Node*
InlineSkipList<Comparator>::FindRandomEntry() const {// TODO(bjlemaire): consider adding PREFETCH calls.Node *x = head_, *scan_node = nullptr, *limit_node = nullptr;// ... (注释中的例子) ...std::vector<Node*> lvl_nodes;Random* rnd = Random::GetTLSInstance();int level = GetMaxHeight() - 1;while (level >= 0) {// 1. 收集当前层的候选节点lvl_nodes.clear();scan_node = x;while (scan_node != limit_node) {lvl_nodes.push_back(scan_node);scan_node = scan_node->Next(level);}// 2. 从候选节点中随机选择一个uint32_t rnd_idx = rnd->Next() % lvl_nodes.size();x = lvl_nodes[rnd_idx];// 3. 更新下一轮搜索的范围("缩放"操作)if (rnd_idx + 1 < lvl_nodes.size()) {limit_node = lvl_nodes[rnd_idx + 1];}level--;}// 4. 处理边界情况并返回结果return x == head_ && head_ != nullptr ? head_->Next(0) : x;
}
// ... existing code ...

分步图解(使用代码注释中的例子)

假设跳表有100个元素,最大高度为5 (GetMaxHeight() 返回 5)。

初始状态:

  • level = 4 (最高层)
  • x = head_ (搜索起点是头节点)
  • limit_node = nullptr (搜索终点是整个链表的末尾,没有限制)

第 1 轮: level = 4

  1. 收集while (scan_node != nullptr) 循环会遍历第 4 层的所有节点。假设找到了 {#1, #15, #67, #84} 这几个节点(head_ 也算一个,但为简化我们从数据节点开始看)。lvl_nodes 里面就是这些节点的指针。
  2. 随机选择rnd->Next() % lvl_nodes.size() 在这些节点中随机选择一个。假设选中了 #15
  3. 缩放:
    • x 被更新为 #15
    • limit_node 被更新为 #15 在 lvl_nodes 中的下一个节点,即 #67
    • 关键点: 这意味着,下一轮的搜索范围被缩小到了 #15 (含) 和 #67 (不含) 之间!

第 2 轮: level = 3

  1. 收集while (scan_node != #67) 循环会遍历第 3 层,但只在 #15 和 #67 之间的节点。假设找到了 {#15, #21, #45, #51}
  2. 随机选择: 在这4个节点中随机选择一个。假设选中了 #51
  3. 缩放:
    • x 被更新为 #51
    • limit_node 保持为 #67,因为 #51 已经是这个范围内最后一个第3层的节点了。
    • 搜索范围进一步被缩小到了 #51 (含) 和 #67 (不含) 之间。

第 3 轮: level = 2

  1. 收集while (scan_node != #67) 循环遍历第 2 层,范围是 #51 到 #67。假设找到了 {#51, #56, #62}
  2. 随机选择: 假设选中了 #56
  3. 缩放:
    • x 更新为 #56
    • limit_node 更新为 #62
    • 搜索范围缩小为 #56 (含) 和 #62 (不含)。

... 直到 level = 0

最后一轮在 level = 0 上进行,此时的搜索范围可能已经非常小了(比如在 #56 和 #58 之间)。它会在这个极小的范围内再做一次随机选择,最终选出的那个节点就是本次 FindRandomEntry 的结果。

总结

这个算法的精妙之处在于:

  • 它不是随机选层,而是逐层随机选择“区间”
  • 在任意一层 L,所有节点把整个链表划分成若干个区间。算法先在 L 层随机选择一个节点,这等价于随机选择了一个它所代表的区间。
  • 然后,算法进入下一层 L-1,但只在刚刚选定的那个小区间内进行下一次的随机选择。
  • 这个过程就像从一张世界地图上随机选一个国家,再从这个国家的地图上随机选一个省,再选一个市... 最后定位到一个街道。

通过这种方式,保证了最底层的每一个节点,无论它上面有多少层“快车道”,它被最终选中的概率都是完全一样的 1/N(N为节点总数)。这就实现了一个真正公平、均匀的随机采样。

http://www.xdnf.cn/news/1199755.html

相关文章:

  • WAIC 2025 热点解读:如何构建 AI 时代的“视频神经中枢”?
  • [N1盒子] 斐讯盒子N1 T1通用刷机包(可救砖)
  • SpringBoot 整合 Langchain4j AIService 深度使用详解
  • Valgrind Helgrind 工具全解:线程同步的守门人
  • 编程语言Java——核心技术篇(五)IO流:数据洪流中的航道设计
  • JavaWeb(苍穹外卖)--学习笔记13(微信小程序开发,缓存菜品,Spring Cache)
  • Java中get()与set()方法深度解析:从封装原理到实战应用
  • 8. 状态模式
  • 零基础 “入坑” Java--- 十五、字符串String
  • 一场关于电商零售增长破局的深圳探索
  • 金融科技中的跨境支付、Open API、数字产品服务开发、变革管理
  • 【Ollama】大模型本地部署与 Java 项目调用指南
  • 字符串是数据结构还是数据类型?
  • 基于Prometheus+Grafana的分布式爬虫监控体系:构建企业级可观测性平台
  • Git Commit 生成与合入 Patch 指南
  • java--WebSocket简单介绍
  • 多模态视觉语言模型FILA-细粒度分辨率融合策略
  • [10月考试] B
  • Flutter 生命周期介绍
  • 基于Java的KTV点歌系统的设计与实现
  • 电商项目_核心业务_分布式ID服务
  • [STM32][HAL]stm32wbxx 超声波测距模块实现(HY-SRF05)
  • selenium完整版一览
  • 三、搭建springCloudAlibaba2021.1版本分布式微服务-springcloud loadbalancer负载均衡
  • git 提交时排除一个或多个文件
  • 【H264视频编码】一、基本概念
  • 沪深L2逐笔十档委托队列分时Tick历史数据分析处理
  • 集合框架学习
  • day25
  • vulkan从小白到专家——YUV处理