排序
1. 排序概念及应用
1.1排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2 排序的应用
排序的使用广泛存在于各个方面,很多事情我们都会关注他们的排名,比如:
1.3 常见的排序算法
2. 插入排序
2.1 直接插入排序
2.1.1 基本思想
直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想
玩扑克牌时,我们要使手中的牌有序,就是每抽一张牌,然后从后往前,找到这张抽到的牌的合适的位置,将扑克牌插入,这就是插入排序的思想。
2.1.2 直接插入排序的实现
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移
单趟直接插入排序的实现(在一个有序数组中插入一个数):
void OnceInsertSort(int* a, int n) { int end; int temp = a[end + 1]; while (end >= 0) { if (temp < a[end]) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = temp; }
直接插入排序的完整实现
void InsertSort(int* a, int n)//直接插入排序 { for (int i = 0; i < n - 1; i++)//注意循环结束条件是i < n-1 避免造成越界 { int end = i; int temp = a[end + 1]; while (end >= 0) { if (temp < a[end])//将小于号该为大于号,排序将变成降序 { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = temp; } }
2.1.3 直接插入排序的特性总结
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 最坏-O(N^2) ->逆序(比如将降序数组排成升序)
- 最好-O(N)->(接近有序)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.2 希尔排序(缩小增量排序)
直接插入排序在接近有序时效率可观,可是在接近逆序使效率难堪,希尔排序的出现是为了优化直接插入排序(将直接插入排序的效率向有序时优化)
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序
数据分组排序实现:
对一组数据进行基本插入排序(假设gap为3):
void OnceShellSort(int* a, int n) { int gap = 3; for (int i = 0; i < n - gap; i += gap) { int end = i; int temp = a[end + gap]; while (end >= 0) { if (temp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = temp; } }
对每一组进行基本插入排序(假设gap为3):
void OnceShellSort(int* a, int n) { int gap = 3; for (int j = 0; j < gap; j++)//用gap次循环排序gap个组数据 { for (int i = j; i < n - gap; i += gap)//对每一组进行排序 { int end = i; int temp = a[end + gap]; while (end >= 0) { if (temp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = temp; } } }
可以将两层循环简化成一层(假设gap为3):
void ShellSort(int* a, int n) { int gap = 3; for (int i = 0; i < n - gap; i++)//gap组数据交替进行排序 { int end = i; int temp = a[end + gap]; while (end >= 0) { if (temp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = temp; } }
2.2.1 希尔排序的实现
将数据分为gap组,进行组内排序的过程叫做预排序,预排序的作用是将数据向有序的方向进行操作,预排序可以有很多次,当数组只被分成一组时就成为了直接插入排序,希尔排序就是:预排序+直接插入排序
void ShellSort(int* a, int n)//希尔排序 { //gap>1 预排序 //gap=1 直接插入排序 int gap = n; while (gap > 1) { //gap /= 2;//无论奇数或者偶数最后都能变成1 gap = gap / 3 + 1;//如果不加1不能保证最后是1 for (int i = 0; i < n - gap; i++) { int end = i; int temp = a[end + gap]; while (end >= 0) { if (temp < a[end])//将小于号该为大于号,排序将变成降序 { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = temp; } } }
预排序特性:
gap越大,大的数可以更快的跳到后面,小的数可以更快的跳到前面,越不接近有序
gap越小,数据跳动的越慢,越接近有序
2.2.2 希尔排序的特性总结
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此许多书中给出的希尔排序的时间复杂度都不固定:
《数据结构(C语言版)》— 严蔚敏
《数据结构-用面相对象方法与C++描述》— 殷人昆
因此我们认为希尔排序的时间复杂度为O(N^1.3)
- 稳定性:不稳定
2.3 直接插入排序和希尔排序的效率对比
在vs2022下,用release模式对一百万个数据进行测试:
通过实际测试,希尔排序的效率远大于直接插入排序
3. 选择排序
3.1 直接选择排序
3.1.1 基本思想
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
- 在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素
- 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
- 在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } void SelectSort(int* a, int n)//选择排序 { for (int i = 0; i < n - 1; i++) { int temp = i; for (int j = i + 1; j < n; j++) { if (a[j] < a[temp])//将小于号该为大于号,排序将变成降序 { temp = j; } } Swap(&a[temp], &a[i]); } }
以上实现一次只选择一个数,对其进行改良,一次选择两个数:
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } void SelectSort(int* a, int n)//选择排序 { int begin = 0, end = n - 1; while (begin < end) { int mini = begin, maxi = begin; for (int i = begin; i < n - begin; i++) { if (a[i] < a[mini]) { mini = i; } if (a[i] > a[maxi]) { maxi = i; } } Swap(&a[mini], &a[begin]); if (maxi == begin)//如果maxi指向begin,上一句指令会把maxi指向的数据交换到mini位置 { maxi = mini; } Swap(&a[maxi], &a[end]); /*Swap(&a[mini], &a[end]);//降序写法 if (maxi == end) { maxi = mini; } Swap(&a[maxi], &a[begin]);*/ begin++; end--; } }
3.1.2 直接选择排序的特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
3.2 堆排序
3.2.1 基本思想
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
建堆
升序:建大堆
降序:建小堆
利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
3.2.2 堆排序的实现
void Swap(int* p1, int* p2)
{
int temp = *p1;
*p1 = *p2;
*p2 = temp;
}
void AdjustDown(int* a, int n, int parent)//向下调整
{
int child = parent * 2 + 1;
while (child < n)
{
//确保指向的是大的那个孩子
if (child + 1 < n && a[child + 1] > a[child])//大堆的调整
{
child++;
}
//如果父亲小于儿子,进行调整
if (a[child] > a[parent])
{
Swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
//如果父亲大于儿子,调整结束
else
{
break;
}
}
}
void HeapSort(int* a, int n)//堆排序
{
//向下调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
for (int i = 0; i < n-1; i++)
{
AdjustDown(a, n - i, 0);
Swap(&a[0], &a[n - i - 1]);
}
}
想要更详细了解堆排序相关内容可以浏览此文:https://blog.csdn.net/csdn_myhome/article/details/129250750
3.2.3 堆排序的特性总结
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
4. 交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
4.1 冒泡排序
4.1.1 基本思想
冒泡排序就是利用相邻数据两两对比,然后进行挪动,两个数相比把较大(小)的数挪到后面,在每一趟排序后,能够将该趟最大(小)的数据挪动到最后。
4.1.2 冒泡排序的实现
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } void BubbleSort(int* a, int n)//冒泡排序 { for (int i = 0; i < n; i++) { for (int j = 1; j < n - i; j++) { if (a[j] < a[j - 1])//将小于号该为大于号,排序将变成降序 { Swap(&a[j], &a[j - 1]); } } } }
冒泡排序还能进行一些小小的优化,就是在数据有序后,不再进行冒泡:
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } void BubbleSort(int* a, int n)//冒泡排序 { for (int i = 0; i < n; i++) { int exchange = 0; for (int j = 1; j < n - i; j++) { if (a[j] < a[j - 1]) { Swap(&a[j], &a[j - 1]); exchange = 1; } } //数据一旦有序,冒泡排序停止 if (exchange == 0) { break; } } }
4.1.3 冒泡排序的特性总结
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
4.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
4.2.1 Hoare法
Hoare法实现单趟排序的思路如下:
选择最左(右)的数据作为key位置,左边一个指针从最左边出发,右边一个指针从最右边出发,右边先动,找到比key位置的值小的数据停下,然后左边再动,找到比key位置的值大的数据停下,然后将左右互相指向的值进行交换,在右边和左边移动的过程中,如果相遇就停止,然后将key位置的值和左右指针停止的位置的数据交换。
在完成单趟排序后,实现的结果是原本key位置指向的数据到了合适的位置,左边数据比原本key位置指向的数据小,右边数据比原本key位置指向的数据大。
单趟排序后,数据被分为两个区间,左边的区间比原本key位置指向的数据小,右边的区间右边数据比原本key位置指向的数据大,
想要完成全部的排序,左区间的数排序和右区间的数排序就变成一个子问题,只要不断地对左区间和右区间找到key的位置,然后分割区间,在左区间和右区间找到key的位置,就能完成全部的排序,也就是变成了一个递归问题。
注意:
左边做key,右指向先走,保证停下的位置一定比key指向的数据小
右边做key,左指向边先走,保证停下的位置一定比key指向的数据大
以左边做key,右指向先走举例会发生以下的几种情况:
右指向找到小,左指向找到大,两两交换,左指向左边都比key指向的数据小,右指向右边都比比key指向的数据大。
右指向找到小,左指向遇到右指向,相遇位置是比key指向的数据小的,交换到key的位置保证了左指向左边比key指向的数据小
右指向一直到左指向位置也没有找到小,也就是key位置,key指向的数据原地交换。
Hoare法单趟排序的实现:
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } void OnceQuickSort(int* a, int begin, int end) { int left = begin, right = end; int keyi = left; while (left < right) { while (left < right && a[right] >= a[keyi])//注意left<right条件避免了right越过left,如果改写a[right] > a[keyi],遇到于a[keyi]相等的数据会造成死循环 { right--; } while (left < right && a[left] <= a[keyi]) { left++; } Swap(&a[left], &a[right]); } Swap(&a[left], &a[keyi]); keyi = left; //数据被分成三段 //[bgein, keyi -1] keyi [keyi + 1, end] }
Hoare法实现快速排序
void Swap(int* p1, int* p2)
{
int temp = *p1;
*p1 = *p2;
*p2 = temp;
}
void QuickSort(int* a, int begin, int end)//快速排序
{
if (begin >= end)//如果区间不合法或者只有一个数据就结束
{
return;
}
int left = begin, right = end;
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
//数据被分成三段
//[bgein, keyi -1] keyi [keyi + 1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
4.2.2 挖坑法
挖坑法的思路依旧是选择最左(右)的数据作为key位置,左边一个指针从最左边出发,右边一个指针从最右边出发,右边先动,找到比key位置的值小的数据停下,然后将右边指向的数据填入坑位,右边指向的位置成为新的坑位,然后左边再动找大,找到比key位置的值大的数据停下,然后将左边指向的数据填入坑位,左边指向的位置成为新的坑位,然后右边再走重复上述操作,当左右指向相遇,将key填入坑内,完成单趟排序。
挖坑法单趟排序实现:
void OnceQuickSort(int* a, int begin, int end) { int left = begin, right = end; int hole = left; int key = a[hole]; while (left < right) { //右边先走找小,填入坑位 while (left < right && a[right] >= key) { right--; } a[hole] = a[right]; hole = right; //左边再走找大,填入坑位 while (left < right && a[left] <= key) { left++; } a[hole] = a[left]; hole = left; } a[hole] = key; }
挖坑法实现快速排序
void QuickSort(int* a, int begin, int end)//快速排序
{
if (begin >= end)
{
return;
}
int left = begin, right = end;
int hole = left;
int key = a[hole];
while (left < right)
{
//右边先走找小,填入坑位
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
//左边再走找大,填入坑位
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
QuickSort(a, begin, hole - 1);
QuickSort(a, hole + 1, end);
}
4.2.3 前后指针版本
前后指针版本的思路是,两个指针一前一后,前面的指针找小,找到后停下,后面的指针加1,然后将两个指针指向的值交换,当前面的指针遍历完整个数据后,将key的值于后面的指针的数据交换。过程中后面的指针在加1前,永远在大于key的值前面,加1将小于key的值与大于key的值交换,遍历停止时,后面指针指向的也是小于key的值,保证了单趟排序的正确性。
单趟排序的实现:
void OnceQuickSort(int* a, int begin, int end) { int keyi = begin; int prev = begin, cur = begin + 1; while (cur <= end) { //只有cur找到才进行交换,小的数往前挪,大的数往后挪 if (a[cur] < a[keyi] && ++prev != cur) Swap(&a[cur], &a[prev]); cur++; } Swap(&a[keyi], &a[prev]); keyi = prev; }
前后指针版本实现快速排序
void QuickSort(int* a, int begin, int end)//快速排序
{
if (begin >= end)
{
return;
}
int keyi = begin;
int prev = begin, cur = begin + 1;
while (cur <= end)
{
//只有cur找到才进行交换,小的数往前挪,大的数往后挪
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[cur], &a[prev]);
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
4.2.4 快速排序优化
快速排序的性能是不够稳定的,理想情况下快速排序的时间复杂度为O(N*log N),但某些的情况下时间复杂度又是O(N),并且在最坏的情况下递归也会造成栈溢出。
因此快速排序需要进行优化,使其性能接近理想情况(以 Hoare 法举例修改,优化内容同样使用其他方法):
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } int GetMidIndex(int* a, int begin, int end)//三数取中 { int mid = (begin + end) / 2; if (a[begin] < a[mid])// begin mid { if (a[mid] < a[end])// begin mid end { return mid; } else if (a[end] < a[begin])// end begin mid { return begin; } else { return end; } } else // a[begin] > a[mid] { if (a[end] < a[mid])// end mid begin { return mid; } else if (a[end] > a[begin])// mid begin end { return begin; } else { return end; } } } void QuickSort(int* a, int begin, int end)//快速排序 { if (begin >= end) { return; } int mid = GetMidIndex(a, begin, end);//三数取中(优化) Swap(&a[begin], &a[mid]); //以下为未优化时,单趟排序的实现,使用其他方法实现快速排序只需修改以下代码 int left = begin, right = end; int keyi = left; while (left < right) { while (left < right && a[right] >= a[keyi]) { right--; } while (left < right && a[left] <= a[keyi]) { left++; } Swap(&a[left], &a[right]); } Swap(&a[left], &a[keyi]); keyi = left; QuickSort(a, begin, keyi - 1); QuickSort(a, keyi + 1, end); }
加入三数取中后,快速排序的单趟排序几乎不会出现最坏的情况,并且接近最好的情况,因此在加入三数取中后,快速排序的时间复杂度为O(N*log N)
除了取数需要优化,还有小区间的快速排序需要优化,由于快速排序是递归的,随着递归的深入,数据量很小,但是要调用很多个栈帧,很降低效率,并且还有栈溢出的风险。
在区间比较小时,我们选择用直接插入排序来代替递归,因为直接插入排序的适应性很好,在接近有序时效率很高
void Swap(int* p1, int* p2) { int temp = *p1; *p1 = *p2; *p2 = temp; } void InsertSort(int* a, int n)//直接插入排序 { for (int i = 0; i < n - 1; i++) { int end = i; int temp = a[end + 1]; while (end >= 0) { if (temp < a[end]) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = temp; } } void QuickSort(int* a, int begin, int end)//快速排序 { if (begin >= end) { return; } if ((end - begin + 1) < 15)//小区间优化(优化) { InsertSort(a + begin, (end - begin + 1)); } else { int mid = GetMidIndex(a, begin, end);//三数取中 Swap(&a[begin], &a[mid]); //以下为未优化时,单趟排序的实现,使用其他方法实现快速排序只需修改以下代码 int left = begin, right = end; int keyi = left; while (left < right) { while (left < right && a[right] >= a[keyi]) { right--; } while (left < right && a[left] <= a[keyi]) { left++; } Swap(&a[left], &a[right]); } Swap(&a[left], &a[keyi]); keyi = left; QuickSort(a, begin, keyi - 1); QuickSort(a, keyi + 1, end); } }
4.2.5 快速排序的非递归实现
由于递归要不断调用栈可能会栈溢出,因此快速排序的非递归实现也很重要,快速排序的非递归需要借助栈来实现,快速排序的就是对数据进行单趟排序,然后分割区间,再对区间单趟排序,因此非递归实现栈就是要实现区间的分割,然后进行单趟排序。
#include <stdio.h> #include <stdbool.h> #include <stdlib.h> #include <assert.h> int OnceQuickSort(int* a, int begin, int end) { int keyi = begin; int prev = begin, cur = begin + 1; while (cur <= end) { //只有cur找到才进行交换,小的数往前挪,大的数往后挪 if (a[cur] < a[keyi] && ++prev != cur) Swap(&a[cur], &a[prev]); cur++; } Swap(&a[keyi], &a[prev]); keyi = prev; return keyi; } typedef int STDataType; typedef struct Stack { STDataType* a; int capacity; int top; }ST; bool StackEmpty(ST* ps) { assert(ps); return ps->top == 0; } void StackInit(ST* ps) { assert(ps); ps->a = (STDataType*)malloc(sizeof(STDataType) * 4); if (ps->a == NULL) { perror("malloc fail"); exit(-1); } ps->top = 0; ps->capacity = 4; } void StackDestroy(ST* ps) { assert(ps); ps->a = NULL; ps->top = ps->capacity = 0; } void StackPush(ST* ps, STDataType x) { assert(ps); if (ps->top == ps->capacity) { STDataType* tmp = (STDataType*)realloc(ps->a, sizeof(STDataType) * ps->capacity * 2); if (tmp == NULL) { perror("realloc fail"); exit(-1); } ps->a = tmp; ps->capacity *= 2; } ps->a[ps->top] = x; ps->top++; } void StackPop(ST* ps) { assert(ps); //assert(ps->top > 0); assert(!StackEmpty(ps)); ps->top--; } STDataType StackTop(ST* ps) { assert(ps); //assert(ps->top > 0); assert(!StackEmpty(ps)); return ps->a[ps->top - 1]; } void QuickSortNonR(int* a, int begin, int end)//快速排序的非递归实现 { ST st; StackInit(&st); StackPush(&st, begin); StackPush(&st, end); while (!StackEmpty(&st)) { int right = StackTop(&st); StackPop(&st); int left = StackTop(&st); StackPop(&st); int keyi = OnceQuickSort(a, left, right); // [left, keyi-1] keyi [keyi+1, right] if (keyi + 1 < right)//合法的区间入栈 { StackPush(&st, keyi + 1); StackPush(&st, right); } if (left < keyi - 1)//合法的区间入栈 { StackPush(&st, left); StackPush(&st, keyi - 1); } } StackDestroy(&st); }
欲更详细的了解栈的实现,可以阅读此文:C语言实现栈和队列及经典题目(附详细源码)_好想写博客的博客-CSDN博客_c语言队列源码
4.2.6 快速排序的特性总结
- 快速排序整体的综合性能和使用场景都是比较好的(优化后),所以才敢叫快速排序
- 时间复杂度:O(N*log N)
- 空间复杂度:O(log N)
- 稳定性:不稳定
5. 归并排序
5.1 基本思想
基本思想: 归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
将数据分成两段,当两段数据都有序时,再将两段数据按顺序插入,数据并非有序时就继续分割区间,然后直到分割到只剩一个数据时就认为有序,然后将区间合并进行按顺序插入。
将区间合并并按顺序插入时需注意,需要不能在原数组进行,因为可能会造成数据的覆盖,丢失等问题,需要额外开新的数组进行按顺序插入数据,并且每次将区间合并并按顺序插入时需要把额外开的新的数组的数据拷贝会原数组,因为在下一次区间合并时使用的数据是上一次合并并按顺序插入时的数据顺序。
将区间合并并按顺序插入就是归并,归并的过程是对比两个有序区间的数据大小,按照顺序依次插入数组。
5.2 归并排序的实现
void _MergeSort(int* a, int begin, int end, int* temp)//规定排序子函数
{
if (begin >= end)//如果区间长度等于1认为有序
{
return;
}
int mid = (begin + end) / 2;//区间分割
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
_MergeSort(a, begin1, end1, temp);//对分割出的区间进行排序
_MergeSort(a, begin2, end2, temp);
int i = begin;
while (begin1 <= end1 && begin2 <= end2)//归并,即将数据按顺序插入
{
if (a[begin1] < a[begin2])
{
temp[i++] = a[begin1++];
}
else
{
temp[i++] = a[begin2++];
}
}
//如果一个区间内数据插入完,将另一个区间剩余的数据插入
while (begin1 <= end1)
{
temp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
temp[i++] = a[begin2++];
}
memcpy(a + begin, temp + begin, sizeof(int)*(end - begin + 1));//每次对两段区间进行归并后拷贝数据给原数组
}
void MergeSort(int* a, int n)//归并排序
{
int* temp = (int*)malloc(sizeof(int) * n);
if (temp == NULL)
{
perror("malloc fail");
exit(-1);
}
//由于只需要开一次空间,因此选择用一个子函数完成归并
_MergeSort(a, 0, n - 1, temp);
free(temp);
temp = NULL;
}
5.3 归并排序的特性总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*log N)
- 空间复杂度:O(N) (N+Log N建立Log N个栈帧)
- 稳定性:稳定
5.4 归并排序的非递归实现
归并排序的本质就是区间分割+归并,归并的方式是固定的,因此要实现归并排序重要的是区间的分割,实现归并排序的非递归也是如此,我们可以利用循环来实现,将每次数据按间距分为若干组,第一次设定间距为一,也就是若干组只有一个数据的区间的两两归并,然后后面每次都将间距大小乘二进行归并,也就是将上一次归并的各个区间两两合并再进行归并,直到区间长度大于等于数据个数为止。
在实现归并排序的非递归排序前有很多细节需要主要,因为如果每次只是简单的将区间扩大二倍,然后进行归并,有可能区间长度大于了数据个数造成越界。
越界的情况有以下几种(begin1 , end1; begin2, end2分别为两个要归并的区间的头和尾):
- end1,begin2,end2越界
- begin2,end2越界
- end2越界
我们需要对越界情况进行处理,根据处理方式不同,需要采用不同的拷贝方式
如果采用修正区间的方式(适用于归并完了整体拷贝 or 归并每组拷贝):
对第一种越界情况:必须修正左区间,右区间改成不存在区间
对第二种越界情况:必须修正左区间,右区间改成不存在区间
对第三种越界情况:必须修正右区间
如果采用错误区间break方式(适用于归并每组拷贝):
对第一种越界情况:break
对第二种越界情况:break
对第三种越界情况:必须修正右区间
整体拷贝不适用的原因是:在临时数组中有一部分未进行归并的值或者随机值拷贝回去会对正确的值进行覆盖
void MergeSortNonR(int* a, int n)//归并排序的非递归实现 { int* temp = (int*)malloc(sizeof(int) * n); if (temp == NULL) { perror("malloc fail"); exit(-1); } int rangeN = 1; while (rangeN < n) { for (int i = 0; i < n; i += 2 * rangeN) { int begin1 = i, end1 = i + rangeN - 1; int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1; 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; } //单趟归并 int j = i; while (begin1 <= end1 && begin2 <= end2) { if (a[begin1] <= a[begin2])//保证稳定性 { temp[j++] = a[begin1++]; } else { temp[j++] = a[begin2++]; } } while (begin1 <= end1) { temp[j++] = a[begin1++]; } while (begin2 <= end2) { temp[j++] = a[begin2++]; } memcpy(a + i, temp + i, sizeof(int) * (end2 - i + 1)); } // 也可以整体归并完了再拷贝 //memcpy(a, tmp, sizeof(int)*(n)); rangeN *= 2; } }
void MergeSortNonR(int* a, int n)//归并排序的非递归实现 { int* temp = (int*)malloc(sizeof(int) * n); if (temp == NULL) { perror("malloc fail"); exit(-1); } int rangeN = 1; while (rangeN < n) { for (int i = 0; i < n; i += 2 * rangeN) { int begin1 = i, end1 = i + rangeN - 1; int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1; if (end1 >= n) { break; } else if (begin2 >= n) { break; } else if (end2 >= n) { end2 = n - 1; } //单趟归并 int j = i; while (begin1 <= end1 && begin2 <= end2) { if (a[begin1] <= a[begin2])//保证稳定性 { temp[j++] = a[begin1++]; } else { temp[j++] = a[begin2++]; } } while (begin1 <= end1) { temp[j++] = a[begin1++]; } while (begin2 <= end2) { temp[j++] = a[begin2++]; } memcpy(a + i, temp + i, sizeof(int) * (end2 - i + 1)); } rangeN *= 2; } }
6. 计数排序(非比较排序)
6.1 基本思想
计数排序是一个比较特别的排序,他只能在特定的场景下使用,只能在数据都是整形的时候使用。
计数排序的思路是:
- 统计出相同元素的出现次数。
- 根据统计的结果将数据放到正确的位置。
首先根据元素的最大值和最小值确定数据的范围大小,创建一个数据范围大小的数组用来记录相同元素的出现次数,用来记录相同元素出现次数的数组的下标用来代表某一元素的记录位置,每遇到一个元素就在记录相同元素出现次数的数组的对应下标位置加一,直到数据遍历完毕,然后按顺序根据记录相同元素出现次数的数组的每个下标对应的数据和次数对原数组进行覆盖。
上面实现的是数据从0开始的计数排序,数据和数组的下标直接对应,如果数据的范围是从负数开始,或者从一个远大于0的数据开始计数排序要怎样实现呢?实现以上两种情况的方法是一样的,我们可以利用数据的相对位置来对应数据的下标,我们让最小的值对应数组的0下标,后续的数据用其相对最小值大多少来作为对应的下标。
6.2 计数排序的代码实现
void CountSort(int* a, int n)
{
int max = a[0];
int min = a[0];
for (int i = 1; i < n; i++)//便利数据找到最大值和最小值确定数据范围
{
if (max < a[i])
{
max = a[i];
}
if (min > a[i])
{
min = a[i];
}
}
int range = max - min + 1;//确定数据范围
int* count = (int*)calloc(range, sizeof(int));//创建范围大小数组记录相同元素的个数
if (count == NULL)
{
perror("malloc fail");
exit(-1);
}
for (int i = 0; i < n; i++)//在对应下标位置记录次数
{
count[a[i]-min]++;
}
int k = 0;
for (int i = 0; i < range; i++)//将原数据覆盖
{
while (count[i]--)
{
a[k++] = i + min;
}
}
}
6.3 计数排序的特性总结
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围)) (数据范围很小为O(N),很大为O(范围))
- 空间复杂度:O(范围)
- 稳定性:稳定
7. 排序算法复杂度及稳定性分析
稳定性:若经过排序,这些数据的相对次序保持不变。
稳定性的价值在实际中有它独特的价值,比如考试时同分的同学按交卷先后顺序排序,如果排序不具有稳定性就会打乱整个交卷顺序。
直接插入排序稳定的原因:只有小于前面的数据才会挪到前面,相等时不会。
希尔排序不稳定的原因:预排序时相等的数据可能被分到不同组。
选择排序不稳定的原因:比如说:5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。也就是选择的数据的位置和要交换到的位置两者之间有与要交换到的位置的数据相同的。
堆排序不稳定的原因:它在交换数据的时候,是比较父结点和子节点之间的数据,所以,即便是存在两个数值相等的兄弟节点,它们的相对顺序在排序也可能发生变化。
冒泡排序稳定的原因:相等的数据对比后不进行操作。
快速排序不稳定的原因:由于key值要换到中间,可能换到的位置数据前面有相等的数据。
归并排序稳定的原因:归并时相等的数据可以选择顺序在前面的插入。