数据结构——排序 笔记合集(C语言)完结

文章中其实有很多图来帮助理解,但是因为外链的原因,我电脑上的图不能直接拉过来,要完整版的可以评论我直接发PDF版本。因为笔记的原因没有英文注释代码,个人笔记,仅供参考。

排序

排序算法的评估指标

算法的稳定性:若待排序表中有两个元素 R i R_i Ri R j R_j Rj,其对应的关键字相同即 k e y i = k e y j key_i = key_j keyi=keyj,且在排序前 R i R_i Ri R j R_j Rj的前面,若使用某一排序算法排序后, R i R_i Ri仍然在 R j R_j Rj的前面,则称这个排序算法时稳定的,否则称排序算法是不稳定的。

排序算法的分类

  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;                     //复制到插入位置
    }
}

算法效率分析

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

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

算法稳定性:稳定

优化——折半插入排序

思路:先用折半查找找到应该插入的位置,再移动元素

l o w > h i g h low > high low>high时,折半查找停止,应将 [ l o w ,   i − 1 ] [low,\ i-1] [low, i1]内的元素全部右移,并将 A [ 0 ] A[0] A[0]复制到 l o w low low所指位置。

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

//折半插入排序
void InsertSort(int A[], int n){
    
    
  for 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];           //插入操作
  }
}

希尔排序(Shell Sort)

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

先将待排序表分割成若干形如 L [ i , i + d , i + 2 d , . . . , i + k d ] L[i, i+d, i+2d,..., i+kd] L[i,i+d,i+2d,...,i+kd]的“特殊”子表,对各个子表分布进行直接插入排序。缩小增量 d d d,重复上述过程,直到 d = 1 d=1 d=1为止。(每次将增量缩小一半)

//希尔排序
void ShellSort(int A[i], 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];         //插入
      }//if
}

算法性能分析

时间复杂度:和增量序列 d 1 , d 2 , d 3 . . . d_1,d_2,d_3... d1,d2,d3...的选择有关,目前无法证明。

稳定性:不稳定

适用性:仅适用于顺序表,不适用于链表

冒泡排序

从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即 A [ i − 1 ] > A [ i ] A[i-1]>A[i] A[i1]>A[i]),则交换它们,直到序列比较完。称这样的过程为“一趟”冒泡排序。

//交换
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;                     //本趟遍历后没有发生交换,说明表已经有序
  }
}

算法性能分析

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

时间复杂度: O ( n 2 ) O(n^2) O(n2)

快速排序

算法思想:在待排序表 L [ 1... n ] L[1...n] L[1...n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分 L [ 1... k − 1 ] L[1...k-1] L[1...k1] L [ k + 1... n ] L[k+1...n] L[k+1...n],使得 L [ 1... k − 1 ] L[1...k-1] L[1...k1]中所有元素小于pivot, L [ k + 1... n ] L[k+1...n] L[k+1...n]中所有元素大于等于pivot,则pivot放在了其最终位置 L ( k ) L(k) L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

//用第一个元素将待排序序列划分成左右两个部分
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);            //划分右子表
  }
}

算法效率分析

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

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

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

稳定性:不稳定

把n个元素组织成二叉树,二叉树的层数就是递归调用的层数。

n个结点的二叉树: 最小高度 = ⌊ l o g 2 n ⌋ + 1 \lfloor log_2n \rfloor + 1 log2n+1; 最大高度 = n

故:最好时间复杂度 = O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)

​ 最坏时间复杂度 = O ( n 2 ) O(n^2) O(n2)

​ 最好空间复杂度 = O ( l o g 2 n ) O(log_2n) O(log2n)

​ 最坏空间复杂度 = O ( n ) O(n) O(n)

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

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

e.g.1 选头、中、尾三个位置的元素,取中间值作为枢轴元素

e.g.2 随机选一个元素作为枢轴元素

简单选择排序

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

//简单选择排序
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次
  }
}

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

算法性能分析

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

时间复杂度: O ( n 2 ) O(n^2) O(n2)

稳定性:不稳定

适用性:既可以用于顺序表,也可用于链表

堆排序

堆的定义

