使用 Random 和 OrderBy 是一个好的 shuffle 算法吗?

我已经在 编程恐怖上阅读了关于各种洗牌算法的 一篇文章。我曾经看到有人这样做来洗牌:

var r = new Random();
var shuffled = ordered.OrderBy(x => r.Next());

这是一个好的洗牌算法吗? 它到底是如何工作的? 这是一种可以接受的方法吗?

51963 次浏览

这不是我喜欢的一种洗牌方式,主要是因为它是 O (n log n) ,而实现 O (n)洗牌很容易,而且没有什么好的理由。问题中的代码“工作”基本上是给出一个随机(希望是唯一的!)每个元素的数字,然后根据该数字对元素进行排序。

我更喜欢 Durstenfeld 的 Fisher-Yates 洗牌的变体,它可以交换元素。

实现一个简单的 Shuffle扩展方法基本上包括对输入调用 ToListToArray,然后使用 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不是线程安全的。

我有 一篇关于 Random的文章进入这些问题的更多细节,并提供解决方案。

对于大多数情况,它可能是可以的,并且几乎总是产生一个真正的随机分布(除非是随机分布。Next ()产生两个相同的随机整数。

它的工作原理是为序列中的每个元素分配一个随机整数,然后按照这些整数对序列进行排序。

对于99.9% 的应用程序,它是完全可以接受的(除非您绝对需要处理上面的边缘情况)。另外,Skeet 对其运行时的反对是有效的,所以如果你正在改组一个长列表,你可能不想使用它。

该算法为列表中的每个值生成一个新的随机值,然后按照这些随机值对列表进行排序。可以将其视为向内存中的表中添加一个新列,然后用 GUID 填充它,然后按该列进行排序。在我看来,这是一种有效的方法(尤其是有了 lambda Sugar!)

看起来是个不错的洗牌算法如果你不太担心性能的话。我要指出的唯一问题是它的行为是不可控的,所以你可能很难测试它。

一种可能的选择是将种子作为参数传递给随机数生成器(或作为参数传递给随机数生成器) ,这样您就可以有更多的控制并更容易地测试它。

这以前出现过很多次,在 StackOverflow 上搜索 Fisher-Yates。

下面是我为这个算法编写的 C # 代码示例。如果您愿意,您可以在其他类型上参数化它。

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;
}
}
}

稍微有点不相关,但这里有一个有趣的方法(即使它真的是过量的,已经真正实现)为真正的随机生成骰子卷!

骰子机

我之所以在这里发表这篇文章,是因为他提出了一些有趣的观点,关于他的用户如何对使用算法洗牌的想法作出反应,通过实际的骰子。当然,在现实世界中,这种解决方案只适用于真正极端的情况,在这种情况下,随机性会产生如此大的影响,而且这种影响可能会影响到金钱;)。

这是基于 Jon Skeet 的 回答

在这个答案中,数组被洗牌,然后使用 yield返回。最终的结果是,在 foreach 期间,数组以及迭代所需的对象都保存在内存中,但是开销都在开始阶段——产出基本上是一个空循环。

这个算法在游戏中经常使用,前三个项目被选中,其他的项目只有在以后才需要。我的建议是 yield的数字尽快交换。这将降低启动成本,同时将迭代成本保持在 O (1)(基本上每次迭代5个操作)。总成本将保持不变,但洗牌本身会更快。在被称为 collection.Shuffle().ToArray()的情况下,从理论上讲它不会有什么不同,但是在前面提到的用例中,它会加速启动。此外,这将使算法在您只需要几个唯一项的情况下非常有用。例如,如果您需要从一副52的牌中抽出三张牌,您可以调用 deck.Shuffle().Take(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];
}

我想说,这里的许多答案,比如“这个算法为列表中的每个值生成一个新的随机值,然后根据这些随机值对列表进行排序”,可能是非常错误的!

我认为这并没有为源集合的每个元素赋予一个随机值。相反,可能有一个运行类似 Quicksort 的排序算法,它会调用比较函数大约 n 个 log n 次。某些排序算法真的期望这个比较函数是稳定的,并且总是返回相同的结果!

