zoukankan      html  css  js  c++  java
  • 重谈快速排序

    重谈快速排序

             ——《数据结构与算法分析——C语言描述》

             快速排序是最为常见的排序算法之一。谷歌、百度“快速排序”,会有很多快速排序的讲解和代码实现。但是这么多实现中,真正好的实现并不多见,快速排序思想很简单,但是实现起来有很多细节需要注意,稍不留意,就会出现错误或者效率降低。

             快速排序有如下两个关键点:

             1.枢纽元的选择

             2.选定了枢纽元后,如何进行左右分割

             这两点都会直接影响快速排序的效率,尤其是第二点,如果有些细节实现没有考虑清楚,会导致效率降低,甚至出现死循环。

             选定了枢纽元后,进行左右分割有很多方法,只要最后分割的左右两部分元素满足小于或大于枢纽元即可。

             常见的方法有挖坑填数法,具体实现网上有很多。但是大多数实现虽然完成了快速排序的功能,其运行效率并不是最好的,尤其是当待排序序列中的元素都是一样时,大多数的实现其时间复杂度退化为O(N*N)。而理想情况下,即便待排序序列中元素都一样时,快排的时间复杂度也应该为O(NlogN)。网上一些实现例子有:实现1234

             对待排序序列进行分割思想很简单,实现起来有很多细节需要把握,稍不留意就会造成偏差,其原因在于我们选定枢纽元后,对待排序的分割操作是在原序列的上进行的,其空间复杂度为O(1),这样会造成分割过程中一些细节的处理需要稍微谨慎。如果开辟一个临时的序列空间,用于暂存分割的序列,那么其空间复杂度为O(N),时间复杂度没有提高,但是整个逻辑流程会变得很清晰,不会导致什么错误。

             本文,我们是依照经典数据结构教材《数据结构与算法分析——C语言描述》中关于快速排序的讲解,对排序排序进行一实现。相关原理细节可以参阅本书。我们给出快速排序的实现,代码中添加一些快速排序的注释说明。

             快速排序的最坏情况、最好情况、平均情况时间复杂度分析可以参照一些教材。我们这里重点在于算法的实现,不涉及数学方面的理论证明。

    // 快速排序的实现
    #include <iostream>
    using namespace std;
    
    // 交换函数
    void swap(int& a, int& b)
    {
        int t = a;
        a = b;
        b = t;
    }
    
    // 该函数用于选择合适的枢纽元
    // 具体做法是:选取三个元素,这三个元素为待排序序列中的第一个、最后一个、中间一个
    // 去三个元素的中位数,作为快排的枢纽元
    // 三个元素的选取也可以完全随机,但是没必要完全随机
    // 该函数有两个功能:1.选择合适的枢纽元;2.将枢纽元交换到right-1位置
    int Median3(int arr[], int left, int right)
    {
        // 取中间的坐标
        int center = (left + right) / 2;
    
        // 对三个数进行排序
        if (arr[left] > arr[center])
        {
            swap(arr[left], arr[center]);
        }
        if (arr[left] > arr[right])
        {
            swap(arr[left], arr[right]);
        }
        if (arr[center] > arr[right])
        {
            swap(arr[center], arr[right]);
        }
    
        // 我们选arr[center]作为枢纽元
        // arr[right]本身会大于等于枢纽元
        // arr[left]本身会小于等于枢纽元
        // 所以,我们不再将枢纽元置坐标置为right,而是置为right-1
        // 在后续操作中,i的初始值设置为left+1,j的初始值设置为right-2
    
        // 将枢纽元置于序列的后面,但不是最后一个——right,而是right-1
        swap(arr[center], arr[right - 1]);
    
        // 返回枢纽元
        return arr[right - 1];
    }
    
    // 打印序列
    void PrintArr(int arr[], int n)
    {
        for (int i = 0; i < n; ++i)
        {
            cout << arr[i] << ' ';
        }
        cout << endl;
    }
    
    // 插入排序
    // 时间复杂度O(N*N)
    void InsertionSort(int arr[], int n)
    {
        if (n <= 1)
        {
            return;
        }
        for (int i = 1; i < n; ++i)
        {
            int tmp = arr[i];
            int p   = i - 1;
            while (p >= 0 && arr[p] > arr[p + 1])
            {
                arr[p + 1] = arr[p];
                --p;
            }
            arr[p + 1] = tmp;
        }
    }
    
    // 用于定义调用快速排序的最低元素个数:Cutoff+1
    const int Cutoff = 3;
    
    // 快速排序实现
    // 相关细节详见函数内注释说明
    void QSort(int arr[], int left, int right)
    {
        int i = 0, j = 0;
        int pivot = 0;
    
        if (left + Cutoff <= right) // 如果元素个数大于等于Cutoff+1个,则调用快速排序
        //if (left < right) // 如果没有插入排序的话
        {
            pivot = Median3(arr, left, right);
            // Median3函数已经选择出枢纽元
            // 并且将枢纽元交换到right-1的位置
    
            i = left; // 注意这里还不是left+1,因为后面i的操作是++i
            j = right - 1; // 这里j还不是right-2,因为后面j的操作是--j
    
            for (;;)
            {
                while (arr[++i] < pivot);
                // 说明:++i操作,从left+1位置开始
                // 如果i元素小于枢纽元,则i右移
                // 不能使<=,因为如果真个序列的元素都是一个值,那么会造成时间复杂度为O(N*N)
                // <可以使时间复杂度为O(NlogN),另一原因是++i
                // <导致=也会停止,进行交换
    
                while (arr[--j] > pivot);
                // 参照++i
                // 不能使>=,因为同样会导致时间复杂度为O(N*N)
    
                if (i < j)
                {
                    // 如果i和j没有交叉,此时,arr[i]大于等于pivot、arr[j]小于等于pivot
                    // 交换两个数
                    // 如果arr[i]和arr[j]都等于pivot,也没关系,直接交换,因为上面是++i和--j
    
                    swap(arr[i], arr[j]);
                }
                else
                {
                    // 这里有可能是i=j
                    // 如果出现i=j,说明arr[i]>=pivot,arr[j]<=pivot,又因为i=j,所以arr[i]=arr[j],所以arr[i] = pivot
    
                    // 如果i>j
                    // 这里只能出现一种情况就是i=j+1
                    
                    // 不管是i=j还是i>j,这一轮交换完毕
                    break;
                }
            }
    
            // for 循环结束之后进行交换
            // 此时i指向的元素大于或等于枢纽元,j指向的元素小于或等于枢纽元
            // 由于枢纽元此时在right-1位置,所以需要枢纽元和i元素交换
            swap(arr[i], arr[right - 1]);
    
            // 完成一轮分割后,进行下一轮分割
            // 分治递归
            // 注意下一轮分割的左右部分都不包含上一轮的枢纽元,因为枢纽元是左右部分的中间元素,没必要包含
            // 更为重要的是,如果包含了,有可能导致死循环,递归无法结束
            // 此时枢纽元所在的位置为i
            QSort(arr, left, i - 1);
            QSort(arr, i + 1, right);
        }
        else // 调用插入排序
        {
            InsertionSort(arr + left, right - left + 1);
    
            // 插入排序和快速排序的时间复杂度不同,各自有各自的特点和适用场合
        }
    }
    
    // 对快速排序进行封装
    void QuickSort(int arr[], int n)
    {
        QSort(arr, 0, n - 1);
    }
    
    int main()
    {
        int arr[] = {8, 1, 4, 9, 6, 3, 5, 2, 7, 0};
    
        PrintArr(arr, sizeof (arr) / sizeof (*arr));
        QuickSort(arr, sizeof (arr) / sizeof (*arr));
        PrintArr(arr, sizeof (arr) / sizeof (*arr));
    
        return 0;
    }

             对于QSort中循环的实现:

            i = left; // 注意这里还不是left+1,因为后面i的操作是++i
            j = right - 1; // 这里j还不是right-2,因为后面j的操作是--j
    
            for (;;)
            {
                while (arr[++i] < pivot);
            
                while (arr[--j] > pivot);
                
                if (i < j)
                {
                    swap(arr[i], arr[j]);
                }
                else
                { 
                    break;
                }
            }

             如果我们将其改为:

            i = left + 1;  // 一开始就将i置为left+1
            j = right - 2; // 一开始就将j置为right-2
    
            for (;;)
            {
                while (arr[i] < pivot)
                {
                    ++i;
                }
                while (arr[j] > pivot)
                {
                    --j;
                }
                if (i < j)
                {
                    swap(arr[i], arr[j]);
                }
                else
                {
                    break;
                }
            }

             如果出现arr[i]=arr[j]=pivot情况,for循环将进入死循环,因为每次检查到arr[i]=pivot和arr[j]=pivot就会终止循环,并且i<j,相互交换,还都是pivot,无法终止。

             一个改进的方法就是,我们在每次swap之后,修改i和j的值,对i自加,对j自减,这种操作是可行的,因为当前i和j元素交换,满足i元素小于等于pivot,j元素大于等于pivot。改进如下:

            i = left + 1;  // 一开始就将i置为left+1
            j = right - 2; // 一开始就将j置为right-2
    
            for (;;)
            {
                while (arr[i] < pivot)
                {
                    ++i;
                }
                while (arr[j] > pivot)
                {
                    --j;
                }
                if (i < j)
                {
                    swap(arr[i], arr[j]);
                    ++i; // 修改i
                    --j; // 修改j
                }
                else
                {
                    break;
                }
            }

             完整的程序如下:

    // 两个循环
    #include <iostream>
    using namespace std;
    
    // 交换函数
    void swap(int& a, int& b)
    {
        int t = a;
        a = b;
        b = t;
    }
    
    // 该函数用于选择合适的枢纽元
    // 具体做法是:选取三个元素,这三个元素为待排序序列中的第一个、最后一个、中间一个
    // 去三个元素的中位数,作为快排的枢纽元
    // 三个元素的选取也可以完全随机,但是没必要完全随机
    // 该函数有两个功能:1.选择合适的枢纽元;2.将枢纽元交换到right-1位置
    int Median3(int arr[], int left, int right)
    {
        // 取中间的坐标
        int center = (left + right) / 2;
    
        // 对三个数进行排序
        if (arr[left] > arr[center])
        {
            swap(arr[left], arr[center]);
        }
        if (arr[left] > arr[right])
        {
            swap(arr[left], arr[right]);
        }
        if (arr[center] > arr[right])
        {
            swap(arr[center], arr[right]);
        }
    
        // 我们选arr[center]作为枢纽元
        // arr[right]本身会大于等于枢纽元
        // arr[left]本身会小于等于枢纽元
        // 所以,我们不再将枢纽元置坐标置为right,而是置为right-1
        // 在后续操作中,i的初始值设置为left+1,j的初始值设置为right-2
    
        // 将枢纽元置于序列的后面,但不是最后一个——right,而是right-1
        swap(arr[center], arr[right - 1]);
    
        // 返回枢纽元
        return arr[right - 1];
    }
    
    // 打印序列
    void PrintArr(int arr[], int n)
    {
        for (int i = 0; i < n; ++i)
        {
            cout << arr[i] << ' ';
        }
        cout << endl;
    }
    
    // 插入排序
    // 时间复杂度O(N*N)
    void InsertionSort(int arr[], int n)
    {
        if (n <= 1)
        {
            return;
        }
        for (int i = 1; i < n; ++i)
        {
            int tmp = arr[i];
            int p   = i - 1;
            while (p >= 0 && arr[p] > arr[p + 1])
            {
                arr[p + 1] = arr[p];
                --p;
            }
            arr[p + 1] = tmp;
        }
    }
    
    // 用于定义调用快速排序的最低元素个数:Cutoff+1
    const int Cutoff = 3;
    
    // 快速排序实现
    // 相关细节详见函数内注释说明
    void QSort(int arr[], int left, int right)
    {
        int i = 0, j = 0;
        int pivot = 0;
    
        if (left + Cutoff <= right) // 如果元素个数大于等于Cutoff+1个,则调用快速排序
        //if (left < right) // 如果没有插入排序的话
        {
            pivot = Median3(arr, left, right);
            // Median3函数已经选择出枢纽元
            // 并且将枢纽元交换到right-1的位置
    
            i = left; // 注意这里还不是left+1,因为后面i的操作是++i
            j = right - 1; // 这里j还不是right-2,因为后面j的操作是--j
    
            for (;;)
            {
                while (arr[++i] < pivot);
                // 说明:++i操作,从left+1位置开始
                // 如果i元素小于枢纽元,则i右移
                // 不能使<=,因为如果真个序列的元素都是一个值,那么会造成时间复杂度为O(N*N)
                // <可以使时间复杂度为O(NlogN),另一原因是++i
                // <导致=也会停止,进行交换
    
                while (arr[--j] > pivot);
                // 参照++i
                // 不能使>=,因为同样会导致时间复杂度为O(N*N)
    
                if (i < j)
                {
                    // 如果i和j没有交叉,此时,arr[i]大于等于pivot、arr[j]小于等于pivot
                    // 交换两个数
                    // 如果arr[i]和arr[j]都等于pivot,也没关系,直接交换,因为上面是++i和--j
    
                    swap(arr[i], arr[j]);
                }
                else
                {
                    // 这里有可能是i=j
                    // 如果出现i=j,说明arr[i]>=pivot,arr[j]<=pivot,又因为i=j,所以arr[i]=arr[j],所以arr[i] = pivot
    
                    // 如果i>j
                    // 这里只能出现一种情况就是i=j+1
                    
                    // 不管是i=j还是i>j,这一轮交换完毕
                    break;
                }
            }
    
            // for 循环结束之后进行交换
            // 此时i指向的元素大于或等于枢纽元,j指向的元素小于或等于枢纽元
            // 由于枢纽元此时在right-1位置,所以需要枢纽元和i元素交换
            swap(arr[i], arr[right - 1]);
    
            // 完成一轮分割后,进行下一轮分割
            // 分治递归
            // 注意下一轮分割的左右部分都不包含上一轮的枢纽元,因为枢纽元是左右部分的中间元素,没必要包含
            // 更为重要的是,如果包含了,有可能导致死循环,递归无法结束
            // 此时枢纽元所在的位置为i
            QSort(arr, left, i - 1);
            QSort(arr, i + 1, right);
        }
        else // 调用插入排序
        {
            InsertionSort(arr + left, right - left + 1);
    
            // 插入排序和快速排序的时间复杂度不同,各自有各自的特点和适用场合
        }
    }
    
    // 对快速排序进行封装
    void QuickSort(int arr[], int n)
    {
        QSort(arr, 0, n - 1);
    }
    
    // 快速排序实现
    // 相关细节详见函数内注释说明
    void QSort2(int arr[], int left, int right)
    {
        int i = 0, j = 0;
        int pivot = 0;
    
        if (left + Cutoff <= right)
        {
            pivot = Median3(arr, left, right);
    
    
            i = left + 1;  // 一开始就将i置为left+1
            j = right - 2; // 一开始就将j置为right-2
    
            for (;;)
            {
                while (arr[i] < pivot)
                {
                    ++i;
                }
                while (arr[j] > pivot)
                {
                    --j;
                }
                if (i < j)
                {
                    swap(arr[i], arr[j]);
                    ++i; // 修改i
                    --j; // 修改j
                }
                else
                {
                    break;
                }
            }
    
            swap(arr[i], arr[right - 1]);
    
            QSort(arr, left, i - 1);
            QSort(arr, i + 1, right);
        }
        else
        {
            InsertionSort(arr + left, right - left + 1);
        }
    }
    
    // 对QSort2封装
    void QuickSort2(int arr[], int n)
    {
        QSort2(arr, 0, n - 1);
    }
    
    int main()
    {
        int arr[] = {8, 1, 4, 9, 6, 3, 5, 2, 7, 0};
    
        PrintArr(arr, sizeof (arr) / sizeof (*arr));
        QuickSort2(arr, sizeof (arr) / sizeof (*arr));
        PrintArr(arr, sizeof (arr) / sizeof (*arr));
    
        int arr2[] = {9, 9, 9, 9, 9, 9, 9, 9, 9, 9};
    
        PrintArr(arr2, sizeof (arr2) / sizeof (*arr2));
        QuickSort2(arr2, sizeof (arr2) / sizeof (*arr2));
        PrintArr(arr2, sizeof (arr2) / sizeof (*arr2));
    
        return 0;
    }

           插入排序和快速排序

             插入排序的时间复杂度是O(N*N),快速排序平均的时间复杂度是O(NlogN)。但是并不是所有情况下快速排序都优于插入排序。当待排序元素个数较少时,更适宜用插入排序,比如程序中如果left+Cutoff>right时。

           总结

             以上是关于快速排序的相关内容。现在网上大多数快速排序的实现都并不是太好,存在或多或少的瑕疵,比如在分割过程中,如果当全部元素都是一样时,时间复杂度将为O(N*N)等。

             快速排序的分割操作是在其自身位置进行的分割,容易导致分割过程中一些细节性问题。但是分割的思想很简单,目的是为了最大化的平分左右部分。

  • 相关阅读:
    C# 日期帮助类【原创】
    C# 发送邮件
    每日一题力扣453
    每日力扣628
    每日一题力扣41巨坑
    每日一题力扣274
    每日一题力扣442有坑
    每日一题力扣495
    每日一题力扣645
    每日一题力扣697
  • 原文地址:https://www.cnblogs.com/unixfy/p/3479573.html
Copyright © 2011-2022 走看看