PHP随机数不重复怎么实现:从数组中随机抽取元素

影视资讯15小时前发布 jdkjadmin
0 00
PHP随机数不重复怎么实现:从数组中随机抽取元素

PHP随机数不重复怎么实现:从数组中随机抽取元素

PHP 中“随机数不重复”常常不是要生成一串互不相同的数字,而是要从一个数组里随机抽取若干项,并且同一项不能被抽中两次。例如,从候选名单中抽取奖品获得者、从题库里随机选题、从商品列表中生成一组推荐内容。

这类需求的核心不是不断生成随机数再去重,而是明确“抽取单位”是什么,以及抽取后是否允许重复。只要把这两个问题处理清楚,就能避免重复、越界、数组键值混淆等常见问题。本文从简单写法讲到安全抽取,并说明适用场景和边界。

先区分三种“重复”

讨论代码之前,先把“重复”拆成三个不同概念:

  • 抽取位置不重复:同一个数组下标最多被选中一次。这通常是“无放回抽样”的含义。
  • 抽取的值不重复:结果中不能出现相同的值。即使原数组不同位置存放了相同内容,也只能选其中一个。
  • 生成的随机数字不重复:例如在 1 到 100 之间抽取 10 个互不相同的整数。这可以看成先生成一个候选数组,再从中无放回抽样。

数组中若有重复值,仅保证抽取位置不重复,不等于保证结果值不重复。例如 ["苹果", "苹果", "梨"] 的两个“苹果”位于不同位置。若两个位置都被抽中,结果中仍会有两个相同的字符串。

因此,编写抽取函数时,要先决定“唯一”是按数组位置判断,还是按值判断。奖品名单一般要按人或记录的唯一 ID 判断;抽取随机整数则通常按数字值判断。

最简单的方案:打乱数组后截取

如果需要抽取的数量较少,且不介意打乱整个数组,可以用 shuffle():先打乱,再取前几项。

<?php
$items = ["苹果", "香蕉", "梨", "橙子"];
$count = 2;

if ($count < 0 || $count > count($items)) {
    throw new InvalidArgumentException("抽取数量超出范围");
}

shuffle($items);
$result = array_slice($items, 0, $count);

var_dump($result);

array_slice() 在这里取出打乱后数组的前 $count 项,因此同一个数组位置不会被选中两次。若输入数组的值本身有重复,结果值仍可能重复。

要注意,shuffle() 会直接修改传入的数组,而且会重新排列数字下标。如果还要使用原始顺序或原始键,应当先复制数组,或者改用后文的抽样函数。PHP 的数组赋值采用写时复制机制,普通数组赋值后再修改副本通常不会立即改动原数组,但代码意图上仍建议明确区分输入和工作数组。

这个方法的时间与内存开销大致随整个数组规模增长。即使只抽取一项,也会先打乱整个数组。对于一般的小数组,这通常最简单;对于大数组只取少量元素,部分洗牌更合适。

按位置抽取:使用 array_rand()

array_rand() 用于随机返回数组中的一个或多个键。拿到键之后,再从原数组取出对应值:

<?php
$items = [
    "apple" => "苹果",
    "banana" => "香蕉",
    "pear" => "梨",
];

$key = array_rand($items);
$value = $items[$key];

echo $key . ": " . $value;

若要一次抽取多个键,可以给 array_rand() 传入数量:

<?php
$items = ["苹果", "香蕉", "梨", "橙子"];
$count = 2;

if ($count < 0 || $count > count($items)) {
    throw new InvalidArgumentException("抽取数量超出范围");
}

$keys = array_rand($items, $count);
$result = [];

foreach ($keys as $key) {
    $result[] = $items[$key];
}

var_dump($result);

array_rand() 抽取的是数组键,不是数组值,也不会修改原数组。它的返回形式有一个容易忽略的差异:数量为 1 时返回单个键;数量大于 1 时返回包含多个键的数组。上面的多项示例在确定 $count 大于 1 时可以直接遍历;如果函数允许数量为 1,则应当统一返回形式,或者单独处理该情况。

