土法炼钢兴趣小组的算法知识备份

随机化算法:当运气成为武器

目录

在算法的世界里,确定性一直被视为美德。给定相同的输入,程序总是产生相同的输出——这是我们从第一天学编程就被灌输的信条。然而,当我们引入一个看似荒谬的元素——掷硬币——许多困难问题突然变得出人意料地简单。

这不是偷懒,也不是碰运气。随机化算法(Randomized Algorithm)是一种严肃的、有数学保证的算法设计范式。它的核心思想是:让算法做出随机选择,用概率论来保证整体性能

考虑一个直觉:如果一个确定性算法要防御所有可能的最坏情况输入,它必须”小心翼翼”地运行。但如果算法可以掷硬币,那么没有任何对手能构造出针对所有随机选择都最坏的输入。随机性打破了对手与算法之间的信息对称

本文将深入探讨随机化算法的理论基础与实际应用,从基本分类到经典算法,从分析工具到工程实践。

一、Las Vegas 与 Monte Carlo:两大流派

随机化算法按其输出保证的不同,分为两大类。

1.1 Las Vegas 算法

Las Vegas 算法的特征是:结果总是正确的,但运行时间是随机变量

Las Vegas 算法 L:
  repeat:
    随机选择策略 s
    运行确定性子程序 D(input, s)
    if D 输出了正确结果:
      return 结果
  // 循环直到找到正确结果

经典例子:

1.2 Monte Carlo 算法

Monte Carlo 算法的特征是:运行时间是确定的(或有确定上界),但结果可能错误

Monte Carlo 算法 M:
  随机选择策略 s
  运行确定性子程序 D(input, s)
  return D 的输出  // 可能正确,也可能错误

根据错误类型,Monte Carlo 算法又分为:

经典例子:

1.3 两者的互相转换

Las Vegas → Monte Carlo

如果有一个 Las Vegas 算法 L,期望运行时间为 T,我们可以把它变成 Monte Carlo 算法:运行 L 最多 2T 时间,如果没结束就输出”失败”。由 Markov 不等式,失败概率 ≤ 1/2。

// Las Vegas -> Monte Carlo 转换
result_t las_vegas_to_monte_carlo(input_t input, int time_limit) {
    // 运行 Las Vegas 算法,但限制时间
    start_timer();
    result_t result = run_las_vegas(input);
    if (elapsed_time() <= time_limit) {
        return result;  // 在时限内完成,结果正确
    }
    return FAILURE;     // 超时,输出失败
}

Monte Carlo → Las Vegas

如果有一个 Monte Carlo 算法 M(错误概率 ≤ 1/3),并且我们有办法验证结果的正确性,就可以把它变成 Las Vegas 算法:反复运行 M,每次验证结果,直到结果正确。

// Monte Carlo -> Las Vegas 转换(需要验证器)
result_t monte_carlo_to_las_vegas(input_t input) {
    while (1) {
        result_t result = run_monte_carlo(input);
        if (verify(input, result)) {
            return result;  // 验证通过,结果正确
        }
        // 验证失败,重新运行
    }
    // 期望迭代次数:1/(1-error_prob) 次
}

关键限制:Monte Carlo → Las Vegas 的转换要求存在高效的验证器。这并非总是可能的。例如,判断一个数是否为素数——如果 Miller-Rabin 说它是素数(可能错),我们如何”验证”?在 AKS 算法发明之前,这个验证本身就很困难。

1.4 错误概率的放大

Monte Carlo 算法的一个核心性质是:可以通过重复运行来任意降低错误概率

如果单次错误概率 ≤ 1/3,独立运行 k 次后取多数结果(对于双边错误)或取”任何一次成功”(对于单边错误),错误概率指数下降:

这意味着 O(log(1/δ)) 次重复就能将错误概率降到 δ 以下。在实践中,重复 50-100 次就能使错误概率低于宇宙中质子自发衰变的概率。

二、Schwartz-Zippel 引理:多项式的随机检验

2.1 引理陈述

Schwartz-Zippel 引理是随机化算法中最优雅、最实用的工具之一。

引理:设 P(x_1, x_2, …, x_n) 是域 F 上的非零多项式,总度数为 d。设 S 是 F 的一个有限子集。如果 r_1, r_2, …, r_n 从 S 中独立均匀随机选取,则:

Pr[P(r_1, r_2, ..., r_n) = 0] ≤ d / |S|

直觉理解:一个 d 次多项式最多有 d 个零点(一元情况)。在一个大集合 S 中随机选一个点,命中零点的概率不超过 d/|S|。多元情况下,这个界依然成立——这才是引理的深刻之处。

2.2 证明思路

对变量个数 n 做归纳。基础情形(n=1)显然:一元 d 次多项式最多 d 个根。归纳步骤将 P 按 x_1 展开为 Σx_1^i · Q_i(x_2,…,x_n),其中 Q_k 非零。先随机选 r_2,…,r_n:

  1. Q_k(r_2,…,r_n) = 0 的概率 ≤ (d-k)/|S|(归纳假设)
  2. 否则 P(x_1,r_2,…,r_n) 是非零 k 次一元多项式,零点概率 ≤ k/|S|

总概率 ≤ (d-k)/|S| + k/|S| = d/|S|。

2.3 应用一:矩阵乘法验证

问题:给定三个 n×n 矩阵 A、B、C,验证 AB = C。

确定性方法:直接计算 AB,时间 O(n^3)(朴素算法)或 O(n^2.37)(最优已知)。

Freivalds 随机化算法(1979):

