数据结构:查找

📌目录

  • [🔍 一,查找的基本概念](#🔍 一,查找的基本概念)
    • [1. 核心定义](#1. 核心定义)
    • [2. 查找效率的评价指标](#2. 查找效率的评价指标)
  • [📏 二,线性表的查找](#📏 二,线性表的查找)
  • [🌳 三,树表的查找](#🌳 三,树表的查找)
  • [🔑 四,散列表的查找](#🔑 四,散列表的查找)
    • (一)散列表的基本概念
      • [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. 散列表查找:基于“直接映射”与“冲突处理”)
    • 二、核心算法性能与适用场景对比
    • 三、查找算法的选型原则
      • [1. 数据规模:小规模优先简单算法,大规模侧重高效算法](#1. 数据规模:小规模优先简单算法,大规模侧重高效算法)
      • [2. 数据动态性:静态查用"无需维护"算法,动态查用"支持增删"算法](#2. 数据动态性:静态查用“无需维护”算法,动态查用“支持增删”算法)
      • [3. 存储环境:内存查找侧重时间,外部查找侧重I/O优化](#3. 存储环境:内存查找侧重时间,外部查找侧重I/O优化)
      • [4. 业务需求:是否需要有序性或范围查找](#4. 业务需求:是否需要有序性或范围查找)
    • 四、核心结论

🔍 一,查找的基本概念

查找(Searching)是数据处理中最频繁的操作之一,指从一组无序或有序的数据元素(称为"查找表")中,根据给定的"关键字"找到满足条件的数据元素的过程。小到手机通讯录中查找联系人,大到数据库中千万级数据的检索,都依赖高效的查找算法。

1. 核心定义

  • 查找表(Search Table):由若干个数据元素(或记录)组成的集合,每个元素包含一个或多个属性,其中用于标识元素的属性称为"关键字"。
  • 关键字(Key) :分为两类:
    • 主关键字(Primary Key):唯一标识一个数据元素(如身份证号、学号),通过主关键字查找时,结果要么唯一,要么不存在;
    • 次关键字(Secondary Key):非唯一标识(如姓名、年龄),通过次关键字查找可能返回多个满足条件的元素。
  • 查找操作
    • 静态查找:仅查询元素是否存在或获取元素信息,不改变查找表的结构(如查询字典中的单词释义);
    • 动态查找:在查找过程中可能插入新元素或删除已有元素(如购物车中添加/删除商品后查找商品)。
  • 查找成功与失败
    • 查找成功:找到与关键字匹配的元素,返回元素的位置或相关信息;
    • 查找失败:未找到匹配元素,返回特定标识(如-1NULL)。

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. 核心思想

利用线性表的有序性(假设为升序),每次将查找范围缩小一半:

  1. 设查找范围的左边界为low(初始为0),右边界为high(初始为length-1);
  2. 计算中间位置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
  3. 重复步骤2,直至low > high(查找失败)。

2. 示例(有序顺序表:[5, 13, 19, 21, 37, 56, 64, 75, 80, 88, 92],查找key=21

  • 初始:low=0high=10mid=5elem[5].key=56 > 21)→ 左半区,high=4
  • 第二次:mid=2elem[2].key=19 < 21)→ 右半区,low=3
  • 第三次:mid=3elem[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个变量(lowhighmid),空间复杂度为O(1)

  • 特点:效率高,但依赖有序的顺序表,插入/删除元素时需移动大量数据(维护有序性成本高),适合静态有序查找表。

(三)分块查找

分块查找(Block Search)又称"索引顺序查找",结合了顺序查找和折半查找的优点,适用于**"分块有序"的线性表**(将线性表分为若干块,块内元素无序,但块间元素有序)。

1. 核心概念

  • 分块有序:假设线性表按升序分块,满足"第一块中所有元素的关键字 ≤ 第二块中所有元素的关键字 ≤ ... ≤ 第k块中所有元素的关键字",但块内元素无需有序;
  • 索引表:为每个块建立一个索引项,包含"块的最大关键字"和"块的起始位置",索引表按块的最大关键字有序排列。

2. 核心思想

分块查找分为两步:

  1. 查找索引表:根据查找关键字,在有序的索引表中找到目标元素所在的块(可用顺序查找或折半查找);
  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
    1. 索引表有序,用折半查找:21介于12和26之间,确定在块2(起始下标3);
    2. 块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. 查找操作

核心思路

利用二叉排序树的有序性,从根节点开始逐层比较:

  1. 若查找关键字key等于当前节点关键字,查找成功,返回当前节点;
  2. key小于当前节点关键字,递归查找左子树(若左子树存在);
  3. key大于当前节点关键字,递归查找右子树(若右子树存在);
  4. 若遍历至空节点,查找失败。
代码实现
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)插入操作

插入的核心是"找到合适的空节点位置",确保插入后仍满足二叉排序树性质:

  1. 若树为空,直接创建新节点作为根节点;
  2. 若树非空,从根节点开始比较:
    • 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)

适用于"右子树失衡"场景(如插入节点导致根节点的右子树高度过高):

  • 操作步骤:

    1. 设失衡节点为A,其右孩子为B
    2. B的左子树作为A的右子树;
    3. A作为B的左子树;
    4. 更新AB的高度(从下往上)。
  • 示例 :失衡场景(A的 B F = − 2 BF=-2 BF=−2):

    复制代码
      失衡前:        左旋后:
         A(-2)          B(0)
          \            / \
           B(-1)      A(0) C(0)
            \
             C(0)
(2)右旋(Right Rotation)

与左旋对称,适用于"左子树失衡"场景(插入节点导致根节点的左子树高度过高):

  • 操作步骤:
    1. 设失衡节点为A,其左孩子为B
    2. B的右子树作为A的左子树;
    3. A作为B的右子树;
    4. 更新AB的高度。
(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)通常定义为mm≥2),一棵m阶B-树需满足以下条件:

  1. 每个节点最多有m个子节点(称为"分支"),最多包含m-1个关键字;
  2. 根节点最少有2个子节点(若根节点非叶子节点),最少包含1个关键字;
  3. 除根节点外,所有非叶子节点最少有⌈m/2⌉个子节点,最少包含⌈m/2⌉ - 1个关键字;
  4. 所有叶子节点位于同一层(树的高度一致,确保平衡);
  5. 每个节点的关键字按升序排列,且第i个关键字对应第i+1个子节点的所有关键字(即子节点关键字范围与父节点关键字划分对应)。

示例 :一棵3阶B-树(m=3,每个节点最多2个关键字、3个子节点;非根非叶子节点最少1个关键字、2个子节点):

复制代码
          [15, 30]  (根节点,2个关键字,3个子节点)
         /    |    \
[5,10]  [20,25]  [35,40]  (叶子节点,位于同一层)

2. 查找操作

B-树的查找过程与二叉排序树类似,区别在于每个节点需比较多个关键字,确定下一层的子节点:

  1. 从根节点开始,在当前节点的关键字中顺序或折半查找目标关键字key
    • 若找到key,查找成功;
    • 若未找到,根据key与节点关键字的大小关系,确定下一个待访问的子节点(如key小于第i个关键字,则访问第i个子节点);
  2. 重复步骤1,直至访问到叶子节点仍未找到key,查找失败。

示例 :在上述3阶B-树中查找key=25

  • 根节点[15,30]:25介于15和30之间,访问中间子节点[20,25]
  • 子节点[20,25]:找到25,查找成功(共访问2个磁盘块:根节点块、子节点块)。

3. 插入与分裂操作

B-树的插入需确保插入后仍满足阶的约束,核心操作是"节点分裂"(当节点关键字数量超过m-1时):

  1. 找到插入位置(叶子节点,按关键字顺序插入);
  2. 若插入后节点关键字数量≤m-1,插入完成;
  3. 若插入后节点关键字数量=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-树对比)
  1. 关键字存储分离

    • 非叶子节点的关键字仅作为"索引路标",不关联实际数据,仅用于指引下一层子节点的访问方向;
    • 所有关键字(包括非叶子节点的索引关键字)均重复存储在叶子节点中,叶子节点包含完整的关键字集合和对应数据,是实际数据的唯一载体。
    • 例:非叶子节点中的15仅作为索引,指向"关键字≤15"的子节点,而15对应的实际数据仅存于叶子节点[5,10,15]中。
  2. 叶子节点有序串联

    • 所有叶子节点通过"双向指针"连接,形成一个有序链表(如示例中[5,10,15] ↔ [20,25,30] ↔ [35,40,45]);
    • 链表的有序性与叶子节点内部关键字的有序性一致,无需回溯父节点即可完成跨节点的范围查找。
  3. 层级平衡特性

    • 所有叶子节点严格位于同一层(树的高度由叶子节点层级决定),确保任意关键字的查找路径长度一致,避免因树的倾斜导致查找效率波动。
  4. 索引与子节点的对应关系

    • 非叶子节点中,第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(符合"最多m=3个"的约束),且均包含完整关键字(如153045同时存在于非叶子节点和叶子节点);
    • 叶子节点通过双向指针串联,形成有序链表,支持从545的连续范围遍历。

若删除叶子节点[5,10,15]中的10,该叶子节点关键字数量变为2(≥⌈3/2⌉=2,符合最少约束),无需分裂或合并;若继续删除5,关键字数量变为1(<2),则需触发"合并"操作(与相邻叶子节点合并,并更新父节点索引),确保树的结构约束不被破坏。

2. 查找操作

B+树的查找分为"精确查找"和"范围查找",两种场景均利用其结构特性实现高效检索:

(1)精确查找

流程与B-树类似,但最终需定位到叶子节点(非叶子节点仅为索引,不存储实际数据):

  1. 从根节点开始,在当前节点的关键字中顺序或折半查找,根据目标关键字key与节点关键字的大小关系,确定下一个待访问的子节点(例如,若key≤第i个关键字,则访问第i个子节点);
  2. 重复步骤1,直至访问到叶子节点;
  3. 在叶子节点的关键字中查找key,找到则返回对应数据(或数据地址),未找到则查找失败。

示例 :在上述3阶B+树中查找key=25

  • 根节点[15,30,45]:25≤30且25>15,访问第2个子节点[20,25,30](叶子节点);
  • 叶子节点中找到25,返回对应数据(共访问2个磁盘块,与B-树相同)。
(2)范围查找

B+树的核心优势之一,借助叶子节点的"有序链表"结构,无需回溯即可完成范围查询:

  1. 按精确查找流程,找到范围的起始关键字(如查找10≤key≤35,先找到key=10所在的叶子节点);
  2. 沿叶子节点的串联指针,依次遍历后续叶子节点,收集所有符合范围的关键字,直至超出范围上限(如遍历到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+树中间关键字需复制到父节点,而非移动,确保叶子节点包含所有关键字):

  1. 找到插入位置(叶子节点,按关键字顺序插入,保持叶子节点有序);
  2. 若插入后叶子节点的关键字数量≤mm为阶,叶子节点最多m个关键字),插入完成;
  3. 若插入后叶子节点关键字数量=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} + 1n为总关键字数),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) < mm为散列表长度);
  • 冲突(Collision) :不同的关键字通过散列函数映射到同一个存储位置,即key1 ≠ key2H(key1) = H(key2),这种现象称为冲突,key1key2互称为"同义词"。

2. 示例

设散列表长度m=11,散列函数H(key) = key % 11(取模运算):

  • 关键字key=22H(22)=22%11=0(存储在地址0);
  • 关键字key=33H(33)=33%11=0(与22冲突,均映射到地址0);
  • 关键字key=14H(14)=14%11=3(存储在地址3)。

3. 设计目标

散列表的核心目标是:

  1. 设计均匀的散列函数,使关键字尽可能均匀分布在散列表中,减少冲突;
  2. 设计高效的冲突处理方法,当冲突发生时,能快速找到新的存储位置。

(二)散列函数的构造方法

一个好的散列函数应满足:计算简单 (时间开销小)、分布均匀(减少冲突)。常用构造方法如下:

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,分割为123456789 → 叠加和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=933%13=744%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 = ii=1,2,...,m-1),即依次探测下一个位置(H(key)+1, H(key)+2, ...,超过表长则循环到表头)。

  • 示例m=11H(key)=key%11,插入关键字22(0)33(0)(冲突):
    • 33H_1=(0+1)%11=1(若地址1空闲,存入1);
    • 若地址1已被占用,继续探测H_2=2,以此类推。
  • 特点:实现简单,但易产生"聚集(Clustering)"现象(冲突的关键字集中在某一区域,导致后续插入/查找效率下降)。
