二叉树衍生

Huffman Tree

哈夫曼树/最优树:带权路径长度WPL最短的树

  • 哈夫曼树中没有度为1的结点,又称为严格的(正则的)二叉树
  • 树带权路径长度:树中所有叶子结点的带权路径长度之和 $WPL = \sum_{k=1}^n w_k l_k$

哈夫曼算法

哈夫曼算法:构建最小加权路径二叉树

  • 输入:给定的n个权值${w_1, w_2, \cdots, w_n}$
  • 初始化n个单节点二叉树集合$F={T_1, T_2, \cdots, T_n}$
  • 合并权重最小的两棵树,将其权重之和作为新树权重记录于新树 根节点中
  • 重复,直至生成单独一棵树

特点

  • 贪婪算法
  • Huffman算法构建的最优二叉树是只有叶子节点有权值,若所有 节点都有权值的最优二叉查找树,需要使用动态规划算法, 参见cs_algorithm/data_structure/tree_search

哈夫曼编码

  • 哈夫曼编码:编码总长度最短的二进制前缀编码
  • 前缀编码:任意编码都不是其他编码的前缀,此时编码可以
  • 前缀编码可以使用二叉树设计,叶子结点代表字符,根节点到 叶子结点路径上分支代表的二进制串即为其二进制编码

  • 对给定出现频率$P[1..n]$的字符集$C[1..n]$,生成哈夫曼树 即可得到哈夫曼编码

链式存储

哈夫曼树

1
2
3
4
typedef struct HTNode{
unsigned int weight;
struct HTNode *parent, *lchild, *rchild;
}HTNode, *HuffmanTree;

选拔树

优胜树

优胜树:非叶结点取值是两个孩子中较小者的完全二叉树

tree_winner_structure

  • 根据定义,根节点的取值是整个树的最小值
  • 从叶节点构建/重构优胜树的过程中
    • 每对兄弟结点捉对比赛,胜利者晋升为父亲结点
    • 胜者逐级向上直到根节点为止
    • 调整优胜树的时间效率$\in \Theta(logk)$

顺序存储结构

1
typedef SqBiTree SqVictTree;
  • 数组实现的二叉树可以通过完全二叉树性质迅速计算父节点、 孩子节点位置

淘汰树

淘汰树:非叶结点值是两个孩子结点中较大者,即指向失败者的 选拔树

tree_loser_structure

  • 可以简化选拔树重构过程
  • 需要额外结点记录/指向胜者

用途

归并多路有序序列

  • 问题:k路有序(降序)序列,要将其归并为一组有序序列, 归并过程每轮输出一个最小关键字记录 (显然只能是当前k路序列中第一个记录)
  • 以k路序列首k个元素建立k个叶节点的选拔树

  • 构建选拔树,输出最小元素值

    tree_winner_structure

  • 用其所属序列下个元素替换其所在叶节点值,重构选拔

    tree_winner_reconstruct

  • 重复n轮:所有k轮归并总时间为$\in \Theta(nlogk)$

)$

Tree

树&森林

Free tree

自由树:连通无回路图,具有一些其他图不具有的重要 特性

  • 边数总比顶点数少一:$|E|=|V|-1$

    • 这个是图为一棵树的必要条件,但不充分
    • 若图是连通的,则是充分条件
  • 任意两个顶点之间总是存在简单路径

(Rooted)Tree

(有根)树:存在根节点的自由树

  • $root$:根节点数据元素
  • $F=(T_1, T_2, \cdots, T_m)$:森林
  • $T_i=(r_i, F_i)$:根root的第i棵子树
  • 在任意一棵非空树中

    • 有且仅有一个特定称为根的节点
    • 节点数n>1时,其余节点可以分为m个互不相交的有限集 ,每个集合本身又是一棵树,称为根的子树
  • 树中任何两个节点间总存在简单路径,所以可以任选自由树 中某节点,作为有根树的根

  • 有根树远远比自由树重要,所以也简称为树

    • 根一般放在树的顶层,第0层
    • 之后节点根据和根的距离放在相应层数

Forest

森林:无回路但不一定连通的图

  • 其每个连通分量是一棵树
  • 对树中每个节点,其子树集合即为森林

Ordered Tree

有序树:所有顶点的所有子女都是有序(不能交换次序)的有根树

应用

  • 常用于描述层次关系

    • 文件目录
    • 企业的组织结构
    • 字典的实现
    • 超大型的数据集合的高效存储
    • 数据编码
  • 用于分析递归算法

    • state-space tree:状态空间树,强调了两种算法设计 技术:回溯、分支界限

结构

  • ancestor:从根到该顶点上的简单路径上的所有顶点
  • proper ancestor:除自身外的所有祖先顶点
  • parent:从根到顶点简单路径中,最后一条边的另一端节点
  • parental:至少有一个子女的顶点
  • child
  • sibling:具有相同父母的顶点
  • leaf:没有子女的顶点
  • descendent:所有以该顶点为祖先的顶点
  • proper descendent:不包括顶点自身的子孙
  • subtree:顶点的所有子孙、连接子孙的边构成以该顶点为根的 子树
  • depth:根到该顶点的简单路径的长度
  • height:根到叶节点的最长简单路径的长度

链式存储结构

  • 链表结点代表树中一个顶点,其中至少包含:数据域、指向子女 的指针域
  • 链表头指针指向二叉树根节点

双亲表存储

双亲表示法

  • 利用除根节点外,每个结点只有一个双亲,给所有结点添加一个 指向双亲的指针域
1
2
3
4
5
6
7
8
typedef struct PTNode{
TElemType data;
int parent;
}PTNode;
typedef struct{
PTNode nodes[MAX_TREE_SIZE];
int r, n;
}PTree;
  • 求结点双亲时是常数时间
  • 求结点孩子时需要遍历整个结构

孩子链表存储

孩子表示法

  • 把每个结点的孩子结点排列起来,视为线性表,以单链表作为 存储结构

    • 否则结点同构则浪费空间,不同构时操作不方便

      tree_child_representation_node

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct CTNode{
// 逻辑意义的结点,存储兄弟关系
int child;
// 结点位置,`nodes`中序号
struct CTNode *next;
// 指示下个兄弟结点
}*ChildPtr;
typedef struct{
// 实际存储信息的结点
TElemType data;
ChildPtr firstchild;
// 孩子链表头指针
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n, r;
// 节点数,根节点位置
}CTree;

tree_child_representation

二叉链表存储

First Child-next Silbling Representaion:孩子兄弟/二叉链表 /二叉树表示法

  • 每个节点只包含两个指针,左指针指向第一个子女,右指针指向 节点的下一个兄弟
  • 节点的所有兄弟通过节点右指针被单独的链表连接
1
2
3
4
typedef struct CSNode{
ElemType data;
struct CSNode *firstchild, *nextsibling;
}CSNode, *CSTree;
  • 可高效的将有序树改造成一棵二叉树,称为关联二叉树
  • 易于实现某些操作
    • 寻找孩子结点

森林与二叉树转换

  • 给定一棵树,可以以二叉链表为媒介导出树与二叉树之间的对应 关系,即可以找到唯一一棵二叉树和与之对应

    • 任何一棵树对应的二叉树,其右子树必然为空
  • 将森林中各棵树的根节点看作兄弟结点,则可以得到森林和 二叉树的对应关系

森林转换为二叉树

森林$F={T_1, T_2, \cdots, T_M}$转换为二叉树的 $B=(root, LB, RB)$

  • 若F为空,即m=0,则B为空树
  • 若F非空
    • root即为森林中第一个树的根$ROOT(T_1)$
    • LB是从$T_1$中根节点的子树森林转换而成的二叉树
    • RB是从森林$F^{‘}$转换而来的二叉树

二叉树转换为森林

二叉树的$B=(root, LB, RB)$转换为森林 $F={T_1, T_2, \cdots, T_M}$

  • 若B为空,即m=0,则F为空树
  • 若B非空
    • F中第一棵树根$ROOT(T_1)$即为二叉树根root
    • $T_1$中根节点的子树森林$F_1$是由B的左子树LB转换来的 子树森林
    • F中除$T_1$外的其余树组成的森林$F^{‘}$是由B的右子树 RB转换而来的子树森林

树、森林遍历

  • 以二叉链表作树、森林的存储结构式,树、森林的先(根)序 遍历、后(根)序遍历对应二叉树先序、后序遍历

Binary Tree

二叉树:所有顶点子女个数不超过2个,每个子女不是父母的 left child就是right child的有序树

  • 二叉树的根是另一棵二叉树顶点的左(右)子女
  • 左右子树也是二叉树,所以二叉树可以递归定义
  • 涉及二叉树的问题可以用递归算法解决

特点

  • 二叉树第i层之多有$2^{i-1}$个节点
  • 深度为k的二叉树最多有$2^k-1$节点
  • 对任何二叉树$T$,如果其终端节点数$n_0$,度为2的节点数为 $n_2$,则$n_0=n_2+1$

    • $n, n_0, n_1, n_2$:总结点数、终端节点数、度1节点数 、度2节点数

顺序存储结构

完全二叉树

顺序存储结构:顺序存储完全二叉树结点元素

1
2
typedef TElemType SqBiTree[MAX_TREE_SIZE];
SqBiTree bt;
  • 将完全二叉树编号为i的结点存储在一维数组中下标为i-1分量中
  • 一般二叉树则将每个结点与完全二叉树结点相对照,存储在相应 分量中,并标记不存在的结点
    • 对某些二叉树空间利用效率极低
    • 所以顺序存储结构只适合完全二叉树

链式存储结构

  • 二叉树的链表节点中至少包含3个域:数据域、左右指针域
  • 链表头指针指向二叉树根节点
    • 为方便,也可以添加一个头结点,其lchild指针域指向 根结点

二叉链表

1
2
3
4
typedef struct BiTNode{
TElemType data;
struct BiTNode *lchild, *rchild;
}BiTNode, *BiTree;
  • 含有n个结点的二叉链表中有n+1个空链域

三叉链表

1
2
3
4
typedef struct BiTriNode{
TElemType data;
struct BiTriNode *parent, *lchild, *rchild;
}BiTriNode, *BiTriTree;
  • 在二叉链表基础上增加指向双亲结点的指针域

二叉线索链表

Threaded Binary Tree:线索二叉树/线索化树

  • 使用二叉链表中的n+1个空链域存放二叉树遍历的前驱、后继 信息
  • 附设标记域区分指针域存放子孙结点、前驱/后继
  • 适合经常需要遍历的二叉树、查找遍历所得线性序列中前驱、 后继
    • 时间复杂度常数系数小
    • 无需设栈
1
2
3
4
5
6
typedef enum PointerTag{Link, Thread};
typedef struct BiThrNode{
TElemType data;
struct BitThrNode *lchild, *rchild;
PointerTag LTag, RTag;
}BiThrNode, *BiThrTree;
  • 后序线索化树找后继时需要知道双亲,应该使用带标志域的 三叉链表

Complete Binary Tree

  • 满二叉树:深度为k且有$2^k-1$节点的二叉树,每层上的节点数 都是最大节点数

  • 完全二叉树:essentially complete,树的每层都是满的,除 最后一层最右边的元素(一个或多个)可能有缺位