1. 随机生成向量 r ∈ {0,1}^n
2. 计算 A(Br) - Cr
3. 如果结果为零向量,输出 "AB = C"
4. 否则输出 "AB ≠ C"

时间复杂度:O(n^2)——只需要三次矩阵-向量乘法。

正确性: - 如果 AB = C,则 A(Br) - Cr = (AB - C)r = 0,一定输出正确。 - 如果 AB ≠ C,设 D = AB - C ≠ 0。那么 Dr = 0 的概率是多少?

D 的某一行 d_i 非零。dr = d_1·r_1 + … + d_n·r_n 是 r 的一次多项式。由 Schwartz-Zippel,在 {0,1}^n 中,Pr[d_i · r = 0] ≤ 1/2。

所以单次错误概率 ≤ 1/2。重复 k 次,错误概率 ≤ 1/2^k

// Freivalds 矩阵乘法验证
// 检验 A * B == C,均为 n x n 矩阵
// 返回 1 表示通过验证,0 表示 AB != C
int freivalds_check(int n, double A[][MAX_N], double B[][MAX_N],
                    double C[][MAX_N], int iterations) {
    double r[MAX_N], Br[MAX_N], ABr[MAX_N], Cr[MAX_N];

    for (int iter = 0; iter < iterations; iter++) {
        // 生成随机向量 r ∈ {0,1}^n
        for (int i = 0; i < n; i++) {
            r[i] = rand() % 2;
        }

        // 计算 Br
        for (int i = 0; i < n; i++) {
            Br[i] = 0;
            for (int j = 0; j < n; j++) {
                Br[i] += B[i][j] * r[j];
            }
        }

        // 计算 A(Br)
        for (int i = 0; i < n; i++) {
            ABr[i] = 0;
            for (int j = 0; j < n; j++) {
                ABr[i] += A[i][j] * Br[j];
            }
        }

        // 计算 Cr
        for (int i = 0; i < n; i++) {
            Cr[i] = 0;
            for (int j = 0; j < n; j++) {
                Cr[i] += C[i][j] * r[j];
            }
        }

        // 检查 ABr == Cr
        for (int i = 0; i < n; i++) {
            if (fabs(ABr[i] - Cr[i]) > 1e-9) {
                return 0;  // 确定 AB != C
            }
        }
    }

    return 1;  // 通过所有轮次,高概率 AB == C
}

2.4 应用二:多项式恒等性检验

问题:给定两个多项式的符号表达式,判断它们是否恒等。

例如:(x+y)(x-y) 和 x^2 - y^2 是否恒等?

直接展开可能导致指数级别的项数。但用 Schwartz-Zippel,只需随机代入一个点检查即可。

1. 令 Q(x_1, ..., x_n) = P_1(x_1, ..., x_n) - P_2(x_1, ..., x_n)
2. 从足够大的集合 S 中随机选取 r_1, ..., r_n
3. 计算 Q(r_1, ..., r_n)
4. 如果为 0,输出 "恒等";否则输出 "不恒等"

这是典型的 Monte Carlo 单边错误算法:说”不恒等”一定对。

2.5 应用三:完美匹配存在性

Tutte 矩阵:对于图 G = (V, E),定义 n×n 的反对称矩阵 T,其中 T[i][j] = x_{ij}(形式变量),T[j][i] = -x_{ij}。

Tutte 定理:G 有完美匹配 ⟺ det(T) 不是零多项式。

用 Schwartz-Zippel:随机代入所有 x_{ij},计算行列式。如果结果非零,则(高概率)存在完美匹配。这给出了一个 O(n^3) 的随机化算法——长期以来,确定性方法需要的时间更多。

三、随机化快速选择(Randomized QuickSelect)

3.1 问题定义

给定一个无序数组 A[0..n-1] 和一个整数 k,找到第 k 小的元素。

3.2 随机化 QuickSelect

// 随机化快速选择:期望 O(n)
int randomized_select(int *arr, int left, int right, int k) {
    if (left == right) {
        return arr[left];
    }

    // 随机选择 pivot
    int pivot_idx = left + rand() % (right - left + 1);
    pivot_idx = partition(arr, left, right, pivot_idx);

    int rank = pivot_idx - left + 1;
    if (k == rank) {
        return arr[pivot_idx];
    } else if (k < rank) {
        return randomized_select(arr, left, pivot_idx - 1, k);
    } else {
        return randomized_select(arr, pivot_idx + 1, right, k - rank);
    }
}

int partition(int *arr, int left, int right, int pivot_idx) {
    int pivot_val = arr[pivot_idx];
    // 把 pivot 移到末尾
    swap(&arr[pivot_idx], &arr[right]);

    int store_idx = left;
    for (int i = left; i < right; i++) {
        if (arr[i] < pivot_val) {
            swap(&arr[store_idx], &arr[i]);
            store_idx++;
        }
    }
    swap(&arr[store_idx], &arr[right]);
    return store_idx;
}

3.3 期望时间分析

设 T(n) 为在 n 个元素中 QuickSelect 的期望比较次数。

如果 pivot 的秩为 i(第 i 小),则: - 如果 k = i,结束。 - 如果 k < i,递归到大小为 i-1 的子问题。 - 如果 k > i,递归到大小为 n-i 的子问题。

最坏情况是 k = 1(找最小值),此时:

T(n) ≤ n + (1/n) Σ_{i=1}^{n} T(max(i-1, n-i))
     ≤ n + (2/n) Σ_{i=n/2}^{n} T(i)

