快速排序思想
快速排序号称20世纪最伟大的十大算法之一,也是nlogn级别的排序算法,它的思想是类似冒泡排序,是一种交换排序,同时加入分治法。
上图中我们选取待排序数组第一个元素为基准元素,通过比较交换,将比基准元素小的元素放在左边,比基准元素大的放在右边。那么此时基准元素(紫色元素),就放在了最终排序后数组应该在的位置。然后通过同样的方式,将左边(绿色)和右边(橙色)部分排序。过称如下:
每轮分成3个步骤:
- 选取基准元素
- 基准元素方法排好序后的位置
- 继续拆分,直到剩下一个待排序元素
如何编码 ?
当知道了思想后,下面就要考虑到编码。不管是算法还是排序中,边界的定义和指针的指向是最重要的两个因素,只有定义好了边界范围和指针的含义才能写好算法。
待排序数组 4 7 6 5 3 2 8 1
按照上面的思想,定义一下边界:
- 选取待排序数组第一个元素为基准元素4
- 左边边界为l(left),右边界为r(right),形成全闭区间
- j指向左边<=4区域的第一个元素
- i执行待比较的元素e
执行逻辑 单边循环法
由上面得知,两个区间的范围是[l+1...j]<=4和[j+1...r]>4,经过上面一轮比较后,4就在放在排好序的位置,然后分成左右2部分,然后这两部分再分别继续上面的步骤。
代码如下:
/**
* 单边循环法
* @param arr 排序数组
* @param l 左边界 起始0
* @param r 右边界 其实length-1
*/
public static void quickSort(int[] arr, int l, int r) {
if (l >= r) {//只有一个元素 无需排序
return;
}
//找到 arr[l] 的位置
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
/**
*
* @param arr
* @param l
* @param r
* @return
*/
public static int partition(int[] arr, int l, int r) {
int v = arr[l];
//左边区间为[l+1...j] 初始时j=l那么[l+1...l]是个无效区间
//其实就是初始化时 左边区间是没有元素的
int j = l;
for (int i = l + 1; i <= r; i++) {
// if(arr[i]>v){
// //上面知道 i++ 但是已经在循环中了 那么就什么都不做
// }
if (arr[i] <= v) {
swap(arr, j + 1, i);
j++;
}
}
swap(arr, l, j);
return j;
}
上面的代码,完全是按照我们分析的步骤写的。理解后在看下开始的图:
更能理解这里的思想和在递归中都做了什么
双边循环法
上面我们用的是单边循环法,也就是i指针从左到右一直移动。下面再介绍下双边循环法,也就是从两边循环。
双边循环,那么也就是需要两个指针一起移动。
- 数组范围[satrtIndex...endIndex]
- left代表左区间最后一个元素[startIndex...left] 并不断向右移动
- left代表右区间最后一个元素[right...endIndex] 并不断向左移动
- 当 left和right 指向同一个元素截至
接下来进行第1次循环,从right指针开始,让指针所指向的元素和基准元素做
比较。如果大于pivot ,则指针向左移动;如果小于等于pivot ,则right指针停
止移动,切换到left指针。
由于1eft开始指向的是基准元素,判断肯定相等,所以left右移1位。
由于7>4 , 1eft指针在元素7的位置停下。这时,让left和right指针所指向的元
素进行交换。
第二次循环
代码实现
public static void quickSort(int[] arr, int startIndex, int endIndex) {
if (startIndex >= endIndex) {
return;
}
int p = partition(arr, startIndex, endIndex);
quickSort(arr, startIndex, p - 1);
quickSort(arr, p + 1, endIndex);
}
public static int partition(int[] arr, int startIndex, int endIndex) {
int v = arr[startIndex];
int left = startIndex;
int right = endIndex;
while (left != right) {
//TODO 这里移动是先移动右边 再移动左边 否则当 left=3 right=5 时
//先移动左边 那么left=right的位置在5的位置,那么4和5交换,最后显然不是有序的
while (right > left && arr[right] > v) {
right--;
}
while (left < right && arr[left] <= v) {
left++;
}
if (left < right) {
swap(arr, left, right);
}
}
swap(arr, startIndex, left);
return left;
}
注意点
在TODO位置处这里移动是先移动右边 再移动左边 否则当 left=3 right=5 时,先移动左边 那么left=right的位置在5的位置,那么4和5交换,最后显然不是有序的
复杂度分析
上图中也知道8个元素递归3轮,4个元素2轮,2个元素1轮,正好是log28=3,log24=2,log2^2=1,每轮排序O(n) ,所以是nlogn
优化
1. 数据小的时候用插入排序,这个优化和归并一样
2. 随机选取基准元素的选择
如上图,当数组像上面时,每次选的基准值要么最大,要么最小,就无法起到分治的效果,从而退化成O(n^2),随意可以随机原则数组中的数值,然后与arr[0]交换,再在排序
3. 双路排序
4. 三路排序
双路排序
双路排序思想,当数组相同元素比较多的时候也会退化成O(n^2),如给定一个数组如下:
左边相同元素非常多。导致分治不平均。最后算法退化O(n^2)。
双路排序就是将拆分的2个数组分配到两端,左边遍历的元素为ei,右边遍历元素为ej,当ei>4=和ej<=4时交换。那么此时相同的元素就会分到2端,而不会只在一端,退化成O(n^2)。
代码实现
//对arr[l...r]进行快速排序
private static void quickSort(Comparable[] arr, int l, int r) {
// if (l >= r) {//当l=r的时候说明只有一个元素 那么他就是有序的
// return;
// }
if (r - l <= 15) {
Main5.sort(arr, l, r);
return;
}
/**
* TODO 优化1 : 上面边界的优化 对于小规模数组, 使用插入排序
*
*
* if( r - l <= 15 ){
* InsertionSort.sort(arr, l, r);
* return;
* }
*
*
*/
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
/**
* 这个的特点就是 当出现了值相等的情况 我们不具体像第一版代码 给他划分到某一端 而是取分布在两端了
*
* @param arr
* @param l
* @param r
* @return
*/
// 双路快速排序的partition
// 返回p, 使得arr[l...p-1] <= arr[p] ; arr[p+1...r] >= arr[p]
// 双路快排处理的元素正好等于arr[p]的时候要注意,详见下面的注释:)
private static int partition(Comparable[] arr, int l, int r) {
// TODO 最重要的优化: 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
// 这样我们和l交换 这样退化成一个链表 也就是O(n*n)的概率是非常低的
swap(arr, l, (int) (Math.random() * (r - l + 1)) + l);
//我们找到这个数组中第一个位置 也就是l的位置 为基准数
Comparable v = arr[l];
// TODO arr[l+1...i) <= v; arr(j...r] >= v
int i = l + 1;
int j = r;
while (true) {
// 注意这里的边界, arr[i].compareTo(v) < 0, 不能是arr[i].compareTo(v) <= 0
// 思考一下为什么?
//TODO 因为 如果相等 就违背了我们这么设计的初衷
// 如果我们存在很多相同的元素 原来的排序就会导致 相同的元素永远在一侧 ,如果这里加上等于号
// [1,1,1,1,1,1,2] 按照之前的逻辑 v我们取1 那么就会导致 一侧是全数据
// 另一侧只有2 如过不加等于 那么 根据我们的逻辑会分成[111] [1112] 分布均匀
while (i <= r && arr[i].compareTo(v) < 0) {
i++;
}
while (j >= l + 1 && arr[j].compareTo(v) > 0) {
j--;
}
// 对于上面的两个边界的设定
// 注意这里的边界, j >= l + 1和i <= r, 不能是j > l + 1和i < r
// TODO 因为 这里的区间是arr[l+1...i) <= v; arr(j...r] >= v i和j是指向将要排序的元素
// 所以i j) {
break;
}
swap(arr, i, j);
i++;
j--;
}
swap(arr, l, j);
return j;
}
三路排序
明白了双路排序,再来看三路排序,其本质的事项是一样的.
将数组分成3部分,并按照图中标示定义边界位置
- lt
- i待查看的元素
- gt >v 左边界
e==v
直接i++
e
- arr[i]与arr[lt+1]交换
- lt++
- i++
e>v
- arr[i]与arr[gt+1]交换
- gt--
==注意此时i是不用变的因为gt+1的位置也是未排序的,交换后i位置还是待排序元素,所以i不变==
最后排序完成后的样子
代码实现
private static void quickSort(int[] arr, int l, int r) {
if (l >= r)
return;
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
public static int partition(int[] arr, int l, int r) {
int v = arr[l];
//[l+1...i) (j...r]
int i = l + 1, j = r;
while (true) {
while (i <= r && arr[i] < v) {
i++;
}
while (j >= l + 1 && arr[j] > v) {
j--;
}
if (i > j) {
break;
}
Main.swap(arr, j, i);
j--;
i++;
}
Main.swap(arr, l, j);
return j;
}
思考
随机数组第K大的元素
快速排序 经过随机选择,当partition后返回P
当arr[k]>arr[p] 那么在右侧,否则在左侧,
那么每次查找就减少一半
最终通过O(n)完成查找
O(n)=n/2+n/4+n/...=2n
- arr[i]与arr[lt+1]交换
- lt++
- i++
e>v
- arr[i]与arr[gt+1]交换
- gt--
==注意此时i是不用变的因为gt+1的位置也是未排序的,交换后i位置还是待排序元素,所以i不变==
最后排序完成后的样子
代码实现
private static void quickSort(int[] arr, int l, int r) {
if (l >= r)
return;
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
public static int partition(int[] arr, int l, int r) {
int v = arr[l];
//[l+1...i) (j...r]
int i = l + 1, j = r;
while (true) {
while (i <= r && arr[i] < v) {
i++;
}
while (j >= l + 1 && arr[j] > v) {
j--;
}
if (i > j) {
break;
}
Main.swap(arr, j, i);
j--;
i++;
}
Main.swap(arr, l, j);
return j;
}
思考
随机数组第K大的元素
快速排序 经过随机选择,当partition后返回P
当arr[k]>arr[p] 那么在右侧,否则在左侧,
那么每次查找就减少一半
最终通过O(n)完成查找
O(n)=n/2+n/4+n/...=2n