IEnumerableSorter 会不会为每个算法步骤调用一个比较函数,例如快速排序,并且每次在不缓存这些参数的情况下为这两个参数调用函数 x => r.Next()

在这种情况下,您可能真的会搞乱排序算法,使其比构建算法的预期更糟糕。当然,它最终会变得稳定并返回一些东西。

稍后我可能会通过将调试输出放入一个新的“ Next”函数来检查它,看看会发生什么。 在反射器中,我不能立即找出它是如何工作的。

在清除所有线程并缓存每个新测试的代码上运行的启动时间,

第一个不成功的代码。它运行在 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 ms

List. OrderBy (x = > Guid. NewGuid ())使用36.7634 ms (推荐使用 MSDN)

之后的第二次,他们两个在同一时间使用。

编辑: 测试代码在英特尔酷睿 i74@2.1 GHz,内存8 GB DDR3@1600,硬盘 sATA 5200 rpm 和[数据: www.dropbox.com/s/pbtmh5s9lw285kp/Data ]

using System;
using System.Runtime;
using System.Diagnostics;
using System.IO;
using System.Collections.Generic;
using System.Collections;
using System.Linq;
using System.Threading;


namespace Algorithm
{
class Program
{
public static void Main(string[] args)
{
try {
int i = 0;
int limit = 10;
var result = GetTestRandomSort(limit);
foreach (var element in result) {
Console.WriteLine();
Console.WriteLine("time {0}: {1} ms", ++i, element);
}
} catch (Exception e) {
Console.WriteLine(e.Message);
} finally {
Console.Write("Press any key to continue . . . ");
Console.ReadKey(true);
}
}


public static IEnumerable<double> GetTestRandomSort(int limit)
{
for (int i = 0; i < 5; i++) {
string path = null, temp = null;
Stopwatch st = null;
StreamReader sr = null;
int? count = null;
List<string> list = null;
Random r = null;


GC.Collect();
GC.WaitForPendingFinalizers();
Thread.Sleep(5000);


st = Stopwatch.StartNew();
#region Import Input Data
path = Environment.CurrentDirectory + "\\data";
list = new List<string>();
sr = new StreamReader(path);
count = 0;
while (count < limit && (temp = sr.ReadLine()) != null) {
//                  Console.WriteLine(temp);
list.Add(temp);
count++;
}
sr.Close();
#endregion


//              Console.WriteLine("--------------Random--------------");
//              #region Sort by Random with OrderBy(random.Next())
//              r = new Random();
//              list = list.OrderBy(l => r.Next()).ToList();
//              #endregion


//              #region Sort by Random with OrderBy(Guid)
//              list = list.OrderBy(l => Guid.NewGuid()).ToList();
//              #endregion


//              #region Sort by Random with Parallel and OrderBy(random.Next())
//              r = new Random();
//              list = list.AsParallel().OrderBy(l => r.Next()).ToList();
//              #endregion


//              #region Sort by Random with Parallel OrderBy(Guid)
//              list = list.AsParallel().OrderBy(l => Guid.NewGuid()).ToList();
//              #endregion


//              #region Sort by Random with User-Defined Shuffle Method
//              r = new Random();
//              list = list.Shuffle(r).ToList();
//              #endregion


//              #region Sort by Random with Parallel User-Defined Shuffle Method
//              r = new Random();
//              list = list.AsParallel().Shuffle(r).ToList();
//              #endregion


// Result
//
st.Stop();
yield return st.Elapsed.TotalMilliseconds;
foreach (var element in list) {
Console.WriteLine(element);
}
}


}
}
}

结果描述: < a href = “ https://www.drobox.com/s/9dw9wl259dfs04g/ResultDescription.PNG”rel = “ nofollow”> https://www.dropbox.com/s/9dw9wl259dfs04g/resultdescription.png
结果统计: https://www.dropbox.com/s/ewq5ybtsvesme4d/resultstat.png

