【算法实验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();
}

此处仅以归并排序为例,展示代码调用过程:

  1. 通过Context调用的抽象方法,根据不同的选择(此处是MergeSort类)将自身作为参数传入策略类,调用策略类中的具体排序方法:
public double[] sortDoubleArray(double[] a) {
    return this.alg.sort(a, this);
}
  1. 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换算成微秒级别。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
排序算法运行时间是衡量算法效率的重要指标之一。在Python中,常见的排序算法包括冒泡排序、选择排序、插入排序、快速排序、归并排序等。下面是这些算法的简要介绍及其运行时间的分析: 1. 冒泡排序(Bubble Sort): - 平均时间复杂度:O(n^2) - 最好情况时间复杂度:O(n) - 最坏情况时间复杂度:O(n^2) - 空间复杂度:O(1) - 稳定性:稳定 2. 选择排序(Selection Sort): - 平均时间复杂度:O(n^2) - 最好情况时间复杂度:O(n^2) - 最坏情况时间复杂度:O(n^2) - 空间复杂度:O(1) - 稳定性:不稳定 3. 插入排序(Insertion Sort): - 平均时间复杂度:O(n^2) - 最好情况时间复杂度:O(n) - 最坏情况时间复杂度:O(n^2) - 空间复杂度:O(1) - 稳定性:稳定 4. 快速排序(Quick Sort): - 平均时间复杂度:O(nlogn) - 最好情况时间复杂度:O(nlogn) - 最坏情况时间复杂度:O(n^2) - 空间复杂度:O(logn)~O(n) - 稳定性:不稳定 5. 归并排序(Merge Sort): - 平均时间复杂度:O(nlogn) - 最好情况时间复杂度:O(nlogn) - 最坏情况时间复杂度:O(nlogn) - 空间复杂度:O(n) - 稳定性:稳定 需要注意的是,以上时间复杂度是基于平均情况下的估计,实际运行时间还受到数据规模、数据分布等因素的影响。此外,还有其他更高效的排序算法,如堆排序、计数排序、基数排序等,它们的运行时间复杂度更低。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值