每日算法 — 使用java实现洗牌算法:Fisher-Yates随机排列与公平性验证

洗牌算法是计算机科学中最经典且实用的随机算法之一,其目标是将一组元素随机重排,使得每一种排列出现的概率完全相等。一个看似简单的需求,却曾因早期实现中的常见错误(如Firefox浏览器的伪随机漏洞)引发了严重的安全问题。本文将深入讲解Fisher-Yates洗牌算法(又称Knuth洗牌),分析其正确性证明,并提供完整的Java实现与公平性验证方法。

算法原理与正确性

Fisher-Yates洗牌算法的核心思想是从后向前遍历数组,对于位置i,从[0, i]范围内随机选择一个元素与位置i交换。这种”原地置换”策略确保了:

  1. 均匀性:n个元素的n!种排列中,每种排列出现的概率恰好为1/n!
  2. 原地性:仅需O(1)额外空间
  3. 线性时间:仅需O(n)次操作

正确性归纳证明

假设算法对长度为k的数组能生成均匀随机排列。对于长度为k+1的数组:
– 最后一个位置k从k+1个元素中等概率选择一个
– 剩余k个元素由归纳假设形成均匀随机排列
– 因此整体排列概率为(1/(k+1)) × (1/k!) = 1/(k+1)!

常见错误与陷阱

错误实现:从整个范围选择

// 错误!这种实现会产生偏置
for (int i = 0; i < n; i++) {
    int j = random.nextInt(n); // 错误:应从[0,i]选择
    swap(arr, i, j);
}

这种错误实现会产生n^n种可能结果,但n!通常不能整除n^n,导致某些排列概率更高。

模运算偏置

使用random() % n而非random.nextInt(n)会引入模偏置,当随机数范围不是n的整数倍时,前几个数字概率略高。

Java实现

import java.util.*;

/**
 * Fisher-Yates洗牌算法完整实现
 * 包含标准洗牌、有偏检测与多种验证方法
 */
public class FisherYatesShuffle {

    private final Random random;

    public FisherYatesShuffle() {
        this.random = new Random();
    }

    public FisherYatesShuffle(long seed) {
        this.random = new Random(seed);
    }

    /**
     * 标准Fisher-Yates洗牌算法(Knuth Shuffle)
     * 时间复杂度: O(n)
     * 空间复杂度: O(1)
     * @param array 待洗牌数组(会被原地修改)
     */
    public <T> void shuffle(T[] array) {
        if (array == null || array.length <= 1) {
            return;
        }

        // 从后向前遍历,也可从前向后
        for (int i = array.length - 1; i > 0; i--) {
            // 关键:从[0, i]范围内均匀随机选择
            int j = random.nextInt(i + 1);
            swap(array, i, j);
        }
    }

    /**
     * 对int数组进行洗牌
     */
    public void shuffle(int[] array) {
        if (array == null || array.length <= 1) return;
        for (int i = array.length - 1; i > 0; i--) {
            int j = random.nextInt(i + 1);
            int temp = array[i];
            array[i] = array[j];
            array[j] = temp;
        }
    }

    /**
     * 对List进行洗牌(返回新列表,不修改原列表)
     */
    public <T> List<T> shuffleCopy(List<T> list) {
        List<T> copy = new ArrayList<>(list);
        Collections.shuffle(copy, random);
        return copy;
    }

    /**
     * 带权重的随机抽样(水库抽样变体)
     * 从流式数据中均匀随机选取k个元素
     */
    public <T> List<T> reservoirSample(Iterator<T> stream, int k) {
        List<T> reservoir = new ArrayList<>(k);
        int count = 0;

        // 填充前k个元素
        while (count < k && stream.hasNext()) {
            reservoir.add(stream.next());
            count++;
        }

        // 对后续元素以k/n概率替换
        while (stream.hasNext()) {
            T item = stream.next();
            count++;
            int j = random.nextInt(count);
            if (j < k) {
                reservoir.set(j, item);
            }
        }

        return reservoir;
    }

