swift-algorithm-club 洗牌算法详解:从朴素实现到 Fisher-Yates / Knuth Shuffle

发布时间:2026/9/20 1:46:23
swift-algorithm-club 洗牌算法详解:从朴素实现到 Fisher-Yates / Knuth Shuffle
swift-algorithm-club 洗牌算法详解从朴素实现到 Fisher-Yates / Knuth Shuffle【免费下载链接】swift-algorithm-clubAlgorithms and data structures in Swift, with explanations!项目地址: https://gitcode.com/gh_mirrors/sw/swift-algorithm-club洗牌Shuffle是把数组元素重新随机排列的操作与排序恰好相反是卡牌游戏、随机抽样、数据增强等场景的基础能力。本篇以 swift-algorithm-club 仓库中的 Shuffle 文档 为主体结合仓库内的 Shuffle.swift 源码与 Playground 示例逐步讲解一种时间复杂度 O(n²) 的朴素实现、业界标准的 Fisher-Yates / Knuth 洗牌O(n) 线性时间以及生成随机排列数组的变体算法读完你可以在自己的 Swift 工程中写出正确、高效、可验证的洗牌代码。问题背景用数组表示一副牌假设你在开发一个卡牌游戏需要洗一副牌。可以用Card对象数组表示牌堆洗牌就是改变这些对象在数组中的顺序——即打乱数组它是排序sorting的对立面。swift-algorithm-club 的这一章正是围绕这个场景展开的其配套示例见 Shuffle/Shuffle.playgroundContents.swift 中直接演示了对 7 个字符串元素的数组进行三次洗牌以及生成 0~9 随机排列。洗牌的关键在于随机二字每次运行都要产生不同的排列并且从统计学上说每种可能的排列数学上称为 permutation参见仓库中的 Combinatorics 专题出现的概率应当大致相等。下文从最简单的思路开始逐步演进到标准解法。朴素实现不断抽取并搬入临时数组最容易想到的做法是新建一个临时数组然后反复从原数组中随机抽取一个元素、删除它并追加到临时数组末尾直到原数组为空最后把临时数组复制回原数组。文档给出的 Swift 代码如下extension Array { public mutating func shuffle() { var temp [Element]() while !isEmpty { let i random(count) let obj remove(at: i) temp.append(obj) } self temp } }在 Playground 中复制这段代码即可试验var list [ a, b, c, d, e, f, g ] list.shuffle() list.shuffle() list.shuffle()你会看到三次互不相同的排列。注意该方法是mutating的它原地in place修改原数组内容算法先把元素搬进temp再整体写回self。为什么它是 O(n²)这个实现正确但效率很差。瓶颈在于remove(at:)Swift 的Array是连续存储结构删除中间某个元素后其右侧的所有元素都要前移一位单次删除就是O(n)操作而循环总共执行n次删除因此整体复杂度为O(n²)。当数组规模很大例如真实牌堆、大型数据集的随机抽样时二次方增长是不可接受的。我们完全可以做得更好。Fisher-Yates / Knuth 洗牌线性时间的标准解法文档给出了大幅改进的版本这也是业界公认的标准洗牌算法——Fisher-Yates常被称为 Knuth 洗牌Knuth shuffleextension Array { public mutating func shuffle() { for i in stride(from: count - 1, through: 1, by: -1) { let j Int.random(in: 0...i) if i ! j { swap(self[i], self[j]) } } } }核心思想与朴素版本一样是随机挑选元素但处理方式截然不同朴素版本用临时数组来标记哪些已洗好、哪些还没洗改进版本则把已洗好的元素挪到原数组的末尾用数组自身维护这两个区域从而完全避免删除元素带来的 O(n) 移位开销。逐步走查以数组[ a, b, c, d, e, f, g ]为例。循环从数组末尾i 6开始一路回退到开头i 1第一步随机数可从整个数组范围 0...6 中选取。假设返回 2即c的下标把c与末尾的g交换c被挪到已洗好区域[ a, b, g, d, e, f | c ] * *此时数组被|分成两个区域竖线右侧是已经洗好的部分。第二步随机数只在 0...5 范围内选取即只可能落在未洗区域[ a, b, g, d, e, f ]永远不会再选中已经就位的c。假设随机数为 0a把a与未洗区域最后一个元素f交换[ f, b, g, d, e | a, c ] * *第三步随机数在[ f, b, g, d, e ]中选取假设为 3交换d与e[ f, b, g, e | d, a, c ] * *如此反复直到未洗区域只剩一个元素例如[ b | e, f, g, d, a, c ]此时b没有可交换的对象洗牌结束。因为每个元素只会被访问一次、每次交换都是 O(1)该算法保证 O(n) 运行时间——已经是最优量级没有比这更快的可能性了。仓库源码视角仓库根目录下的 Shuffle/Shuffle.swift 实现了同样的算法并明确注释了算法名与复杂度extension Array { /* Randomly shuffles the array in-place This is the Fisher-Yates algorithm, also known as the Knuth shuffle. Time complexity: O(n) */ public mutating func shuffle() { for i in (1...count-1).reversed() { let j random(i 1) if i ! j { let t self[i] self[i] self[j] self[j] t } } } }有两个实现细节值得注意随机数来源的差异仓库根目录的 Shuffle.swift 依赖一个自定义的random(_ n: Int)辅助函数它内部调用arc4random_uniform(UInt32(n))返回 0~n-1 的均匀随机整数——这是经典的 C 风格 API而 Playground 内嵌源码 Shuffle.playground/Sources/Shuffle.swift 与 README 文档则使用 Swift 标准库自带的Int.random(in: 0...i)。如果你的工程面向较新的 Swift 版本优先使用Int.random(in:)它不依赖 Foundation 的arc4random也更安全越界由Range边界保证。交换的两种写法根目录版本手动用临时变量t完成交换与swap等价Playground 版本则直接调用swap(self[i], self[j])。两者行为一致swap更简洁。变体直接生成 0...n-1 的随机排列有时你不需要洗一个已存在的数组而是想创建一个新的数组实例其内容是0到n-1的随机排列。shuffledArray(_:)正是为此设计的public func shuffledArray(_ n: Int) - [Int] { var a Int for i in 0..n { let j Int.random(in: 0...i) // for the Fisher–Yates_shuffles pseudo code implement in wiki, it will check if i ! j a[i] a[j] a[j] i } return a }使用方法let numbers shuffledArray(10)一次运行可能返回[3, 0, 9, 1, 8, 5, 2, 6, 7, 4]——0~9 每个数字都出现且仅出现一次只是顺序被打乱你自己运行的结果必然不同。原理一边填充一边挪位shuffledArray(_:)先创建一个装满n个 0 的数组然后循环n次每一步把序列中的下一个数字即当前的i插入到数组的某个随机位置j。关键在于保证新插入的数字不会覆盖掉旧值先把位置j上已有的值a[j]搬到a[i]即a[i] a[j]再把新数字i放到j上即a[j] i。这一步先把旧值挪走再放入新值是整个技巧的核心。关于if i ! j判断Wiki 上 Fisher-Yates 的伪代码对此的说明是在允许访问未初始化数组值、且赋值成本低于比较成本的语言里这个检查可以省略。从仓库源码看两个版本的取舍正好体现了这一点——README 文档版本保留了该判断而根目录 Shuffle.swift 和 Playground 的 Sources/Shuffle.swift 在shuffledArray中直接省略了检查仅用注释说明因为去掉检查可以减少一次比较、略微优化性能而i j时执行a[i] a[j]只是自我赋值无副作用。这个算法同样巧妙建议你在纸上或 Playground 里亲自走一遍提示它同样把数组拆成了两个区域——左侧已填好、右侧待填充。如何运行与验证仓库为本章提供了开箱即用的 PlaygroundShuffle/Shuffle.playground。在 Xcode 中打开该 Playground 即可运行 Contents.swiftimport Foundation var list [ a, b, c, d, e, f, g ] list.shuffle() list.shuffle() list.shuffle() let numbers shuffledArray(10)你可以自行验证两条不变量这也是检验洗牌实现正确性的通用方法元素守恒洗牌前后的数组包含完全相同的元素集合只是顺序不同排列完整性shuffledArray(n)的返回值始终是0...n-1的一个排列即集合不变、顺序随机。若你的代码需要与老版本 Swift 兼容可参考根目录 Shuffle.swift 的arc4random_uniform写法若使用现代 Swift直接采用Int.random(in:)版本即可。延伸阅读洗牌产生的不同排列在数学上对应排列permutation概念swift-algorithm-club 在 Combinatorics 一章有专门讲解本文涉及的两种实现文档版与源码版都源自 Fisher-Yates 洗牌的经典伪代码若想直观理解算法的双区域思想可以把本文第 3 节的逐步走查在 Playground 中配print逐步输出观察竖线右侧的已洗好区域如何一点点生长。本文依据 swift-algorithm-club 仓库中的 Shuffle/README.markdown 编写并参考 Shuffle/Shuffle.swift 与 Shuffle/Shuffle.playground 源码进行核实与补充。【免费下载链接】swift-algorithm-clubAlgorithms and data structures in Swift, with explanations!项目地址: https://gitcode.com/gh_mirrors/sw/swift-algorithm-club创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考