我们通常所说的排序算法往往指的是内部排序算法,即数据记录在内存中进行排序。
排序算法大体可分为两种:
算法的复杂度和稳定性:
排序算法的稳定性:
排序算法稳定性的简单形式化定义为:如果Ai = Aj,排序前Ai在Aj之前,排序后Ai还在Aj之前,则称这种排序算法是稳定的。通俗地讲就是保证排序前后两个相等的数的相对顺序不变
冒泡排序
冒泡排序是一种极其简单的排序算法,也是我所学的第一个排序算法。它重复地走访过要排序的元素,依次比较相邻两个元素,如果他们的顺序错误就把他们调换过来,直到没有元素再需要交换,排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序算法的运作如下:
- 比较相邻的元素,如果前一个比后一个大,就把它们两个调换位置。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
- 动图演示
- 代码实现:
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- 如果能在内部循环第一次运行时,使用一个旗标来表示有无需要交换的可能,可以把最优时间复杂度降低到O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void BubbleSort(int array[], int size)
{
int i, j;
for (i = 0; i < size - 1; i++)
{
int isSorted = 1;
for (j = 0; j < size - 1 - i; j++)
{
if (array[j] > array[j + 1])
{
Swap(array + j, array + j + 1);
isSorted = 0;
}
}
if (isSorted == 1)
{
break;
}
}
}
- 冒泡排序的改进--鸡尾酒排序
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- 如果序列在一开始已经大部分排序过的话,会接近O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void CocktailSort(int array[], int size)
{
int left = 0; // 初始化边界
int right = size- 1;
while (left < right)
{
for (int i = left; i < right; i++) // 前半轮,将最大元素放到后面
{
if (array[i] > array[i + 1])
{
Swap(array + i, array + i + 1);
}
}
right--;
for (int i = right; i > left; i--) // 后半轮,将最小元素放到前面
{
if (array[i - 1] > array[i])
{
Swap(array + i - 1, array + i);
}
}
left++;
}
}
- 动图演示
快速排序
快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序n个元素要O(nlogn)次比较。在最坏状况下则需要O(n^2)次比较,但这种状况并不常见。事实上,快速排序通常明显比其他O(nlogn)算法更快,因为它的内部循环可以在大部分的架构上很有效率地被实现出来。
快速排序使用分治策略(Divide and Conquer)来把一个序列分为两个子序列。步骤为:
- 从序列中挑出一个元素,作为"基准"(pivot).
- 把所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准的后面(相同的数可以到任一边),这个称为分区(partition)操作。
- 对每个分区递归地进行步骤1~2,递归的结束条件是序列的大小是0或1,这时整体已经被排好序了。
- 动图演示
- 代码实现
// 分类 ------------ 内部比较排序
// 数据结构 --------- 数组
// 最差时间复杂度 ---- 每次选取的基准都是最大(或最小)的元素,导致每次只划分出了一个分区,需要进行n-1次划分才能结束递归,时间复杂度为O(n^2)
// 最优时间复杂度 ---- 每次选取的基准都是中位数,这样每次都均匀的划分出两个分区,只需要logn次划分就能结束递归,时间复杂度为O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ 主要是递归造成的栈空间的使用(用来保存left和right等局部变量),取决于递归树的深度,一般为O(logn),最差为O(n)
// 稳定性 ---------- 不稳定
int Partition_01(int array[], int left, int right)
{
int begin = left;
int end = right;
while (begin < end) {
while (begin < end && array[begin] <= array[right]) {
begin++;
}
while (begin < end && array[end] >= array[right]) {
end--;
}
Swap(array + begin, array + end);
}
Swap(array + begin, array + right);
return begin;
}
int Partition_02(int array[], int left, int right)
{
int begin = left;
int end = right;
int pivot = array[right];
while (begin < end) {
while (begin < end && array[begin] <= pivot) {
begin++;
}
array[end] = array[begin];
while (begin < end && array[end] >= pivot) {
end--;
}
array[begin] = array[end];
}
array[begin] = pivot;
return begin;
}
int Partition_03(int array[], int left, int right)
{
int cur, div;
for (cur = left, div = left; cur < right; cur++) {
if (array[cur] < array[right]) {
Swap(array + cur, array + div);
div++;
}
}
Swap(array + div, array + right);
return div;
}
void __QuickSort(int array[], int left, int right)
{
if (left == right) {
return;
}
if (left > right) {
return;
}
int div = Partition_01(array, left, right); //获取基准值
__QuickSort(array, left, div - 1);
__QuickSort(array, div + 1, right);
}
void QuickSort(int array[], int size)
{
__QuickSort(array, 0, size - 1);
}
直接插入排序
插入排序是一种简单直观的排序算法。它的工作原理非常类似于我们抓扑克牌
对于未排序数据(右手抓到的牌),在已排序序列(左手已经排好序的手牌)中从后向前扫描,找到相应位置并插入。
插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后
- 重复步骤2~5
- 动图演示
- 代码实现
// 分类 ------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 最坏情况为输入序列是降序排列的,此时时间复杂度O(n^2)
// 最优时间复杂度 ---- 最好情况为输入序列是升序排列的,此时时间复杂度O(n)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void InsertSort(int array[], int size)
{
int key;
int i, j;
for (i = 1; i < size; i++)
{
key = array[i];
for (j = i - 1; j >= 0; j--)
{
if (key >= array[j])
{
break;
}
else
{
array[j + 1] = array[j];
}
}
array[j + 1] = key;
}
}
- 插入排序的改进:二分插入排序
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 稳定
void InsertSortDichotomy(int array[], int n)
{
for (int i = 1; i < n; i++)
{
int key= array[i]; // 右手抓到一张扑克牌
int left = 0; // 拿在左手上的牌总是排序好的,所以可以用二分法
int right = i - 1; // 手牌左右边界进行初始化
while (left <= right) // 采用二分法定位新牌的位置
{
int mid = (left + right) / 2;
if (array[mid] > key)
right = mid - 1;
else
left = mid + 1;
}
for (int j = i - 1; j >= left; j--) // 将欲插入新牌位置右边的牌整体向右移动一个单位
{
array[j + 1] = array[j];
}
array[left] = key; // 将抓到的牌插入手牌
}
}
当n较大时,二分插入排序的比较次数比直接插入排序的最差情况好得多,但比直接插入排序的最好情况要差,所当以元素初始序列已经接近升序时,直接插入排序比二分插入排序比较次数少。二分插入排序元素移动次数与直接插入排序相同,依赖于元素初始序列。
希尔排序
希尔排序,也叫递减增量排序,是插入排序的一种更高效的改进版本。希尔排序是不稳定的排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位
希尔排序通过将比较的全部元素分为几个区域来提升插入排序的性能。这样可以让一个元素可以一次性地朝最终位置前进一大步。然后算法再取越来越小的步长进行排序,算法的最后一步就是普通的插入排序,但是到了这步,需排序的数据几乎是已排好的了(此时插入排序较快)。
假设有一个很小的数据在一个已按升序排好序的数组的末端。如果用复杂度为O(n^2)的排序(冒泡排序或直接插入排序),可能会进行n次的比较和交换才能将该数据移至正确位置。而希尔排序会用较大的步长移动数据,所以小数据只需进行少数比较和交换即可到正确位置。
- 动图演示
- 代码实现
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- 根据步长序列的不同而不同。已知最好的为O(n(logn)^2)
// 最优时间复杂度 ---- O(n)
// 平均时间复杂度 ---- 根据步长序列的不同而不同。
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
void _InsertSort(int array[], int size, int gap)
{
int key;
int i, j;
for (i = gap; i < size; i++)
{
key = array[i];
for (j = i - gap; j >= 0; j -= gap)
{
if (key >= array[j])
{
break;
}
else
{
array[j + gap] = array[j];
}
}
array[j + gap] = key;
}
}
void ShellSort(int array[], int size)
{
int gap = size;
while (1)
{
gap = gap / 3 + 1;
_InsertSort(array, size, gap);
if (gap == 1)
{
break;
}
}
}
选择排序
选择排序也是一种简单直观的排序算法。它的工作原理很容易理解:初始时在序列中找到最小(大)元素,放到序列的起始位置作为已排序序列;然后,再从剩余未排序元素中继续寻找最小(大)元素,放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
注意选择排序与冒泡排序的区别:冒泡排序通过依次交换相邻两个顺序不合法的元素位置,从而将当前最小(大)元素放到合适的位置;而选择排序每遍历一次都记住了当前最小(大)元素的位置,最后仅需一次交换操作即可将其放到合适的位置。
- 动图演示
- 代码实现
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(n^2)
// 最优时间复杂度 ---- O(n^2)
// 平均时间复杂度 ---- O(n^2)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
void Swap(int* a, int* b)
{
int t;
t = *a;
*a = *b;
*b = t;
}
void SelectSort(int array[], int size)
{
int i, j;
for (i = size; i > 1; i--)
{
int max = 0;
for (j = 1; j < i; j++)
{
if (array[j] > array[max])
{
max = j;
}
}
Swap(array + max, array + i - 1);
}
}
void SelectSortOP(int array[], int size)
{
int left = 0;
int right = size - 1;
while (left < right)
{
int max = left;
int min = left;
int i;
for (i = left + 1; i <= right; i++)
{
if (array[i] > array[max])
{
max = i;
}
if (array[i] < array[min])
{
min = i;
}
}
Swap(array + min, array + left);
if (max == left)
{
max == min;
}
Swap(array + right, array + max);
left++;
right--;
}
}
堆排序
堆排序是指利用堆这种数据结构所设计的一种选择排序算法。堆是一种近似完全二叉树的结构(通常堆是通过一维数组来实现的),并满足性质:以最大堆(也叫大根堆、大顶堆)为例,其中父结点的值总是大于它的孩子节点。
我们可以很容易的定义堆排序的过程:
- 由输入的无序数组构造一个最大堆,作为初始的无序区
- 把堆顶元素(最大值)和堆尾元素互换
- 把堆(无序区)的尺寸缩小1,并调用heapify(A, 0)从新的堆顶元素开始进行堆调整
- 重复步骤2,直到堆的尺寸为1
- 动图演示
- 代码实现
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(nlogn)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ O(1)
// 稳定性 ------------ 不稳定
void AdjustDown(int array[], int size, int root)
{
int left = 2 * root + 1;
int right = 2 * root + 2;
if (left >= size)
{
return;
}
int max = left;
if (right < size && array[right] > array[left])
{
max = right;
}
if (array[max] <= array[root])
{
return;
}
Swap(array + root, array + max);
AdjustDown(array, size, max);
}
void CreateHeap(int array[], int size)
{
int i;
for (i = size / 2 - 1; i >= 0; i--)
{
AdjustDown(array, size, i);
}
}
//不稳定
//时间复杂度 O(N*logN)
//空间复杂度 O(1)
void HeapSort(int array[], int size)
{
int i;
CreateHeap(array, size);
for (i = 0; i < size; i++)
{
Swap(&array[0], &array[size - 1 - i]);
AdjustDown(array, size - i - 1, 0);
}
}
归并排序
归并排序是创建在归并操作上的一种有效的排序算法,效率为O(nlogn),1945年由冯·诺伊曼首次提出。
归并排序的实现分为递归实现与非递归(迭代)实现。递归实现的归并排序是算法设计中分治策略的典型应用,我们将一个大问题分割成小问题分别解决,然后用所有小问题的答案来解决整个大问题。非递归(迭代)实现的归并排序首先进行是两两归并,然后四四归并,然后是八八归并,一直下去直到归并了整个数组。
归并排序算法主要依赖归并(Merge)操作。归并操作指的是将两个已经排序的序列合并成一个序列的操作,归并操作步骤如下:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
- 重复步骤3直到某一指针到达序列尾
- 将另一序列剩下的所有元素直接复制到合并序列尾
- 动图演示
- 代码实现
// 分类 -------------- 内部比较排序
// 数据结构 ---------- 数组
// 最差时间复杂度 ---- O(nlogn)
// 最优时间复杂度 ---- O(nlogn)
// 平均时间复杂度 ---- O(nlogn)
// 所需辅助空间 ------ O(n)
// 稳定性 ------------ 稳定
void Merge(int array[], int left, int mid, int right, int extra[])
{
int left_i = left;
int right_i = mid;
int extra_i = left;
while (left_i < mid && right_i < right) {
if (array[left_i] <= array[right_i]) {
extra[extra_i++] = array[left_i++];
}
else {
extra[extra_i++] = array[right_i++];
}
}
while (left_i < mid) {
extra[extra_i++] = array[left_i++];
}
while (right_i < right) {
extra[extra_i++] = array[right_i++];
}
int i;
for (i = left; i < right; i++) {
array[i] = extra[i];
}
}
//递归
void __MergeSort(int array[], int left, int right, int extra[])
{
if (left == right - 1) {
return;
}
if (left >= right) {
return;
}
int mid = left + (right - left) / 2;
__MergeSort(array, left, mid, extra);
__MergeSort(array, mid, right, extra);
Merge(array, left, mid, right, extra);
}
void MergeSort(int array[], int size)
{
int *extra = (int *)malloc(sizeof(int)* size);
__MergeSort(array, 0, size, extra);
free(extra);
}
//非递归
void MergeSortLoop(int array[], int size)
{
int i, j;
int *extra = (int *)malloc(sizeof(int)* size);
for (i = 1; i < size; i *= 2) {
for (j = 0; j < size; j = j + 2 * i) {
int left = j;
int mid = j + i;
int right = mid + i;
if (mid >= size) {
continue;
}
if (right > size) {
right = size;
}
Merge(array, left, mid, right, extra);
}
}
free(extra);
}