特点

  • 只存在一棵n个节点完全二叉树,高度为 $\lfloor log_2 n \rfloor$

    • 深度为k、节点数为n的完全二叉树同深度为k的满二叉树中 1-n号节点一一对应
    • 叶子节点只可能在层次最大的两层上出现
    • 对任一节点,其左分支只能和右分支深度相同或大1
  • 从上到下、从左到右对结点编号,即使用数组H存储完全二叉树 (从1开始编号,数组H[0]不存储节点值,或存放限位器)

    • 父母节点键会位于数组前$\lfloor n/2 \rfloor$个位置中 ,叶子节点位于后$\lceil n/2 \rceil$

    • 对位于父母位置i的键,其子女位于2i、2i+1,相应的对于 子女位置i的键,父母位于$\lfloor i/2 \rfloor$

应用

二叉树高度

  • 将空树高度定义为-1

算法

1
2
3
4
5
6
7
8
Height(T):
// 递归计算二叉树的高度
// 输入:二叉树T
// 输出:T的高度
if T = null_set
return -1
else
return max{Height(T_left), Height(T_right)} + 1

特点

  • 检查树是否为空是这个算法中最频繁的操作

  • 算法时间效率

    • 树的节点数为n,则根据加法操作次数满足递推式 $A(n(T))=A(n(T{left})) + A(n(T{right})) + 1$, 得到$A(n(T)) = n$

    • 考虑为树中每个节点的空子树添加外部节点得到 扩展树,则外部节点数量x满足$x=n+1$

    • 检查树是否为空次数即为扩展树节点数目 $C(n(T))=n+x=2x+1$

二叉树遍历

  • 不是所有关于二叉树的算法都需要遍历两棵子树,如:查找、 插入、删除只需要遍历两颗子树中的一棵,所以这些操作属于 减可变规模(减治法)

  • 先序、中序、后序遍历都需要用到栈

  • 中序遍历得到的序列称为中序置换/中序序列,先序、后序类似

递归版本

  • Preorder Traversal:先序

    1
    2
    3
    4
    5
    6
    PreOrder(T):
    visit(T)
    if T_left not null:
    PreOrder(T_left)
    if T_right not null:
    PreOrder(T_right)
  • Inorder Traversal:中序

    1
    2
    3
    4
    5
    6
    InOrder(T):
    if T_left not null:
    InOrder(T_left)
    visit(T)
    if T_right not null:
    InOrder(T_right)
  • Postorder Traversal:后序

    1
    2
    3
    4
    5
    6
    PostOrder(T):
    if T_left not null:
    PostOrder(T_left)
    visit(T)
    if T_right not null:
    PostOrder(T_right)

栈非递归

  • 先序遍历

    • 深度优先入栈:左子树优先入栈

      • 节点先访问后入栈,栈内存已访问节点
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      PreOrder(T):
      s = InitStack()
      cur = T
      while s.not_empty() or cur:
      while cur:
      visit(cur)
      s.push_back(cur)
      cur = cur.left
      cur = s.pop()
      cur = cur.right

      // 等价写法,仅使用`if`利用外层循环
      PreOrder(T):
      s = InitStack()
      cur = T
      while s.not_empty() or cur:
      if cur:
      visit(cur)
      s.push_back(cur)
      cur = cur.left
      else:
      cur = s.pop()
      cur = cur.right
      • 基于对遍历性质的考虑
      • 扩展性较好,可以扩展到中序、后序遍历
    • 广度优先入栈:同层右、左节点先后入栈

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      PreOrder(T):
      s = InitStack()
      s.push_back(T)
      while s.not_empty():
      cur = s.pop()
      if cur.right:
      s.push_back(cur.right)
      if cur.left:
      s.push_back(cur.left)
      visit(cur)
  • 中序遍历

    • 深度优先入栈

      • 节点先入栈后访问,栈内存未访问节点
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      InOrder(T):
      s = InitStack()
      cur = T
      while s.not_empty() or cur:
      while cur:
      s.push_back(cur)
      cur = cur.left
      cur = s.pop()
      visit(cur)
      cur = cur.right

      // 等价写法,仅使用`if`利用外层循环
      InOrder(T):
      s = InitStack()
      cur = T
      while s.not_empty() or cur:
      if cur:
      s.push_back(cur)
      cur = cur.left
      else:
      cur = s.pop()
      visit(cur)
      cur = cur.right
  • 后序:需要标记当前节点左、右子树是否被访问

    • 深度优先入栈

      • 节点先入栈后访问,栈内存未访问节点
      • 记录最后一次访问节点,判断右子树是否被访问 (若右子树被访问,右子节点必然是上个被访问节点)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      PostOrder(T):
      s = InitStack()
      cur = T
      last = NULL
      while s.not_empty() or cur:
      while cur:
      s.push_back(cur)
      cur = cur.left
      cur = s.top()
      // 检查右子树是否被访问过
      if cur.right == NULL or cur.right == last:
      visit(cur)
      last = s.pop() // 此时再弹出`cur`
      cur = NULL // 置`cur`为`NULL`,否则
      // 再次访问左子树,死循环
      else:
      cur = cur.right
    • 或者为每个节点附设标志位

层次遍历

  • 队列实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    # 判断节点是否存在、再填充至队列
    LevelTraversal(T):
    q = InitQueue()
    cur = T
    while q.not_empty() or cur:
    if cur.left:
    q.push_back(cur.left)
    if cur.right:d
    q.push_back(cur.right)
    visit(cur)
    cur = q.pop_first()

    # 先填充、再判断节点是否为`None`
    # 填充可保证、适合节点位置和满二叉树对应
    LevelTraversal(T):
    q = InitQueue()
    q.push(T)
    # 层次遍历使用队列实现,所以无需像栈一样使用
    # 两个判断条件`q.not_empty or cur`
    while q.not_empty():
    cur = q.pop_first()
    # 弹出时判断节点是否有效
    if cur:
    visit(cur)
    q.push_back(cur.left)
    q.push_back(cur.right)
  • 严格分层遍历:记录队列长度、遍历固定长度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    LevelTraversal(T):
    q = InitQueue()
    q.push(T)
    while q.not_empty():
    # 记录当前开始时队列长度
    # 每轮遍历该长度数目元素,严格分层遍历节点
    for i=0 to len(q):
    cur_node = q.pop_left()
    visit(cur_node)
    if cur.left:
    q.push_back(cur.left)
    if cur.right:
    q.push_back(cur.right)

树的计数

  • 二叉树相似:二者为空树或二者均不为空树,且其左右子树分别 相似
  • 二叉树等价:二者不仅相似,而且所有对应结点上的数据元素 均相同
  • 二叉树的计数:n个结点、互不相似的二叉树数量$b_n$
  • 树和一棵没有右子树的二叉树一一对应,所以具有n个结点不同 形态的树的数目,等于具有n-1个结点互不相似的二叉树数目

数学推导

二叉树可以看作是根节点、i个结点的左子树、n-i-1个结点的右子树 组成,所有有如下递推

求解得

遍历性质

  • 给定结点的前序序列、中序序列,可以唯一确定一棵二叉树

  • n个结点,不同形态二叉树数目恰是前序序列为$1 \cdots n$ 二叉树能得到的中序序列数目

  • 中序遍历过程实质是结点进栈、出栈的过程,序列$1 \cdots n$ 按不同顺序进栈、出栈能得到排列的数目即为中序置换数目 $C{2n}^n - C{2n}^{n-1} = \frac 1 {n+1} C_{2n}^n$

高维检索树

K-dimentional Tree

Kd树:循环遍历各维度,按该维度取值二分数据

  • 对高维数据进行快速搜索二叉树

    • 超平面都垂直于轴的BSPTree
  • Kd树对样本点的组织表示对k维空间的划分

    • 每个节点对应k维空间中超矩形区域
    • 构造kd树相当于用垂直于坐标轴超平面不断划分k维空间, 得到一系列超矩形区域
  • Kd树构建目标

    • 树应该尽量平衡,即分割应尽量均匀
    • 最大化邻域搜索的剪枝

建树

  • 输入:数据点$X_i, i=1,2,\cdots,N$
  • 确定划分维度(轴)
    • 选择方差最大的轴,使得数据尽量分散
    • 按次序循环遍历所有轴:方便查找时定位轴
  • 选择该维度上数值中位数作为划分点
    • 中位数查找方法
      • 各维度统一全体排序、记录
      • 抽样,使用样本中位数
    • 小于中位数的数据点划分至左子树,否则划分至右子树
  • 递归建立左、右子树直至无法继续划分
    • 节点中包含数据项数量小于阈值

查找K近邻

  • 输入:Kd树、目标点x
  • 在Kd树中找出包含目标点x的叶节点,以之为近邻点

    • 从根节点出发,与节点比较对应坐标值,递归访问至叶节点 为止
    • 目标点在训练样本中不存在,必然能够访问到叶节点
  • 沿树回溯,检查节点是否距离目标点更近,尝试更新

  • 检查该节点另一子区域是否可能具有更近距离的点

    • 即考察以目标点为圆心、当前近邻距离为半径圆,同划分轴 是否相交
    • 则只需比较目标点同相应切分平面距离、近邻距离
  • 若目标点同该对应切分平面距离小于近邻距离

    • 则将目标节点视为属于该子区域中的点
    • 从节点未访问子树开始重复以上步骤,进行近邻搜索
  • 否则继续回退

  • 退回到根节点时,搜索结束,近邻点

  • 回溯过程中需要盘对子域是否访问过,可以通过标记、比较相应 轴坐标等方式判断
  • k>1的情况类似,不过检测时使用最远近邻,新近邻需要和所有 原近邻依次比较

其他操作

插入新节点

  • 从根节点出发,根据待插入节点、当前节点在对应维度取值确定 插入左、右子树
  • 遍历直至叶子节点,插入

删除节点

  • 简单方法:将待删除节点子节点组成新集合,对其重新构建, 将新子树挂载在原被删节点位置

  • 分类讨论:设删除节点T对应划分维度为D

    • 节点无子树:直接删除
    • 节点有右子树
      • 在右子树寻找维度D取值最小节点P,替换被删除节点T
      • 在右子树递归处理删除节点P
    • 节点无右子树有左子树
      • 在左子树寻找维度D取值最小节点P,替换被删除节点T
      • 将T的左子树作为P的右子树
      • 在右子树递归处理删除节点P

查找维度D最小点

  • 若当前结点切分维度为D:只需查找左子树
  • 否则需要对左、右子树分别递归搜索

Vantage Point Tree

VP树:任选样本点,按照数据点与该点距离二分数据

  • 对高维数据进行快速搜索二叉树

  • VP树对样本点的组织表示对k维空间的划分

    • 每个节点对应k维空间中一个球形划分
    • 构造kd树相当于用以给定样本点为球心不断划分k维空间, 得到一系列球内、球外区域

建树

  • 输入:数据$X_i, i=1,2,\cdots,n$
  • 选择某数据点$X_v$作为划分球心
  • 计算其他数据点距离$D_i = d(X_i, X_v)$
  • 求出$D_i$中位数$M$
    • 与$X_v$距离$D_i \leq M$的数据点$D_i$划分至左子树
    • 与$X_v$距离$D_i \gt M$的数据点$D_i$划分至右子树

Rectangle Tree

R树:将空间划分为有重叠的

  • B树高维推广
    • 类似B树将一维区间划分为多个不重叠的子区间
    • 同样是平衡树,所有叶子位于同一层上
  • R树退化至1维有分割区间重叠问题,效果不如B树

