Chiriri's blog Chiriri's blog
首页
  • Java

    • JavaSE
    • JavaEE
    • 设计模式
  • Python

    • Python
    • Python模块
    • 机器学习
  • Golang

    • Golang
    • gRPC
  • 服务器

    • Linux
    • MySQL
    • NoSQL
    • Kubernetes
  • 项目

    • 传智健康
    • 畅购商城
  • Hadoop生态

    • Hadoop
    • Zookeeper
    • Hive
    • Flume
    • Kafka
    • Azkaban
    • Hbase
    • Scala
    • Spark
    • Flink
  • 大数据项目

    • 离线数仓
  • 青训营

    • 第四届青训营
  • HTML

    • HTML
    • JavaScript
  • Vue

    • Vue2
    • TypeScript
    • Vue3
    • Uni-APP
  • 数据结构与算法
  • C语言
  • 考研数据结构
  • 计算机组成原理
  • 计算机操作系统
  • Java基础

    • Java基础
    • Java集合
    • JUC
    • JVM
  • 框架

    • Spring
    • Dubbo
    • Spring Cloud
  • 数据库

    • MySQL
    • Redis
    • Elasticesearch
  • 消息队列

    • RabbitMQ
    • RocketMQ
  • 408

    • 计算机网络
    • 操作系统
    • 算法
  • 分类
  • 标签
  • 归档
  • 导航站
GitHub (opens new window)

Iekr

苦逼后端开发
首页
  • Java

    • JavaSE
    • JavaEE
    • 设计模式
  • Python

    • Python
    • Python模块
    • 机器学习
  • Golang

    • Golang
    • gRPC
  • 服务器

    • Linux
    • MySQL
    • NoSQL
    • Kubernetes
  • 项目

    • 传智健康
    • 畅购商城
  • Hadoop生态

    • Hadoop
    • Zookeeper
    • Hive
    • Flume
    • Kafka
    • Azkaban
    • Hbase
    • Scala
    • Spark
    • Flink
  • 大数据项目

    • 离线数仓
  • 青训营

    • 第四届青训营
  • HTML

    • HTML
    • JavaScript
  • Vue

    • Vue2
    • TypeScript
    • Vue3
    • Uni-APP
  • 数据结构与算法
  • C语言
  • 考研数据结构
  • 计算机组成原理
  • 计算机操作系统
  • Java基础

    • Java基础
    • Java集合
    • JUC
    • JVM
  • 框架

    • Spring
    • Dubbo
    • Spring Cloud
  • 数据库

    • MySQL
    • Redis
    • Elasticesearch
  • 消息队列

    • RabbitMQ
    • RocketMQ
  • 408

    • 计算机网络
    • 操作系统
    • 算法
  • 分类
  • 标签
  • 归档
  • 导航站
GitHub (opens new window)
  • 数据结构

  • 数据结构与算法

    • 绪论
    • 线性表
    • 栈和队列
    • 串
    • 树与二叉树
    • 图
    • 查找
    • 排序
      • 排序基本概念
        • 排序算法的评价指标
        • 分类
        • 总结
      • 插入排序
        • 算法实现
        • 算法效率分析
        • 优化——折半插入排序
        • 对链表进行插入排序
        • 总结
      • 希尔排序
        • 算法实现
        • 算法性能分析
        • 总结
      • 冒泡排序
        • 算法实现
        • 算法性能分析
        • 总结
      • 快速排序
        • 算法实现
        • 算法效率分析
        • 最坏的情况
        • 比较好的情况
        • 总结
      • 简单选择排序
        • 算法实现
        • 算法性能分析
        • 总结
      • 堆排序
        • 建立大根堆
        • 代码实现
        • 基于大根堆进行排序
        • 代码实现
        • 算法效率分析
        • 稳定性
        • 总结
      • 堆插入删除
        • 在堆中插入新元素
        • 在堆中删除元素
        • 总结
      • 归并排序
        • 2路归并
        • 4路归并
        • 归并排序(手算模拟)
        • 代码实现
        • 算法效率分析
        • 总结
      • 基数排序
        • 算法效率分析
        • 稳定性
        • 基数排序的应用
        • 总结
      • 外部排序
        • 外存、内存之间的数据交换
        • 外部排序原理
        • 构造初始“归并段”
        • 第⼀趟归并
        • 第⼆趟归并
        • 第三趟归并
        • 时间开销分析
        • 优化-多路归并
        • 优化-减少初始归并段数量
        • 总结
        • 纠正:什么是多路平衡归并?
      • 败者树
        • 多路平衡归并带来的问题
        • 败者树
        • 败者树在多路平衡归并中的应用
        • 败者树的实现思路
        • 总结
      • 置换-选择排序
        • 土办法构造初始归并段
        • 置换-选择排序
        • 总结
      • 最佳归并树
        • 归并树的神秘性质
        • 构造2路归并的最佳归并树
        • 多路归并的情况
        • 多路归并的最佳归并树
        • 如果减少⼀个归并段
        • 正确的做法
        • 添加虚段的数量
        • 总结
  • 计算机组成原理

  • 操作系统

  • 408
  • 数据结构与算法
Iekr
2022-12-19
目录

排序

# 排序

# 排序基本概念

排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

image-20221219221717565

# 排序算法的评价指标

  • 时间复杂度

  • 空间复杂度

  • 算法的稳定性。 若待排序表中有两个元素RI 和 R j ,其对应的关键字相同即keyi=keyj ,且在排序 前RI 在Rj 的前⾯ 若使用某⼀排序算法排序后,RI 仍然在Rj 的前⾯,则称这个排序算法是稳定 的,否则称排序算法是不稳定的。

image-20221219221907953

排序类型 时间复杂度 空间复杂度 是否稳定
最好情况 平均情况 最坏情况
直接插入排序 O(n) O(n2) O(n2) O(1) 是
冒泡排序 O(n) O(n2) O(n2) O(1) 是
希尔排序 O(1) 否
快速排序 O(nlog2n) O(nlog2n) O(n2) O(log2n) 否
堆排序 O(nlog2n) O(nlog2n) O(nlog2n) O(1) 否
归并排序 O(nlog2n) O(nlog2n) O(nlog2n) O(n) 是
基数排序 O(d(n+r)) O(d(n+r)) O(d(n+r)) O(r) 是

# 分类

image-20221219222146234

# 总结

image-20221219222242853

可视化算法 (opens new window)

# 插入排序

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

image-20221219222544760

image-20221219222551294

image-20221219222557346

image-20221219222604477

image-20221219222612224

image-20221219222627160

image-20221219222633198

image-20221219222639921

image-20221219222647436

image-20221219222730113

image-20221219222740805

image-20221219222748969

image-20221219222754890

image-20221219222801515

image-20221219222811180

image-20221219222818083

以此类推,直到全部记录插入完成

image-20221219222844015

# 算法实现

//直接插入排序
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; //复制到插入位置
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

image-20221219223429793

//直接插入排序(带哨兵)
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]; //复制到插入位置
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13

image-20221219223752479

# 算法效率分析

空间复杂度:O(1)

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

最好情况:共 n-1 趟处理,每⼀趟只需要对比关键字 1 次,不用移动元素,最好时间复杂度 —— O(n)

最坏情况:第 i 趟:对比关键字 i+1 次,移动元素 i+2 次,最坏时间复杂度 ——O(n2)


空间复杂度:O(1) 最好时间复杂度(全部有序):O(n) 最坏时间复杂度(全部逆序):O(n2) 平均时间复杂度:O(n2)

算法稳定性:稳定

# 优化 —— 折半插入排序

思路:先用折半查找前面已经有序的元素找到应该插入的位置,再移动元素

image-20221219224157506

image-20221219224205863

image-20221219224219763

image-20221219224228392

当 low>high 时折半查找停止,应将 [low, i-1] 内的元素全部右移,并将 A [0] 复制到 low 所指位置

