• 经典排序方法


    概述

    我知道它很重要,不知道为了什么,从来没认真学过。

    本文依次介绍冒泡排序、简单选择排序、直接插入排序、希尔排序、堆排序、归并排序和快速排序。主要参考《大话数据结构》,语言演示用 c++.
     


    主要内容

     

    排序用到的结构与函数

     
    为了后边简洁明了地写清楚代码思想,先写好用于排序的顺序表结构,如下:

    #define MAXSIZE  10      // 用于要排序数组个数最大值,自定义
    typedef struct
    {
        int r[MAXSIZE+1];    // 用于存储要排序数组,r[0] 用作哨兵或临时变量
        int length;          // 用于记录顺序表的长度
    }SqList;
    

    另外 ,由于排序最常用到的操作是数组两元素的交换,这里将它写成函数:

    void swap(SqList *L, int i, int j)    //交换 L 中数组 r 的下标为 i 和 j 的值
    {
        int temp = L->r[i];
        L->r[i] = L->r[j];
        L->r[j] = temp;
    }
    
    

     

    冒泡排序

     
    这个算法是思路最简单,最容易理解的排序算法,其排序过程就像冒泡一样:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。冒泡排序在实现上也有少许细节变化的改进,下面先给出初级版本的代码:

    // 对顺序表 L 作交换排序(冒泡排序初级版)
    void BubbleSort0(SqList *L)
    {
        int i, j;
        for(i = 1; i < L->length; i++)
        {
            for(j = i+1; j <= L->length; j++)  //一次循环后一定能确定一个最小值 
            {
                if(L->r[i] > L->r[j])
                {
                    swap(L, i, j);  // 交换值
                }
            }
        }
    }
    

    上边的代码严格来说不算是标准的冒泡排序算法,因为它不满足“两两比较相邻记录”的冒泡排序思想。假设我们待排序的关键字序列是 ({9, 1, 5, 8, 3, 7, 4, 6, 2}),其前两步排序过程如下图:

    这个算法效率有点低,下面看看正宗的冒泡算法。

    // 对顺序表 L 作冒泡排序
    void BubbleSort2(SqList *L)
    {
        int i, j;
    
        for(i = 1; i < L->length; i++)
        {
            for(j =L->length-1; j >= i; j--)  //注意 j 是从后往前循环 
            {
                if(L->r[j] > L->r[j+1])  // 若前者大于后者(注意与上边算法的区别) 
                {
                    swap(L, j, j+1);  // 交换值
                }
            }
        }
    }
    

    同样假设我们待排序的关键字序列是 ({9, 1, 5, 8, 3, 7, 4, 6, 2}),其第一步排序过程如下图:

    在第一次循环中,除了将关键字 (1) 放到第一的位置,我们还将关键字 (2) 从第九位置提到了第三的位置,这点就是比前边算法进步的地方。图中较小的数字如同气泡般慢慢浮到上面,因此形象地称它为冒泡算法。这样的冒泡程序仍可以优化,比方说 我们待排序的关键字序列是 ({2, 1, 3, 4, 5, 6, 7, 8, 9}),也就是说,除了第一和第二的关键字需要交换外,别的都已经排好了,但是算法仍然会按“程序”遍历结束,尽管没有交换数据。为了避免作不必要的工作,我们增加一个标记变量 (flag) 来实现这一算法的改进。程序如下:

    // 对顺序表 L 作改进冒泡算法
    void BubbleSort2(SqList *L)
    {
        int i, j;
        Status flag = TRUE;    // flag 用作标记是否需要排序
        for(i = 1; i < L->length && flag; i++)    //若 flag 为 false 则退出循环
        {
            flag = FALSE;  // 初始化为 false
            for(j =L->length-1; j >= i; j--)  //注意 j 是从后往前循环 
            {
                if(L->r[j] > L->r[j+1])  // 若前者大于后者(注意与上边算法的区别) 
                {
                    swap(L, j, j+1);  // 交换值
                    flag = TRUE;  // 如果有数据交换,则 flag 为 true
                }
            }
        }
    }
    

    代码改动的关键就是在 (i) 变量的 (for) 循环中增加了对 (flag) 是否了 (true) 的判断,如果 (flag = TRUE),说明进行过数据交换,这样就不确定后边数组已经有序了,所以还得进行遍历。经过这样的改进,冒泡排序在性能上有了上些提升,可以避免因已经有序的情况下的无意义循环判断。
     

    冒泡排序复杂度分析

     
    当最好的情况,也就是要排序的表本身就是有序的,根据最后改进的代码,可以推断出需 (n-1) 次的比较,没有数据交换,时间复杂度为 (O(n)). 当最坏的情况,即待排序表是逆序的情况,此时需要比较 (frac{n(n-1)}{2}) 次,并作等数量级的移动。因此,总的时间复杂度为 (O(n^2)).
     


    简单选择排序

     
    简单选择排序的思想:每一趟在 (n-i+1(i=1,2,cdots,n-1)) 个记录中选取关键字最小的记录作为有序序列的第 (i) 个记录。代码如下:

    // 对顺序表 L 作简单选择排序
    void SelectSort(SqList *L)
    {
        int i, j, min;
        for(i = 1; i < L->length; i++)    
        {
            min = i;  // 将当前下标定义为最小值下标
            for(j = i+1; j <= L->length;  j++)   
            {
                if(L->r[min] > L->r[j])  // 如果有小于当前最小值的关键字 
                {
                    min = j;  // 将此关键字的下标赋值给 min
                }
                if(i != min)     //说明找到最小值
                    swap(L, i, min);  //  
            }
        }
    }
    

    代码不难理解,不再详细解释。
     

    简单选择排序的复杂度分析

     
    从简单选择排序的过程来看,它最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度,无论最好最差的情况,其比较次数都是一样的多:(frac{n(n-1)}{2}) 次。而对交换次数而言,当最好的时候交换 (0) 次,最差时,也就是初始降序时,交换次数为 (n-1) 次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为 (O(n^2)). 尽管与冒泡排序有相同的时间复杂度,但简单选择排序在性能上还是要略优于冒泡排序的。
     


    直接插入排序

     
    就像我们玩扑克牌,一边摸牌,一边理牌。其基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增 (1) 的有序表。它是插入排序的方法,直接看代码:

    // 对顺序表 L 作直接插入排序
    void InsertSort(SqList *L)
    {
        int i, j;
        for(i = 2; i <=  L->length; i++)    
        {
            if(L->r[i] <  L->r[i-1])  // 需将 L->r[i] 插入有序子表    
            {
                L->r[0] = L->r[i];    //设置哨兵 
                for(j = i-1; L->r[j] > L->r[0]; j--)
                    L->r[j+1] = L->r[j];    // 记录后移
                L->r[j+1] = L->r[0];   //插入到正确位置
            }
        }
    }
    

    此代码也不难理解,不再详细解释。
     

    直接插入排序复杂度分析

     
    从空间上来说,它只需一个记录的辅助空间,因此关键看它的时间复杂度。当最好的情况,也就是数组本身有序,时间复杂度为 (O(n)). 当最坏情况,即是逆序的情况,需要比较 (sum_{i=2}^{n}i = frac{(n+2)(n-1)}{2}) 次,而记录的移动次数也达到最大值 (sum_{i=2}^{n}i+1 = frac{(n+4)(n-1)}{2}) 次。如果排序记录是随机的,平均比较和移动次数约为 (frac{n^2}{4}) 次。因此,直接插入排序法的时间复杂度为 (O(n^2)). 从这里看出,虽然同样的复杂度,直接插入排序法比冒泡法和简单选择排序的性能要好一些。
     


    希尔排序

     
    前边有提到直接插入排序在记录本身就是基本有序时,效率很高,由此启发,我们想办法将原始数据变得基本有序。

    很容易想到的是将原本有大量记录数的记录进行分组,分割成若干个子序列,此时每个子序列待排的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列基本有序时,再对全体记录进行一次直接插入排序。这里的基本有序是指:就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。不过现在问题是怎么才能做到基本有序?单单分割待排序记录是行不通的,比方说数组 ({9, 1, 5, 8, 3, 7, 4, 6, 2}),我们将它分成三组,({9, 1, 5})({8, 3, 7})({4, 6, 2}),哪怕排好了变成 ({1, 5, 9})({3, 7, 8})({2, 4, 6}),再合并成 ({1, 5, 9, 3, 7, 8, 2, 4, 6}),这仍是个杂乱无序的,谈不上基本有序。

    为此,我们需要采取跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果就是基本有序而不是局部有序

    先看代码

    #include<iostream>
    
    using namespace std;
    
    typedef int SElemType; // SElemType 类型根据实际情况而定,这里假设为 int
    #define MAXSIZE 9
    
    struct SqList
    {
        SElemType r[MAXSIZE+1];
        int length;
    };
    
    // 对顺序表 L 作希尔排序
    void ShellSort(SqList* L)
    {
        int i, j;
        int increment = L->length; // 起始增量初始化为待排序的记录数
        do
        {
            increment = increment / 3 + 1; //增量序列
            for(i = increment+1; i <= L->length; i++)
            {
                if(L->r[i] < L->r[i-increment]) //需将 L->r[i] 插入有序增量子表
                {
                    L->r[0] = L->r[i];  //暂存在 L->r[0]
                    for(j = i-increment; j > 0 && L->r[0] < L->r[j]; j -= increment )
                        L->r[j+increment] = L->r[j]; // 记录后移,查找插入位置
                    L->r[j+increment] = L->r[0];//插入到正确位置
                }
            }
        }
        while(increment > 1); // 增量为 1 就可以跳出循环了
    }
    
    int main()
    {
        SqList* L = new SqList;
        *L = {{0, 9,1,5,8,3,7,4,6,2}, 9};
        ShellSort(L);
    
        for(int i = 1; i <= L->length; i++)
            cout<<L->r[i]<<endl;
    
        return 0;
    }
    
    

    程序可能有点不好理解,我们下面举个例子。假设我们待排的序列是 (r[10] = {0, 9, 1, 5, 8, 3, 7, 4, 6, 2})(length=9),按照程序一步步来,最终一轮循环后,得到

    我们可以发现,数字 (1, 2) 等小数字已经在前两位,而 (8,9) 等大数字跑到后头了,也就是说,通过这样的排序,我们已经让整个序列基本有序了。这就是希尔排序的精华所在。

    一轮循环后 (increment = 4>1),所以继续第二轮循环,此时 (increment = 4 / 3 + 1 = 2). 经过循环之后,数组变成

    此时 (increment = 2 > 1),再循环一次,得到 (increment = 2 / 3 + 1 = 1),所以这是最后一轮,由于数组已经基本有序,所以效率其实很高,最终完成排序。
     

    希尔排序复杂度分析

     
    由代码可知,希尔排序并不是随便分组后的自排序,这里的“增量”的选取非常关键,迄今为止没有人找到一种最好的选取增量标准,不过经验告诉我们,当增量序列为 (delta[k] = 2^{t-k+1} \,\,(0 leqslant k leqslant t leqslant floor(log_2(n+1)) )) 时,可以获得不错的效率,其时间复杂度为 (O(n^{3/2})),要好于直接排序的 (O(n^2)). 需要注意的是,增量序列的最后一个增量值必须是 (1) 才行。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定性的排序算法(稳定性是说任意两位数排序前是正序关系,经过排序后不会反序)。
     


    堆排序

     
    前面所讲的简单选择排序在排序过程中,对先前的比较结果没有保存下来,后来的比较过程中,有很多重复的比较,如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序就是基于这种想法实现的。在这种方法中也首次提出了这种数据结构。

    堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(如下左图),每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(如下右图)。

     
    如果按照层序遍历的方式给结点从 (1) 开始编号,则结点之间满足如下关系:
    egin{align}
    egin{cases}
    k_i geqslant k_{2i} \ k_i geqslant k_{2i+1}
    end{cases}
    ext{或}
    egin{cases}
    k_i leqslant k_{2i} \ k_i leqslant k_{2i+1}
    end{cases}
    \,\, 1leqslant i leqslant floor(frac{n}{2}) otag
    end{align}

    堆排序就是利用堆排序进行排序的方法。假设利用大顶堆,它的基本思想是:**将待排序的序列构造成一个大顶堆。此时整个序列的最大值就是顶的根结点。将它移走(其实就是将其与堆顶数组的末尾交换元素,此时末尾元素就是最大值),然后将剩余的 (n-1) 个序列重新构造成一个堆,这样就会得到 (n) 个元素中的次小值。如此反复执行,便完成了排序 **。

    现在面临两个问题:

    • 如何由一个无序序列构成一个堆?
    • 如果在输出堆顶元素后,调整剩余元素成为一个新的堆?

    要解释清楚它们,先来看代码。

    #include<iostream>
    
    using namespace std;
    
    typedef int SElemType; // SElemType 类型根据实际情况而定,这里假设为 int
    #define MAXSIZE 9
    
    struct SqList
    {
        SElemType r[MAXSIZE+1];
        int length;
    };
    
    // 已知 L->r[s..m] 中记录的关键字除 L->r[s] 之外均满足堆的定义
    // 本函数调整 L->r[s] 的关键字,使 L->r[s..m] 成为一个大顶堆
    void swap(SqList* L, int m, int n)
    {
        int temp = L->r[m];
        L->r[m] = L->r[n];
        L->r[n] = temp;
    }
    
    void HeapAdjust(SqList *L, int s, int m)
    {
        int temp, j;
        temp = L->r[s];
        for (j = 2*s; j <= m; j *= 2)    // 沿关键字较大的孩子结点向下筛选
        {
            if (j < m && L->r[j] < L->r[j+1])
                ++j;          // j 为关键字中较大记录的下标
            if (temp >= L->r[j])
                break;     // rc 应插入到位置 s 上
            L->r[s] = L->r[j];
            s = j;
        }
        L->r[s] = temp;    // 插入
    }
    
    void HeapSort(SqList *L)
    {
        int i;
        for(i = L->length / 2; i > 0; i--)   // 把 L 中的 r 构建成一个大顶堆
            HeapAdjust(L, i, L->length);
    
        for(i = L->length; i > 1; i--)
        {
            swap(L, 1, i);   //将堆顶记录和当前未经过排序子序列的最后一个记录交换
            HeapAdjust(L, 1, i-1);   // 将 L->r[1..i-1] 重新调整为大顶堆
        }
    }
    
    int main()
    {
        SqList* L = new SqList;
        *L = {{0, 90,10,50,80,30,70,40,60,20}, 9};
        HeapSort(L);
    
        for(int i = 1; i <= L->length; i++)
            cout<<L->r[i]<<endl;
    
        return 0;
    }
    
    

    从代码中可以看出,Heap Sort 排序过程分为两个 (for) 循环。第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆。

    假设我们要排序的序列是 ({50, 10, 90, 30, 70, 40, 80, 60, 20}),那么 (L.length=9),第一个 (for 循环,)i$ 是从 (9 / 2 = 4) 开始,(4 ightarrow 3 ightarrow 2 ightarrow 1) 的变量变化。为什么不是从 (1)(9) 或者从 (9)(1),而是从 (4)(1) 呢?看下图,其实它们都是有孩子的结点。注意灰色结点的下标编号就是 (1, 2, 3, 4).

    我们所谓的将待排序的序列构成一个大顶堆,其实就是从下往上、从右到左,将每个非终端结点当作根结点,将其和其子树调整成为大顶堆。(i)(4 ightarrow 3 ightarrow 2 ightarrow 1) 的变量变化,其实也就是 (30, 90, 10, 50) 的结点调整过程。
    中间有用到二叉树的性质,如果不明白,多试着模拟计算机执行的方式走几遍,应该就可以理解其原理。

     

    堆排序复杂度分析

     
    它的运行时间主要是消耗在初始化构建堆和在重建堆时的反复筛选上。构建堆的时间复杂度为 (O(n));在正式排序时,第 (i) 次取堆顶记录重建堆需要用 (O(log i)) 的时间(完全二叉树的某个结点到根结点的距离为 (floor(log_2 i)+1)),并且需要取 (n-1) 次堆顶记录,因此重建堆的时间复杂度为 (O(nlog n)).

    所以总体来说,堆排序的时间复杂度为 (O(nlog n)). 由于堆排序对原始记录的排序状态并不敏感,因此无论是最好、最坏和平均时间复杂度均为 (O(nlog n)).

    空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换跳跃式进行,因此堆排序也是一种不稳定的排序方法。另外,由于初始化构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
     


    归并排序

     
    “归并”一词的的含义就是合并、并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。归并排序就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有 (n) 个记录,则可以看成是 (n) 个有序的子序列,每个子序列的长度为 (1),然后两两归并,得到 (floor(frac{n}{2})+1) 个长度为 (2)(1) 的有序子序列;再两两归并······,如此重复,直到得到一个长度为 (n) 的有序序列为止,这种排序方法称为 (2) 路归并排序

    下面来看代码。

    //对顺序表 L 作归并排序
    void MergeSort(SqList *L)
    {
        MSort(L->r, L->r, 1, L->length);
    }
    

    一句代码,为了递归调用更加简洁,所以外封装了一个函数。假设我们现在要对数组 ({50, 10, 90, 30, 70, 40, 80, 60, 20}) 进行排序,(L.length = 9),现在我们来看看 MSort 的实现。

    // 将 SR[s...t] 归并排序为 TR1[s...t]
    void MSort (int SR[], int TR1, int s, int t)
    {
        int m;
        int TR2[MAXSIZE+1];
        if (s == t)
            TR1[s] = SR[s];
        else
        {
            m = (s+t) / 2;    // 将 SR[s..t] 平分为 SR[s..m] 和 SR[m+1..t]
            MSort(SR, TR2, s, m);    //递归将 SR[s..m] 归并为有序的 TR2[s..m]
            MSort(SR, TR2, m+1, t);   //递归将 SR[m+1..t] 归并为有序的 TR2[m+1..t]
            Merge(TR2, TR1, s, m, t);    //将 TR2[s..m] 和 TR2[m+1..t] 归并到 TR1[s..t] 
        }
    }
    

    现在我们来看看程序运行的情况:

    • MSort 被调用时, SR 与 TR1 都是 ({50, 10, 90, 30, 70, 40, 80, 60, 20},s = 1, t = 9),最终我们的目的就是要将 TR1 中的数组排好顺序。
    • 第 5 行,显然 s 不等于 t,执行第 8~13 行语句块。
    • 第 9 行,m = (1+9) / 2 = 5. m 就是序列中的正中间下标。
    • 此时第 10 行,调用 MSort(SR, TR2, 1, 5) 的目标就是将数组 SR 中的第 1~5 的关键字归并到有序的 TR2(调用前 TR2 为空数组),第 11 行, 调用 MSort(SR, TR2, 6, 9) 的目标就是将数组 SR 中的第 6~9 的关键字归并到有序的 TR2. 也就是说,在调用这两句代码之前,代码已经准备将数组分成了 两组了,如图

    • 第 12 行,函数 Merge 的实现等会儿给出,调用 Merge(TR2, TR1, 1, 5, 9) 的目标其实就是将第 10 行和第 11 行代码获得的数组 TR2(注意它是下标 1~5 和 6~9 的关键字分别有序)归并为 TR1,此时相当于整个排序就已经完成了,如图

    • 再来看第 10 行递归调用进去后,s = 1, t=5, m= (1+5) / 2 = 3. 此时相当于将 5 个记录拆分为三个和两个。继续递归下去,直到细分为一个记录填入 TR2,此时 s 与 t 相等,递归返回 ,如下左图所示。每次递归返回后都会执行当前递归函数的第 12 行,将 TR2 归并到 TR1 中,如下图右所示,最终使得当前序列有序。

    • 同样的第 11 行也是类似方式,如下图:

    • 此时也就是刚才所讲的最后一次执行第 12 行代码,将 ({10, 30, 50, 70, 90})({20, 40, 60, 80}) 归并为最终有序的序列。

    现在我们来看 Merge 函数的代码是如何实现的

    // 将有序的 SR[i..m] 和 SR[m+1..n] 归并为有序的 TR[i,,n]
    void Merge(int SR[], int TR[], int i, int m, int n)
    {
        int j, k, l;
        for(j = m+1, k = i; i <= m && j <= n; k++)   // 将 SR 中记录由小到大小小归并入 TR
        {
            if (SR[i] < SR[j])
                TR[k] = SR[i++];
            else
                TR[k] = SR[j++];
        }
        if( i<= m)
        {
            for (l = 0; l <= m-i; l++)
                TR[k+l] = SR[i+1];     //将剩余的 SR[i..m] 复制到 TR
        }
        if( j<= n)
        {
            for (l = 0; l <= n-j; l++)
                TR[k+1] = SR[j+1]    //将剩余的 SR[j..n] 复制到 TR
        }
    }
    

    代码不再分析了,一步步走就行了。可用以下代码进行测试:

    int main()
    {
        //int arr[] = {0, 50, 10, 90, 30, 70, 40, 80, 60, 20};
        SqList* L = new SqList;  //指针指向的是内存,必须先 new 一下分配内存
        *L = {{0, 50, 10, 90, 30, 70, 40, 80, 60, 20}, 9};
    
        MergeSort(L);
        cout<<L->r[2]<<endl;
    
        return 0;
    }
    

     

    归并排序复杂度分析

     
    一趟归并需要将 SR[1]~SR[n] 中相邻的长度为 h 的有序序列进行两两归并。并将结果放到 TR[1]~TR[n] 中,这需要将待排序序列中的所有记录扫描一遍,因此耗费 (O(n)) 时间,而由完全二叉树的深度可知,整个归并排序需要进行 (floor(log _2 n) +1) 次,因此总的时间复杂度为 (O(nlog n)),而且这是归并排序算法中最好、最坏、平均时间性能。

    由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为 (log_2 n) 的栈空间,因此空间复杂度为 (n + log_2 n).

    另外,Merge 函数中有 if (SR[i] < SR[j]) 语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。

    也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。非递归方法不再介绍,可参考书中内容。
     


    快速排序

     
    如果只学一种排序算法的话,那非快速排序莫属,它被列为 20 世纪十大算法之一。

    快速排序的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的

    下面来看代码。

    //对顺序表 L 作快速排序 
    void QuickSort(SqList *L)
    {
        QSort (L, 1, L->length);
    }
    

    和归并排序一样,由于需要递归调用,因此我们外封装了一个函数。现在我们来看 QSort 的实现。

    \ 对顺序表 L 中的子序列 L->r[low..high] 作快速排序
    void QSort(SqList *L, int low, int high)
    {
        int pivot;
        if(low < high)
        {
            pivot = Partition(L, low, high);   // 将 L->r[low...high] 一分为二
            QSort(L, low, pivot - 1);    // 对低子表递归排序
            QSort(L, pivot + 1, high);  // 对高子表递归排序
        }
    }
    

    这一代码的核心是 “pivot = Partition(L, low, high);”;假设待排序的数组是 ({50, 10, 90, 30, 70, 40, 80, 60, 20}),Partition 函数要做的就是先选取当中的一个关键字,比如选择第一个关键字 (50),然后想尽办法将它放到一个位置,使得它左边的值都比它小,右边的值比它大,我们将这样的关键字称为枢轴(pivot)。

    下面来看 Partition 函数的实现。

    // 交换顺序表 L 中子表的记录,使枢轴记录到位,并返回其所在位置
    int Partition(SqList *L, int low, int high)
    {
        int pivotkey;
        pivotkey = L->r[low];  //用子表的第一个记录作枢轴记录
        while(low<high)   // 从表的两端冲交替向中间扫描
        {
            while (low < high && L->r[high] >= pivotkey)
                high--;
            swap(L, low, high);  // 将比枢轴记录小的记录交换到低端
            while(low < high && L->r[low] <= pivotkey)
                low++;
            swap(L, low, high);  // 将枢轴记录大的记录交换到高端
        }
        return low;   //返回枢轴所在的位置
    }
    

    这段代码也不难理解,不再解释。
     

    快速排序优化

     
    1. 优化选取枢轴
     
    从前边的分析中可以看到枢轴的选取非常关键,如果我们选取的 pivotkey 是处于整个序列的中间位置,那么能够获得很好的性能。那么万一取不到中间的值呢?为了尽量减少风险,有人提出三位数取中法,进而还有九数取中法

     
    2. 优化不必要的交换
     
    我们对 Partition 函数的代码再进行优化。

    // 快速排序优化算法
    int Partition(SqList *L, int low, int high)
    {
        int pivotkey;
        pivotkey = L->r[low];  //用子表的第一个记录作枢轴记录
        L->r[0] = pivotkey;   // 将枢轴关键字备份到 L->r[0]
        while(low<high)   // 从表的两端冲交替向中间扫描
        {
            while (low < high && L->r[high] >= pivotkey)
                high--;
            L->r[low] = L->r[high];  // 采用替换而不是交换的方式进行操作
            while(low < high && L->r[low] <= pivotkey)
                low++;
            L->r[high] = L->r[low];  // 采用替换而不是交换的方式进行操作
        }
        L->r[low] = L->r[0];   // 将枢轴数值替换回 L.r[low]
        return low;   //返回枢轴所在的位置
    }
    

    我们将 pivotkey 备份到 L.r[0] 中,然后在之前是 swap 时,只作替换的工作,最终当 low 与 high 会合,即找到了枢轴的位置时,再将 L.r[0] 的数值赋给 L.r[low]. 因为这当中少了多次交换数据的操作,在性能上又得到了部分的提高。 其实,不必再引用 L.r[0],因为 pivotkey 这个关键字没有变过,所以使用 pivotkey 就好。
     

    快速排序复杂度分析

     

    快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递算法的执行情况。如下图:

    它是 ({50, 10, 90, 30, 70, 40, 80, 60, 20}) 在快速排序过程中的递归过程。由于我们的第一个关键字是 (50),它正好是待排序的序列中的中间值,因此递归树是平衡的,此时性能也比较好。

    在最优情况下, Partition 每次都划分得很均匀,如果排序 (n) 个关键字,其递归树的深度就为 (floor(log_2 n) + 1),即仅需递归 (log_2n) 次,快速排序算法的时间复杂度为 (O(nlog n)).

    在最坏情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果用递归树画出来,它就是一棵斜树。此时需要执行 (n-1) 次递归调用,最终其时间复杂度为 (O(n^2)).

    平均情况下,快速排序算法的时间复杂度为 (O(nlog n)).

    就空间复杂度来说,主要是递归造成的栈空间的使用,平均情况,空间复杂度也为 (O(log n)).

    可惜的是,由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。
     


    总结

     
    根据排序过程中借助的主要操作,我们将排序分为:插入排序、交换排序、选择排序和归并排序四类,之后介绍的 7 种排序法,就分别是各种分类的代表算法。

    事实上,目前还没有十全十美的排序算法,即使是快速排序法,也只是整体性能上优越,它也存在不稳定、需要大量的辅助空间、对少量数据排序无优势等不足。我们将 7 种算法的各种指标进行对比,如下图。

    从得法的简单性来看,我们将 7 种算法分为两类:

    • 简单算法:冒泡、简单选择、直接插入。
    • 改进算法:希尔、堆、归并、快速。

    从平均来看,显然最后 3 种改进看法要胜过希尔排序,并远远超过前 3 种简单算法。

    从最好情况来看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序列总是基本有序,反而不应该考虑 4 种复杂的改进算法。

    从最坏情况来看,堆排序与归并排序又强过快速排序以及其他简单排序。

     
     
     
     

  • 相关阅读:
    Adding Swap Files
    Creating a Swap Partition
    linux删除或隐藏命令历史记录history
    Unix系统解压tar包时出现@LongLink错误
    【白话经典算法系列之十七】 数组中只出现一次的数 其他三次
    MapReduce原理及其主要实现平台分析
    二叉搜索树转换为有序双向链表
    实现O(1)获取最大最小值的栈----java
    对线性回归,logistic回归和一般回归的认识
    SMO优化算法(Sequential minimal optimization)
  • 原文地址:https://www.cnblogs.com/zhoukui/p/8545303.html
Copyright © 2020-2023  润新知