性质

  • $M$:节点中最大键数量
  • $m \leq \frac M 2$:节点中条目最小数量
  • 非根叶节点包含$m-M$索引记录:$I$表示可在空间中完全覆盖 节点中条目点的MBR

  • 非根、非叶节点包含$m-m$个子节点:$I$表示可在空间中完全 覆盖节点中条目矩形的MBR

  • 根节点条目数$[2, m]$,除非为叶子节点

  • minimal bounding rectangleMBR,最小边界矩形

节点结构

  • 叶子节点结构:$(I, tuple-ids)$

    tree_hd_rtree_node_structure

    • $I((s_1, e_1), (s_2, e_2), \cdots, (s_n, e_n))$: n维空间中矩形
    • $tuple-ids$:节点包含的记录
  • 非叶节点:$(I, child-pointer)$

操作

建树

矩形搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
SearchRect(T, S, ret):
// 利用R树搜索矩形范围中包含的记录点
// 输入:R树根节点T、待搜索矩形S
// 输出:矩形S覆盖的条目

if T.I join S == NULL:
return

// 若T不是叶子节点,检查其每个条目E
if not T.is_leaf():
for E in T.entries:
// 对与S相交E.I对应条目E,递归调用搜索
if T.I join S != NULL:
SearchRect(E, S, ret)

// 若T是叶子节点且T.I与S相交,检查其每个记录点
elif T.I join S != NULL:
for E in T.entries:
if E in S:
ret.add(E)

选择所属叶子

1
2
3
4
5
6
7
8
9
10
11
12
13
ChooseLeaf(T, E):
// 在R树中寻找新索引条目所属叶子节点
// 输入:R树根节点T、索引条目E
// 输出:E所属R树中叶子节点

if T.is_leaf():
Assert(E.is_subset(T))
return T

else:
for T_E in T.entries:
if E.is_subset(T_E)
return ChooseLeaf(T_E, E) or T_E

插入新条目

1
2
3
4
5
6
7
8
9
10
11
12
Insert(T, E):
// 向R树中插入新条目
// 输出:R树根T、新条目E

L = ChooseLeaf(T, E)
if L.has_slot():
L.add(E)
else:
LL = L.split()
L.add(E)
P = L.get_parent()

调整树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
AdjustTree(T, L):
// 从不满足节点开始调整R树至满足要求
// 输入:R树根T、不满足要求节点L
// 输出:

if L.is_root():
return

P = L.get_parent_node()
if L.splitted():
NN = L.get_split_node()
if P.
// 调整节点L在父节点中矩形框I大小
addjust_I(P.L.I)

R*-tree

X-tree

SS-tree

SR-Tree

Metric-tree

高维数据检索方法

相似性检索

相似性检索:从指定目标集合中检索出与给定样本相似的目标

  • range searches:范围检索,给定查询点、检索距离阈值
  • K-neighbor searches:K近邻检索,给定查询点、检索结果 数量
  • 待检索目标、样本:以指定feature space中的高维数据点 表示

  • 相似性检索则在相应metric space中搜索样本点最近邻作为 检索结果

  • 关键:对待检索的目标建立有效的相似性索引

    • 对待检索目标进行预划分,在对给定样本进行检索时,只需 对比相似索引中给出的可能相似的目标
    • 减少相似性检索的对比次数、I/O,让相似性检索在大规模 数据集中应用成为可能

Tree-Based Index

基于树结构的索引

  • 向量维度大于20之后,仍然需要扫描整个向量集合的大部分, 与线性扫描没有太大差别

  • 包括

    • kd-tree
    • R-tree
    • R*-tree
    • X-tree
    • SS-tree
    • SR-tree
    • VP-tree
    • metric-trees

Hasing-Based Index

基于哈希的索引技术:利用LSH函数简化搜索

  • locality sensitive hashingLSH,局部敏感哈希,特征 向量越接近,哈希后值越可能相同

    • 局部敏感哈希值能够代表代替原始数据比较相似性
    • 支持对原始特征向量进行非精确匹配
  • hash技术能从两个方面简化高维数据搜索

    • 提取特征、减小特征维度

      • 在损失信息较小的情况下对数据进行降维
      • hash函数(特征提取方法)选择依赖于对问题认识
      • 一般都归于特征提取范畴
    • 划分特征空间(哈希桶)、缩小搜索空间

      • 将高维特征映射到1维先进行近似搜索得到候选集, 然后在候选集中进行精确搜索
      • hash函数的选择取决于原始特征表示、度量空间
      • 一般LSH都是指此类哈希技术

提取特征

  • average hashingaHash,平均哈希
  • perceptual hashingpHash,感知哈希
  • differantiate hashingdHash,差异哈希

划分空间

  • MinHashing:最小值哈希,基于Jaccard系数
  • 基于汉明距离的LSH
  • 基于曼哈顿距离的LSH
  • Exact Euclidean LSHE2LSH,基于欧式距离

Visual Words Based Inverted Index

向量化方法:将向量映射为标量,为(图像)特征建立 visual vocabulary

  • 基于K-means聚类(层级K-means、近似K-means)
  • 在图像检索实际问题中取得了一定成功
  • K-means聚类算法的复杂度与图像特征数量、聚类数量有关
    • 图像规模打达到百万级时,索引、匹配时间复杂度依然较高
  • visual vocabulary:视觉词库,代表聚类类别整体
  • visual word:视觉单词,每个代表一个聚类类别

查找/搜索树

总述

  • 二叉查找树:最基础的查找树,但是不平衡时效率很差
  • 自平衡查找树:使用旋转技术得到平衡二叉树
  • 多路查找树:使用多叉树达到平衡
  • 树高度一般即决定其查找、插入、删除效率
  • 以代码复杂性、插入节点时间代价,换取查询时$logN$性能

Self-Balancing Binary Tree

自平衡查找树:如果节点插入、删除产生了一棵违背平衡要求的树, 就从称为旋转的一系列特定变换中选择一种,重新构造树使得 树满足平衡要求

  • 不同的对平衡的定义产生了不同的实现
  • 这种方案属于变治法实例化简

Balance Factor

平衡因子:节点左、右子树高度差(一般右树-左数)

  • AVL树中平衡情况下节点平衡因子只能为-1、+1、0

  • 更新节点后可能存在不平衡节点,其平衡因子可能变为-2、+2

  • 平衡因子也可以被定义为左右子树叶子数

Rotation

旋转需要使得二叉树平衡时,保证二叉树依然有序

  • 左旋:节点平衡因子符号为正,即右子树高于左子树

    • 节点T下沉为其儿子R的儿子
    • 如果右儿子R本身有左子树RL,则左子树RL成为节点T 新右子树
  • 右旋:节点平衡因子符号为负,即左子树高于右子树,

    • 节点T下沉为其儿子L的儿子
    • 如果左儿子L本身有右子树LR,则右子树LR成为节点T 新左子树
  • 旋转只涉参与选择的至多3个节点指针变化,其余节点状态保持
  • “旋转”行为确实类似:节点绕其子节点旋转,子节点旋转后上浮 成为父节点
  • 参与旋转的两个节点也称为轴

分类

  • AVL树
  • 红黑树
  • 分裂树

Multiway Search Tree

多路查找树:允许查找树中单个节点中不止包含一个元素

  • 二叉查找树的推广,用于磁盘超大数据集的高效存取
  • 此方案属于变治法改变表现

分类

  • 2-3树
  • 2-4树
  • B树
  • B+树

Binary Search Tree

二叉查找树:分配给每个父母顶点的数字都比其左子树中数字大, 比右子树数字小

  • 二叉树有序,保证二叉树能够快速找到中间元素,从而支持 二分查找

特点

  • 对于n个顶点的二叉树,满足 $ \lfloor {log_{2}^{n}} \rfloor \leq h \leq n-1 $

  • 二叉查找树的查找算法效率取决于二叉树的高度

    • 平均情况下,查找、插入、删除时间$\in Theta(logn)$

    • 最差情况下(严重不平衡的树,接近链表结构),树高度 h接近节点数n,查找、插入、删除时间$\in Theta(n)$

  • 包含n个键的二叉查找树总数量 $c(n) = \frac 1 {n+1} C_{2n}^n, c(0)=1$

应用

  • 查找

操作

查找

在给定二叉查找树中查找给定键值K

  • 如果树为空,查找失败

  • 若树不为空,将查找键K和树根节点K(r)比较

    • 相等则查找停止
    • $K < K(r)$:在左子树中继续查找
    • $K > K(r)$:在右子树中继续查找
特点
  • 算法时间效率
    • 最差情况下,二叉树完全偏斜,需要进行n次比较
    • 随机情况下,查找n个随机键构造的二叉树比较次数$2logn$

插入

除非是空树,否则总是把新键K插入叶子节点中

  • 通过查找K确定合适插入位置(叶子节点)
  • 若K大于叶子节点键值,则作为右子女,否则作为左子女

最优二叉查找树

对集合中元素确定的查找概率,成功查找的平均比较次数最小的 二叉查找树(可以扩展到包含不成功查找)

  • $a_i, i=1,2,\cdots,n$:从小到大互不相等的键
  • $p_i, i=1,2,\cdots,n$:键查找概率
  • $T_i^j$:由键$a_i, \cdots, a_j$构成的二叉树
  • $C(i, j)$:成功查找的最小平均查找次数

动态规划构造

  • 根据最优化法则,最优二叉查找树左右子树均是最优排列的

  • 二叉树$Ti^j$有序,设树根为$a_1$,$a_2,..,a{k-1}$构成 的左子树、$a_{k+1},..,a_j$构成右子树均是最优排列的

递推式如下

  • $1 \leqslant i \leqslant j \leqslant n$

  • $1 \leqslant i \leqslant n+1, C(i, i-1)=0$:空树

  • $1 \leqslant i \leqslant n, C(i, i)=p_i$:单节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
OptimalBST(P[1..n])
// 动态规划法构建最优二叉查找树
// 输入:P[1..n]n个键查找概率
// 输出:在最优BST中查找成功的平均比较次数,最优BST子树根表
for i = 1 to n do
C[i, i-1] = 0
C[i, i] = P[i]
R[i, i] = i
C[n+1, n] = 0
// 初始化平均查找次数表、子树根表
for d = 1 to n-1 do
// d为二叉树节点数
for i = 1 to n-d do
// n-d是为了控制j的取值
j = i + d
minval = \infty
for k = i to j do
// 遍历设置二叉树根节点
if C[i, k-1] + C[k+1, j] < minval
minval = C[i, k-1] + C[k+1, j]
kmin = k
R[i, j] = kmin
sum = P[i]
for s=i+1 to j do
sum += P[s]
C[i, j] = minval + sum
return C[1, n], R

算法特点

  • 算法效率
    • 算法时间效率为立方级
    • 算法空间效率为平方级

AVL Tree

AVL树:要求其节点在左右子树高度差不能超过1的平衡二叉树

  • 基本思想:总能通过一次简单的节点重排使树达到平衡

    • 如果树插入操作使得一棵AVL树失去平衡,利用旋转对树作 变换
    • 若有多个这样的节点,找出最靠近新插入的叶子节点不平衡 结点,然后旋转以该节点为根的子树
  • AVL树高度h即为其查找、插入效率

    • 包含n个节点AVL树高度h满足 $\lfloor log_2 n \rfloor \leq h < 1.4405log_2(n+2) - 1.3277$

    • 最差情况下,操作效率$\in \Theta(logn)$

    • 平均而言,在n不是太小时,高度h平均为 $1.01log_2 n + 0.1$(几乎同折半查找有序数组)

  • AVL树缺点(平衡代价),阻碍AVL树成为实现字典的标准结构

    • 需要频繁旋转
    • 维护树的节点平衡
    • 总体比较复杂