解这个递推,得到 T(n) ≤ 4n = O(n)。

更精确的分析:对于找中位数,期望比较次数约为 (2 + 2ln2)n ≈ 3.39n。

3.4 与确定性 Median-of-Medians 的对比

Blum、Floyd、Pratt、Rivest 和 Tarjan(1973)给出了确定性 O(n) 的选择算法——Median of Medians(BFPRT 算法):

1. 将数组分成 n/5 组,每组 5 个元素
2. 找到每组的中位数(常数时间)
3. 递归找这些中位数的中位数 m
4. 用 m 作为 pivot 进行 partition
5. 递归到相应的子问题

m 保证了至少 3n/10 的元素在每一边,所以递推为:

T(n) ≤ T(n/5) + T(7n/10) + O(n)

这给出 T(n) = O(n),但常数因子很大(约 20-40 次比较)。

3.5 工程中为何偏爱随机化版本

对比维度 随机化 QuickSelect 确定性 BFPRT
期望比较次数 ~3.4n ~20-40n
最坏情况 O(n^2)(概率极低) O(n)(严格保证)
缓存友好性 好(顺序访问) 差(分组+递归开销)
实现复杂度 简单 复杂
实际运行时间 慢 2-5 倍
可被对手攻击 不能(随机化) 不能(确定性)

结论:在绝大多数工程场景中,随机化 QuickSelect 是首选。除非在对延迟有严格最坏情况保证的实时系统中,才考虑 BFPRT。

C++ STL 的 std::nth_element 早期使用 introselect(随机化 + 退化时切换到 BFPRT),但实际上 BFPRT 分支几乎不会被触发。

四、Karger 最小割算法

4.1 最小割问题

给定无向图 G = (V, E),找到一个边集 C ⊆ E,使得删除 C 后图不连通,且 |C| 最小。

确定性算法:运行 n-1 次最大流(每次固定一个源点,枚举汇点),由最大流-最小割定理得到最小割。时间复杂度 O(n · maxflow)。用 Stoer-Wagner 算法可以做到 O(nm + n^2 log n)。

Karger(1993)的随机收缩算法令人惊叹地简单。

4.2 随机收缩算法

Karger-Contract(G):
  while |V| > 2:
    均匀随机选一条边 (u, v)
    收缩 (u, v):将 u 和 v 合并为一个超级节点
                  删除 u-v 之间的所有边(自环)
                  保留所有多重边
  return 剩余两个超级节点之间的边数
Karger 随机收缩算法过程

关键操作——边的收缩

当我们收缩边 (u, v) 时: 1. 创建新节点 w 代表 {u, v} 的合并 2. 对于所有与 u 或 v 相邻的节点 x(x ≠ u, x ≠ v),将边 (u,x) 和 (v,x) 都变为 (w,x) 3. 删除 u 和 v 之间的所有边(自环) 4. 图中可能出现多重边,必须保留

4.3 成功概率分析

定理:Karger 单次收缩算法输出最小割的概率 ≥ 2/(n(n-1)) ≥ 2/n^2。

证明

设最小割大小为 k。

关键观察:图中每个节点的度数 ≥ k(否则单个节点和其余节点之间的割 < k,矛盾)。因此图中边数 m ≥ nk/2。

第一步收缩:一条随机边属于最小割的概率 ≤ k/m ≤ k/(nk/2) = 2/n。所以不切到最小割的概率 ≥ 1 - 2/n = (n-2)/n。

第二步收缩:图缩小到 n-1 个节点,最小割仍为 k(只要第一步没切到它)。同理不切到的概率 ≥ 1 - 2/(n-1) = (n-3)/(n-1)。

以此类推,第 i 步不切到的概率 ≥ (n-2-i)/(n-i)。

总成功概率

P ≥ ∏_{i=0}^{n-3} (n-2-i)/(n-i)
  = (n-2)/n · (n-3)/(n-1) · (n-4)/(n-2) · ... · 1/3
  = 2/(n(n-1))
  ≥ 2/n²

这是一个望远镜求积(telescoping product),大部分项互消。

4.4 重复策略

单次成功概率 ≥ 2/n^2。重复 t = n^2/2 · ln(n) 次,取最小值:

P(所有 t 次都失败) ≤ (1 - 2/n²)^t
                    ≤ e^{-2t/n²}
                    = e^{-ln(n)}
                    = 1/n

所以以概率 ≥ 1 - 1/n 找到最小割。

每次收缩需要 O(n^2) 时间(选边 + 收缩),总共 n-2 次收缩,所以单次运行 O(n^2)。重复 O(n^2 log n) 次,总时间 O(n^4 log n)。

4.5 Karger-Stein 改进

Karger-Stein(1996)的核心观察:前期的收缩步骤很安全(因为节点多,切到最小割的概率低),后期才危险

改进策略:

Karger-Stein(G):
  if |V| ≤ 6:
    用暴力方法找最小割
    return min_cut
  t = ceil(|V| / sqrt(2) + 1)
  G1 = Contract(G, t)   // 收缩到 t 个节点
  G2 = Contract(G, t)   // 独立再做一次收缩到 t 个节点
  return min(Karger-Stein(G1), Karger-Stein(G2))

每次收缩到 n/√2 个节点时的成功概率 ≥ 1/2。然后分两个独立分支递归,至少一个分支成功的概率足够高。

递推分析:

T(n) = 2T(n/√2) + O(n²)

递归深度 O(log n),每层 O(n^2) 工作,分支数 2^{O(log n)} = n^{O(1)}。精确分析给出 T(n) = O(n^2 log^3 n)。