image-20221219224248943

image-20221219224300896

如果遇到相同元素呢?

image-20221219224406715

image-20221219224417910

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

image-20221219224432540

image-20221219224444328

当 low>high 时折半查找停止,应将 [low, i-1] 内的元素全部右移,并将 A [0] 复制到 low 所指位置

image-20221219224503249

image-20221219224515794

//折半插入排序
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]; //插入操作
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 当 low>high 时折半查找停止,应将 [low, i-1] 内的元素全部右移,并将 A [0] 复制到 low 所指位置
  • 当 A [mid]==A [0] 时,为了保证算法的 “稳定性”,应继续在 mid 所指位置右边寻找插入位置

比起 “直接插入排序”,比较关键字的次数减少了,但是移动元素的次数没变,整体来看时间复杂度依然是O(n2)

# 对链表进行插入排序

image-20221219225208771

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

# 总结

image-20221219225239998

# 希尔排序

希尔排序:先追求表中元素部分有序,再逐渐逼近全局有序; 先将待排序表分割成若⼲形如 L[i,i+d,i+2d,…,i+kd] 的 “特殊” 子表,对各个子表分别进行直接插入排序。缩小增量 d,重复上述过程,直到 d=1 为止。

image-20221220043750324

image-20221220043841803

image-20221220043903056

image-20221220043930738

image-20221220043943253

image-20221220044003974

image-20221220044015302

image-20221220044030413

image-20221220044038819

image-20221220044044623

image-20221220044051776

image-20221220044100665

整个表已呈现出 “基本有序”,对整体再进行⼀次 “直接插入排序”

image-20221220044123783

image-20221220044130587

整合,希尔本⼈建议:每次将增量缩小⼀半

image-20221220044143039

也可以将增量缩小其他值,,考试中可能遇到各种增量

image-20221220044334848

image-20221220044348479

image-20221220044403366

image-20221220044714564

image-20221220044724082

image-20221220044740547

image-20221220044814281

image-20221220044822889

image-20221220044836756

# 算法实现

//希尔排序
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[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]; //插入
			}
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 算法性能分析

image-20221220050420363

时间复杂度:和增量序列 $d_1 , d_2 , d_3 … 的选择有关,目前⽆法用数学手段证明确切的时间复杂度最坏时间复杂度为的选择有关,目前∗∗⽆法用数学手段证明确切的时间复杂度∗∗最坏时间复杂度为 O (n^2),当在某个范围内时,可达,当n在某个范围内时,可达 O (n^{1.3} )$

image-20221220050550634

稳定性:不稳定!

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

# 总结

image-20221220050612274

# 冒泡排序

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

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

image-20221220050723709

image-20221220050737328

image-20221220050748078

image-20221220050755363

image-20221220050806869

image-20221220050813091

image-20221220050840019

image-20221220050852428

image-20221220050902974

image-20221220050915870

image-20221220050925832

image-20221220050932280

image-20221220050943185

image-20221220050950518

image-20221220050958123

image-20221220051005091

以此类推,直到某⼀趟排序没有发⽣ “交换”

image-20221220051130835

image-20221220051149398

# 算法实现

//交换
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]) { //若为逆序,只有A[j-1]>A[j]时才交换,因此算法是稳定的
				swap(A[j - 1], A[j]); //交换
				flag = true;
			}
		}
		if (flag == false) {
			return; //本趟遍历后没有发生交换,说明表已经有序
		}
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

image-20221220052831183

# 算法性能分析

空间复杂度:O(1)

最好情况(有序):比较次数 = n-1;交换次数 = 0 最好时间复杂度 =O(n)

最坏情况(逆序):比较次数=(n−1)+(n−2)+…+1=n(n−1)2= 交换次数

最坏时间复杂度 =O(n2)

平均时间复杂度 =O(n2)

# 总结

image-20221220053034099

# 快速排序

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

image-20221220053222781

以 49 作为枢轴(或基准)

