📌目录
- [🔍 一,查找的基本概念](#🔍 一,查找的基本概念)
-
- [1. 核心定义](#1. 核心定义)
- [2. 查找效率的评价指标](#2. 查找效率的评价指标)
- [📏 二,线性表的查找](#📏 二,线性表的查找)
-
- (一)顺序查找
-
- [1. 核心思想](#1. 核心思想)
- [2. 两种实现方式](#2. 两种实现方式)
- [3. 链式存储的顺序查找](#3. 链式存储的顺序查找)
- [4. 性能分析](#4. 性能分析)
- (二)折半查找
-
- [1. 核心思想](#1. 核心思想)
- [2. 示例(有序顺序表:`[5, 13, 19, 21, 37, 56, 64, 75, 80, 88, 92]`,查找`key=21`)](#2. 示例(有序顺序表:
[5, 13, 19, 21, 37, 56, 64, 75, 80, 88, 92]
,查找key=21
)) - [3. 代码实现](#3. 代码实现)
- [4. 性能分析](#4. 性能分析)
- (三)分块查找
-
- [1. 核心概念](#1. 核心概念)
- [2. 核心思想](#2. 核心思想)
- [3. 示例(线性表:`[8, 3, 12, 17, 9, 26, 15, 21, 30, 28, 36]`,分块有序)](#3. 示例(线性表:
[8, 3, 12, 17, 9, 26, 15, 21, 30, 28, 36]
,分块有序)) - [4. 代码实现](#4. 代码实现)
- [5. 性能分析](#5. 性能分析)
- [🌳 三,树表的查找](#🌳 三,树表的查找)
-
- (一)二叉排序树
- (二)平衡二叉树
-
- [1. 定义与平衡条件](#1. 定义与平衡条件)
- [2. 失衡与旋转调整](#2. 失衡与旋转调整)
-
- [(1)左旋(Left Rotation)](#(1)左旋(Left Rotation))
- [(2)右旋(Right Rotation)](#(2)右旋(Right Rotation))
- (3)双旋转(左右旋、右左旋)
- [3. 查找与动态操作性能](#3. 查找与动态操作性能)
- [4. 适用场景](#4. 适用场景)
- (三)B-树
-
- [1. 定义与核心特征](#1. 定义与核心特征)
- [2. 查找操作](#2. 查找操作)
- [3. 插入与分裂操作](#3. 插入与分裂操作)
- [4. 性能分析](#4. 性能分析)
- [5. 适用场景](#5. 适用场景)
- (四)B+树
-
- [1. 定义与核心特征](#1. 定义与核心特征)
- [2. 查找操作](#2. 查找操作)
- [3. 插入与分裂操作](#3. 插入与分裂操作)
- [4. B+树与B-树的核心差异](#4. B+树与B-树的核心差异)
- [5. 性能分析](#5. 性能分析)
- [6. 适用场景](#6. 适用场景)
- [🔑 四,散列表的查找](#🔑 四,散列表的查找)
-
- (一)散列表的基本概念
-
- [1. 核心定义](#1. 核心定义)
- [2. 示例](#2. 示例)
- [3. 设计目标](#3. 设计目标)
- (二)散列函数的构造方法
-
- [1. 直接定址法](#1. 直接定址法)
- [2. 数字分析法](#2. 数字分析法)
- [3. 平方取中法](#3. 平方取中法)
- [4. 折叠法](#4. 折叠法)
- [5. 除留余数法(最常用)](#5. 除留余数法(最常用))
- [6. 随机数法](#6. 随机数法)
- (三)处理冲突的方法
-
- [1. 开放定址法](#1. 开放定址法)
-
- [(1)线性探测法(Linear Probing)](#(1)线性探测法(Linear Probing))
- [(2)二次探测法(Quadratic Probing)](#(2)二次探测法(Quadratic Probing))
- (3)伪随机探测法
- [2. 链地址法(Chaining,最常用)](#2. 链地址法(Chaining,最常用))
- [3. 其他方法](#3. 其他方法)
- (四)散列表的查找
-
- [1. 查找步骤(以链地址法为例)](#1. 查找步骤(以链地址法为例))
- [2. 代码实现(链地址法)](#2. 代码实现(链地址法))
- [3. 性能分析](#3. 性能分析)
- [4. 适用场景](#4. 适用场景)
- [👊 章结](#👊 章结)
-
- 一、查找算法的核心逻辑与分类
-
- [1. 线性表查找:基于"线性遍历"或"区间划分"](#1. 线性表查找:基于“线性遍历”或“区间划分”)
- [2. 树表查找:基于"层级划分"与"平衡维护"](#2. 树表查找:基于“层级划分”与“平衡维护”)
- [3. 散列表查找:基于"直接映射"与"冲突处理"](#3. 散列表查找:基于“直接映射”与“冲突处理”)
- 二、核心算法性能与适用场景对比
- 三、查找算法的选型原则
-
- [1. 数据规模:小规模优先简单算法,大规模侧重高效算法](#1. 数据规模:小规模优先简单算法,大规模侧重高效算法)
- [2. 数据动态性:静态查用"无需维护"算法,动态查用"支持增删"算法](#2. 数据动态性:静态查用“无需维护”算法,动态查用“支持增删”算法)
- [3. 存储环境:内存查找侧重时间,外部查找侧重I/O优化](#3. 存储环境:内存查找侧重时间,外部查找侧重I/O优化)
- [4. 业务需求:是否需要有序性或范围查找](#4. 业务需求:是否需要有序性或范围查找)
- 四、核心结论
🔍 一,查找的基本概念
查找(Searching)是数据处理中最频繁的操作之一,指从一组无序或有序的数据元素(称为"查找表")中,根据给定的"关键字"找到满足条件的数据元素的过程。小到手机通讯录中查找联系人,大到数据库中千万级数据的检索,都依赖高效的查找算法。
1. 核心定义
- 查找表(Search Table):由若干个数据元素(或记录)组成的集合,每个元素包含一个或多个属性,其中用于标识元素的属性称为"关键字"。
- 关键字(Key) :分为两类:
- 主关键字(Primary Key):唯一标识一个数据元素(如身份证号、学号),通过主关键字查找时,结果要么唯一,要么不存在;
- 次关键字(Secondary Key):非唯一标识(如姓名、年龄),通过次关键字查找可能返回多个满足条件的元素。
- 查找操作 :
- 静态查找:仅查询元素是否存在或获取元素信息,不改变查找表的结构(如查询字典中的单词释义);
- 动态查找:在查找过程中可能插入新元素或删除已有元素(如购物车中添加/删除商品后查找商品)。
- 查找成功与失败 :
- 查找成功:找到与关键字匹配的元素,返回元素的位置或相关信息;
- 查找失败:未找到匹配元素,返回特定标识(如
-1
、NULL
)。
2. 查找效率的评价指标
查找算法的效率主要通过平均查找长度(Average Search Length, ASL) 衡量,指在查找过程中,平均需要比较的关键字次数,计算公式为:
A S L = ∑ i = 1 n P i ⋅ C i ASL = \sum_{i=1}^{n} P_i \cdot C_i ASL=i=1∑nPi⋅Ci
- n n n:查找表中数据元素的个数;
- P i P_i Pi:查找第 i i i个元素的概率(若未指定,通常假设所有元素查找概率相等,即 P i = 1 / n P_i = 1/n Pi=1/n);
- C i C_i Ci:查找第 i i i个元素时,需要比较的关键字次数。
ASL越小,查找算法效率越高。此外,还需考虑算法的空间复杂度(额外占用的内存)和对数据有序性的要求。
📏 二,线性表的查找
线性表的查找指基于线性表(顺序表、链表)结构的查找方法,核心特点是数据元素按线性关系存储,查找过程需按线性顺序或特定规则遍历元素。根据线性表是否有序,分为顺序查找、折半查找和分块查找。
(一)顺序查找
顺序查找(Sequential Search)又称"线性查找",是最简单的查找方法,对线性表的有序性无要求,既适用于顺序存储的线性表,也适用于链式存储的线性表。
1. 核心思想
从线性表的一端(通常是第一个元素或最后一个元素)开始,逐个将元素的关键字与给定的查找关键字进行比较,直至找到匹配元素(查找成功)或遍历完所有元素(查找失败)。
2. 两种实现方式
(1)无哨兵的顺序查找(顺序表)
从第一个元素开始,依次比较至最后一个元素:
c
#define MAXSIZE 100
// 查找表元素结构
typedef struct {
int key; // 关键字(假设为整数)
// 其他数据项(如姓名、年龄等)
} ElemType;
// 顺序表结构
typedef struct {
ElemType elem[MAXSIZE]; // 存储元素的数组
int length; // 线性表的实际长度
} SqTable;
// 顺序查找(无哨兵,顺序表)
int SequentialSearch(SqTable ST, int key) {
// 从第一个元素开始遍历
for (int i = 0; i < ST.length; i++) {
if (ST.elem[i].key == key) {
return i; // 查找成功,返回元素下标
}
}
return -1; // 查找失败,返回-1
}
(2)有哨兵的顺序查找(优化版)
在顺序表的第一个元素(或最后一个元素)位置设置"哨兵"(将查找关键字存入哨兵位置),避免在循环中每次判断"是否越界",减少比较次数:
c
// 顺序查找(有哨兵,顺序表,哨兵存在elem[0])
int SequentialSearchWithGuard(SqTable ST, int key) {
ST.elem[0].key = key; // 哨兵:将查找关键字存入elem[0]
int i = ST.length; // 从最后一个元素开始向前遍历
while (ST.elem[i].key != key) {
i--;
}
return i; // 若i=0,说明查找失败;否则返回元素下标
}
3. 链式存储的顺序查找
对于单链表,需从表头开始遍历,通过指针逐个访问节点:
c
// 单链表节点结构
typedef struct LNode {
int key; // 关键字
struct LNode *next; // 指向下一个节点的指针
} LNode, *LinkList;
// 顺序查找(单链表)
LNode* SequentialSearchLink(LinkList L, int key) {
LNode *p = L->next; // 从首元节点开始遍历
while (p != NULL && p->key != key) {
p = p->next;
}
return p; // 查找成功返回节点指针,失败返回NULL
}
4. 性能分析
以顺序表(有哨兵)、等概率查找为例分析ASL:
-
查找成功 :若查找的元素是第 i i i个( i i i从1到 n n n),比较次数 C i = n − i + 1 C_i = n - i + 1 Ci=n−i+1(从最后一个元素向前遍历),则:
A S L 成功 = ∑ i = 1 n 1 n ⋅ ( n − i + 1 ) = 1 n ⋅ ( n + ( n − 1 ) + ⋯ + 1 ) = n + 1 2 ASL_{成功} = \sum_{i=1}^{n} \frac{1}{n} \cdot (n - i + 1) = \frac{1}{n} \cdot (n + (n-1) + \dots + 1) = \frac{n+1}{2} ASL成功=i=1∑nn1⋅(n−i+1)=n1⋅(n+(n−1)+⋯+1)=2n+1 -
查找失败 :需遍历至哨兵位置,比较次数 C 失败 = n + 1 C_{失败} = n + 1 C失败=n+1,则:
A S L 失败 = n + 1 ASL_{失败} = n + 1 ASL失败=n+1 -
时间复杂度 :无论成功或失败,最坏情况下需遍历所有元素,时间复杂度为O(n);
-
空间复杂度 :仅需1个临时变量或指针,空间复杂度为O(1);
-
特点:算法简单,对线性表的有序性、存储结构无要求;但效率较低,适合小规模数据或无序线性表。
(二)折半查找
折半查找(Binary Search)又称"二分查找",是一种高效的查找方法,但仅适用于有序的顺序表(链表无法随机访问,不支持折半查找)。
1. 核心思想
利用线性表的有序性(假设为升序),每次将查找范围缩小一半:
- 设查找范围的左边界为
low
(初始为0),右边界为high
(初始为length-1
); - 计算中间位置
mid = (low + high) / 2
,比较中间元素elem[mid].key
与查找关键字key
:- 若
elem[mid].key == key
:查找成功,返回mid
; - 若
elem[mid].key > key
:关键字在左半区,更新high = mid - 1
; - 若
elem[mid].key < key
:关键字在右半区,更新low = mid + 1
;
- 若
- 重复步骤2,直至
low > high
(查找失败)。
2. 示例(有序顺序表:[5, 13, 19, 21, 37, 56, 64, 75, 80, 88, 92]
,查找key=21
)
- 初始:
low=0
,high=10
,mid=5
(elem[5].key=56 > 21
)→ 左半区,high=4
; - 第二次:
mid=2
(elem[2].key=19 < 21
)→ 右半区,low=3
; - 第三次:
mid=3
(elem[3].key=21 == 21
)→ 查找成功,返回3
。
3. 代码实现
c
// 折半查找(有序顺序表,升序)
int BinarySearch(SqTable ST, int key) {
int low = 0, high = ST.length - 1; // 初始化查找范围
while (low <= high) {
int mid = (low + high) / 2; // 计算中间位置
if (ST.elem[mid].key == key) {
return mid; // 查找成功,返回下标
} else if (ST.elem[mid].key > key) {
high = mid - 1; // 关键字在左半区
} else {
low = mid + 1; // 关键字在右半区
}
}
return -1; // 查找失败,返回-1
}
4. 性能分析
折半查找的过程可通过"二叉判定树"直观表示:树的每个节点对应顺序表中的一个元素,左子树节点为该元素左侧的元素,右子树节点为右侧的元素,查找过程即从根节点到目标节点的路径。
以等概率查找、n=11(有序表长度) 为例:
-
二叉判定树的深度为 ⌈ l o g 2 ( n + 1 ) ⌉ = 4 \lceil log_2(n+1) \rceil = 4 ⌈log2(n+1)⌉=4(根节点深度为1);
-
查找成功 :每个节点的比较次数等于其深度,ASL为:
A S L 成功 = 1 11 ⋅ ( 1 × 1 + 2 × 2 + 3 × 4 + 4 × 4 ) = 33 11 = 3 ASL_{成功} = \frac{1}{11} \cdot (1 \times 1 + 2 \times 2 + 3 \times 4 + 4 \times 4) = \frac{33}{11} = 3 ASL成功=111⋅(1×1+2×2+3×4+4×4)=1133=3 -
查找失败 :失败位置对应判定树的空节点,每个空节点的比较次数等于其双亲节点的深度,ASL为:
A S L 失败 = 1 12 ⋅ ( 3 × 4 + 4 × 8 ) = 44 12 ≈ 3.67 ASL_{失败} = \frac{1}{12} \cdot (3 \times 4 + 4 \times 8) = \frac{44}{12} \approx 3.67 ASL失败=121⋅(3×4+4×8)=1244≈3.67 -
时间复杂度 :每次查找范围缩小一半,时间复杂度为O(log n),远优于顺序查找;
-
空间复杂度 :仅需3个变量(
low
、high
、mid
),空间复杂度为O(1); -
特点:效率高,但依赖有序的顺序表,插入/删除元素时需移动大量数据(维护有序性成本高),适合静态有序查找表。
(三)分块查找
分块查找(Block Search)又称"索引顺序查找",结合了顺序查找和折半查找的优点,适用于**"分块有序"的线性表**(将线性表分为若干块,块内元素无序,但块间元素有序)。
1. 核心概念
- 分块有序:假设线性表按升序分块,满足"第一块中所有元素的关键字 ≤ 第二块中所有元素的关键字 ≤ ... ≤ 第k块中所有元素的关键字",但块内元素无需有序;
- 索引表:为每个块建立一个索引项,包含"块的最大关键字"和"块的起始位置",索引表按块的最大关键字有序排列。
2. 核心思想
分块查找分为两步:
- 查找索引表:根据查找关键字,在有序的索引表中找到目标元素所在的块(可用顺序查找或折半查找);
- 查找块内元素:在找到的块中,用顺序查找(块内无序)找到目标元素。
3. 示例(线性表:[8, 3, 12, 17, 9, 26, 15, 21, 30, 28, 36]
,分块有序)
- 分块 :分为3块,块1:
[8,3,12]
(最大关键字12)、块2:[17,9,26]
(最大关键字26)、块3:[15,21,30,28,36]
(最大关键字36); - 索引表 :
[(12, 0), (26, 3), (36, 6)]
(每个索引项为"最大关键字,块起始下标"); - 查找
key=21
:- 索引表有序,用折半查找:
21
介于12和26之间,确定在块2(起始下标3); - 块2元素:
[17,9,26]
,顺序查找找到21(下标7)→ 查找成功。
- 索引表有序,用折半查找:
4. 代码实现
c
// 索引表项结构
typedef struct {
int max_key; // 块的最大关键字
int start; // 块的起始下标
} IndexItem;
// 分块查找结构
typedef struct {
ElemType elem[MAXSIZE]; // 原始线性表(分块有序)
IndexItem index[MAXSIZE]; // 索引表
int block_num; // 块的数量
int elem_num; // 线性表元素总数
} BlockTable;
// 分块查找(索引表用折半查找,块内用顺序查找)
int BlockSearch(BlockTable BT, int key) {
// 步骤1:查找索引表,确定目标块(折半查找)
int low = 0, high = BT.block_num - 1;
int block_idx = -1; // 目标块的索引表下标
while (low <= high) {
int mid = (low + high) / 2;
if (BT.index[mid].max_key >= key) {
block_idx = mid;
high = mid - 1; // 继续向左查找更小的符合条件的块
} else {
low = mid + 1;
}
}
if (block_idx == -1) {
return -1; // 无符合条件的块,查找失败
}
// 步骤2:在目标块内顺序查找
int start = BT.index[block_idx].start; // 块的起始下标
int end; // 块的结束下标
if (block_idx == BT.block_num - 1) {
end = BT.elem_num - 1; // 最后一块,结束下标为线性表末尾
} else {
end = BT.index[block_idx + 1].start - 1; // 非最后一块,结束下标为下一块起始前1
}
// 块内顺序查找
for (int i = start; i <= end; i++) {
if (BT.elem[i].key == key) {
return i; // 查找成功,返回元素下标
}
}
return -1; // 块内未找到,查找失败
}
5. 性能分析
分块查找的ASL由"索引表查找的ASL"和"块内查找的ASL"两部分组成,设:
- 线性表元素总数为 n n n,分为 b b b块,每块含 s s s个元素( n = b × s n = b \times s n=b×s);
- 索引表查找的ASL为 A S L 索引 ASL_{索引} ASL索引,块内查找的ASL为 A S L 块内 ASL_{块内} ASL块内;
则总ASL为: A S L 总 = A S L 索引 + A S L 块内 ASL_{总} = ASL_{索引} + ASL_{块内} ASL总=ASL索引+ASL块内。
(1)等概率查找,索引表用折半查找,块内用顺序查找
- A S L 索引 ≈ l o g 2 ( b + 1 ) − 1 ASL_{索引} \approx log_2(b + 1) - 1 ASL索引≈log2(b+1)−1(折半查找ASL);
- A S L 块内 = s + 1 2 ASL_{块内} = \frac{s + 1}{2} ASL块内=2s+1(顺序查找ASL);
- 总ASL: A S L 总 ≈ l o g 2 ( n s + 1 ) − 1 + s + 1 2 ASL_{总} \approx log_2(\frac{n}{s} + 1) - 1 + \frac{s + 1}{2} ASL总≈log2(sn+1)−1+2s+1。
当 s = n s = \sqrt{n} s=n 时,总ASL最小,约为 n + l o g 2 n \sqrt{n} + log_2\sqrt{n} n +log2n ,介于顺序查找(O(n))和折半查找(O(log n))之间。
(2)复杂度与特点
- 时间复杂度 :取决于块的大小 s s s,通常为O( n \sqrt{n} n );
- 空间复杂度 :需额外存储索引表( b b b个索引项),空间复杂度为O(b) ( b = n / s b = n/s b=n/s,通常较小);
- 特点:兼顾顺序查找(块内无序,插入/删除方便)和折半查找(块间有序,索引查找高效)的优点
🌳 三,树表的查找
树表的查找指基于树形结构(如二叉排序树、平衡二叉树、B-树等)的查找方法。与线性表查找相比,树表查找通过树形结构的层级划分,将查找范围逐层缩小,平均查找效率更高,且支持动态插入/删除操作(无需大规模移动数据),适用于动态查找场景(如数据库索引、字典检索)。
(一)二叉排序树
二叉排序树(Binary Sort Tree,简称BST)又称"二叉查找树",是一种特殊的二叉树,其结构天然支持高效查找、插入和删除操作,核心特点是"左子树关键字 ≤ 根节点关键字 ≤ 右子树关键字"。
1. 定义与性质
二叉排序树的递归定义:
- 若左子树不为空,则左子树上所有节点的关键字均小于等于根节点的关键字;
- 若右子树不为空,则右子树上所有节点的关键字均大于等于根节点的关键字;
- 左、右子树本身也为二叉排序树。
示例:一棵合法的二叉排序树(关键字为整数):
15
/ \
10 20
/ \ / \
8 12 18 25
2. 查找操作
核心思路
利用二叉排序树的有序性,从根节点开始逐层比较:
- 若查找关键字
key
等于当前节点关键字,查找成功,返回当前节点; - 若
key
小于当前节点关键字,递归查找左子树(若左子树存在); - 若
key
大于当前节点关键字,递归查找右子树(若右子树存在); - 若遍历至空节点,查找失败。
代码实现
c
// 二叉排序树节点结构
typedef struct BSTNode {
int key; // 关键字
struct BSTNode *lchild; // 左孩子指针
struct BSTNode *rchild; // 右孩子指针
} BSTNode, *BSTree;
// 二叉排序树查找(递归版)
BSTNode* BST_Search_Rec(BSTree T, int key) {
if (T == NULL) {
return NULL; // 查找失败
}
if (key == T->key) {
return T; // 查找成功,返回节点
} else if (key < T->key) {
return BST_Search_Rec(T->lchild, key); // 查找左子树
} else {
return BST_Search_Rec(T->rchild, key); // 查找右子树
}
}
// 二叉排序树查找(非递归版,效率更高)
BSTNode* BST_Search_Iter(BSTree T, int key) {
BSTNode *p = T;
while (p != NULL && p->key != key) {
if (key < p->key) {
p = p->lchild;
} else {
p = p->rchild;
}
}
return p; // 成功返回节点,失败返回NULL
}
3. 插入与删除操作(动态维护)
(1)插入操作
插入的核心是"找到合适的空节点位置",确保插入后仍满足二叉排序树性质:
- 若树为空,直接创建新节点作为根节点;
- 若树非空,从根节点开始比较:
- 若
key
小于当前节点关键字,且左子树为空,将新节点作为左孩子插入;否则递归遍历左子树; - 若
key
大于当前节点关键字,且右子树为空,将新节点作为右孩子插入;否则递归遍历右子树; - (注:通常不插入关键字重复的节点,若需支持重复关键字,可规定"重复关键字插入左子树或右子树")。
- 若
(2)删除操作
删除操作需分三种情况处理,确保删除后树的结构仍为二叉排序树:
- 情况1:删除节点为叶子节点 :直接删除该节点,将其父节点的对应指针设为
NULL
; - 情况2:删除节点仅有一棵子树:将该节点的子树直接连接到其父节点,替代被删除节点的位置;
- 情况3:删除节点有两棵子树:找到该节点的"中序前驱"(左子树中关键字最大的节点)或"中序后继"(右子树中关键字最小的节点),用其关键字替换被删除节点的关键字,再删除前驱/后继节点(前驱/后继节点必为情况1或情况2)。
4. 性能分析
二叉排序树的查找效率取决于树的高度(层级数):
- 理想情况(树为平衡二叉树) :树的高度为 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)( n n n为节点数),平均查找长度 A S L ≈ l o g 2 n ASL \approx log_2 n ASL≈log2n,时间复杂度为O(log n);
- 最坏情况(树退化为单链表) :如插入的关键字有序(如1,2,3,4,5),树退化为左斜树或右斜树,高度为 n n n,平均查找长度 A S L = n + 1 2 ASL = \frac{n+1}{2} ASL=2n+1,时间复杂度退化为O(n);
- 空间复杂度 :需存储 n n n个节点的指针,空间复杂度为O(n)。
5. 适用场景
- 动态查找表(需频繁插入/删除元素);
- 关键字分布随机的场景(可避免树退化为单链表);
- 不适用于关键字有序或分布极端的场景(需用平衡二叉树优化)。
(二)平衡二叉树
平衡二叉树(Balanced Binary Tree)又称"AVL树"(以发明者Adelson-Velsky和Landis命名),是对二叉排序树的优化。其核心目标是通过维护"树的平衡性",避免二叉排序树退化为单链表,确保查找、插入、删除操作的时间复杂度稳定为 O ( l o g n ) O(log n) O(logn)。
1. 定义与平衡条件
平衡二叉树的定义(基于二叉排序树):
- 树上所有节点的平衡因子(Balance Factor, BF) 的绝对值不超过1;
- 平衡因子定义: B F ( n o d e ) = 左子树高度 − 右子树高度 BF(node) = 左子树高度 - 右子树高度 BF(node)=左子树高度−右子树高度。
示例:一棵平衡二叉树(每个节点标注平衡因子):
15 (BF=0)
/ \
10(BF=0)20(BF=0)
/ \ / \
8(BF=0)12(BF=0)18(BF=0)25(BF=0)
若在上述树中插入关键字7
,节点8
的左子树高度变为1,右子树高度为0, B F ( 8 ) = 1 BF(8)=1 BF(8)=1(仍平衡);若继续插入6
,节点8
的 B F = 2 BF=2 BF=2,树失去平衡,需通过"旋转"恢复平衡。
2. 失衡与旋转调整
当插入或删除节点导致树失衡(某节点 ∣ B F ∣ > 1 |BF|>1 ∣BF∣>1)时,需通过"旋转"操作调整树的结构,恢复平衡。旋转分为单旋转 (左旋、右旋)和双旋转(左右旋、右左旋),核心是通过调整节点的父子关系,降低树的高度,同时保持二叉排序树的性质。
(1)左旋(Left Rotation)
适用于"右子树失衡"场景(如插入节点导致根节点的右子树高度过高):
-
操作步骤:
- 设失衡节点为
A
,其右孩子为B
; - 将
B
的左子树作为A
的右子树; - 将
A
作为B
的左子树; - 更新
A
和B
的高度(从下往上)。
- 设失衡节点为
-
示例 :失衡场景(
A
的 B F = − 2 BF=-2 BF=−2):失衡前: 左旋后: A(-2) B(0) \ / \ B(-1) A(0) C(0) \ C(0)
(2)右旋(Right Rotation)
与左旋对称,适用于"左子树失衡"场景(插入节点导致根节点的左子树高度过高):
- 操作步骤:
- 设失衡节点为
A
,其左孩子为B
; - 将
B
的右子树作为A
的左子树; - 将
A
作为B
的右子树; - 更新
A
和B
的高度。
- 设失衡节点为
(3)双旋转(左右旋、右左旋)
适用于"子树的子树失衡"场景(单旋转无法恢复平衡):
- 左右旋(Left-Right Rotation) :先对失衡节点的左孩子左旋,再对失衡节点右旋;
- 适用场景:失衡节点
A
的 B F = 2 BF=2 BF=2,且其左孩子B
的 B F = − 1 BF=-1 BF=−1(左子树的右子树过高)。
- 适用场景:失衡节点
- 右左旋(Right-Left Rotation) :先对失衡节点的右孩子右旋,再对失衡节点左旋;
- 适用场景:失衡节点
A
的 B F = − 2 BF=-2 BF=−2,且其右孩子B
的 B F = 1 BF=1 BF=1(右子树的左子树过高)。
- 适用场景:失衡节点
3. 查找与动态操作性能
- 查找操作 :与二叉排序树一致,时间复杂度取决于树的高度。由于平衡二叉树的高度严格控制在 O ( l o g n ) O(log n) O(logn)(最坏情况下高度为 1.44 l o g 2 ( n + 2 ) − 1.328 1.44log_2(n+2)-1.328 1.44log2(n+2)−1.328),查找时间复杂度稳定为O(log n);
- 插入/删除操作 :插入时最多需2次旋转(双旋转)恢复平衡,删除时最多需 O ( l o g n ) O(log n) O(logn)次旋转,整体时间复杂度仍为O(log n);
- 空间复杂度 :需额外存储每个节点的平衡因子(或高度),空间复杂度为O(n)。
4. 适用场景
- 需频繁进行插入、删除和查找的动态查找表;
- 对查找效率要求稳定,不允许出现 O ( n ) O(n) O(n)最坏情况的场景(如实时系统、高频访问的数据库索引);
- 不适用于插入/删除频率远高于查找频率的场景(旋转调整会带来额外开销)。
(三)B-树
B-树是一种多路平衡查找树,由R.Bayer和E.McCreight于1972年提出,核心设计目标是"减少磁盘I/O次数",适用于外部查找(如数据库、文件系统的索引结构)------外部存储中,数据按"块"读取,B-树通过增大每个节点的关键字数量(降低树的高度),减少每次查找需访问的磁盘块数。
1. 定义与核心特征
B-树的阶(Order)通常定义为m
(m≥2
),一棵m
阶B-树需满足以下条件:
- 每个节点最多有
m
个子节点(称为"分支"),最多包含m-1
个关键字; - 根节点最少有2个子节点(若根节点非叶子节点),最少包含1个关键字;
- 除根节点外,所有非叶子节点最少有
⌈m/2⌉
个子节点,最少包含⌈m/2⌉ - 1
个关键字; - 所有叶子节点位于同一层(树的高度一致,确保平衡);
- 每个节点的关键字按升序排列,且第
i
个关键字对应第i+1
个子节点的所有关键字(即子节点关键字范围与父节点关键字划分对应)。
示例 :一棵3阶B-树(m=3
,每个节点最多2个关键字、3个子节点;非根非叶子节点最少1个关键字、2个子节点):
[15, 30] (根节点,2个关键字,3个子节点)
/ | \
[5,10] [20,25] [35,40] (叶子节点,位于同一层)
2. 查找操作
B-树的查找过程与二叉排序树类似,区别在于每个节点需比较多个关键字,确定下一层的子节点:
- 从根节点开始,在当前节点的关键字中顺序或折半查找目标关键字
key
:- 若找到
key
,查找成功; - 若未找到,根据
key
与节点关键字的大小关系,确定下一个待访问的子节点(如key
小于第i
个关键字,则访问第i
个子节点);
- 若找到
- 重复步骤1,直至访问到叶子节点仍未找到
key
,查找失败。
示例 :在上述3阶B-树中查找key=25
:
- 根节点
[15,30]
:25介于15和30之间,访问中间子节点[20,25]
; - 子节点
[20,25]
:找到25,查找成功(共访问2个磁盘块:根节点块、子节点块)。
3. 插入与分裂操作
B-树的插入需确保插入后仍满足阶的约束,核心操作是"节点分裂"(当节点关键字数量超过m-1
时):
- 找到插入位置(叶子节点,按关键字顺序插入);
- 若插入后节点关键字数量≤
m-1
,插入完成; - 若插入后节点关键字数量=
m
(超出限制),执行"分裂":- 取节点的中间关键字
key_mid
(位置⌈m/2⌉
); - 将节点分裂为左、右两个子节点:左节点包含前
⌈m/2⌉ - 1
个关键字,右节点包含后m - ⌈m/2⌉
个关键字; - 将
key_mid
插入父节点,并将左、右子节点作为父节点的子节点; - 若父节点分裂后仍超出限制,递归向上分裂,直至根节点(根节点分裂会导致树的高度加1)。
- 取节点的中间关键字
4. 性能分析
B-树的性能核心在于"树的高度",设m
为阶,n
为总关键字数,h
为树的高度:
- 树的高度上限: h ≤ l o g ⌈ m / 2 ⌉ n + 1 2 + 1 h ≤ log_{\lceil m/2 \rceil} \frac{n+1}{2} + 1 h≤log⌈m/2⌉2n+1+1(叶子节点位于第
h
层); - 查找次数:每次查找需访问
h
个节点(每个节点对应一个磁盘块),磁盘I/O次数为h
; - 时间复杂度:磁盘I/O时间占主导,为O(log_m n) (
m
越大,高度h
越小,I/O次数越少); - 空间复杂度:需存储
n
个关键字和n+1
个子节点指针,空间复杂度为O(n)。
5. 适用场景
- 磁盘索引(如关系型数据库的索引,如MySQL的InnoDB存储引擎早期使用B-树索引);
- 外部查找(数据存储在磁盘,需减少I/O次数);
- 不适用于内存中的查找(内存访问速度快,B-树的多路优势不明显,二叉树或平衡二叉树更高效)。
(四)B+树
B+树是B-树的变种,由B-树优化而来,在数据库和文件系统中应用更广泛(如MySQL的InnoDB、Oracle的索引均采用B+树)。其核心改进是"将所有关键字集中在叶子节点",进一步优化范围查找和磁盘I/O效率。
1. 定义与核心特征
与B-树类似,B+树也以"阶(Order)"作为核心参数(通常用m
表示,m≥2
),用于约束节点的关键字数量和子节点数量。作为B-树的优化变种,B+树在结构上做了针对性改进,以更适配外部存储(如磁盘)的I/O特性和范围查找需求,其核心特征需满足以下条件:
(1)节点结构约束(基于m
阶)
- 非叶子节点 (索引节点):
- 最多包含
m
个关键字和m
个子节点(分支),关键字按升序排列; - 除根节点外,所有非叶子节点最少包含
⌈m/2⌉
个关键字和⌈m/2⌉
个子节点; - 根节点若为非叶子节点,最少包含1个关键字和2个子节点(保证树的层级扩展)。
- 最多包含
- 叶子节点 (数据节点):
- 最多包含
m
个关键字(每个关键字对应一条实际数据或数据地址),按升序排列; - 除根节点外,所有叶子节点最少包含
⌈m/2⌉
个关键字; - 根节点若为叶子节点(树中仅一个节点),关键字数量可在1~
m
之间。
- 最多包含
(2)核心结构差异(与B-树对比)
-
关键字存储分离:
- 非叶子节点的关键字仅作为"索引路标",不关联实际数据,仅用于指引下一层子节点的访问方向;
- 所有关键字(包括非叶子节点的索引关键字)均重复存储在叶子节点中,叶子节点包含完整的关键字集合和对应数据,是实际数据的唯一载体。
- 例:非叶子节点中的
15
仅作为索引,指向"关键字≤15"的子节点,而15
对应的实际数据仅存于叶子节点[5,10,15]
中。
-
叶子节点有序串联:
- 所有叶子节点通过"双向指针"连接,形成一个有序链表(如示例中
[5,10,15] ↔ [20,25,30] ↔ [35,40,45]
); - 链表的有序性与叶子节点内部关键字的有序性一致,无需回溯父节点即可完成跨节点的范围查找。
- 所有叶子节点通过"双向指针"连接,形成一个有序链表(如示例中
-
层级平衡特性:
- 所有叶子节点严格位于同一层(树的高度由叶子节点层级决定),确保任意关键字的查找路径长度一致,避免因树的倾斜导致查找效率波动。
-
索引与子节点的对应关系:
- 非叶子节点中,第
i
个关键字(1≤i≤m
)对应第i
个子节点,且该子节点中所有关键字的取值范围为:- 若
i=1
:子节点关键字 ≤ 第1个关键字; - 若
1<i<m
:第i-1
个关键字 < 子节点关键字 ≤ 第i
个关键字; - 若
i=m
:子节点关键字 > 第m-1
个关键字(最后一个关键字仅用于分隔倒数第二个子节点和最后一个子节点)。
- 若
- 非叶子节点中,第
(3)示例解析(3阶B+树)
以m=3
(3阶B+树)为例,结合结构约束理解节点关系:
[15, 30, 45] (非叶子节点/根节点)
/ | \
[5,10,15] [20,25,30] [35,40,45] (叶子节点,双向串联)
←───────→ ←───────→
- 非叶子节点(根节点) :
- 关键字数量=3(符合"最多
m=3
个"的约束),子节点数量=3(符合"最多m=3
个"的约束); - 第1个关键字
15
对应第1个子节点[5,10,15]
(子节点关键字≤15); - 第2个关键字
30
对应第2个子节点[20,25,30]
(15<子节点关键字≤30); - 第3个关键字
45
对应第3个子节点[35,40,45]
(30<子节点关键字≤45)。
- 关键字数量=3(符合"最多
- 叶子节点 :
- 每个叶子节点关键字数量=3(符合"最多
m=3
个"的约束),且均包含完整关键字(如15
、30
、45
同时存在于非叶子节点和叶子节点); - 叶子节点通过双向指针串联,形成有序链表,支持从
5
到45
的连续范围遍历。
- 每个叶子节点关键字数量=3(符合"最多
若删除叶子节点[5,10,15]
中的10
,该叶子节点关键字数量变为2(≥⌈3/2⌉=2
,符合最少约束),无需分裂或合并;若继续删除5
,关键字数量变为1(<2),则需触发"合并"操作(与相邻叶子节点合并,并更新父节点索引),确保树的结构约束不被破坏。
2. 查找操作
B+树的查找分为"精确查找"和"范围查找",两种场景均利用其结构特性实现高效检索:
(1)精确查找
流程与B-树类似,但最终需定位到叶子节点(非叶子节点仅为索引,不存储实际数据):
- 从根节点开始,在当前节点的关键字中顺序或折半查找,根据目标关键字
key
与节点关键字的大小关系,确定下一个待访问的子节点(例如,若key
≤第i
个关键字,则访问第i
个子节点); - 重复步骤1,直至访问到叶子节点;
- 在叶子节点的关键字中查找
key
,找到则返回对应数据(或数据地址),未找到则查找失败。
示例 :在上述3阶B+树中查找key=25
:
- 根节点
[15,30,45]
:25≤30且25>15,访问第2个子节点[20,25,30]
(叶子节点); - 叶子节点中找到25,返回对应数据(共访问2个磁盘块,与B-树相同)。
(2)范围查找
B+树的核心优势之一,借助叶子节点的"有序链表"结构,无需回溯即可完成范围查询:
- 按精确查找流程,找到范围的起始关键字(如查找
10≤key≤35
,先找到key=10
所在的叶子节点); - 沿叶子节点的串联指针,依次遍历后续叶子节点,收集所有符合范围的关键字,直至超出范围上限(如遍历到
key=35
所在节点后停止)。
示例 :查找10≤key≤35
:
- 找到
key=10
所在叶子节点[5,10,15]
,收集10、15; - 沿串联指针访问下一个叶子节点
[20,25,30]
,收集20、25、30; - 继续访问下一个叶子节点
[35,40,45]
,收集35; - 停止遍历,最终结果为
[10,15,20,25,30,35]
(仅需访问3个磁盘块,若用B-树需回溯父节点,磁盘I/O次数更多)。
3. 插入与分裂操作
B+树的插入逻辑与B-树类似,核心差异在于"分裂后中间关键字的处理"(B+树中间关键字需复制到父节点,而非移动,确保叶子节点包含所有关键字):
- 找到插入位置(叶子节点,按关键字顺序插入,保持叶子节点有序);
- 若插入后叶子节点的关键字数量≤
m
(m
为阶,叶子节点最多m
个关键字),插入完成; - 若插入后叶子节点关键字数量=
m+1
(超出限制),执行"分裂":- 取中间位置
⌊(m+1)/2⌋
,将叶子节点分裂为左、右两个子节点:左节点包含前⌊(m+1)/2⌋
个关键字,右节点包含剩余关键字; - 将中间关键字(左节点的最后一个关键字)复制到父节点,作为索引,并将左、右子节点作为父节点的子节点;
- 若父节点分裂后关键字数量超出
m
(非叶子节点最多m
个关键字),递归向上分裂,直至根节点(根节点分裂会导致树的高度加1)。
- 取中间位置
示例 :3阶B+树(m=3
,叶子节点最多3个关键字)插入key=38
(原叶子节点[35,40,45]
插入后变为[35,38,40,45]
,需分裂):
- 分裂为左节点
[35,38]
、右节点[40,45]
,中间关键字38
复制到父节点; - 父节点原关键字
[15,30,45]
变为[15,30,38,45]
(超出3个关键字限制),继续分裂父节点,最终树高加1。
4. B+树与B-树的核心差异
对比维度 | B-树 | B+树 |
---|---|---|
关键字存储位置 | 非叶子节点和叶子节点均存储关键字 | 仅叶子节点存储所有关键字,非叶子节点仅存索引(复制自叶子节点) |
数据存储位置 | 非叶子节点和叶子节点均存储数据(或地址) | 仅叶子节点存储数据(或地址) |
叶子节点关系 | 叶子节点独立,无直接关联 | 叶子节点通过指针串联,形成有序链表 |
范围查找效率 | 需回溯父节点,效率低 | 沿叶子节点链表遍历,效率高 |
分裂后关键字处理 | 中间关键字从原节点移动到父节点 | 中间关键字复制到父节点,原节点保留 |
5. 性能分析
B+树的性能优势集中在外部查找场景,核心指标仍为"磁盘I/O次数":
- 树的高度 :与B-树相近,
m
阶B+树的高度h ≤ log_{\lceil (m+1)/2 \rceil} \frac{n}{m} + 1
(n
为总关键字数),m
越大,高度越低,I/O次数越少; - 查找时间复杂度 :精确查找与B-树一致,为
O(log_m n)
;范围查找效率更高,仅需额外遍历叶子节点链表,时间复杂度为O(log_m n + k)
(k
为范围内关键字数量); - 空间复杂度 :因非叶子节点需复制叶子节点的关键字,空间开销略高于B-树,但仍为
O(n)
(额外空间可忽略)。
6. 适用场景
B+树是数据库和文件系统索引的首选结构,主要适用于:
- 关系型数据库索引 (如MySQL InnoDB、PostgreSQL):需频繁进行范围查询(如
WHERE age BETWEEN 20 AND 30
)和排序操作(如ORDER BY id
); - 文件系统索引(如NTFS文件系统):需高效定位文件块,且支持按文件名范围检索;
- 大规模外部存储查找:数据存储在磁盘,需通过减少I/O次数提升效率,且需兼顾精确查找和范围查找。
相比之下,B-树更适用于"无需范围查找,仅需精确查找"的场景(如NoSQL数据库中的部分索引),但实际应用中B+树因功能更全面(支持范围查找、排序),应用范围更广。
🔑 四,散列表的查找
散列表(Hash Table)又称"哈希表",是一种通过"关键字直接映射存储位置"的查找结构,核心思想是"以空间换时间"------通过预设的"散列函数"将关键字转换为存储地址,实现平均O(1)
时间复杂度的查找、插入和删除操作,是效率最高的查找方法之一。
(一)散列表的基本概念
散列表的设计围绕"关键字→地址"的直接映射展开,核心术语包括:
1. 核心定义
- 散列表(Hash Table):由有限个连续的存储单元组成的数组,每个单元称为"桶(Bucket)",用于存储关键字对应的元素;
- 散列函数(Hash Function) :将关键字
key
映射到散列表中存储位置的函数,记为H(key)
,其中H(key)
的取值范围为散列表的下标(0 ≤ H(key) < m
,m
为散列表长度); - 冲突(Collision) :不同的关键字通过散列函数映射到同一个存储位置,即
key1 ≠ key2
但H(key1) = H(key2)
,这种现象称为冲突,key1
和key2
互称为"同义词"。
2. 示例
设散列表长度m=11
,散列函数H(key) = key % 11
(取模运算):
- 关键字
key=22
→H(22)=22%11=0
(存储在地址0); - 关键字
key=33
→H(33)=33%11=0
(与22冲突,均映射到地址0); - 关键字
key=14
→H(14)=14%11=3
(存储在地址3)。
3. 设计目标
散列表的核心目标是:
- 设计均匀的散列函数,使关键字尽可能均匀分布在散列表中,减少冲突;
- 设计高效的冲突处理方法,当冲突发生时,能快速找到新的存储位置。
(二)散列函数的构造方法
一个好的散列函数应满足:计算简单 (时间开销小)、分布均匀(减少冲突)。常用构造方法如下:
1. 直接定址法
直接以关键字或关键字的线性函数作为散列地址:
H ( k e y ) = k e y 或 H ( k e y ) = a ⋅ k e y + b H(key) = key \quad \text{或} \quad H(key) = a \cdot key + b H(key)=key或H(key)=a⋅key+b
- 示例 :存储学生信息,关键字为学号(如2023001, 2023002, ...),可直接用
H(key) = key - 2023000
,映射到地址1,2,... - 特点:无冲突(关键字不重复时),但仅适用于关键字分布连续且范围较小的场景(否则散列表会过大)。
2. 数字分析法
分析关键字的各位数字,选择分布均匀的若干位作为散列地址(适用于关键字位数较多且已知分布的场景)。
- 示例 :存储手机号(11位:前3位运营商,中间4位地区码,后4位用户码),若后4位分布均匀,可取
H(key) = 后4位数字
。 - 特点:针对性强,需预知关键字的分布规律。
3. 平方取中法
将关键字平方后,取中间几位作为散列地址(利用平方后中间位与关键字各位均相关的特性,使分布更均匀)。
- 示例 :关键字
key=123
→ 平方为15129
→ 取中间3位512
作为H(key)
。 - 特点:适用于关键字位数较少且分布未知的场景,如字符串哈希(先将字符串转换为数字再平方)。
4. 折叠法
将关键字分割为位数相同的若干部分(最后一部分可短些),取各部分的叠加和(舍去进位)作为散列地址。
- 示例 :关键字
key=123456789
,分割为123
、456
、789
→ 叠加和123+456+789=1368
→ 取H(key)=368
(舍去高位1)。 - 特点:适用于关键字位数较多的场景,如身份证号、长编号。
5. 除留余数法(最常用)
取关键字除以散列表长度m
的余数作为散列地址:
H ( k e y ) = k e y m o d m H(key) = key \mod m H(key)=keymodm
- 关键 :
m
的选择直接影响分布均匀性,通常取质数 (如11, 13, 17)或不包含小于20的质因数的合数(如100=2²×5²,避免与关键字的因子冲突)。 - 示例 :
m=11
(质数),关键字22,33,44
→ 余数均为0(冲突);若m=13
(质数),则22%13=9
,33%13=7
,44%13=5
(无冲突)。 - 特点:计算简单,适用范围广,是最常用的散列函数构造方法。
6. 随机数法
选择一个随机函数,取H(key) = random(key)
作为散列地址(random
为随机函数)。
- 特点:适用于关键字长度不固定的场景(如变长字符串),但需确保随机函数的稳定性(同一关键字映射到同一地址)。
(三)处理冲突的方法
即使散列函数设计得再均匀,冲突仍不可避免(称为"哈希碰撞")。处理冲突的核心是为冲突的关键字找到新的空闲存储位置,常用方法分为"开放定址法"和"链地址法"两类。
1. 开放定址法
当冲突发生时(H(key)
已被占用),按照某种规则在散列表中寻找下一个空闲位置,公式为:
H i ( k e y ) = ( H ( k e y ) + d i ) m o d m ( i = 1 , 2 , . . . , k , k ≤ m − 1 ) H_i(key) = (H(key) + d_i) \mod m \quad (i=1,2,...,k, \, k ≤ m-1) Hi(key)=(H(key)+di)modm(i=1,2,...,k,k≤m−1)
d_i
为增量序列,决定了寻找下一个位置的规则,常见增量序列如下:
(1)线性探测法(Linear Probing)
d_i = i
(i=1,2,...,m-1
),即依次探测下一个位置(H(key)+1, H(key)+2, ...
,超过表长则循环到表头)。
- 示例 :
m=11
,H(key)=key%11
,插入关键字22(0)
、33(0)
(冲突):33
的H_1=(0+1)%11=1
(若地址1空闲,存入1);- 若地址1已被占用,继续探测
H_2=2
,以此类推。
- 特点:实现简单,但易产生"聚集(Clustering)"现象(冲突的关键字集中在某一区域,导致后续插入/查找效率下降)。
(2)二次探测法(Quadratic Probing)
d_i = ±i²
(i=1,2,...,k
,k≤m/2
),即探测H(key)±1, H(key)±4, H(key)±9,...
。
- 示例 :
H(key)=0
,冲突后探测1, -1(10), 4, -4(7), ...
(m=11
)。 - 特点 :减少聚集现象,但需要散列表长度
m
为4k+3
型质数(确保能探测到所有位置),且空间利用率较低。
(3)伪随机探测法
d_i
为预设的随机序列(如d_i = random(i)
)。
- 示例 :随机序列为
3,5,2,...
,H(key)=0
冲突后探测(0+3)%11=3
、(0+5)%11=5
等。 - 特点:无聚集现象,但需保存随机序列,实现稍复杂。
2. 链地址法(Chaining,最常用)
将所有冲突的关键字(同义词)存储在同一个链表中,散列表的每个位置作为链表的头节点,指向对应同义词的链表。
-
示例 :
m=11
,H(key)=key%11
,插入22(0)
、33(0)
、44(0)
:散列表地址0 → 22 → 33 → 44(链表串联所有同义词) 地址1 → NULL ...
-
插入流程 :计算
H(key)
,将关键字插入对应链表的头部或尾部(通常头部,插入更快); -
查找流程 :计算
H(key)
,遍历对应链表,比较关键字是否匹配。 -
特点 :
- 无聚集现象,冲突仅影响链表长度,不扩散到其他地址;
- 空间利用率高(散列表和链表动态分配);
- 插入/删除方便(无需移动其他元素),是实际应用中最常用的冲突处理方法(如Java的
HashMap
、Python的dict
)。
3. 其他方法
- 再散列法 :准备多个散列函数,冲突时依次使用下一个散列函数
H_1(key), H_2(key), ...
,直至找到空闲位置; - 公共溢出区法:将散列表分为"基本表"和"溢出表",冲突的关键字统一存入溢出表,查找时先查基本表,未找到再查溢出表。
(四)散列表的查找
散列表的查找过程与插入过程对应,核心是"通过散列函数定位初始位置,若冲突则按处理规则继续查找"。
1. 查找步骤(以链地址法为例)
- 计算待查找关键字
key
的散列地址H(key)
; - 遍历散列表
H(key)
位置对应的链表:- 若找到关键字等于
key
的节点,查找成功,返回该节点; - 若遍历完链表仍未找到,查找失败。
- 若找到关键字等于
2. 代码实现(链地址法)
c
#define m 11 // 散列表长度(质数)
#define NULL_KEY -1 // 空关键字标识
// 链表节点结构(存储同义词)
typedef struct Node {
int key; // 关键字
struct Node *next; // 指向下一个同义词
} Node, *LinkList;
// 散列表结构(数组+链表)
typedef struct {
LinkList elem[m]; // 散列表数组,每个元素为链表头指针
} HashTable;
// 初始化散列表
void InitHashTable(HashTable *HT) {
for (int i = 0; i < m; i++) {
HT->elem[i] = NULL; // 所有链表初始为空
}
}
// 散列函数(除留余数法)
int Hash(int key) {
return key % m;
}
// 插入关键字到散列表
void InsertHash(HashTable *HT, int key) {
int addr = Hash(key); // 计算散列地址
Node *p = (Node*)malloc(sizeof(Node));// 创建新节点
p->key = key;
p->next = HT->elem[addr]; // 插入到链表头部(头插法)
HT->elem[addr] = p;
}
// 散列表查找
Node* SearchHash(HashTable HT, int key) {
int addr = Hash(key); // 计算初始地址
Node *p = HT->elem[addr]; // 指向对应链表
// 遍历链表查找关键字
while (p != NULL && p->key != key) {
p = p->next;
}
return p; // 成功返回节点,失败返回NULL
}
3. 性能分析
散列表的查找效率取决于装载因子(Load Factor) :
α = n m \alpha = \frac{n}{m} α=mn
n
:散列表中关键字的实际数量;m
:散列表的长度;α
越小,冲突概率越低,查找效率越高(理想情况α=0
,无冲突)。
(1)平均查找长度(ASL)
- 链地址法 :在随机情况下,查找成功的
ASL ≈ 1 + α/2
,查找失败的ASL ≈ α
(α
为链表平均长度); - 开放定址法 :查找成功的
ASL ≈ -\frac{1}{\alpha} \ln(1-\alpha)
,查找失败的ASL ≈ \frac{1}{1-\alpha}
(0 < α < 1
)。
(2)时间复杂度
- 理想情况(无冲突):
O(1)
; - 平均情况:
O(1)
(α
控制在0.7~0.8以下时); - 最坏情况(所有关键字冲突,如链地址法退化为单链表):
O(n)
。
(3)空间复杂度
- 链地址法:
O(n + m)
(n
个关键字节点,m
个头指针); - 开放定址法:
O(m)
(固定大小的数组)。
4. 适用场景
散列表是"以空间换时间"的典型应用,适用于:
- 需频繁查找、插入、删除的场景(如缓存系统、数据库索引、哈希表数据结构);
- 关键字分布较均匀,且可设计有效散列函数的场景;
- 不要求关键字有序存储的场景(散列表中的关键字无序)。
实际应用中,Java的HashMap
、Python的字典、C++的unordered_map
等均基于散列表实现,核心是通过链地址法处理冲突,并动态调整散列表大小(当α
过大时扩容)以维持高效查找。
👊 章结
"查找"是数据结构中针对"数据检索"的核心操作,其效率直接决定了数据处理系统的性能。本章围绕不同存储结构(线性表、树、散列表),系统讲解了多种查找算法的原理、实现与适用场景,核心是根据数据的规模、有序性、动态性及存储环境,选择最优的查找策略。以下从核心逻辑、算法对比、选型原则三方面进行总结:
一、查找算法的核心逻辑与分类
查找的本质是"根据关键字在查找表中定位目标元素",根据查找表的存储结构,可将算法分为三大类,其核心逻辑各有侧重:
1. 线性表查找:基于"线性遍历"或"区间划分"
- 核心特点:数据按线性关系存储(顺序表/链表),查找过程依赖元素的线性顺序或块间有序性;
- 代表算法 :
- 顺序查找:无前提条件,通过逐元素比较定位,逻辑最简单但效率最低;
- 折半查找:依赖"有序顺序表",通过二分缩小查找范围,效率高但需维护有序性;
- 分块查找:结合"块间有序、块内无序",先查索引表(缩小范围),再查块内(线性遍历),兼顾灵活性与效率。
2. 树表查找:基于"层级划分"与"平衡维护"
- 核心特点:数据按树形结构存储,通过树的层级关系逐层缩小查找范围,支持动态插入/删除(无需大规模移动元素);
- 代表算法 :
- 二叉排序树:利用"左小右大"的特性,查找逻辑与二分思想类似,但易退化为单链表;
- 平衡二叉树(AVL树):在二叉排序树基础上通过"旋转"维护平衡性,确保查找效率稳定为
O(log n)
; - B-树/B+树:多路平衡查找树,针对外部存储(磁盘)设计,通过增大节点关键字数量降低树高,减少磁盘I/O次数,B+树额外优化范围查找(叶子节点串联)。
3. 散列表查找:基于"直接映射"与"冲突处理"
- 核心特点 :通过散列函数将关键字直接映射到存储地址,跳过中间比较过程,理论上可实现
O(1)
平均时间复杂度; - 关键设计 :
- 散列函数:需满足"计算简单、分布均匀"(如除留余数法),减少冲突;
- 冲突处理:通过开放定址法(线性探测)或链地址法(链表串联)解决同义词冲突,链地址法因无聚集问题应用最广。
二、核心算法性能与适用场景对比
不同查找算法的性能(时间复杂度、空间复杂度、稳定性)差异显著,需结合实际需求选择,下表为核心指标对比:
算法类型 | 代表算法 | 时间复杂度(平均) | 空间复杂度 | 稳定性 | 核心前提条件 | 适用场景 |
---|---|---|---|---|---|---|
线性表查找 | 顺序查找 | O(n) |
O(1) |
稳定 | 无(顺序表/链表均可) | 小规模数据、无序表、链表存储 |
折半查找 | O(log n) |
O(1) |
稳定 | 有序顺序表 | 静态有序数据、高频查找、低频插入/删除 | |
分块查找 | O(√n) |
O(b) |
稳定 | 分块有序(块间有序、块内无序) | 中等规模数据、需兼顾查找与插入灵活性 | |
树表查找 | 二叉排序树 | O(log n) (理想) |
O(n) |
不稳定 | 无(动态维护有序) | 动态查找表、关键字分布随机 |
平衡二叉树 | O(log n) |
O(n) |
不稳定 | 动态维护平衡(旋转调整) | 动态查找表、需稳定查找效率(如实时系统) | |
B+树 | O(log_m n) |
O(n) |
稳定 | 多路平衡、外部存储 | 数据库索引、文件系统(需高效范围查找) | |
散列表查找 | 链地址法散列 | O(1) |
O(n+m) |
不稳定 | 散列函数均匀、冲突处理合理 | 高频查找/插入/删除、不要求有序(如缓存) |
三、查找算法的选型原则
在实际应用中,需结合以下4个核心因素选择查找算法,平衡效率、复杂度与业务需求:
1. 数据规模:小规模优先简单算法,大规模侧重高效算法
- 小规模数据(
n≤100
):顺序查找(实现简单,无需额外维护成本); - 中等规模数据(
100<n≤10000
):折半查找(有序时)、分块查找(无序但可分块时); - 大规模数据(
n>10000
):平衡二叉树(内存中动态数据)、B+树(磁盘中数据库索引)、散列表(内存中高频操作)。
2. 数据动态性:静态查用"无需维护"算法,动态查用"支持增删"算法
- 静态查找表(仅查询,不增删):折半查找(有序)、顺序查找(无序);
- 动态查找表(需增删):二叉排序树(分布随机)、平衡二叉树(需稳定效率)、散列表(高频操作)。
3. 存储环境:内存查找侧重时间,外部查找侧重I/O优化
- 内存查找:优先选择时间复杂度低的算法(散列表、平衡二叉树),无需考虑磁盘I/O;
- 外部查找(数据存磁盘):必须选择B-树/B+树(通过降低树高减少磁盘块访问次数),避免频繁I/O导致效率骤降。
4. 业务需求:是否需要有序性或范围查找
- 需有序输出或范围查找(如
BETWEEN
查询):B+树(叶子节点串联,效率最高)、折半查找(有序表,支持简单范围查找); - 仅需精确查找,无需有序:散列表(
O(1)
平均效率)、二叉排序树(实现简单)。
四、核心结论
查找算法的设计与选择,本质是"在时间复杂度、空间复杂度、实现复杂度之间寻找平衡":
- 追求极致效率且内存充足:优先选择散列表(需设计好散列函数和冲突处理);
- 需稳定效率且支持动态操作:平衡二叉树(内存中)或B+树(磁盘中);
- 数据有序且仅静态查询:折半查找(最简单高效);
- 数据无序或小规模:顺序查找(实现成本最低)。
在实际工程中(如数据库、缓存系统),往往会结合多种算法的优势(如散列表+链表解决冲突、B+树+索引优化范围查找),以适应复杂的业务场景。