数据结构(Java版) - 排序

252 阅读19分钟

6.排序

文中源代码: JavaDataStructure: Java 版数据结构 (github.com)

6.1 排序概念和排序方法概述

6.1.1 排序基本概念

1.排序

排序是按关键字的非递减或非递增顺序对一组记录重新进行排列的操作。

2.排序的稳定性

当排序记录中的关键字都不相同时,排序后得到的结果唯一;当排序记录中的关键字中存在两个或两个以上关键字相等的记录时,则排序所得的结果不唯一。

两个相等的关键字在排序后,其先后顺序和原序列中相同,则称所用的排序方法是稳定的;反之,则称所用的方法是不稳定的

3.内部排序和外部排序

根据在排序过程中记录所占用的存储设备,可将排序方法分为两大类:

  • 内部排序——待排序记录全部存放在计算机内存中进行排序的过程
  • 外部排序——待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程

6.1.2 内部排序方法分类

内部排序的过程是一个逐步扩大记录的有序序列长度的过程。在排序的过程中,可以将排序记录区分为两个区域:有序序列区和无序序列区。

(1)插入类: 将无序序列中的一个或几个记录“插入”到有序序列中,从而增加有序子序列长度。主要包括直接插入排序、折半插入排序和希尔排序

(2)交换类: 通过“交换”无序序列中的记录从而得到其中关键字最小或最大的记录,并加入有序序列中。主要包括冒泡排序和快速排序

(3)选择类: 从记录的无序子序列中“选择”关键字最小或最大的记录,并将它加入到有序子序列中。主要包括简单选择排序、树形选择排序和堆排序

(4)归并类: 通过“归并”两个或两个以上的记录有序子序列,逐步增加记录有序序列的长度。2-路归并排序是最为常见的归并排序方法

(5)分配类: 不需要进行关键字之间的比较。排序时主要利用分配和收集两个基本操作来完成。基数排序是主要的分配类排序方法

6.1.3 待排序记录的存储方式

(1)顺序表: 记录之间的次序关系由其存储位置决定,实现排序需要移动记录

(2)链表: 记录之间的次序关系由指针指示,实现排序不需要移动记录,仅需修改指针即可;称为链表排序

(3)待排序记录本身存储在一组地址连续的存储单元内,同时另设一个指示各个记录存储位置的地址向量,在排序过程中不移动记录本身,而移动地址向量中这些记录的“地址”,在排序结束之后再按照地址向量中的值调整记录存储位置;称为地址排序

6.1.4 排序算法效率的评价指标

(1)执行时间——高效的排序算法的比较次数和移动次数都应该尽可能地少

(2)辅助空间——理想的空间复杂度为 O(1)O(1),即算法执行期间所需要的辅助空间与待排序的数据量无关

6.2 插入排序

6.2.1 直接插入排序

算法步骤:

(1)设待排序的记录存放在数组 nums[1n]nums[1 \dots n] 中,nums[1]nums[1] 是一个有序序列

(2)循环 n1n-1 次,每次使用顺序查找法,查找 nums[i](i=2,,n)nums[i](i=2,\dots,n) 在已排序好的序列 nums[1i1]nums[1 \dots i-1] 中的插入位置,并将其插入到该位置

数据结构 - 排序.png

算法分析:

(1)时间复杂度

最好情况下(正序),每次比较 11 次,不移动;最坏情况下(逆序),每次比较 ii 次 (依次同前面的 i1i-1 个记录进行比较,并和哨兵比较 11 次),移动 i+1i+1 次(前面的 i1i-1 个记录依次向后移动,开始时将待插入的记录移动到监视哨中,最后将该记录插入对应位置)。

最坏情况下,总的关键字比较次数 KCNKCN 和记录移动次数 RMNRMN 均达到最大值:

KCN=i=2ni=(n1)(n+2)2n22KCN=\sum^n_{i=2}i=\frac{(n-1)(n+2)}{2} \approx \frac{n^2}{2}
RMN=i=2n(i+1)=(n1)(n+4)2n22RMN=\sum^n_{i=2}(i+1)=\frac{(n-1)(n+4)}{2} \approx \frac{n^2}{2}

直接插入排序的时间复杂度为 O(n2)O(n^2)

(2)空间复杂度

需要一个辅助空间,所以空间复杂度为 O(1)O(1)

算法特点:

  • 稳定排序
  • 算法简便,且容易实现
  • 适用于链式存储结构
  • 适合于初始记录基本有序;当初始记录无序,且 nn 较大时,不宜采用
void InsertSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    for(int i=2;i<nums.length;i++){
        if(nums[i-1] > nums[i]){
            // 在 nums[0] 处加入哨兵
            nums[0] = nums[i];
            int index = i-1;
            // 从后向前查找插入位置
            int j = i-1;
            for(;nums[0] < nums[j];j--){
                nums[j+1] = nums[j];
            }
            nums[j+1] = nums[0];
        }
    }
}