1c077c43-aeed-46b8-a241-f270dc13c9af

image-20221220053630189

image-20221220053609730

image-20221220053653051

image-20221220053709353

image-20221220053723159

image-20221220053732092

image-20221220053741382

image-20221220053753103

image-20221220053804152

image-20221220053814743

image-20221220053827096

image-20221220053842669

以 49 为基准,将左右拆分为两个子表,重复上述步骤

image-20221220054105875

image-20221220054121103

image-20221220054129255

image-20221220054138818

image-20221220054146480

image-20221220054156984

image-20221220054204720

右子表

image-20221220054227395

image-20221220054242518

image-20221220054301627

image-20221220054312875

image-20221220054323506

6ace6296-e616-4ee6-9661-c08087e91ed9

image-20221220054405276

image-20221220054414199

image-20221220054425805

image-20221220054435959

image-20221220054446258

image-20221220054454322

# 算法实现

//快速排序
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; //返回存放枢轴的最终位置
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 算法效率分析

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

image-20221220060935518

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

image-20221220060441611

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

image-20221220061105955

最好时间复杂度 =O(nlog2n) 最坏时间复杂度 =O(n2)

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

平均时间复杂度 =O(nlog2n)

稳定性:不稳定!

# 最坏的情况

若每⼀次选中的 “枢轴” 将待排序序列划分为很不均匀的两个部分,则会导致递归深度增加,算法效率变低

若初始序列有序或逆序,则快速排序的性能最差(因为每次选择的都是最靠边的元素)

image-20221220061313307

# 比较好的情况

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

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

  1. 选头、中、尾三个位置的元素,取中间值作为枢轴元素;
  2. 随机选⼀个元素作为枢轴元素

# 总结

image-20221220061700717

# 简单选择排序

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

简单选择排序:每⼀趟在待排序元素中选取关键字最小的元素加入有序子序列

image-20221220063001104

image-20221220063009188

image-20221220063017048

image-20221220063022460

image-20221220063028319

image-20221220063034534

image-20221220063042794

image-20221220063102166

# 算法实现

//简单选择排序
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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 算法性能分析

空间复杂度:O(1)

时间复杂度:O(n2)

image-20221220063530266

稳定性:不稳定

# 总结

image-20221220063549417

# 堆排序

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

  • 若满足:$L (i)≥L (2i) 且 L (i)≥L (2i+1) $$(1 ≤ i ≤n/2 )$—— 大根堆(大顶堆)
  • 若满足:且L(i)≤L(2i)且L(i)≤L(2i+1) ()(1≤i≤n/2)—— 小根堆(小顶堆)

image-20221220063653332

image-20221220063801579

image-20221220063932911

# 建立大根堆

大根堆:根≥左、右

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

在顺序存储的完全⼆叉树中,非终端结点编号 i≤⌊n/2⌋

image-20221220065153565

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

image-20221220065203999

image-20221220065213535

image-20221220065222441

image-20221220065231604

image-20221220065240642

image-20221220065249825

image-20221220065313355

image-20221220065357788

若元素互换破坏了下⼀级的堆,则采用相同的⽅法继续往下调整(小元素不断 “下坠”)

image-20221220065509192

image-20221220065524919

# 代码实现

//建立大根堆
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]; //被筛选结点的值放入最终位置
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

image-20221220070256196

image-20221220070620986

# 基于大根堆进行排序

堆排序:每⼀趟将堆顶元素加入有序子序列(与待排序序列中的最后⼀个元素交换)

image-20221220070620986

image-20221220070700575

image-20221220070714385

将待排序元素序列再次调整为大根堆(小元素不断 “下坠”)调用 HeadAdjust(A ,1 ,len-1)

image-20221220070847218

image-20221220070855856

image-20221220070912285

经过调整后待排序元素序列再次构成⼀个 “大根堆

image-20221220070947133

image-20221220070958939

以此类推,不断与待排序序列中的最后⼀个元素交换,并将待排序元素序列再次调整为大根堆

