第一章 绪论
1.1 什么是数据结构
1.1.1 数据结构的定义
数据是描述客观事物的数、字符以及所有能输入计算机中并被计算机程序处理的符号的集合。例如,日常生活中使用的各种文字、数字和特定符号都是数据。从计算机的角度看,数据是所有能被输入计算机中且能被计算机处理的符号的集合。它是计算机操作的对象的总称,也是计算机处理的信息的某种特定的符号表示形式。
通常以数据元素作为数据的基本单位(例如,A班中的每个学生记录都是一个数据元素),也就是说数据元素是组成数据的有一定意义的基本单位,在计算机中通常作为整体处理,有些情况下数据元素也称为元素、结点、记录等。有时候,一个数据元素可以由若干个数据项组成。数据项是具有独立含义的数据最小单位,也称为域[例如,A班中的每个数据元素(学生记录)是由学号、姓名、性别和班号等数据项组成]。
数据对象是性质相同的有限个数据元素的集合,它是数据的一个子集,例如大写字母数据对象是集合 C = {‘A’, ‘B’, ‘C’, …, ‘Z’};1~100 的整数数据对象是集合 N = {1, 2, …, 100}。在默认情况下,数据结构中的数据都是指的数据对象。
数据结构是指所涉及的数据元素的集合以及数据元素之间的关系,由数据元素之间的关系构成结构,因此可把数据结构看成是带结构的数据元素的集合。数据结构包括如下几个方面:
- 数据元素之间的逻辑关系,即数据的逻辑结构,它是数据结构在用户面前呈现的形式。
- 数据元素及其关系在计算机存储器中的存储方式,即数据的存储结构,也称为数据的物理结构。
- 施加在该数据上的操作,即数据的运算。
数据的逻辑结构是从逻辑关系(主要是指数据元素的相邻关系)上描述数据的,它与数据的存储无关,是独立于计算机的,因此数据的逻辑结构可以看作是从具体问题抽象出来的数学模型。
1.1.2 数据的逻辑结构
-
逻辑结构的表示
为了更通用地描述数据的逻辑结构,通常采用二元组表示数据的逻辑结构,一个二元组如下:
其中 B 是一种逻辑数据结构,D 是数据元素的集合,在 D 上数据元素之间可能存在多种关系,R 是所有关系的集合,即
其中 dᵢ 表示集合 D 中的第 i (0≤i≤n-1) 个数据元素(或结点),n 为 D 中数据元素的个数,特别地,若 n=0,则 D 是一个空集,此时 B 也就无结构可言;rⱼ (1≤j≤m) 表示集合 R 中的第 j 个关系,m 为 R 中关系的个数,特别地,若 m=0,则 R 是一个空集,表明集合 D 中的数据元素间不存在任何关系,彼此是独立的,这和数学中集合的概念是一致的。
说明: 为了方便起见,数据结构中元素的逻辑序号统一从 0 开始。
R 中的某个关系 rⱼ (1≤j≤m) 是序偶的集合,对于 rⱼ 中的任一序偶 <x, y> (x, y ∈ D),把 x 叫作序偶的第一元素,把 y 叫作序偶的第二元素,又称序偶的第一元素为第二元素的前驱元素,称第二元素为第一元素的后继元素。例如在 <x, y> 的序偶中,x 为 y 的前驱元素,而 y 为 x 的后继元素。
若某个元素没有前驱元素,则称该元素为开始元素;若某个元素没有后继元素,则称该元素为终端元素。
对于对称序偶,满足这样的条件:若 <x, y> ∈ r (r ∈ R),则 <y, x> ∈ r (x, y ∈ D),可用圆括号代替尖括号,即 (x, y) ∈ r。
对于 D 中的每个数据元素,通常用一个关键字来唯一标识,例如高等数学成绩表中学生成绩记录的关键字为学号。
示例:全国部分城市交通图(假设城市名为关键字)的二元组表示如下。
-
逻辑结构的类型
在现实生活中数据呈现不同类型的逻辑结构,归纳起来数据的逻辑结构主要分为以下类型。
(1)集合:结构中的数据元素之间除了“同属于一个集合”的关系外没有其他关系,与数学中集合的概念相同。
(2)线性结构:若结构是非空的,则有且仅有一个开始元素和终端元素,并且所有元素最多只有一个前驱元素和一个后继元素。
高等数学成绩表中,每一行为一个学生成绩记录(或成绩元素),其逻辑结构特性是只有一个开始记录(比如姓名为王华的记录)和一个终端记录(也称为尾记录,比如姓名为李英的记录),其余每个记录只有一个前驱记录和一个后继记录,也就是说记录之间存在一对一的关系,其逻辑结构特性为线性结构。
(3)树形结构:若结构是非空的,则有且仅有一个元素为开始元素(也称为根结点),可以有多个终端元素,每个元素有零个或多个后继元素,除开始元素外每个元素有且仅有一个前驱元素。
某大学组织结构图的逻辑结构特性是只有一个开始结点(即大学名称结点),有若干个终端结点(例如科学系等),每个结点有零个或多个下级结点,也就是说结点之间存在一对多的关系,其逻辑结构特性为树形结构。
(4)图形结构:若结构是非空的,则每个元素可以有多个前驱元素和多个后继元素。
全国部分城市交通线路图的逻辑结构特性是每个结点和一个或多个结点相连,也就是说结点之间存在多对多的关系,其逻辑结构特性为图形结构。
1.1.3 数据的存储结构
1. 顺序存储结构
顺序存储结构是把逻辑上相邻的元素存储在物理位置上相邻的存储单元里,元素之间的逻辑关系由存储单元的邻接关系来体现(称为直接映射)。通常顺序存储结构是借助于计算机程序设计语言(例如 Java、C/C++语言等)的数组来实现。
顺序存储方法的主要优点是节省存储空间,因为分配给数据的存储单元全部用于存放元素值,元素之间的逻辑关系没有占用额外的存储空间。在采用这种方法时,可实现对结点的随机存取,即每个元素对应一个序号,由该序号可直接计算出元素的存储地址。顺序存储方法的主要缺点是初始空间大小难以确定,插入和删除操作需要移动较多的元素。
2. 链式存储结构
链式存储结构中每个逻辑元素用一个结点存储,不要求逻辑上相邻的元素在物理位置上也相邻,元素间的逻辑关系用附加的指针域来表示。由此得到的存储表示称为链式存储结构,它通常要借助于计算机程序设计语言的指针(或者引用)来实现。
链式存储方法的主要优点是便于进行插入和删除操作,实现这些操作仅需要修改相应结点的指针字段,不必移动结点。与顺序存储方法相比,链式存储方法的主要缺点是存储空间的利用率较低,因为分配给数据的存储单元有一部分被用来存储元素之间的逻辑关系。另外,由于逻辑上相邻的元素在存储空间中不一定相邻,所以链式存储方法不能对元素进行随机存取。
3. 索引存储结构
索引存储结构通常是在存储元素信息的同时还建立附加的索引表。索引表中的每一项称为索引项,索引项的一般形式是(关键字,地址),关键字唯一标识一个元素,索引表按关键字有序排列,地址作为指向元素的指针。这种带有索引表的存储结构可以大大提高数据查找的速度。
线性结构采用索引存储方法后可以对元素进行随机访问,在进行插入、删除运算时只需移动在索引表中对应元素的存储地址,而不必移动存放在元素表中的数据,所以仍保持较高的数据修改运算效率。索引存储方法的缺点是增加了索引表,降低了存储空间的利用率。
4. 哈希(或散列)存储结构
哈希存储结构的基本思想是根据元素的关键字通过哈希函数直接计算出一个值,并将这个值作为该元素的存储地址。
哈希存储方法的优点是查找速度快,只要给出待查元素的关键字就可以立即计算出该元素的存储地址。与前 3 种存储方法不同的是,哈希存储方法只存储元素的数据,不存储元素之间的逻辑关系。哈希存储方法一般只适用于要求对数据能够进行快速查找和插入的场合。
1.1.4 数据的运算
将数据存放在计算机中的目的是为了实现一种或多种运算。运算包括功能描述(或运算功能)和功能实现(或运算实现),前者是基于逻辑结构的,是用户定义的,是抽象的;后者是基于存储结构的,是程序员用计算机语言或伪码表示的,是详细的过程,其核心是设计实现某一运算功能的处理步骤,即算法设计。
1.1.5 数据结构和数据类型
-
数据类型
数据类型是一组性质相同的值的集合和定义在此集合上的一组操作的总称。
例如在高级语言中已实现的或非高级语言直接支持的数据结构即为数据类型。在程序设计语言中,一个变量的数据类型不仅规定了这个变量的取值范围,而且定义了这个变量可用的运算。
总之,数据结构是指计算机处理的数据元素的组织形式和相互关系,而数据类型是某种程序设计语言中已实现的数据结构。在程序设计语言提供的数据类型的支持下,可以根据从问题中抽象出来的各种数据模型逐步构造出描述这些数据模型的各种新的数据结构。
-
抽象数据类型
抽象数据类型 (Abstract Data Type, ADT) 指的是用户进行软件系统设计时从问题的数学模型中抽象出来的逻辑数据结构和逻辑数据结构上的运算,而不考虑计算机的具体存储结构和运算的具体实现算法。抽象数据类型中的数据对象和数据运算的声明与数据对象的表示和数据运算的实现相互分离,也称为抽象模型。
“抽象”意味着应该从与实现方法无关的角度研究数据结构,只关心数据结构做什么,而不是如何实现。但是在程序中使用数据结构之前必须提供实现方法,并且还要关心运算的执行效率。
一个具体问题的抽象数据类型的定义通常采用简洁、严谨的文字描述,一般包括数据对象(即数据元素的集合)、数据关系和基本运算 3 个方面的内容。抽象数据类型可用 (D, S, P) 三元组表示,其中 D 是数据对象,S 是 D 上的关系集,P 是 D 中数据运算的基本运算集。其基本格式如下:
ADT 抽象数据类型名{数据对象:数据对象的声明数据关系:数据关系的声明基本运算:基本运算的声明}ADT 抽象数据类型名其中基本运算的声明格式如下:
基本运算名(参数表):运算功能描述示例:
ADT Stack{数据对象:D = {a_i | a_i ∈ ElemType, i = 1, 2, ..., n, n ≥ 0}(即栈中元素类型为 ElemType,个数 n 可以为 0)数据关系:R = {<a_{i-1}, a_i> | a_{i-1}, a_i ∈ D, i = 2, ..., n}(元素按线性顺序排列,后进先出)基本运算:InitStack(&S):初始化空栈 S。DestroyStack(&S):销毁栈 S,释放所占空间。ClearStack(&S):将栈 S 置为空栈。StackEmpty(S):若栈 S 为空返回 true,否则返回 false。StackLength(S):返回栈 S 中元素个数。GetTop(S, &e):若栈非空,用 e 返回栈顶元素。Push(&S, e):将元素 e 压入栈 S 的栈顶。Pop(&S, &e):若栈非空,删除栈顶元素,并用 e 返回其值。StackTraverse(S, visit()):从栈底到栈顶依次对每个元素调用 visit() 函数。}ADT Stack抽象数据类型有两个重要特征,即数据抽象和数据封装。所谓数据抽象,是指用 ADT 描述程序处理的实体时强调的是其本质的特征、其所能完成的功能以及它和外部用户的接口(即外界使用它的方法)。所谓数据封装,是指将实体的外部特性和其内部实现细节分离,并且对外部用户隐藏其内部实现细节。抽象数据类型需要通过固有数据类型(高级编程语言中已实现的数据类型,例如 Java 中的类)来实现。
1.2 算法及其描述
1.2.1 什么是算法
数据元素之间的关系有逻辑关系和物理关系,对应的运算有逻辑结构上的运算(抽象运算)和具体存储结构上的运算(运算实现)。算法是在具体存储结构上实现某个抽象运算。
确切地说,算法是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示计算机的一个或多个操作。所谓算法设计,就是把逻辑层面设计的接口(抽象运算)映射到实现层面具体的实现方法(算法)。
(1)有穷性:指算法在执行有限的步骤之后自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。
(2)确定性:对于每种情况下执行的操作在算法中都有确定的含义,不会出现二义性,并且在任何条件下算法都只有一条执行路径。
(3)可行性:算法的每条指令都是可执行的,即便人借助纸和笔都可以完成。
(4)输入性:算法有零个或多个输入。在大多数算法中输入参数是必要的,但对于较简单的算法,例如计算 1+2 的值,不需要任何输入参数,因此算法的输入可以是零个。
(5)输出性:算法至少有一个或多个输出。算法用于某种数据处理,如果没有输出,这样的算法是没有意义的,算法的输出是和输入有着某些特定关系的量。
1.2.2 算法描述
1.3 算法分析
1.3.1 算法设计的要求
算法设计应满足以下几个要求。
(1)正确性:要求算法能够正确地执行预先规定的功能和性能要求,这是最重要也是最基本的标准。
(2)可使用性:要求算法能够很方便地使用,这个特性也叫作用户友好性。
(3)可读性:算法应该易于人们理解,也就是可读性好。为了达到这个要求,算法的逻辑必须是清晰的、简单的和结构化的。
(4)健壮性:要求算法具有很好的容错性,即提供异常处理,能够对不合理的数据进行检查,不经常出现异常中断或死机现象。
(5)高时间性能与低存储量需求:对于同一个问题,如果有多种算法可以求解,执行时间少的算法时间性能高。算法存储量指的是算法执行过程中所需的存储空间。算法的时间性能和存储量都与问题的规模有关。
1.3.2 算法的时间性能分析
一、基本概念
- 输入规模 ( n ):通常指问题的大小,例如数组长度、图的顶点数等。
- 基本操作:算法中最耗时、重复执行的操作,如比较、赋值、加法等。
- 时间复杂度:用大 O 记号(Big-O notation)表示,描述上界(最坏情况)的增长速率。
二、分析步骤
-
确定输入规模 ( n )
例如:对长度为 ( n ) 的数组排序。
-
找出基本操作
通常是循环体内的操作,如
a[i] == key、sum += a[i]等。 -
计算基本操作的执行次数 ( T(n) )
- 单层循环(从 0 到 ( n-1 )):执行约 ( n ) 次 → ( T(n) = n )
- 双重嵌套循环(两层都到 ( n )):执行约 ( n^2 ) 次 → ( T(n) = n^2 )
- 对数循环(如
i *= 2):执行约 ( \log_2 n ) 次 - 分治算法(如归并排序):( T(n) = 2T(n/2) + n ) → 解得 ( T(n) = O(n \log n) )
-
用大 O 表示法简化 ( T(n) )
保留最高阶项,忽略常数和低阶项:
- ( T(n) = 3n^2 + 5n + 10 ) → ( O(n^2) )
- ( T(n) = 100 \log n + 20 ) → ( O(\log n) )
- ( T(n) = 5 )(与 ( n ) 无关)→ ( O(1) )
三、常见时间复杂度(从小到大)
| 复杂度 | 名称 | 示例算法 |
|---|---|---|
| O(1) | 常数时间 | 访问数组元素 |
| O(\log n) | 对数时间 | 二分查找 |
| O(n) | 线性时间 | 遍历数组 |
| O(n \log n) | 线性对数时间 | 归并排序、快速排序(平均) |
| O(n^2) | 平方时间 | 冒泡排序、两层嵌套循环 |
| O(2^n) | 指数时间 | 暴力解旅行商问题(TSP) |
| O(n!) | 阶乘时间 | 全排列生成 |
实用建议:尽量设计 ( O(n \log n) ) 或更低的算法;( O(n^2) ) 在 ( n > 10^4 ) 时可能超时;指数级通常不可行。
四、示例分析
示例 1:双重循环
for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (a[i] == a[j]) count++; // 基本操作 }}- 执行次数:( n \times n = n^2 )
- 时间复杂度:( O(n^2) )
示例 2:二分查找
while (low <= high) { mid = (low + high) / 2; if (arr[mid] == key) return mid; else if (arr[mid] < key) low = mid + 1; else high = mid - 1;}- 每次将问题规模减半 → 循环约 ( \log_2 n ) 次
- 时间复杂度:( O(\log n) )
1.3.3 算法的存储空间分析
一个算法的存储量包括形参所占空间和临时变量所占空间。在对算法进行存储空间分析时只考虑临时变量所占空间,如图 1.17 所示,其中临时空间为变量 i、maxi 占用的空间。所以,空间复杂度是对一个算法在运行过程中临时占用的存储空间大小的量度,一般也是问题规模 n 的函数,并以数量级形式给出,记作 S(n) = O(g(n)),其中“O”的含义与时间复杂度分析中的相同。若一个算法所需临时空间相对于问题规模来说是常数[或者说算法的空间复杂度为 O(1) ,则称此算法为原地工作或就地工作。

1.4 数据结构的目标
算法设计分为 3 个步骤,即通过抽象数据类型进行问题定义,设计存储结构和设计算法。由于数据存储结构会影响算法的好坏,所以设计存储结构是关键的一步,在选择存储结构时需要考虑其对算法的影响。存储结构对算法的影响主要有以下两个方面。
(1)存储结构的存储能力:如果存储结构的存储能力强、存储的信息多,算法将会方便设计,反之过于简单的存储结构可能要设计一套比较复杂的算法,往往存储能力是与所使用的空间大小成正比的。
(2)存储结构应与所选择的算法相适应:存储结构是实现算法的基础,也会影响算法的设计,其选择要充分考虑算法的各种操作,应与算法的操作相适应。
总之数据结构的目标就是针对求解问题设计好的算法。为了达到这一目标,程序人员不仅要具有较好的编程能力,还需要掌握各种常用的数据结构,例如线性表、栈和队列、二叉树和图等,这些是在后面各章中将要学习的内容。
第二章 线性表
2.1 线性表的定义
2.1.1 什么是线性表
线性表严格的定义是具有相同特性的数据元素的一个有限序列。其特征有三,一是所有数据元素类型相同;二是线性表由有限个数据元素构成;三是线性表中的数据元素与位置相关,即每个数据元素有唯一的序号(或索引),这一点表明线性表不同于集合,在线性表中可以出现值相同的数据元素(它们的序号不同),而集合中不会出现值相同的数据元素。
线性表的逻辑结构一般表示为 ,用图形表示的逻辑结构如图 2.1 所示。

说明:线性表中每个元素 的唯一位置通过序号或者索引 i 表示,为了使算法设计方便,将逻辑序号和存储序号统一,均假设从 0 开始,这样含 n 个元素的线性表的元素序号 i 满足 。
其中,用 表示线性表的长度(即线性表中数据元素的个数)。当 n=0 时表示线性表是一个空表,不包含任何数据元素。
2.1.2 线性表的抽象数据类型描述
线性表的抽象数据类型描述如下:
ADT List{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, aᵢ 为 E 类型} // E 是用户指定的类型
数据关系: r = {<aᵢ, aᵢ₊₁> | aᵢ, aᵢ₊₁ ∈ D, i = 0, ..., n-2}
基本运算: void CreateList(E[] a):由 a 数组中的全部元素建立线性表的相应存储结构。 void Add(E e):将元素 e 添加到线性表末尾。 int size():求线性表的长度。 void Setsize(int nlen):设置线性表的长度为 nlen。 E GetElem(int i):求线性表中序号为 i 的元素。 void SetElem(int i, E e):设置线性表中序号为 i 的元素值为 e。 int GetNo(E e):求线性表中第一个值为 e 的元素的序号。 void swap(int i, int j):交换线性表中序号为 i 和序号为 j 的元素。 void Insert(int i, E e):在线性表中插入数据元素 e 作为第 i 个元素。 void Delete(int i):在线性表中删除第 i 个数据元素。 String toString():将线性表转换为字符串。}2.2 线性表的顺序存储结构
2.2.1 线性表的顺序存储结构——顺序表
线性表的顺序存储结构是把线性表中的所有元素按照其逻辑顺序依次存储到从计算机存储器中指定存储位置开始的一块连续的存储空间中。线性表的顺序存储结构称为顺序表。 这里采用 Java 语言中的一维数组 data 来实现顺序表,并设定该数组的容量(存放的最多元素个数)为 capacity。线性表的长度是线性表中实际的数据元素个数,用 size 表示,随着线性表的插入和删除操作,size 是 变化的,但在任何时刻 size 都应该小于等于容量 capacity; 否则应该扩大 data 数组的容量,通常按 size 的两倍来扩大 data 数组的容量。

假设线性表的元素类型为 E,设计顺序表泛型类为 SqListClass
public class SqListClass <E>{ final int initcapacity = 10; //顺序表的初始容量(常量) public E[] data; //存放顺序表中的元素 public int size; //存放顺序表的长度 private int capacity; //存放顺序表的容量 public SqListClass() { data = (E[])new Object[initcapacity]; //构造方法,实现 data 和 length 的初始化 capacity = initcapacity; //强制转换为 E 类型数组 size = 0; } //线性表的基本运算算法}2.2.2 线性表的基本运算算法在顺序表中的实现
-
在顺序表中插入 e 作为第 i 个元素:
Insert(i, e)
对应的算法如下:
public void Insert(int i, E e){if(i < 0 || i > size)throw new IllegalArgumentException("插入:位置i不在有效范围内"); //参数错误抛出异常if(size == capacity)updatecapacity(2 * size); //满时倍增容量for(int j = size; j > i; j--)data[j] = data[j - 1]; //将 data[i] 及后面的元素后移一个位置data[i] = e; //插入元素 esize++; //顺序表的长度增 1}本算法的主要时间花在元素的移动上,元素移动的次数不仅与表长 n 有关,而且与插入位置 i 有关。有效插入位置 i 的取值是 ,共有 n + 1 个位置可以插入元素:
(1)当 i = 0 时移动次数为 n ,达到最大值。
(2)当 i = n 时移动次数为 0 ,达到最小值。
(3)其他情况需要移动 的每个元素,移动次数为 (n - 1) - i + 1 = n - i 。
假设每个位置插入元素的概率相同, 表示在第 i 个位置上插入一个元素的概率,则 ,这样在长度为 n 的线性表中插入一个元素时所需移动元素的平均次数为:
因此插入算法的平均时间复杂度为 O(n) 。
-
在线性表中删除第 i 个数据元素:
Delete(i)对应的算法如下:
public void Delete(int i){if(i < 0 || i > size - 1)throw new IllegalArgumentException("删除:位置i不在有效范围内"); //参数错误抛出异常for(int j = i; j < size - 1; j++)data[j] = data[j + 1]; //将 data[i] 之后的元素前移一个位置size--; //顺序表的长度减 1if(capacity > initcapacity && size == capacity / 4) //满足要求容量减半updatecapacity(capacity / 2);}本算法的主要时间花在元素的移动上,元素移动的次数也与表长 n 和删除元素的位置 i 有关,有效删除位置 i 的取值是 ,共有 n 个位置可以删除元素:
(1)当 i = 0 时移动次数为 n - 1,达到最大值。
(2)当 i = n - 1 时移动次数为 0,达到最小值。
(3)其他情况需要移动 data[i+1..n-1] 的每个元素,移动次数为 (n - 1) - (i + 1) + 1 = n - i - 1。
假设 表示删除第 i 个位置上元素的概率,则 ,所以在长度为 n 的线性表中删除一个元素时所需移动元素的平均次数为:
因此删除算法的平均时间复杂度为 O(n)。
2.2.3 顺序表的应用算法设计示例
-
基于整体建立顺序表的算法设计
【例 2.1】 对于含有 n 个整数元素的顺序表 L ,设计一个尽可能高效的算法删除所有相邻重复的元素,即多个相邻重复的元素仅仅保留一个,例如 L = (1, 2, 2, 2, 1) ,删除后 L = (1, 2, 1) ,并给出算法的时间复杂度和空间复杂度。
解:采用例 2.4 中解法 1 的整体创建顺序表的算法思路,首先将序号为 0 的元素插入结果顺序表中, k 表示结果顺序表中元素的个数(初始时 k = 1 ,因为开始元素一定是要保留的元素),其末尾元素的序号为 k - 1 。从 i = 1 开始扫描原 L ,若当前元素值不等于结果顺序表中的末尾元素,则它不是相邻重复的元素,将其插入结果顺序表中。最后重新设置结果顺序表的长度。
对应的算法如下:
public static void Delsame(SqListClass<Integer> L){int k = 1; // k: 左指针(= 最终有效元素个数 - 1)for(i = 1; i < L.size(); i++) // i: 右指针if(L.GetElem(i) != L.GetElem(k)) //将不是相邻重复的元素插入{L.SetElem(++k, L.GetElem(i)); // 新的有效元素, k 向右移动一位}L.Setsize(k+1); //重置长度}上述算法的时间复杂度为 O(n) ,空间复杂度均为 O(1) 。
-
有序顺序表的算法设计
【例 2.7】 一个长度为 的升序序列 S,处在第 个位置的数称为 S 的中位数。例如,若序列 ,则 的中位数是 15。两个序列的中位数是包含它们所有元素的升序序列的中位数。例如,若 ,则 和 的中位数是 11。现有两个等长的升序序列 A 和 B,设计一个在时间和空间两方面都尽可能高效的算法,找出两个序列 A 和 B 的中位数。假设两个升序序列分别用顺序表 A 和 B 存储,所有元素为整数。
解:两个升序序列分别用
SqListClass<Integer>对象 A 和 B 存储,它们的元素个数均为 n。若采用二路归并得到含 2n 个元素的升序序列 C,则其中序号为 n-1 的元素就是两个序列的中位数。实际上这里求出的中位数仅仅是一个元素,没有必要求出整个 C 中的 2n 个元素,为此用 k 累计归并元素的个数(初始为 0),当归并到第 n 个元素(此时 k == n)时,两个比较中较小的那个元素就是中位数。
对应的算法如下:
public static int Middle(SqListClass<Integer> A, SqListClass<Integer> B){int i=0, j=0, k=0; // i -> A; j -> B; k -> total stepint n = A.size(); // 只获取一次 size,因为 A 和 B 等长while(i < n && j < n) // 当前归并的元素个数增 1{k++; // 由于最终判断的是 k = n = A.size(); 所以 k 要提前+1if(A.GetElem(i) < B.GetElem(j)) // 归并 A 中较小的元素{if(k == n) // 若当前归并的元素是第 n 个元素return A.GetElem(i); // 返回 A 中的当前元素i++;}else // 归并 B 中较小的元素{if(k == n) // 若当前归并的元素是第 n 个元素return B.GetElem(j); // 返回 B 中的当前元素j++;}}return 0;}上述算法的时间复杂度为 O(n),空间复杂度为 O(1)。
2.2.4 顺序表容器——ArrayList
在 Java 中提供了列表接口 List (其中元素为对象序列),它是 Collection 接口的子接口,List 有一个重要的实现类——ArrayList 类,它采用动态数组存储对象序列,可以看成是顺序存储结构的表在 Java 语言中的实现。在实际应用中可以用 ArrayList 类对象作为顺序表,使用其提供的各种方法完成更复杂问题的求解。
1. ArrayList 类的基本应用
ArrayList 类的构造方法如下。
(1) ArrayList():构造一个初始容量为 10 的空列表。
(2) ArrayList(int initialCapacity):构造一个具有指定初始容量的空列表。
(3) ArrayList(Collection<? extends E> c):构造一个包含指定集合的元素的列表。
ArrayList 类的主要方法如下。
(1) boolean isEmpty():如果列表中不包含元素,则返回 true。
(2) int size():返回此列表中的元素数。
(3) add(E e):向列表的尾部添加指定的元素。
(4) void add(int index, E element):在列表的指定位置插入指定元素。
(5) boolean contains(Object o):如果列表中包含指定的元素,则返回 true。
(6) E get(int index):返回列表中指定位置的元素。
(7) E set(int index, E element):用指定元素替换列表中指定位置的元素。
(8) int indexOf(Object o):返回此列表中第一次出现的指定元素的索引。如果此列表中不包含该元素,则返回 -1。
(9) int lastIndexOf(Object o):返回此列表中最后出现的指定元素的索引。如果列表中不包含此元素,则返回 -1。
(10) void clear():从列表中移除所有元素。
(11) E remove(int index):移除列表中指定位置的元素。
(12) boolean remove(Object o):从此列表中移除第一次出现的指定元素(如果存在)。
2. ArrayList 类元素的排序
在许多应用中需要对顺序表元素排序,后面第 10 章专门讨论各种排序算法设计,这里从应用的角度介绍 ArrayList 类提供的几种排序方法。
若 ArrayList 对象中的元素属于 Java 基本数据类型,例如:
ArrayList<Integer> myarrlist = new ArrayList<Integer>(); //元素类型为整型
则排序方法如下。
(1) 按元素递增排序:Collections.sort(myarrlist);
(2) 按元素递减排序:Collections.sort(myarrlist, Collections.reverseOrder());
若 ArrayList 对象中的元素属于类类型,则需要指定按什么成员变量排序、按什么次序排序等,主要方式如下:
1) 设置 Comparable 排序接口
若一个类实现了 Comparable 接口,就意味着“该类支持排序”,为此重写 compareTo() 方法以定制排序方式。compareTo() 方法的用法是“当前对象的属性 . compareTo (比较对象的属性)”,若当前对象的值<比较对象的值,返回一个负整数;若当前对象的值=比较对象的值,返回 0;若当前对象的值>比较对象的值,返回一个正整数。然后调用 Collections.sort(ArrayList 对象) 进行排序。
2) 设置 Comparator 对象比较器接口
若需要定制某个类对象的排序次序,而该类本身不支持排序(即没有实现 Comparable 排序接口),可以建立一个“比较器”来进行排序。这个“比较器”只需要实现 Comparator 接口即可。其格式如下:
Collections.sort(ArrayList 对象, new Comparator<元素类> { @Override public int compare(元素类 o1, 元素类 o2) { return o1.比较属性().compareTo(o2.比较属性()); }});其中,compare(o1, o2) 方法的用法是根据第一个参数小于、等于或大于第二个参数分别返回负整数、零或正整数。
3) 调用 ArrayList 类的 sort() 方法
在 Java 8 中增加了使用 Comparator 的 comparing() 进行排序,按照 ArrayList 类对象中元素类的排序属性进行递增排序的格式如下:
ArrayList 类对象.sort(Comparator.comparing(元素类::排序属性));
按照 ArrayList 类对象中元素类的排序属性进行递减排序的格式如下:
ArrayList 类对象.sort(Comparator.comparing(元素类::排序属性).reversed());
如果是按多个成员变量值排序,还可以增加 thenComparing() 等,例如 ArrayList 类对象 myarrlist 中的元素为 User 类对象,User 类有 3 个成员变量 F1、F2 和 F3,对应的属性分别为 getF1()、getF2() 和 getF3(),则以下语句依次按 F1、F2 和 F3 递增排序:
myarrlist.sort(comparing(User::getF1) .thenComparing(User::getF2) .thenComparing(User::getF3));2.3 线性表的链式存储结构
2.3.1 线性表的链式存储结构——链表
线性表的链式存储结构称为链表。在链表中每个结点不仅包含有元素本身的信息(称之为数据成员),而且包含有元素之间逻辑关系的信息,即一个结点中包含有后继结点的地址信息或者前驱结点的地址信息,称为指针成员,这样可以通过一个结点的指针成员方便地找到后继结点或者前驱结点。
如果每个结点只设置一个指向其后继结点的指针成员,这样的链表称为线性单向链表,简称单链表;如果每个结点中设置两个指针成员,分别用于指向其前驱结点和后继结点,这样的链表称为线性双向链表,简称双链表。无前驱结点或者后继结点的相应指针成员用常量 null 表示。
在单链表中,当访问过一个结点后,只能接着访问它的后继结点,而无法访问它的前驱结点。在双链表中,当访问过一个结点后,既可以依次向后访问后继结点,也可以依次向前访问前驱结点。
为了便于在链表中插入和删除结点,通常链表带有一个头结点,并通过头结点指针唯一地标识该链表,因为从头指针所指的头结点出发,沿着结点的链可以访问到链表中的每个结点。图 2.14(a) 是带头结点的单链表 head,图 2.14(b) 是带头结点的双链表 dhead,分别称为 head 单链表和 dhead 双链表。

通常将 p 指向的结点称为 p 结点或者结点 p,头结点为 head 的链表称为 head 链表,头结点中不存放任何数据元素(空表是仅包含头结点的链表),存放序号为 0 的元素的结点称为开始结点或者首结点,存放终端元素的结点称为终端结点或者尾结点。一般链表的长度不计头结点,仅仅指其中数据结点的个数。
2.3.2 单链表
在单链表中,假定每个结点为 LinkNode<E> 泛型类对象,它包括存储元素的数据成员,这里用 data 表示,并假设其数据类型为 E,还包括存储后继结点的指针成员,这里用 next 表示。LinkNode<E> 泛型类的定义如下:
class LinkNode<E> //单链表结点泛型类{ E data; LinkNode<E> next; public LinkNode() //构造方法 { next = null; } public LinkNode(E d) //重载构造方法 { data = d; next = null; }}为了使算法设计简单、清晰,上述 LinkNode<E> 泛型类中的 data 和 next 成员默认为 public 权限,如果设计为 private 权限,则需要增加相应的存取方法。
设计单链表泛型类 LinkedListClass<E>,其中 head 为单链表的头结点,构造方法用于创建这个头结点,并且置 head 结点的 next 为空,所以满足 head.next == null 条件的单链表为一个空单链表:
public class LinkedListClass<E> //单链表泛型类{ LinkNode<E> head; //存放头结点 public LinkedListClass() //构造方法 { head = new LinkNode<E>(); //创建头结点 head.next = null; //头结点的 next 成员置为 null } //基本运算算法}1. 插入和删除结点操作
在单链表中,插入和删除结点是最常用的操作,它是建立单链表和相关基本运算算法的基础。
1) 插入结点操作
插入运算是将结点 s 插入单链表中 p 结点的后面。如图 2.16(a) 所示,为了插入结点 s,需要修改结点 p 中的指针成员,令其指向结点 s,而结点 s 中的指针成员应指向 p 结点的后继结点,从而实现 3 个结点之间逻辑关系的变化,其过程如图 2.16 所示。

上述指针修改用 Java 语句描述如下:
s.next = p.next; p.next = s;
注意: 这两个语句的顺序不能颠倒,如果先执行 p.next = s 语句,会找不到指向值为 b 的结点,再执行 s.next = p.next 语句,相当于执行 s.next = s,这样插入操作错误。
2. 整体建立单链表
所谓整体建立单链表就是一次性创建单链表的多个结点,这里是通过一个含有 n 个元素的 a 数组来建立单链表。建立单链表的常用方法有以下两种。
1) 用头插法建表
该方法从一个空表开始,依次读取数组 a 中的元素,生成新结点 s,将读取的数据存放到新结点的数据成员中,然后将新结点 s 插入当前链表的表头上,如图 2.18 所示。重复这一过程,直到 a 数组的所有元素读完为止。

采用头插法建表的算法如下:
public void CreateListF(E[] a) //头插法:由数组 a 整体建立单链表{ LinkNode<E> s; for (int i = 0; i < a.length; i++) { s = new LinkNode<E>(a[i]); //新建存放 a[i] 元素的结点 s s.next = head.next; //将 s 结点插入开始结点之前、头结点之后 head.next = s; }}本算法的时间复杂度为 O(n),其中 n 为 a 数组中元素的个数。
若数组 a 中包含 4 个元素 ‘a’、‘b’、‘c’ 和 ‘d’,调用 CreateListF(a) 建立的单链表如图 2.19 所示。从中看到,用头插法建立的单链表中数据结点的次序与 a 数组中的次序正好相反。

2) 用尾插法建表
用头插法建立链表虽然算法简单,但生成的链表中结点的次序和原数组中元素的顺序相反。若希望两者次序一致,可采用尾插法建立。该方法是将新结点 s 插入当前链表的表尾上,为此需要增加一个尾指针 t,使其始终指向当前链表的尾结点,如图 2.20 所示。

采用尾插法建表的算法如下:
public void CreateListR(E[] a) //尾插法:由数组 a 整体建立单链表{ LinkNode<E> s, t; t = head; //t 始终指向尾结点,开始时指向头结点 for(int i = 0; i < a.length; i++) { s = new LinkNode<E>(a[i]); //新建存放 a[i] 元素的结点 s t.next = s; //将 s 结点插入 t 结点之后 t = s; } t.next = null; //将尾结点的 next 成员置为 null}本算法的时间复杂度为 O(n),其中 n 为 a 数组中元素的个数。
若数组 a 中包含 4 个元素 ‘a’、‘b’、‘c’ 和 ‘d’,调用 CreateListR(a) 建立的单链表如图 2.21 所示。从中可以看到,用尾插法建立的单链表中数据结点的次序与 a 数组中的次序正好相同。

2.3.3 单链表的应用算法设计示例
1. 基于单链表基本操作的算法设计
在这类算法设计中主要包括单链表结点的查找、插入和删除等基本操作。
【例 2.10】 有一个长度大于 2 的整数单链表 L,设计一个算法查找 L 的中间位置的元素。例如,L = (1, 2, 3),返回元素为 2;L = (1, 2, 3, 4),返回元素为 2。
快慢指针法: 设置快指针 fast 和慢指针 slow,首先均指向首结点,当 fast 结点后面至少存在两个结点时,让慢指针 slow 每次后移一个结点,让快指针 fast 每次后移两个结点。否则 slow 指向的结点就是满足题目要求的结点。对应的算法如下:
public static int Middle2(LinkedListClass<Integer> L){ LinkNode<Integer> slow = L.head.next; LinkNode<Integer> fast = L.head.next; // 均指向首结点 while(fast.next != null && fast.next.next != null) // 找中间位置的 p 结点 { slow = slow.next; // 慢指针每次后移一个结点 fast = fast.next.next; // 快指针每次后移两个结点 } return slow.data;}2.3.4 双链表
对于双链表,采用类似于单链表的结点类型定义,双链表中每个结点的泛型类 DLinkNode<E> 定义如下:
class DLinkNode<E> //双链表结点泛型类{ E data; //结点元素值 DLinkNode<E> prior; //前驱结点指针 DLinkNode<E> next; //后继结点指针 public DLinkNode() //构造方法 { prior = null; next = null; } public DLinkNode(E d) //重载构造方法 { data = d; prior = null; next = null; }}双链表泛型类 DLinkListClass<E> 包含双链表的基本运算方法,其中 dhead 成员为双链表的头结点:
public class DLinkListClass<E> //双链表泛型类{ DLinkNode<E> dhead; //存放头结点 public DLinkListClass() //构造方法 { dhead = new DLinkNode<E>(); //创建头结点 dhead.prior = null; dhead.next = null; } //线性表的基本运算算法}1. 插入和删除结点操作
在双链表中插入和删除结点是最基本的操作,这也是双链表算法设计的基础。
1) 插入结点操作
假设在双链表中的 p 结点之后插入一个 s 结点,插入过程如图 2.27 所示,共涉及 4 个指针成员的变化。其操作语句描述如下:
s.next = p.next;p.next.prior = s;s.prior = p;p.next = s;
说明: 在双链表中的 p 结点之后插入 s 结点时,p 结点是直接给定的,而 p 结点的后继结点是间接找到的,一般先做间接找到结点的相关操作,后做直接给定结点的相关操作,例如 p.next = s 总是放在前两个语句之后执行。
2) 删除结点操作
假设删除双链表中 p 结点的后继结点,删除过程如图 2.28 所示,共涉及两个指针成员的变化。其操作语句描述如下:
p.next.prior = p.prior;p.prior.next = p.next;
2.3.5 双链表的应用算法设计示例
2.3.6 循环链表
循环链表是另一种形式的链式存储结构,分为循环单链表和循环双链表两种形式,它们分别是从单链表和双链表变化而来的。
1. 循环单链表
带头结点 head 的循环单链表中尾结点的 next 指针成员不再是空,而是指向头结点,从而整个链表形成一个首尾相接的环。
循环单链表的特点是从表中任一结点出发都可找到其他结点,与单链表相比,无须增加存储空间,仅对链接方式稍作修改,即可使得表处理更加方便、灵活。在默认情况下,循环单链表也是通过头结点 head 标识的。
循环单链表中的结点类型与非循环单链表中的结点类型相同,仍为 LinkNode<E>,循环单链表泛型类 CLinkListClass<E> 定义如下:
public class CLinkListClass<E> //循环单链表泛型类{ LinkNode<E> head; //存放头结点 public CLinkListClass() //构造方法 { head = new LinkNode<E>(); //创建头结点 head.next = head; //置为空的循环单链表 } //线性表的基本运算算法}循环单链表的插入和删除结点操作与非循环单链表的相同,所以两者的许多基本运算算法是相似的,主要区别如下:
(1) 初始只有头结点 head,在循环单链表的构造方法中需要通过 head.next = head 语句置为空表。
(2) 循环单链表中涉及查找操作时需要修改表尾判断的条件,例如用 p 遍历时,尾结点满足的条件是 p.next == head 而不是 p.next == null。
2. 循环双链表
循环双链表尾结点的 next 指针成员指向头结点,头结点的 prior 指针成员指向尾结点。其特点是整个链表形成两个环,由此从表中任意一个结点出发均可找到其他结点,最突出的优点是通过头结点在 O(1) 时间内找到尾结点。在默认情况下,循环双链表也是通过头结点 dhead 标识的。
循环双链表中的结点类型与非循环双链表中的结点类型相同,仍为 DLinkNode<E>,循环双链表泛型类 CDLinkListClass<E> 定义如下:
public class CDLinkListClass<E> //循环双链表泛型类{ DLinkNode<E> dhead; //存放头结点 public CDLinkListClass() //构造方法 { dhead = new DLinkNode<E>(); //创建头结点 dhead.prior = dhead; //构成空的循环双链表 dhead.next = dhead; } //线性表的基本运算算法}循环双链表的插入和删除结点操作与非循环双链表的相同,所以两者的许多基本运算算法是相似的,主要区别如下:
(1) 初始只有头结点 dhead,在循环双链表的构造方法中需要通过 dhead.prior = dhead 和 dhead.next = dhead 两个语句置为空表。
(2) 循环双链表中涉及查找操作时需要修改表尾判断的条件,例如用 p 遍历时,尾结点满足的条件是 p.next == head 而不是 p.next == null。
2.3.7 链表容器——LinkedList
在 Java 中 List 接口还有另外一个重要的实现类 — LinkedList类,它采用循环双链表存储对象序列,可以看成是链式存储结构的表在 Java 语言中的实现。类 LinkedList 的使用方法与类 ArrayList 几乎相同,这里不详述。
2.4 顺序表和链表的比较
-
存储空间比较
- 顺序表
- 存储密度 = 1(无额外指针开销)
- 需预先分配连续内存:
- 分配过小 → 易溢出,扩容成本高(需移动元素)
- 分配过大 → 浪费空间
- ✅ 适合长度固定或变化小的场景
- 链表(单/双/循环)
- 存储密度 < 1(每个结点含指针,空间开销大)
- 动态分配内存,按需申请,无溢出问题
- ✅ 适合长度变化大、难以预估的场景
- 顺序表
-
时间效率比较
操作 顺序表 链表 按位查找 O(1)(随机存取) O(n)(顺序遍历) 插入/删除 O(n)(平均移动 n/2 个元素) O(1)(仅修改指针,已知位置时) - ✅ 多查找、少修改 → 选顺序表
- ✅ 频繁插入/删除 → 选链表
第三章 栈和队列
3.1 栈
3.1 栈的定义
抽象起来,栈是一种只能在一端进行插入或删除操作的线性表。在表中允许进行插入、删除操作的一端称为栈顶。栈顶的当前位置是动态的,由一个称为栈顶指针的位置指示器来指示。表的另一端称为栈底。当栈中没有数据元素时称为空栈。栈的插入操作通常称为进栈或入栈,栈的删除操作通常称为退栈或出栈。
说明: 对于线性表,可以在中间和两端的任何地方插入和删除元素,而栈只能在同一端插入和删除元素。
栈的主要特点是“后进先出”,即后进栈的元素先出栈。每次进栈的元素都放在原当前栈顶元素之前成为新的栈顶元素,每次出栈的元素都是原当前栈顶元素。栈也称为后进先出表。
抽象数据类型栈的定义如下:
ADT Stack{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, 元素 aᵢ 为 E 类型} 数据关系: R = {r} r = {<aᵢ, aᵢ₊₁> | aᵢ, aᵢ₊₁ ∈ D, i = 0, ..., n-2} 基本运算: boolean empty(): 判断栈是否为空,若栈空返回真,否则返回假。 void push(E e): 进栈操作,将元素 e 插入栈中作为栈顶元素。 E pop(): 出栈操作,返回栈顶元素。 E peek(): 取栈顶操作,返回当前的栈顶元素。}3.1.2 栈的顺序存储结构及其基本运算算法的实现
由于栈中元素的逻辑关系与线性表的相同,所以可以借鉴线性表的两种存储结构来存储栈。
当采用顺序存储时,分配一块连续的存储空间,用 data<E> 数组来存放栈中元素,并用一个整型变量 top(栈顶指针)指向当前的栈顶,以反映栈中元素的变化。采用顺序存储的栈称为顺序栈。
capacity 为 data 数组的容量,由于将 data[0] 端作为栈底,所以 top + 1 恰好表示栈中实际元素的个数。
图 3.4 是一个栈的动态示意图,图 3.4(a) 表示一个空栈,top = -1;图 3.4(b) 表示元素 a 进栈以后的状态;图 3.4(c) 表示元素 b、c、d 进栈以后的状态;图 3.4(d) 表示出栈元素 d 以后的状态。

从中看到,初始时置栈顶指针 top = -1。顺序栈的四要素如下:
(1) 栈空的条件为 top == -1。
(2) 栈满(栈上溢出)的条件为 top == capacity - 1,这里采用动态扩展容量的方式,即栈满时将 data 数组的容量扩大两倍。
(3) 元素 e 进栈操作是先将栈顶指针 top 增 1,然后将元素 e 放在栈顶指针处。
(4) 出栈操作是先将栈顶指针 top 处的元素取出,然后将栈顶指针 top 减 1。
说明: 在进栈操作中,栈中元素始终是向一端伸展的。在采用 data 数组存放栈元素时,既可以将 data[0] 端作为栈底,也可以将 data[capacity-1] 端作为栈底,但不能将 data 数组的中间位置作为栈底,这里的顺序栈设计采用前一种方式。
顺序栈泛型类 SqStackClass<E> 设计如下:
public class SqStackClass<E> //顺序栈泛型类{ final int initcapacity = 10; //顺序栈的初始容量(常量)
private int capacity; //存放顺序栈的容量 private E[] data; //存放顺序栈中的元素 private int top; //存放栈顶指针
public SqStackClass() //构造方法,实现 data 和 top 的初始化 { data = (E[]) new Object[initcapacity]; //强制转换为 E 类型数组 capacity = initcapacity; top = -1; }
private void updatecapacity(int newcapacity) //改变顺序栈的容量为 newcapacity { E[] newdata = (E[]) new Object[newcapacity]; for(int i = 0; i < top; i++) //复制原来的元素 newdata[i] = data[i]; capacity = newcapacity; //设置新容量 data = newdata; //仍由 data 标识数组 } //栈的基本运算算法}栈的部分基本运算算法如下:
1) 出栈:pop()
元素出栈只能从栈顶出,不能从栈底或中间位置出。
在出栈中,当栈空时抛出异常,否则先将栈顶元素赋给 e,然后将栈顶指针 top 减 1,若栈容量大于初始容量并且实际元素个数仅为当前容量的 1/4,则当前容量减半,最后返回 e。
对应的算法如下:
public E pop() //出栈操作{ if(empty()) throw new IllegalArgumentException("栈空"); E e = (E)data[top]; top--; if(capacity > initcapacity && top + 1 == capacity / 4) //满足要求则容量减半 updatecapacity(capacity / 2); return e;}2) 取栈顶元素:peek()
在栈不为空的条件下将栈顶元素赋给 e,不移动栈顶指针。对应的算法如下:
public E peek() //取栈顶元素操作{ if(empty()) throw new IllegalArgumentException("栈空"); return (E)data[top];}从以上看出,栈的各种基本运算算法的时间复杂度均为 O(1)。
3.1.3 顺序栈的应用算法设计示例
【例 3.1】 有 1~n 的 n 个元素,通过一个栈可以产生多种出栈序列,设计一个算法判断序列 b 是否为一个合法的出栈序列,并给出操作过程,要求用相关数据进行测试。
解: 先建立一个整型顺序栈 st,将进栈序列 1~n 存放到数组 a 中,判断 a 序列通过一个栈算是否得到出栈序列 b
令 i, j 分别遍历 a, b 数组(初始值均为 0),反复执行以下操作,直到 a 或者 b 数组遍历完为止:
(1) 若栈空,a[i] 进栈,i++。
(2) 若栈不空,如果栈顶元素 ≠ b[j],将 a[i] 进栈,i++。
(3) 否则出栈一个元素,j++。
上述过程简单地说就是当栈顶元素 = b 序列的当前元素时出栈一次,否则将 a 序列的当前元素进栈。
当该过程结束,再将栈中与 b 序列相同的元素依次出栈。由于 a 序列的每个元素最多进栈一次、出栈一次,而只有出栈时 j 才后移,所以如果 b 序列遍历完 (j == n),说明 b 序列是合法的出栈序列,返回 true,否则不是合法的出栈序列,返回 false。其中用字符串 op 记录所有的栈操作。
当上述过程返回 true 时输出 op,否则输出提示信息。对应的完整程序如下:
import java.util.*;public class Exam3_6{ static String op = ""; static int cnt;
public static boolean isSerial(int[] b) // b: 目标数组 { int i, j, n = b.length; // i: index(a); j: index(b); n: length(b) Integer e; int[] a = new int[n]; SqStackClass<Integer> st = new SqStackClass<Integer>(); for (i = 0; i < n; i++) a[i] = i + 1; // 将 1~n 放入数组 a 中: a = [1, 2, ..., n] i = 0; j = 0; while (i < n && j < n) // a 和 b 均没有遍历完 { if (st.empty() || (st.peek() != b[j])) // 栈空或者栈顶元素不是 b[j] { st.push(a[i]); op += " 元素" + a[i] + "进栈\n"; i++; } else //否则出栈 { e = st.pop(); op += " 元素" + e + "出栈\n"; j++; // j 只在成功出栈时才增加 } } while (!st.empty() && st.peek() == b[j]) // (处理尾部残留) 使栈中与 b 序列相同的元素出栈 { e = st.pop(); j++; } if (j == n) return true; //是出栈序列时返回 true else return false; //不是出栈序列时返回 false }}假设 n = 3,b = [2, 1, 3](合法序列)
- 初始:
i=0,j=0, 栈空 - 栈空 →
a[0]=1进栈 →i=1 - 栈顶=1 ≠
b[0]=2→a[1]=2进栈 →i=2 - 栈顶=2 ==
b[0]=2→ 出栈2 →j=1 - 栈顶=1 ==
b[1]=1→ 出栈1 →j=2 - 栈顶=空 ≠
b[2]=3→a[2]=3进栈 →i=3 - 栈顶=3 ==
b[2]=3→ 出栈3 →j=3
✅ 最终 j == 3 == n → 合法!
上述过程可以进一步简化,同样用 i, j 分别遍历 a, b 数组(初始值均为 0),在 a 数组没有遍历完时:
(1) 将 a[i] 进栈,i++。
(2) 栈不空并且栈顶元素与 b[j] 相同时循环,即出栈元素 e, j++(改为连续判断)。
在上述过程结束后,如果栈空,返回 true,否则返回 false。
对应的简化算法如下:
public static boolean isSerial1(int[] b) //简化的算法{ int i, j, n = b.length; Integer e; int[] a = new int[n]; SqStackClass<Integer> st = new SqStackClass<Integer>(); for (i = 0; i < n; i++) a[i] = i + 1; //将 1~n 放入数组 a 中 i = 0; j = 0; while (i < n) //a 没有遍历完 { st.push(a[i]); op += " 元素" + a[i] + "进栈\n"; i++; while (!st.empty() && st.peek() == b[j]) //b[j] 与栈顶匹配的情况 { e = st.pop(); op += " 元素" + e + "出栈\n"; j++; } } return st.empty(); //栈空返回 true,否则返回 false}说明: 上述两个算法中可以不使用 a 数组,直接将 a[i] 的地方用 (i + 1) 代替,但使用 a 数组时稍微修改一下,可以判断任何进栈序列 a 是否可以得到合法的出栈序列 b。
算法步骤拆解(以 b = [2, 1, 3] 为例)
- 初始状态:
i=0,j=0, 栈空。 - 第1轮外循环:
a[0]=1进栈 → 栈:[1]i=1- 内层循环:栈顶=1 ≠
b[0]=2→ 跳过。
- 第2轮外循环:
a[1]=2进栈 → 栈:[1, 2]i=2- 内层循环:栈顶=2 ==
b[0]=2→ 出栈2 →j=1- 再次检查:栈顶=1 ==
b[1]=1→ 出栈1 →j=2 - 再次检查:栈空 → 退出内层循环。
- 再次检查:栈顶=1 ==
- 第3轮外循环:
a[2]=3进栈 → 栈:[3]i=3- 内层循环:栈顶=3 ==
b[2]=3→ 出栈3 →j=3
- 结束:
i=3不再小于n,退出外层循环。- 最终返回
st.empty()→true。
3.1.4 栈的链式存储结构及其基本运算算法的实现
采用链式存储的栈称为链栈,这里采用单链表实现。链栈的优点是不需要考虑栈满溢出的情况。用带头结点的单链表 head 表示的链栈如图 3.11 所示,首结点是栈顶结点,尾结点是栈底结点,栈中元素自栈顶到栈底依次是 a₀, a₁, ..., aₙ₋₁。

从该链栈的存储结构看到,初始时只含有一个头结点 head 并置 head.next 为 null。链栈的四要素如下:
(1) 栈空的条件为 head.next == null。
(2) 由于只有在内存溢出才会出现栈满,通常不考虑这种情况。
(3) 元素 e 进栈操作是将包含该元素的结点 s 插入作为首结点。
(4) 出栈操作返回首结点值并且删除该结点。
和单链表一样,链栈中每个结点的类型 LinkNode<E> 定义如下:
class LinkNode<E> //链栈结点泛型类{ E data; LinkNode<E> next; public LinkNode() //构造方法 { next = null; } public LinkNode(E d) //重载构造方法 { data = d; next = null; }}链栈类模板 LinkStackClass<E> 的设计如下:
public class LinkStackClass<E> //链栈泛型类{ LinkNode<E> head; //存放头结点 public LinkStackClass() //构造方法 { head = new LinkNode<E>(); //创建头结点 head.next = null; //设置为空栈 } //栈的基本运算算法}在链栈中实现栈的部分基本运算的算法如下。
1) 进栈:push(e)
新建包含数据元素 e 的结点 p,将 p 结点插入头结点之后。对应的算法如下:
public void push(E e) //元素 e 进栈{ LinkNode<E> s = new LinkNode<E>(e); //新建结点 s s.next = head.next; //将结点 s 插入表头 head.next = s;}2) 出栈:pop()
在链栈空时抛出异常,否则将首结点的数据域赋给 e,然后将其删除。对应的算法如下:
public E pop() //出栈操作{ if (empty()) throw new IllegalArgumentException("栈空"); E e = (E)head.next.data; //取首结点值 head.next = head.next.next; //删除原首结点 return e;}3) 取栈顶元素:peek()
在链栈空时抛出异常,否则将首结点的数据域赋给 e,但不删除该结点。对应的算法如下:
public E peek() //取栈顶元素操作{ if (empty()) throw new IllegalArgumentException("栈空"); E e = (E)head.next.data; //取首结点值 return e;}3.1.5 链栈的应用算法设计举例
3.1.6 Java 中的栈容器——Stack
Java 语言中提供了栈容器 Stack<E>,它是从 Vector<E> 继承的,除了继承的许多方法外,作为栈的主要方法如下。
(1) boolean empty():判断栈是否为空。
(2) int size():返回栈中元素的个数。
(3) E push(E item):把对象压入栈顶,即进栈操作。
(4) E pop():移除栈顶对象,并作为此函数的值返回该对象,即出栈操作。
(5) E peek():查看栈顶对象,但不从栈中移除它,即返回栈顶元素操作。
(6) int search(Object o):返回元素 o 在栈中的位置,该位置从栈顶开始往下算,栈顶为 1。
(7) boolean contains(Object o):如果栈中包含指定的元素 o,返回 true,否则返回 false。
3.1.7 栈的综合应用
-
用栈简单求解表达式求值问题
用栈实现四则运算(即表达式求值)的核心思想是使用两个栈:
- 操作数栈(Operand Stack):存放数字;
- 运算符栈(Operator Stack):存放
+ - * / ( )等运算符。
整个过程基于中缀表达式转后缀(逆波兰)表达式的思想,或直接边扫描边计算。以下是简要步骤(以中缀表达式直接求值为例):
基本步骤(Dijkstra 双栈算法)
- 初始化两个栈:
numStack(数字)、opStack(运算符)。 - 从左到右扫描表达式的每个字符:
- 如果是数字 → 直接入
numStack。 - 如果是 ’(’ → 入
opStack。 - 如果是 ’)’ → 不断弹出
opStack的运算符并计算,直到遇到 ’(’。 - 如果是运算符(+ - * /):
- 比较其与
opStack栈顶运算符的优先级; - 如果当前运算符优先级 ≤ 栈顶运算符,则先弹出栈顶运算符,从
numStack弹出两个数进行计算,结果再入numStack; - 重复此过程,直到栈顶运算符优先级更低或遇到 ’(‘,然后将当前运算符入栈。
- 比较其与
- 如果是数字 → 直接入
- 扫描结束后,将
opStack中剩余运算符依次弹出并计算。 - 最终结果在
numStack的栈顶。
关键点
- 运算符优先级:
/>+ -;括号具有最高局部优先级。 - 处理多位数:需完整读取连续数字(如 “123”)。
- 括号处理:
(入栈,)触发计算直到(。
举例:计算
3 + 5 * 2步骤 当前字符 numStack opStack 说明 1 3 [3] [] 数字入栈 2 + [3] [+] 运算符入栈 3 5 [3,5] [+] 数字入栈 4 * [3,5] [+, *] *优先级 >+,入栈5 2 [3,5,2] [+, *] 数字入栈 结束 — [3,10] [+] 先算 5*2=10— [13] [] 再算 3+10=13✅ 结果:13
3.2 队列
3.2.1 队列的定义
队列(简称为队)是一种操作受限的线性表,其限制为仅允许在表的一端进行插入,而在表的另一端进行删除。通常把进行插入的一端称为队尾(rear),把进行删除的一端称为队头或队首(front)。向队列中插入新元素称为进队或入队,新元素进队后就成为新的队尾元素;从队列中删除元素称为出队或离队,元素出队后,其直接后继元素就成为队首元素。
由于队列的插入和删除操作分别是在表的一端进行的,每个元素必然按照进入的次序出队,所以又把队列称为先进先出表。
抽象数据类型队列的定义如下:
ADT Queue{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, aᵢ 为 E 类型} 数据关系: R = {r} r = {<aᵢ, aᵢ₊₁> | aᵢ, aᵢ₊₁ ∈ D, i = 0, ..., n-2} 基本运算: boolean empty(): 判断队列是否为空,若队列为空,返回真,否则返回假。 void push(E e): 进队,将元素 e 进队作为队尾元素。 E pop(): 出队,从队头出队一个元素。 E peek(): 取队头,返回队头元素值而不出队。}【例】 若元素进队的顺序为 1234,能否得到 3142 的出队序列?
解: 进队顺序为 1234,则出队顺序只能是 1234(先进先出),所以不能得到 3142 的出队序列。
3.2.2 队列的顺序存储结构及其基本运算算法的实现
1. 非循环队列
初始时置 front 和 rear 均为 -1 (front == rear)。非循环队列的四要素如下:
(1) 队空的条件为 front == rear,图 3.20(a) 和 (d) 满足该条件。
(2) 队满(队上溢出)的条件为 rear == MaxSize - 1(因为每个元素进队都让 rear 增 1,当 rear 到达最大下标时不能再增加),图 3.20(d) 满足该条件。
(3) 元素 e 进队的操作是先将队尾指针 rear 增 1,然后将元素 e 放在该位置(进队的元素总是在尾部插入的)。
(4) 出队操作是先将队头指针 front 增 1,然后取出该位置的元素(出队的元素总是从头部出来的)。
说明: 为什么让 front 指向队列中当前队头元素的前一个位置呢?因为在 front 增 1 后,该位置的元素已经出队(被删除了)。
非循环队列的泛型类 SqQueueClass<E> 定义如下:
class SqQueueClass<E> //非循环队列的泛型类{ final int MaxSize = 100; //假设容量为 100 private E[] data; //存放队列中的元素 private int front, rear; //队头、队尾指针
public SqQueueClass() //构造方法 { data = (E[]) new Object[MaxSize]; front = -1; rear = -1; } //队列的基本运算算法}在上述非循环队列中,元素进队时队尾指针 rear 增1,元素出队时队头指针 front 增1,当进队 MaxSize 个元素后,满足设置的队满条件,即 rear == MaxSize-1 成立,此时即使出队若干元素,队满条件仍然成立(实际上队列中有空位置),这种队列中有空位置但仍然满足队满条件的上溢出称为假溢出。也就是说,非循环队列存在假溢出现象。
-
循环队列
为了克服非循环队列的假溢出,充分使用数组中的存储空间,可以把
data数组的前端和后端连接起来,形成一个循环数组,即把存储队列元素的表从逻辑上看成一个环,称为循环队列(也称为环形队列)。循环队列首尾相连,当队尾指针
rear = MaxSize - 1时再前进一个位置就应该到达 0 位置,这可以利用数学上的求余运算 (%) 实现。(1) 队首指针循环进 1:
front = (front + 1) % MaxSize(2) 队尾指针循环进 1:rear = (rear + 1) % MaxSize循环队列的队头指针和队尾指针初始化时都置 0,即
front = rear = 0。在进队元素和出队元素时,队头和队尾指针都循环前进一步。那么循环队列的队满和队空的判断条件是什么呢?若设置队空条件是
rear == front,如果进队元素的速度快于出队元素的速度,队尾指针很快就赶上了队头指针,此时可以看出循环队列在队满时也满足rear == front,所以这种设置无法区分队空和队满。实际上循环队列的结构与非循环队列相同,也需要通过
front和rear标识队列状态,一般是采用它们的相对值(即|front - rear|)实现的。若data数组的容量为m,则队列的状态有m+1种,分别是队空、队中有 1 个元素、队中有 2 个元素、……、队中有m个元素(队满)。front和rear的取值范围均为0 ~ m-1,这样|front - rear|只有m个值,显然m+1种状态不能直接用|front - rear|区分,因为必定有两种状态不能区分。为此让队列中最多只有m-1个元素,这样队列恰好只有m种状态,就可以通过front和rear的相对值区分所有状态了。在规定队列中最多只有
m-1个元素时,设置队空条件仍然是rear == front。当队列有m-1个元素时一定满足(rear + 1) % MaxSize == front。这样循环队列在初始时置
front = rear = 0,其四要素如下:(1) 队空条件为
rear == front。 (2) 队满条件为(rear + 1) % MaxSize == front(相当于试探进队一次,若rear达到front,则认为队满了)。 (3) 元素e进队时,rear = (rear + 1) % MaxSize,将元素e放置在该位置。 (4) 元素出队时,front = (front + 1) % MaxSize,取出该位置的元素。图 3.23 说明了循环队列的几种状态,这里假设
MaxSize等于 5。图 3.23(a) 为空队,此时front = rear = 0;图 3.23(b) 中有 3 个元素,当进队元素d后队中有 4 个元素,此时满足队满的条件。循环队列的泛型类
CSqQueueClass<E>定义如下:class CSqQueueClass<E> //循环队列的泛型类{final int MaxSize = 100; //假设容量为 100private E[] data; //存放队列中的元素private int front, rear; //队头、队尾指针public CSqQueueClass() //构造方法{data = (E[]) new Object[MaxSize];front = 0;rear = 0;}//队列的基本运算算法}在这样的循环队列中,实现队列的基本运算算法如下。
1) 进队:push(e)
在队列不满时,先将队尾指针
rear循环增 1,然后将元素e放到该位置处,否则抛出异常。对应的算法如下:public void push(E e) //元素 e 进队{if ((rear + 1) % MaxSize == front) //队满throw new IllegalArgumentException("队满");rear = (rear + 1) % MaxSize;data[rear] = e;}2) 出队:pop()
在队列不为空的条件下,将队头指针
front循环增 1,并返回该位置的元素值,否则抛出异常。对应的算法如下:public E pop() //出队元素{if (empty()) //队空throw new IllegalArgumentException("队空");front = (front + 1) % MaxSize;return (E)data[front];}
3.2.3 循环队列的应用算法设计示例
3.2.4 队列的链式存储结构及其基本运算算法的实现
队列的链式存储结构也是通过由结点构成的单链表实现的,此时只允许在单链表的表首进行删除操作和在单链表的表尾进行插入操作,这里的单链表是不带头结点的,需要使用两个指针(即队首指针 front 和队尾指针 rear)来标识,front 指向队首结点,rear 指向队尾结点。用于存储队列的单链表简称为链队。
链队中存放元素的结点类型 LinkNode<E> 定义如下:
class LinkNode<E> //链队结点泛型类{ E data; LinkNode<E> next; public LinkNode() //构造方法 { next = null; } public LinkNode(E d) //重载构造方法 { data = d; next = null; }}设计链队泛型类 LinkQueueClass<E> 如下:
public class LinkQueueClass<E> //链队泛型类{ LinkNode<E> front; //首结点指针 LinkNode<E> rear; //尾结点指针
public LinkQueueClass() //构造方法 { front = null; rear = null; } //队列的基本运算算法}图 3.25 说明了一个链队的动态变化过程。图 3.25(a) 是链队的初始状态,图 3.25(b) 是在链队中进队 3 个元素后的状态,图 3.25(c) 是从链队中出队两个元素后的状态。

从图 3.25 可以看到,初始时置 front = rear = null。链队的四要素如下:
(1) 队空的条件为 front = rear = null,用户不妨仅以 front == null 作为队空条件。
(2) 由于只有在内存溢出时才出现队满,通常不考虑这样的情况。
(3) 元素 e 进队的操作是在单链表尾部插入存放 e 的 s 结点,并让队尾指针指向它。
(4) 出队操作是取出队首结点的 data 值,并将其从链队中删除。
对应队列的基本运算算法如下:
1) 进队:push(e)
创建存放 e 的结点 s。若原队列为空,则将 front 和 rear 指向 s 结点,否则将 s 结点链到单链表的末尾,并让 rear 指向它。对应的算法如下:
public void push(E e) //元素 e 进队{ LinkNode<E> s = new LinkNode<E>(e); //新建结点 s if(empty()) //原链队为空 front = rear = s; else //原链队不空 { rear.next = s; //将 s 结点链接到 rear 结点的后面 rear = s; }}2) 出队:pop()
若原队为空,抛出异常;若队中只有一个结点(此时 front 和 rear 都指向该结点),取首结点的 data 值赋给 e,并删除之,即置 front = rear = null;否则说明有多个结点,取首结点的 data 值赋给 e,并删除之。最后返回 e。对应的算法如下:
public E pop() //出队操作{ E e; if(empty()) //原链队不空 throw new IllegalArgumentException("队空"); if(front == rear) //原链队只有一个结点 { e = (E)front.data; //取首结点值 front = rear = null; //置为空 } else //原链队有多个结点 { e = (E)front.data; //取首结点值 front = front.next; //front 指向下一个结点 } return e;}3.2.5 链队的应用算法设计示例
3.2.6 Java 中的队列接口——Queue
在 Java 语言中提供了队列接口 Queue<E>,提供了队列的修改运算,但不同于 Stack<E>,由于它是接口,所以在创建时需要指定元素类型,例如 LinkedList<E>。Queue<E> 接口的主要方法如下。
(1) boolean isEmpty():返回队列是否为空。
(2) int size():队列中元素的个数。
(3) boolean add(E e):将元素 e 进队(如果立即可行且不会违反容量限制),在成功时返回 true,如果当前没有可用的空间,则抛出异常。
(4) boolean offer(E e):将元素 e 进队(如果立即可行且不会违反容量限制),当使用有容量限制的队列时,此方法通常要优于 add(e),后者可能无法插入元素,而只是抛出一个异常。
(5) E peek():取队头元素,如果队列为空,则返回 null。
(6) E element():取队头元素,它对 peek() 方法进行简单封装,如果队头元素存在,则取出但并不删除,如果不存在则抛出异常。
(7) E poll():出队,如果队列为空,则返回 null。
(8) E remove():出队,直接删除队头的元素。
在 Queue<E> 的使用中,主要操作是进队方法 offer()、出队方法 poll()、取队头元素方法 peek(),它们的时间复杂度均为 O(1)。
3.2.7 队列的综合应用
*3.2.8 双端队列
双端队列是在队列的基础上扩展而来的,其示意图如图 3.30 所示。双端队列与队列一样,元素的逻辑关系也是线性关系,但队列只能在一端进队,在另外一端出队,而双端队列可以在两端进行进队和出队操作,因此双端队列的使用更加灵活。

在 Java 中提供了双端队列接口 Deque<E>,它继承自 Queue<E>。Deque<E> 接口的主要方法如下。
(1) boolean offer(E e):将元素 e 插入双端队列的尾部(与队列进队操作相同)。如果成功,返回 true,如果当前没有可用的空间,则返回 false。
(2) boolean offerFirst(E e):将元素 e 插入双端队列的开头。如果成功,返回 true,如果当前没有可用的空间,则返回 false。
(3) boolean offerLast(E e):将元素 e 插入双端队列的尾部(与队列进队操作相同)。如果成功,返回 true,如果当前没有可用的空间,则返回 false。与 offer(E e) 相同。
(4) E poll():从双端队列中出队队头元素(与队列出队操作相同)。如果双端队列为空,则返回 null。
(5) E pollFirst():从双端队列中出队队头元素(与队列出队操作相同)。如果双端队列为空,则返回 null。与 poll() 相同。
(6) E pollLast():从双端队列中出队队尾元素。如果双端队列为空,则返回 null。
(7) E peek():取双端队列的队头元素(与队列取队头元素操作相同)。如果双端队列为空,则返回 null。
(8) E peekFirst():取双端队列的队头元素(与队列取队头元素操作相同)。如果双端队列为空,则返回 null。用 peek() 相同。
(9) E peekLast():取双端队列的队尾元素。如果双端队列为空,则返回 null。
从中可以看出,如果规定 Deque<E> 只在一端进队,另外一端出队,即只使用 offer(E e)/offerLast(E e)、poll()/pollFirst()、peek()/peekFirst()(后端进,前端出),或者 offerLast(E e)、pollFirst()、peekFirst()(前端进,后端出),此时 Deque<E> 相当于队列。
如果规定 Deque<E> 只在同端进队和出队,即只使用 offerFirst(E e)、pollFirst()、peekFirst()(前端进,前端出),或者 offerLast(E e)、pollLast()、peekLast()(后端进,后端出),此时 Deque<E> 相当于栈。
第四章 串
4.1 串的基本概念
4.1.1 什么是串
串是由零个或多个字符组成的有限序列,记作 str = a₁a₂…aₙ (n ≥ 0),其中 str 是串名,用双引号括起来的字符序列为串值,引号是界限符,aᵢ (1 ≤ i ≤ n) 是一个任意字符(字母、数字或其他字符),它称为串的元素,是构成串的基本单位,串中所包含的字符个数 n 称为串的长度,当 n = 0 时称为空串。
将串值括起来的双引号本身不属于串,它的作用是避免串与常数或标识符混淆。例如 A = "123" 是数字字符串,长度为 3,它不同于常整数 123。通常将仅由一个或多个空格组成的串称为空白串。注意空串和空白串不同,例如 " "(含一个空格)和 ""(不含任何字符)分别表示长度为 1 的空白串和长度为零的空串。
一个串中任意连续的字符组成的子序列称为该串的子串,例如 "a"、"ab"、"abc" 和 "abcd" 等都是 "abcde" 的子串。包含子串的串相应地称为主串。通常称字符在序列中的序号为该字符在串中的位置,例如字符元素 aᵢ (1 ≤ i ≤ n) 的序号为 i。子串在主串中的位置则以子串的第一个字符首次出现在主串中的位置来表示。例如,设有两个字符串 s 和 t:
s = "This is a string."t = "is"则它们的长度分别为 17 和 2,t 是 s 的子串,s 为主串。t 在 s 中出现了两次,其中首次出现所对应的主串位置是 3,因此称 t 在 s 中的序号(或位置)为 3。
若两个串的长度相等且对应字符都相等,则称两个串相等。当两个串不相等时,可按“词典顺序”区分大小。
4.1.2 串的抽象数据类型
抽象数据类型串的定义如下:
ADT String{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, aᵢ 为 char 类型} 数据关系: R = {r} r = {<aᵢ, aᵢ₊₁> | aᵢ, aᵢ₊₁ ∈ D, i = 0, ..., n-2} 基本运算: void StrAssign(cstr): 由字符串常量 cstr 创建一个串,即生成其值等于 cstr 的串。 char geti(int i): 返回序号为 i 的字符。 void seti(int i, char x): 设置序号为 i 的字符为 x。 String StrCopy(): 串复制,返回由当前串复制产生的一个串。 int size(): 求串长,返回当前串中字符的个数。 String Concat(t): 串连接,返回一个当前串和串 t 连接后的结果。 String SubStr(i, j): 求子串,返回当前串中从第 i 个字符开始的 j 个连续字符组成的子串。 String InsStr(i, t): 串插入,返回串 t 插入当前串的第 i 个位置后的子串。 String DelStr(i, j): 串删除,返回当前串中删去从第 i 个字符开始的 j 个字符后的结果。 String RepStr(i, j, t): 串替换,返回用串 t 替换当前串中从第 i 个字符开始的 j 个字符后的结果。 String toString(): 将串转换为字符串。}4.2 串的存储结构
4.2.1 串的顺序存储结构——字符串
设计顺序串类 SqStringClass 如下:
public class SqStringClass //顺序串类{ final int MaxSize = 100; //假设容量为 100 char[] data; //存放串中的字符 int size; //串中字符的个数
public SqStringClass() //构造方法 { data = new char[MaxSize]; size = 0; } //串的基本运算算法}4.2.2 串的链式存储结构——链串
链串的组织形式与一般的链表类似,主要区别在于链串中的一个结点可以存储多个字符。通常将链串中的每个结点所存储的字符个数称为结点大小。图 4.2 和图 4.3 分别表示了同一个串“ABCDEFGHIJKLMN”的结点大小为 4(存储密度大)和 1(存储密度小)的链式存储结构。

当结点大小大于 1(例如结点大小或等于 4)时,链串的最后一个结点的各个数据域不一定总能全被字符占满,此时应在这些未占用的数据域里补上不属于字符集的特殊符号(例如 '#' 字符),以示区别(参见图 4.2 中的最后一个结点)。
在设计链串时,结点大小越大,则存储密度越大。当链串的结点大小大于 1 时,一些操作(例如插入、删除、替换等)有所不便,可能引起大量字符移动,因此它适合在串基本保持静态使用方式时采用。为简便起见,这里规定链串的结点大小均为 1。
链串的结点类型 LinkNode 的定义如下:
class LinkNode //链串结点类型{ char data; //存放一个字符 LinkNode next; //指向下一个结点的指针
public LinkNode() //构造方法 { next = null; }
public LinkNode(char ch) //重载构造方法 { data = ch; next = null; }}一个链串用一个头结点 head 来唯一标识,设计链串类 LinkStringClass 如下:
public class LinkStringClass //链串类{ LinkNode head; //链串中字符的个数 int size; //构造方法
public LinkStringClass() //建立头结点 { head = new LinkNode(); size = 0; } //串的基本运算算法}4.3 Java 中的字符串
4.3.1 String
在 Java 中提供了 String 字符串类(位于 java.lang 命名空间中),所有字符串字面值(例如 "abc")都作为此类的实例实现。
String 类包括的方法可用于检查序列的单个字符、比较字符串、搜索字符串、提取子串、创建字符串副本并将所有字符全部转换为大写或小写等。另外,Java 语言还提供对字符串串联符号("+")以及将其他对象转换为字符串的特殊支持。
String 类的主要方法如下。
(1) char charAt(int index):返回指定索引处的 char 值。
(2) int compareTo(String anotherString):按字典顺序比较两个字符串。
(3) int compareToIgnoreCase(String str):按字典顺序比较两个字符串,不考虑大小写。
(4) String concat(String str):将指定字符串连接到此字符串的结尾。
(5) boolean endsWith(String suffix):测试此字符串是否以指定的后缀结束。
(6) boolean equals(Object anObject):将此字符串与指定的对象比较。
(7) boolean equalsIgnoreCase(String anotherString):将此 String 与另一个 String 比较,不考虑大小写。
(8) static String format(String format, Object... args):使用指定的格式字符串和参数返回一个格式化字符串。
(9) int indexOf(int ch):返回指定字符在此字符串中第一次出现处的索引。
(10) int indexOf(int ch, int fromIndex):返回在此字符串中第一次出现指定字符处的索引,从指定的索引开始搜索。
(11) int indexOf(String str):返回指定子字符串在此字符串中第一次出现处的索引。
(12) int indexOf(String str, int fromIndex):返回指定子字符串在此字符串中第一次出现处的索引,从指定的索引开始。
(13) boolean isEmpty():当且仅当 length() 为 0 时返回 true。
(14) int lastIndexOf(int ch):返回指定字符在此字符串中最后一次出现处的索引。
(15) int lastIndexOf(int ch, int fromIndex):返回指定字符在此字符串中最后一次出现处的索引,从指定的索引处开始进行反向搜索。
(16) int lastIndexOf(String str):返回指定子字符串在此字符串中最右边出现处的索引。
(17) int lastIndexOf(String str, int fromIndex):返回指定子字符串在此字符串中最后一次出现处的索引,从指定的索引开始反向搜索。
(18) int length():返回此字符串的长度。
(19) String replace(char oldChar, char newChar):返回一个新的字符串,它是通过用 newChar 替换此字符串中出现的所有 oldChar 得到的。
(20) String replaceAll(String regex, String replacement):使用给定的 replacement 替换此字符串所有匹配给定的正则表达式的子字符串。
(21) String replaceFirst(String regex, String replacement):使用给定的 replacement 替换此字符串匹配给定的正则表达式的第一个子字符串。
(22) String[] split(String regex):根据给定正则表达式的匹配拆分此字符串。
(23) String[] split(String regex, int limit):根据匹配给定的正则表达式来拆分此字符串。
(24) boolean startsWith(String prefix):测试此字符串是否以指定的前缀开始。
(25) boolean startsWith(String prefix, int toffset):测试此字符串从指定索引开始的子字符串是否以指定前缀开始。
(26) String substring(int beginIndex):返回一个新的字符串,它是此字符串的一个子字符串。
(27) String substring(int beginIndex, int endIndex):返回一个新字符串,它是此字符串的一个子字符串。
(28) char[] toCharArray():将此字符串转换为一个新的字符数组。
(29) String toLowerCase():使用默认语言环境的规则将此 String 中的所有字符都转换为小写。
(30) String toUpperCase():使用默认语言环境的规则将此 String 中的所有字符都转换为大写。
读者需要注意字符串比较中 "==" 操作符和 equals() 方法的区别。"==" 可以用于基本数据类型的比较,当用于引用对象比较时是判断引用是否指向堆内存的同一块地址;而 equals() 方法的作用是用于判断两个变量是否为同一个对象的引用,即堆中的内容是否相同,返回值为布尔类型。
4.3.2 StringBuffer
String 字符串是常量,其值在创建之后不能更改,为此 Java 中提供了 StringBuffer 字符串缓冲区支持可变的字符串。StringBuffer 每次都对对象本身进行操作,而不是生成新的对象,所以在字符串内容不断改变的情况下建议使用 StringBuffer。
StringBuffer 类的主要方法如下。
(1) StringBuffer append(String str):将指定的字符串添加到此字符序列的末尾。
(2) char charAt(int index):返回此序列中指定索引处的 char 值。
(3) StringBuffer delete(int start, int end):移除此序列的子字符串中的字符。
(4) StringBuffer deleteCharAt(int index):移除此序列指定位置的 char。
(5) StringBuffer insert(int offset, String str):将字符串插入此字符序列中。
(6) StringBuffer replace(int start, int end, String str):使用给定 String 中的字符替换此序列的子字符串中的字符。
(7) StringBuffer reverse():将此字符序列用其反转形式取代。
(8) String substring(int start):返回一个新的 String,它包含此字符序列当前所包含的字符子序列。
(9) String substring(int start, int end):返回一个新的 String,它包含此序列当前所包含的字符子序列。
例如有以下程序及其执行结果:
import java.lang.*;public class tmp{ public static void main(String[] args) { String s1 = "abc"; String s2 = s1; s1 += "123"; System.out.println(s1 == s2); //false System.out.println("s1: " + s1); //s1: abc123 System.out.println("s2: " + s2); //s2: abc StringBuffer s3 = new StringBuffer("abc");
StringBuffer s4 = s3; s3.append("123"); System.out.println(s3 == s4); //返回 true System.out.println("s3: " + s3); //s3: abc123 System.out.println("s4: " + s4); //s4: abc123 }}在上述程序中,String 对象 s1 是常量,它是不可更改的,当执行 s1 += "123" 后会建立一个新对象存放 "abc123",每次修改都是如此;而 StringBuffer 对象是在原来对象的基础上修改的,并不新建对象。
4.4 串的模式匹配
设有两个串 s 和 t,串 t 定位操作就是在串 s 中查找与子串 t 相等的子串。通常把串 s 称为目标串,把串 t 称为模式串,因此定位也称为模式匹配。模式匹配成功是指在目标串 s 中找到一个模式串 t,不成功则指目标串 s 中不存在模式串 t。
4.4.1 Brute-Force 算法
Brute-Force 简称为 BF 算法,也称简单匹配算法,其基本思路是从目标串 s = "s₀s₁…sₙ₋₁" 的 s₀ 字符开始和模式串 t = "t₀t₁…tₘ₋₁" 中的 t₀ 字符比较,若相等,则继续逐个比较后续字符;否则从目标串 s 的 s₁ 字符开始重新与模式串 t 的 t₀ 字符进行比较。以此类推,若从目标串 s 的 sᵢ 字符开始,每个字符依次和模式串 t 中的对应字符相等,则匹配成功,该算法返回 i;若从目标串 s 的每个字符开始的匹配均不成功,则表示匹配失败,算法返回 -1。
例如,设目标串 s = "aaaaab",模式串 t = "aaab",s 的长度为 n (n=6),t 的长度为 m (m=4),用整型变量 i 遍历目标串 s,模式匹配过程如图 4.5 所示,其采用的是穷举思路

其中,i 可视为左指针,每一次失败匹配后向右移动一次(在 s 串中。在 t 串中从 0 开始),j 可视为右指针,每一次向右移动一次检查 t 的子串是否匹配,每一次失败匹配后从 i 的位置重新开始移动(在 s 串中。在 t 串中从 0 开始)。
4.4.2 KMP 算法
KMP(Knuth–Morris–Pratt)算法用于在一个主串中查找模式串第一次出现的位置。 它解决的是朴素字符串匹配算法效率低的问题。
朴素算法在匹配失败时,会把模式串整体右移一位,已经匹配过的信息会被浪费,最坏时间复杂度是 O(n × m)。
KMP 的核心思想是: 当匹配失败时,模式串不回到起点,而是利用“已匹配的信息”直接跳到合适的位置继续匹配,从而把时间复杂度优化到 O(n + m)。
KMP 的核心思想
假设:
主串: T = "abababca"
模式串:P = "ababca"
当匹配到某一位失败时,KMP 会问一个问题: “模式串中,有没有一段前缀,正好等于已经匹配成功的后缀?”
如果有,就把模式串滑动到这个前缀对应的位置,而不是从头重新匹配。
这个“前缀 = 后缀”的信息,提前保存在一个数组中,这个数组就是 next 数组(也叫部分匹配表)。
next 数组是什么
对于模式串 P 的每一个位置 i,next[i] 表示:
在 P[0..i-1] 这个子串中,
最长的「相等的真前缀和真后缀」的长度。
通俗说: next[i] 告诉我们,如果在 i 位置匹配失败,模式串下一步应该跳到哪里。
示例:
模式串 P = "ababca"
下标: 0 1 2 3 4 5 字符: a b a b c a next: 0 0 1 2 0 1
KMP 匹配过程
设两个指针: i 指向主串 j 指向模式串
规则如下:
- 如果 T[i] == P[j] i++,j++
- 如果 T[i] != P[j]
- 若 j > 0,j = next[j - 1] (
next[j-1]代表的是可复用子串的长度,而 j 不减一的目的是使其自然地指向下一个等待匹配的 p 模式串字符) - 若 j == 0,i++ (模式串的第一个字符就和 T[i] 不相等,不存在任何“可复用的前缀”)
- 若 j > 0,j = next[j - 1] (
- 当 j == 模式串长度时,说明匹配成功 返回 i - j
T: a b a b a b c a ↑P: a b a b c a ↑Step 1: 不匹配:a != c 。此时 i = 4; j = 4
Step 2: 设置 j = next[j - 1] = 2 。这个 2 代表的是 P 中的 a b 是可复用的,跳过 P 中的这一前缀
T: a b a b a b c a ↑P: a b a b c a ↑Step 3:继续匹配,此时剩余的字串恰好完全匹配。
T: a b a b a b c a ↑P: a b a b c a ↑Step 4: 匹配结束,此时 i = 8; j = 6。返回 i - j = 2
整个过程中,i 永不回退,这就是 KMP 高效的原因。
Java 实现
public class KMP {
// 构建 next 数组 public static int[] buildNext(String pattern) { int m = pattern.length(); int[] next = new int[m];
int j = 0; // 当前最长前后缀长度 for (int i = 1; i < m; i++) { while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) { j = next[j - 1]; } if (pattern.charAt(i) == pattern.charAt(j)) { j++; } next[i] = j; } return next; }
// KMP 字符串匹配 public static int kmpSearch(String text, String pattern) { if (pattern.length() == 0) { return 0; }
int[] next = buildNext(pattern); int j = 0;
for (int i = 0; i < text.length(); i++) { while (j > 0 && text.charAt(i) != pattern.charAt(j)) { j = next[j - 1]; } if (text.charAt(i) == pattern.charAt(j)) { j++; } if (j == pattern.length()) { return i - j + 1; ## 此时 i 指向的是 s 子串的匹配结尾; j 指向的是 t 字串的结尾 + 1 也就是其长度。所以 + 1 进行修正。 } } return -1; }}时间复杂度
构建 next 数组:O(m) 字符串匹配过程:O(n)
总时间复杂度:O(n + m) 空间复杂度:O(m)
第五章 递归
5.1 什么是递归
5.1.1 递归的定义
在定义一个过程或函数时有时会出现调用本过程或本函数的成分,称之为递归。若调用自身,称之为直接递归。若过程或函数 A() 调用过程或函数 B(),而 B() 又调用 A(),称之为间接递归。在算法设计中,任何间接递归算法都可以转换为直接递归算法来实现,所以后面主要讨论直接递归。
递归不仅是数学中的一个重要概念,也是计算技术中重要的概念之一。在计算技术中,与递归有关的概念有递归数列、递归过程、递归算法、递归程序和递归方法等。
如果一个递归过程或递归函数中的递归调用语句是最后一条执行语句,则称这种递归调用为尾递归。
递归算法通常把一个大的复杂问题层层转化为一个或多个与原问题相似的规模较小的问题来求解,递归策略只需少量的代码就可以描述出解题过程中所需要的多次重复计算,大大减少了算法的代码量。
一般来说,能够用递归解决的问题应该满足以下 3 个条件:
(1) 需要解决的问题可以转化为一个或多个子问题来求解,而这些子问题的求解方法与原问题完全相同,只是在数量规模上不同。 (2) 递归调用的次数必须是有限的。 (3) 必须有结束递归的条件来终止递归。
递归算法的优点是结构简单、清晰,易于阅读,方便其正确性的证明;缺点是算法执行中占用的内存空间较多,执行效率低,不容易优化。
5.1.2 何时使用递归
在以下 3 种情况下经常要用到递归方法。
1. 定义是递归的
有许多数学公式、数列等的定义是递归的,例如求 n! 和 Fibonacci(斐波那契)数列等。对于这些问题的求解,可以将其递归定义直接转化为对应的递归算法,例如求 n! 可以转化为例 5.1 的递归算法。
2. 数据结构是递归的
有些数据结构是递归的,例如第 2 章中介绍过的单链表就是一种递归数据结构
3. 问题的求解方法是递归的
有些问题的解法是递归的,典型的有 Hanoi(汉诺)塔问题的求解,该问题是设有 3 个分别命名为 X、Y 和 Z 的塔座,在塔座 X 上有 n 个直径各不相同的盘片,从小到大依次编号为 1、2、…、n,现要求将 X 塔座上的 n 个盘片移到塔座 Z 上并按同样的顺序叠放,在移动盘片时必须遵守以下规则:每次只能移动一个盘片;盘片可以插在 X、Y 和 Z 中的任一塔座上;任何时候都不能将一个较大的盘片放在较小的盘片上。对于 n=4 的 Hanoi 问题,设计求解该问题的算法。
Hanoi 塔问题特别适合采用递归方法来求解。设 Hanoi(n, X, Y, Z) 表示将 n 个盘片从 X 塔座借助 Y 塔座移动到 Z 塔座上,递归分解的过程是:
Hanoi(n,X,Y,Z) ----> Hanoi(n-1,X,Z,Y); move(n,X,Z): 将第n个圆盘从X移到Z; Hanoi(n-1,Y,X,Z)其含义是首先将 X 塔座上的 n-1 个盘片借助 Z 塔座移动到 Y 塔座上(1, n-1, 0),此时 X 塔座上只有一个盘片,将其直接移动到 Z 塔座上(0, n-1, 1),再将 Y 塔座上的 n-1 个盘片借助 X 塔座移动到 Z 塔座上(动图参考)。由此得到 Hanoi 递归算法如下:
public static void Hanoi(int n, char X, char Y, char Z){ if(n == 1) //只有一个盘片的情况 System.out.printf("将第%d个盘片从%c移动到%c\n", n, X, Z); else //有两个或多个盘片的情况 { Hanoi(n - 1, X, Z, Y); System.out.printf("将第%d个盘片从%c移动到%c\n", n, X, Z); Hanoi(n - 1, Y, X, Z); }}5.1.3 递归模型
递归模型是递归算法的抽象,它反映一个递归问题的递归结构。
一般地,一个递归模型由递归出口和递归体两部分组成。递归出口确定递归到何时结束,即指出明确的递归结束条件。递归体确定递归求解时的递推关系。
递归出口的一般格式如下:
f(s₁) = m₁
这里的 s₁ 与 m₁ 均为常量。有些递归问题可能有几个递归出口。递归体的一般格式如下:
f(sₙ) = g(f(sᵢ), f(sᵢ₊₁), ..., f(sₙ₋₁), cⱼ, cⱼ₊₁, ..., cₘ)
其中,n, i, j, m 均为正整数。这里的 sₙ 是一个递归“大问题”,sᵢ, sᵢ₊₁, ..., sₙ₋₁ 为递归“小问题”,cⱼ, cⱼ₊₁, ..., cₘ 是若干个可以直接(用非递归方法)解决的问题,g 是一个非递归函数,可以直接求值。
实际上,递归思路是把一个不能或不好直接求解的“大问题”转换成一个或几个“小问题”来解决,再把这些“小问题”进一步分解成更小的“小问题”来解决,如此分解,直至每个“小问题”都可以直接解决(此时分解到递归出口)。但递归分解不是随意的分解,递归分解要保证“大问题”与“小问题”相似,即求解过程与环境都相似。
5.1.4 递归与数学归纳法
5.1.5 递归的执行过程
实际上一个递归函数的调用过程类似于多个函数的嵌套调用,只不过调用函数和被调用函数是同一个函数。为了保证递归函数的正确执行,系统需设立一个工作栈。具体地说,递归调用的内部执行过程如下:
(1) 执行开始时,首先为递归调用建立一个工作栈,其结构包括参数、局部变量和返回地址。 (2) 每次执行递归调用之前,把递归函数的参数值和局部变量的当前值以及调用后的返回地址进栈。 (3) 每次递归调用结束后,将栈顶元素出栈,使相应的参数值和局部变量值恢复为调用前的值,然后转向返回地址指定的位置继续执行。
例如有以下程序段:
public static int S(int n){ return (n <= 0) ? 0 : S(n - 1) + n;}public static void main(String[] args){ System.out.printf("%d\n", S(1));}程序执行时使用一个栈来保存调用过程的信息,这些信息用 main()、S(0) 和 S(1) 表示,那么自栈底到栈顶保存的信息的顺序是怎样的呢?
首先从 main() 开始执行程序,将 main() 信息进栈,遇到调用 S(1),将 S(1) 信息进栈,在执行递归函数 S(1) 时又遇到调用 S(0),再将 S(0) 信息进栈(如图 5.5 所示),所以自栈底到栈顶保存的信息的顺序是 main() → S(1) → S(0)。
用递归算法的参数值表示状态,由于在递归算法的执行中系统栈保存了递归调用的参数值、局部变量和返回地址,所以在递归算法中一次递归调用后会自动恢复该次递归调用前的状态。
5.1.6 递归算法的时空分析
5.2 递归算法的设计
5.2.1 递归算法设计的步骤
(1) 对原问题 f(sₙ) 进行分析,假设出合理的“小问题” f(sₙ₋₁)。
(2) 假设小问题 f(sₙ₋₁) 是可解的,在此基础上确定大问题 f(sₙ) 的解,即给出 f(sₙ) 与 f(sₙ₋₁) 之间的关系,也就是确定递归体(与数学归纳法中假设 i = n-1 时等式成立,再求证 i = n 时等式成立的过程相似)。
(3) 确定一个特定情况[例如 f(1) 或 f(0)]的解,由此作为递归出口(与数学归纳法中求证 i=1 或 i=0 时等式成立相似)。
由此得出构造求解问题的递归模型(简化递归模型)的步骤如下:
递归算法的求解过程是先将整个问题划分为若干个子问题,然后分别求解子问题,最后获得整个问题的解。这是一种分而治之的思路,通常由整个问题划分的若干子问题的求解是独立的,所以求解过程对应一棵递归树。如果在设计算法时就考虑递归树中的每一个分解/求值部分,会使问题复杂化,不妨只考虑递归树中第 1 层和第 2 层之间的关系,即“大问题”和“小问题”的关系,其他关系与之相似。
5.2.2 基于递归数据结构的递归算法设计
5.2.3 基于归纳方法的递归算法设计
第六章 数组和稀疏矩阵
6.1 数组
6.1.1 数组的基本概念
从逻辑结构上看,数组是一个二元组 (idx, value) 的集合,对于每个 idx,都有一个 value 值与之对应。idx 称为下标,可以由一个整数、两个整数或多个整数构成,下标含有 d (d ≥ 1) 个整数时称数组的维数是 d。数组按维数分为一维、二维和多维数组。
一维数组 A 是 n (n ≥ 1) 个相同类型的元素 a₀, a₁, ..., aₙ₋₁ 构成的有限序列,其逻辑表示为 A = (a₀, a₁, ..., aₙ₋₁),其中,A 是数组名,aᵢ (0 ≤ i ≤ n-1) 是数组 A 中序号为 i 的元素。
一个二维数组可以看作每个数据元素都是相同类型的一维数组的一维数组。以此类推,多维数组可以看作一个这样的线性表,其中的每个元素又是一个线性表。
大家也可以这样看,一个 d 维数组中含有 b₁ × b₂ × ... × b_d(假设第 i 维的大小为 bᵢ)个元素,每个元素受到 d 个关系的约束,且这 d 个关系都是线性关系。当 d=1 时,数组就退化为定长的线性表,当 d>1 时,d 维数组可以看成是线性表的推广。例如,以下的二维数组的逻辑关系用二元组表示如下:
B = (D, R)R = {r₁, r₂}r₁ = {<1,2>, <2,3>, <3,4>, <5,6>, <6,7>, <7,8>, <9,10>, <10,11>, <11,12>}r₂ = {<1,5>, <5,9>, <2,6>, <6,10>, <3,7>, <7,11>, <4,8>, <8,12>}其中,D 含有 12 个元素,这些元素之间有两种关系,r₁ 表示行关系,r₂ 表示列关系,r₁ 和 r₂ 均为线性关系。
数组具有以下特点:
(1) 数组中的各元素具有统一的数据类型。
(2) d (d ≥ 1) 维数组中的非边界元素有 d 个前驱元素和 d 个后继元素。
(3) 在数组维数确定后,数据元素个数和元素之间的关系不再发生改变,特别适合于顺序存储。
(4) 每个有意义的下标都存在一个与其相对应的数组元素值。
d 维数组抽象数据类型的定义如下:
ADT Array{ 数据对象: D = { 数组中的所有元素 } 数据关系: R = {r₁, r₂, ..., r_d} rᵢ = {元素之间第 i 维的线性关系 | i = 1, ..., d} 基本运算: Value(A, i₁, i₂, ..., i_d): A 是已存在的 d 维数组,其运算结果是返回 A[i₁, i₂, ..., i_d] 值。 Assign(A, e, i₁, i₂, ..., i_d): A 是已存在的 d 维数组,其运算结果是置 A[i₁, i₂, ..., i_d] = e。 ...}6.1.2 数组的存储结构
数组的主要操作是存取元素值,没有插入和删除操作,所以数组通常采用顺序存储方式来实现。
1. 一维数组
一维数组的所有元素按逻辑次序存放在一片连续的内存存储单元中,其起始地址为元素 a₀ 的地址,即 LOC(a₀)。假设每个数据元素占用 k 个存储单元,则任一数据元素 aᵢ 的存储地址 LOC(aᵢ) 就可以由以下公式求出:
LOC(aᵢ) = LOC(a₀) + i × k (1 ≤ i < n)该式说明一维数组中任一元素的存储地址可直接计算得到,即一维数组中的任一元素可直接存取,正因为如此,一维数组具有随机存储特性。
2. d 维数组
对于 d (d ≥ 2) 维数组,必须约定其元素的存放次序(存储方案),这是因为存储单元是一维的(计算机的存储结构是线性的),而数组是 d 维的。通常 d 维数组的存储方案有按行优先和按列优先两种。
下面以 m 行 n 列的二维数组 Aₘ×ₙ = (aᵢ,ⱼ) 为例进行讨论(二维数组也称为矩阵)。
A 按行优先存储 假设每个元素占 k 个存储单元,LOC(a₀,₀) 表示 a₀,₀ 元素的存储地址,对于元素 aᵢ,ⱼ,其存储地址为:
LOC(aᵢ,ⱼ) = LOC(a₀,₀) + (i × n + j) × kA 按列优先存储 对于元素 aᵢ,ⱼ,其存储地址为:
LOC(aᵢ,ⱼ) = LOC(a₀,₀) + (j × m + i) × k前面均假设二维数组的行、列下界为 0。更一般的情况是二维数组的行下界是 c₁、行上界是 d₁,列下界是 c₂、列上界是 d₂,即为数组 A[c₁..d₁, c₂..d₂](A[c₁..d₁, c₂..d₂] 表示数组 A 的行从 c₁ 到 c₂,列从 d₁ 到 d₂。所以 A[m][n] 或 A[m,n] 数组可以表示为 A[0..m-1, 0..n-1]),则该数组按行优先存储时有:
LOC(aᵢ,ⱼ) = LOC(a_c₁,c₂) + [(i - c₁) × (d₂ - c₂ + 1) + (j - c₂)] × k按列优先存储时有:
LOC(aᵢ,ⱼ) = LOC(a_c₁,c₂) + [(j - c₂) × (d₁ - c₁ + 1) + (i - c₁)] × k综上所述,从二维数组的元素地址计算公式 LOC(aᵢ,ⱼ) 看出,一旦数组元素的下标和元素类型确定,对应元素的存储地址就可以直接计算出来,也就是说与一维数组相同,二维数组也具有随机存储特性。这样的推导公式和结论可推广至三维甚至更高维数组中。
6.1.3 Java 中的数组
1. 一维数组
Java 声明一维数组的语法有两种,两者在形式上稍有差异,但使用效果完全一样:
int[] a; //第一种int a[]; //第二种与 C/C++ 不同,Java 中的数组是一种引用类型的变量,数组变量并不是数组本身,它只是指向堆内存中的数组对象,因此在 [] 中无须指定数组元素的个数,即数组长度,所以必须在分配内存空间后才能访问数组中的元素。为数组分配内存空间的方式如下:
int[] a;a = new int[3]; //定义一个 int 数组变量 a //为 int 数组变量 a 指定数组元素个数为 3,此时分配内存空间也可以这样写:
int[] a = new int[3]; //相当于在定义 int 数组变量的时候为其分配内存空间Java 数组的初始化有两种方式,一种是静态初始化,即初始化时由程序员显式指定每个数组元素的初始值,由系统确定数组长度;另外一种是动态初始化,即初始化时程序员只指定数组长度,由系统为数组元素分配初始值。例如:
int[] a = new int[]{1, 2, 3}; //静态初始化String[] b = new String[3]; //动态初始化b[0] = "Hello";b[1] = "World";b[2] = "Hello World";不管采用哪种方式初始化 Java 数组,一旦初始化完成,该数组的长度就不可以改变。在 Java 中通过数组的 length 属性来获取数组的长度。
Java 数组在内存中存储时一般需要 24 字节的头信息(占用 24 字节)再加上保存值所需的内存。例如,int[] c = new int[N] 定义的一维数组 c 的内存空间如图 6.4 所示,总共占用 24 + 4N 字节。

用户可以通过下标来访问数组元素,与 C/C++ 不同,Java 在引用数组元素时要进行越界检查,以确保程序的安全性,如果出现数组的下标越界的情况,就会自动抛出数组越界异常 (java.lang.ArrayIndexOutOfBoundsException)。
2. 二维数组
二维数组的声明、初始化和访问元素与一维数组相似,例如:
int[][] a = {{1,2}, {2,3}, {4,5}}; //静态初始化int[][] b = new int[2][3]; //动态初始化b[0][0] = 1;b[0][1] = 2;//...b[1][2] = 6;在 Java 语言中把二维数组看作是一维数组的数组,数组空间不是连续分配的,所以不要求二维数组中每一维的大小相同。例如:
int[][] a = new int[2][];a[0] = new int[3];a[1] = new int[5];System.out.printf("数组 a 的大小: %d\n", a.length); //输出: 2System.out.printf("a[0]的大小: %d\n", a[0].length); //输出: 3System.out.printf("a[1]的大小: %d\n", a[1].length); //输出: 5以 double[][] c = new double[M][N] 为例,c 是一个 M 行 N 列的二维数组,在内存中存储时,c 的每个元素又是一个一维数组,存放的是对应一维数组的引用,其内存空间如图 6.5 所示。这里每个对象引用地址为 8 字节,总共占用 24 + 8M + M(24 + 8N) = 24 + 32M + 8MN 字节。

有关 Java 数组的几点说明如下:
(1) Java 数组都是静态数组,一旦被声明其容量就固定了,不能改变,所以在声明数组时一定要考虑数组的最大容量,防止出现容量不够的现象。
(2) 如果希望在执行程序时动态地改变容量,可以使用 Java 集合中的 ArrayList 或者 Vector 容器。
(3) 由于 Java 中的数组(非基本类型的一维数组或者二维及二维以上的数组)并不像 C/C++ 中的数组那样占用一片连续空间,所以 C/C++ 数组的一些优化操作并不适合 Java 数组。
3. Java 中的数组类 Arrays
在 Java 中提供了 Arrays 类可以方便地操作数组,它提供的所有方法都是静态的,实际上在 Java 中建立的各种数组都看成是 Arrays 类的实例。
Arrays 类的主要方法(以 int 数据类型为例)如下。
(1) static int binarySearch(int[] a, int key):使用折半查找法来搜索 int 型数组 a,以获得指定的值。
(2) static int binarySearch(int[] a, int fromIndex, int toIndex, int key):使用折半查找法来搜索 int 数组 a 的 [fromIndex, toIndex) 范围,以获得指定的值。该范围从索引 fromIndex(包括)一直到索引 toIndex(不包括)。
(3) static boolean equals(int[] a1, int[] a2):如果两个指定的 int 型数组 a1 和 a2 彼此相等,则返回 true。
(4) static void fill(int[] a, int val):将指定的 int 值分配给 int 型数组 a 的每个元素。
(5) static void sort(int[] a):对 int 型数组 a 按数字升序进行排序。
(6) static void sort(int[] a, int fromIndex, int toIndex):对 int 型数组 a 的 [fromIndex, toIndex) 范围按数字升序进行排序。排序范围从索引 fromIndex(包括)一直到索引 toIndex(不包括),如果 fromIndex = toIndex,则排序范围为空。
(7) <E> void sort(E[] a, Comparator<? super E> c):根据指定比较器产生的顺序对对象数组 a 进行排序。
(8) <E> void sort(E[] a, int fromIndex, int toIndex, Comparator<? super E> c):根据指定比较器产生的顺序对指定对象数组 a 从索引 fromIndex(包括)一直到索引 toIndex(不包括)范围内的元素进行排序。
(9) static String toString(int[] a):返回 int 型数组 a 的内容的字符串表示形式。
6.1.4 数组的应用
6.2 特殊矩阵的压缩存储
1. 对称矩阵的压缩存储
若一个 n 阶方阵 A 的元素满足 aᵢ,ⱼ = aⱼ,ᵢ (0 ≤ i, j ≤ n-1),则称其为 n 阶对称矩阵。
如果直接采用二维数组来存储对称矩阵,占用的内存空间为 n² 个元素大小。由于对称矩阵的元素关于主对角线对称,因此在存储时可只存储其上三角和主对角线部分的元素,或者下三角和主对角线部分的元素,使得对称的元素共享同一存储空间,这样就可以将 n² 个元素压缩存储到 n(n+1)/2 个元素的空间中。不失一般性,按行优先存储时仅存储其下三角和主对角线部分的元素。
采用一维数组 B = {bₖ} 作为 n 阶对称矩阵 A 的压缩存储结构,在 B 中只存储对称矩阵 A 的下三角和主对角线部分的元素 aᵢ,ⱼ (i ≥ j),这样 B 中的元素个数为 n(n+1)/2。
(1) 将 A 中下三角和主对角线部分的元素 aᵢ,ⱼ (i ≥ j) 存储在 B 数组的 bₖ 元素中。那么 k 和 i, j 之间是什么关系呢?
对于这样的元素 aᵢ,ⱼ,求出它前面共存储的元素的个数。不包括第 i 行,它前面共有 i 行(行下标为 0 ~ i-1,第 0 行有 1 个元素,第 1 行有 2 个元素,…,第 i-1 行有 i 个元素),则这 i 行有 1 + 2 + ... + i = i(i+1)/2 个元素。在第 i 行中,元素 aᵢ,ⱼ 的前面也有 j 个元素,则元素 aᵢ,ⱼ 之前共有 i(i+1)/2 + j 个元素,所以有 k = i(i+1)/2 + j。
(2) 对于 A 中上三角部分的元素 aᵢ,ⱼ (i < j),其值等于 aⱼ,ᵢ,而 aⱼ,ᵢ 元素在 B 中的存储位置 k = j(j+1)/2 + i。
归纳起来,A 中任一元素 aᵢ,ⱼ 和 B 中的元素 bₖ 之间存在着如下对应关系:
k = { i(i + 1)/2 + j , i ≥ j j(j + 1)/2 + i , i < j}2. 三角矩阵的压缩存储
有些非对称的矩阵也可借用上述方法存储,例如 n 阶下(上)三角矩阵。所谓 n 阶下(上)三角矩阵,是指矩阵的上(下)三角部分(不包括主对角线)中的元素均为常数 c 的 n 阶方阵。
采用一维数组 B = {bₖ} 作为 n 阶三角矩阵 A 的存储结构,B 中的元素个数为 n(n+1)/2 + 1(其中常数 c 占用一个元素空间),则 A 中任一元素 aᵢ,ⱼ 和 B 中的元素 bₖ 之间存在着如下对应关系。
上三角矩阵:
k = { i(2n - i + 1)/2 + j - i , i ≤ j n(n + 1)/2 , i > j}下三角矩阵:
k = { i(i + 1)/2 + j , i ≥ j n(n + 1)/2 , i > j}其中,B 的最后元素 bₙ₍ₙ₊₁₎/₂ 中存放常数 c。
好的,这是从图片中提取的文字内容:
3. 对角矩阵的压缩存储
若一个 n 阶方阵 A 的所有非零元素都集中在以主对角线为中心的带状区域中,其他元素均为 0,则称其为 n 阶对角矩阵。其主对角线的上、下方各有 b 条次对角线,称 b 为矩阵的半带宽、(2b+1) 为矩阵的带宽。对于半带宽为 b (0 ≤ b ≤ (n-1)/2) 的对角矩阵,其 |i - j| ≤ b 的元素 aᵢ,ⱼ 不为零,其余元素为零。图 6.8 所示为半带宽为 b 的对角矩阵示意图。
对于 b = 1 的对角矩阵 A,只存储其非零元素,并按行优先存储到一维数组 B 中,将 A 的非零元素 aᵢ,ⱼ 存储到 B 的元素 bₖ 中,k 的计算过程如下:
(1) 当 i = 0 时为第 0 行,共两个元素。
(2) 当 i > 0 时,第 0 行~第 i-1 行共 2 + 3(i-1) 个元素。
(3) 对于非零元素 aᵢ,ⱼ,第 i 行最多 3 个元素,该行的首个非零元素为 aᵢ,ᵢ₋₁(另外两个元素是 aᵢ,ᵢ 和 aᵢ,ᵢ₊₁),即该行中元素 aᵢ,ⱼ 前面存储的非零元素的个数为 j - (i - 1) = j - i + 1。
所以非零元素 aᵢ,ⱼ 前面压缩存储的元素的总个数 = 2 + 3(i - 1) + j - i + 1 = 2i + j,即 k = 2i + j。
示例:
假设我们有一个 5x5 的对角矩阵 A,其半带宽 b=1。这意味着只有主对角线、以及它上下各一条对角线上的元素可能非零,其余元素均为0。
矩阵 A 如下:
A = [ [a₀₀, a₀₁, 0, 0, 0], [a₁₀, a₁₁, a₁₂, 0, 0], [ 0, a₂₁, a₂₂, a₂₃, 0], [ 0, 0, a₃₂, a₃₃, a₃₄], [ 0, 0, 0, a₄₃, a₄₄]]我们需要将这个矩阵的所有非零元素按行优先顺序压缩存储到一维数组 B 中。
计算过程
根据公式 k = 2i + j 来计算每个非零元素在 B 数组中的位置 k。
行号 i | 列号 j | 元素 aᵢ,ⱼ | 计算 k = 2i + j | 存储位置 B[k] |
|---|---|---|---|---|
| 0 | 0 | a₀₀ | 2*0 + 0 = 0 | B[0] |
| 0 | 1 | a₀₁ | 2*0 + 1 = 1 | B[1] |
| 1 | 0 | a₁₀ | 2*1 + 0 = 2 | B[2] |
| 1 | 1 | a₁₁ | 2*1 + 1 = 3 | B[3] |
| 1 | 2 | a₁₂ | 2*1 + 2 = 4 | B[4] |
| 2 | 1 | a₂₁ | 2*2 + 1 = 5 | B[5] |
| 2 | 2 | a₂₂ | 2*2 + 2 = 6 | B[6] |
| 2 | 3 | a₂₃ | 2*2 + 3 = 7 | B[7] |
| 3 | 2 | a₃₂ | 2*3 + 2 = 8 | B[8] |
| 3 | 3 | a₃₃ | 2*3 + 3 = 9 | B[9] |
| 3 | 4 | a₃₄ | 2*3 + 4 = 10 | B[10] |
| 4 | 3 | a₄₃ | 2*4 + 3 = 11 | B[11] |
| 4 | 4 | a₄₄ | 2*4 + 4 = 12 | B[12] |
最终压缩结果
一维数组 B 将包含所有非零元素,按上述顺序排列:
B = [a₀₀, a₀₁, a₁₀, a₁₁, a₁₂, a₂₁, a₂₂, a₂₃, a₃₂, a₃₃, a₃₄, a₄₃, a₄₄]6.3 稀疏矩阵
当一个阶数较大的矩阵中的非零元素个数 s 相对于矩阵元素的总个数 t 十分小时,即 s ≪ t 时,称该矩阵为稀疏矩阵。例如一个 100 × 100 的矩阵,若其中只有 100 个非零元素,就可称其为稀疏矩阵。
6.3.1 稀疏矩阵的三元组表示
由于稀疏矩阵中非零元素的个数很少,显然其压缩存储方法就是只存储非零元素。但不同于前面介绍的各种特殊矩阵,稀疏矩阵中非零元素的分布没有规律(或者说随机分布),所以在存储非零元素时除了元素值以外还需存储对应的行、列下标。这样稀疏矩阵中的每一个非零元素需由一个三元组 (i, j, aᵢ,ⱼ) 来表示,稀疏矩阵中的所有非零元素构成一个三元组线性表。
图 6.9 所示为一个 6 × 7 阶稀疏矩阵 A(为图示方便,所取的行、列数都很小)及其对应的三元组表示。从中看到,这里的稀疏矩阵三元组表示是一种顺序存储结构。

三元组表示中每个元素的类定义如下:
class TupElem<E> //三元组元素类{ int r; //行号 int c; //列号 int d; //元素值
public TupElem(int r1, int c1, int d1) //构造方法 { r = r1; c = c1; d = d1; }}设计稀疏矩阵三元组存储结构类 TupClass 如下:
public class TupClass //三元组表示类{ int rows; //行数 int cols; //列数 int nums; //非零元素个数 ArrayList<TupElem> data; //稀疏矩阵对应的三元组顺序表
public TupClass() //构造方法 { data = new ArrayList<TupElem>(); nums = 0; }
public void CreateTup(int[][] A, int m, int n) //创建三元组表示 public boolean SetValue(int i, int j, int x) //三元组元素赋值 A[i][j] = x public int GetValue(int i, int j) //执行 x = A[i][j] public void DispTup() //输出三元组表示}其中,data 为 ArrayList 对象,用于存放稀疏矩阵中所有的非零元素,通常按行优先顺序排列。这种有序结构可简化大多数稀疏矩阵运算算法。
6.3.2 稀疏矩阵的十字链表表示
十字链表是稀疏矩阵的一种链式存储结构。
对于一个 m × n 的稀疏矩阵,每个非零元素用一个结点表示,该结点中存放该非零元素的行号、列号和元素值。同一行中的所有非零元素结点链接成一个带头结点的行循环单链表,将同一列的所有非零元素结点链接成一个带头结点的列循环单链表。之所以采用循环单链表,是因为矩阵运算中常常是一行(列)操作完后进行下一行(列)操作,最后一行(列)操作完后进行首行(列)操作。
这样对稀疏矩阵的每个非零元素结点来说,它既是某个行链表中的一个结点,同时又是某个列链表中的一个结点,每个非零元素就好比在一个十字路口,由此称为十字链表。
每个非零元素结点的类型设计如图 6.10(a) 所示的结构,其中 i, j, value 分别代表非零元素所在的行号、列号和相应的元素值;down 和 right 分别称为向下指针和向右指针,分别用来链接同列中和同行中的下一个非零元素结点。

这样行循环单链表个数为 m(每一行对应一个行循环单链表),列循环单链表个数为 n(每一列对应一个列循环单链表),那么行、列头结点的个数就是 m + n。实际上,行头结点与列头结点是共享的,即 h[i] 表示第 i 行循环单链表的头结点,同时也是第 i 列循环单链表的头结点,这里 0 ≤ i < MAX(m, n),即行、列头结点的个数是 MAX(m, n),所有行、列头结点的类型与非零元素结点的类型相同。
另外,将所有行、列头结点再链接起来构成一个带头结点的循环单链表,这个头结点称为总头结点,即 hm,通过 hm 来标识整个十字链表。
总头结点的类型设计如图 6.10(b) 所示的结构(之所以这样设计,是为了与非零元素结点的类型一致,这样在整个十字链表中采用指针遍历所有结点时更方便),它的 link 域指向第一个行、列头结点,其 i, j 域分别存放稀疏矩阵的行数 m 和列数 n,而 down 和 right 域没有作用。
从中看出,在 m × n 的稀疏矩阵的十字链表存储结构中有 m 个行循环单链表,n 个列循环单链表,另外一个行、列头结点构成的循环单链表,总的循环单链表个数是 m + n + 1,总的头结点个数是 MAX(m, n) + 1。
设稀疏矩阵如下:
B₃ₓ₄ = [ [1, 0, 0, 2], [0, 0, 3, 0], [0, 0, 0, 4]]对应的十字链表如图 6.11 所示。为图示清楚,把每个行列头结点分别画成两个,实际上行列值相同的头结点只有一个。

十字链表元素结点和头结点合起来声明的结点类型如下:
class CNode //十字链表结点类{ int i; //行号 int j; //列号 CNode right, down; //向右和向下的指针 boolean tag; //tag 为 true 表示头结点,为 false 表示非零元素结点 int value; //存放非零元素值 CNode link; //头结点指针
public CNode(int i1, int j1, boolean tag1) { i = i1; j = j1; tag = tag1; right = down = link = null; }}第七章 树和二叉树
7.1 树
7.1.1 树的定义
树是由 n (n ≥ 0) 个结点组成的有限集合(记为 T)。如果 n = 0,它是一棵空树,这是树的特例;如果 n > 0,这 n 个结点中存在(有且仅存在)一个结点作为树的根结点(root),其余结点可分为 m (m ≥ 0) 个互不相交的有限集 (T₁, T₂, ..., Tₘ),其中每个子集本身又是一棵符合本定义的树,称为根结点的子树。
树的定义是递归的,因为在树的定义中又用到树的定义。它刻画了树的固有特性,即一棵树由若干棵互不相交的子树构成,而子树又由更小的若干棵子树构成。
树是一种非线性数据结构,具有以下特点:它的每一结点可以有零个或多个后继结点,但有且只有一个前驱结点(根结点除外);这些数据结点按分支关系组织起来,清晰地反映了数据元素之间的层次关系。可以看出,数据元素之间存在的关系是一对多的关系。
抽象数据类型树的定义如下:
ADT Tree{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, aᵢ 为 E 类型} 数据关系: R = {r} r = {<aᵢ, aⱼ> | aᵢ, aⱼ ∈ D, 0 ≤ i, j ≤ n-1, 其中每个结点最多只有一个前驱结点、 可以有零个或多个后继结点,有且仅有一个结点(即根结点)没有前驱结点} 基本运算: bool CreateTree(): 由树的逻辑结构表示建立其存储结构。 String toString(): 返回由树转换的括号表示串。 E GetParent(int i): 求编号为 i 的结点的双亲结点值。 ...}7.1.2 树的逻辑结构表示方法
树的逻辑结构表示方法有多种,但不管用户采用哪种表示方法,都应该能够正确地表达出树中数据元素之间的层次关系。下面是几种常见的树的逻辑结构表示方法。
(1) 树形表示法:用一个圆圈表示一个结点,圆圈内的符号代表该结点的数据信息,结点之间的关系通过连线表示。虽然每条连线上都不带有箭头(即方向),但它仍然是有向的,其方向隐含着从上向下,即连线的上方结点是下方结点的前驱结点,下方结点是上方结点的后继结点。它的直观形象是一棵倒置的树(树根在上,树叶在下),如图 7.2(a) 所示。
(2) 文氏图表示法:每棵树对应一个圆圈,圆圈内包含根结点和子树的圆圈,同一个根结点下的各子树对应的圆圈是不能相交的。在用这种方法表示的树中,结点之间的关系是通过圆圈的包含来表示的。图 7.2(a) 所示的树对应的文氏图表示法如图 7.2(b) 所示。
(3) 凹入表示法:每棵树的根对应着一个条形,子树的根对应着一个较短的条形,且树根在上,子树的根在下,同一个根下的各子树的根对应的条形长度是相同的。图 7.2(a) 所示的树对应的凹入表示法如图 7.2(c) 所示。
(4) 括号表示法:每棵树对应一个由根作为名字的表,表名放在表的左边,表是由在一个括号里的各子树对应的子表组成的,子表之间用逗号分开。在用这种方法表示的树中,结点之间的关系是通过括号的嵌套表示的。图 7.2(a) 所示的树对应的括号表示法如图 7.2(d) 所示。

7.1.3 树的基本术语
下面介绍树的基本术语。
(1) 结点的度与树的度:树中某个结点的子树的个数称为该结点的度。树中各结点的度的最大值称为树的度,通常将度为 m 的树称为 m 次树。例如,图 7.2(a) 是一棵 3 次树。
(2) 分支结点与叶子结点:度不为零的结点称为非终端结点,又叫分支结点。度为零的结点称为终端结点或叶子结点。在分支结点中,每个结点的分支数就是该结点的度。例如对于度为 1 的结点,其分支数为 1,被称为单分支结点;对于度为 2 的结点,其分支数为 2,被称为双分支结点,其余类推。例如,在图 7.2(a) 所示的树中,B、C 和 D 等是分支结点,而 E、F 和 J 等是叶子结点。
(3) 路径与路径长度:对于任意两个结点 kᵢ 和 kⱼ,一定存在一条路径连接该两个结点。例如,在图 7.2(a) 所示的树中,从结点 A 到结点 K 的路径为 A→D→I→K,其长度为 3。
(4) 孩子结点、双亲结点和兄弟结点:人话说就是子节点、父节点、同级节点。例如,在图 7.2(a) 所示的树中,结点 B、C 互为兄弟结点;结点 D 的子孙结点有 H、I、K、L 和 M;结点 I 的祖先结点有 A、D。
(5) 结点的层次和树的高度:根结点为第一层,它的孩子结点为第二层,以此类推,一个结点所在的层次为其双亲结点所在的层次加 1。树中结点的最大层次称为树的高度(或树的深度)。
(6) 有序树和无序树:若树中各结点的子树是按照一定的次序从左向右安排的,且相对次序是不能随意变换的,则称之为有序树,否则称为无序树。默认为有序树。
(7) 森林:n (n > 0) 个互不相交的树的集合称为森林。森林的概念与树的概念十分相近,因为只要把树的根结点删去就成了森林。反之,只要给 n 棵独立的树加上一个结点,并把这 n 棵树作为该结点的子树,则森林就变成了树。
7.1.4 树的性质
性质 1 树中的结点数等于所有结点的度之和加 1(根节点)。
性质 2 度为 m 的树中第 i 层上最多有 mⁱ⁻¹ 个结点 (i ≥ 1。0度:0;1度:1;2度:1/2;3度:1/3/9)。取最多时就称这个数为满 m 次树
性质 3 高度为 h 的 m 次树最多有 (mʰ - 1) / (m - 1) 个结点(也等于 `m⁰ + m¹ + m² + … + mʰ⁻¹)。
性质 4 具有 n 个结点的 m 次树的最小高度为 h = ⌈logₘ(n(m-1)+1)⌉ (向上取整)。
证明: 设具有 n 个结点的 m 次树的最小高度为 h,这样的树中前 h-1 层都是满的,即每一层的结点数都等于 mⁱ⁻¹ 个 (1 ≤ i ≤ h-1),第 h 层(即最后一层)的结点数可能满,也可能不满,但至少有一个结点。
根据树的性质 3 可得:(mʰ⁻¹ - 1) / (m - 1) < n ≤ (mʰ - 1) / (m - 1)
乘 (m - 1) 后得:mʰ⁻¹ - 1 < n(m - 1) + 1 ≤ mʰ
以 m 为底取对数后得:h - 1 < logₘ(n(m - 1) + 1) ≤ h
即 logₘ(n(m - 1) + 1) ≤ h < logₘ(n(m - 1) + 1) + 1
因 h 只能取整数,所以 h = ⌈logₘ(n(m - 1) + 1)⌉,结论得证。
例如,对于 2 次树,求最小高度的计算公式为 ⌈log₂(n + 1)⌉,若 n = 20,则最小高度为 5;
对于 3 次树,求最小高度的计算公式为 ⌈log₃(2n + 1)⌉,若 n = 20,则最小高度为 4。
【例 7.1】 若一棵 3 次树中度为 3 的结点为两个、度为 2 的结点为一个、度为 1 的结点为两个,则该 3 次树中总的结点个数和叶子结点个数分别是多少?
解: 设该 3 次树中总的结点个数、度为 0 的结点个数、度为 1 的结点个数、度为 2 的结点个数和度为 3 的结点个数分别为 n、n₀、n₁、n₂ 和 n₃。显然,每个度为 i 的结点在所有结点的度之和中贡献 i 个度。依题意有 n₁ = 2,n₂ = 1,n₃ = 2。由树的性质 1 可知:
n = 所有结点的度之和 + 1 = 0 × n₀ + 1 × n₁ + 2 × n₂ + 3 × n₃ + 1 = 11又因为 n = n₀ + n₁ + n₂ + n₃,即 n₀ = n - n₁ - n₂ - n₃ = 11 - 2 - 1 - 2 = 6
所以该 3 次树中总的结点个数和叶子结点个数分别是 11 和 6。
说明: 在 m 次树中计算结点时常用的关系式有:
① 树中所有结点的度之和 = 分支数 = n - 1;
② 所有结点的度之和 = n₁ + 2n₂ + ... + m × nₘ;
③ n = n₀ + n₁ + ... + nₘ。
7.1.5 树的基本运算
1. 先根遍历
先访问根结点,然后再按照从左到右的次序先根遍历根结点的每一棵子树。
例如,对于图 7.2(a) 所示的树,采用先根遍历得到的结点序列为 ABEFCGJDHIKLM。
2. 后根遍历
按照从左到右的次序后根遍历根结点的每一棵子树,最后访问根结点。
例如,对于图 7.2(a) 所示的树,采用后根遍历得到的结点序列为 EFBJGCHKLMDIA。
3. 层次遍历
层次遍历的过程为从根结点开始,按照从上到下、从左到右的次序访问树中的每一个结点。
例如,对于图 7.2(a) 所示的树,采用层次遍历得到的结点序列为 ABCDEFGHIJKLM
7.1.6 树的存储结构
1. 双亲存储结构
这种存储结构是一种顺序存储结构,用一组连续空间存储树的所有结点,同时在每个结点中附设一个伪指针指示其双亲结点的位置。双亲存储结构中结点类 PTree 的定义如下:
class PTree<E> //双亲存储结构的结点类{ E data; //存放结点的值 int parent; //存放双亲的位置(列表中的**指针**)}
PTree<E>[] t; //双亲存储结构(列表) t该存储结构利用了每个结点(根结点除外)只有唯一双亲的性质。在这种存储结构中,求某个结点的双亲结点十分容易,但在求某个结点的孩子结点时需要遍历整个结构。
2. 孩子链存储结构
在这种存储结构中,每个结点不仅包含数据值,还包括指向所有孩子结点的指针。由于树中每个结点的子树个数(即结点的度)不同,如果按各个结点的度设计变长结构,则每个结点的孩子结点的指针域个数可能不同,使算法实现非常麻烦。为此孩子链存储结构中按树的度(即树中所有结点的度的最大值)设计结点的孩子结点指针域个数。
孩子链存储结构的结点类 TSonNode 定义如下:
class TSonNode<E> //孩子链存储结构的结点类{ E data; //结点的值 TSonNode<E>[] sons; //孩子结点**列表**}其中,sons 数组的大小为 MaxSons,即为最多的孩子结点个数或该树的度。
孩子链存储结构的优点是查找某结点的孩子结点十分方便,其缺点是查找某结点的双亲结点比较费时,另外,当树的度较大时存在较多的空指针域,可以证明含有 n 个结点的 m 次树采用孩子链存储结构时有 mn - n + 1 个空指针域。
3. 孩子兄弟链存储结构
孩子兄弟链存储结构是为每个结点固定设计 3 个域,即一个数据元素域、一个指向该结点的第一个孩子结点的指针域、一个指向该结点的下一个兄弟结点的指针域。
孩子兄弟链存储结构中的结点类 TSBNode 定义如下:
class TSBNode<E> //孩子兄弟链存储结构中的结点类{ E data; //结点的值 TSBNode<E> hp; //指向兄弟 TSBNode<E> vp; //指向孩子结点}由于在树的孩子兄弟链存储结构中每个结点固定只有两个指针域,并且这两个指针是有序的(即兄弟域和孩子域不能混淆),所以孩子兄弟链存储结构实际上是把该树转换为二叉树的存储结构。这在后面将会讨论到,把树转换为二叉树所对应的结构恰好就是这种孩子兄弟链存储结构,所以孩子兄弟链存储结构的最大优点是可以方便地实现树和二叉树的相互转换。孩子兄弟链存储结构的缺点和孩子链存储结构的缺点一样,即从当前结点查找双亲结点比较麻烦,需要从树的根结点开始逐个结点比较查找。
7.2 二叉树
7.2.1 二叉树的概念
1. 二叉树的定义
二叉树也称为二分树,它是有限的结点集合,这个集合或者为空,或者由一个根结点和两棵互不相交的称为左子树和右子树的二叉树组成。
二叉树中的许多概念(例如结点的度、孩子结点、双亲结点、结点层次、子孙结点和祖先结点等)与树中的概念相同。在含 n 个结点的二叉树中,所有结点的度小于等于 2,通常用 n₀ 表示叶子结点个数、n₁ 表示单分支结点个数、n₂ 表示双分支结点个数。
需要注意二叉树和树是两种不同的树形结构,不能认为二叉树就是度为 2 的树(2 次树),实际上二叉树和度为 2 的树(2 次树)是不同的,其差别如下:
(1) 度为 2 的树中至少有一个结点的度为 2,也就是说,度为 2 的树中至少有 3 个结点,而二叉树没有这种要求,二叉树可以为空。 (2) 度为 2 的树中一个度为 1 的结点不分左、右子树,而二叉树中一个度为 1 的结点是严格区分左、右子树的。
二叉树有 5 种基本形态,如图 7.5 所示,任何复杂的二叉树都是这 5 种基本形态的组合。其中图 7.5(a) 是空二叉树,图 7.5(b) 是单结点的二叉树,图 7.5(c) 是右子树为空的二叉树,图 7.5(d) 是左子树为空的二叉树,图 7.5(e) 是左、右子树都不空的二叉树。
2. 二叉树的抽象数据类型
ADT BTree{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, aᵢ 为 E 类型} //为了简单,除了特别说明外假设 E 为 char 数据关系: R = {r} r = {<aᵢ, aⱼ> | aᵢ, aⱼ ∈ D, 0 ≤ i, j ≤ n-1, 当 n=0 时称为空二叉树,否则其中有一个根结点, 其他结点构成根结点的互不相交的左、右子树,该左、右两棵子树也是二叉树} 基本运算: void CreateBTree(string str): 根据二叉树的括号表示串建立其存储结构。 String toString(): 返回由二叉树转换的括号表示串。 BTNode FindNode(x): 在二叉树中查找值为 x 的结点。 int Height(): 求二叉树的高度。 ...}3. 满二叉树和完全二叉树
在一棵二叉树中,如果所有分支结点都有左、右孩子结点,并且叶子结点都集中在二叉树的最下一层,这样的二叉树称为满二叉树。图 7.6(a) 所示就是一棵满二叉树。用户可以对满二叉树的结点进行层序编号,约定编号从树根为 1 开始,按照层次从小到大、同一层从左到右的次序进行,图中每个结点旁的数字为对该结点的编号。满二叉树也可以从结点个数和树高度之间的关系来定义,即一棵高度为 h 且有 2ʰ - 1 个结点的二叉树称为满二叉树。
满二叉树的特点如下:
(1) 叶子结点都在最下一层。
(2) 只有度为 0 和度为 2 的结点。
(3) 含 n 个结点的满二叉树的高度为 log₂(n + 1),叶子结点个数为 ⌊n/2⌋ + 1,度为 2 的结点个数为 ⌊n/2⌋。
若二叉树中最多只有最下面两层的结点的度可以小于 2,并且最下面一层的叶子结点都依次排列在该层最左边的位置上,则这样的二叉树称为完全二叉树,图 7.6(b) 所示为一棵完全二叉树。
好的,这是从图片中提取的文字内容:
不难看出,满二叉树是完全二叉树的一种特例,并且完全二叉树与同高度的满二叉树对应结点的层序编号相同。图 7.6(b) 所示的完全二叉树与等高度的满二叉树相比,它在最后一层的右边缺少了 4 个结点。
完全二叉树的特点如下:
(1) 叶子结点只可能出现在最下面两层中。
(2) 最下一层中的叶子结点都依次排列在该层最左边的位置上。
(3) 如果有度为 1 的结点,只可能有一个,且该结点最多只有左孩子而无右孩子。
(4) 按层序编号后,一旦出现某结点(其编号为 i)为叶子结点或只有左孩子,则编号大于 i 的结点均为叶子结点。
7.2.2 二叉树的性质
性质 1 非空二叉树上叶子结点数等于双分支结点数加 1。
证明: 在一棵二叉树中,总结点数 n = n₀ + n₁ + n₂,所有结点的分支数(即所有结点的度之和)应等于单分支结点数加上双分支结点数的两倍,即总的分支数 = n₁ + 2n₂。
因为二叉树中除根结点以外,每个结点都有唯一的双亲,每个这样的父子关系对应一个分支,所以上述三个等式可得 n₁ + 2n₂ = n₀ + n₁ + n₂ - 1,即 n₀ = n₂ + 1。
说明: 在二叉树中计算结点时常用的关系式有:
① 所有结点的度之和 = 分支数 = n - 1;
② 所有结点的度之和 = n₁ + 2n₂;
③ n = n₀ + n₁ + n₂。
性质 2 对完全二叉树中层序编号为 i 的结点 (1 ≤ i ≤ n, n ≥ 1, n 为总结点数) 有:
(1) 若 i ≤ ⌊n/2⌋,即 2i ≤ n,则编号为 i 的结点为分支结点,否则为叶子结点。
(2) 若 n 为奇数,则 n₁ = 0,这样每个分支结点都是双分支结点[例如图 7.6(b) 所示的完全二叉树就是这种情况,其中 n=11,分支结点 1~5 都是双分支结点,其他为叶子结点];若 n 为偶数,则 n₁ = 1,只有一个单分支结点,该单分支结点是编号最大的分支结点(编号为 ⌊n/2⌋)。
(3) 若编号为 i 的结点有左孩子结点,则左孩子结点的编号为 2i;若编号为 i 的结点有右孩子结点,则右孩子结点的编号为 2i + 1。
(4) 若编号为 i 的结点有双亲结点,其双亲结点的编号为 ⌊i/2⌋。
**【例7.4】**一棵完全二叉树中有501个叶子结点,则最少有多少个结点?
解: 该二叉树中有 ,由二叉树的性质 1 可知 ,所以 ,则 。由于完全二叉树中 或 ,则 时结点 个数最少,此时 n=1001 ,即最少有 1001 个结点。
7.2.3 二叉树的存储结构
二叉树主要有顺序存储结构和链式存储结构两种,本节分别予以介绍。
1. 二叉树的顺序存储结构
在顺序存储一棵二叉树时,就是用一组连续的存储单元存放二叉树中的结点。由二叉树的性质 4 可知,对于完全二叉树和满二叉树,树中结点的层序可以唯一地反映出结点之间的逻辑关系,所以可以用一维数组按层序顺序存储树中所有的结点值,通过数组元素的下标关系反映完全二叉树或满二叉树中结点之间的逻辑关系。
例如,图 7.6(b) 所示的完全二叉树对应的顺序存储结构 sb 如图 7.9 所示,编号为 i 的结点值存放在数组下标为 i 的元素中。由于 Java 语言中数组的下标从 0 开始,这里为了保持一致性而没有使用下标为 0 的数组元素。

然而对于一般的二叉树,如果仍按照从上到下和从左到右的顺序将树中的结点顺序存储在一维数组中,则数组元素的下标关系不能够反映二叉树中结点之间的逻辑关系,这时可将一般二叉树进行改造,增添一些并不存在的空结点,使之成为一棵完全二叉树的形式,并对所有结点进行层序编号,再把各结点值按编号存储到一维数组中[空结点在数组中用特殊值(例如 '#')表示],如图 7.11 所示。也就是说,一般二叉树采用顺序存储结构后,各结点的编号与等高度的完全二叉树中相应位置上结点的编号相同。
二叉树的顺序存储结构采用字符串(或者字符数组)存 放:
String sb; //二叉树的顺序存储结构用 sb 字符串存储显然,完全二叉树或满二叉树采用顺序存储结构比较合适,既能够最大可能地节省存储空间,又可以利用数组元素的下标确定结点在二叉树中的位置以及结点之间的关系。对于一般二叉树,如果它接近于完全二叉树形态,需要增加的空结点个数不多,也可采用顺序存储结构。如果需要增加很多空结点才能将一棵二叉树改造成为一棵完全二叉树,采用顺序存储结构会造成空间的大量浪费,这时不宜用顺序存储结构。最坏情况是右单支树(除叶子结点外每个结点只有一个右孩子),一棵高度为 h 的右单支树只有 h 个结点,却需要分配 2ʰ - 1 个存储单元。
在顺序存储结构中,查找一个结点的孩子、双亲结点都很方便,编号(下标)为 i 的结点的层次为 ⌊log₂(i+1)⌋ (向下取整)。
2. 二叉树的链式存储结构
二叉树的链式存储结构是指用一个链表来存储一棵二叉树,二叉树中的每一个结点用链表中的一个链结点来存储。在二叉树中,标准存储方式的结点结构为 (lchild, data, rchild),其中,data 为值成员变量,用于存储对应的数据元素,lchild 和 rchild 分别为左、右指针变量,用于分别存储左孩子和右孩子结点(即左、右子树的根结点)的地址。这种链式存储结构通常简称为二叉链。
对应 Java 语言的二叉链结点类 BTNode<E> 定义如下:
class BTNode<E> //二叉链中的结点类{ E data; //存放数据元素 BTNode lchild; //指向左孩子结点 BTNode rchild; //指向右孩子结点
public BTNode() //默认构造方法 { lchild = rchild = null; }
public BTNode(E d) //重载构造方法 { data = d; lchild = rchild = null; }}7.2.4 二叉树的递归算法设计
7.2.5 二叉树的基本运算及其实现
二叉树类的设计
在二叉链中通过根结点 b 来唯一标识二叉树,对应的二叉树类设计如下:
public class BTreeClass //二叉树类{ BTNode<Character> b; //根结点 String bstr; //二叉树的括号表示串
public BTreeClass() //构造方法 { b = null; } //二叉树基本运算算法}二叉树基本运算算法的实现
下面讨论二叉树基本运算算法的实现。
创建二叉树:CreateBTree(str)
假设采用括号表示串 str 表示的二叉树是正确的,且每个结点的值是单个字符(比如"A(B(D,G),C(E,F))")。用 ch 扫描 str,其中只有 4 类字符,各类字符的处理方式如下:
(1) ch = '(':表示前面刚创建的 p 结点存在着孩子结点,需将其进栈,以便建立它和其孩子结点的关系(如果一个结点刚创建完毕,其后的一个字符不是 '(',表示该结点是叶子结点,不需要进栈)。然后开始处理该结点的左孩子,因此置 flag = true,表示其后创建的结点将作为这个结点(栈顶结点)的左孩子结点。
(2) ch = ')':表示以栈顶结点为根结点的子树创建完毕,将其退栈。
(3) ch = ',':表示开始处理栈顶结点的右孩子结点,置 flag = false。
(4) 其他情况:只能是单个字符,表示要创建一个新结点 p,根据 flag 值建立 p 结点与栈顶结点之间的关系,当 flag = true 时表示 p 结点作为栈顶结点的左孩子结点,当 flag = false 时表示 p 结点作为栈顶结点的右孩子结点。
如此循环直到 str 处理完毕。对应的算法如下:
public void CreateBTree(String str) //创建以 b 为根结点的二叉链存储结构{ Stack<BTNode> st = new Stack<BTNode>(); // 建立一个栈 st BTNode<Character> p = null; // 当前节点对象 boolean flag = true; // 是否存在子树 char ch; int i = 0;
while(i < str.length()) //循环扫描 str 中的每个字符 { ch = str.charAt(i); switch(ch) { case '(': st.push(p); // 刚刚新建的结点(上一个循环中创建的父节点, p != null)有孩子, 将其进栈 flag = true; // 存在子树 break;
case ')': st.pop(); // 栈顶结点的子树处理完, 出栈 break;
case ',': flag = false; // 开始处理栈顶结点的右孩子, 此时不处理子树 break;
default: // 处理节点对象 p = new BTNode<Character>(ch); //用 ch 值新建一个结点 if(b == null){ // 若尚未建立根结点, p 作为根结点 b = p; break; } if(flag) // 这个节点是父元素的(左)子树 { if(!st.empty()) // 可看作保险机制,确保 `st.peek()` 执行成功 st.peek().lchild = p; // 新结点 p 作为栈顶结点(父节点)的左孩子 } else // 这个节点是父元素的右子树 { if(!st.empty()) st.peek().rchild = p; // 新结点 p 作为栈顶结点的右孩子 } } i++; // 继续遍历 }}7.3 二叉树的先序、中序和后序遍历
7.3.1 二叉树遍历的概念
二叉树遍历是指按照一定的次序访问二叉树中的每个结点,并且每个结点仅被访问一次的过程。通过遍历得到二叉树中某种结点的线性序列,即将非线性结构线性化,这里“访问”的含义可以很多,例如输出结点值或对结点值实施某种运算等。二叉树遍历是最基本的运算,是二叉树中所有其他运算的基础。
在二叉树中左子树和右子树是有严格区别的,在遍历一棵非空二叉树时,根据访问根结点、遍历左子树和遍历右子树之间的先后关系可以组合成 6 种遍历方法,若再规定先遍历左子树,后遍历右子树,则对于非空二叉树,可得到如下 3 种递归的遍历方法(即 NLR、LNR 和 LRN)。
1. 先序遍历
先访问根结点,再先序遍历左右子树。
2. 中序遍历
首先中序遍历左子树,中间访问根结点,最后中序遍历右子树。
3. 后序遍历
首先后序遍历左右子树,最后访问根结点。
7.3.2 先序、中序和后序遍历递归算法
7.3.3 递归遍历算法的应用
【例 7.8】 假设二叉树采用二叉链存储结构存储,设计一个算法将二叉树 bt1 复制到二叉树 bt2。
解: 采用直接递归算法设计方法。设 f(t1, t2) 是由二叉链 t1 复制产生 t2,这是“大问题”。f(t1.lchild, t2.lchild) 和 f(t1.rchild, t2.rchild) 分别复制左子树和右子树,它们是“小问题”。假设小问题可解,也就是说左、右子树都可复制,则只需复制根结点,如图 7.15 所示。对应的递归模型如下:
f(t1, t2) ≡ t2 = null , t1 = nullf(t1, t2) ≡ 由 t1 根结点复制产生 t2 根结点; , t1 ≠ null f(t1.lchild, t2.lchild); f(t1.rchild, t2.rchild);
对应的递归算法如下(复制产生的 t2 作为方法的返回值):
public static BTreeClass CopyBTree1(BTreeClass bt1) // 基于先序遍历复制二叉树{ BTreeClass bt2 = new BTreeClass(); bt2.b = CopyBTree11(bt1.b); // 复制根节点(二叉树根节点对象) return bt2;}
private static BTNode<Character> CopyBTree11(BTNode<Character> t1) // 由 t1 复制产生 t2{ if (t1 != null) { BTNode<Character> t2 = new BTNode<Character>(t1.data); // 复制根结点 t2.lchild = CopyBTree11(t1.lchild); // 递归复制左子树 t2.rchild = CopyBTree11(t1.rchild); // 递归复制右子树 return t2; } return null; // t1 为空时返回 null}【例 7.11】 假设二叉树采用二叉链存储结构,且所有结点值均不相同,设计一个算法输出值为 x 的结点的所有祖先。
解法 1: 根据二叉树中祖先的定义可知,若一个结点的左孩子或右孩子值为 x,则该结点是 x 结点的祖先结点;若一个结点的左孩子或右孩子为 x 结点的祖先结点,则该结点也为 x 结点的祖先结点。设 f(t, x) 表示 t 结点是否为 x 结点的祖先结点,对应的递归模型 f(t, x) 如下:
f(b, x) = false , b == nullf(b, x) = true, 并输出 b 结点 , b 结点有值为 x 的左孩子结点f(b, x) = true, 并输出 b 结点 , b 结点有值为 x 的右孩子结点f(b, x) = true, 并输出 b 结点 , f(b.lchild, x) 为 true 或 f(b.rchild, x) 为 truef(b, x) = false , 其他对应的求解类如下:
public class Exam7_11{ static String ans; //存放 x 结点的所有祖先结点
public static String Ancestor1(BTreeClass bt, char x) //返回 x 结点的祖先 { ans = ""; Ancestor11(bt.b, x); return ans; }
private static boolean Ancestor11(BTNode<Character> t, Character x) { if (t == null) return false;
if (t.lchild != null and t.lchild.data == x) { ans += t.data + " "; //t 结点是 x 结点的祖先 return true; }
if (t.rchild != null and t.rchild.data == x) { ans += t.data + " "; //t 结点是 x 结点的祖先 return true; }
if (Ancestor11(t.lchild, x) || Ancestor11(t.rchild, x)) { ans += t.data + " "; //祖先的祖先也是祖先 return true; }
return false; //其他情况返回 false }}【例 7.12】 假设二叉树采用二叉链存储结构,且所有结点值均不相同,设计一个算法求二叉树的宽度(二叉树中结点个数最多的那一层的结点个数)。
解: 设置一个数组 w,w[i] 表示第 i (1 ≤ i ≤ 最大层次) 层的结点个数(初始所有元素为 0),采用先序遍历 w(通过递归算法参数赋初值的方式指定根结点的层次为 1),w 中的最大元素就是该二叉树的宽度。对应的求解类如下:
public class Exam7_12{ final static int MaxLevel = 100; //最大层次 static int[] w = new int[MaxLevel]; //存放每一层的结点个数
public static int Width(BTreeClass bt) //求二叉树的宽度 { Width1(bt.b, 1); int ans = 0; for(int i = 1; i < MaxLevel; i++) //求 w 中的最大元素 if(ans < w[i]) ans = w[i]; return ans; }
private static void Width1(BTNode<Character> t, int h) //被 Width() 方法调用 { if(t == null) return; w[h]++; //第 h 层的结点个数增 1 Width1(t.lchild, h + 1); //遍历左子树 Width1(t.rchild, h + 1); //遍历右子树 }}【例 7.13】 假设二叉树采用二叉链存储结构,且所有结点值均不相同,设计一个算法求二叉树中第 k (1 ≤ k ≤ 二叉树的高度) 层的结点个数。
解: 采用先序遍历思路,设计 KCount11(BTNode<Character> t, int h, int k) 递归算法在根结点 t 的二叉树中求第 k 层的结点个数 cnt,其中 h 表示 t 指向结点的层次(采用参数赋初值的方法,t 为根结点时 h 对应的实参数为 1)。对应的求解类如下:
public class Exam7_13{ static int cnt; //累计第 k 层的结点个数
public static int KCount1(BTreeClass bt, int k) //求二叉树的第 k 层的结点个数 { cnt = 0; KCount11(bt.b, 1, k); return cnt; }
private static void KCount11(BTNode<Character> t, int h, int k) // h: current height; k: target height { if(t == null) //空树返回 return;
if(h == k) //当前层的结点在第 k 层, cnt 增 1 cnt++;
if(h < k) //当前结点层小于 k, 递归处理左、右子树 { KCount11(t.lchild, h + 1, k); KCount11(t.rchild, h + 1, k); } }}*7.3.4 先序、中序和后续遍历非递归算法
7.4 二叉树的层次遍历
7.4.1 层次遍历过程
7.4.2 层次遍历算法设计
在二叉树的层次遍历中,对一层的结点访问完后,再按照它们的访问次序对各个结点的左、右孩子顺序访问,这样一层一层进行,即先访问结点的左、右孩子也先访问,这与队列的先进先出的特点吻合,因此层次遍历算法采用一个队列 qu 来实现。
先将根结点 b 进队,在队不空时循环:从队列中出队一个结点 p,访问它;若它有左孩子结点,将左孩子结点进队;若它有右孩子结点,将右孩子结点进队。如此操作直到队空为止。对应的算法如下:
public void LevelOrder(BTreeClass bt) //层次遍历的算法{ BTNode<Character> p; Queue<BTNode> qu = new LinkedList<BTNode>(); //定义一个队列 qu qu.offer(bt.b); //根结点进队 while(!qu.isEmpty()) //队不空时循环 { p = qu.poll(); //出队一个结点 System.out.print(p.data + " "); //访问 p 结点 if(p.lchild != null) qu.offer(p.lchild); //有左孩子时将其进队 if(p.rchild != null) qu.offer(p.rchild); //有右孩子时将其进队 }}7.4.3 层次遍历算法的应用
7.5 二叉树的构造
7.5.1 由先序/中序序列或后续/中序序列构造二叉树
定理7.1: 任何 n(n≥0) 个不同结点的二叉树都可由它的中序序列和先序序列唯一地确定。
假设二叉树的每个结点的值为单个字符,且没有值相同的结点。由先序序列 pre[i..i+n-1] 和中序序列 in[j..j+n-1] 创建二叉链 t 的过程如图 7.28 所示。

对应的构造算法如下:
public static BTreeClass CreateBTI(String pre, String in) //由先序序列 pre 和中序序列 in 创建二叉树{ BTreeClass bt = new BTreeClass(); bt.b = CreateBTI1(pre, 0, in, 0, pre.length()); return bt;}
private static BTNode<Character> CreateBTI1(String pre, int i, String in, int j, int n) // pre: 先序序列; i: 先序序列起始位置; in: 中序序列; j: 中序序列起始位置; n: 子树长度{ BTNode<Character> t; char ch; int p, k;
if(n <= 0) return null; // 如果当前要处理的子树长度 n 为 0 或负数,说明这是一个空树,直接返回 null ch = pre.charAt(i); // 先序序列的第一个元素必定是整棵树的根结点, 根节点的值为 ch t = new BTNode<Character>(ch); // 创建根节点 t p = j; // 创建临时遍历 p 用于查找中序序列中根节点的位置,初始为最左 while(p < j + n) // 遍历中序序列(左子树 -> 根节点 ×-> 右子树) { if(in.charAt(p) == ch) // 碰到根节点停止循环: 此时 p 等于中序序列中根节点的位置 break; p++; }
k = p - j; // 确定左子树中的结点个数 k t.lchild = CreateBTI1(pre, i + 1, in, j, k); // 递归构造左子树: i+1 是先序遍历中左子树的位置 t.rchild = CreateBTI1(pre, i + k + 1, in, p + 1, n - k - 1); // 递归构造右子树: i+k+1 是先序遍历中右子树的位置; p + 1 是中序遍历中右子树的位置(根节点指针 + 1); n - k - 1: 右节点的子树个数: 总节点数 - 左子树 - 根节点 return t;}例如,已知先序序列为 ABDGCEF、中序序列为 DGBAECF,则构造二叉树的过程如图 7.29 所示。

【例 7.17】 若某非空二叉树的先序序列和后序序列正好相同,则该二叉树的形态是什么?
解: 用 N 表示根结点,L、R 分别表示根结点的左、右子树,二叉树的先序序列是 NLR,后序序列是 LRN。如果要使 NLR = LRN 成立,则 L 和 R 均为空。所以满足条件的二叉树只有一个根结点。
【例 7.18】 若某非空二叉树的先序序列和中序序列正好相反,则该二叉树的形态是什么?
解: 二叉树的先序序列是 NLR,中序序列是 LNR。如果要使 NLR = RNL(中序序列的反序)成立,则 R 必须为空。所以满足条件的二叉树的形态是所有结点没有右子树。
*7.5.2 序列化与反序列化
7.6 线索二叉树
7.6.1 线索二叉树的定义
对于含 n 个结点的二叉树,在采用二叉链存储结构时每个结点有两个指针成员,总共有 2n 个指针成员,又由于只有 n-1 个结点被有效指针所指向(n 个结点中只有根结点没有被有效指针所指向),则共有 2n - (n - 1) = n + 1 个空指针。
遍历二叉树的结果是一个结点的线性序列。用户可以利用这些空指针存放相应的前驱结点和后继结点的地址,这样的指向该线性序列中的“前驱结点”和“后继结点”的指针称作线索。
由于遍历方式不同,产生的遍历线性序列也不同,做如下规定:当某结点的左指针为空时,让该指针指向对应遍历序列的前驱结点;当某结点的右指针为空时,让该指针指向对应遍历序列的后继结点。但如何区分左指针指向的结点是左孩子还是前驱结点,右指针指向的结点是右孩子还是后继结点呢?为此在结点的存储结构上增加两个标识位来区分这两种情况,左、右标识的取值如下:
左标识 ltag = { 0, 表示 lchild 指向左孩子结点 { 1, 表示 lchild 指向前驱结点的线索
右标识 rtag = { 0, 表示 rchild 指向右孩子结点 { 1, 表示 rchild 指向后继结点的线索这样每个结点的存储结构如图 7.33 所示。
按上述方法在每个结点上添加线索的二叉树称作线索二叉树。对二叉树以某种方式遍历使其变为线索二叉树的过程称为线索化。
为了使算法设计方便,在线索二叉树中再增加一个头结点。头结点的 data 成员为空,lchild 指向二叉树的根结点,ltag 为 0,rchild 指向遍历序列的尾结点,rtag 为 1。图 7.34 为图 7.12(a) 所示二叉树的线索二叉树。其中,图 7.34(a) 是中序线索二叉树(中序序列为 DGBAECF),图 7.34(b) 是先序线索二叉树(先序序列为 ABDGCEF),图 7.34(c) 是后序线索二叉树(后序序列为 GDBEFCA)。图中实线表示二叉树原来指针所指的结点,虚线表示线索二叉树所添加的线索。

注意: 在中序、先序和后序线索二叉树中所有的实线均相同,即线索化之前的二叉树相同,所有结点的标识位的取值也完全相同,只是当标识位取 1 时不同的线索二叉树将用不同的虚线表示,即不同的线索树中线索指向的前驱结点和后继结点不同。
7.6.2 线索化二叉树
从 7.6.1 节的讨论得知,对同一棵二叉树的遍历方式不同,所得到的线索树也不同,二叉树有先序、中序和后序 3 种遍历方式,所以线索树也有先序线索二叉树、中序线索二叉树和后序线索二叉树 3 种。这里以中序线索二叉树为例讨论建立线索二叉树的算法。
建立线索二叉树,或者说对二叉树线索化,实质上就是遍历一棵二叉树,在遍历的过程中检查当前结点的左、右指针域是否为空,如果为空,将它们改为指向前驱结点或后继结点的线索。另外,在对一棵二叉树添加线索时创建一个头结点,并建立头结点与二叉树的根结点的线索。在对二叉树线索化后还需建立尾结点与头结点之间的线索。
为了实现线索化二叉树,将前面二叉链结点的类型的定义修改如下:
class ThNode //线索二叉树的结点类型{ char data; //存放结点值 ThNode lchild, rchild; //左、右孩子或线索的指针 int ltag, rtag; //左、右标识
public ThNode() //默认构造方法 { lchild = rchild = null; ltag = rtag = 0; }
public ThNode(char d) //重载构造方法 { data = d; lchild = rchild = null; ltag = rtag = 0; }}本节仅讨论二叉树的中序线索化,设计中序线索化二叉树类 ThreadClass 如下:
public class ThreadClass{ ThNode b; // 二叉树的(当前)根结点 ThNode root; // 线索二叉树的头结点 ThNode pre; // 用于中序线索化, 指向中序前驱结点
String bstr;
public ThreadClass() { root = null; } //中序线索二叉树的基本运算}中序线索二叉树的算法如下:
public void CreateThread() //建立以 root 为头结点的中序线索二叉树{ root = new ThNode(); // 创建头结点 root root.ltag = 0; root.rtag = 1; // 头结点的域置初值(前驱节点不存在; 存在后继节点) if(b == null) // b 为空树时 { root.lchild = root; // ltag = 0; lchild 指向左孩子结点。赋值为它自身,避免在遍历等操作中对空树进行特殊判断。 root.rchild = null; // rtag = 1; rchild 指向后继结点的线索 } else // b 不为空树时 { root.lchild = b; // ltag = 0; lchild 指向左孩子结点。 pre = root; // pre 是 p 的前驱结点, 用于线索化 Thread(b); // 中序遍历线索化二叉树 pre.rchild = root; // rtag = 1; rchild 指向后继结点的线索 pre.rtag = 1; root.rchild = pre; // 根结点右线索化 }}
private void Thread(ThNode p) //对以 p 为根结点的二叉树进行中序线索化{ if(p != null) { Thread(p.lchild); //左子树线索化
if(p.lchild == null) //结点 p 的左指针为空 { p.lchild = pre; //给结点 p 添加前驱线索 p.ltag = 1; } else p.ltag = 0;
if(pre.rchild == null) //结点 pre 的右指针为空 { pre.rchild = p; //给结点 pre 添加后继线索 pre.rtag = 1; } else pre.rtag = 0;
pre = p; //置 p 结点为下一个访问结点的前驱结点
Thread(p.rchild); //右子树线索化 }}CreateThread() 算法是将以二叉链存储的二叉树 b(b 指向二叉链的根结点)进行中序线索化,并返回线索化后的头结点 root。其算法思路是先创建头结点 root,其 lchild 为链指针,rchild 为线索。如果二叉树 b 为空,则将其 lchild 指向自身;如果二叉树 b 不为空,则将 root 的 lchild 指向 b 结点。
首先 p 指向 b 结点,pre 指向 root 结点(pre 作为类成员变量),然后调用 Thread(p) 对整个二叉树线索化。最后加入指向头结点的线索,并将头结点的 rchild 指针域线索化为指向尾结点(由于线索化直到 p 等于 null 为止,所以线索化结束后尾结点为 pre 结点)。
Thread(p) 算法采用中序递归遍历对以 p 为根结点的二叉树中序线索化。在整个算法中 p 总是指向当前访问的结点,pre 指向其前驱结点。
(1) 若 p 结点没有左孩子结点,置其 lchild 指针为线索,指向前驱结点 pre,ltag 为 1,如图 7.35(a) 所示;否则表示 lchild 指向其左孩子结点,置其 ltag 为 0。
(2) 若 pre 结点的 rchild 指针为 null,置其 rchild 指针为线索,指向其后继结点 p,rtag 为 1,如图 7.35(b) 所示;否则表示 rchild 指向其右孩子结点,置其 rtag 为 0。再将 pre 替换成 p 作为中序遍历下一个访问结点的前驱结点。

7.6.3 遍历线索二叉树
遍历某种次序的线索二叉树的过程分为两个步骤,一是找到该次序下的开始结点,访问该结点;二是从刚访问的结点出发,反复找到该结点在该次序下的后继结点并访问之,直到尾结点为止。
在先序线索二叉树中查找一个结点的先序后继结点很简单,而查找先序前驱结点必须知道该结点的双亲结点。同样,在后序线索二叉树中查找一个结点的后序前驱结点也很简单,而查找后序后继结点也必须知道该结点的双亲结点。由于二叉链中没有存放双亲的指针,所以在实际应用中先序线索二叉树和后序线索二叉树较少用到,这里主要讨论中序线索二叉树的中序遍历。
在中序线索二叉树中,尾结点的 rchild 指针被线索化为指向头结点 root,在其中实现中序遍历的两个步骤如下。
(1) 求中序序列的开始结点:实际上该结点就是根结点的最左下结点。
(2) 对于一个结点 p,求其后继结点的过程如下:
① 如果 p 结点的 rchild 指针为线索,则 rchild 所指为其后继结点。
② 否则 p 结点的后继结点是其右孩子 q 的最左下后继结点,如图 7.37 所示。

这样得到中序线索二叉树中实现中序遍历的算法如下:
public void ThInOrder() //中序线索二叉树的中序遍历{ ThNode p = root.lchild; while(p != root) { while(p != root && p.ltag == 0) //找中序开始结点 p = p.lchild; System.out.print(p.data + " "); //访问 p 结点 while(p.rtag == 1 && p.rchild != root) //如果是线索, 一直找下去 { p = p.rchild; System.out.print(p.data + " "); //访问 p 结点 } p = p.rchild; //如果不再是线索, 转向其右子树 }}显然该算法是一个非递归算法,算法的时间复杂度为 O(n),空间复杂度为 O(1),相比递归和非递归中序遍历算法的时间和空间复杂度均为 O(n),其空间性能得到改善。
7.7 哈夫曼树
7.7.1 哈夫曼树的定义
在许多应用中经常将树中的结点赋上一个有着某种意义的数值,称此数值为该结点的权。从树根结点到某个结点之间的路径长度与该结点上权的乘积称为结点的带权路径长度。一棵二叉树中所有叶子结点的带权路径长度之和称为该树的带权路径长度,通常记为:
WPL = Σ (wᵢ × lᵢ) (i 从 0 到 n₀-1)其中,n₀ 表示叶子结点个数;wᵢ 和 lᵢ (0 ≤ i ≤ n₀ - 1) 分别表示叶子结点 kᵢ 的权值和根到 kᵢ 之间的路径长度(即从根到达该叶子结点的路径上的分支数)。
在 n₀ 个带权叶子结点构成的所有二叉树中,带权路径长度最小的二叉树称为哈夫曼树(或最优二叉树)。因为构造这种树的算法最早是由哈夫曼于 1952 年提出的,所以这种树被称为哈夫曼树。
例如给定 4 个叶子结点,设其权值分别为 1、3、5、7,可以构造出形状不同的 4 棵二叉树,如图 7.38 所示。图中带阴影的结点表示叶子结点,结点中的值表示权值。它们的带权路径长度分别为:
(a) WPL = 1×2 + 3×2 + 5×2 + 7×2 = 32 (b) WPL = 1×2 + 3×3 + 5×3 + 7×1 = 33 (c) WPL = 7×3 + 5×3 + 3×2 + 1×1 = 43 (d) WPL = 1×3 + 3×3 + 5×2 + 7×1 = 29

由此可见,对于一组具有确定权值的叶子结点可以构造出多个具有不同带权路径长度的二叉树,把其中最小带权路径长度的二叉树称作哈夫曼树,又称最优二叉树。可以证明,图 7.38(d) 所示的二叉树是一棵哈夫曼树。
7.7.2 哈夫曼树的构造算法
给定 n₀ 个权值,如何构造一棵含有 n₀ 个带有给定权值的叶子结点的二叉树,使其带权路径长度最小呢?哈夫曼最早给出了一个带有一般规律的算法,称为哈夫曼算法。哈夫曼算法如下:
(1) 根据给定的 n₀ 个权值 W = (w₀, w₁, ..., wₙ₀₋₁),对应结点构成 n₀ 棵二叉树的森林 T = (T₀, T₁, ..., Tₙ₀₋₁),其中每棵二叉树 Tᵢ (0 ≤ i ≤ n₀ - 1) 中都只有一个权值为 wᵢ 的根结点,其左、右子树均为空。
(2) 在森林 T 中选取两棵结点的权值最小的子树作为左、右子树构造一棵新的二叉树,并且置新的二叉树的根结点的权值为其左、右子树上根的权值之和。这称为合并,每合并一次 T 中减少一棵二叉树。
(3) 重复 (2) 直到 T 中只含一棵树为止,这棵树便是哈夫曼树。
例如,假定仍采用 7.7.1 节给定的权值 W = (1, 3, 5, 7) 来构造一棵哈夫曼树,按照上述算法,图 7.39 给出了一棵哈夫曼树的构造过程,其中图 7.39(d) 就是最后生成的哈夫曼树,它的带权路径长度为 29。
说明: 在构造哈夫曼树的过程中,每次合并都是取两个权值最小的二叉树合并,并添加一个根结点,这两棵二叉树作为根结点的左、右子树是任意的,这样构造的哈夫曼树可能不相同,但 WPL 一定是相同的。如图 7.38(d) 和图 7.39(d) 所示的哈夫曼树都是由 {1, 3, 5, 7} 构造的,尽管树形不同,但它们的 WPL 都是 29。

定理 7.3: 具有 n₀ 个叶子结点的哈夫曼树共有 2n₀ - 1 个结点。
证明: 从哈夫曼树的构造过程看出,每次合并都是将两棵二叉树合并为一个,所以哈夫曼树不存在度为 1 的结点,即 n₁ = 0。由二叉树的性质 1 可知 n₀ = n₂ + 1,即 n₂ = n₀ - 1,则结点总数 n = n₀ + n₁ + n₂ = n₀ + n₂ = n₀ + n₀ - 1 = 2n₀ - 1。
为了设计构造哈夫曼树的算法,这里采用静态数组 ht 存储哈夫曼树,即每个数组元素存放一个结点。设计哈夫曼树中的结点类如下:
class HTNode //哈夫曼树的结点类{ char data; // 结点值, 假设为单个字符 double weight; // 权值 public HTNode parent; // 双亲结点 HTNode lchild; // 左孩子结点 HTNode rchild; // 右孩子结点 boolean flag; // 标识是双亲的左或者右孩子 public HTNode() // 构造方法 { parent = null; lchild = null; rchild = null; } public double getw() //取结点权值的方法 { return weight; }}若有 n₀ 个叶子结点,哈夫曼树中总共有 2n₀ - 1 个结点,所有叶子结点集中放在 ht[0..n₀-1] 部分,并且用 hcd 字符串数组存放哈夫曼编码(在 7.7.3 节介绍),ht[n₀..2n₀-2] 存放其他需要构造的非叶子结点。设计哈夫曼树类 HuffmanClass 如下:
public class HuffmanClass //哈夫曼树类{ final int MAXN = 100; // 最多结点个数 double[] w; // 权值数组 String str; // 存放字符串 int n0; // 权值个数 HTNode[] ht; // 存放哈夫曼树 String[] hcd; // 存放哈夫曼编码
public HuffmanClass() // 构造方法 { ht = new HTNode[MAXN]; hcd = new String[MAXN]; w = new double[MAXN]; }
public void Setdata(int n0, double[] w, String str) // 设置初始值 { this.n0 = n0; for(int i = 0; i < n0; i++) this.w[i] = w[i]; this.str = str; }
public void CreateHT() { ... } //构造哈夫曼树 public void CreateHCode() { ... } //根据哈夫曼树求哈夫曼编码 public void DispHuffman() { ... } //输出哈夫曼编码}由于构造哈夫曼树中的合并操作是取两个根结点权值最小的二叉树进行合并,为此设计一个优先队列(按结点 weight 越小越优先)pq,其初始为空。构造哈夫曼树就是建立 ht[n₀..2n₀-2] 的结点,其过程如下:
(1) 建立 ht[0..n₀-1] 的叶子结点(设置结点的 data 和 weight,并将 parent 置为空),将它们进入 pq。
(2) i 从 n₀ 到 2n₀-2 循环(执行 n₀ - 1 次合并操作),每次出队两个结点 p1 和 p2,建立 ht[i] 结点,设置 p1 和 p2 的双亲为 ht[i],求权值和(ht[i].weight = p1.weight + p2.weight),p1 作为双亲 ht[i] 的左孩子(p1.flag = true),p2 作为双亲 ht[i] 的右孩子(p2.flag = false),并将 ht[i] 进入 pq。
循环结束后就构造好了哈夫曼树,对应的算法如下:
public void CreateHT() //构造哈夫曼树{ Comparator<HTNode> priComparator = new Comparator<HTNode>() //定义 priComparator { public int compare(HTNode o1, HTNode o2) //用于创建小根堆 { return (int)(o1.getw() - o2.getw()); //按 weight 越小越优先 } };
PriorityQueue<HTNode> pq = new PriorityQueue<>(MAXN, priComparator); //定义优先队列
for(int i = 0; i < n0; i++) //建立 n₀ 个叶子结点并进队 { ht[i] = new HTNode(); ht[i].parent = null; //双亲设置为空 ht[i].data = str.charAt(i); ht[i].weight = w[i]; pq.offer(ht[i]); //进队 }
for(int i = n0; i < (2 * n0 - 1); i++) //n₀ - 1 (= 2*n0 - 1 - n0)次合并操作 { HTNode p1 = pq.poll(); //出队两个权值最小的结点 p1 和 p2 HTNode p2 = pq.poll();
ht[i] = new HTNode(); //建立 ht[i] 结点 p1.parent = ht[i]; //设置 p1 和 p2 的双亲为 ht[i] p2.parent = ht[i];
ht[i].weight = p1.weight + p2.weight; //求权值和 ht[i].lchild = p1; //p1 作为双亲 ht[i] 的左孩子 p1.flag = true; ht[i].rchild = p2; //p2 作为双亲 ht[i] 的右孩子 p2.flag = false;
pq.offer(ht[i]); //ht[i] 结点进队 }}7.7.3 哈夫曼编码
在数据通信中经常需要将传送的文字转换为由二进制字符 0 和 1 组成的二进制字符串,称这个过程为编码。显然,我们希望电文编码的代码长度最短。哈夫曼树可用于构造使电文编码的代码长度最短的编码方案。
其具体构造方法如下:设需要编码的字符集合为 {d₀, d₁, ..., dₙ₀₋₁},各个字符在电文中出现的次数集合为 {w₀, w₁, ..., wₙ₀₋₁},以 d₀, d₁, ..., dₙ₀₋₁ 作为叶子结点,以 w₀, w₁, ..., wₙ₀₋₁ 作为各根结点到每个叶子结点的权值构造一棵哈夫曼树,规定哈夫曼树中的左分支为 0,右分支为 1,则从根结点到每个叶子结点所经过的分支对应的 0 和 1 组成的序列便为该结点对应字符的编码。这样的编码称为哈夫曼编码。
哈夫曼编码的实质就是使用频率越高的采用越短的编码。注意,只有 ht[0..n₀-1] 的叶子结点才对应哈夫曼编码,用 hcd[i] (0 ≤ i ≤ n₀ - 1) 表示 ht[i] 叶子结点的哈夫曼编码。
当构造好哈夫曼树 ht 后,i 从 0 到 n₀ - 1 循环,从 ht[i] 结点向根结点查找路径并产生逆向的哈夫曼编号 hcd[i] (叶子节点 -> 根节点),将 hcd[i] 逆置得到正向的哈夫曼编码(根节点 -> 叶子节点)。对应的算法如下:
private String reverse(String s) //逆置字符串 s{ String t = ""; for(int i = s.length() - 1; i >= 0; i--) t += s.charAt(i); return t;}
public void CreateHCode() //根据哈夫曼树求哈夫曼编码{ for(int i = 0; i < n0; i++) //遍历下标从 0 到 n₀ - 1 的叶子结点 { hcd[i] = ""; HTNode p = ht[i]; //从 ht[i] 开始找双亲结点 while(p.parent != null) { if(p.flag) //p 结点是双亲的左孩子 hcd[i] += '0'; else //p 结点是双亲的右孩子 hcd[i] += '1'; p = p.parent; } System.out.println("hcd:" + hcd[i]); hcd[i] = reverse(hcd[i]); //逆置得到正向的哈夫曼编码 }}输出所有叶子结点的哈夫曼编码的算法如下:
public void DispHuffman() //输出哈夫曼编码{ for(int i = 0; i < n0; i++) System.out.println(ht[i].data + " " + hcd[i]);}说明: 在一组字符的哈夫曼编码中,任一字符的哈夫曼编码不可能是另一字符的哈夫曼编码的前缀。
【例 7.19】 假定用于通信的电文仅由 a, b, c, d, e, f, g, h 共 8 个字母组成 (n₀ = 8),字母在电文中出现的频率分别为 0.07, 0.19, 0.02, 0.06, 0.32, 0.03, 0.21 和 0.10。试为这些字母设计哈夫曼编码。
解:
最后构造的哈夫曼树如图 7.40 所示(树中结点的数字表示频率,结点旁的数字为结点的编号),给所有的左分支加上 0、所有的右分支加上 1,得到各字母的哈夫曼编码如下。

a: 1010 b: 00 c: 10000 d: 1001e: 11 f: 10001 g: 01 h: 1011这样,在求出每个叶子结点的哈夫曼编码后,求得该哈夫曼树的带权路径长度 WPL = 4 × 0.07 + 2 × 0.19 + 5 × 0.02 + 4 × 0.06 + 2 × 0.32 + 5 × 0.03 + 2 × 0.21 + 4 × 0.10 = 2.61。
7.8 二叉树与树、森林之间的转换
7.8.1 树到二叉树的转换及还原
1. 树到二叉树的转换
对于任意的一棵树,可以按照以下规则转换为二叉树。
(1) 加线:连接各兄弟节点(使用虚线以区分原有的连线,因为抹线的操作消除的是原有的连线,下同)
(2) 抹线:抹掉除了其最左子树之外,这个节点与其孩子的连线
(3) 调整:向左对齐
经过这种方法转换所对应的二叉树是唯一的,并具有以下特点: (1) 此二叉树的根结点只有左子树没有右子树。 (2) 转换生成的二叉树中各结点的左孩子是它原来树中的最左孩子(左分支不变),右孩子是它在原来树中的下一个兄弟(兄弟变成右分支)。
【例 7.20】 将如图 7.41(a) 所示的一棵树转换为对应的二叉树。
解: 其转换过程如图 7.41(b)~图 7.41(d) 所示,图 7.41(d) 为最终转换成的二叉树。

2. 一棵由树转换的二叉树还原为树
这样的二叉树的根结点没有右子树,可以按照以下规则还原其相应的一棵树。
(1) 加线:逐层连接左孩子中,各级右孩子的所有节点
(2) 抹线:抹掉与右孩子的所有连线
(3) 调整:居中对齐
【例7.21】 将如图 7.42(a) 所示的二叉树还原成树。
**解:**其转换过程如图 7.42(b) ~ 图 7.42(d) 所示,图 7.42(d) 为最终由一棵二叉树还原成的树。

7.8.2 森林到二叉树的转换及还原
从树与二叉树的转换可知,一棵树转换之后的二叉树的根结点没有右子树,如果把森林中的第二棵树的根结点看成是第一棵树的根结点的兄弟,则同样可以导出森林和二叉树的对应关系。
1. 森林转换为二叉树
对于含有两棵或两棵以上的树的森林可以按照以下规则转换为二叉树。
(1) 转换:将森林中的每一棵树转换成二叉树,设转换成的二叉树为 bt₁, bt₂, ..., btₘ。
(2) 连接:将各棵转换后的二叉树的根结点相连。
(3) 调整:向左对齐
【例 7.22】 将如图 7.43(a) 所示的森林(由 3 棵树组成)转换成二叉树。
解: 转换为二叉树的过程如图 7.43(b)~图 7.43(e) 所示,最终结果如图 7.43(e) 所示。

2. 二叉树还原为森林
当一棵二叉树的根结点有 m - 1 个右下孩子时,还原的森林中有 m 棵树。这样的二叉树可以按照以下规则还原其相应的森林。
(1) 抹线:抹掉从根节点开始的所有各级右孩子的连线(碰到只有左节点的双亲节点停止),分成若干个以右链上的结点为根结点的二叉树,设这些二叉树为 bt₁, bt₂, ..., btₘ。
(2) 转换:分别将 bt₁, bt₂, ..., btₘ 二叉树还原成一棵树:根节点连接左孩子的各级右节点
(3) 调整:居中对齐

解: 还原为森林的过程如图 7.44(b)~图 7.44(e) 所示,最终结果如图 7.44(e) 所示。
注意: 当森林、树转换成对应的二叉树后,其左、右子树的概念已改变,即左链是原来的孩子关系,右链是原来的兄弟关系。
*7.9 树算法设计和并查集
第八章 图
8.1 图的基本概念
8.1.1 图的定义
无论多么复杂的图都是由顶点和边构成的。采用形式化的定义,图 G (Graph) 由两个集合 V (Vertex) 和 E (Edge) 组成,记为 G = (V, E),其中 V 是顶点的有限集合,记为 V(G),E 是连接 V 中两个不同顶点(顶点对)的边的有限集合,记为 E(G)。
抽象数据类型图的定义如下:
ADT Graph{ 数据对象: D = {aᵢ | 0 ≤ i ≤ n-1, n ≥ 0, aᵢ 为 int 类型} //aᵢ 为每个顶点的唯一编号 数据关系: R = {r} r = {<aᵢ, aⱼ> | aᵢ, aⱼ ∈ D, 0 ≤ i ≤ n-1, 0 ≤ j ≤ n-1, 其中 aᵢ 可以有零个或多个前驱元素,也可以有零个或多个后继元素} 基本运算: void CreateGraph(): 根据相关数据建立一个图。 void DispGraph(): 输出一个图。 ...}通常用字母或自然数(顶点的编号)来标识图中的顶点,约定用 i (0 ≤ i ≤ n - 1) 表示第 i 个顶点的编号。E(G) 表示图 G 中边的集合,它确定了图 G 中数据元素的关系。E(G) 可以为空集,当 E(G) 为空集时,图 G 只有顶点没有边。
在图 G 中,如果代表边的顶点对(或序偶)是无序的,则称 G 为无向图。在无向图中代表边的无序顶点对通常用圆括号括起来,以表示一条无向边。例如 (i, j) 表示顶点 i 与顶点 j 的一条无向边,显然,(i, j) 和 (j, i) 所代表的是同一条边。如果表示边的顶点对(或序偶)是有序的,则称 G 为有向图。在有向图中代表边的顶点对通常用尖括号括起来,以表示一条有向边(又称为弧),例如 <i, j> 表示从顶点 i 到 j 的一条边,通常用顶点 i 到 j 的箭头表示,可见有向图中 <i, j> 和 <j, i> 是两条不同的边。
说明: 图中的边一般不重复出现,如果允许出现重复边,这样的图称为多重图,例如一个无向图中顶点 1 和 2 之间出现两条或两条以上的边。数据结构课程中讨论的图均指非多重图。
如图 8.1 所示,图 8.1(a) 是一个无向图 G₁,其顶点集合 V(G₁) = {0, 1, 2, 3, 4},边集合 E(G₁) = {(1,2), (1,3), (1,0), (2,3), (3,0), (2,4), (3,4), (4,0)}。图 8.1(b) 是一个有向图 G₂,其顶点集合 V(G₂) = {0, 1, 2, 3, 4},边集合 E(G₂) = {<1,2>, <1,3>, <0,1>, <2,3>, <0,3>, <2,4>, <4,3>, <4,0>}。

说明: 本章约定,对于有 n 个顶点的图,其顶点编号为 0 ~ n-1,用编号 i (0 ≤ i ≤ n-1) 来唯一标识一个顶点。
8.1.2 图的基本术语
下面讨论有关图的各种基本术语。
1. 端点和邻接点
在一个无向图中,若存在一条边 (i, j),则称顶点 i 和顶点 j 为该边的两个端点,并称它们互为邻接点,即顶点 i 是顶点 j 的一个邻接点,顶点 j 也是顶点 i 的一个邻接点。
在一个有向图中,若存在一条边 <i, j>,则称此边是顶点 i 的一条出边,同时也是顶点 j 的一条入边,称 i 和 j 分别为此边的起始端点(简称为起点)和终止端点(简称为终点),并称顶点 j 是 i 的出边邻接点,顶点 i 是 j 的入边邻接点。
2. 顶点的度、入度和出度
在无向图中,顶点所关联的边的数目称为该顶点的度。在有向图中,顶点 i 的度又分为入度和出度,以顶点 i 为终点的入边的数目称为该顶点的入度,以顶点 i 为起点的出边的数目称为该顶点的出度。一个顶点的入度与出度的和为该顶点的度。
若一个图(无论有向图或无向图)中有 n 个顶点和 e 条边,每个顶点的度为 dᵢ (0 ≤ i ≤ n-1),则有
也就是说,一个图中所有顶点的度之和等于边数的两倍,因为图中每条边分别作为两个邻接点的度各计一次。
【例 8.1】 一个无向图中有 16 条边,度为 4 的顶点有 3 个,度为 3 的顶点有 4 个,其余顶点的度均小于 3,则该图至少有多少个顶点?
解: 设该图有 n 个顶点,图中度为 i 的顶点数为 nᵢ (0 ≤ i ≤ 4),n₄ = 3,n₃ = 4,要使顶点数最少,该图应是连通的,
即 n₀ = 0,n = n₄ + n₃ + n₂ + n₁ + n₀ = 7 + n₂ + n₁,
即 n₂ + n₁ = n - 7。
度之和 = 4 × 3 + 3 × 4 + 2 × n₂ + n₁ = 24 + 2n₂ + n₁ ≤ 24 + 2(n₂ + n₁) = 24 + 2 × (n - 7) = 10 + 2n,
而度之和 = 2e = 32,
所以有 10 + 2n ≥ 32,
即 n ≥ 11,即这样的无向图至少有 11 个顶点。
3. 完全图
若无向图中的每两个顶点之间都存在着一条边,有向图中的每两个顶点之间都存在着方向相反的两条边,则称此图为完全图。显然,含有 n 个顶点的完全无向图有 n(n-1)/2 条边,含有 n 个顶点的完全有向图包含有 n(n-1) 条边。例如,图 8.2(a) 所示的图是一个具有 4 个顶点的完全无向图,共有 6 条边;图 8.2(b) 所示的图是一个具有 4 个顶点的完全有向图,共有 12 条边。

4. 稠密图和稀疏图
当一个图接近完全图时称为稠密图。相反,当一个图含有较少的边数时称为稀疏图。
5. 子图
设有两个图 G = (V, E) 和 G' = (V', E'),若 V' 是 V 的子集,即 V' ⊆ V,且 E' 是 E 的子集,即 E' ⊆ E,则称 G' 是 G 的子图。
6. 路径和路径长度
在一个图 G = (V, E) 中,从顶点 i 到顶点 j 的一条路径是一个顶点序列 (i, i₁, i₂, ..., iₘ, j)。路径长度是指一条路径上经过的边的数目。若一条路径上除开始点和结束点可以相同外,其余顶点均不相同,则称此路径为简单路径。例如,在图 8.2(b) 中 (0, 2, 1) 就是一条简单路径,其长度为 2。
7. 回路或环
若一条路径上的开始点与结束点为同一个顶点,则此路径被称为回路或环。开始点与结束点相同的简单路径被称为简单回路或简单环。
8. 连通、连通图和连通分量
在图 G 中,若从顶点 i 到顶点 j 有路径,则称顶点 i 和顶点 j 是连通的。若图 G 中任意两个顶点都连通,则称图 G 为连通图,否则称为非连通图。图 G 中的极大连通子图称为 G 的连通分量。显然,任何连通图的连通分量只有一个,即本身,而非连通图有多个连通分量。
如图 8.3 所示的无向图是非连通图,由两个连通分量构成,对应的顶点集分别是 {0, 1, 2, 3} 和 {4}。

9. 强连通图和强连通分量
在有向图 G 中,若任意两个顶点 i 和 j 都连通,即从顶点 i 到顶点 j 和从顶点 j 到顶点 i 都存在路径,则称图 G 是强连通图。有向图 G 中的极大强连通子图称为 G 的强连通分量。显然,强连通图只有一个强连通分量,即本身,非强连通图有多个强连通分量。一般单个顶点自身就是一个强连通分量。
如图 8.4(a) 所示的有向图是非强连通图,由两个强连通分量构成,如图 8.4(b) 所示,对应的顶点集分别是 {0, 1, 2, 3} 和 {4}。

10. 关结点和重连通图
假如在删除图 G 中的顶点 i 以及相关联的各边后,能将图的一个连通分量分割成两个或多个连通分量,则称顶点 i 为该图的关结点。一个没有关结点的连通图称为重连通图。
11. 权和网
图中的每一条边都可以附有一个对应的数值,这种与边相关的数值称为权。权可以表示从一个顶点到另一个顶点的距离或花费的代价。边上带有权的图称为带权图,也称作网。
【例 8.2】 n 个顶点的强连通图至少有多少条边?这样的有向图是什么形状?
解: 根据强连通图的定义可知,图中的任意两个顶点 i 和 j 都连通,即从顶点 i 到顶点 j 和从顶点 j 到顶点 i 都存在路径。这样每个顶点的度 dᵢ ≥ 2,设图中总的边数为 e,有:
即 e ≥ n。因此 n 个顶点的强连通图至少有 n 条边,刚好只有 n 条边的强连通图是环形的,即顶点 0 到顶点 1 有一条有向边,顶点 1 到顶点 2 有一条有向边,……,顶点 n-1 到顶点 0 有一条有向边,如图 8.6 所示。

8.2 图的存储结构
8.2.1 邻接矩阵
1. 邻接矩阵存储方法
邻接矩阵是表示顶点之间邻接关系的矩阵。设 G = (V, E) 是含有 n(设 n > 0)个顶点的图,各顶点的编号为 0 ~ n-1,则 G 的邻接矩阵数组 A 是 n 阶方阵,其定义如下。
(1) 如果 G 是不带权图(或无权图),则:
A[i][j] = { 1, 当 (i,j) ∈ E(G) 或者 <i,j> ∈ E(G) { 0, 其他(2) 如果 G 是带权图(或有权图),则:
A[i][j] = { wᵢⱼ, 当 i ≠ j 并且 (i,j) ∈ E(G) 或者 <i,j> ∈ E(G) { 0, 当 i = j { ∞, 其他邻接矩阵的特点如下:
(1) 图的邻接矩阵表示是唯一的。
(2) 对于含有 n 个顶点的图,在采用邻接矩阵存储时,无论是有向图还是无向图,也无论边的数目是多少,其存储空间均为 O(n²),所以邻接矩阵适合于存储边数较多的稠密图。
(3) 无向图的邻接矩阵一定是一个对称矩阵,因此在顶点个数 n 很大时可以采用对称矩阵的压缩存储方法减少存储空间。
(4) 对于无向图,邻接矩阵的第 i 行(或第 i 列)非零元素(或非 ∞ 元素)的个数正好是顶点 i 的度。
(5) 对于有向图,邻接矩阵的第 i 行(或第 i 列)非零元素(或非 ∞ 元素)的个数正好是顶点 i 的出度(或入度)。
(6) 在用邻接矩阵存储图时,确定任意两个顶点之间是否有边相连的时间为 O(1)。
设计图的邻接矩阵类 MatGraphClass 如下:
public class MatGraphClass //图的邻接矩阵类{ final int MAXV = 100; //表示最多顶点个数 final int INF = 0x3f3f3f3f; //表示 ∞ int[][] edges; //邻接矩阵数组, 假设元素为 int 类型 int n, e; //顶点数和边数 String[] vexs; //存放顶点信息
public MatGraphClass() //构造方法 { edges = new int[MAXV][MAXV]; vexs = new String[MAXV]; } //图的基本运算算法}说明: INF 表示 ∞,对于 int 类型,INF 置为 0x3f3f3f3f 而不是 Integer.MAX_VALUE,原因是图算法中会出现 INF + INF 的情况,如果 INF 取值 Integer.MAX_VALUE,则会出现溢出,导致 INF + INF < 0 的情况发生。
8.2.2 邻接表
1. 邻接表存储方法
图的邻接表存储方法是一种顺序分配与链式分配相结合的存储方法。在表示含 n 个顶点的图的邻接表中,每个顶点建立一个单链表,第 i (0 ≤ i ≤ n-1) 个单链表中的结点表示依附于顶点 i 的边(有向图是顶点 i 出边)。每个单链表上附设一个表头结点,将所有表头结点构成一个头结点数组。边结点和头结点的结构如图 8.8 所示。

其中,边结点通常由 3 个成员变量组成,adjvex 表示与顶点 i 邻接的顶点编号(终点),weight 存储与边相关的信息,例如权值等,nextarc 表示下一条边的结点(同起点的另外一条边)。头结点通常由两个成员变量组成,data 存储顶点 i 的名称或其他信息,firstarc 指向顶点 i 的单链表中的第一个边结点。
例如,图 8.1(a) 中的无向图 G₁、图 8.1(b) 中的有向图 G₂ 和图 8.5 中的带权有向图 G₃ 对应的邻接表分别如图 8.9(a)~图 8.9(c) 所示。

提示: 对于不带权的图,假设边的权值均为 1,在邻接表的边结点中通常不画出 weight(权值)部分。对于有向图,在邻接表的边结点中必须画出 weight 部分标识相应边的权值。
邻接表的特点如下:
(1) 邻接表的表示不唯一。这是因为 在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,取决于建立邻接表的算法以及边的输入次序。
(2) 对于有 n 个顶点和 e 条边的无向图,其邻接表有 n 个表头结点和 2e 个边结点;对于有 n 个顶点和 e 条边的有向图,其邻接表有 n 个表头结点和 e 个边结点。显然,对于边数目较少的稀疏图,用邻接表比用邻接矩阵要节省空间。
(3) 对于无向图,顶点 i (0 ≤ i ≤ n-1) 对应的单链表的边结点个数正好是顶点 i 的度。
(4) 对于有向图,顶点 i (0 ≤ i ≤ n-1) 对应的单链表的边结点个数仅仅是顶点 i 的出度。顶点 i 的入度是邻接表中所有 adjvex 值为 i 的边结点个数。
(5) 在用邻接表存储图时,确定任意两个顶点之间是否有边相连的时间为 O(m)(m 为最大顶点出度,m < n)。
设计图的邻接表存储类 AdjGraphClass 如下:
class ArcNode //边结点类{ int adjvex; // 该边的终点编号 ArcNode nextarc; // 指向下一条边的指针 int weight; // 该边的相关信息, 例如边的权值}
class VNode // 头结点类{ String[] data; // 顶点信息(编号等) ArcNode firstarc; // 指向第一条边的邻接顶点}
public class AdjGraphClass // 图邻接表类{ final int MAXV = 100; // 表示最多顶点个数 final int INF = 0x3f3f3f3f; // 表示 ∞ VNode[] adjlist; // 邻接表头数组 int n, e; // 图中的顶点数 n 和边数 e
public AdjGraphClass() // 构造方法 { adjlist = new VNode[MAXV]; for(int i = 0; i < MAXV; i++) adjlist[i] = new VNode(); } //图的基本运算算法}由于在有向图的邻接表中 adjlist[i] 的单链表只存放了顶点 i 的出边,所以不易找到顶点 i 的入边,为此可以设计有向图的逆邻接表。所谓逆邻接表,就是在有向图的邻接表中将 adjlist[i] 的单链表的出边改为入边。例如,在有向图 G 中有边 <1,3>、<2,3>、<4,3>,顶点 3 的入边顶点是 1、2 和 4,则 adjlist[3] 的单链表改为包含 1、2 和 4 的边结点。
2. 图基本运算在邻接表中的实现
1) 创建图的邻接表
这里假设给定图的邻接矩阵数组 a、顶点数 n 和边数 e 来建立图的邻接表存储结构。其过程是先将头结点数组 adjlist 的所有元素的 firstarc 成员变量设置为空,再按行序优先遍历 a 数组,若 a[i][j] 是非 0 非 ∞ 元素,表示存在一条从顶点 i 到顶点 j 的边,新建一个边结点 p 存放该边的信息,采用头插法将其插入 adjlist[i] 单链表的开头。对应的算法(包括在 AdjGraphClass 类中)如下:
public void CreateAdjGraph(int[][] a, int n, int e) // 通过 a, n 和 e 建立图的邻接表{ this.n = n; this.e = e; // 置顶点数和边数 ArcNode p; for(int i = 0; i < n; i++) adjlist[i].firstarc = null; // 将邻接表中所有头结点的指针置初值
for(int i = 0; i < n; i++) //检查边数组 a 中的每个元素 for(int j = n - 1; j >= 0; j--) if(a[i][j] != 0 && a[i][j] != INF) //存在一条边 { p = new ArcNode(); //创建一个边结点 p p.adjvex = j; p.weight = a[i][j]; p.nextarc = adjlist[i].firstarc; //采用头插法插入 p adjlist[i].firstarc = p; }}【例 8.4】 一个含有 n 个顶点、e 条边的图采用邻接表存储,设计以下算法:
该图为有向图,求该图中顶点 v 的出度和入度。
解:
对于一个采用邻接表存储的有向图,统计第 v 个单链表的边结点个数即为顶点 v 的出度,统计所有 adjvex 为 v 的边结点个数即为顶点 v 的入度。对应的算法如下:
public static int[] Degree2(AdjGraphClass G, int v) //在有向图邻接表中求顶点 v 的出度和入度{ int[] ans = new int[2]; ArcNode p;
ans[0] = 0; //累计出度 p = G.adjlist[v].firstarc; while(p != null) { ans[0]++; //统计第 v 个单链表中的边结点个数 p = p.nextarc; }
ans[1] = 0; //累计入度 for(int i = 0; i < G.n; i++) //遍历所有的头结点 { p = G.adjlist[i].firstarc; //遍历第 i 个单链表 while(p != null) { if(p.adjvex == v) { ans[1]++; break; //每个单链表中最多只有一个这样的边结点 } else p = p.nextarc; } }
return ans; //返回出度和入度}3. 简化的邻接表
在线编程中常常采用简化的邻接表,即直接用数组表示,头结点数组为 head (index: 点编号; value: 出边在 edge 数组的索引),边结点数组 edge 为 ENode 类型,该类型包含 v、w 和 next 成员变量,其中 head[i] 表示顶点 i 的单链表(head[i] = -1 表示顶点 i 没有出边)。edge 数组包含所有边,若 head[i] 指向边结点 (v₁, w₁, next₁),而 next₁ 指向 (v₂, w₂, -1),表示顶点 i 有两条出边为 <i, v₁, w₁> 和 <i, v₂, w₂>(next = -1 表示没有其他出边)。图 8.5 中的带权有向图 G₃ 对应的简化邻接表如图 8.11 所示。

在简化邻接表中实现创建邻接表和输出邻接表的 AdjGraphClass1 类如下:
class ENode //边结点类{ int v; //邻接点 int w; //边权值 int next; //下一条边
public ENode(int v, int w) //构造方法 { this.v = v; this.w = w; }}
public class AdjGraphClass1 // 图的简化邻接表类{ final int MAXV = 100; // 表示最多顶点个数 final int MAXE = 300; // 表示最多边数 final int INF = 0x3f3f3f3f; // 表示 ∞ int[] head; // 邻接表头结点数组 ENode[] edge; // 边结点数组 int n, e; // 图中的顶点数 n 和边数 e int toll; // 边数组下标
public AdjGraphClass1() //构造方法 { head = new int[MAXV]; // 创建头结点数组 Arrays.fill(head, -1); // head 的所有元素初始化为 -1 edge = new ENode[MAXE]; // 创建边结点数组 toll = 0; // edge 数组的下标从 0 开始 }
public void addEdge(int u, int v, int w) //图中增加边 <u, v, w>: u start, v end(邻接点), w weight { edge[toll] = new ENode(v, w); edge[toll].next = head[u]; // 将新边的 next 指向当前 head[u] 指向的边(开始时这个点只有这一条边, head[u] = -1, 也就是 next 不存在) head[u] = toll++; // 更新 head[u],使其指向这条新添加的边, 然后再 toll + 1 来给下一条边使用 }
public void CreateAdjGraph(int[][] a) //通过边数组 a 建立图的简化邻接表 { n = a.length; e = 0; //初始化顶点数和边数 for(int i = 0; i < n; i++) for(int j = 1; j < n; j++) //检查边数组 a 中的每个元素 if(a[i][j] != 0 && a[i][j] != INF) //存在一条边 { addEdge(i, j, a[i][j]); e++; //边数增 1 } }
public void DispAdjGraph() //输出图的邻接表 { for(int i = 0; i < n; i++) { System.out.printf(" [%d]", i); for (int e = head[i]; e != -1; e = edge[e].next) System.out.printf("->(%d, %d)", edge[e].v, edge[e].w); System.out.println("->^"); } }}8.3 图的遍历
8.3.1 图遍历的概念
从给定图中任意指定的顶点(称为初始点)出发,按照某种搜索方法沿着图的边访问图中的所有顶点,使每个顶点仅被访问一次,这个过程称为图遍历。如果给定图是连通的无向图或者是强连通的有向图,则遍历一次就能完成,并可按访问的先后顺序得到由该图的所有顶点组成的一个序列。
图遍历比树遍历更复杂,因为从树根到达树中的每个顶点只有一条路径,而从图的起始点到达图中的每个顶点可能存在着多条路径。
说明: 为了避免同一个顶点被重复访问,用户必须记住访问过的顶点。为此可设置一个访问标识数组 visited,初始时所有元素置为 0,当顶点 i 访问过时,该数组元素 visited[i] 置为 1。
根据遍历方式的不同,图的遍历方法有两种,一种是深度优先遍历 (DFS) 方法,另一种是广度优先遍历 (BFS) 方法。
好的,这是从图片中提取的文字内容:
8.3.2 深度优先遍历
深度优先遍历的过程是从图中某个起始点 v 出发,首先访问初始顶点 v,然后选择一个与顶点 v 邻接且没被访问过的顶点 w 为初始顶点,再从 w 出发进行深度优先搜索,直到图中与当前顶点 v 邻接的所有顶点都被访问过为止。
从中看出,深度优先遍历是一个递归过程,每次都以当前顶点的一个未访问过的邻接点进行深度优先遍历。因此采用递归算法实现非常直观。
图采用邻接表为存储结构,其深度优先遍历算法如下(其中,v 是起始点编号,visited 是类成员数组):
public static void DFS(AdjGraphClass G, int v) //邻接表 G 中顶点 v 出发深度优先遍历{ int w; ArcNode p; System.out.print(v + " "); //访问顶点 v visited[v] = 1; //置已访问标记 p = G.adjlist[v].firstarc; //p 指向顶点 v 的第一个邻接点 while(p != null) { w = p.adjvex; if(visited[w] == 0) //若 w 顶点未访问, 递归访问它 DFS(G, w); p = p.nextarc; //p 置为下一个邻接点 }}从上述算法看出,在遍历中对图中的每个顶点最多访问一次,所以算法的时间复杂度为 O(n + e)。需要注意的是同一个图可能对应不同的邻接表,而不同的邻接表得到的 DFS 序列可能不同。
8.3.3 广度优先遍历
广度优先遍历的过程是首先访问起始点 v,接着访问顶点 v 的所有未被访问过的邻接点 v₁, v₂, ..., vₜ,然后再按照 v₁, v₂, ..., vₜ 的次序访问每一个顶点的所有未被访问过的邻接点,以此类推,直到图中所有和初始点 v 有路径相通的顶点或者图中所有已访问顶点的邻接点都被访问过为止。
广度优先遍历类似于树的层次遍历,即按树的深度来遍历,先访问深度为 1 的结点,再访问深度为 2 的结点,以此类推。对于图是按起始点为 v,由近至远,依次访问和 v 有路径相通且路径长度为 1, 2, … 的顶点的过程。
由于广度优先遍历图中访问顶点的次序是“先访问的顶点的邻接点”先于“后访问的顶点的邻接点”,所以需要使用一个队列。
图采用邻接表为存储结构,其广度优先遍历算法如下(其中,v 是起始点编号,visited 是类成员数组,可以改为局部数组):
public static void BFS(AdjGraphClass G, int v) //邻接表 G 中顶点 v 出发广度优先遍历{ ArcNode p; int w; Queue<Integer> qu = new LinkedList<Integer>(); //定义一个队列 System.out.print(v + " "); //访问顶点 v visited[v] = 1; //置已访问标记 qu.offer(v); //v 进队
while(!qu.isEmpty()) //队列不空循环 { v = qu.poll(); //出队顶点 v p = G.adjlist[v].firstarc; //找顶点 v 的第一个邻接点 while(p != null) { w = p.adjvex; if(visited[w] == 0) //若 v 的邻接点 w 未访问 { System.out.print(w + " "); //访问顶点 w visited[w] = 1; //置已访问标记 qu.offer(w); //w 进队 } p = p.nextarc; //找下一个邻接点 } }}从上述算法看出,在遍历中对图中的每个顶点最多访问一次,所以算法的时间复杂度为 O(n + e)。同样,同一个图可能对应不同的邻接表,而不同的邻接表得到的 BFS 序列可能不同。
以邻接矩阵为存储结构的图广度优先遍历算法如下(其中,v 是起始点编号,visited 是类成员数组,可以改为局部数组):
public static void BFS(MatGraphClass g, int v) //邻接矩阵 g 中顶点 v 出发广度优先遍历{ Queue<Integer> qu = new LinkedList<Integer>(); //定义一个队列 System.out.print(v + " "); //访问顶点 v visited[v] = 1; //置已访问标记 qu.offer(v); //v 进队
while(!qu.isEmpty()) //队列不空循环 { v = qu.poll(); //出队顶点 v for(int w = 0; w < g.n; w++) //遍历所有顶点 w { if(g.edges[v][w] != 0 && g.edges[v][w] != g.INF) //存在边 <v, w> 并且 w 未访问 { if(visited[w] == 0) { System.out.print(w + " "); //访问顶点 w visited[w] = 1; //置已访问标记 qu.offer(w); //w 进队 } } } }}上述算法的时间复杂度为 O(n²)。
例如,对于如图 8.12(b) 所示的邻接表,针对该邻接表从顶点 0 开始进行广度优先遍历,得到的访问序列是 0 1 2 5 3 4。其遍历过程如图 8.14 所示,顶点旁与实线相交的粗棒表示访问点。

从中看出,在广度优先遍历中,起始点为 v,考虑 v 到图中顶点 u 的最短路径长度,则最短路径长度越小的顶点越优先访问。实际上在遍历中产生以 v 为根的搜索子树(若搜索到达边 <v, w> 并且 w 是首次访问,则 <v, w> 构成该子树的边),对该子树进行层次遍历得到广度优先遍历序列。
8.3.4 非连通图的遍历
上面讨论的两种图遍历方法,对于无向图来说,若是连通图,则一次遍历能够访问到图中的所有顶点;若是非连通图,则只能访问到起始点所在连通分量中的所有顶点,其他连通分量中的顶点是不可能访问到的,为此需要从其他每个连通分量中选择起始点分别进行遍历,这样才能够访问到图中的所有顶点。
对于有向图来说,若从起始点到图中的其他每个顶点都有路径,则能够访问到图中的所有顶点;否则不能访问到所有顶点,为此同样需要再选起始点,继续进行遍历,直到图中的所有顶点都被访问过为止。
非连通图采用邻接表存储结构,其深度优先遍历算法如下:
public static void DFSA(AdjGraphClass G) //非连通图的 DFS{ Arrays.fill(visited, 0); //visited 数组元素均置为 0 for(int i = 0; i < G.n; i++) //遍历所有顶点 if(visited[i] == 0) //若顶点 i 没有访问过 DFS(G, i); //从顶点 i 出发深度优先遍历}非连通图采用邻接表存储结构,其广度优先遍历算法如下:
public static void BFSA(AdjGraphClass G) //非连通图的 BFS{ Arrays.fill(visited, 0); //visited 数组元素均置为 0 for(int i = 0; i < G.n; i++) //遍历所有顶点 if(visited[i] == 0) //若顶点 i 没有访问过 BFS(G, i); //从顶点 i 出发广度优先遍历}8.3.5 图遍历算法的应用
-
基于深度优先遍历算法的应用
图的深度优先遍历算法是从顶点
v出发,以纵向方式一步一步向后访问各个顶点的。对图 8.12(b) 的邻接表执行DFS(G, 0),其执行过程是DFS(G, 0)⇒DFS(G, 1)⇒DFS(G, 5)⇒ 回退到顶点 1 ⇒ 回退到顶点 0 ⇒DFS(G, 2)⇒DFS(G, 3)⇒ 回退到顶点 2 ⇒DFS(G, 4)⇒ 回退到顶点 2 ⇒ 回退到顶点 0。简单地说,从起始点
v深度优先遍历时,找顶点v的一个未访问的邻接点u(对应边<v, u>),从顶点u继续找一个未访问的邻接点w(对应边<u, w>),以此类推,若顶点w没有未访问的邻接点,则回退到顶点u,再找顶点u的下一个未访问的邻接点,直到满足求解问题中的条件为止。这种思路常用于图算法设计中。【例 8.6】 假设图
G采用邻接表存储,设计一个算法判断顶点u到顶点v之间是否有路径,并对于图 8.15 所示的有向图判断从顶点 0 到顶点 5、从顶点 0 到顶点 2 是否有路径。解: 利用深度优先遍历方法,先置
visited数组的所有元素值为 0。从顶点u开始,置visited[u] = 1,找到顶点u的一个未访问过的邻接点u₁;再从顶点u₁出发,置visited[u₁] = 1,找到顶点u₁的一个未访问过的邻接点u₂,…,当找到的某个未访问过的邻接点uₙ = v时,说明顶点u到v有简单路径,返回true,如果图遍历完都没有返回true,则表示u到v没有路径,返回false,其过程如图 8.16 所示(深度优先遍历中包括自动回退过程)。
实际上,从递归算法设计角度看,
f(G, u, v)是大问题,表示图G中从顶点u到顶点v是否存在简单路径,再找到顶点u的没有搜索过的边<u, w>,则f(G, w, v)是小问题,若小问题返回true,表示G中从顶点w到顶点v存在简单路径,显然可推出顶点u到v存在简单路径;若小问题返回false,表示G中从顶点w到顶点v不存在简单路径,回退再搜索顶点u的下一条没有搜索过的边,以此类推。对应的完整程序如下:
import java.util.*;public class Exam8_6{static final int MAXV = 100; //表示最多顶点个数static int[] visited = new int[MAXV]; //全局变量数组public static boolean HasPath(AdjGraphClass G, int u, int v) //判断 u 到 v 是否有简单路径{Arrays.fill(visited, 0); //初始化return HasPath1(G, u, v);}private static boolean HasPath1(AdjGraphClass G, int u, int v) //被 HasPath() 方法调用{ArcNode p;int w;visited[u] = 1;p = G.adjlist[u].firstarc; //p 指向 u 的第一个相邻点while(p != null){w = p.adjvex; //w 是 u 的邻接点if(w == v)return true; //找到目标顶点后返回if(visited[w] == 0 and HasPath1(G, w, v))return true;p = p.nextarc; //p 指向下一个邻接点}return false;}} -
基于广度优先遍历算法的应用
【例 8.12】 假设图
G采用邻接表存储,设计一个算法求不带权图G中从顶点u到顶点v的一条最短路径(假设两顶点之间存在一条或多条简单路径),并对于图 8.15 所示的有向图求从顶点 0 到顶点 5 的一条最短简单路径。解: 图
G是不带权图,一条边的长度计为 1,因此求顶点u到顶点v的最短路径即求顶点u到顶点v的边数最少的顶点序列。利用广度优先遍历算法,从u出发进行广度优先遍历,类似于从顶点u出发一层一层地向外扩展,当第一次找到顶点v时队列中便包含了从顶点u到顶点v最近的路径,如图 8.24 所示,再利用队列输出最短路径(逆路径),由于要利用队列找出路径,所以设计成非循环队列。
对应的算法如下:
public static void ShortPath(AdjGraphClass G, int u, int v){class QNode //队列元素类型{int no; //顶点编号QNode parent; //前驱顶点}Queue<QNode> qu = new LinkedList<QNode>(); //定义一个队列QNode e, el;ArcNode p;e = new QNode(); //初始点对应队列元素的前驱为空e.no = u; e.parent = null;qu.offer(e); //u 进队visited[u] = 1; //设置已访问标记while(!qu.isEmpty()) //队列不空循环{e = qu.poll(); //出队元素 eif(e.no == v) //找到 v 时输出路径之逆并退出{int[] path = new int[MAXV];int d = -1;QNode f = e;while(f != null) //通过前驱关系求逆路径{d++; path[d] = f.no;f = f.parent;}for(int i = d; i >= 0; i--) //反向输出逆路径构成正向路径System.out.print(path[i] + " ");System.out.println();return; //输出一条路径后返回}p = G.adjlist[e.no].firstarc; //找 e 对应顶点的第一个邻接点while(p != null){int w = p.adjvex;if(visited[w] == 0) //若 u 的邻接点 w 未访问{el = new QNode(); //建立队列元素el.no = w; el.parent = e; //其前驱为 evisited[w] = 1; //设置已访问标记qu.offer(el); //el 进队}p = p.nextarc; //找下一个邻接点}}}
*8.3.6 求有向图中强连通分量的 Tarjan 算法
8.4 生成树和最小生成树
8.4.1 生成树和最小生成树的概念
通常生成树是针对无向图的,最小生成树是针对带权无向图的。
1. 什么是生成树
一个有 n 个顶点的连通图的生成树是一个极小连通子图,它含有图中的全部顶点,但只包含构成一棵树的 n-1 条边。如果在一棵生成树上添加一条边,必定构成一个环,因为这条边使得它依附的那两个顶点之间有了第二条路径。
如果一个无向图有 n 个顶点和少于 n-1 条边,则是非连通图。如果它有多于 n-1 条边,则一定有回路,但是有 n-1 条边的图不一定都是生成树。
2. 连通图的生成树和非连通图的生成森林
在对无向图进行遍历时,若是连通图,仅需调用遍历过程(DFS 或 BFS)一次,从图中任一顶点出发,便可以遍历图中的各个顶点。在遍历中搜索边 <v, w> 时,若顶点 w 首次访问(该边也是首次搜索到),则该边是一条树边,所有树边构成一棵生成树。
若是非连通图,则需对每个连通分量调用一次遍历过程,所有连通分量对应的生成树构成整个非连通图的生成森林。
3. 由两种遍历方法产生的生成树
连通图可以产生一棵生成树,非连通分量可以产生生成森林。由深度优先遍历得到的生成树称为深度优先生成树,由广度优先遍历得到的生成树称为广度优先生成树。无论哪种生成树,都是由相应遍历中首次搜索的边构成的。
【例 8.14】 对于如图 8.27 所示的无向图,画出其邻接表存储结构,并在该邻接表中以顶点 0 为根画出图 G 的深度优先生成树和广度优先生成树。

解: 假设该图的邻接表如图 8.28 所示(注意,图 G 的邻接表不是唯一的)。

对于该邻接表,从顶点 0 出发的深度优先遍历过程如图 8.29 所示,因此对应的深度优先生成树如图 8.30 所示。

对于该邻接表,从顶点 0 出发的广度优先遍历过程如图 8.31 所示,因此对应的广度优先生成树如图 8.32 所示。

4. 什么是最小生成树
一个带权连通图 G(假定每条边上的权值均大于零)可能有多棵生成树,每棵生成树中所有边上的权值之和可能不同,其中边上的权值之和最小的生成树称为图的最小生成树。
按照生成树的定义,n 个顶点的连通图的生成树有 n 个顶点、n-1 条边,因此构造最小生成树的准则有以下几条:
(1) 必须只使用该图中的边来构造最小生成树。
(2) 必须使用且仅使用 n-1 条边来连接图中的 n 个顶点,生成树一定是连通的。
(3) 不能使用产生回路的边。
(4) 最小生成树的权值之和是最小的,但一个图的最小生成树不一定是唯一的。
求图的最小生成树有很多实际应用,例如城市之间的交通工程造价最优问题就是一个最小生成树问题。构造图的最小生成树主要有两个算法,即普里姆算法和克鲁斯卡尔算法,将分别在后面介绍。
8.4.2 普利姆算法
核心思想(贪心)
从一个起点开始,每一步选择“连接已选顶点集合和未选顶点集合的最小权值边”
逐步扩展生成树,直到包含所有顶点。
算法基本步骤
- 任选一个顶点作为起点,加入生成树集合
S - 在所有:
- 一端在
S中 - 另一端不在
S中 的边里,选择权值最小的一条
- 一端在
- 将这条边及其连接的顶点加入
S - 重复步骤 2、3,直到所有顶点都在
S中
算法特点
- 思想直观、实现简单
- 适合稠密图
但:
- 要求图必须是连通的
- 只能用于无向图
算法实现
public class PrimAlgorithm {
private static final int INF = Integer.MAX_VALUE;
/** * 使用 Prim 算法求最小生成树 * @param graph 邻接矩阵表示的无向图 * graph[i][j] 表示 i 到 j 的边权 * 若 i 和 j 不连通,则为 INF */ public static void prim(int[][] graph) { int n = graph.length;
// 标记顶点是否已加入最小生成树 boolean[] visited = new boolean[n];
// minDist[i] 表示当前生成树到顶点 i 的最小边权 int[] minDist = new int[n];
// parent[i] 记录 i 是通过哪个顶点加入生成树的 int[] parent = new int[n];
// 初始化 for (int i = 0; i < n; i++) { minDist[i] = INF; parent[i] = -1; }
// 从 0 号顶点开始,希望第一个被选中的点一定是起点 minDist[0] = 0;
// 一共选择 n 个顶点 for (int i = 0; i < n; i++) {
// 1. 选出当前未访问、minDist 最小的顶点 int u = -1; int min = INF; for (int v = 0; v < n; v++) { if (!visited[v] && minDist[v] < min) { min = minDist[v]; u = v; } }
// 将该顶点加入最小生成树 visited[u] = true;
// 2. 用 u 更新其他未加入顶点的 minDist for (int v = 0; v < n; v++) { // 如果 v 未加入生成树,且 u-v 的边权更小 if (!visited[v] && graph[u][v] != INF && graph[u][v] < minDist[v]) { minDist[v] = graph[u][v]; parent[v] = u; } } }
// 输出最小生成树 System.out.println("最小生成树的边:"); int totalWeight = 0; for (int i = 1; i < n; i++) { System.out.println(parent[i] + " - " + i + " : " + graph[i][parent[i]]); totalWeight += graph[i][parent[i]]; } System.out.println("最小生成树的总权值:" + totalWeight); }}【示例】
顶点:0, 1, 2, 3, 4
邻接矩阵(INF 表示不连通):
| 0 | 1 | 2 | 3 | 4 | |
|---|---|---|---|---|---|
| 0 | 0 | 2 | ∞ | 6 | ∞ |
| 1 | 2 | 0 | 3 | 8 | 5 |
| 2 | ∞ | 3 | 0 | ∞ | 7 |
| 3 | 6 | 8 | ∞ | 0 | 9 |
| 4 | ∞ | 5 | 7 | 9 | 0 |
初始化:
visited = [F, F, F, F, F]minDist = [0, ∞, ∞, ∞, ∞] // 从 0 号点开始parent = [-1, -1, -1, -1, -1]第 1 轮(选第一个顶点)
① 选 u(minDist 最小的未访问顶点)
| 顶点 | visited | minDist |
|---|---|---|
| 0 | F | 0 |
| 1 | F | ∞ |
| 2 | F | ∞ |
| 3 | F | ∞ |
| 4 | F | ∞ |
选中 0
visited = [T, F, F, F, F]② 用 0 更新其它顶点的 minDist
- 0 → 1:2 < ∞ → 更新
- 0 → 3:6 < ∞ → 更新
minDist = [0, 2, ∞, 6, ∞]parent = [-1, 0, -1, 0, -1]第 2 轮
① 选 u
| 顶点 | visited | minDist |
|---|---|---|
| 0 | T | 0 |
| 1 | F | 2 |
| 2 | F | ∞ |
| 3 | F | 6 |
| 4 | F | ∞ |
选中 1
visited = [T, T, F, F, F]用 1 更新
- 1 → 2:3 < ∞ → 更新
- 1 → 4:5 < ∞ → 更新
- 1 → 3:8 > 6 → 不更新
minDist = [0, 2, 3, 6, 5]parent = [-1, 0, 1, 0, 1]第 3 轮
① 选 u
| 顶点 | visited | minDist |
|---|---|---|
| 2 | F | 3 |
| 3 | F | 6 |
| 4 | F | 5 |
选中 2
visited = [T, T, T, F, F]② 用 2 更新
- 2 → 4:7 > 5 → 不更新
状态不变:
minDist = [0, 2, 3, 6, 5]第 4 轮
① 选 u
| 顶点 | visited | minDist |
|---|---|---|
| 3 | F | 6 |
| 4 | F | 5 |
选中 4
visited = [T, T, T, F, T]② 用 4 更新
- 4 → 3:9 > 6 → 不更新
第 5 轮
① 选 u
➡️ 只剩 3
visited = [T, T, T, T, T]最终最小生成树
根据 parent 数组:
| 边 | 权值 |
|---|---|
| 0 — 1 | 2 |
| 1 — 2 | 3 |
| 0 — 3 | 6 |
| 1 — 4 | 5 |
总权值 = 2 + 3 + 6 + 5 = 16
8.4.3 Kruskal 算法
把所有边按权值从小到大排序,依次尝试加入,只要不形成环就要。
它是一个以“边”为中心的贪心算法。
核心思想(贪心策略)
- 当前能选的最小权值边,一定属于某一棵最小生成树
- 唯一要避免的事:形成环
👉 判断是否成环,靠 并查集(Union-Find)
算法步骤
- 将图中所有边按 权值从小到大排序
- 初始化并查集(每个顶点各自一个集合)
- 从小到大遍历边:
- 若边
(u, v)的两个端点不在同一集合 → 加入 MST,并合并集合 - 否则(会成环)→ 跳过
- 若边
- 当选够
V - 1条边时结束
并查集在 Kruskal 中的作用
并查集支持两种操作:
find(x):找 x 的根(所属集合)union(x, y):合并两个集合
👉 是否成环 = 是否属于同一集合
【示例】
图的边(权值):
(0-1, 2)(1-2, 3)(1-4, 5)(0-3, 6)(2-4, 7)(1-3, 8)(3-4, 9)① 排序
2 → 3 → 5 → 6 → 7 → 8 → 9② 依次选边
| 边 | 是否成环 | 结果 |
|---|---|---|
| 0-1 | 否 | 加入 |
| 1-2 | 否 | 加入 |
| 1-4 | 否 | 加入 |
| 0-3 | 否 | 加入 |
| 已有 4 条边 | 结束 |
算法实现
import java.util.*;
/** * Kruskal 算法求最小生成树 */public class KruskalAlgorithm {
/** * 边的定义 */ static class Edge { int u; // 边的一个端点 int v; // 边的另一个端点 int weight; // 边权值
Edge(int u, int v, int weight) { this.u = u; this.v = v; this.weight = weight; } }
/** * 并查集(Union-Find) */ static class UnionFind { int[] parent; int[] rank; // 用于按秩合并,优化性能
UnionFind(int n) { parent = new int[n]; rank = new int[n]; // 初始化:每个元素都是独立的集合 for (int i = 0; i < n; i++) { parent[i] = i; rank[i] = 0; } }
/** * 查找 x 的根节点(带路径压缩) */ int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 路径压缩 } return parent[x]; }
/** * 合并两个集合 */ boolean union(int x, int y) { int rootX = find(x); int rootY = find(y);
// 已经在同一个集合中,不能合并(会成环) if (rootX == rootY) { return false; }
// 按秩合并 if (rank[rootX] < rank[rootY]) { parent[rootX] = rootY; } else if (rank[rootX] > rank[rootY]) { parent[rootY] = rootX; } else { parent[rootY] = rootX; rank[rootX]++; } return true; } }
/** * Kruskal 算法主逻辑 */ public static void kruskal(int vertices, List<Edge> edges) {
// 1. 按边权从小到大排序 edges.sort(Comparator.comparingInt(e -> e.weight));
UnionFind uf = new UnionFind(vertices);
int totalWeight = 0; int edgeCount = 0;
System.out.println("最小生成树的边:");
// 2. 依次尝试加入最小的边 for (Edge edge : edges) {
// 如果加入该边不会形成环 if (uf.union(edge.u, edge.v)) { System.out.println(edge.u + " - " + edge.v + " : " + edge.weight); totalWeight += edge.weight; edgeCount++;
// 最小生成树只需要 V - 1 条边 if (edgeCount == vertices - 1) { break; } } }
System.out.println("最小生成树的总权值:" + totalWeight); }}8.5 最短路径
8.5.1 最短路径的概念
在一个不带权图中,若从一顶点到另一顶点存在着一条路径,则称该路径的长度为该路径上所经过边的数目,它等于该路径上的顶点数减 1。从一顶点到另一顶点可能存在着多条路径,每条路径上所经过的边数可能不同,即路径长度不同,把路径长度最短(即经过的边数最少)的那条路径称为最短路径,把其路径长度称为最短路径长度或最短距离。
对于带权图,考虑路径上各边的权值,通常把一条路径上所经边的权值之和定义为该路径的路径长度或称带权路径长度。从源点到终点可能不止一条路径,把带权路径长度最短的那条路径称为最短路径,把其路径长度(权值之和)称为最短路径长度或者最短距离。
求图的最短路径主要包括两个方面的问题,一是求图中某一顶点到其余各顶点的最短路径(称为单源最短路径),这里介绍 Dijkstra(狄克斯特拉)算法;二是求图中每一对顶点之间的最短路径(称为多源最短路径),这里介绍 Floyd(弗洛伊德)算法。
8.5.2 Dijkstra 算法
从一个源点出发,逐步确定到其它所有顶点的最短路径长度。
它解决的是: 👉 单源最短路径(Single Source Shortest Path)问题
核心思想(贪心)
每一步都选取: 当前“已知最短距离最小”的未确定顶点 并“固定”它的最短路径
一旦某个点被选中:
- 它的最短距离 不会再改变
算法步骤
- 初始化:
dist[source] = 0- 其它顶点距离 = ∞
- 维护集合:
- 已确定最短路径的顶点集合
S
- 已确定最短路径的顶点集合
- 重复以下过程:
- 从未在
S中的顶点里,选dist最小的顶点u - 将
u加入S - 用
u松弛(relax) 它的所有邻边
- 从未在
松弛(Relaxation)
对边 (u, v, w):
如果 dist[u] + w < dist[v]就更新 dist[v] = dist[u] + w含义是:
“走到 u 再走到 v,会不会更近?”
【示例】
图(从 0 出发):
0 →1 (2)0 →2 (6)1 →2 (3)1 →3 (1)2 →3 (1)初始化
dist = [0, ∞, ∞, ∞]第 1 轮
- 选
0 - 更新:
dist = [0, 2, 6, ∞]第 2 轮
- 选
1 - 更新:
dist = [0, 2, 5, 3]第 3 轮
- 选
3 - 无更新
第 4 轮
- 选
2 - 结束
最终结果:
dist = [0, 2, 5, 3]算法实现
import java.util.*;
/** * Dijkstra 算法(优先队列实现) * 适用于:边权非负的图 */public class DijkstraAlgorithm {
/** * 边的定义(邻接表用) */ static class Edge { int to; // 边的终点 int weight; // 边权
Edge(int to, int weight) { this.to = to; this.weight = weight; } }
/** * Dijkstra 主算法 * @param graph 邻接表表示的图 * @param source 源点 */ public static void dijkstra(List<List<Edge>> graph, int source) { int n = graph.size();
// dist[i]:源点到 i 的最短距离 int[] dist = new int[n]; Arrays.fill(dist, Integer.MAX_VALUE); dist[source] = 0;
// visited[i]:i 是否已经确定最短路径 boolean[] visited = new boolean[n];
// 优先队列:按当前距离从小到大取顶点(已经被发现,但还没最终确认最短路径的顶点) // int[] = {顶点编号, 当前距离} PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));
// 从源点开始 pq.offer(new int[]{source, 0});
while (!pq.isEmpty()) { int[] cur = pq.poll(); int u = cur[0];
// 如果该点已确定,跳过(这是堆优化的关键) if (visited[u]) { continue; }
// 确定 u 的最短路径(将 u 标记为已计算出最短路径,因为第一次从优先队列中取出的某个顶点 u,一定是“所有未确定顶点中 dist 最小的”) visited[u] = true;
// 遍历 u 的所有邻边,尝试松弛 for (Edge edge : graph.get(u)) { int v = edge.to; int weight = edge.weight;
// 如果通过 u 到 v 的路径更短,就更新 if (!visited[v] && dist[u] != Integer.MAX_VALUE && dist[u] + weight < dist[v]) { dist[v] = dist[u] + weight; // 将更新后的距离放入优先队列 pq.offer(new int[]{v, dist[v]}); } } }
// 输出结果 System.out.println("从源点 " + source + " 到各点的最短距离:"); for (int i = 0; i < n; i++) { System.out.println(source + " -> " + i + " = " + dist[i]); } }}8.5.3 Floyd 算法
通过不断尝试“是否经过某个中间点 k”,逐步求出任意两点之间的最短路径。
它解决的是: 👉 多源最短路径(All-Pairs Shortest Path)
核心思想(动态规划)
状态定义
dist[i][j] = 从 i 到 j 的当前最短距离状态转移方程(核心公式)
dist[i][j] = min( dist[i][j], dist[i][k] + dist[k][j])含义是:
“从 i 到 j, 要不要 绕一下 k,可能会更短?”
循环顺序(非常关键)
for (k = 0..n-1) for (i = 0..n-1) for (j = 0..n-1)k 在最外层的含义
当前只允许使用编号 ≤ k 的点作为“中间点”
这是 Floyd 正确性的关键。
算法步骤
- 初始化
dist:dist[i][i] = 0- 有边则为权值
- 无边则为 ∞
- 枚举中间点
k - 尝试更新所有
(i, j) - 最终
dist[i][j]就是最短路径
【示例(小图)】
初始图(邻接矩阵)
0 1 20 0 4 ∞1 ∞ 0 32 2 ∞ 0k = 0
- 更新
2 → 1:
dist[2][0] + dist[0][1] = 2 + 4 = 6k = 1
- 更新
0 → 2:
4 + 3 = 7k = 2
- 更新
1 → 0:
3 + 2 = 5最终结果
0 1 20 0 4 71 5 0 32 2 6 0算法实现
public class FloydAlgorithm {
// 表示“不可达”的一个大数 private static final int INF = 1_000_000_000;
/** * Floyd 算法 * @param graph 邻接矩阵 graph[i][j] * i 到 j 有边 → 权值 * 无边 → INF */ public static void floyd(int[][] graph) { int n = graph.length;
// dist 数组:保存最短路径结果 int[][] dist = new int[n][n];
// 1. 初始化 dist for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { dist[i][j] = graph[i][j]; } }
// 2. 核心三重循环 // k:当前允许作为“中间点”的顶点 for (int k = 0; k < n; k++) { // i:起点 for (int i = 0; i < n; i++) { // j:终点 for (int j = 0; j < n; j++) {
// 如果 i->k 或 k->j 不可达,就跳过 if (dist[i][k] == INF || dist[k][j] == INF) { continue; }
// 尝试通过 k 更新 i->j 的最短路径 if (dist[i][k] + dist[k][j] < dist[i][j]) { dist[i][j] = dist[i][k] + dist[k][j]; } } } }
// 3. 输出结果 System.out.println("任意两点之间的最短距离:"); for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (dist[i][j] == INF) { System.out.print("INF "); } else { System.out.print(dist[i][j] + " "); } } System.out.println(); } }}8.6 拓扑排序
8.6.1 什么是拓扑排序
设 G = (V, E) 是一个具有 n 个顶点的有向图,当且仅当 V 中的顶点序列 v₁, v₂, ..., vₙ 满足下列条件时称为一个拓扑序列:若 <vᵢ, vⱼ> 是图中的有向边或者从顶点 vᵢ 到顶点 vⱼ 有一条路径,则在序列中顶点 vᵢ 必须排在顶点 vⱼ 之前。
在一个有向图 G 中找一个拓扑序列的过程称为拓扑排序。
例如,计算机专业的学生必须完成一系列规定的基础课和专业课才能毕业,假设这些课程的名称与相应编号如表 8.3 所示。
| 课程编号 | 课程名称 | 先修课程 |
|---|---|---|
| C₁ | 高等数学 | 无 |
| C₂ | 程序设计 | 无 |
| C₃ | 离散数学 | C₁ |
| C₄ | 数据结构 | C₂, C₃ |
| C₅ | 编译原理 | C₂, C₄ |
| C₆ | 操作系统 | C₁, C₇ |
| C₇ | 计算机组成原理 | C₂ |
表 8.3 课程名称与相应编号的关系
课程之间的这种先修关系可用一个有向图表示,如图 8.47 所示。这种用顶点表示活动、用有向边表示活动之间优先关系的有向图称为顶点表示活动的网(简称为 AOV 网)。
对这个有向图进行拓扑排序可得到一个拓扑序列 C₁ → C₃ → C₂ → C₄ → C₇ → C₆ → C₅,也可得到另一个拓扑序列 C₂ → C₇ → C₁ → C₃ → C₄ → C₅ → C₆,还可以得到其他的拓扑序列。学生按照任何一个拓扑序列都可以有顺序地进行课程学习。
拓扑排序的过程如下:
(1) 从有向图中选择一个没有前驱(即入度为 0)的顶点并且输出它。 (2) 从图中删去该顶点,并且删去从该顶点发出的全部有向边。 (3) 重复上述两步,直到剩余的图中不再存在没有前驱的顶点为止。
拓扑排序的结果有两种:一种是图中的全部顶点都被输出,即得到包含全部顶点的拓扑序列,称为成功的拓扑排序;另一种就是图中顶点未被全部输出,即只能得到部分顶点的拓扑序列,称为失败的拓扑排序。
由拓扑排序过程看出,如果只得到部分顶点的拓扑序列,那么剩余的顶点均有前驱顶点,或者说至少有两个顶点相互为前驱,从而构成一个有向回路。
说明: 对一个有向图进行拓扑排序,如果不能得到全部顶点的拓扑序列,则图中存在有向回路,否则图中不存在有向回路,可以利用这个特点采用拓扑排序判断一个有向图中是否存在回路。
8.6.2 拓扑排序算法设计
在设计拓扑排序算法时,假设给定的有向图采用邻接表作为存储结构,需要考虑顶点的入度,为此设计一个 ind 数组,ind[i] 存放顶点 i 的入度,先通过邻接表 G 求出 ind。拓扑排序时的设计要点如下:
(1) 在某个时刻,可以有多个入度为 0 的顶点,为此设置一个栈 st,以存放多个入度为 0 的顶点,栈中的顶点都是入度为 0 的顶点。
(2) 在出栈顶点 i 时,将顶点 i 输出,同时删去该顶点的所有出边,实际上没有必要真删去这些出边,只需要将顶点 i 的所有出边邻接点的入度减 1 就可以了。
对应的拓扑排序算法如下:
public static void TopSort(AdjGraphClass G) //拓扑排序{ int[] ind = new int[MAXV]; //记录每个顶点的入度 Arrays.fill(ind, 0); //初始化 ind 数组
ArcNode p; for(int i = 0; i < G.n; i++) //求顶点 i 的入度 { p = G.adjlist[i].firstarc; while(p != null) { int j = p.adjvex; ind[j]++; //有边 <i, j>, 顶点 j 的入度增 1 p = p.nextarc; } }
Stack<Integer> st = new Stack<Integer>(); //定义一个栈 for(int i = 0; i < G.n; i++) //所有入度为 0 的顶点进栈 if(ind[i] == 0) st.push(i);
while(!st.empty()) //栈不为空循环 { int i = st.pop(); //出栈一个顶点 i System.out.print(i + " "); //输出顶点 i
p = G.adjlist[i].firstarc; //寻找第一个邻接点 while(p != null) { int j = p.adjvex; ind[j]--; //顶点 j 的入度减 1 if(ind[j] == 0) //入度为 0 的邻接点进栈 st.push(j); p = p.nextarc; //寻找下一个邻接点 } }}上述算法仅仅输出一个拓扑序列(实际应用中绝大多数情况都是如此,例如 8.7 节讨论的求关键路径就只需要产生一个拓扑序列)。对于图 8.48 所示的有向图,输出的拓扑序列为 4 5 0 1 2 3。
说明: 在拓扑排序中找栈仅仅用于存放所有入度为 0 的顶点,不必考虑先后顺序,可以用队列代替栈。对于图 8.48 所示的有向图,采用队列时输出的拓扑序列为 0 4 1 5 2 3。
*8.6.3 逆拓扑序列和非递归深度优先遍历
8.7 AOE 网与关键路径
8.7.1 什么是 AOE 网和关键路径
-
什么是 AOE 网
AOE 网(边表示活动的有向无环图),常用于工程计划与项目管理中,用来分析:
- 一个工程有哪些活动
- 活动之间的先后依赖关系
- 工程最早 / 最晚完成时间
基本概念
AOE 网是一个 有向无环图(DAG),其中:
- 顶点(事件) 表示某一阶段的完成状态(事件本身不消耗时间)
- 有向边(活动) 表示具体的工作活动,边的权值表示完成该活动所需的时间
👉 这点很重要:
AOE 网中「时间在边上」,而不是在点上
-
- AOE 网的特点**
- 只能有一个源点(工程开始)
- 只能有一个汇点(工程结束)
- 不允许有环(否则活动会形成循环依赖)
-
什么是关键路径
关键路径是:
从源点到汇点的 最长路径
为什么是最长路径?
- 因为路径长度表示完成该路径上所有活动所需的总时间
- 最长路径决定了整个工程的最短完成时间
关键路径的重要性:
- 关键路径上的活动称为 关键活动
- 关键活动一旦延误,整个工程就会延误
- 缩短工程工期 → 只能缩短关键路径上的活动时间
-
时间参数
在 AOE 网中,通常会计算以下几个时间:
事件的最早发生时间
ve(i)- 表示事件
i最早 什么时候可以发生 - 从源点开始,按拓扑序向前推
公式:
ve(j) = max(ve(i) + duration(i, j))事件的最晚发生时间
vl(i)- 表示事件
i最晚 什么时候发生而不影响工期 - 从汇点开始,按逆拓扑序向后推
公式:
vl(i) = min(vl(j) - duration(i, j))活动的最早开始时间
e(i, j)e(i, j) = ve(i)活动的最晚开始时间
l(i, j)l(i, j) = vl(j) - duration(i, j)活动的时间余量(松弛时间)
slack = l(i, j) - e(i, j)- slack = 0 → 关键活动
- 关键活动组成的路径 → 关键路径
- 表示事件
【示例】

图中元素含义
- 圆圈 A、B、C、D、E、F、G、H、I 👉 表示 事件(Event),本身不耗时
- 箭头 a₁, a₂, …, a₁₁ 👉 表示 活动(Activity),箭头上的数字是活动持续时间
- A 是源点(工程开始)
- I 是汇点(工程结束)
计算事件的最早发生时间 ve(正向推)
从 源点 A 开始,ve(A)=0,按拓扑顺序往前推。
-
第一层
- ve(B) = 0 + 6 = 6
- ve(C) = 0 + 4 = 4
- ve(D) = 0 + 5 = 5
-
到事件 E(注意取最大值)
E 有两个前驱:
- B → E:6 + 1 = 7
- C → E:4 + 1 = 5
👉 ve(E) = max(7, 5) = 7
-
后续事件
- ve(F) = 7 + 9 = 16
- ve(G) = 7 + 7 = 14
- ve(H) = 5 + 2 = 7
-
汇点 I(多个前驱,仍取最大)
- F → I:16 + 2 = 18
- G → I:14 + 4 = 18
- H → I:7 + 4 = 11 👉 ve(I) = 18
📌 工程最短完成时间 = 18
计算事件的最晚发生时间 vl(逆向推)
从 汇点 I 开始:
- vl(I) = ve(I) = 18
-
倒推前一层
- vl(F) = 18 − 2 = 16
- vl(G) = 18 − 4 = 14
- vl(H) = 18 − 4 = 14
-
事件 E
E 有两条出边:
- E → F:16 − 9 = 7
- E → G:14 − 7 = 7
👉 vl(E) = min(7, 7) = 7
-
继续倒推
- vl(B) = 7 − 1 = 6
- vl(C) = 7 − 1 = 6
- vl(D) = 14 − 2 = 12
-
源点 A
- A → B:6 − 6 = 0
- A → C:6 − 4 = 2
- A → D:12 − 5 = 7
👉 vl(A) = 0
找关键活动(slack = 0)
公式:
e(i,j) = ve(i)l(i,j) = vl(j) − duration当:
e(i,j) = l(i,j)👉 该活动是 关键活动
关键活动(图中粗线)
- A → B
- B → E
- E → F
- F → I
把它们连起来:
A → B → E → F → I8.7.2 求 AOE 网中关键路径的算法
- 构建 AOE 网(有向无环图)
- 拓扑排序
- 按拓扑序计算
ve - 初始化汇点
vl = ve(汇点) - 逆拓扑序计算
vl - 找出
slack = 0的活动 - 连接这些活动,得到关键路径
// 边(活动)class Edge { int to; // 终点事件编号 int weight; // 活动持续时间
public Edge(int to, int weight) { this.to = to; this.weight = weight; }}
/** * AOE 网关键路径求解(邻接表存储) */public class CriticalPath {
private int n; // 事件个数(顶点数) private List<Edge>[] graph; // 邻接表 private int[] indegree; // 入度数组
private int[] ve; // 事件最早发生时间 private int[] vl; // 事件最晚发生时间 private List<Integer> topoOrder; // 拓扑序列
@SuppressWarnings("unchecked") public CriticalPath(int n) { this.n = n; graph = new ArrayList[n]; indegree = new int[n]; ve = new int[n]; vl = new int[n]; topoOrder = new ArrayList<>();
for (int i = 0; i < n; i++) { graph[i] = new ArrayList<>(); } }
/** * 添加一条活动(边) * @param from 起点事件 * @param to 终点事件 * @param w 活动持续时间 */ public void addEdge(int from, int to, int w) { graph[from].add(new Edge(to, w)); indegree[to]++; }
/** * 拓扑排序 + 计算 ve */ private boolean topologicalSort() { Queue<Integer> queue = new LinkedList<>();
// 1. 所有入度为 0 的点入队 for (int i = 0; i < n; i++) { if (indegree[i] == 0) { queue.offer(i); ve[i] = 0; // 源点 ve 初始化为 0 } }
// 2. BFS 拓扑排序 while (!queue.isEmpty()) { int u = queue.poll(); topoOrder.add(u);
for (Edge e : graph[u]) { int v = e.to;
// 更新 ve[v] ve[v] = Math.max(ve[v], ve[u] + e.weight);
// 入度减 1 if (--indegree[v] == 0) { queue.offer(v); } } }
// 如果拓扑排序点数不足,说明有环 return topoOrder.size() == n; }
/** * 求关键路径 */ public void criticalPath() { if (!topologicalSort()) { System.out.println("该 AOE 网中存在环,无法求关键路径!"); return; }
// 1. 初始化 vl int maxTime = ve[topoOrder.get(topoOrder.size() - 1)]; Arrays.fill(vl, maxTime);
// 2. 按逆拓扑序计算 vl for (int i = topoOrder.size() - 1; i >= 0; i--) { int u = topoOrder.get(i);
for (Edge e : graph[u]) { int v = e.to; vl[u] = Math.min(vl[u], vl[v] - e.weight); } }
// 3. 输出关键活动 System.out.println("关键活动(关键路径上的边):"); for (int u = 0; u < n; u++) { for (Edge e : graph[u]) { int v = e.to;
int eStart = ve[u]; // 活动最早开始时间 int lStart = vl[v] - e.weight; // 活动最晚开始时间
// slack = 0 → 关键活动 if (eStart == lStart) { System.out.println(u + " -> " + v + " (duration = " + e.weight + ")"); } } }
System.out.println("工程最短完成时间:" + maxTime); }}第九章 查找
9.1 查找的基本概念
- 查找的定义:
- 在一个包含
n个元素的查找表中,给定一个值k,寻找关键字等于k的元素。 - 成功则返回该元素的位置,失败则返回相应的指示信息。
- 在一个包含
- 查找表的组成:
- 元素/记录:由若干数据项组成。
- 主关键字:能唯一标识一个元素的数据项。
- 次关键字:可以标识多个元素的数据项。
- 本章默认按主关键字进行查找。
- 查找表的分类:
- 静态查找表:只进行查找操作(如查询某个元素是否存在或其属性),不涉及插入或删除。
- 动态查找表:在查找过程中允许插入新元素或删除已有元素。
- 查找的内外之分:
- 内查找:整个查找过程都在内存中完成。
- 外查找:查找过程中需要访问外存(如硬盘)。
性能衡量
- 平均查找长度 (ASL - Average Search Length) 是衡量查找算法效率的主要依据。
- 它定义为查找过程中关键字的平均比较次数。
- 公式:
ASL = Σ(pᵢ × cᵢ),其中:n是查找表中元素的总数。pᵢ是查找第i个元素的概率(通常假设每个元素概率相等,即pᵢ = 1/n)。cᵢ是查找到第i个元素所需的关键字比较次数。
- ASL 又细分为成功情况下的平均查找长度和不成功情况下的平均查找长度。
9.2 线性表的查找
线性表是最简单也是最常见的一种查找表。本节将介绍 3 种线性表查找方法,即顺序查找、折半查找和分块查找算法。这里的线性表采用顺序表存储,由于顺序表不适合数据修改操作(插入和删除元素几乎需要移动一半的元素),所以顺序表是一种静态查找表。
为了简单,假设元素的查找关键字为 int 类型,顺序表中元素的类型如下:
class RecType //顺序表的元素类型{ int key; //存放关键字, 假设关键字为 int 类型 String data; //存放其他数据, 假设为 String 类型
public RecType(int d) //构造方法 { key = d; }}定义一个顺序表查找类 SqListSearchClass 如下:
public class SqListSearchClass //顺序表查找类{ final int MAXN = 100; //表示最多元素个数 RecType[] R; //存放查找表的数组, R[0..n-1] 表示查找表 int n; //实际元素个数
public void CreateR(int[] a) //由关键字序列 a 构造顺序表 R { R = new RecType[MAXN]; for (int i = 0; i < a.length; i++) R[i] = new RecType(a[i]); n = a.length; }
public void Disp() //输出顺序表 { for(int i = 0; i < n; i++) System.out.print(R[i].key + " "); System.out.println(); } //各种顺序表查找算法将在后面讨论}9.2.1 顺序查找
1. 顺序查找算法
顺序查找是一种最简单的查找方法。其基本思路是从顺序表的一端开始依次遍历,将遍历的元素关键字和给定值 k 相比较,若两者相等,则查找成功,返回该元素的序号;若遍历结束后仍未找到关键字等于 k 的元素,则查找失败,返回 -1。为了简单,假设从顺序表的前端开始遍历(从顺序表后端开始遍历的过程与之类似),对应的顺序查找算法如下:
public int SeqSearch1(int k) //顺序查找算法 1{ int i = 0; while(i < n && R[i].key != k) i++; //从表头往后找 if(i >= n) return -1; //未找到返回 -1 else return i; //找到后返回其序号 i}当然也可以设置一个哨兵,即将顺序表 R[0..n-1] 后面位置 R[n] 的关键字设置为 k,这样 i 从 0 开始依次比较,当满足 R[i].key = k 时(任何查找一定会出现这种情况),若 i = n 说明查找失败,返回 -1,否则说明查找成功,返回 i。对应的算法如下:
public int SeqSearch2(int k) //顺序查找算法 2{ //添加哨兵 R[n] = new RecType(k); int i = 0; while(R[i].key != k) i++; //从表头往后找 if (i == n) return -1; //未找到返回 -1 else return i; //找到后返回其序号 i}说明: 上述 SeqSearch1 算法中需要做 i 的越界判断,由于查找算法中主要考虑关键字比较次数,所以这里只考虑 R[i].key 和 k 之间的比较次数,在查找失败时需要 n 次关键字比较。增加哨兵后的 SeqSearch2 算法在查找中不需要做 i 的越界判断,但在查找失败时需要 n + 1 次关键字比较。
9.2.2 折半查找
1. 折半查找算法
折半查找又称二分查找,它是一种效率较高的查找方法。但是折半查找要求线性表是有序表,即表中的元素按关键字有序。在下面的讨论中均默认表中元素是递增有序的。
折半查找的基本思路是设 R[low..high] 是当前的非空查找区间(下界为 low,上界为 high),首先确定该区间的中间位置 mid = (low + high) / 2(或者 mid = (low + high) >> 1),然后将待查的 k 值与 R[mid].key 比较:
(1) 若 k == R[mid].key,则查找成功并返回该元素的序号 mid。
(2) 若 k < R[mid].key,则由表的有序性可知 R[mid..high].key 均大于 k,因此若表中存在关键字等于 k 的元素,则该元素必定在左子表中,故新查找区间为 R[low..mid-1],即下界不变,上界改为 mid - 1。
(3) 若 k > R[mid].key,则要查找的 k 必在右子表 R[mid+1..high] 中,故新查找区间为 R[mid+1..high],即下界改为 mid + 1,上界不变。
下一次查找是针对非空新查找区间进行的,其过程与上述过程类似。若新查找区间为空,表示查找失败,返回 -1。
因此可以从初始的查找区间 R[0..n-1] 开始,每经过一次与当前查找区间的中间位置上的关键字的比较,就可确定查找是否成功,不成功则新查找区间缩小一半。重复这一过程,直到找到关键字为 k 的元素(查找成功)或者新查找区间为空(查找失败)时为止。对应的折半查找算法如下:
public int BinSearch1(int k) //折半查找非递归算法{ int low = 0, high = n - 1, mid; while(low <= high) //当前区间非空时 { mid = (low + high) / 2; //求查找区间的中间位置
if(k == R[mid].key) //查找成功返回其序号 mid return mid; if(k < R[mid].key) high = mid - 1; //继续在 R[low..mid-1] 中查找 else low = mid + 1; //k > R[mid].key, 继续在 R[mid+1..high] 中查找 } return -1; //当前查找区间空时返回 -1}说明: 将 mid = (low + high) / 2 改为 mid = low + (high - low) / 2 效果会更好,因为当 low + high 的结果大于 int 类型所能表示的最大值时会产生溢出,再除以 2 不会得到正确的结果,而 low + (high - low) / 2 不存在这个问题。

上述 BinSearch1 算法是采用迭代方式(循环语句)实现的,实际上折半查找过程是一个递归过程,也可以采用以下递归算法来实现:
public int BinSearch2(int k) //折半查找递归算法{ return BinSearch21(0, n - 1, k);}
private int BinSearch21(int low, int high, int k) //被 BinSearch2 方法调用{ if(low <= high) //当前查找区间非空时 { int mid = (low + high) / 2; //求查找区间的中间位置 if(k == R[mid].key) //查找成功返回其序号 mid return mid;
if(k < R[mid].key) return BinSearch21(low, mid - 1, k); //递归在左区间中查找 else return BinSearch21(mid + 1, high, k); //k > R[mid].key, 递归在右区间中查找 } else return -1; //当前查找区间空时返回 -1}说明: 折半查找算法需要快速地确定查找区间的中间位置,所以不适合链式存储结构的数据查找,而适合顺序存储结构(具有随机存取特性)的数据查找。
2. 折半查找算法分析
时间复杂度 (核心优势)
- 最坏情况 & 平均情况: O(log₂n)
- 最好情况: O(1)(当要查找的关键字恰好是中间元素时)
-
- 空间复杂度**
- 非递归实现 (迭代): O(1)
- 只需要几个辅助变量(
low,high,mid),占用的内存空间是常数级别的。
- 只需要几个辅助变量(
- 递归实现: O(log₂n)
- 递归调用会使用系统栈,每次递归调用都会在栈中保存当前的
low和high等信息。由于递归深度最多为log₂n,所以空间复杂度为O(log₂n)。
- 递归调用会使用系统栈,每次递归调用都会在栈中保存当前的
结论: 在实际应用中,通常优先使用非递归的实现方式,因为它更节省内存。
平均查找长度 (ASL)
平均查找长度衡量了找到一个元素平均需要比较多少次。
- 成功查找的 ASL: 大约为
log₂(n+1) - 1。 - 不成功查找的 ASL: 大约为
log₂(n+1)。
这个结果可以通过将折半查找过程看作在一棵判定树(或称为二叉判定树)上进行搜索来推导。
判定树的构造:
- 将查找表的中间元素作为树的根。
- 左半部分的中间元素作为左子树的根。
- 右半部分的中间元素作为右子树的根。
- 以此类推,直到所有元素都被放入树中。
关键点:
- 判定树是一棵平衡二叉树(或近似平衡)。
- 查找过程中关键字的比较次数 = 该元素在判定树中的层数。
- 因此,成功查找的 ASL 就等于树中所有内部结点(有实际数据的结点)的层数之和 除以结点总数
n。 - 不成功查找的 ASL 则与树中所有外部结点(代表失败的空指针)的层数有关。
例如,对于 n=10 的有序表,其成功查找的 ASL 约为 2.9,意味着平均只需要比较不到 3 次就能找到目标。
9.2.3 索引存储结构和分块查找
1. 索引存储结构
索引存储结构是在采用数据表存储数据的同时还建立附加的索引表。索引表中的每一项称为索引项,索引项的一般形式为(关键字,地址),其中关键字唯一标识一个元素,地址为该关键字元素在数据表中的存储地址,整个索引表按关键字有序排列。例如,对于第 1 章中表 1.1 所示的高等数学成绩表,以学号为关键字时的索引存储结构如图 9.7 所示,其中数据表采用顺序表结构存放所有学生的成绩元素,每个元素有一个地址(这里采用相对地址),索引表由学号和地址组成,并且按学号递增排列。在这样的索引存储结构中,数据表的每个元素都对应索引表的一个索引项,也就是说数据表和索引表的长度相同,称之为稠密索引。

含 n 个元素的线性表采用索引存储结构后,按关键字 k 的查找过程是先在索引表中按折半查找方法找到关键字为 k 的索引项,得到其地址,所花时间为 O(log₂n),再通过地址在数据表中找到对应的元素,所花时间为 O(1),合起来的查找时间为 O(log₂n),与折半查找的性能相同,属于高效的查找方法。索引存储结构的缺点是为了建立索引表而增加了时间和空间的开销。
2. 分块查找
分块查找是一种介于 顺序查找 和 二分查找 之间的查找方法。
核心思想一句话概括:
把数据分成若干块,块间有序,块内无序(或可无序),先定位块,再在块内查找。
基本结构
分块查找通常由 两部分 组成:
- 主表(数据表)
- 存放所有数据元素
- 每一块包含若干个元素
- 块内可以无序
示例(主表):
[ 5 2 8 | 10 7 9 | 15 12 18 ] 块1 块2 块3- 索引表(块表)
- 每一块对应一个索引项
- 索引项通常包含:
- 该块的 最大关键字(或最小关键字)
- 该块在主表中的 起始位置
- 索引表必须有序
示例(索引表):
| 块号 | 最大关键字 | 起始位置 |
|---|---|---|
| 1 | 8 | 0 |
| 2 | 10 | 3 |
| 3 | 18 | 6 |
- 分块查找的查找过程
假设要查找关键字 x:
a. 在索引表中查找块
- 在索引表中查找 第一个 ≥ x 的块
- 索引表有序 → 可用:
- 顺序查找(块数少)
- 二分查找(常考)
b. 在块内查找元素
- 确定块后
- 在该块中进行 顺序查找
时间复杂度分析
设:
- 总元素数:
n - 每块元素数:
b - 块数:
m = n / b
- 索引表查找
- 顺序查找:
O(m) - 二分查找:
O(log m)
- 块内查找
- 顺序查找:
O(b)
平均查找长度(ASL)
若索引表用二分查找:
ASL ≈ log2(n / b) + b / 2当 b ≈ √n 时,ASL 最小:
ASL ≈ √n👉 这也是教材中常说的:
分块查找的平均查找效率介于顺序查找和二分查找之间
【示例】
主表:
[ 12, 3, 7 | 20, 15, 18 | 25, 30, 28 ]索引表(按最大值):
| 块 | 最大值 |
|---|---|
| 1 | 12 |
| 2 | 20 |
| 3 | 30 |
查找 x = 15:
- 索引表中:12 < 15 ≤ 20 → 块 2
- 在块 2 中顺序查找 → 找到 15
算法实现
/** * 索引表中的一个块信息 */class Block { int maxKey; // 该块中的最大关键字 int start; // 该块在主表中的起始下标 int size; // 该块包含的元素个数
public Block(int maxKey, int start, int size) { this.maxKey = maxKey; this.start = start; this.size = size; }}
/** * 分块查找实现 */public class BlockSearch {
private int[] data; // 主表 private Block[] index; // 索引表
public BlockSearch(int[] data, Block[] index) { this.data = data; this.index = index; }
/** * 分块查找 * @param key 要查找的关键字 * @return 找到返回主表下标,否则返回 -1 */ public int search(int key) {
/* ---------- 第一步:在索引表中查找块 ---------- */
int left = 0; int right = index.length - 1; int blockIndex = -1;
// 索引表按 maxKey 有序,使用二分查找 while (left <= right) { int mid = (left + right) / 2;
if (key <= index[mid].maxKey) { blockIndex = mid; right = mid - 1; // 尝试找更前面的块 } else { left = mid + 1; } }
// 没有合适的块 if (blockIndex == -1) { return -1; }
/* ---------- 第二步:在块内顺序查找 ---------- */
Block block = index[blockIndex]; int start = block.start; int end = start + block.size;
for (int i = start; i < end; i++) { if (data[i] == key) { return i; // 找到,返回下标 } }
return -1; // 块内未找到 }}【例 9.7】 对于具有 10 000 个元素的顺序表,假设数据分布满足相应的要求,回答以下问题。
(1) 若采用分块查找方法,并用顺序查找来确定元素所在的块,则分成几块最好?每块的最佳长度为多少?此时成功情况下的平均查找长度为多少?在这种情况下,若改为用折半查找确定块,成功情况下的平均查找长度为多少?
(2) 若采用分块查找方法,仍用顺序查找来确定元素所在的块,假定每块长度为 s = 20,此时成功情况下的平均查找长度是多少?在这种情况下,若改为用折半查找确定块,成功情况下的平均查找长度为多少?
(3) 若直接采用顺序查找和折半查找方法,其成功情况下的平均查找长度各是多少?
解:
(1) 对于具有 10 000 个元素的文件,若采用分块查找方法,并用顺序查找来确定元素所在的块,每块中最佳元素个数 s = √10000 = 100,总的块数 b = ⌈n/s⌉ = 100。此时成功情况下的平均查找长度为:
在这种情况下,若改为用折半查找确定块,此时成功情况下的平均查找长度为:
(2) s = 20,则 b = ⌈n/s⌉ = 10000/20 = 500。
在进行分块查找时,若仍用顺序查找确定块,此时成功情况下的平均查找长度为:
在这种情况下,若改为用折半查找确定块,此时成功情况下的平均查找长度为:
(3) 若直接采用顺序查找,此时成功情况下的平均查找长度为:
若直接采用折半查找,此时成功情况下的平均查找长度为:
由此可见,分块查找算法的效率介于顺序查找和折半查找之间。
9.3 数表的查找
9.3.1 二叉排序树
1. 二叉排序树的定义
二叉排序树(简称 BST)又称二叉查找(搜索)树,其定义为二叉排序树或者是空树,或者是满足如下性质的二叉树:
(1) 若它的左子树非空,则左子树上所有结点的值(默认为结点关键字)均小于根结点值。 (2) 若它的右子树非空,则右子树上所有结点的值均大于根结点值。 (3) 左、右子树本身又各是一棵二叉排序树。
上述性质称为二叉排序树性质(简称为 BST 性质),故二叉排序树实际上是满足 BST 性质的二叉树,并且假设所有结点值唯一。
从 BST 性质可推出二叉排序树的一些重要性质:按中序遍历该树所得到的中序序列是一个递增有序序列。整棵二叉排序树中关键字最小的结点是根结点的最左下结点(中序序列的开始结点),关键字最大的结点是根结点的最右下结点(中序序列的尾结点)。
正是因为二叉排序树的中序序列是一个有序序列,所以对于一个任意的关键字序列构造一棵二叉排序树,其实质是对此关键字序列进行排序,使其变为有序序列。“排序树”的名称也由此而来。
定义二叉排序树的结点类型如下(这里每个结点值仅有关键字 key,若有其他数据项可相应地增加成员):
class BSTNode //二叉排序树结点类{ public int key; //存放关键字, 假设关键字为 int 类型 public BSTNode lchild; //存放左孩子指针 public BSTNode rchild; //存放右孩子指针
BSTNode() //构造方法 { lchild = rchild = null; }}设计二叉排序树类模板 BSTClass 如下:
public class BSTClass //二叉排序树类{ public BSTNode r; //二叉排序树的根结点 private BSTNode f; //用于存放待删除结点的双亲结点
public BSTClass() //构造方法 { r = null; } //二叉排序树的基本运算算法}2. 二叉排序树的查找
基本思想
利用 BST 的“左小右大”性质:
- 若
key == 当前结点.key→ 查找成功 - 若
key < 当前结点.key→ 去左子树查找 - 若
key > 当前结点.key→ 去右子树查找
👉 每比较一次,就能排除一半子树(理想情况下)
查找过程(直观示例)
给定 BST:
45 / \ 24 53 / \ \ 12 37 90查找 37:
- 37 < 45 → 左子树
- 37 > 24 → 右子树
- 37 == 37 → 查找成功
二叉排序树的查找算法
- 递归查找
/** * 在 BST 中递归查找 key */public TreeNode search(TreeNode root, int key) { // 查找失败 if (root == null) { return null; }
// 查找成功 if (key == root.val) { return root; }
// 去左子树 if (key < root.val) { return search(root.left, key); } // 去右子树 else { return search(root.right, key); }}- 非递归查找
/** * 在 BST 中非递归查找 key */public TreeNode search(TreeNode root, int key) { while (root != null) { if (key == root.val) { return root; } else if (key < root.val) { root = root.left; } else { root = root.right; } } return null; // 查找失败}查找性能分析
时间复杂度
查找时间 = 树的高度 h
-
最好情况(近似平衡):
h ≈ log₂n → O(log n) -
最坏情况(极度不平衡,退化成链表):
h = n → O(n)
【例 9.8】 已知一组关键字为 (25, 18, 46, 2, 53, 39, 32, 4, 74, 67, 60, 11),按该顺序依次插入一棵初始为空的二叉排序树中,画出最终的二叉排序树,并求在等概率情况下查找成功的平均查找长度和查找不成功的平均查找长度。
解: 最终构造的二叉排序树如图 9.10 所示,图中的圆形结点为内部结点,小方形结点为外部结点。

在等概率的情况下,查找成功的平均查找长度为:
在等概率的情况下,查找不成功(落在 空指针(外部结点))的平均查找长度为:
3. 二叉排序树的删除
基本思想
删除结点 p 后,必须保证:
删除后仍满足 BST 的“左小右大”性质
因此,删除操作不能简单地断开结点,而要根据被删结点的情况进行调整。
三种删除情况
设要删除的结点为 p,其父结点为 parent。
a. p 是叶子结点(无孩子)
parent | p处理方式
- 直接删除
p - 将
parent指向p的指针置为null
📌 不影响 BST 性质
b:p 只有一个孩子
① 只有左孩子
parent | p / L② 只有右孩子
parent | p \ R处理方式(统一)
- 用
p的唯一孩子替代p - 让
parent直接指向该孩子
📌 子树整体上移,BST 性质不变
c:p 有两个孩子(最重要)
p / \ L R方法 A:用「中序前驱」替代
- 找到
p左子树中 最大的结点 - 用该结点的值替换
p - 再递归删除这个前驱结点
中序前驱 = 左子树最右结点方法 B:用「中序后继」替代
- 找到
p右子树中 最小的结点 - 用该结点的值替换
p - 再递归删除这个后继结点
中序后继 = 右子树最左结点📌 前驱 / 后继结点一定至多只有一个孩子 👉 最终会退化为 情况 1 或 2

【示例】
示例 BST
50 / \ 30 70 \ \ 40 90删除 50(有两个孩子):
- 找中序后继 →
70的最左结点是70 - 用
70替换50 - 删除原来的
70
结果:
70 / \ 30 90 \ 40时间复杂度
- 查找删除结点:
O(h) - 查找前驱 / 后继:
O(h) - 总体:
O(h)
其中 h 是树高:
- 平衡 BST:
O(log n) - 退化链表:
O(n)
算法实现
/** * 二叉排序树结点 */class TreeNode { int key; // 关键字 TreeNode left; // 左孩子 TreeNode right; // 右孩子
public TreeNode(int key) { this.key = key; }}
/** * 二叉排序树(BST) */public class BinarySearchTree {
private TreeNode root;
/* ================= 插入操作 ================= */
/** * 插入关键字(递归) */ public void insert(int key) { root = insert(root, key); }
private TreeNode insert(TreeNode node, int key) { // 插入位置 if (node == null) { return new TreeNode(key); }
if (key < node.key) { node.left = insert(node.left, key); } else if (key > node.key) { node.right = insert(node.right, key); } // 若 key 已存在,通常不插入(或按约定处理) return node; }
/* ================= 删除操作(重点) ================= */
/** * 删除关键字 key */ public void delete(int key) { root = delete(root, key); }
/** * 在以 node 为根的 BST 中删除 key,返回新的子树根 */ private TreeNode delete(TreeNode node, int key) {
// 情况 0:未找到要删除的结点 if (node == null) { return null; }
if (key < node.key) { // 去左子树删除 node.left = delete(node.left, key); } else if (key > node.key) { // 去右子树删除 node.right = delete(node.right, key); } else { // ===== 找到要删除的结点 node =====
/* ---------- 情况 1 & 2:结点至多只有一个孩子 ---------- */ if (node.left == null) { // 只有右孩子 或 无孩子 return node.right; } else if (node.right == null) { // 只有左孩子 return node.left; }
/* ---------- 情况 3:结点有两个孩子 ---------- */ // 使用“中序后继”替代(右子树中最小结点)
TreeNode successor = findMin(node.right);
// 用后继结点的值替换当前结点 node.key = successor.key;
// 在右子树中删除这个后继结点 node.right = delete(node.right, successor.key); }
return node; }
/** * 查找以 node 为根的子树中的最小结点 * (一直向左走) */ private TreeNode findMin(TreeNode node) { while (node.left != null) { node = node.left; } return node; }
/* ================= 查找操作(辅助) ================= */
/** * 查找关键字 */ public TreeNode search(int key) { TreeNode cur = root; while (cur != null) { if (key == cur.key) { return cur; } else if (key < cur.key) { cur = cur.left; } else { cur = cur.right; } } return null; }
/* ================= 中序遍历(验证 BST) ================= */
public void inorder() { inorder(root); System.out.println(); }
private void inorder(TreeNode node) { if (node != null) { inorder(node.left); System.out.print(node.key + " "); inorder(node.right); } }}9.3.2 平衡二叉树
平衡二叉树通常指 AVL 树(Adelson-Velsky & Landis)。
定义
平衡二叉树是一种二叉排序树(BST),并且对任一结点都满足: 左右子树的高度差(平衡因子)的绝对值 ≤ 1
平衡因子(Balance Factor)
对结点 v:
BF(v) = height(left subtree) − height(right subtree)- BF ∈ {−1, 0, +1} → 平衡
- |BF| > 1 → 失衡,需要调整
AVL 树的基本性质
- 是 二叉排序树
- 任一结点左右子树高度差 ≤ 1
- 中序遍历结果仍然 有序
- 是最严格的平衡二叉树

AVL 树的失衡与调整
AVL 树在 插入或删除 后,可能出现失衡,需要通过 旋转 来恢复平衡。
四种失衡类型(必考)
记忆口诀:LL、RR、LR、RL
- LL 型(左左)
特征
- 在某结点的 左孩子的左子树 插入导致失衡
A / B / C调整方式:右旋
B / \ C A- RR 型(右右)
特征
- 在某结点的 右孩子的右子树 插入导致失衡
A \ B \ C调整方式:左旋
B / \ A C- LR 型(左右)
特征
- 在某结点的 左孩子的右子树 插入导致失衡
A / B \ C调整方式:先左旋,再右旋
- RL 型(右左)
特征
- 在某结点的 右孩子的左子树 插入导致失衡
A \ B / C调整方式:先右旋,再左旋
AVL 树的操作复杂度
| 操作 | 时间复杂度 |
|---|---|
| 查找 | O(log n) |
| 插入 | O(log n) |
| 删除 | O(log n) |
| 旋转 | O(1) |
插入结点时的失衡与调整
插入只会导致 从插入点向上第一个失衡结点 失衡 调整一次即可恢复平衡
删除结点时的失衡与调整
删除可能导致 多个祖先结点连续失衡 需要 一路向上调整
删除与插入的关键区别
| 项目 | 插入 | 删除 |
|---|---|---|
| 失衡结点数 | 1 个 | 可能多个 |
| 调整次数 | 1 次 | 多次 |
| BF 变化 | +1 或 −1 | −1 或 +1 |
删除中的特殊情况
LL / RR 型中,子结点 BF = 0
示例(LL):
A (BF = +2) / B (BF = 0)- 插入中不会出现
- 删除中可能出现
📌 处理方式:
- 仍做单旋(右旋或左旋)
- 旋转后树高 可能继续降低
- 必须继续向上检查
插入 vs 删除 调整规则对比
| 项目 | 插入 | 删除 | ||||
|---|---|---|---|---|---|---|
| 触发条件 | BF | = 2 | BF | = 2 | ||
| 子结点 BF = 0 | 不出现 | 可能出现 | ||||
| 调整次数 | 一次 | 多次 | ||||
| 调整后高度 | 恢复 | 可能继续变化 |
AVL 树的查找
AVL 树的查找过程与二叉排序树(BST)完全相同, 区别只在于 AVL 树保证高度平衡。
也就是说:
- 比较规则:左小右大
- 查找路径:从根一路向下
- 不涉及旋转(旋转只发生在插入 / 删除)
AVL 树查找的时间复杂度
树高分析
AVL 树始终保持:
更严格地:
查找复杂度
- 最好情况:
O(1)(查根) - 最坏情况:
O(log n) - 平均情况:
O(log n)
9.3.3 Java 中的 TreeMap 和 TreeSet 集合
1. 红黑树
红黑树是另外一种平衡二叉树,因为 AVL 调整复杂且代价较高,所以稍微放松一点限制条件,通过结点变色和左右旋转维护平衡性,同样可以在 O(log₂n) 的时间内完成查找。红黑树是满足如下条件的二叉排序树:
(1) 每个结点要么是红色,要么是黑色。 (2) 根结点和外部结点必须是黑色。 (3) 红色结点不能连续(也就是说红色结点的孩子和双亲结点都不能是红色)。 (4) 从任意结点到外部结点的任何路径上都含有相同个数的黑色结点。 (5) 在树的结构发生改变时(插入或者删除操作)往往会破坏上述条件 (3) 或条件 (4),需要通过调整使得查找树重新满足红黑树的条件。
如图 9.24 所示的二叉排序树就是一棵红黑树(带阴影的结点表示黑色结点),其中根结点和所有外部结点是黑色,没有相邻的红色结点,从任意结点到外部结点的任何路径上都含有相同个数的黑色结点。

2. TreeMap
TreeMap (SetMap.Entry<K, V>。其底层是采用红黑树来实现的,不允许出现重复的关键字。它的两种创建方法如下:
(1) TreeMap():使用键的自然顺序构造一个新的空映射树,例如键为 Integer 类型时自然顺序为递增顺序。
(2) TreeMap(Comparator<? super K> comparator):构造一个新的空映射树,该映射根据给定比较器进行排序。对于映射中的任意两个键 k1 和 k2,执行 comparator.compare(k1, k2) 不得抛出比较异常 ClassCastException。如果比较器的比较结果为 0,即比较的两个键值相等,将会发生值覆盖,但键值不变。
TreeMap 提供的一些方法如下:
(1) int size():返回 TreeMap 中的键值映射关系数。
(2) V put(K key, V value):将指定值与 TreeMap 中的指定键进行关联。
(3) V remove(Object key):如果 TreeMap 中存在该键的映射关系,则将其删除。
(4) void clear():从 TreeMap 中移除所有映射关系。
(5) V get(Object key):返回指定键所映射的值,如果 TreeMap 中不包含该键的任何映射关系,则返回 null。
(6) boolean containsKey(Object key):如果 TreeMap 中包含指定键的映射关系,则返回 true。
(7) boolean containsValue(Object value):如果 TreeMap 中为指定值映射一个或多个键,则返回 true。
(8) Set<Map.Entry<K,V>> entrySet():返回 TreeMap 中包含的映射关系的 Set 视图。
(9) Map.Entry<K,V> firstEntry():返回一个与 TreeMap 中的最小键关联的键值映射关系,如果 TreeMap 为空,则返回 null。
(10) Map.Entry<K,V> floorEntry(K key):返回 TreeMap 中的一个键值映射关系,它与小于等于给定键的最大键关联。如果不存在这样的键,则返回 null。
(11) Map.Entry<K,V> lastEntry():返回与 TreeMap 中的最大键关联的键值映射关系。如果 TreeMap 为空,则返回 null。
(12) Map.Entry<K,V> lowerEntry(K key):返回 TreeMap 中的一个键值映射关系,它与严格小于给定键的最大键关联。如果不存在这样的键,则返回 null。
(13) Set<K> keySet():返回 TreeMap 中包含的键的 Set 视图。
(14) K firstKey():返回 TreeMap 中的当前第一个(最低)键。
(15) K floorKey(K key):返回 TreeMap 中小于等于给定键的最大键。如果不存在这样的键,则返回 null。
(16) K lastKey():返回 TreeMap 中的当前最后一个(最高)键。
(17) K lowerKey(K key):返回 TreeMap 中严格小于给定键的最大键。如果不存在这样的键,则返回 null。
(18) Collection<V> values():返回 TreeMap 中包含的值的 Collection 视图。
3. TreeSet
TreeSet 集合是用来对元素(而不是 TreeMap 中的键值映射关系)进行排序的,同样它要求元素值唯一,对应的泛型类为 TreeSet<E>,E 为该集合的元素类型。实际上,TreeSet 的底层是采用 TreeMap 存放元素的,即 TreeSet 也是采用红黑树结构。
与 TreeMap 的构造方法相似,TreeSet 既可以使用元素的自然顺序对元素进行排序,也可以根据创建 TreeSet 时提供的 Comparator(比较器)进行排序。但不同于 TreeMap,
TreeSet 可以使用 Iterator() 和 descendingIterator() 返回的迭代器分别实现正向和反向迭代。
TreeSet 提供的一些方法如下。
(1) int size():返回 TreeSet 中的元素数(容量)。
(2) boolean add(E e):将指定的元素添加到 TreeSet 中(如果该元素之前不存在)。
(3) E ceiling(E e):返回 TreeSet 中大于等于给定元素的最小元素。如果不存在这样的元素,则返回 null。
(4) void clear():移除 TreeSet 中的所有元素。
(5) boolean contains(Object o):如果 TreeSet 中包含指定的元素,则返回 true。
(6) Iterator<E> descendingIterator():返回在 TreeSet 元素上按降序进行迭代的迭代器。
(7) E first():返回 TreeSet 中的当前第一个(最低)元素。
(8) E floor(E e):返回 TreeSet 中小于等于给定元素的最大元素。如果不存在这样的元素,则返回 null。
(9) E higher(E e):返回 TreeSet 中严格大于给定元素的最小元素。如果不存在这样的元素,则返回 null。
(10) boolean isEmpty():如果 TreeSet 中不包含任何元素,则返回 true。
(11) Iterator<E> iterator():返回在 TreeSet 中的元素上按升序进行迭代的迭代器。
(12) E last():返回 TreeSet 中的当前最后一个(最高)元素。
(13) E lower(E e):返回 TreeSet 中严格小于给定元素的最大元素。如果不存在这样的元素,则返回 null。
(14) E pollFirst():获取并移除第一个(最低)元素。如果 TreeSet 为空,则返回 null。
(15) E pollLast():获取并移除最后一个(最高)元素。如果 TreeSet 为空,则返回 null。
(16) boolean remove(Object o):将指定的元素从 TreeSet 中移除(如果该元素存在于 TreeSet 中)。
TreeSet 中成员方法 add()、remove() 和 contains() 等的时间复杂度均为 O(log₂n)。
9.3.4 B- 树
B-树(B-Tree) 是一种 多路平衡查找树,主要用于:
外存(磁盘)索引、数据库、文件系统
它的核心目标不是“少比较”,而是:
尽量减少磁盘 I/O 次数
基本定义(以 m 阶 B-树为例)
设 m 阶 B-树(m ≥ 3):
- 结点结构
一个结点包含:
- 最多 m − 1 个关键字
- 最多 m 个子树指针
P0 | K1 | P1 | K2 | ... | Kt | Pt- 关键字与子树的关系
-
子树
Pi中所有关键字:Ki < keys(Pi) < K(i+1)
- 关键约束
除根结点外,每个结点必须满足:
| 项目 | 数量范围 |
|---|---|
| 关键字个数 | ⌈m/2⌉ − 1 到 m − 1 |
| 子树个数 | ⌈m/2⌉ 到 m |
根结点:
- 至少 1 个关键字
- 至少 2 个孩子(非叶时)
- 平衡性
所有叶子结点在同一层
这是 B-树查找效率稳定的关键。
例如,图 9.26 所示为一棵 3 阶 B-树,m = 3。它的特点如下:

(1) 根结点有两个孩子结点。
(2) 除根结点外,所有的非叶子结点至少有 ⌈m/2⌉ = 2 个孩子,最多有 m = 3 个孩子(这类结点的关键字个数为 1~2 个)。
(3) 所有叶子结点都在同一层上。
(4) 树的高度为 h = 3(h 中不含外部结点层)。
B-树的查找过程
查找步骤
- 在当前结点的 关键字数组中顺序或二分查找
- 若命中关键字 → 查找成功
- 否则进入对应子树指针
- 重复直到找到或到达叶结点
查找复杂度
- 树高:
O(logₘ n) - 每个结点内查找:
O(m)或O(log m) - 磁盘 I/O 次数 ≈ 树高
B-树的插入
插入规则
- 总是插入到 叶结点
- 若结点未满 → 直接插入
- 若结点已满 → 分裂
结点分裂(重点)
- 将中间关键字 提升到父结点
- 左右两部分分别形成新结点
- 分裂可能向上传播
- 根分裂 → 树高 +1

B-树的删除
删除原则
- 若关键字在 叶结点
- 直接删除
- 若关键字数不足 → 调整
- 若关键字在 内部结点
- 用前驱或后继替代
- 再删除替代结点
删除调整方式
- 向兄弟借关键字
- 与兄弟合并
- 可能向上递归调整

9.3.5 B+树
B+ 树 是在 B-树(B-Tree) 基础上的一种改进型 多路平衡查找树,是:
数据库与文件系统中最常用的索引结构
👉 在实际系统中,几乎所有范围查询索引都用 B+ 树。
核心定义(m 阶 B+ 树)
-
结点分类
B+ 树的结点分为:
- 内部结点(索引结点)
- 叶结点(数据结点)
-
关键特性
(1)关键字分布
- 内部结点
- 只存 索引关键字
- 不存放实际数据
- 叶结点
- 存放 全部关键字 + 数据记录指针
📌 内部结点的关键字 在叶结点中必然出现
(2)叶结点链表(必考)
- 所有叶结点按关键字大小 顺序链接
- 支持高效 范围查询
[Leaf1] → [Leaf2] → [Leaf3] → ...(3)平衡性
所有叶结点都在同一层
- 内部结点
-
m 阶 B+ 树的数量约束(考试重点)
内部结点(非根)
-
子树数:
⌈m/2⌉ ~ m -
关键字数:
⌈m/2⌉ − 1 ~ m − 1
叶结点
-
关键字数:
⌈m/2⌉ − 1 ~ m − 1
根结点
- 至少 2 个子树(非叶时)
-
例如,图 9.35 所示为一棵 4 阶的 B+ 树。通常在 B+ 树上有两个标识指针,一个指向根结点,这里为 root,另一个指向关键字最小的叶子结点,这里为 sqt。

B+ 树的查找过程
查找单个关键字
- 从根结点开始
- 在内部结点中查找索引关键字
- 沿对应指针向下
- 最终 一定查到叶结点
- 在叶结点中定位数据
📌 无论查找是否成功,都要到叶结点
范围查找
- 查找到范围起始关键字所在的叶结点
- 沿叶结点链表顺序扫描
B+ 树的插入
- 查找插入位置(叶结点)
- 插入关键字
- 若结点未满 → 结束
- 若结点已满 → 分裂
- 中间关键字 复制到父结点
- 叶结点仍保留全部关键字
- 分裂可能向上传播
- 根分裂 → 树高 +1
📌 与 B-树的关键区别: 提升的是“索引”,不是“删除关键字”
B+ 树的删除
- 删除叶结点中的关键字
- 若关键字数仍满足下限 → 结束
- 否则:
- 向兄弟结点 借关键字
- 或与兄弟 合并
- 必要时更新父结点索引
- 可能向上递归调整
9.4 哈希表的查找
9.4.1 哈希表的基本概念
哈希表存储的基本思路是设要存储的元素个数为 n,设置一个长度为 m (m ≥ n) 的连续内存单元,以每个元素的关键字 kᵢ (0 ≤ i ≤ n-1) 为自变量,通过一个哈希函数 h 把 kᵢ 映射为内存单元的地址(或相对地址)h(kᵢ),并把该元素存储在这个内存单元中。通常把这样构造的存储结构称为哈希表。
例如,对于第 1 章中表 1.1 所示的高等数学成绩表 (n = 7),学号为关键字,采用长度 m = 12 的哈希表 ha[0..11] 存储,哈希函数为 h(学号) = 学号 - 2018001,对应的哈希表如图 9.36 所示(其中的空结点不存放元素,可以用特殊关键字 NULLKEY 值表示)。

9.4.2 哈希函数的构造方法
构造哈希函数的目标是使 n 个元素的哈希地址尽可能均匀地分布在 m 个连续内存单元地址上,同时使计算过程尽可能简单,以达到尽可能高的时间效率。根据关键字的结构和分布的不同,可构造出许多不同的哈希函数。这里主要讨论几种常用的整数类型关键字的哈希函数的构造方法。
1. 直接定址法
直接定址法是以关键字 k 本身或关键字加上某个常量 c 作为哈希地址的方法。直接定址法的哈希函数 h(k) 为:
h(k) = k + c图 9.36 所示哈希表的哈希函数就是采用直接定址法,这种哈希函数计算简单,并且不可能有冲突发生。当关键字的分布基本连续时,可用直接定址法的哈希函数;否则,若关键字分布不连续将造成内存单元的大量浪费。
2. 除留余数法
除留余数法是用关键字 k 除以某个不大于哈希表长度 m 的整数 p 所得的余数作为哈希地址的方法。除留余数法的哈希函数 h(k) 为:
h(k) = k mod p (mod 为求余运算, p ≤ m)除留余数法的计算比较简单,适用范围广,是最经常使用的一种哈希函数。这种方法的关键是选好 p,使得元素集合中的每一个关键字通过该函数映射到哈希表范围内的任意地址上的概率相等,从而尽可能减少发生冲突的可能性。例如,p 取奇数就比取偶数好。理论研究表明,p 在取不大于 m 的素数时效果最好。
3. 数字分析法
该方法是提取关键字中取值较均匀的数字位作为哈希地址的方法。它适合于所有关键字值都已知的情况,并需要对关键字中每一位的取值分布情况进行分析。例如,有一组关键字为 (92317602, 92326875, 92739628, 92343634, 92706816, 92774638, 92381262, 92394220),通过分析可知,每个关键字从左到右的第 1、2、3 位和第 6 位取值较集中,不宜作为哈希函数,剩余的第 4、5、7 和 8 位取值较分散,可根据实际需要取其中的若干位作为哈希地址。若取最后两位作为哈希地址,则哈希地址的集合为 {2, 75, 28, 34, 16, 38, 62, 20}。
其他构造整数关键字的哈希函数的方法还有平方取中法、折叠法等。平方取中法是取关键字平方后分布均匀的几位作为哈希地址的方法。折叠法是先把关键字中的若干段作为一组,然后把各小组折叠相加后分布均匀的几位作为哈希地址的方法。
9.4.3 哈希冲突的解决方法
一类:开放定址法(Open Addressing)
核心思想:
冲突时,在哈希表内部寻找下一个空位置
- 线性探测法(Linear Probing)
原理
若:
h(key) = i发生冲突,则依次探测:
i+1, i+2, i+3, ... (对表长取模)- 特点
- 实现简单
- 易产生 一次聚集(Primary Clustering)
适用
- 表不太满
- 实现要求简单
- 二次探测法(Quadratic Probing)
原理
探测序列为:
i + 1², i − 1², i + 2², i − 2², ...特点
- 缓解一次聚集
- 仍存在 二次聚集
- 双重哈希法(Double Hashing)
原理
使用两个哈希函数:
h1(key) = 初始地址h2(key) = 探测步长探测序列:
h1(key) + k * h2(key)特点
- 冲突最少
- 探测分布均匀
- 实现较复杂
📌 开放定址法中效果最好
二类:链地址法(拉链法 / Chaining)
核心思想:
每个哈希地址对应一个链表(或其他结构)
原理示意
table[0] → key1 → key5table[1] → key2table[2] → key3 → key8 → key9特点
- 冲突处理简单
- 不易产生聚集
- 表可“逻辑扩展”
缺点
- 需要额外指针空间
- 链表过长会退化
改进方式(进阶)
- 链表 → 红黑树(Java HashMap)
- 链表 → B+ 树(数据库)
9.4.4 哈希表的查找及性能分析
-
采用开放定址法建立的哈希表的查找
基本思想
**开放定址法(Open Addressing)**的核心思想是:
所有元素都存放在哈希表数组中,发生冲突时,按一定探测序列寻找下一个空地址。
因此:
- 插入时使用什么探测序列
- 查找时必须使用完全相同的探测序列
查找类型
查找分为两种:
- 查找成功
- 查找不成功
二者的停止条件不同。
查找过程(通用描述)
设:
- 哈希函数:
h(key) - 探测函数:
d(i)(第 i 次探测的偏移)
第 i 次探测地址为:
查找步骤
- 计算初始地址
H₀ = h(key) - 若
H₀位置元素等于key→ 查找成功 - 若
H₀位置元素 ≠key且不为空 → 按探测序列继续 - 若在探测过程中:
- 找到 key → 成功
- 遇到空位置 → 查找失败
- 探测回到初始位置 / 探测完表 → 查找失败
不成功查找
停止条件
在探测过程中,一旦遇到“空单元”,即可判定查找失败。
原因
- 若该关键字曾被插入
- 插入时一定会占用该空位置
- 因此遇空即失败
查找效率分析(装填因子 α)
平均查找长度
设装填因子:
线性探测法:
-
成功查找:
-
不成功查找:
📌 α 越接近 1,查找效率急剧下降。
-
采用拉链法建立的哈希表的查找
查找过程
查找步骤
设要查找关键字为
key:-
计算哈希地址
i = h(key)
-
访问哈希表第
i个槽 -
在对应的 链表中顺序查找:
- 找到 → 查找成功
- 到链表末尾仍未找到 → 查找失败
📌 只需要访问一个哈希地址
不成功查找
特点
- 需遍历整个链表
- 查找长度 = 该链表的长度
📌 与开放定址法不同:
- 不会因为遇到“空槽”提前结束
- 不成功查找只在链表结束时才确定
查找效率分析
装填因子
其中:
- n:关键字个数
- m:哈希表槽数
平均查找长度(均匀散列假设)
-
成功查找:
-
不成功查找:
📌 与装填因子 线性相关
-
*9.4.5 Java 中的HashMap和 HashSet集合
第十章 排序
10.1 排序的基本概念
和第 9 章类似,假定被排序的是由一组元素组成的表,而元素由若干个数据项组成,其中用来标识元素的数据项称为关键字项,该数据项的值称为关键字。关键字可用作排序的依据。在本章中假设要排序元素的关键字可以重复,两个元素的比较默认为它们之间的关键字比较。
1. 什么是排序
所谓排序,就是要整理表中的元素,使之按关键字递增或递减有序排列。本章仅讨论递增排序的情况,在默认情况下所有的排序均指递增排序。排序的输入与输出如下。
输入: n 个元素序列为 R₀, R₁, ..., Rₙ₋₁,其相应的关键字分别为 k₀, k₁, ..., kₙ₋₁。
输出: Rᵢ₀, Rᵢ₁, ..., Rᵢₙ₋₁,使得 kᵢ₀ ≤ kᵢ₁ ≤ ... ≤ kᵢₙ₋₁。
因此排序算法就是要确定 0 ~ n-1 的一种排列 i₀, i₁, ..., iₙ₋₁,使表中的元素依此顺序按关键字排序。
2. 内排序和外排序
在排序过程中,若整个表都是放在内存中处理,排序时不涉及数据的内、外存交换,则称之为内排序;反之,若排序过程中要进行数据的内、外存交换,则称之为外排序。内排序受到内存限制,适用于能够一次将全部元素放入内存的小表;外排序不受内存限制,适用于不能一次将全部元素放入内存的大表。内排序方法是外排序的基础。
3. 内排序的分类
根据内排序算法是否基于关键字的比较,将内排序算法分为基于比较的排序算法和不基于比较的排序算法。像插入排序、交换排序、选择排序和归并排序都是基于比较的排序算法;而基数排序是不基于比较的排序算法。
4. 基于比较的排序算法的性能
在基于比较的排序算法中主要进行以下两种基本操作。
(1) 比较:元素关键字之间的比较。 (2) 移动:元素从一个位置移动到另一个位置。
排序算法的性能是由算法的时间和空间确定的,而时间又是由比较和移动的次数确定的。
5. 排序的稳定性
当待排序元素的关键字均不相同时,排序的结果是唯一的,否则排序的结果不一定唯一。如果待排序的表中存在多个关键字相同的元素,经过排序后这些具有相同关键字的元素之间的相对次序保持不变,则称这种排序方法是稳定的;反之,若具有相同关键字的元素之间的相对次序发生变化,则称这种排序方法是不稳定的。注意,排序算法的稳定性是针对所有输入实例而言的。也就是说,在所有可能的输入实例中,只要有一个实例使得算法不满足稳定性要求,则该排序算法就是不稳定的。
6. 排序数据的组织
在讨论内排序算法时,以顺序表作为排序数据的存储结构(除基数排序采用单链表外)。假设关键字为 int 类型,待排序的顺序表中元素的类型如下:
class RecType //顺序表元素类型{ int key; //存放关键字, 假设关键字为 int 类型 String data; //存放其他数据, 假设为 String 类型
public RecType(int d) //构造方法 { key = d; }}设计用于内排序的 SqListSortClass 类(除基数排序外)如下:
public class SqListSortClass //顺序表排序类{ final int MAXN = 100; //表示最多元素个数 RecType[] R; //存放排序的元素 int n; //实际元素个数
public void swap(int i, int j) //交换 R[i] 和 R[j] { RecType tmp = R[i]; R[i] = R[j]; R[j] = tmp; }
public void CreateR(int[] a) //由关键字序列 a 构造顺序表 R[0..n-1] { R = new RecType[MAXN]; for(int i = 0; i < a.length; i++) R[i] = new RecType(a[i]); n = a.length; }
public void Disp() //输出顺序表 R[0..n-1] { for(int i = 0; i < n; i++) System.out.print(R[i].key + " "); System.out.println(); }
public void CreateR1(int[] a) //由关键字序列 a 构造顺序表 R[1..n], 用于堆排序 { R = new RecType[MAXN]; for(int i = 0; i < a.length; i++) R[i + 1] = new RecType(a[i]); n = a.length; }
public void Disp1() //输出顺序表 R[1..n], 用于堆排序 { for(int i = 1; i <= n; i++) System.out.print(R[i].key + " "); System.out.println(); } //各种基于比较的排序方法, 将在后面讨论}10.2 插入排序
10.2.1 直接插入排序
直接插入排序(Insertion Sort)是一种简单直观的比较排序算法,思想类似于我们打扑克牌时整理手牌的过程。
基本思想
直接插入排序把序列分为两部分:
- 已排序部分
- 未排序部分
初始时,第一个元素默认是已排序的。 之后从第二个元素开始,依次将当前元素插入到前面已排序序列的合适位置,使序列仍然有序。
排序过程示例
以序列:
[5, 3, 4, 1, 2]为例(升序):
- 初始:
[5 | 3, 4, 1, 2] - 插入 3 →
[3, 5 | 4, 1, 2] - 插入 4 →
[3, 4, 5 | 1, 2] - 插入 1 →
[1, 3, 4, 5 | 2] - 插入 2 →
[1, 2, 3, 4, 5]
算法步骤
- 从第 2 个元素开始遍历数组
- 取出当前元素
key - 在已排序部分中从后向前比较
- 将比
key大的元素向后移动 - 将
key插入到正确位置
时间与空间复杂度
| 情况 | 时间复杂度 |
|---|---|
| 最好情况(已排序) | O(n) |
| 最坏情况(逆序) | O(n²) |
| 平均情况 | O(n²) |
- 空间复杂度:O(1)(原地排序)
- 稳定性:稳定(相等元素相对顺序不变)
算法实现*
public class InsertionSort {
/** * 对顺序表进行直接插入排序(按 key 升序) * @param R 待排序的 RecType 数组 */ public static void insertSort(RecType[] R) { int n = R.length;
// 从第 2 个元素开始,依次插入到前面已排序序列中 for (int i = 1; i < n; i++) {
// 暂存当前待插入的元素 RecType temp = R[i];
// j 指向已排序部分的最后一个元素 int j = i - 1;
// 从后向前查找插入位置 // 若当前元素 key 大于 temp.key,则向后移动 while (j >= 0 && R[j].key > temp.key) { R[j + 1] = R[j]; // 元素后移 j--; }
// 将 temp 插入到正确位置 R[j + 1] = temp; } }}10.2.2 折半插入排序
折半插入排序(Binary Insertion Sort)是对直接插入排序的一种改进,主要思想是: 👉 利用折半查找(二分查找)来确定插入位置,从而减少比较次数。
基本思想
在第 ( i ) 趟排序中:
- 前 ( i-1 ) 个元素已经 有序
- 对第 ( i ) 个元素,不用从后向前逐个比较
- 而是对已排序部分使用 折半查找,快速找到插入位置
- 再将该位置之后的元素整体后移,插入该元素
⚠️ 注意: 虽然比较次数减少,但元素移动次数不变,所以时间复杂度数量级仍为 ( O(n^2) )
排序过程示例
对序列:
[5, 3, 4, 1, 2]以升序为例:
- 初始:
[5 | 3, 4, 1, 2] - 插入 3
- 有序区:[5]
- 折半查找插入位置 → 0
→
[3, 5 | 4, 1, 2]
- 插入 4
- 有序区:[3, 5]
- 折半查找 → 位置 1
→
[3, 4, 5 | 1, 2]
- 插入 1
→
[1, 3, 4, 5 | 2] - 插入 2
→
[1, 2, 3, 4, 5]
算法步骤
- 从第 2 个元素开始遍历
- 取当前元素作为
key - 在
[0 … i-1]的有序区间中用 折半查找 找插入位置 - 将插入位置之后的元素整体后移一位
- 插入
key
时间与空间复杂度
| 指标 | 说明 |
|---|---|
| 比较次数 | ( O(n \log n) ) |
| 移动次数 | ( O(n^2) ) |
| 时间复杂度 | ( O(n^2) ) |
| 空间复杂度 | ( O(1) ) |
| 稳定性 | 稳定 |
算法实现
public class BinaryInsertionSort {
/** * 折半插入排序(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void binaryInsertSort(RecType[] R) { int n = R.length;
// 从第 2 个元素开始插入(下标 1) for (int i = 1; i < n; i++) {
// 保存当前待插入的元素 RecType temp = R[i];
int low = 0; // 折半查找的下界 int high = i - 1; // 折半查找的上界
// 在已排序区 R[0..i-1] 中折半查找插入位置 while (low <= high) { int mid = (low + high) / 2;
if (temp.key < R[mid].key) { // 插入位置在左半区 high = mid - 1; } else { // 插入位置在右半区(保证稳定性) low = mid + 1; } }
// 此时 low 即为最终插入位置
// 将插入位置之后的元素整体后移一位 for (int j = i - 1; j >= low; j--) { R[j + 1] = R[j]; }
// 插入元素 R[low] = temp; } }}10.2.3 希尔排序
希尔排序(Shell Sort)是一种基于插入排序的改进型排序算法,由 Donald Shell 于 1959 年提出。 它的核心思想是:先让记录“基本有序”,再用直接插入排序完成最终排序。
基本思想
将整个序列按一定的增量(gap)分组, 对每一组分别进行直接插入排序, 随着增量逐步减小,序列越来越接近有序, 最后当增量为 1 时,完成整体排序。
排序过程示例
以序列:
[8, 9, 1, 7, 2, 3, 5, 4, 6, 0]为例,假设增量序列为 gap = n/2, n/4, ..., 1:
第 1 趟:gap = 5
分成 5 组:
- (8,3)
- (9,5)
- (1,4)
- (7,6)
- (2,0)
各组分别插入排序后:
[3,5,1,6,0,8,9,4,7,2]第 2 趟:gap = 2
继续分组并排序:
[0,2,1,3,5,4,7,6,9,8]第 3 趟:gap = 1
相当于一次直接插入排序:
[0,1,2,3,4,5,6,7,8,9]算法步骤
- 选取一个增量序列(gap)
- 按 gap 将序列分成若干子序列
- 对每个子序列进行直接插入排序
- 缩小 gap,重复上述过程
- 当 gap = 1 时,完成排序
时间与空间复杂度
| 项目 | 说明 |
|---|---|
| 时间复杂度 | 与增量序列有关,一般为 O(n^1.3 ~ n^2) |
| 最坏情况 | O(n²) |
| 空间复杂度 | O(1) |
| 稳定性 | 不稳定 |
❗ 希尔排序不稳定,因为跨组交换可能改变相同关键字的相对顺序。
算法实现
public class ShellSort {
/** * 希尔排序(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void shellSort(RecType[] R) { int n = R.length;
// gap 为增量,初始取 n/2,之后逐步缩小 for (int gap = n / 2; gap > 0; gap /= 2) {
// 对每一个子序列进行直接插入排序 // 子序列元素下标:gap, 2*gap, 3*gap, ... for (int i = gap; i < n; i++) {
// 暂存当前待插入的元素 RecType temp = R[i];
int j = i - gap;
// 在当前子序列中进行插入排序 while (j >= 0 && R[j].key > temp.key) { // 元素向后移动 gap 位 R[j + gap] = R[j]; j -= gap; }
// 插入到正确位置 R[j + gap] = temp; } } }}10.3 交换排序
10.3.1 冒泡排序
冒泡排序(Bubble Sort)是一种最基础、最直观的交换排序算法,因排序过程中较大的元素会像“气泡”一样逐步向序列末端移动而得名。
基本思想
重复比较相邻的两个元素, 如果顺序不正确就交换它们, 每一趟排序都会把当前未排序部分中最大的元素“冒泡”到最后。
排序过程示例
以序列:
[5, 3, 4, 1, 2]为例(升序):
第 1 趟
- (5,3) 交换 →
[3,5,4,1,2] - (5,4) 交换 →
[3,4,5,1,2] - (5,1) 交换 →
[3,4,1,5,2] - (5,2) 交换 →
[3,4,1,2,5]
👉 最大值 5 到达末尾
第 2 趟
[3,4,1,2 | 5]- 排完后 →
[3,1,2,4,5]
第 3 趟
[1,2,3,4 | 5]
排序完成。
算法步骤
- 比较相邻元素
- 若前一个大于后一个,则交换
- 每一趟减少一次比较范围
- 重复直到序列有序
改进版冒泡排序
增加一个 标志位 flag:
- 若某一趟没有发生交换
- 说明序列已经有序
- 可提前结束排序
时间与空间复杂度
| 情况 | 时间复杂度 |
|---|---|
| 最好情况(已排序) | O(n) |
| 最坏情况(逆序) | O(n²) |
| 平均情况 | O(n²) |
- 空间复杂度:O(1)
- 稳定性:稳定
算法实现*
public class BubbleSort {
/** * 冒泡排序(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void bubbleSort(RecType[] R) { int n = R.length;
// 外层循环控制排序趟数 for (int i = 0; i < n - 1; i++) {
boolean flag = false; // 标记本趟是否发生交换
// 内层循环进行相邻元素比较 // 每一趟后,末尾 i 个元素已经有序 for (int j = 0; j < n - 1 - i; j++) {
// 若前一个元素大于后一个元素,则交换 if (R[j].key > R[j + 1].key) { RecType temp = R[j]; R[j] = R[j + 1]; R[j + 1] = temp;
flag = true; // 发生了交换 } }
// 若本趟未发生交换,说明序列已完全有序 if (!flag) { break; } } }}10.3.2 快速排序
快速排序(Quick Sort)是一种高效、应用最广泛的内部排序算法之一,由 C. A. R. Hoare 提出。它采用分治思想,在平均情况下性能非常优秀。
基本思想
选择一个基准元素(pivot), 将序列划分为两部分: 左边都 小于等于 pivot,右边都 大于等于 pivot, 然后对左右两部分分别递归进行快速排序。
排序过程示例
以序列:
[5, 3, 8, 4, 2, 7, 1, 6]为例(选第一个元素为基准):
第 1 趟划分
- pivot = 5
- 划分结果:
[3, 4, 2, 1 | 5 | 8, 7, 6]递归排序
- 左区间
[3,4,2,1] - 右区间
[8,7,6]
继续递归,最终得到有序序列。
核心步骤(划分 partition)
快速排序的关键在于 一次划分操作:
- 选定基准(pivot)
- 从两端向中间扫描
- 交换不符合条件的元素
- 基准元素放到最终位置
算法步骤
- 若区间长度 ≤ 1,结束
- 选取基准元素
- 执行一次划分(partition)
- 对左右子区间递归排序
时间与空间复杂度
| 情况 | 时间复杂度 |
|---|---|
| 最好情况 | O(n log n) |
| 平均情况 | O(n log n) |
| 最坏情况 | O(n²)(已排序或近乎有序) |
- 空间复杂度:
- 平均:O(log n)(递归栈)
- 最坏:O(n)
- 稳定性:❌ 不稳定
算法实现
public class QuickSort {
/** * 快速排序主方法 * @param R 待排序的顺序表(数组) * @param low 当前排序区间的左端下标 * @param high 当前排序区间的右端下标 */ public static void quickSort(RecType[] R, int low, int high) { // 当区间中至少有两个元素时才进行排序 if (low < high) {
// 一次划分,返回基准元素的最终位置 int pivotPos = partition(R, low, high);
// 对基准左侧子序列进行快速排序 quickSort(R, low, pivotPos - 1);
// 对基准右侧子序列进行快速排序 quickSort(R, pivotPos + 1, high); } }
/** * 划分函数(partition) * 以 R[low] 作为基准,将序列划分为左右两部分 */ private static int partition(RecType[] R, int low, int high) {
// 选取第一个元素作为基准 RecType pivot = R[low];
// 使用左右指针向中间扫描 while (low < high) {
// 从右向左找第一个小于基准的元素 while (low < high && R[high].key >= pivot.key) { high--; } // 将该元素移到左端 R[low] = R[high];
// 从左向右找第一个大于基准的元素 while (low < high && R[low].key <= pivot.key) { low++; } // 将该元素移到右端 R[high] = R[low]; }
// 将基准元素放入最终位置 R[low] = pivot;
// 返回基准元素的位置 return low; }}10.4 选择排序
10.4.1 简单选择排序
简单选择排序(Simple Selection Sort)是一种直观、易理解的选择类排序算法,其特点是: 👉 每一趟从未排序序列中选出最小(或最大)元素,放到正确位置。
基本思想(一句话)
第 ( i ) 趟排序时, 从第 ( i ) 个元素到最后一个元素中, 选择关键字最小的元素, 与第 ( i ) 个元素交换位置。
排序过程示例
以序列:
[5, 3, 4, 1, 2]为例(升序):
第 1 趟
- 未排序区:[5, 3, 4, 1, 2]
- 最小值:1
- 交换 →
[1, 3, 4, 5, 2]
第 2 趟
- 未排序区:[3, 4, 5, 2]
- 最小值:2
- 交换 →
[1, 2, 4, 5, 3]
第 3 趟
- 未排序区:[4, 5, 3]
- 最小值:3
- 交换 →
[1, 2, 3, 5, 4]
最终有序。
算法步骤
- 将序列分为 已排序区 和 未排序区
- 在未排序区中查找最小元素
- 与未排序区第一个元素交换
- 扩大已排序区,重复上述过程
时间与空间复杂度
| 项目 | 说明 |
|---|---|
| 时间复杂度 | O(n²)(最好 / 最坏 / 平均均相同) |
| 比较次数 | 固定为 n(n−1)/2 |
| 交换次数 | 最多 n−1 次 |
| 空间复杂度 | O(1) |
| 稳定性 | ❌ 不稳定 |
❗ 不稳定原因:最小元素与前面元素交换,可能改变相同关键字的相对顺序。
算法实现
public class SelectionSort {
/** * 简单选择排序(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void selectionSort(RecType[] R) { int n = R.length;
// 外层循环:控制排序趟数 // 第 i 趟确定第 i 个位置上的元素 for (int i = 0; i < n - 1; i++) {
int minIndex = i; // 记录最小元素的下标
// 在未排序区 R[i+1 ... n-1] 中查找最小元素 for (int j = i + 1; j < n; j++) { if (R[j].key < R[minIndex].key) { minIndex = j; } }
// 若找到的最小元素不在当前位置,则交换 if (minIndex != i) { RecType temp = R[i]; R[i] = R[minIndex]; R[minIndex] = temp; } } }}10.4.2 堆排序
堆排序(Heap Sort)是一种基于完全二叉树(堆)结构的选择类排序算法, 其核心特点是:时间复杂度稳定为 O(n log n),且不需要额外辅助空间。
基本思想
将待排序序列构建成一个 大根堆(或小根堆), 每次将堆顶元素(最大或最小)与末尾元素交换, 再调整堆结构, 重复上述过程直到排序完成。
堆的基本概念
完全二叉树
- 除最后一层外,其余层节点都满
- 最后一层节点从左到右依次排列
堆(Heap)
- 大根堆:每个结点 ≥ 其左右孩子(用于升序排序)
- 小根堆:每个结点 ≤ 其左右孩子(用于降序排序)
排序过程示例(升序,大根堆)
以序列:
[4, 10, 3, 5, 1]-
建立初始大根堆
10/ \5 3/ \4 1 -
交换堆顶与末尾
[1, 5, 3, 4 | 10] -
调整剩余部分为大根堆
[5, 4, 3, 1 | 10]
不断重复,最终有序。
算法步骤
- 将无序序列建立为一个大根堆
- 将堆顶元素与最后一个元素交换
- 缩小堆的范围,对堆顶进行调整
- 重复步骤 2~3
时间与空间复杂度
| 项目 | 说明 |
|---|---|
| 建堆时间 | O(n) |
| 排序时间 | O(n log n) |
| 总时间复杂度 | O(n log n) |
| 空间复杂度 | O(1) |
| 稳定性 | ❌ 不稳定 |
算法实现
public class HeapSort {
/** * 堆排序主方法(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void heapSort(RecType[] R) { int n = R.length;
// 1. 建立初始大根堆 // 从最后一个非叶子结点开始向上调整 for (int i = n / 2 - 1; i >= 0; i--) { heapAdjust(R, i, n); }
// 2. 反复将堆顶元素与末尾元素交换 for (int i = n - 1; i > 0; i--) {
// 交换堆顶(最大值)与当前末尾元素 RecType temp = R[0]; R[0] = R[i]; R[i] = temp;
// 将剩余的前 i 个元素重新调整为大根堆 heapAdjust(R, 0, i); } }
/** * 堆调整函数 * @param R 顺序表 * @param i 当前需要调整的结点下标 * @param n 当前堆中元素个数 */ private static void heapAdjust(RecType[] R, int i, int n) {
// 暂存当前结点 RecType temp = R[i];
// j 指向 i 的左孩子结点 for (int j = 2 * i + 1; j < n; j = 2 * j + 1) {
// 若右孩子存在,且右孩子比左孩子大,则指向右孩子 if (j + 1 < n && R[j + 1].key > R[j].key) { j++; }
// 若孩子结点大于父结点,则向上调整 if (R[j].key > temp.key) { R[i] = R[j]; i = j; } else { break; } }
// 将 temp 放到最终位置 R[i] = temp; }}10.4.3 堆数据结构
在堆排序中使用到堆,实际上堆本身就是一种数据结构,其逻辑结构属于线性结构,它提供的基本运算如下。
(1) void push(E e):向堆中插入元素 e。
(2) E pop():删除一个元素并且返回该元素。这里的删除运算仅仅删除非空堆的堆顶元素。
(3) boolean empty():判断堆是否为空。
现在实现上述定义的堆。为了简单,假设元素类型为 RecType,即用 R[1..n] 存放堆中的 n 个元素(空堆时 n = 0),并且为大根堆。
1. 插入运算算法的设计
若 R[1..n] 是一个堆,插入元素 e 的过程是先将元素 e 添加到 R 的末尾,即执行 n++,R[n] = e,然后在从该结点向根结点方向的路径上调整,若与双亲逆序(即该结点大于双亲结点),两者交换,直到根结点为止。
例如,图 10.24(a) 所示为一个大根堆,插入元素 10 的过程如图 10.24(b)~图 10.24(d) 所示,恰好经过了从根结点到插入结点的一条路径,时间复杂度为 O(log₂n)。

对应的插入算法如下:
public void push(RecType e) //插入元素 e{ n++; //堆中元素个数增 1 R[n] = e; //将 e 添加到末尾 if(n == 1) return; //e 作为根结点的情况 int j = n, i = j / 2; //i 指向 R[j] 的双亲结点 while(true) { if(R[j].key > R[i].key) //若孩子结点较大 swap(i, j); //交换 if(i == 1) break; //到达根结点时结束 j = i; i = j / 2; //继续向上调整 }}2. 删除运算算法的设计
在堆中只能删除非空堆的堆顶元素,即最大元素。删除运算的过程是先用 e 存放堆顶元素,用堆中的末尾元素覆盖堆顶元素,执行 n-- 减少元素个数,采用堆排序中的筛选算法调整为一个堆,最后返回 e。
例如,图 10.25(a) 所示为一个大根堆,删除一个元素的过程如图 10.25(b) 和图 10.25(c) 所示,主要操作是筛选,时间复杂度为 O(log₂n)。

对应的删除算法如下:
public RecType pop() //删除堆顶元素{ if(n == 0) return null; //取出堆顶元素 RecType e = R[1]; //用尾元素覆盖 R[1] R[1] = R[n]; //元素个数减少 1 n--; sift(1, n); //筛选为一个堆 return e;}10.5 归并排序
10.5.1 自底向上的二路归并排序
自底向上的二路归并排序(Bottom-Up Two-Way Merge Sort)是一种非递归的归并排序实现方式, 它采用迭代(循环)而非递归的方式完成归并过程,非常适合与“自顶向下”的递归归并排序进行对比学习。
基本思想
从长度为 1 的有序子序列开始, 每一趟将相邻的两个有序子序列合并成一个更大的有序子序列, 子序列长度依次翻倍(1 → 2 → 4 → 8 → …), 直到整个序列有序。
排序过程示例
以序列:
[8, 4, 5, 7, 1, 3, 6, 2]为例:
第 1 趟(子序列长度 = 1)
[8] [4] [5] [7] [1] [3] [6] [2]↓[4,8] [5,7] [1,3] [2,6]第 2 趟(子序列长度 = 2)
[4,8] [5,7] → [4,5,7,8][1,3] [2,6] → [1,2,3,6]第 3 趟(子序列长度 = 4)
[4,5,7,8] [1,2,3,6]↓[1,2,3,4,5,6,7,8]排序完成。
算法步骤
- 设子序列长度
len = 1 - 将相邻两个长度为
len的子序列进行归并 - 每完成一趟,
len *= 2 - 重复直到
len ≥ n
时间与空间复杂度
| 项目 | 说明 |
|---|---|
| 时间复杂度 | O(n log n) |
| 空间复杂度 | O(n)(辅助数组) |
| 稳定性 | 稳定 |
| 是否原地 | 否 |
算法实现
public class MergeSortBU {
/** * 自底向上的二路归并排序(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void mergeSort(RecType[] R) { int n = R.length;
// 辅助数组,用于归并 RecType[] temp = new RecType[n];
// 子序列长度,从 1 开始,每趟翻倍 for (int len = 1; len < n; len *= 2) {
// 每一趟对相邻的两个长度为 len 的子序列进行归并 for (int left = 0; left < n; left += 2 * len) {
int mid = Math.min(left + len - 1, n - 1); // 第一个子序列结束位置 int right = Math.min(left + 2 * len - 1, n - 1); // 第二个子序列结束位置
// 若右子序列不存在,则无需归并 if (mid < right) { merge(R, temp, left, mid, right); } } } }
/** * 将两个相邻的有序子序列归并为一个有序序列 * R[left..mid] 和 R[mid+1..right] 均为有序 */ private static void merge(RecType[] R, RecType[] temp, int left, int mid, int right) {
int i = left; // 第一个子序列的起始下标 int j = mid + 1; // 第二个子序列的起始下标 int k = left; // temp 数组的起始下标
// 依次比较两个子序列中的元素,较小者放入 temp while (i <= mid && j <= right) { if (R[i].key <= R[j].key) { temp[k++] = R[i++]; } else { temp[k++] = R[j++]; } }
// 将第一个子序列中剩余元素复制到 temp while (i <= mid) { temp[k++] = R[i++]; }
// 将第二个子序列中剩余元素复制到 temp while (j <= right) { temp[k++] = R[j++]; }
// 将归并后的结果复制回原数组 R for (int t = left; t <= right; t++) { R[t] = temp[t]; } }}10.5.2 自顶向下的二路归并排序
自顶向下的二路归并排序(Top-Down Two-Way Merge Sort)是归并排序中最经典、最常见的递归实现方式,它充分体现了分治思想,在数据结构课程中非常重要。
基本思想
将待排序序列不断二分, 直到每个子序列只包含 1 个元素(天然有序), 然后从底向上逐层归并相邻的两个有序子序列, 最终得到一个整体有序的序列。
排序过程示例
以序列:
[8, 4, 5, 7, 1, 3, 6, 2]为例:
- 不断二分
[8 4 5 7 1 3 6 2]→ [8 4 5 7] [1 3 6 2]→ [8 4] [5 7] [1 3] [6 2]→ [8] [4] [5] [7] [1] [3] [6] [2]- 自底向上归并
[8] [4] → [4 8][5] [7] → [5 7][1] [3] → [1 3][6] [2] → [2 6]
→ [4 8] [5 7] → [4 5 7 8]→ [1 3] [2 6] → [1 2 3 6]
→ [4 5 7 8] [1 2 3 6]→ [1 2 3 4 5 6 7 8]算法步骤(递归版)
- 若子序列长度 ≤ 1,返回
- 求中点
mid - 递归排序左子序列
- 递归排序右子序列
- 合并两个有序子序列
时间与空间复杂度
| 项目 | 说明 |
|---|---|
| 时间复杂度 | O(n log n) |
| 空间复杂度 | O(n)(辅助数组) |
| 稳定性 | 稳定 |
| 是否递归 | 是 |
| 是否原地排序 | 否 |
算法实现
public class MergeSortTD {
/** * 自顶向下二路归并排序主方法 * @param R 待排序的顺序表(数组) * @param left 当前排序区间左端下标 * @param right 当前排序区间右端下标 */ public static void mergeSort(RecType[] R, int left, int right) {
// 当区间中至少有两个元素时才继续划分 if (left < right) {
// 计算中间位置 int mid = (left + right) / 2;
// 对左半部分递归排序 mergeSort(R, left, mid);
// 对右半部分递归排序 mergeSort(R, mid + 1, right);
// 将两个已排序的子序列归并 merge(R, left, mid, right); } }
/** * 归并函数 * 将两个相邻的有序子序列合并成一个有序序列 * R[left..mid] 和 R[mid+1..right] 均为有序 */ private static void merge(RecType[] R, int left, int mid, int right) {
// 辅助数组,用于暂存归并结果 RecType[] temp = new RecType[right - left + 1];
int i = left; // 第一个子序列起始下标 int j = mid + 1; // 第二个子序列起始下标 int k = 0; // temp 数组下标
// 依次比较两个子序列的元素,较小者先放入 temp while (i <= mid && j <= right) { if (R[i].key <= R[j].key) { temp[k++] = R[i++]; } else { temp[k++] = R[j++]; } }
// 将左子序列中剩余元素复制到 temp while (i <= mid) { temp[k++] = R[i++]; }
// 将右子序列中剩余元素复制到 temp while (j <= right) { temp[k++] = R[j++]; }
// 将 temp 中的有序结果复制回原数组 R for (int t = 0; t < temp.length; t++) { R[left + t] = temp[t]; } }}10.6 基数排序
基数排序(Radix Sort)是一种非比较型排序算法,它不通过元素之间的大小比较来排序,而是按“位”进行多趟分配与收集,非常适合整数或定长字符串的排序。
基本思想
将关键字按个位、十位、百位……(或从最高位到最低位), 依次进行稳定排序, 多趟排序后,序列自然有序。
两种实现方式
- LSD(Least Significant Digit,最低位优先)
- 从 最低位 → 最高位
- 最常用、实现简单
- 教材重点
- MSD(Most Significant Digit,最高位优先)
- 从 最高位 → 最低位
- 常用递归
- 实现较复杂
👉 数据结构课程中一般讲 LSD 基数排序
LSD 基数排序过程示例
对整数序列:
[329, 457, 657, 839, 436, 720, 355]第 1 趟(按个位)
0: 7205: 3556: 4367: 457, 6579: 329, 839→ [720, 355, 436, 457, 657, 329, 839]第 2 趟(按十位)
2: 720, 3293: 436, 8395: 355, 457, 657→ [720, 329, 436, 839, 355, 457, 657]第 3 趟(按百位)
3: 329, 3554: 436, 4576: 6577: 7208: 839→ 排序完成算法步骤(LSD)
- 找出关键字的最大位数
d - 从第 1 位(最低位)开始
- 按当前位将元素分配到 0~9 个桶中
- 按桶号顺序依次收集
- 重复
d趟
时间与空间复杂度
| 项目 | 说明 |
|---|---|
| 时间复杂度 | O(d × n) |
| 空间复杂度 | O(n + r)(r 为基数,通常 r=10) |
| 稳定性 | 稳定 |
| 是否比较 | 否 |
算法实现
public class RadixSort {
/** * LSD 基数排序(按 key 升序) * @param R 待排序的顺序表(数组) */ public static void radixSort(RecType[] R) { int n = R.length;
// 1. 找出最大关键字,确定最大位数 int maxKey = R[0].key; for (int i = 1; i < n; i++) { if (R[i].key > maxKey) { maxKey = R[i].key; } }
// 计算最大关键字的位数 int d = 0; while (maxKey > 0) { d++; maxKey /= 10; }
// 2. 创建 10 个桶(0~9),每个桶是一个数组 RecType[][] bucket = new RecType[10][n]; int[] count = new int[10]; // 记录每个桶中元素个数
int radix = 1; // 当前处理的位:1 表示个位,10 表示十位……
// 3. 从最低位到最高位,进行 d 趟分配与收集 for (int i = 0; i < d; i++) {
// 每一趟开始前,桶计数清零 for (int j = 0; j < 10; j++) { count[j] = 0; }
// ===== 分配过程 ===== for (int j = 0; j < n; j++) { // 取当前位上的数字 int digit = (R[j].key / radix) % 10;
// 放入对应桶中 bucket[digit][count[digit]] = R[j]; count[digit]++; }
// ===== 收集过程 ===== int index = 0; for (int j = 0; j < 10; j++) { for (int k = 0; k < count[j]; k++) { R[index++] = bucket[j][k]; } }
// 处理更高一位 radix *= 10; } }}10.7 各种内排序方法的比较和选择
前面介绍了多种内排序方法,将这些排序方法总结为表 10.1。通常按平均时间复杂度将排序方法分为 3 类。
| 排序方法 | 时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 |
|---|---|---|---|---|
| 平均情况 | 最坏情况 | 最好情况 | ||
| 直接插入排序 | O(n²) | O(n²) | O(n) | O(1) |
| 希尔排序 | O(n¹.⁵⁸) | O(n²) | O(n) | O(1) |
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) |
| 快速排序 | O(n log₂n) | O(n²) | O(n log₂n) | O(log₂n) |
| 简单选择排序 | O(n²) | O(n²) | O(n²) | O(1) |
| 堆排序 | O(n log₂n) | O(n log₂n) | O(n log₂n) | O(1) |
| 归并排序 | O(n log₂n) | O(n log₂n) | O(n log₂n) | O(n) |
| 基数排序 | O(d(n + r)) | O(d(n + r)) | O(d(n + r)) | O(r) |
表 10.1 各种排序方法的性能
(1) 平方阶排序:一般称为简单排序,例如直接插入排序、简单选择排序和冒泡排序。
(2) 线性对数阶排序:例如快速排序、堆排序和归并排序。
(3) 线性阶排序:例如基数排序(假定排序数据的位数 d 和进制 r 为常量)。
10.7 外排序
外排序(External Sorting)是一类用于处理数据量远大于内存容量的排序方法, 当待排序数据无法一次性装入内存时,就必须借助**外存(磁盘)**来完成排序。
外排序的基本思想
分段排序 + 多路归并
整体分为两个阶段:
- 生成初始归并段(Run)
- 每次从外存读入一部分数据到内存
- 在内存中进行排序(内排序)
- 将排好序的数据写回外存
- 得到多个有序子文件(归并段)
- 多路归并排序
- 同时从多个有序子文件中读取数据
- 进行多路归并
- 输出为更大的有序文件
- 重复直到只剩一个有序文件
典型算法:外部归并排序
外排序中最常用、最经典的算法就是 外部多路归并排序。
关键技术:
- 置换-选择排序(生成更长初始段)
- k 路归并
- 败者树 / 胜者树(减少比较次数)
- 缓冲区管理
外排序过程示例
假设:
-
内存只能放 3 个记录
-
待排序文件:
8 4 5 7 1 3 6 2
- 生成初始归并段
- 读入:
8 4 5→ 内排序 →4 5 8 - 读入:
7 1 3→ 内排序 →1 3 7 - 读入:
6 2→ 内排序 →2 6
得到 3 个有序段:
R1: 4 5 8R2: 1 3 7R3: 2 6- 多路归并
- 三路归并:
→ 1 2 3 4 5 6 7 8