使用 Random 和 OrderBy 是不是是一个好的 shuffle 算法? [关闭]
Posted
技术标签:
【中文标题】使用 Random 和 OrderBy 是不是是一个好的 shuffle 算法? [关闭]【英文标题】:Is using Random and OrderBy a good shuffle algorithm? [closed]使用 Random 和 OrderBy 是否是一个好的 shuffle 算法? [关闭] 【发布时间】:2010-11-20 05:30:00 【问题描述】:我在Coding Horror 上阅读了an article 关于各种洗牌算法的信息。我已经看到有人在某处这样做来洗牌:
var r = new Random();
var shuffled = ordered.OrderBy(x => r.Next());
这是一个好的洗牌算法吗?它是如何工作的?这是一种可接受的方式吗?
【问题讨论】:
【参考方案1】:这不是我喜欢的洗牌方式,主要是因为当它很容易实现 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
,它更详细地介绍了这些问题并提供了解决方案。
【讨论】:
嗯,小而重要的实现,我想说在 *** 上找到这样的东西总是很好。所以是的,如果你愿意的话,请=) 乔恩 - 您对 Fisher-Yates 的解释等同于问题中给出的实现(天真的版本)。 Durstenfeld/Knuth 不是通过赋值,而是通过从递减集合中选择和交换来实现 O(n)。这样选择的随机数可能会重复,算法只需要 O(n)。 你可能已经厌倦了听到我的消息,但我在单元测试中遇到了一个你可能想知道的小问题。 ElementAt 有一个怪癖,它每次都会调用扩展,从而产生不可靠的结果。在我的测试中,我在检查以避免这种情况之前将结果具体化。 @tvanfosson:一点也不生病 :) 但是,是的,调用者应该知道它是惰性评估的。 有点晚,但请注意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?)
【参考方案2】:
这是基于 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];
【讨论】:
哎哟!这可能不会返回源中的所有项目。您不能依赖随机数在 N 次迭代中是唯一的。 @P 爸爸:嗯?需要详细说明吗? @Svish:一个极端的例子:rng.Next(i + 1)
可以每次都返回零,就像一个翻转的四分之一可以连续出现 15 次正面。尽管它实际上不太可能连续出现零 N 次,但很可能会出现 一些 次重复,因此完全覆盖的机会相当低。
或者您可以将 > 0 替换为 >= 0 而不必这样做(尽管额外的 RNG 命中加上冗余分配)
启动成本为O(N)作为source.ToArray()的成本;【参考方案3】:
从 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 个元素)的数组进行排序
【讨论】:
说得好,完美地显示了原始问题的问题。这应该与乔恩的答案合并。【参考方案4】:对于大多数用途来说可能没问题,而且几乎总是会生成真正的随机分布(除非 Random.Next() 生成两个相同的随机整数)。
它的工作原理是为序列的每个元素分配一个随机整数,然后按这些整数对序列进行排序。
这对于 99.9% 的应用程序来说是完全可以接受的(除非您绝对需要处理上述边缘情况)。此外,skeet 对其运行时的反对是有道理的,所以如果你正在改组一长串列表,你可能不想使用它。
【讨论】:
【参考方案5】:这已经出现过很多次了。在 *** 上搜索 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
@Jon Skeet:当然,这是一个合理的论点。 OTOH,OP 询问的是一种完全错误的算法,而这是正确的(多线程洗牌用例除外)。
这只是意味着这比 OP 的方法“错误更少”。这并不意味着它的代码应该在不理解它不能在多线程上下文中安全使用的情况下使用......这是你没有提到的。有一个合理的期望,静态成员可以在多个线程中安全使用。
@Jon Skeet:当然,我可以改变它。完毕。我倾向于认为,回到三年半前回答的一个问题并说,“这是不正确的,因为它不处理多线程用例”,而 OP 从来没有询问过算法过多的任何问题。回顾我多年来的回答。通常,我给 OP 的答复超出了规定的要求。我因此受到批评。不过,我不希望 OP 得到适合所有可能用途的答案。
我只访问了这个答案,因为有人在聊天中指出我。虽然 OP 没有具体提到线程,但我认为当静态方法不是线程安全时绝对值得一提,因为它不寻常并且使代码不适合很多情况不用修改。您的新代码是线程安全的 - 但仍然不理想,就像您在“大致”同时从多个线程调用它以对两个相同大小的集合进行混洗一样,混洗将是等效的。基本上,Random
使用起来很痛苦,正如我的文章中所述。【参考方案6】:
如果您不太担心性能的话,这似乎是一个很好的洗牌算法。我要指出的唯一问题是它的行为是不可控的,所以你可能很难测试它。
一种可能的选择是将种子作为参数传递给随机数生成器(或将随机生成器作为参数),这样您就可以更轻松地进行控制和测试。
【讨论】:
【参考方案7】:我发现 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];
【讨论】:
【参考方案8】:寻找算法?你可以使用我的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 40 1 2 3 40 1 2 4 30 1 2 4 31 2 4 3 01 2 4 3 01 2 3 0 41 2 3 0 42 3 0 4 12 3 0 4 1 3 0 4 1 2
【讨论】:
这不是 O(n)。 RemoveAt 单独是 O(n)。 嗯,看来你是对的,我的错!我会删除那部分。【参考方案9】:该算法通过为列表中的每个值生成一个新的随机值,然后按这些随机值对列表进行排序来进行混洗。将其视为向内存表中添加一个新列,然后用 GUID 填充它,然后按该列排序。对我来说似乎是一种有效的方法(尤其是使用 lambda 糖!)
【讨论】:
【参考方案10】:有点不相关,但这里有一个有趣的方法(尽管它真的很过分,但已经真正实现了)用于真正随机生成掷骰子!
Dice-O-Matic
我在此处发布此内容的原因是,他提出了一些有趣的观点,说明他的用户对使用算法随机播放实际骰子的想法有何反应。当然,在现实世界中,这种解决方案仅适用于随机性具有如此大影响并且可能影响金钱的极端极端情况;)。
【讨论】:
【参考方案11】:我会说这里的许多答案,例如“此算法通过为列表中的每个值生成一个新的随机值,然后按这些随机值对列表进行排序”来进行混洗,这可能是非常错误的!
我认为这不会为源集合的每个元素分配随机值。取而代之的可能是运行类似 Quicksort 的排序算法,它会调用一个比较函数大约 n log n 次。某种算法真的希望这个比较函数是稳定的并且总是返回相同的结果!
难道 IEnumerableSorter 会为每个算法步骤调用一个比较函数,例如快速排序,并且每次都为两个参数调用函数x => r.Next()
,而不缓存这些!
在这种情况下,您可能真的会弄乱排序算法,使其比算法所基于的预期更糟糕。当然,它最终会变得稳定并返回一些东西。
稍后我可能会通过将调试输出放入新的“Next”函数来检查它,看看会发生什么。 在 Reflector 中,我无法立即了解它是如何工作的。
【讨论】:
不是这样的:内部覆盖 void ComputeKeys(TElement[] elements, int count);声明类型:System.Linq.EnumerableSorter值得注意的是,由于 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()
或等效方法枚举元素,则此方法不适用。但是,这种行为在许多情况下可能会导致错误,其中之一是当混洗后的集合预期在每个索引处包含一个唯一元素时。
【讨论】:
【参考方案13】:在清除所有线程并缓存每个新测试的代码上运行的启动时间,
第一个不成功的代码。它在 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); 公共静态 IEnumerableGetTestRandomSort(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 方法差。
答案:它们是矛盾的。
【讨论】:
第二个选项不正确,因此它的性能无关。这也仍然没有回答随机数排序是否可接受、有效或如何工作的问题。第一个解决方案也存在正确性问题,但它们没什么大不了的。 对不起,我想知道 Linq OrderBy 的 Quicksort 的哪种参数更好?我需要测试性能。但是,我认为 int 类型的速度比 Guid 的字符串要好,但事实并非如此。我明白为什么 MSDN 推荐了。第一个解决方案编辑的性能与带有 Random 实例的 OrderBy 相同。 衡量不能解决问题的代码的性能有什么意义?性能只是两个都可以工作的解决方案之间的一个考虑因素。当您有可行的解决方案时,然后您可以开始比较它们。 我必须有时间测试更多数据,如果完成,我保证会再次发布。假设:我认为 Linq OrderBy 并不比第一个解决方案差。意见:易于使用和理解。 它的效率明显低于非常简单直接的洗牌算法,但性能再次无关。除了性能较低之外,它们还不能可靠地改组数据。以上是关于使用 Random 和 OrderBy 是不是是一个好的 shuffle 算法? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章
为 orderby 生成 sql 查询的方法是不是在 tiny_tds 0.2.3 和 0.4.3 之间更改
Hibernate 文档:@OrderBy - 它是不是在内存中排序?
.Net Core 3.1 中的 IEnumerable.OrderBy().First() 优化是不是记录在任何地方?