    /**
     * 交换数组中两个元素
     */
    private <T> void swap(T[] array, int i, int j) {
        T temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    /**
     * 验证洗牌的均匀性(卡方检验)
     * @param elementCount 元素数量(使用小数字便于统计,如4或5)
     * @param trials 试验次数
     * @return 卡方统计量与是否通过检验
     */
    public static class UniformityResult {
        public final double chiSquare;
        public final boolean passed;
        public final double pValue;

        public UniformityResult(double chiSquare, boolean passed, double pValue) {
            this.chiSquare = chiSquare;
            this.passed = passed;
            this.pValue = pValue;
        }
    }

    /**
     * 通过枚举所有排列频率验证均匀性
     */
    public UniformityResult testUniformity(int elementCount, int trials) {
        if (elementCount > 8) {
            throw new IllegalArgumentException("元素过多会导致排列爆炸,建议elementCount <= 8");
        }

        // 初始化有序数组
        Integer[] base = new Integer[elementCount];
        for (int i = 0; i < elementCount; i++) {
            base[i] = i;
        }

        // 统计每种排列出现的次数
        Map<String, Integer> frequencyMap = new HashMap<>();
        int totalPermutations = factorial(elementCount);
        double expectedFrequency = (double) trials / totalPermutations;

        FisherYatesShuffle shuffler = new FisherYatesShuffle();

        for (int t = 0; t < trials; t++) {
            Integer[] copy = base.clone();
            shuffler.shuffle(copy);
            String key = Arrays.toString(copy);
            frequencyMap.put(key, frequencyMap.getOrDefault(key, 0) + 1);
        }

        // 卡方检验
        double chiSquare = 0;
        for (int count : frequencyMap.values()) {
            chiSquare += Math.pow(count - expectedFrequency, 2) / expectedFrequency;
        }

        // 自由度 = 排列数 - 1
        int degreesOfFreedom = totalPermutations - 1;
        // 简化p值计算(使用卡方分布近似)
        double pValue = approximatePValue(chiSquare, degreesOfFreedom);
        boolean passed = pValue > 0.05; // 95%置信水平

        return new UniformityResult(chiSquare, passed, pValue);
    }

    /**
     * 检测常见错误实现的有偏性
     * 对比正确实现与错误实现的分布差异
     */
    public void demonstrateBias() {
        int n = 4;
        int trials = 1000000;

        // 正确实现统计
        Map<String, Integer> correctFreq = new HashMap<>();
        Integer[] arr = {0, 1, 2, 3};
        FisherYatesShuffle correct = new FisherYatesShuffle(12345);

        for (int t = 0; t < trials; t++) {
            Integer[] copy = arr.clone();
            correct.shuffle(copy);
            correctFreq.merge(Arrays.toString(copy), 1, Integer::sum);
        }

        // 错误实现统计(从整个范围选择)
        Map<String, Integer> wrongFreq = new HashMap<>();
        Random rand = new Random(12345);

        for (int t = 0; t < trials; t++) {
            Integer[] copy = arr.clone();
            for (int i = 0; i < n; i++) {
                int j = rand.nextInt(n);
                Integer temp = copy[i];
                copy[i] = copy[j];
                copy[j] = temp;
            }
            wrongFreq.merge(Arrays.toString(copy), 1, Integer::sum);
        }

        System.out.println("=== 正确实现(Fisher-Yates) ===");
        correctFreq.entrySet().stream()
            .sorted(Map.Entry.comparingByValue())
            .forEach(e -> System.out.printf("%s: %d (%.3f%%)%n", 
                e.getKey(), e.getValue(), e.getValue() * 100.0 / trials));

        System.out.println("\n=== 错误实现(范围选择偏置) ===");
        wrongFreq.entrySet().stream()
            .sorted(Map.Entry.comparingByValue())
            .forEach(e -> System.out.printf("%s: %d (%.3f%%)%n", 
                e.getKey(), e.getValue(), e.getValue() * 100.0 / trials));

        // 计算错误实现的偏置程度
        double expected = trials / 24.0; // 4! = 24
        double maxDeviation = wrongFreq.values().stream()
            .mapToDouble(v -> Math.abs(v - expected))
            .max().orElse(0);
        System.out.printf("%n错误实现最大偏差: %.1f (期望: %.1f)%n", maxDeviation, expected);
    }

    private static int factorial(int n) {
        int result = 1;
        for (int i = 2; i <= n; i++) {
            result *= i;
        }
        return result;
    }

    /**
     * 卡方分布p值近似(简化版)
     */
    private double approximatePValue(double chiSquare, int df) {
        // 使用不完全伽马函数的简化近似
        if (chiSquare < df) {
            return 1.0;
        }
        return Math.exp(-(chiSquare - df) / (2 * Math.sqrt(df)));
    }

    public static void main(String[] args) {
        FisherYatesShuffle shuffler = new FisherYatesShuffle();

        // 演示1:基本洗牌
        System.out.println("=== 基本洗牌演示 ===");
        Integer[] cards = new Integer[52];
        for (int i = 0; i < 52; i++) cards[i] = i + 1;

        System.out.println("洗牌前: " + Arrays.toString(Arrays.copyOf(cards, 10)) + "...");
        shuffler.shuffle(cards);
        System.out.println("洗牌后: " + Arrays.toString(Arrays.copyOf(cards, 10)) + "...");

        // 演示2:均匀性验证
        System.out.println("\n=== 均匀性验证 (4个元素, 100万次试验) ===");
        UniformityResult result = shuffler.testUniformity(4, 1000000);
        System.out.printf("卡方值: %.4f%n", result.chiSquare);
        System.out.printf("P值: %.4f%n", result.pValue);
        System.out.println("是否通过均匀性检验: " + result.passed);

        // 演示3:有偏对比
        System.out.println("\n=== 正确实现 vs 错误实现 ===");
        shuffler.demonstrateBias();

        // 演示4:水库抽样
        System.out.println("\n=== 水库抽样演示 ===");
        List<String> stream = Arrays.asList("A", "B", "C", "D", "E", "F", "G", "H", "I", "J");
        List<String> sample = shuffler.reservoirSample(stream.iterator(), 3);
        System.out.println("从10个元素中抽样3个: " + sample);
    }
}

复杂度分析

  • 标准Fisher-Yates:时间复杂度O(n),空间复杂度O(1)
  • 均匀性验证:需要统计n!种排列,时间复杂度O(trials),空间复杂度O(n!)
  • 水库抽样:单次遍历O(n),空间复杂度O(k)
  • 错误实现检测:时间复杂度O(trials × n)

应用场景扩展

Fisher-Yates洗牌算法广泛应用于:扑克牌游戏发牌、音乐播放器随机播放、A/B测试分组、密码学中的随机置换、机器学习中的训练集乱序等。理解其正确实现与常见错误,是每一位开发者避免”随机性陷阱”的必修课。