image-20221220071209862

注意:基于 “大根堆” 的堆排序得到 “递增序列”

# 代码实现

//建立大根堆
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 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); //把剩下的待排序元素整理成堆
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 算法效率分析

建堆过程

image-20221220071742385

image-20221220071754639

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

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

image-20221220071847781


排序过程

总共需要n−1 趟,每⼀趟交换后都需要将根节点 “下坠” 调整

根节点最多 “下坠” h-1 层,每下坠⼀层

⽽每 “下坠” ⼀层,最多只需对比关键字 2 次,因此每⼀趟排序复杂度不超过 O(h)=O(log2n)

共 $n-1 趟,总的时间复杂度趟,总的时间复杂度 = O (nlog_2 n)$

堆排序的时间复杂度 =O(n)+O(nlog2n)=O(nlog2n) 堆排序的空间复杂度 =O(1)

# 稳定性

image-20221220072250012

if (i < len && A[i] < A[i + 1]) {
    i++; //取key较大的子结点下标
}
1
2
3

注意:若左右孩子⼀样大,则优先和左孩子交换

image-20221220072354678

image-20221220072422813

结论:堆排序是不稳定的

# 总结

image-20221220072514576

# 堆插入删除

# 在堆中插入新元素

对于小根堆,新元素放到表尾,与⽗节点对比,若新元素比⽗节点更小,则将⼆者互换。新元素就这样⼀路 “上升”,直到⽆法继续上升为止

image-20221220075733836

插入 13 元素

image-20221220075753313

image-20221220075836556

image-20221220075930005

对比关键字的次数 = 3 次

# 在堆中删除元素

image-20221220080024748

删除 13 元素

image-20221220080039044

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

image-20221220080101408

image-20221220080116504

image-20221220080139754

对比关键字的次数 = 4 次

# 总结

image-20221220080356932

# 归并排序

归并:把两个或多个已经有序的序列合并成⼀个

image-20221220215118074

image-20221220215139214

image-20221220215147514

image-20221220215208553

image-20221220215216036

image-20221220215223975

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

image-20221220215236086

image-20221220215251640

# 2 路归并

归并:把两个或多个已经有序的序列合并成⼀个

“2 路” 归并 —— 每选出⼀个小元素注需对比关键字 1 次

image-20221220215431968

# 4 路归并

“4 路” 归并 —— 每选出⼀个小元素注需对比关键字 3 次

51590607-accb-4626-8f65-65c19a1d013d

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

# 归并排序(手算模拟)

在内部排序中⼀般采用 2 路归并

核⼼操作:把数组内的两个有序序列归并为⼀个

image-20221220215602195

# 代码实现

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); //对右半部分归并排序
		MergeSort(A, mid, high); //归并
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

下面是一趟合并的过程

image-20221220220418849

image-20221220220517295

image-20221220220606145

image-20221220220631491

image-20221220220644094

image-20221220220742567

image-20221220220758456

image-20221220220821939

image-20221220220842394

image-20221220220858954

image-20221220220911421

没有归并完的部分复制到尾部

image-20221220220940110

image-20221220221000834

image-20221220221016363

# 算法效率分析

2 路归并的 “归并树”—— 形态上就是⼀棵倒立的⼆叉树

image-20221220221446192

⼆叉树的第 h 层最多有 2h−1 个结点,若树⾼为 h,则应满足n≤2h−1,即h−1=⌈log2n⌉

结论:n 个元素进行 2 路归并排序,归并趟数⌈log2n⌉

每趟归并时间复杂度为O(n) ,则算法时间复杂度为 O(nlog2n),空间复杂度=O(n) ,来⾃于辅助数组 B

# 总结

image-20221220221739496

# 基数排序

第⼀趟:以 “个位” 进行 “分配”

image-20221220225014960

image-20221220225026359

image-20221220225040556

image-20221220225058087

第⼀趟 “收集”

image-20221220225126700

image-20221220225140343

image-20221220225147382

image-20221220225156505

