目录
前言
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
生活中各种地方都需要用到排序,所以学好排序算法是非常重要的。
排序分为 内部排序 和 外部排序。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。这部分主要是内部排序。排序讲解都以升序为例。
常见的排序算法:
// 排序实现的接口// 插入排序void InsertSort(int* a, int n);// 希尔排序void ShellSort(int* a, int n);// 选择排序void SelectSort(int* a, int n);// 堆排序void AdjustDwon(int* a, int n, int root);void HeapSort(int* a, int n);// 冒泡排序void BubbleSort(int* a, int n);// 快速排序递归实现// 快速排序hoare版本int PartSort1(int* a, int left, int right);// 快速排序挖坑法int PartSort2(int* a, int left, int right);// 快速排序前后指针法int PartSort3(int* a, int left, int right);void QuickSort(int* a, int left, int right);// 快速排序 非递归实现void QuickSortNonR(int* a, int left, int right);// 归并排序递归实现void MergeSort(int* a, int n);// 归并排序非递归实现void MergeSortNonR(int* a, int n);// 计数排序void CountSort(int* a, int n);
常见排序算法的实现
1.插入排序
思想:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想:
演示过程动图:
单趟:若数组(arr)除最后一个元素外其余全部有序,设最后一个元素的下标为i,将arr[i]与前面的元素比较,前面的元素比他大则前面的元素向右移动,比他小则在该元素的后面插入。
复合:因为不知道数组中得到前几个元素是已经有序的,所以直接从第二个元素开始执行插入排序,单个过程与上述相同,将每个元素都进行一次插入排序。
代码如下:
// 插入排序void InsertSort(int* a, int n){ assert(a); int i = 0; for (i = 0; i < n - 1; i++)//因为x元素位置是i的下一个位置,为防止x越界,需要使 i < n-1 { int end = i;//已经有序的最后一个元素(一个元素不需要排序,所以默认从0开始) int x = a[end + 1];//需要排序的元素 //单趟 while (end >= 0) { //若前一个数字大于x,则需将他向右移动 if (a[end] > x) { a[end + 1] = a[end]; //继续判断前面的元素 --end; } //前面元素小于x else { break; } } //将x插入正确位置(两种情况) //1.前面的数字小于x //2.前面的数字都大于x,x放在下标为0处 a[end + 1] = x; }}
直接插入排序的特性总结: 元素集合越接近有序,直接插入排序算法的时间效率越高 时间复杂度:O(N^2) 空间复杂度:O(1),它是一种稳定的排序算法 稳定性:稳定
2.希尔排序
希尔排序法又称缩小增量法。
基本思想:
先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的元素进行排序。
然后将gap逐渐减小重复上述分组和排序的工作。
当到达gap=1时,所有元素在统一组内排好序。
静态图演示:
动图演示:
这里重要的是理解分组思想,每一个组其实就是一个插入排序,相当于进行多次插入排序。
代码如下:
// 希尔排序void ShellSort(int* a, int n){ assert(a); int gap = n; while (gap > 1) { //gap /= 2; gap = gap / 3 + 1; for (int i = 0; i < n - gap; i++) { int end = i; int x = a[end + gap]; while (end >= 0) { if (a[end] > x) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = x; } }}
希尔排序的特性总结: 希尔排序是对直接插入排序的优化。 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,这里不深究。 时间复杂度O(N^1.5) 空间复杂度O(1) 稳定性:不稳定。
3.选择排序
基本思想:
第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始(末尾)位置,
然后选出次小(或次大)的一个元素,存放在最大(最小)元素的下一个位置,
重复这样的步骤直到全部待排序的数据元素排完 。
动图演示:
代码入下:
这里我们可以进行一个优化,最小值和最大值同时选,然后将最小值与起始位置交换,将最大值与末尾位置交换。
// 选择排序void SelectSort(int* a, int n){ assert(a); int begin = 0;//保存数组的起始位置 int end = n - 1;//保存换数组的末尾位置 while (begin < end) { int maxi = begin;//保存最大元素下标 int mini = begin;//保存最小元素下标 //遍历数组寻找最小和最大元素 for (int i = begin; i <= end; i++) { if (a[i] < a[mini]) { mini = i; } if (a[i] > a[maxi]) { maxi = i; } } //将最小元素交换到起始位置 Swap(a+begin, a+mini); //判断最大值的位置是否在起始位置 if (maxi == begin) { maxi = mini; } //将最大元素交换到末尾位置 Swap(a+end, a+maxi); //移动数组起始和末尾位置 begin++; end--; }}
注意:
在进行最小值和最大值同时交换时也会出现一个问题,
如果最大值在起始位置的时候,交换了最小值之后,最大值就被交换到了min的位置,
如果继续交换max,就会将最小值交换到末尾位置。
所以,在每次交换了最小值之后应该判断一下最大值是否在起始位置,如果在需要将max赋值为min。
直接选择排序的特性总结: 直接选择排序思考非常好理解,但是效率不是很好(不论数组是否有序都会执行原步骤)。实际中很少使用 时间复杂度:O(N^2) 空间复杂度:O(1) 稳定性:不稳定
4.堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1. 建堆
- 升序:建大堆
- 降序:建小堆
2. 利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。这里以升序为例:
- 首先应该建一个大堆,不能直接使用堆来实现。可以将需要排序的数组看作是一个堆,但需要将数组结构变成堆。
- 我们可以从堆从下往上的第二行最右边开始依次向下调整直到调整到堆顶,这样就可以将数组调整成一个堆,且如果建立的是大堆,堆顶元素为最大值。
- 然后按照堆删的思想将堆顶和堆底的数据交换,但不同的是这里不删除最后一个元素。
- 这样最大元素就在最后一个位置,然后从堆顶向下调整到倒数第二个元素,这样次大的元素就在堆顶,重复上述步骤直到只剩堆顶时停止。
动图演示:
注意:实际中并没有删除堆中元素,图中为了方便表示,将交换后的位置画成了空。
代码如下:
// 堆排序void AdjustDown(int* a, int n, int root)//向下调整{ assert(a); int parent = root; int child = parent * 2 + 1; while (child < n) { if (child + 1 < n && a[child + 1] > a[child]) { child++; } if (a[child] > a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else { break; } }}void HeapSort(int* a, int n){ assert(a); //建堆 for (int i = (n - 1 - 1) / 2; i >= 0; i--) { AdjustDown(a, n, i); } //交换 for (int i = n - 1; i > 0; i--) { Swap(&a[i], &a[0]); AdjustDown(a, i, 0); }}
直接选择排序的特性总结: 堆排序使用堆来选数,效率就高了很多。 时间复杂度:O(N*logN) 空间复杂度:O(1) 稳定性:不稳定
5.冒泡排序
冒泡排序应该是我们最熟悉的排序了,在C语言阶段我们就学习了冒泡排序。
他的思想也非常简单:
两两元素相比,前一个比后一个大就交换,直到将最大的元素交换到末尾位置。这是第一趟
一共进行n-1趟这样的交换将可以把所有的元素排好。
(n-1趟是因为只剩两个元素时只需要一趟就可以完成)
动图演示:
代码如下:
// 冒泡排序void BubbleSort(int* a, int n){ assert(a); int i = 0; int flag = 0; //n-1趟排序 for (i = 0; i < n-1; i++) { int j = 0; //一趟冒泡排序 for (j = 0; j < n - i - 1; j++) { if (a[j] > a[j+1]) { Swap(&a[j], &a[j+1]); flag = 1; } } //若某一趟排序中没有元素交换则说明所有元素已经有序,不需要再排序 if (flag == 0) { break; } }}
冒泡排序的特性总结: 冒泡排序是一种非常容易理解的排序 时间复杂度:O(N^2) 空间复杂度:O(1) 稳定性:稳定
6.快速排序
这里是排序算法的重点了,非常重要!
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,
其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
6.1 hoare版本
具体思路是: 选定一个基准值,最好选定最左边或者最右边,选中间会给自己找麻烦。 确定两个指针left 和right 分别从左边和右边向中间遍历数组。 如果选最右边为基准值,那么left指针先走,如果遇到大于基准值的数就停下来。 然后右边的指针再走,遇到小于基准值的数就停下来。 交换left和right指针对应位置的值。 重复以上步骤,直到left = right ,最后将基准值与left(right)位置的值交换。
这样基准值左边的所有数都比他小,而他右边的数都比他大,从而他所在的位置就是排序后的正确位置。
之后再递归排以基准值为界限的左右两个区间中的数,当区间中没有元素时,排序完成。
动图演示:
这里选定基准值为最右边的元素。
单趟图解:
继续按照上述步骤进行。。。
代码如下:
// 快速排序hoare版本int PartSort1(int* a, int left, int right){ int key = right;//选定基准值 while (left < right) { //选右边为基准值,左指针先走 while (left < right && a[left] <= a[key]) { left++; } //右指针再走 while (left < right && a[right] >= a[key]) { right--; } Swap(&a[left], &a[right]); } Swap(&a[left], &a[key]); return left;}void QuickSort(int* a, int left, int right){ assert(a); if (left >= right) { return; } int keyi = PartSort1(a, left, right); QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); }
6.2挖坑法
挖坑法与上面的方法类似。
具体思路是: 先将选定的基准值(最左边)直接取出,然后留下一个坑, 当右指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位, 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位, 重复该步骤,直到左右指针相等。最后将基准值放入坑位之中。
之后也是以基准值为界限,递归排序基准值左右区间。
动图演示:
这里不再单趟演示该过程。。。
代码如下:
// 快速排序挖坑法int PartSort2(int* a, int left, int right){ int key = a[left];//取出基准值 int hole = left;//保存坑的位置 while (left < right) { while (left < right && a[right] >= key) { right--; } a[hole] = a[right]; hole = right; while (left < right && a[left] <= key) { left++; } a[hole] = a[left]; hole = left; } a[hole] = key; return hole;}void QuickSort(int* a, int left, int right){ assert(a); if (left >= right) { return; } int keyi = PartSort2(a, left, right); QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); }
6.3前后指针法
前后指针法是一个新思路,不太好理解,但是代码比较简单。
具体思路是: 选定基准值,定义prev和cur指针(cur = prev + 1) cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置 将prev对应值与cur对应值交换 重复上面的步骤,直到cur走出数组范围 最后将基准值与prev对应位置交换 递归排序以基准值为界限的左右区间
动图演示:
单趟演示:
代码如下:
// 快速排序前后指针法int PartSort3(int* a, int left, int right){ //1.将基准值定在left int keyi = left; int prev = left; int cur = left + 1; while (cur <= right) { if (a[cur] < a[keyi] && ++prev != cur) { Swap(&a[prev], &a[cur]); } cur++; } Swap(&a[prev], &a[keyi]); //2.将基准值定在right /*int keyi = right; int prev = left - 1; int cur = prev + 1; while (cur <= right) { if (a[cur] < a[keyi] && ++prev != cur) { Swap(&a[cur], &a[prev]); } cur++; } Swap(&a[keyi], &a[++prev]); return prev;*/}void QuickSort(int* a, int left, int right){ assert(a); if (left >= right) { return; } int keyi = PartSort2(a, left, right); QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right);}
6.4快速排序优化
上面就是快速排序递归的三种方法。
但是上面的程序还有一些缺陷:
在基准值的选择上,如果选择的基准值为恰好为最小值,会进行不必要的递归。
在排序大量有序数据或者接近有序数据时,效率会比较低,甚至可能会出现程序崩溃的情况。
这是因为在排序有序数据时,快速排序的递归调用次数过多,会导致栈溢出的情况。
为了解决这些问题,这里有两种优化方法:
- 三数取中法选基准值
- 递归到小的子区间时,可以考虑使用插入排序
1.即在在起始位置,中间位置,末尾位置中选出中间值,作为基准值。
//三数取中int MidIndex(int* a, int left, int right){ int mid = (left + right) / 2; //防止mid越界 //int mid = left+(right - left) / 2; if (a[left] < a[right]) { if (a[mid] < a[left]) { return left; } else if (a[mid] > a[right]) { return right; } else { return mid; } } else { if (a[mid] > a[left]) { return left; } else if (a[mid] < a[right]) { return right; } else { return mid; } }}
2.类似于二叉树,每个子树都会进行一次递归调用,越到下面递归调用会越多。为了减少递归调用,当到递归到下层时,我们可以使用其他的排序来替代。这里我们使用插入排序。
以前后指针法为例,完整代码为:
// 快速排序前后指针法int PartSort3(int* a, int left, int right){ int mid = MidIndex(a, left, right); //将基准位置调整至最左边 Swap(&a[mid], &a[left]); //1.将基准值定在left int keyi = left; int prev = left; int cur = left + 1; while (cur <= right) { if (a[cur] < a[keyi] && ++prev != cur) { Swap(&a[prev], &a[cur]); } cur++; } Swap(&a[prev], &a[keyi]); //2.将基准值定在right /*int keyi = right; int prev = left - 1; int cur = prev + 1; while (cur <= right) { if (a[cur] < a[keyi] && ++prev != cur) { Swap(&a[cur], &a[prev]); } cur++; } Swap(&a[keyi], &a[++prev]); return prev;*/}void QuickSort(int* a, int left, int right){ assert(a); if (left >= right) { return; } //小区间优化,,减少递归次数 if (right - left + 1 < 10) { InsertSort(a + left, right -left + 1); } else { int keyi = PartSort3(a, left, right); QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); }}
6.5快速排序非递归实现
快速排序非递归实现,需要借助栈,栈中存放的是需要排序的左右区间。
而且非递归可以彻底解决栈溢出的问题。
其实他的思想与递归是类似的:
具体思想: 将数组左右下标入栈, 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限 将区间中的元素按照前后指针法排序(其余两种也可)得到基准值的位置 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈 重复上述步骤直到栈为空
代码如下:
// 快速排序 非递归实现void QuickSortNonR(int* a, int left, int right){ //创建栈 Stack st; StackInit(&st); //原始数组区间入栈 StackPush(&st, right); StackPush(&st, left); //将栈中区间排序 while (!StackEmpty(&st)) { //注意:如果right先入栈,栈顶为left left = StackTop(&st); StackPop(&st); right = StackTop(&st); StackPop(&st); //得到基准值 int mid = PartSort3(a, left, right); // 以基准值为分割点,形成左右两部分 if (right > mid+1) { StackPush(&st, right); StackPush(&st, mid + 1); } if (left < mid - 1) { StackPush(&st, mid - 1); StackPush(&st, left); } } StackDestroy(&st);}
快速排序的特性总结: 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序 时间复杂度:O(N*logN) 空间复杂度:O(logN) 稳定性:不稳定
7.归并排序
7.1递归实现
基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。
将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
若将两个有序表合并成一个有序表,称为二路归并。
归并排序核心步骤:
按照其算法思想:
首先应在数组中找出有序的序列,但数组是否有序编译器可不知道。
所以可以将数组划分,一分二,二分四......直到每个序列中只有一个数字。
一个数字总可以认为他是有序的吧。
最后将同时划分的序列合并。
动图演示:
代码如下:
void _MergeSort(int* a, int left, int right,int* tmp){ //区间中没有元素时不再合并 if (left >= right) { return; } //划分数组,每次一分为二 int mid = (left + right) / 2; _MergeSort(a, left, mid,tmp);//划分左区间 _MergeSort(a, mid + 1, right,tmp);//划分右区间 //合并有序序列 int begin1 = left, end1 = mid;//有序序列1 int begin2 = mid + 1, end2 = right;//有序序列2 int i = left; while (begin1 <= end1 && begin2 <= end2)//注意结束条件为一个序列为空时就停止 { if (a[begin1] < a[begin2]) { tmp[i++] = a[begin1++]; } else { tmp[i++] = a[begin2++]; } } //两序列不可能同时为空,将剩余元素合并 while (begin1 <= end1) { tmp[i++] = a[begin1++]; } while (begin2 <= end2) { tmp[i++] = a[begin2++]; } //将合并后的序列拷贝到原数组中 //在这里拷贝的原因是 保证返回到上一层递归后两个子序列中的元素是有序的 int j = 0; for (j = left; j <= right; j++) { a[j] = tmp[j]; }}// 归并排序递归实现void MergeSort(int* a, int n){ assert(a); //因为需要将两个有序序列合并,需借助额外数组 int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc"); exit(-1); } _MergeSort(a, 0, n - 1,tmp); free(tmp); tmp = NULL;}
7.2非递归实现
非递归实现的思想与递归实现的思想是类似的。
不同的是,这里的序列划分过程和递归是相反的,不是一次一分为二,而是先1个元素一组,再2个元素一组,4个元素一组....直到将所有的元素归并完。
静态图示:
代码如下:
// 归并排序非递归实现void MergeSortNonR(int* a, int n){ assert(a); int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc"); exit(-1); } //初始化每组的元素个数为1 int gap = 1; while (gap < n)//gap为n时就只有一个序列了,所以gap<n { //归并每两组归并一次 int i = 0; for (i = 0; i < n; i+=2*gap)//两组中的元素个数为2*gap { //控制两组边界 int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; //当原数组中元素个数不是2^n时,最后两组组会出现元素不匹配的情况 //情况1:end1>=n或begin2>=n,即最后两组中只有一组有元素,则不需归并 if (end1 >= n || begin2 >= n) { break; } //情况2:end2>=n,即最后两组中,第二组元素个数小于第一组,则需要调整第二组边界 if (end2 >= n) { end2 = n - 1; } //归并 int j = i; while (begin1 <= end1 && begin2 <= end2) { if (a[begin1] < a[begin2]) { tmp[j++] = a[begin1++]; } else { tmp[j++] = a[begin2++]; } } while (begin1 <= end1) { tmp[j++] = a[begin1++]; } while (begin2 <= end2) { tmp[j++] = a[begin2++]; } //将归并后的有序序列拷贝到原数组中 for (j = i; j <= end2; j++) { a[j] = tmp[j]; } } //每次循环每组元素个数增大2倍 gap *= 2; } free(tmp); tmp = NULL;}
归并排序的特性总结: 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。 时间复杂度:O(N*logN) 空间复杂度:O(N) 稳定性:稳定
8.计数排序(了解)
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤
- 统计相同元素出现次数
- 根据统计的结果将序列回收到原来的序列中
动图演示:
代码如下:
// 计数排序void CountSort(int* a, int n){ assert(a); // 创建计数数组,数组大小为原数组中最大值-最小值+1 int max = a[0], min = a[0]; int i = 0; for (i = 0; i < n; i++) { if (a[i] > max) { max = a[i]; } if (a[i] < min) { min = a[i]; } } int range = max - min + 1; int* count = (int*)malloc(sizeof(int) * range); // 初始化计数数组为0 memset(count, 0, range * 4); // 统计次数 for (i = 0; i < n; i++) { count[a[i] - min]++; } // 根据次数,进行排序 int j = 0; for (i = 0; i < range; i++) { while (count[i]--) { a[j++] = i+min; } }}
注意:计数排序在排负数时,可将负数的类型转化成 unsigned int。
数组中元素有正有负的情况时不适用计数排序。
计数排序的特性总结: 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。 时间复杂度:O(MAX(N,范围)) 空间复杂度:O(范围) 稳定性:稳定
排序算法复杂度及稳定性分析
注:在特殊情况下,排序的时间复杂度会发生变化,不能作为判断该排序是否稳定的依据。
稳定性:指数组中相同元素在排序后相对位置不发生变化。
例如:在冒泡排序中,相同元素比较时不会交换顺序,从而排序后相同元素的相对位置不会发生变化,所以冒泡排序是稳定的。
最后我们对各种排序算法进行一个简单的测试:随机测试10000个数据
void TestOP(){ srand(time(0)); const int N = 100000; int* a1 = (int*)malloc(sizeof(int) * N); int* a2 = (int*)malloc(sizeof(int) * N); int* a3 = (int*)malloc(sizeof(int) * N); int* a4 = (int*)malloc(sizeof(int) * N); int* a5 = (int*)malloc(sizeof(int) * N); int* a6 = (int*)malloc(sizeof(int) * N); for (int i = 0; i < N; ++i) { a1[i] = rand(); a2[i] = a1[i]; a3[i] = a1[i]; a4[i] = a1[i]; a5[i] = a1[i]; a6[i] = a1[i]; } int begin1 = clock(); InsertSort(a1, N); int end1 = clock(); int begin2 = clock(); ShellSort(a2, N); int end2 = clock(); int begin3 = clock(); SelectSort(a3, N); int end3 = clock(); int begin4 = clock(); HeapSort(a4, N); int end4 = clock(); int begin5 = clock(); QuickSort(a5, 0, N - 1); int end5 = clock(); int begin6 = clock(); MergeSort(a6, N); int end6 = clock(); printf("InsertSort:%d\n", end1 - begin1); printf("ShellSort:%d\n", end2 - begin2); printf("SelectSort:%d\n", end3 - begin3); printf("HeapSort:%d\n", end4 - begin4); printf("QuickSort:%d\n", end5 - begin5); printf("MergeSort:%d\n", end6 - begin6); free(a1); free(a2); free(a3); free(a4); free(a5); free(a6);}
如有错误,感谢指正~
来都来了,点个赞再走吧~~
原文: https://blog.csdn.net/Edward_Asia/article/details/121419975