数据结构与算法笔记8 排序

143 阅读10分钟

1. 排序概念

重新排列表中的元素,使表中的元素满足关键字有序的过程。

image.png

1.1 排序算法的评价指标

时间复杂度,空间复杂度,算法的稳定性。

稳定性:

关键字相同的元素在排序之后相对位置不变。

image.png

1.2 排序算法分类

    1. 内部排序:数据都在内存中
    • 关注如何使算法时间、空间复杂度更低
    1. 外部排序:数据太多,无法全部放入内存
    • 关注如何使算法时间、空间复杂度更低
    • 关注如何使读写磁盘次数更少

2. 插入排序

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

如增序插入排序
    依次检查该项关键字前比他大的数据项,将其右移。
    关键字与其相等的数据项不用右移,可以保证算法的稳定性。
//直接插入排序
void InsertSort(int A[], int n) 
{
	int i, j, temp;
	for (i = 1; i < n; i++)				//将各元素插入已排好虚的序列中
	{
		if (A[i] < A[i - 1])				//若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;									//复制到插入位置
		}
	}
}

也可以使用带哨兵的办法,可以避免前面的两次判断。 image.png

2.1 算法效率分析

2.1.1 空间复杂度 O(1)O(1)

2.1.2 时间复杂度:主要来自对比关键字、移动元素,若有n个元素,则需要n-1趟处理

image.png

image.png

平均时间复杂度 O(n2)O(n^{2})

2.1.3 算法稳定性:稳定

2.2 优化——折半插入排序

image.png

image.png

折半插入排序,相比“直接插入排序”,比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度仍是O(n2)O(n^{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)
			A[j + 1] = A[j];				//统一后移元素,空出插入位置
		A[high + 1] = A[0];          //插入操作
	}
}

2.3 对链表进行插入排序

移动元素的次数变少了,但是关键字对比的次数仍是O(n)O(n)数量级,整体来看时间复杂度依然是 O(n2)O(n^{2})

3. 希尔排序(Shell Sort)

希尔排序,先追求表中的元素部分有序,再逐渐的逼近全局有序。

image.png

image.png

image.png

希尔本人建议:每次将增量缩小为原来的一半

3.1 算法实现

//希尔排序
// 这个实现并没有单独将增量下的子表拎出来
void ShellSort(int A[],int n)
{
    int d, i ,j;
    //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)
                    A[j+d] = A[j];         //记录后移,查找插入的位置
                A[j+d] =  A[0]
            }
}

3.2 算法性能分析

空间复杂度 O(1)O(1)

时间复杂度 和增量序列 d1,d2,d3 的选择有关,目前无法用数学手段证明确切的时间复杂度,最坏时间复杂度为 o(n2)o(n^{2}) 当n在某个范围内时,可达 O(n1.3)O(n^{1.3})

算法稳定性:不稳定。

适用性:只适用于顺序表,不能用于链表!!

4. 交换排序

基于“交换”的排序:根据序列中两个元素的关键字的比较结果来兑换这两个记录在序列中的位置

4.1 冒泡排序

image.png

4.1.1 冒泡排序算法实现

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

//冒泡排序
void BubbleSort(int A[],int n)
{
    for(int i = 0; i <n-1; i++)
    {
        bool flag = false;            //表示本趟冒泡是否发生交换的标志
        for(int j = n-1; j>i;j--)     //一趟冒泡过程
        {
            if(A[j-1]>A[j])           //若为逆序
            {
                swap(A[j-1],A[j]);    //交换
                flag = true;
            }
        }
        if (flag == false)
            return;                   //本趟遍历后没有发生交换,则表明已有序
    }
}

image.png

4.1.2 冒泡算法性能分析

算法空间复杂度 O(1)O(1)

算法时间复杂度:最好情况 O(n)O(n) 最坏情况 O(n2)O(n^{2})

算法稳定性 稳定 image.png

冒泡排序可以应用于链表中

4.2 快速排序

image.png

不断进行“划分”这个动作,分别递归地对两个子表重复上述过程,直到每部分内只有一个元素或者为空为止。

4.2.1 算法实现

//用第一个元素将待排序序列划分为左右两个部分
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;                   //返回存放枢轴的最终位置
}

//快速排序
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);
    }
}

4.2.2 快速排序算法性能分析

Patition()函数的时间复杂度为 O(n)O(n)

每一层的QuickSort只需要处理剩余待排序元素,时间复杂度不会超过 O(n)O(n)