结论:
假设: 在第一个解决方案中,LINQOrderBy (r. Next ())和 OrderBy (Guid.NewGuid ())不比用户定义的 Shuffle 方法差。

答: 它们是矛盾的。

从斯基特的这句话开始:

这不是我喜欢的一种洗牌方式,主要是因为它是 O (n log n) ,而实现 O (n)洗牌很容易,而且没有什么好的理由。问题中的代码基本上是给每个元素一个随机的(希望是独一无二的!)数字,然后根据这个数字对元素进行排序。

我会继续解释 希望是独一无二的!的原因

现在,从 可枚举的 OrderBy:

此方法执行稳定的排序; 也就是说,如果两个元素的键相等,则保留元素的顺序

这很重要!如果两个元素“接收”相同的随机数,会发生什么情况?它们碰巧保持在数组中的相同顺序。现在,这种情况发生的可能性有多大?很难精确计算,但是 生日问题就是这个问题。

这是真的吗,是真的吗?

像往常一样,当有疑问时,写几行程序: http://pastebin.com/5CDnUxPG

这个小代码块将一个由3个元素组成的数组重新洗牌若干次,使用的是 Fisher-Yates 算法向后进行,Fisher-Yates 算法向前进行(在 维基百科页面中有两个伪代码算法... ... 它们产生相同的结果,但是一个是从第一个元素到最后一个元素,而另一个是从最后一个元素到第一个元素) ,使用的是初级错误的 http://blog.codinghorror.com/the-danger-of-naivete/算法和 .OrderBy(x => r.Next()).OrderBy(x => r.Next(someValue))

现在 随机,下一个

大于或等于0且小于 MaxValue 的32位有符号整数。

所以它相当于

OrderBy(x => r.Next(int.MaxValue))

为了测试这个问题是否存在,我们可以放大数组(非常慢)或者简单地降低随机数生成器的最大值(int.MaxValue不是一个“特殊”数字... 它只是一个非常大的数字)。最后,如果算法没有因为 OrderBy的稳定性而产生偏差,那么任何范围的值都应该给出相同的结果。

然后程序测试一些值,范围是1... 4096。看看结果,很明显,对于低值(< 128) ,算法是非常有偏见的(4-8%)。如果有3个值,则至少需要 r.Next(1024)。如果将数组放大(4或5) ,那么即使是 r.Next(1024)也是不够的。我不是洗牌和数学方面的专家,但是我认为对于数组的每一个额外的位,你需要额外的2个最大值(因为生日问题连接到 sqrt (numvalue)) ,所以如果最大值是2 ^ 31,我会说你应该能够对数组排序到2 ^ 12/2 ^ 13位(4096-8192个元素)

寻找一个算法? 你可以使用我的 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在每个步骤中逐步减少,它在 0count之间取一个随机数,并将其移动到列表的末尾。

在下面的逐步示例中,可以移动的项目是 斜体,选择的项目是 大胆:

0 1 2 3 < em > 4
0 1 2 3 < em > 4
0 1
0 1
1 2 430
1 2 430
1 2304
1 2304
2 3041
2 3041
30412

我认为 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];
}
}

值得注意的是,由于 LINQ 的 延期执行,在 OrderBy()中使用随机数生成器实例可能导致 可能是意想不到的行为: 在读取集合之前排序不会发生。这意味着 每次读取或枚举集合时,顺序都会更改。 One 可能期望元素被洗牌一次,然后在此后每次访问时保持顺序。


Random random = new();
var shuffled = ordered.OrderBy(x => random.Next())

上面的代码将 lambda 函数 x => random.Next()作为参数传递给 OrderBy()。这将 捕捉random引用的实例,并将其与 lambda 一起保存,这样它就可以在这个实例上调用 Next()来执行稍后的排序,该排序发生在它被枚举之前(当从集合请求第一个元素时)。 这里的问题在于,由于这个执行保存到以后,排序发生在使用在同一个随机实例上调用 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()或等效项立即枚举元素,则不适用这种方法。但是,这种行为在许多情况下可能导致 bug,其中之一是当洗牌后的集合预期在每个索引处包含一个唯一元素时。