连续投硬币一千次,连续出现10次正面或反面的概率

如果不考虑使用数学解法,通过程序暴力解题
首先的解题思路是贴近原文:
随机生成1000个1或0存到长度1000的数组中,然后再从前到后从数组中取出10个数和10个0与10个1的数组进行对比是否相同,如果其中一个相同就代表出现了这个结果,再将这个过程重复很多次查看这个结果占所有结果的比例

private readonly int _rollCount = 1_000;  
  
[Benchmark]
public void Loop1()
{
	int matchCount = 0;
	int[] lz = Enumerable.Repeat(1, 10).ToArray();
	int[] ll = Enumerable.Repeat(0, 10).ToArray();
	int[] lt = new int[1000];
	for (int i = 0; i < _rollCount; i++)
	{
		for (int j = 0; j < 1000; j++)
		{
			lt[j] = Random.Shared.Next(0, 2);
		}

		if (ContainsSubArray(lt, lz) || ContainsSubArray(lt, ll))
			matchCount++;
	}
	
	Console.WriteLine((double)matchCount/_rollCount*100 + "%");
}

static bool ContainsSubArray(int[] mainArray, int[] subArray)
{
	if (subArray.Length == 0 || subArray.Length > mainArray.Length)
		return false;

	for (int i = 0; i <= mainArray.Length - subArray.Length; i++)
	{
		if (mainArray.Skip(i).Take(subArray.Length).SequenceEqual(subArray))
			return true;
	}

	return false;
}

耗时:38.706 ms 😀

我们很容易可以看出优化思路,因为每次的投掷都是独立事件,所以就算是并行运算也不影响结果,我们可以并行化for循环,使用多核处理器

private readonly int _rollCount = 1_000;  

[Benchmark]
public void Loop2()
{
	int matchCount = 0;
	int[] lz = Enumerable.Repeat(1, 10).ToArray();
	int[] ll = Enumerable.Repeat(0, 10).ToArray();
	int[] lt = new int[1000];
	Parallel.For(0, _rollCount, _ =>
	{
		for (int j = 0; j < 1000; j++)
		{
			lt[j] = Random.Shared.Next(0, 2);
		}

		if (ContainsSubArray(lt, lz) || ContainsSubArray(lt, ll))
			matchCount++;
	});

	Console.WriteLine((double)matchCount/_rollCount*100 + "%");
}

耗时:20.083 ms 😉

使用数组对比因为涉及装箱所以非常耗时,我们可以通过使用字符串拼接的方法将1000个0或1拼接在一起再使用contain判断是否含有10个1或0的字符串即可,不仅更快速而且代码也更简单

private readonly string _lzstr = "1111111111";  
private readonly string _llstr = "0000000000";  
private readonly int _rollCount = 1_000;

[Benchmark]
public void Loop3()
{
	int matchCount = 0;
	Parallel.For(0, _rollCount, _ =>
	{
		string lt = "";
		for (int i = 0; i < 1000; i++)
		{
			lt += Random.Shared.Next(0, 2);
		}

		if (lt.Contains(_lzstr) || lt.Contains(_llstr))
			matchCount++;
	});
	
	Console.WriteLine((double)matchCount/_rollCount*100 + "%");
}

耗时:73.133 ms 😯

为什么优化的耗时更长了呢,String的拼接实际上非常耗时,我们改成StringBuilder呢

private readonly string _lzstr = "1111111111";  
private readonly string _llstr = "0000000000";  
private readonly int _rollCount = 1_000;

[Benchmark]
public void Loop4()
{
	int matchCount = 0;
	Parallel.For(0, _rollCount, _ =>
	{
		StringBuilder sb = new StringBuilder(1000);
		for (int i = 0; i < 1000; i++)
		{
			sb.Append(Random.Shared.Next(0, 2));
		}

		string lt = sb.ToString();
		if (lt.Contains(_lzstr) || lt.Contains(_llstr))
			matchCount++;
	});
	
	Console.WriteLine((double)matchCount/_rollCount*100 + "%");
}

耗时:1.884 ms 🤗

在不涉及GC以及unsafe代码下,我们还能更快吗?
答案是可以
我们知道二进制数每一位都是0或1,这和我们构造的字符串十分相似,那我们只需要每次都随机构造一个很长的二进制数,然后再查看其内部是否含有10个连续的0或1不就可以了。当然我们的前提是每一位出现0或1的概率都是相等的,那我们的随机二进制数应该是从000…000~111…111这样的范围,这样每位都可能表示两个数字,所以概率是一致的,将其转化成十进制数后也是一一对应,我们只需要在十进制数的0到最大二进制数的十进制数之间随机取数即可,因为int最大表示31位二进制数,我们每次构造25位的二进制数,重复40次即可构造出1000位的二进制数

private readonly string _lzstr = "1111111111";  
private readonly string _llstr = "0000000000";  
private readonly int _base2end = 0B1111111111111111111111111;  
private readonly int _rollCount = 1_000;
  
[Benchmark(Baseline = true)]
public void Loop5()
{
	int matchCount = 0;
	Parallel.For(0, _rollCount, _ =>
	{
		StringBuilder sb = new StringBuilder(1000);
		for (int i = 0; i < 40; i++)
		{
			sb.Append(Convert.ToString(Random.Shared.Next(0, _end), 2).PadLeft(25, '0'));
		}

		string lt = sb.ToString();
		if (lt.Contains(_lzstr) || lt.Contains(_llstr))
			matchCount++;
	});
	
	Console.WriteLine((double)matchCount/_rollCount*100 + "%");
}

耗时:1.047 ms 🤩

全体跑分:

我们从Loop1到Loop5加速了37倍!