这是一个巨大的改进:从 O(n^4 log n) 到 O(n^2 log^3 n)。

4.6 Karger 最小割的 C 实现

使用并查集实现收缩操作,基于随机排列避免 rejection sampling 的效率问题:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>

#define MAX_VERTICES 500
#define MAX_EDGES 50000

typedef struct { int u, v; } Edge;

typedef struct {
    int n, m;
    Edge edges[MAX_EDGES];
    int parent[MAX_VERTICES];
    int rank_uf[MAX_VERTICES];
} Graph;

int find(Graph *g, int x) {
    while (g->parent[x] != x) {
        g->parent[x] = g->parent[g->parent[x]];
        x = g->parent[x];
    }
    return x;
}

void unite(Graph *g, int x, int y) {
    int rx = find(g, x), ry = find(g, y);
    if (rx == ry) return;
    if (g->rank_uf[rx] < g->rank_uf[ry]) g->parent[rx] = ry;
    else if (g->rank_uf[rx] > g->rank_uf[ry]) g->parent[ry] = rx;
    else { g->parent[ry] = rx; g->rank_uf[rx]++; }
    g->n--;
}

void init_graph(Graph *g, int n) {
    g->n = n; g->m = 0;
    for (int i = 0; i < n; i++) { g->parent[i] = i; g->rank_uf[i] = 0; }
}

void add_edge(Graph *g, int u, int v) {
    g->edges[g->m++] = (Edge){u, v};
}

// 基于随机排列的单次收缩:O(m·α(n))
int karger_single_run(Graph *g_orig) {
    Graph g;
    memcpy(&g, g_orig, sizeof(Graph));

    // Fisher-Yates 随机排列所有边
    for (int i = g.m - 1; i > 0; i--) {
        int j = rand() % (i + 1);
        Edge tmp = g.edges[i]; g.edges[i] = g.edges[j]; g.edges[j] = tmp;
    }

    // 按排列顺序收缩非自环边
    for (int p = 0; g.n > 2 && p < g.m; p++) {
        int u = find(&g, g.edges[p].u), v = find(&g, g.edges[p].v);
        if (u != v) unite(&g, u, v);
    }

    int cut = 0;
    for (int i = 0; i < g.m; i++)
        if (find(&g, g.edges[i].u) != find(&g, g.edges[i].v)) cut++;
    return cut;
}

int karger_min_cut(Graph *g, int repetitions) {
    int min_cut = INT_MAX;
    for (int i = 0; i < repetitions; i++) {
        int cut = karger_single_run(g);
        if (cut < min_cut) min_cut = cut;
    }
    return min_cut;
}

相比朴素的 rejection sampling(随机选边,自环则重选),随机排列法避免了后期大量自环导致的效率退化。单次运行严格 O(m · α(n)),其中 α 是逆 Ackermann 函数。

五、Miller-Rabin 素性测试

Miller-Rabin 是随机化算法在数论中最成功的应用之一。这个算法在 第 100 篇 中已有详细讨论,这里从随机化算法的角度做一个总结。

5.1 费马小定理与伪素数

费马小定理:若 p 为素数,则对所有 1 ≤ a < p,a^{p-1} ≡ 1 (mod p)。

反过来,如果存在 a 使得 a^{n-1} ≢ 1 (mod n),则 n 一定是合数。这个 a 被称为费马证人(Fermat witness)。

问题:有些合数 n 对所有 gcd(a,n) = 1 的 a 都满足费马小定理——Carmichael 数(如 561 = 3 × 11 × 17)。

5.2 Miller-Rabin 的改进

将 n-1 写成 2^s · d 的形式(d 为奇数)。如果 n 是素数,则对任意 a:

要么 a^d ≡ 1 (mod n),要么存在 r ∈ {0, 1, …, s-1} 使得 a{2r · d} ≡ -1 (mod n)。

// Miller-Rabin 素性测试
// 返回 1 表示"可能是素数",0 表示"一定是合数"
typedef unsigned long long u64;

u64 mulmod(u64 a, u64 b, u64 m) {
    return (__uint128_t)a * b % m;
}

u64 powmod(u64 base, u64 exp, u64 mod) {
    u64 result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1)
            result = mulmod(result, base, mod);
        exp >>= 1;
        base = mulmod(base, base, mod);
    }
    return result;
}

int miller_rabin_witness(u64 a, u64 d, u64 n, int s) {
    u64 x = powmod(a, d, n);

    if (x == 1 || x == n - 1)
        return 0;  // 不是证人,n 可能是素数

    for (int r = 1; r < s; r++) {
        x = mulmod(x, x, n);
        if (x == n - 1)
            return 0;  // 不是证人
    }

    return 1;  // 是证人,n 一定是合数
}

int is_probable_prime(u64 n, int iterations) {
    if (n < 2) return 0;
    if (n == 2 || n == 3) return 1;
    if (n % 2 == 0) return 0;

    // 将 n-1 写成 2^s * d
    u64 d = n - 1;
    int s = 0;
    while (d % 2 == 0) {
        d /= 2;
        s++;
    }

    // 对于 n < 3,317,044,064,679,887,385,961,981,
    // 使用固定的前若干个素数作为基底即可确定性判断
    u64 deterministic_bases[] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37};
    int num_bases = sizeof(deterministic_bases) / sizeof(deterministic_bases[0]);

    for (int i = 0; i < num_bases && i < iterations; i++) {
        u64 a = deterministic_bases[i];
        if (a >= n) continue;
        if (miller_rabin_witness(a, d, n, s)) {
            return 0;  // 一定是合数
        }
    }

    return 1;  // 高概率是素数
}

