头图

原文: https://zhuanlan.zhihu.com/p/...
欢迎关注我知乎号: https://www.zhihu.com/people/...

快速排序QuickSort采用了分治法Divide-and-ConquerMethod,通过将数组链表或其他元素集分为待排序集合和已排序集合,并在一次次迭代中将待排序集合的元素转化到已排序集合当中直到全部元素都为已排序则完成排序。

快速排序利用这一策略,节约了处理已排序元素的成本。算法只关注剩余待排序的元素,其中位置连续的未排序元素子串又分为:S1(左侧),pivot(交换枢纽元),S2(右侧)

以快速排序来实现升序排序为例:

先从数组中选取出一个数组作为枢轴元pivot。(选取枢纽元的策略很关键)
将待排序集合所有小于枢轴元pivot的元素移至s1(左侧),所有大于枢轴元pivot的元素移至s2(右侧):(双指针中的相向指针法)
先将枢轴元pivot(准有序部分)放到最左或者最右区分出待排序部分。
将i, j分别指向待排序部分索引的最左和最右。
如果i索引指向的元素小于枢纽元,则i++;否则,i停止。j索引指向的元素大于枢纽元,j--;否则,j停止。
如果i<j,则交换两个元素,继续循环3,4步骤;否则跳出循环,将i对应的元素与枢纽元pivot交换(这时候完成了分割)
这个时候定义枢轴元pivot为已排序部分,对剩余未排序部分S1(左侧)S2(右侧)继续循环步骤1,2,3直到所有元素都已排序。
最原始的快速排序
最原始的快速排序采用最简单粗暴的枢纽元选取策略,即选取第一个或最后一个元素为pivot。

该方案的平均时间复杂度为O(nlogn),当遇到数组刚好有序的情况下会出现最坏时间复杂度O(n^2),因为当输入序列本身有序时,会导致S1或S2集合为空,除枢纽元外所有元素都在S1或S2,此时遍历交换的时间效率大幅下降,因为浪费了另一个区间的作用。

Python代码

nums = [23,2,4,6,2,5,1,6,13,54,8]

def quicksort(nums, left, right):   # left为最左索引,righ为最右索引
    if left >= right:
        return
    pivot = left //取第一个元素为pivot
    i, j = left, right
    while i < j:
        while nums[pivot] <= nums[j] and i < j:
            j -= 1
        while nums[pivot] >= nums[i] and i < j:
            i += 1
        if i < j:
            nums[i], nums[j] = nums[j], nums[i]
    nums[pivot], nums[i] = nums[i], nums[pivot]
    quicksort(nums, left, i-1)
    quicksort(nums, i+1, right)

def quicksort2(nums, left, right):          # 用栈代替递归
    if left >= right:
        return
    stack = []
    while stack or left < right:
        if left < right:
            pivot = left
            i, j = left, right
            while i < j:
                while nums[pivot] <= nums[j] and i < j:
                    j -= 1
                while nums[pivot] >= nums[i] and i < j:
                    i += 1
                if i < j:
                    nums[i], nums[j] = nums[j], nums[i]
            nums[pivot], nums[i] = nums[i], nums[pivot]
            stack.append((left, i, right))
            right = i - 1
        else:
            left, mid, right = stack.pop()
            left = mid + 1

quicksort2(nums, 0, len(nums)-1)
print(nums) 

由于堆栈存储(递归等价于栈代替),快速排序空间复杂度O(logn)

