经典七大比较排序算法 ·上
- 1 选择排序
- 1.1 算法思想
- 1.2 代码实现
- 1.3 选择排序特性
- 2 冒泡排序
- 2.1 算法思想
- 2.2 代码实现
- 2.3 冒泡排序特性
- 3 堆排序
- 3.1 堆排序特性:
- 4 快速排序
- 4.1 算法思想
- 4.2 代码实现
- 4.3 快速排序特性
- 5 归并排序
- 5.1 算法思想
- 5.2 代码实现
- 5.3 归并排序特性
1 选择排序
1.1 算法思想
选择排序,每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
大致操作步骤如下:
- 第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置;
- 然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾;
- 重复第二步操作,直到全部待排序的数据元素的个数为零。
选择排序视频演示
1.2 代码实现
选择排序还是很好理解的。但这里的实现会对直接选择排序做一些小的改进。
既然直接选择排序遍历一遍可以找出最小的数据,那遍历一遍同样可以找出最大的元素。这样每遍历一次,待排序数据元素的起始位置就变成最小的了,结束位置就变成最大的了。
参考代码如下:
//n - 数据个数
void SelectSort(int* a, int n)
{
int begin = 0; //待排序数据起始位置下标
int end = n - 1;//待排序数据结束位置下标
int minIndex = 0;//记录最小数据的下标
int maxIndex = 0;//记录最大数据的下标
//待排序数据大于1时进入循环
while (begin < end)
{
//假设一开始最小元素和最大元素下标都是第一个待排序数据下标
minIndex = begin;
maxIndex = begin;
//遍历一遍
for (int i = begin + 1; i <= end; ++i)
{
//选出最小元素下标
if (a[i] < a[minIndex])
{
minIndex = i;
}
//选出最大元素下标
if (a[i] > a[maxIndex])
{
maxIndex = i;
}
}
//将最小元素和待排序序列的起始位置交换
Swap(&a[begin], &a[minIndex]);
//将最大元素和待排序序列的结束位置交换
Swap(&a[end], &a[maxIndex]);
//缩小待排序区间,重复上述过程
++begin;
--end;
}
}
很多老铁在敲完上面代码之后,觉得已经没有问题了。
但实际上,像遇到上面情况。如果[maxIndex]指向的是[begin]中存放的内容,[minIndex]的内容先和[begin]交换之后,[maxIndex]指向的就不在是待排序序列中的最大元素了。需要做相应的修改。
Swap(&a[begin], &a[minIndex]);
//如果begin和maxIndex重叠,那么修正maxInedx
if (begin == maxIndex)
{
maxIndex = minIndex;
}
Swap(&a[end], &a[maxIndex]);
1.3 选择排序特性
选择排序作为一种简单直观的排序算法,虽然好理解,但效率并不高。
无论是在面对什么样的排序序列,时间复杂度都是稳定的
O
(
n
2
)
O(n^2)
O(n2)量级,实际中很少使用。
2 冒泡排序
2.1 算法思想
冒泡排序作为一种交换排序,它会重复地走访要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到不再需要交换,也就是说该数列已经排序完成。
大致操作步骤如下:
- 比较相邻的两个元素。如果第一个和第二个大元素的顺序不符合要求,就交换他们两个。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
冒泡排序视频演示
2.2 代码实现
//n - 数据个数
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
for (int j = 0; j < n - 1 - i; ++j)
{
//排升序
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
}
}
}
}
冒泡排序还有一种优化的方法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序,不需要再继续进行冒泡排序了。
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
int flag = 1;
for (int j = 0; j < n - 1 - i; ++j)
{
if (a[j] > a[j + 1])
{
flag = 0;
Swap(&a[j], &a[j + 1]);
}
}
if (1 == flag)
{
break;
}
}
}
但这种改进优化在实际上对于提升性能来说并没有什么作用并不是很大。
2.3 冒泡排序特性
冒泡排序也是一种非常简单直观的排序。
在最好的情况下(序列接近有序),冒泡排序的时间复杂度是
O
(
n
)
O(n)
O(n)量级的;
在最坏的情况下(序列逆序),冒泡排序的时间复杂度是
O
(
n
2
)
O(n^2)
O(n2)量级的。
3 堆排序
关于堆排序的算法思想和代码实现相关内容,可以参考阿顺的堆结构的两个应用这篇博客,里面有比较详细的介绍。这里就不在赘述了。
3.1 堆排序特性:
堆排序使用堆的结构来选数,效率就高了很多。
堆排序的平均时间复杂度是
O
(
n
∗
l
o
g
2
n
)
O(n*log_2n)
O(n∗log2n)量级的。
4 快速排序
4.1 算法思想
快速排序是Hoare提出的一种二叉树结构的交换排序方法。
其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两个子序列,其中左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
算法步骤:
- 从序列中挑出一个元素作为基准
- 排序数列,将所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆放在基准后面(相同的数可以在任一边)。在这个分区排完之后,该基准就处于数列的中间位置
- 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序
快速排序视频演示
4.2 代码实现
快速排序在发展的过程中涌现出了很多种不同的版本,但核心思想是不会变的。这里会介绍3种常见版本,以供大家参考。
//a - 待排序序列
//begin - 待排序序列起始位置
//end - 待排序序列结束位置
void QuickSort(int* a, int begin, int end)
{
//序列只有一个值或没有值就不再排序
if (begin >= end)
{
return;
}
//对区间进行快速排序,并返回排序后的基准值位置
int keyIndex = QuickPartSort(a, begin, end);
//[begin, keyIndex - 1] keyIndex [keyIndex + 1, end]
//左区间排序
QuickSort(a, begin, keyIndex - 1);
//右区间排序
QuickSort(a, keyIndex + 1, end);
}
上面代码就是快速排序实现的主框架,QuickPartSort
函数有不同的实现方式。
其实从主框架上可以看出快速排序的递归实现和二叉树的前序遍历还是很像的。
- hoare版本
//以排升序为例
int QuickPartSort1(int* a, int begin, int end)
{
int left = begin;
int right = end;
//取序列第1个数据做基准值
int keyIndex = left;
while (left < right)
{
//右边先走,找小
while (left < right && a[right] >= a[keyIndex])
{
--right;
}
//左边再走,找大
while (left < right && a[left] <= a[keyIndex])
{
++left;
}
//左大右小-交换
Swap(&a[left], &a[right]);
}
//left和right相遇,此时将基准值和相遇值交换
Swap(&a[keyIndex], &a[left]);
//基准值下标跟着改变
keyIndex = left;
return keyIndex;
}
观察hoare版本的代码发现,选择的是最左边的值做基准值。此时,却是让右边的索引先走,那能不能让左边的值先走呢?又为什么选择右边的值先走呢?
答案是左边的值先走也可以,但是处理起来细节上更复杂一些。
选择右边先走的原因是为了保证相遇位置的值,比基准值小(或者就是基准位置的值)。
右边先走的情况无非就是:
right
先走,right
遇到比基准值小的,right
停下来,让left
走,left
去遇到了right
。
这种情况相遇位置就是right
停下来的位置,right
停的位置也就是比基准值要小的位置。right
先走,right
没有遇到比基准值要小的值,right
去遇到了left
。
这种情况下,相遇位置是left
上一轮停下来的位置,该位置要么是基准值的位置,要么就是比基准值小的位置。
这时,反过来想一下,如果选最左边为基准值,left
先走的话,相遇位置的值就是大于基准值的,这样相遇位置的值就不能和基准值交换,这里需要做一些处理,来让基准值交换到合适的位置去,最终徒劳增加了麻烦。
所以,一般建议是:
左边做基准值,让右边先走;
右边做基准值,让左边先走。
- 挖坑版本
在面对上面hoare版本比较不好理解的左边做基准值,右边先走的问题时,挖坑法的版本能让快速排序更好理解一点点。
int QuickPartSort2(int* a, int begin, int end)
{
//最左边做基准值
int key = a[begin];
//坑位给到基准值位置
int pitIndex = begin;
int left = begin;
int right = end;
while (left < right)
{
//右边找小,填到左边的坑里面去。这个位置形成新的坑。
while (left < right && a[right] >= key)
{
--right;
}
a[pitIndex] = a[right];
pitIndex = right;
//左边找大,填到右边的坑里面去。这个位置形成新的坑。
while (left < right && a[right] <= key)
{
++left;
}
a[pitIndex] = a[left];
pitIndex = left;
}
//坑位再填上基准值
a[pitIndex] = key;
return pitIndex;
}
因为坑位的存在,左边有坑,右边就走,然后填坑;右边有坑,左边就走,然后填坑。这个过程就更好理解了。但是挖坑法在算法性能上相比hoare版本并没有什么大的改进。
3. 前后指针版本
前后指针的使用和前两个版本的排序方式就有较大的区别了。
int QuickPartSort3(int* a, int begin, int end)
{
//基准值选择
int keyIndex = begin;
int prev = begin;//前指针
int cur = begin + 1;//后指针
while (cur <= end)
{
//cur位置的值小于keyIndex位置的值
if (a[cur] < a[keyIndex])
{
++prev;
Swap(&a[prev], &a[cur]);
}
++cur;
}
Swap(&a[prev], &a[keyIndex]);
keyIndex = prev;
return keyIndex;
}
前后指针方法中两个指针都是从左向右的方向在走,直到cur
走完整个序列。
但是在走的过程中,prev
的位置+1后,如果和cur
位置相等,因为指向同一个位置,就不需要交换了,代码进一步优化如下:
if (a[cur] < a[keyIndex] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
前后指针版本不仅好理解,而且代码也很简洁,是几个版本中最推荐的了。
但是,以上方法选基准值还是存在一定问题的。
鉴于以上几个版本都选择的是最左边作为基准值,如果是排已经有序的序列的话,如下图所示:
序列有多少的数据就递归多少次,很容易栈溢出。
所以,为了让每次选择基准值避免选择到序列中的最值,有的地方给出用随机数的方法选择基准值,但这里主要介绍三数取中选择基准值的方法。
所谓三数取中,不过是在序列的起止位置,中间位置,末位位置选择这三个数出来,通过比较,找到不是最大也不是最小的一个数,让这个数做基准值。
int GetMidIndex(int* a, int begin, int end)
{
int mid = begin + (end - begin) / 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//begin>=mid
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
找到基准值之后,可以将基准值和序列的起始位置进行一个交换,这样就和之前排序的代码一样仍是选择序列的起始位置做基准值了。代码也不需要更多的修改了。
//三数取中优化
int mid = GetMidIndex(a, begin, end);
Swap(&a[keyIndex], &a[mid]);
因为三数取中的优化,使的每次递归左右子序列更加的二分,代码效率自然就得到了提高。
当然,我们知道递归需要是创建销毁和销毁栈帧的(具体参考阿顺的这篇博文你也能看懂的的函数栈帧创建与销毁)
因为快速排序的递归和二叉树前序递归很像,如果快速递归的划分接近二分的话,就会像满二叉树的遍历一样,最后一层的数量(
2
h
−
1
2^{h-1}
2h−1)会是整棵树数量(
2
h
−
1
2^h-1
2h−1)的一半,也就是快速排序最后一层的递归次数是整个过程递归次数的一半。在面对小数据量时,还要递归这么多次,进行栈帧的开销,确实会影响到程序的效率。
所以,可以对小区间进行优化:当递归划分小区间,区间比较小的时候,就不再递归划分去排序这个小区间。而是可以考虑直接用其它排序对小区间进行处理。
这里就给出对小区间元素小于10时的插入排序处理。
if (end - begin > 10)
{
//int keyIndex = PartSort1(a, begin, end);
//int keyIndex = PartSort2(a, begin, end);
int keyIndex = QuickPartSort3(a, begin, end);
QuickSort(a, begin, keyIndex - 1);
QuickSort(a, keyIndex + 1, end);
}
else
{
InsertSort(a + begin, end - begin + 1);
}
通过计算可以得出,以上代码在采用小区间优化后,函数调用次数可以减少
80
%
80\%
80%左右,这种提升还是很明显的。
以上都是对快速排序的递归版本的介绍。但是递归有一个死穴就是递归太深,栈溢出,程序会崩溃。所以,也要能够对快速排序进行非递归的实现。
可以借助栈数据结构或者队列数据结构进行非递归实现。这里就选择栈数据结构来进行实现。借助栈来实现的运行逻辑是可以和递归实现的递归逻辑达成一致的。
因为是C语言实现,所以对于栈数据结构的需要可以参考阿顺的这篇博文顺序表实现栈(C语言)
void QuickSortNonR(int* a, int begin, int end)
{
Sk stack;
StackInit(&stack);
StackPush(&stack, end);
StackPush(&stack, begin);
while (!StackEmpty(&stack))
{
int left = StackTop(&stack);
StackPop(&stack);
int right = StackTop(&stack);
StackPop(&stack);
int keyIndex = QuickPartSort3(a, left, right);
// [left, keyIndex-1] keyIndex [keyIndex+1, right]
if (keyIndex + 1 < right)
{
StackPush(&stack, right);
StackPush(&stack, keyIndex + 1);
}
if (left < keyIndex - 1)
{
StackPush(&stack, keyIndex - 1);
StackPush(&stack, left);
}
}
StackDestroy(&stack);
}
代码结合画图就很容易理解快速排序的非递归了。
4.3 快速排序特性
快速排序,一听到这个名字你就知道它存在的意义l。而且快速排序整体的综合性能和使用场景都是比较好的,所以也才敢叫快速排序。
快速排序的一次划分算法从两头交替搜索,直到left和right重合,因此其时间复杂度是
O
(
n
)
O(n)
O(n);而整个快速排序算法的时间复杂度与划分的趟数有关。
理想的情况是,每次划分所选择的中间数恰好将当前序列几乎等分,经过
l
o
g
2
n
log_2n
log2n趟划分,便可得到长度为1的子表。这样,整个算法的时间复杂度为
O
(
n
∗
l
o
g
2
n
)
O(n*log_2n)
O(n∗log2n)。
最坏的情况是,每次所选的中间数是当前序列中的最大或最小元素,这使得每次划分所得的子表中一个为空表,另一子表的长度为原表的长度-1。这样,长度为n的数据表的快速排序需要经过n趟划分,使得整个排序算法的时间复杂度为
O
(
n
2
)
O(n2)
O(n2)。
从空间性能上看,尽管快速排序只需要一个元素的辅助空间,但快速排序需要一个栈空间来实现递归。最好的情况下,即快速排序的每一趟排序都将元素序列均匀地分割成长度相近的两个子表,所需栈的最大深度为
l
o
g
2
(
n
+
1
)
log_2{(n+1)}
log2(n+1);但最坏的情况下,栈的最大深度为n。
5 归并排序
5.1 算法思想
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序。若将两个有序表合并成一个有序表,称为二路归并。
算法步骤:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
- 设定两个指针,最初位置分别指向两个已经排序序列的起始位置;
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
- 重复步骤 3 直到某一指针到达序列尾;
- 将另一序列剩下的所有元素直接复制到合并序列尾。
归并排序视频演示
5.2 代码实现
作为一种典型的分而治之思想的算法应用,归并排序的实现有两种方法:
- 自上而下的递归;
//n - 数据个数
void MergeSort(int* a, int n)
{
//开辟归并空间
int* tmp = (int*)malloc(n * sizeof(int));
assert(tmp != NULL);
//归并排序
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
void _MergeSort(int* a, int begin, int end, int* tmp)
{
//递归区间不足1个数据就不再递归
//因为1个数据即有序
if (begin >= end)
{
return;
}
int mid = begin + (end - begin) / 2;
// [begin, mid][mid+1, end] 分治递归,让子区间有序
//左区间递归
_MergeSort(a, begin, mid, tmp);
//又去见递归
_MergeSort(a, mid+1, end, tmp);
//归并过程
int begin1 = begin;
int end1 = mid;
int begin2 = mid + 1;
int 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));
}
- 自下而上的迭代。
所谓自下而上的迭代,不过是最开始从1个和1个开始归并(因为1个数据即有序)。
之后每次归并数据都扩2倍,直到最后一组的数据已经大于等于整个序列的数据为止就完成归并了。可以用循环来解决。
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(n * sizeof(int));
assert(tmp != NULL);
//最开始,一组只有一个数据
int gap = 1;
//一组的数据要小于序列长度
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
//[i,i+gap-1]和[i+gap, i+2*gap-1]区间的数据进行归并
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 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, tmp, n * sizeof(int));
//一组数据扩2倍
gap *= 2;
}
free(tmp);
}
但是上面代码有一个致命的bug就是,只能适用于
2
n
2^n
2n个数据的归并。否则就存在越界的错误。
所以需要对归并区间进行界线的判断和修正。
因为begin1=i
不会越界,而end1
begin2
end2
都可能越界。
所以存在三种情况下的修正:
//修正边界
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n - 1;
}
上述是修正的一种方法。还可以采用更省事的策略:在遇到越界的区间时,就不再归并了。
if (end1 >= n || begin1 >= n)
{
break;
}
else if (end2 >= n)
{
end2 = n - 1;
}
但这种方法,需要每归并一次,就拷贝一次,而不是等到一趟归并完在拷贝。memcpy
函数应该放在for
循环之内。
5.3 归并排序特性
归并排序的时间复杂度是稳定的
O
(
n
∗
l
o
g
2
n
)
O(n*log_2n)
O(n∗log2n),而因为要开辟额外的归并数组,所以空间复杂度是
O
(
n
)
O(n)
O(n)。
归并排序速度仅次于快速排序,一般用于对总体无序,但是各子项相对有序的数列排序。
归并排序的比较次数小于快速排序的比较次数,移动次数一般多于快速排序的移动次数。
虽然归并排序比较占用内存,但却是一种效率高且稳定的算法。