第⼀趟 “收集” 结束:得到按 “个位” 递减排序的序列

image-20221220225213054

第⼆趟:以 “⼗位” 进行 “分配”

image-20221220225245394

image-20221220225253801

image-20221220225301137

image-20221220225313592

第⼆趟 “收集”:

image-20221220225330368

image-20221220225347344

image-20221220225354434

第⼆趟 “收集” 结束:得到按 “⼗位” 递减排序的序列,“⼗位” 相同的按 “个位” 递减排序

image-20221220225402794

image-20221220225432816

image-20221220225439521

image-20221220225446923

image-20221220225502101

第三趟 “收集”

image-20221220225514631

image-20221220225522165

image-20221220225529283

第三趟按 “百位” 分配、收集:得到⼀个按 “百位” 递减排列的序列,若 “百位” 相同则按 “⼗位” 递减排列,若 “⼗位” 还相同则按 “个位” 递减排列

image-20221220225544507


image-20221220225615729

image-20221220225757414

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

  • 初始化: 设置 r 个空队列,Qr−1,Qr−2,…,Q0
  • 按照各个 关键字位 权重递增的次序(个、⼗、百),对 d 个关键字位分别做 “分配” 和 “收集”
  • 分配:顺序扫描各个元素,若当前处理的关键字位 = x,则将元素插入 Qx 队尾
  • 收集:把 Qr−1,Qr−2,…,Q0 各个队列中的结点依次出队并链接

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

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

  • 初始化: 设置 r 个空队列,Q0,Q1,…,Qr−1
  • 按照各个 关键字位 权重递增的次序(个、⼗、百),对 d 个关键字位分别做 “分配” 和 “收集”
  • 分配:顺序扫描各个元素,若当前处理的关键字位 = x,则将元素插入 Qx 队尾
  • 收集:把 Q0,Q1,…,Qr−1 各个队列中的结点依次出队并链接

# 算法效率分析

image-20221220230145173

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

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

# 稳定性

image-20221220230348307

image-20221220230401389

image-20221220230408248

image-20221220230442507

# 基数排序的应用

image-20221220230533275

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

  1. 数据元素的关键字可以⽅便地拆分为 d 组,且 d 较小
  2. 每组关键字的取值范围不大,即 r 较小
  3. 数据元素个数 n 较大;如给⼗亿⼈的身份证号排序

# 总结

image-20221220230742764

# 外部排序

# 外存、内存之间的数据交换

操作系统以 “块” 为单位对磁盘存储空间进行管理,如:每块大小 1KB 各个磁盘块内存放着各种各样的数据

申请缓冲区

image-20221220232047650

读取块到缓冲区中,磁盘的读 / 写以 “块” 为单位数据读入内存后才能被修改修改完了还要写回磁盘

image-20221220232135297

修改缓冲区数据

image-20221220232226249

以块为单位,重新写入磁盘

image-20221220232303975

或者写入其他块

image-20221220232324965

# 外部排序原理

外部排序:数据元素太多,⽆法⼀次全部读入内存进行排序

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

image-20221220232359487

# 构造初始 “归并段”

“归并排序” 要求各个子序列有序,每次读入两个块的内容,进行内部排序后写回磁盘

image-20221220232506355

image-20221220232515819

image-20221220232531756

image-20221220232544517

image-20221220232554303

image-20221220232613161

image-20221220232827764

这样的步骤就完成了⼀个有序的 “归并段”,重复上述步骤直到将上述 16 个块完成内部有序。

image-20221220232846214

# 第⼀趟归并

把 8 个有序子序列(初始归并段)两两归并,将两个有序归并段归并为⼀个

image-20221220232941824

image-20221220232959641

image-20221220233027837

写回外存

image-20221220233052417

image-20221220233131997

缓冲区 1 空了就要立即用归并段 1 的下⼀块补上

image-20221220233150786

image-20221220233207020

image-20221220233218689

缓冲区 2 空了就要立即用归并段 2 的下⼀块补上

