数据结构:线性表、栈队列、串、树、图、查找、排序(2)

注:本文为 "数据结构" 相关合辑。

图片清晰度受引文原图所限。

略作重排,如有内容异常,请看原文。

csdn 篇幅所限,分篇连载。


数据结构:树(Tree)详解

2024-10-16 15:45:03

本文系统阐述树与二叉树的理论体系,内容包含树的形式化定义、存储实现方式,二叉树的规约定义、特殊形态、数学性质、存储结构与遍历算法,同时说明树与二叉树的相互映射规则,并介绍二叉排序树、平衡二叉树、哈夫曼树及哈夫曼编码的工程应用。

一、树

1.1 知识框架

1.2 树的基本概念

1.2.1 树的形式化定义

树为包含 n n n( n ≥ 0 n \ge 0 n≥0)个结点的有限集合。当 n = 0 n = 0 n=0 时,该集合定义为空树。任意非空树满足两条约束:

  1. 集合内存在唯一的根结点;
  2. 若 n > 1 n>1 n>1,剩余结点可划分为 m m m( m > 0 m>0 m>0)个两两不相交的有限子集 T 1 , T 2 , ... , T m \boldsymbol{T_1,T_2,\dots,T_m} T1,T2,...,Tm,每个子集自身均为一棵树,作为根结点的子树。

树采用递归定义范式,属于递归型逻辑数据结构,同时具备分层结构特征,结构约束如下:

  1. 根结点不存在前驱结点,其余所有结点有且仅有一个直接前驱结点;
  2. 任意结点可拥有零个或多个直接后继结点。

由结构约束可推导:包含 n n n 个结点的树,边的总数量为 n − 1 n-1 n−1。

1.2.2 基础术语定义

结合下述示意图对树的标准术语进行界定:

  1. 祖先、子孙、双亲、孩子、兄弟

    从根结点 A A A 至结点 K K K 的唯一路径上全部结点,均为结点 K K K 的祖先 ;结点 K K K 为上述结点的子孙 。路径中距离结点 K K K 最近的结点 E E E 为 K K K 的双亲 , K K K 为结点 E E E 的孩子 。拥有同一双亲的结点互为兄弟 ,如结点 K K K 与结点 L L L。根结点 A A A 为整棵树内唯一无双亲的结点。

  2. 结点的度、树的度

    单个结点拥有的孩子数量为该结点的度 ;整棵树所有结点度数的最大值为树的度 。示例中结点 B B B 的度为 2 2 2,结点 D D D 的度为 3 3 3,该树的度取值为 3 3 3。

  3. 分支结点、叶子结点

    度数大于 0 0 0 的结点称为分支结点 (非终端结点);度数等于 0 0 0、无后代结点的结点称为叶子结点(终端结点)。分支结点的分支数量等价于自身度数。

  4. 层次、深度、高度、堂兄弟、树的高度

    结点的层次 自根结点开始计数,根结点归属第 1 1 1 层,其子结点归属第 2 2 2 层,层级依次向下递推。双亲位于同一层级的结点互为堂兄弟 ,图示中结点 G G G 与结点 E 、 F 、 H 、 I 、 J E、F、H、I、J E、F、H、I、J 互为堂兄弟。

    结点的深度 :自根结点自上而下逐层累加得到的层级数值;

    结点的高度 :自底层叶子结点自下而上逐层累加得到的层级数值;

    树的高度(树的深度) :树内结点层级的最大值,图示树的高度为 4 4 4。

  5. 有序树、无序树

    若树中结点的各棵子树存在固定左右次序、不可互换位置,该树为有序树 ;子树次序不做约束的树为无序树。若将有序树同一结点下子结点位置调换,将得到结构不同的另一棵有序树。

  6. 路径、路径长度

    树内两个结点间的路径 由两点间途经的全部结点序列构成;路径长度 定义为路径经过的边的条数。

    树的分支具备单向指向性,仅由双亲指向孩子,因此路径仅允许自上而下遍历,同一双亲的两个孩子结点之间不存在有效路径。

  7. 森林

    森林 为包含 m m m( m ≥ 0 m\ge0 m≥0)棵两两互不相交树的集合。森林与树可相互转换:删除单棵树的根结点即可得到对应森林;为 m m m 棵独立树新增一个公共根结点,并将所有树作为该结点的子树,即可由森林构造得到单棵树。

上述概念可依托实例完成理解,无需机械记忆。

1.2.3 树的数学性质

树满足四条定量性质:

  1. 树内结点总数量等于全部结点度数之和加 1 1 1;
  2. 度为 m m m 的树,第 i i i 层最多可容纳 m i − 1 m^{i-1} mi−1 个结点( i ≥ 1 i\ge1 i≥1);
  3. 高度为 h h h 的 m m m 叉树,结点数量上限为 m h − 1 m − 1 \dfrac{m^h-1}{m-1} m−1mh−1;
  4. 包含 n n n 个结点的 m m m 叉树,理论最小高度为 ⌈ log ⁡ m ( n ( m − 1 ) + 1 ) ⌉ \lceil \log_m\big(n(m-1)+1\big) \rceil ⌈logm(n(m−1)+1)⌉。

1.3 树的三类存储结构

下述存储结构均以同一棵示例树作为参照模型:

1.3.1 双亲表示法

采用连续一维数组存储空间存储所有树结点,每个结点增设一个下标指示器,记录其双亲结点在数组内的存储位置。结点由数据域 data、双亲下标指针域 parent 两部分构成。

C 语言结构体定义
c 复制代码
#define MAX_TREE_SIZE 100
typedef int TElemType;

// 双亲表示法单个结点结构
typedef struct PTNode {
    TElemType data;
    int parent;
} PTNode;

// 树整体存储结构
typedef struct {
    PTNode nodes[MAX_TREE_SIZE];
    int r, n;   // r:根结点数组下标;n:总结点数量
} PTree;

该结构查询结点双亲的时间复杂度为 O ( 1 ) O(1) O(1),判定根结点的条件为 parent = -1;若需要检索结点的孩子结点,则需要对整个数组完成全量遍历。

1.3.2 孩子表示法

为每个结点独立构建一条存储其所有孩子下标的单链表, n n n 个结点对应 n n n 条孩子链表,叶子结点对应的链表为空;所有链表的头指针存入一维顺序数组,构成表头数组。

结构分为两类结点:

  1. 孩子链表结点:child 存储孩子结点数组下标,next 指向下一个孩子结点
  2. 表头数组结点:data 存储结点数据,firstchild 为孩子链表头指针
C 语言结构体定义
c 复制代码
#define MAX_TREE_SIZE 100
typedef int TElemType;

// 孩子链表子结点
typedef struct CTNode {
    int child;
    struct CTNode *next;
} *ChildPtr;

// 表头数组结点
typedef struct CTBox {
    TElemType data;
    ChildPtr firstchild;
} CTBox;

// 树整体结构
typedef struct {
    CTBox nodes[MAX_TREE_SIZE];
    int r, n;
} CTree;

该结构便于查询结点的孩子、兄弟结点,整树遍历仅需循环遍历表头数组;缺陷为查询结点双亲需要遍历全部链表,可将双亲表示法与孩子表示法进行组合优化,本文不展开实现。

1.3.3 孩子兄弟表示法

利用树结点的两个唯一属性:第一个孩子结点唯一、右侧相邻兄弟结点唯一,为每个结点设置两个指针域:firstchild 指向第一个孩子,rightsib 指向右兄弟。

C 语言结构体定义
c 复制代码
typedef int TElemType;

typedef struct CSNode {
    TElemType data;
    struct CSNode *firstchild, *rightsib;
} CSNode, *CSTree;

该存储结构可将任意一棵树等价转换为一棵二叉树,是树与二叉树相互映射的底层实现基础,转换效果如下:

二、二叉树

2.1 二叉树基础概念

2.1.1 二叉树的规约定义

二叉树为限定度上限为 2 2 2 的有序树形结构,任意结点至多拥有两棵子树,子树严格区分左子树、右子树,二者不可互换位置。递归形式定义如下:

二叉树为包含 n n n( n ≥ 0 n\ge0 n≥0)个结点的有限集合:

  1. 当 n = 0 n = 0 n=0 时,为空二叉树;
  2. 当 n > 0 n>0 n>0 时,由一个根结点与两棵互不相交的左、右子树构成,左、右子树自身均为二叉树。

二叉树属于有序树,仅存在单棵子树时仍需判定归属左子树或右子树。二叉树共包含五种基础形态:

2.1.2 五类特殊二叉树
(1)斜树

全部结点仅保留左子树的二叉树称为左斜树;全部结点仅保留右子树的二叉树称为右斜树,两类结构统称为斜树。斜树遍历时间复杂度退化为 O ( n ) O(n) O(n)。

(2)满二叉树

高度为 h h h、结点总数为 2 h − 1 2^h-1 2h−1 的二叉树为满二叉树,每一层均填充最大数量结点。叶子结点全部位于最底层,非叶子结点度数均等于 2 2 2。

对满二叉树自根结点开始自上而下、自左向右层序编号(根编号为 1 1 1),编号规则:

  • 编号为 i i i 的结点,双亲编号为 ⌊ i / 2 ⌋ \lfloor i/2 \rfloor ⌊i/2⌋;
  • 左孩子编号为 2 i 2i 2i,右孩子编号为 2 i + 1 2i+1 2i+1。
(3)完全二叉树

高度为 h h h、包含 n n n 个结点的二叉树,若所有结点与同高度满二叉树编号 1 ∼ n 1 \sim n 1∼n 的结点一一对应,则为完全二叉树。

完全二叉树约束特征:

  1. 满足 i ≤ ⌊ n / 2 ⌋ i \le \lfloor n/2 \rfloor i≤⌊n/2⌋ 的结点 i i i 为分支结点,其余为叶子结点;
  2. 叶子结点仅分布于最大两层,最底层叶子结点靠左连续排布;
  3. 若存在度数为 1 1 1 的结点,仅允许存在一个,且仅拥有左孩子;
  4. 层序编号下,若结点 i i i 为叶子结点或仅含左孩子,则编号大于 i i i 的所有结点均为叶子结点;
  5. n n n 为奇数时所有分支结点均具备左右孩子; n n n 为偶数时仅编号 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋ 的结点仅含左孩子。
(4)二叉排序树

递归约束:左子树所有结点关键字值小于根结点关键字值,右子树所有结点关键字值大于根结点关键字值,左、右子树同样遵循该规则。中序遍历可得到严格递增有序序列。

(5)平衡二叉树

树内任意结点左子树与右子树的高度差值绝对值不大于 1 1 1,也称为 AVL 树。

2.1.3 二叉树数学性质
  1. 任意包含 n n n 个结点的树形结构,边的数量恒为 n − 1 n-1 n−1;
  2. 非空二叉树内叶子结点数量 n 0 n_0 n0 与度为 2 2 2 的结点数量 n 2 n_2 n2 满足: n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1;
  3. 非空二叉树第 k k k 层最多包含 2 k − 1 2^{k-1} 2k−1 个结点( k ≥ 1 k\ge1 k≥1);
  4. 高度为 h h h 的二叉树结点数量最大值为 2 h − 1 2^h - 1 2h−1( h ≥ 1 h\ge1 h≥1);
  5. 完全二叉树层序编号规则(编号 1 , 2 , ... , n 1,2,\dots,n 1,2,...,n):
    • i > 1 i>1 i>1 时,结点 i i i 双亲编号为 ⌊ i / 2 ⌋ \lfloor i/2 \rfloor ⌊i/2⌋,偶数编号为左孩子,奇数编号为右孩子;
    • 2 i ≤ n 2i \le n 2i≤n 时存在左孩子,编号为 2 i 2i 2i;
    • 2 i + 1 ≤ n 2i+1 \le n 2i+1≤n 时存在右孩子,编号为 2 i + 1 2i+1 2i+1;
    • 结点 i i i 所在深度为 ⌊ log ⁡ 2 i ⌋ + 1 \lfloor \log_2 i \rfloor + 1 ⌊log2i⌋+1;
  6. 包含 n n n( n > 0 n>0 n>0)个结点的完全二叉树高度为 ⌊ log ⁡ 2 n ⌋ + 1 \lfloor \log_2 n \rfloor + 1 ⌊log2n⌋+1。
2.1.4 二叉树存储结构
(1)顺序存储结构

采用一维连续数组存储完全二叉树层序编号对应的结点,编号为 i i i 的结点存入数组下标 i − 1 i-1 i−1 位置。

满二叉树、完全二叉树使用顺序存储可通过下标直接推导父子逻辑关系,空间利用率最优;普通二叉树需要补充空占位结点以对齐完全二叉树编号规则,单支斜树最坏占用 2 h − 1 2^h-1 2h−1 个数组单元,空间开销较大。数组内数值 0 0 0 代表不存在的虚拟结点。

(2)链式存储结构(二叉链表)

每个结点设置一个数据域、两个指针域,lchild 指向左孩子,rchild 指向右孩子,该结构称为二叉链表。

C 语言结构体定义
c 复制代码
typedef int TElemType;