5.3 从随机化角度看 Miller-Rabin

Miller-Rabin 是典型的 Monte Carlo 单边错误算法:

单次测试的错误概率 ≤ 1/4(Rabin 证明了至少 3/4 的基底是证人)。k 次独立测试后,错误概率 ≤ (1/4)^k。

在实践中,对于 64 位整数,使用特定的 12 个基底可以实现确定性判断——这是一种”去随机化”。

六、分布式系统中的随机化

随机化在分布式计算中扮演着不可替代的角色。事实上,有些分布式问题被证明不存在确定性解(如异步共识),但随机化算法可以解决它们。

6.1 共识算法中的随机化:Ben-Or 协议

FLP 不可能性定理(1985):在异步系统中,即使只有一个进程可能崩溃,也不存在能保证终止的确定性共识算法。

Ben-Or(1983)的随机化共识协议绕过了这个不可能性:

Ben-Or 协议(进程 i,输入 v_i):
  r = 1  // 轮次
  while true:
    // 第一阶段:广播自己的值
    广播 (PROPOSE, r, v_i)
    等待收到 n-f 个 PROPOSE 消息
    if 收到的某个值 v 出现 > n/2 次:
      广播 (DECIDE, r, v)
    else:
      广播 (DECIDE, r, ?)  // 不确定

    // 第二阶段:尝试决定
    等待收到 n-f 个 DECIDE 消息
    if 收到的某个值 v(非 ?)出现 > f 次:
      v_i = v
      if v 出现 > n/2 次:
        输出 v 并终止
    else:
      v_i = 随机选 0 或 1  // 关键的随机化步骤

    r = r + 1

当进程无法从其他进程获得足够信息来做决定时,它随机选择一个值。这打破了可能的活锁。

期望轮数:O(2^n)(指数级),但对于恒定数量的故障,更优的随机化协议可以做到期望常数轮。

6.2 负载均衡:Power of Two Choices

问题:n 个球(任务)投入 n 个桶(服务器),如何最小化最大负载?

完全随机:每个球随机选一个桶。最大负载为 Θ(log n / log log n)。

Power of Two Choices:每个球随机选两个桶,放入负载较轻的那个。最大负载降为 O(log log n)

Power of Two Choices:
  for each ball:
    i = 随机选桶
    j = 随机选桶(j ≠ i)
    将球放入 load[i] < load[j] ? i : j

这是一个指数级的改进,只需要一点额外的随机性(选两个而不是一个)。

直觉:当负载为 k 的桶数量为 n/e^k 时(完全随机的稳态),两个桶都负载 ≥ k 的概率为 (1/ek)2 = 1/e^{2k}。这个双指数衰减导致了 O(log log n) 的最大负载。

这个结果在实际负载均衡系统中被广泛使用,例如: - Nginx 的 least_conn 策略的随机化变体 - 微服务框架中的”两次随机选择”负载均衡策略

6.3 指数退避

指数退避(Exponential Backoff)是分布式系统中最常见的随机化技术之一。当多个客户端竞争同一个资源时:

指数退避:
  for attempt = 1, 2, 3, ...:
    尝试获取资源
    if 成功:
      break
    等待 random(0, 2^attempt) 的时间

通过随机化等待时间,避免了所有客户端同步重试导致的”惊群效应”。

关于指数退避的详细分析,参见 第 73 篇 的讨论。

6.4 一致性哈希与虚拟节点

一致性哈希(Consistent Hashing)利用随机哈希函数将键和节点映射到环上。每个节点创建 O(log n) 个虚拟节点(随机位置),保证了负载的均匀分布。

当一个节点加入或离开时,只有 O(1/n) 比例的键需要重新映射——这远优于传统哈希的 O(1) 比例。

七、去随机化(Derandomization)

随机化算法虽然强大,但有时我们需要确定性保证。去随机化是将随机化算法转换为确定性算法的技术。

7.1 条件期望法

条件期望法(Method of Conditional Expectations)是最经典的去随机化技术。

基本思想:如果随机变量 X 的期望 E[X] ≤ c,那么存在某个特定的选择使得 X ≤ c。条件期望法通过逐步固定随机选择,确保每一步都维持这个保证。

例子——MAX-CUT 问题

给定无向图 G,找一个割使得跨越割的边数最大。

随机化算法:每个节点独立地以 1/2 概率放入 S 或 T。

对于每条边 (u,v):
  Pr[(u,v) 被割] = 1/2(u 和 v 在不同侧的概率)

期望割大小 = m/2

去随机化:逐个处理节点,每次选择使条件期望不减的一边。

// MAX-CUT 的条件期望法去随机化
void derandomized_max_cut(int n, int m, int edges[][2], int *side) {
    // side[i] = 0 或 1,表示节点 i 在哪一侧

    for (int v = 0; v < n; v++) {
        // 计算将 v 放入 0 侧和 1 侧的条件期望
        int cut_if_0 = 0, cut_if_1 = 0;

        for (int e = 0; e < m; e++) {
            int u = edges[e][0], w = edges[e][1];

            if (u == v || w == v) {
                int other = (u == v) ? w : u;
                if (other < v) {
                    // other 已经被分配了
                    if (side[other] != 0) cut_if_0++;  // v=0, other!=0 -> 被割
                    if (side[other] != 1) cut_if_1++;  // v=1, other!=1 -> 被割
                } else {
                    // other 尚未分配,贡献期望 1/2
                    // 两种选择的贡献相同,无需区分
                }
            }
        }

        // 选择使条件期望更大的一侧
        side[v] = (cut_if_1 >= cut_if_0) ? 1 : 0;
    }
}

