快速排序和冒泡排序均属于交换排序范畴,意味着其基本操作是交换两数。
快速排序,顾名思义快速的排序,毫无遮拦得介绍了自己得特征。而冒泡排序也正如其名称,如同养鱼冒泡一样慢条斯理锝排序。(说笑了,哈哈哈)
本文所提及的算法测试时,使用随机数来进行排序算法的测试,其中随机数产生的方法请见
https://blog.csdn.net/Fairchild_1947/article/details/118757154
下面言归正传,介绍两种排序得原理和详细的测试过程。时间、空间复杂度的数量级和真实运行中消耗的时间和内存空间的对比,将于文末展示。
首先,上考点
算法名称 | 时间复杂度 | 空间复杂度 | 是否稳定 | ||
---|---|---|---|---|---|
最好情况 | 平均情况 | 最坏情况 | |||
快速排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
冒泡排序 | O(nlog2n) | O(nlog2n) | O(n²) | O(log2n) | 否 |
快速排序在排序有序数列时,时间复杂度将为O(n²),且其一般由于使用递归的运行方式,其空间复杂度也将达到O(n)。顾在使用快速排序时,需要注意其非常不适合排序已经有一定顺序的序列。(该现象会在后面的测试部分进行实际的测试)
快速排序在理想状态下,每次选择的枢轴都是该区域正中间的数值,以此进行下去,其递归运行的过程类似于该数列的平衡二叉树,其空间复杂度的O(log2n)也是因为平衡二叉树总共有log2n层,故在递归时需要log2n层的压栈。(实际的内存资源消耗也会在其后的测试部分具体体现)
既然上文已经提到了快速排序,那就请牛逼哄哄的快速排序先上场了,快速排序将会把第一个数值选定为枢轴元素,并将比枢轴大的元素移动到枢轴后面,将比枢轴小的元素移动到枢轴前面,最终枢轴将会移动到其排序后的最终位置。此后,将以枢轴为接线划分为前后两部分,再将前后两部分重复上述操作,选定第一个元素为枢轴,并将比枢轴大的移动到枢轴后面,将比枢轴小的...... 以此一次次迭代下去,最终将会把所有的元素放在其最终的位置上。
快速排序的动态演示如下:
图中红色元素为选中元素,黄色元素为枢轴元素,绿色元素为小于枢轴的元素,紫色的元素为大于枢轴的元素,橙色元素为已经确定最终位置的元素。
代码分为下标使用无符号数的版本和有符号数的版本,因为下边不可能为负数,但是在排序的过程中,下标变量有时会变成幅值,如(i>=0)这样的判断,若i为无符号数,其将满足条件,顾在程序运行时会引发异常。
其次代码部分与王道书本主要的不同之处有两处:
1.单独定义变量用于哨兵而不是在数组中,因为这是非常不显示的事情,因为一般情况下,需要排序的数组中数据都是从下标为0号的元素开始存放的,若要将0号元素作为哨兵则代码失去了很好的移植性。
2.形参从(数组首地址,数组长度)改为(数组首地址,开始排序的下标,结束排序的下标),这样可以满足对数组中任意部分排序,而不是死板地每次排一整个数组。
3.变量定义均在函数开头,由于C51编译器无法支持C99模式,在函数中定义变量将会导致报错,为满足移植此排序算法到8051单片机,代码编写均向下兼容C51编译器。
最后,在功能方面,该函数这对无符号32位整型数组排序,若需要为其它类型的数据排序,可直接修改数组数据类型。
下标无符号版本:
/* 计算枢轴位置并将枢轴元素移动到最终位置 */
int Partition(uint32_t array[], uint32_t start, uint32_t end)
{
uint32_t pivot = array[start]; //获取枢轴
while(start < end){
while(start < end && array[end]>=pivot) end--; //必须使用>=若仅仅使用>则会在array[start]和array[end]的数值相等时死循环
array[start] = array[end]; //找到小于枢轴的元素,交换到枢轴左侧
while(start < end && array[start]<=pivot) start++;//必须使用>=若仅仅使用>则会在array[start]和array[end]的数值相等时死循环
array[end] = array[start]; //找到大于枢轴的元素,交换到枢轴右侧
}
array[start] = pivot;
return start;
}
/* 快速排序算法,无符号整形,无符号下标版本 */
void QuickSort_UINT32(uint32_t array[], uint32_t start, uint32_t end)
{
uint32_t pivotpos; //枢轴元素位置信息变量定义
if(start < end){
pivotpos = Partition(array, start, end);//获取枢轴元素位置信息
if(pivotpos > 0){
QuickSort_UINT32(array, start, pivotpos-1); //对枢轴左侧元素进行排序,由于下标信息使用无符号数定义,顾需要额外判断是否大于零
} else{
;
}
QuickSort_UINT32(array, pivotpos+1, end);//对枢轴右侧元素进行排序
}
}
有符号版本:
/* 计算枢轴位置并将枢轴元素移动到最终位置 */
int Partition_UINT32(uint32_t array[], int32_t start, int32_t end)
{
uint32_t pivot = array[start]; //获取枢轴
while(start < end){
while(start < end && array[end]>=pivot) end--; //必须使用>=若仅仅使用>则会在array[start]和array[end]的数值相等时死循环
array[start] = array[end]; //找到小于枢轴的元素,交换到枢轴左侧
while(start < end && array[start]<=pivot) start++;//必须使用>=若仅仅使用>则会在array[start]和array[end]的数值相等时死循环
array[end] = array[start]; //找到大于枢轴的元素,交换到枢轴右侧
}
array[start] = pivot;
return start;
}
/* 快速排序算法,无符号整形,无符号下标版本 */
void QuickSort_UINT32(uint32_t array[], int32_t start, int32_t end)
{
uint32_t pivotpos; //枢轴元素位置信息变量定义
if(start < end){
pivotpos = Partition_UINT32(array, start, end);//获取枢轴元素位置信息
QuickSort_UINT32(array, start, pivotpos-1); //对枢轴左侧元素进行排序,由于下标信息使用无符号数定义,顾需要额外判断是否大于零
QuickSort_UINT32(array, pivotpos+1, end);//对枢轴右侧元素进行排序
}
}
有符号版本与无符号版本相比较减少了对枢轴元素位置是否为零的判断,在实际运行时略快于无符号版本。但是有符号版本千万要注意start和end必须使用大于0的数值,否则将会导致将负值作为数组下标导致访问越界。
接下来,测试该算法
测试内容分别为:对长度为32的随机数表排序并完整显示排序结果验证算法的正确性、对长度为1024的随机数表排序的时间和空间使用、对长度为1024的有序数表进行排序的时间和空间使用。
测试环境部分要点说明:
1.时间复杂度测试,毫秒级别的使用FreeRTOS嵌入式实时操作系统提供的osKernelGetTickCount()(原函数为xTaskGetTickCount())函数分别在排序算法运行前和运行后获取时间戳,相减得到时间长度。微秒级别的时间使用定时器测量原理与毫秒级别测试相似,在算法执行之前清零并打开定时器算法结束时读取定时器数值并关闭定时器。通过毫秒级和微秒级分别测量,可以避免定时器计满溢出的问题。
2.空间复杂度测试使用FreeRTOS嵌入式实时操作系统提供的内存最大水位线测试函数osThreadGetStackSpace()(原函数为uxTaskGetStackHighWaterMar())进行测试,分别在算法运行之前和运行之后测试两次,并得出内存的使用情况。
使用长度为32的随机数表验证算法的正确性:
使用长度为1024的随机数表进行算法性能测试:
快速排序若遇到局部有序的序列,将不能很有效得划分区域得到近似一个平衡二叉树,会导致递归的深度变深,递归的次数变多导致时间和空间使用增加。
上文提到快速排序不利于排序有序数列,接下来做个最坏的情况的测试,使用冒泡排序先将随机数表排序成有序,再使用冒泡排序进行排序,可以看到此时快速排序确实需要大量的时间和空间。
快速排序的测试完成了,接下来轮到冒泡排序了
冒泡排序顾名思义,就是做着冒泡得动作完成排序。如同在水中用吸管在水的底部吹入油滴,油滴先和水比密度,油滴小,所以上浮,到达水表面后再和空气比密度,油比空气密度大所以不再上浮。同理,将空气通过吸管吹入水底部,空气先与水比密度,空气密度小,待上浮到水表面后,再和油比密度,空气密度小,所以继续上浮到油表面。
通过这个例子可以看出,冒泡排序的特点是:逐个比较,不断上浮。
冒泡排序的动画演示如下:
图中绿色部分为逐个比较时正在比较的两个元素,橙色部分为确定最终位置的元素。
冒泡排序在具体实现上,主要分为比较和上浮两个动作进行实现。其中比较形式确定,直接编写在算法函数内,而上浮动作是两数交换的动作,两数交换的动作具体实现分为借助第三变量和不借助第三变量两种方式。
冒泡排序算法主体代码:
void BubbleSort_UINT32(uint32_t array[], uint32_t start, uint32_t end)
{
uint32_t i,j;
bool flag;
for(i=start; i<end; i++){
flag = false;
for(j=end; j>i; j--){
if(array[j-1]>array[j]){
Swap_UINT32(&array[j-1], &array[j]); //借助第三变量交换两元素
//Swap_UINT32_XOR(&array[j-1], &array[j]);//不借助第三变量交换两元素
flag = true;
}else{
;
}
}
if(flag == false){
return;
}
}
}
借助第三变量交换两元素代码:
void Swap_UINT32(uint32_t *a, uint32_t *b)
{
uint32_t temp;
temp = *b;
*b = *a;
*a = temp;
}
不借助第三变量交换两元素代码:
void Swap_UINT32_XOR(uint32_t *a, uint32_t *b)
{
*a = (*a)^(*b);
*b = (*a)^(*b);
*a = (*a)^(*b);
}
接下来,测试此算法。
测试内容有:分别使用两种交换两元素的方法对长度为32的随机数表排序验证算法的正确性、分别使用两种交换两元素的方法对长度为1024的随机数表进行排序并测定算法使用的时间和空间。
测试环境同快速排序。
冒泡排序正确性测试:
使用借助第三变量交换两数的方式排序随机数表:
不借助第三变量交换两数的方式排序随机数表:
现在交换排序的两种算法都有了结果,下面将对比起时间、空间复杂度与运行过程中真实消耗的时间和空间。
1.时间复杂度:冒泡排序O(n²),快速排序O(nlog2n),直接对复杂度做比可得,对于相同的数据表,冒泡排序与快速排序的速度比为n/log2n,本文所述测试环境中,均使用长度为1024的数组,代入计算得,冒泡排序应当比快速排序大约慢100倍。实际的情况是,冒泡排序大约在200MS左右,快速排序在4.7MS左右,虽然不是100倍,但数量级相同。
2.空间复杂度:冒泡排序O(1),快速排序O(log2n)。通过实际测试可得,冒泡排序在运行时,确实没有使用内存资源。而快速排序使用的内存量带入数据表的长度1024得,使用10个内存。在实际使用时,由于本测试平台使用的STM32单片机内部的处理器是Cortex-M3,该处理器为32位不带浮点单元的处理器,所以在递归调用时保存的断点信息、部分寄存器值和中间变量值,所以实际大约需要40字左右的空间,该大小符合数量级。
综上所述,可以明显看出,快速排序是远快于冒泡排序的,但是快速排序消耗的内存资源也是比较多的,尤其在遇到有序序列时,会吞下非常巨大的内存。这使得在设计使用快速排序算法的程序时,必须要考虑到数表的实际情况,是否有可能出现有序序列,其次也需要为快速排序算法留出比较大的内存空间。