若n个关键字序列 L [ 1... n ] L[1...n] L[1...n]满足下面某一条性质,则称为堆(Heap):

  1. 若满足: L ( i ) > = L ( 2 i ) L(i)>=L(2i) L(i)>=L(2i) L ( i ) > = L ( 2 i + 1 )     ( 1 < = i < = n 2 ) L(i)>=L(2i+1)\ \ \ (1<=i<=\frac{n}{2}) L(i)>=L(2i+1)   (1<=i<=2n) ,则称为大根堆(大顶堆)

  2. 若满足: L ( i ) < = L ( 2 i ) L(i)<=L(2i) L(i)<=L(2i) L ( i ) < = L ( 2 i + 1 )     ( 1 < = i < = n 2 ) L(i)<=L(2i+1)\ \ \ (1<=i<=\frac{n}{2}) L(i)<=L(2i+1)   (1<=i<=2n) ,则称为小根堆(小顶堆)

大根堆:完全二叉树中,根>=左、右

小根堆:完全二叉树中,根<=左、右

建立大根堆

思路:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整。

在顺序存储的完全二叉树中,非终端结点编号 i < = ⌊ n / 2 ⌋ i<= \lfloor n/2 \rfloor i<=n/2

检查当前结点是否满足根>=左、右,若不满足,将当前结点与更大的一个孩子互换。

若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整。

//建立大根堆
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];                    //被筛选结点的值放入最终位置
}

基于大根堆进行排序

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

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

//建立大根堆
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);        //把剩余的待排序元素整理成堆
  }
}

算法效率分析

一个结点,每“下坠”一层,最多只需对比关键字2次

若树高为h,某结点在第i层,则将这个结点向下调整最多只需要“下坠”h-i层,关键字对比次数不超过2(h-i)。

n个结点的完全二叉树树高 h = ⌊ l o g 2 n ⌋ + 1 h = \lfloor log_2n \rfloor + 1 h=log2n+1

第i层最多有 2 i − 1 2^{i-1} 2i1个结点,而只有第1~(h-1)层的结点才有可能需要“下坠”调整。

将整棵树调整为大根堆,关键字对比次数不超过
∑ i = h − 1 1 2 i − 1 2 ( h − i ) = ∑ i = h − 1 1 2 i ( h − i ) = ∑ j = 1 h − 1 2 h − j j < = 2 n ∑ j = 1 h − 1 j 2 j < = 4 n \sum^1_{i=h-1}2^{i-1}2(h-i) = \sum^1_{i=h-1}2^i(h-i) = \sum^{h-1}_{j=1}2^{h-j}j <= 2n\sum^{h-1}_{j=1}\frac{j}{2^j} <= 4n i=h112i12(hi)=i=h112i(hi)=j=1h12hjj<=2nj=1h12jj<=4n
∑ j = 1 h − 1 j 2 j \sum^{h-1}_{j=1}\frac{j}{2^j} j=1h12jj是差比数列求和,用错位相减。

所以建堆的时间复杂度: O ( n ) O(n) O(n)

堆的调整需要时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)

堆排序的时间复杂度: O ( n ) + O ( n l o g 2 n ) = O ( n l o g 2 n ) O(n)+O(nlog_2n) = O(nlog_2n) O(n)+O(nlog2n)=O(nlog2n)

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

稳定性:不稳定

堆——插入删除

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

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

归并排序(Merge Sort)

两个或多个已经有序的序列合并成一个。(“2路”归并

“2路”归并:设置三个指针 i , j , k i,j,k i,j,k分别指向两个有序序列和一个将要存放归并序列的序列,对比i和j的大小,并存入k中,出和入的指针都加1。

其余路归并思想大致相同。

m路归并,每选出一个元素需要对比关键字m-1次,类似于冒泡挑出最值。

归并排序中,在内部排序中一般采用2路归并,先将每一个元素看作一路,两两相归并。

Screen Shot 2020-06-06 at 5.16.24 PM
int *B = (int *)malloc(n*sizeof(int));    //辅助数组B

//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++];                      //将较小值复制到A中
    else
      A[k] = B[j++];
  }
  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);     //归并
  }
}

算法效率分析

时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)

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

稳定性:稳定的

基数排序(Radix Sort)