这给出了一个确定性的、保证割 ≥ m/2 的算法,时间 O(nm)。

7.2 成对独立性与 k-wise 独立性

很多随机化算法并不需要完全独立的随机比特,只需要有限独立性

用 O(k log n) 个真随机比特可以生成 n 个 k-wise 独立的随机变量。当 k 很小时(如 k=2 或 k=4),随机比特的数量大幅减少。

这种有限独立性对很多应用已经足够:

7.3 伪随机生成器

如果一个函数 G: {0,1}^s → {0,1}^n(s << n)的输出对某类算法”看起来像”真随机,那么 G 是该类算法的伪随机生成器。

Nisan-Wigderson 构造:在某些复杂度假设下,可以用 O(log n) 个真随机比特”骗过”所有多项式时间算法。这意味着 BPP = P(如果这些假设成立)。

这是去随机化理论的终极目标:证明随机性在多项式时间计算中是不必要的。虽然这至今未被完全证明,但绝大多数复杂度理论家相信 BPP = P。

八、Chernoff 界与 Union Bound

分析随机化算法需要精确的概率尾界。Chernoff 界和 union bound 是两个最基本的工具。

8.1 Chernoff 界

设 X_1, X_2, …, X_n 是独立的 {0,1} 随机变量,X = ΣX_i,μ = E[X]。

上尾:对于 δ > 0:

Pr[X ≥ (1+δ)μ] ≤ (e^δ / (1+δ)^{1+δ})^μ

常用简化形式:

Pr[X ≥ (1+δ)μ] ≤ exp(-μδ²/3)    (0 < δ ≤ 1)
Pr[X ≥ (1+δ)μ] ≤ exp(-μδ/3)     (δ > 1)

下尾:对于 0 < δ < 1:

Pr[X ≤ (1-δ)μ] ≤ exp(-μδ²/2)

8.2 Chernoff 界的应用示例

随机化负载均衡:n 个球投入 n 个桶,每个桶的期望负载 μ = 1。

Pr[某个桶的负载 ≥ c·log(n)/log(log(n))] ≤ 1/n²

对所有 n 个桶取 union bound:

Pr[最大负载 ≥ c·log(n)/log(log(n))] ≤ n · 1/n² = 1/n

这就是为什么完全随机投球的最大负载是 Θ(log n / log log n)。

8.3 Union Bound(Boole 不等式)

对于任意事件 A_1, A_2, …, A_n:

Pr[A_1 ∪ A_2 ∪ ... ∪ A_n] ≤ Σ Pr[A_i]

虽然看起来粗糙,但在与 Chernoff 界结合时非常有效。

典型用法:证明”没有坏事发生”。如果有 n 个可能的坏事件,每个的概率 ≤ 1/n^2,则所有坏事件都不发生的概率 ≥ 1 - n · 1/n^2 = 1 - 1/n。

8.4 Chernoff 界的证明思路

使用矩母函数(Moment Generating Function)方法:

Pr[X ≥ a] = Pr[e^{tX} ≥ e^{ta}]          (t > 0,单调性)
           ≤ E[e^{tX}] / e^{ta}            (Markov 不等式)
           = ∏ E[e^{tX_i}] / e^{ta}        (独立性)

对每个 E[e^{tX_i}] 用 e^x ≤ 1 + x(e-1)(当 x ∈ {0,1})来上界,然后对 t 取最优。

8.5 实际应用模板

在分析随机化算法时,一个常见的模板是:

1. 定义"坏事件" B_1, B_2, ..., B_k
2. 用 Chernoff 界证明每个 Pr[B_i] ≤ very_small
3. 用 union bound 证明 Pr[∪B_i] ≤ still_small
4. 结论:以高概率,所有坏事件都不发生

“高概率”(with high probability, w.h.p.)通常指概率 ≥ 1 - 1/n^c,其中 c 是可以通过调参来增大的常数。

九、完整实现:整合测试驱动

将前面各节的 Freivalds 验证和 Karger 最小割整合为一个可编译运行的测试程序。核心算法实现参见第二节和第四节,这里给出驱动代码和测试用例。

/* randomized_algo_demo.c
 * 编译:gcc -O2 -o demo randomized_algo_demo.c -lm
 * 包含:Freivalds 矩阵验证(第二节)+ Karger 最小割(第四节)
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <math.h>
#include <limits.h>

/* ---- Freivalds 矩阵乘法验证 ---- */
#define MAX_N 200

void mat_mul(int n, double A[][MAX_N], double B[][MAX_N], double C[][MAX_N]) {
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++) {
            C[i][j] = 0;
            for (int k = 0; k < n; k++)
                C[i][j] += A[i][k] * B[k][j];
        }
}

void mat_vec(int n, double M[][MAX_N], double *v, double *out) {
    for (int i = 0; i < n; i++) {
        out[i] = 0;
        for (int j = 0; j < n; j++)
            out[i] += M[i][j] * v[j];
    }
}

int freivalds_verify(int n, double A[][MAX_N], double B[][MAX_N],
                     double C[][MAX_N], int rounds) {
    double r[MAX_N], Br[MAX_N], ABr[MAX_N], Cr[MAX_N];
    for (int t = 0; t < rounds; t++) {
        for (int i = 0; i < n; i++) r[i] = rand() % 2;
        mat_vec(n, B, r, Br);
        mat_vec(n, A, Br, ABr);
        mat_vec(n, C, r, Cr);
        for (int i = 0; i < n; i++)
            if (fabs(ABr[i] - Cr[i]) > 1e-9) return 0;
    }
    return 1;
}