旋转

按照节点平衡符号进行旋转:+左旋、-右旋

  • 单旋:不平衡节点不平衡子节点不平衡因子符号相同

    • 全为+不平衡节点左旋
    • 全为-不平衡节点右旋
  • 双旋:不平衡节点不平衡子节点不平衡因子符号不同

    • 先旋转不平衡子节点
    • 再旋转不平衡节点
  • 优先考虑最底层、不平衡节点

插入节点

AVL树插入关键:查找不平衡节点

  • 节点中应有存储其平衡因子的实例变量
  • 插入过程中经过每个节点返回当前节点子树深度是否变化
  • 比较节点平衡因子、子节点深度变化返回值判断是否平衡
  • 插入过程中查询的每个节点都有可能不平衡
  • 自下而上返回深度变化情况,优先旋转最底层不平衡节点

4种插入情况

根据插入情况的不同,对最靠近新插入叶子节点的不平衡点T

LL

插入T左儿子L的左子树LL:平衡因子全-

  • 即T到最底层叶节点(只可能有一个)的路径为LL(中间省略)
  • R-rotation:右单转,对T做一次右旋即可平衡

avl_ll_rotation

RR

插入T右儿子R的右子树R:平衡因子全+

  • 即T到最底层叶节点(只可能有一个)的路径为RR(中间省略)
  • L-rotation:左单转,对T做一次左旋即可平衡

avl_ll_rotation

LR

插入T左儿子L的右子树R:平衡因子-+

  • 即T到最底层叶节点(只可能有一个)的路径为LR(中间省略)
  • LR-rotation:左右双转
    • 先对左儿子L做一次左旋,变成LL模式
    • 在LL模式下,再对T做一次右旋

avl_lr_rotation

RL

插入T右儿子R的左子树L:平衡因子+-

  • 即T到最底层叶节点(只可能有一个)的路径为RL(中间省略)
  • RL-rotation:右左双转
    • 先对右儿子R做一次右旋,变成RR模式
    • 在RR模式下,再对T做一次左旋

avl_rl_rotation

实现

删除节点

  • 在AVL树中删除键相对而言比较困难,但也是对数级的

实现

Red-Black Tree

红黑树:能够容忍同一节点的一棵子树高度是另一棵的2倍

Splay Tree

分裂树:

2-3树

2-3树:可以包含两种类型的节点2节点、3节点,树的所有叶子节点 必须位于同一层

2_3_tree

  • 2-3树的高度即决定其查找、插入、删除效率

    • 节点数为n的2-3数高度h满足 $log_3(n+1)-1 \leq h \leq log_2(n+1) - 1$
    • 最差、平均情况下,操作效率$\in \Theta(logn)$
  • 2-3树总是高度平衡

    • 所有叶子节点位于同一层,即树根到其路径长度相同
    • 2-3树平衡代价
      • 树中节点可以有1或2个键,需要处理2种节点类型
      • 拆分3节点的情况有很多种
  • 节点类型

    • 2节点:只包含1个键K、2个子女

      • 左子女:作为所有键都小于K的子树的根
      • 右子女:作为所有键都大于K的子树的根
    • 3节点:两个有序键$K_1 < K_2$、3个子女

      • 最左边子女:作为键值小于$K_1$的子树的根
      • 中间子女:作为键值位于$[K_1, K_2]$之间子树的根
      • 最右边子女:作为键值大于$K_2$的子树的根
  • 类似的还有2-4树

查找

  • 如果根是2节点,当作二叉树查找

    • 若K等于根键值,停止
    • 若K小、大于根键值,在左、右子树中查找
  • 若根是3节点

    • 和两个键值比较,有相等,停止
    • 否则能确定应该在3棵子树中哪棵继续查找

插入节点

除非是空树,否则总是把新键K插入叶子节点中

  • 查找K确定合适的插入位置(叶子节点)

  • 若叶子节点2节点,根据大小关系将K作为第一个键或第二个键 插入 2_3_2node_insert

  • 若叶子节点3节点,把叶子分裂为两个节点:3个键中最小的放入 第一个叶子中,最大的放入第二个叶子中,中间的键提升到原 叶子节点父母中

    2_3_3node_insert

    • 若叶子就是根,则需要创建新根容纳中间键
    • 中间键的提升可能会导致父母节点分裂,并引起祖先链条上 多个节点的分裂

删除节点

B树

B树:允许节点可以有$1~m-1$个键($2~m$个子女)

1
2
3
4
5
6
7
#define BTREE_M
typedef struct BTNode{
unsigned int degree; // 节点实际包含键数
KeyType key_slots[BTREE_M-1]; // 节点存储键数组
struct BTNode ptr_slots[BTREE_M]; // 指向子女节点指针
struct BTNode * ptr_parent; // 父节点指针
}BTNode, *BTNodePtr;
  • $m$:B树阶数,节点允许最大子女数
    • 节点最多允许有$m-1$个条目
    • $m=2$即为二叉树、$m=3$即为2-3树
  • 键:用于构建树的关键字,和具体条目类似于键值对
  • 根节点具有$2~m$个子女,除非为叶子节点

  • 非根、非叶节点具有$[\lceil m/2 \rceil, m]$个子女

    • 即具有$[\lceil m/2 \rceil - 1, m]$个有序

    • 其中子树$T_0$所有键小于$K_1$,子树$T_1$中所有键大于 等于$K_1$小于 $K_2$,以此类推

  • B树是完美平衡的:所有叶子节点在同一层上

查找

算法

  • 输入:B树、目标查找键
  • 输出:查找键所属B树节点(若存在)
  • 从根节点开始,比较查找键k和节点中键序列大小

    • 节点中键有序,若B树阶数足够大,可考虑折半查找
  • 若在节点键序列中找到匹配键,则查找成功、返回

  • 否则根据比较结果选择合适分支,顺着指针链前进,在相应子女 节点中进行查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BTreeSearch(T, k):
// 在B树中搜索键
// 输入:B树根节点T、目标查找键k
// 输出:目标键所在节点、目标键在节点中序号
i = 0
cur = T

// 遍历寻找目标键对应键、分支
while i < cur.degree and k > cur.key_slots[i]:
i += 1

if i < cur.degree and k == cur.key_slots[i]:
return cur, i
elif cur.is_leaf(): // 未找到目标键
return cur, -1
else:
return BTreeSearch(cur.ptr_slots[i], k)

分裂

算法

  • 输入:待分裂节点父节点、待分裂节点序号
  • 计算待分裂节点分裂中心

    • 将分裂中心右侧数据移动至新节点
  • 将节点分裂中心、新节点提升至父节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
BTreeNodeSplit(T, ptr_idx):
// 分裂B树中节点
// 输入:待分裂节点父节点`T`、待分裂节点序号`ptr_idx`

nnode = BTreeNode()
fnode = T.ptr_slots[ptr_idx]

// 分裂中心
mid = fnode.degree // 2

// 复制右半部分键至新节点
nnode.key_slots[:fnode.degree - mid] = fnode.key_slots[mid:fnode.degree]
fnode.key_slots[mid:fnode.degree] = NULL

// 若原节点非叶子节点,复制右半部分指针至新节点
if not fnode.is_leaf():
nnode.ptr_slots[:fnode.degree + 1 - mid] = fnode.ptr_slots[mid: fnode.degree + 1]
fnode.ptr_slots[mid: fnode.degree+1] = NULL

// 修改两节点度
nnode.degree = fnode.degree - mid
fnode.degree = mid

// 将分裂中心提升至父节点
// 修改父节点指向子女节点指针
T.key_slots.insert(fnode.key_slots[mid], ptr_idx)
T.ptr_slots.insert(nnode, ptr_idx+1) // 原节点指针不变

插入

  • 输入:B树根T、待插入键K
  • 假设B树中不存在键K,否则查找、更新对应值即可
  • 根据需要插入键K,查找需要插入的叶子节点L
  • 若叶子节点L键数小于m-1,直接插入结束
  • 否则以叶子节点键值中位数mid为中心分裂
    • 将mid插入叶子节点L父节点P中
    • 将mid左子支指向分裂后左子树、右子支指向分裂后右子树
  • 对父节点尝试操作
1
2
3
4
5
6
7
8
9
10
11
12
13
BTreeInsert(T, k)
// 向B树中插入不存在键K
// 输入:B树根T、待插入键k

// 找到键`k`应该插入的叶子节点`L`
L, _ = BTreeSearch(T, k)
L.key_slots.insert_in_order(k)


// 若节点已满、须分裂
while L.is_full():
BTreeNodeSplit(L.parent_ptr, L.get_index())
L = L.parent_ptr
  • 此实现是考虑节点都有空位容纳新键,节点插入新键后再 判断是否需要分裂

删除#todo

应用

  • 类似一般查找树,把数据记录插入初始为空的树中构造B树

B+树

B+树:B树的一种变形树

  • 其中非叶节点只有索引作用,跟记录相关信息均存放在 叶结点中
  • B+树中所有叶结点构成有序链表,可以按照键次序遍历

查找

算法

  • 从根开始,比较查找键K和节点中键大小,选择合适指针,顺着 指针链前进

  • 指针链指向可能包含查找键的叶子节点,在其中查找K

    • 父母节点、叶子节点都是有序的,如果B树次数足够 大,可以考虑使用折半查找

算法效率

以B树作索引存储大型数据文件为例

  • 查找指定查找键访问B树节点数量为B树高度$h+1$ (即磁盘访问次数)

  • 次数为m、高度为h的B树能够包含最少节点数为 $n \geqslant 4 \lceiling m/2 \rceiling ^ {h-1} -1$, 即$h \leqslant \lfloor log_{[m/2]} \frac {n+1} 4 \rfloor + 1$

  • 所以在B树中查找,访问磁盘次数$\in O(logn)$

    • 实际应用中,磁盘访问次数很少超过3,因为B树的根、甚至 是第一层节点会存储在内存中减少磁盘访问次数

插入

算法

  • 查找新记录键K对应的叶子节点

  • 若叶子节点中还有空间存放此记录,在保持键有序的情况下插入

  • 若节点中没有空间

    • 将节点分裂,后半部分记录放在新节点中
    • 新节点中最小键$K^{‘}$、指向其的指针插入原叶子节点 父母节点中(原叶子节点键、指针之后)
    • 插入过程可以一直回溯到树根,甚至直到树根分裂

其他考虑

  • 查找新记录对应叶子节点时就分裂满节点,可以避免递归分裂
  • 将满叶子节点中部分键移动给兄弟节点,同时替换父母节点中的 键值(保证B树特点),增加少许复杂度以节约空间

算法特点

  • 算法效率
    • 算法时间效率$\in O(logn)$

应用

  • B+树是存储结构化记录数据集合最重要的索引结构
    • 所有数据记录(键)按照升序存储在叶子节点中,其父母 节点作为索引
    • B+树中节点常常对应磁盘中页
    • 考虑到访问磁盘页面是比较内存中键值比较时间多好几个 数量级,磁盘访问次数是衡量B树效率的主要指标

Heap

