【算法实验1】不同排序算法的运行时间,Selection问题
写在前面
本文章是根据哈工大(威海)2021年秋季学期王金宝老师的算法设计与分析(软件工程专业)课程实验报告写就,所有代码已通过验收,能力有限不一定是最优解,全部实验与代码仓库见这个链接,还没来得及写,后期会改链接。如有错误,还请指正。
内容背景
不同排序算法的运行时间随数据规模增长的情况各不相同,我们将在不同规模的数据上测试各种排序算法,以获取不同排序算法运行时间的增长情况。
Selection问题可以通过先排序,再定位的方式解决,也可以通过课堂上学习的分治(减治)算法直接解决。我们将在不同规模的数据上对比两种解决方式的运行时间。
问题概述
本实验将解决以下问题:
- 测试数据生成:随机生成不同规模的测试数据,数据类型采用双精度数字(double类型),数据规模为10,102,103,104,105,106,107个数字.
- 实现InsertionSort、MergeSort和QuickSort,测试InsertionSort、MergeSort和QuickSort在不同规模数据上的运行时间。
- 实现先排序再定位和直接使用分治(减治)的算法分别求解Selection问题,并测试两种方式在不同规模数据上所需的时间。(Selection问题中选择目标的序号可以随机生成,或者指定为数据规模的一半)
- 绘制线图展示:
- 不同排序算法在不同规模数据集上的运行时间
- 对比两种解决Selection问题的方法所需的时间。(绘图可以使用GNUPlot、Python或Excel等)
注意:为了消除随机性,我们为每个数据规模随机生成一个数据集,随后在每个数据规模的数据集上运行1000次测试,每个数据规模对应的运行时间取1000次测试的平均值。
思路及程序
测试数据生成
使用随机数库生成(0,1)之间的随机数,通过对10取不同大小指数来确定规模,循环生成数据。再将数据与对应规模大小相乘以保证分布的均匀性,并存储于二维数组中:
private static double[] gendata(int exp) {
int total = (int) Math.pow(10, exp);
double[] data = new double[total];
for (int i = 0; i < total; i++) {
double tmp = Math.random() * total;
data[i] = tmp;
}
return data;
}
实现InsertionSort、MergeSort和QuickSort,测试运行时间
采用策略模式将计时方法放在SortContext类中以复用,运行时将算法自身作为参数传入策略类,以调用其计时方法。策略类中,让不同的排序算法实现于相同的抽象接口,返回一个排好序的double数组。整个项目类图如下所示:
在Exp1(含主函数的类)中使用静态变量定义数据规模大小(因为插入排序在10的7次方时无法跑出结果,所以需要单独处理),每轮数据循环次数,以及初始化传入不同的三种策略的Context:
static int COUNT = 10;
static int EXP = 6;
static SortContext ist_cnt = new SortContext(new InsertSort());
static SortContext mer_cnt = new SortContext(new MergeSort());
static SortContext qck_cnt = new SortContext(new QuickSort());
通过循环将产生的数据传入每个Context的sortDoubleArray方法中,该方法将根据不同的策略类执行不同的sort方法。累加每轮循环的时间存储于大小为3的数组,分别对应三种排序方法,打印该数组并存储结果:
for (int exp = 1; exp < EXP + 1; exp++) {
double[] times = new double[3];
for (int cnt = 0; cnt < COUNT; cnt++) {
double[] data = gendata(exp);
rundata(data, times);
}
for (int i = 0; i < 3; i++) {
times[i] = times[i]/COUNT;
}
System.out.println(Arrays.toString(times));
}
rundata函数体如下,将data传入策略类,更新时间:
private static void rundata(double[] data, double[] times) {
double[] insertdata = (double[]) data.clone();
ist_cnt.sortDoubleArray(insertdata);
double[] quickdata = (double[]) data.clone();
qck_cnt.sortDoubleArray(quickdata);
double[] mergedata = (double[]) data.clone();
mer_cnt.sortDoubleArray(mergedata);
times[0] += ist_cnt.getExeTime();
times[1] += mer_cnt.getExeTime();
times[2] += qck_cnt.getExeTime();
}
此处仅以归并排序为例,展示代码调用过程:
- 通过Context调用的抽象方法,根据不同的选择(此处是MergeSort类)将自身作为参数传入策略类,调用策略类中的具体排序方法:
public double[] sortDoubleArray(double[] a) {
return this.alg.sort(a, this);
}
- MergeSort类中具体的排序方法,分别对前半部分和后半部分递归的调用该函数,最后调用函数merge进行两部分的合并。该sort函数最后返回的是排好序的double数组:
public double[] sort(double[] doubleArray, SortContext ct) {
ct.startExecution();
mergesort(doubleArray, 0, doubleArray.length-1);
ct.endExecution();
return doubleArray;
}
public double[] mergesort(double[] doubleArray, int low, int high) {
int mid = (low + high) / 2;
if (low < high) {
mergesort(doubleArray, low, mid);
mergesort(doubleArray, mid + 1, high);
//左右归并
merge(doubleArray, low, mid, high);
}
return doubleArray;
}
public void merge(double[] doubleArray, int low, int mid, int high) {
int i = low;
int j = mid + 1;
int k = low;
// 把较小的数先移到新数组中
while (i <= mid && j <= high) {
if (doubleArray[i] < doubleArray[j]) {
temp[k++] = doubleArray[i++];
} else {
temp[k++] = doubleArray[j++];
}
}
// 把左边剩余的数移入数组
while (i <= mid) {
temp[k++] = doubleArray[i++];
}
// 把右边边剩余的数移入数组
while (j <= high) {
temp[k++] = doubleArray[j++];
}
// 把新数组中的数覆盖nums数组
for (int x = low; x <= high; x++) {
doubleArray[x] = temp[x];
}
}
Context中的计时方法实现如下,采取纳秒级别计时,最后除以1000L换算单位为微秒:
public void startExecution() { startTime = System.nanoTime(); }
public void endExecution() { endTime = System.nanoTime(); }
public long getExeTime() {
long exeTime = 0;
exeTime = endTime - startTime;
return exeTime / 1000L;
}
实现先排序再定位和直接使用分治(减治)的算法分别求解Selection问题
先排序再定位使用快速排序方法,首先进行数据的交换,将数组划分成小于pivot,大于pivot和等于pivot三个部分,分别对小于和大于两个部分递归的调用快速排序,最终得到有序数组,取下标为k-1处的数即为def partition(nums, left, right):
def partition(nums, left, right):
new_i, pivot = left, nums[right]
for index in range(left, right):
if nums[index] <= pivot:
nums[new_i], nums[index] = nums[index], nums[new_i]
new_i = new_i+1
nums[new_i], nums[right] = nums[right], nums[new_i]
return new_i
def QuickSort(nums, left, right):
if left < right:
sentry = partition(nums, left, right)
QuickSort(nums, left, sentry-1)
QuickSort(nums, sentry+1, right)
分治(减治)的算法将改进快速排序算法,这里没有采用课本上的先划分中位数的方法,当pivot等于k时已经可以确定,前面的数都小于pivot,后面的数都大于pivot,返回结果,否则判断k和当前划分下标的关系,仅对第k个数所在部分进行快速排序即可:
def Cutbyk(nums, k):
left, right = 0, len(nums)-1
sentry = partition(nums, left, right)
pos = sentry+1
if pos == k:
return nums[k-1]
elif pos > k:
return Cutbyk(nums[0:sentry], k)
else:
return Cutbyk(nums[sentry:], k-sentry)选择结果(第k小的数):
绘制线图展示
使用pyplot库进行线图的绘制,对相同规模不同排序算法,相同排序算法不同规模比对进行分析;并使用插入排序预计O(nlogn)复杂度,与实际情况进行比对,以比较复杂度阶的关系;最后对排序后再选择和采用减治算法的选择进行图表绘制,具体代码较长,不再放出。
结果
不同规模下排序算法的比较
对每个规模的数据运行三种不同算法,相同算法相同规模数据进行20次循环取平均值,自左至右分别是10的1,2,3…7次方规模,自上而下分别是InsertSort,MergeSort,QuickSort,程序输出结果如下图所示(单位微秒):
可以发现插入排序在10,100个数字时尚未体现出劣势,而在1000以及更大规模下有着明显速度差异,其中10的7次方插入排序无法运行出精确结果,故以INF代替。
而归并排序与快速排序速度在数据规模不大时(10的4次方以内)相差不大且无明显优劣之分,随着数据规模的增长,快速排序将体现出更好的性能。将它们绘制于同一张折线图以观察趋势与差异性:
根据课上所学知识,插入排序呈现O(n^2)的增长趋势,归并和快速排序呈现O(nlogn)的增长趋势,通过对插入排序的时间进行开方计算,折合成相应的n,计算nlogn对归并和快速排序进行时间预测。
预测结果如下图所示,可以看到阶的关系趋势大致相同(较大规模时不再满足,使用插入排序预计的更慢):
不同规模下排序后选择和减治算法的比较
对每个规模的数据运行快速排序和减治算法,随机选取规模大小内的第k个数字。相同算法相同规模数据进行20次循环取平均值,自上而下分别是10的1,2,3…6次方规模,10的7次方超过python核递归深度,不予讨论。程序输出结果如下图所示,输出找到的数字相同验证了其正确性:
根据数据绘制折线图,先排序再定位的性能显然不如减治算法,且随着数据规模增大,二者差异逐渐也增大,满足O(nlogn)与O(n)的增长趋势:
实验结论
不同排序算法的运行时间随数据规模增长而增长,但增长幅度不同,插入排序,归并排序和快速排序的增长趋势基本满足分析的时间复杂度关系,即O(n^2)和O(nlogn)。在相同规模下,插入排序算法效率最低,快速排序算法效率最高,且随着规模的增长差异逐渐显著。
Selection问题可以通过先排序,再定位的方式解决,也可以通过课分治(减治)算法直接解决,在不同规模的数据上对比两种解决方式的运行时间,发现减治算法拥有更好的表现。
遇到的问题及解决
运行时间过长问题
- 一开始使用的python,在10的4次方时就会报错,
RuntimeError: maximum recursion depth exceeded in comparison
,查阅资料后发现是python默认递归栈深度为8000,通过sys库的sys.setrecursionlimit(100000)
将最大递归栈深度扩大。后来10的7次方规模数据仍然跑不出来,改用Java。 - 最开始使用的是定义函数的方法,即每种排序算法作为一个函数,函数参数中包含当前的数据,在递归调用时采取复制切片的方法。发现在较大规模数据时归并排序的速度比插入还慢,推测是占用太多空间导致GC收集过于频繁,于是将每种算法抽象成类,使用类变量存储数据,不再冗余复制。
等号赋值传引用问题
一开始使用的都是data作为参数,但是发现三种算法谁放在前面谁运行时间最长,经过调试发现是函数参数传递的引用,最前面的算法对data已经进行了排序,导致后面算法以最好的复杂度运行。将data由引用传值改为赋值传值即可。
时间输出总是0问题
因为10的1~4次方速度较快,总是输出0,将毫秒计数的方法System.currentTimeMillis()
改为纳秒计数的方法System.nanoTime()
,最后乘以1000换算成微秒级别。