typedef struct BiTNode {
    TElemType data;
    struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;

n n n 个结点的二叉链表总指针域数量为 2 n 2n 2n,实际有效分支占用 n − 1 n-1 n−1 个指针,空指针域数量为 n + 1 n+1 n+1。

2.2 二叉树遍历算法

二叉树遍历定义为从根结点出发,按照指定规则访问全部结点,每个结点有且仅有一次被访问。

2.2.1 先序遍历(PreOrder)

递归执行规则:二叉树非空时,依次执行访问根结点、递归先序遍历左子树、递归先序遍历右子树。

递归实现代码
c 复制代码
void PreOrder(BiTree T) {
    if (T != NULL) {
        visit(T);
        PreOrder(T->lchild);
        PreOrder(T->rchild);
    }
}
2.2.2 中序遍历(InOrder)

递归执行规则:二叉树非空时,依次执行递归中序遍历左子树、访问根结点、递归中序遍历右子树。

递归实现代码
c 复制代码
void InOrder(BiTree T) {
    if (T != NULL) {
        InOrder(T->lchild);
        visit(T);
        InOrder(T->rchild);
    }
}
2.2.3 后序遍历(PostOrder)

递归执行规则:二叉树非空时,依次执行递归后序遍历左子树、递归后序遍历右子树、访问根结点。

递归实现代码
c 复制代码
void PostOrder(BiTree T) {
    if (T != NULL) {
        PostOrder(T->lchild);
        PostOrder(T->rchild);
        visit(T);
    }
}

三类递归遍历的时间复杂度均为 O ( n ) O(n) O(n);递归调用栈深度等价于树的高度,最坏单支树场景下空间复杂度为 O ( n ) O(n) O(n)。

2.2.4 遍历非递归迭代实现

依托辅助栈结构消除递归调用,遍历示例树如下:

(1)中序遍历非递归算法
c 复制代码
void InOrder2(BiTree T) {
    SqStack S;
    InitStack(&S);
    BiTree p = T;
    while (p || !IsEmpty(&S)) {
        if (p) {
            Push(&S, p);
            p = p->lchild;
        } else {
            Pop(&S, &p);
            visit(p);
            p = p->rchild;
        }
    }
}
(2)先序遍历非递归算法
c 复制代码
void PreOrder2(BiTree T) {
    SqStack S;
    InitStack(&S);
    BiTree p = T;
    while (p || !IsEmpty(&S)) {
        if (p) {
            visit(p);
            Push(&S, p);
            p = p->lchild;
        } else {
            Pop(&S, &p);
            p = p->rchild;
        }
    }
}
(3)后序遍历非递归算法

后序迭代逻辑复杂度最高,增设标记指针记录上一次访问结点,判定右子树访问状态:

c 复制代码
void PostOrder2(BiTree T) {
    SqStack S;
    InitStack(&S);
    BiTree p = T, r = NULL;
    while (p || !IsEmpty(&S)) {
        if (p) {
            Push(&S, p);
            p = p->lchild;
        } else {
            GetTop(&S, &p);
            if (p->rchild && p->rchild != r) {
                p = p->rchild;
                Push(&S, p);
                p = p->lchild;
            } else {
                Pop(&S, &p);
                visit(p);
                r = p;
                p = NULL;
            }
        }
    }
}
2.2.5 层序遍历

借助队列完成逐层遍历,规则:根结点入队,循环执行队头结点出队访问、左孩子入队、右孩子入队,直至队列为空。

层序遍历代码
c 复制代码
void LevelOrder(BiTree T) {
    SqQueue Q;
    InitQueue(&Q);
    BiTree p;
    EnQueue(&Q, T);
    while (!IsEmpty(&Q)) {
        DeQueue(&Q, &p);
        visit(p);
        if (p->lchild != NULL)
            EnQueue(&Q, p->lchild);
        if (p->rchild != NULL)
            EnQueue(&Q, p->rchild);
    }
}
2.2.6 遍历序列还原二叉树

可唯一确定二叉树的序列组合:

  1. 先序序列 + 中序序列;
  2. 后序序列 + 中序序列;
  3. 层序序列 + 中序序列。

仅通过先序+后序序列无法唯一还原树形结构。

2.3 线索二叉树

2.3.1 设计原理

二叉链表存在 n + 1 n+1 n+1 个空闲指针域,将空左指针指向遍历前驱结点、空右指针指向遍历后继结点,此类附加指针定义为线索;嵌入线索的链表为线索链表,对应二叉树为线索二叉树。

结点增设两个标志位区分指针类型:

  • ltag = 0lchild 指向左孩子;ltag = 1lchild 指向前驱线索;
  • rtag = 0rchild 指向右孩子;rtag = 1rchild 指向后继线索。
2.3.2 线索二叉树结构体定义
c 复制代码
typedef int ElemType;

typedef struct ThreadNode {
    ElemType data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;
} ThreadNode, *ThreadTree;
2.3.3 二叉树线索化实现

线索化本质为一次完整遍历,遍历过程中修改空指针为前驱、后继线索。

(1)中序线索化递归算法
c 复制代码
void InThread(ThreadTree p, ThreadTree *pre) {
    if (p != NULL) {
        InThread(p->lchild, pre);
        // 建立前驱线索
        if (p->lchild == NULL) {
            p->lchild = *pre;
            p->ltag = 1;
        }
        // 建立前驱结点后继线索
        if (*pre != NULL && (*pre)->rchild == NULL) {
            (*pre)->rchild = p;
            (*pre)->rtag = 1;
        }
        *pre = p;
        InThread(p->rchild, pre);
    }
}

// 构建中序线索二叉树入口函数
void CreateInThread(ThreadTree T) {
    ThreadTree pre = NULL;
    if (T != NULL) {
        InThread(T, &pre);
        pre->rchild = NULL;
        pre->rtag = 1;
    }
}

可增设头结点构建双向循环线索链表,实现正向、逆向连续遍历,遍历时间复杂度为 O ( n ) O(n) O(n)。

(2)先序、后序线索二叉树说明

先序、后序线索化仅调整递归遍历左右子树与线索绑定的执行顺序;后序线索二叉树查找结点后继依赖双亲信息,通常需要三叉链表结构辅助实现。

2.4 树、森林与二叉树的相互映射

2.4.1 树转换为二叉树

转换规则(左孩子右兄弟规则):结点左指针指向第一个孩子,右指针指向右侧相邻兄弟;根结点无右子树。

绘图步骤:

  1. 所有兄弟结点之间添加水平连线;
  2. 每个结点仅保留与第一个孩子的竖直连线,删除其余孩子连线;
  3. 以根结点为轴心顺时针旋转 45 ∘ 45^\circ 45∘ 完成规整。
2.4.2 森林转换为二叉树

绘图步骤:

  1. 将森林内每一棵树独立转换为对应二叉树;
  2. 所有二叉树的根结点视为兄弟结点,依次添加水平连线;
  3. 以第一棵树根结点为轴心顺时针旋转 45 ∘ 45^\circ 45∘。

二叉树反向还原为树、森林为上述操作逆过程。

2.5 树与森林的遍历规则

2.5.1 树的两种遍历方式
  1. 先根遍历:访问根结点,依次先根遍历每一棵子树;遍历序列等价于对应二叉树先序序列;
  2. 后根遍历:依次后根遍历每一棵子树,访问根结点;遍历序列等价于对应二叉树中序序列。

树同样支持层序遍历,规则与二叉树层序遍历一致。

2.5.2 森林的两种遍历方式
  1. 先序遍历森林:访问第一棵树根结点 → 先序遍历其所有子树构成的森林 → 先序遍历剩余树构成的森林;
  2. 后序遍历森林:后序遍历第一棵树根结点的子树森林 → 访问根结点 → 后序遍历剩余树构成的森林。

森林转换为二叉树后,先序遍历等价于二叉树先序遍历,后序遍历等价于二叉树中序遍历。

三、树结构典型应用

3.1 二叉排序树(BST)

3.1.1 形式化定义

二叉排序树为空树,或满足三条递归约束:

  1. 左子树非空时,左子树全部结点关键字小于根结点关键字;
  2. 右子树非空时,右子树全部结点关键字大于根结点关键字;
  3. 左、右子树均为二叉排序树。

中序遍历输出严格递增有序序列。

3.1.2 基础操作实现

结点结构体复用二叉链表定义:

c 复制代码
typedef struct BiTNode {
    int data;
    struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
(1)关键字查找递归算法
c 复制代码
bool SearchBST(BiTree T, int key, BiTree f, BiTree *p) {
    if (!T) {
        *p = f;
        return false;
    } else if (key == T->data) {
        *p = T;
        return true;
    } else if (key < T->data) {
        return SearchBST(T->lchild, key, T, p);
    } else {
        return SearchBST(T->rchild, key, T, p);
    }
}
(2)结点插入算法
c 复制代码
bool InsertBST(BiTree *T, int key) {
    BiTree p, s;
    if (!SearchBST(*T, key, NULL, &p)) {
        s = (BiTree)malloc(sizeof(BiTNode));
        s->data = key;
        s->lchild = s->rchild = NULL;
        if (!p) {
            *T = s;
        } else if (key < p->data) {
            p->lchild = s;
        } else {
            p->rchild = s;
        }
        return true;
    } else {
        return false;
    }
}

批量构建示例:

c 复制代码
int arr[10] = {62, 88, 58, 47, 35, 73, 51, 99, 37, 93};
BiTree T = NULL;
for (int i = 0; i < 10; i++) {
    InsertBST(&T, arr[i]);
}
(3)结点删除算法

删除分为三类场景:叶子结点直接释放;单孩子结点直接子树顶替;双孩子结点使用中序直接前驱/后继顶替数值后删除前驱结点。

c 复制代码
bool Delete(BiTree *p) {
    BiTree q, s;
    if ((*p)->rchild == NULL) {
        q = *p;
        *p = (*p)->lchild;
        free(q);
    } else if ((*p)->lchild == NULL) {
        q = *p;
        *p = (*p)->rchild;
        free(q);
    } else {
        q = *p;
        s = (*p)->lchild;
        while (s->rchild) {
            q = s;
            s = s->rchild;
        }
        (*p)->data = s->data;
        if (q != *p)
            q->rchild = s->lchild;
        else
            q->lchild = s->lchild;
        free(s);
    }
    return true;
}

bool DeleteBST(BiTree *T, int key) {
    if (!*T)
        return false;
    if (key == (*T)->data)
        return Delete(T);
    else if (key < (*T)->data)
        return DeleteBST(&((*T)->lchild), key);
    else
        return DeleteBST(&((*T)->rchild), key);
}
3.1.3 性能局限与平衡优化导向

二叉排序树查找效率由树形高度决定,有序序列插入会退化为斜树,查找时间复杂度劣化为 O ( n ) O(n) O(n);随机插入序列可趋近于 O ( log ⁡ n ) O(\log n) O(logn)。为稳定查找效率,引入平衡二叉树对树形做约束。

3.2 平衡二叉树(AVL 树)

3.2.1 定义与平衡因子

平衡二叉树为附加高度约束的二叉排序树,任意结点左、右子树高度差绝对值不超过 1 1 1。定义平衡因子 BF : B F = h 左子树 − h 右子树 BF = h_{\text{左子树}} - h_{\text{右子树}} BF=h左子树−h右子树,合法取值仅为 − 1 、 0 、 1 -1、0、1 −1、0、1,任意结点 ∣ B F ∣ > 1 |BF|>1 ∣BF∣>1 判定为失衡。

3.2.2 查找性能边界

深度为 h h h 的平衡二叉树最少结点数递推公式:

{ n 0 = 0 n 1 = 1 n h = n h − 1 + n h − 2 + 1 \begin{cases} n_0 = 0 \\ n_1 = 1 \\ n_h = n_{h-1} + n_{h-2} + 1 \end{cases} ⎩ ⎨ ⎧n0=0n1=1nh=nh−1+nh−2+1

n n n 个结点平衡二叉树最大高度为 O ( log ⁡ 2 n ) O(\log_2 n) O(log2n),查找、插入、删除平均时间复杂度稳定为 O ( log ⁡ n ) O(\log n) O(logn)。

3.2.3 插入失衡的四类旋转调整

插入新结点后,定位距离插入点最近的失衡结点构成最小不平衡子树,通过旋转操作恢复平衡,分为四类调整方案:

  1. LL 右单旋转 :A 左孩子的左子树插入结点,整体右旋;
  2. RR 左单旋转 :A 右孩子的右子树插入结点,整体左旋;
  3. LR 先左后右双旋 :A 左孩子的右子树插入结点,先左旋左子树、再右旋根结点;
  4. RL 先右后左双旋 :A 右孩子的左子树插入结点,先右旋右子树、再左旋根结点。

关键字序列 { 15 , 3 , 7 , 10 , 9 , 8 } \{15,3,7,10,9,8\} {15,3,7,10,9,8} 完整构造过程:

同类平衡二叉搜索树还包含红黑树,二者在插入调整开销、高度约束上存在差异。

3.3 哈夫曼树与哈夫曼编码

3.3.1 带权路径长度与哈夫曼树定义

为叶子结点赋予对应权值 w i w_i wi,带权路径长度 定义为该结点权值与根至该结点路径长度 l i l_i li 的乘积;整棵树带权路径长度 WPL 为所有叶子结点带权路径长度累加和:

W P L = ∑ i = 1 n w i l i WPL = \sum_{i=1}^{n} w_i l_i WPL=i=1∑nwili

n n n 个带权叶子结点可构造多棵二叉树,其中 WPL 取值最小的二叉树定义为哈夫曼树(最优二叉树)

3.3.2 哈夫曼树构造步骤
  1. 将所有权值叶子结点升序排列为有序集合;
  2. 取出权值最小的两个结点作为新结点左、右孩子,新结点权值为两子结点权值之和;
  3. 新结点替换原有两个子结点,重新维持集合有序;
  4. 循环执行步骤 2、3,直至集合仅剩余一个根结点。
3.3.3 哈夫曼编码数据压缩原理

哈夫曼编码为前缀无损压缩编码,规则:哈夫曼树左分支标记 0 0 0、右分支标记 1 1 1,根至叶子结点途经 0 / 1 0/1 0/1 序列作为该字符编码。

以字符频次 A:27%、B:8%、C:15%、D:15%、E:30%、F:5% \text{A:27\%、B:8\%、C:15\%、D:15\%、E:30\%、F:5\%} A:27%、B:8%、C:15%、D:15%、E:30%、F:5% 为例,哈夫曼树总带权路径长度:

W P L = 2 × ( 15 + 27 + 30 ) + 3 × 15 + 4 × ( 5 + 8 ) = 241 WPL = 2\times(15+27+30) + 3\times15 + 4\times(5+8) = 241 WPL=2×(15+27+30)+3×15+4×(5+8)=241

编码具备前缀性:任意字符编码不会成为另一字符编码的前缀,可无歧义译码。高频字符分配短编码、低频字符分配长编码,实现传输比特总量压缩。

补充说明:左右分支 0 / 1 0/1 0/1 标记规则可自定义,哈夫曼树形态不唯一,但所有合法构造的 WPL 数值完全相等。


数据结构:图(Graph)详解

2024-10-16 15:45:45

本文介绍图的基本概念,包括图的定义、分类、存储结构等,并讨论图的遍历方法,如深度优先遍历与广度优先遍历。此外,还介绍最小生成树、最短路径、拓扑排序等经典算法。

知识框架

图的基本概念

在线性表中,数据元素之间仅存在线性关系,每个数据元素只有一个直接前驱和一个直接后继。在树形结构中,数据元素之间具有层次关系,每一层上的数据元素可能与下一层中多个元素相关,但只能与上一层中一个元素相关。图是一种较线性表和树更为复杂的数据结构。在图结构中,顶点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。

一、图的定义

图(Graph)是由顶点的有穷非空集合 V ( G ) V(G) V(G) 和顶点之间边的集合 E ( G ) E(G) E(G) 组成,通常表示为 G = ( V , E ) G=(V,E) G=(V,E),其中 G G G 表示一个图, V V V 是图 G G G 中顶点的集合, E E E 是图 G G G 中边的集合。若 V = { v 1 , v 2 , ... , v n } V=\{v_1,v_2,\dots,v_n\} V={v1,v2,...,vn},则用 ∣ V ∣ |V| ∣V∣ 表示图 G G G 中顶点的个数,也称图 G G G 的阶; E = { ( u , v ) ∣ u ∈ V , v ∈ V } E=\{(u,v)\mid u\in V,v\in V\} E={(u,v)∣u∈V,v∈V},用 ∣ E ∣ |E| ∣E∣ 表示图 G G G 中边的条数。

注意 :线性表可以是空表,树可以是空树,但图不可以是空图。即图中不能一个顶点也没有,图的顶点集 V V V 一定非空,但边集 E E E 可以为空,此时图中只有顶点而没有边。

二、图的基本概念和术语

1. 有向图

若 E E E 是有向边(也称弧)的有限集合,则图 G G G 为有向图。弧是顶点的有序对,记为 ⟨ v , w a n g l e \langle v,w angle ⟨v,wangle,其中 v , w v,w v,w 是顶点, v v v 称为弧尾, w w w 称为弧头, ⟨ v , w a n g l e \langle v,w angle ⟨v,wangle 称为从顶点 v v v 到顶点 w w w 的弧,也称 v v v 邻接到 w w w,或 w w w 邻接自 v v v。

图(a)所示的有向图 G 1 G_1 G1 可表示为

G 1 = ( V 1 , E 1 ) , V 1 = { 1 , 2 , 3 } , E 1 = { ⟨ 1 , 2 a n g l e , ⟨ 2 , 1 a n g l e , ⟨ 2 , 3 a n g l e } . G_1=(V_1,E_1),\quad V_1=\{1,2,3\},\quad E_1=\{\langle 1,2 angle,\langle 2,1 angle,\langle 2,3 angle\}. G1=(V1,E1),V1={1,2,3},E1={⟨1,2angle,⟨2,1angle,⟨2,3angle}.

2. 无向图

若 E E E 是无向边(简称边)的有限集合,则图 G G G 为无向图。边是顶点的无序对,记为 ( v , w ) (v,w) (v,w) 或 ( w , v ) (w,v) (w,v),因为 ( v , w ) = ( w , v ) (v,w)=(w,v) (v,w)=(w,v),其中 v , w v,w v,w 是顶点。可以说顶点 w w w 和顶点 v v v 互为邻接点。边 ( v , w ) (v,w) (v,w) 依附于顶点 w w w 和 v v v,或者说边 ( v , w ) (v,w) (v,w) 和顶点 v , w v,w v,w 相关联。

图(b)所示的无向图 G 2 G_2 G2 可表示为

G 2 = ( V 2 , E 2 ) , V 2 = { 1 , 2 , 3 , 4 } , E 2 = { ( 1 , 2 ) , ( 1 , 3 ) , ( 1 , 4 ) , ( 2 , 3 ) , ( 2 , 4 ) , ( 3 , 4 ) } . G_2=(V_2,E_2),\quad V_2=\{1,2,3,4\},\quad E_2=\{(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)\}. G2=(V2,E2),V2={1,2,3,4},E2={(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)}.

3. 简单图

一个图 G G G 若满足:① 不存在重复边;② 不存在顶点到自身的边,则称图 G G G 为简单图。上图中 G 1 G_1 G1 和 G 2 G_2 G2 均为简单图。数据结构中仅讨论简单图。

4. 多重图

若图 G G G 中某两个顶点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则 G G G 为多重图。多重图的定义和简单图是相对的。

5. 完全图(也称简单完全图)

对于无向图, ∣ E ∣ |E| ∣E∣ 的取值范围是 0 0 0 到 n ( n − 1 ) / 2 n(n-1)/2 n(n−1)/2,有 n ( n − 1 ) / 2 n(n-1)/2 n(n−1)/2 条边的无向图称为完全图,在完全图中任意两个顶点之间都存在边。对于有向图, ∣ E ∣ |E| ∣E∣ 的取值范围是 0 0 0 到 n ( n − 1 ) n(n-1) n(n−1),有 n ( n − 1 ) n(n-1) n(n−1) 条弧的有向图称为有向完全图,在有向完全图中任意两个顶点之间都存在方向相反的两条弧。上图中 G 2 G_2 G2 为无向完全图,而 G 3 G_3 G3 为有向完全图。

6. 子图

设有两个图 G = ( V , E ) G=(V,E) G=(V,E) 和 G ′ = ( V ′ , E ′ ) G'=(V',E') G′=(V′,E′),若 V ′ V' V′ 是 V V V 的子集,且 E ′ E' E′ 是 E E E 的子集,则称 G ′ G' G′ 是 G G G 的子图。若有满足 V ( G ′ ) = V ( G ) V(G')=V(G) V(G′)=V(G) 的子图 G ′ G' G′,则称其为 G G G 的生成子图 。上图中 G 3 G_3 G3 为 G 1 G_1 G1 的子图。

注意 :并非 V V V 和 E E E 的任何子集都能构成 G G G 的子图,因为这样的子集可能不是图,即 E E E 的子集中的某些边关联的顶点可能不在这个 V V V 的子集中。

7. 连通、连通图和连通分量

在无向图中,若从顶点 v v v 到顶点 w w w 有路径存在,则称 v v v 和 w w w 是连通的。若图 G G G 中任意两个顶点都是连通的,则称图 G G G 为连通图 ,否则称为非连通图 。无向图中的极大连通子图称为连通分量 。若一个图有 n n n 个顶点,并且边数小于 n − 1 n-1 n−1,则此图必是非连通图。如下图(a)所示,图 G 4 G_4 G4 有 3 个连通分量,如图(b)所示。

注意:弄清连通、连通图、连通分量的概念非常重要。首先要区分极大连通子图和极小连通子图,极大连通子图是无向图的连通分量,极大即要求该连通子图包含其所有的边;极小连通子图是既要保持图连通又要使得边数最少的子图。

8. 强连通图、强连通分量

在有向图中,若从顶点 v v v 到顶点 w w w 和从顶点 w w w 到顶点 v v v 之间都有路径,则称这两个顶点是强连通的。若图中任何一对顶点都是强连通的,则称此图为强连通图 。有向图中的极大强连通子图称为有向图的强连通分量 ,图 G 1 G_1 G1 的强连通分量如下图所示。

注意:强连通图、强连通分量只是针对有向图而言的。一般在无向图中讨论连通性,在有向图中考虑强连通性。

9. 生成树、生成森林

连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为 n n n,则它的生成树含有 n − 1 n-1 n−1 条边。对生成树而言,若砍去它的一条边,则会变成非连通图;若加上一条边则会形成一个回路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。图 G 2 G_2 G2 的一个生成树如下图所示。

注意:包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图将不再连通。

10. 顶点的度、入度和出度

图中每个顶点的度定义为以该顶点为一个端点的边的数目。

对于无向图,顶点 v v v 的度是指依附于该顶点的边的条数,记为 T D ( v ) \mathrm{TD}(v) TD(v)。

在具有 n n n 个顶点、 e e e 条边的无向图中, ∑ i = 1 n T D ( v i ) = 2 e \displaystyle\sum_{i=1}^{n}\mathrm{TD}(v_i)=2e i=1∑nTD(vi)=2e,即无向图的全部顶点的度的和等于边数的 2 倍,因为每条边和两个顶点相关联。

对于有向图,顶点 v v v 的度分为入度出度 ,入度是以顶点 v v v 为终点的有向边的数目,记为 I D ( v ) \mathrm{ID}(v) ID(v);出度是以顶点 v v v 为起点的有向边的数目,记为 O D ( v ) \mathrm{OD}(v) OD(v)。顶点 v v v 的度等于其入度和出度之和,即

T D ( v ) = I D ( v ) + O D ( v ) . \mathrm{TD}(v)=\mathrm{ID}(v)+\mathrm{OD}(v). TD(v)=ID(v)+OD(v).

在具有 n n n 个顶点、 e e e 条边的有向图中, ∑ i = 1 n I D ( v i ) = ∑ i = 1 n O D ( v i ) = e \displaystyle\sum_{i=1}^{n}\mathrm{ID}(v_i)=\sum_{i=1}^{n}\mathrm{OD}(v_i)=e i=1∑nID(vi)=i=1∑nOD(vi)=e,即有向图的全部顶点的入度之和与出度之和相等,并且等于边数。这是因为每条有向边都有一个起点和终点。

11. 边的权和网

在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值 。这种边上带有权值的图称为带权图 ,也称

12. 稠密图、稀疏图

边数很少的图称为稀疏图,反之称为稠密图。稀疏和稠密本身是模糊的概念,稀疏图和稠密图常常是相对而言的。一般当图 G G G 满足 ∣ E ∣ < ∣ V ∣ log ⁡ ∣ V ∣ |E|<|V|\log|V| ∣E∣<∣V∣log∣V∣ 时,可以将 G G G 视为稀疏图。

13. 路径、路径长度和回路

顶点 v p v_p vp 到顶点 v q v_q vq 之间的一条路径是指顶点序列 v p , v i 1 , v i 2 , ... , v i m , v q v_p,v_{i_1},v_{i_2},\dots,v_{i_m},v_q vp,vi1,vi2,...,vim,vq,当然关联的边也可以理解为路径的构成要素。路径上边的数目称为路径长度 。第一个顶点和最后一个顶点相同的路径称为回路 。若一个图有 n n n 个顶点,并且有大于 n − 1 n-1 n−1 条边,则此图一定有环。

14. 简单路径、简单回路

在路径序列中,顶点不重复出现的路径称为简单路径。除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。

15. 距离

从顶点 u u u 出发到顶点 v v v 的最短路径若存在,则此路径的长度称为从 u u u 到 v v v 的距离。若从 u u u 到 v v v 根本不存在路径,则记该距离为无穷( ∞ \infty ∞)。

16. 有向树

一个顶点的入度为 0、其余顶点的入度均为 1 的有向图,称为有向树。

图的存储结构

由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方式,要么会造成很多存储单元的浪费,要么又带来操作的不便。因此,对于图来说,如何对它实现物理存储是个难题,接下来介绍五种不同的存储结构。

一、邻接矩阵

图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。

设图 G G G 有 n n n 个顶点,则邻接矩阵 A A A 是一个 n × n n\times n n×n 的方阵,定义为:

下图是一个无向图和它的邻接矩阵:

可以看出:

  1. 无向图的邻接矩阵一定是一个对称矩阵(即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都相等)。因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
  2. 对于无向图,邻接矩阵的第 i i i 行(或第 i i i 列)非零元素(或非 ∞ \infty ∞ 元素)的个数正好是第 i i i 个顶点的度 T D ( v i ) \mathrm{TD}(v_i) TD(vi)。比如顶点 v 1 v_1 v1 的度就是 1 + 0 + 1 + 0 = 2 1+0+1+0=2 1+0+1+0=2。
  3. 求顶点 v i v_i vi 的所有邻接点就是将矩阵中第 i i i 行元素扫描一遍, A i j Aij Aij 为 1 就是邻接点。

下图是有向图和它的邻接矩阵:

可以看出:

  1. 主对角线上数值依然为 0。但因为是有向图,所以此矩阵并不对称。
  2. 有向图讲究入度与出度,顶点 v 1 v_1 v1 的入度为 1,正好是第 v 1 v_1 v1 列各数之和。顶点 v 1 v_1 v1 的出度为 2,即第 v 1 v_1 v1 行的各数之和。
  3. 与无向图同样的办法,判断顶点 v i v_i vi 到 v j v_j vj 是否存在弧,只需要查找矩阵中 A i j Aij Aij 是否为 1 即可。

对于带权图而言,若顶点 v i v_i vi 和 v j v_j vj 之间有边相连,则邻接矩阵中对应项存放着该边对应的权值。

下图是有向网图和它的邻接矩阵:

通过以上对无向图、有向图和网的描述,可定义出邻接矩阵的存储结构:

c 复制代码
#define MaxVertexNum 100    // 顶点数目的最大值
typedef char VertexType;    // 顶点的数据类型
typedef int EdgeType;       // 带权图中边上权值的数据类型
typedef struct {
    VertexType Vex[MaxVertexNum];               // 顶点表
    EdgeType Edge[MaxVertexNum][MaxVertexNum];  // 邻接矩阵,边表
    int vexnum, arcnum;                         // 图的当前顶点数和弧数
} MGraph;

注意

① 在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略)。

② 当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType 可定义为值为 0 和 1 的枚举类型。

③ 无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储。

④ 邻接矩阵表示法的空间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n 为图的顶点数 ∣ V ∣ |V| ∣V∣。

⑤ 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。

⑥ 稠密图适合使用邻接矩阵的存储表示。

二、邻接表

当一个图为稀疏图时(边数相对顶点较少),使用邻接矩阵法显然要浪费大量的存储空间,如下图所示:

而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。

所谓邻接表,是指对图 G G G 中的每个顶点 v i v_i vi 建立一个单链表,第 i i i 个单链表中的结点表示依附于顶点 v i v_i vi 的边(对于有向图则是以顶点 v i v_i vi 为尾的弧),这个单链表就称为顶点 v i v_i vi 的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点,如下图所示。

顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc)构成,边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc)构成。

无向图的邻接表的实例如下图所示。

有向图的邻接表的实例如下图所示。

此时很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。

对于带权值的网图,可以在边表结点定义中再增加一个 weight 的数据域,存储权值信息即可。

图的邻接表存储结构定义如下:

c 复制代码
#define MAXVEX 100    // 图中顶点数目的最大值
typedef char VertexType;    // 顶点类型应由用户定义
typedef int EdgeType;       // 边上的权值类型应由用户定义

/* 边表结点 */
typedef struct EdgeNode {
    int adjvex;         // 该弧所指向的顶点的下标或者位置
    EdgeType weight;    // 权值,对于非网图可以不需要
    struct EdgeNode *next;  // 指向下一个邻接点
} EdgeNode;

/* 顶点表结点 */
typedef struct VertexNode {
    VertexType data;        // 顶点域,存储顶点信息
    EdgeNode *firstedge;    // 边表头指针
} VertexNode, AdjList[MAXVEX];

/* 邻接表 */
typedef struct {
    AdjList adjList;
    int numVertexes, numEdges;  // 图中当前顶点数和边数
} GraphAdjList;

图的邻接表存储方法具有以下特点:

  1. 若 G G G 为无向图,则所需的存储空间为 O ( ∣ V ∣ + 2 ∣ E ∣ ) O(|V|+2|E|) O(∣V∣+2∣E∣);若 G G G 为有向图,则所需的存储空间为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。前者的倍数 2 是由于无向图中,每条边在邻接表中出现了两次。
  2. 对于稀疏图,采用邻接表表示将极大地节省存储空间。
  3. 在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为 O ( n ) O(n) O(n)。但是,若要确定给定的两个顶点间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较低。
  4. 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数;但求其顶点的入度则需要遍历全部的邻接表。因此,也有人采用逆邻接表的存储方式来加速求解给定顶点的入度。当然,这实际上与邻接表存储方式是类似的。
  5. 图的邻接表表示并不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。

三、十字链表

十字链表是有向图的一种链式存储结构。

对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来呢?答案是肯定的,就是把它们整合在一起。这就是有向图的一种存储方法:十字链表(Orthogonal List)

重新定义顶点表结点结构如下表所示。

其中 firstin 表示入边表头指针,指向该顶点的入边表中第一个结点,firstout 表示出边表头指针,指向该顶点的出边表中的第一个结点。

重新定义的边表结点结构如下表所示。

其中 tailvex 是指弧起点在顶点表的下标,headvex 是指弧终点在顶点表中的下标,headlink 是指入边表指针域,指向终点相同的下一条边,taillink 是指边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个 weight 域来存储权值。

接下来通过一个例子详细介绍十字链表的结构。

如下图所示,顶点依然是存入一个一维数组 { V 0 , V 1 , V 2 , V 3 } \{V_0,V_1,V_2,V_3\} {V0,V1,V2,V3},实线箭头指针的图示完全与邻接表的结构相同 。就以顶点 V 0 V_0 V0 来说,firstout 指向的是出边表中的第一个结点 V 3 V_3 V3。所以 V 0 V_0 V0 边表结点的 h e a d v e x = 3 \mathrm{headvex}=3 headvex=3,而 t a i l v e x \mathrm{tailvex} tailvex 就是当前顶点 V 0 V_0 V0 的下标 0,由于 V 0 V_0 V0 只有一个出边顶点,所以 headlink 和 taillink 都是空。

重点需要解释虚线箭头的含义,它其实就是此图的逆邻接表的表示 。对于 V 0 V_0 V0 来说,它有两个顶点 V 1 V_1 V1 和 V 2 V_2 V2 的入边。因此 V 0 V_0 V0 的 firstin 指向顶点 V 1 V_1 V1 的边表结点中 headvex 为 0 的结点,如上图右图中的 ①。接着由入边结点的 headlink 指向下一个入边顶点 V 2 V_2 V2,如图中的 ②。对于顶点 V 1 V_1 V1,它有一个入边顶点 V 2 V_2 V2,所以它的 firstin 指向顶点 V 2 V_2 V2 的边表结点中 headvex 为 1 的结点,如图中的 ③。顶点 V 2 V_2 V2 和 V 3 V_3 V3 也是同样有一个入边顶点,如图中 ④ 和 ⑤。

十字链表的好处就是因为把邻接表和逆邻接表整合在了一起 ,这样既容易找到以 V 1 V_1 V1 为尾的弧,也容易找到以 V 1 V_1 V1 为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。

四、邻接多重表

邻接多重表是无向图的另一种链式存储结构。

在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。比如下图中,若要删除左图的 ( V 0 , V 2 ) (V_0,V_2) (V0,V2) 这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。

重新定义的边表结点结构如下表所示。

其中 ivex 和 jvex 是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点 ivex 的下一条边,jlink 指向依附顶点 jvex 的下一条边。这就是邻接多重表结构。

每个顶点也用一个结点表示,它由如下所示的两个域组成。

其中,data 域存储该顶点的相关信息,firstedge 域指示第一条依附于该顶点的边。

来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下图 7 所示,左图告诉我们它有 4 个顶点和 5 条边,显然,就应该先将 4 个顶点和 5 条边的边表结点画出来。

开始连线,如图,首先连线的 ①②③④ 就是将顶点的 firstedge 指向一条边,顶点下标要与 ivex 的值相同,这很好理解。接着,由于顶点 V 0 V_0 V0 的 ( V 0 , V 1 ) (V_0,V_1) (V0,V1) 边的邻边有 ( V 0 , V 3 ) (V_0,V_3) (V0,V3) 和 ( V 0 , V 2 ) (V_0,V_2) (V0,V2)。因此 ⑤⑥ 的连线就是满足指向下一条依附于顶点 V 0 V_0 V0 的边的目标,注意 ilink 指向的结点的 jvex 一定要和它本身的 ivex 的值相同 。同样的道理,连线 ⑦ 就是指 ( V 1 , V 0 ) (V_1,V_0) (V1,V0) 这条边,它是相当于顶点 V 1 V_1 V1 指向 ( V 1 , V 2 ) (V_1,V_2) (V1,V2) 边后的下一条。 V 2 V_2 V2 有三条边依附,所以在 ③ 之后就有了 ⑧⑨。连线 ④ 的就是顶点 V 3 V_3 V3 在连线 ④ 之后的下一条边。左图一共有 5 条边,所以右图有 10 条连线,完全符合预期。

到这里,可以明显地看出,邻接多重表与邻接表的差别,仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。这样对边的操作就方便多了,若要删除左图的 ( V 0 , V 2 ) (V_0,V_2) (V0,V2) 这条边,只需要将右图的 ⑥⑨ 的链接指向改为 NULL 即可。

五、边集数组

边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成,如下图所示。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。

图的遍历

图的遍历是和树的遍历类似,希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。

对于图的遍历来说,通常有两种遍历次序方案:深度优先遍历和广度优先遍历。

一、深度优先遍历

深度优先遍历(Depth First Search),也有称为深度优先搜索,简称为 DFS。

1. DFS 算法

深度优先搜索类似于树的先序遍历。如其名称中所暗含的意思一样,这种搜索算法所遵循的搜索策略是尽可能"深"地搜索一个图。它的基本思想如下:首先访问图中某一起始顶点 v v v,然后由 v v v 出发,访问与 v v v 邻接且未被访问的任一顶点 w 1 w_1 w1,再访问与 w 1 w_1 w1 邻接且未被访问的任一顶点......重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。

一般情况下,其递归形式的算法十分简洁,算法过程如下:

c 复制代码
bool visited[MAX_VERTEX_NUM];   // 访问标记数组

/* 从顶点 v 出发,深度优先遍历图 G */
void DFS(Graph G, int v) {
    int w;
    visit(v);                   // 访问顶点
    visited[v] = TRUE;          // 设已访问标记
    // FirstNeighbor(G,v): 求图 G 中顶点 v 的第一个邻接点,若有则返回顶点号,否则返回 -1
    // NextNeighbor(G,v,w): 假设图 G 中顶点 w 是顶点 v 的一个邻接点,返回除 w 外顶点 v 的下一个邻接点
    for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) {
        if (!visited[w]) {      // w 为 v 的尚未访问的邻接顶点
            DFS(G, w);
        }
    }
}

