快速排序--QuickSort,看完自己就能写出来的快排思路推演

快速排序(QuickSort)介绍

首先发明者竟然敢给自己发明的算法叫做QuickSort,这个名字闪不闪亮?好比别的武功叫做六脉神剑、降龙十八掌,我这个叫做“天下无敌神功”。别的排序算法都是按照特点来起的,你这个不是应该叫分块递归排序法吗?或者和希尔一样,叫做霍尔排序也可以啊,这么高调是要干啥啊?我给了他一次机会,特意去查了一下,这个名字并不是江湖朋友抬爱给的,就是发明者自己起的,社会社会。。。不过看完这篇博客,理解了快排的思想,你会发现这个名字名副其实。

思路推演:

思路是,假设一个数组,我们可以用一种办法分成小数块和大数块,然后递归继续分成小数块和大数块,最后每一块都只有1个(或者0个)的时候,排序就完成了

为什么快速排序是冒泡排序的改进版?

这是个结论,或者说是事实,但是不一定是算法发明者的初衷,发明者的思路,应该是先发现了能将数组分成大小两块的方法,然后延伸到可以通过递归拆分成最多一个的数据块,进而达到排序的效果,这个我们在最后还会再说。
然而最终的模式变成,我们把大的数放后面,小的数放到前面(这个就是冒泡的套路,只不过冒泡是一个一个的,这是一次一块的),然后不停的拆(分治),大的放后面,小的放前面,最终到每块最多1个元素的时候,排序完成。
所以是冒泡排序的改进版,但是并不是看到冒泡而发明的,只是结果上成为了冒泡排序的改进版,不像插入排序和希尔排序,后者是看着前者而发明的。

理想情况是每次都能取到一组数的中位数作为基准数,这样每次拆分都是平均的,这也是快排能达到O(nlogn)的情况。但是中位数的前提是已经排序,这样就矛盾了。
既然数组是乱序的,基准数从哪里取都一样(其实不一样,我们姑且先认为一样),取0位置的数作为基准数,尝试将数组变成左边比这个数大,右边比这个数小。

怎么分块

怎么将一个数,放到合适的位置,让左边的都比它小,右边的都比它大

思路1:

用low的方法怎么实现,比如使用插入排序来实现:
假设四个数3 4 2 1 ,以3为基准数,我们可以这样,找到小于3的数,放到第一位,然后把后面的都往后移动一位,变成2 3 4 1,然后找到1,放到第二位,后面移动一位,直到找不到比3更小的。。。是不是很low,这样查找移动要消耗O(n^2)·····然后拆分需要花费O(logn)~O(n),那就是O(n^2*logn)到O(n^3),简直疯了
3 4 2 1
2 3 4 1
2 1 3 4

思路2:

再换个思路,用归并排序的思路,假设我们可以在另一个相同长度的新数组newArr中安放元素,假设我们选择了第0位为base,base=arr[0],为了让数组变成base和左右两块,我们可以:
1、设置left = 0;
2、设置right = n - 1;
3、从1到最后遍历数组 i,小于base从前面依次排放,newArr[left] = arr[i],left++;
大于base的从后面依次排放,newArr[right] = arr[i],right–;
4、遍历完成后, 新数组肯定还空着一个坑,直接newArr[i] = base; 这样就可以了
5、但是,如果我们想在排序中使用,还需要将新数组复制到原始数组,这也不失为一个办法,并且还是稳定的算法,但是需要消耗O(n)的空间。
6、如果不复制,怎么办,快排是一种原地排序算法,in-place

真正的快排思路1、两头交换法:

网上的快排大体有两种,什么挖坑填坑法和两头交换法,我们先讲的是两头交换法,因为这个思路比较直接,两头交换实现之后再去看挖坑填坑法。看到后面会发现这两种叫法都是有问题的
网上有个坐在马桶上看算法系列,讲到快排的时候,说一定要先从右往左找。
在我看来其实先从左先从右是没区别的,并且左边更顺,我们先尝试推演完再说。
有点难,,,这是我最开始的推演思路,后面会简化这个推演
1、我们先设置个很一般的情况,假设只有三个数B C A,基数是B,这两个CA将来一定会变成AC,并且B一定会出现在A和C之间。
2、假设C之前只有小于B的数,A之后只有大于B的数,如:BAAAAAC XXXX ADEFG,这个结论也正确(我们先都不考虑等于的情况,等于的情况,其实只要是互斥的就可以,也就是如果从左边找大等于的,从右边就找小于的,反之亦然)。
3、继续放大这个结论
从前面找到的第一个比目标大的数下标i,从后面找到的第一个比目标小的数下标j,如果这时数组没相交,i < j,将来目标一定出现在这两个数之间,一定可以交换使得数据趋于排序的最终结果
如果在寻找的过程中i >= j,这个时候寻找就应该退出,这个时候不应该再出现CA交换的情况,分开讨论:
如果i >= j,有两种可能:
从左边一直没找到比B大的数,循环到endIndex然后自然退出,这个时候同样B应该和i位置的互换,返回i,分块完毕
从左边找到第一个比B大的数之后,从右边没有找到比B小的数,,这个时候特征是i位置之后的数都比B大。i位置之前的数字都比B小。
因为i是第一个大数,所以i之前的数都比B小
从后面没有找到更小数,说明i之后的数都比B大
B应该在i - 1的位置,返回i - 1,分块完毕
也就是一共有三种情况:
a)找到了可交换的数,交换并继续递归找
b)没找到可交换的数,1是从左边没找到,说明B是最大的,返回endIndex;
c)2是后面的数以i为分界点,后面的比B大,前面的比B小,这时候应该返回i - 1。