Heap

堆/大根堆:每个节点包含一个键、且大于等于其子女键、基本完备 的二叉树

  • 认为非叶子节点自动满足大于其子女键值
  • 根到某个叶子节点路径上,键值序列递减(允许键值相等则是 非递增)
  • 键值之间没有从左到右的次序,即树的同一层节点间没有任何 关系,或者说左右子树之间没有任何关系

堆特性

  • 完全二叉树特性参见完全二叉树
  • 堆的根总是包含了堆的最大元素
  • 堆的节点及该节点子孙也是一个堆
  • 堆这种数据结构经常被用于实现优先队列

最小堆

min-heap:(最大)堆的镜像

  • 堆的主要特性最小堆也满足,但
    • 最小堆根节点包含最小元素
  • 可通过给元素取反构造(最大)堆得到最小堆

堆算法

Bottom-Up Heap Construction

自底向上堆构造:先利用所有元素构造二叉树,从倒数第二层开始 修改使得整棵树满足堆要求

算法

  • 将给定线性表对应为(初始化)一棵完全二叉树
  • 对二叉树进行堆化,从最后父母节点开始、到根为止,算法检查 节点键是否满足大于等于其子女键
    • 若不满足,交换节点键K和其子女最大键值
    • 在新位置上检查是否满足大于子女键值,直到满足为止
  • 对以当前节点为根的子树满足堆化条件后,算法对节点直接前趋 进行检查,直到树根满足堆化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
HeapAdjust(H[0..n-1], cur):
// 左右子树已经为堆,仅根节点元素不一定满足
// 输入:待调整堆H、根节点cur,cur左右子树已经为堆
// 输入:调整后堆
while 2*cur+2 < n:

// 获取左右子女中最大者
if 2*cur+2 < n:
_tmp = argmax(H[2*cur+1], H[2*cur+2])
else:
_tmp = 2*cur+1

// 交换自身和最大子女
if H[_tmp] > H[cur]:
swap(H[cur], H[_tmp])
cur = _tmp
else:
// 从底向上建堆,左、右子树均为堆
// 则此时已经满足堆性质
break

return H

HeapBottomUp(H[0..n-1]):
// 用自底向上算法,从给定数组元素中构造一个堆
// 输入:可排序数组H[0..n-1]
// 输出:堆H[0..n-1]
for i=floor((n-1)/2) to 0:
HeapAdjust(H, i)
return H

算法特点

  • 算法效率
    • 时间效率:最差情况,每个位于树第i层节点会移到叶子层 h中,每次移动比较两次,则总键值比较次数 $C_{worst}=2(n-log_2(n+1))$ (将各层叶子节点可能交换次数加总即差比数列求和)

Top-Down Heap Construction

自顶向下堆构造算法:先利用部分元素构建堆顶,把新键连续插入 已经构造好的堆末尾,修改使之满足堆要求

算法

  • 把包含键K的新节点附加在当前堆最后一个叶子节点
  • 将K与其父母进行比较
    • 若后者大于K,算法停止
    • 否则交换这两个键,并比较K和其新父母,直到K不大于其 父母或是达到了树根

算法特点

  • 算法效率
    • 操作所需键值比较次数小于堆高度$h \approx log_2 n$, 则总比较次数$\in \Theta(nlogn)$

删除堆根节点

算法

  • 根键和堆中最后一个键K交换
  • 堆规模减1(删除原根键)
  • 按照自底向上堆构造算法,把K沿着树向下筛选使得新树满足 堆化

算法特点

  • 算法效率
    • 效率取决于树堆化所需比较次数,不可能超过树高度两倍, 即$\in O(logn)$

堆排序算法

算法

  • 为给定数组构造堆
  • 删除最大键,即对堆应用n-1此根删除操作

算法特点

  • 算法效率

    • 根删除阶段算法所需比较次数

    • 两阶段总效率$\in O(nlogn)$

    • 详细分析证明,无论最差情况、平均情况下,时间效率 $\in \Theta(nlogn)$
  • 堆排序时间效率和归并排序、快排时间效率属于同一类,但是 堆排序是在位的(不需要额外存储空间)

  • 随机实验表明:堆排序比快排慢、和归并排序相比有竞争力

算法设计策略

蛮力法

Brute Force:简单直接解决问题的方法,常常直接基于问题的描述 和所涉及的概念定义

特点

  • 蛮力法可以解决各种问题,实际上可能是唯一几乎可以解决所有 问题的方法
  • 对某些重要问题,蛮力法可以产生合理算法,具备实用价值, 且不必限制实例规模
  • 如果解决问题实例不多,蛮力法速度可接受,设计高效算法可能 不值得
  • 蛮力法可以用于研究、教学目的,如:衡量同样问题其他更高效 算法

案例

因为基本所有问题都可以使用蛮力得到理论可行的解决方法, 所以这里只包含实际可行、有价值算法

  • 排序
    • 选择排序
    • 冒泡排序
  • 查找
    • 顺序查找
    • 蛮力字符串匹配
  • 几何
    • 最近对问题
    • 凸包问题
  • 组合
    • 背包问题
    • 旅行商问题
    • 分配问题
  • 图处理
    • 深度优先搜索
    • 广度优先搜索

Recursion

  • reduction:简化论,仅仅通过理解构成对象某一部分就可以 理解整个对象
  • holism:整体论,总体总比构成它的部分更为重要

递归:将大问题通过简化成相同形式的小问题来解决问题

  • 递归的思考需要从整体角度考虑
  • 需要习惯于采用递归的稳步跳跃理念
  • 关键在于如何正确的将原始问题分解为有效的子问题
    • 更加简单,且和原问题求解形式相同
    • 最终能达到简单情况,并正确解决
    • 能重新组合子问题的解得到原始问题的解
  • recursive leap of faith:递归的稳步跳跃理念,任何更 简单的递归调用将正确工作

Recursive Paradigm

递归范型:递归函数体形式

1
2
3
4
5
6
7
if(test for simple case){
// 非递归解决简单问题
} else {
// 将问题简化为某种形式的子问题(一个或多个)
// 递归调用函数解决每个子问题
// 将子问题的解组合得到原问题的解
}

返回值问题

  • 无返回值:没有返回值回退过程、处理函数

    • 全局变量、引用参数记录结果
    • 参数传递结果,最末栈直接使用
  • 有返回值:有返回值回退过程

    • 可以完全类似无返回值函数调用,此时返回值无实际价值
    • 也可以最终结果在初始主调函数中得到
  • 不方便使用全局变量记录结果时,可以给真实递归调用函数 添加记录结果的引用参数,外层包装其、提供实参
  • 是否有返回值不是递归调用的关键,关键是是否继续调用

减治法

Decrease and Conquer:利用问题给定实的解和同样问题较小 实例解之间某种关系,可以自底向上、自底向上的运用该关系

  • 自顶向下会自然导致递归算法,但是还是非递归实现较好
  • 自底向上往往是迭代实现的,从求解问题较小实例开始

减治法有3种主要变化形式

Decrease-by-Constant

减常量:每次算法迭代总是从实例中减去相同常量

  • 新问题规模 = 原问题规模 - constant
  • 一般来说这个常量为1

Decrease-by-A-Contant-Factor

减去常量因子:在算法迭代过程中总是减去相同的常数因子

  • 新问题规模 = 原问题规模 / constant-factor
  • 常数因子一般为2

Variable-Size-Decrease

减可变规模:算法每次迭代时,规模减小模式不同

案例

  • 减常量法

    • 数值计算
      • 自顶向下递归计算指数
      • 利用指数定义自底向上计算指数
    • 排序
      • 插入排序
      • 希尔排序
    • 图问题
      • 拓扑排序
    • 组合
      • 生成排列
      • 生成子集
  • 减常因子法

    • 数值计算
      • 递归的计算$a^{n/2}$计算指数
      • 俄式乘法
      • 约瑟夫斯问题
    • 查找
      • 数值问题
  • 减可变规模

    • 数值计算
      • 计算最大公约数的欧几里得算法
    • 排序
      • 顺序统计量
    • 查找
      • 差值查找
      • 二叉查找树
    • 组合
      • 拈游戏

分治法

Divide-and-Conquer

  • 将问题划分为同一类型的若干子问题,子问题规模最好相同
  • 对子问题求解
    • 一般使用递归方法
    • 规模足够小时,有时也会利用其他算法
  • 有必要则合并子问题的解得到原始问题答案

案例

  • 查找
    • 求解二叉树高度
    • 遍历二叉树
  • 数值计算
    • 大整数乘法
    • Strassen矩阵乘法
  • 几何
    • 最近对问题
    • 凸包问题

变治法

  • 输入增强
  • 时空权衡

变治法分成两个阶段工作

  • “变”:出于某种原因,把问题实例变得容易求解
  • “治”:对实例问题进行求解

根据对问题的变换方式,可以分为3类

Instance Simplification

实例化简:变换为同样问题的更简单、更方便的实例

  • 预排序:

Representation Change

改变表现:变换为同样实例不同表现

problem reduction

问题化简:变换为算法已知的另一个问题的实例

典例

  • 排序

    • 预排序(线性表)
      • 比较计数排序
      • 分布计数排序
  • 查找

    • 预排序
      • 检验线性表唯一性
      • 寻找线性表众数
      • 查找线性表中元素
    • 字符串模式增强
      • Horspool算法
      • Boyer-Moore算法
      • KMP算法
      • 最长公共子串
  • 数值

    • 高斯消元法
      • 前向消去法
      • 部分选主元法
      • 反向替换法
    • 数值计算
      • 霍纳法则
      • 二进制(计算)幂
      • 欧几里得算法
    • 极大、极小值转换
    • 极值转换为求导数为0点
    • 线性规划:在极点求解
    • 整数规划
    • 把问题转换为状态图求解
  • Hash(散列)

动态规划

Dynamic Programming:记录、再利用子问题结果

  • 记录子问题解,试图避免不必要、重复子问题求解

    • 否则就是普通递归,不能避免重复求解
    • 或者说动态规划就是普通递归+子问题解记录
  • 适合解决的问题特点

    • 离散最优化问题:如递推关系中包含maxminsum等,递推式中

      • 因变量待求解最优化问题
      • 自变量则为问题中涉及的离散变量
    • 交叠子问题构成复杂问题,需遍历比较

    • 问题具有最优子结构,由最优解定理,最优解由 子问题最优解构成

求解空间

求解空间:问题涉及的两个、多个取离散值变量,根据递推关系考虑 离散变量待求解问题可能组合

  • 离散变量包括

    • 明显离散列表
    • 因变量限制条件
  • 可利用变量间限制条件组合因变量

    • 默认各变量组合是笛卡尔积
    • 由于限制条件可以减少变量组合取值数量
  • 某变量可能为其他变量提供搜索空间

    • 即其每个取值均需和其他变量组合、搜索
    • 此时可以不将其计入求解变量、动态规划表,而是遍历其 (如:找零问题中零钱)

递推式、递推关系