/* 对图进行深度优先遍历 */
void DFSTraverse(MGraph G) {
    int v;
    for (v = 0; v < G.vexnum; ++v) {
        visited[v] = FALSE;     // 初始化已访问标记数组
    }
    for (v = 0; v < G.vexnum; ++v) {  // 从 v=0 开始遍历
        if (!visited[v]) {
            DFS(G, v);
        }
    }
}

以下面这个无向图为例:

其深度优先遍历的结果为 a b d e h c f g abdehcfg abdehcfg。

2. DFS 算法的性能分析

DFS 算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。

对于 n n n 个顶点 e e e 条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2) 的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。

对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。

3. 深度优先的生成树和生成森林

深度优先搜索会产生一棵深度优先生成树。当然,这是有条件的,即对连通图调用 DFS 才能产生深度优先生成树,否则产生的将是深度优先生成森林,如下图所示。基于邻接表存储的深度优先生成树是不唯一的。

二、广度优先遍历

广度优先遍历(Breadth First Search),又称为广度优先搜索,简称 BFS。

1. BFS 算法

如果说图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了。

广度优先搜索是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。

以下是广度优先遍历的代码:

c 复制代码
/* 邻接矩阵的广度遍历算法 */
void BFSTraverse(MGraph G) {
    int i, j;
    Queue Q;
    for (i = 0; i < G.numVertexes; i++) {
        visited[i] = FALSE;
    }
    InitQueue(&Q);              // 初始化一辅助用的队列
    for (i = 0; i < G.numVertexes; i++) {
        // 若是未访问过就处理
        if (!visited[i]) {
            visited[i] = TRUE;  // 设置当前访问过
            visit(i);           // 访问顶点
            EnQueue(&Q, i);     // 将此顶点入队列
            // 若当前队列不为空
            while (!QueueEmpty(Q)) {
                DeQueue(&Q, &i);    // 顶点 i 出队列
                // FirstNeighbor(G,v): 求图 G 中顶点 v 的第一个邻接点,若有则返回顶点号,否则返回 -1
                // NextNeighbor(G,v,w): 假设图 G 中顶点 w 是顶点 v 的一个邻接点,返回除 w 外顶点 v 的下一个邻接点
                for (j = FirstNeighbor(G, i); j >= 0; j = NextNeighbor(G, i, j)) {
                    // 检验 i 的所有邻接点
                    if (!visited[j]) {
                        visit(j);           // 访问顶点 j
                        visited[j] = TRUE;  // 访问标记
                        EnQueue(&Q, j);     // 顶点 j 入队列
                    }
                }
            }
        }
    }
}