/* ---- Karger 最小割 ---- */
#define KMAX_V 500
#define KMAX_E 50000

typedef struct { int u, v; } KEdge;
typedef struct {
    int n, m;
    KEdge edges[KMAX_E];
    int par[KMAX_V], rnk[KMAX_V];
} KGraph;

int kfind(KGraph *g, int x) {
    while (g->par[x] != x) { g->par[x] = g->par[g->par[x]]; x = g->par[x]; }
    return x;
}

void kunite(KGraph *g, int x, int y) {
    int rx = kfind(g, x), ry = kfind(g, y);
    if (rx == ry) return;
    if (g->rnk[rx] < g->rnk[ry]) g->par[rx] = ry;
    else if (g->rnk[rx] > g->rnk[ry]) g->par[ry] = rx;
    else { g->par[ry] = rx; g->rnk[rx]++; }
    g->n--;
}

void kinit(KGraph *g, int n) {
    g->n = n; g->m = 0;
    for (int i = 0; i < n; i++) { g->par[i] = i; g->rnk[i] = 0; }
}

void kadd(KGraph *g, int u, int v) {
    g->edges[g->m++] = (KEdge){u, v};
}

int karger_once(KGraph *orig) {
    KGraph g; memcpy(&g, orig, sizeof(KGraph));
    for (int i = g.m - 1; i > 0; i--) {
        int j = rand() % (i + 1);
        KEdge t = g.edges[i]; g.edges[i] = g.edges[j]; g.edges[j] = t;
    }
    for (int p = 0; g.n > 2 && p < g.m; p++) {
        int u = kfind(&g, g.edges[p].u), v = kfind(&g, g.edges[p].v);
        if (u != v) kunite(&g, u, v);
    }
    int cut = 0;
    for (int i = 0; i < g.m; i++)
        if (kfind(&g, g.edges[i].u) != kfind(&g, g.edges[i].v)) cut++;
    return cut;
}

int karger_mincut(KGraph *g, int reps) {
    int best = INT_MAX;
    for (int i = 0; i < reps; i++) {
        int c = karger_once(g);
        if (c < best) best = c;
    }
    return best;
}

/* ---- 测试 ---- */
int main(void) {
    srand((unsigned)time(NULL));

    /* Freivalds 测试 */
    printf("=== Freivalds 矩阵乘法验证 ===\n");
    int n = 4;
    double A[MAX_N][MAX_N] = {{1,2,3,4},{5,6,7,8},{9,10,11,12},{13,14,15,16}};
    double B[MAX_N][MAX_N] = {{1,0,0,0},{0,1,0,0},{0,0,1,0},{0,0,0,1}};
    double C[MAX_N][MAX_N];
    mat_mul(n, A, B, C);
    printf("A*I == A*I ? %s\n", freivalds_verify(n, A, B, C, 20) ? "通过" : "失败");
    C[0][0] += 1.0;
    printf("A*I == (A*I+扰动) ? %s\n",
           freivalds_verify(n, A, B, C, 20) ? "误报" : "检测到不等");

    /* Karger 测试:K4 完全图,最小割 = 3 */
    printf("\n=== Karger 最小割 ===\n");
    KGraph g; kinit(&g, 4);
    kadd(&g,0,1); kadd(&g,0,2); kadd(&g,0,3);
    kadd(&g,1,2); kadd(&g,1,3); kadd(&g,2,3);
    printf("K4 最小割: %d(期望 3)\n", karger_mincut(&g, 48));

    /* 含桥图,最小割 = 1 */
    KGraph g2; kinit(&g2, 6);
    kadd(&g2,0,1); kadd(&g2,1,4); kadd(&g2,0,4);
    kadd(&g2,1,2); // 桥
    kadd(&g2,2,3); kadd(&g2,2,5); kadd(&g2,3,5);
    printf("含桥图最小割: %d(期望 1)\n", karger_mincut(&g2, 180));

    return 0;
}

十、工程实践中的陷阱

10.1 随机数生成器的选择

场景 推荐 PRNG 原因
算法竞赛/原型 rand()、mt19937 简单快速,足够用
生产环境算法 xoshiro256**、PCG 统计质量好,速度快
密码学相关 /dev/urandom、CSPRNG 安全性要求
需要可重现 任意,固定种子 调试方便
分布式系统 独立种子或中央种子 避免相关性

10.2 常见陷阱

陷阱 说明 后果
rand() % n 的偏差 当 RAND_MAX 不是 n 的倍数时有偏差 均匀性被破坏
忘记设置随机种子 每次运行相同的”随机”序列 退化为确定性最坏情况
多线程共享 PRNG 竞态条件或锁竞争 性能下降或结果错误
用 time(NULL) 做种子 同一秒内启动的多个进程种子相同 相关性导致算法失效
浮点精度问题 随机化算法中比较相等性 误判,尤其在 Schwartz-Zippel 中
不够多的重复次数 理论需要 O(n^2 log n) 但实践中偷工减料 错误概率超出预期

10.3 rand() % n 的偏差修正

// 有偏差的实现
int biased_random(int n) {
    return rand() % n;  // 当 RAND_MAX+1 不是 n 的倍数时有偏差
}