递推式、递推关系:将原问题分解为较小、交叠子问题的方法

  • 动态规划递推中包含重要思想有序组合

    • 有序剔除遍历相关变量,一般单向剔除,有些变量需要 考虑双向,视为两个有约束条件的独立变量 (如:最优二叉树)

    • 因为只需要求解全集最优解,所以只需要考虑 部分有序子集,直观类比矩阵可逆只需要判断 顺序主子式

  • 原问题解可能无法直接得到递推关系

    • 原始问题非求数值解

      • 应该寻找数值中间解构建递推关系,
      • 再利用动态规划表得到最终解(最长子序列)
    • 原始问题是求宽松范围解即解不要求包含断点的解

      • 各个元素分别作为端点的解构建递推关系
      • 以最优者即为宽松范围解(最长子序列)
  • 递推式中应优先考虑限制条件:减少搜索空间

    • 单个自变量限制条件
    • 两端都需要变化的变量视为两个独立、相互约束变量

动态规划表

动态规划表:存储已求解交叠子问题解,相同问题查表避免重复求解

  • 动态规划表结构

    • n维结构化数据表(常用)
      • n为自变量数量(组合变量视为单个变量)
      • 每维对应某离散变量
    • 字典:适合自顶向下动态规划
  • 求解问题时

    • 将问题分解为交叠子问题
    • 先查动态规划表,尝试从表中得到问题解,否则求解问题, 记录于动态规划表
    • 并用于求解更大规模问题,直至原问题求解完成

分类

自底向上(经典)

自底向上:求解给定问题所有较小子问题,最终得到的原始问题解

  • 计算用所有小问题解、填充动态规划表格

    • 常逐行、逐列填充动态表(求解子问题)
    • 一般先填充初始化变量对应维度(即位于内部循环)
      • 先初始化行,在初始化列过程中可以在循环中填充行
      • 先初始化列,在初始化行过程中可以在循环中填充列
    • 循环保证所需子问题已经求解完毕
  • 特点

    • 自底向上没有对问题整体的全局把握,必须求解全部子问题
    • 无需递归栈空间

自顶向下

自顶向下:整体把握原始问题,只计算对求解原问题的有必要子问题

  • 用自顶向下方式求解给定问题

    • 先将问题分解子问题
    • 求解必要的子问题
      • 先检查相应动态规划表中该问题是否已求解,
      • 否则求解子问题,记录解于动态规划表
    • 所有子问题求解完毕,回溯得到原问题解
  • 特点

    • 整体把握问题整体,避免求解不必要子问题
    • 需要通过回溯(递归栈)得到问题最优解的构成

Principle of Optimality

最优化法则:最优化问题的任一实例的最优解,都是由其子问题实例 的最优解构成

  • 最优化法则在大多数情况下成立,但也存在少数例外:寻找图 中最长简单路径

  • 在动态规划算法中,可以方便检查最优化法则是否适用

  • 动态规划的大多数应用都是求解最优化问题

典例

  • 组合问题
    • 币值最大化问题
    • 找零问题
    • 硬币问题
    • 背包问题
    • 最优二叉查找树
    • 最长公共子序列
  • 查找问题
    • 字符串
      • 最长上升子序列
      • 最长公共字串
      • 编辑距离

贪婪技术

贪婪法:通过一系列步骤构造问题的解,每步对目前构造的部分解 作扩展,直到得到问题的完整解

特点

  • 只能应用于最优问题,但可以作为一种通用设计技术

  • 贪婪法每步条件

    • feasible:必须可行,满足问题约束
    • locally optimal:是当前所有步骤中所有可行选择的 最佳局部选择
    • irrevocable:选择一旦做出不能更改
  • 希望通过通过一系列局部最优选择产生全局最优解

    • 有些问题能够通过贪婪算法获得最优解
    • 对于无法通过贪婪算法获得最优解的问题,如果满足于、 关心近似解,那么贪婪算法依然有价值

正确性证明

证明贪婪算法能够获得全局最优解方法

  • 数学归纳法

  • 证明在接近目标过程中,贪婪算法每步至少不比其他任何算法差

  • 基于算法的输出、而不是算法操作证明贪婪算法能够获得最优解

拟阵

典例

    • Prim算法
    • Kruskal算法
    • Dijkstra算法
  • 组合

    • 哈夫曼树(编码)

回溯法

Backtracing:每次只构造解的一个满足约束分量,然后评估 此部分构造解

  • 尝试对部分构造解进行进一步构造(构造下个分量),若 存在不违反问题约束的下个分量,则接受首个合法选择

  • 若无法得到下个分量合法选择,则不必考虑之后分量,此时进行 回溯,将部分构造解最后一个分量替换为下个选择

  • 回溯法核心就是对状态空间树进行剪枝,忽略无法产生解的分支

适合问题

  • 适合处理含有约束条件、困难的组合问题

    • 往往只需要求出feasible solution
    • 问题往往有精确解,但是没有高效算法求解
  • 回溯法目标是最终输出:n元组$(x_1, x_2, \cdots, x_n)$

    • 其中元素$x_i$为有限线性集$S_i$的一个元素
    • 元组可能需要满足额外约束

状态空间树

  • 回溯法会显式、隐式的生成一棵空间状态树

    • 树根表示查找解之前的初始状态

    • 树的第$i$层节点表示对第$i$个分量的选择

      • 应该、可以认为是经过$i$次可能选择后的由$i$个 元素组成的解分量整体$(x_1, x_2, \cdots, x_i)$
    • 叶子节点

      • 完整树中为无希望解分量、完整解之一
      • 构造中的树为无希望分量、未处理解分量之一
  • 大部分情况下,回溯算法的状态空间树按照深度优先方式构造

    • 如果当前节点(解分量)有希望,向解分量添加 下个分量下个选择得到新的节点,处理新节点

    • 如果当前节点无希望,回溯到节点父母重新处理

算法

1
2
3
4
5
6
7
8
9
10
11
12
Backtrack(X[1..i])
// 回溯算法通用模板
// 输入:X[1..i]一个解的前i个有希望的分量
// 输出;代表问题解的所有元组
if X[1..i] 是一个解
write X[1..i]
else
for 满足约束的x \in S_{i+1} do
// 不符合约束的不处理,即回溯
// 符合约束则继续深度优先搜索
X[i+1] = x
Backtrack(X[1..i+1])

Promising

  • Promising:有希望,当前解分量(节点)仍然有可能导致 完整解,满足
    • 当前解分量符合约束:新添加分量个体约束、解分量整体 约束
    • 当前解分量节点仍有未处理的子女节点
  • Nonpromising:没希望,当前解分量不满足有希望两个条件 ,无法导致完整解

注意:有希望不能采用递归定义:是否有希望是当前状态的结果, 当时并不知道、不需要知道子女状态(是否有希望)

约束判断位置

处理节点时,对子女的约束条件有两种说法

  • 添加下个分量满足约束的选择:这里是将约束说法上提前 考虑

    • 此说法可能适合约束只需要考虑最后分量的情况
    • 此种情况下的有希望只需要满足:解分量节点有未处理、 合法子女
    • 是这里回溯法部分的说法
  • 添加下个分量下个选择:这里是将约束说法上延后考虑

    • 此说法可能适合约束需要考虑解分量整体的情况
    • 此种情况下的有希望就是前面条件
    • 是这里状态空间树的说法
  • 但其实两者是一样的,只是说法不同

    • 前一个说法绘制状态空间树也同样需要 *绘制不满足约束的节点

    • 后一个说法也不定会直接把元素添加解分量中

算法特点

  • 回溯法时间效率不稳定,无法保证优良性能

    • 回溯法对状态空间树剪枝,是对穷举法的改进,避免考虑 某些无效解

    • 回溯法在最坏情况下必须生成指数、甚至更快增长的状态 空间中所有解

    • 但回溯法至少可以期望在期望时间能,对规模不是很小的 问题在可接受时间内求解

    • 即使回溯法没能消去状态空间中任何元素,其依然提供了 一种特定的解题方法,方法本身就有价值

  • 回溯法状态空间树规模基本不能通过分析法求解

    • 可以通过生成一条根到叶子的随机路径,按照生成路径中 不同选择的数量${c_i, i=1,2,\cdots,n}$信息估计规模

    • 树节点数量为:$1 + \sum{i=1}^n \prod{j=1}^i c_j$

    • 可以多做几次估计取平均值

  • 有些技巧可以用于缩小状态空间规模

    • 组合问题往往有对称性,如:n皇后问题中第个皇后只需要 考虑前一半位置

    • 把值预先分配给解的分量

    • 预排序

分支界限法

分支界限法:类似于回溯法,但是用于求optimal solution

  • 在回溯法的基础上,比较叶子节点边界值、目前最优解

    • 叶子边界值:节点对应部分解向量衍生的解集合在目标函数 值上的最优边界

    • 对最小化问题,边界值为下界;对最大化问题,边界值为 上界

  • 类似于在回溯法的约束条件中增加:节点最优边界必须超越当前 最优值

    • 随着深度增加,节点最优边界逐渐紧密,节点更容易被终止
  • 分支界限法适合问题、算法特点类似回溯法

状态空间树

分支界限空间树和节点生成顺序有关

  • best-first branch-and-bound:最佳优先分支边界策略, 在当前树未终止叶子中,选择拥有最佳边界的节点作为最有 希望节点,优先处理

    • 最优边界比较范围是全局比较,不仅仅局限于
    • 这种策略可能会得到较好的结果,消除更多分支,甚至有时 只需计算一个完整解元组就能消除其他所有分支
    • 当然,最优解最终可能属于其他分支,这种策略也不一定 能够加速算法
  • 顺序策略:类似于回溯法,优先处理最近、有希望节点

边界函数

发现好的边界函数比较困难

  • 希望函数容易计算,否则得不偿失
  • 函数不能过于简单,否则无法得到紧密边界,尽可能削剪状态 空间树分支
  • 需要对具体问题各个实例进行大量实验,才能在两个矛盾的要求 之间达到平衡

迭代策略

迭代策略:从某些可行解出发,通过重复应用一些简单步骤不断改进

  • 这些步骤会通过一些小的、局部的改变生成新可行解
  • 并使得目标函数更加优化
  • 当目标函数无法再优化时,把最后可行解返回

问题

  • 需要一个初始可行解

    • 平凡解
    • 其他算法(贪婪算法)得到近似解
    • 有些问题得到初始可行解也不简单
  • 对可行解的改变需要考虑

  • 局部极值问题

NP-Hard近似算法

NP-Hard组合优化问题即使是分支界限法也不能保证优良性能,考虑 使用近似算法快速求解

  • 近似算法往往是基于特定问题的启发式算法
  • 有些应用不要求最优解,较优解可能足够
  • 且实际应用中常常处理不精确的数据,这种情况近似解更合适
  • Heuristic Algorithm:启发式算法,来自于经验而不是数学 证明的经验规则

Perfermance Ratio