以下面这个无向图为例:

其广度优先遍历的结果为 a b c d e f g h abcdefgh abcdefgh。

2. BFS 算法性能分析

无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列 Q Q Q, n n n 个顶点均需入队一次,在最坏的情况下,空间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。

采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),在搜索任一顶点的邻接点时,每条边至少访问一次,算法总的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),故算法总的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。

注意:图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同。因此,对于同样一个图,基于邻接矩阵的遍历所得到的 DFS 序列和 BFS 序列是唯一的,基于邻接表的遍历所得到的 DFS 序列和 BFS 序列是不唯一的。

三、图的遍历与图的连通性

图的遍历算法可以用来判断图的连通性。

对于无向图来说,若无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。

故在 BFSTraverse() 或 DFSTraverse() 中添加了第二个 for 循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点。对于无向图,上述两个函数调用 BFS(G,i) 或 DFS(G,i) 的次数等于该图的连通分量数;而对于有向图则不是这样,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分量和非强连通分量,非强连通分量一次调用 BFS(G,i) 或 DFS(G,i) 无法访问到该连通分量的所有顶点。

如下图所示为有向图的非强连通分量。

最小生成树

一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的 n − 1 n-1 n−1 条边,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。对于一个带权连通无向图 G = ( V , E ) G=(V,E) G=(V,E),生成树不同,其中边的权值之和最小的那棵生成树(构造连通网的最小代价生成树),称为 G G G 的最小生成树(Minimum-Spanning-Tree, MST)

构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:假设 G = ( V , E ) G=(V,E) G=(V,E) 是一个带权连通无向图, U U U 是顶点集 V V V 的一个非空子集。若 ( u , v ) (u,v) (u,v) 是一条具有最小权值的边,其中 u ∈ U , v ∈ V − U u\in U,v\in V-U u∈U,v∈V−U,则必存在一棵包含边 ( u , v ) (u,v) (u,v) 的最小生成树。

基于该性质的最小生成树算法主要有 Prim 算法和 Kruskal 算法,它们都基于贪心算法的策略。

下面介绍一个通用的最小生成树算法:

c 复制代码
GENERIC_MST(G) {
    T = NULL;
    while T 未形成一棵生成树
        do 找到一条最小代价边 (u,v) 并且加入 T 后不会产生回路;
        T = T \cup {(u,v)};
}

通用算法每次加入一条边以逐渐形成一棵生成树,下面介绍两种实现上述通用算法的途径。

一、普里姆(Prim)算法

Prim 算法构造最小生成树的过程如下图所示。初始时从图中任取一顶点(如顶点 1)加入树 T T T,此时树中只含有一个顶点,之后选择一个与当前 T T T 中顶点集合距离最近的顶点,并将该顶点和相应的边加入 T T T,每次操作后 T T T 中的顶点数和边数都增 1。以此类推,直至图中所有的顶点都并入 T T T,得到的 T T T 就是最小生成树。此时 T T T 中必然有 n − 1 n-1 n−1 条边。

该算法可概括为:从一个顶点出发,在保证不形成回路的前提下,每找到并添加一条最短的边,就把当前形成的连通分量当做一个整体看待,然后重复"找最短的边并添加"的操作。

Prim 算法的步骤如下:

假设 G = ( V , E ) G=(V,E) G=(V,E) 是连通图,其最小生成树 T = ( U , E T ) T=(U,E_T) T=(U,ET), E T E_T ET 是最小生成树中边的集合。

  • 初始化 :向空树 T = ( U , E T ) T=(U,E_T) T=(U,ET) 中添加图 G = ( V , E ) G=(V,E) G=(V,E) 的任一顶点 u 0 u_0 u0,使 U = { u 0 } U=\{u_0\} U={u0}, E T = ∅ E_T=\varnothing ET=∅。
  • 循环 (重复下列操作直至 U = V U=V U=V):从图 G G G 中选择满足 { ( u , v ) ∣ u ∈ U , v ∈ V − U } \{(u,v)\mid u\in U,v\in V-U\} {(u,v)∣u∈U,v∈V−U} 且具有最小权值的边 ( u , v ) (u,v) (u,v),加入树 T T T,置 U = U ∪ { v } U=U\cup\{v\} U=U∪{v}, E T = E T ∪ { ( u , v ) } E_T=E_T\cup\{(u,v)\} ET=ET∪{(u,v)}。

为描述该算法,先构造一个邻接矩阵,如下图的右图所示。

于是普里姆(Prim)算法代码如下,左侧数字为行号。其中 INFINITY 为权值极大值,不妨设 65535,MAXVEX 为顶点个数最大值,此处大于等于 9 即可。

c 复制代码
/* Prim 算法生成最小生成树 */
void MiniSpanTree_Prim(MGraph G) {
    int min, i, j, k;
    int adjvex[MAXVEX];     // 保存相关顶点下标
    int lowcost[MAXVEX];    // 保存相关顶点间边的权值
    lowcost[0] = 0;         // 初始化第一个权值为 0,即 v0 加入生成树
    // lowcost 的值为 0,在这里就是此下标的顶点已经加入生成树
    adjvex[0] = 0;          // 初始化第一个顶点下标为 0
    for (i = 1; i < G.numVertexes; i++) {
        lowcost[i] = G.arc[0][i]; // 将 v0 顶点与之组成边的权值存入数组
        adjvex[i] = 0;            // 初始化都为 v0 的下标
    }
    for (i = 1; i < G.numVertexes; i++) {
        min = INFINITY;     // 初始化最小权值为 \infty,通常设置一个不可能的很大的数字
        j = 1; k = 0;
        // 循环全部顶点
        while (j < G.numVertexes) {
            // 如果权值不为 0 且权值小于 min
            if (lowcost[j] != 0 && lowcost[j] < min) {
                min = lowcost[j];   // 则让当前权值成为最小值
                k = j;              // 将当前最小值的下标存入 k
            }
            j++;
        }
        printf("(%d, %d)", adjvex[k], k);  // 打印当前顶点边中权值的最小边
        for (j = 1; j < G.numVertexes; j++) {
            // 若下标为 k 顶点各边权值小于此前这些顶点未被加入生成树权值
            if (lowcost[j] != 0 && G.arc[k][j] < lowcost[j]) {
                lowcost[j] = G.arc[k][j];   // 将较小权值存入 lowcost
                adjvex[j] = k;              // 将下标为 k 的顶点存入 adjvex
            }
        }
    }
}

由算法代码中的循环嵌套可得知此算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。

二、克鲁斯卡尔(Kruskal)算法

与 Prim 算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。

Kruskal 算法构造最小生成树的过程如下图所示。初始时为只有 n n n 个顶点而无边的非连通图 T = ( V , ∅ ) T=(V,\varnothing) T=(V,∅),每个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在 T T T 中不同的连通分量上,则将此边加入 T T T,否则舍弃此边而选择下一条权值最小的边。以此类推,直至 T T T 中所有顶点都在一个连通分量上。

算法思路:可以直接以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路而已。此时就用到了图的存储结构中的边集数组结构。以下是 edge 边集数组结构的定义代码:

c 复制代码
/* 对边集数组 Edge 结构的定义 */
typedef struct {
    int begin;
    int end;
    int weight;
} Edge;

将下面左图的邻接矩阵通过程序转化为右图的边集数组,并且对它们按权值从小到大排序。

于是 Kruskal 算法代码如下,左侧数字为行号。其中 MAXEDGE 为边数量的极大值,此处大于等于 15 即可,MAXVEX 为顶点个数最大值,此处大于等于 9 即可。

c 复制代码
/* Kruskal 算法生成最小生成树 */
void MiniSpanTree_Kruskal(MGraph G) {
    int i, n, m;
    Edge edges[MAXEDGE];    // 定义边集数组
    int parent[MAXVEX];     // 定义一数组用来判断边与边是否形成环路
    /* 此处省略将邻接矩阵 G 转化为边集数组 edges 并按照权由小到大排序的代码 */
    for (i = 0; i < G.numVertexes; i++) {
        parent[i] = 0;      // 初始化数组为 0
    }
    for (i = 0; i < G.numEdges; i++) {
        n = Find(parent, edges[i].begin);
        m = Find(parent, edges[i].end);
        /* 假如 n 与 m 不等,说明此边没有与现有生成树形成环路 */
        if (n != m) {
            /* 将此边的结尾顶点放入下标为起点的 parent 中
               表示此顶点已经在生成树集合中 */
            parent[n] = m;
            printf("(%d, %d, %d)", edges[i].begin,
                   edges[i].end, edges[i].weight);
        }
    }
}

/* 查找连线顶点的尾部下标 */
int Find(int *parent, int f) {
    while (parent[f] > 0) {
        f = parent[f];
    }
    return f;
}

此算法的 Find 函数由边数 e e e 决定,时间复杂度为 O ( log ⁡ e ) O(\log e) O(loge),而外面有一个 for 循环 e e e 次。所以克鲁斯卡尔算法的时间复杂度为 O ( e log ⁡ e ) O(e\log e) O(eloge)。

对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。

最短路径

在网图和非网图中,最短路径的含义是不同的。由于非网图没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径是指两顶点之间经过的边上权值之和最少的路径,并且称路径上的第一个顶点是源点,最后一个顶点是终点。

一、迪杰斯特拉(Dijkstra)算法

Dijkstra 算法用于构建单源点的最短路径------即图中某个点到任何其他点的距离都是最短的。例如,构建地图应用时查找自己的坐标离某个地标的最短距离。可以用于有向图,但是不能存在负权值。

以上图为例,Dijkstra 算法并不是一下子求出了 v 0 v_0 v0 到 v 8 v_8 v8 的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到结果。

Dijkstra 算法设置一个集合 S S S 记录已求得的最短路径的顶点。

在构造的过程中还设置了一个辅助数组:

  • d i s t \mathrm{dist}\\, dist\[\]:记录从源点 v 0 v_0 v0 到其他各顶点当前的最短路径长度,它的初态为:若从 v 0 v_0 v0 到 v i v_i vi 有弧,则 d i s t i \mathrm{dist}i disti 为弧上的权值;否则置 d i s t i \mathrm{dist}i disti 为 ∞ \infty ∞。

例如,对图 6.17 中的图应用 Dijkstra 算法求从顶点 1 出发至其余顶点的最短路径的过程,如表 6.1 所示。算法执行过程的说明如下。

  • 初始化 :集合 S S S 初始为 { v 1 } \{v_1\} {v1}, v 1 v_1 v1 可达 v 2 v_2 v2 和 v 5 v_5 v5, v 1 v_1 v1 不可达 v 3 v_3 v3 和 v 4 v_4 v4,因此 d i s t \mathrm{dist}\\, dist\[\] 数组各元素的初值依次设置为 d i s t 2 = 10 \mathrm{dist}2=10 dist2=10, d i s t 3 = ∞ \mathrm{dist}3=\infty dist3=∞, d i s t 4 = ∞ \mathrm{dist}4=\infty dist4=∞, d i s t 5 = 5 \mathrm{dist}5=5 dist5=5。
  • 第一轮 :选出最小值 d i s t 5 \mathrm{dist}5 dist5,将顶点 v 5 v_5 v5 并入集合 S S S,即此时已找到 v 1 v_1 v1 到 v 5 v_5 v5 的最短路径。当 v 5 v_5 v5 加入 S S S 后,从 v 1 v_1 v1 到集合 S S S 中可达顶点的最短路径长度可能会产生变化。因此需要更新 d i s t \mathrm{dist}\\, dist\[\] 数组。 v 5 v_5 v5 可达 v 2 v_2 v2,因 v 1 → v 5 → v 2 v_1\to v_5\to v_2 v1→v5→v2 的距离 8 比 d i s t 2 = 10 \mathrm{dist}2=10 dist2=10 小,更新 d i s t 2 = 8 \mathrm{dist}2=8 dist2=8; v 5 v_5 v5 可达 v 3 v_3 v3, v 1 → v 5 → v 3 v_1\to v_5\to v_3 v1→v5→v3 的距离 14,更新 d i s t 3 = 14 \mathrm{dist}3=14 dist3=14; v 5 v_5 v5 可达 v 4 v_4 v4, v 1 → v 5 → v 4 v_1\to v_5\to v_4 v1→v5→v4 的距离 7,更新 d i s t 4 = 7 \mathrm{dist}4=7 dist4=7。
  • 第二轮 :选出最小值 d i s t 4 \mathrm{dist}4 dist4,将顶点 v 4 v_4 v4 并入集合 S S S。继续更新 d i s t \mathrm{dist}\\, dist\[\] 数组。 v 4 v_4 v4 不可达 v 2 v_2 v2, d i s t 2 \mathrm{dist}2 dist2 不变; v 4 v_4 v4 可达 v 3 v_3 v3, v 1 → v 5 → v 4 → v 3 v_1\to v_5\to v_4\to v_3 v1→v5→v4→v3 的距离 13 比 d i s t 3 \mathrm{dist}3 dist3 小,故更新 d i s t 3 = 13 \mathrm{dist}3=13 dist3=13。
  • 第三轮 :选出最小值 d i s t 2 \mathrm{dist}2 dist2,将顶点 v 2 v_2 v2 并入集合 S S S。继续更新 d i s t \mathrm{dist}\\, dist\[\] 数组。 v 2 v_2 v2 可达 v 3 v_3 v3, v 1 → v 5 → v 2 → v 3 v_1\to v_5\to v_2\to v_3 v1→v5→v2→v3 的距离 9 比 d i s t 3 \mathrm{dist}3 dist3 小,更新 d i s t 3 = 9 \mathrm{dist}3=9 dist3=9。
  • 第四轮 :选出唯一最小值 d i s t 3 \mathrm{dist}3 dist3,将顶点 v 3 v_3 v3 并入集合 S S S,此时全部顶点都已包含在 S S S 中。

