【发布时间】:2010-11-20 05:30:00
【问题描述】:
我在Coding Horror 上阅读了an article 关于各种洗牌算法的信息。我已经看到有人在某处这样做来洗牌:
var r = new Random();
var shuffled = ordered.OrderBy(x => r.Next());
这是一个好的洗牌算法吗?它是如何工作的?这是一种可接受的方式吗?
【问题讨论】:
我在Coding Horror 上阅读了an article 关于各种洗牌算法的信息。我已经看到有人在某处这样做来洗牌:
var r = new Random();
var shuffled = ordered.OrderBy(x => r.Next());
这是一个好的洗牌算法吗?它是如何工作的?这是一种可接受的方式吗?
【问题讨论】:
这不是我喜欢的洗牌方式,主要是因为当它很容易实现 O(n) 洗牌时,它是 O(n log n) 没有充分理由。问题中的代码“工作”基本上是给每个元素一个随机(希望是唯一的!)数字,然后根据该数字对元素进行排序。
我更喜欢 Durstenfeld 的 Fisher-Yates shuffle 变体,它可以交换元素。
实现一个简单的Shuffle 扩展方法基本上包括在输入上调用ToList 或ToArray,然后使用Fisher-Yates 的现有实现。 (传入Random 作为参数,让生活总体上更美好。)周围有很多实现......我可能在某个地方得到了答案。
这种扩展方法的好处在于,它会让读者非常清楚你实际上想要做什么。
编辑:这是一个简单的实现(没有错误检查!):
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> source, Random rng)
{
T[] elements = source.ToArray();
// Note i > 0 to avoid final pointless iteration
for (int i = elements.Length-1; i > 0; i--)
{
// Swap element "i" with a random earlier element it (or itself)
int swapIndex = rng.Next(i + 1);
T tmp = elements[i];
elements[i] = elements[swapIndex];
elements[swapIndex] = tmp;
}
// Lazily yield (avoiding aliasing issues etc)
foreach (T element in elements)
{
yield return element;
}
}
编辑:下面对性能的评论提醒我,我们实际上可以在洗牌时返回元素:
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> source, Random rng)
{
T[] elements = source.ToArray();
for (int i = elements.Length - 1; i >= 0; i--)
{
// Swap element "i" with a random earlier element it (or itself)
// ... except we don't really need to swap it fully, as we can
// return it immediately, and afterwards it's irrelevant.
int swapIndex = rng.Next(i + 1);
yield return elements[swapIndex];
elements[swapIndex] = elements[i];
}
}
这现在只会做它需要做的工作。
请注意,在这两种情况下,您都需要小心使用 Random 的实例:
Random 实例将产生相同的随机数序列(以相同方式使用时)Random 不是线程安全的。我有an article on Random,它更详细地介绍了这些问题并提供了解决方案。
【讨论】:
source.ToArray(); 要求您在同一文件中拥有using System.Linq;。如果你不这样做,你会得到这个错误:'System.Collections.Generic.IEnumerable<T>' does not contain a definition for 'ToArray' and no extension method 'ToArray' accepting a first argument of type 'System.Collections.Generic.IEnumerable<T>' could be found (are you missing a using directive or an assembly reference?)
这是基于 Jon Skeet 的 answer。
在该答案中,数组被打乱,然后使用yield 返回。最终结果是,在 foreach 期间,数组以及迭代所需的对象都保存在内存中,但成本都在开始 - 产量基本上是一个空循环。
这个算法在游戏中被大量使用,前三个项目被选中,其他的只有在以后才需要。我的建议是在交换数字后立即发送给yield。这将降低启动成本,同时将迭代成本保持在 O(1)(基本上每次迭代 5 次操作)。总成本将保持不变,但洗牌本身会更快。在称为collection.Shuffle().ToArray() 的情况下,理论上它不会有任何区别,但在上述用例中,它将加快启动速度。此外,这将使该算法适用于您只需要一些独特项目的情况。例如,如果您需要从一副 52 张牌中抽出三张牌,您可以调用 deck.Shuffle().Take(3) 并且只进行 3 次交换(尽管必须先复制整个数组)。
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> source, Random rng)
{
T[] elements = source.ToArray();
// Note i > 0 to avoid final pointless iteration
for (int i = elements.Length - 1; i > 0; i--)
{
// Swap element "i" with a random earlier element it (or itself)
int swapIndex = rng.Next(i + 1);
yield return elements[swapIndex];
elements[swapIndex] = elements[i];
// we don't actually perform the swap, we can forget about the
// swapped element because we already returned it.
}
// there is one item remaining that was not returned - we return it now
yield return elements[0];
}
【讨论】:
rng.Next(i + 1)可以每次都返回零,就像一个翻转的四分之一可以连续出现 15 次正面。尽管它实际上不太可能连续出现零 N 次,但很可能会出现 一些 次重复,因此完全覆盖的机会相当低。
从 Skeet 的这句话开始:
这不是我喜欢的洗牌方式,主要是因为当它很容易实现 O(n) 洗牌时它是 O(n log n) 没有充分的理由。问题中的代码“工作”基本上是给每个元素一个随机(希望是唯一的!)数字,然后根据该数字对元素进行排序。
我将继续解释希望独一无二的原因!
现在,来自Enumerable.OrderBy:
此方法执行稳定排序;也就是说,如果两个元素的键相等,则保留元素的顺序
这很重要!如果两个元素“接收”相同的随机数会发生什么?碰巧它们保持在数组中的相同顺序。现在,这种情况发生的可能性有多大?很难准确计算,但是有Birthday Problem就是这个问题。
现在,这是真的吗?是真的吗?
一如既往,如有疑问,请编写几行程序:http://pastebin.com/5CDnUxPG
这个小代码块使用向后完成的 Fisher-Yates 算法和向前完成的 Fisher-Yates 算法(在wiki 页面中有两个伪代码算法),将 3 个元素的数组洗牌一定次数。 .. 它们产生相同的结果,但一个是从第一个元素到最后一个元素完成,而另一个是从最后一个元素到第一个元素完成),http://blog.codinghorror.com/the-danger-of-naivete/ 的幼稚错误算法并使用.OrderBy(x => r.Next()) 和.OrderBy(x => r.Next(someValue))。
现在,Random.Next 是
大于等于 0 且小于 MaxValue 的 32 位有符号整数。
所以它相当于
OrderBy(x => r.Next(int.MaxValue))
为了测试这个问题是否存在,我们可以扩大数组(这很慢)或简单地减少随机数生成器的最大值(int.MaxValue 不是一个“特殊”数字......它只是一个非常大的数字)。最后,如果算法不受OrderBy 的稳定性的影响,那么任何值范围都应该给出相同的结果。
然后程序会测试一些值,范围是 1...4096。从结果来看,很明显,对于低值(r.Next(1024)。如果您使数组更大(4 或 5),那么即使 r.Next(1024) 也不够。我不是洗牌和数学方面的专家,但我认为对于数组长度的每一额外位,您需要 2 个额外的最大值位(因为生日悖论与 sqrt(numvalues) 相关联),所以如果最大值是 2^31,我会说你应该能够对最多 2^12/2^13 位(4096-8192 个元素)的数组进行排序
【讨论】:
对于大多数用途来说可能没问题,而且几乎总是会生成真正的随机分布(除非 Random.Next() 生成两个相同的随机整数)。
它的工作原理是为序列的每个元素分配一个随机整数,然后按这些整数对序列进行排序。
这对于 99.9% 的应用程序来说是完全可以接受的(除非您绝对需要处理上述边缘情况)。此外,skeet 对其运行时的反对是有道理的,所以如果你正在改组一长串列表,你可能不想使用它。
【讨论】:
这已经出现过很多次了。在 StackOverflow 上搜索 Fisher-Yates。
这是我为这个算法写的C# code sample。如果您愿意,可以将其参数化为其他类型。
static public class FisherYates
{
// Based on Java code from wikipedia:
// http://en.wikipedia.org/wiki/Fisher-Yates_shuffle
static public void Shuffle(int[] deck)
{
Random r = new Random();
for (int n = deck.Length - 1; n > 0; --n)
{
int k = r.Next(n+1);
int temp = deck[n];
deck[n] = deck[k];
deck[k] = temp;
}
}
}
【讨论】:
Random 作为静态变量——Random 不是线程安全的。见csharpindepth.com/Articles/Chapter12/Random.aspx
Random 使用起来很痛苦,正如我的文章中所述。
如果您不太担心性能的话,这似乎是一个很好的洗牌算法。我要指出的唯一问题是它的行为是不可控的,所以你可能很难测试它。
一种可能的选择是将种子作为参数传递给随机数生成器(或将随机生成器作为参数),这样您就可以更轻松地进行控制和测试。
【讨论】:
我发现 Jon Skeet 的回答完全令人满意,但我客户的机器人扫描仪会将任何 Random 实例报告为安全漏洞。所以我把它换成了System.Security.Cryptography.RNGCryptoServiceProvider。作为奖励,它修复了提到的线程安全问题。另一方面,RNGCryptoServiceProvider 被测量为比使用 Random 慢 300 倍。
用法:
using (var rng = new RNGCryptoServiceProvider())
{
var data = new byte[4];
yourCollection = yourCollection.Shuffle(rng, data);
}
方法:
/// <summary>
/// Shuffles the elements of a sequence randomly.
/// </summary>
/// <param name="source">A sequence of values to shuffle.</param>
/// <param name="rng">An instance of a random number generator.</param>
/// <param name="data">A placeholder to generate random bytes into.</param>
/// <returns>A sequence whose elements are shuffled randomly.</returns>
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> source, RNGCryptoServiceProvider rng, byte[] data)
{
var elements = source.ToArray();
for (int i = elements.Length - 1; i >= 0; i--)
{
rng.GetBytes(data);
var swapIndex = BitConverter.ToUInt32(data, 0) % (i + 1);
yield return elements[swapIndex];
elements[swapIndex] = elements[i];
}
}
【讨论】:
寻找算法?你可以使用我的ShuffleList 类:
class ShuffleList<T> : List<T>
{
public void Shuffle()
{
Random random = new Random();
for (int count = Count; count > 0; count--)
{
int i = random.Next(count);
Add(this[i]);
RemoveAt(i);
}
}
}
然后,像这样使用它:
ShuffleList<int> list = new ShuffleList<int>();
// Add elements to your list.
list.Shuffle();
让我们获取前 5 个整数的初始排序列表:{ 0, 1, 2, 3, 4 }。
该方法首先计算元素的数量并将其称为count。然后,随着count 每一步递减,它会在0 和count 之间取一个随机数并将其移动到列表的末尾。
在下面的分步示例中,可以移动的项目是斜体,选中的项目是粗体:
0 1 2 3 4
0 1 2 3 4
0 1 2 4 3
0 1 2 4 3
1 2 4 3 0
1 2 4 3 0
1 2 3 0 4
1 2 3 0 4
2 3 0 4 1
2 3 0 4 1
3 0 4 1 2
【讨论】:
该算法通过为列表中的每个值生成一个新的随机值,然后按这些随机值对列表进行排序来进行混洗。将其视为向内存表中添加一个新列,然后用 GUID 填充它,然后按该列排序。对我来说似乎是一种有效的方法(尤其是使用 lambda 糖!)
【讨论】:
有点不相关,但这里有一个有趣的方法(尽管它真的很过分,但已经真正实现了)用于真正随机生成掷骰子!
我在此处发布此内容的原因是,他提出了一些有趣的观点,说明他的用户对使用算法随机播放实际骰子的想法有何反应。当然,在现实世界中,这种解决方案仅适用于随机性具有如此大影响并且可能影响金钱的极端极端情况;)。
【讨论】:
我会说这里的许多答案,例如“此算法通过为列表中的每个值生成一个新的随机值,然后按这些随机值对列表进行排序”来进行混洗,这可能是非常错误的!
我认为这不会为源集合的每个元素分配随机值。取而代之的可能是运行类似 Quicksort 的排序算法,它会调用一个比较函数大约 n log n 次。某种算法真的希望这个比较函数是稳定的并且总是返回相同的结果!
难道 IEnumerableSorter 会为每个算法步骤调用一个比较函数,例如快速排序,并且每次都为两个参数调用函数x => r.Next(),而不缓存这些!
在这种情况下,您可能真的会弄乱排序算法,使其比算法所基于的预期更糟糕。当然,它最终会变得稳定并返回一些东西。
稍后我可能会通过将调试输出放入新的“Next”函数来检查它,看看会发生什么。 在 Reflector 中,我无法立即了解它是如何工作的。
【讨论】:
值得注意的是,由于 LINQ 的deferred execution,使用带有OrderBy() 的随机数生成器实例可能会导致可能出现意外行为:直到集合才进行排序被读取。这意味着每次读取或枚举集合时,顺序都会发生变化。人们可能会期望元素被打乱一次,然后每次访问时都保持顺序。
Random random = new();
var shuffled = ordered.OrderBy(x => random.Next())
上面的代码将一个lambda函数x => random.Next()作为参数传递给OrderBy()。这将capturerandom 引用的实例并将其与 lambda来自集合)。
这里的问题是,由于此执行被保存以供以后使用,排序发生在每次在使用通过在同一随机实例上调用 Next() 获得的新数字枚举集合之前。
为了演示这种行为,我使用了 Visual Studio 的 C# Interactive Shell:
> List<int> list = new() { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
> Random random = new();
> var shuffled = list.OrderBy(element => random.Next());
> shuffled.ToList()
List<int>(10) { 5, 9, 10, 4, 6, 2, 8, 3, 1, 7 }
> shuffled.ToList()
List<int>(10) { 8, 2, 9, 1, 3, 6, 5, 10, 4, 7 } // Different order
> shuffled.ElementAt(0)
9 // First element is 9
> shuffled.ElementAt(0)
7 // First element is now 7
>
甚至可以通过在使用 Visual Studio 的调试器时在创建 IOrderedEnumerable 的位置之后放置一个断点来实际看到此行为:每次将鼠标悬停在变量上时,元素都会以不同的顺序显示。
当然,如果您立即通过调用 ToList() 或等效方法枚举元素,则此方法不适用。但是,这种行为在许多情况下可能会导致错误,其中之一是当混洗后的集合预期在每个索引处包含一个唯一元素时。
【讨论】:
在清除所有线程并缓存每个新测试的代码上运行的启动时间,
第一个不成功的代码。它在 LINQPad 上运行。如果你跟着来测试这段代码。
Stopwatch st = new Stopwatch();
st.Start();
var r = new Random();
List<string[]> list = new List<string[]>();
list.Add(new String[] {"1","X"});
list.Add(new String[] {"2","A"});
list.Add(new String[] {"3","B"});
list.Add(new String[] {"4","C"});
list.Add(new String[] {"5","D"});
list.Add(new String[] {"6","E"});
//list.OrderBy (l => r.Next()).Dump();
list.OrderBy (l => Guid.NewGuid()).Dump();
st.Stop();
Console.WriteLine(st.Elapsed.TotalMilliseconds);
list.OrderBy(x => r.Next()) 使用 38.6528 毫秒
list.OrderBy(x => Guid.NewGuid()) 使用 36.7634 毫秒(从 MSDN 推荐。)
第二次之后两人同时使用。
编辑:
Intel Core i7 4@2.1GHz、Ram 8 GB DDR3 @1600、HDD SATA 5200 rpm 上的测试代码 [数据:www.dropbox.com/s/pbtmh5s9lw285kp/data]
使用系统;
使用 System.Runtime;
使用 System.Diagnostics;
使用 System.IO;
使用 System.Collections.Generic;
使用 System.Collections;
使用 System.Linq;
使用 System.Threading;
命名空间算法
{
课堂节目
{
公共静态无效主要(字符串[]参数)
{
尝试 {
诠释 i = 0;
整数限制 = 10;
var 结果 = GetTestRandomSort(limit);
foreach(结果中的 var 元素){
Console.WriteLine();
Console.WriteLine("时间 {0}: {1} ms", ++i, element);
}
} 捕捉(异常 e){
Console.WriteLine(e.Message);
} 最后 {
Console.Write("按任意键继续...");
Console.ReadKey(true);
}
}
公共静态 IEnumerable GetTestRandomSort(int limit)
{
for (int i = 0; i 列表 = 空;
随机 r = null;
GC.Collect();
GC.WaitForPendingFinalizers();
线程.睡眠(5000);
st = 秒表.StartNew();
#region 导入输入数据
路径 = Environment.CurrentDirectory + "\\data";
列表 = 新列表();
sr = new StreamReader(路径);
计数 = 0;
while (count r.Next()).ToList();
// #结束区域
// #region 使用 OrderBy(Guid) 随机排序
// list = list.OrderBy(l => Guid.NewGuid()).ToList();
// #结束区域
// #region 使用 Parallel 和 OrderBy(random.Next()) 随机排序
// r = new Random();
// list = list.AsParallel().OrderBy(l => r.Next()).ToList();
// #结束区域
// #region 使用 Parallel OrderBy(Guid) 随机排序
// list = list.AsParallel().OrderBy(l => Guid.NewGuid()).ToList();
// #结束区域
// #region 使用用户定义的 Shuffle 方法随机排序
// r = new Random();
// list = list.Shuffle(r).ToList();
// #结束区域
// #region 使用 Parallel User-Defined Shuffle 方法随机排序
// r = new Random();
// list = list.AsParallel().Shuffle(r).ToList();
// #结束区域
// 结果
//
st.停止();
收益率返回 st.Elapsed.TotalMilliseconds;
foreach(列表中的 var 元素){
Console.WriteLine(元素);
}
}
}
}
}
结果说明:https://www.dropbox.com/s/9dw9wl259dfs04g/ResultDescription.PNG
结果统计:https://www.dropbox.com/s/ewq5ybtsvesme4d/ResultStat.PNG
结论:
假设:LINQ OrderBy(r.Next()) 和 OrderBy(Guid.NewGuid()) 不比第一个解决方案中的用户定义的 Shuffle 方法差。
答案:它们是矛盾的。
【讨论】: