快速排序的介绍以及其两大坑点

前言


 

  学完冒泡排序之后,我们会发现冒泡排序虽然比桶排序的空间效率要高,但是冒泡排序的执行效率却是很低的,它的时间复杂度达到O(N^2)。假设电脑一秒可运行10亿次,那么对一亿个数进行排序的话,桶排序需要0.1秒,而冒泡排序需要1千万秒,相当于115天,是不是很可怕?有没有什么双赢的算法呢?有的,就是快速排序。

  

步骤


 

  假设给出一个数列 6 1 2 7 9 3 4 5 10 8,输出 1 2 3 4 5 6 7 8 9 10。我们利用快速排序的解法去做

  1.设一个基准数(也称中枢元素,起对照作用),譬如设头元素6

  2.把比基准数小的都排在左边,把比基准数大的都排在右边,譬如3 1 2 5 4 6 9 7 10 8 

  3.对左边的数列 3 1 2 5 4 再进行步骤 1 步骤 2 的操作

  4.子数列排序成了 2 1 3 5 4

  5.再对它的子数列进行步骤 1 2 的操作

  6.最终将得到10个单元素数列 1  , 2 , 3, 4, 5, 6, 7, 8, 9, 10

  

第一轮快排的卡通版过程图如下(源于《啊哈!算法》):

 

  

  

  

  

  

  

 

全部的流程图如下:

  

 

 代码实现


 

void quickSort (int *a, int left, int right) {
    int centerIndex;
    int i, j, temp;
    centerIndex = a[left];
    i = left;
    j = right;
    if (i >= j) {return;} // prevent the ocurrence "left > j-1" 
    while (i < j) {
        while ((a[j] >= centerIndex) && (i < j)) { // j is first 
            --j;
        }
        while ((a[i] <= centerIndex) && (i < j)) {
            ++i;
        }
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
    temp =  a[j]; //exchange real centerIndex
    a[j] = a[left];
    a[left] = temp;
    quickSort(a, left, j-1);
    quickSort(a, j+1, right);
}

 

或者使用这个更可读性更好的 C++ 版本,能解决 Leetcode 912. Sort an Array,做了最基本的优化,遇到已排序的数组就不用再排序了

class Solution {
public:
    vector<int> sortArray(vector<int>& nums) {
        if (nums.size() <= 1) return nums;
        
        // 快排的最坏时间复杂度是 O(n^2),过不去 case,需要对最坏情况(排好序或倒序)进行额外处理
        if (std::is_sorted(nums.begin(), nums.end())) {
            return nums;
        }
        if (std::is_sorted(nums.begin(), nums.end(), std::greater<int>())) {
            std::reverse(nums.begin(), nums.end());
            return nums;
        }
        
        qsort(nums, 0, nums.size() - 1);
        return nums;
    }
    
private:
    void qsort(vector<int>& nums, int left, int right) {
        // std::cout << "left: " << left << ", right: " << right << std::endl;
        if (left >= right) return;
        int pivot = partition(nums, left, right);
        qsort(nums, left, pivot - 1);
        qsort(nums, pivot + 1, right);
    }
    
    int partition(vector<int>& nums, int left, int right) {
        int pivot = left;
        while (left < right) {
            while (left < right && nums[right] >= nums[pivot]) --right;
            while (left < right && nums[left] <= nums[pivot]) ++left;
            
            if (left != right) std::swap(nums[left], nums[right]);
        }
        
        if (pivot != right) std::swap(nums[pivot], nums[right]);
        
        return right;
    }
};

 

两大坑点


  第一个坑点:每次扫描时必须先保证哨兵 j 先动,然后哨兵 i 再动,而且在扫描时时刻保持 i<j 。

  这个设计思路是为了应对当子序列已经是排序好的情况,此时只能交换的数字只能是中枢元素本本身,不应把基准数与其他数字再交换而破坏了原子序列。举个例子,当子序列为 1 2 3 4 ,设基准数是 1 ,如果此时i 先走,最终 i  = 1, j = 1,a[i] = 2,a[j] = 2,交换基准数与 a[j] ,子序列变为 2 1 3 4 ,原子序列被破坏。

  第二个坑点是递归的出口需考虑哨兵 j 越界的情况,结束递归的条件是 left > =right 而不是 left == right。

  我一开始也认为递归出口设置为 left == right 就万事大吉了,因为它可以表示当前子序列只有一个元素,那么就不用进行快排了。但是漏考虑了在子序列已经排序好的情况时 进行扫描的话,会出现 j - 1 < left 的越界问题。举个例子,当子序列为 3 7 9 6 ,基准数是 3 ,最终经过扫描后 j = 0,a[j] = 0,此时 (j-1) = -1,你将把 j-1 作为 right 传入函数中,而此时  left = 0,下次的分治会出现 left > right ,出现逻辑问题。

 

时间复杂度以及空间复杂度分析


 

  平均情况下,划分需要时间 O(n),而递归深度O(lgn) ,所以时间复杂度是 O(nlgn),最坏情况是输入有序串,导致 O(n^2) ,如果采用随机化算法,时间复杂度为O(nlgn)

   由于需要递归调用,所以空间复杂度是 O(lgn)

 

总结


 

  总的来说,快排的操作是这样的:快速排序有两个方向,当 a[i] <= a[center_index],左边的i下标一直往右走,其中 center_index 是中枢元素的数组下标,一般取为数组第0个元素。而当 a[j] > a[center_index],右边的j下标一直往左走。如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j。交换 a[j] 和 a[center_index],完成一趟快速排序。

  快速排序办法之所以快速,是因为相比于冒泡排序,每次交换都是跳跃式的。每次排序的时候都设置一个基准点,小的放左边,大的放右边,这样就不会像冒泡排序那样只在相邻的数之间进行交换,交换的距离就大得多了。当然,在最坏的情况下,仍可能是相邻的两个数进行交换。因此,快排的最差时间复杂度仍是O(N^2),它的平均时间复杂度为O(NlogN)。这种方法基于“二分”思想,涉及到了分治法(不断分割成子数列,然后分别处理)

 

posted @ 2017-07-14 21:20  bw98  阅读(459)  评论(1编辑  收藏  举报