显然,Dijkstra 算法也是基于贪心策略的。使用邻接矩阵或者带权的邻接表表示时,时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。

人们可能只希望找到从源点到某个特定顶点的最短路径,但这个问题和求解源点到其他所有顶点的最短路径一样复杂,时间复杂度也为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。

二、弗洛伊德(Floyd)算法

定义一个 n n n 阶方阵序列 A ( − 1 ) , A ( 0 ) , ... , A ( n − 1 ) A^{(-1)},A^{(0)},\dots,A^{(n-1)} A(−1),A(0),...,A(n−1),其中

A ( − 1 ) i j = a r c s i j , A^{(-1)}ij=\mathrm{arcs}ij, A(−1)ij=arcsij,

A ( k ) i j = min ⁡ { A ( k − 1 ) i j ,    A ( k − 1 ) i k + A ( k − 1 ) k j } , k = 0 , 1 , ... , n − 1. A^{(k)}ij=\min\{A^{(k-1)}ij,\;A^{(k-1)}ik+A^{(k-1)}kj\},\quad k=0,1,\dots,n-1. A(k)ij=min{A(k−1)ij,A(k−1)ik+A(k−1)kj},k=0,1,...,n−1.

式中, A ( k ) i j A^{(k)}ij A(k)ij 是从顶点 v i v_i vi 到 v j v_j vj、中间顶点的序号不大于 k k k 的最短路径的长度。Floyd 算法是一个迭代的过程,每迭代一次,在从 v i v_i vi 到 v j v_j vj 的最短路径上就多考虑了一个顶点;经过 n n n 次迭代后,所得到的 A ( n − 1 ) i j A^{(n-1)}ij A(n−1)ij 就是 v i v_i vi 到 v j v_j vj 的最短路径长度,即方阵 A ( n − 1 ) A^{(n-1)} A(n−1) 中就保存了任意一对顶点之间的最短路径长度。

上图所示为带权有向图 G G G 及其邻接矩阵。算法执行过程的说明如下。

  • 初始化 :方阵 A ( − 1 ) i j = a r c s i j A^{(-1)}ij=\mathrm{arcs}ij A(−1)ij=arcsij
  • 第一轮 :将 v 0 v_0 v0 作为中间顶点,对于所有顶点对 { i , j } \{i,j\} {i,j},如果有 A − 1 i j > A − 1 i 0 + A − 1 0 j A^{-1}ij>A^{-1}i0+A^{-1}0j A−1ij>A−1i0+A−10j,则将 A − 1 i j A^{-1}ij A−1ij 更新为 A − 1 i 0 + A − 1 0 j A^{-1}i0+A^{-1}0j A−1i0+A−10j。有 A − 1 2 1 > A − 1 2 0 + A − 1 0 1 = 11 A^{-1}21>A^{-1}20+A^{-1}01=11 A−121>A−120+A−101=11,更新 A − 1 2 1 = 11 A^{-1}21=11 A−121=11,更新后的方阵标记为 A ( 0 ) A^{(0)} A(0)。
  • 第二轮 :将 v 1 v_1 v1 作为中间顶点,继续监测全部顶点对 { i , j } \{i,j\} {i,j}。有 A ( 0 ) 0 2 > A ( 0 ) 0 1 + A ( 0 ) 1 2 = 10 A^{(0)}02>A^{(0)}01+A^{(0)}12=10 A(0)02>A(0)01+A(0)12=10,更新后的方阵标记为 A ( 1 ) A^{(1)} A(1)。
  • 第三轮 :将 v 2 v_2 v2 作为中间顶点,继续监测全部顶点对 { i , j } \{i,j\} {i,j}。有 A ( 1 ) 1 0 > A ( 1 ) 1 2 + A ( 1 ) 2 0 = 9 A^{(1)}10>A^{(1)}12+A^{(1)}20=9 A(1)10>A(1)12+A(1)20=9,更新后的方阵标记为 A ( 2 ) A^{(2)} A(2)。此时 A ( 2 ) A^{(2)} A(2) 中保存的就是任意顶点对的最短路径长度。

应用 Floyd 算法求所有顶点之间的最短路径长度的过程如下表所示。

从这个表中,可以发现一些规律:

可以看出,矩阵中,每一步中红线划掉的部分都不用考虑计算,只需要计算红线外的部分,节省了计算量。

Floyd 算法的时间复杂度为 O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)。不过由于其代码很紧凑,且并不包含其他复杂的数据结构,因此隐含的常数系数是很小的,即使对于中等规模的输入来说,它仍然是相当有效的。

Floyd 算法允许图中有带负权值的边,但不允许有包含带负权值的边组成的回路。Floyd 算法同样适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。

也可以用单源最短路径算法来解决每对顶点之间的最短路径问题。轮流将每个顶点作为源点,并且在所有边权值均非负时,运行一次 Dijkstra 算法,其时间复杂度为 O ( ∣ V ∣ 2 ) ⋅ ∣ V ∣ = O ( ∣ V ∣ 3 ) O(|V|^2)\cdot|V|=O(|V|^3) O(∣V∣2)⋅∣V∣=O(∣V∣3)。

拓扑排序

一、定义

在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,称为 AOV 网(Activity On Vertex Network)。

若用 DAG 图(有向无环图)表示一个工程,其顶点表示活动,用有向边 ⟨ V i , V j ⟩ \langle V_i,V_j\rangle ⟨Vi,Vj⟩ 表示活动 V i V_i Vi 必须先于活动 V j V_j Vj 进行的这样一种关系。在 AOV 网中,活动 V i V_i Vi 是活动 V j V_j Vj 的直接前驱,活动 V j V_j Vj 是活动 V i V_i Vi 的直接后继,这种前驱和后继关系具有传递性,且任何活动 V i V_i Vi 不能以它自己作为自己的前驱或后继。

设 G = ( V , E ) G=(V,E) G=(V,E) 是一个具有 n n n 个顶点的有向图, V V V 中的顶点序列 V 1 , V 2 , ... , V n V_1,V_2,\dots,V_n V1,V2,...,Vn,满足若从顶点 V i V_i Vi 到 V j V_j Vj 有一条路径,则在顶点序列中顶点 V i V_i Vi 必在顶点 V j V_j Vj 之前。则称这样的顶点序列为一个拓扑序列。

所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。每个 AOV 网都有一个或多个拓扑排序序列。

二、算法

对一个 AOV 网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:

  1. 从 AOV 网中选择一个没有前驱的顶点并输出。
  2. 从网中删除该顶点和所有以它为起点的有向边。
  3. 重复 1 和 2 直到当前的 AOV 网为空或当前网中不存在无前驱的顶点为止。如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是 AOV 网。

上图所示为拓扑排序过程的示例。每一轮选择一个入度为 0 的顶点并输出,然后删除该顶点和所有以它为起点的有向边,最后得到拓扑排序的结果为 { 1 , 2 , 4 , 3 , 5 } \{1,2,4,3,5\} {1,2,4,3,5}。

拓扑排序算法的实现如下:

c 复制代码
bool TopologicalSort(Graph G) {
    InitStack(S);           // 初始化栈,存储入度为 0 的顶点
    for (int i = 0; i < G.vexnum; i++) {
        if (indegree[i] == 0) {
            Push(S, i);     // 将所有入度为 0 的顶点进栈
        }
    }
    int count = 0;          // 计数,记录当前已经输出的顶点数
    while (!IsEmpty(S)) {   // 栈不空,则存在入度为 0 的顶点
        Pop(S, i);          // 顶点元素出栈
        printf("%d ", i);   // 输出顶点 i
        count++;
        for (p = G.vertices[i].firstarc; p; p = p->nextarc) {
            // 将所有 i 指向的顶点的入度减 1,并且将入度减为 0 的顶点压入栈 S
            v = p->adjvex;
            if (!--indegree[v]) {
                Push(S, v); // 入度为 0,则入栈
            }
        }
    }
    if (count < G.vexnum) {
        return false;       // 输出顶点少了,有向图中有回路,排序失败
    } else {
        return true;        // 拓扑排序成功
    }
}

由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。

此外,利用深度优先遍历也可实现拓扑排序。

注意 :用拓扑排序算法处理 AOV 网时,应注意以下问题:

① 入度为零的顶点,即没有前驱活动的或前驱活动都已经完成的顶点,工程可以从这个顶点所代表的活动开始或继续。

② 若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各个顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序的结果是唯一的。

③ 由于 AOV 网中各顶点的地位平等,每个顶点编号是人为的,因此可以按拓扑排序的结果重新编号,生成 AOV 网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑序列;反之则不一定成立。

关键路径

一、定义

拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时还需要解决工程完成需要的最短时间问题。

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称 AOE 网。AOE 网和 AOV 网都是有向无环图,不同之处在于它们的边和顶点所代表的含义是不同的,AOE 网中的边有权值;而 AOV 网中的边无权值,仅表示顶点之间的前后关系。

AOE 网具有以下两个性质:

  1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
  2. 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。

如上图的 AOE 网,在 AOE 网中仅有一个入度为 0 的顶点,称为开始顶点(源点) ,它表示整个工程的开始;网中也仅存在一个出度为 0 的顶点,称为结束顶点(汇点) ,它表示整个工程的结束。把路径上各个活动所持续的时间之和称为路径长度 ,从源点到汇点具有最大长度的路径叫关键路径 ,在关键路径上的活动叫关键活动

完成整个工程的最短时间就是关键路径的长度,即关键路径上各活动花费开销的总和。这是因为关键活动影响了整个工程的时间,即若关键活动不能按时完成,则整个工程的完成时间就会延长。因此,只要找到了关键活动,就找到了关键路径,也就可以得出最短完成时间。

二、算法

在分析算法之前,需要了解几个重要的参数:

  1. 事件的最早发生时间 v e ve ve :即顶点 V k V_k Vk 的最早发生时期。
  2. 事件的最晚发生时间 v l vl vl :即顶点 V k V_k Vk 的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
  3. 活动的最早开始时间 e e e :即弧 a i a_i ai 的最早发生时间。
  4. 活动的最晚开始时间 l l l :即弧 a i a_i ai 的最晚发生时间,也就是不推迟工期的最晚开工时间。
  5. 一个活动 a i a_i ai 的最迟开始时间 l ( i ) l(i) l(i) 和其最早开始时间 e ( i ) e(i) e(i) 的差额 d ( i ) = l ( i ) − e ( i ) d(i)=l(i)-e(i) d(i)=l(i)−e(i) :它是指该活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动 a i a_i ai 可以拖延的时间。若一个活动的时间余量为零,则说明该活动必须要如期完成,否则就会拖延整个工程的进度,所以称 l ( i ) − e ( i ) = 0 l(i)-e(i)=0 l(i)−e(i)=0 即 l ( i ) = e ( i ) l(i)=e(i) l(i)=e(i) 的活动 a i a_i ai 是关键活动。

求关键路径的算法步骤如下:

  1. 从源点出发,令 v e ( 源点 ) = 0 ve(\text{源点})=0 ve(源点)=0,按拓扑排序 求其余顶点的最早发生时间 v e (   ) ve(\,) ve()。
  2. 从汇点出发,令 v l ( 汇点 ) = v e ( 汇点 ) vl(\text{汇点})=ve(\text{汇点}) vl(汇点)=ve(汇点),按逆拓扑排序 求其余顶点的最迟发生时间 v l (   ) vl(\,) vl()。
  3. 根据各顶点的 v e (   ) ve(\,) ve() 值求所有弧的最早开始时间 e (   ) e(\,) e()。
  4. 根据各顶点的 v l (   ) vl(\,) vl() 值求所有弧的最迟开始时间 l (   ) l(\,) l()。
  5. 求 AOE 网中所有活动的差额 d (   ) d(\,) d(),找出所有 d (   ) = 0 d(\,)=0 d()=0 的活动构成关键路径

上图所示为求解关键路径的过程,简单说明如下:

  1. 求 v e (   ) ve(\,) ve():初始 v e ( 1 ) = 0 ve(1)=0 ve(1)=0,在拓扑排序输出顶点的过程中,求得 v e ( 2 ) = 3 ve(2)=3 ve(2)=3, v e ( 3 ) = 2 ve(3)=2 ve(3)=2, v e ( 4 ) = max ⁡ { v e ( 2 ) + 2 ,   v e ( 3 ) + 4 } = max ⁡ { 5 , 6 } = 6 ve(4)=\max\{ve(2)+2,\,ve(3)+4\}=\max\{5,6\}=6 ve(4)=max{ve(2)+2,ve(3)+4}=max{5,6}=6, v e ( 5 ) = 6 ve(5)=6 ve(5)=6, v e ( 6 ) = max ⁡ { v e ( 5 ) + 1 ,   v e ( 4 ) + 0 ,   v e ( 3 ) + 3 } = max ⁡ { 7 , 8 , 5 } = 8 ve(6)=\max\{ve(5)+1,\,ve(4)+0,\,ve(3)+3\}=\max\{7,8,5\}=8 ve(6)=max{ve(5)+1,ve(4)+0,ve(3)+3}=max{7,8,5}=8。
  2. 求 v l (   ) vl(\,) vl():初始 v l ( 6 ) = 8 vl(6)=8 vl(6)=8,在逆拓扑排序出栈过程之中,求得 v l ( 5 ) = 7 vl(5)=7 vl(5)=7, v l ( 4 ) = 6 vl(4)=6 vl(4)=6, v l ( 3 ) = min ⁡ { v l ( 4 ) − 4 ,   v l ( 6 ) − 3 } = min ⁡ { 2 , 5 } = 2 vl(3)=\min\{vl(4)-4,\,vl(6)-3\}=\min\{2,5\}=2 vl(3)=min{vl(4)−4,vl(6)−3}=min{2,5}=2, v l ( 2 ) = min ⁡ { v l ( 5 ) − 3 ,   v l ( 4 ) − 2 } = min ⁡ { 4 , 4 } = 4 vl(2)=\min\{vl(5)-3,\,vl(4)-2\}=\min\{4,4\}=4 vl(2)=min{vl(5)−3,vl(4)−2}=min{4,4}=4, v l ( 1 ) vl(1) vl(1) 必然为 0 而无需再求。
  3. 弧的最早开始时间 e (   ) e(\,) e() 等于该弧的起点的顶点的 v e (   ) ve(\,) ve(),求得结果如上表所示。
  4. 弧的最迟开始时间 l ( i ) l(i) l(i) 等于该弧的终点的顶点的 v l (   ) vl(\,) vl() 减去该弧持续的时间,求得结果如上表所示。
  5. 根据 l ( i ) − e ( i ) = 0 l(i)-e(i)=0 l(i)−e(i)=0 的关键活动,得到的关键路径为 ( v 1 , v 3 , v 4 , v 6 ) (v_1,v_3,v_4,v_6) (v1,v3,v4,v6)。

