排序算法是算法中非常重要的部分,我们要做的不仅仅写出各种算法代码,更重要的是在解决实际问题时根据每种算法的时间复杂度、空间复杂度以及稳定性选出合适的算法。
PS:排序算法的稳定性
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,ri=rj,且ri在rj之前,而在排序后的序列中,ri仍在rj之前,则称这种排序算法是稳定的;否则称为不稳定的。
直接插入排序(Straight Insertion Sort)是一种最简单的排序方法,其基本操作是将一条记录插入到已排好的有序表中,从而得到一个新的、记录数量增1的有序表。
每一步将一个待排序的元素,按其排序码的大小,插入到前面已经排好序的一组元素的合适位置上去,直到元素全部插完为止。
数组arr[N],现对其进行插入排序。
1. 开始时数组中有序元素是a[0]。a[1]~a[N-1]均为无序的。
2. 把a[1]按升序或降序规则插入到有序序列中,此时a[0]~a[1]为有序区。
3. 循环第二步,直到将最后一个元素a[N-1]插入到数组当中。
时间复杂度:
最好的情况下为 O(n) O ( n ) ,最坏的情况下为 O(n2) O ( n 2 ) ,平均情况: O(n2) O ( n 2 )
空间复杂度: O(1) O ( 1 )
稳定性:稳定
< code >
void InsertSort(DataType* a, size_t size) // 1.直接插入排序
{
assert(a != NULL);
int end = 0;
size_t i = 0; //记录end退后之前的坐标
for (i = 0; i < size - 1; ++i)
{
end = i;
while (end >= 0)
{
if (a[end] > a[end + 1])
{
Swap(&a[end], &a[end + 1]);
end--; //往后找到这个元素的合适位置
}
else
{
break;
}
}
}
}
希尔排序也叫缩小增量排序,算是直接插入排序的进阶版。我们知道直接插入排序的效率和元素的有序度有关,数组中的元素越有序那排序的速度越快。所以我们可以在排序前先进行预排序,提高元素的有序度。
预排序:
直接插入排序,不管数组元素分布是怎么样的,它都逐步逐步地对元素进行比较,移动,插入。如果数组是[9, 8, 7, 6, 5, 4, 3, 2, 1]
这种倒序序列,要把1插入到正确位置,比较和移动操作都需要进行8次。
在希尔排序中,我们不再一步步操作元素,而是采用跳跃式分组的策略。例如在数组a[N]中,我们设置一个增量 gap=length/3+1 g a p = l e n g t h / 3 + 1 ( length l e n g t h 为数组长度),通过这个增量将数组元素划分为若干组。
第一组:a[0], a[0+gap], a[0+2*gap], a[0+3*gap]……
第二组:a[1], a[1+gap], a[1+2*gap], a[1+3*gap], a[1+4*gap]……
第三组:a[2], a[2+gap], a[2+2*gap], a[2+3*gap], a[2+4*gap]……
……
然后分组进行插入排序,随后逐步缩小增量gap,继续按组进行插入排序操作。当增量为1,进行一次直接插入排序。
希尔排序通过这种策略使得整个数组在初始阶段达到从宏观上看基本有序,小的基本在前,大的基本在后。然后缩小增量,到增量为1时,其实多数情况下只需微调即可,不会涉及过多的数据移动。
第一次排序 gap=10/3+1=4 g a p = 10 / 3 + 1 = 4
< code >
void ShellSort(int* a, size_t n)//希尔排序
{
assert(a != NULL);
//预排序
int gap = n / 3 + 1;//希尔排序所需要的跨度
while (gap > 1)
{
for (int i = 0; i < n - gap; ++i)
{
int end = i;
while (end >= 0)
{
if (a[end] > a[end + gap])
{
Swap(&a[end], &a[end + gap]);
end -= gap;
}
else
{
break;
}
}
}
gap = gap / 3 + 1;
}
//直接插入排序
InsertSort(a, n);
}
时间复杂度:最好情况 O(n) O ( n ) ,最坏情况 O(n2) O ( n 2 ) ,平均情况 O(n1.3) O ( n 1.3 )
空间复杂度: O(1) O ( 1 )
稳定性:稳定
选择排序的思想是很简单的,遍历一遍数组,选出其中最大或最小的数字,把它放到合适的位置上。但这里我们直接对选择排序进行优化,每遍历一次数组,同时找到一个最大值和最小值,然后将两者分别放在它们应该出现的位置,这样遍历的次数就比较少了,效率也略微提高了一点。
选择排序的具体实现是利用两个指针left和right来标记无序元素范围,开始时这两个指针指向数组首尾元素,a[left]~a[right]都是无序的,然后遍历一遍选出最大数和最小数,让最小数和a[left]交换位置,最大数和a[right]交换位置,接着left++、right–,缩小无序元素范围再进行上面操作。
但这里有个点需要注意,如果最大元素本来就是数组首元素即a[left],当最小数和a[left]交换位置后最大元素的位置已经改变了,这就会导致排序错误。
虽然我们对选择排序进行了优化,但要知道的是选择排序几乎是排序算法中最low的一种,在最好的情况下即数组已经有序了,它任然要遍历所有元素。
时间复杂度:最好情况 O(n2) O ( n 2 ) ,最坏情况 O(n2) O ( n 2 ) ,平均情况 O(n2) O ( n 2 ) 。
空间复杂度: O(1) O ( 1 ) 。
稳定性:不稳定。
< code >
void SelectSort(DataType* a, size_t size) //3.选择排序
{
assert(a != NULL);
size_t left = 0;
size_t right = size - 1;
while (left < right)
{
size_t max = left; //开始时都以第一个元素作为基准
size_t min = left;
size_t i = left;
for (i = left; i <= right; ++i)
{
/*找出这一趟中最大的元素和最小的元素*/
if (a[i] > a[max])
{
max = i;
}
if (a[i] < a[min])
{
min = i;
}
}
Swap(&a[left], &a[min]);
/*如果最大的元素是数组的首元素,即a[left] == a[Max],那么Swap(&a[left], &a[min])执行完后,最大值的位置就已经被改变。*/
if (left != max)
{
Swap(&a[right], &a[max]);
}
else
{
max = min;
Swap(&a[right], &a[max]);
}
left++;
right--;
}
}
关于堆排序的时间复杂度:
排序的复杂度是: O(nlogn) O ( n l o g n )
循环 n -1 次,每次都是从根节点往下进行调整,一次调整的时间是 logn l o g n ,总共要调整(n-1)
次总时间: logn(n−1)=nlogn−logn l o g n ( n − 1 ) = n l o g n − l o g n ,最后结果就是 O(nlogn) O ( n l o g n )
初始化建堆过程时间: O(n) O ( n ) 推算过程
< code >
//向下调整法,大堆
void AdjustDown(DataType* a, size_t size, size_t parent)
{
size_t child = parent * 2 + 1;
while (child < size)
{
if (a[child] < a[child + 1] && (child + 1) < size)
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(DataType* a, size_t size) //4.堆排序
{
assert(a != NULL);
//建大堆
int i = (size - 2) >> 1;
for (; i >= 0; --i)
{
AdjustDown(a, size, i);
}
int end = size - 1;
while (end > 0)
{
Swap(&a[end], &a[0]);
AdjustDown(a, end, 0);
end--;
}
}
冒泡排序算是这几种排序中最简单的了,设有数组a[N](排升序):
第一趟:从a[0]~a[N-1]中选出一个最大数放到当前范围的最后一个位置a[N-1]处。
第二趟:从a[0]~a[N-2]中选出一个最大数放到当前范围的最后一个位置a[N-2]处。
第三趟:从a[0]~a[N-3]中选出一个最大数放到当前范围的最后一个位置a[N-3]处。
……
同样冒泡排序也是可以进行优化的。设置一个标志符flag,flag初始值是0,如果在一趟遍历中有出现元素交换的情况,那就把flag置为1。一趟冒泡结束后,检查flag的值。如果flag依旧是0,那这一趟就没有元素交换位置,也就是说数组此时已经是有序的了,循环结束。这样可以减少外层循环的次数,提高了效率。
上面我们已经利用了flag标志优化了外层循环,此时如还想再优化只能从内层循环入手了。我们知道,内存循环每循环一趟,就能确定一个元素的正确位置,但对于 5 1 3 4 2 6 7 8 9
这样的序列,我们要冒泡四趟才能确定 6 7 8 9
这四个元素的顺序,但其实他们本就是有序的了,我们可以用一趟冒泡就确定这四个元素的的顺序。
定义一个变量k,用来记录每趟循环最后发生交换的元素的下标(它后面的元素没有进行交换,就表示已经有序了),下一趟冒泡只需对k之前的元素进行冒泡,进一步提高了排序效率。
时间复杂度:最好情况 O(n) O ( n ) ,最坏情况 O(n2) O ( n 2 ) ,平均情况 O(n2) O ( n 2 ) 。
空间复杂度: O(1) O ( 1 ) 。
稳定性:稳定。
< code >
void BubbleSort(DataType* a, size_t size) //5.冒泡排序
{
if (NULL == a || size < 2)
return;
size_t k = size - 1; //记录一趟冒泡最后进行交换的元素的下标
size_t m = 0;
size_t i = 0;
size_t j = 0;
size_t flag = 0;
for (i = 0; i < size - 1; ++i)
{
flag = 0;
for (j = 0; j < k; ++j)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
flag = 1;
m = j;
}
}
if (0 == flag)
break;
k = m;
}
}
快速排序可以说是这里面最难理解的算法之一了,它的总思想是——分治。主要步骤是:
1. 从数组中选取一个数key作为基准。
2. 分区,把比key小的数放到它左边,把比key大的数放到它右边。
3. 再对左右区间进行第二步操作,直到区间中只有一个元素。
三个步骤看起来清楚明了,看似复杂的第三步其实只要用递归就能完成,那第二步分区又该如何实现呢?下面介绍分区的三种实现方法(三种方法复杂度一样)。
时间复杂度:最好情况: O(nlgn) O ( n l g n ) ,最坏情况: O(n2) O ( n 2 ) ,平均情况: O(nlgn) O ( n l g n )
空间复杂度: O(lgn) O ( l g n )
稳定性:不稳定
ps:默认数组尾元素为基准key
设数组a[N],left和right是数组的首、尾元素下标。
< code >
DataType Part1Sort(DataType* a, size_t left, size_t right)//方法一:左右指针法
{
assert(a != NULL);
size_t start = left;
size_t end = right;
DataType key = a[right]; //选取数组尾元素为基准
while (start < end)
{
while (start < end && a[start] <= key) //找到第一个大于key的数
{
start++;
}
while (start < end && a[end] >= key) //找到第一个小于key的数
{
end--;
}
Swap(&a[start], &a[end]);
}
Swap(&a[start], &a[right]);
return start;
}
设数组a[N],left和right是数组的首、尾元素下标。
把数组尾元素赋值给基准key,此时数组最后一个位置a[right]就相当于一个‘坑’。
< code >
DataType Part2Sort(DataType* a, size_t left, size_t right) //方法二:挖坑法
{
assert(a != NULL);
size_t start = left;
size_t end = right;
DataType key = a[right];
while (start < end)
{
while (start < end && a[start] <= key)
{
start++;
}
a[end] = a[start];
while (start < end && a[end] >= key)
{
end--;
}
a[start] = a[end];
}
a[start] = key;
return start;
}
写不动了,这里就直接上代码。
< code >
DataType Part3Sort(DataType* a, int left, int right) //方法三:前后指针法
{
assert(a != NULL);
DataType cur = left;
DataType prev = left - 1;
DataType key = a[right];
// 简洁写法
//while (cur < right)
//{
// if (a[cur] <= key && a[cur] != a[++prev])
// {
// Swap(&a[cur], &a[prev]);
// }
// cur++;
//}
//Swap(&a[++prev], &a[cur]);
//return prev;
// 基础写法
while (cur < right)
{
if (a[cur] <= key)
{
++prev;
if (cur != prev)
{
Swap(&a[prev], &a[cur]);
}
}
++cur;
}
++prev;
Swap(&a[prev], &a[cur]);
return prev;
}
快速排序的效率和基准key的值相关,如果key选的好,(每次key恰好是中间数),那么算法的时间复杂度就是 O(nlogn) O ( n l o g n ) ,但如果选的不好(每次key恰好是最大值或者最小值),那么算法的时间复杂度就是 O(n2) O ( n 2 ) 。上面我们都是选数组尾元素作为基准,如果尾元素恰好是最大值或最小值,那就很坑了。所以这里必须优化key的选取方法。
首先声明一下,本人觉得这种方法效果并不大。
这里顾名思义就是从数组中随机选取一个值作为key(但这个随机值还可能是最大值或最小值啊)。
简单写一下代码:
sand(time(0));
size_t index = rand()%(right - left + 1);
size_t key = a[index];
重头戏来了,这里的三数取中法是如何实现的呢?
选取数组的首元素、尾元素以及中间元素(注意这里是中间元素,不是中位数),选出三者的中位数作为key值。这时候选出来的key可以保证绝对不是最大数或最小数(如果三个数都是相同的,那key还是最大或最小值,不过出现这种情况概率及其低)。
注意:我们找出中位数作为key值后,把这个数和数组尾元素交换一下位置。
下面放上左右指针法优化了选取key值后的代码。
< code >
int GetMidNumber(DataType*a, size_t left, size_t right) //返回三个数的中间数的下标
{
DataType mid = left + ((right - left) >> 1);
// 1<2<3 || 3<2<1
if ((a[left] < a[mid] && a[mid] < a[right])
|| (a[mid] > a[right] && a[left] > a[mid]))
{
return mid;
}
//2<3<1 || 2<1<3
else if ((a[left] > a[right] && a[left] < a[mid])
|| (a[left] > a[mid] && a[left] < a[right]))
{
return left;
}
//1<3>2 || 3>1<2
else
{
return right;
}
}
DataType Part1Sort(DataType* a, size_t left, size_t right)//方法一:左右指针法
{
assert(a != NULL);
size_t start = left;
size_t end = right;
DataType MidIndex = GetMidNumber(a, left, right); //获得中间数下标
DataType key = a[MidIndex];
Swap(&a[MidIndex], &a[right]); //把中间数和最后一个数交换位置,这样下面的代码就不需要更改
while (start < end)
{
while (start < end && a[start] <= key) //找到第一个大于key的数
{
start++;
}
while (start < end && a[end] >= key) //找到第一个小于key的数
{
end--;
}
Swap(&a[start], &a[end]);
}
Swap(&a[start], &a[right]);
return start;
}
切割区间时,当区间内元素数量比较少时就不用切割区间了(递归太深影响效率),这时候就直接对这个区间采用直接插入法,可以进一步提高算法效率。
void QuickSortNR(int* a, int left, int right)//快排,非递归
{
assert(a != NULL);
Stack s;
StackInit(&s);
StackPush(&s, left);
StackPush(&s, right);
while (StackEmpty(&s) != 0)//栈不为空
{
int end = StackTop(&s);
StackPop(&s);
int start = StackTop(&s);
StackPop(&s);
int div = GetMidNumber(a, start, end);
if (start< div-1)
{
StackPush(&s, start);
StackPush(&s, div - 1);
}
if (end > div + 1)
{
StackPush(&s, div + 1);
StackPush(&s, end);
}
}
}
时间复杂度:最好情况: O(nlgn) O ( n l g n ) ,最坏情况: O(nlgn) O ( n l g n ) ,平均情况: O(nlgn) O ( n l g n )
空间复杂度: O(N) O ( N )
稳定性: 稳定
< code >
void _MergeSort(DataType* a, size_t left, size_t mid, size_t right)
{
DataType* tmp = (DataType*)malloc(sizeof(int)*(right - left + 1));
assert(tmp != NULL);
memset(tmp, 0, sizeof(int)*(right - left + 1));
size_t index = 0; //tmp的初始下标
size_t start1 = left; //第一个数组的范围
size_t end1 = mid;
size_t start2 = mid + 1; //第二个数组的范围
size_t end2 = right;
while (start1 <= end1 && start2 <= end2) //把两个数组的元素逐一比较,选出较小的放到tmp中
{
if (a[start1] <= a[start2])
{
tmp[index++] = a[start1++];
}
else
{
tmp[index++] = a[start2++];
}
}
/*如果数组1或者数组2中还有元素,就直接复制到tmp中*/
if (start1 <= end1)
{
while (start1 <= end1)
{
tmp[index++] = a[start1++];
}
}
else
{
while (start2 <= end2)
{
tmp[index++] = a[start2++];
}
}
/*最后把数组tmp中元素全部拷贝到数组a中*/
for (index = 0; index < right - left + 1; index++)
{
a[left + index] = tmp[index];
}
free(tmp);
}
void MergeSort(DataType* a, size_t left, size_t right) //7.归并排序
{
assert(a != NULL);
if (left >= right)
{
return;
}
if (right - left + 1 > 5) //小区间优化法
{
size_t mid = left + ((right - left) >> 1);
MergeSort(a, left, mid);
MergeSort(a, mid + 1, right);
_MergeSort(a, left, mid, right);
}
else
{
InsertSort(a + left, right - left + 1);
}
}
计数排序算法的原理跟哈希表的K-V模型比较相似
①遍历一遍数组,得出数组的范围range,创建一个大小为range的数组,即哈希表,初始化为全0。
②再从头开始遍历数组,数字重复出现一次,在其相应的位置对应的数值加1。
③从左到右开始遍历哈希表,将数值不为0的位置的下标存储到原数组中,且数值是多少就存储多少个 。
PS:计数排序会去除重复的元素。
类别 | 直接插入排序 | 希尔排序 | 选择排序 | 堆排序 | 冒泡排序 | 快速排序 | 归并排序 |
---|---|---|---|---|---|---|---|
平均时间复杂度 | O(n2) O ( n 2 ) | O(n1.3) O ( n 1.3 ) | O(n2) O ( n 2 ) | O(nlgn) O ( n l g n ) | O(n2) O ( n 2 ) | O(nlgn) O ( n l g n ) | O(nlgn) O ( n l g n ) |
最好时间复杂度 | O(n) O ( n ) | O(n) O ( n ) | O(n2) O ( n 2 ) | O(nlgn) O ( n l g n ) | O(n) O ( n ) | O(nlgn) O ( n l g n ) | O(nlgn) O ( n l g n ) |
最坏时间复杂度 | O(n2) O ( n 2 ) | O(n2) O ( n 2 ) | O(n2) O ( n 2 ) | O(nlgn) O ( n l g n ) | O(n2) O ( n 2 ) | O(n2) O ( n 2 ) | O(nlgn) O ( n l g n ) |
空间复杂度 | O(1) O ( 1 ) | O(1) O ( 1 ) | O(1) O ( 1 ) | O(1) O ( 1 ) | O(1) O ( 1 ) | O(lgn) O ( l g n ) | O(n) O ( n ) |
稳定性 | 稳定 | 不稳定 | 不稳定 | 不稳定 | 稳定 | 不稳定 | 稳定 |