摘要
1. 快速排序其实也是分而治之的思想
2. 快速排序是递归的
3. 首先找一个基准点,把比基准点小的数字都放到它的左边,比它大的数字都放在它的右边,一趟下来基准点的位置找到了,且它左边的数字小于(等于)它,右边的数字大于(等于)它。 再递归地对它左边的数字和右边的数字做同样的操作,直到递归结束则整个数组有序。 一般的实现需要两个指针,low初始指向需要排序的数据的头部,high指向需要排序的数据的尾部。low向尾走,找寻比基准点大的数字;high向头走,找寻比基准点小的数字,然后交换数字位置,相遇了则一次递归调用结束。不同的实现细节上会略有差异,但思想大概都是这样。
4. 快速排序的时间复杂度是O(N*logN), 最坏时间复杂度是O(N^2)
5. 基准点的选择,可以选择每段的第一个元素为基准点,但这样在极端的情况下时间复杂度可能会退化为O(N^2)。 可以采用每次随机选择元素作为基准点。
详解
排序的步骤为:
1. 首先选择基准点,以数据段的第一个元素作为基准点即可,即使是采用随机的基准点的方法,也是随机选择元素的下标,将选择的元素和第一个元素互换。将基准点的数字保存在变量num中。
2. 设置i,j 两个下标(第一趟排序时i=0(数组开头), j=length-1(数组结尾),后面递归时,i每次都指向本次要排序的数据段(原数组的一部分)的开头元素,而j每次都指向结尾元素)
3. 每次都先移动j,往左一直找到<num的元素停下,然后移动i, 一直找>num的元素或者与j相遇,将i和j所指的元素交换。直到i和j相等,他们的位置就是基准点的位置,把这个位置的元素跟基准点原来的位置,也就是最左边的位置互换。至此第一趟排序结束,基准点的位置(下标i)就是整体排序完成后它应该在的位置,它左边的数字都小于等于它,右边的数字都大于等于它。这时数组就被基准点分为了两半,左半段下标为[0~i-1], 右半段的下标为[i+1,length-1].
4. 递归对左半段和右半段进行上面第3步的操作。每一段的开头下标为s(start),结尾下标为e(end). 程序结束的条件是s>=e(本段没有数字或者只有一个数字)
先上代码:
1 public static void quickSort_NG(int[] testArr,int s, int e) { 2 if(s >= e) {return;} 3 int p = testArr[s];//p为上文中的num 4 int l = s, r=e; //l为上文中的i,r为j 5 while(l<r) { 6 while(testArr[r] >= p && l<r) {r--;} 7 while(testArr[l] <= p && l<r) {l++;} 8 int temp = testArr[l]; 9 testArr[l] = testArr[r]; 10 testArr[r] = temp; 11 12 } 13 testArr[s] = testArr[l]; 14 testArr[l] = p; 15 quickSort_NG(testArr,s,l-1); 16 quickSort_NG(testArr,l+1,e); 17 18 } 19 public static void main(String[] args) { 20 int[] testArr = {4,3,8,1,-1,2,5,4}; 21 quickSort_NG(testArr,0,testArr.length-1); 22 }
步骤推演和图解:
如果上面的说明和代码仍然比较抽象的话,看下面每一步的推演和图示一定可以明白:
仍然是用代码中的例子, 用一个有8个元素的数组[4,3,8,1,-1,2,5,4] ,包含一个重复数字4.
下面的表格是每一步的操作过程:
其中每一次排序的数据用相同颜色的底纹。
下标 0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
4 |
3 |
8 |
1 |
-1 |
2 |
5 |
4 |
i |
|
|
|
|
|
|
j |
Num=4,先移动j,向左一直到找到<num的数字,来到2的位置,然后移动i,向右一直找到>num的数字,来到8的位置,如下: |
|||||||
|
|
i |
|
|
j |
|
|
交换i j 位置的元素 |
|||||||
4 |
3 |
2 |
1 |
-1 |
8 |
5 |
4 |
重复上面i,j 移动的逻辑,j来到-1的位置,i向右移动,没有找到>num的数字,与j在-1处相遇 |
|||||||
|
|
|
|
i j |
|
|
|
i j 相遇的位置为num在排序完后应该在的位置,把它的位置的数字与num原来的位置(也就是本段开头的位置)的数字互换,这里就是4和-1互换位置 |
|||||||
-1 |
3 |
3 |
1 |
4 |
8 |
5 |
4 |
至此第一趟排序结束,枢轴的下标位置为4,左半段下标为[0~3],右半段下标为[5~7],下面进入递归进入其左半段的排序 |
|||||||
-1 |
3 |
3 |
1 |
|
|
|
|
i |
|
|
j |
|
|
|
|
Num = -1, j先向左移动,一直没有找到<-1的数字,直到与i相遇,此趟排序没有变化。枢轴的位置为下标0,左半段[0~-1], 右半段[1~3]。 此时还是进入左半部分的递归,但左半部分的左边界0>右边界-1,直接退出,进入右半部分 |
|||||||
|
3 |
3 |
1 |
|
|
|
|
|
i |
|
j |
|
|
|
|
Num=3, j先移动,它目前所在位置的1<num,所以不动,移动i,没有找到>num的数字,在1处于j相遇 |
|||||||
|
|
|
i j |
|
|
|
|
i j 相遇的位置就是排序完成后num应该在的位置,把它的位置的数字与num原来的位置(也就是本段开头的位置)的数字互换,这里就是3和1互换位置 |
|||||||
|
1 |
3 |
3 |
|
|
|
|
本趟排序结束,枢轴的位置为下标3,左半段为[1~2],右半段为[4~3],递归进入左半段的排序 |
|||||||
|
1 |
3 |
|
|
|
|
|
|
i |
j |
|
|
|
|
|
Num=1, 先移动j,直接与i在1的位置相遇。本次排序结束,进入右半段的排序,但是右半段[4~3],左边界>右边界,表明了没有数字,直接退出。至此整体的左半段排序完成。方法调用来到整体的右半段 |
|||||||
-1 |
1 |
3 |
3 |
4 |
8 |
5 |
4 |
|
|
|
|
|
i |
|
j |
Num=8,先移动j,它当前所在的4<num,先不动,移动i,没有找到>num的数字,在4的位置与j相遇。交换8和4的位置 |
|||||||
|
|
|
|
|
|
|
i j |
|
|
|
|
|
4 |
5 |
8 |
此趟排序完成,枢轴的位置为下标7, 左半段为[5~6],右半段为[8~7],递归进入其左半段的排序 |
|||||||
|
|
|
|
|
4 |
5 |
|
|
|
|
|
|
i |
j |
|
Num=4, 先移动j,在4处与i相遇,4与自己交换,本次排序结束。右半段的左边界8>右边界7,证明没有数字,直接退出 |
|||||||
|
|
|
|
|
ij |
|
|
至此排序结束 |
|||||||
-1 |
1 |
3 |
3 |
4 |
4 |
5 |
8 |
下面再用图概括一下整体的过程:
图中红色的数字为每一次进入quickSort_NG方法的顺序。我们可以发现,这个顺序其实就是二叉树的深度优先遍历。
关于时间复杂度
众所周知,快速排序的时间复杂度是O(N*logN), 其实上面的算法在最坏的情况,时间复杂度可以退化到O(N^2)。
我们先看一下时间复杂度是如何计算的。
其实很自然的我们就可以想到,这个算法所花费的时间就是:方法调用所花费的时间*方法递归调用的次数。
那么我们先看一次方法调用所花费的时间,我们在一次方法中所进行的操作(除了递归调用自己之外)是:
1. 左右指针i, j 对数组进行了一次遍历,操作次数为此段数组的长度n
2.遍历的时候会与基准点的数字进行比较,n个数字比较n次
3.最后交换一下基准点的位置,操作次数为1
所以操作次数为2n+1, 我们知道算时间复杂度的时候会忽略掉常数, 而且是只去最高幂次项。所以每一次调用的时间复杂度为O(N), N为每次调用的数组长度。我们可以简单的认为每次方法调用所耗费的时间是N个单位时间,其中N是本次调用的数组长度。
那么我们会递归调用多少次呢。为了看得更清晰,我们举一个最好情况的例子,假设我们有8个数字,而每次我们选择的基准点的位置都能把数组分为大概相等的两部分,如下图:
第一层初始调用时有8个元素,第一趟排序后分成比较均匀的两段,然后递归地调用。其实此处不是很准确, 每次排序完基准点的位置就确定了,不用参与到后面的排序,所以理论上第一次排序参与下次排序的是7个元素,均匀的情况也不是分成4,4的两段, 可能是4,3或者3,4,为了直观才这样画,便于理解。
我们可以看到有4层的方法调用,其实就是(log8+1), 第一层方法调用一次,元素个数是8, 第二层方法调用两次,每次元素个数是4.以此类推。 我们上面又推导出每次方法调用的时间是跟该次调用的元素个数线性相关的。那么本次排序所花费的总时间是(每一层的方法调用时间加起来):
8+4*2+2*4+1*8 个单位时间。发现每层调用的时间相同,都是8,我们把8替换成N,可以发现是N+N+N+N,也就是4个N,4这个数字其实是log8+1, 同样把8替换成变量N,我们可以得到时间是(logN+1)*N. 由于时间复杂度只保留最高幂次项,结果为O(logN*N)
改进空间
现在我们考虑下面这组数字:
1,2,3,4,5,6,7,8,9
仍然用上面的方法,发现每次只能搞定一个数字,每趟排序完都是只有右半段有数据,左半段是空。调用层数增加,这时候的时间复杂度退化成了O(N^2).
改进的思路有两个(这部分为整理左神的视频课):
1. 每次都从待排序的数据段中随机选择一个数字作为基准点,把它跟第一个或最后一个数字交换。随机选择基准点的方式选择到每个数字的概率相等,将每种的概率乘以该种情况下的时间复杂度,做累加,然后求长期期望,可以得到时间复杂度为O(N*logN).
2. 上面的排序方法还有一个小问题,我们发现原始数组[4,3,8,1,-1,2,5,4] 中有两个4,在我们第一趟排序选择4为基准点排序后,两个4没有在一起。其实此处可以应用荷兰国旗问题的思路,每趟排序后达成比基准点小的数字在左边,等于基准点的数字在中间,大于基准点的数字在右边。这样如果有重复的数字作为基准点的话,一次就能确定多个数字的位置,比每次只确定一个数字的位置理论上会快一些。
每趟排序的思路与上面类似,略有不同,具体如下:
1. 首先选取每段数组的基准点,数组的左边为<区,设置low为<区的右边界,初始为[数组的第一个下标-1],也就是初始<区没有数字;数组的右边为>区,设置hign为>区的左边界,初始为数组的长度n,也就是初始的时候>区也没有数字。
2.设置指针i,初始指向本段数组的第一个数字
3.指针i后移,假设数组名字为arr, 基准点为num:
1)如果arr[i]<num,则将arr[i]与<区的下一个数字做交换,且<区向右扩展一位,i右移一位
2)如果arr[i]==num, 则i右移一位,无其他操作
3)如果arr[i]>num,则将arr[i]与>区的前一个数字做交换,且>区向左扩展一位,i原地不动。
4. 当指针i和>的左边界high相遇时,本趟排序结束
5. 每趟排序结束时,数组的开头到low指针的位置是下次待排序的左半段, hign指针到数组结尾的位置为下次待排序的右半段。 low 和high之间的为基准点数字,可能不止一个。
6. 递归地对左半段和右半段进行排序
结合上面两个改进思路的代码如下:
1 public static void quickSort(int[] testArr, int s, int e) { 2 if(s >= e) {return;} 3 int low = s-1, high = e+1,i = s; 4 //随机选择一个数字作为基准点,这种写法好像不用把它换到头部或者尾部 5 int num = testArr[s+(int)(Math.random() * (e-s+1))]; 6 while(i<high) { 7 if(testArr[i] < num) { 8 swap(testArr,i++,++low); 9 } else if(testArr[i] == num){ 10 i++; 11 } else{ 12 swap(testArr,i,--high); 13 } 14 } 15 quickSort(testArr, s, low); 16 quickSort(testArr, high, e); 17 }