image-20221220233251664

image-20221220233303493

image-20221220233311468

image-20221220233324278

image-20221220233332010

image-20221220233338968

image-20221220233419947

重复上述步骤两两归并,指定将八个归并段完成两两内部有序

image-20221220233531513

# 第⼆趟归并

把 4 个有序子序列(归并段)两两归并

image-20221220233615427

image-20221220233646326

image-20221220233658815

image-20221220233705094

image-20221220233712712

image-20221220233721970

image-20221220233751597

image-20221220233758393

image-20221220233805774

image-20221220233819741

image-20221220233832831

image-20221220233842917

不断重复读取归并写入,指定将两个归并段合成为一个有序的归并段

注意:虽然我们图中外存写入位置是原位置,但在计算机底层中我们是开辟了另外的空间写入,而原先空间归还给系统,具体底层在计算机操作系统课程中。

image-20221220234003250

image-20221220234015690

image-20221220234228068

# 第三趟归并

把 2 个有序子序列(归并段)归并

image-20221220234254860

image-20221220234312345

# 时间开销分析

image-20221220234344089

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

# 优化 - 多路归并

image-20221220234440155

image-20221220234450233

image-20221220234513606

image-20221220234556390

image-20221220234540006

image-20221220234611998

image-20221220234635817

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

image-20221220234645920

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

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

# 优化 - 减少初始归并段数量

image-20221220234919506

image-20221220234926922

image-20221220234941545

image-20221220234956830

⽣成初始归并段的 “内存⼯作区” 越大,初始归并段越长

image-20221220235026937

image-20221220235043696

结论:若能增加初始归并段的长度,则可减少初始归并段数量 r

# 总结

image-20221220235111330

# 纠正:什么是多路平衡归并?

image-20221221010241069

k 路平衡归并:

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

image-20221221010312394

# 败者树

# 多路平衡归并带来的问题

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

归并趟数S=⌈logkr⌉ ,归并路数 k 增加,归并趟数 S 减小,读写磁盘总次数减少,归并路数 k 增加,归并趟数 S 减小,读写磁盘总次数减少

使用 k 路平衡归并策略,选出⼀个最小元素需要对比关键字 (k-1)次,导致内部归并所需时间增加

eg:8 路平衡归并,从⼋个归并段中选出⼀个最小元素需要对比关键字 7 次

image-20221221010513742

# 败者树

失败者留在这⼀回合,胜利者进入下⼀回合比拼

image-20221221010628647

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

image-20221221010708948

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

image-20221221010914908

image-20221221010859601

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

image-20221221011028160

选出当前最小值 1

image-20221221011055337

头头结点记录的是归并段 3,所以下一个元素从归并段 3 中选出,并替代原来 1 位置的叶子节点

image-20221221011242218

重新对比

image-20221221011319727

更新败者树中的归并段

image-20221221011416421

有了败者树,选出最小元素,只需对比关键字⌈log2k⌉ 次

image-20221221011514617

# 败者树的实现思路

image-20221221011647352

image-20221221011716914

# 总结

败者树解决的问题:使用多路平衡归并可减少归并趟数,但是用⽼土⽅法从 k 个归并段选出⼀个最小 / 最大元素需要对比关键字 k-1 次,构造败者树可以使关键字对比次数减少到 ⌈log2k⌉

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

# 置换 - 选择排序

# 土办法构造初始归并段

image-20221221013427055

image-20221221013434771

image-20221221013444882

image-20221221013452011

image-20221221013513406

image-20221221013524113

image-20221221013543255

image-20221221013557638

image-20221221013624775

# 置换 - 选择排序

image-20221221013715955

将当前内存工作区最小的元素置换出去,并用变量 MINMAX 记录值

image-20221221013746084

image-20221221013808940

从带排序文件中读取

image-20221221014454858

image-20221221014502422

image-20221221014532408

image-20221221014545861

以此类推,重复读取代排序文件中的元素放入内存工作区,置换最小元素并更新 MINMAX

