快速排序(Quicksort)是对冒泡排序的一种改进。基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列,其时间复杂度为线性对数阶O(n log n)
快速排序实现方法比较多,大致可以分为:基准值+左右指针交换法,基准值+左右指针填坑法,基准值+单指针法;非递归栈法
基准值+左右指针交换法
此种方法是最常见的方法,定义左指针和右指针分别指数组向头部和数组尾部;定义个基准值key(一般选取第一个元素或者中间元素)左指针不动,右指针向前扫描,找到比当前基准值小的数m;然后右指针不动,左指针向后扫描找到一个比当前基准值大的数n;交换m与n的值;即左指针位置和右指针位置的值;
交换后完成一次切分,此过程称之为分区。分别对左指针最终停下的左边部分递归;和右边部分递归指针排序完成。
从宏观上将快速排序体现分治思想
从微观上讲快速排序本质还是交换位置法(指针位置交换)
分治原理:
1.找一个基准值,用两个指针分别指向数组的头部和尾部;
2.先从尾部向头部开始搜索一个比基准值小的元素,搜索到即停止,并记录指针的位置;
3.再从头部向尾部开始搜索一个比基准值大的元素,搜索到即停止,并记录指针的位置;
4.交换当前左边指针位置和右边指针位置的元素;
5.重复2,3,4步骤,直到左边指针的值大于右边指针的值停止。
public static int[] quickSort2(int[] arr, int left, int right){
int pivot = arr[(left+right)/2], i = left, j = right; // 以中间的数为基准,i是左指针,往右走,j是右指针,往左走
if (left >=right){
return null;
}
while (i< j){
while (arr[j] > pivot&&i< j ) {
j--;
}
// 从右边寻找比基准数小的数
while (arr[i] <pivot&&i< j) {
i++;
}// 从左边寻找比基准数大的数
if (arr[i]<=arr[j]&&i< j) {// 优化:如果相等或者小不需要交换,继续移动左指针或者右指针
i++;
}
else {
// 找完了,交换
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
//此时已经分成左右两部分,左边比基准小,右边比基准大
//对左边部分排序
quickSort2(arr, left, i-1 );
//对右边部分排序
quickSort2(arr, i+1 , right);
return arr;
}
当然只种每次遇到合适的即左指针比基准值大的右指针比基准值小的都需要交换,频繁的交换位置是比较耗费性能的。所以就有了基准值+左右指针填坑法
基准值+左右指针填坑法
与交换法不同。填坑法首先挖个坑作为基准值(一般选第一个即左指针开始的位置) 右指针向前扫描比基准值小的数不是交换位置,而是将此位置的值赋予左指针的位置,即填坑。同理右指针开始的位置也挖个坑,当左指针向右扫描找到比基准值大的填入右指针位置的坑中。一次循环结束在将基准值填入当前循环结束的位置即左指针==右指针的位置。分别对基准值左边位置递归,右边位置递归,直到排序完成
为什么要从右指针先从右至左扫描?先左指针从左到右扫描不可以吗?–此问题代码部分会有解释
网上的一张动图很好的诠释填坑法的过程
//基准值+左右指针填坑法
public static int[] quickSort1(int[] arr, int left, int right) {
if (left>= right) {
return null;
}
int prev = left;
int last = right;
int key = arr[left];//如果选取arr[right]则先走prev指针
/*
* 如果选取最左边的值为基准值,则需要用比它小的值来放在基准值原来的位置,这个值一定是从右侧开始遍历取到的,因为左边指针遇到比基准值大的停下来,右边指针遇到比基准值小的停下来,
* 这样依此覆盖和往中间遍历,直到左右指针相遇,这时把基准值放到这个下标位置,完成一轮排序,左边的都比它小,右边的都比它大
*
* 为什么要从右指针先从右至左扫描?先左指针从左到右扫描不可以吗?
* 即prev先走,next后走呢?---6 1 2 7 9
*
* prev最终停在大于基准值的位置,停在 7的位置并prev交换到last的位置,交换后为6 1 2 7 7,last停在倒数第二个7 的位置无法前进(prev<last),导致无法继续向左扫描寻找最小值的情况出现,
*
* 基准值归位后为6 1 2 6 7 ---完了9不见了跑丢了。所以先左后右行不通
*
*
*/
while (prev <last ) {
while (prev < last && arr[last]> key) {//找到右侧小于基准值放入该坑位中,保证当前last位置及其后的都是大的
last--;
}
if (prev<last){//优化:如果还小,加快prev指针移动,因为last在交换前已经和key比较过了, 因此prev交换后,下一个while应该从prev的下一个元素开始遍历
arr[prev++] = arr[last];//基准值开始在left位置 交换,交换后此位置及以后的值都比基准值小
}
while (prev < last && arr[prev] <= key) {//找到左侧大于基准值放入该坑位中,保证当前prev位置及其后的都是比较小的
prev++;
}
if(prev<last){
// 优化:基准值经过right向左扫描后已经与基准值进行了交换,所以此时last指针位置就是基准值的位置,交换后此位置及之前的值都比基准值大
arr[last--] = arr[prev];//此时会把从右到左找到的小值的坑位覆盖,确保此坑位的值是比基准值,从右至左,从左到右三者中的最大值
}
}
arr[prev] = key;// 为什么要归位?,因为基准值的位置在上面循环操作中已经被arr[prev]或者 arr[last]替换,不归位会造成基准值的丢失,当然你也可以使用arr[last]=key进行归位,因为此时prev=last
//循环结束此时,arr[prev]=arr[last]=key,所以我们只需要对prev之前的进行递归,prev之后的在进行递归
quickSort1(arr, left, prev - 1);//对[left---prev-1]之间进行排序
quickSort1(arr, prev + 1, right);//对[prev+1---right]之间进行排序
return arr;
}
左右指针填坑法相比左右指针交换法要高效点,因为不需要频繁的交换位置,本质是插入的思想。但这两种方法无法解决单链表的快速排序—单链表只有单个指针;所以就有了单指针快速排序法
单指针快速排序法
选取第一个元素的值作为key当做基准值,定义指针prev,next,确保prev,及prev之前的元素都比key小。prev和next之间的值都比key大。 当next指针到达尾部节点如果prev比key值小则交换prev与key完成一次切分。分别对prev左边的部分递归和prev后变的部分递归,直至排序完成
如果想了解单链表快速排序请参考 数据结构单链表实现
里面存在单链表的快速排序
/基准值+单指针(或者前后指针)
public static int[] quickSort(int[] arr, int left, int right) {
if (left+1>= right) {//前后指针间隔为1,说明中间没有元素完全没必要递归
return null;
}
int prev = left;//前指针
int next = left + 1;//后指针
int key = arr[left];
while (next < right) {
if (arr[next] < key) {//确保prev与next之间的元素都比key大,是之间的,即[prev+1--next]
prev++;
if (prev != next) {//^运算如果相等会变成0,其相等也不需要交换
arr[prev] = arr[prev] ^ arr[next];
arr[next] = arr[prev] ^ arr[next];
arr[prev] = arr[prev] ^ arr[next];
}
}
next++;
}
if (arr[prev]<arr[left]) {//小于基准值位置则交换与prev指针位置的值
arr[prev] = arr[prev] ^ arr[left];
arr[left] = arr[prev] ^ arr[left];
arr[prev] = arr[prev] ^ arr[left];
}
quickSort(arr, left, prev);//对[left---prev]进行递归
quickSort(arr, prev + 1, right);//对[prev+1---right]进行递归
return arr;
}
以上方法均涉及到递归,递归本身耗费内存,本质就是栈。万一面试官:请用非递归快速排序—完全是有可能的。不用怕非递归的这里也有实现。思想是与递归是相同的只是实现方式不同而已
非递归栈快速排序
//基准值+非递归
public static int[] quickSort3(int[] arr, int left, int right) {
var stack = new Stack<Integer>();
if (left <right) {
stack.push(left);
stack.push(right);
while (!stack.isEmpty()) {
var lastIndex = stack.pop();
var prevIndex = stack.pop();
var last = lastIndex;
var prev = prevIndex ;
var key = arr[prev];
while (prev < last) {
while (prev < last && arr[last] > key) {
last--;
}
if (prev<last) arr[prev++] = arr[last];
while (prev < last && arr[prev] <= key) {
prev++;
}
if (prev<last) arr[last--] = arr[prev];
}
arr[prev] = key;
if (prev - 1 > prevIndex ) {
stack.push(prevIndex );
stack.push(prev - 1);
}
if (prev + 1 < lastIndex) {
stack.push(prev + 1);
stack.push(lastIndex);
}
}
}
return arr;
}
快速排序的优化
以上在几种实现多多少少涉及到部分细节的优化,存在一种三数取中的优化:
每次在排序之前,我们都无法知道数据是逆序或者是正序,如果每次去检测那就太麻烦,
所以我们选取三个数值(left,right,mid)如果是正序或者逆序那么就直接选取中间的数值,
反之任意选取两边任意一个数当作基准值。这样就很容易将数据等分开来,大大提高了代码的效率。
三数取中的算法实现
private static int getMid(int[] arr, int left, int right) {
//获取中间位置
int mid = left + ((right - left) >> 1);
if (arr[mid] > arr[right])
{
swap(arr, mid, right);
}
if (arr[left] > arr[right])
{
swap(arr, left, right);
}
if (arr[mid] > arr[left])
{
swap(arr, mid, left);
}
return arr[left];
}
//交换
public static void swap(int[] arr, int left, int right)
{
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
}
最终优化
//快速排序最终优化
public static int[] betterQuickSort(int[] arr, int left, int right){
if (right - left + 1 < 10) {// 优化
insertSort(arr, left, right);
return arr;
}
if(left>=right){
return null;
}
int key = getMid(arr, left, right);// 三数取中优化
int prev=left;
int last=right;
while (prev < last) {
while (prev < last&&arr[last]>= key) {
last--;
}
//优化:如果还小,加快prev指针移动,因为last在交换前已经和key比较过了, 因此prev交换后,下一个while应该从prev的下一个元素开始遍历,加快指针移动
if (prev<last) arr[prev++] = arr[last];
while (prev < last&&arr[prev]< key) {
prev++;
}
// 优化:基准值经过right向左扫描后已经与基准值进行了交换,所以此时last指针位置就是基准值的位置,交换后此位置及之前的值都比基准值大,加快指针移动
if (prev<last) arr[last--] = arr[prev];
}
arr[prev]= key;
betterQuickSort(arr,left,prev-1);
betterQuickSort(arr,prev+1,right);
return arr;
}
同样8000个数效率:
可以看到三数取中优化还是比较给力的