6.2.2 折半插入排序

算法步骤:

(1)设待排序的记录存放在数组 nums[1n]nums[1 \dots n] 中,nums[1]nums[1] 是一个有序序列

(2)循环 n1n-1 次,每次使用折半查找法,查找 nums[i](i=2,,n)nums[i](i=2,\dots,n) 在已排序好的序列 nums[1i1]nums[1 \dots i-1] 中的插入位置,并将其插入到该位置

算法分析:

(1)时间复杂度

从时间上比较,折半查找比顺序查找快,所以就平均性能来说,折半插入排序优于直接插入排序。

折半插入排序所需要的关键字比较次数与待排序序列的初始排列无关,仅依赖于记录的个数。在插入第 ii 个记录时,需要经过 log2i+1\lfloor log_2i \rfloor+1 次比较,才能确定它应插入的位置。

在平均情况下,折半插入排序仅减少了关键字间的比较次数,而记录的移动次数不变,故折半插入排序的时间复杂度仍为 O(n2)O(n^2)

(2)空间复杂度

需要一个记录的辅助空间,所以空间复杂度为 O(1)O(1)

算法特点:

  • 稳定排序
  • 只适用于顺序结构
  • 适合初始记录无序、nn 较大时的情况
void BInsertSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    for(int i=2;i<nums.length;i++){
        // 在 nums[0] 处加入哨兵
        nums[0] = nums[i];
        int left = 1,right = i-1;
        // 寻找右侧边界的二分法
        // left 指示了要插入的位置
        while(left <= right){
            int mid = left + (right-left)/2;
            if(nums[mid] == nums[0]){
                left = mid + 1;
            }else if(nums[mid] < nums[0]){
                left = mid + 1;
            }else{
                right = mid - 1;
            }
        }
        for(int j=i-1;j>=left;j--){
            nums[j+1] = nums[j];
        }
        nums[left] = nums[0];
    }
}

6.2.3 希尔排序

希尔排序又称“缩小增量排序”。

算法步骤:

(1)第一趟取增量 d1(d1<n)d_1(d_1<n) 把全部记录分成 d1d_1 组,所有间隔为 d1d_1 的记录分在同一组,在各个组中进行直接插入排序

(2)第二趟取增量 d2d_2d2<d1d_2<d_1),重复上述的分组和排序

(3)依次类推,直到所取的增量 dt=1(dt<dt1<...<d2<d1)d_t=1(d_t<d_{t-1}<...<d_2<d_1),所有记录在同一组中进行直接插入排序为止

数据结构 - 排序2.png

算法分析:

(1)时间复杂度

当增量大于 11 时,关键字较小的记录就不是一步一步地挪动,而是跳跃式地移动,从而使得在进行最后一趟增量为 11 的插入排序中,序列已基本有序,只要做记录的少量比较和移动即可完成排序,因此希尔排序的时间复杂度较直接插入排序低。平均情况下,时间复杂度为 O(n1.3)O(n^{1.3})

(2)空间复杂度

需要一个辅助空间,故空间复杂度为 O(1)O(1)

算法特点:

  • 记录跳跃式地移动导致排序方法是不稳定的
  • 只能用于顺序结构
  • 增量序列可以有各种取法,但应该使增量序列中的值没有除 11 之外的公因子,并且最后一个增量值必须等于 11
  • 记录总的比较次数和移动次数都比直接插入排序要少,nn 越大时,效果越明显。所以适合初始无记录、nn 较大时的情况
void ShellInsert(int[] nums,int dk){
    // 数组索引从 1 开始
    // nums[1...dk] 分别为 dk 个组的第一个元素
    for(int i=dk+1;i<nums.length;i++){
        if(nums[i] < nums[i-dk]){
            // 暂存在 nums[0]
            nums[0] = nums[i];
            int j=i-dk;
            for(;j>0 && nums[0]<nums[j];j-=dk){
                nums[j+dk] = nums[j];
            }
            nums[j+dk] = nums[0];
        }
    }
}

void ShellSort(int[] nums,int[] dt){
    for(int k=0;k<dt.length;k++){
        ShellInsert(nums,dt[k]);
    }
}

6.3 交换排序

6.3.1 冒泡排序

冒泡排序通过两两比较相邻记录的关键字来进行排序。

算法步骤:

(1)设待排序的记录存放在数组 nums[1n]nums[1 \dots n]

(2)首先将第一个记录的关键字和第二个记录的关键字进行比较,若是逆序(nums[1]>nums[2]nums[1] \gt nums[2]),则交换两个记录。然后比较第二个和第三个。依次类推,直到第 n1n-1 个记录和第 nn 个记录的关键字进行过比较为止。这时已挑选出一个最大值,并放在末尾

(3)然后进行第二趟冒泡排序,对前 n1n-1 个记录进行同样的操作。重复该过程,直到在某一趟排序过程中没有进行过交换记录的操作,说明序列已排序完成

数据结构 - 排序3.png

算法分析:

(1)时间复杂度

每次交换都需要移动 33 次。

最好的情况(正序):只需进行一次排序,比较次数为 n1n-1,时间复杂度为 O(n)O(n)

最坏的情况(逆序):需进行 n1n-1 趟排序,总的比较次数 KCNKCN 和移动次数 RMNRMN 分别为

KCN=i=n2(i1)=(n2+1)(n1+1)2=(n1)n2n22KCN=\sum^2_{i=n}(i-1)=\frac{(n-2+1)(n-1+1)}{2}=\frac{(n-1)n}{2} \approx \frac{n^2}{2}
RMN=3i=n2(i1)=3n(n1)23n22RMN=3\sum^2_{i=n}(i-1)=\frac{3n(n-1)}{2} \approx \frac{3n^2}{2}

在平均情况下,时间复杂度为 O(n2)O(n^2)

(2)空间复杂度

需要一个辅助空间,故空间复杂度为 O(1)O(1)

算法特点:

  • 稳定排序
  • 可用于顺序结构和链式结构
  • 移动次数比较多,算法平均性能比直接插入排序差;当初始记录无序,nn较大时,此算法不宜采用
void BubbleSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    int m = nums.length - 1;
    // 标记是否在排序过程中存在交换操作
    boolean flag = true;
    while(m>1 && flag){
        flag = false;
        for(int j=1;j<m;j++){
            if(nums[j] > nums[j+1]){
                flag = true;
                int t = nums[j];
                nums[j] = nums[j+1];
                nums[j+1] = t;
            }
        }
        m--;
    }
}

6.3.2 快速排序

快速排序是由冒泡排序改进而得到的。一次交换可能消除多个逆序。

算法步骤:

(1)在待排序的 nn 个记录中任取一个记录(通常取第一个记录)作为枢轴(或支点),设其关键字为 pivotkeypivotkey

(2)经过一趟排序后,把所有关键字小于 pivotkeypivotkey 的记录交换到前面,把所有关键字大于 pivotkeypivotkey 的记录交换到后面,结果将待排序记录分成两个子表,将 pivotkeypivotkey 放在分界处的位置

(3)对左、右子表重复上述过程,直至每一子表只有一个记录时,排序完成

数据结构 - 排序4.png

算法分析:

(1)时间复杂度

快速排序的趟数取决于递归树的深度。

最好情况:每一趟排序后都能将记录均匀地分割成两个长度大致相等的子表,类似折半查找。时间复杂度为 O(nlog2n)O(nlog_2n)

最坏情况:待排序序列已排好序的情况下,其递归树成为单支树。时间复杂度为 O(n2)O(n^2)

平均情况下,时间复杂度为 O(nlog2n)O(nlog_2n)

(2)空间复杂度

快速排序是递归的,执行时需要一个栈来存放相应的数据。最大递归调用次数与递归树的深度一致,所以最好的情况下空间复杂度为 O(log2n)O(log_2n)最坏情况下为 O(n)O(n)

算法特点:

  • 不稳定排序
  • 适用于顺序结构
  • O(n)O(n) 较大时,在平均情况下其是内部排序中速度最快的一种,所以适合初始记录无序、O(n)O(n) 较大时的情况
int Partition(int[] nums,int low,int high){
    // 对子表 nums[low...high] 进行一趟排序,并返回枢轴位置
    // nums[0] 保存 pivotkey
    nums[0] = nums[low];
    while(low < high){
        while (low<high && nums[0]<=nums[high]) high--;
        nums[low] = nums[high];
        while(low<high && nums[0]>=nums[low]) low++;
        nums[high] = nums[low];
    }
    nums[low] = nums[0];
    return low;
}

void QSort(int[] nums,int low,int high){
    if(low < high){
        int pivotLoc = Partition(nums,low,high);
        QSort(nums,low,pivotLoc-1);
        QSort(nums,pivotLoc+1,high);
    }
}

void QuickSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    QSort(nums,1, nums.length-1);
}

6.4 选择排序

6.4.1 简单选择排序

简单选择排序也称作直接选择排序

算法步骤:

(1)设待排序的记录存放在数组 nums[1n]nums[1 \dots n] 中。第一趟从 nums[1]nums[1] 开始,通过 n1n-1 次比较,从 nn 个记录中选出关键字最小的记录,记为 nums[k]nums[k],交换 nums[1]nums[1]nums[k]nums[k]

(2)第二趟从 nums[2]nums[2] 开始,通过 n2n-2 次比较,从 n1n-1 个记录中选出关键字最小的记录,记为 nums[k]nums[k],交换 nums[2]nums[2]nums[k]nums[k]

(3)依次类推,第 ii 趟从 nums[i]nums[i] 开始,通过 nin-i 次比较,从 ni+1n-i+1 个记录中选出关键字最小的记录,记为 nums[k]nums[k],交换 nums[i]nums[i]nums[k]nums[k]

(4)经过 n1n-1 趟,排序完成

数据结构 - 排序5.png

算法分析:

(1)时间复杂度

最好情况(正序):不移动;最坏情况(逆序):移动 3(n1)3(n-1)

所需进行的关键字间的比较次数相同,均为

KCN=i=1n1ni=n(n1)2n22KCN=\sum^{n-1}_{i=1}n-i=\frac{n(n-1)}{2} \approx \frac{n^2}{2}

因此时间复杂度为 O(n2)O(n^2)

(2)空间复杂度

需要一个辅助空间,空间复杂度为 O(1)O(1)

算法特点:

  • 稳定的排序方法(下方的方法不是稳定的,因为采用了“交换记录”的策略所造成)
  • 适用于顺序结构和链式结构
  • 移动记录次数较少,当每一记录占用的空间较多时,此方法比直接插入排序快
void SelectSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    for(int i=1;i<nums.length;i++){
        int k = i;
        for(int j=i+1;j<nums.length;j++){
            if(nums[j] < nums[k]){
                k = j;
            }
        }
        if(i != k){
            int t = nums[i];
            nums[i] = nums[k];
            nums[k] = t;
        }
    }
}

6.4.2 树形选择排序

树形选择排序,又称锦标赛排序

算法分析:

时间复杂度为 O(nlog2n)O(nlog_2n)

空间复杂度为 O(n)O(n)

算法特点:

  • 不稳定
  • 辅助存储空间较多
  • 和 "最大值" 进行多余比较

数据结构 - 排序6.png

void TreeSelectSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    int m = nums.length-1;
    // 叶子结点个数
    int leafCount = 1;
    // 计算满二叉树的叶子结点数
    while(leafCount < m){
        leafCount *= 2;
    }
    // 假设满二叉树的深度为 h,则叶子结点有 leafCount=2^(h-1),总结点数是 2^h-1
    // 总结点数 = 2*leafCount*2 - 1
    // tree[0] 不保存结点
    int[] tree = new int[leafCount*2];
    Arrays.fill(tree,Integer.MAX_VALUE);

    // 将数组中的数据保存进满二叉树的叶子结点中
    for(int i=1;i<nums.length;i++){
        tree[tree.length-i] = nums[i];
    }

    // 初始化,构建满二叉树
    for(int i=tree.length-1;i>1;i-=2){
        tree[i/2] = Math.min(tree[i],tree[i-1]);
    }
    int index = 1;
    nums[index++] = tree[1];

    // 还需要挑选出 m-1 个最小值
    for(int i=1;i<m;i++){
        // 寻找树根值所在的叶子节点的位置
        int minIndex = tree.length-1;
        while(tree[minIndex] != tree[1]){
            minIndex--;
        }
        tree[minIndex] = Integer.MAX_VALUE;

        // 调整满二叉树
        while(minIndex > 1){
            if(minIndex%2 == 0){
                tree[minIndex/2] = Math.min(tree[minIndex],tree[minIndex+1]);
            }else{
                tree[minIndex/2] = Math.min(tree[minIndex],tree[minIndex-1]);
            }
            minIndex /= 2;
        }

        nums[index++] = tree[1];
    }
}

6.4.3 堆排序

堆排序是一种树形选择排序,在排序过程中,将待排序的记录 nums[1n]nums[1 \dots n] 看成是一棵完全二叉树的顺序存储结构。

堆顶元素(或完全二叉树的根)必为序列中 nn 个元素的最大值(或最小值),分别称之为大根堆小根堆

算法步骤:

(1)按堆的定义将待排序序列 nums[1n]nums[1 \dots n] 调整为大根堆(这个过程称为建初堆),交换 nums[1]nums[1]nums[n]nums[n],则 nums[n]nums[n] 为关键字最大的记录

(2)将 nums[1n]nums[1 \dots n] 重新调整为堆,交换 nums[1]nums[1]nums[n1]nums[n-1],则 nums[n1]nums[n-1] 为关键字次大的记录

(3)循环 n1n-1 次,直到交换了 nums[1]nums[1]nums[2]nums[2] 为止,得到了一个非递减的有序序列 nums[1n]nums[1 \dots n]

数据结构 - 排序7.png

数据结构 - 排序8.png

数据结构 - 排序9.png

算法分析:

(1)时间复杂度

设有 nn 个记录的初始序列所对应的完全二叉树的深度为 hh,建初堆时,每个非终端结点都要自上而下进行“筛选”。由于第 ii 层上的结点数小于等于 2i12^{i-1},且第 ii 层结点最大下移的深度为 hih-i,每下移一层要做两次比较,所以建初堆时关键字总的比较次数为:j=hi2h<2nj=h-i \quad \quad 2h<2n

i=h112i1×2(hi)=i=h112i×(hi)=i=h112hj×j2nj=1h1j2j4n\sum^1_{i=h-1}2^{i-1} \times 2(h-i)=\sum^1_{i=h-1}2^i \times (h-i)=\sum^1_{i=h-1}2^{h-j} \times j \le 2n\sum^{h-1}_{j=1}\frac{j}{2^j}\le 4n

堆排序时,要做 n1n-1 次“筛选”,每次“筛选”都要将根节点下移到合适的位置。nn 个结点的完全二叉树的深度为 log2n+1\lfloor log_2n \rfloor+1,则重建堆时关键字的总比较次数不超过:

2(log2(n1)+log2(n2)+...+log22)<2nlog2n2(\lfloor log_2(n-1) \rfloor+\lfloor log_2(n-2) \rfloor+...+\lfloor log_22 \rfloor) < 2n\lfloor log_2n \rfloor

堆排序在最坏的情况下,其时间复杂度也为 O(nlog2n)O(nlog_2n)

比较次数推理过程: 通过错位相消法计算得到结果

j=1h1j2j2\sum^{h-1}_{j=1}\frac{j}{2^j} \le 2

Sn=12+222+323+...+h12h1S_n = \frac{1}{2}+\frac{2}{2^2}+\frac{3}{2^3}+...+\frac{h-1}{2^{h-1}}

12Sn=122+223+324+...+h22h1+h12h\frac{1}{2}S_n = \frac{1}{2^2}+\frac{2}{2^3}+\frac{3}{2^4}+...+\frac{h-2}{2^{h-1}}+\frac{h-1}{2^h}

Sn12Sn=12Sn=12Sn=12+122+123+...+12h1h12hS_n-\frac{1}{2}S_n=\frac{1}{2}S_n=\frac{1}{2}S_n = \frac{1}{2}+\frac{1}{2^2}+\frac{1}{2^3}+...+\frac{1}{2^{h-1}}-\frac{h-1}{2^h}

                                     =12+122+123+...+12h1+12hh2h\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ =\frac{1}{2}+\frac{1}{2^2}+\frac{1}{2^3}+...+\frac{1}{2^{h-1}}+\frac{1}{2^h}-\frac{h}{2^h}

                                     =12(112h)112h2h\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ =\frac{\frac{1}{2}(1-\frac{1}{2^h})}{1-\frac{1}{2}}-\frac{h}{2^h}

                                     =1h+12h\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ =1-\frac{h+1}{2^h}

Sn=2h+12h12S_n=2-\frac{h+1}{2^{h-1}} \le 2

(2)空间复杂度

需要一个辅助空间,所以空间复杂度为 O(1)O(1)

算法特点:

  • 不稳定排序
  • 只适用于顺序结构
  • 记录较少时不宜采用
void HeapAdjust(int[] nums,int s,int m){
    // 假设 r[s+1..m] 已经是堆,将 r[s..m] 调整为以 r[s] 为根的大根堆
    int rc = nums[s];
    // 每次都挑选左右孩子中比它大的最大的那个,然后交换位置
    // 移动到该孩子结点的位置,接着往下比较,直到无法找到比之大的,或者无孩子结点可比较
    for(int j=2*s;j<=m;j*=2){
        // 挑选大的孩子比较
        if(j<m && nums[j]<nums[j+1]) j++;
        // 没有比之大的孩子,则说明已是大根堆
        if(rc >= nums[j]) break;
        nums[s] = nums[j];
        s = j;
    }
    nums[s] = rc;
}

void CreateHeap(int[] nums){
    // n 是最后一个叶子结点的索引
    int n = nums.length - 1;
    // 从最后一个非终端结点开始调整
    for(int i=n/2;i>0;i--){
        HeapAdjust(nums,i,n);
    }
}

void HeapSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    // 建立大根堆,并不断把根节点与堆的最后一个结点交换,再缩小堆并调整
    CreateHeap(nums);
    for(int i=nums.length-1;i>1;i--){
        int x = nums[1];
        nums[1] = nums[i];
        nums[i] = x;
        HeapAdjust(nums,1,i-1);
    }
}

6.5 归并排序

归并排序就是将两个或两个以上的有序表合并成一个有序表的过程。将两个有序表合并成一个有序表的过程称为2-路归并,2-路归并最为简单和常用。

算法步骤:

(1)将当前序列一分为二,求出分裂点 mid=(low+high)/2mid=(low+high)/2

(2)对子序列 R[low..mid]R[low..mid] 递归,进行归并排序,结果放入 S[low..mid]S[low..mid]

(3)对子序列 R[mid+1..high]R[mid+1..high] 递归,进行归并排序,结果放入 S[mid+1..high]S[mid+1..high]

(4)调用算法 MergeMerge,将有序的两个子序列 S[low..mid]S[low..mid]S[mid+1..high]S[mid+1..high] 归并为一个有序的序列 T[low..high]T[low..high]

数据结构 - 排序10.png

算法分析:

(1)时间复杂度

当有 nn 条记录时,需进行 log2n\lceil log_2n \rceil 趟归并排序(把归并过程看成一棵树),每一趟归并,其关键字比较次数不超过 nn,元素移动次数都是 nn。因此,归并排序的时间复杂度为 O(nlog2n)O(nlog_2n)

(2)空间复杂度

需要一个和待排序记录个数相等的辅助存储空间,所以空间复杂度为 O(n)O(n)

算法特点:

  • 稳定排序
  • 可用于顺序结构和链式结构
void Merge(int[] nums,int[] T,int low,int mid,int high){
    int i = low,j = mid+1,k = low;
    while(i<=mid && j<=high){
        if(nums[i] <= nums[j]){
            T[k++] = nums[i++];
        }else{
            T[k++] = nums[j++];
        }
    }
    while(i <= mid){
        T[k++] = nums[i++];
    }
    while(j <= high){
        T[k++] = nums[j++];
    }
    // 将合并后的有序表更新到 nums 数组
    if (high + 1 - low >= 0) System.arraycopy(T, low, nums, low, high + 1 - low);
}

void MSort(int[] nums,int[] T,int low,int high){
    if(low != high){
        int mid = low + (high-low)/2;
        MSort(nums,T,low,mid);
        MSort(nums,T,mid+1,high);
        Merge(nums,T,low,mid,high);
    }
}

void MergeSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;
    int[] T = new int[nums.length];
    MSort(nums,T,1,nums.length-1);
}

6.6 基数排序

6.6.1 多关键字排序

例如:将一幅牌按照花色排序,花色相同的根据面值大小排序

(1)最高位优先法——先按不同“花色”分成有序的4堆,每一堆具有相同的花色,然后分别按照面值排序

(2)最低位优先法——先按不同“面值”分成13堆,每一堆具有相同的面值,然后将每堆按照面值从小到大的次序收集到一起,再重新对这些牌按不同“花色”分成4堆

6.6.2 链式基数排序

数据结构 - 排序11.png

数据结构 - 排序12.png

算法分析:

(1)时间复杂度

对于 nn 个记录(假设记录最大位数是 dd,每个关键字的取值范围是 rdrd 个值,即桶的个数),每一趟分配的时间复杂度为 O(n)O(n),每一趟收集的复杂度为 O(rd)O(rd),整个排序需进行 dd 趟分配和收集,所以时间复杂度为 O(d(n+rd))O(d(n+rd))

(2)空间复杂度

一共需要 rd×nrd \times n 个辅助空间作为桶,需要 rdrd 个辅助空间来保存每个桶的计数,所以空间复杂度为 O(rd(n+1))O(rd(n+1))

算法特点:

  • 是稳定排序
  • 可用于顺序结构和链式结构
  • 时间复杂度可达到 O(n)O(n)
  • 基数排序使用条件有严格的要求:需要知道各级关键字的主次关系和各级关键字的取值范围
void RadixSort(int[] nums){
    // 数组下标从 1 开始
    if(nums==null || nums.length==2) return;

    //定义一个二维数组,表示10个桶,每个桶就是一个一维数组
    int[][] bucketGreaterZero = new int[10][nums.length];// 正数部分的数组
    int[][] bucketLessZero = new int[10][nums.length];// 负数部分的数组

    // 为了记录每个桶中实际存放了多少个数据,定义一个一维数组来记录每次放入数据的个数
    // 比如bucketElementCounts[0]=3,意思是bucket[0]存放了3个数据
    int[] bucketGreaterZeroElementCounts = new int[10];
    int[] bucketLessZeroElementCounts = new int[10];

    // 每次取出的元素的位数
    int digitOfGreaterElement = 0;
    int digitOfLessElement = 0;

    // 正数和负数的个数
    int positiveCount = 0;
    int negativeCount = 0;
    for(int i=1;i<nums.length;i++){
        if(nums[i] >= 0){
            positiveCount++;
        }else{
            negativeCount++;
        }
    }

    // 将正数和负数分别保存进不同的数组
    int[] arrLessZero = new int[negativeCount];
    int[] arrGreaterZero = new int[positiveCount];
    positiveCount = 0;
    negativeCount = 0;
    for(int i=1;i<nums.length;i++){
        if(nums[i] >= 0){
            arrGreaterZero[positiveCount++] = nums[i];
        }else{
            arrLessZero[negativeCount++] = nums[i];
        }
    }

    // 将负数变成正数
    for(int i=0;i<arrLessZero.length;i++){
        arrLessZero[i] *= -1;
    }

    //找到正数数组中最大数的位数
    int maxGreaterZero = 0;
    for (int i = 0; i < arrGreaterZero.length; i++) {
        if (maxGreaterZero < String.valueOf(arrGreaterZero[i]).length()) {
            maxGreaterZero = String.valueOf(arrGreaterZero[i]).length();
        }
    }

    //找到负数数组中最大数的位数
    int maxLessZero = 0;
    for (int i = 0; i < arrLessZero.length; i++) {
        if (maxLessZero < String.valueOf(arrLessZero[i]).length()) {
            maxLessZero = String.valueOf(arrLessZero[i]).length();
        }
    }

    // 对负数进行桶排序
    int n1 = 1;
    for(int i=0;i < maxLessZero;i++,n1*=10){
        // 开始第 i+1 轮排序
        for(int j=0;j<arrLessZero.length;j++){
            // 根据位数放入相应的桶,并进行计数
            digitOfLessElement = arrLessZero[j]/n1 % 10;
            bucketLessZero[digitOfLessElement][bucketLessZeroElementCounts[digitOfLessElement]] = arrLessZero[j];
            bucketLessZeroElementCounts[digitOfLessElement]++;
        }

        // 将桶中数据根据顺序放回原数组
        int index = 0;
        for(int k=0;k<bucketLessZeroElementCounts.length;k++){
            if(bucketLessZeroElementCounts[k] != 0){
                for(int l=0;l<bucketLessZeroElementCounts[k];l++){
                    arrLessZero[index++] = bucketLessZero[k][l];
                }
            }
            bucketLessZeroElementCounts[k] = 0;
        }
    }

    //临时数组,从后往前遍历负数数组元素再乘上-1,保存到temp数组里,这样负数部分就排序好了
    int[] temp = new int[arrLessZero.length];
    int index3 = 0;
    for (int i = arrLessZero.length - 1; i >= 0; i--) {
        temp[index3++] = arrLessZero[i] * (-1);
    }

    int n2 = 1;
    for(int i=0;i < maxGreaterZero;i++,n2*=10){
        // 开始第 i+1 轮排序
        for(int j=0;j<arrGreaterZero.length;j++){
            // 根据位数放入相应的桶,并进行计数
            digitOfGreaterElement = arrGreaterZero[j]/n2 % 10;
            bucketGreaterZero[digitOfGreaterElement][bucketGreaterZeroElementCounts[digitOfGreaterElement]] = arrGreaterZero[j];
            bucketGreaterZeroElementCounts[digitOfGreaterElement]++;
        }

        // 将桶中数据根据顺序放回原数组
        int index = 0;
        for(int k=0;k<bucketGreaterZeroElementCounts.length;k++){
            if(bucketGreaterZeroElementCounts[k] != 0){
                for(int l=0;l<bucketGreaterZeroElementCounts[k];l++){
                    arrGreaterZero[index++] = bucketGreaterZero[k][l];
                }
            }
            bucketGreaterZeroElementCounts[k] = 0;
        }
    }

    // 将排序完成的正数和负数合并
    System.arraycopy(temp,0,nums,1,temp.length);
    System.arraycopy(arrGreaterZero,0,nums,temp.length+1,arrGreaterZero.length);
}

6.7 外部排序

6.7.1 外部排序的基本方法

(1)按可用内存大小,将外存上含 nn 个记录的文件分成若干长度为 ll 的子文件或

(2)依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存,通常称这些有序文件为归并段顺串

(3)然后,对这些归并段进行逐趟归并,使归并段(有序的子文件)逐渐由小至大,直至得到整个有序文件为止

对同一文件而言,进行外排时所需读\写外存的次数和归并的趟数 ss 成正比。而在一般情况下,对 mm 个初始归并段进行 kk-路平衡归并时,归并的趟数 s=logkms=\lceil log_km \rceil

为了减少趟数,可以从以下两个方面进行改进:

(1)增加归并段的个数 kk

(2)减少初始归并段的个数 mm

6.7.2 多路平衡归并的实现

增加 kk 可以减少 ss,从而减少外存读\写的次数。但是单纯增加 kk 将导致比较次数增多,而内部归并时间变长。

uu 个记录分布在 kk 个归并段上,归并后的第一个记录应是从每个归并段的第一个记录的相互比较中选出的最小者,这需要进行 k1k-1 次比较。为得到 uu 个记录的归并段需进行 (u1)(k1)(u-1)(k-1) 次比较。所以,对 nn 个记录的文件进行外排时,在内部归并过程中进行的总的比较次数为 s(n1)(k1)s(n-1)(k-1) 。假设初始归并段有 mm 个,可知

logkm(n1)(k1)=log2mlog2k(n1)(k1)\lceil log_km \rceil(n-1)(k-1)=\lceil \frac{log_2m}{log_2k} \rceil(n-1)(k-1)

由于 k1log2k\frac{k-1}{log_2k}kk 的增长而增长,则内部归并时间亦随 kk 的增长而增长。这将抵消由于 kk 增大而减少外存信息读写时间所得的效益。