例如,要把返回值统一成数组,可以这样写:

<?php
$keys = array_rand($items, $count);
$keys = is_array($keys) ? $keys : [$keys];

$result = [];
foreach ($keys as $key) {
    $result[] = $items[$key];
}

调用前应当验证数组非空,并确保抽取数量在可接受范围内。数量为 0 不适合作为 array_rand() 的有效抽取请求;若业务允许“抽取 0 个”,应在调用它之前直接返回空数组。数量大于数组元素个数时也无法完成抽取。

array_rand() 返回的多个键不应被假定为原数组顺序,也不应依赖某种固定顺序。如果业务要求结果按原数组顺序排列,需要另行排序或依据原始键的位置整理。

推荐封装:部分 Fisher–Yates 洗牌

只抽取少量元素时,可以只处理最终需要的部分,而不是打乱整个数组。下面的函数使用部分 Fisher–Yates 算法:每一轮从尚未选取的区间里随机选一个位置,将它换到已选区间的末尾,然后继续下一轮。

代码使用 random_int() 生成区间内的随机整数,适用于 PHP 7 及以上版本。函数返回的是值组成的数字下标数组,不保留输入数组的原键。

<?php
declare(strict_types=1);

/**
 * 从数组中无放回抽取若干项。
 *
 * 按数组位置保证不重复;如果输入值有重复,结果值仍可能重复。
 *
 * @param array $items 候选数组
 * @param int   $count 抽取数量
 * @return array 抽取结果,使用从 0 开始的数字下标
 */
function sampleWithoutReplacement(array $items, int $count): array
{
    if ($count < 0) {
        throw new InvalidArgumentException("抽取数量不能为负数");
    }

    $values = array_values($items);
    $size = count($values);

    if ($count > $size) {
        throw new InvalidArgumentException("抽取数量不能超过数组元素个数");
    }

    if ($count === 0) {
        return [];
    }

    for ($i = 0; $i < $count; $i++) {
        $j = random_int($i, $size - 1);

        $temporary = $values[$i];
        $values[$i] = $values[$j];
        $values[$j] = $temporary;
    }

    return array_slice($values, 0, $count);
}

$items = [
    "用户甲",
    "用户乙",
    "用户丙",
    "用户丁",
    "用户戊",
];

$winners = sampleWithoutReplacement($items, 3);

var_dump($winners);
var_dump($items); // 原数组保持不变

代码中的 array_values() 把输入数组整理为从 0 开始的连续数字下标。这很重要:PHP 数组可以使用字符串键,也可以使用不连续的数字键;若直接用位置变量访问任意输入数组,可能访问到不存在的键。整理后,$i 到 $size - 1 就始终是有效下标。

循环第 $i 轮时,前面已经确定的 $i 项不再参与后续抽取。random_int($i, $size - 1) 会从未处理区间中选择一个下标,将其与当前位置交换。循环结束后,前 $count 项就是抽取结果。

当 $count 为 0 时,函数返回空数组;当数量为负数,或大于候选项数量时,函数抛出 InvalidArgumentException。这比静默截断或返回不完整结果更容易让调用方发现参数错误。生产代码也可以按项目规范改为返回错误对象或使用其他异常类型,但应保留明确的边界处理。

若抽取数量等于数组长度,函数会返回全部元素,只是顺序经过随机化。若数量为 1,则返回只含一项的数组,而不是单个值,这让函数的返回类型保持一致。

如果要求抽取的值也不重复

当输入数组含重复值,而业务要求结果值唯一时,需要先按业务定义去重,再抽取。对于字符串或整数等简单值,可以使用 array_unique():

<?php
$items = ["苹果", "苹果", "香蕉", "梨"];

$uniqueItems = array_values(array_unique($items));
$result = sampleWithoutReplacement($uniqueItems, 2);

var_dump($result);

这里的“去重”规则由 array_unique() 决定,不适合所有数据结构。尤其是数组或对象代表数据库记录时,应该按业务唯一标识去重,而不是直接比较显示文本。例如两名不同用户可能有相同姓名,仅按姓名去重会错误地把其中一人排除。