(2)二次探测法(Quadratic Probing)

d_i = ±i²i=1,2,...,kk≤m/2),即探测H(key)±1, H(key)±4, H(key)±9,...

  • 示例H(key)=0,冲突后探测1, -1(10), 4, -4(7), ...m=11)。
  • 特点 :减少聚集现象,但需要散列表长度m4k+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=11H(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. 查找步骤(以链地址法为例)

  1. 计算待查找关键字key的散列地址H(key)
  2. 遍历散列表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+树+索引优化范围查找),以适应复杂的业务场景。

相关推荐
Brookty6 小时前
【算法】双指针(一)移动零
学习·算法
THMAIL6 小时前
机器学习从入门到精通 - 循环神经网络(RNN)与LSTM:时序数据预测圣经
人工智能·python·rnn·算法·机器学习·逻辑回归·lstm
程序员Xu6 小时前
【LeetCode热题100道笔记】二叉树的直径
笔记·算法·leetcode
superlls6 小时前
(数据结构)哈希碰撞:线性探测法 vs 拉链法
算法·哈希算法·散列表
midsummer_woo6 小时前
#数据结构----2.1线性表
数据结构
ShineWinsu6 小时前
对于单链表相关经典算法题:206. 反转链表及876. 链表的中间结点的解析
java·c语言·数据结构·学习·算法·链表·力扣
再睡一夏就好7 小时前
【C++闯关笔记】STL:list 的学习和使用
c语言·数据结构·c++·笔记·算法·学习笔记
Ka1Yan7 小时前
MySQL索引优化
开发语言·数据结构·数据库·mysql·算法
AndrewHZ7 小时前
【图像处理基石】图像预处理方面有哪些经典的算法?
图像处理·python·opencv·算法·计算机视觉·cv·图像预处理