时间复杂度 = O(n递归层数)O(n*递归层数)

image.png

空间复杂度 O(递归层数)O(递归层数)

算法时间和空间复杂度和递归调用的层数正相关

递归调用层数可以看做按n个元素组织成二叉树的层数

image.png

image.png

若每次选中的“枢轴”将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高。

4.2.3 快速排序算法优化思路:尽量选择可以把数轴中分的数轴元素

image.png

4.2.4 快速排序算法效率分析

image.png 算法稳定性: 快速排序算法是不稳定的。

注意一趟排序和一次划分的区别

5. 选择排序

选择排序:每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

5.1 简单选择排序

每一趟在待排序元素中选取关键字最小的元素加入有序子序列

image.png

5.1.1 简单选择排序代码实现

void SelectSort(int A[], int n)
{
    for(int i = 0; i <n-1; i++)       //一共进行n-1趟
    {
        int min = i;                  //记录最小元素位置
        for(int j = i+1; j < n; j++)  //在A[i...n-1]中选择最小的元素
        {
            if(A[j]<A[min])           
                min = j;              //更新最小元素位置
        }
        if(min != i)
            swap(A[i],A[min]);        //封装的swap()函数共移动元素3次
    }
}

image.png

5.1.2 算法性能分析

image.png

算法稳定性: 不稳定

适用性 顺序表 链表都可以

5.2 堆排序

5.2.1 堆(Heap)

可以把堆理解为顺序存储的“完全二叉树” image.png

image.png

5.2.2 建立大根堆

思路:把所有非终端结点 in/2i\le\left\lfloor n/2 \right\rfloor 都检查一遍,是否满足大根堆的要求,如不满足则进行调整。

    1. 检查当前结点是否满足 根\ge左、右孩子,如不满足,将当前结点与更大的一个孩子互换
    1. 若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整(小元素不断“下坠”)
    1. 小元素无法继续下坠,则调整完成

image.png

5.2.3 建立大根堆的代码实现

image.png

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

//将以k为根的子树调整为大根堆
void HeadAdjust(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值,以便继续向下筛选
        }
    }
    A[k] = A[0];                      //被筛选结点的值放入最终位置              
}

5.2.4 基于大根堆进行选择排序

  • 每一趟将堆顶元素将入有序子序列,将堆顶元素和待排序序列中的最后一个元素交换
  • 并将待排序元素序列再次调整为大根堆(小元素不断“下坠”) image.png

image.png

基于大根堆的排序得到 递增序列

基于小根堆的排序得到 递减序列

5.2.5 基于大根堆进行排序代码实现

//建立大根堆
void BuildMaxHeap(int A[], int len)

//将以k为根的子树调整为大根堆
void HeadAdjust(int A[], int k, int len)

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

image.png

5.2.6 堆排序的算法效率分析

  • 建堆时,需要考虑结点下坠算法效率,时间复杂度为 O(n)O(n) image.png
  • 排序时

image.png

因此堆排序的时间复杂度为O(nlog2n)O(nlog_{2}n)

堆排序的空间复杂度是 O(1)O(1)

堆排序的算法是不稳定的。

5.2.7 堆的插入删除

1)插入

  • 对于小根堆,新元素放到表尾,与父结点对比,新元素比父结点更小,则将二者互换,新元素就这样一路“上升”,直到无法继续上升为止。

2) 删除

被删除的元素用堆底元素替代,然后让该元素不断“下坠”,直到无法下坠为止。

6. 归并排序(Merge Sort)

把两个或多个已经有序的序列合并成一个

image.png 只剩一个子表未合并时,可以将该表中剩余元素全部加到总表

6.1 “m路”归并 m合一

m路归并,每选出一个元素需要对比关键字 m-1 次

6.2 内部排序中的归并排序

在内部排序中,一般采用2路归并。

核心操作:把数组内的两个有序序列合并成一个

6.3 归并排序的代码实现

int *B = (int *)malloc(n *sizeof(int)); //新建一个辅助数组B用来暂存A数组的内容

//A[low...mid]A[mid+1...high]各自有序,将两个部分归并
void MergeS(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++]; //将较小或相等的值复制到A中,然后移动i的位置,可以保证稳定性
        else
            A[k] = B[j++]; //将较大的值复制到A中,然后向后移动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); //归并,左右两个子序列分别有序之后再将二者归并
    }
}

image.png

image.png

6.4 归并排序的算法效率分析

