目录
♫什么是排序
♪排序的概念
♪排序的稳定性
♪排序的分类
♪常见的排序算法
♫直接插入排序
♪基本思想
♪算法实现
♪算法稳定性
♪时间复杂度
♪空间复杂度
♫希尔排序
♪基本思想
♪算法实现
♪算法稳定性
♪时间复杂度
♪空间复杂度
♫直接选择排序
♪基本思想
♪算法实现
♪算法稳定性
♪时间复杂度
♪空间复杂度
♫堆排序
♪基本思想
♪算法实现
♪算法稳定性
♪时间复杂度
♪空间复杂度
♫冒泡排序
♪基本思想
♪算法实现
♪算法稳定性
♪时间复杂度
♪空间复杂度
♫快速排序
♪基本思想
♪算法实现
♪算法的优化
♪算法稳定性
♪时间复杂度
♪空间复杂度
♫归并排序
♪基本思想
♪算法实现
♪算法的稳定性
♪时间复杂度
♪空间复杂度
♫排序算法的比较
♫什么是排序
♪排序的概念
排序是将一组数据,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
♪排序的稳定性
将一组数据进行排序后,如果原数据与排序后的数据中相同元素的相对位置仍然保持不变,那么该排序就是一个稳定的排序,否则就不是一个稳定的排序。
注:一个本身稳定的排序可以通过不稳定的算法实现,而一个不稳定的排序是不能通过稳定的算法实现的。
♪排序的分类
排序分为内部排序和外部排序两种:
1. 内部排序:数据元素全部放在内存中的排序。2. 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序♪常见的排序算法
常见的排序算法有插入排序(直接插入排序、希尔排序),选择排序(直接选择排序、堆排序),交换排序(冒泡排序、快速排序),归并排序等,这些排序算法也是我们接下来(以从小到大排序为例)重点要了解的东西。
♫直接插入排序
♪基本思想
直接插入排序是一种简单的插入排序算法,它的基本思想是:从第二个元素开始,每个元素都往前找合适的位置插入,直到所有的记录插入完为止,就能得到一个有序序列。
♪算法实现
//直接插入排序(从小到大) public void insertSort(int[] arr) { //空数组,不用排 if (arr == null) { return; } //从第二个数开始到最后一个数 for (int i = 1; i < arr.length; i++) { //将待插入的数拿出来 int tmp = arr[i]; int j = 0; //从后往前找合适的插入位置(待插入值比j位置的值大的位置的后面) for (j = i - 1; j >= 0; j--) { //判断待插入值是否比j位置的值小 if (tmp < arr[j]) { //小,位置不对,将该位置的值往后挪,继续找 arr[j + 1] = arr[j]; } else { //大,找到合适位置,退出循环 arr[j + 1] = tmp; break; } } //当待插入值最小时,将待插入值插入到第一个位置;当找到合适位置,将待插入值插入合适位置 arr[j + 1] = tmp; } }
♪算法稳定性
当相邻的元素比较大小时,若相等,直接插入排序不会改变它们的位置,因此保证了排序前后相等元素的相对位置不变,故直接插入排序是稳定的排序。
♪时间复杂度
♩最好情况(排序一组有序序列):对n-1个元素进行插入操作,由于每个元素都不需要移动,故时间复杂度为O(n)。
♩最坏情况(排序一组逆序序列):对n-1个元素进行插入操作,每个元素对应的移动次数依次为:1、2、3、....、n-1,等差数列求和得(n-1)(1+n-1)/2=n(n-1)/2=(n^2-n)/2,故算法时间复杂度为O(n^2)。注:数组越稳定,使用直接插入排序时效率越高
♪空间复杂度
只需要一个额外的空间存放待插入值,故空间复杂度为O(1)。
♫希尔排序
♪基本思想
我们知道序列越有序,直接插入排序的效率越高,而希尔排序就是先通过局部的直接插入排序将序列变得相对有序后再进行整体的直接插入排序的排序算法,希尔排序也叫作缩小增量排序。希尔排序法的基本思想是:① 先取gap作为第一个增量,把序列根据gap分组。② 所有距离为gap的倍数的记录放在同一个组中,在各组内进行直接插入排序。③ 取第二个缩小的增量gap2,重复上述的分组和排序,直至所取的增量gap=1,即所有记录放在同一组中进行直接插入排序为止。
以gap为元素个数的一半,每次gap的值缩小一半为例:
当 gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样整体的直接插入排序就会很快。♪算法实现
这里依然以gap为元素个数的一半,每次gap的值缩小一半为例:
//希尔排序(从小到大) public void shellSort(int[] arr) { //可数组,不用排 if (arr == null) { return; } //增量的大小取数组元素的个数的一半 int gap = arr.length / 2; //增量每次缩小一半,直至为减为1 for (; gap > 0; gap /= 2) { //对数组进行指定增量的直接插入排序 shell(arr, gap); } } //指定增量的直接插入排序 public void shell(int[] arr, int gap) { //从第一组的第二个元素开始直到整个数组的最后一个元素 for (int i = gap; i < arr.length; i++) { //取出待插入的元素 int tmp = arr[i]; int j = 0; //从每组待插入元素的位置到该组的第一个元素 for (j = i - gap; j >= 0; j -= gap) { //判断待插入值是否比j位置的值小 if (tmp < arr[j]) { //小,位置不对,将该位置的值往后挪,继续找 arr[j + gap] = arr[j]; } else { //大,找到合适位置,退出循环 arr[j + gap] = tmp; break; } } //当待插入值最小时,将待插入值插入到第一个位置;当找到合适位置,将待插入值插入合适位置 arr[j + gap] = tmp; } }
♪算法稳定性
因为希尔排序的是缩小增量的插入排序(无法保证每次分组相同元素都在同一组),故希尔排序是不稳定的排序。
♪时间复杂度
希尔排序的时间复杂度根据gap取法的不同而不同,上述取法的时间复杂度约为O(n^1.25)~O(1.6*n^1.25)之间,其中n为数组中元素的个数。
♪空间复杂度
希尔排序的空间复杂度和直接插入排序一样都是O(1)。
♫直接选择排序
♪基本思想
直接选择排序的基本思想是:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。♪算法实现
//选择排序(从小到大) public void selectSort(int[] arr) { //空数组,不用排 if (arr == null) { return; } //从第一个元素开始到倒二个元素 for (int i = 0; i < arr.length-1; i++) { //记录i及其i后面元素的最小值 int minIndex = i; //找出i及其后面最小元素的下标 for (int j = i + 1; j < arr.length; j++) { if (arr[minIndex] > arr[j]) { minIndex = j; } } //将最小元素的位置移到i位置 if (i != minIndex) { int tmp = arr[i]; arr[i] = arr[minIndex]; arr[minIndex] = tmp; } } }
♪算法稳定性
在选择排序中,如果数组中有两个相等的元素,在选择排序的过程中,可能会先选中后面的元素,导致它们的原始位置交换,这样就破坏了它们在原数组中的相对顺序,故直接选择排序是不稳定的排序。
♪时间复杂度
对n-1个元素进行操作,每个元素对应的比较次数依次为:n-1,n-2,n-3,...,1,等差数列求和得(n-1)(1+n-1)/2=n(n-1)/2=(n^2-n)/2,故算法时间复杂度为O(n^2)。
♪空间复杂度
直接选择排序只需要一个额外的常数级别的空间来交换元素,因此直接选择排序的空间复杂度为O(1)。
♫堆排序
♪基本思想
堆排序是指利用堆这种数据结构所设计的一种排序算法,它是通过堆来进行选择数据(排升序要建大堆,排降序建小堆),是选择排序的一种。堆排序的基本思想是:①.将待排序序列构造成一个大/小根堆。②.将其与末尾元素进行交换, 此时末尾就为最大值。③.然后将剩余的个元素重新调整成一个大/小根堆,反复执行上述操作, 便能得到一个有序序列了。
♪算法实现
//堆排序(从小到大) public void heapSort(int[] arr) { //空数组,不用排 if (arr == null) { return; } //创建大根堆 createBigHeap(arr); int end = arr.length - 1; while (end > 0) { //将堆顶元素和堆的最后一个元素交换 int tmp = arr[0]; arr[0] = arr[end]; arr[end] = tmp; //将剩余元素重新调整为大根堆 shiftDown(arr, 0, end); //堆的个数减一 end--; } } //创建大根堆 private void createBigHeap(int[] arr) { //从最右边的叶子节点开始 for (int parent = (arr.length - 1 - 1) / 2; parent >= 0; parent--) { //向下调整 shiftDown(arr, parent, arr.length); } } //向下调整 private void shiftDown(int[] arr, int parent, int len) { //child为parent的左孩子(右孩子可能不存在) int child = 2 * parent + 1; while (child < len) { //child为parent的左右孩子节点中的最小节点 if (child + 1 < len && arr[child] < arr[child + 1]) { child = child + 1; } //大根堆:parent节点应该是parent节点和child节点中最大的那个节点 if (arr[parent] < arr[child]) { int tmp = 0; arr[parent] = tmp; arr[parent] = arr[child]; arr[child] = tmp; //继续向下调整 parent = child; child = 2 * parent + 1; } else { //已经是大根堆了,退出循环 break; } } }
♪算法稳定性
因为在调整堆的过程中,会进行多次元素的交换,这样可能会打破原本相等元素之间的相对位置关系,故堆排序是不稳定的排序。
♪时间复杂度
初始化堆的时间复杂度为O(n),删除操作的时间复杂度为O(logn),每删除一个元素,总元素减一,n-1次的删除操作的时间应该为:log(n)+log(n-1)+…+log(3)+log(2) = log(n!),
又因(n/2)^(n/2) ≤ n ≤ n^n,即 1/4*nlog(n) ≤ n! ≤ nlogn,去掉常数后时间复杂度为O(nlogn),故堆排序的时间复杂度为O(nlogn)。♪空间复杂度
堆排序是一种原地排序算法,所有操作都在原始的数组中进行,故堆排序的空间复杂度为O(1)。
♫冒泡排序
♪基本思想
冒泡排序可以看成冒泡的过程,每次排序都会让一个“泡泡”(指一个元素)浮到数组的正确位置。冒泡排序的基本思想是:①从数组的第一个元素开始,比较相邻的两个元素。②如果相邻的两个元素顺序不符合要求(比如升序排序时,前一个数大于后一个数),则交换这两个元素的位置。③继续比较下一个相邻的两个元素,直到数组的末尾。④重复以上步骤,每次比较时都从数组的第一个元素开始,直到数组完全有序为止。
♪算法实现
//冒泡排序(从小到大) public void bubbleSort(int[] arr) { //空数组,不用排 if (arr == null) { return; } //总共需要对对n-1个元素进行比较 for (int i = 0; i < arr.length - 1; i++) { boolean flg = false; //每个元素和n-1-i个元素进行比较(i为序列后面已经有序的元素) for (int j = 0; j < arr.length - 1 - i; j++) { //比较相邻两个元素,大的往后移 if (arr[j] < arr[j + 1]) { int tmp = arr[j + 1]; arr[j + 1] = arr[j]; arr[j] = tmp; //进行过交换操作,当前序列仍未有序 flg = true; } } //不需要进行交换操作,当前序列已经有序了 if (flg == false) { break; } } }
♪算法稳定性
在冒泡排序中只有前后元素比较大小并进行交换,而相同元素之间是不会交换的,故冒泡排序是一种稳定的排序。
♪时间复杂度
在最坏的情况下,即序列本来就是逆序的情况下,需要进行n-1次遍历,每次遍历需要比较和交换n-i次,因此总的比较和交换次数是n(n-1)/2,故冒泡排序的时间复杂度是O(n^2)。
♪空间复杂度
因为冒泡排序只需要一个常量级别的额外空间来交换相邻的元素,故冒泡排序的空间复杂度为O(1)。
♫快速排序
♪基本思想
快速排序(Quicksort)是一种高效的排序算法,基于分治的思想,通过不断地将待排序序列划分为独立的两部分,然后对每一部分递归地进行快速排序,最终得到有序序列。 快速排序的基本思想是: 任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。♪算法实现
//快速排序(从小到大) public void quickSort(int[] arr) { //空数组,不用排 if (arr == null) { return; } //对整个数组进行快速排序 quick(arr, 0, arr.length - 1); } private void quick(int[] arr, int start, int end) { //递归结束条件 if (end <= start) { return; } //获取基准数的位置 int pivot = partition(arr, start, end); //对基准数的左边进行快速排序 quick(arr, start, pivot - 1); //对基准数的右边进行快速排序 quick(arr, pivot + 1, end); }
其中找基准数的代码根据基准值划分区间的方式不同,算法的实现也就不同,常见的划分区间的方式有:♩. Hoare法Hoare法的具体步骤如下:①.首先选择一个pivot元素,将待排序数组分成左右两部分;
②.然后将左右两部分分别用一个指针来遍历数组;
③.左指针指向左部分的第一个元素,右指针指向右部分的最后一个元素;
④.左指针向右移动,直到找到第一个大于等于pivot的元素;
⑤.右指针向左移动,直到找到第一个小于等于pivot的元素;
⑥.如果左指针和右指针还没有相遇,则将它们所指的元素交换;
⑦.重复步骤4-6,直到左指针和右指针相遇;
⑧.最后将pivot元素放到相遇点的位置上,使得左部分的元素都小于等于pivot,右部分的元素都大于等于pivot;
⑨.递归地对左右两部分进行排序,直到整个数组有序。
代码实现://Hoare法找基准数 private int partition(int[] arr, int left, int right) { int n = left; //初始基准 int pivot = arr[left]; while (left < right) { //从右边开始找比基准数大的 while (left < right && arr[right] >= pivot) { right--; } //从左边找比基准数小的 while (left < right && arr[left] <= pivot) { left++; } //左边小的和右边打的交换 int tmp = arr[left]; arr[left] = arr[right]; arr[right] = tmp; } //left和right相遇了,将left和初始基准数交换 int tmp = arr[n]; arr[n] = arr[left]; arr[left] = tmp; //返回新基准数:left return left; }
注:只能先从右边找比基准数大的数,如果先从左边找比基准数小的数的话最后和基准数交换的时候会把比基准数大的数换到基准数前面。♩挖坑法挖坑法的具体步骤如下:①.选定一个基准元素,通常选择第一个元素或最后一个元素。
②.将序列分为两部分,一部分为小于基准元素的部分,一部分为大于等于基准元素的部分。
③.在小于基准元素的部分中,从右到左寻找第一个大于等于基准元素的元素,将该元素赋值到基准元素的位置。
④.在大于等于基准元素的部分中,从左到右寻找第一个小于基准元素的元素,将该元素赋值到第3步所挖的坑中。
⑤.重复3、4步,直到左右两部分扫描相遇,然后将基准元素放回该位置。
⑥.递归地对左右两部分进行快速排序
代码实现:
//挖坑法找基准数 private int partition(int[] arr, int left, int right) { //记录初始基准数 int pivot = arr[left]; while (left < right) { //从右边开始找比基准数大的 while (left < right && arr[right] >= pivot) { right--; } //将右边大的放到left位置 arr[left] = arr[right]; //从右边开始找比基准数大的 while (left < right && arr[left] <= pivot) { left++; } //将左边小的放到left位置 arr[right] = arr[left]; } //left和right相遇,将初始基准数放到left位置 arr[left] = pivot; //返回新基准数:left return left; }
♩前后指针法前后指针法的步骤为:①.选取一个基准数,一般选择第一个数或最后一个数;
②.定义前后指针i和j,分别指向数列的开头和结尾;
③.从后往前找到第一个比基准数小的数,记录其坐标j;
④.从前往后找到第一个比基准数大的数,记录其坐标i;
⑤.如果i < j,则交换i和j的位置,使得比基准数大的数在右侧,比基准数小的数在左侧;
⑥.重复③、④、⑤步骤,直到i >= j,此时前后指针相遇;
⑦.交换基准数和前后指针相遇的位置,即将基准数放入正确的位置;
⑧.将数列分成两个部分,分别对左边和右边的部分重复以上步骤,直到整个数列有序。
代码实现:
//前后指针法找基准数 private int partition(int[] arr, int left, int right) { int prev = left, cur = left+1; while (cur <= right) { while (arr[cur] < arr[left] && arr[++prev] != arr[cur]) { int tmp = arr[cur]; arr[cur] = arr[prev]; arr[prev] = tmp; } cur++; } int tmp = arr[left]; arr[left] = arr[prev]; arr[prev] = tmp; return prev; }
♪算法的优化
♩三数取中
当取到的基准数为最大或最小时,快速排序的效率是比较低的,故我们可以通过选取最左边,中间,最右边三个数的中间值降低取到的基准数最大或最小的情况。
private void quick(int[] arr, int start, int end) { //递归结束条件 if (end <= start) { return; } //找到下标为start、end、(start+end)/2中的数中第二大的数的小标 int MidIndex = findMidValOfIndex(arr, start, end); //确保三个数中第二大的在start位置 if (MidIndex != start) { int tmp = arr[start]; arr[start] = arr[MidIndex]; arr[MidIndex] = tmp; } //获取下一个基准数的位置 int pivot = partition(arr, start, end); //对基准数的左边进行快速排序 quick(arr, start, pivot - 1); //对基准数的右边进行快速排序 quick(arr, pivot + 1, end); } //找到下标为start、end、(start+end)/2中的数中第二大的数的小标 private int findMidValOfIndex(int[] array, int start, int end) { int midIndex = (start+end) / 2; if(array[start] < array[end]) { if(array[midIndex] < array[start]) { return start; }else if(array[midIndex] > array[end]) { return end; }else { return midIndex; } }else { if(array[midIndex] > array[start]) { return start; }else if(array[midIndex] < array[end]) { return end; }else { return midIndex; } } }
♩小区间快排
快速排序越排是越有序的,故当待排序序列长度较小时,插入排序的性能可能比快速排序更优。因此可以在递归深度达到一定阈值时,使用插入排序来处理子序列。
private void quick(int[] arr, int start, int end) { //递归结束条件 if (end <= start) { return; } //递归到小区间时,用直接插入排序 if (end - start + 1 <= 15) { insertSort(arr, start, end); return; } //找到下标为start、end、(start+end)/2中的数中第二大的数的小标 int MidIndex = findMidValOfIndex(arr, start, end); //确保三个数中第二大的在start位置 if (MidIndex != start) { int tmp = arr[start]; arr[start] = arr[MidIndex]; arr[MidIndex] = tmp; } //获取下一个基准数的位置 int pivot = partition(arr, start, end); //对基准数的左边进行快速排序 quick(arr, start, pivot - 1); //对基准数的右边进行快速排序 quick(arr, pivot + 1, end); } //直接插入排序 private void insertSort(int[] arr, int left, int right) { for (int i = left+1; i <= right; i++) { int tmp = arr[i]; int j = 0; for (j = i-1; j >= left; j--) { if (arr[j] > tmp) { arr[j+1] = arr[j]; } else { break; } } arr[j+1] = tmp; } }
♪算法稳定性
在快速排序中,如果两个元素的值相同,它们在排序后可能会交换位置,因此快速排序是不稳定的排序。
♪时间复杂度
快速排序的时间复杂度取决于分割点选择的方法,如果每次选择中位数作为分割点,则最坏时间复杂度为O(n^2),平均时间复杂度为O(n log n);如果分割点随机选择,则最坏时间复杂度为O(n^2)的概率很小,平均时间复杂度为O(n log n)。因此,快速排序的平均时间复杂度为O(n log n),最坏时间复杂度为O(n^2)。
♪空间复杂度
快速排序的空间复杂度为 O(nlogn),其中 n 为排序的元素个数。这是因为快速排序是一种原地排序算法,它只需要将排序过程中所需的一些暂存空间复用,不会额外占用大量的内存空间。而在最坏情况下,快速排序的空间复杂度可能会退化为 O(n),但这种情况非常罕见。
♫归并排序
♪基本思想
归并排序是一种分治算法,是将一个大的问题分解成若干个小问题来解决,然后将这些小问题的解合并起来,得到原始问题的解。归并排序的基本思想是:先将待排序的序列分成两个部分,直到每个部分只有一个元素为止,再将两个单元素部分归并成一个有序序列,直到所有序列都合并成一个有序序列为止。
♪算法实现
//归并排序(从小到大) public void mergeSort(int[] arr) { mergeSortChild(arr, 0, arr.length-1); } public void mergeSortChild(int[] arr, int left, int right) { if (left <= right) { return; } int mid = (left+right) / 2; mergeSortChild(arr, left, mid); mergeSortChild(arr, mid+1, right); merge(arr,left,mid,right); } private static void merge(int[] arr,int left,int mid,int right) { int s1 = left; int e1 = mid; int s2 = mid+1; int e2 = right; int[] tmpArr = new int[right-left+1]; int k = 0;//表示tmpArr 的下标 while (s1 <= e1 && s2 <= e2) { if(arr[s1] <= arr[s2]) { tmpArr[k++] = arr[s1++]; }else{ tmpArr[k++] = arr[s2++]; } } while (s1 <= e1) { tmpArr[k++] = arr[s1++]; } while (s2 <= e2) { tmpArr[k++] = arr[s2++]; } //tmpArr当中 的数据 是right left 之间有序的数据 for (int i = 0; i < k; i++) { arr[i+left] = tmpArr[i]; } }
♪算法的稳定性
归并排序在合并两个有序子序列的时候,如果两个元素的大小相同,那么在归并的结果中,左边的元素肯定会先被放入序列中,也就是说它们的相对顺序没有改变。故归并排序是一种稳定的排序。
♪时间复杂度
归并排序在排序过程中,先将待排序的序列递归地拆分为两个子序列,每次拆分会将序列长度缩小一半。然后将两个子序列合并成一个有序的序列,合并操作的时间复杂度为O(n)。整个排序过程中,序列被拆分成了logn级别的子序列,每个子序列都要进行一次合并操作,所以时间复杂度为O(nlogn)。
♪空间复杂度
在归并排序中,需要创建一个额外的数组来保存排序的结果,在归并过程中,需要不断地合并两个有序数组,因此需要创建一个长度为n的数组来保存排序后的结果。另外,在归并排序的递归过程中,需要创建一个长度为n的临时数组来保存每次递归的中间结果,所以归并排序的空间复杂度为O(n)。
♫排序算法的比较
排序算法 最好时间复杂度 最坏时间复杂度 平均时间复杂度 空间复杂度 稳定性 冒泡排序 O(n) O(n^2) O(n^2) O(1) 稳定 插入排序 O(n) O(n^2) O(n^2) O(1) 稳定 选择排序 O(n^2) O(n^2) O(n^2) O(1) 不稳定 希尔排序 O(n) O(n^2) O(n^1.3) O(1) 不稳定 堆排序 O(n*logn) O(n*logn) O(n*logn) O(1) 不稳定 快速排序 O(n*logn) O(n^2) O(n*logn) O(logn)~O(n) 不稳定 归并排序 O(n*logn) O(n*logn) O(n*logn) O(n) 稳定