【数据结构笔记】7.排序

第7章 排序

7.1 排序的基本概念

  • 算法的稳定性。若待排序表中有两个元素Ri和Rj,其对应关键字keyi = keyj,且在排序前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称这个排序算法是不稳定的。
  • 内部排序。是指在排序期间元素全部存放在内存中的排序。
  • 外部排序。是指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动的排序。

并非所有内部排序算法都要基于比较操作,事实上,基数排序就不基于比较。

【注意】
  1. 对同一线性表使用不同的排序方法进行排序,得到的排序结果可能不同。事实上,当对线性表中的元素按关键字进行排序,不稳定的排序算法可能得到的排序结果不同。
  2. 2路归并排序中比较操作的执行次数为 ⌈ log ⁡ 2 ( n ! ) ⌉ \lceil \log_2(n!) \rceil log2(n!)⌉.

7.2 插入排序

7.2.1 直接插入排序

//直接插入排序(带哨兵)
//优点:不用每轮循环都判断i>=0
void InsertSort(int A[], int n){
    int i, j;
    for(i = 2; i <= n; ++i){        //依次将A[2]~A[n]插入到前面已排序序列
        if(A[i] < A[i-1]){          //若A[i]关键码小于其前驱,将A[i]插入有序表
            A[0] = A[i];            //复制为哨兵,A[0]不存放元素
            for(j = i-1; A[0] < A[j]; --j)      //从后往前查找待插入位置
                A[j+1] = A[j];      //向后挪位
            A[j+1] = A[0];          //复制到插入位置
        }//if
    }//for
}
//直接插入排序
void InsertSort(int A[], int n){
    int i, j, temp;
    for(i = 1; i < n; ++i){         //将个元素插入已排好序的序列中
        if(A[i] < A[i-1]){          //若A[i]关键码小于其前驱,将A[i]插入有序表
            temp = A[i];            //用temp暂存A[i]
            for(j = i-1; j >=0 && A[j] > temp; --j)      //从后往前查找待插入位置
                A[j+1] = A[j];      //所有大于temp的元素都向后挪位
            A[j+1] = temp;          //复制到插入位置
        }//if
    }//for
}
  • 空间效率:插入排序的空间复杂度为O(1),
  • 时间效率:最好情况下,表中元素已经有序,此时每插入一个元素,都只需要比较一次而不用移动元素,因而时间复杂度为O(n)。最坏情况下,表中元素顺序刚好与排序结果中元素顺序相反,总的比较次数达到最大,时间复杂度为O(n2)
  • 直接插入排序是一个稳定的算法。
  • 适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。

虽然折半插入排序算法的时间复杂度也为O(n2),但对于数据量较小的排序表,折半插入排序往往能表现出很好的性能。值得注意的是,大部分排序算法都仅适用于顺序存储的线性表

7.2.2 折半插入排序

void InsertSort(int A[], int n){
	int i, j, low, high, mid;
    for(i = 2; i <= n; ++i){		//依次将A[2]~A[n]插入到前面的已排序序列
        A[0] = A[i];				//将A[i]暂存到A[0]
        low = 1;					//设置折半查找的范围
        high = i-1;
        while(low <= high){			//折半查找(默认递增有序)
            mid = (low + high)/2;	//取中间点
            if(A[mid] > A[0])		
                high = mid -1;		//查找左半子表
            else
                low = mid + 1;		//查找右半子表
        }
        for(j = i - 1; j >= high + 1; --j)	//high+1其实就是low,统一后移元素,空出插入位置
            A[j + 1] = A[j];
        A[high + 1] = A[0];			//插入操作
    }
}
  • 当low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到low所指位置。

  • 当A[mid]==A[0]时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置。

  • 折半插入排序仅减少了比较次数,约为O(nlog2n),该比较次数与待排序的表的初始状态无关,仅取决于表中的元素个数n;而元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为O(n2)。

  • 折半插入排序是一种稳定的排序方法。

7.2.3 希尔排序

