等概率随机取数算法的几种实现(洗牌算法)

发布时间 2023-06-27 21:43:00作者: 白露~

等概率随机取数算法的几种实现
  最近读了项目中的工具脚本,发现一个随机取数的函数,功能大概是从M个数中不重复的随机取出N个数,算是数组随机排序然后取前N个值的变种。

  脚本实现采取原始的方法,每随机取一个数就放到一个数组中,下次取数时遍历结果数组判断是否已经取出,平均时间复杂度为O(MlogM),空间复杂度O(N),效率不高。

  想了一下解决方案,能优化的地方应该就是将遍历数组判断是否取出使用哈希或者红黑树实现,以空间换时间,虽然可以降低时间复杂度,但原算法仍存在问题:当M接近于N的时候,效率会急剧下降,十分恐怖。

  如果借助洗牌算法,获取一个随机排列的子集,便能实现等概率随机取数的功能。

1. Fisher-Yates Shuffle算法
  最早于1938年由Ronald Fisher和Frank Yates所著《Statistical tables for biological,agricultural and medical research》提出,算法描述为:

将1到N数字存到数组中
从数组中取一个1到剩下数字个数的随机数k
从低位开始,将数组第k个数字取出,并保存到结果数组末尾
重复第2步,直到所有数字都被取出
第3步得到的结果数组就是所求的随机序列
该算法存在对数组随机元素的删除操作,时间复杂度O(N^2),空间复杂度O(M),效率仍然比较低。

2. Knuth-Durstenfeld Shuffle算法
  Knuth和Durstenfeld在Fisher等人的基础上进行了改进。每次随机取出数字后不是从数组中删除,而是与数组尾部进行交换,时间复杂度提升到O(N)。

lua实现

 

-- Knuth-Durstenfeld Shuffle算法,时间复杂度O(N),不需要额外空间
function shuffle(array)
    local len = #array -- 取array的长度
    while len > 0 do
        local r = math.random(1,len)
        -- 交换
        t[r],t[len] = t[len],t[r]
        -- 待排列数组长度减小
        len = len - 1
    end     
    return array
end
-- 等概率随机取数算法
function random_N_not_repeated(array,n)
    local len = #array
    if n > len then return array end
    local result = {}
    for v = 1 , n do
        local r = math.random(1,len)
        table.insert(result,array[r])
        -- 交换
        array[r] = array[len]
        -- 待取数组长度减小
        len = len - 1
    end
    return result
end

  




3. Inside-Out算法
  Knuth-Durstenfeld Shuffle是一个in-place算法,会改变原始数据的顺序,而有些场景中我们需要保留原始数据,因此需要额外的空间存储打乱的序列。

  Inside-Out算法思想是设置一个游标i从前向后扫描原始数据的拷贝,在[0,i]之间随机一个下标j,然后用位置j的元素替换掉位置i的数字,再用原始数据位置i的元素替换掉拷贝数据位置j的元素。其作用相当于在拷贝数据中交换i与j位置处的值。与直接从复制数组进行交换相比少一次交换操作,时间复杂度O(N)。

lua实现

-- Inside-Out 算法,时间复杂度O(N)
function shuffle(array)
    local len = #array
    local result = deep_copy(array)
    for i = 1 , len do
        r = math.random(1,i)
        result[i] = result[j]
        result[j] = array[i]
    end
    return result
end
-- 等概率随机取数算法
function random_N_not_repeated(array,n)
    local m = #array -- 取array的长度
    -- 集合数目不够抽取数目
    if m <= n then
        return t
    end

    local result = array

    for i = 1 , n do
        j = math.random(1,i)
        result[i] = result[j]
        result[j] = array[i]
    end

    return result
end

  

拓展:数据流等概率取数(蓄水池抽样问题 Reservoir Sampling)
  上述算法均需要一个额外的辅助空间,但实际环境中可能会遇到无法完全存储的海量数据流,从这个海量数据流中等概率取出N个数的思路略有不同,当我们无法确定数据流的个数,即M的大小时,无法使用上述方法进行随机取数,这就是数据科学中常见的蓄水池抽样的问题,算法实现描述为:

将数据流前N个数字存到数组中
从数据流的第N+1个数开始,取出数据流中的一个数。
假设这个数索引为K,则以N/K的概率选中该数。
如果该数选中,则随机替换数组中的一个记录。
重复第2步直到数据流结束。
参考文献
1.Fisher-Yates shuffle 洗牌算法

2.洗牌算法shuffle

3.蓄水池抽样-《编程珠玑读书笔记》