// 无偏差的实现(rejection sampling)
int uniform_random(int n) {
    int limit = RAND_MAX - (RAND_MAX % n);
    int r;
    do {
        r = rand();
    } while (r >= limit);
    return r % n;
}

10.4 线程安全的 PRNG

#include <pthread.h>

// 每个线程维护独立的 PRNG 状态
typedef struct {
    uint64_t state;
} ThreadRNG;

// SplitMix64:简单高效的 PRNG
uint64_t splitmix64_next(ThreadRNG *rng) {
    uint64_t z = (rng->state += 0x9e3779b97f4a7c15ULL);
    z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL;
    z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL;
    return z ^ (z >> 31);
}

// 初始化:每个线程用不同的种子
void init_thread_rng(ThreadRNG *rng, int thread_id) {
    rng->state = (uint64_t)time(NULL) ^ ((uint64_t)thread_id << 32);
}

十一、随机化算法的哲学与个人思考

11.1 随机化不是偷懒

初学者常有一个误解:随机化算法是”碰运气”。事实恰恰相反。随机化算法的正确性分析往往比确定性算法更精细、更深刻。

我的理解是:确定性算法是”积极防御”——主动避开所有陷阱。随机化算法是”概率免疫”——不试图避开陷阱,而是证明掉入陷阱的概率极低

这就像疫苗:不是让你永远不接触病毒,而是让你接触后不会生病的概率极高。

11.2 随机性是一种资源

在计算复杂度理论中,随机性被视为一种计算资源,就像时间和空间一样。BPP(有界错误概率多项式时间)是否等于 P,是理论计算机科学最重要的开放问题之一。

目前的主流猜想是 BPP = P,即随机性不能提供本质上的计算能力。但即使如此,随机性在实践中仍然极其有用:

11.3 对抗性与平均情况

随机化算法的一个深刻优势:对手无法构造最坏情况输入

对于确定性算法,如果对手知道算法的代码,就可以精心构造一个输入使算法最慢。但对于随机化算法,即使对手知道算法的代码,由于随机选择是在运行时做出的,对手无法预测。

这在安全性场景中尤为重要——哈希表的随机化种子就是一个例子。

11.4 我的工程准则

经过多年实践,我对随机化算法的使用有以下准则:

  1. 能用随机化就用随机化:除非有严格的最坏情况要求(如实时系统),随机化版本几乎总是更简单、更快。

  2. 不要低估错误概率:2^{-100} 的错误概率比硬件故障低得多。在实践中,与其担心 Miller-Rabin 的错误概率,不如担心宇宙射线翻转内存位。

  3. 警惕伪随机数质量:算法的正确性依赖于随机数的质量。劣质 PRNG 可能导致意想不到的相关性。在生产环境中,至少使用 PCG 或 xoshiro 系列。

  4. 可重现性很重要:总是记录随机种子。当出现 bug 时,能用相同的种子复现问题是无价的。

  5. 重复次数要有理论依据:不要拍脑袋决定”重复 100 次够了”。做一下概率计算,确保错误概率符合需求。

十二、总结与展望

随机化算法是算法设计者工具箱中最锋利的武器之一。本文覆盖了以下内容:

主题 核心要点
Las Vegas vs Monte Carlo 前者结果正确时间随机,后者时间确定结果可能错误
Schwartz-Zippel 引理 多项式零点概率 ≤ d/|S|,应用广泛
随机化 QuickSelect 期望 O(n),比确定性 BFPRT 快 5 倍以上
Karger 最小割 随机收缩,成功概率 ≥ 2/n^2,Karger-Stein 改进到 O(n^2 log^3 n)
Miller-Rabin 单边错误 Monte Carlo,实践中几乎完美
分布式随机化 Ben-Or 共识、Power of Two Choices、指数退避
去随机化 条件期望法、有限独立性
分析工具 Chernoff 界 + Union Bound

随机化算法的发展远未结束。近年来的前沿方向包括:

记住:当你面对一个看起来很难的问题时,试着掷一枚硬币

参考文献

  1. Motwani, R., & Raghavan, P. (1995). Randomized Algorithms. Cambridge University Press.
  2. Mitzenmacher, M., & Upfal, E. (2017). Probability and Computing: Randomization and Probabilistic Techniques in Algorithms and Data Analysis. Cambridge University Press.
  3. Karger, D. R. (1993). Global min-cuts in RNC, and other ramifications of a simple min-cut algorithm. SODA.
  4. Karger, D. R., & Stein, C. (1996). A new approach to the minimum cut problem. Journal of the ACM, 43(4), 601-640.
  5. Schwartz, J. T. (1980). Fast probabilistic algorithms for verification of polynomial identities. Journal of the ACM, 27(4), 701-717.
  6. Freivalds, R. (1979). Fast probabilistic algorithms. MFCS, Lecture Notes in Computer Science, vol. 74.
  7. Miller, G. L. (1976). Riemann’s hypothesis and tests for primality. Journal of Computer and System Sciences, 13(3), 300-317.
  8. Rabin, M. O. (1980). Probabilistic algorithm for testing primality. Journal of Number Theory, 12(1), 128-138.
  9. Ben-Or, M. (1983). Another advantage of free choice: Completely asynchronous agreement protocols. PODC.
  10. Mitzenmacher, M. (2001). The power of two choices in randomized load balancing. IEEE Transactions on Parallel and Distributed Systems, 12(10), 1094-1104.

算法系列导航上一篇:SIMD 算法设计模式 | 下一篇:竞争分析与在线算法

相关阅读Treap 与跳表 | 最近点对与随机化几何算法


By .