最基础的算法问题,温故知新。排序算法的几个主要指标是,时间复杂度(最好,最差和平均),空间复杂度(额外空间)和稳定性。本文主要描述八种常见算法:简单选择排序、冒泡排序、简单插入排序、希尔排序、归并排序、快速排序、堆排序和基数排序,关于它们的指标统计可以直接看最后。本文均为基本C++实现,不使用STL库。 值得一提的是排序算法的稳定性,之前关注较少。稳定性的意思是对于序列中键值(Key value)相同的元素,它们在排序前后的相对关系保持不变。对于int这样的基本数据类型,稳定性基本上是没有意义的,因为它的键值就是元素本身,两个元素的键值相同他们就可以被认为是相同的。但对于复杂的数据类型,数据的键值相同,数据不一定相同,比如一个Student类,包括Name和Score两个属性,以Score为键值排序,这时候键值相同元素间的相对关系就有意义了。 简单选择排序应该是最自然的思路。选择排序的思想是,从全部序列中选取最小的,与第0个元素交换,然后从第1个元素往后找出最小的,与第一个元素交换,再从第2个元素往后选取最小的,与第2个元素交换,直到选取最后一个元素。 void selectionSort(int a[], int n) { for (int i = 0; i < n - 1; ++i) { int minIdx = i; for (int j = i + 1; j < n; ++j) { if (a[j] < a[minIdx]) { minIdx = j; } } int tmp = a[i]; a[i] = a[minIdx]; a[minIdx] = tmp; }} 无论如何都要完整地执行内外两重循环,故最好、最差和平均时间复杂度都是O(n2),不需要额外空间。选择排序是不稳定的。 冒泡排序冒泡排序的思想是,从第0个元素到第n-1个元素遍历,若前面一个元素大于后面一个元素,则交换两个元素,这样可将整个序列中最大的元素冒泡到最后,然后再从第0个到第n-2遍历,如此往复,直到只剩一个元素。
冒泡排序与简单选择排序类似,无论如何都要执行完两重循环,故最好、最坏和平均时间复杂度均为O(n2),不需要额外空间。冒泡排序是稳定的。 简单插入排序(Insertion Sort)思路是类似扑克牌的排序,每次从未排序序列的第一个元素,插入到已排序序列中的合适位置。假设初始的有序序列为第0个元素(本文描述的序号都从0开始),只有一个元素的序列肯定是有序的,然后从原先序列的第1个元素开始到第n-1个元素遍历,每次将当前元素插入到它之前序列中的合适位置。 void insertionSortBSearch(int a[], n) { for (int i = 1; i < n; ++i) { int j, val = a[i]; for (j = i - 1; j >= 0 && a[j] > val; --j) { a[j + 1] = a[j]; } a[j + 1] = val; }} 两重循环,最差和平均时间复杂度为O(n2),最好情况是原序列已有序,则忽略内层循环,时间复杂度O(n)。插入排序是稳定的。
希尔排序希尔排序可以被认为是简单插入排序的一种改进。插入排序一个比较耗时的地方在于需要将元素反复后移,因为它是以1为增量进行比较的元素的后移可能会进行多次。一个长度为n的序列,以1为增量就是一个序列,以2为增量就形成两个序列,以i为增量就形成i个序列。希尔排序的思想是,先以一个较大的增量,将序列分成几个子序列,将这几个子序列分别排序后,合并,在缩小增量进行同样的操作,知道增量为1时,序列已经基本有序,这是进行简单插入排序的效率就会较高。希尔排序的维基词条上有一个比较好的解释例子如下: // 原始序列13 14 94 33 82 25 59 94 65 23 45 27 73 25 39 10// 以5为增量划分,5列,每列即为一个子序列13 14 94 33 8225 59 94 65 2345 27 73 25 3910// 对每一个子序列进行插入排序得到以下结果10 14 73 25 2313 27 94 33 3925 59 94 65 8245// 恢复一行显示为10 14 73 25 23 13 27 94 33 39 25 59 94 65 82 45// 再以3为增量划分,3列,每列即为一个子序列10 14 7325 23 1327 94 3339 25 5994 65 8245// 对每一个子序列进行插入排序得到如下结果10 14 1325 23 3327 25 5939 65 7345 94 8294// 恢复一行为10 14 13 25 23 33 27 25 59 39 65 73 45 94 82 94// 然后再以1为增量进行插入排序,即简单插入排序// 此时序列已经基本有序,分布均匀,需要反复后移的情况较少,效率较高 上面的例子中,我们依次选取了5、3和1为增量,实际中增量的选取并没有统一的规则,唯一的要求就是最后一次迭代的增量需为1。最初的增量选取规则为,从n/2折半递减直到1。还有一些关于希尔排序增量选取的研究,针对不同数据有不同的表现,在此不做展开。下面是增量从n/2折半递减到1的代码示例。
希尔排序在简单插入排序的基础上做了些改进,它的最好及最差时间复杂度和简单插入排序一样,分别是是O(n)和O(n2),平均时间复杂度试增量选取规则而定,一般认为介于O(n)和O(n2)之间。它不需要额外空间。它是不稳定的。 归并排序归并排序的思想是,利用二分的特性,将序列分成两个子序列进行排序,将排序后的两个子序列归并(合并),当序列的长度为2时,它的两个子序列长度为1,即视为有序,可直接合并,即达到归并排序的最小子状态。基于递归的实现如下: void mergeSortRecursive(int a[], int b[], int start, int end) { if (start >= end) { return; } int mid = start + (end - start) / 2, start1 = start, end1 = mid, start2 = mid + 1, end2 = end; mergeSortRecursive(a, b, start1, end1); mergeSortRecursive(a, b, start2, end2); int i = 0; while (start1 <= end1 && start2 <= end2) { b[i++] = a[start1] < a[start2] ? a[start1++] : a[start2++]; } while (start1 <= end1) { b[i++] = a[start1++]; } while (start2 <= end2) { b[i++] = a[start2++]; } for (i = start; i < end; ++i) { a[i] = b[i]; }}void mergeSort(int a[], int n) { int *b = new int[n]; mergeSortRecursive(a, b, 0, n - 1); delete[] b;} 归并排序的最好,最坏和平均时间复杂度都是O(n*logn)。但需要O(n)的辅助空间。归并排序是稳定的。 快速排序快速排序可能是最常被提到的排序算法了,快排的思想是,选取第一个数为基准,通过一次遍历将小于它的元素放到它的左侧,将大于它的元素放到它的右侧,然后对它的左右两个子序列分别递归地执行同样的操作。
快速排序利用分而治之的思想,它的最好和平均实际复杂度为O(nlogn),但是,如果选取基准的规则正好与实际数值分布相反,例如我们选取第一个数为基准,而原始序列是倒序的,那么每一轮循环,快排都只能把基准放到最右侧,故快排的最差时间复杂度为O(n2)。快排算法本身没有用到额外的空间,可以说需要的空间为O(1);对于递归实现,也可以说需要的空间是O(n),因为在递归调用时有栈的开销,当然最坏情况是O(n),平均情况是O(logn)。快速排序是不稳定的。 堆排序堆排序利用的是二叉树的思想,所谓堆就是一个完全二叉树,完全二叉树的意思就是,除了叶子节点,其它所有节点都有两个子节点,这样子的话,完全二叉树就可以用一个一块连续的内存空间(数组)来存储,而不需要指针操作了。堆排序分两个流程,首先是构建大顶堆,然后是从大顶堆中获取按逆序提取元素。 ![]() 构建大顶堆示意图 构建完大顶堆后,我们需要按逆序提取元素,从而获得一个递增的序列。首先将根节点和最后一个节点交换,这样最大的元素就放到最后了,然后我们更新大顶堆,再次将新的大顶堆根节点和倒数第二个节点交换,如此循环直到只剩一个节点,此时整个序列有序。下面是一个较好的示意图来自bubkoo: ![]() 从大顶堆逆序提取元素使其有序示意图 void updateHeap(int a[], int i, int n) { int iMax = i, iLeft = 2 * i + 1, iRight = 2 * (i + 1); if (iLeft < n && a[iMax] < a[iLeft]) { iMax = iLeft; } if (iRight < n && a[iMax] < a[iRight]) { iMax = iRight; } if (iMax != i) { int tmp = a[iMax]; a[iMax] = a[i]; a[i] = tmp; updateHeap(a, iMax, n); }}void heapSort(int a[], int n) { for (int i = (n - 1) / 2; i >= 0; i--) { updateHeap(a, i, n); } for (int i = n - 1; i > 0; --i) { int tmp = a[i]; a[i] = a[0]; a[0] = tmp; updateHeap(a, i, n); }} 堆排序的整个过程中充分利用的二分思想,它的最好、最坏和平均时间复杂度都是O(nlogn)。堆排序不需要额外的空间。堆排序的交换过程不连续,显然是不稳定的。 基数排序基数排序是一种典型的空间换时间的排序方法。以正整数为例,将所有待比较数值统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位(个位)排序一直到最高位排序完成以后,数列就变成一个有序序列。
基数排序的最好,最好、最坏和平均时间复杂度都是O(n*k),其中n是数据大小,k是所选基数。它需要O(n+k)的额外空间。它是稳定的。 八种排序算法总结上面介绍了最常提到的八种排序算法,最基础的是选择和插入,基于选择和插入分别改进出了冒泡和希尔。基于二分思想又提出了归并、快排和堆排序。最后基于数据的分布特征,提出了基数排序。这些排序算法的主要指标总结如下。
参考排序算法时间复杂度:https://www./time-complexities-of-all-sorting-algorithms/ |
|