冒泡排序应该是最简单也是最容易理解的一种排序算法,它的逻辑是每趟将最大的数移到数组的最右边:
我之前写过冒泡排序的详细说明,链接如下:冒泡排序
代码实现:
//交换函数
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void BubbleSort(int* a, int n)
{
int i,j;
for (i = 0; i < n - 1; i++)//趟数
{
int exchange = 0;//如果没有发生排序说明已经有序,exchange为0
for (j = 0; j < n - i - 1; j++)//交换的次数
{
if (a[j] > a[j + 1])//交换两个元素
{
Swap(&a[j], &a[j + 1]);
exchange = 1;//第一趟发生了排序
}
}
if (exchange == 0)//如果没发生排序直接跳出循环
{
break;
}
}
}
最好的情况:排序前的数组已经是有序的,则只需要进行n-1次比较即可,没有数据交换,时间复杂度为O(N)。
最坏的情况:排序前数组,则需要比较并交换n-1+n-2+…3+2+1次,也就是n(n-1)/2次 时间复杂度为O(N2)。
时间复杂度是最坏的情况,O(N2)
空间复杂度是O(1).
快排的性能在所有排序算法里面是最好的,数据规模越大快速排序的性能越优。快排在极端情况下会退化成 O(N2)的算法,因此假如在提前得知处理数据可能会出现极端情况的前提下,可以选择使用较为稳定的归并排序。
快速排序算法通过多次比较和交换来实现排序,其排序流程如下:
快速排序的比较和交换方式可以通过递归和非递归的形式完成,每种形式又可以通过三种方式完成:
挖坑法的实现思路是:
这样一趟排序让6的左边都是比它小的数,右边都是比它大的数,如果再将6的左区间和右区间用相同的方法递归下去,则可以实现最终的排序。
代码实现:
//挖坑法
void QuickSort1(int* a, int left, int right)//left是数组a的左下标,right是右下标
{
if (left >= right)//当只有一个数据或是序列不存在时,不需要进行操作
{
return;
}
int begin = left, end = right;//确定begin和end的位置
int pivot = begin;//选最左边左边为坑
int key = a[begin];//选最左边的元素为key
while (begin < end)//begin和end相遇时停止
{
//右边找小,放到左边
while (begin < end && a[end] >= key)//遇到大于等于key的元素就继续向右走
{
end--;
}
//小的放到坑位,自己形成新的坑位
a[pivot] = a[end];
pivot = end;
//左边找大,自己形成新的坑
while (begin < end && a[begin] <= key)//遇到比小于等于key的元素就继续往左走
{
begin++;
}
//大的放到坑位,自己形成新的坑位
a[pivot] = a[begin];
pivot = begin;
}
a[pivot] = key;//begin和right相遇后跳出循环,将key放入相遇的坑位
QuickSort1(a, left,pivot-1);//递归左区间
QuickSort1(a, pivot+1,right);//递归右区间
}
左右指针法和挖坑法差不多,唯一的区别在于左右指针法是交换两个数,挖坑法是将数放入坑里:
另外,这种方法和挖坑法排完一趟后数的顺序是不一样的。
再将6的左区间和右区间用相同的方法递归下去,则可以实现最终的排序。
//左右指针快排
void QuickSort2(int* a, int left, int right)
{
if (left >= right)//当只有一个数据或是序列不存在时,不需要进行操作
{
return;
}
int begin = left, end = right;//确定begin和end的位置
int key = begin;//key为最左边的元素
while (begin < end)
{
//找小
while (begin < end && a[end] >= a[key])
{
end--;
}
//找大
while (begin < end && a[begin] <= a[key])
{
begin++;
}
Swap(&a[begin], &a[end]);//交换
}
Swap(&a[begin], &a[key]);//相遇时和key交换
QuickSort1(a, left, begin - 1);//key的左序列进递归
QuickSort1(a, begin + 1, right);//key的右序列进行递归
}
前后指针法的步骤:
再将6的左区间和右区间用相同的方法递归下去,则可以实现最终的排序。
//前后指针快排
void QuickSort3(int* a, int left, int right)
{
if (left >= right)//当只有一个数据或是序列不存在时,不需要进行操作
{
return;
}
int key = left;
int prev = left, cur = left + 1;
while (cur <= right)//当cur>right也就是越界时,跳出循环
{
if (a[cur] < a[key] && prev++ != cur)
{
Swap(&a[prev], &a[cur]);//交换
}
cur++;
}
Swap(&a[key], &a[prev]);//交换key和prev指针指向的内容
QuickSort3(a, left, prev - 1);//key的左序列进行递归
QuickSort3(a, prev + 1, right);//key的右序列进行递归
}
如果使用非递归实现,也就意味着我们需要使用额外的操作来起到递归的效果,一般来说可以用循环或者栈模拟递归的过程,这里只能使用栈来实现。
既然要用非递归,那么我们原来的递归函数就要修改成非递归只能排一趟了,但具体的思路没有变,只是多了一个返回值:
//挖坑法
int PartSort1(int* a, int left, int right)
{
if (left >= right)//当只有一个数据或是序列不存在时,不需要进行操作
{
return;
}
int begin = left, end = right;
int pivot = begin;
int key = a[begin];
while (begin < end)
{
//右边找小,放到左边
while (begin < end && a[end] >= key)
{
end--;
}
//小的放到坑位,自己形成新的坑位
a[pivot] = a[end];
pivot = end;
//左边找大,自己形成新的坑
while (begin < end && a[begin] <= key)
{
begin++;
}
a[pivot] = a[begin];
pivot = begin;
}
a[pivot] = key;
return pivot;//返回key的下标位置
}
//左右指针法
int PartSort2(int* a, int left, int right)
{
int begin = left, end = right;
int key = begin;
while (begin < end)
{
//找小
while (begin < end && a[end] >= a[key])
{
end--;
}
//找大
while (begin < end && a[begin]<= a[key])
{
begin++;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin], &a[key]);
return key;//返回key的下标位置
}
//前后指针法
int PartSort3(int* a, int left, int right)
{
int index = GetMidIndex(a, left, right);
Swap(&a[left], &a[index]);
int key = left;
int prev = left, cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[key]&&prev++!=cur)
{
prev++;
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[key], &a[prev]);
return prev;
}
利用栈先进后出的特点,用栈实现非递归的思路:
在这里需要用到栈的函数,在之前的文章中有线性表之栈和队列
void QuickSortNonR(int* a, int n)
{
ST st;//创建一个栈
StackInit(&st);//初始化栈
//栈里的区间就是需要被单趟分割排序的
StackPush(&st, n - 1);//将最后一个元素下标入栈
StackPush(&st, 0);//将第一个元素下标入栈
while (!StackEmpty(&st))//栈不为空则执行循环
{
int left = StackTop(&st);//获取左边第一个元素的下标
StackPop(&st);//弹出左边第一个元素的下标
int right = StackTop(&st);//获取右边第一个元素
StackPop(&st);//弹出右边第一个元素的下标
int keyIndex = PartSort1(a, left, right);//快排并获取返回的key的位置
//这一次快排以后,数组的区间如下:
//[left,keyIndex-1]keyIndex[keyIndex+1,right]
//接下来只需要将左右区间的left,right压栈即可
if (keyIndex + 1 < right)
//如果keyIndex + 1 >= right说明右区间没有元素,不用入栈
{
StackPush(&st, right);//右区间的最后一个元素入栈
StackPush(&st, keyIndex + 1);//右区间第一个元素入栈
}
if (left < keyIndex - 1)
//如果left > keyIndex - 1说明左区间没有元素,不用入栈
{
StackPush(&st, keyIndex - 1);//左区间最后一个元素入栈
StackPush(&st, left);//左区间第一个元素入栈
}
}
StackDestory(&st);//销毁栈
}
注意入栈和出栈的顺序不要弄错。如果是先取left后取right,则应该先入right,后入left。
理想情况下,对于一个大小为N的数组,每次递归的左右区间中的元素个数如果都相同,那么只需要调用log2N次即可。
但是当数组有序或者接近有序时,我们若是依然每次都选取最左边作为key,这就会导致左边区间根本就没有数,右边区间的数是除了key剩下的数,这样就必须调用N次递归了,那么快速排序的效率就会非常低。
三数取中的意思是取数组左边,中间,右边三个数中不是最大也不是最小的那个数,然后返回下标,接着交换这个数和最左边的数即可:
//三数取中
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) / 2;//数组中间元素的下标
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
else
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
}
取中以后只需要在快排函数的最前面交换这个数和最左边的数:
int index = GetMidIndex(a, left, right);
Swap(&a[left], &a[index]);
通过三数取中后,递归后的key就不会在靠近中间的位置,减少了调用次数,提高了效率。
通过这张图不难看出来,当划分的左右子区间的元素个数很小的时候(一般为十几个),需要调用的次数会非常多,这时候使用别的排序算法比如直接插入排序比快速排序更加的高效。
//小区间优化的快速排序
void QuickSort(int* a, int left,int right)
{
if (left >= right)
{
return;
}
int keyIndex = PartSort1(a, left, right);
//排序完成以后,从坑位分成三部分
//[left,pivot-1] pivot [pivot+1,right]
if (keyIndex - 1 - left > 10)//如果区间范围小于10,则不递归
{
QuickSort(a, left, keyIndex - 1);
}
else
{
InsertSort(a + left, keyIndex - 1 - left + 1);//使用直接插入排序
//a + left是左区间的起始下标,keyIndex - 1 - left + 1是左区间元素个数
}
if (right - (keyIndex + 1)>10)//如果区间范围小于10,则不递归
{
QuickSort(a, keyIndex + 1, right);
}
else
{
InsertSort(a + keyIndex +1, right-(keyIndex +1)+1);//使用直接插入排序
//a + keyIndex +1是右区间的起始下标,right-(keyIndex +1)+1是右区间元素个数
}
}
如果没有优化:每次调用都会遍历一次传入的数组,所以调用一次的时间复杂度是O(N),而最好的情况就是key每次都是中间的位置,这样只需要调用log2N次,而最坏的情况需要调用N次,所以最好的时间复杂度是 O(Nlog2N),最坏的时间复杂度是O(N2)。
优化后,key一般都会在靠近中间的位置,所以优化后的时间复杂度一般为O(Nlog2N)
每次递归调用都会开辟空间,所以空间复杂度最好的情况是O(Nlog2N),最坏的是O(N2)。
平均来看:
时间复杂度为O(NlogN)
空间复杂度为O(NlogN)