假设长度为n的线性表中每个结点 a j a_j aj的关键字由d元组 ( k j d − 1 , k j d − 2 , k j d − 3 , . . . , k j 1 , k j 0 ) (k^{d-1}_j,k^{d-2}_j,k^{d-3}_j,...,k^1_j,k^0_j) (kjd1,kjd2,kjd3,...,kj1,kj0)组成,其中, 0 ≤ k j i ≤ r − 1 0\le k^i_j\leq r-1 0kjir1,r称为“基数”。

基数排序得到递减序列的过程如下

初始化:设置r个空队列, Q r − 1 , Q r − 2 , . . . , Q 0 Q_{r-1}, Q_{r-2},...,Q_0 Qr1,Qr2,...,Q0

按照各个关键字位权重递增的次数(个、十、百),对d个关键字位分别做“分配”和“收集”

分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入 Q x Q_x Qx队尾

收集:把 Q r − 1 , Q r − 2 , . . . , Q 0 Q_{r-1}, Q_{r-2},...,Q_0 Qr1,Qr2,...,Q0各个队列中的结点依次出队并链接。

基数排序不是基于“比较”的排序算法

基数序列通常基于链式存储实现。

算法效率分析

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

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

稳定性:稳定的

基数排序的应用

统计学生的信息按年龄递减排序

权重:年 > 月 > 日

第一趟分配、收集(按“日”递增)

第二趟分配、收集(按“月”递增)

第三趟分配、收集(按“年”递增)

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

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

外部排序

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

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

  1. 构造初始“归并段”,在输入缓冲区1和输入缓冲区2排好两块的序,然后通过输出缓冲区写入磁盘;

  2. 第一趟归并;

  3. 第二趟归并;

  4. …直到所有数据被归并完毕,得到完整的排序。

时间开销分析(二路)

外部排序时间开销 = 读写外存的时间 + 内部排序所需时间 + 内部归并所需时间

读写磁盘次数 = 文件总块数*2 + 文件总块数*2*归并趟数

故优化方法可以减少归并趟数

优化:多路归并

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

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

若树高为 h h h,则归并趟数 = h − 1 h - 1 h1 = ⌈ l o g k r ⌉ \lceil log_kr\rceil logkr

推导: k k k叉树第 h h h层最多有 k h − 1 k^{h-1} kh1个结点,则 r ≤ k h − 1 r\le k^{h-1} rkh1 ( h − 1 ) 最 小 = ⌈ l o g k r ⌉ (h-1)_{最小}=\lceil log_kr\rceil (h1)=logkr

多路归并带来的负面影响:

  1. k路归并时,需要开辟k个输入缓冲区,内存开销增加;
  2. 每挑选一个关键字需要对比关键字(k - 1)次,内部归并所需时间增加。

优化:减少初始归并段数量

由 归并趟数 = h − 1 h - 1 h1 = ⌈ l o g k r ⌉ \lceil log_kr\rceil logkr可得:

当r减小时,归并趟数也会减小

多路平衡归并

k路平衡归并:

  1. 最多只能有k个段归并为1个;
  2. 每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到 ⌈ m / k ⌉ \lceil m/k \rceil m/k个新的归并段。

败者树

可视为一棵完全二叉树(多一个头)。k个叶结点分别时当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。

败者树在多路平衡归并中的应用

对于k路归并,第一次构造败者树需要对比关键字k-1次

有了败者树,选出最小元素,只需对比关键字 ⌈ l o g 2 r ⌉ \lceil log_2r\rceil log2r

置换-选择排序

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

使用置换-选择排序,可以让每个初始归并段的长度超越内存工作区大小的限制

最佳归并树

重要结论:归并过程中磁盘I/O次数 = 归并树的WPL(weight path length)*2

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

多路归并的最佳归并树

k路归并也是用哈夫曼树,每层k个结点。

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

补充“虚段”:

  1. 若(初始归并段数量-1) % (k-1) = 0,说明刚好可以构成严格k叉树,此时不需要添加虚段;
  2. 若(初始归并段数量-1) % (k-1)= u ≠ \ne = 0,则需要补充(k-1)- u 个虚段。

猜你喜欢

转载自blog.csdn.net/weixin_42731543/article/details/106590947