一、插入排序
插入排序的核心思想是把一个数据插入已经排好序的一组数据中的正确位置。当运用插入排序来排序一组数据时,先把第一个数看作有序,把第二个数插入正确位置;再把前两个数看作有序,把第三个数插入正确位置,以此类推,直到所有数据按照正确顺序排列。
以升序为例:假设[0-end]有序。每一趟排序,需要将end+1位置的数据与前面的数据依次比较。如果小于当前位置的数据,需要将当前位置的数据向前移动一位,否则就将这个数据插入到当前数据的后面。将移动一位的操作放在while循环外面来写有这样的考虑:如果end+1位置的数据是最小的,那么最后end会减小至-1导致跳出循环。所以把移动数据的逻辑写在循环外面,就不用写两次了。
void InsertSort(int* a, int n) { for (int i = 0; i < n - 1; i++) { int end = i;//认为0-end有序,end+1位置的值插入[0,end],保持有序 int tmp = a[end + 1]; while (end >= 0) { if (a[end] > tmp) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = tmp; } }二、希尔排序
希尔排序是对插入排序的优化。它的核心思想是通过预排序使数组接近有序,然后再进行插入排序。
我们将整组数据分为gap组(每一组中,每gap个数据,取一个数据),然后将每一组插入排序,再整体插入排序。
我们首先实现每一组插入排序的代码:
void ShellSort(int* a, int n) { int gap = 3; for (int j = 0; j < gap; j++) { for (size_t i = j; i < n - gap; i += 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; for (size_t 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越小,跳得越慢,但预排序后的结果越接近有序。
一般会走多组预排序,每次预排序gap的值发生变化。下一次预排序的gap值与这一次预排序的gap值满足gap = gap/3 + 1比较合适,可以保证最后一次gap=1,也就是最后一次进行的是整体的插入排序。
void ShellSort(int* a, int n) { int gap = n; while (gap > 1) { gap = gap/3 + 1; for (size_t 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; } } }希尔排序的时间复杂度约为O(n^1.3),其证明涉及一些复杂的数学知识。
三、选择排序
选择排序的核心思想是遍历一次所有数据选出最大和最小的数据放在两端,再遍历一次剩下的数据,选出其中最大和最小的数据放在第二和倒数第二的位置......直到数组有序。
void SelectSort(int* a, int n) { int begin = 0, end = n-1; while (begin < end) { int mini = begin, maxi = end; for (size_t 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--; } }这里有一个需要注意的地方:如果maxi恰好与begin重合,那么交换begin与mini位置的数据后,最大的数据就移动到mini的位置了。所以要写一个判断语句来专门处理这种情况。
四、快速排序
4.1 hoare版快速排序
快速排序的核心思想是:将数组中的一个数据作为“关键值”,两个指针分别从数组的左端和右端开始遍历数组,遍历的过程中将所有比“关键值”小的数据放到左边,把所有比“关键值”大的数据都放到右边;当两个指针相遇时,将相遇位置的数据与“关键值”互换,这样,关键值就把数组分成了两半,左边数据都小于关键值,右边数据都大于关键值。然后,在分出的两个数组中再进行一次这样的遍历......整个过程类似二叉树的递归遍历,终止条件是分出的数组中只剩1个值或没有值;递归遍历结束时,整个数组就有序了。
void QuickSort(int* a, int left, int right) { if (left >= right) return; int keyi = left; int begin = left, end = right; while (begin < end) { //右边找小的 while (begin < end && a[end] >= a[keyi]) { --end; } //左边找大的 while (begin < end && a[begin] <= a[keyi]) { ++begin; } Swap(&a[begin], &a[end]); } Swap(&a[keyi], &a[begin]); keyi = begin; QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); }此处有一个可能的疑点:为什么begin和end相遇的位置数据一定比关键值小?
分两种情况分析:
如果是begin遇到了end:end先走,停下的条件是遇到比关键值小的数据,所以end停下的位置的数据一定比关键值小。begin没有找到比关键值更大的数据,所以遇到end停下了。
如果是end遇到了begin:end先走,找比关键值更小的数据,没有找到,直接和begin相遇了。此时begin停留的位置是上一轮交换过后的位置,上一轮交换把比关键值小的数据移动到begin的位置了。
不管是那种情况,相遇位置的数据,一定比关键值小。
4.2 快速排序算法的优化
我们已经写出了一个简单版的快速排序算法。然而,这个算法现在有很大的缺陷。首先,这个算法面对已经有序的数组时,每趟排序只能解决一个数据,此时它就退化成一个O(N^2)的算法,递归深度太深,会有栈溢出的风险。
为了避免有序情况下的效率退化,我们可以对算法进行一些优化。我们可以不要总是选择数组的第一个数据作为关键值,可以采取“三数取中”的方法,在最前面、最后面和最中间这三个数据中选择一个中间的数据作为关键值。
此时我们需要实现一个三数取中的函数。
int GetMidi(int* a, int left, int right) { int midi = (left + right) / 2; if (a[left] < a[midi]) { if (a[midi] < a[right]) { return midi; } else if (a[left] < a[right]) { return right; } else { return left; } } else { if (a[midi] < a[right]) { return midi; } else if (a[right] < a[left]) { return right; } else { return left; } } }然后修改一下我们写好的快速排序算法:
void QuickSort(int* a, int left, int right) { if (left >= right) return; int midi = GetMidi(a, left, right); Swap(&a[left], &a[midi]); int keyi = left; int begin = left, end = right; while (begin < end) { //右边找小的 while (begin < end && a[end] >= a[keyi]) { --end; } //左边找大的 while (begin < end && a[begin] <= a[keyi]) { ++begin; } Swap(&a[begin], &a[end]); } Swap(&a[keyi], &a[begin]); keyi = begin; QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); }此时仍然有优化的空间。现在的快速排序算法用的是递归遍历,递归到接近结束的时候,假设一个小区间只有五个数,那么仍然任然需要用6次递归使之有序,非常浪费。刚才提快速排序的过程类似二叉树的递归遍历,而二叉树仅最后一层的递归次数就占整体的50%,最后三层的递归次数占整体的80%多。
所以,我们可以通过“小区间优化”,在分出的区间足够小时改用插入排序,就可以大幅减少递归的次数。
void QuickSort(int* a, int left, int right) { if ((right - left + 1) < 10) { InsertSort(a + left, right - left + 1); //注意此处调用插入排序,第一个参数应为a + left } else { if (left >= right) return; int midi = GetMidi(a, left, right); Swap(&a[left], &a[midi]); int keyi = left; int begin = left, end = right; while (begin < end) { //右边找小的 while (begin < end && a[end] >= a[keyi]) { --end; } //左边找大的 while (begin < end && a[begin] <= a[keyi]) { ++begin; } Swap(&a[begin], &a[end]); } Swap(&a[keyi], &a[begin]); keyi = begin; QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); } }4.3 双指针版快速排序
hoare版本的单趟排序不太好理解,而双指针法好理解,写起来也更简单(效率并没有区别)。
用双指针法来实现快速排序的单趟排序,就是定义两个指针prev和cur,让prev从左端开始走,cur从左端数的第二个位置开始走,cur向前移动寻找比关键值小的数据,没有找到就继续向前移动,找到了就让prev++,然后交换prev和cur位置的数据,一直到cur走到最右端为止。然后,交换prev和最左端位置的数据,就完成了单趟排序。
把单趟排序的过程拎出来,就是这样的:
int PartSort2(int* a, int left, int right) { //三数取中 int midi = GetMidi(a, left, right); Swap(&a[left], &a[midi]); int keyi = left; int prev = left; int cur = prev + 1; while (cur <= right) { if (a[cur] < a[keyi] && ++prev != cur) { Swap(&a[prev], &a[cur]); } ++cur; } Swap(&a[prev], &a[left]); return prev; }4.4 非递归版快速排序
快速排序中每个递归调用的栈帧实际上主要用来记录区间。递归有栈溢出风险,我们可以改用栈这种数据结构来存储区间的值,实现非递归版本的快速排序。每次从栈顶取出一个区间,单趟排序后,将分出来的右、左区间压栈,一直重复到栈为空为止,就完成了排序。
void QuickSortNonR(int* a,int left, int right) { ST st; STInit(&st); STPush(&st, right); STPush(&st, left); while (!STEmpty(&st)) { int begin = STTop(&st); STPop(&st); int end = STTop(&st); STPop(&st); int keyi = PartSort2(a, begin, end); if (end > keyi + 1) { STPush(&st, end); STPush(&st, keyi + 1); } if (keyi - 1 > begin) { STPush(&st, keyi - 1); STPush(&st, begin); } } STDestroy(&st); }4.5 三路划分版快速排序
当数据中有大量重复数据时,快速排序的性能会急剧下降。为了解决这个问题,可以用三路划分的思路:将数组分为严格小于关键值、等于关键值和大于关键值三部分。接下来,只需要递归遍历严格小于和严格大于关键值的部分即可。
具体的实现思路是用三个指针:left从左开始移动,right从右开始移动,cur从左开始遍历数组。cur所在位置的值比关键值小时,交换left和cur位置的值,然后让left向右移动一位,让cur向后移动一位;如果cur所在位置的值等于关键值,不进行交换操作,直接让cur向后移动;如果cur所在位置的值大于关键值,交换cur和right位置的值,让right向左移动一位。此时cur不必移动,因为不确定新换到cur位置的值与关键值的大小关系,需要重新进入判断语句进行判断。
void QuickSort2(int* a, int begin, int end) { if (begin >= end) { return; } int key = a[begin]; int left = begin; int right = end; int cur = begin; while (cur <= right) { if (a[cur] == key) { cur++; } else if (a[cur] < key) { Swap(&a[cur], &a[left]); left++; cur++; } else { Swap(&a[cur], &a[right]); right--; } } QuickSort2(a, begin, left - 1); QuickSort2(a, right + 1, end); }五、归并排序
5.1 递归版归并排序
我们已经做过归并两个有序链表的问题,当时我们用了两个指针分别从两个有序链表的第一个节点开始移动,将两个指针指向的两个数据中小的那个插入新链表,直到其中一个链表中的所有数据全部插入新链表中,再把另一个链表中剩下的所有节点插入到新链表中。
假设一个数组由两个已经有序的数组拼接而成,我们就可以用类似的思路进行排序。然而数组是无序的。此时我们就可以用递归的方法,将无序的数组继续拆成两个数组,直到拆出的数组中只有一个数据,就可以视为有序的了。
每次拆分我们以 mid =(左端下标+右端下标)/2 作为分界点,将数组拆成[begin,mid] 和[mid + 1, end]两部分。递归的终止条件是数组只有一个元素,也就是左端点等于右端点。
void _MergeSort(int* a, int* tmp, int begin, int end) { if (begin == end) { return; } int mid = (begin + end) / 2; //[begin, mid][mid + 1,end] _MergeSort(a, tmp, begin, mid); _MergeSort(a, tmp, mid + 1, end); //归并 int begin1 = begin, end1 = mid; int begin2 = mid + 1, end2 = end; int i = begin; while (begin1<=end1 && begin2<=end2) { if (a[begin1] < a[begin2]) { tmp[i++] = a[begin1++]; } else { tmp[i++] = a[begin2++]; } } while (begin2 <= end2) { tmp[i++] = a[begin2++]; } while (begin1 <= end1) { tmp[i++] = a[begin1++]; } 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) { perror("malloc failed"); return; } _MergeSort(a, tmp, 0, n - 1); free(tmp); tmp = NULL; }归并的时间复杂度是O(N*logN),空间复杂度是O(N)。
5.2 非递归版归并排序
可以像快速排序一样用栈来模拟递归,也可以直接用循环:先让数据两个两个归并,再四个四个归并,再八个八个归并,直到所有数据有序为止。
我们将每次归并的数据个数设为gap。gap从1开始,每次乘2。归并的逻辑和刚才一样,只需要注意每个数组的起末位置不要写错即可。
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc failed"); return; } int gap = 1; while (gap < n) { for (int i = 0; i < n; i += gap * 2) { int cur = i; int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; while (begin1 <= end1 && begin2 <= end2) { if (a[begin1] < a[begin2]) { tmp[cur++] = a[begin1++]; } else { tmp[cur++] = a[begin2++]; } } while (begin2 <= end2) { tmp[cur++] = a[begin2++]; } while (begin1 <= end1) { tmp[cur++] = a[begin1++]; } memcpy(a + i, tmp + i, gap * 2 * sizeof(int)); } gap =gap * 2; } free(tmp); tmp = NULL; }此时,我们还要解决一个越界的问题:如果数组中数据个数不是2的幂次,就会越界访问。
越界分成三种情况:1、只有end2越界;2、begin2和end2越界;3、end1、begin2、end2都越界。
由于begin1和end2之间的数据一定是有序的,begin2和end2之间的数据也一定是有序的,所以上述的第2、3种情况都不需要处理,而第一种情况只需要调整end2的值为n-1即可。
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc failed"); return; } int gap = 1; while (gap < n) { for (int i = 0; i < n; i += gap * 2) { int cur = i; int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; if (begin2 >= n) { break; } else if (end1 >= n) { end2 = n - 1; } while (begin1 <= end1 && begin2 <= end2) { if (a[begin1] < a[begin2]) { tmp[cur++] = a[begin1++]; } else { tmp[cur++] = a[begin2++]; } } while (begin2 <= end2) { tmp[cur++] = a[begin2++]; } while (begin1 <= end1) { tmp[cur++] = a[begin1++]; } memcpy(a + i, tmp + i, gap * 2 * sizeof(int)); } gap =gap * 2; } free(tmp); tmp = NULL; }5.3 归并排序实现外排序
当要排序的数据非常多而内存有限时,就可以采用外排序。
比如我们现在创建一个有一千万个随机数的数据文件:
void CreateData() { int n = 10000000; srand(time(0)); const char* file = "data.txt"; FILE* fin = fopen(file, "w"); if (fin == NULL) { perror("fopen failed"); return; } for (int i = 0; i < n; i++) { int x = rand() + 1; fprintf(fin, "%d\n", x); } fclose(fin); }我们可以用归并排序的思路来实现外排序。我们可以先从从文件中读取100000个数,用C语言库中的qsort排序后放入file1文件中。再读取100000个数排序后放入file2文件中。用归并排序将这200000个数据归并到mfile中。然后,删除file1和file2,将mfile重命名为file1,然后再从数据文件中读取100000个数据放入新的file2中,再次进行归并......直到所有数据都被排序,此时的排序结果就存在file1中。
void FileSort() { CreateData(); const char* file = "data.txt"; const char* file1 = "file1.txt"; const char* file2 = "file2.txt"; const char* mfile = "mfile.txt"; FILE* fout = fopen(file, "r"); if (fout == NULL) { perror("fopen failed"); return; } int m = 1000000; ReadNDataSortToFile(fout, m, file1); ReadNDataSortToFile(fout, m, file2); while (true) { MergeFile(file1, file2, mfile); remove(file1); remove(file2); rename(mfile, file1); int n = 0; n = ReadNDataSortToFile(fout, m, file2); if (n == 0) break; if (n < 100) { int x = 0; } } }整体的框架有了。接下来我们需要实现ReadNDataSortToFile和MergeFile的具体逻辑。
先看ReadNDataSortToFile:传数据文件的句柄作为参数,将数据文件中的100000个数据读取后放到malloc开辟的数组中,用qsort进行排序(需要自己写比较函数),然后放入指定文件中。因为要处理数据个数不是100000的整数倍的情况,所以用变量j记录成功读取的数据个数,排序时排j个数据。如果刚开始就读到了EOF,说明数据文件已经读完了,直接返回0。外部得到返回值为0时,也终止循环。
int compare(const void* a, const void* b) { return (*(int*)a - *(int*)b); } int ReadNDataSortToFile(FILE* fout, int n, const char* file1) { int x = 0; int* a = malloc(sizeof(int) * n); if (a == NULL) { perror("malloc failed"); return; } int j = 0; for (int i = 0; i < n; i++) { if (fscanf(fout, "%d", &x) == EOF) break; a[j++] = x; } if (j == 0) { free(a); return 0; } qsort(a, j, sizeof(int), compare); FILE* fin = fopen(file1, "w"); if (fin == NULL) { perror("fopen failed"); return; } for (int i = 0; i < j; i++) { fprintf(fin, "%d\n", a[i]); } fclose(fin); free(a); return j; }MergeFile的逻辑就是归并排序。
void MergeFile(const char* file1, const char* file2, const char* mfile) { FILE* fout1 = fopen(file1, "r"); if (fout1 == NULL) { perror("fopen failed"); return; } FILE* fout2 = fopen(file2, "r"); if (fout2 == NULL) { perror("fopen failed"); return; } FILE* mfin = fopen(mfile, "w"); if (mfin == NULL) { perror("fopen failed"); return; } int x1 = 0; int x2 = 0; int ret1 = fscanf(fout1, "%d", &x1); int ret2 = fscanf(fout2, "%d", &x2); while (ret1 != EOF && ret2 != EOF) { if (x1 < x2) { fprintf(mfin, "%d\n", x1); ret1 = fscanf(fout1, "%d", &x1); } else { fprintf(mfin, "%d\n", x2); ret2 = fscanf(fout2, "%d", &x2); } } while (ret1 != EOF) { fprintf(mfin, "%d\n", x1); ret1 = fscanf(fout1, "%d", &x1); } while (ret2 != EOF) { fprintf(mfin, "%d\n", x2); ret2 = fscanf(fout2, "%d", &x2); } fclose(fout1); fclose(fout2); fclose(mfin); }六、计数排序
计数排序是一种非比较排序,它利用了数组下标天然有序的特点进行排序。
它的核心思想是:进行一次遍历找出数组中最大和最小的数,进而算出所有数据的范围,然后开辟一个大小恰好等于这个范围的count数组,这样所有的数据都和count数组的下标有了映射关系。再次遍历数组,每读取一个数据就让count数组中对应下标位置的数据+1。
最后,遍历count数组,每个下标位置的数据是几,就往原来数组中放入几个对应数据。
void CountSort(int* a, int n) { int min = a[0]; int max = a[0]; for (int i = 0; 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*)calloc(range, sizeof(int)); if (count == NULL) { perror("calloc failed"); return; } //统计次数 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; } } }计数排序只适用于整数的排序,适合处理范围比较集中的数据的排序。时间复杂度是O(N+range)。
七、排序算法的性能比较
7.1 稳定性
稳定性指的是排序后相同的值相对顺序不变的性能。在结构体排序中这个性能就很重要。
| 直接插入排序 | 稳定 | 不改变相同值的相对位置 |
| 希尔排序 | 不稳定 | 相同的数据在预排序时可能被分到不同的组里 |
| 选择排序 | 不稳定 | 排序过程中有交换数据位置的操作 |
| 堆排序 | 不稳定 | 排序过程中有交换数据位置的操作 |
| 冒泡排序 | 稳定 | 不改变相同值的相对位置 |
| 快速排序 | 不稳定 | 会交换数据位置 |
| 归并排序 | 稳定 | 不改变相同值的相对位置 |
7.2 时间复杂度和空间复杂度
| 算法 | 时间复杂度 | 空间复杂度 |
| 直接插入排序 | O(N^2) | O(1) |
| 希尔排序 | O(N^1.3) | O(1) |
| 选择排序 | O(N^2) | O(1) |
| 堆排序 | O(N*logN) | O(1) |
| 冒泡排序 | O(N^2) | O(1) |
| 快速排序 | O(N*logN) | O(logN) |
| 归并排序 | O(N*logN) | O(N) |
END