时间复杂度 O(nlog2n)O(nlog_{2}n)

image.png

空间复杂度O(n)O(n),来自于辅助数组B

归并排序算法是稳定的

7. 基数排序(Radix Sort)

注意:基数排序不是一个基于比较\color{red}{比较}的排序算法

image.png

7.1 基数排序的算法思想

image.png

7.2 基数排序的算法效率分析

空间复杂度 O(r)O(r), 因为需要r个辅助队列

时间复杂度:

一趟分配时间 O(n)
一趟收集时间 O(r)
总共d趟分配、收集,总的时间复杂度 = O(d(n+r))

image.png

算法的稳定性:稳定

image.png

7.3 基数排序的应用

不一定每一个关键字要采用相同的r个辅助队列

image.png

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

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

8. 外部排序

8.1 外存和内存之间的数据交换

image.png

8.2 外部排序

数据元素太多,无法一次全部读入内存进行排序

使用 归并排序的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大文件进行排序

image.png

    1. 构造初始“归并段”

image.png

    1. 第一趟归并:归并过程中缓冲区一空就要读入

image.png

  • 3. 第二趟归并

image.png -4. 按如上思想继续归并

8.3 外部排序的时间开销分析

image.png

减少归并的趟数是外部排序时间开销优化的关键

8.3.1 外部排序的优化1: 多路归并

image.png

若采用4路归并,只需进行两趟归并即可,读、写磁盘次数 = 32 + 32 * 2 = 96次

重要结论,采用多路归并可以减少归并趟数,从而减少磁盘I/O(读写)次数

对r个初始归并段,做k路归并,则归并树可用k叉树表示

若树高为h,则归并趟数 = h -1 = logkr\left\lceil log_{k}r\right \rceil

推导: k叉树第h层最多有 kh1k^{h-1}个结点,则有 rkh1r \le k^{h-1},则 (h1)min=logkr(h-1)_{min}=\left\lceil log_{k}r\right \rceil

K越多,r越小,归并趟数越少,读写磁盘次数越少\color{red}{K越多,r越小,归并趟数越少,读写磁盘次数越少}

多路归并带来的负面影响

    1. k路归并时,需要开辟k个输入缓冲区,内存开销增加
    1. 每挑选一个关键字需要对比关键字 (k-1)次,内部归并所需时间增加(可用败者树减少关键字对比次数)

image.png

8.3.2 外部排序的优化2:减少初始归并段的数量

image.png

image.png

按照上述方法,生成的初始归并段,总共有N个记录,内存工作区可以容纳L个记录,则初始归并段数量r = N/L(可用置换-选择排序进一步减少初始归并段的数量)

8.3.3 多路平衡归并

课本有误

image.png

非多路平衡归并的反例

image.png

8.3.4 败者树 解决多路平衡归并带来的问题

image.png

8.3.4.1 败者树构造

image.png

基于已经构建好的败者树,可以减少比较的次数。

结点中记录的是来自哪个号段

8.3.4.2 败者树的代码实现

败者树中叶子结点是虚拟的,实际不存在的,仅在逻辑上存在。

image.png

image.png

8.3.5 置换-选择排序 减少初始归并段数量

  • 土方法构造初始归并段,可用一片更大的内存区域来进行内部排序

用于内部排序的内存工作区WA可容纳l个记录,则每个初始归并段也只能包含l个记录,若文件共有n个记录,则初始归并段的数量 r = n/l

image.png

8.3.5.1 置换-选择排序

-1. 构造归并段1 image.png

image.png

    1. 归并段1构造结束,照此规则构造归并段2 image.png
  • 3, 照此原则,构造其他归并段

image.png

image.png

8.3.6 最佳归并树

在经过置换选择排序后 初始归并段所占块数不相同,则会有如下性质:

归并过程中的磁盘 I/O次数 = 归并树的WPL * 2

image.png

要让磁盘 I/O次数最少,就要使归并树 WPL最小——哈夫曼树

image.png

8.3.6.1 多路归并的最佳归并树

image.png

对于k叉归并,若初始归并段的数量无法构成严格的K叉归并树,则需要补充几个长度为0的虚段,在进行k叉哈夫曼树的构造。

错误示范 image.png

正确示范

image.png

1) 添加虚段的数量

如果是 “严格k叉树” n01k1\frac{n_{0}-1}{k-1} 一定能除得尽,因此需要添加 x个虚段保证

“(初始归并段数量 -1) % (k - 1)=0”

image.png