最近编程时遇到一个问题:有一组对象,要求随机地访问其中每一个对象,并且每个对象只访问一次。如果我们将访问顺序转换为一组整数序列,那么这就是一个关于“非重复随机序列生成算法”的问题。
本文将探讨这个问题的多种解法,并给出一个非常高效的算法。
【问题描述】:有一个自然数N,希望得到一个整型序列,该序列包含N个整数,从0到N-1,呈随机分布状态,且不重复。
【问题分析】:生成随机数是简单的,关键是,如何保证不重复呢?一般来说,我们有两种思路:
思路1:我们不能保证每次生成的随机数都是不重复的,但是可以在生成随机数之后,判断这个数值是否已经生成过了,如果已经生成过了,那就重新生成一个,直到生成一个新的数值。
思路2:每生成一个随机数之后,就调整随机数的生成规则,将已经生成过的数值排除掉,从而保证每次生成的随机数一定是新的。
接下来,我们将根据这两种思路,给出几种算法,并分析每种算法的复杂度。
【算法1】
“思路1”是绝大多数程序员的思维。如果在项目开发过程中遇到这个问题,我相信大部分程序员一定会这么做:创建一个List,用于保存随机生成的数值,每次插入时,先判断这个数值是否已经在List中存在了,如果已经存在,则重新生成。C#代码如下:
/// <summary> /// 生成一个非重复的随机序列。 /// </summary> /// <param name="count">序列长度。</param> /// <returns>序列。</returns> private static List<int> BuildRandomSequence1(int length) { List<int> list = new List<int>(); int num = 0; for (int i = 0; i < length; i++) { do { num = random.Next(0, length); } while (list.Contains(num)); list.Add(num); } return list; }
上述算法简单易懂,时间复杂度是O(N²) ,空间复杂度是O(N)。该算法的局限在于,每生成一个随机数,都要遍历List,判断是否已经存在,这就导致时间复杂度太高。那么,如何改善这个算法呢?请继续往下看。
【算法2】
我们知道,遍历List的时间复杂度是O(N),访问Hashtable的时间复杂度是O(1)。所以,如果我们使用Hashtable来判断是否重复,就可以将整个算法的时间复杂度从O(N²)降至O(N),而空间复杂度仍然基本保持在O(N)。
C#代码如下:
/// <summary> /// 生成一个非重复的随机序列。 /// </summary> /// <param name="count">序列长度。</param> /// <returns>序列。</returns> private static Hashtable BuildRandomSequence2(int length) { Hashtable tab = new Hashtable(length); int num = 0; for (int i = 0; i < length; i++) { do { num = random.Next(0, length); } while (tab.Contains(num)); tab.Add(num, null); } return tab; }
经过测试,性能确实有了非常大的提升。但是,有一个问题依然存在:随着Hashtable中的数值越来越多,重复概率也会越来越高,这一点很容易理解。如何才能降低重复概率、进一步提高算法性能呢?不妨尝试一下“思路2”。
【算法3】
“思路2”的基本思想是:利用随机数的生成特点,将已经生成的数值,排除在随机区间之外,这样就可以确保下次生成的随机数一定是新的。具体来说,我们可以这样做:
首先,建立一个长度为N的数组array,初始值是0…N-1。
然后,生成一个随机数x1=random.Next(0, N),则x1∈[0,N)。取num1=array[x1]作为序列中的第一个成员。接下来是关键步骤:将num1和array[N-1]交换。
然后,生成下一个随机数x2= random.Next(0, N-1),则x2∈[0,N-1)。由于num1已经被交换到了array[N-1],而x2<N-1,所以num2=array[x2]一定不等于num1,从而避免了重复。然后将num2和array[N-2]交换。
按照上述方法,可以得到序列中第三、第四…第N个成员。最后得到的array就是一个非重复的随机序列。以下是整个计算过程的图形演示(假设N=5):
C#代码如下:
/// <summary> /// 生成一个非重复的随机序列。 /// </summary> /// <param name="count">序列长度。</param> /// <returns>序列。</returns> private static int[] BuildRandomSequence3(int length) { int[] array = new int[length]; for (int i = 0; i < array.Length; i++) { array[i] = i; } int x = 0, tmp = 0; for (int i = array.Length - 1; i > 0; i--) { x = random.Next(0, i + 1); tmp = array[i]; array[i] = array[x]; array[x] = tmp; } return array; }
经过分析,算法3的时间和空间复杂度都是O(N),性能非常高。通过巧妙的交换位置的方法,可以确保每次得到的数值一定是不重复的,所以不用去判断是否重复。而且,使用数组来保存序列,比List和Hashtable性能更好。
上述算法生成的随机序列是从0到N-1,如果我们指定了别的区间范围呢?例如,要求生成一个非重复的随机序列,范围是[low, high]。实现起来非常简单,只要把算法3稍微改一下就可以了。C#代码如下:
/// <summary> /// 生成一个非重复的随机序列。 /// </summary> /// <param name="low">序列最小值。</param> /// <param name="high">序列最大值。</param> /// <returns>序列。</returns> private static int[] BuildRandomSequence4(int low, int high) { int x = 0, tmp = 0; if (low > high) { tmp = low; low = high; high = tmp; } int[] array = new int[high - low + 1]; for (int i = low; i <= high; i++) { array[i - low] = i; } for (int i = array.Length - 1; i > 0; i--) { x = random.Next(0, i + 1); tmp = array[i]; array[i] = array[x]; array[x] = tmp; } return array; }
为了验证上述三种算法的实际性能,我们以生成随机序列所耗的平均时间为标准,进行了实际测试。
测试环境为:Windows7/ CPU 1.6GHZ /VS2008/C#。测试结果为:
序列长度 |
算法1 |
算法2 |
算法3 |
100 |
15ms |
<1ms |
<1ms |
1000 |
46ms |
<1ms |
<1ms |
10000 |
4430ms |
31ms |
<1ms |
【总结】
本文算法3的关键方法是:在现有数组基础上,通过不断地交换位置,来巧妙地达到时间和空间的最优。
其实,一些经典排序算法采用的也是这个思想,例如:冒泡排序、快速排序、堆排序,等等。
算法3也可以理解为一种随机排序算法,可以应用在很多场合,例如:洗牌、抽签等。