自学内容网 自学内容网

【数据结构初阶】排序算法(下)冒泡排序与归并排序


4. 交换排序

交换排序基本思想:
所谓交换**,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置**。
交换排序的特点是: 将值较大的数据向序列的尾部移动,值较小的数据向序列的前部移动(也可以反过来)。

常见的交换排序有两个:

  1. 冒泡排序——这是一个除了教学意义外几乎没有任何用处的排序,因为它的时间复杂度太高
  2. 快速排序——实践中最常用的排序,在快排专题中已经介绍过。

4. 1 冒泡排序

冒泡排序是一种最基础的交换排序。之所以叫做冒泡排序,因为每一个元素都可以像小气泡一样,根据自身大小一点一点向数组的一侧移动

冒泡排序的思路就是每一趟通过两两比较逐渐将未排序的数据中的最大值(或最小值)送到它最终应该在的地方,也就是未排序数据的最右边

举例:

int a[] = {3, 5, 9, 7, 2, 1};

通过冒泡排序把它变成降序的。

第一次排序,从最左边开始拿3和5比较,3比5小,交换

int a[] = {5, 3, 9, 7, 2, 1};

接着3又和9比较,3小,再交换

int a[] = {5, 9, 3, 7, 2, 1};

接着3又和7比较,3小,再交换

int a[] = {5, 9, 7, 3, 2, 1};

接着3又和2比较,2小,不交换,但操作的元素从3变成2
2与1比较,1小,不交换,第一轮比较结束。
从原理上看,只有1被放到正确的位置,但是我们可以发现实际上被正确放置的还有2,3,这就会导致在后面几次比较时,可能整个数组都已经有序了。比如下一次遍历之后:

int a[] = {9, 7, 5, 3, 2, 1};

可以看到此时数组已经有序了,但是循环没有停止,这会大大降低冒泡排序的效率
可以在每一轮循环中增加一个变量,当发生交换时,改变它的值,如果在一轮循环后这个变量的值没有发生改变,就说明所有的数据已经有序了,就可以提前停止循环

代码:

void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int jug = 1;//单次循环的交换检测变量
for (int j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
jug = 0;
}
}
if (!jug)
jug = 1;
else//如果jug没被更改,就说明已经有序了
break;
}
}

时间复杂度:O(N2)
空间复杂度:O(1)

5. 归并排序

归并排序算法思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divideand Conquer)的一个非常典型的应用。
将已有序的子序列合并,得到完全有序的序列。即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:

归并
归并排序的核心就是把数组拆分再一点点地合并,并在每次合并后时合并的这部分有序,直到合并成整个数组

合并时,应该怎么让合并后的部分有序呢?调用快排吗?
当然不需要,要注意合并的这两部分已经有序了,我们可以采用双指针遍历这两个数组,把较小的数放到前面,较大的数放到后面(这里以升序为例,降序则相反)。这样做的时间复杂度是O(N),而快排是O(NlogN)

但这里又出现了一个问题:把较小的数放到哪里?直接放到这个数组里面吗?这显然不行,因为这样可能会覆盖还没排序的数据。
所以我们可以创建一个新的数组,这个数组和排序的数组的大小一致,把排序好的数据放进去,在本次合并完成时,就把数据从新数组里拷贝回来就可以了。

void _MergeSort(int* a, int* tmp, int left, int right)
{
//递归的停止条件
if (left >= right)
return;

//递归的思路是反着来的,要先传递下去才能回归,把数组不断二分,最终就可以开始回归,也就是上面说的步骤
int mid = (left + right) / 2;

_MergeSort(a, tmp, left, mid);
_MergeSort(a, tmp, mid + 1, right);
//此时开始合并,合并的两个序列已经是有序的了
int cur1 = left, end1 = mid;
int cur2 = mid + 1, end2 = right;
int cur = left;
//双指针遍历两个序列,把较小的数放到tmp里,形成升序
while (cur1 <= end1 && cur2 <= end2)
{
if (a[cur1] < a[cur2])
tmp[cur++] = a[cur1++];
else
tmp[cur++] = a[cur2++];
}
//当上面的循环结束时,有且仅有一个序列还没遍历完,将剩下的放入tmp数组
while (cur1 <= end1)
tmp[cur++] = a[cur1++];
while (cur2 <= end2)
tmp[cur++] = a[cur2++];

//把tmp里的数据拷贝回来,合并完成
for (int i = left; i <= right; i++)
a[i] = tmp[i];
}

