【数据结构与算法】万字剖析八大排序(直接插入排序、希尔排序、选择排序、堆排序、冒泡排序、快速排序、归并排序、计数排序)
目录
一.直接插入排序二.希尔排序三.选择排序四.堆排序五.冒泡排序六.快速排序1.hoare版2.挖坑法3.前后指针4.选取基准值的优化(1)快速排序非递归
七.归并排序(2)归并排序非递归
八.计数排序九.八大排序稳定性分析
一.直接插入排序
初窥直接插入排序我们先来看一张动图: 由动图我们可以分析出直接插入排序是从第二个数据开始遍历,与前面的数据进行比较如果小于 则让前面的数据向前移动 自己接着向前面的数据比较 直到比较到大于等于自己的数据或者没有数据能进行比较时停止 插入当前的位置。
直接插入排序性能分析: 时间复杂度:O(N^2),最好情况当数据本来就有序时可以做到O(N); 空间复杂度:直接插入排序并没再开过一段连续的空间所以为O(1);
完整代码如下,我们看到while循环中的判断即是让tmp小于的数据 不断向前覆盖 直到找到小于tmp的数据 或者 end小于0(前面没有可比较的数据)时 跳出循环将数据插入。
void InsertSort(int* a, int size)
{
assert(a);
for (int i = 0; i < size - 1; i++)
{
int tmp = a[i + 1];
int end = i;
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
二.希尔排序
希尔排序又称缩小增量排序,它选定了一个整数gap将待排数据分组,然后对组内的数据进行直接插入排序,接着减小gap 改变分组的区间 再进行排序。此过程称为预排序。最后gap减小到1时,数据已经接近有序,再对数据进行直接插入的排序效率则较高。动图演示如下:
希尔排序性能分析: 时间复杂度:最坏O(N^ 2) ,平均在<数据结构>严蔚敏中给出O(N^1.3); 空间复杂度:O(1);
完整代码如下,如有疑问随时私信或者在评论区提出。
void ShellSort(int* a, int size)
{
assert(a);
int gap = size;
while (gap > 1)
{
gap /= 2;//使得gap最后能减到1 成为直接插入排序
//gap /= 3 + 1;
for (int i = 0; i < size - gap; i += gap)
{
int end = i;
int tmp = a[i + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
三.选择排序
选择排序在八大排序中属于是最“老实的”一个排序,其基本思想 首先选出一个关键值 一般是数据的第一位 接着遍历剩下的数据 记录剩下数据中最小值和最大值的下标。接着将最小值放到左边 最大值放到右边 接着减小需要排序的区间(因为已经排一个最大值和一个最小值了) 当前代码逻辑实现如下:
void SelectSort(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int mini = left, maxi = left;
for (int i = left + 1; i <= right; i++)
{
if (a[mini] > a[i])
{
mini = i;
}
if (a[maxi] < a[i])
{
maxi = i;
}
}
Swap(&a[left], &a[mini]);
Swap(&a[right], &a[maxi]);
--right;
++left;
}
}
将我测试代码时发现,有些情况,上面的代码无法处理。 这是为什么了,我们调试观察到: 此时关键值为a[3] 6 ,在下标区间4到6遍历后max仍然是6,min是3。接着min与a[left]交换即: 在后续最大值与a[right]交换时,原本应该放着最大值的a[left]已经被换成了最小值,所以交换会发生错误。也就是left与maxi重叠时,需要将maxi赋为mini才不会发生错误。
选择排序性能: 时间复杂度:O(N^2); 空间复杂度:O(1);
正确代码如下:
void SelectSort(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int mini = left, maxi = left;
for (int i = left + 1; i <= right; i++)
{
if (a[mini] > a[i])
{
mini = i;
}
if (a[maxi] < a[i])
{
maxi = i;
}
}
Swap(&a[left], &a[mini]);
if (left == maxi)//防止left与maxi重叠
{
maxi = mini;
}
Swap(&a[right], &a[maxi]);
--right;
++left;
}
}
四.堆排序
堆排序在之前我就写过文章讲解,这里就不赘述了。需要注意的是:排升序、建大堆,排降序、建小堆文章堆和堆排序的链接
五.冒泡排序
冒泡排序在学习C语言时就已经学过,所以实现它轻而易举,代码如下:
选择排序性能: 时间复杂度:O(N^2); 空间复杂度:O(1);
void BubbleSort(int* a, int size)
{
for (int i = 0; i < size - 1; i++)
{
for (int j = 1; j < size - i; j++)
{
if (a[j - 1] > a[j])
{
Swap(&a[j - 1], &a[j]);
}
}
}
}
六.快速排序
1.hoare版
快速排序顾名思义它的效率较高,是由Hoare于1962年提出的一种二叉树结构的交换排序方法。它的思想是选定一个基准值,将小于基准值的数据放到左边,大于的放到右边。接着在分别递归左右区间。
快速排序性能: 时间复杂度:O(N*logN)最坏O(N^2) 空间复杂度:O(1);
此版本需要注意的是如果将左边第一个作为基准值就需要让右边先走,需要小于基准值的数据,相反就让左边先走。
void QuickSort11(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left;
int end = right;
int keyi = left;
while (left < right)
{
while (left < right && a[keyi] <= a[right])
--right;
while (left < right && a[keyi] >= a[left])
++left;
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
QuickSort11(a, begin, keyi - 1);
QuickSort11(a, keyi + 1, end);
}
2.挖坑法
挖坑法的优化不明显,排序思想也与原先的差不多。 代码如下,供大家理解:
void QuickSort22(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left;
int end = right;
int hole = left;
int key = a[left];
while (left < right)
{
while (left < right && key <= a[right])
--right;
a[hole] = a[right];
hole = right;
while (left < right && key >= a[left])
++left;
a[hole] = a[left];
hole = left;
}
a[hole] = key;
QuickSort22(a, begin, hole - 1);
QuickSort22(a, hole + 1, end);
}
3.前后指针
前后指针是快速排序的第三个版本,它的做法就非常的妙: 完整代码如下:
void QuickSort33(int* a, int left, int right)
{
if (left >= right)
return;
int begin = left;
int end = right;
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[keyi], &a[prev]);
QuickSort33(a, begin, prev - 1);
QuickSort33(a, prev + 1, end);
}
4.选取基准值的优化
我们知道快速排序的效率是很高的,特别是处理无序的数据时,但是当数组为有序时,时间复杂度就会下降到O(N^2),这是因为我们总是将第一个数据作为基准值导致的。这里就有个优化的方法———三数取中———它将比较mid left right的数据,取中值作为基准值,使得即使数据有序,效率也不会太差。
int GetMidNumi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] > a[mid])
{
if (a[mid] > a[right])
return mid;
else if (a[left] < a[right])
return left;
else
return right;
}
else
{
if (a[mid] < a[right])
return mid;
else if (a[right] < a[left])
return left;
else
return right;
}
}
(1)快速排序非递归
上文快速排序的三个版本都使用了递归,当数据量过大,建立的栈帧空间过多时,容易产生栈溢出,导致错误。所以我们要学习 将代码从递归改为非递归避免错误 是以后我们工作的必备技能。 快速排序的非递归需要使用到栈 使用栈辅助改为循环。 如何利用栈将快排改为循环呢?我们将数组左右下标压入栈,为了出栈顺序为先左后右,我们则要先压右下标,再压左下标。然后当栈非空时就 继续弹出区间给快排函数排序,当左右区间数据少于1时停止压栈。
void QuickSortNon(int* a, int left, int right)
{
ST QS;
InitST(&QS);
Push(&QS, right);
Push(&QS, left);
while (!STEmpty(&QS))
{
int begin = STTop(&QS);
Pop(&QS);
int end = STTop(&QS);
Pop(&QS);
int key = QuickSort3(a, begin, end);
if (begin < key - 1)
{
Push(&QS, key - 1);
Push(&QS, begin);
}
if (key + 1 < end)
{
Push(&QS, end);
Push(&QS, key + 1);
}
}
DestroyST(&QS);
}
七.归并排序
归并排序采用了分治的算法思想,将数据分成两个区间不断递归到两个区间有序后,将两个区间内的数据排到新malloc的辅助空间中。 演示动图如下:
归并排序性能: 时间复杂度:O(N*logN); 空间复杂度:O(N);归并排序需要的辅助空间较多,所以常用于解决磁盘的外排序问题。
完整代码如下:
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = (left + right) / 2;
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid+1, right, tmp);
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
tmp[i++] = a[begin2++];
}
else
{
tmp[i++] = a[begin1++];
}
}
while(begin1 <= end1)//前面两个区间中还有多余没有拿下来的数据,一并排进去
tmp[i++] = a[begin1++];
while (begin2 <= end1)
tmp[i++] = a[begin2++];
memcpy(a + left, tmp+left, sizeof(int)*(right-left+1));
}
(2)归并排序非递归
由上面的归并排序代码我们可知,排序开始先找到mid 将数据分为两个区间,接着不断递归到区间内只有一个数据也就是有序了然后与右区间的数据进行比较排进辅助空间中。 那我们可不可以不通过递归 控制数据区间有序呢?可以直接定义gap控制一区间内的数据个数,gap初值为1,也就达到了区间有序 控制如下:
int gap=1;
while (gap < size)
{
gap *= 2;
}
而且非递归版本不想递归有mid,便于控制区间,其区间的控制需要理解:
int gap = 1;
while (gap < size)
{
for (int i = 0; i < size; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;//区间控制
int begin2 = i + gap, end2 = i + 2 * gap - 1;//好好体会
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
tmp[j++] = a[begin2++];
}
else
{
tmp[j++] = a[begin1++];
}
}
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
memcpy(a, tmp, sizeof(int) * (end2-1+1));
}
到这里我们就不免有个疑问,不同于上图所示,如果数据为奇数,不能正好的分区间,那该怎么办呢? 我们则需要在区间控制后,再判断一下区间是否越界以及相关的处理。
int begin1 = i, end1 = i + gap - 1;//区间控制
int begin2 = i + gap, end2 = i + 2 * gap - 1;
区间如上所示,又因为i是0到size-1的所以end1 begin2 end2都有越界的可能,我们分类讨论解决一下。 共有三种情况:
end1就越界了,begin2和end2肯定也就越界了,这时只有左区间的部分数据有效,所以无法进行比较+排入,所以这时的gap是错误了,此次循环不进行直接break。begin2和end2越界时,这时只有左区间有效,无法进行比较+排入与第一种情况相同直接break最后一种情况只有end2越界了,我们只需将end2赋为正常区间之内即可。 调整代码如下:
int begin1 = i, end1 = i + gap - 1;//区间控制
int begin2 = i + gap, end2 = i + 2 * gap - 1;
if (end1 >= size || begin2 >= size)
{
break;
}
else if (end2 >= size)
{
end2 = size - 1;
}
完整代码如下:
void _MergeSortNon6(int* a, int size)
{
int* tmp = (int*)malloc(sizeof(int) * size);
if (tmp == NULL)
{
perror("malloc fail");
}
int gap = 1;
while (gap < size)
{
for (int i = 0; i < size; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;//区间控制
int begin2 = i + gap, end2 = i + 2 * gap - 1;//好好体会
if (end1 >= size || begin2 >= size)
{
break;
}
else if (end2 >= size)
{
end2 = size - 1;
}
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] > a[begin2])
{
tmp[j++] = a[begin2++];
}
else
{
tmp[j++] = a[begin1++];
}
}
while (begin1 <= end1)
tmp[j++] = a[begin1++];
while (begin2 <= end2)
tmp[j++] = a[begin2++];
memcpy(a, tmp, sizeof(int) * (end2-1+1));
}
gap *= 2;
}
free(tmp);
}
上面的代码是归并一部分就拷贝一部分,也可以在for循环外一次性拷贝进去,一次性拷贝对区间的控制更为麻烦,这里就不过多赘述。感兴趣的可以在我的gitee中找到代码匿名者Unit码云
八.计数排序
计数排序属于非比较排序,适合范围集中,并且范围不大的整形数组排序。
计数排序性能: 时间复杂度:O(N+range); 空间复杂度:O(range);
完整代码如下:
void CountSort(int* a, int n)
{
int max = a[0], min = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if(a[i]