1.排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
2.交换排序
2.1基本思想
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
2.2冒泡排序
冒泡排序是一种基于比较和交换操作的排序算法。 每轮冒泡的过程都是从第一个元素开始,将该元素和相邻下一个元素进行比较和交换,使得较大的元素向右移动(如果该元素大于下一个元素,则两个元素交换;如果该元素小于等于下一个元素,则保持不变)。这样一来,每轮冒泡的过程都可以确定一个元素放在正确的位置上,而这个元素就是剩余元素中最大的元素,正确的位置就是剩余位置中的最右侧的位置。这个过程就像是气泡上浮一样,所以叫做冒泡排序。
动图如下:
具体代码如下:
//交换
void Swap(int* a, int* b)
{
int tmp = 0;
tmp = *a;
*a = *b;
*b = tmp;
}
//冒泡排序
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; i++)
{
//exchange是用来标记一趟排序是否进行了元素交换
int exchange = 0;
//for循环中j < n-1-i ,是因为每走完一趟,待排序的元素就少一个
for (int j = 0; j < n - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
//如果exchange==0,那说明这一趟没有进行元素交换,冒牌排序可以直接结束
if (exchange == 0)
{
break;
}
}
}
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
如果数组的n个元素原本就逆序,而我们要把它排成顺序,这时元素交换的次数是最多的,第一趟交换n-1次,第二趟交换n-2次,逐次递减,满足等差数列,用大O渐近算法求得时间复杂度为O(N^2)。 - 空间复杂度:O(1)
- 稳定性:稳定 (元素相同时不会进行交换操作)
2.3快速排序
2.3.1快排的递归实现
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
这个过程就是递归实现的,下面先给出递归的大致过程,具体一趟排序怎么去实现还要具体讲。
// 假设按照升序对array数组中[left, right]区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
//结束递归的条件
if(left>=right)
return;
// 按照基准值对array数组的 [left, right]区间中的元素进行划分(待实现)
int mid = partion(array, left, right);
// 划分成功后以mid为边界形成了左右两部分 [left, mid-1] 和 [mid+1, right]
// 递归排[left, mid-1]
QuickSort(array, left, mid);
// 递归排[mid+1, right]
QuickSort(array, mid+1, right);
}
将区间按照基准值划分为左右两半部分的常见方式有:
1. hoare版本:
区间为 [left,right] ,一般选区间第一个元素作为基准值,在 left < right 的前提下,从下标为 right 的位置开始找,找比基准值小的元素,如果找到了就停在那,再从下标为 left 的位置开始找,如果找到了就停在那,然后将这两个位置的元素交换,直到 left >= right ,再将基准值和 left 位置的值交换。这样该区间排序结束,基准值被放到了正确的位置。
动图如下:
具体分析代码:
//交换
void Swap(int* a, int* b)
{
int tmp = 0;
tmp = *a;
*a = *b;
*b = tmp;
}
int partSort1(int* a, int begin, int end)
{
int keyi = begin;
while (begin < end)
{ //先从右边比起
while (begin < end && a[end] >= a[keyi])
{
end--;
}
//再从左边走起
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi], &a[begin]);
keyi = begin;
//返回基准值正确位置的下标
return keyi;
}
2. 挖坑法
区间为 [left,right] , 一般将区间的第一个元素给key,这时区间第一个元素的位置就相当于被挖去元素后的坑,在left < right 的前提下,先从下标为right的位置开始往左找小于key的元素,如果找到了就将元素填到左边的坑里,此时自己又变成了坑,再从left开始往右找大于key的元素,找到了就填到右边的坑里,此时自己又变为坑。接着重复上面的过程,直到left>=right就结束。
动图如下:
代码具体分析:
//交换
void Swap(int* a, int* b)
{
int tmp = 0;
tmp = *a;
*a = *b;
*b = tmp;
}
int partSort2(int* a, int begin, int end)
{
//将区间第一个元素赋给key
int key = a[begin];
//将begin所在的位置标记为hole
int hole = begin;
while (begin < end)
{
while (begin < end && a[end]>=key)
{
end--;
}
//将找到的值给hole所在的位置
a[hole] = a[end];
将end所在的位置标记为hole
hole = end;
while (begin < end && a[begin] <= key)
{
begin++;
}
//将找到的值给hole所在的位置
a[hole] = a[begin];
//将begin所在的位置标记为hole
hole = begin;
}
//最终将key的值给hole所在的位置
a[hole] = key;
//返回key值在区间中正确位置的下标
return hole;
}
3. 前后指针版本
区间为 [left,right] ,一般选区间第一个元素为基准值,下标为key,再定义一个prev和cur分别等于key和key+1,在 cur<=right 的前提下,先检查cur所指位置的元素是否小于基准值,如果不是则cur++,prev不变,如果是小于基准值,则prev++,再将cur和prev所指位置的值进行交换,prev++。重复上述过程,直到cur>right 时再将基准值和此时prev所指位置的元素进行交换,到此时这一趟排序结束,基准值被放到正确的位置。
//交换
void Swap(int* a, int* b)
{
int tmp = 0;
tmp = *a;
*a = *b;
*b = tmp;
}
//双指针版
int partSort3(int* a, int begin, int end)
{
int cur = begin + 1;
int prev = begin;
int keyi = begin;
while (cur <= end)
{
//如果++prev之后prev==cur,那就可以不用交换了
if (a[cur]<a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[prev], &a[keyi]);
//keyi=prev之后,keyi为基准值正确位置的下标
keyi = prev;
return keyi;
}
具体分析代码:
下面为快排递归实现的代码,只要调用上面任意一种实现方法的函数就可以实现快排。
//快排
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
//调用的函数(任选1个)
//int mid=partSort1(a, begin, end);
//int mid = partSort2(a, begin, end);
//int mid = partSort3(a, begin, end);
QuickSort(a, begin, mid - 1);
QuickSort(a, mid + 1, end);
}
以上就是用快排实现排序的三种方法。
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)(logN就是以2为底N的对数)
用快排来实现一个数组的排序,可以把它的整个过程看成是一棵普通的二叉树(排除掉极端情况,也就是造成时间复杂度更差的情况),而看成一棵满二叉树求得的时间复杂度和看成一棵普通二叉树时求得的时间复杂度又是一样的。看成一棵满二叉树时,树的高度为 log2,二叉树的第一层确定了一个基准值的正确位置,可以大致看成是和基准值进行了n次比较,第二层又确定了2个基准值的正确位置,也可以大致看成和基准值进行了n次比较,并不影响最终时间复杂度的结果,就这样总的时间复杂度大致O(N*logN)
3. 空间复杂度:O(logN)
一般情况下二叉树的高度就是logN,相当于最多连续开辟了logN个栈区,每个栈去开辟了常数个变量,那空间复杂度就是O(logN)。
- 稳定性:不稳定
2.3.2快排递归实现的优化
2.3.2.1三数取中优化
到目前为止,快排递归实现一般情况就介绍完了,但我们仔细观察会发现一些可以优化的地方,比如待排序的元素是逆序或顺序的情况下,快排的时间复杂度会变成O(N^2),如果递归的深度很深,还会造成栈溢出。那为什么会这样,怎么解决这个特殊情况呢?
当数组为升序时,进行快排,确定基准值的正确位置后,会发现被划分的两个区间中左区间是空的,只有左区间有元素,这样构成的二叉树的高度为N,那它的时间复杂度就变成了O(N^2)。数组为逆序时,右区间没有元素。
为了解决顺序或逆序的的极端情况,我们就要想办法破坏它的顺序或逆序。
这里我们用三数取中法。
三数取中法就是取一个区间的第一个元素、最后一个元素和中间元素 (基准值取区间第一个位置的元素) ,这三个元素如果构成顺序或逆序,从三个元素中选一个和基准值交换,交换后这三个位置的元素不再是顺序或逆序的,这样就可以将极端情况化为一般情况。(注意三数取中并不能优化部分元素相等或全部相等的情况)
三数取中具体代码:
//三柱取中
//这个函数的作用就是当区间元素为顺序或逆序时,从三个数中选择一个元素并返回它的下标
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
//下面函数就是判断该返回哪个元素的下标
//我们可以先把两个元素相等和三个元素相等的情况划分到一类,这类我的返回值都是end
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else //a[begin]>=a[mid]
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
//快排
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
//下面这两行代码就是调用函数,用mid接收它的返回值,然后将它们两个元素进行交换
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
//int mid=partSort1(a, begin, end);
//int mid = partSort2(a, begin, end);
int mid = partSort3(a, begin, end);
QuickSort(a, begin, mid - 1);
QuickSort(a, mid + 1, end);
}
2.3.2.2小区间优化
快排的递归实现,如果元素特别多,那会有大量的递归调用,那这时单纯的递归调用也需要很多时间。递归调用我们可以类似看成二叉树的先序遍历过程,而二叉树的特点是最下面几层的结点就占了整棵树的大部分,我们可以选择一个适当的常数,当区间元素个数小于或等于这个常数时,就不用递归函数来实现了,我们可以选择直接插入函数来实现。这样也可以起到一定的优化效果,但不是很明显。
//快排
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
//如果end-begin+1<15就用直接插入函数,否则还是用递归函数来实现
if((end-begin+1)<15)
{
InsertSort(a+begin,end-begin+1);
}
else
{
//下面这两行代码就是调用函数,用mid接收它的返回值,然后将它们两个元素进行交换
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
//int mid=partSort1(a, begin, end);
//int mid = partSort2(a, begin, end);
int mid = partSort3(a, begin, end);
QuickSort(a, begin, mid - 1);
QuickSort(a, mid + 1, end);
}
}
2.3.2快排的非递归实现
快排的递归实现虽然看起来很好,但最大的问题就是当递归深度很深的时候可能发生栈溢出,而且有时候随着元素的增多,时间复杂度会很差。这个时候就不得不用非递归的方式来实现了。
快排递归实现的递归流程图中,每次确定了一个基准值(取区间第一个元素)的正确位置下标后,都可以确定基准值左右两个区间的下标范围,分别是[begin,mid-1]、[mid+1,end],但只会先递归调用左边区间,直到左边区间递归调用结束了,才会递归调用右边区间。如上图,当递归调用完第3步的时候,不满足继续递归调用的条件了,这时才结束次左区间的递归调用,再去递归调用右区间,也就是第4步。从这里我们可以发现左区间递归调用结束后,最后被划分出来并确定下标范围的区间却最先被递归调用,看到最后、最先这几个字眼我们应该第一时间想到栈,栈不就是这样吗,我们可以每次把确定的左右区间的下标压栈,然后只把左区间出栈,这样就可以非递归实现了。(栈的内容前面已经讲过了)
代码具体分析:
void QuickSortNonR(int* a, int begin, int end)
{
ST st;
StackInit(&st);
//先把待排序区间的左右下标压栈
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
//出栈两个下标,并分别赋给right和left
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
//将出栈的两个下标范围内的区间排序,并得到左右区间的下标
int keyi = partSort1(a, left, right);
//[left,keyi-1][key+1,right]
//先判断右区间的下标是否满足压栈的条件,满足的话就压栈
//再同样的判断左区间的下标,如果满足压栈条件则压栈
if (keyi + 1 < right)
{
StackPush(&st, keyi + 1);
StackPush(&st, right);
}
if (left < keyi - 1)
{
StackPush(&st, left);
StackPush(&st, keyi-1);
}
}
}
以上就是快排的相关内容,内容虽然比较多,但只要一点点的去搞懂,那对我们还是很有帮助的。