• 快速排序全面详解


    目录

    1 基本思想

    2 排序步骤

    3 代码实现

    3.1 区间划分算法(hoare初始版本):

    3.2 主框架

    4 区间划分算法

    4.1 hoare法

    4.2 挖坑法

    4.3 前后指针法

    5 快排优化

    5.1 取key方面的优化

    5.2 递归方面的优化

    5.3 区间划分方面的优化

    6 快排非递归实现

    6.1 栈实现(代码+图解)

    6.2 队列实现

    7 特性总结


    1 基本思想

    快速排序采用分治法,任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

    2 排序步骤

    1. 选取基准值,通过区间划分算法把待排序序列分割成左右两部分。

    2. 左右序列中递归重复1。

    3 代码实现

    3.1 区间划分算法(hoare初始版本):

    区间划分算法有三个版本:hoare法,挖坑法,前后指针法,这里介绍hoare法,也是快排的初始划分法。

    三种划分方法的结果可能不同,但都是让小的值往前靠拢,大的值往后靠拢,让整个序列逐渐趋于有序。

    步骤:

    1. 默认序列最左边的元素为基准值key,设置left,right指针;

    2. left找大,right找小,right要先找,都找到后交换a[left]和a[right];

    3. 重复步骤3

    4. 当left == right时,交换key和相遇位置的元素,完成分割。

    走完这一趟后,key值左边都不比key大,key值右边都不比key小,key值到了他排序后应该在的位置,不需要挪动这个元素了。

    图解:

    算法分析:

    为什么能保证相遇位置的值一定比key值小,然后交换?

    关键点就是让right先找!

    相遇有两种情况:

    • left往right靠拢,与right相遇:right先找到了小的元素,相遇后的值一定比key小。

    • right往left靠拢,与left相遇:left指针指向的元素是上一波交换过后的元素,该元素比key小。

    假如我们让left先找的话,相遇位置比key值大,不能交换。

    代码:

    1. int Partion(int* a, int left, int right)
    2. {
    3. int keyI = left;
    4. //left == right两个指针相遇,退出循环
    5. while (left < right)
    6. {
    7. //right先找,right找小
    8. while (left < right && a[right] >= a[keyI])
    9. {
    10. right--;
    11. }
    12. //left找大
    13. while (left < right && a[left] <= a[keyI])
    14. {
    15. left++;
    16. }
    17. //都找到了,交换
    18. Swap(&a[left], &a[right]);
    19. }
    20. //left和right相遇,交换key和相遇位置元素
    21. Swap(&a[keyI], &a[left]);
    22. return left;
    23. }

    划分方法一般不用hoare,是因为这种算法实现的代码很容易出现bug,比如:

    1. key值一般取最左边或者最右边的值,但是要注意key不能用变量保存,而是要保存key的下标keyI,否则最后key与相遇位置的交换并没有真正交换数组中的key。(注意:有些划分算法是用变量保存key,有些是保存下标keyI,视情况而定。)

    2. right找小和left找大的过程中,要保证left < right,否则可能出现数组越界,比如1,9,6,4,2,7,8,2 ;右边的值都比key大,会导致越界。

    3. a[right] >= a[keyI]或者a[left] <= a[keyI]时,才能--right或者++left;如果是a[right] > a[keyI]或者a[left] < a[keyI]可能出现死循环,比如a[left] == a[right] == key时,交换完后不进入内部while,外部while陷入死循环。

    3.2 主框架

    1. void _QuickSort(int* a, int begin, int end)
    2. {
    3. if (begin >= end)
    4. return;
    5. //根据基准值把数组划分成左右序列
    6. int keyI = Partion(a, begin, end);
    7. //左右序列递归划分下去
    8. _QuickSort(a, begin, keyI - 1);
    9. _QuickSort(a, keyI + 1, end);
    10. }
    11. void QucikSort(int* a, int n)
    12. {
    13. _QuickSort(a, 0, n - 1);
    14. }

    上述为快速排序递归实现的主框架,与二叉树前序遍历规则非常像。

    二叉树的递归终止条件是空树,快排的终止条件是数组只有一个元素(left==right)或者数组区间不存在(left>right)。

    浅画一下展开图:

    4 区间划分算法

    前面所说,hoare划分法有一定的缺陷,我们再介绍其他两种常用的划分方法。

    4.1 hoare法

    1. int Partion(int* a, int left, int right)
    2. {
    3. int keyI = left;
    4. //left == right两个指针相遇,退出循环
    5. while (left < right)
    6. {
    7. //right先找,right找小
    8. while (left < right && a[right] >= a[keyI])
    9. {
    10. right--;
    11. }
    12. //left找大
    13. while (left < right && a[left] <= a[keyI])
    14. {
    15. left++;
    16. }
    17. //都找到了,交换
    18. Swap(&a[left], &a[right]);
    19. }
    20. //left和right相遇,交换key和相遇位置元素
    21. Swap(&a[keyI], &a[left]);
    22. return left;
    23. }

    4.2 挖坑法

    步骤:

    1. 默认序列最左边的元素为基准值key,把值挖走用key变量保存,该位置为一个坑。

    2. 右边找小,找到后把值填给坑位,该位置成为新的坑位。

    3. 左边找大,找到后把值填给坑位,该位置成为新的坑位。

    4. 重复步骤2~3。

    5. 左右相遇,相遇位置也是个坑位,key值填入坑位。

    图解:

    代码:

    1. int Partion2(int* a, int left, int right)
    2. {
    3. int key = a[left];
    4. int hole = left;
    5. while (left < right)
    6. {
    7. while (left < right && a[right] >= key)
    8. {
    9. right--;
    10. }
    11. a[hole] = a[right];
    12. hole = right;
    13. while (left < right && a[left] <= key)
    14. {
    15. left++;
    16. }
    17. a[hole] = a[left];
    18. hole = left;
    19. }
    20. a[hole] = key;
    21. return hole;
    22. }

    与前面代码不同的是,这里的key值我们不存下标,用一个变量保存。

    4.3 前后指针法

    步骤:

    1. 默认序列最左边的元素为基准值key,设置prev指针 == left,cur指针 == left+1。

    2. cur找小,找到后,prev++,a[prev]和a[cur]交换。

    3. 重复步骤2。

    4. cur走完以后,a[prev]和key交换。

    图解:

    代码:

    1. int Partion3(int* a, int left, int right)
    2. {
    3. int keyI = left;
    4. int prev = left, cur = prev + 1;
    5. while (cur <= right)
    6. {
    7. if (a[cur] < a[keyI] && ++prev != cur)
    8. Swap(&a[prev], &a[cur]);
    9. ++cur;
    10. }
    11. Swap(&a[prev], &a[keyI]);
    12. return prev;
    13. }

    为了避免自己和自己交换,prev先++判断和cur是否相等,相等就不交换。

    很明显这种分割方法的代码相比前面两种简单了许多,这种划分法也是最常用的。

    5 快排优化

    5.1 取key方面的优化

    最理想的情况就是key值每次都是中间的值,快排的递归就是一个完美的二分。

    快排在面对一些极端数据时效率会明显下降;就比如完全有序的序列,这种序列的基准值key如果再取最左边或者最右边的数,key值就是这个序列的最值,复杂度会变成O(N^2):

    这时候就可以用三数取中法来解决这个弊端,三个数为:a[left],a[mid],a[right],这样就可以尽量避免key值选到最值的情况。

    1. //三数取中法选key值
    2. int GetMidIndex(int* a, int left, int right)
    3. {
    4. int mid = (left + right) / 2;
    5. if (a[left] < a[mid])
    6. {
    7. if (a[mid] < a[right])
    8. return mid;
    9. else if (a[left] > a[right])
    10. return left;
    11. else
    12. return right;
    13. }
    14. else  //mid < left
    15. {
    16. if (a[left] < a[right])
    17. return left;
    18. else if (a[mid] > a[right])
    19. return mid;
    20. else
    21. return right;
    22. }
    23. }
    24. //前后指针划分
    25. int Partion3(int* a, int left, int right)
    26. {
    27.    //中间值的下标为midI,a[left]替换为此中间值
    28. int midI = GetMidIndex(a, left, right);  
    29. Swap(&a[left], &a[midI]);
    30. int keyI = left;
    31. int prev = left, cur = prev + 1;
    32. while (cur <= right)
    33. {
    34. if (a[cur] < a[keyI] && ++prev != cur)
    35. Swap(&a[prev], &a[cur]);
    36. ++cur;
    37. }
    38. Swap(&a[prev], &a[keyI]);
    39. return prev;
    40. }

    除了三数取中法,我们还可以考虑随机数法,都能在一定程度上避免这种极端情况。

    1. srand((unsigned int)time(NULL));
    2. //前后指针划分
    3. int Partion3(int* a, int left, int right)
    4. {
    5. //随机数取key
    6.    int keyI = left + rand() % (right - left + 1);
    7. Swap(&a[left], &a[keyI]);
    8. int keyI = left;
    9. int prev = left, cur = prev + 1;
    10. while (cur <= right)
    11. {
    12. if (a[cur] < a[keyI] && ++prev != cur)
    13. Swap(&a[prev], &a[cur]);
    14. ++cur;
    15. }
    16. Swap(&a[prev], &a[keyI]);
    17. return prev;
    18. }

    5.2 递归方面的优化

    我们知道,递归深度太深并不是一件好事,所以我们可以针对递归方面来进行优化,减少绝大多数的递归调用。

    如何优化呢?当递归到区间内元素个数<=10时,调用直接插入排序。

    1. void _QuickSort(int* a, int begin, int end)
    2. {
    3. if (begin >= end)
    4. return;
    5. //区间内元素个数 <= 10,调用直接插入排序
    6. if (end - begin + 1 <= 10)
    7. {
    8. InsertSort(a + begin, end - begin + 1);
    9. //注意:起始地址是a + begin,不是a
    10. }
    11. else
    12. {
    13. //根据基准值把数组划分成左右序列
    14. int keyI = Partion3(a, begin, end);
    15. //左右序列递归划分下去
    16. _QuickSort(a, begin, keyI - 1);
    17. _QuickSort(a, keyI + 1, end);
    18. }
    19. }

    这种优化其实可以减少绝大多数的递归调用,我们把快排的递归划分想象成一颗二叉树,区间长度小于10的数组大概在这棵二叉树的最后三层,而最后三层占了整棵树结点个数的80%多(最后一层50%,倒数第二层25%...),类比快排的递归来看,我们省去了80%多的递归调用,并且对于数据规模较小的情况下,直插和快排的效率差不了多少,所以这是一个极大的优化,算法库中的sort函数也大多是这种优化。

    5.3 区间划分方面的优化

    快排针对某些极端数据,效率会下降至O(N^2),这种极端数据我们前面说过:

    • 完全有序的序列算是一个,

    • 还有一种极端数据就是数组中某个元素(我们称为x)大量出现,甚至数组中全部都是一个元素x。

    针对情况一,我们可以优化取key来解决这个问题,针对情况二,这种方法不奏效。

    那么我们可以从区间划分算法下手:

    • 以前的区间划分算法(前后指针法)是双路划分,也就是一遍走完之后,数组被划分成[left, keyI - 1], keyI, [keyI + 1, right]三部分,左区间 < key, 右区间 >= key,然后左右两个区间再递归划分下去;

    • 这种划分方法有一个弊端,就是x大量出现时(甚至整个数组都是一种元素x),会导致左右区间的元素数量严重失衡,导致快排效率下降。

    • 这里我们就可以使用三路划分了,所谓三路划分,就是数组被划分成三部分:< key、==key、 和> key三个部分,我们只需递归划分key这两部分的区间。

    • 由于key值很容易取到x(一旦取到x,左右区间的size一定会大大减小),这种算法一定程度上提高了效率。

    如何实现?详见LeetCode912

    1. class Solution {
    2. public:
    3.    pair<int, int> Partion(vector<int>& nums, int begin, int end)
    4.   {
    5. //随机数取key
    6.        int keyI = begin + rand() % (end - begin + 1);
    7.        swap(nums[begin], nums[keyI]);
    8.        int key = nums[begin];
    9.        int left = begin, right  = end, cur = left + 1;
    10.        while(cur <= right)
    11.       {
    12.            if(nums[cur] < key)
    13.           {
    14.                swap(nums[left++], nums[cur++]);
    15.           }
    16.            else if(nums[cur] == key)
    17.           {
    18.                cur++;
    19.           }
    20.            else
    21.           {
    22.                swap(nums[right--], nums[cur]);
    23.           }
    24.       }
    25.        return make_pair(left, right);
    26.   }
    27.    void QuickSort(vector<int>& nums, int begin, int end)
    28.   {
    29.        if(end - begin + 1 <= 1)
    30.            return;
    31.        pair<int, int> p = Partion(nums, begin, end);
    32.        QuickSort(nums, begin, p.first - 1);
    33.        QuickSort(nums, p.second + 1, end);
    34.   }
    35.    vector<int> sortArray(vector<int>& nums)
    36.   {
    37.        srand((unsigned int)time(NULL));
    38.        QuickSort(nums, 0, nums.size() - 1);
    39.        return nums;
    40.   }
    41. };

    6 快排非递归实现

    快排的非递归我们可以使用一个栈(深度优先遍历)或者一个队列实现(广度优先遍历)。

    6.1 栈实现(代码+图解)

    1. void QuickSortNonRByStack(int* a, int n)
    2. {
    3. Stack st;
    4. StackInit(&st);
    5. int begin = 0, end = n - 1;
    6. //先Push右边界,在Push左边界
    7. //记住push顺序,取top的时候左右不要取反了
    8. StackPush(&st, end);
    9. StackPush(&st, begin);
    10. while (!StackEmpty(&st))
    11. {
    12. int begin = StackTop(&st);  
    13. StackPop(&st);  
    14. int end = StackTop(&st);
    15. StackPop(&st);
    16. int keyI = Partion3(a, begin, end);
    17. //[begin, keyI - 1] keyI [keyI + 1, end]
    18. //先递归到左区间,所以右区间先入栈
    19. if (keyI + 1 < end)
    20. {
    21. //先Push右边界,在Push左边界
    22. StackPush(&st, end);  
    23. StackPush(&st, keyI + 1);  
    24. }
    25. if (begin < keyI - 1)
    26. {
    27. //先Push右边界,在Push左边界
    28. StackPush(&st, keyI - 1);
    29. StackPush(&st, begin);
    30. }
    31. }
    32. StackDestory(&st);
    33. }

     

    6.2 队列实现

    1. void QuickSortNonRByQueue(int* a, int n)
    2. {
    3. Queue q;
    4. QueueInit(&q);
    5. int begin = 0, end = n - 1;
    6. QueuePush(&q, begin);
    7. QueuePush(&q, end);
    8. while (!QueueEmpty(&q))
    9. {
    10. int begin = QueueFront(&q);
    11. QueuePop(&q);
    12. int end = QueueFront(&q);
    13. QueuePop(&q);
    14. int keyI = Partion3(a, begin, end);
    15. if (begin < keyI - 1)
    16. {
    17. QueuePush(&q, begin);  
    18. QueuePush(&q, keyI - 1);
    19. }
    20. if (keyI + 1 < end)
    21. {
    22. QueuePush(&q, keyI + 1);
    23. QueuePush(&q, end);  
    24. }
    25. }
    26. QueueDestory(&q);
    27. }

    7 特性总结

    • 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序

    • 时间复杂度:O(NlogN)

    最好情况,每次key都在中间位置,正好二分

    最坏情况,每次key都是最值,复杂度O(N^2)

    平均情况(带优化),复杂度O(NlogN)

    • 空间复杂度:O(logN)

  • 相关阅读:
    【PAT甲级 - C++题解】1088 Rational Arithmetic
    【无标题】
    Layui之用户(CURD)
    「PAT乙级真题解析」Basic Level 1103 (问题分析+完整步骤+伪代码描述+提交通过代码)
    MQ认证探讨
    如何实现用django读写elasticsearch
    二,几何相交-5,BO算法实现--(3)事件和操作
    Qt国际化翻译解决方案
    AJAX介绍
    手写RPC框架
  • 原文地址:https://blog.csdn.net/qq_63981383/article/details/133831827