目录
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减排列。稳定性:假定在待排序的序列中,存在多个具有相同的值,若经过排序,这些值的相对次序保持不变,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。内部排序:数据元素全部放在内存中的排序。外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
基本思想:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
当插入第i(i>=1)个元素时,前面的arr[0],arr[1],…,arr[i-1]已经排好序,此时用arr[i]的排序码与arr[i-1],arr[i-2],…的排序码顺序进行比较,找到插入位置即将arr[i]插入,原来位置上的元素顺序后移。
用简单的图文分析一下
void InsertSort(int* a, int n) { assert(a); //[0, end]有序,要把end+1位置的值插入,保持有序 int end; int tmp = a[end + 1]; while (end >= 0) { if (tmp < a[end]) { a[end + 1] = a[end]; end--; } else { //最好不要在这里赋值,如果end--到-1,循环就不会进入,就会缺少一个值 break; } } a[end + 1] = tmp; }要注意的是,这只是单趟的插入排序,如果一开始给的就是一个无序的数组
void InsertSort(int* a, int n) { assert(a); int i = 0; for (i = 0; i < n - 1; i++) { //[0, end]有序,要把end+1位置的值插入,保持有序 int end = i; int tmp = a[end + 1]; while (end >= 0) { if (tmp < a[end]) { a[end + 1] = a[end]; end--; } else { //最好不要在这里赋值,如果end--到-1,循环就不会进入,就会缺少一个值 break; } } a[end + 1] = tmp; } }
写完了代码,接下来就可以聊一聊它的特性:
例如时间复杂度,一个经典的O(n^2)
这种排序最坏就是逆序,每一趟都需要改动,经典的等差数列求和
最优的就是有序或者接近有序,不需要排序或者只排序几次效率很高,元素集合越接近有序,直接插入排序算法的时间效率越高。
希尔排序也可以说是直接插入排序的优化。希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数当做距离差,把待排序文件中所有数据分成几个组,所有距离差相同的分在同一组内,并对每一组内的数据进行排序。然后取重复上述分组和排序的工作。每次距离差都要-1,当距离差到达1时,相当于直接插入排序。这种先分组再分别排序就叫做预排序,预排序的作用就是使这个序列接近有序,因为越接近有序,直接插入排序的效率越高。最后一次间隔是1,就相当于直接插入排序。
- void ShellSort(int* a, int n)
- {
- //还是和刚才一样,先来处理单趟的
- int gap;//开始我们并不需要知道gap和end是多少
- int end;//后面的代码和前面的差不多,只是不是挪一位而是挪gap位
- int tmp = a[end + gap];
- while (end >= 0)
- {
- if (tmp < a[end])
- {
- a[end + gap] = a[end];
- end -= gap;
- }
- else
- {
- break;
- }
- }
- a[end + gap] = tmp;
- }
void ShellSort(int* a, int n) { int gap = 3;//先假设gap是3 int j = 0; for (j = 0; j < gap; j++) { int i = 0; for (i = j; i < n - gap; i += gap) { //每次跳过gap步 int end = i; int tmp = a[end + gap]; while (end >= 0) { if (tmp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; } } }上面的代码只是一趟的代码,但也并不是最优的写法,来看看下面这种巧妙的方法
void ShellSort(int* a, int n) { int gap = 3; int i = 0; for (i = 0; i < n - gap; i++) { int end = i; int tmp = a[end + gap]; while (end >= 0) { if (tmp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; } }
这样还剩一个问题没有解决,gap到底该取几?
根据前面的描述,gap=1就是直接插入排序,gap越小,越接近有序;gap越大,较大的值和较小的值都可以更快的去往两边,但是越不接近有序。
void ShellSort(int* a, int n) { int gap = n;//较官方一点的写法就是这样 while (gap > 1) { gap = gap / 3 + 1;//gap每次都除3,加1就可以保证最后一次是直接插入排序 //如果不加这个1最后这个序列还没排好的时候gap已经是0了 //gap>1就是预排序 //gap=1就是直接插入排序 int i = 0; for (i = 0; i < n - gap; i++) { int end = i; int tmp = a[end + gap]; while (end >= 0) { if (tmp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; } } }这就是希尔排序,它的效率非常高,想要算时间复杂度也不是那么容易,gap根据n来定,每次的排序也不确定,所以有些大佬就算出它的时间复杂度大致是O(n^1.3)。
选择排序的基本思想:
每一次从待排序的数据元素中选出最小 (或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
在元素集合arr[i]——arr[n-1]中选择关键码最大(小)的数据元素。若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)。元素交换在剩余的arr[i]——arr[n-2](arr[i+1]——arr[n-1])集合中,重复上述步骤,直到集合剩余1个元素。
不看这些冗长的定义和讲解,简单来说就是找出最大的和最小的数值的下标,分别往两边放,每次寻找到范围缩小,直到数组有序。
void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } void SelectSort(int* a, int n) { int begin = 0; int end = n - 1; while (begin < end) { int mini = begin, maxi = begin; for (int i = begin + 1; i <= end; i++) { if (a[i] < a[mini])//比最小值小就记录这个下标 mini = i; if (a[i] > a[maxi])//比最大值大 maxi = i; } Swap(&a[mini], &a[begin]); //如果begin和maxi重叠,就修正maxi的位置 if (begin == maxi) { maxi = mini; } Swap(&a[maxi], &a[end]); begin++; end--; } }交换中间的if语句一定要加上,因为begin和maix重叠的时候,第一个交换改了maxi指向最大值的下标。
再下面就要计算一下它的时间复杂度:
如果一开始没有直接找两个最值,直接找一个最值就是经典的等差数列,时间复杂度就是O(N^2)。
虽然在这里提到了选择排序,但它还是一个令人嫌弃的排序,不说和希尔这种非常好的排序比就是和插入排序比,它的效率还是低,例如一个接近有序的数组,选择排序的时间复杂度还是O(N^2),但是插入排序的时间复杂度可能会降低,所以这个排序还是挺让人嫌弃的。
堆排序在之前的篇章中也讲过,就不过多说明了
void AjustUp(int* a, int child) { int parent = (child - 1) / 2; while (child > 0) { if (a[child] < a[parent]) { swap(a[child], a[parent]); child = parent; parent = (child - 1) / 2; } else { break; } } } void AjustDown(int* a, int size, int parent) { int child = parent * 2 + 1; while (child < size) { if (child + 1 < size && a[child] < a[child + 1]) child++; if (a[parent] < a[child]) { swap(a[child], a[parent]); parent = child; child = parent * 2 + 1; } else { break; } } } //降序 -- 建小堆 //升序 -- 建大堆 void HeapSort(int* a, int n) { //第一种建堆方式 时间复杂度O(N*logN) //for (int i = 1; i < n; i++) //{ // AjustUp(a, i); //} //第二种建堆方式 时间复杂度O(N) for (int i = (n - 1 - 1) / 2; i >= 0; i--) { AjustDown(a, n, i); } int end = n - 1; while (end > 0) { swap(a[0], a[end]); AjustDown(a, end, 0); end--; } }堆排序的效率很高,它的时间复杂度为O(N*logN),空间复杂度为O(1)。
冒泡排序是最早接触到的交换排序,也不过多说明了
void BubbleSort(int* a, int n) { assert(a); for (int i = 0; i < n - 1; i++) { int flag = 0; for (int j = 0; j < n - i - 1; j++) { if (a[j + 1] < a[j]) { swap(a[j], a[j + 1]); flag = 1; } } if (flag == 0) return; } }时间复杂度为O(N^2),最好的情况就是有序,时间复杂度为O(N);相比之下,插入排序最好的情况也是O(N),而选择排序无论好坏,时间复杂度都是O(N^2)。
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
下面的只是一趟的代码
void QuickSort(int* a, int n) { int left = 0; int keyi = left; int right = n - 1; while (left < right) { //右边先走,找小 while (left < right && a[right] >= a[keyi]) { right--; } //左边再走,找大 while (left < right && a[left] <= a[keyi]) { left++; } swap(a[left], a[right]); } swap(a[keyi], a[left]); keyi = left }还有一个问题是,为什么要让右边的先走
快速排序是使用递归实现的,所以要把参数改一下
void QuickSort(int* a, int begin, int end) { int left = begin; int keyi = left; int right = end; while (left < right) { //右边先走,找小 while (left < right && a[right] >= a[keyi]) { right--; } //左边再走,找大 while (left < right && a[left] <= a[keyi]) { left++; } swap(a[left], a[right]); } swap(a[keyi], a[left]); keyi = left; }
void QuickSort(int* a, int begin, int end) { if (begin >= end)//区间不存在就是大于,只有一个就是等于 return; int left = begin; int keyi = left; int right = end; while (left < right) { //右边先走,找小 while (left < right && a[right] >= a[keyi]) { right--; } //左边再走,找大 while (left < right && a[left] <= a[keyi]) { left++; } swap(a[left], a[right]); } swap(a[keyi], a[left]); keyi = left; QuickSort(a, 0, keyi - 1); QuickSort(a, keyi + 1, end); }再来说一下快速排序的时间复杂度,分治也和二分查找似的,从上到下共有logN次,每一行差不多有N个数,合在一起时间复杂度就是O(N*logN)。
这个方法和上面的版本写法差不多
void QuickSort(int* a, int begin, int end) { if (begin >= end) return; int left = begin; int key = a[left]; int right = end; int piti = left; while (left < right) { //右边找小,填到左边的坑中,变成新的坑 while (left < right && a[right] >= key) --right; a[piti] = a[right]; piti = right; //左边找大,填到右边的坑中,变成新的坑 while (left < right && a[left] <= key) ++left; a[piti] = a[left]; piti = left; } a[piti] = key; int keyi = piti; QuickSort(a, 0, keyi - 1); QuickSort(a, keyi + 1, end); }
void QuickSort(int* a, int begin, int end) { if (begin >= end) return; int keyi = begin; int prev = begin; int cur = prev + 1; while (cur <= end) { //a[cur] < a[keyi] if (a[cur] < a[keyi] && ++prev != cur) { swap(a[prev], a[cur]); } ++cur; } swap(a[keyi], a[prev]); keyi = prev; QuickSort(a, 0, keyi - 1); QuickSort(a, keyi + 1, end); }
写到这里还是会有一些问题,这样写时间复杂度最坏就是有序的情况,第一趟N个数,第二趟N-1个数,直到最后一个数,这就是等差数列,时间复杂度就是O(N^2),如果数据过大,递归层数就会增加,就有栈溢出的问题,怎么能让快排出现这样的问题呢?
有人就提出了一些解决方法:
1、随机选key,随机选就可以有效的避免上面的问题,但是这个方法还是不太稳定。
2、三数取中,第一个数、中间的数和最后一个数之间选一个中间值。
int GetMidIndex(int* a, int begin, int end) { int mid = (begin + end) / 2; if (a[begin] < a[mid]) { if (a[mid] < a[end]) { return mid; } else if (a[begin] < a[end]) { return end; } else { return begin; } } else//a[begin] >= a[mid] { if (a[mid] > a[end]) { return mid; } else if (a[begin] > a[end]) { return end; } else { return begin; } } } void QuickSort(int* a, int begin, int end) { if (begin >= end) return; int keyi = begin; int prev = begin; int cur = prev + 1; int mid = GetMidIndex(a, begin, end); swap(a[keyi], a[mid]); while (cur <= end) { //a[cur] < a[keyi] if (a[cur] < a[keyi] && ++prev != cur) { swap(a[prev], a[cur]); } ++cur; } swap(a[keyi], a[prev]); keyi = prev; QuickSort(a, 0, keyi - 1); QuickSort(a, keyi + 1, end); }还有一种方式,尽管排序的序列个数很少,那也要进行很多次递归,为了提高效率,就是当递归划分的区间较小的时候,就不再递归递归了,可以直接使用其他排序方式对小区间处理。比如直插入排序,在递归序列长度小于10的时候就使用直接插入排序
void QuickSort(int* a, int begin, int end) { if (begin >= end) return; if (end - begin > 10) { int keyi = begin; int prev = begin; int cur = prev + 1; int mid = GetMidIndex(a, begin, end); swap(a[keyi], a[mid]); while (cur <= end) { //a[cur] < a[keyi] if (a[cur] < a[keyi] && ++prev != cur) { swap(a[prev], a[cur]); } ++cur; } swap(a[keyi], a[prev]); keyi = prev; QuickSort(a, 0, keyi - 1); QuickSort(a, keyi + 1, end); } else { InsertSort(a, end - begin + 1); } }
从上面的快速排序的讲解,可以得出快排的效率很高,又有这么多的优化,但是它还是有一个致命的缺点,就是递归层数太深的时候会栈溢出。
为了解决栈溢出的问题:可以由递归改为循环;还有一种就是用数据结构的栈来模拟递归过程。
//前后指针法 int PartSort(int* a, int begin, int end) { int keyi = begin; int prev = begin; int cur = prev + 1; int mid = GetMidIndex(a, begin, end); swap(a[keyi], a[mid]); while (cur <= end) { //a[cur] < a[keyi] if (a[cur] < a[keyi] && ++prev != cur) { swap(a[prev], a[cur]); } ++cur; } swap(a[keyi], a[prev]); keyi = prev; return keyi; } //递归改非递归 void QuickSoreNonR(int* a, int begin, int end) { stack<int> st; st.push(end); st.push(begin); while (!st.empty()) { int left = st.top(); st.pop(); int right = st.top(); st.pop(); int keyi = PartSort(a, left, right);//PartSort就是前后指针法,返回keyi的值 //其他方法也可以改变一下这样去写 if (keyi + 1 < right) { st.push(right); st.push(keyi + 1); } if (left < keyi - 1) { st.push(keyi - 1); st.push(left); } } }当然,除了用栈这种数据结构来实现,还可以使用其他数据结构来实现。
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。在之前的链表oj题中也提到过归并排序。先申请一块空间来存放排好的数据,等排好了再把数据拷贝过去。第一步找到序列的中间值,分成多个小区间,只要不是一个数就继续递归,等左右两个序列都是一个数时,就可以进行排序了,排好了就把排列好的拷贝到原来的数组中。先分解,左边归并,右边归并,一起归并,再返回上一层。
void _MergeSort(int* a, int begin, int end, int* tmp) { if (begin >= end)//剩下一个数就直接返回 return; int mid = (begin + end) / 2;//找到中间值 //[begin, mid] [mid+1, end]分治递归,每个子区间有序 _MergeSort(a, begin, mid, tmp);//递归左边 _MergeSort(a, mid + 1, end, tmp);//递归右边 //归并[begin, mid] [mid+1, end] int begin1 = begin, end1 = mid; int begin2 = mid + 1, end2 = end; int i = begin1; 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++]; //把归并数据拷贝回原数组 memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int)); } void MergeSort(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); assert(tmp); _MergeSort(a, 0, n - 1, tmp); free(tmp); }归并排序可以看成是后序遍历,先左再右然后再归并,而快速排序是前序遍历,先挪动key,再左边、右边。
归并排序的时间复杂度也是经典的O(N*logN),它的空间复杂度就是O(N),多开辟了一块新的数组。
这个非递归实现就不能使用栈来实现了,栈适合前序遍历,但不适合后序遍历,而且栈也不能实现我们想要的结果。
看了归并的底层,最后一层都是要把数据变成一个一个的,然后开始归并,所以我们就可以手动把数据进行分组,然后归并,返回上一层。
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); assert(tmp); int gap = 1; while (gap < n) { for (int i = 0; i < n; i += 2 * gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + gap * 2 - 1; int j = begin1; 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++]; } memcpy(a, tmp, sizeof(int) * n); gap *= 2; } free(tmp); }这只是我们理想状态下的,这种写法一定会存在越界的问题,就像图中的例子,下标为8和9的位置,当gap为1的时候,没有越界;gap为2的时候,8和9表示begin1和end1的区间,没有begin2和end2的区间;当gap为4的时候,end1就会越界;当gap为8的时候,end2就会越界。
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); assert(tmp); int gap = 1; while (gap < n) { for (int i = 0; i < n; i += 2 * gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + gap * 2 - 1; //修正边界 if (end1 >= n) { end1 = n - 1; //begin1没有越界,end1越界,begin2和end2要修改为不存在的区间 //后面就会直接追加begin1到end1的区间 end2 = n - 1; begin2 = n; } else if (begin2 >= n) { //begin1和end1没有越界,begin2越界就把后区间修改为不存在的区间 begin2 = n; end2 = n - 1; } else if (end2 >= n) { //begin2没有越界,end2越界,修改end2的位置 end2 = n - 1; } int j = begin1; 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++]; } memcpy(a, tmp, sizeof(int) * n); gap *= 2; } free(tmp); }除了更改边界,还有一种修改方法,下面是部分代码
for (int i = 0; i < n; i += 2 * gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + gap * 2 - 1; //如果end1或者begin2越界了就不要归并了 if (end1 >= n || begin2 >= n) { break; } //如果end2越界了,就修正end2 else if (end2 >= n) { end2 = n - 1; } //上一种是不管归不归并,把所有数据都拿下来,再拷贝到a中 //既然不把越界的归并,拷贝的数据个数就要控制一下 int m = end2 - begin1 + 1; . . . memcpy(a + i, tmp + i, sizeof(int) * m); }
计数排序是一种非比较排序,它的写法是先找到整个数组中的最大值和最小值,再定义一个数组,数组长度由最大值和最小值的范围决定,之后统计每个元素出现的个数,只要个数不是0就一一放到原来的数组中。
void CountSort(int* a, int n) { int min = a[0], max = a[0]; for (int i = 1; i < n; i++) { if (a[i] < min) min = a[i]; if (a[i] > max) max = a[i]; } int range = max - min + 1; int* count = (int*)malloc(sizeof(int) * range); assert(count); memset(count, 0, sizeof(int) * range); //统计次数 for (int i = 0; i < n; i++) { count[a[i] - min]++; } //排序 int j = 0; for (int i = 0; i < range; i++) { while (count[i]--) a[j++] = i + min; } free(count); }相比于快排和归并来讲,计数排序简直太好理解了,也就不做过多讲解。
虽然它相对简单,但是它也是有它的局限性:
1、对于浮点数和字符串就没有办法排序
2、如果数据范围很大,空间复杂度就会很高,就不是很合适
所以计数排序适合范围集中,且数据重复的多的序列。
它的时间复杂度就为O(max(range, n)),空间复杂度就为O(range)。
前面关于各种排序的复杂度都有提到过,这里就总结一下
在本文的开篇也提到了内部排序和外部排序,内部就是内存,外部就是硬盘,这篇文章中提到的排序都可以用于内部排序,但是只有归并排序既可以在内部排,在数据多的时候也可以使用外部排。
关于几种排序的内容就讲解到这里,但这还没有结束,仅仅只是开始而已。