news 2026/11/5 3:42:26

纯数组+递归实现不重复随机编号的完整思路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯数组+递归实现不重复随机编号的完整思路

看到一个很有意思的算法题,标题很长:“(纯数组+递归写法)目前有100名囚犯,每个囚犯的编号是1-200之间的随机数。现在要求依次随机生成100名囚犯的编号(要求这些囚犯的编号是不能重复的),然后让他们依次站成一排。”说白了就一句话:用纯数组加递归,在1到200之间给100个囚犯随机分配不重复的编号,并让他们按生成顺序站成一排。

这个题我看了三遍,越看越觉得适合拿来写一篇完整复盘。知识点不深,但查重、递归状态传递、随机数边界、复杂度估算、调用栈安全这些问题全都能串起来,跟平时写抽奖、工号分配、优惠券去重这些业务场景密切相关。如果你正在刷题或者想巩固基础,这篇文章可以给你一套能直接跑起来的思路和代码。

1. 先把题目翻译成人话:它到底想让你干什么

1.1 拆需求:四个关键字对应四个考点

题目原文是:

(纯数组+递归写法)目前有100名囚犯,每个囚犯的编号是1-200之间的随机数。现在要求依次随机生成100名囚犯的编号(要求这些囚犯的编号是不能重复的),然后让他们依次站成一排。

我拆成了四个关键信息,每个背后都藏着一个考点,整理成表格更直观。

题干关键词表面含义隐藏考点
100名囚犯人数固定递归/循环的终止条件
1-200随机数编号区间随机数上下界处理
编号不能重复去重约束冲突检测与重试策略
纯数组+递归实现手段数组查重、状态传递、递归边界

很多人第一眼看到“100名囚犯,编号1-200”,脑子里立刻蹦出“抽屉原理”:100个人用200个号,空出来100个,所以只要处理不重复就行。这个直觉对,但只对了一半。真正要紧的是“依次随机生成”这六个字——每生成一个编号,就要立刻拿去跟已有的所有编号比较,相同就重新生成,不同才让下一个人录用。整道题的核心复杂度全部压在“冲突检测”和“重试策略”上。

1.2 一个容易被忽略的前提:编号范围必须不小于人数

这道题是100人对200个号,注定能完成。但如果你把人数改成200人、编号范围还是1-200,那最后一个编号的成功概率就变成了 1/200,平均要试两百次。要是人数超过范围,比如150人抢100个号,那就是无解,无论什么算法都不可能让每个人拿到不同编号。

我写这类逻辑有个习惯:先做前置条件断言。在递归开始前检查人数是否超过编号范围,如果超了,直接抛异常,避免进到递归里死循环。很多人写代码不看这个前提,把参数调大之后程序卡死,还以为是递归出错了,折腾半天才发现是数学上就不可能。

2. 为什么偏偏要“纯数组+递归”:限制背后的设计逻辑

2.1 “纯数组”挡掉了什么:Set 和隐式哈希

在真实工程里,给一列数据去重,最顺手的一定是 Set。一个new Set()加个size判断,一秒钟搞定。但题目特意点出“纯数组”,就是不想让你走这条捷径。数组没有哈希能力,你只能在它身上做两种操作:遍历比较,或者用值作为下标去定位。这两种操作对应两种截然不同的查重策略。

  • 线性查重:每生成一个新编号,遍历已有数组逐个比对。零额外空间,但时间复杂度 O(n²)。
  • 标志数组:新开一个长度为201的布尔数组,用编号作下标,命中即占用。这是“数组下标就是哈希键”的经典思路,查重降到 O(1)。

很多人看到“纯数组”就以为只能用数组保存结果,其实不是。题目限制的是不能借助 Set、Map、对象这些高级结构,但并没有禁止辅助数组。在不引入哈希表的前提下,标志数组就是最快也最符合题意的实现。我建议两个版本都写一遍,先线性后标志,对比一下性能,你才会真正理解什么叫“用空间换时间”。

2.2 递归替代循环:用调用栈模拟“试错-重试-前进”

循环写法很好理解:while 结果长度不到100,就一直生成、查重、再生成。递归的写法则是把“剩余待生成人数”作为状态参数,每次调用只生成一个编号,成功则剩余人数减一,继续调用;失败则剩余人数不变,在当前函数里重新生成。

这种写法的最大好处是,控制流完全变成数据流。你不需要在循环里维护额外状态变量,所有状态都显式挂在函数参数上。配合数组的 push 操作,一次成功对应一次 push,一次失败对应一次“原地重试”。调试的时候在函数入口打个断点,整个随机过程看得清清楚楚,每一步的结果都对应一个调用层级。

2.3 洗牌算法那么好,为什么这里不适合用