当出现当前内存工作区中最小元素比 MINMAX 更小的情况,不能把 10 置换出去,只能选择比 MINMAX 大并在内存工作区考前的元素(即 14)

image-20221221014729647

image-20221221014845988

image-20221221014856261

image-20221221014926379

image-20221221014941209

image-20221221014948353

image-20221221014956638

若 WA 内的关键字都比 MINIMAX 更小,则该归并段在此截止

image-20221221015011477

image-20221221015024420

重复上述步骤,直到 WA 内的关键字都比 MINMAX 更小,然后截取归并段。

image-20221221015114485

# 总结

设初始待排⽂件为 FI,初始归并段输出⽂件为 FO,内存⼯作区为 WA,FO 和 WA 的初始状态为空,WA 可容纳 w 个记录。置换 - 选择算法的步骤如下:

  1. 从 FI 输入 w 个记录到⼯作区 WA。
  2. 从 WA 中选出其中关键字取最小值的记录,记为 MINIMAX 记录。
  3. 将 MINIMAX 记录输出到 FO 中去。
  4. 若 FI 不空,则从 FI 输入下⼀个记录到 WA 中。
  5. 从 WA 中所有关键字比 MINIMAX 记录的关键字大的记录中选出最小关键字记录,作为新的 MINIMAX 记录。
  6. 重复 3~5,直⾄在 WA 中选不出新的 MINIMAX 记录为止,由此得到⼀个初始归并段,输出⼀个归并段的结束标志到 FO 中去。
  7. 重复 2~6,直⾄ WA 为空。由此得到全部初始归并段。

# 最佳归并树

# 归并树的神秘性质

image-20221221015739182

image-20221221015816280

每个初始归并段看作⼀个叶子结点,归并段的长度作为结点权值,则这棵归并树的带权路径长度 WPL=2∗1+(5+1+6+2)∗3=44= 读磁盘的次数 = 写磁盘的次数

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

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

# 构造 2 路归并的最佳归并树

image-20221221020133500

最佳归并树 WPLmin=(1+2)∗4+2∗3+5∗2+6∗1=34

读磁盘次数 = 写磁盘次数 = 34 次;总的磁盘 I/O 次数 = 68

# 多路归并的情况

image-20221221020229119

WPL=(9+30+12+18+3+17+2+6+24)∗2=242

归并过程中 磁盘 I/O 总次数 = 484 次

# 多路归并的最佳归并树

image-20221221020423460

image-20221221020315870

上图这棵就是 3 路归并的最佳归并树

WPLmin=(2+3+6)∗3+(9+12+17+24+18)∗2+30∗1=223

归并过程中 磁盘 I/O 总次数 = 446 次

# 如果减少⼀个归并段

image-20221221020519957

image-20221221020558492

上图这个不是最佳归并树

WPL=(2+3+6)∗3+(9+12+17+24+18)∗2=193

归并过程中 磁盘 I/O 总次数 = 386 次

# 正确的做法

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

image-20221221020706780

image-20221221020723539

上图这棵就是 3 路归并的最佳归并树

WPLmin=(2+3+0)∗3+(6+9+12+17+18)∗2+24∗1=163

归并过程中 磁盘 I/O 总次数 = 326 次

# 添加虚段的数量

k 叉的最佳归并树⼀定是⼀棵严格的 k 叉树,即树中只包含度为 k、度为 0 的结点。

设度为 k 的结点有 $ n_k$ 个,度为 0 的结点有 n0 个 ,归并树总结点数 = n 则:

初始归并段数量 + 虚段数量 =n0

image-20221221020923359

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

# 总结

image-20221221021020294

编辑 (opens new window)
上次更新: 2023/12/06, 01:31:48
查找
计算机系统概述

← 查找 计算机系统概述→

最近更新
01
k8s
06-06
02
进程与线程
03-04
03
计算机操作系统概述
02-26
更多文章>
Theme by Vdoing | Copyright © 2022-2025 Iekr | Blog
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式