void MergeSort(int* a, int n)
{
//创建新数组
int* tmp = (int*)malloc(sizeof(int) * n);
//开始递归
_MergeSort(a, tmp, 0, n - 1);
//记得释放动态申请的数组
free(tmp);
tmp = NULL;
}

时间复杂度:O(NlogN)
空间复杂度:O(N)

6. 非比较排序

6. 1 计数排序

计数排序又称为鸽巢原理,是对哈希直接定址法(不了解这个也没关系)的变形应用。操作步骤

  1. 统计相同元素出现次数
  2. 根据统计的结果将序列回收到原来的序列中

比如

int a[]={6, 1, 2, 9, 4, 2, 4, 1, 4};

我们以这个数组为例:

  1. 首先遍历一次数组,找到数据中的最大值max和最小值min创建一个新数组,把新数组中元素都置为0,数组的大小是max
  2. 再来遍历数组,每遍历一个数据,就把新数组中对应下标的数据+1
  3. 当遍历完成后,再遍历新数组,根据新数组中数据的下标推算出对应的数据(因为放入时是根据数据对应的下标放入的,所以可以反推出数据)放入原数组个数就是新数组中存放的数据

jishu
在这个例子中,计数排序表现良好,但是如果换一个例子:
比如:

int a[]={100, 101, 109, 105, 101, 105};

这是我们再根据最大值开空间就会造成极大的浪费
2
我们可以根据范围来开空间,开一个max-min+1大小的新数组,这样每一个数据都可以在-min之后找到对应的新数组下标,还能大幅度节省空间。

void CountSort(int* a, int n)
{
//找最大值和最小值
int max = a[0];
int min = a[0];

for (int i = 0; i < n; i++)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
//创建数组
int N = max - min + 1;
int* count = (int*)calloc(N, sizeof(int));
if (!count)
{
perror("calloc");
exit(1);
}
//遍历,并在新数组中将数据标记
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
//放回原数组
int index = 0;
for (int i = 0; i < N; i++)
{
while (count[i]--)
a[index++] = i + min;
}
}

计数排序的特性:
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
时间复杂度:O(N+range)
空间复杂度:O(range)

5. 排序性能分析

time.h库中提供了clock()函数,可以在程序中记录运行到这里时的时间,而代码又是顺序执行的,因此只要保存一段代码前后的时间,再相减就可以得到这段代码运行的时间,由此,我们可以做出这样的测试代码:

void TestOP()
{
srand((unsigned int)time(0));
const int N = 100000;//可以适当改小这个数值,提高速度
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
//制作相同的数组,避免其他变量
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
 
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
 
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
 
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
 
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
 
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
 
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
 
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
printf("BubbleSort:%d\n", end7 - begin7);
  
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
}
int main()
{
TestOP();
return 0;
}

注:由于冒泡排序真的很慢,所以程序运行起来后一段时间的卡顿是正常的,可以修改N来减少等待时间,但N不能太小,否则时间太短看不出来差异。
结果
这样就可以很直观地看出来不同排序算法的差异了。

6. 排序算法复杂度及稳定度分析

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的,否则称为不稳定的。

排序办法平均情况最好情况最坏情况辅助空间稳定性
冒泡排序O(n2)O(n)O(n2)O(1)稳定
直接选择排序O(n2)O(n2)O(n2)O(1)不稳定
直接插入排序O(n2)O(n)O(n2)O(1)稳定
希尔排序O(nlogn)~O(n2)O(n1.3)O(n2)O(1)不稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)稳定
快速排序O(nlogn)O(nlogn)O(n2)O(nlogn)~O(n2)不稳定

关于稳定性,没必要死记硬背,需要时在脑海里拿一个比较简单的数组模拟着走一遍这个排序算法的步骤,看看有没有发生不判断两个数相等的交换就可以了,发生了就是不稳定的。
比如堆排序的第一个元素与最后一个元素交换,快排的基准值与prev或是其他变量的交换,这些都会导致这个算法不是稳定的。

谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!
我会持续更新更多优质文章


原文地址:https://blog.csdn.net/fhvyxyci/article/details/142601044

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!