1 排序的概念及应用 1.1 排序的概念排序算法是非常常用的算法,从介绍排序的基本概念,到介绍各种排序算法的思想、实现方式以及效率分析。最后比较各种算法的优劣性和稳定性。
排序:所谓排序,就是一串记录,按照某个关键字的大小,按照递增或者递减的顺序进行排列的操作。
稳定性:排序的稳定性,在排序前,有许多相同关键字的记录,他们是按照一定的次序排列的。在排序后,还能按照原先的次序进行排序,那么我们称这种排序算法是稳定的,否则是不稳定的。
内部排序:数据全部在内存中排序。
外部排序:数据元素过多,无法在内存中排序,需要通过内外存之间移动数据来进行排序。
1.2 排序的应用排序在现实场景中的应用是非常多的,比如财富排行榜、游戏中的排名等等。
1.3 常见的排序算法 2 常见的排序算法的实现 2.1 直接插入排序 2.1.1 基本思想直接插入排序是一种简单的插入排序,思想是把待排序的记录按照其关键值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完成,得到一个新的有序序列。
就比如说,我们现实生活中的玩扑克牌的方式就用了这种思想,我们每摸到一张牌的时候,原先手上的牌是排好序的,我们拿到这张新的牌,会插入到原先的有序序列,然后插入之后,我们新的序列又是有序的。之后,每次摸牌都按照这种方式,最终会得到一个完全有序的序列。
2.1.2 代码实现// 直接插入排序 void InsertSort(int* a, int n) { for (int 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 { break; } } a[end + 1] = tmp; } }
首先,我们定义一个end,end指向已经排好序的序列的尾部,然后依次将end+1的位置的值插入到已经排好序的序列中。
2.1.3 算法分析首先,我们先分析算法的时间复杂度,要考虑算法的最好、最坏以及平均复杂度。最好的情况是当这个数组已经有序或者接近有序的情况下,我们只需要进行比较,不需要移动元素,最好时间复杂度为O(N)。而最坏的情况是,这个数组是完全逆序的时候,我们每次比较后,都要移动大量的元素,最坏时间复杂度为O(N2)**,平均复杂度为**O(N2)。
那么,这个算法的空间复杂度为O(1),因为只需要使用常量级别的空间。
最后,这个算法是否稳定,答案是稳定的。因为每次是从后往前依次比较,不会改变原先的次序,移动的过程中也是一个一个进行移动的。
2.2 希尔排序 2.2.1 基本思想希尔排序也是插入排序中的一种,因为其本质就是使用了插入排序,我们从插入排序的结论中可以得出,越接近有序的数组使用插入排序的效率越高。希尔排序的思想,就是使一个数组先部分有序,最后在全局有序。那么如何实现部分有序呢,我们可以对数组的元素按照某种规律进行分组,分组后,对组内的记录进行排序,如何重复进行分组和排序,当最终每组的成员只剩一个时,在进行排序的时候,就是使用了插入排序。
2.2.2 代码实现// 希尔排序 void ShellSort(int* a, int n) { int gap = n; while (gap > 1) { gap = gap / 3 + 1; for (int 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是间隔,也就是将i,i+gap,i+2*gap...依次进行排序,之后,gap/2或者gap/3+1,按照某种规律,最终gap=1的时候,在进行排序,就是进行了一次直接插入排序。
2.2.3 算法分析希尔排序的时间复杂度是一个复杂的问题,在Kunth所著的《计算机程序设计技巧》第3卷中,利用大量的实验统计资料得出,平均复杂度为O(N^1.25)到O(1.6 * N^1.25)。这里的就暂且不讨论该结果具体得出的方式。
希尔排序是否是稳定的算法呢?答案是不稳定的,因为我们在预排序的过程中,我们会进行大量的跳动式的移动元素的值,因此会导致不能按照原先的序列进行排序。
希尔排序中的gap是如何取值的呢?当成Shell,也就是该算法的原作者,提出取gap= gap/2,然后向下取整,直到gap=1时。后来Kunth提出取gap = gap/3 + 1 ,gap/3向下取整的方式,直到gap=1时。这两种方式没有说哪个好,哪个坏,因此,使用其中一个即可。
2.3 直接选择排序 2.3.1 基本思想直接选择排序,就是每一次从待排序的数据元素中选择最小或者最大的元素,放在序列的起始位置,直到全部排序完毕。
2.3.2 代码实现// 基本选择排序 void swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } void SelectSort(int* a, int n) { assert(a); int begin = 0, 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[begin], &a[mini]); // 如果begin和maxi重叠,那么要修正一下maxi的位置 if (begin == maxi) { maxi = mini; } swap(&a[end], &a[maxi]); ++begin; --end; } }
实现方式,创建两个变量begin和end,分别指向数组的头和尾,每次找到**最小(最大)**的值,记录当前的位置,并且与开始位置进行交换。然后重复进行该操作,直到集合中只剩一个元素为止。
2.3.3 算法分析直接选择排序是比较简单的一种,但是效率并不高,无论是什么情况,算法时间复杂度都为O(N^2),因此,实际中很少使用。
空间复杂度为O(1),仅使用了常量级别的空间。
直接选择排序是否是稳定的算法呢?答案是不稳定的,在交换的过程中,可能会导致相对次序进行改变。比如,表L={2,2,1},经过第一趟排序后,结果为L={1,2,2},显示已经和原先的次序不一致,故该排序算法是不稳定的。
2.4 堆排序 2.4.1 基本思想在了解堆排序之前,首先要知道堆这个数据结构。堆是一颗完全二叉树,满足根节点大于或者小于左右孩子结点。堆可以分为大根堆和小根堆。大根堆的最大元素存放在根基诶单,任意一颗非根节点的值小于等于其双亲结点的值。而小根堆与大根堆恰好相反,小根堆的根元素为最小。
那么,堆排序的思想很简单,首先在排序前,将待排序的数组构建成为一个堆,以大根堆为例,将堆顶元素与堆底元素进行交换,然后继续将堆顶元素进行向下调整,然后保持大根堆的特性。因为对顶元素永远是当前堆中最大的一个,将其放在最后,就相当于把最大元素放在了数组的最后,再将堆的范围缩小,因此大根堆排序后的结果为升序。
2.4.2 代码实现void swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } // 堆排序 // 向下取整 void AdjustDwon(int* a, int n, int root) { int child = root * 2 + 1; while (child < n) { if (child + 1 < n && a[child + 1] > a[child]) { ++child; } if (a[child] > a[root]) { swap(&a[child], &a[root]); root = child; child = root * 2 + 1; } else { break; } } } void HeapSort(int* a, int n) { // 建堆方式2:O(N) for (int i = (n - 2) / 2; i >= 0; --i) { AdjustDwon(a, n, i); } // O(N*logN) int end = n - 1; while (end > 0) { swap(&a[0], &a[end]); AdjustDwon(a, end, 0); --end; } }2.4.3 算法分析
堆排序是一种效率很高的排序,通过使用堆的数据结构来进行排序,时间复杂度为O(N*logN),建堆的时间为O(N),然后有n-1次向下调整操作,每次调整的时间复杂度与高度有关,而h=log2n + 1,故时间复杂度为O(N*logN)。
空间也是仅使用了常数个辅助单元,故空间复杂的为O(1)。
堆排序是否是稳定的算法呢?答案是不稳定的,在进行筛选的过程中,可能把后面相同的元素调整到前面来。
2.5 冒泡排序 2.5.1 基本思想冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,依次比较两个元素,如果它们的顺序错误就把它们交换过来。如果遍历一遍数组,发现没有进行交换,故该数组已经有序,就不需要再进行排序。
2.5.2 代码实现void swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } // 冒泡排序 void BubbleSort(int* a, int n) { for (int j = 0; j < n - 1; ++j) { int exchange = 0; for (int i = 1; i < n - j; ++i) { if (a[i - 1] > a[i]) { swap(a[i - 1], &a[i]); exchange = 1; } } if (exchange == 0) break; } }
该排序算法的代码实现也很简单,每趟排序,遍历一遍数组,两两比较,每一趟会将最小的值放在第一位。如果该趟排序没有元素交换,则不需要再进行排序了。
2.5.3 算法分析时间复杂度,当元素为逆序时,需要进行n-1趟排序,而每趟需要比较n-1次。故最坏时间复杂度为O(N^2)。当元素为有序时,第一趟后,发现不需要交换元素,则只需要进行n-1次比较。故最好时间复杂度为O(N)。
空间也是仅使用了常数个辅助单元,故空间复杂度为O(1)。
冒泡排序是一种稳定的算法。
2.6 快速排序 2.6.1 基本思想快速排序是Hoare于1962年提出的一种以二叉树结构的交换排序。其本质是基于分治法实现的,基本思想是任取待排序元素序列中的某个元素作为基准,按照该排序码将待排序集合分割成两子序列,左子序列的所有元素均小于基准值,右子序列均大于基准值。然后左右子序列重复该操作,知道该序列有序为止。
2.6.2 代码实现快速排序可以通过递归或者非递归的方式实现,递归的版本有hoare版、挖坑法还有前后指针法。
递归版本大体框架:
// 快速排序递归实现 void QuickSort(int* a, int begin, int end) { if (begin >= end) return; int keyi = PartSort1(a, begin, end); // [begin, keyi-1] keyi [keyi+1, end] QuickSort(a, begin, keyi - 1); QuickSort(a, keyi + 1, end); }
(1)hoare版本
具体思路:
- 选定一个基准值,选定最左边或者最右边。
- 确定两个指针left 和right 分别从左边和右边向中间遍历数组。
- 如果选最右边为基准值,那么left指针先走,如果遇到大于基准值的数就停下来。
- 然后右边的指针再走,遇到小于基准值的数就停下来。
- 交换left和right指针对应位置的值。
- 重复以上步骤,直到left = right ,最后将基准值与left(right)位置的值交换。
// hoare版本 int PartSort1(int* a, int begin,int end) { int left = begin, right = end; int keyi = left; 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]); return left; }
(2)挖坑法
具体思路:
- 先将选定的基准值(最左边)直接取出,然后留下一个坑,
- 当右指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位,
- 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位,
- 重复该步骤,直到左右指针相等。最后将基准值放入坑位之中。
- 之后也是以基准值为界限,递归排序基准值左右区间。
// 挖坑法 int PartSort2(int* a, int begin, int end) { int key = a[begin]; int piti = begin; while (begin < end) { // 右边找小,填到左边的坑里面去。这个位置形成新的坑 while (begin < end && a[end] >= key) { --end; } a[piti] = a[end]; piti = end; // 左边找大,填到右边的坑里面去。这个位置形成新的坑 while (begin < end && a[begin] <= key) { ++begin; } a[piti] = a[begin]; piti = begin; } a[piti] = key; return piti; }
(3)前后指针法
具体思路:
- 选定基准值,定义prev和cur指针(cur = prev + 1)
- cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置
- 将prev对应值与cur对应值交换
- 重复上面的步骤,直到cur走出数组范围
- 最后将基准值与prev对应位置交换
- 递归排序以基准值为界限的左右区间
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; } } int PartSort3(int* a, int begin, int end) { int key = begin,prev = begin; int cur = begin + 1; // 加入三数取中 int midi = GetMidIndex(a, begin, end); swap(&a[key], &a[midi]); while (cur <= end) { if (a[cur] < a[key] && ++prev != cur) swap(&a[cur], &a[prev]); cur++; } swap(&a[key], &a[prev]); key = prev; return key; }
这里借助了三数取中的方法,在基准值的选择上,如果选择的基准值为恰好为最小值,会进行不必要的递归。在排序大量有序数据或者接近有序数据时,效率会比较低,甚至可能会出现程序崩溃的情况。所以,我们会从最左边、中间、最右边的值中选出中间值作为基准。
快速排序优化:从刚刚的第三个方法,我们已经对快速排序的效率进行了优化,但是没有办法解决递归的时候,栈溢出的问题,每次递归后,像一棵树一样,不断分割,会导致底层越来越大,子树越来越多,这时候,就越容易出现栈溢出的情况,我们可以当递归到下层的时候,采用其他的排序算法进行排序,这里使用了插入排序来进行优化。
// 优化 void QuickSort(int* a, int begin, int end) { callCount++; if (begin >= end) return; // 小区间优化 if (end - begin > 20) { int keyi = PartSort3(a, begin, end); // [begin, keyi-1] keyi [keyi+1, end] QuickSort(a, begin, keyi - 1); QuickSort(a, keyi + 1, end); } else { InsertSort(a + begin, end - begin + 1); } }
(4)非递归版本
快速排序的非递归,需要借助栈来实现,栈中存放需要排序的左右区间。因为C语言没有栈,所以需要使用自己实现的栈版本。非递归能够彻底解决栈溢出的问题。
- 将数组左右下标入栈。
- 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限
- 将区间中的元素按照前后指针法排序(其余两种也可)得到基准值的位置
- 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈
- 重复上述步骤直到栈为空
void QuickSortNonR(int* a, int begin, int end) { ST st; StackInit(&st); StackPush(&st, end); StackPush(&st, begin); while (!StackEmpty(&st)) { int left = StackTop(&st); StackPop(&st); int right = StackTop(&st); StackPop(&st); int keyi = PartSort3(a, left, right); // [left, keyi-1] keyi[keyi+1, right] if (keyi + 1 < right) { StackPush(&st, right); StackPush(&st, keyi + 1); } if (left < keyi - 1) { StackPush(&st, keyi - 1); StackPush(&st, left); } } StackDestroy(&st); }2.6.3 算法分析
快速排序整体的综合性能与使用场景都是比较好的,所以才能称为快速排序。时间复杂度在优化后,基本上能达到O(N*logN),且优化后,优势更加明显。
空间复杂度,因为使用了递归,导致空间复杂度为O(logN)。
算法是不稳定的。
2.7 归并排序 2.7.1 基本思想归并排序是建立在归并操作上的一种有效的排序算法,该算法采用的是分治法。其思想就是将序列分成n个子序列,再使用子序列有序,之后,将其合并为一个新的有序表,如果两个有序表合并为一个有序表,称为二路归并。
2.7.2 代码实现递归版本:
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); if (tmp == NULL) { printf("malloc failn"); exit(-1); } _MergeSort(a, 0, n - 1, tmp); free(tmp); }
首先,要知道这个算法的实现,要先要理解分治,分就是将数组分成n个子序列,治就是合并的意思,这里采用的是二路归并排序,将两个子序列进行比较,然后在拷贝为原数组中。最后将同时划分的序列合并。
非递归版本:
非递归实现的思想与递归实现的思想是类似的。
不同的是,这里的序列划分过程和递归是相反的,不是一次一分为二,而是先1个元素一组,再2个元素一组,4个元素一组…直到将所有的元素归并完。
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { printf("malloc failn"); exit(-1); } 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 + 2 * gap - 1; // end1越界或者begin2越界,则可以不归并了 if (end1 >= n || begin2 >= n) { break; } else if (end2 >= n) { end2 = n - 1; } int m = end2 - begin1 + 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 + i, tmp + i, sizeof(int) * m); } gap *= 2; } free(tmp); }2.7.3 算法分析
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
每趟归并的时间为O(N),并且需要log2N趟归并,所以时间复杂度为O(N*logN)。
二路归并排序不会改变相同关键字记录的相对次序,因此是一种稳定的算法。
2.8 计数排序 2.8.1 基本思想计数排序是一种不需要进行比较的排序,首先,先统计相同元素出现次数,然后再根据统计的结果将序列回收到原来的序列中。
2.8.2 代码实现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); if (count == NULL) { printf("malloc failn"); exit(-1); } 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) { // 出现几次就会回写几个i+min while (count[i]--) { a[j++] = i + min; } } }2.8.3 算法分析
基数排序在数据集中的时候效率很高,但是使用场景有限。时间复杂度和空间复杂度都与范围有关。
时间复杂度:O(MAX(N,范围))
空间复杂度:O(范围)
稳定性:稳定
3 排序算法总结 2.8.2 代码实现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); if (count == NULL) { printf("malloc failn"); exit(-1); } 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) { // 出现几次就会回写几个i+min while (count[i]--) { a[j++] = i + min; } } }2.8.3 算法分析
基数排序在数据集中的时候效率很高,但是使用场景有限。时间复杂度和空间复杂度都与范围有关。
时间复杂度:O(MAX(N,范围))
空间复杂度:O(范围)
稳定性:稳定
3 排序算法总结