上面的推演有点粗略,我们一边总结一边限定,并考虑边界问题:
1)如何确定base的位置?
设置base下标为startIndex
设置i为开始位置startIndex+1
设置j为结束位置endIndex
// 从左边循环到j,尝试找比B大的数
从i开始往后找到第一个大于B的数,break,记录下标为新的i
如果没找到下标i会到达endIndex,为了区分i是break还是循环到最后退出,我们将i++写在break后面,假设没找到,i = endIndex + 1;
如果i > endIndex,没找到说明B是最大的数,B应该在endIndex的位置,return endIndex值,本次分块结束,进入下一次分区。
// 从右边循环到i+1,尝试找比B小的数
从j开始往前,直到i+1,尝试找一个小于B的数。
如果找到,break,记录新的下标j(递归,从i+1到j-1的位置继续找,递归当前这一两个循环)
如果没找到j会停留在i+1的位置,为了区分j是因为找到了break,还是自然结束循环,将j–放到break后面。如果j==i一样说明没有找到

判断如果i < j说明找到了可以互换的CA,将i位置元素和j位置元素互换,并取i+1和j - 1进入下一次寻找。
否则(其实只有i == j的情况),返回 i - 1,进入下一次分区。

写代码前还有两个问题需要考虑
2)找到base的位置之后怎么办?
如果找到了base的位置,因为base本身已经是中间的数了,所以base不用再参与到左右两边的再次排序,那么下一次递归的分区边界为startIndex~(base -1)、 (base + 1) ~ endIndex。
这里可以引出两点:
a)每次快排分成左右两块的时候,整体扣掉了一个中间元素不需要再次参与排序,最好情况是不是比logn要小啊
b)可以引出快排的一种优化思路,假设有许多和基准数相同的数,我们应该找到这部分数的起止位置,假设判断在B之后用的是大等于,那么从B开始,找到那个位置。分块的时候和B相同的不再参与分块,这样需要排序的个数又少了很多。
3)排序的递归什么时候退出?
左右两边最多只有一个数的时候,这个时候startIndex <= endIndex,递归退出,也就是if(startIndex <= endIdex) return;
为什么说至多只有一个数,有一个数的时候,startIndex == endIndex不难理解
有0个数的情况,B是边界值,在startIndex或者endIndex的位置,下一次递归就是有一边就是空的了,体现在代码上下次边界为本次的startIndex ~ startIndex - 1,或者endIndex + 1 ~ endIndex,这个时候就是startIndex = endIndex + 1,所以我们用startIndex <= endIndex来退出递归。

算法复杂度

每一层比较交换的过程会最终会把所有的元素点个名比较一次,从左到右,最终一定会相交,所以是O(n)。即使递归拆分成n个块之后,合起来仍然是O(n)。
一共会交换多少层,取决于base实际在排序后的元素中的位置,这个就好像一个凭运气的二分拆分,假设运气爆棚,每次都能取到中位数,那么需要比较logn次。假设运气贼烂,每次都取到一头的数,使得每次拆分都是一颗完全的歪脖子树,也就是一条直线,就是n次。所以快速排序的复杂度介于O(nlogn)~O(n^2)之间

为了尽量缩小随机的因素,各种改进版,,,也就是如何取base
1、最初是我们上面的例子。取0位作为base
2、三数取中(midian-of-three)假设数组已经一定程度有序,并且我们知道它一定程度有序,这个时候取base,也就是最左边,将会是概率上最差的选择。这个时候有从startIndex,endIndex,midIndex三个值中取中值的做法,这是一种极限思想,假设完全有序,那么直接取中值是不是很安逸,具体涉及到数学思想,我没水平说的太深
3、取随机数,我个人觉得这个有点扯淡,在不知道顺序情况下,0和随机位置没区别。在知道有一定顺序的情况下,用三数取中。随机怎么看都不具有优势。