void ShellSort(int A[], int n){
    //对顺序表做希尔插入排序,本算法和直接插入排序相比,做了以下修改:
    //1.前后记录位置的增量是d,不是1
    //2.A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
    for(d = n/2; d >= 1; d = d/2){          //步长变化
        for(i =  d + 1; i <= n; ++i){
            if(A[i] < A[i-d]){              //需将A[i]插入有序增量子表
                A[0] = A[i];                //暂存在A[0]
                for(j = i-d; j > 0 && A[0] < A[j]; j -= d){
                    /*这个循环体的操作保证了全部序列中相隔d个元素的位置上的n/d个元素相对有序*/
                    A[j + d] = A[j];
                }
                A[j + d] = A[0];			//插入
            }//if
        }
    }
}
  • 空间效率:空间复杂度为O(1);
  • 时间效率:当n在某个特定范围时,希尔排序的时间复杂度为O(n1.3)。在最坏情况下希尔排序的时间复杂度为O(n2)。
  • 希尔排序是一种不稳定的排序方法。
  • 希尔排序算法仅适用于线性表为顺序存储的情况
【注意】
  1. 在待排序的元素序列基本有序的前提下,效率最高的排序方法是直接插入排序。
  2. 选择排序、冒泡排序、堆排序,在每趟排序后均会使一个记录存放在最终位置上。
  3. 插入排序经k趟排序后,会使原序列中的前k+1个元素拍成局部有序序列。
  4. 2路归并排序经过k趟排序后,原序列中每2k个元素局部有序。

7.3 交换排序

7.3.1 冒泡排序

void swap(int & a, int & b){
    int temp = a;
    a = b;
    b = temp;
}

void BubbleSort(int A[], int n){
    //用冒泡排序法将序列A中的元素按从小到大排列
    for(i = 0; i < n-1; i++){
        bool flag = false;              //表示本趟冒泡是否发生交换的标志
        for(j = n-1; j > i; j--){       //一趟冒泡的过程
            if(A[j-1] > A[j]){          //若为逆序
                swap(A[j-1], A[j]);     //交换
                flag = true;
            }
        }
        if(flag == false)
            return;     //本趟遍历后没有发生交换,说明表已经有序。
    }
}
  • 空间效率:仅使用了常数个辅助单元,因而空间复杂度为O(1)。

  • 时间效率:当初始序列有序时,显然第一趟冒泡后flag依然为false(本趟冒泡没有元素交换),从而直接跳出循环,比较次数为n-1,移动次数为0,从而最好情况下的时间复杂度为O(n);当初始序列为逆序时,需要进行n-1趟排序,第i趟排序要进行n-1次关键字的比较,而且每次比较都必须移动元素3次来交换元素位置。

比较次数 = ∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 , 移动次数 = ∑ i = 1 n − 1 3 ( n − i ) = 3 n ( n − 1 ) 2 比较次数 = \sum_{i=1}^{n-1}(n-i) = \frac{n(n-1)}2, \quad 移动次数 = \sum_{i=1}^{n-1}3(n-i) =\frac{3n(n-1)}2 比较次数=i=1n1(ni)=2n(n1),移动次数=i=1n13(ni)=23n(n1)

​ 从而,最坏时间复杂度为O(n2),其平均时间复杂度也为O(n2)。

  • 稳定性:由于i>j且A[i].ekey = A[j].key时,不会交换两个元素,因此冒泡排序是一种稳定的排序方法。

冒泡排序中所产生的有序子序列一定是全局有序的,这样每趟排序都会一个元素放置在其最终的位置上。

7.3.2 快速排序

//快速排序
void QuickSort(int A[], int low, int high){
    if(low < high){         //递归跳出的条件
        int pivotpos = Partition(A, low, high);     //划分
        quickSort(A, low, pivotpos - 1);            //划分左子表
        quickSort(A, pivotpos + 1, high);           //划分右子表
    }
}

int Partition(int A[], int low, int high){
    //严蔚敏《数据结构》教材中的划分算法(一趟排序过程)
    int pivot = A[low];        //将当前表中第一个元素设为枢轴值,对表进行划分。
    while(low < high){         //用low、high搜素枢轴的最终位置
        while(low < high && A[high] >= pivot)       //注意比较的是下标值
            --high;
        A[low] = A[high];       //比枢轴小的元素移动到左端
        while(low < high && A[low] <= pivot)        //注意比较的是下标值
            ++low;
        A[high] = A[low];       //比枢轴大的元素移动到右端
    }
    A[low] = pivot;             //枢轴元素存放到最终位置
    return low;                 //返回存放枢轴的最终位置
}
  • 空间效率:由于快速排序是递归的,需要借助一个递归工作栈来跑村每层递归调用的必要信息,其容量应与递归调用的最大深度一致。最好情况下为 ⌈ log ⁡ 2 ( n + 1 ) ⌉ \lceil \log_2(n+1) \rceil log2(n+1)⌉;最坏情况下,因为要进行n-1次递归调用,所以栈的深度为O(n);平均情况下,栈的深度为O(log2n)。
  • 时间效率:快速排序的运行时间与划分是否对称有关,而后者又与具体使用的划分算法有关。快速排序的最坏情况发生在两个区域分别包含n-1个元素和0个元素时,这种最大程度的不对成性若发生在每层递归上,即对应于初始排序表基本有序时,就得到最坏情况下的时间复杂度为O(n2)。在最理想的状态下,即partition()可能做到最平衡的划分中,得到的两个子问题的大小都不可能大于n/2,在这种情况下,时间复杂度为O(nlog2n)。快速排序平均情况下的运行时间与其最佳情况下的运行时间很接近,时所有内部排序算法中平均性能最优的排序算法。
  • 快速排序是一种不稳定的排序方法。
  • 在快速排序算法中,并不产生有序子序列,但每趟排序后会将一个元素(基准元素)放到其最终位置上。
  • 有的题中会说,对所有尚未确定最终位置的所有元素进行一边处理称为“一趟”排序,因此一次“划分” ≠ \neq =一趟排序。一次划分可以确定一个元素的最终位置,而一趟排序也许可以确定多个元素的最终位置。
【注意】
  1. 计算冒泡排序的排序的趟数有些类似寻找逆序,注意体会。
  2. 对于快排,当每次枢轴把表等分为长度相近的两个子表时,速度是最快的;当划分后的子表已经有序或逆序时,都会使得速度变慢;若表本身已经有序或逆序,此时速度最慢。
  3. 在使用非递归方法实现快速排序时,通常要利用一个栈记忆待排序区间的两个端点,同样也可以用队列来代替栈。在快速排序过程中,通过一趟划分,可以把一个待排序区间分为两个子区间,然后分别对两个子区间施行同样的划分。栈的作用是在处理一个子区间时,保存另一个子区间的上界和下界(排序过程中可能产生新的左、右子区间),待该区间处理完后再从栈中取出另一个子区间的边界,对其进行处理。这个功能用队列也可以实现,只不过处理子区间的顺序有所变动而已。

7.4 选择排序

7.4.1 简单选择排序

//交换
void swap(int & a, int &b){
    int temp = a;
    a = b;
    b = temp;
}

void SelectSort(int A[], int n){
    //对表A做简单选择排序,A[]从0开始存放元素
    for(int i = 0; i < n-1; i++){
        int min = i;
        for(j = i+1; j < n; j++)
            if(A[j] < A[min])
                min = j;
        if(min != i)
            swap(A[i], A[min]);
    }
}
  • 空间效率:仅使用常数个辅助单元,空间效率为O(1).
  • 时间效率:在简单选择排序过程中,元素移动的操作次数很少,不会超过3(n-1),最好的情况是移动0次;但元素间比较的次数与序列的状态无关,始终是n(n-1)/2,所以时间复杂度始终是O(n2).
  • 稳定性:简单选择排序是一种不稳定的排序方法。

7.4.2 堆排序

堆排序是一种树形选择排序方法,其特点是:在排序过程中,将L视为一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)的元素。

非叶结点大于左右孩子结点的堆称为大根堆,非叶结点小于左右孩子结点的堆称为小根堆。在大根堆中,最大元素存放在根结点中,小根堆中,最小元素存放在根结点。堆经常被用来实现优先级队列,优先级队列在操作系统的作业调度和其他领域有广泛的应用。

⭐️对初始序列建堆,是一个反复筛选的过程。n个结点的完全二叉树,最后一个非叶结点是第 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor n/2个结点,对第 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor n/2个结点为根的子树筛选(对于大根堆,若根节点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。之后向前依次对各结点( ⌊ n / 2 ⌋ \lfloor n/2 \rfloor n/2-1~1)为根的子树进行筛选,看该结点值是否大于其左右子结点的值,若不大于,则将左右子结点中较大者与之交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级堆,直到以该结点为根的子树构成堆为止。反复利用上述调整堆的方法建堆,直到根结点。

初始堆的建立并不是逐个添加结点构成堆的结构,而是先排成一个完全二叉树,再调整成为堆。

//函数AdjustDown将以k为根的子树调整为大根堆
void AdjustDown(int A[], int k, int len){
    A[0] = A[k];                        //A[0]暂存子树的根结点
    for(int i = 2*k; i <= len; i *= 2){ //沿key较大的子结点向下筛选
        if(i < len && A[i] < A[i+1])
            ++i;                        //取key较大的子节点的下标
        if(A[0] >= A[i])
            break;                      //筛选结束
        else{
            A[k] = A[i];                //将A[i]调整到双亲结点上
            k =  i;                     //修改k值,以便继续向下筛选
        }
    }//for
    A[k] = A[0];                        //被筛选结点的值放入最终位置
}

//建立大根堆
void BuildMaxHeap(int A[], int len){
    for(int i = len/2; i > 0; --i)      //从后往前调整所有非终端结点
        AdjustDown(A, i, len);
}

//堆排序的完整逻辑
void HeapSort(int A[], int len){
    BuildMaxHeap(A, len);               //初始建堆
    for(int i = len; i>1; i--){         //n-1趟的交换和建堆过程
        swap(A[i], A[1]);               //堆顶元素和堆底元素交换
        AdjustDown(A, 1, i-1);          //把剩余的待排序元素整理成堆
    }
}

可以证明再元素个数为n的序列上建堆,其时间复杂度为O(n),这说明可以在线性时间内将一个无序数组中组建成一个大根堆。

由于堆本身的特点(以大根堆为例),堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大根堆的性质,堆被破坏,将对顶元素向下调整使其继续保持大根堆的性质,再输出堆顶元素,直至队中仅剩下一个元素为止。

//下面是向上调整堆的算法
void adjustUp(ElemType A[], int k){
    A[0] =  A[k];
    int i = k/2;
    while(i > 0 && A[i] < A[0])
    {
        A[k] = A[i];
        k = i;
        i = k/2;
    }//while
    A[k] = A[0];
}
  • 空间效率:仅使用了常数个辅助单元,所以空间复杂度为O(1).
  • 时间效率:建堆时间为O(n),之后有n-1次向下调整操作,每次调整的时间复杂度为O(h),故在最好、最坏和平均情况下,堆排序的时间复杂度为O(nlog2n)
  • 稳定性:堆排序算法是一种不稳定的排序方法。
【注意】
  1. 堆与二叉排序树的区别:
    以小根堆为例,堆的特点是双亲结点的关键字必然小于等于该孩子结点的关键字,而两个孩子结点的关键字没有次序规定。在二叉排序树中,每个双亲结点的关键字均大于左子树结点的关键字,均小于右子树结点的关键字,也就是说,每个双亲结点的左、右孩子的关键字有次序关系。这样,当对两种树执行中序遍历后,二叉排序树会得到一个有序的序列,而堆则不一定能得到一个有序的序列。
  2. 若只想得到一个序列中第k(k≥5)个最小元素之间的部分排序序列,则最好采用什么排序方法?
    在基于比较的排序方法中,插入排序、快速排序和归并排序只有在将元素全部排完序后,才能得到前k小的元素序列,算法的效率不高。
    冒泡排序、堆排序和简单排序可以,建立初始堆的时间不超过4n,取得第k个最小元素之前的排序序列所花的时间为klog2n,总时间为4n+klog2n;冒泡和简单选择排序完成此功能索化时间为kn,当k≥5时,通过比较可以得出堆排序最优。

7.5 归并排序和基数排序

7.5.1 归并排序

int *B = (int *)malloc(sizeof(int) * n);    //辅助数组

//表A的两段A[low...mid]和A[mid+1...high]各自有序,将它们合并成一个有序表
void Merge(int A[], int low, int mid, int high){
    int i, j, k;
    for(k = low; k <= high; ++k)
        B[k] = A[k];                        //将A中所有元素复制到B中
    for(i = low, j = mid + 1, k = i; i <= mid && j <= high; ++k){
        //比较两端元素“开头”的数字,每次将较小的元素放入辅助数组
        if(B[i] <= B[j])
            A[k] = B[i++];
        else
            A[k] = B[j++];
    }//for
    //以下两个循环只有一个会执行
    while(i <= mid)    A[k++] = B[i++]; 
    while(j <= high)   A[k++] = B[j++]; 
}

void MergeSort(int A[], int low, int high){
    if(low < high){
        int mid = (low + high)/2;           //从中间划分
        MergeSort(A, low, mid);             //对左半部分归并排序
        MergeSort(A, mid+1, high);          //右左半部分归并排序
        Merge(A, low, mid, high);           //归并
    }
}

二路归并排序算法的性能分析如下:

  • 空间效率:merge()操作中,辅助空间刚好占用n个单元,所以归并排序的时间复杂度为O(n)。
  • 时间效率:每趟归并的时间复杂度为O(n),共需要进行 ⌈ log ⁡ 2 n ⌉ \lceil \log_2n \rceil log2n趟归并,所以算法时间复杂度为O(nlog2n).
  • 稳定性:2路归并排序算法是稳定的排序方法。

7.5.2 基数排序

基数排序是一种不基于比较进行排序,而采用多关键字排序思想(即基于关键字各位的大小进行排序),借助“分配”和“收集”两种操作对单逻辑关键字进行排序。基数排序又分为最高位优先(MSD)和最低位优先(LSD)排序。

  • 空间效率:一趟排序需要的辅助存储空间为r(r个队列,r是关键字的取值范围)。空间复杂度为O®.
  • 时间效率:基数排序需要进行d(d也是关键字个数)趟分配和收集,一趟分配需要O(n),一趟收集需要O®,所以基数排序的时间复杂度为O(d*(n+r)),它与序列的初始状态无关。
  • 稳定性:基数排序是一种稳定的排序方法。

基数排序擅长解决的问题:

  • 数据元素的关键字可以方便得拆分为d组,且d较小。
  • 每组关键字的取值范围不大,即r较小。
  • 数据元素个数n较大。

【注意】

  1. 选择排序的比较次数与序列初始状态无关,归并排序也与序列的初始状态无关。
  2. 外部排序通常采用归并排序法。
  3. 对N个元素进行k路归并排序时,排序的趟数m满足km=N。

7.6 内部排序算法的比较及应用

算法种类最好时间复杂度平均时间复杂度最坏时间复杂度空间复杂度稳定性
直接插入排序O(n)O(n2)O(n2)O(1)稳定
折半插入排序O(n2)O(1)稳定
希尔排序O(n)O(n1.3)O(n2)O(1)不稳定
冒泡排序O(n)O(n2)O(n2)O(1)稳定
快速排序O(nlog2n)O(nlog2n)O(n2)平均:O(log2n);最坏:O(n)不稳定
简单选择排序O(n2)O(n2)O(n2)O(1)不稳定
堆排序O(nlog2n)O(nlog2n)O(nlog2n)O(n)不稳定
归并排序O(nlog2n)O(nlog2n)O(nlog2n)O(n)稳定
基数排序O(d(n+r))O(d(n+r))O(d(n+r))稳定
  1. 若n较小(n≤50),则可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动操作较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好。
  2. 若文件初始状态已按关键字基本有序,则选哪用直接插入排序或冒泡排序为宜。
  3. 若n较大,则应采用时间复杂度为O(nlog2n)的排序方法:快速排序、堆排序或归并排序。快速排序被认为时目前基于比较的内部排序法中最好的方法,当待排序的关键字随机分布时,快速排序的平均时间最短。堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,这两种排序都是不稳定的。若要求排序稳定且时间复杂度为O(nlog2n),则可选用归并排序。但通常将归并排序和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序文件,然后两两归并。直接插入排序是稳定的,因此改进后的归并排序仍是稳定的。
  4. 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的n个关键字随机分布时,任何借助于“比较”排序算法,至少需要O(nlog2n)的时间。
  5. 若n很大,记录的关键字位数较少且可以分解时,采用基数排序较好。
  6. 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
  • 6
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值