目录
一、没什么大用的逼逼叨
(一).排序算法应用
(二).排序算法的分类
二.如何认识和评价一个排序算法
(一)时间复杂度
(二)空间复杂度
(三)平均性能
(四)稳定性
(五)对不同数据类型的适应性
三.算法机制和实现
(一)准备工作&说明
(二)BubbleSort(冒泡排序)
(三)Insertion(插入排序)
(四)SelectionSort(选择排序)
(五)ShellSort(希尔排序)
(六)MergeSort(归并排序)
(七)QuickSort(快速排序)
(八)TestSort(测试方法)
1.测试数组的生成
2.计时器编写
四.总结
下面看点相关知识热身
搜索和查找:排序后的数据可以采用更高效的搜索和查找算法进行操作。
数据分析和统计:排序算法可以帮助对数据进行分析和统计。例如,对一组无序的数字进行排序后,可以更方便地计算中位数、众数、范围等统计指标。
数据压缩和编码:某些压缩算法和编码技术要求数据按照一定的顺序进行排列。排序算法可以满足这些需求,使得数据在进行压缩或编码时更加高效。
数据展示和可视化:排序后的数据可以更直观地展示和可视化。例如,在图表或图形中展示排序后的数据,可以更清晰地观察数据的分布。
感觉我在水文章。。。。。
排序按照移动方式可分为直接移动和逻辑移动两种方式。直接移动是直接交换存储数据的位置(在前期阶段的学习中更加常见),而逻辑移动并不会移动数据存储的位置,仅改变指向这些数据辅助指针的值。这里对逻辑移动做一个简单展开,便于各位理解
链表结构:当排序的数据以链表的形式存储时,直接交换节点的位置可能会导致时间复杂度较高。此时,通过逻辑移动,即改变节点之间的指针关系,可以更高效地进行排序操作。
大规模数据:对于大规模数据集合,直接交换元素的位置可能会涉及较多的数据搬移操作,导致性能下降。在这种情况下,通过逻辑移动,即通过修改辅助指针的值,可以避免大规模的数据移动,提高排序效率。
硬件限制:在某些硬件环境下,直接交换数据的位置可能会引入较高的开销。例如,使用磁盘进行排序时,读取和写入数据的成本较高,直接移动数据可能会导致较多的磁盘操作。使用逻辑移动,可以减少实际的数据移动操作,从而提高排序的效率。
排序按照实现过程分为内部排序和外部排序。数据量小时,所有的数据均可以全部加载到内存进行排序,这种排序称为内部排序;数据量过大而无法一次性加载到内存中,则需要借助硬件(磁带,磁盘等)辅助存储器进行排序的,则称为外部排序。
请各部门进入学习状态,下面正式开始
请各部门进入学习状态,下面正式开始
请各部门进入学习状态,下面正式开始
时间复杂度是衡量算法执行时间随输入规模增长而增长的量度。它描述了算法执行所需的时间与输入规模之间的关系。
在算法评估中,时间复杂度用于比较不同算法的效率。具有较低时间复杂度的算法意味着它在处理大规模问题时所需的时间较少。通过比较算法的时间复杂度,可以选择最优的算法来解决特定的问题。
此外,时间复杂度还有助于预测算法在实际应用中的执行时间,可以提供一个相对的参考,帮助我们估计算法的可行性和可接受性。
时间复杂度只考虑算法执行时间与问题规模之间的关系,不考虑具体的硬件环境、编程语言等因素。
//想要得到更详细的知识可点击链接,水平不够就不多bb了
空间复杂度是衡量算法所需的额外空间随输入规模增长而增长的量度。它描述了算法所使用的额外空间与输入规模之间的关系。
额外空间和输入空间:空间复杂度通常指的是算法所需的额外空间,即除了输入数据本身所占用的空间外,算法执行过程中额外使用的空间。输入空间是指算法所接受的输入数据的空间占用。在分析空间复杂度时,通常将额外空间与输入空间区分开来。
原地算法(In-place Algorithm):原地算法是指一种空间复杂度为常数的算法,它可以在有限的额外空间下完成操作,而不需要使用与输入规模成比例的额外空间。原地算法对于内存受限的环境或大规模数据的处理非常有用。
空间优化:通过重新设计数据结构、减少临时变量的使用、使用迭代代替递归等技巧,可以对算法进行空间优化,以减少额外空间的使用。
空间复杂度分析方法:空间复杂度可以使用数据结构、辅助空间和递归深度等来推导。常见的计算方法包括使用大O表示法、计算递归调用所需的栈空间、分析递推关系式等。
例如:冒泡排序、插入排序、选择排序、希尔排序和快速排序都是原地排序算法,它们的空间复杂度都是O(1),不需要额外的空间。而递归排序的空间复杂度取决于递归调用的深度,最坏情况下的空间复杂度为O(n),这就是所谓的空间换时间。
在设计算法时,我们也需要考虑空间复杂度和时间复杂度之间通常存在权衡关系,有时可以通过增加额外空间来减少时间复杂度,或者通过减少额外空间来增加时间复杂度,在选择算法时,需要根据具体问题和需求来平衡空间和时间的利用。
我们先针对本文的情况进行分析,想要更加客观的评判排序算法的平均性能,除了通过暴力美学进行数学计算,我们还可以采用代入一些特殊的数组进行模拟排序,记录空间和时间复杂度,通常特殊数组的设计采取下面这几种思路
//生成一个长度为N的完全逆序数组,用来表示排序面临的最差情况,估测算法的O( ),这只是一个通用的方法,针对不同的排序方法,最差情况不一定是如此
//生成一个长度为N的正序的数据序列,针对算法设计的数组,表示最优情况。相对而言参考价值不高,适用于需要对结果进行多次check的实例。
//随机生成数组,估测算法的
//针对一些特殊情况我们还可以用撒点法针对性生成符合正态分布,有大量重复元素的数组等
(在后面将会进行简答的代码演示)(八)TestSort(测试方法)
下面是关于估算平均状态下算法性能的一些理论知识和习题
输入分布:算法的平均情况下的性能取决于输入数据的分布情况。不同的输入分布可能导致不同的执行路径和性能表现。常见的输入分布包括均匀分布、正态分布、随机分布等。根据实际应用场景和数据特点,选择适当的输入分布模型是分析算法平均性能的关键。
注意!在除了更换排序算法,我们还可以通过数据处理改变输入数据的分布情况,不过这更多牵扯到机器学习的内容。概率分析:通过概率论和统计学的方法来分析算法在平均情况下的性能。它涉及到计算各种输入情况下的概率,并将这些概率与算法的执行时间或空间利用进行加权平均。这样可以得到算法的平均性能估计。
期望值:期望值是概率分析中常用的概念,表示随机变量的平均值。对于算法的平均性能分析,可以计算算法在各种输入情况下的性能,通过加权平均数计算。
随机化算法:随机化算法是一种利用随机性来提供更好平均性能的算法。通过引入随机性,随机化算法可以减少最坏情况出现的可能性,并在平均情况下提供更好的性能。常见的随机化算法包括快速排序的随机化版本(会更新)和随机选择算法等。
排序的稳定性指的是保证排序前2个相等的数其在序列的前后位置顺序在排序后不变。
如数组arr[6]={5 , 3 , 4 , 2(1) , 2(2) , 3}其中有两个相等的元素2,我们给他编号为2(1)和2(2),没有开始排序时,2(1)在2(2)之前。如果排序算法在排序后,有可能使得2(1)和2(2)前后关系发生改变,则这个算法不具有稳定性。
在某些情况下,需要对对象的多个属性进行排序,其中某些属性具有相等的值。如果排序算法是稳定的,那么在对其中一个属性进行排序的同时,可以保持其他属性的相对顺序不变。简单来说,比如淘宝里多重检索,价格+评价双排序,查询论文年限和关键词双排序。
在应用中的排序不仅仅局限于整数、浮点数类型的数据,很多时候还需要对字符串甚至图片等其他类型进行排序。递归排序通常是基于比较的排序算法,适用于各种类型的可比较数据。可以通过自定义比较函数来处理自定义数据类型。
如果不考虑效率,其实我们只需要针对每一种数据类型设计一个判定“大小”的函数,就可以实现排序算法的移植了
为了增强代码的可读性和复用性,我们选择先编写了一个抽象类,其中less()用于在比较时替换掉让人眼花缭乱的大于小于号,exchange()则将交换封装起来
show(),isSorted(),sort()则更多是一个承上启下的作用,为操作者提供了一些简单的功能方便对后续的性能测试和检验。
同时,在分析算法性能时我们采用了不同的乱序数组,希望提前了解的可以先看(测试),不影响食用。
ps:这里我们均采用直接移动的方式。当然如果我们希望这个抽象类进一步兼容逻辑移动,可以选择给exchange()一个Comsumer
代码速览
exchange(a[] ,b,c) a数组中序号为b和c交换
less(a,b) 表示
a
小于b
,方法返回true,
反之为falseshow(a[ ]) 20个一行打印
isSorted(a[ ]) 检查a[ ]是否已经排序完成
sort() 抽象方法
源代码附上
public abstract class SortAlgorithm { public abstract void sort(Comparable[] objs); protected void exchange(Comparable[] numbers, int i, int j){ Comparable temp; temp = numbers[i]; numbers[i] = numbers[j]; numbers[j] = temp; } protected boolean less(Comparable one, Comparable other){ return one.compareTo(other) < 0; } protected void show(Comparable[] numbers){ int N = numbers.length; int line = 0; for(int i = 0; i < N; i++){ System.out.printf("%s ", numbers[i]); line++; if(line % 20 == 0) System.out.println(); } System.out.println(); } protected boolean isSorted(Comparable[] numbers){ int N = numbers.length; for(int i = 0; i < N-1; i++) if(numbers[i].compareTo(numbers[i+1]) > 0) return false; return true; } }
冒泡的原理思维是从第一个元素开始,比较相邻元素的大小,如果大小顺序有误,则将其交换位置后继续进行与下一个元素的比较。经过一次扫描操作之后就可以确保最后一个元素被排到了正确的位置上。所以针对一个N元素的数组,至多需要N次循环就可以完成排序。这种排序方法的作用emmm,更多的用于被我们学习
public class BubbleSort extends SortAlgorithm { public void sort(Comparable[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - i - 1; j++) {//每循环一次都可以保证最后第i个位置上的元素是正确 if (less(arr[j + 1], arr[j])) { exchange(arr, j, j + 1); }}}}}
冒泡排序在各种排序算法中可能表现的不够惊艳,但是他仍然可以有一些进阶改动来提升效率(后面有优化代码)
添加标志位:在每一轮遍历中,如果没有发生元素交换,说明数组已经有序,可以提前结束排序。为了实现这一优化,可以引入一个标志位来记录是否发生了元素交换。如果在某一轮遍历中没有交换发生,则可以提前退出循环。
//这是一种普适的优化,后面不再展开写记录最后交换:记录下最后一次交换的位置,该位置之后的元素已经有序,下一轮遍历时只需遍历到该位置即可。这种处理可以有效的改善对部分有序的数组的排序性能
鸡尾酒排序:鸡尾酒排序(Cocktail Sort),也称为双向冒泡排序,是对冒泡排序的一种改进。它从数组的两端开始进行排序,并在每一轮遍历中交替进行正向和反向的冒泡过程。这样可以在一定程度上减少遍历次数。‘
这里是对于1,2优化的实现(这里没有再用继承了,可以直接复制运行
import java.util.Arrays; public class BubbleSort { public static void bubbleSort(int[] arr) { int n = arr.length; boolean swapped = true; int end = n - 1;//最后一次交换的位置,有效减少遍历次数 while (swapped) { swapped = false;//其实2优化本身已经包含了1 的功能,这里为了一起体现所以写了一个缝合怪 for (int i = 0; i < end; i++) { if (arr[i] > arr[i + 1]) { int temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; swapped = true; end = i;//这一步就是2优化的核心 } } } } 送一组测试数组 Original array: [5, 2, 8, 12, 1, 6] Sorted array: [1, 2, 5, 6, 8, 12]
都写到这里了,就把鸡尾酒排序也写了吧,说的很高级,其实就是把二优化放到了两头去,加快了效率(代码测试回头补)
import java.util.Arrays; public class CocktailSort { public static void cocktailSort(int[] arr) { boolean swapped; int start = 0; int end = arr.length - 1; while (start < end) { swapped = false; // 正向冒泡,将最大元素移动到末尾 for (int i = start; i < end; i++) { if (arr[i] > arr[i + 1]) { int temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; swapped = true; end=i; } } // 反向冒泡,将最小元素移动到开头 for (int i = end - 1; i >= start; i--) { if (arr[i] > arr[i + 1]) { int temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; swapped = true; start = i; } } if (!swapped) { break; // 如果没有发生交换,说明数组已经有序,提前退出 } } } }
时间复杂度:
- 最好情况:当输入数组已经有序时,冒泡排序只需要进行一次完整的遍历,没有发生任何元素交换。因此,最好情况下的时间复杂度为 O(n),其中 n 是数组的长度。
(注意:只有使用了添加标志位优化后时间复杂度才是O(n))- 最差情况:当输入数组完全逆序排列时,冒泡排序需要进行 n-1 轮完整的遍历,每轮遍历需要比较和交换 n-i-1 次,其中 i 是当前轮数。因此,最差情况下的时间复杂度为 O(n^2)。
空间复杂度:
- 冒泡排序的空间复杂度为 O(1),因为它只需要使用常数级别的额外空间来存储临时变量和进行元素交换。
稳定性:
- 每次比较只会对大小不同的元素进行交换,并不会改变相同数据的前后关系
(1)没有经过优化后的冒泡排序
y轴上取了对数,可以看到没有经过优化前,无论面对什么情况,冒泡排序的时间复杂度都趋近O(n^2),当数组大小来到2的16方时排序耗时大约介于2E9-3E9。
(2)优化过后的冒泡排序(懒惰不想放一起对比了,大家凑合着看
经过优化后最优情况的时间复杂度已经变成了O(n),而在其他情况下的耗时,emm非常好,增加了,大量的赋值操作其实在平均情况下的表现并不好,这些优化通常只在大量有序的数组展现出优势(但是大量有序用选择排序不香吗????冒泡反正,,挺废的就是说
鸡尾酒排序不再写了,是一个good idea但实际价值有点低
插入排序全称直接插入排序,它的基本思想是将待排序的序列分为已排序和未排序两部分,,然后逐步将未排序部分的元素插入到已排序部分的合适位置,直到整个序列排序完成。我们平时斗地主整理牌面,将牌插到按照大小顺序整理,就是一种插入排序。
插入排序的特点是:每次插入一个元素时,该元素前面的部分都是有序的。在处理逆序数较低的数组时,插入排序地位极高。
插入排序的具体步骤如下:
- 从第二个元素开始,将其视为当前要插入的元素。
- 将当前元素与已排序部分的元素进行比较,找到合适的插入位置。
- 将当前元素插入到合适的位置,并将已排序部分中的元素后移一位,为插入元素腾出位置。
- 重复步骤 2 和步骤 3,直到所有元素都被插入到合适的位置。
这里直接给出基础代码,这里为,使用了两层嵌套的循环,外层循环从第二个元素开始,内层循环将当前元素与已排序的部分进行比较并交换位置,直到找到合适的插入位置。
public class Insertion extends SortAlgorithm { public void sort(Comparable[] objs){ int N = objs.length; for(int i = 1; i < N; i++){ for(int j = i; j > 0 && less(objs[j], objs[j-1]); j--) exchange(objs, j, j-1); } }}
但说实话这样的代码并不让人满意,下面简单列举一些改进思路
使用二分查找:在内层循环中,可以使用二分查找来确定当前元素的插入位置
减少交换次数:在内层循环中,使用逐个比较和交换元素的方式可能会导致较多的元素交换。可以考虑减少交换次数,改为保存当前元素的值,然后在找到插入位置后再进行一次赋值操作。落到实际操作上,用一个临时变量的空间成本换取了交换次数的大量减少,我认为这是很值得的
优化边界条件判断:在内层循环中,可以优化边界条件的判断。例如,可以使用一个临时变量保存当前元素的值,并将边界条件判断提到循环外部,从而减少每次循环中的判断次数。
分类优化:可以设置一个阈值,当子数组的大小小于该阈值时,引入二分查找。
//设置阈值分情况排序也是一种很普遍的优化,在多种排序中均可以使用
因此,我们优化的思路是减少插入阶段的寻找下标耗时和减少访问和判断次数,下面是优化版
public class Insertion extends SortAlgorithm { private static final int INSERTION_THRESHOLD = 10; // 阈值,用于确定何时切换到插入排序 public void sort(Comparable[] objs) { int N = objs.length; for (int i = 1; i < N; i++) { //这里实现了优化4 if (i <= INSERTION_THRESHOLD) { // 对小规模子数组使用普通插入排序 insertionSort(objs, i); } else { // 使用二分查找确定插入位置,并减少交换次数 Comparable current = objs[i]; //这里实现优化2和优化3,但3其实并不会对结果产生很大的影响 int j = binarySearch(objs, current, 0, i - 1); for (int k = i; k > j; k--) { objs[k] = objs[k - 1]; } objs[j] = current; } } } private void insertionSort(Comparable[] objs, int endIndex) { for (int i = 1; i <= endIndex; i++) { Comparable current = objs[i]; int j = i - 1; while (j >= 0 && less(current, objs[j])) { objs[j + 1] = objs[j]; j--; } objs[j + 1] = current; } } //下面这堆是二分查找,优化1 private int binarySearch(Comparable[] objs, Comparable target, int start, int end) { int low = start; int high = end; while (low <= high) { int mid = low + (high - low) / 2; if (less(target, objs[mid])) { high = mid - 1; } else { low = mid + 1; } } return low; } }
时间复杂度
- 最好情况:当输入数组已经有序时,插入排序每次比较都不需要交换元素,只需遍历一次即可完成排序。因此,最好情况下的时间复杂度为 O(n),其中 n 是数组的长度。
- 最坏情况:当输入数组完全逆序排列时,每次插入操作都需要将当前元素与已排序部分的所有元素进行比较并交换位置。总共需要进行约 (n^2)/2 次比较和约 (n^2)/2 次交换。因此,最坏情况下的时间复杂度仍然为 O(n^2)。
空间复杂度:
- 插入排序的空间复杂度为 O(1),因为它只需要使用常数级别的额外空间来存储临时变量和进行元素交换。
稳定性:
- 插入排序算法有稳定性,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序。当然如果你调皮,也可以叫他不稳定。
没有优化前的运行时间复杂度和上文分析的一致
优化之后效率大约提升20%,还是十分不错的
选择排序是一种简单直观的排序算法。它的基本思想是每次从未排序的部分选择最小(或最大)的元素,然后将其放到已排序部分的末尾。通过重复这个过程,直到所有元素都排序完毕。节省了查找插入位置的时间,但是增加了检索未排序数组的成本,如果需要分批次对有序数组进行处理,选择排序一定程度上有保护作用。
选择排序的步骤如下:
- 在未排序部分中,找到最小(或最大)的元素。
- 将最小(或最大)的元素与未排序部分的第一个元素交换位置,将其放到已排序部分的末尾。
- 重复上述步骤,直到所有元素都排序完毕。
优点:代码直观 缺点:慢的嘞
public class SelectionSort extends SortAlgorithm { public void sort(Comparable[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { int minIndex = i; for (int j = i + 1; j < n; j++) { if (less(arr[j], arr[minIndex])) { minIndex = j; } } exchange(arr, i, minIndex);//直接找到最大的进行替换 } } }
没啥好优化的地方,一些通用优化思蛮写点,这里就不进行代码实现了
针对部分有序数组的优化:如果待排序数组的部分区域已经有序,可以记录有序区域的边界,并在选择最小(或最大)元素时跳过这部分已排序的区域。
多线程并行处理:选择排序的每一轮选择最小(或最大)元素可以作为一个独立的任务,并行处理多个任务可以加快排序的速度。
时间复杂度
- 最优情况:选择排序的时间复杂度是O(n^2),因为无论输入数据的初始顺序如何,每个元素都需要和其他元素进行比较,并进行交换操作。即使数组已经部分有序,也需要进行完整的比较和交换操作。
- 最差情况:每次选择最小(或最大)元素都需要遍历未排序部分的所有元素,因此需要进行n-1次比较操作。因此时间复杂度也是O(n^2)
空间复杂度
- 选择排序的空间复杂度为O(1),只需要常数级别的额外空间
稳定性
- 选择排序是一种不稳定的排序算法,相同元素的相对位置可能会发生改变。例如数组{5,3,4,5,2},显然当第一次插入时2和5进行位置交换,两个5的前后顺序就发生了改变。
孤零零的一张图,可以看到他和冒泡排序不分伯仲(卧龙凤雏)
希尔排序(Shell Sort)是一种改进的插入排序算法。它的基本思想是先将待排序数组分割成若干个较小的子数组,对每个子数组进行插入排序。通过不断缩小子数组的间隔,使得数组中相隔较远的元素可以进行比较和交换,从而加快排序的速度。
希尔排序的步骤如下:
- 选择一个间隔序列,通常使用希尔增量(Shell Increment)来确定间隔的值。
- 根据间隔序列,将待排序数组分割成若干个子数组。
- 对每个子数组进行插入排序,即按照插入排序的方法将每个子数组排序。
- 缩小间隔序列,重复步骤2和步骤3,直到间隔为1时完成最后一次插入排序。
这种算法最核心的部分就在于对采取合适的间隔,大大提升了exchange的交换范围,下面是基础算法的实现,选取的间隔公式为(最小的h必须为1)
h在下面代码中参数名为gap
public class ShellSort extends SortAlgorithm { public void sort(Comparable[] arr) { int n = arr.length; int gap = 1; // 初始化间隔序列,满足 hi= h * 3 + 1 while (gap < n / 3) { gap = gap * 3 + 1; } //通过这个运算保证循环最后gap的值为1,而不是2或3 while (gap > 0) { for (int i = gap; i < n; i++) { Comparable temp = arr[i]; int j = i; while (j >= gap && less(temp, arr[j - gap])) { arr[j] = arr[j - gap]; //相当于一次交换就让元素朝着目标位置移动了gap距离,飞一样的感觉 j -= gap; } arr[j] = temp; } gap = (gap-1) / 3; // 缩小间隔 } } }
间隔的选择很大程度上影响了该算法的排序效率,我们需要更加清楚的了解不同循环轮次(不同gap)中数组是怎么进行遍历的
所以我们对于gap的选择有两个基本原则
1.最后一次遍历一定保证gap=1,否则无法保证数组有序
2.不同循环轮次的gap之间尽可能互质,因为如果存在公因数会出现重复比较的情况,如数组{1,2,3,4},当gap=4或2时,对于元素1和4的比较就重复了,降低了效率。
这也就解释了为什么我们为什么绕了一圈将gap的值设定的长相新奇。
下面是一些常见gap选择
希尔增量(Shell Increment)序列:希尔增量序列是最常用的间隔序列之一。它是通过不断除以2来确定间隔的值,直到间隔为1。常用的序列有希尔原始序列(1, 2, 4, 8, ...)和希伯德序列(1, 3, 7, 15, ...)等。显然希伯德序列会比希尔原始序列更加高效
Sedgewick增量序列:Sedgewick增量序列是另一种常用的间隔序列。它通过一系列的指数和乘法操作来确定间隔的值。具体的计算公式如下:
- h(k) = 9 * (4^k - 2^k) + 1,当k为偶数时;
- h(k) = 2^(k+2) * (2^(k+2) - 3) + 1,当k为奇数时。
Sedgewick增量序列的选择在一些情况下可以达到较好的排序性能。Hibbard增量序列:Hibbard增量序列也是一种常用的间隔序列。它通过2^k - 1来确定间隔的值,其中k从大到小递减,直到间隔为1。
时间复杂度:
- 最优情况:时间复杂度为O(n log n)。
- 最差情况:时间复杂度为O(n^2)。在最差的间隔序列下,每次分组后的子数组可能会出现较大的乱序,导致插入排序的效率较低。
- 平均情况:希尔排序的平均时间复杂度较难确定,因为它依赖于间隔序列的选择。在一些常见的间隔序列下,希尔排序的平均时间复杂度可以达到O(n log n)。
空间复杂度:
- 希尔排序的空间复杂度为O(1),即只需要常数级别的额外空间来存储临时变量和交换元素。不随待排序数组的规模而变化。
稳定性:
- 希尔排序是一种不稳定的排序算法,这是因为在每次分组后的子数组中,相隔较远的元素可能会交换位置,导致相同元素之间的相对顺序发生变化。
可以看出在平均情况下,希尔排序的时间复杂度确实是nlog(n),但是这里的worst时间复杂度为什么不是O(n^2)呢?
请注意我们对最差情况的描述,表格中的最差情况用的是完全逆序数组模拟的,但是希尔排序最差情况不是这样(具体是怎么样我也不知道,需要数学计算)
正如前面说的,希尔排序的优化主要体现在gap的选择上,翻了翻csdn只有一篇写的不错,大家也可以用他的方法尝试不同的gap(gap优化)
归并排序(Merge Sort)是一种基于分治思想的排序算法。它将待排序的数组递归地分成两个子数组,然后对每个子数组进行排序,最后将两个已排序的子数组合并成一个有序的数组。通过不断地分割和合并操作,最终完成整个数组的排序。
归并排序的步骤如下:
- 将待排序数组分成两个相等(或近似相等)的子数组。
- 对每个子数组进行递归排序,即重复步骤1和步骤2,直到子数组长度为1,即无法再分割。
- 将两个已排序的子数组合并成一个有序的数组。比较两个子数组的首个元素,选择较小的元素放入结果数组中,并将相应子数组的指针向后移动,重复此过程,直到一个子数组的所有元素都放入结果数组中。(合并操作是归并排序的关键步骤)
- 将剩余的子数组的元素依次放入结果数组中。
归并方法需要对于基础的递归有一定的掌握,递归讲解需要自取。
为了后续展开,该代码采取了一种效率偏低的方法,通过在merge方法中创建临时数组来方便比较,但这也导致运行过程中我们需要创建多次数组,可以先思考下有什么方法可以改进。
public class MergeSort extends SortAlgorithm { public void sort(Comparable[] arr) { mergeSort(arr, 0, arr.length - 1); } private void mergeSort(Comparable[] arr, int low, int high) { if (low < high) { int mid = (low + high) / 2; //递归 mergeSort(arr, low, mid); mergeSort(arr, mid + 1, high); //排序 merge(arr, low, mid, high); } } private void merge(Comparable[] arr, int low, int mid, int high) { int n1 = mid - low + 1; int n2 = high - mid; // 创建临时数组 Comparable[] left = new Comparable[n1]; Comparable[] right = new Comparable[n2]; // 复制数据到临时数组 System.arraycopy(arr, low, left, 0, n1); System.arraycopy(arr, mid + 1, right, 0, n2); int i = 0, j = 0, k = low; //选择最小的数组放进去 while (i < n1 && j < n2) { if (less(left[i], right[j])) { arr[k] = left[i]; i++; } else { arr[k] = right[j]; j++; } k++; } //左右两边有一边已经全部有序 // 将剩余元素复制到数组 while (i < n1) { arr[k] = left[i]; i++; k++; } while (j < n2) { arr[k] = right[j]; j++; k++; } } }
完成了基础的代码我们回到前面的问题,上面的算法还有很多可以改进的地方,我们先来看看有一共哪些
优化合并操作:在
merge
方法中,可以添加一个判断条件,当左边数组的最大值小于等于右边数组的最小值时,可以跳过合并操作,直接将左边数组的元素复制到结果数组中,右边数组的元素也同理。这样可以避免一些不必要的比较和赋值操作。(听起来有点像赌狗)优化临时数组的创建:在
merge
方法中,每次递归调用都会创建临时的左右子数组,可以在排序的开始时创建一个与原始数组相同大小的临时数组,然后在递归过程中重复使用这个数组,避免频繁的内存分配和释放操作。这便是上文所讲的优化方案常规的优化:设定阈值分类讨论,设置flag检查是否发生交换,使用位运算替代除法和取余运算
(算法的优化均可以对这些进行考虑!!)
优化后的代码偏长,慎点!!
public class MergeSort extends SortAlgorithm { private static final int INSERTION_THRESHOLD = 10; // 阈值,小于该长度的子数组使用插入排序 public void sort(Comparable[] arr) { Comparable[] temp = new Comparable[arr.length]; mergeSort(arr, temp, 0, arr.length - 1); } private void mergeSort(Comparable[] arr, Comparable[] temp, int low, int high) { if (low < high) { if (high - low <= INSERTION_THRESHOLD) { // 使用插入排序对小规模子数组进行排序 insertionSort(arr, low, high); } else { int mid = low + ((high - low) >> 1); mergeSort(arr, temp, low, mid); mergeSort(arr, temp, mid + 1, high); if (less(arr[mid], arr[mid + 1])) { // 左边子数组的最大值小于等于右边子数组的最小值,无需合并 return; } merge(arr, temp, low, mid, high); } } } private void merge(Comparable[] arr, Comparable[] temp, int low, int mid, int high) { System.arraycopy(arr, low, temp, low, high - low + 1); int i = low, j = mid + 1, k = low; while (i <= mid && j <= high) { if (less(temp[i], temp[j])) { arr[k++] = temp[i++]; } else { arr[k++] = temp[j++]; } } while (i <= mid) { arr[k++] = temp[i++]; } while (j <= high) { arr[k++] = temp[j++]; } } private void insertionSort(Comparable[] arr, int low, int high) { for (int i = low + 1; i <= high; i++) { Comparable current = arr[i]; int j = i - 1; while (j >= low && less(current, arr[j])) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = current; } } }
借此机会,我们实现了一些常规的优化方法,把这些优化方法融入编程习惯中是非常有用的
(吧
时间复杂度
最优情况下,时间复杂度是O(n log n),归并排序始终需要将数组分成两半,然后对每一半进行递归排序,最后再将两个有序的子数组合并。
最差情况下,复杂度仍是O(n log n)。无论输入数据的顺序如何,归并排序都会将数组均匀地分成子数组,然后对每一对子数组进行合并。
空间复杂度:
- 归并排序的空间复杂度是O(n),在排序的过程中,需要创建一个与原始数组大小相同的临时数组来存储合并过程中的中间结果。因此,归并排序的空间复杂度是线性的。
稳定性
- 归并排序是一种稳定的排序算法。在合并操作中,当遇到两个元素相等时,我们先将左边子数组的元素放入结果数组,这就保证了相等元素的相对顺序不会改变。
和希尔一样,很快!
优化后处理随机数组时的效率优化了40%左右
快速排序(QuickSort)快速排序算法被誉为 20 世纪科学和工程领域的十大算法之一。它的基本思想是选择一个基准元素,通过将数组分割成两个子数组,对这两个子数组分别进行递归排序,最终将整个数组排序。
快速排序的关键在于分区操作,它的目标是通过一趟遍历将基准元素放在正确的位置上,并将数组分割成两个子数组。常用的分区算法是Lomuto分区和Hoare分区
快速排序通过选择基准元素进行分割,并递归排序左右子数组,再进行合并操作;是一种原地排序算法,它可以在原始数组上进行操作,不需要额外的辅助空间。
归并排序先递归排序子数组,再进行合并操作。需要额外的辅助空间来存储中间结果,将子数组合并成更大的有序数组。通常情会创建一个与原始数组大小相同的临时数组。
快速排序具体的实现步骤如下:
- 选择一个基准元素(这里选最后一个元素)。
- 将数组分割成两个子数组,使得一个子数组中的所有元素都小于基准元素,而另一个子数组中的所有元素都大于基准元素。这个过程称为分区(Partition)。
- 对两个子数组分别进行递归排序,直到子数组的长度为1或0,此时子数组已经有序。
- 合并两个子数组,即将基准元素放在它们的中间位置,这样整个数组就被排序了。
采取的排序思路是遍历整个子数组,将子数组中比pivot小的元素放在子数组的左边来实现分割,这种方法更加直观易懂,当然并不是最优解,同时在在运行时可能会出现栈溢出的情况,需要进行vm参数的设置,具体方法需根据软件调整,可自行查阅
PS:快速优化一般来说当分割到阈值以下后就要变成插入排序,这样就不会出现栈溢出。那为什么我这里不写阈值呢?因为作业说不要这样写,,,,,
public class QuickSort extends SortAlgorithm { public void sort(Comparable[] arr) { quickSort(arr, 0, arr.length - 1); } private void quickSort(Comparable[] arr, int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } } private int partition(Comparable[] arr, int low, int high) { Comparable pivot = arr[high]; int i = low - 1; for (int j = low; j <= high - 1; j++) { if (less(arr[j], pivot)) { i++; exchange(arr, i, j); } } exchange(arr, i + 1, high); return i + 1; } }
阈值优化,常规优化如位运算替换除法等
随机选择基准元素:在原始的快速排序算法中,选择基准元素通常是取子数组的最后一个元素。这种选择基准元素的方式可能导致分割不均匀,使得快速排序的性能下降。
三数取中法:选择基准元素时,也可以采用三数取中法(Median-of-Three)来选择一个相对中间大小的元素作为基准。从子数组的开头、中间和末尾选择三个元素,然后取它们的中间值作为基准元素
这里2,3的实现大同小异 private int partition(Comparable[] arr, int low, int high) { // 三数取中法选择基准元素 int mid = low + (high - low) / 2; if (less(arr[high], arr[low])) { exchange(arr, high, low); } if (less(arr[mid], arr[low])) { exchange(arr, mid, low); } if (less(arr[high], arr[mid])) { exchange(arr, high, mid); } Comparable pivot = arr[mid]; 。。。。。。。。。。。。 如果是随机,那就直接把mid改成随机
尾递归优化:快速排序可能会导致递归调用栈的溢出。可以使用尾递归优化,通过将递归调用转换为循环,避免递归调用栈的溢出,通过增加循环次数,减少栈空间的占用
private void quickSort(Comparable[] arr, int low, int high) { while (low < high) { // 随机选择基准元素 int randomIndex = getRandomIndex(low, high); exchange(arr, randomIndex, high); int pi = partition(arr, low, high); // 尾递归调用 if (pi - low < high - pi) { quickSort(arr, low, pi - 1); low = pi + 1; } else { quickSort(arr, pi + 1, high); high = pi - 1; } } }
双指针分割法:可以使用双指针法来将小于基准元素的元素放在左边,大于基准元素的元素放在右边。依次移动左指针和右指针,找到需要交换的元素后进行交换
private int partition(Comparable[] arr, int low, int high) { Comparable pivot = arr[low]; // 使用左端元素作为基准元素 int i = low; int j = high; while (i < j) { while (i < j && !less(arr[j], pivot)) { j--; } while (i < j && less(arr[i], pivot)) { i++; } if (i < j) { exchange(arr, i, j); } } // 将基准元素放到正确的位置 exchange(arr, low, j); return j; }
编程很多情况下需要随机应变,快速排序很多衍生算法不能直接定义为“优化”,但这些情况也很值得我们进行进一步的了解
基准元素的重复值处理:在原始的快速排序算法中,如果数组中存在大量重复的元素,可能会导致分割不均匀,使得性能下降。为了处理这种情况,可以采用三路快速排序,将数组分为小于、等于和大于基准元素的三个部分,然后递归地对小于和大于部分进行排序。
多线程排序:快速排序是天然适合并行化的排序算法。可以将数组分割成多个子数组,并使用多个线程并行地对这些子数组进行排序。这可以充分利用多核处理器的优势.
循环优化:为了避免过大递归的开销,可以使用迭代的方式实现快速排序。这可以通过使用栈或队列来模拟递归过程,并手动管理分割子数组的边界。(例如尾递归)
重复元素的随机化:在存在大量重复元素的情况下,可以考虑使用双指针扫描的方式,将相同的元素聚集在一起,并在后续的排序中跳过这些已经确定位置的元素。
查找第K大/小元素:快速排序可以用于在未排序数组中查找第K大或第K小的元素。通过对数组进行分割操作,确定基准元素的位置,如果基准元素的位置等于K,则找到了第K大/小的元素;如果基准元素的位置大于K,则在左侧子数组中继续查找;如果基准元素的位置小于K,则在右侧子数组中继续查找。这样可以快速定位目标元素,时间复杂度为O(n),其中n是数组的长度。
中位数查找:通过快速排序算法,可以在未排序的数组中找到中位数。中位数是指将数组排序后,位于中间位置的元素。可以使用快速排序的分割操作,找到基准元素的位置,如果基准元素的位置恰好是数组长度的一半,则找到了中位数;如果基准元素的位置小于一半,则在右侧子数组中继续查找;如果基准元素的位置大于一半,则在左侧子数组中继续查找。这样可以快速找到中位数,时间复杂度为O(n),其中n是数组的长度。
荷兰国旗问题:荷兰国旗问题是指对包含红、白、蓝三种颜色的元素的数组进行排序,使得相同颜色的元素相邻。快速排序的分割操作可以用于解决荷兰国旗问题。通过设置两个指针,一个指向当前遍历的元素,另一个指向已经排好序的红色区域的后一个位置。通过交换元素的方式,将红色元素放在左侧,蓝色元素放在右侧,白色元素放在中间。这样可以实现对数组的原地排序,时间复杂度为O(n),其中n是数组的长度。
时间复杂度
- 最优情况:每次划分都能将序列均匀地分成两部分,每次选择的基准元素都刚好是当前子序列的中位数,复杂度为O(nlogn)。
- 最差情况:每次划分都只能将序列分成一个子序列和一个空序列,每次选择的基准元素都是当前子序列的最小或最大元素。在这种情况下,快速排序的时间复杂度为O(n^2)。
- 通过采用随机化选择基准元素或者使用三数取中法来选择基准元素,可以大大减少最差情况的发生概率。
空间复杂度
- 快速排序的空间复杂度主要取决于递归调用栈的深度。在最差情况下,递归调用栈的深度达到n,空间复杂度为O(n)。在最优情况下,递归调用栈的深度为logn,空间复杂度为O(logn)。需要注意的是,快速排序是一种原地排序算法,不需要额外的空间来存储排序结果,所以除了递归调用栈的空间,快速排序的额外空间消耗是很小的。
稳定性
- 快速排序有两个方向,左边的i下标一直往右走,右边的j下标一直往左走,直到i>j, 交换a[j]和a[center_index],在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱
- 快排的不稳定是来自于最后的那一次交换(j指向元素与基准元素的交换)而不是在扫描的过程中控制等号的问题
没有优化前的快速排序是较慢,我们模拟的best数组是本来就有序的数组,可以思考下为什么在快速排序中,best和worst几乎耗时一样(check--)
快速排序的优化太多了,直接放大神讲解吧------快速排序优化
这里我们分两部分介绍,测试数组的生成以及测试方法
关于生成策略上文已经写过了,而这次实验我们生成了正序数组,逆序数组,随机数组,大量重复数组(这里我们取巧了直接取特殊值),正态分布数组,代码如下
正态分布数组我们不使用他进行测试,这里主要阐述一下生成方法
import java.util.Random; public class GenerateData { // 生成一个长度为N的均匀分布的数据序列 public static Double[] getRandomData(int N){ Double[] numbers = getSortedData(N); shuffle(numbers, 0, numbers.length); return numbers; } // 生成一个长度为N的正序的数据序列 public static Double[] getSortedData(int N){ Double[] numbers = new Double[N]; double t = 0.0; for (int i = 0; i < N; i++){ numbers[i] = t; t = t + 1.0/N; } return numbers; } // 生成一个长度为N的逆序的数据序列 public static Double[] getInversedData(int N){ Double[] numbers = new Double[N]; double t = 1.0; for (int i = 0; i < N; i++){ t = t - 1.0/N; numbers[i] = t; } return numbers; } // 将数组numbers中的[left,right)范围内的数据随机打乱 private static void shuffle(Double[] numbers, int left, int right){ int N = right - left; Random rand = new Random(); for(int i = 0; i < N; i++){ int j = i + rand.nextInt(N-i); exchange(numbers, i+left, j+left); } } private static void exchange(Double[] numbers, int i, int j){ double temp = numbers[i]; numbers[i] = numbers[j]; numbers[j] = temp; } //生成大量重复数据的数组 public static Double[] getUnevenData(int size) { Double[] data = new Double[size]; int halfSize = size / 2; int quarterSize = size / 4; int eighthSize = size / 8; double t= 0.0; // 生成 1/2 的数据为 0 for (int i = 0; i < halfSize; i++) { data[i] = t+0; } // 生成 1/4 的数据为 1 for (int i = halfSize; i < halfSize + quarterSize; i++) { data[i] = t+1; } // 生成 1/8 的数据为 2 for (int i = halfSize + quarterSize; i < halfSize + quarterSize + eighthSize; i++) { data[i] = t+2; } // 生成 1/8 的数据为 3 for (int i = halfSize + quarterSize + eighthSize; i < size; i++) { data[i] = t+3; } // 随机打乱数据的顺序 shuffle(data,0,size); return data; } //正态分布数组 public static Double[] getNormalDistributionData(int size, double mean, double stddev) { Double[] numbers = new Double[size]; Random rand = new Random(); for (int i = 0; i < size; i++) { double u1 = rand.nextDouble(); double u2 = rand.nextDouble(); double z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); double value = mean + stddev * z0; numbers[i] = value; } return numbers; } }
针对重复率不同的数组,我们也可以进行模拟 ,空间有限我们给一种,这个函数可以用于构建重复率不同的数组。(原理自己gpt)
private static int[] getSpecialData(int size, double duplicateRate) { Random random = new Random(); int[] array = new int[size]; for (int i = 0; i < size; i++) { if (random.nextDouble() < duplicateRate) { // 生成重复元素 array[i] = random.nextInt(10); } else { // 生成非重复元素 array[i] = i; } } return array; //主函数里面有转化的 }
还有一部分是算法计时,这里直接放出来就好,简单的对运行时间进行了计算
有部分代码可以封装但是为了方便直接调整就不多做了
public class SortTest { // 使用指定的排序算法完成一次排序所需要的时间,单位是纳秒 public static double time(SortAlgorithm alg, Double[] numbers){ double start = System.nanoTime(); alg.sort(numbers); double end = System.nanoTime(); return end - start; } // 为了避免一次测试数据所造成的不公平,对一个实验完成T次测试,获得T次测试之后的平均时间 public static double test(SortAlgorithm alg, Double[] numbers, int T) { double totalTime = 0; for(int i = 0; i < T; i++) totalTime += time(alg, numbers); return totalTime/T; } static int[] dataLength = new int[9]; // 创建一个大小为 9 的数组,用于存储 8 到 16 次方的结果 public static void set(){ for (int i = 8; i <= 16; i++) { dataLength[i - 8] = (int) Math.pow(2, i); // 计算 2 的 i 次方,并存储到数组中 } } public static void timeclick(SortAlgorithm alg){ set(); double[] elapsedTime = new double[dataLength.length]; double[] bestTime = new double[dataLength.length]; double[] worstTime = new double[dataLength.length]; double[] UnevenTime=new double[dataLength.length]; for(int i = 0; i < dataLength.length; i++) { elapsedTime[i] = test(alg, GenerateData.getRandomData(dataLength[i]), 5); bestTime[i] = test(alg, GenerateData.getSortedData(dataLength[i]), 5); worstTime[i] = test(alg, GenerateData.getInversedData(dataLength[i]), 5); UnevenTime[i] = test(alg, GenerateData.getUnevenData(dataLength[i]), 5); } //这坨可以考虑封装起来这里不做了 System.out.println("averageTime"); for(double time: elapsedTime) System.out.printf("%6.3f ", time); System.out.println(); System.out.println("bestTime"); for(double time: bestTime) System.out.printf("%6.3f ", time); System.out.println(); System.out.println("worstTime"); for(double time: worstTime) System.out.printf("%6.3f ", time); System.out.println(); System.out.println("UnevenTime"); for(double time: UnevenTime) System.out.printf("%6.3f ", time); double[] X= {8,9,10,11,12,13,14,15,16}; double[][] Y={elapsedTime,bestTime,worstTime}; LineXYDemo.print(X,Y); } public static void main(String[] args) { //针对插入排序法的测试 SortAlgorithm sort1 = new BubbleSort(); //SortAlgorithm sort2 = new Insertion(); //SortAlgorithm sort3 = new SelectionSort(); //SortAlgorithm sort4 = new ShellSort(); //SortAlgorithm sort5 = new QuickSort(); //SortAlgorithm sort6 = new MergeSort(); // SortAlgorithm sort7 = new Insertion(); timeclick(sort1);//改成sort234567效果不变 } }
几种算法的大乱斗放在这里,但是实际情况比我们模拟的发杂的的多得多,需要我们更详细的了解和分析,应用更多的优化(冒泡进来啥都看不见了,只好扔了它)
第一次写长文,有表达冗长或者废话连篇的现象可以指出,一定修正。针对文中一些没讲清楚的地方可以指出,会及时修改或者添加大佬文章的超链接,感谢!