看到这种题,脑回路快的朋友一定会想到 Fisher-Yates 洗牌:先造一个1-200的完整数组,从后往前随机交换,最后取前100个。时间复杂度 O(n),保证不重复,公认的最优解之一。但这里用洗牌算法,跟题意有语义冲突。

题目要求的是“依次随机生成”,强调的是逐个产生的过程,而不是“从现成的牌堆里抽出100张”。洗牌算法先私有化一个完整序列再取样,把冲突检测这个环节直接掩盖掉了。出题人如果特意强调“纯数组+递归”,想考察的就是你在不借助随机抽样算法时,能不能自己处理碰撞。所以即便洗牌在效率上更优,在这道题里也只能当背景板。

3. 从递归骨架到可运行代码:完整实现拆开讲

3.1 递归函数的设计:三个问题必须先回答

不管写哪个版本,递归函数都要先回答清楚三个问题:函数需要哪些参数,基线条件是什么,递推关系怎么写。我的设计是:

function generate(remain, rangeLimit, output) { if (remain === 0) return; const candidate = randomIn(1, rangeLimit); if (contains(output, candidate)) { generate(remain, rangeLimit, output); } else { output.push(candidate); generate(remain - 1, rangeLimit, output); } }

remain表示还有几个人没拿到编号,rangeLimit是编号上限,output是结果数组。基线条件是remain === 0,说明100个人都站好了。每次调用先生成候选编号,再用contains检查是否被占用。占用就递归重试,不占用就推进。

这里有一个很重要的经验:把随机数生成函数randomIn和查重函数contains单独抽出来。边界条件、随机数逻辑、查重逻辑三者如果混在一起,调试起来会非常痛苦;拆开之后,每个小函数都能单独验证。

3.2 线性查重版本:正宗的“纯数组”思路

先看最低配版本,它贯彻了“不借助任何额外结构”的原则:

const row = []; function randomIn(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function contains(arr, num) { for (let i = 0; i < arr.length; i++) { if (arr[i] === num) return true; } return false; } function generate(remain, rangeLimit, output) { if (remain === 0) return; const candidate = randomIn(1, rangeLimit); if (contains(output, candidate)) { generate(remain, rangeLimit, output); } else { output.push(candidate); generate(remain - 1, rangeLimit, output); } } generate(100, 200, row); console.log(row);

核心逻辑就是:候选编号生成后,先跟row里已经站好的人逐个比对。这相当于让新来的囚犯挨个检查前面的人有没有拿同号,撞了就重抽。全过程不消耗额外空间,最坏情况时间复杂度 O(n²),但100人的规模实际跑起来毫秒级完成,完全没问题。

我在本机跑了一次,截取前20个输出:

[87, 193, 15, 121, 76, 3, 102, 188, 45, 174, 23, 144, 66, 199, 8, 111, 59, 137, 31, 152, ...]

一眼看过去数字分布很散,没有明显聚集,符合1-200随机整数该有的样子。验证方式就三条:长度必须100,所有值在1到200之间,集合去重后数量也必须是100。我在代码里单独写了个assertResult()来做这三次校验,每次跑完自动检查,比自己肉眼靠谱得多。

3.3 标志数组版本:编号即下标,查重降到 O(1)

线性查重最大的消耗在contains要遍历整个数组。优化思路很直接:编号范围固定是1到200,那就用一个长度为201的辅助数组,把编号当下标直接定位。

const row = []; const used = new Array(201).fill(false); function randomIn(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function generate(remain, rangeLimit, output, usedFlags) { if (remain === 0) return; const candidate = randomIn(1, rangeLimit); if (usedFlags[candidate]) { generate(remain, rangeLimit, output, usedFlags); } else { usedFlags[candidate] = true; output.push(candidate); generate(remain - 1, rangeLimit, output, usedFlags); } } generate(100, 200, row, used); console.log(row);

这里的查重变成了对used[candidate]的一次下标访问。数组随机访问是 O(1),整个算法时间复杂度从 O(n²) 降到了 O(n),代价是一个201长度的布尔数组。这就是经典的“空间换时间”,在这道题里空间代价几乎可以忽略。

注意new Array(201).fill(false),下标0到200共201个元素,有效下标是1到200,0那个位置永远用不到。很多人这里少写一个,或者把201写成200,数组就越界。这个问题看似低级,但我在实际调试中见过好几份代码栽在这一行。

3.4 “站成一排”的数据结构表达

题目最后说“依次站成一排”,在我实现里,这个“排”就是row数组本身:row[0]是第一个站好的人,row[1]是第二个,依此类推。每次成功生成编号后执行row.push(candidate),天然维护了入列顺序,不需要二次排序。

如果你想让输出更贴近“站队”的视觉感,可以把数组 map 成带序号的对象数组:

const queue = row.map((number, index) => ({ position: index + 1, number: number })); console.log(queue);

控制台就能看到“第 1 位囚犯拿的是 87 号”这种信息。本质上,队列顺序和数组下标的映射关系已经很清晰,只是换了个展示形式。

4. 复杂度、冲突概率与递归深度:直觉在这里经常翻车

4.1 两种写法复杂度对比

版本单次生成平均比较次数总量级额外空间适用场景
线性查重随已占用人数递增O(n²)O(1)数据量小、思路演示
标志数组O(1)O(n)O(rangeLimit)数据量大、范围可控

这道题 n=100、rangeLimit=200,线性查重最坏也就一万次比较,现代机器完全感知不到差异。但如果把人数从100改成10000,范围从200改成5000,线性查重会肉眼可见地变慢。到那个量级,标志数组的价值就体现出来了。

4.2 冲突概率计算:平均要试多少次才能凑齐100个

很多人觉得“200个号选100个,肯定很轻松”,实际上越往后越难。第 k 个人站队时,已经有 k-1 个编号被占用,他一次抽中“未占用编号”的概率是:

P(第 k 人一次成功) = (200 - (k - 1)) / 200 = (201 - k) / 200

第100个人站队时,也就是 k=100,一次成功概率是 (201 - 100) / 200 = 101 / 200 ≈ 50.5%。换句话说,最后一个囚犯有一半概率要重新抽号。如果运气不好连续抽到好几个已占用编号,就要递归重试好几层。

把100个人的期望生成次数加起来,公式是:

总和 = 200/200 + 200/199 + 200/198 + ... + 200/101 = 200 × (H_200 - H_100)

其中 H_n 是调和级数第 n 项。查表可知 H_200 ≈ 5.878,H_100 ≈ 5.187,于是:

总和 ≈ 200 × 0.691 ≈ 138.2

也就是说,最终虽然只要100个编号,但平均需要生成约138次随机数,多出来的约38次全是冲突后的重试。我最初凭感觉以为重试十几次就够,算完后才意识到后半程的冲突率比想象中高得多。我在本地写了10000次模拟,统计平均生成次数大约138.6,跟理论值吻合得很好。

4.3 递归深度会炸吗:期望深度与最坏情况的灰色地带

递归深度取决于“连续冲突”的最长链。最不利时刻的冲突概率约是50%,连续十次都冲突的概率大约 0.5^10 ≈ 0.1%,理论上会出现但极其罕见。100人规模下,总递归深度通常几十层,远低于 JavaScript 默认的调用栈上限(大约一万层),正常运行不会爆栈。

但要特别提醒:有些语言对递归限制很严。把这套逻辑原样搬到 Python,默认递归深度限制是1000层,这道题大概率不触发,可一旦把人数放大到500、范围缩小到1000,就会看到栈溢出错误。所以递归写法在“教学题”里很优雅,在“生产环境”里要谨慎。真遇到大批量数据,我会换迭代加标志数组,两者思路完全一致,只是把函数自调用改成 while 循环。

5. 常见问题与排查实录:这五个坑我基本全踩过

5.1 随机数边界写错:为什么总是混进 0 号或 201 号

Math.random()返回 [0, 1) 的浮点数,永远不包含1。所以:

  • Math.floor(Math.random() * 200)取值范围是 0 到 199
  • 想得到 1 到 200,必须Math.floor(Math.random() * 200) + 1
  • 通式是 min 到 max,写成Math.floor(Math.random() * (max - min + 1)) + min

很多初学者把 +1 加在乘法里,写成Math.floor(Math.random() * 201),于是随机数可能拿到0,也可能拿到200,结果数组里出现0号编号。排查方法很简单:连续跑一千次,统计数字的最小值和最大值,立刻就能定位。

5.2 递归参数写错导致死循环:程序不退,你以为是命里注定

最常见的情况是把剩余人数和数组长度搞混。我在某位朋友代码里看到generate(remain--),这行代码会先传值再自减,导致递归调用时 remain 根本没变,无限递归。排查方法是在函数第一行打印 remain 和 row.length,肉眼比对两者关系,很快就能看出状态是否在推进。

5.3 标志数组越界:少写一个 1,查重就直接失效

new Array(200)的下标最大是199,可随机数可能到200,此时used[200]是 undefined,永远 falsy。结果两个囚犯可能同时拿到200号,查重逻辑形同虚设。排查方法很简单:打印used.length和最大下标,确认长度是201。这段代码看起来只差一个数字,实际排查能折腾半天。

5.4 “站成一排”的顺序理解偏差:push 和 unshift 别混用

有人为了实现“第一个站的排最前面”,错误地用了unshift,结果队形顺序跟生成顺序完全反了。题目说“依次随机生成并站成一排”,生成顺序就是站队顺序,直接 push 到数组尾部。如果你的业务场景真的需要“后来者站前面”,才会考虑 unshift 或 reverse。

5.5 连续测试结果看起来很像:随机性判断不能靠肉眼

递归加 Math.random 每次运行都会产生不同序列。但有时候连着跑五次,输出看起来“差不多”,新手会怀疑随机数是不是有问题。其实这可能只是正常重叠:100个编号分布在整个1-200区间,两组数据之间大量重合是意料之中的。判断随机性要用统计手段,比如重复率、均值、分布区间,或者跑 A/B 对比,不能凭视觉找规律。

个人还踩过一个经验教训:不要在重试逻辑里硬编码“最多重试 N 次”。题目要求生成成功为止,重试次数天然随机。一旦加了上限,就可能出现100个人没凑齐就返回的错误结果,到时候排查起来比递归爆栈还痛苦。

写在最后的一点体会

我自己在本地把这套代码完整跑了很多遍,印象最深的是第99个人和第100个人站队的瞬间。每当控制台连续打印好几个“冲突重试”,我才真正理解文章前面算出来的那个50%成功率意味着什么。这种递归加数组的写法,未必是最优性能方案,但教学价值极高,它把随机过程、冲突检测、递归状态传递三个概念压在一起,用一遍就能记得很牢。如果后续你想在这个基础上扩展,可以把它改造成不重复随机抽奖、工号分配或者验证码池的小工具,核心逻辑完全复用,只需要把编号范围换成业务需要的值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 4:24:17

共享储能怎么帮工业用户降低电费?从原理到实践一次讲透

工业电费单上那一串数字&#xff0c;可能是大多数企业主最头疼却又最无可奈何的支出。尤其是那些动辄几千瓦时起步的重载车间&#xff0c;每到月底财务把电费单摊在桌上&#xff0c;那种感觉就像被人从账户里硬生生划走一台设备。我在这行摸爬滚打了十几年&#xff0c;见过太多…

作者头像 李华
网站建设 2026/10/22 9:44:40

Flutter iOS包安全加固:从Dart混淆到IPA运行期校验

做了几年 Flutter&#xff0c;最让我不踏实的一句话就是“这个包很安全”。很多人觉得 Flutter 的 Dart 代码会被 AOT 编译成原生机器码&#xff0c;天然就比js或者 Java 难逆向&#xff0c;可真实情况是&#xff0c;直接把别人编译好的 Flutter iOS 包拖进工具里一扫&#xff…

作者头像 李华
网站建设 2026/11/4 15:15:08

DeepSeek 从入门到精通:API 调用、提示词工程与长上下文处理实战

简介&#xff1a;《DeepSeek从入门到精通》是一份面向AI初学者、开发者与内容创作者的PDF学习资料&#xff0c;由清华大学新闻与传播学院新媒体研究中心团队编写&#xff0c;系统讲解DeepSeek的能力边界与实战用法。内容覆盖智能对话、文本生成、语义理解、计算推理与代码补全等…

作者头像 李华
网站建设 2026/11/4 23:20:24

TensorFlow全栈解析:从计算图原理到工业级AI部署

1. 这不是“学个框架”那么简单&#xff1a;TensorFlow到底在解决什么问题&#xff1f;很多人第一次听说TensorFlow&#xff0c;是在某次技术分享会上听到“谷歌开源的深度学习框架”&#xff0c;或者在招聘JD里看到“熟悉TensorFlow者优先”。但如果你真去翻官方文档、跑通第一…

作者头像 李华
网站建设 2026/10/24 19:37:11

彩色图像零水印算法Matlab实现:快速四元数GPCET特征提取与版权认证

做彩色图像版权保护&#xff0c;绕不开一个问题&#xff1a;你到底是“往图里塞水印”&#xff0c;还是“从图里算出水印”。前几年我一直在做嵌入式的隐写水印&#xff0c;嵌入强度调高一点&#xff0c;压缩攻击一上来图像质量就崩&#xff1b;调低一点&#xff0c;鲁棒性又不…

作者头像 李华
网站建设 2026/11/4 9:06:50

注意力机制定位恶意API:从序列检测到行为可解释

刚接触这篇论文标题的时候&#xff0c;我第一反应是“这不就是在API序列上跑个注意力模型吗”。但真正把全文读下来才发现&#xff0c;题目里的“定位”两个字才是关键——它要解决的并不是“这个文件是不是恶意软件”&#xff0c;而是“在成百上千个API调用里&#xff0c;到底…

作者头像 李华