算法性能比:$R_A = \min{c|{r(s_a) \leqslant c}$

  • $r(s_a) = \frac {f(s_a} {f(s^{})}$:优化函数$f$在近似解 $s_a$下的accuracy ratio*

    • 这里$f$为最小化问题
    • 若$f$为最大化问题,则取倒数使得精确率总大于1
    • 比值越接近1,近似解质量越高
  • $R_A$即为:问题所有实例中,最差(大)精确率

    • 有些问题没有有限性能比的近似算法,如:旅行商问题 (除非$P = NP$)
  • $R_A$是衡量近似算质量的主要指标

    • 需要寻找的是$R_A$接近1的算法
    • 某些简单算法性能比趋于$\infty$,这些算法也可以使用, 只是需要注意其输出
    • 算法也被称为$R_A$近似算法

旅行商问题无有限近似比算法

  • 若存在有限近似比算法,则 $\exists c, f(s_a) \leqslant cf(s^{*})$

  • 将哈密顿回路问题图G变换为旅行商图$G^{‘}$,G中原有边距离 为1,不存在边距离为$cn+1$

  • 近似算法能在多项式时间内生成解 $s_a, f(s_a) \leqslant cf(s^{*}) = cn$,

    • 若存在哈密顿回路,则旅行商问题中最优解$s^{*} = n$
    • 否则,旅行商问题最优解$s^{*} > cn+1$
  • 则近似算法能在多项式时间解决哈密顿回路问题,而哈密顿回路 问题为NPC问题,除非$P = NP$

说明

  • 虽然大多数NP-Hard问题的精确求解算法,对于可在多项式时间 相互转换问题难度级别相同,但是近似算法不是,某些问题的 求良好近似解比其他问题简单的多

    • 因为近似算法是基于特定问题的,不具有普遍性
  • 某些组合优化难题具有特殊的实例类型,这些类型在实际应用 中比较重要,而且也容易求解

Set

集合

集合:互不相同项的无序组合

  • 要么指出集合的特殊属性,只有集合中元素才满足的特性
  • 要么显式列出集合的所有成员

存储映像

位向量存储表示

位串表示法

  • 每个集合S使用一个位串表示,位串中每位代表全集U的一个元素
  • 当且仅当全集$U$中第i个元素被包含在子集$S$中时,位向量 第i个元素为1
1
typedef int BitSet[MAX_SET_SIZE/sizeof(int)];
  • 可以实现快速的标准集合运算
  • 以使用大量存储空间为代价的

线性表存储表示

线性表表示法

  • 每个集合使用一个线性表表示,线性表中存储集合元素
1
2
3
4
typedef SqList SLSet;
// 顺序表表示集合
typedef LinkList LLSet;
// 链表表示集合
  • 集合不能包含相同元素,列表可以

    • 可以引入multisetbag绕过对唯一性的要求
    • 多重集和包是可重复项的无序组合
  • 集合是元素的组合,而列表是集合的有序组合

    • 用线性表表示集合时,不必维护线性表的有序排列

集合运算

  • 检查元素是否属于集合
  • 集合的并集
  • 集合的交集

Disjoint Set/Union Find

不相交集/并查集:由某个有限集的一系列不相交子集,及相应 操作构成

  • 通常假设集合中元素为整数,或可以映射为整数
  • 主要包括findunion操作

存储映像

  • 实现应该对findunion有特殊优化

    • 按秩合并:将包含较少结点的集合合并到含有较多结点集合
    • 路径压缩:将每个结点都直接指向根节点
  • 大多数实现会使用集合某个元素作为该集合代表

    • 有些对代表没有特殊约定
    • 有的要求代表为子集中最小元素等

树双亲表存储表示

双亲表示法

  • 使用树的双亲表示法作存储结构
1
typedef PTree MFSet;
  • 以集合中某个元素作为树根、集合名,其余所有结点都作为根 的孩子结点

  • 每个结点只能有一个双亲结点,即只能属于一个集合,适合存储 不相交集

应用

  • 生成不相交集
    • 求解无向图中连通分量数量
    • 求解等价问题:两两等价元素作为一类,求解每类元素
  • 集合归并
    • 生成迷宫:连通入口、出口连通分量

Map/Dictionary

映射/字典:能查找给定元素、增加新元素、删除元素的集合

  • 需要处理的是动态内容的查找,因此需要在查找效率和其他两种 操作中达到平衡

  • 数组、散列法、平衡查找树都可以实现字典

    ||散列表|平衡查找树| |——-|——-|———| |渐进时间效率|平均$\in \Theta(1);最坏$\in \Theta(n)$|$\in \Theta(logn)| |有序性保留|不假定键有序,不保证,不适合按序遍历、按范围查询|保证|

应用

  • Extendible Hashing:可扩充散列,用于存储磁盘上大型字典
    • 查找时先计算可能包含查找键K的存储段磁盘地址
    • 然后从磁盘中读取段中所有键,从中查找K
    • 因为存取主存开销较磁盘小很多,宁可多次存取主存

Infomation Security

Hash/摘要方法

文件校验

MD4

  • 附加填充bits:在末尾对消息填充,使得消息$M$长度满足 $len(M) mod 512 = 448$

    • 填充最高位位为1、其余为0
  • 分块:将填充后消息512bits分块为$M_1,M_2,\cdots,M_K$

  • 初始化MD4缓存值

    • MD4使用128bits缓存存储哈希函数中间、最终摘要
    • 将其视为4个32bits寄存器初始化为
      • A = 0x67452301
      • B = 0xefcbab89
      • C = 0x98badcfe
      • D = 0x10325476
  • 使用压缩函数迭代计算K个消息分块、上次计算结果

    • $H_{i+1} = C(H_i, M_i)$
    • 最终$H_K$即为MD4摘要值

MD5

SHA

数字签名

鉴权协议

排序

总述

  • 按照升序重新排列给定列表中的数据项
  • 为了让问题有意义,列表中的数据项应该能够排序(数据之间 有一种全序关系)
  • 键:在对记录排序时,需要选取的、作为排序的依据的一段信息

目的

  • 排序可能是所求解的问题输出要求
  • 排序能够更方便的求解和列表相关的问题
    • 查找问题
  • 在其他领域的重要算法中,排序也被作为辅助步骤

发展

  • 排序领域已经有很多不错的算法,只需要做$nlog_{x}^{n}$次 比较就能完成长度为$n$的任意数组排序,且没有一种基于 值比较(相较于比较键值部分内容而言)的排序算法能 在本质上操作其,

  • 但是还是需要不断探寻新的算法虽然有些算法比其他的要好, 但是没有任何算法在任何情况下是最优的

    • 有些算法比较简单,但速度较慢
    • 有些算法适合随机排列的输入,而有些适合基本有序的列表
    • 有些算法适合驻留在快速存储器中的列表,而有些适合存储 在磁盘上的大型文件排序

评价

  • 稳定性:排序算法保留等值元素在输入中的相对顺序
    • 一般来说,将相隔很远的键交换位置的算法虽然不稳定, 但往往很快
  • 在位性:排序算法不需要额外的存储空间,除极个别存储单元外

线性表排序

选择排序

  • 每轮选择出剩余元素中最小值,放在对于位置上

顺序表算法

1
2
3
4
5
6
7
8
9
10
SelectionSort(A[0..n-1]):
// 选择排序排序数组
// 输入:可排序数组A[0..n-1]
// 输出:升序排序数组A[0..n-1]
for i = 0 to n-1 do
min = i
for j = i to n-1 do
if A[j] < A[min]
min = j
swap A[i] and A[min]
  • 扫描整个列表找到最小元素,然后和首个元素交换,将其放在 最终位置上
  • 从第2个元素开始寻找之后最小元素,和第2个元素交换,将其 放在最终位置上
  • 重复n-1次,列表有序

链表算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
SelectionSort(linked):
// 选择排序排序链表
// 输入:可排序链表linked头
// 输出:排序后链表头
if linked == NULL:
return NULL
linked_head = ListNode()
// 为方便附设头结点
linked_head.next = linked
unsorted_head = linked_head
// 未排序头结点
// 后续过程中是链表中元素

while unsorted_head.next != NULL:
cur_node = unsorted_head
min_node = unsorted_head
// 全是链表中元素
while cur_node.next != NULL:
if cur_node.next.val < min_node.next.val:
min_node = cur_node
cur_node = cur_node.next

// 若`min_node.next`就是`unsorted_start.next`,以下
// 代码中的断开、重组链表操作会出现环
// 完全切开再重组链表,则需要判断`unsorted_start`
// 是否为空
if unsorted_start.next != min_node.next:
_tmp_node = unsorted_head.next
// 记录原`unsorted_head.next`

unsorted_head.next = min_node.next
// `unsorted_head.next`断开、连接`min_node`

min_node.next = min_node.next.next
// `min_node.next`断开、跳过`min_node`重连
// 若`min_node.next`是`unsorted_start.next`
// 会断开`unsorted_start`和`min_node`

unsorted_head.next.next = _tmp_node
// 原`min_node`重连原`unsorted_head.next`

unsorted_start = unsorted_start.next

return linked_head.next

特点

  • 对任何输入,选择排序键值比较都是$\Theta(n^2)$
  • 键交换次数仅为$\Theta(n)$
    • 选择排序此特性优于许多其他排序算法

冒泡排序

  • 比较表中相邻元素,如果逆序就交换位置
  • 重复多次则最大元素被“沉到”列表最后位置
  • 第2轮比较将第2大元素“沉到”其最终位置
  • 重复比较n-1轮,列表有序

顺序表算法

1
2
3
4
5
6
7
8
BubbleSort(A[0..n-1]):
// 冒泡排序排序数组
// 输入:可排序数组A[0..n-1]
// 输出:升序排序数组A[0..n-1]
for i = 0 to n-2 do
for j = 0 to n-2-i do
if A[j+i] < A[j]
swap A[j] and A[j+1]

链表算法

1
2
3
4
BublleSort(head):
// 冒泡排序排序链表
// 输入:可排序链表首个元素
// 输出:排序后列表首个元素

特点

  • 对任何输入,冒泡排序键值比较都是$\Theta(n^2)$
  • 其交换次数取决于特定输入
    • 最坏情况是遇到降序排列数组,此时键交换次数同比较次数
  • 冒泡排序看起来就像是选择排序的一直交换+最大优先版本

改进

  • 如果对列表比较一轮后没有交换任何元素,说明列表有序,可以 结束算法

Insertion Sort

插入排序:利用减一技术对数组$A[0..n-1]$进行排序

算法

  • 假设对较小数组$A[0..i-2]$排序已经解决
  • 从右至左(方便将将元素右移)扫描有序子数组,直到遇到首个 小于等于$A[i-1]$元素,将$A[i-1]$插入其后
1
2
3
4
5
6
7
8
9
10
11
12
InsertionSort(A[0..n-1])
// 用插入排序对给定数组排序
// 输入:n个可排序元素构成数组
// 输出:非降序排序数组
for i = 1 to n-1 do
v = A[i]
j = i - 1
while j >= 0 and A[j] > v do
A[j+1] = A[j]
j = j - 1
A[j+1] = v
// 上一步比较元素已经赋值给其后,所以应该覆盖其值

特点

  • 插入排序是自顶向下基于递归思想,但是自底向上使用迭代实现 算法效率更高

  • 算法时间效率

    • 算法基本操作是键值比较,比较次数依赖于特定输入
    • 最坏情况(递减数组)下:每轮比较次数都到达最大,此时 键值比较次数$\in \Theta(n^2)$
    • 最优情况(递增数组)下:每轮比较次数仅为1,此时键值 比较次数$\in \Theta(n)$
    • 对随机序列:比较次数$\in \Theta(n^2)$
    • 许多应用中都会遇到基本有序的文件,插入排序能保证良好 性能
  • 减常量法

Shell Sort

插入排序的扩展,Shell排序基于h有序

H有序

数组中任意间隔为h的元素都是有序的,对应的数组称为h有序数组

  • h有序数组:就是h个互相独立的有序数组编织在一起数组
  • h有序数组具有分离、局部有序的特点

算法

  • 使用插入排序对h子数组独立排序,每次交换相隔h的元素
  • h逐渐减小到1,shell排序退化(最后一轮)为插入排序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ShellSort(A[0..n-1])
// 用插入排序对给定数组排序
// 输入:n个可排序元素构成数组
// 输出:非降序排序数组
h = 1
while h < n/3
h = h*3 + 1
// 获取初始h值,之后每轮h变为1/3
while h >= 1:
for i = h to n-1:
v = A[i]
j = i - h
while j >= 0 and A[j] > v do
A[j] = A[j-h]
j = j - h
A[j+h] = v
h = h/3

特点

  • Shell排序全衡量子数组规模、有序性,更加高效

  • h递增序列

    • 子数组部分有序程度取决于h递增序列的选择
    • 不同的h递增序列对算法效率有常数倍的变动,但是在实际 应用中效果不明显
  • Shell排序是唯一无法准确描述其对于乱序数组性能特征的排序 方法

  • 减常量法

归并排序

Mergesort

  • 将需要排序的数组A[0..n-1]均分的
  • 对两个子数组递归排序,然后把排序后的子数组合并为有序 数组
1
2
3
4
5
6
7
8
9
10
Mergesort(A[0..n-1]):
// 递归调用MergeSort对数组排序
// 输入:可排序数组A[0..n-1]
// 输出:非降序排列数组A[0..n-1]
if n > 1:
copy A[0..floor(n/2)-1] to B[0..floor(n/2)-1]
copy A[floor(n/2)..n-1] to C[0..ceiling(n/2)-1]
Mergesort(B[0..floor(n/2)-1])
Mergesort(C[0..ceiling(n/2)-11])
Merge(B, C, A)

Merge

  • 初始两只指针指向待合并数组首个元素
  • 比较两个元素大小,将较小元素添加到新数组中,被复制元素 的数组指针右移
  • 重复直到某一数组处理完毕,将未处理完数组复制到新数组尾部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Merge(B[0..p-1], C[0..q-1], A[0..p+q-1]):
// 将两个有序数组合并为新的有序数组
// 输入:有序数组B[0..p-1]、C[0..q-1]
// 输出:存放有B、C中元素的有序数组A[0..p+q-1]
while i < p and j < q:
if B[i] <= C[j]
A[k] = B[i]
i = i + 1
else:
A[k] = C[j]
j = j + 1
k = k + 1
if i == p:
copy C[j..q-1] to A[k..p+q-1]
else:
copy B[i..p-1] to A[k..p+q-1]

特点

  • 算法时间效率

    • 最坏情况下比较次数$\in \Theta(nlogn)$,为 $nlog_2n-n+1$,十分接近基于比较的排序算法理论上能够 达到的最少次数
  • 相较于快排、堆排序

    • 合并排序比较稳定,缺点在于需要线性额外空间
    • 虽然能做到在位,但是算法过于复杂,只具有理论上意义
  • 算法可以自底向上合并数组的元素对,再合并有序对

    • 这可以避免使用堆栈递归调用时时空开销
  • 可以把数组划分为待排序的多个部分,再对其递归排序,最后 合并在一起

    • 这尤其适合对存在二级存储空间的文件进行排序
    • 也称为Multiway Mergesort
  • 分治算法

快速排序

相较于合并排序按照元素在数组中的位置进行划分,快速排序按照 元素值对其进行划分

Quicksort

  • 对数组中元素重新排列,使得A[s]左边元素均小于A[s],A[s] 右边元素都大于等于A[s]
    • 此时A[s]已经位于它在有序数组中的最终位置
  • 接下来对A[s]左右子数组进行排序
1
2
3
4
5
6
7
8
Quicksort(A[l..]r)
// 对给定可比较数组进行排序
// 输入:可比较数组A[0..n-1]子数组A[l..r]
// 输出:非降序排序的子数组A[l..r]
if l<r
s = partition(A[l..r])
Quicksort(A[l..s-1])
Quicksort(A[s+1..r])

特点

  • 需要选择合适的划分算法J

    • LomutoPartition
    • HoarePartition
  • 算法效率

    • 最优情况:分裂点都位于数组中点,算法比较次数为 $nlog_2n$
    • 最差情况:分裂点都趋于极端(逆序输入),算法比较 次数$\in \Theta(n^2)$
    • 平均:比较次数为$2nlnn \approx 1.39nlog_2n$
    • 且算法内层循环效率非常高,在处理随机排列数组时,速度 比合并排序快
  • 快速排序缺点

    • 不稳定
    • 需要堆栈存储未被排序的子数组参数,尽管可以先对较短 子数组排序使得堆栈大小降低到$O(logn)$,但是还是比 堆排序的$O(1)$差
    • 仍然存在最差情况出现的可能性,即使有很多巧妙地中轴 选择办法
    • 对随机数组排序性能的好坏,不仅与算法具体实现有关, 还和计算机系统结构、数据类型有关
  • 分治算法

算法改良

  • 更好的中轴选择方法

    • randomized quicksort:随机快速排序,使用随机元素作为 中轴
    • median-of-three method:三平均划分法,以数组最左边、 最右边、最中间的中位数作为中轴
  • 子数组足够小时(5~15),改用插入排序;或者直接不对小数组 排序,而是在快速排序结束后使用插入排序对整个近似有序的 数组进行排序

  • 改进划分方法

    • 三路划分:将数组划分为3段,小于、等于、大于中轴元素

比较计数排序

算法

  • 遍历待排序列表中每个元素
  • 计算、记录列表中小于该元素的元素个数
  • 更新大于其的元素的小于元素的元素个数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ComparisonCountingSort(A[0..n-1])
// 用比较计数法排序
// 输入:可排序数组A[0..n-1]
// 输出:将A中可排序数组按照升序排列数组
for i = 0 to n-1 do
count[i] = 0
for i = 0 to n-2 do
for j = i+1 to n-1 do
if A[i] < A[j]
count[j] += 1
else
count[i] += 1
for i = 0 to n-1 do
S[count[i]] = A[i]

特点

  • 算法效率

    • 算法比较次数为$n(n-1)/2$
    • 占用了线性数量的额外空间
  • 算法使得键值的可能移动次数最小化,能够直接将键值放在在 有序数组最终位置

  • 输入增强

分布计数排序

  • 待排序元素来自于某个已知小集合$[l..u]$

算法

  • 扫描列表,计算、存储列表中元素出现频率于数组$F[l..u]$中
  • 再次扫描列表,根据值填入相应位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DistributionCountingSort(A[0..n-1], l, u):
// 分布计数法排序,对元素来自有限范围整数的数组排序
// 输入:数组[0..n-1],数组中整数位于l、u间
// 输出:A中元素构成非降序数组S[0..n-1]
for j = 0 to u-l do
D[j] = 0
for i = 0 to n=1 do
D[A[i]-l] += 1
for j = 1 to u-l do
D[j] += D[j-1]
// 存储各元素最后出现索引+1
for i = n-1 downto 0 do
j = A[i] - l
S[D[j]-1] = A[i]
D[j] -= 1
// 更新应该存储的位置,类似于压栈
return S

特点

  • 算法效率

    • 如果元素值范围固定,效率为线性(不是基于比较的排序, $nlogn$没有限制)
    • 用空间换时间,其实可以看作是hash
    • 利用了输入列表独特自然属性
  • 变治法(输入增强)

应用

  • 判断线性表元素是否唯一
  • 寻找线性表众数
  • 快速查找

线性表顺序统计量

寻找列表中第k小的元素

  • 也即:求出给定列表中k个最小元素问题

  • 采用partitioning的思路,需要将给定列表根据某个值先行划分

    • Lumuto划分算法

QuickSelect算法

算法

  • 对划分完后出数组,s为分割位置
    • 若:s == k-1,则中轴p就是第k小元素
    • 若:s < k-1,则应该是数组右边划分第k-s小元素
    • 若:s > k-1,则应该是数组左边划分第k小元素
  • 这样就得到规模更小的问题实例
1
2
3
4
5
6
7
8
9
10
11
QuickSelect(A[l..r], k)
// 用基于划分递归算法解决选择问题
// 输入:可排序数组A[0..n-1]的子数组A[l..r]、整数k
// 输出:A[l..r]中第k小元素
s = Partition(A[l..r])
if s = l+k-1
return A[s]
elif s > l+k-1
QuickSelect(A[l..s-1], k)
else
QuickSelect(A[s+1..r], l+k-1-s)

特点

  • 时间效率:和划分算法有关(对Lomuto算法)

    • 最好情况下只需要划分一次即找到,需要比较$n-1$次
    • 最坏情况下需要比较$n(n-1)/2$次,这比直接基于排序更差
    • 数学分析表明,基于划分的算法平均情况下效率是线性的
  • 已经找到复杂算法替代Lomuto算法用于在快速选择中选出 中轴,在最坏情况下仍保持线性时间效率

  • QuickSelect算法可以不用递归实现,且非递归版本中甚至 不需要调整参数k值,只需要最后s == k-1即可

  • 减可变规模:Lomuto算法性质

线性表有序划分

LomutoPartition

算法

  • 考虑子数组A[l..r]分为三段,按顺序排在中轴p之后

    • 小于p的元素,最后元素索引记为s
    • 大于等于p的元素,最后元素索引记为i
    • 未同p比较过元素
  • 从左至右扫描A[l..r],比较其中元素和p大小

    • A[i] >= p,扩大大于等于p元素段
    • A[i] < p,需要扩大小于等于p元素段
1
2
3
4
5
6
7
8
9
10
11
12
LomutoPartition(A[l..r])
// 采用Lomuto算法,用首个元素作中轴划分数组
// 输入:可排序数组A[0..n-1]的子数组A[l..r]
// 输出:A[l..r]的划分、中轴位置
p = A[l]
s = l
for i = l+1 to r
if A[i] < p
s = s+1
swap(A[s], A[i])
swap(A[l], A[s])
return s

HoarePartition

算法

  • 选择一个元素p作为中轴(最简单的,首个元素p=A[l])

  • 从数组A[l..r]两端进行扫描,将扫描到的元素同中轴比较

    • 从左至右的扫描忽略小于中轴元素,直到遇到大于等于中轴 元素停止(从第二个元素开始i=l+1)
    • 从右至左的扫描忽略大于中轴元素,直到遇到小于等于中轴 元素停止(j=r-1)
  • 若扫描停止后

    • 两指针不相交i < j,交换A[i]、A[j],i=i+1、j=j-1, 继续扫描
    • 若指针相交i > j,把中轴和A[j]交换即得到数组一个划分 ,分裂点为s=j
    • 如果指针重合i==j,此元素一定等于p,也得到数组的一个 划分,分裂点为s==i==j
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HoarePartition(A[l..r])
// 以首元素为中轴,对数组进行划分
// 输入:可排序数组A[1..n]的子数组A[l..r]
// 输出:A[l..r]的一个划分,返回分裂点位置
p = A[l]
i = l
j = r+1
repeat
repeat i = i+1 until A[i] >= p
// 这里i有可能越界,可以添加一个限位器
repeat j = j-1 until j[i] <= p
// 从左右两端都是遇到等于p的元素就停止扫描
// 这样可以保证即使数组中有许多和p相等的元素
// 也能够将数组划分得比较均匀
swap(A[i], A[j])
// 这样写没有关系,不需要在这里给调整i、j
// 因为循环下一步就是调整i、j
until i >= j
swap(A[i], A[j])
// 撤销算法最后一次交换
swap(A[l], A[j])
return j

三路划分

算法