注意 :对于关键路径,需要注意以下几点:

① 关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能会变成非关键活动。

② 网中的关键路径并不唯一,且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

总结

图是计算机科学中非常常用的一类数据结构,同时也是最复杂的数据结构之一,对它的学习,涉及到顺序表、链表、栈、队列、树等之前学的几乎所有数据结构,所以学习图之前要对这几种数据结构都要有所了解才行。


数据结构:查找(Search)详解

2024-10-16 15:46:15

一、查找知识框架

二、查找概论

2.1 查找基础术语定义

  1. 查找(Searching)
    根据给定数值,在查找表中定位关键字与给定值相等的数据元素或记录的运算过程。
  2. 查找表(Search Table)
    由同类型数据元素或记录构成的有限集合。
  3. 关键字(Key)
    可唯一标识单个数据元素的数据项取值。基于关键字执行查找运算时,运算结果具备唯一性。以学生信息集合为例,学号字段的取值可作为关键字完成唯一元素定位。
  4. 静态查找表(Static Search Table)
    仅支持查找类运算的查找表,不执行元素的插入与删除操作。
    可执行运算:
    (1)判定指定数据元素是否存在于查找表内;
    (2)读取指定数据元素的全部属性信息。
  5. 动态查找表(Dynamic Search Table)
    查找过程中可同步完成元素插入、删除操作的查找表。
    可执行运算:
    (1)查找失败时,将目标元素插入查找表;
    (2)查找命中时,删除表内已存在的目标元素。
  6. 平均查找长度
    单次查找长度定义为完成本次查找所需进行的关键字比较次数;平均查找长度为全部查找场景下关键字比较次数的数学期望,数学表达式:
    ( A S L ) = ∑ _ i = 1 n P _ i C _ i \mathrm{(\mathrm{ASL})} = \sum\_{i=1}^{n} P\_i C\_i (ASL)=∑_i=1nP_iC_i
    式中:
  • n n n:查找表内数据元素总个数;
  • P _ i P\_i P_i:查找第 i i i 个元素的概率,等概率假设下 P _ i = 1 n P\_i = \dfrac{1}{n} P_i=n1;
  • C _ i C\_i C_i:定位第 i i i 个元素所需的关键字比较次数。

平均查找长度为衡量查找算法时间性能的量化指标。

三、顺序表查找

3.1 算法定义

顺序查找(Sequential Search)亦称为线性查找,属于基础查找算法。算法逻辑为从线性表一端开始,逐一对元素关键字与目标值进行等值判定:若匹配成功则返回元素在线性表中的存储位置;若遍历至表尾仍未匹配,则判定为查找失败。

3.2 带哨兵实现代码(C语言)

复制代码
/* 带哨兵优化的顺序查找算法 */
int Sequential_Search(int *a, int n, int key)
{
    int i;
    a[0] = key;    /* 将数组下标0位置设置为哨兵关键字 */
    i = n;         /* 从数组尾部向前遍历 */
    while (a[i] != key)
    {
        i--;
    }
    return i;      /* 返回值为0代表查找失败 */
}

哨兵机制可消除每次循环内的下标越界判定语句,在大数据量场景下可降低循环分支开销。该算法时间复杂度为 ( O ( n ) ) (O(n)) (O(n))。

四、有序表查找

有序表查找的前置约束为:线性表采用顺序存储结构,且表内元素关键字按数值升序排列。包含折半查找、插值查找、斐波那契查找三类实现方案。

4.1 折半查找(Binary Search)

4.1.1 算法原理

每次选取有序表中间位置记录作为比较对象:

  1. 若目标值与中间记录关键字相等,查找命中;
  2. 若目标值小于中间关键字,在左半子区间递归执行查找;
  3. 若目标值大于中间关键字,在右半子区间递归执行查找;
    持续迭代直至区间为空,判定查找失败。
4.1.2 C语言实现代码
复制代码
int Binary_Search(SeqList L, ElemType key)
{
    int low = 0, high = L.length - 1, mid;
    while (low <= high)
    {
        mid = (low + high) / 2;    /* 计算区间中间下标 */
        if (L.elem[mid] == key)
        {
            return mid;            /* 查找成功返回下标 */
        }
        else if (L.elem[mid] > key)
        {
            high = mid - 1;        /* 收缩至左半区间 */
        }
        else
        {
            low = mid + 1;         /* 收缩至右半区间 */
        }
    }
    return -1;    /* 查找失败返回标记值 */
}
4.1.3 性能说明

折半查找的判定树为完全二叉树形态, n n n 个结点二叉树的深度为 ⌈ log ⁡ _ 2 ( n + 1 ) ⌉ \lceil \log\_2(n+1) \rceil ⌈log_2(n+1)⌉,算法时间复杂度为 O ( log ⁡ _ 2 n ) O(\log\_2 n) O(log_2n)。该算法依赖顺序存储的随机访问能力,无法适配链式存储结构。

4.2 插值查找(Interpolation Search)

4.2.1 算法改进逻辑

折半查找取区间中点的公式可等价变形为:

m i d = l o w + h i g h − l o w 2 \mathrm{mid} = \mathrm{low} + \frac{\mathrm{high}-\mathrm{low}}{2} mid=low+2high−low

插值查找将固定系数 1 2 \dfrac{1}{2} 21 替换为基于关键字分布比例的动态系数,下标计算公式:

KaTeX parse error: Undefined control sequence: \ at position 87: ...{\\mathrm{key}-a\\̲\[̲\\mathrm{low}\\}...

4.2.2 核心代码片段
复制代码
/* 插值查找下标计算语句 */
mid = low + (key - L.elem[low]) / (L.elem[high] - L.elem[low]) * (high - low);
4.2.3 性能说明

插值查找时间复杂度同样为 O ( log ⁡ _ 2 n ) O(\log\_2 n) O(log_2n),在表长较大、关键字数值均匀分布的有序表中,平均查找次数优于折半查找;若关键字分布极度离散(如 0 , 1 , 2 , 2000 , 2001 , ... , 999998 , 999999 {0,1,2,2000,2001,\dots,999998,999999} 0,1,2,2000,2001,...,999998,999999),算法效率会出现退化。

4.3 斐波那契查找(Fibonacci Search)

4.3.1 算法原理

基于黄金分割比例完成区间分割,依托斐波那契数列 F F F 划分查找区间。

斐波那契数组定义图示:

4.3.2 C语言完整实现
复制代码
/* 斐波那契查找算法 */
int Fibonacci_Search(int *a, int n, int key)
{
    int low, high, mid, i, k;
    low = 0;
    high = n;
    k = 0;
    /* 定位n在斐波那契数列中的下标位置 */
    while (n > F[k] - 1)
    {
        k++;
    }
    /* 数组尾部补齐元素,防止下标越界 */
    for (i = n; i < F[k]; i++)
    {
        a[i] = a[n];
    }
    while (low <= high)
    {
        mid = low + F[k-1] - 1;    /* 斐波那契分割下标 */
        if (key < a[mid])
        {
            high = mid - 1;
            k = k - 1;
        }
        else if (key > a[mid])
        {
            low = mid + 1;
            k = k - 2;
        }
        else
        {
            /* 剔除补齐的冗余下标 */
            return (mid <= n) ? mid : n;
        }
    }
    return -1;
}
4.3.3 性能分析

区间分割仅通过加减法完成计算,无浮点运算与除法运算。算法时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn),平均执行效率优于折半查找;最坏查找场景下(目标值始终位于左半长区间),性能弱于折半查找。

区间分割逻辑示意图:

五、线性索引查找

海量数据集的磁盘存储场景中,索引结构用于压缩查找定位开销。索引结构分为线性索引、树形索引、多级索引三类,线性索引将索引项组织为线性表结构,也称为索引表,包含稠密索引、分块索引、倒排索引三类实现形式。

5.1 稠密索引

稠密索引为数据集每一条记录单独建立一条索引项,索引项按关键字升序排列。

稠密索引结构示意图:

索引表有序可直接采用有序表查找算法提升定位速度;缺陷为索引项数量与原始记录数量一致,数据集规模上亿时索引表占用存储空间过大,频繁磁盘IO会降低整体查找效率。

5.2 分块索引

5.2.1 分块有序约束

将原始数据集划分为若干数据块,满足两条约束:

  1. 块内无序:单个数据块内部记录不要求关键字有序;
  2. 块间有序:后序全部数据块的关键字最小值大于前序全部数据块的关键字最大值。
5.2.2 索引项构成

每一个数据块对应一条索引项,索引项包含三个字段:

  1. 块最大关键字:记录当前块内关键字最大值,保障块间有序判定;
  2. 块长度:记录当前块包含的记录条数;
  3. 块首指针:指向数据块在存储区的起始地址。

分块索引整体结构示意图:

5.2.3 查找流程与数学推导

查找分为两个阶段:

  1. 在有序索引表内定位目标关键字所属数据块,可采用折半查找;
  2. 通过块首指针读取数据块,在块内执行顺序查找。

设索引查找平均长度为 L _ I L\_I L_I,块内查找平均长度为 L _ S L\_S L_S,分块查找平均查找长度:

( A S L ) = L _ I + L _ S \mathrm{(\mathrm{ASL})} = L\_I + L\_S (ASL)=L_I+L_S

令总记录数为 n n n,均匀划分为 b b b 块,单块记录数为 s s s,满足 b = n s b = \dfrac{n}{s} b=sn。

  1. 索引表与块内均使用顺序查找时:
    ( A S L ) = b + 1 2 + s + 1 2 = s 2 + 2 s + n 2 s \mathrm{(\mathrm{ASL})} = \frac{b+1}{2} + \frac{s+1}{2} = \frac{s^2 + 2s + n}{2s} (ASL)=2b+1+2s+1=2ss2+2s+n
    当 s = n s = \sqrt{n} s=n 时, ( A S L ) \mathrm{(\mathrm{ASL})} (ASL) 取得极小值 n + 1 \sqrt{n}+1 n +1。
  2. 索引表使用折半查找、块内顺序查找时:
    ( A S L ) = ⌈ log ⁡ _ 2 ( b + 1 ) ⌉ + s + 1 2 \mathrm{(\mathrm{ASL})} = \lceil \log\_2(b+1) \rceil + \frac{s+1}{2} (ASL)=⌈log_2(b+1)⌉+2s+1

5.3 倒排索引

5.3.1 定义

以属性字段作为次关键字建立索引项,索引项存储次关键字与对应记录编号集合,通过次关键字反向定位原始记录,该结构称为倒排索引(Inverted Index)。索引项由次关键字、记录号表两个字段组成。

5.3.2 实例说明

两条文本记录:

  1. Books and friends should be few but good.
  2. A good book is a good friend.

忽略大小写后构建单词-文档编号倒排表:

检索时对次关键字执行有序查找,直接取出关联的文档编号完成结果返回。倒排索引为搜索引擎文本检索的底层基础结构。

六、二叉排序树与平衡二叉树

6.1 二叉排序树(Binary Sort Tree)

6.1.1 定义

二叉排序树为空树,或满足如下三条约束的二叉树:

  1. 左子树非空时,左子树全部结点关键字小于根结点关键字;
  2. 右子树非空时,右子树全部结点关键字大于根结点关键字;
  3. 左、右子树各自均为二叉排序树。

对二叉排序树执行中序遍历,可得到关键字严格升序的序列。

二叉排序树示例:

6.1.2 存储结构定义
复制代码
/* 二叉排序树二叉链表结点结构 */
typedef struct BiTNode
{
    int data;
    struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
6.1.3 核心运算实现
(1)查找运算
复制代码
/* 递归查找二叉排序树,f为双亲结点指针,p存储命中结点地址 */
bool SearchBST(BiTree T, int key, BiTree f, BiTree *p)
{
    if (!T)
    {
        *p = f;
        return false;
    }
    else if (key == T->data)
    {
        *p = T;
        return true;
    }
    else if (key < T->data)
    {
        return SearchBST(T->lchild, key, T, p);
    }
    else
    {
        return SearchBST(T->rchild, key, T, p);
    }
}
(2)插入运算
复制代码
/* 二叉排序树插入结点,关键字重复则拒绝插入 */
bool InsertBST(BiTree *T, int key)
{
    BiTree p, s;
    if (!SearchBST(*T, key, NULL, &p))
    {
        s = (BiTree)malloc(sizeof(BiTNode));
        s->data = key;
        s->lchild = s->rchild = NULL;
        if (!p)
        {
            *T = s;    /* 空树插入为根结点 */
        }
        else if (key < p->data)
        {
            p->lchild = s;
        }
        else
        {
            p->rchild = s;
        }
        return true;
    }
    return false;
}

批量建树调用示例:

复制代码
int i;
int a[10] = {62, 88, 58, 47, 35, 73, 51, 99, 37, 93};
BiTree T = NULL;
for (i = 0; i < 10; i++)
{
    InsertBST(&T, a[i]);
}

批量构建的二叉排序树形态:

(3)删除运算

删除分为三类场景:叶子结点删除、单分支结点删除、双分支结点删除。双分支结点需使用待删结点中序直接前驱或直接后继替换关键字,再删除替换结点。

外层调用函数:

复制代码
bool DeleteBST(BiTree *T, int key)
{
    if (!T)
        return false;
    if (key == (*T)->data)
        return Delete(T);
    else if (key < (*T)->data)
        return DeleteBST(&((*T)->lchild), key);
    else
        return DeleteBST(&((*T)->rchild), key);
}

底层结点释放与重接函数:

复制代码
bool Delete(BiTree *p)
{
    BiTree q, s;
    if ((*p)->rchild == NULL)
    {
        q = *p;
        *p = (*p)->lchild;
        free(q);
    }
    else if ((*p)->lchild == NULL)
    {
        q = *p;
        *p = (*p)->rchild;
        free(q);
    }
    else
    {
        q = *p;
        s = (*p)->lchild;
        /* 查找左子树最右结点(中序前驱) */
        while (s->rchild)
        {
            q = s;
            s = s->rchild;
        }
        (*p)->data = s->data;
        if (q != *p)
            q->rchild = s->lchild;
        else
            q->lchild = s->lchild;
        free(s);
    }
    return true;
}
6.1.4 性能特征

二叉排序树查找路径长度等于目标结点所在层数,查找性能完全依赖树的形态:

  1. 平衡形态下,深度接近完全二叉树,时间复杂度 O ( log ⁡ n ) O(\log n) O(logn);
  2. 关键字按有序序列插入会退化为单斜树,时间复杂度退化至 ( O ( n ) ) (O(n)) (O(n)),等价于顺序查找。

两种极端形态对比:

6.2 平衡二叉树(AVL树)

6.2.1 定义

平衡二叉树为附加高度平衡约束的二叉排序树,约束条件:

  1. 空树为合法平衡二叉树;
  2. 非空树的左、右子树均为平衡二叉树;
  3. 任意结点左子树与右子树的深度差值绝对值不大于 1 1 1。

定义平衡因子 ( B F ) \mathrm{(\mathrm{BF})} (BF) :结点左子树深度减去右子树深度。平衡二叉树所有结点平衡因子仅可取 − 1 、 0 、 1 -1、0、1 −1、0、1,任意结点平衡因子绝对值大于 1 1 1 则判定为失衡。

平衡因子标注示例:

6.2.2 查找性能

深度为 h h h 的平衡二叉树最少结点数递推公式:

{ n _ 0 = 0 n _ 1 = 1 n _ h = n _ h − 1 + n _ h − 2 + 1 \begin{cases} n\_0 = 0 \ n\1 = 1 \ n\h = n\{h-1} + n\{h-2} + 1 \end{cases} {n_0=0 n_1=1 n_h=n_h−1+n_h−2+1

n n n 个结点平衡二叉树最大深度为 O ( log ⁡ _ 2 n ) O(\log\_2 n) O(log_2n),查找时间复杂度稳定为 O ( log ⁡ _ 2 n ) O(\log\_2 n) O(log_2n)。

6.2.3 插入失衡调整规则

插入新结点后,定位距离插入点最近的首个失衡结点,对以该结点为根的最小不平衡子树执行旋转修正,分为四类旋转方案:

  1. LL 右单旋转
    结点左孩子的左子树插入新结点导致失衡,将左孩子右旋提升为子树根结点,原根结点作为其右孩子,左孩子原有右子树挂载至原根结点左分支。
  2. RR 左单旋转
    结点右孩子的右子树插入新结点导致失衡,将右孩子左旋提升为子树根结点,原根结点作为其左孩子,右孩子原有左子树挂载至原根结点右分支。
  3. LR 先左后右双旋转
    结点左孩子的右子树插入新结点,先对左孩子执行RR左旋,再对根结点执行LL右旋完成平衡修正。
  4. RL 先右后左双旋转
    结点右孩子的左子树插入新结点,先对右孩子执行LL右旋,再对根结点执行RR左旋完成平衡修正。

关键字序列 15 , 3 , 7 , 10 , 9 , 8 {15,3,7,10,9,8} 15,3,7,10,9,8 构建平衡二叉树全过程:

七、多路查找树

多路查找树单个结点可存储多条关键字、分支数量大于2,结点内关键字有序排列。常用子类包含2-3树、2-3-4树、B树、B+树,其中2-3树与2-3-4树为B树的特例形式。

2-3树结构示例:

7.1 m阶B树(多路平衡查找树)

7.1.1 严格定义

m m m 阶B树为空树,或满足如下五条约束的平衡 m m m叉树:

  1. 单个结点最多拥有 m m m 棵子树,对应最多存储 m − 1 m-1 m−1 个关键字;
  2. 根结点若非叶子结点,至少拥有2棵子树;
  3. 除根结点外所有非叶子结点,至少拥有 ⌈ m 2 ⌉ \lceil \dfrac{m}{2} \rceil ⌈2m⌉ 棵子树,对应最少存储 ⌈ m 2 ⌉ − 1 \lceil \dfrac{m}{2} \rceil -1 ⌈2m⌉−1 个关键字;
  4. 非叶子结点结构:关键字 K _ 1 < K _ 2 < ⋯ < K _ n K\_1<K\_2<\dots<K\n K_1<K_2<⋯<K_n,指针 P _ i − 1 P\{i-1} P_i−1 指向子树全部关键字小于 K _ i K\_i K_i,指针 P _ i P\_i P_i 指向子树全部关键字大于 K _ i K\_i K_i;
  5. 全部叶子结点处于同一层级,为查找失败的外部虚结点,不存储有效数据。

5阶B树实例:

7.1.2 外存适配原理

磁盘以固定大小页块为最小读写单元,B树阶数可与磁盘页尺寸匹配。高阶级B树单结点可容纳上千条关键字,树高被压缩至极小值,根结点常驻内存时,单次磁盘IO次数等于树的深度,大幅降低外存访问开销,为数据库、文件系统索引底层结构。

7.1.3 查找流程

查找分为磁盘结点读取、内存结点内有序查找两个阶段:从根结点逐层判定关键字区间,沿对应子树指针向下遍历;若在非叶子结点命中关键字则查找成功,若抵达叶子虚结点则查找失败。

B树查找路径示例:

7.1.4 插入规则
  1. 定位至最底层非叶子结点作为插入位置;
  2. 插入后关键字数量未超出 m − 1 m-1 m−1 上限则直接写入;
  3. 若溢出则执行结点分裂:以第 ⌈ m 2 ⌉ \lceil \dfrac{m}{2} \rceil ⌈2m⌉ 个关键字为界,左半部分保留在原结点,右半部分移入新结点,中间关键字上移至父结点;父结点若同步溢出则递归向上分裂,根结点分裂会使整棵树高度加1。

3阶B树结点分裂演示:

7.1.5 删除规则
  1. 待删关键字位于非底层结点时,使用其中序后继底层关键字替换,转为底层结点删除场景;
  2. 底层结点删除判定:
    • 删除后关键字数量不低于下限 ⌈ m 2 ⌉ − 1 \lceil \dfrac{m}{2} \rceil -1 ⌈2m⌉−1,直接删除;
    • 左/右兄弟结点关键字充足,执行父子关键字旋转借位;
    • 左右兄弟均无冗余关键字,将当前结点、兄弟结点、父结点分隔关键字三者合并为单个结点,父结点关键字减少后若不满足下限则递归向上合并。

7.2 m阶B+树

7.2.1 与B树核心差异
  1. 拥有 n n n 棵子树的结点存储 n n n 个关键字;
  2. 全部原始记录关键字与存储指针仅保存在叶子结点,叶子结点按关键字升序通过指针链表串联;
  3. 上层非叶子结点仅存储子树区间最大值关键字,作为索引导航层;
  4. 关键字数量约束:非根非叶子结点 ⌈ m 2 ⌉ ≤ n ≤ m \lceil \dfrac{m}{2} \rceil \le n \le m ⌈2m⌉≤n≤m,根结点 1 ≤ n ≤ m 1 \le n \le m 1≤n≤m。

4阶B+树完整结构:

7.2.2 应用特性

叶子结点链表结构可高效完成区间范围检索,为关系型数据库索引的主流实现方案;插入、删除操作仅在叶子结点执行,上层索引结点仅做区间更新。

八、散列表(哈希表)查找

8.1 基础概念

  1. 散列表 :通过映射函数直接建立关键字与存储地址一一对应关系的存储结构,可规避关键字逐次比较完成定位。地址映射公式:
    存储地址 = f ( key ) \text{存储地址} = f(\text{key}) 存储地址=f(key)
  2. 散列函数(哈希函数) :关键字到存储地址的映射规则 f f f;
  3. 冲突:不同关键字经散列函数计算得到相同存储地址,发生冲突的关键字互称为同义词;
  4. 理想状态下散列表查找时间复杂度为 ( O ( 1 ) ) (O(1)) (O(1))。

8.2 常用散列函数构造方法

8.2.1 直接定址法

采用关键字线性映射作为地址:

H ( k e y ) = k e y 或 H ( k e y ) = a ⋅ k e y + b H(\mathrm{key}) = \mathrm{key} \quad \text{或} \quad H(\mathrm{key}) = a\cdot \mathrm{key}+b H(key)=key或H(key)=a⋅key+b

无冲突产生,适用于关键字连续分布场景。

8.2.2 数字分析法

提取关键字中分布均匀的若干位作为散列地址,适用于已知关键字全集、关键字位数较多的场景。

8.2.3 平方取中法

对关键字做平方运算,截取结果中间若干位作为散列地址,适用于关键字分布未知、位数适中的场景。

8.2.4 除留余数法

工程最常用方案,设表长为 m m m,选取不大于 m m m 的最大质数 p p p:

H ( k e y ) = k e y   m o d   p , p ≤ m H(\mathrm{key}) = \mathrm{key} \bmod p,\quad p \le m H(key)=keymodp,p≤m

可对关键字预处理(折叠、平方取中)后再取模,降低冲突概率。

8.2.5 随机数法

H ( k e y ) = r a n d o m ( k e y ) H(\mathrm{key}) = \mathrm{random}(\mathrm{key}) H(key)=random(key)

适用于关键字长度不统一的场景。

8.3 冲突处理方案

8.3.1 开放定址法

冲突后按增量序列向后探测空闲地址,通用公式:

H _ i ( k e y ) = ( H ( k e y ) + d _ i )   m o d   m H\_i(\mathrm{key}) = \big(H(\mathrm{key}) + d\_i\big) \bmod m H_i(key)=(H(key)+d_i)modm

  • 线性探测法: d _ i = 0 , 1 , 2 , ... , m − 1 d\_i = 0,1,2,\dots,m-1 d_i=0,1,2,...,m−1,易产生数据堆积;
  • 平方探测法: d _ i = 0 2 , 1 2 , − 1 2 , 2 2 , − 2 2 ... d\_i = 0^2,1^2,-1^2,2^2,-2^2\dots d_i=02,12,−12,22,−22...,规避堆积,表长需为 ( ( 4 k + 3 ) ) ((4k+3)) ((4k+3)) 型质数;
  • 双散列法: d _ i = H _ 2 ( k e y ) d\_i = H\_2(\mathrm{key}) d_i=H_2(key),使用第二重散列函数计算步长;
  • 伪随机探测法: d _ i d\_i d_i 取自伪随机序列。

开放定址法仅支持逻辑删除,通过标记位标识删除状态,需定期重构散列表清理标记位。

8.3.2 链地址法(拉链法)

同一散列地址的同义词全部挂载至单链表,散列表数组仅存储链表头指针。冲突仅增加链表遍历长度,无堆积问题,支持物理删除结点。

链地址法存储结构示例:

8.3.3 公共溢出区法

设置独立溢出表存储全部冲突同义词,主表仅存放无冲突关键字,适用于冲突数量极少的数据集。

公共溢出区结构:

8.4 散列表C语言基础实现(线性探测)

8.4.1 结构体与宏定义
复制代码
#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12
#define NULLKEY -32768

typedef struct
{
    int *elem;
    int count;
} HashTable;

int m = HASHSIZE;    /* 全局变量:散列表长度 */
8.4.2 初始化函数
复制代码
bool InitHashTable(HashTable *H)
{
    int i;
    m = HASHSIZE;
    H->count = m;
    H->elem = (int *)malloc(m * sizeof(int));
    for (i = 0; i < m; i++)
    {
        H->elem[i] = NULLKEY;
    }
    return true;
}
8.4.3 散列函数(除留余数法)
复制代码
int Hash(int key)
{
    return key % m;
}
8.4.4 插入函数(线性探测解决冲突)
复制代码
void InsertHash(HashTable *H, int key)
{
    int addr = Hash(key);
    while (H->elem[addr] != NULLKEY)
    {
        addr = (addr + 1) % m;    /* 线性探测向后寻址 */
    }
    H->elem[addr] = key;
}
8.4.5 查找函数
复制代码
bool SearchHash(HashTable H, int key, int *addr)
{
    *addr = Hash(key);
    while (H.elem[*addr] != key)
    {
        *addr = (*addr + 1) % m;
        /* 遍历至空地址或回到起始地址判定查找失败 */
        if (H.elem[*addr] == NULLKEY || *addr == Hash(key))
        {
            return false;
        }
    }
    return true;
}

8.5 性能评价指标

  1. 冲突导致散列表查找仍存在关键字比较操作,使用平均查找长度 ( A S L ) \mathrm{(\mathrm{ASL})} (ASL) 作为评价指标;
  2. 影响因素:散列函数质量、冲突处理策略、装填因子;
  3. 装填因子 α \boldsymbol{\alpha} α :表征散列表填充程度,定义式:
    α = n m \alpha = \frac{n}{m} α=mn
    n n n 为表内有效记录数, m m m 为散列表总长度。 α \alpha α 数值越大,冲突发生概率越高。通过控制装填因子上限,可将散列表平均查找长度稳定为常数级,时间复杂度维持 ( O ( 1 ) ) (O(1)) (O(1))。

数据结构:排序(Sort)详解

2024-10-16 15:49:05

一、排序总述

1.1 知识框架

二、排序基础定义与分类

2.1 排序相关形式化定义

  1. 排序操作
    对线性表内记录进行重排布,使记录按照关键字满足指定偏序关系。该操作的形式化描述:
  • 输入:长度为 n n n 的记录序列 R 1 , R 2 , ... , R n R_1,R_2,\dots,R_n R1,R2,...,Rn,对应关键字序列 k 1 , k 2 , ... , k n k_1,k_2,\dots,k_n k1,k2,...,kn
  • 输出:原序列的一个置换 R 1 ′ , R 2 ′ , ... , R n ′ R'_1,R'_2,\dots,R'_n R1′,R2′,...,Rn′,满足 k 1 ′ ⪯ k 2 ′ ⪯ ⋯ ⪯ k n ′ k'_1 \preceq k'_2 \preceq \dots \preceq k'_n k1′⪯k2′⪯⋯⪯kn′,符号 ⪯ \preceq ⪯ 可替换为任意合法比较运算符。
  1. 排序算法的稳定性

    设待排序序列中存在两个记录 R i R_i Ri、 R j R_j Rj,满足 k i = k j k_i = k_j ki=kj 且原始下标 i < j i < j i<j。若排序完成后二者相对下标仍保持 i ′ < j ′ i' < j' i′<j′,则称该排序算法具备稳定性;若排序后出现 j ′ < i ′ j' < i' j′<i′,则判定为不稳定算法。

    算法稳定性仅作为算法属性的客观描述,不用于评判算法优劣。当待排序关键字无重复值时,排序结果唯一,稳定性不产生实际影响。

  2. 内排序与外排序

  • 内排序:排序全过程所有待处理记录均驻留于内存空间完成运算;
  • 外排序:待排序记录总量超出内存承载上限,需要在内存与外存之间反复调度数据块完成排序流程。

内部排序可划分为五类:插入排序、交换排序、选择排序、归并排序、基数排序。不同类别算法具备不同适用场景,不存在可覆盖全部工况的最优实现方案。内部排序的运行开销由时间复杂度与空间复杂度共同度量,时间开销由关键字比较次数与记录移动次数两项指标决定。

2.2 排序基础数据结构与工具函数

采用顺序存储结构定义待排序线性表,C 语言规范定义如下,通过宏消除数组长度魔法数字:

c 复制代码
#define MAXSIZE 10    // 顺序表数组最大存储容量,可按需修改
typedef struct {
    int R[MAXSIZE];  // 存储待排序关键字数组
    int length;      // 顺序表当前有效元素长度
} SqList;

元素交换为排序高频操作,封装独立交换函数:

c 复制代码
/**
 * @brief 交换顺序表 L 中下标 i 与下标 j 的数组元素
 * @param L 顺序表指针
 * @param i 待交换元素下标1
 * @param j 待交换元素下标2
 */
void swap(SqList *L, int i, int j)
{
    int temp = L->R[i];
    L->R[i] = L->R[j];
    L->R[j] = temp;
}

三、经典内部排序算法原理、实现与性能分析

3.1 冒泡排序(交换排序类)

3.1.1 算法原理

以相邻元素逆序判定为依据执行交换,遍历方向可选择自前向后或自后向前。单趟遍历可将未排序区间内极值元素移动至区间边界,极值元素如同气泡上浮或石块下沉至最终位置。每完成一趟遍历,已确定位置的元素不再参与后续比较,最多执行 n − 1 n-1 n−1 趟遍历即可完成全序列排序。设置交换标记位可提前判定整体有序状态,终止冗余遍历。

排序过程示例图示:

3.1.2 算法代码实现
c 复制代码
void BubbleSort(SqList *L)
{
    int i, j;
    bool flag;    // 标记单趟遍历是否发生元素交换
    // 最多执行 L->length - 1 趟冒泡
    for (i = 0; i < L->length - 1; i++)
    {
        flag = false;
        // 自后向前完成一趟冒泡比较
        for (j = L->length - 1; j > i; j--)
        {
            if (L->R[j-1] > L->R[j])
            {
                swap(L, j-1, j);
                flag = true;
            }
        }
        // 本趟无交换,序列已有序,直接退出
        if (flag == false)
            return;
    }
}
3.1.3 性能量化分析
  1. 空间复杂度 :仅占用常数级辅助变量,空间复杂度为 O ( 1 ) \boldsymbol{\boldsymbol{O(1)}} O(1)。
  2. 时间复杂度
    • 最优工况(初始序列完全升序):仅执行 1 趟遍历,关键字比较次数为 n − 1 n-1 n−1,元素移动次数为 0 0 0,时间复杂度 O ( n ) \boldsymbol{\boldsymbol{O(n)}} O(n);
    • 最坏工况(初始序列完全逆序):总计执行 n − 1 n-1 n−1 趟遍历,第 i i i 趟执行 n − i n-i n−i 次比较,每次比较伴随 3 次元素移动,总比较次数与移动次数均为 n ( n − 1 ) 2 \dfrac{n(n-1)}{2} 2n(n−1),时间复杂度 O ( n 2 ) \boldsymbol{O(n^2)} O(n2);
    • 平均工况:时间复杂度等价于最坏工况,为 O ( n 2 ) \boldsymbol{O(n^2)} O(n2)。
  3. 稳定性:冒泡排序为稳定排序算法。

3.2 简单选择排序(选择排序类)

3.2.1 算法原理

共执行 n − 1 n-1 n−1 趟遍历,第 i i i 趟在区间 i , n − 1 i,\\ n-1 i, n−1 内遍历查找关键字最小的记录,将其与下标 i i i 位置记录完成交换,直至所有元素放置于最终有序下标。

3.2.2 算法代码实现
c 复制代码
void SelectSort(SqList *L)
{
    int i, j, min_idx;
    // 执行 n-1 趟选择
    for (i = 0; i < L->length - 1; i++)
    {
        min_idx = i;
        // 查找未排序区间最小值下标
        for (j = i + 1; j < L->length; j++)
        {
            if (L->R[j] < L->R[min_idx])
                min_idx = j;
        }
        // 最小值不在当前位置则执行交换
        if (min_idx != i)
            swap(L, i, min_idx);
    }
}
3.2.3 性能量化分析
  1. 空间复杂度 :仅使用常数临时变量,空间复杂度 O ( 1 ) \boldsymbol{\boldsymbol{O(1)}} O(1)。
  2. 时间复杂度
    关键字比较次数与原始序列排布无关,固定为 n ( n − 1 ) 2 \dfrac{n(n-1)}{2} 2n(n−1);元素移动次数上限为 3 ( n − 1 ) 3(n-1) 3(n−1),最优工况移动次数为 0 0 0。整体时间复杂度恒为 O ( n 2 ) \boldsymbol{O(n^2)} O(n2)。
  3. 稳定性
    极值元素跨区间交换会破坏等值关键字原始相对位置,简单选择排序为不稳定排序算法。

3.3 直接插入排序(插入排序类)

3.3.1 算法原理

将序列划分为已排序前缀子区间与未排序后缀子区间,依次将未排序区间首个记录插入至前缀有序区间的合法位置,通过向后移位为待插入元素预留存储空间,下标 0 0 0 位置作为哨兵单元暂存待插入关键字,简化边界判定逻辑。

排序过程示例图示:

3.3.2 算法代码实现(修正原文语法错误)
c 复制代码
void InsertSort(SqList *L)
{
    int i, j;
    // 从第 2 个元素开始依次插入,R[1] 作为初始有序子序列
    for (i = 2; i <= L->length; i++)
    {
        if (L->R[i] < L->R[i-1])
        {
            L->R[0] = L->R[i];  // 哨兵暂存待插入元素
            // 自后向前遍历有序区间,元素后移腾出插入位
            for (j = i - 1; L->R[0] < L->R[j]; --j)
                L->R[j+1] = L->R[j];
            L->R[j+1] = L->R[0]; // 完成插入
        }
    }
}
3.3.3 性能量化分析
  1. 空间复杂度 :仅哨兵单元与循环变量占用空间,空间复杂度 O ( 1 ) \boldsymbol{\boldsymbol{O(1)}} O(1)。
  2. 时间复杂度
    • 最优工况(完全升序):单次比较无元素移动,时间复杂度 O ( n ) \boldsymbol{\boldsymbol{O(n)}} O(n);
    • 最坏工况(完全逆序):总比较次数、总移动次数均为 n ( n − 1 ) 2 \dfrac{n(n-1)}{2} 2n(n−1),时间复杂度 O ( n 2 ) \boldsymbol{O(n^2)} O(n2);
    • 平均工况:随机序列下比较与移动次数取上下界均值,时间复杂度仍为 O ( n 2 ) \boldsymbol{O(n^2)} O(n2)。
  3. 稳定性:逐位后移插入不会改变等值关键字相对次序,属于稳定排序算法。
  4. 存储适配性:可适配顺序存储线性表与链式存储线性表,链式结构可从前向后遍历查找插入点。

3.4 折半插入排序(插入排序类优化)

3.4.1 算法原理

继承直接插入排序的整体框架,将有序区间内线性查找插入点替换为折半查找,降低关键字比较次数;元素后移操作仍需遍历执行,移动开销与直接插入排序保持一致。

3.4.2 算法代码实现
c 复制代码
void HalfInsertSort(SqList *L)
{
    int i, j, low, high, mid;
    for (i = 2; i <= L->length; i++)
    {
        L->R[0] = L->R[i];
        low = 1;
        high = i - 1;
        // 折半查找插入下标
        while (low <= high)
        {
            mid = (low + high) / 2;
            if (L->R[mid] > L->R[0])
                high = mid - 1;
            else
                low = mid + 1;
        }
        // 批量后移 high+1 之后所有元素
        for (j = i - 1; j >= high + 1; --j)
            L->R[j+1] = L->R[j];
        L->R[high+1] = L->R[0];
    }
}
3.4.3 性能量化分析

折半查找将关键字比较次数压缩至 O ( n log ⁡ 2 n ) \boldsymbol{O(n\log_2 n)} O(nlog2n),该数值与原始序列排布无关;元素移动开销完全等同于直接插入排序,整体时间复杂度仍为 O ( n 2 ) \boldsymbol{O(n^2)} O(n2)。算法保持稳定性,适用于中等规模顺序表排序场景。

3.5 希尔排序(插入排序类进阶优化)

3.5.1 算法原理

别称缩小增量排序,为直接插入排序的改进方案。设定递减增量序列,将下标差值等于当前增量的记录划分为独立子表,对子表执行直接插入排序;逐轮缩减增量,直至增量取值为 1 1 1 时对整体序列执行一次直接插入排序。经过多轮预排序后序列达到近似有序状态,最终插入排序执行开销大幅降低。本次采用增量递推公式: increment = increment / 3 + 1 \text{increment} = \text{increment}/3 + 1 increment=increment/3+1。

分增量排序过程图示:

3.5.2 算法代码实现(修正函数名笔误 ShellSold → ShellSort)
c 复制代码
/**
 * @brief 对顺序表 L 执行希尔排序
 */
void ShellSort(SqList *L)
{
    int i, j;
    int increment = L->length;
    do
    {
        increment = increment / 3 + 1; // 增量更新规则
        for (i = increment + 1; i <= L->length; i++)
        {
            if (L->R[i] < L->R[i - increment])
            {
                L->R[0] = L->R[i]; // 哨兵暂存
                // 跨增量向前遍历移位
                for (j = i - increment; j > 0 && L->R[0] < L->R[j]; j -= increment)
                    L->R[j + increment] = L->R[j];
                L->R[j + increment] = L->R[0];
            }
        }
    } while (increment > 1);
}
3.5.3 性能量化分析
  1. 空间复杂度 :仅常数级辅助变量,空间复杂度 O ( 1 ) \boldsymbol{\boldsymbol{O(1)}} O(1)。
  2. 时间复杂度 :时间开销依赖增量序列选取,数学层面无通用解析解,在合理增量规则下平均时间复杂度约为 O ( n 1.3 ) \boldsymbol{O(n^{1.3})} O(n1.3),优于直接插入排序的 O ( n 2 ) \boldsymbol{O(n^2)} O(n2)。
  3. 稳定性:等值关键字被拆分至不同子表执行独立排序,原始相对次序存在被打乱的可能,属于不稳定排序算法。
  4. 存储适配性:仅支持顺序存储结构的线性表,链式结构无法高效按增量寻址。

3.6 堆排序(选择排序类进阶优化)

3.6.1 堆的数学定义

堆为满足约束条件的完全二叉树结构,分为两类:

  • 大根堆:任意非叶子节点关键字值大于等于其左右子节点关键字值;
  • 小根堆:任意非叶子节点关键字值小于等于其左右子节点关键字值。

堆结构示意图:

3.6.2 堆排序执行流程
  1. 将无序数组映射为完全二叉树,自下向上筛选完成初始大根堆构建,堆顶元素为全局最大值;
  2. 交换堆顶元素与堆末尾元素,将最大值固定至有序最终位置;
  3. 对剩余未排序区间重新向下筛选修复堆结构;
  4. 循环执行交换与堆修复操作,直至堆内剩余单个元素。

初始建堆调整过程、堆顶替换后修复过程图示:

堆排序典型应用场景:海量数据中筛选 Top-K 极值,例如在 10 8 10^8 108 条数据中提取前 100 个最大值,可构建容量为 100 的小根堆,遍历全部数据动态替换堆顶完成筛选。

3.6.3 算法代码实现
c 复制代码
/**
 * @brief 以下标 k 为根节点向下筛选,维持大根堆性质
 * @param A 待调整数组
 * @param k 根节点下标
 * @param len 当前堆有效长度
 */
void HeapAdjust(ElemType A[], int k, int len)
{
    A[0] = A[k];
    // 沿关键字更大的子节点向下遍历
    for (int i = 2 * k; i <= len; i *= 2)
    {
        // 选取左右子节点中关键字更大的下标
        if (i < len && A[i] < A[i+1])
            i++;
        if (A[0] >= A[i])
            break; // 满足堆约束,终止筛选
        A[k] = A[i];
        k = i;
    }
    A[k] = A[0];
}

/**
 * @brief 对长度为 len 的数组构建初始大根堆
 */
void BuildMaxHeap(ElemType A[], int len)
{
    // 从最后一个非叶子节点向前遍历建堆
    for (int i = len / 2; i > 0; i--)
        HeapAdjust(A, i, len);
}

/**
 * @brief 堆排序主函数
 */
void HeapSort(ElemType A[], int len)
{
    BuildMaxHeap(A, len);
    // 执行 n-1 趟堆顶交换与堆修复
    for (int i = len; i > 1; i--)
    {
        // 堆顶最大值固定至末尾
        Swap(A, 1, i);
        // 对剩余 i-1 个元素重新建堆
        HeapAdjust(A, 1, i - 1);
    }
}
3.6.4 性能量化分析
  1. 空间复杂度 :仅使用常数临时变量,空间复杂度 O ( 1 ) \boldsymbol{\boldsymbol{O(1)}} O(1);
  2. 时间复杂度 :初始建堆开销为 O ( n ) \boldsymbol{\boldsymbol{O(n)}} O(n),每轮堆修复开销为 O ( log ⁡ 2 n ) \boldsymbol{O(\log_2 n)} O(log2n),总计执行 n − 1 n-1 n−1 次修复,最优、最坏、平均工况时间复杂度统一为 O ( n log ⁡ 2 n ) \boldsymbol{O(n\log_2 n)} O(nlog2n);
  3. 稳定性:向下筛选过程会跨下标移动等值关键字,堆排序为不稳定排序算法。

3.7 二路归并排序(归并排序类)

3.7.1 算法原理

归并操作指将多个有序子序列合并为单一有序序列。二路归并排序采用分治思想:

  1. 分解:将长度为 n n n 的序列递归对半拆分,直至子序列长度为 1 1 1(天然有序);
  2. 合并:调用归并函数将两段相邻有序子序列融合为一段有序序列,逐层向上回溯完成整体排序。

二路归并拆分与合并过程图示:

3.7.2 算法代码实现(修正拼写错误 viod → void)
c 复制代码
ElemType *B = NULL; // 全局辅助归并数组

/**
 * @brief 将 A[low...mid] 与 A[mid+1...high] 两段有序区间归并
 */
void Merge(ElemType A[], int low, int mid, int high)
{
    // 拷贝待归并区间至辅助数组
    for (int k = low; k <= high; k++)
        B[k] = A[k];
    int i = low, j = mid + 1, k = low;
    // 双指针遍历两段有序序列,选取较小值回填原数组
    while (i <= mid && j <= high)
    {
        if (B[i] <= B[j])
            A[k++] = B[i++];
        else
            A[k++] = B[j++];
    }
    // 拷贝左区间剩余元素
    while (i <= mid)
        A[k++] = B[i++];
    // 拷贝右区间剩余元素
    while (j <= high)
        A[k++] = B[j++];
}

/**
 * @brief 二路归并排序递归入口
 */
void MergeSort(ElemType A[], int low, int high)
{
    if (low < high)
    {
        int mid = (low + high) / 2;
        MergeSort(A, low, mid);    // 左子区间递归排序
        MergeSort(A, mid + 1, high);// 右子区间递归排序
        Merge(A, low, mid, high);  // 合并有序子区间
    }
}
3.7.3 性能量化分析
  1. 空间复杂度 :需要长度为 n n n 的辅助数组完成归并,空间复杂度 O ( n ) \boldsymbol{\boldsymbol{O(n)}} O(n);递归调用栈深度为 log ⁡ 2 n \log_2 n log2n,渐进量级由辅助数组主导。
  2. 时间复杂度 :共执行 log ⁡ 2 n \log_2 n log2n 趟归并,单趟归并线性扫描 n n n 个元素,最优、最坏、平均工况时间复杂度均为 O ( n log ⁡ 2 n ) \boldsymbol{O(n\log_2 n)} O(nlog2n)。
  3. 稳定性:归并合并时等值元素优先取自左子区间,相对次序保持不变,属于稳定排序算法。

3.8 快速排序(交换排序类进阶优化)

3.8.1 算法原理

被列入 20 世纪十大经典算法,为冒泡排序的分治优化版本。选取枢轴元素对区间进行划分,一趟划分后所有小于枢轴的元素位于左侧子区间,大于等于枢轴的元素位于右侧子区间,枢轴固定于最终有序下标;递归对左右两个独立子区间重复划分操作,直至子区间长度为 1。一趟划分采用左右指针交替向中间收拢、逆序元素交换的方式完成。

一趟划分分步执行图示:

3.8.2 算法代码实现(修正语法缺失分号)
c 复制代码
/**
 * @brief 一趟快速排序划分,返回枢轴最终下标
 */
int Partition(ElemType A[], int low, int high)
{
    ElemType pivot = A[low]; // 选取区间首元素为枢轴
    while (low < high)
    {
        // 右指针左移,寻找小于枢轴的元素
        while (low < high && A[high] >= pivot)
            high--;
        A[low] = A[high];
        // 左指针右移,寻找大于枢轴的元素
        while (low < high && A[low] <= pivot)
            low++;
        A[high] = A[low];
    }
    A[low] = pivot; // 枢轴存入最终位置
    return low;
}

/**
 * @brief 快速排序递归主函数
 */
void QuickSort(ElemType A[], int low, int high)
{
    if (low < high)
    {
        int pivot_pos = Partition(A, low, high);
        QuickSort(A, low, pivot_pos - 1);  // 左子区间递归
        QuickSort(A, pivot_pos + 1, high); // 右子区间递归
    }
}
3.8.3 性能量化分析
  1. 空间复杂度 :由递归调用栈深度决定,最优平衡划分栈深度 log ⁡ 2 n \log_2 n log2n,空间复杂度 O ( log ⁡ 2 n ) \boldsymbol{O(\log_2 n)} O(log2n);最坏单斜划分栈深度 n n n,空间复杂度 O ( n ) \boldsymbol{\boldsymbol{O(n)}} O(n);平均工况为 O ( log ⁡ 2 n ) \boldsymbol{O(\log_2 n)} O(log2n)。
  2. 时间复杂度
    • 最优工况:划分完全均衡,时间复杂度 O ( n log ⁡ 2 n ) \boldsymbol{O(n\log_2 n)} O(nlog2n);
    • 最坏工况:原始序列完全有序或逆序,划分极度失衡,时间复杂度 O ( n 2 ) \boldsymbol{O(n^2)} O(n2);可通过三数取中法、随机枢轴选取规避最坏工况;
    • 平均工况:实际工程表现最优,时间复杂度 O ( n log ⁡ 2 n ) \boldsymbol{O(n\log_2 n)} O(nlog2n)。
  3. 稳定性:跨指针交换会打乱等值关键字原始相对顺序,快速排序为不稳定排序算法。

四、各类内部排序算法综合对比


Reference

相关推荐
Rabitebla1 小时前
C++ STL 之 set 详解:从底层红黑树到实际应用
开发语言·数据结构·c++·算法·leetcode
520拼好饭被践踏1 小时前
JAVA+Agent学习day26
java·开发语言·数据结构·学习·agent
郝学胜-神的一滴2 小时前
力扣 692:巧用小顶堆高效求解前K个高频单词
java·数据结构·python·程序人生·算法·leetcode·职场和发展
额,不知道写啥。2 小时前
题解:P16710 愿望
数据结构·算法
bnmoel2 小时前
数据结构深度剖析二叉树・下篇:链式结构的实现 ,遍历方法全解析
c语言·数据结构·算法·二叉树·
码哥DFS3 小时前
算法练习day1-备战2027届秋招
前端·javascript·数据结构·算法
三克的油3 小时前
数据结构-6
数据结构
wabs6663 小时前
关于图论【卡码网108.多余的边的思考】
数据结构·算法·图论
三克的油3 小时前
数据结构-5
数据结构