按 ID 去重可以构造以 ID 为键的集合:

<?php
$users = [
    ["id" => 101, "name" => "小林"],
    ["id" => 102, "name" => "小林"],
    ["id" => 101, "name" => "小林"],
];

$uniqueUsers = [];
foreach ($users as $user) {
    $uniqueUsers[$user["id"]] = $user;
}

$uniqueUsers = array_values($uniqueUsers);
$result = sampleWithoutReplacement($uniqueUsers, 2);

var_dump($result);

这个例子按用户 ID 保留一份记录。若同一个 ID 对应多份内容,应先确定保留哪一份,而不是依赖键覆盖的偶然结果。

抽取随机整数:不要用“随机后去重”当作首选

如果目标是从一个有限整数范围中抽取不重复数字,可以把范围作为候选数组来处理。例如从 1 到 100 抽取 5 个数:

<?php
$numbers = range(1, 100);
$result = sampleWithoutReplacement($numbers, 5);

var_dump($result);

若范围非常大但只抽少量数字,构造完整的 range() 数组会占用较多内存。这时可以直接在整数区间上执行部分 Fisher–Yates 思路,或者采用适合稀疏抽样的算法;应根据范围大小、抽取数量及性能要求选择。不要先反复调用 rand() 生成数字,再用循环“碰到重复就重抽”作为通用方案:抽取数量接近范围大小时,重复概率会显著增加,循环次数难以预测,极端情况下实现也容易出错。

对于范围较小且抽取数量较少,创建候选数组并调用通用抽样函数最易读、也容易验证。大范围场景则应测量内存和耗时,并把范围上下限、数量限制和随机来源一并纳入设计。

保留原数组键的做法

上面的函数为了简化结果,把键重排为连续数字下标。如果需要保留原键,可以对键本身进行无放回抽样,再按键取值。下面的函数保留关联数组的键值对应关系,返回结果仍是关联数组:

<?php
function sampleAssociativeWithoutReplacement(array $items, int $count): array
{
    if ($count < 0) {
        throw new InvalidArgumentException("抽取数量不能为负数");
    }

    $keys = array_keys($items);
    $size = count($keys);

    if ($count > $size) {
        throw new InvalidArgumentException("抽取数量不能超过数组元素个数");
    }

    for ($i = 0; $i < $count; $i++) {
        $j = random_int($i, $size - 1);

        $temporary = $keys[$i];
        $keys[$i] = $keys[$j];
        $keys[$j] = $temporary;
    }

    $result = [];
    for ($i = 0; $i < $count; $i++) {
        $key = $keys[$i];
        $result[$key] = $items[$key];
    }

    return $result;
}

$items = [
    "sku-A" => "键盘",
    "sku-B" => "鼠标",
    "sku-C" => "显示器",
];

var_dump(sampleAssociativeWithoutReplacement($items, 2));

此版本同样适用于 $count === 0:循环不会执行,结果为空数组。它抽取的是不同数组键,因此即使两个键对应相同值,也可能同时出现在结果中。若要按值或记录 ID 保证唯一,仍需先进行相应去重。

随机性与安全用途

PHP 中常见的 shuffle()、array_rand()、rand() 和 mt_rand() 适合一般程序逻辑中的随机选择,但不要据此假设它们适用于密码、令牌、验证码密钥或其他安全敏感用途。随机选择是否安全,取决于随机数来源和整个业务流程,而不只是“结果看起来够随机”。

random_int() 用于生成给定闭区间内的均匀分布整数,并使用密码学安全的随机数来源;当前运行环境无法提供合适随机源时会抛出异常,而不是保证永远返回一个值。上面的抽样算法用它选择每轮的下标,因此适合需要密码学安全随机来源的抽样实现。异常应由调用方按业务需要记录、处理或向上抛出,不应在失败时悄悄退回 rand()。