所以S1和S2约平衡,快速排序的效率越高,即中值(中位数)是枢纽元最好的选择(因为可以将序列均分为两个子序列,归并排序告诉我们,这时候是O(NlogN)

但要计算一组数组的中位数就比较耗时,会减慢快排的效率。

小问题:为什么当轴元pivot放入左侧时, 必须先平移右指针j,而不能先平移左指针i ?

答案:为了避免平移交换过程中, pivot自己被交换掉。

快速排序的优化
三数中值快速排序(三数中值法选取枢轴元pivot)
虽然计算一组数组的中位数就比较耗时,会减慢快排的效率。但可以通过计算数组的第一个left,中间位置(right-left)/2(向下或向上取整),最后一个right元素的中值来代替。

Python代码

def quicksort_opt1(nums, left, right): 
    if left >= right:
        return
    stack = []
    while stack or left < right:
        if left < right:
            l, m, r = left, (right-left)//2, right # 第一个, 中间位置, 最后一个
            pivot = findmedian(l, m, r) # 三数取中值法选取枢轴元pivot
            i, j = left, right
            while i < j:
                while nums[pivot] <= nums[j] and i < j:
                    j -= 1
                while nums[pivot] >= nums[i] and i < j:
                    i += 1
                if i < j:
                    nums[i], nums[j] = nums[j], nums[i]
            nums[pivot], nums[i] = nums[i], nums[pivot]
            stack.append((left, i, right))
            right = i - 1
        else:
            left, mid, right = stack.pop()
            left = mid + 1

def findmedian(l, m, r):
    if nums[l] <= nums[m]:
        if nums[m] <= nums[r]:
            return m
        else:
            if nums[l] >= nums[r]:
                return l
            else:
                return r
    else:
        if nums[m] >= nums[r]:
            return m
        else:
            if nums[l] >= nums[r]:
                return r
            else:
                return l

用三数中值法来选取枢轴元pivot一定程度上避免了最坏的情况发生。

但是即使这样,我们的快速排序算法仍然有可能出现最坏的情况:时间复杂度O(n^2)

因为重复的元素越多会给算法带来越多的无用功,会涉及以下一个问题:

简单说是遇到与枢纽元相等的元素时,左右索引指针的平移需要停止吗?

如果只有一个停止:这将导致所有等于枢纽元pivot的元素都移动到同一侧(S1或S2),在极端情况下所有元素都是重复,会产生最坏情况O(n^2)
如果都不停止:在极端情况下所有元素都是重复,整个过程枢纽元pivot相当于对整个数组进行了一次遍历,时间复杂度是(n + n-1 +...+2+1)=(1/2)(1+n)*n, 即时间复杂度O(n^2),其实推演一下会发现基于快速排序的执行规则这种情况跟情况1是等价的,都不停止相当于始终只有一个指针在平移,而且是一直平移到末尾才停止。
如果都停止:在极端情况下所有元素都是重复,虽然看似会进行很多次“无意义”的交换,但由于每次双指针相遇的地点都是数组的中点,这个时候恰好将序列分为两个均等分配的子序列,还是归并排序的原理,达到分治法效率最大化,以此类推会让枢纽元pivot以logn的速度走完整个数组。所以这种方法最坏情况的时间复杂度为O(nlogn)
重复元素处理思路:三向切分快速排序

三向切分快速排序推荐简书上这个博客

快速排序(Quick Sort)
​www.jianshu.com/p/779bc4b61254

双路快速排序
图解快速排序及双路三路快速排序 - SegmentFault 思否
​segmentfault.com/a/1190000021726667

双路快速排序把待排序区域用指针 i , j 分为三个子区域:

(<nums[pivot]),未知,(>nums[pivot])

将&amp;amp;lt;v和&amp;amp;gt;v两部分放在数组的两端,用i指向&amp;amp;lt;v部分的下一个元素,用j指向&amp;amp;gt;v部分的前一个元素。

从i开始向后遍历,如果遍历的元素&amp;amp;lt;v,则继续向后遍历,直到遍历的元素&amp;amp;gt;v,则停止遍历。同样从j开始向前遍历,如果遍历的元素&amp;amp;gt;v,则继续向前遍历,直到遍历的元素&amp;amp;lt;v,则停止遍历。

交换i指向的元素和j指向的元素。然后i++,j--继续比较下一个。
Python代码

def quicksort_opt2(nums, left, right): 
    if left >= right:
        return
    stack = []
    while stack or left < right:
        if left < right:
            l, m, r = left, (right-left)//2, right # 第一个, 中间位置, 最后一个
            pivot = findmedian(l, m, r) # 三数取中值法选取枢轴元pivot
            nums[pivot], nums[left] = nums[left], nums[pivot]
            pivot = left
            i, j = left+1, right
            while i <= j:   # 为什么不能是 i < j?
                # 此处先平移j和先平移i对结果没有影响,因为 i=left+1避开了pivot
                while nums[i] < nums[pivot] and i <= j:
                    #不能改为nums[i] <= nums[pivot], 因为这种方式将连续出现的这些值归为其中一方, 使得两棵树不平衡
                    i += 1
                while nums[j] > nums[pivot] and i <= j: 
                    j -= 1
                if i <= j:
                    nums[i], nums[j] = nums[j], nums[i]
                    j -= 1
                    i += 1
            nums[pivot], nums[j] = nums[j], nums[pivot] 
            #因为pivot放置在左边, 所以不可与左侧指针i交换---->会造成无限循环
            stack.append((left, j, right))  
            right = j - 1
        else:
            left, mid, right = stack.pop()
            left = mid + 1

nums = [23,2,4,6,2,5,1,6,13,54,8]
quicksort_opt2(nums, 0, len(nums)-1)
print(nums) 

问题1:为什么此处不能用 i < j 代替 i <= j 当作循环条件?

问题2:为什么平移结束后, pivot要跟 j 交换 而不是 i ?为什么跟 i 交换会造成无限循环?

那么在此基础上,我们能否再次优化,将前文提到的重复元素'无意义'的交换也省了? 当然可以,下面的三路快速排序就是围绕这点进行优化。

三路快速排序 3 way quick sort
在双路快排把未排序区域分为三个子区域的基础上,三路快排多分了一个子区域用来存放重复且等于pivot的元素,来识别并跳过他们。

在双路快速排序的基础上,我们把等于v的元素单独作为一个部分。lt指向小于v部分的最后一个元素,gt指向大于v部分的第一个元素。

从i开始向后遍历,如果遍历的元素e=v,则e直接合并到=v部分,然后i++继续遍历。如果遍历的元素e&amp;amp;lt;v,则将e和=v部分的第一个元素(lt+1指向的元素)交换,然后lt++,i++继续遍历。如果遍历的元素e&amp;amp;gt;v,则将e和&amp;amp;gt;v部分前一个元素(gt-1指向的元素)交换,然后gt--,不过此

Python代码

def quicksort_opt3(nums, left, right): 
    if left >= right:
        return
    stack = []
    while stack or left < right:
        if left < right:
            l, m, r = left, (right-left)//2, right
            pivot = findmedian(l, m, r)
            nums[pivot], nums[left] = nums[left], nums[pivot]
            pivot = left
            lt, gt = left, right + 1
            i = left + 1
            while i < gt:
                if nums[i] < nums[pivot]:
                    nums[i], nums[lt+1] = nums[lt+1], nums[i]
                    i += 1
                    lt += 1
                elif nums[i] > nums[pivot]:
                    nums[i], nums[gt-1] = nums[gt-1], nums[i] 
                    gt -= 1
                else:# nums[i] == nums[pivot]
                    i += 1
            nums[pivot], nums[lt] = nums[lt], nums[pivot] 
            stack.append((left, lt, right))  
            right = lt - 1
        else:
            left, mid, right = stack.pop()
            left = mid + 1

nums = [23,2,4,6,2,5,1,6,13,54,8]
quicksort_opt3(nums, 0, len(nums)-1)
print(nums) 

快速排序复杂度分析
快速排序的平均时间复杂度为O(nlogn)

快速排序平均复杂度数学证明可参考此回答:
https://www.zhihu.com/questio...

快速排序的短板:
即使是三路快速排序也做不到堆排序那样保证最坏时间复杂度为O(nlogn)

快速排序是不稳定排序 (所谓稳定就是当待排数组中存在重复元素的时候,排序后重复元素的相对顺序不会改变)

当待排序序列元素数量很小(N<=20)的时候,快速排序不如插入排序快,并且插入排序是稳定排序。

github代码出处: https://github.com/stevezkw19...

欢迎关注我的github:stevezkw1998 - Overview


stevekk
1 声望0 粉丝

现Facebook offer