一、随机化算法:不只是“碰运气”

说实话,很多同学一听到“随机化算法”,第一反应就是“这不就是靠运气吗?”
嗯,我刚开始学的时候也这么想。直到我在一个广告推荐系统里,遇到了海量数据需要抽样——那时候我才意识到,随机化算法其实是一门非常严谨的数学艺术。

今天咱们聊两个最经典的随机化算法:洗牌算法蓄水池抽样
说白了,一个解决“怎么把数组打乱得足够随机”,另一个解决“数据流太大,我只想抽几个样本,怎么保证公平”。

1.1 洗牌算法:Fisher-Yates 才是正统

先问个问题:给你一个数组,你怎么把它随机打乱?
我见过不少人直接写 Math.random() 排序,或者每次随机取一个元素放到新数组里。这些方法其实都有问题。

真正靠谱的是 Fisher-Yates 洗牌算法,也叫 Knuth 洗牌。
它的核心思想很简单:从后往前遍历,每次从当前位置之前(包括自己)随机选一个元素,然后交换。

核心要点: 每个元素出现在任意位置的概率都是 1/n,这才是真正的均匀随机。
// 经典 Fisher-Yates 洗牌
function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

为什么从后往前?
我个人习惯从后往前写,因为这样只需要一次遍历,而且不需要额外空间。你想想看,如果从前往后,每次选中的位置可能会被后面覆盖,逻辑就乱了。

避坑指南: 我曾经在项目里直接用 arr.sort(() => Math.random() - 0.5) 来洗牌,结果被测试同学发现某些排列出现的概率明显偏高。后来老老实实改成了 Fisher-Yates。

1.2 蓄水池抽样:数据流里的公平抽样

洗牌算法解决的是“已知全部数据”的情况。
但如果你面对的是一个数据流——比如实时日志、无限长的用户点击序列——你根本不知道总共有多少条数据,怎么办?

这时候就要用 蓄水池抽样(Reservoir Sampling) 了。

它的思路很巧妙:

  • 先保留前 k 个元素作为样本
  • 从第 k+1 个元素开始,以 k / i 的概率决定是否替换掉样本中的一个
  • 被替换的元素也是随机选的
// 蓄水池抽样:从数据流中随机抽取 k 个元素
function reservoirSampling(stream, k) {
  const reservoir = [];
  let i = 0;

  for (const item of stream) {
    if (i < k) {
      reservoir.push(item);
    } else {
      const j = Math.floor(Math.random() * (i + 1));
      if (j < k) {
        reservoir[j] = item;
      }
    }
    i++;
  }

  return reservoir;
}

为什么这样能保证公平?
数学证明我就不展开了,你只需要记住:每个元素被选中的概率都是 k/n,其中 n 是总数据量。即使 n 未知,这个性质依然成立。

注意: 蓄水池抽样只适用于“等概率抽样”。如果你需要带权重的抽样,那就得用别的算法了。我在做 A/B 测试平台时踩过这个坑。

1.3 经典例题:打乱数组 & 随机数索引

光讲理论不过瘾,咱们直接上 LeetCode 原题。

例题1:打乱数组(LeetCode 384)

题目要求你实现一个类,支持 reset() 恢复原数组,shuffle() 返回随机打乱后的数组。

class Solution {
  constructor(nums) {
    this.original = [...nums];
    this.array = nums;
  }

  reset() {
    this.array = [...this.original];
    return this.array;
  }

  shuffle() {
    for (let i = this.array.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [this.array[i], this.array[j]] = [this.array[j], this.array[i]];
    }
    return this.array;
  }
}

这里有个小细节:reset() 要深拷贝原数组,否则你 shuffle 之后原数组也被改了。嗯,我刚开始写的时候就忘了这茬,debug 了半天。

例题2:随机数索引(LeetCode 398)

给定一个可能包含重复元素的数组,要求随机返回目标值的索引。每个索引被选中的概率要相等。

这题其实就是蓄水池抽样的变体——只不过我们只关心目标值出现的那些位置。

class Solution {
  constructor(nums) {
    this.nums = nums;
  }

  pick(target) {
    let count = 0;
    let result = -1;

    for (let i = 0; i < this.nums.length; i++) {
      if (this.nums[i] === target) {
        count++;
        // 以 1/count 的概率替换当前选中的索引
        if (Math.random() < 1 / count) {
          result = i;
        }
      }
    }

    return result;
  }
}

你看,代码只有几行,但背后的数学原理很扎实。
每次遇到目标值,都以 1/count 的概率替换掉之前选中的索引。这样每个索引被选中的概率都是 1/count

面试加分点: 如果你能说出“这本质上是蓄水池抽样中 k=1 的特例”,面试官会对你刮目相看。

1.4 知识体系总览

为了让你更直观地理解这两个算法的关系,我画了一张图:

随机化算法 洗牌算法 (Fisher-Yates) 蓄水池抽样 已知全部数据 O(n) 时间 数据流未知长度 O(n) 时间 打乱数组 随机数索引 随机数索引 大数据抽样

1.5 总结与避坑

最后,我把自己这些年踩过的坑总结一下:

场景 推荐算法 常见错误
数组打乱 Fisher-Yates 洗牌 用 sort + random,概率不均匀
数据流抽样 蓄水池抽样 不知道数据总量就放弃抽样
重复元素随机索引 蓄水池抽样 (k=1) 先统计所有索引再随机选,浪费空间

其实这两个算法都不难,关键是要理解它们背后的“公平性”保证。
我当年面试字节跳动的时候,面试官就让我手写蓄水池抽样,还追问了数学证明。幸好我提前准备过,不然真可能翻车。

好了,这一章的内容就到这里。记住:随机化算法不是玄学,是数学。多写几遍代码,多推几遍概率,你就能真正掌握它。


公众号:蓝海资料掘金营,微信deep3321