在进行 kk-路归并时利用“败者树”,则可使在 kk 个记录中选出最小关键字的比较次数只有 log2k\lceil log_2k \rceil,使总的归并次数变为 log2m(n1)\lceil log_2m \rceil(n-1)

败者树:

其中方形结点代表叶子结点,分别为 55 个归并段中当前参加归并选择的记录的关键字。 首先 02022020 比较,可知败者是 2020,使 ls[4]=5ls[4]=5;然后 02020606 比较,可知败者是 0606,使 ls[2]=4ls[2]=410101616 比较,败者是 1616,使 ls[3]=3ls[3]=3;最后 02021010 比较,败者是 1010,使 ls[1]=1ls[1]=1。冠军是 0202,使 ls[0]=2ls[0]=2

数据结构 - 排序13.png

6.7.3 置换-选择排序

归并的趟数 ss 不仅和 归并段的个数kk 成反比,也和 初始归并段的个数mm 成反比;所以减少 mm 是减少 ss 的另一条路径。

置换-选择排序是在树形选择排序的基础上得来的,它的特点是:在整个排序(得到所有初始归并段)的过程中,选择最小(或最大)关键字和输入、输出交叉或平行进行。

置换选择排序算法详解

6.7.4 最佳归并树

若对深度不等的 mm 个初始归并段,构造一棵哈夫曼树作为归并树,便可使在进行外部归并时所需对外存进行读\写次数达到最少,这棵归并树便被称做最佳归并树

其中叶子结点代表每个归并段的长度,每个归并段的长度不等。

在一般情况下,对 kk-路归并而言,假设有 mm 个归并段。若 (m1)MOD(k1)=0(m-1)MOD(k-1)=0,则不需加虚段,否则需附加 k(m1)MOD(k1)1k-(m-1)MOD(k-1)-1 个虚段。

假设目前有 88 个归并段,归并段长度为别为 9,20,22,2,18,6,26,129,20,22,2,18,6,26,12,同时要求是 33 路归并,普通情况如下所示。可知其读\写次数为:

(9+20+22+2+18+6+26+12)×2×2=460(9+20+22+2+18+6+26+12) \times 2 \times 2 = 460

可以将归并段的长度视为权值,乘于该叶子结点到根结点的路径长度,则得到写或读的次数,再乘以2可得到该归并段的读写次数。

数据结构 - 排序14.png

如果直接将其构造成哈夫曼树,如下图所示。可知其读\写次数为:494494,反而变多了。

数据结构 - 排序15.png

因此根据上面的公式,需要增加一个结点的长度为 00虚段。根据哈曼夫树的定义,权值越小的越远离根节点,所以 88 个归并段的最佳归并树如下所示。可知其读\写次数为:424424

数据结构 - 排序16.png

6.8 总结

6.8.1 内部排序方法比较

6.8.2 排序算法选择

速度较慢但实现简单的排序方法,称之为简单的排序方法;速度较快的算法可以看作是对某一排序算法的改进,称之为先进的排序方法

在选择排序算法时,一般综合考虑以下因素:

  • 待排序的记录个数
  • 记录本身的大小
  • 关键字的结构及初始状态
  • 对排序稳定性的要求
  • 存储结构

可得以下几点结论:

(1)当待排序的记录个数 nn 较小时,n2n^2nlog2nnlog_2n 的差别不大,可选用简单的排序方法。而当关键字基本有序时,可以选用直接插入排序或冒泡排序

(2)当 nn 较大时,应该选用先进的排序方法。对于先进的排序方法,从平均时间性能而言,快速排序最佳。但当关键字基本有序时,快速排序的递归深度为 nn,时间复杂度为 O(n2)O(n^2),空间复杂度为 O(n)O(n)。堆排序和归并排序不会出现最坏情况,但归并排序需要较大的辅助空间。当 nn 较大时,具体选用的原则是:

①当关键字分布随机,稳定性不做要求,可采用快速排序

②当关键字基本有序,稳定性不做要求,可采用堆排序

③当关键字基本有序,稳定有要求,可采用归并排序

(3)可以将简单的排序方法和先进的排序方法结合使用。例如

  • 先将待排序序列分成若干子序列,分别进行直接插入排序,然后再利用归并排序,合并成一个完整的有序序列
  • 在快速排序中,当划分子空间的长度小于某个值时,可以转而调用直接插入排序

(4)基数排序的复杂度也可写成 O(d×n)O(d \times n)。因此,它最适用于 nn 值很大而关键字较小的序列。若关键字也很大,而序列中大多数记录的“最高位关键字”均不同,则亦可先按“最高位关键字”不同将序列分成若干“小”的子序列,而后进行直接插入排序

(5)直接插入排序、归并排序都易于在链表上实现;像折半插入排序、希尔排序、快速排序和堆排序,却难于在链表上实现