使用 random_int() 并不自动让整个抽奖系统变得公平、可审计或不可操纵。候选名单是否完整、是否能在抽取后修改、如何记录抽取过程、如何防止重复参与,以及结果如何验证,都属于额外的系统设计问题。涉及奖金、金融或合规要求时,还应根据业务和适用规则设计审计机制,不能只依赖一段随机抽样代码。

若场景并不涉及安全需求,只是普通展示或游戏中的简单随机选择,使用 shuffle() 或 array_rand() 通常更简洁。但当结果关系到访问凭证、秘密值或安全决策时,应选择安全随机来源,并避免把普通随机函数产生的值用于安全目的。

常见错误与排查

1. 用随机下标抽取,却没有处理关联数组

下面这种写法仅在数组下标恰好是连续的 0 到 count($items) - 1 时才可靠:

<?php
$index = random_int(0, count($items) - 1);
$value = $items[$index];

如果数组键是 "user-1"、"user-2",或者数字键不连续,$items[$index] 可能不存在。处理方式是先用 array_values() 重排,或者随机抽取 array_keys() 中的键。

2. 忘记空数组和数量边界

空数组没有可抽取元素。若直接计算 count($items) - 1 并传给 random_int(),范围会无效并产生异常。抽取前要先验证数组大小与数量,尤其要约定清楚 0 个元素、0 个抽取数量、负数数量以及超量请求的行为。

3. 只对结果调用去重

先抽取、再对结果调用 array_unique(),可能导致最终结果少于要求数量。例如要求抽 5 项,结果去重后只剩 4 项。若业务要求值唯一,应在抽取前按正确的唯一规则整理候选集合,并在去重之后再次检查可抽取数量。

4. 误以为不重复就等于均匀

一个结果没有重复,不代表抽样过程公平。若每轮总是取第一个元素,虽然没有重复,却有明显偏差。Fisher–Yates 算法每一轮都从剩余候选范围内均匀选择,并且依赖所用随机数生成器的性质;正确处理区间是实现均匀抽样的重要组成部分。

5. 把返回值的类型当成固定形式

array_rand($items) 返回单个键;传入多个抽取数量时返回多个键组成的数组。混用两种调用方式却不检查类型,可能导致后续遍历或下标访问出错。封装函数并统一返回数组,通常更容易维护。

6. 忽略键的变化

shuffle() 会重新排列数组并重置数字下标;array_values() 也会丢弃原键。如果这些键代表商品 ID、用户 ID 或其他业务标识,不要把重排后的数字下标误当成原标识。需要保留关联关系时,抽取原键并用原键取值。

7. 把随机抽取误当成唯一性校验

随机抽样只负责从当前候选集合中选择元素,不负责判断一个用户是否已经中奖、一个号码是否已发放,或一个请求是否重复提交。多进程、并发请求或跨服务器场景下,唯一性通常要由数据库约束、事务或可靠的业务状态管理保证。单纯在 PHP 内存中检查,无法解决并发竞争。

如何选择实现方式

  • 小数组、抽取数量较多,且可以改变数组顺序:使用 shuffle() 后截取,代码最简单。
  • 从数组中抽取键,且希望直接复用 PHP 内置函数:使用 array_rand(),留意抽取 1 项和多项时的返回类型差异。
  • 只抽取少量项目,或希望封装清晰的无放回抽样:使用部分 Fisher–Yates 算法,按剩余候选区间逐轮抽取。
  • 需要安全随机来源:使用 random_int() 选择下标,并为随机源不可用等异常设计处理方式。
  • 结果值必须唯一:先按业务定义去重,再进行无放回抽样;不要只依赖抽取位置不重复。
  • 需要保留关联键:抽取数组键,再按键取值,不要把重排后的下标当作业务 ID。

归根结底,“从数组中随机抽取不重复元素”应当先定义抽取单位,再选择算法。对于多数 PHP 项目,array_rand() 足以处理简单需求;需要统一的边界行为、可预测的返回结构或安全随机来源时,可以使用部分 Fisher–Yates 封装。无论采用哪种实现,都要在调用前处理空数组、数量上下限、重复值语义和关联键问题,这些边界往往比随机函数本身更容易引发故障。

© 版权声明

相关文章