前言:我是初学者对于许多内容也是刚刚学习不久,所以我对于许多内容会复习好多次,比如前面的那些博客都是复习好多次才写出来的,本来应该继续写基础数据结构的内容,但是复习完排序后感觉快速排序有很多细节需要记忆,很有写一篇博客的必要。
1.介绍
快速排序是一种交换排序。交换排序里还有我们熟知的冒泡排序,但是快速排序的效率可是比我们只有教学意义的冒泡排序的效率快的多得多。
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
文字对于我们理解快速排序的过程是比较抽象的,所以我们看一下下面动图:
上面是整体的快速排序方式,我们来分布看快速排序:
这里的key就是基准值,R往左走找比key小的元素找到停下,L往右走找比key大的元素找到停下,二者停下时两位置的数字惊醒交换,当R,L二者相遇时,相遇位置数字与key进行交换。
这里上面的操作就是右边找小左边找大。这样到最后我们发现key内的值最后的位置的左边都比它小,右边都比它大,达到了一个合适的位置。
上面只是演示了一个过程,大家可能还是比较疑惑是怎么让数组有序的。我们再看一个图。
通过不断地进行上面操作,数组最终会变得有序。
2.代码实现
通过上面的图片,我们不难发现如果我们使用递归的思想可以容易的完成快速排序。
void Quick_sort(int* arr, int left, int right)
{
//当left>=right时递归结束
if (left >= right)
return;
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
//右边找大
while (begin < end && arr[end] >= arr[keyi])
{
--end;
}
//左边找小
while (begin < end && arr[begin] <= arr[keyi])
{
++begin;
}
//进行交换
swap(arr[begin], arr[end]);
}
//key的值与,begin与end相遇位置的值交换
swap(arr[keyi], arr[begin]);
//利用keyi将数组分为两组
keyi = begin;
//[left,keyi-1],keyi,[keyi+1,right]
//这两组在进行快速排序
Quick_sort(arr, left, keyi - 1);
Quick_sort(arr, keyi + 1, right);
}
上面就是简单的快速排序的代码实现,我们从上往下一点点分析我们可能存在疑问的点。
1.为什么递归的终止条件是left>=right?什么时候会存在left>right区间不存在的情况。
这里我们可以看出来左区间相等只有一个元素,右区间不存在。
2.为什么第一个while循环的判断条件就是begin<end,但是第二个和第三个while循环还需要添加这个判断条件?
这里很好理解,就不进行对代码调试解释了,如果找大找小的过程中不存在这个判断条件的话,那么代码在寻找的过程中会存在找到left左边或者right的右边的情况导致出现错误,所以我们需要这个判断。
3.时间复杂度和空间复杂度
那么我们看一看快排的实力。
void Quick_text()
{
srand((unsigned int)time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand() + i;
}
int begin1 = clock();
Quick_sort(a1, 0, N - 1);
int end1 = clock();
printf("InsertSort:%d\n", end1 - begin1);
free(a1);
}
int main()
{
Quick_text();
return 0;
}
这里的N大家可以随意更改,代码的执行结果就是拍好N个数字所需要的时间,单位是毫秒。感兴趣的大家可以试一下,快速排序的效率是非常高的。
对与快速排序的时间复杂度运算是十分困难的,感兴趣的可以自己去搜索一下。本文直接上结论。时间复杂度:O(N*logN).
空间复杂度:原地快排的空间占用是递归造成的栈空间的使用,最好情况下是递归次,所以空间复杂度为,最坏情况下是递归
n-1
次,所以空间复杂度是。
4.算法优化
其实这个代码还有个问题,如果它排有个有序的数组会发生栈溢出。
如果给一个有序数组给这个代码,则会出现上面的报错。
为什么会栈溢出呢?
那么如何解决呢?
发生上面原因就是选择的key数字太极端,导致递归的深度太深而发生栈溢出,那么我们希望key是一个中间的值(不是最大或者最小)这样就不会在有序的情况下固定的去选择key的情况发生。
那么选择key就需要改进一下1.随机选择key,可以解决,但是随机毕竟是随机还是不太靠谱。
所以我们用一个更好的办法2.三数取中
int Getmidi(int* arr, int left, int right)
{
//先找到中间位置
int midi = (left + right) / 2;
//如果左边数小于中间数
if (arr[left] < arr[midi])
{
//并且中间数字小于右边数
if (arr[midi] < arr[right])
return midi;//中间数就是我们需要的数
//如果没走上一个if那么证明右边数大于中间数
//这时如果左边数小于右边数那么右边数是我们需要的数
else if (arr[left] < arr[right])
return right;
//否则左边数是我们需要的数
else
return left;
}
//如果左边数大于中间数
else//if (arr[left] > arr[midi])
{
//并且中间数大于右边数
if (arr[midi] > arr[right])
return midi;//中间数是我们需要的数
//如果没走上一个if那么证明右边数大于中间数
//这时如果左边数大于右边数则右边数是我们需要的数
else if (arr[left] > arr[right])
return right;
//否则左边数是我们需要的数
else
return left;
}
}
对于三数取中就直接上代码了,大家可以依靠注释加动手画图理解一下逻辑。
void Quick_sort(int* arr, int left, int right)
{
if (left >= right)
return;
int midi = Getmidi(arr, left, right);
swap(arr[left], arr[midi]);
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && arr[end] >= arr[keyi])
{
--end;
}
while (begin < end && arr[begin] <= arr[keyi])
{
++begin;
}
swap(arr[begin], arr[end]);
}
swap(arr[keyi], arr[begin]);
keyi = begin;
Quick_sort(arr, left, keyi - 1);
Quick_sort(arr, keyi + 1, right);
}
添加上三数取中之后,排有序的数组就不会发生栈溢出了。
还有一个可以优化地方,小区间优化。
如果每次分组是一个二分的情况下,可以把递归过程想象成一颗二叉树,如果这个二叉树是一个满二叉树,高度是h。(二叉树相关介绍会在后面的文章介绍)。如果不了解的话这里的意思就是如何减少递归次数。
如果递归后数据个数较少的我们可以利用其他排序帮助我们达到减少递归次数的目的
void Quick_sort(int* arr, int left, int right)
{
if (left >= right)
return;
//小区间优化
if ((right - left + 1) < 10)
{
Insert_sort(arr + left, right - left + 1);
}
else
{
//三数取中
int midi = Getmidi(arr, left, right);
swap(arr[left], arr[midi]);
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && arr[end] >= arr[keyi])
{
--end;
}
while (begin < end && arr[begin] <= arr[keyi])
{
++begin;
}
swap(arr[begin], arr[end]);
}
swap(arr[keyi], arr[begin]);
keyi = begin;
Quick_sort(arr, left, keyi - 1);
Quick_sort(arr, keyi + 1, right);
}
}
5.为什么要让右边先走
前面还有个疑问没有解答就是为什么让right先走left,right相遇位置的数一定会比key要小。这个疑问其实仔细想想也很好解释。
我们让R先走,而且右边是要找到比key小的数字停止,L找到比Key大的位置停止。那么就意味着有两种相遇情况,R遇上L,L遇上R。
L遇上R:R先走,停下来,R停下条件是遇到比key小的值,R停下来的位置一定比key小,L没有找到大的遇上R停下来。
R遇上L:R先走,找小,没有找到比key小的值,直接和L相遇了,但是L位置是上一次交换的位置,上一次交换把比key小的值放到了L此时的位置里面。
这样就保证了R先走的时候L,R相遇位置的值一定比key小。相反如果我们以右边做key我们可以让左边先走保证相遇位置一定比key大。结论就是一边作为key就让另一边先走。
上面都是排升序的东西。如果我们想排降序我们需要让右边找大左边找小,让大的换到左边,小的换到右边。其它不发生改变。
6.快排非递归形式
快速排序还有挖坑法和前后指针法两个版本,本篇文章就不做说明了。下面我们写一下hoare版本的快速排序非递归形式。
既然用递归方式有栈溢出的风险,那么我们可以写一个非递归来解决(这里需要用到栈这个数据结构,大家需要简单了解栈的一些特点)。
上面就是用栈数据结构模拟递归过程的解释图。
因为模拟过程需要一个栈这个数据结构,用c++写比较方便,所以用的是c++写的代码。
int Quick_keyi(int* arr, int left, int right)
{
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && arr[end] >= arr[keyi])
{
--end;
}
while (begin < end && arr[begin] <= arr[keyi])
{
++begin;
}
swap(arr[begin], arr[end]);
}
swap(arr[keyi], arr[begin]);
keyi = begin;
return keyi;
}
void Quick_sort_non(int* arr, int left, int right)
{
stack<int> st;
st.push(right);
st.push(left);
while (!st.empty())
{
int begin = st.top();
st.pop();
int end = st.top();
st.pop();
int midi = Quick_keyi(arr, begin, end);
if (midi + 1 < end)
{
st.push(end);
st.push(midi + 1);
}
if (midi - 1 > begin)
{
st.push(midi - 1);
st.push(begin);
}
}
}
上面这段代码就是对解释图写出来的,大逻辑没有变只不过将递归的地方用栈模拟实现了,所以不做更多的解释了。
总结:
快速排序就全部都完事了,可能文章写的没有其它排序文章那么好,但是每一处都是我对知识的理解用文字的方式表达出来,希望对大家能够有所帮助,各位未来的大佬们,下片文章再见。