🤡博客主页:Code_文晓
🥰本文专栏:数据结构与算法
😻欢迎关注:感谢大家的点赞评论+关注,祝您学有所成!
✨✨💜💛想要学习更多数据结构与算法点击专栏链接查看💛💜✨✨
前面我们学习了插入类排序中的直接插入排序和希尔排序,这次,我们学习另一类排序—— 交换排序。
所谓交换类排序,就是根据序列中两个关键字的比较结果,来决定是否要交换这两个关键字对应的记录在序列中的位置。交换类排序主要包括冒泡排序和快速排序。接下来我们先讲冒泡排序再讲快速排序。
1. 冒泡排序
冒泡排序的英文名称是Bubble Sort,也叫起泡排序。
按照从小到大排序来说,它的基本思想是:在有n个元素的序列中,先将第一个记录的关键字和第二个记录的关键字进行比较,也就是两两 比较相邻 记录关键字,如果第一个记录的关键字大于第二个记录的关键字,则交换这两个记录。接着,比较第二个记录的关键字和第三个记录的关键字,如果第二个记录的关键字大于第三个记录的关键字,则交换这两个记录。依次类推,直到第n-1个记录和第n个记录比较完为止。
上述这些过程叫 第一趟 冒泡排序。这样做的结果就是将关键字最大的记录放到了最后一个记录位置上。 下面来一个动态效果图,让我们看的更直观一些:
显然,对于数组{16,1,45,23,99,2,18,67,42,10},第一趟冒泡排序后,结果为 {1,16,23,45,2,18,67,42,10,99}。如图 1 所示:
之所以称为冒泡排序,是因为大的数据往最下面(后面)沉,小的数据自然就会向上冒,这个过程就好像气泡在水中上浮的过程,所以叫做冒泡排序。
接着要开始第二趟冒泡排序了。但因为最后一个记录已经是最大值,因此第二趟冒泡排序只需要两两比较前n-1个元素,这样就会把第二大的记录放到倒数第二个记录位置上。显然,接着第一趟冒泡排序的结果,第二趟冒泡排序的结果为{1,16,23,2,18,45,42,10,67,99}。如图2所示:
接着开始第三趟、第四趟……冒泡排序,当 在一趟排序中没有进行过记录交换的操作 时,就可以认为冒泡排序结束了。具体实现我会在下面的冒泡排序算法的改进代码中进行展示。
冒泡排序的代码编写有很多种方法,比如有的方法会从序列中的最后两条记录开始比较,把关键字最小的记录不断向最前面移动。这里我选择的编码方式还是按照本节最开始描述的冒泡排序基本思想来实现,下面是具体代码。
实现代码:
//冒泡排序(从小到大)
template<typename T>
void BubbleSort(T myarray[], int length)
{
if (length <= 1) //不超过1个元素的数组,没必要排序
return;
//外层循环只控制排序的趟数
for (int i = 0; i < length - 1; ++i)
{
//内层循环控制元素的大小比较和交换位置
for (int j = 0; j < length - i - 1; ++j) //每趟比较的次数都会减少
{
if (myarray[j] > myarray[j + 1]) //前面的数据如果比后面的数据大
{
//交换元素位置
T temp = myarray[j + 1];
myarray[j + 1] = myarray[j];
myarray[j] = temp;
}
} //end for j
//每走一趟显示一下结果
cout <<"第"<< i+1 <<"趟冒泡排序结果为: ";
for (int i = 0; i < length; ++i) cout << myarray[i] <<"";
cout << endl;
} //end for i
return;
}
在main主函数中,加入下面的测试代码。
int arr[] = {16,1,45,23,99,2,18,67,42,10};
int length = sizeof(arr) / sizeof(arr[0]); //数组中元素个数
BubbleSort(arr, length);//对数组元素进行冒泡排序
cout <<"冒泡排序结果为:";
for (int i = 0; i < length; ++i)
{
cout << arr[i] <<" ";
}
cout << endl; //换行
执行结果如下:
从结果可以看到,第7、8、9趟冒泡排序的结果相同,这意味着第8和第9趟排序是没有必要的,也就是这个算法可以提前结束。换句话说。 冒泡排序的结束条件应该是“在一趟排序中没有进行过记录交换的操作”。
所以,可以对上述冒泡排序算法BubbleSort进行改进,下面是改进后的代码。
//冒泡排序(从小到大)
template<typename T>
void BubbleSort(T myarray[], int length)
{
if (length <= 1) //不超过1个元素的数组,没必要排序
return;
//外层循环只控制排序的趟数
for (int i = 0; i < length - 1; ++i)
{
bool cgflag=false;//表本趟冒泡排序是否发生过记录交换,false:无;true:有
//内层循环控制元素的大小比较和交换位置
for (int j = 0; j < length - i - 1; ++j) //每趟比较的次数都会减少
{
if (myarray[j] > myarray[j + 1]) //前面的数据如果比后面的数据大
{
//交换元素位置
T temp = myarray[j + 1];
myarray[j + 1] = myarray[j];
myarray[j] = temp;
cgflag = true; //标记本趟冒泡排序发生过记录交换(可能1次或者多次)
}
} //end for j
if (cgflag == false) //本趟冒泡排序没有发生过记录交换,表示整个冒泡排序结束
break;
//每走一趟显示一下结果
cout <<"第"<< i+1 <<"趟冒泡排序结果为: ";
for (int i = 0; i < length; ++i) cout << myarray[i] <<"";
cout << endl;
} //end for i
return;
}
main主函数中代码不变,执行结果如下:
分析代码和结果可以看到,当进行第8趟冒泡排序后,因为没有发生记录交换,所以直接跳出外循环从而结束整个冒泡排序的过程。
从代码中可以看到,冒泡排序实现代码比较简单。空间复杂度为O(1)。
在时间复杂度方面,对于具有n个元素的数组,在最好的情况下,即数组中元素已经是排好序的情况下,则只需要一趟排序并且这趟排序只需要进行n-1次比较次数且不需要做任何数据交换,所以最好情况时间复杂度为O(n)。
在最坏情况下,即数组中元素正好是逆序排列的情况下,此时需要进行n-1趟排序,比较次数和记录交换次数都是1+2+3+……+(n-1)= 次,即最坏情况时间复杂度为O()。
平均情况时间复杂度的分析要结合一些概率论知识,这里就不详细说明,结论也是O()。此外,从实现代码中不难看到,即使遇到了关键字相同的两条记录,这两条记录的相对顺序也不会发生改变,所以此排序算法是 稳定 的。
总结:冒泡排序会进行多趟排序,每趟排序都会把当前参与排序的数字中的最大数字下沉到最后,当然,不能影响已经在最后面的排好序的数字。
2. 快速排序
前面我们一起学习了交换类排序中的冒泡排序,这次我们继续学习交换类排序中的快速排序。这两种排序算法的主要区别在于排序的效率和实现代码。如果说冒泡排序是通过相邻元素的比较和交换达成排序,那么快速排序就是一种分而治之的思想,是对冒泡排序的改进。
快速排序的英文名称是Quick Sort,他通过分而治之的思想,把待排序的表分隔成若干个子表,每个子表都以一个称为枢轴的元素为基准进行排序。
一般来说,在元素数量一定的内部排序算法中,快速排序算法平均性能是最优秀的,因此,C++标准库中也提供了qsort函数来实现快速排序功能(其实qsort的实现版本中,还可能会用到其他排序)。
快速排序是Hoare于1962年提出的一种交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值(这个元素通常是首元素),按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
通过 一趟排序 将所有关键字小于枢轴的元素都放置在枢轴前面,大于枢轴的元素都放置在枢轴后面。这样,这趟排序就将待排元素 分割 成了两个独立的部分。而且这个时候,枢轴元素所在的位置其实也就是该元素 最终 应该在的位置了。
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int myarray[], int left, int right)
{
if(right - left <= 1)
return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
int div = partion(myarray, left, right);
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
QuickSort(myarray, left, div);
// 递归排[div+1, right)
QuickSort(myarray, div+1, right);
}
上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,同学们在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。
这里为了下面的代码,先说一些结论:
快速排序最好的情况下,时间复杂度为O()。这是因为快速排序采用分治的思想,将数组分成较小和较大的两部分,并对两部分分别进行排序,最终得到有序的数组。
平均情况时间复杂度为 O( )
快速排序最坏情况下,时间复杂度为O()。这种情况发生在待排序的数组已经是有序或基本有序的情况下。在这种情况下,快速排序的划分过程可能会将数组分成极度不平衡的两部分,导致递归深度增加,从而导致性能下降。
快速排序的效果受到输入数据的分布情况的影响。当输入数据近似随机分布时,快速排序的效果通常最好。而当输入数据具有一定的有序性或逆序性时,快速排序的效果可能最差。
结论中可以知道,快速排序中基准元素的选取非常重要,影响着快速排序的效率,所以需要对其进行优化!
QuickSort的优化(如何解决上述存在的问题?)
为了尽可能提高快速排序的效果,可以采用以下几种方法选择基准元素:
- 随机选择:随机选择数组中的一个元素作为划分元素。这种方法可以减少最坏情况发生的概率,并且在平均情况下能够获得较好的效果。
- 中位数选择:选择待排序数组的中位数作为划分元素。这种方法可以尽可能地将数组均分为两部分,避免极度不平衡的划分。
- 三数取中:选择待排序数组的头部、中间和尾部的三个元素,取它们的中位数作为划分元素。这种方法是一种2折中的选择,既考虑了性能,又避免了极端情况的发生。
我们选择三数取中这种方法:选取a[left],a[mid],a[right]中的中位数。这个方案能够在很大程度上改善快速排序算法在最坏情况下的性能。
注意!mid只是索引的中位数,不代表a[mid]三个数中的中位数,所以需要判断大小,下面是选取中位数的实现代码:
int GetMidIndex(int* myarray, int left, int right)
{
int mid = left + (right - left) / 2;
// 数组的头部是中位数
if ((myarray[mid] < myarray[left] && myarray[left] < myarray[right]) || (myarray[right] < myarray[left] && myarray[left] < myarray[mid]))
{
return left;
}
// 数组的中部是中位数
else if ((myarray[left] < myarray[mid] && myarray[mid] < myarray[right]) || (myarray[right] < myarray[mid] && myarray[mid] < myarray[left]))
{
return mid;
}
// 数组的尾部是中位数
else
{
return right;
}
}
将区间按照基准值划分为左右两半部分进行分区的常见方式有:
-
hoare法分区
选择一个基准元素(通常是数组的第一个元素),将其索引记为keyi。
使用两个指针left和right分别指向数组的左右边界。
从右边开始,找到第一个小于基准元素的值,将右指针right向左移动。
从左边开始,找到第一个大于基准元素的值,将左指针left向右移动。
如果左指针left小于右指针right,交换左右指针指向的元素。
继续执行上述步骤,直到左指针left大于等于右指针right。
最后将基准元素a[keyi]与左指针left指向的元素交换位置。
返回左指针left作为分区的分界点。
代码:
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// [left, right]
int Partition1(int* myarray, int left, int right)
{
int mididx = GetMidIndex(myarray, left, right);
Swap(&myarray[left], &myarray[mididx]);
int keyi = left;
while (left < right)
{
// 右指针从右往左找比key小的值
while (left < right && myarray[right] >= myarray[keyi])
{
--right;
}
// 左指针从左往右找比key大的值
while (left < right && myarray[left] <= myarray[keyi])
{
++left;
}
Swap(&myarray[left], &myarray[right]);
}
Swap(&myarray[keyi], &myarray[left]);
return left;
}
-
挖坑法分区
选择一个基准元素(通常是数组的第一个元素),将其值保存在key中。
使用两个指针left和right分别指向数组的左右边界。
从右边开始,找到第一个小于基准元素的值,将右指针right向左移动。
将找到的小于基准元素的值赋给基准元素所在的空位(hole),并将hole更新为右指针right。
从左边开始,找到第一个大于基准元素的值,将左指针left向右移动。
将找到的大于基准元素的值赋给基准元素所在的空位(hole),并将hole更新为左指针left。
继续执行上述步骤,直到左指针left大于等于右指针right。
最后将基准元素的值放入最后一个空位hole中。
返回hole作为分区的分界点。
代码:
int Partition2(int* myarray, int left, int right)
{
int mididx = GetMidIndex(myarray, left, right);
Swap(&myarray[left], &myarray[mididx]);
int key = myarray[left];
int hole = left;
while (left < right)
{
while (left < right && myarray[right] >= key)
{
--right;
}
myarray[hole] = myarray[right];
hole = right;
while (left < right && myarray[left] <= key)
{
++left;
}
myarray[hole] = myarray[left];
hole = left;
}
myarray[hole] = key;
return hole;
}
-
前后指针法分区
选择一个基准元素(通常是数组的第一个元素),将其索引记为keyi。
使用两个指针prev和cur分别指向数组的左边界和左边界的下一个位置。
从左到右遍历数组,如果当前指针cur指向的元素小于基准元素a[keyi],则将prev指针向后移动一位,并交换prev和cur指向的元素。
最后将基准元素a[keyi]与最后一个小于基准元素的元素a[prev]交换位置。
返回keyi作为分区的分界点。
这三个方法都是通过不同的指针移动方式和元素交换操作来实现数组的分区,从而将数组分成左右两个部分,
左边部分的元素都小于等于基准元素,右边部分的元素都大于等于基准元素。
这样,在快速排序算法中,可以通过递归调用这些分区方法来实现对整个数组的快速排序。
//1.最开始prev与cur相邻
//2.当cur遇到比key大的值后,它们之间的值都是比key大的值
//3.cur来找比key小的值,找到小的以后,prev+1,跟加1后的prev位置的值交换。(如果此时prev位置跟cur位置一样,可以交换也可以不交换)
//相当于把prev与cur之间比key大的值翻滚式的往右边推,同时把小的换到左边
int Partition3(int* myarray, int left, int right)
{
int mididx = GetMidIndex(myarray, left, right);
Swap(&myarray[left], &myarray[mididx]);
int prev = left;
int cur = left + 1;
int keyi = left;
while (cur <= right)
{
if (myarray[cur] < myarray[keyi] && ++prev != cur)
{
Swap(&myarray[prev], &myarray[cur]);
}
++cur;
}
Swap(&myarray[prev], &myarray[keyi]);
keyi = prev;
return keyi;
}
上面分区函数写完之后,就可以使用递归来排序了,三种方法使用哪一个都可以,下面代码使用的是第一种:
void QuickSort(int* myarray, int begin, int end)
{
// 在快速排序算法中,当需要排序的子数组的长度为0或1时,已经是有序的,不需要再进行排序。
// 因此,当begin大于等于end时,即子数组的长度为0或1,就可以终止递归,直接返回。
if (begin >= end)
return;
int keyi = Partition1(myarray, begin, end);
// [begin, keyi-1] keyi [keyi+1, end]
QuickSort(myarray, begin, keyi - 1);
QuickSort(myarray, keyi + 1, end);
}
快速排序算法效率分析
从代码和显示的结果可以看到,Partition是核心的分割函数, 这里以数组{16,1,45,23,99,2,18,67,42,10}为例进行排序,来看看是怎么样的排序过程呢?
用hoare法分区,以每一部分的首元素作为基准元素来演示:
第一次调用Partition分割,元素16将整个数组分割成了两块,第一块包括元素10、1、2,第二块包括元素99、23、18、67、42、45。
第二次调用Partition分割(第几次调用该函数如图中圆形编号所示),元素10将数组(这里的数组当然是上面已经分割开的子数组)分割出了第三块,第三块包含元素2、1。
第三次调用Partition分割,元素2将数组分割出了第四块,第四块包含元素1。
第四次调用Partition分割,元素99将数组分割出了第五块,第五块包含元素45、23、18、67、42。
第五次调用Partition分割,元素45将数组分割出了第六块和第七块,第六块包含元素42、23、18,第七块包含元素67。
第六次调动Partition分割,元素42将数组分割出了第八块,第八块包含元素18、23。
第七次调用Partition分割,元素18将数组分割出了第九块,第九块包含元素23。
至此,调用了七次Partition进行分割后,整个快速排序执行完毕。
第一次Partition调用会将数组中的n个元素,也就是从low到high之间的数据全部扫描一次,时间复杂度为O(n)。第二次、第三次……,调用Partition函数所需要扫描的数据会越来越少,都会<n,所以每次调用Partition的时间复杂度都不会超过O(n)。
其实,通过统计Partition被调用的次数来求解快速排序算法的时间复杂度,与通过统计QuickSort递归函数的调用深度来求解快速排序算法的时间复杂度是一回事。
所以,整个快速排序算法的时间复杂度为O(n*递归调用深度),这意味着快速排序算法的时间复杂度可以看成是和递归层数紧密相关。当然,快速排序算法的空间复杂度也和递归层数相关,因为每次递归都会用到栈空间来暂存很多信息。所以,快速排序算法的空间复杂度为O(递归调用深度)。
因为每次递归调用QuickSort都会把当前需要处理的区间再次划分成左右两个子区间。所以图2换一种绘制方式其实可以变成一棵二叉树,如图3所示:
图3中,快速排序把数组中的n个元素组织成了一棵二叉树,二叉树的层数也就代表着递归调用的深度。所以 快速排序算法的递归调用深度问题就可以转换为对二叉树高度范围的判断。
(下面快速排序的效率分析是优化前,默认让每一个区域首元素当作基准元素的情况)
本专栏二叉树文章中曾经讲过,对于有n个节点的二叉树,它的最小高度是⌊⌋ +1,最大高度是n(斜树)。所以,对于快速排序算法,最少的递归深度(递归层数)应该是⌊⌋ +1,而最大的递归深度应该是n,才可以完成整个排序过程。
所以,根据前面所说——整个快速排序算法的时间复杂度为O(n*递归调用深度)。不难看到,快速排序算法最好情况时间复杂度为O(),最坏情况时间复杂度为O(),平均情况时间复杂度为O()。而因为快速排序算法的空间复杂度为O(递归调用深度),所以快速排序算法最好情况空间复杂度为O(),最坏情况空间复杂度为O(),平均情况空间复杂度为O()。
设想一下,如果每一趟快速排序选中的枢轴都能够将数组元素均匀的划分为两个部分,那么调用QuickSort递归的深度就会最小,算法效率就会达到最高。 但如果数组元素原本就是有序(顺序逆序都可以)的,比如数组元素是int arr[] = { 1,2,3,4,5,6,7,8,9,10 };,那么此时枢轴就完全无法将数组元素做均匀划分,此时递归调用的深度将达到9层,此时的算法效率会达到最低。
换句话说,如果给定的数组原本就是有序的(顺序或者逆序),此时快速排序算法的性能最差。这也称为快速排序算法的退化,退化成了冒泡排序算法。
快速排序非递归法 (需要用到栈)
这里可以参考本专栏的有关栈实现的文章《深入理解栈》 ,当然下面这个代码是一种方法,高级语言C++,Java等都有官方库已经实现的栈,直接用即可。
// 快速排序的非递归方式
void QuickSortNonR(int* myarray, int begin, int end)
{
Stack st;
StackInit(&st);
StackPush(&st, end);
StackPush(&st, begin);
while (!StackEmpty(&st))
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyi = Partition1(myarray, left, right);
if (keyi + 1 < right)
{
StackPush(&st, right);
StackPush(&st, keyi + 1);
}
if (left < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
StackDestroy(&st);
}
快速排序方法之三路归并
// 快速排序方法之三路归并
void QuickSort3Ways(int* myarray, int begin, int end)
{
if (begin >= end)
{
return;
}
int left = begin;
int right = end;
int cur = left + 1;
int mididx = GetMidIndex(myarray, left, right);
Swap(&myarray[left], &myarray[mididx]);
int key = myarray[left];
while (cur <= right)
{
if (myarray[cur] < key)
{
Swap(&myarray[left], &myarray[cur]);
++left;
++cur;
}
else if (myarray[cur] > key)
{
Swap(&myarray[right], &myarray[cur]);
--right;
}
else
{
++cur;
}
}
// 小 l r 大
//[begin, left-1] [left, right][right+1, end]
QuickSort3Ways(myarray, begin, left - 1);
QuickSort3Ways(myarray, right+1, end);
}
myarray[c] < key :交换c和l位置的值,++l,++c
myarray[c] > key :交换c和r位置的值,--r
myarray[c] == key :++c
三路划分的本质:
1.小的甩到左边,大的甩到右边
2.跟key相等的值推到中间