稳定性:

不稳定的,跳跃的比较都不是稳定的,最简单的例子,假设B C C A,在以B为基数进行比较的时候,A会和第一个C交换,这样直接没问中间有没有和C等于的数据,所以直接打乱了顺序

Java代码实现:

快排的写法特别容易出错,可能在数据少且没特性的时候,现象上是对的,其实是错的。所以我弄了很长的数组,还会调整成一些特殊情况来验证。

public static void main(String[] args) {
    int[] arr = new int[]{14, 23, 1, 25,36,11, 9, 2, 1, 5, 14,1};

    quickSort(arr);

    System.out.println(Arrays.toString(arr));
}

public static void quickSort(int[] arr) {
    doQuickSort(arr, 0, arr.length - 1);
}

public static void doQuickSort(int[] arr, int startIndex, int endIndex) {
    // startIndex大等于endIndex的时候,递归退出
    if (startIndex >= endIndex) {
        return;
    }
    // 取第一个位置的元素作为基准元素
    int base = arr[startIndex];
    // 获取中轴
    int pivot = partition(arr, base, startIndex + 1, endIndex);
    // 将startIndex和pivot互换,B应该和最后的元素互换
    // 可以判断是否等于,来决定是否交换
    if(pivot != startIndex) {
        swap(arr, startIndex, pivot);
    }

    // 根据中轴分成两块,递归排序,注意不需要再包括pivot
    doQuickSort(arr, startIndex, pivot - 1);
    doQuickSort(arr, pivot + 1, endIndex);
}

/**
 * 一边交换,一边找中轴
 *
 * @param arr
 * @param startIndex
 * @param endIndex
 * @return
 */
private static int partition(int[] arr, int base, int startIndex, int endIndex) {
    // 取i是startIndex+1
    int i = startIndex;
    // 取j是endIndex
    int j = endIndex;

    // 从左边开始找到第一个大于base的数
    // 这里注意控制一下大于小于是否包含等于,这里其实随便定义就好,只要左右互斥
    // 假设大于等于base认为是大于,那么另一个方向就是小于,反之亦然
    // 如果找不到i推进到最后
    // 如果找到i停在找到的位置
    for (; i <= j;) {
        if (arr[i] > base) {
            break;
        }
        i++;
    }

    // 如果没找到比B大的元素,说明B是最大的
    if(i == endIndex + 1){
        return endIndex;
    }

    // 从右边,到i+1截止,尝试找到第一个小于base的数
    // 如果程序提前退出,那么i < j,否则 i == j
    for (; j >= i+1;  ) {
        if (arr[j] <= base) {
            break;
        }
        j--;
    }

    // 如果找到了两个,也就是能交换,则继续递归寻找中轴
    if (i < j) {
        swap(arr, i, j);
        // 从i+1,到j-1继续交换
        return partition(arr, base, i + 1, j - 1);
    } else {
        // 如果没找到j,说明i之后的数(包括i)都大于B,B应该在i-1的位置
        return i - 1;
    }
}

/**
 * 交换元素
 * @param arr
 * @param i
 * @param j
 */
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

另一种标准写法的推演,所谓的挖坑填坑法:

说实话我不太喜欢这种写法,感觉不够直接,后来想了很久,突然理解了,包括上面的推演都可以简化成另一种思路。。。
1、仍然设置最简单的情况,B C A(这是最简单的情况,C A可以看成两个集合,思路相同),如何变得有序,我们之前的交换方法是直接交换C A,在不考虑B的情况下将数据分成大小两块,这两块其实是相邻的,中间并没有B的位置,B最后放到中间,是靠A中的最后一位元素和B进行交换得到的。
把上面的过程抽象化,其实就是有一组乱序B C A,将来会变成A B C,为了变成A B C,我们如何交换的问题
上面的做法是先交换C A,再交换B A,也就是先将最大值放到最后,再将中间值放到中间(也可以描述为先将最大值放到最后,再将最小值放到最前)
这样其实最终才需要交换B
2、其实还有一种做法,先交换B A,再交换B C,先将最小值放到最前,再将中间值放到中间(相对的,或者描述为先将最小值放到最前,再将最大值放到最后。
这样每一次找到后都需要交换B,其实满蛋疼的
因为B一定在最前面,所以交换只有这两种可能
所以所谓的两头交换法和挖坑填坑法,不过是形上的称法,归根结底是为了交换三个数,CA互换,最后B再和A互换称为两头交换法,因为B一直到AC完全分块了才会和A的末尾数交换。
每次都会BA互换,B再和C换称为挖坑填坑法,因为总有一个实际意义上是空坑的位置由base来填。

网上总有人讨论的从右开始还是从左开始问题

假设采取的是CA先交换,目的就是找到CA,先从后找到C和先从前面找到A没有区别,所以从左从右都可以得到,只是写法会略有不同而已
假设采取的是BA先交换,一定要从后面先找到A,看的明白不,BA要交换,我们已经捏了B在手里,需要第一时间找到A,所以这种解法要从右边找(这种找法的倒序也要从右边找,大家自己想一想)

重新梳理并考虑边界:

两种不同的交换顺序,导致了核心的代码有两个很大的区别。
1、前面一种方法,base一直到找到中轴才放下来,base要一直带着走;后一种方法,base是每次交换之后的startIndex位置,base在每次递归找中轴的时候获取
2、前面一种方法的交换,发生在找到中轴后;第二种方法的交换,在找到比base小的数,和找到比base大的数时都会发生
// 设置base下标为startIndex
设置i = startIndex + 1;
设置 j = endIndex;
从j往前找小于base数,如果找到,将base和消失交换,应该将这个数放到开始位置startIndex去,把base放在j位置
如果找不到(这里所有的case全部过一遍,不要先合到一起),说明base是整个数组中最小的,直接return startIndex
在上面能找到的前提下,从i往后,找大于base的数,如果找到,那么将base和大数交换,将j位置设置为i位置的数,将base放到i位置
能找到之后,从i作为startIndex,到j-1,作为endIndex,继续找。
如果从i往后没找到,说明j之后的数都大于base,j之前的数都小于base,返回j,分块递归结束

Java代码实现:

一开始实现代码的时候,最好按照思路写直接点的代码,不要合并逻辑,最后再合并逻辑

public static void main(String[] args) {
    int[] arr = new int[] {6,1,5,4,8,3,9,12,51,11,15,14,13,25,69,47,56,74,26,78};

    quickSort(arr);

    System.out.println(Arrays.toString(arr));
}

public static void quickSort(int[] arr) {
    doQuickSort(arr, 0, arr.length - 1);
}

public static void doQuickSort(int[] arr, int startIndex, int endIndex) {
    // startIndex大等于endIndex时候,退出
    if (startIndex >= endIndex) {
        return;
    }
    // 获取中轴
    int pivot = partition(arr, startIndex, endIndex);

    // 根据中轴分成两块,递归排序,注意不需要再包括pivot
    doQuickSort(arr, startIndex, pivot - 1);
    doQuickSort(arr, pivot + 1, endIndex);
}

/**
 * 一边交换,一边找中轴
 *
 * @param arr
 * @param startIndex
 * @param endIndex
 * @return
 */
private static int partition(int[] arr, int startIndex, int endIndex) {
    // 取第一个位置的元素作为基准元素
    int base = arr[startIndex];
    // 取i是startIndex+1
    int i = startIndex + 1;
    // 取j是endIndex
    int j = endIndex;

    // 从右边,到i截止,尝试找到第一个小于base的数
    // 将这个小数放到前面去,这个时候其实j位置的实际意义是base,虽然base没放进去,但是假设地柜就此退出,base应该放到这个坑里
    for (; j >= i; ) {
        if (arr[j] < base) {
            arr[startIndex] = arr[j];
            arr[j] = base;
            break;
        }
        j--;
    }
    // 如果没找到,说明base是最小的,直接返回startIndex
    if(j < i){
        return startIndex;
    }

    // 从左边到j,尝试找比base大的数
    // 将较大值和base的位置互换,base将来会放在i位置
    for (; i <= j - 1;) {
        if (arr[i] >= base) {
            arr[j] = arr[i];
            // 需要将base和较大值交换
            arr[i] = base;
            break;
        }
        i++;
    }

    // 如果找到了两个,也就是能交换,则base在i的位置,继续递归寻找中轴
    if (i < j) {
        return partition(arr, i, j - 1);
    } else {
        // 如果没找到,说明j位置之前的元素全部比base小,后面的全部比base大,base应该放到j的位置
        return j;
    }
}

/**
 * 交换元素
 * @param arr
 * @param i
 * @param j
 */
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

后记:

YY一下,看到最后,是不是发现这个算法的精髓是确定中轴的这个逻辑,并不是大家所说的分治,递归拆分更像是为了实现排序的辅助手段。也更能确定之前的猜测:作者当年是先发现了快速分成大小块的方法,然后想到通过递归拆分实现排序。作者霍尔认识到这个算法在当时很快,所以称呼它为QuickSort,可能他觉得以后也不会有更牛叉的排序算法了。能发明出这么个算法,肯定是个很聪明的人,聪明的人有点自负,起这个名字也可以接受吧。。。

你可能感兴趣的:(排序算法)