Algorithms I¶
约 8940 个字 1367 行代码 预计阅读时间 53 分钟
并查集 ¶
- 并查集是一种数据结构,支持如下操作:
- 合并(union
) :合并两个元素所属集合 - 查询(find
) :查询某个元素属于哪个集合,用以判断两个元素是否属于同一集合
- 合并(union
- 并查集在数学上基于等价关系和等价类
- 具有等价关系的问题,都可以抽象成并查集模型
- 合并与查询就是对元素所属等价类的合并与查询
实现 ¶
- 思路一:直接用数组 id[] 标记元素所属集合,id[] 值相同的元素属于同一集合
- 合并操作开销很大(需要遍历数组)
- 思路二:构建森林,用数组 id[] 标记元素的父节点,同一棵树上的元素属于同一集合
- 树有可能变得很高,导致查询操作开销很大(寻找根节点可能需要遍历数组)
- 优化一:加权合并
- 总是把树的节点数量 size 小的树合并到 size 大的树上
- $N$ 个元素生成的树的高度不会超过 $\log N$
- 用 size 作为权值不够理想,因为加权合并要解决的问题关键在于树的高度,节点数量大小不一定能反映树的高度大小
- 总是把树的高度 height 小的树合并到 height 大的树上
- 看起来似乎没问题,但如果同时使用了路径压缩,height 会很难维护,因为查询的时候发生了路径压缩,会多次改变树的高度
- 总是把树的秩 rank 小的树合并到 rank 大的树上
- rank 只在合并两棵 rank 一样的树的时候更新,并且每次增加 1
- 按秩合并是按高度合并的一种优化版本
- 总是把树的节点数量 size 小的树合并到 size 大的树上
- 优化二:路径压缩
- 寻找元素的根节点时,将遍历到的所有节点 id[] 直接指向根节点
- 需要递归或者再次遍历实现
- 寻找元素的根节点时,将遍历到的所有节点 id[] 指向其父节点的父节点
- 较为简单,但表现不错,每次寻找根节点可以将路径长度减半
- 寻找元素的根节点时,将遍历到的所有节点 id[] 直接指向根节点
代码 ¶
Java Implementation
复杂度分析 ¶
- Hopcroft-Ulman,Tarjan:从空的内含 $N$ 个元素的数据结构开始,任何 $M$ 次合并与查找操作都至多需要 $c(N+M \lg^{*} N)$ 次数组访问
- Fredman-Saks:并查集问题没有线性时间复杂度算法
思考题 ¶
- 设计并查集,使其支持查询任一元素所在集合的最大元,且合并、查询操作不能超过对数时间复杂度
- 思路一:额外用一个数组存储每个集合的最大元
- 思路二:总是把根节点小的树合并到根节点大的树上,查询根节点即可得到最大元
- 实际上每棵树都是一个堆(优先队列)
- 树有可能变得很高,导致查询开销超过对数时间复杂度
- 思路三:加权合并,每次合并比较两棵树的根节点大小,如果较大的根节点将被合并到另一棵树上,则交换两个根节点
- 是对思路二的一种优化,每棵树并不是堆,但是根节点存储最大元
栈和队列 ¶
- 栈和队列都是线性的数据结构
- 栈是后进先出的(LIFO: last in first out)
- 队列是先进先出的(FIFO: first in first out)
实现 ¶
- 链表实现
- 可变数组实现
- 当数组满了的时候,新建一个两倍大小的数组,并把数据迁移到新数组
- 当数组四分之一满的时候,新建一个一半大小的数组,并把数据迁移到新数组
- 当数组半满的时候 resize 是不合理的,比如在半满位置反复执行 push 和 pop 操作会导致反复 resize,效率极低
- 链表:
- 每个操作总是消耗常数时间,即使是在最坏的情况下;
- 需要消耗额外的时间和空间来处理链表结构;
- 可变数组:
- 均摊分析后具有更高的平均效率,但是消耗会集中在在临界状态(需要倍增或倍减数组时)发生;
- 浪费更少的空间;
代码 ¶
Stack: Java Implementation
Queue: Java Implementation
思考题 ¶
- 栈和队列可以相互实现
- 用双栈实现一个队列,要求出入队操作的均摊开销为常数时间
- 用双队列实现一个栈,要求出入栈操作的均摊开销为常数时间
排序基础 ¶
- 排序算法的前提是数据是可排序的,在数学上基于全序关系
- 分享一个很有意思的排序算法可视化网站:
https://www.toptal.com/developers/sorting-algorithms
排序复杂度 ¶
- 基于比较的排序算法在最坏情况下至少需要 $\log (N!) \sim N\log N$ 次比较
- 证明:决策树 + Stirling 近似
希尔排序 ¶
- 希尔排序基于一个增量序列,不断对原始数据按一定间隔取子序列进行排序
- 为什么希尔排序对于每个子序列的具体排序算法是插入排序?
- 对于增量序列中的较大值,取出的每个子序列中数据数量都很少,小规模数据插入排序的表现并不差
- 对于增量序列中的较小值,此时的数据已经几乎有序了,插入排序的表现非常好
- 基于一个事实:大增量有序的数据在小增量排序后仍然是大增量有序的,证明可以参考这里
代码 ¶
Java Implementation
复杂度分析 ¶
- 希尔排序的时间复杂度和增量序列的选取有关
- Knuth:1,4,13,40,121,364,...
- $h_{k}=3h_{k-1}+1$
- 比较容易计算实现,最坏情况比较次数为 $\Omicron(N^ {3/2})$
- Sedgewick:1,5,19,41,109,209,...
- 奇偶交替由 $(9\times 4^ i)-(9\times 2^ i)+1$ 和 $4^ i-(3\times 2^ i)+1$ 计算得到
- 在现有研究中几乎是表现最好的
- Knuth:1,4,13,40,121,364,...
- 希尔排序的最佳增量序列和最佳时间复杂度仍然是一个尚未解决的开放性问题
应用 ¶
- 希尔排序在实际应用中很有价值
- 对于小规模的数据,希尔排序的速度非常快
- 希尔排序的代码规模很小,常用于嵌入式系统等
归并排序 ¶
- 归并排序是一种高效的、通用的、稳定的基于比较的算法
- 关于归并排序的研究已经比较成熟,应用也非常广泛
- 通常的归并排序需要 $\Omicron(N)$ 的辅助空间,但已经有很多研究提出了空间复杂度为 $\Omicron(1)$ 的原地归并,但它们的实现都非常繁琐复杂,在现实中很少被应用
实现 ¶
- 自上而下(top-down)递归实现
- 优化一:递归到小规模数据时改用插入排序
- 优化二:如果待合并的两串数据已经有序,则直接返回,减少合并操作的开销
- 比较前一串数据的最大值和后一串数据的最小值
- 处理几乎有序的数组效果很好
- 优化三:每次递归时交换输入数组和辅助数组的身份,可以节省多次复制数组的时间开销
- 未优化时,每次合并操作都需要进行一次完整的数组复制,从而保证合并后的数据总是存储在输入数组中
- 优化后,合并操作不再需要数组复制,只需要在排序之初将输入数组完整复制到辅助数组,并不断交换两者的身份即可;自上而下递归能保证最终有序的数据存储在输入数组中
- 下文的代码同时实现了这三种优化
- 自下而上(bottom-up)迭代实现
- 一般情况下,自下而上迭代实现的归并排序要比自上而下递归实现的归并排序慢 10%
代码 ¶
Java Implementation
思考题 ¶
- 用归并排序计算数据中逆序对的数量
- 每次合并操作中,后段首元素被作为当前最小值取出时,前段剩余元素个数即为这次合并操作减少的逆序对数
快速排序 ¶
- 快速排序被誉为二十世纪最伟大的十大算法之一
- 关于快速排序的研究已经比较成熟,应用也非常广泛
实现 ¶
- 快速排序基本步骤
- 对数组进行随机洗牌(shuffle
) ,第一个元素作为基准元素 - 对数组进行划分(partition
) ,使得基准元素位于其最终位置,其左边的元素都不大于它,其右边的元素都不小于它 - 递归地处理划分得到的两部分
- 对数组进行随机洗牌(shuffle
- 注意:对于重复键值的处理,应当在遇到和基准元素有相同键值的元素时停止
- 虽然有些反直觉,但这确实是更优的选择,详细解释请移步重复键值
- 优化一:递归到小规模数据时改用插入排序
- 优化二:取样本中位数作为基准元素
- 最优的情况是,总是选择数据的中位数作为基准元素
- 用若干个样本的中位数来近似代替所有数据的中位数,也能取得不错的效果,比如使用 3 个随机元素的中位数作为基准元素
复杂度分析 ¶
- 对于含有 $N$ 个相异元素的数组,快排的平均比较次数为 $\sim 2(N+1)\ln N \approx 1.39N\log N$
- 最坏情况下(数据已经有序或逆序
) ,快排的比较次数 $\sim \frac{1}{2}N^ 2$ - 平均情况下,快排虽然比归并多消耗 39% 的比较次数,但运行速度比归并更快,因为快排对数据的移动操作比较少
选择算法 ¶
- 选择算法用于找第 k 大的数
- 基于快速排序的选择算法,平均情况下可以在线性时间内找到第 k 大的数
- 最坏情况下,比较次数退化为 $\sim \frac{1}{2}N^ 2$
- Blum,Floyd,Pratt,Rivest,Tarjan:基于比较的选择算法即使在最坏情况下也可以是线性的
- 但是,目前已知的能在最坏情况下保持线性的选择算法都具有较大的常数因子,所以运行速度较慢,在实践中很少被使用
- 寻找具有实用价值的能在最坏情况下保持线性的选择算法,至今仍然是一个尚未解决的问题;而在找到这样一个算法之前,基于快速排序的选择算法仍然在大多数情况下具有较高的实用价值
重复键值 ¶
- 实践中,很多时候需要排序的数据会具有大量的重复键值
- 实现一:快排,把所有和基准元素相等的元素都放在基准的某一侧
- 当所有元素都相等时,比较次数退化为 $\sim \frac{1}{2}N^ 2$
- 这是错误的快排实现
- 实现二:快排,当遇到和基准元素相等的元素时停止
- 当所有元素都相等时,比较次数仍保持 $\sim N\log N$
- 这是标准的快排实现
- 实现三:三路快排
- 将数组划分为 3 个部分,小于基准、等于基准和大于基准
- 源自 Dijkstra 提出的荷兰国旗问题
- 对于含有 $n$ 个相异元素且第 $i$ 个元素重复了 $x_i$ 次的数组,任何基于比较的排序算法在最坏情况下都需要至少 $\log (\frac{N!}{x_1!x_2!\cdots x_n!}) \sim -\sum_{i=1}^ {n}x_i\log \frac{x_i}{N}$ 次比较
- 当所有元素都相异时,上式的值为 $N\log N$
- 当数组中仅有常数种相异元素时,上式的值是线性的
- Sedgewick-Bentley:三路快排是熵最优的(可以理解为拥有最小的下界)
- 补充:Bentley-McIlroy 三路快排
- 对于小规模数据,改用插入排序
- 对于大规模数据,使用 Tukey's ninther 方法进行划分,即取 3 个样本(每个样本含有 3 个元素)的中位数的中位数作为基准元素,相当于取这 9 个元素的中位数的估计值作为基准元素
- 这种三路快排方法不需要随机洗牌,开销较小;但是缺点是容易受到 killer input 的攻击,即程序对于一些特别设计的输入会直接崩溃
- 随机洗牌在很大程度上可以避开这种攻击,但很多系统设计者不喜欢随机洗牌,因为他们觉得这种做法改变了数据的固有顺序,且开销太大
代码 ¶
Quick Sort & Quick Selection: Java Implementation
Quick Sort with 3-way Partitioning: Java Implementation
Quick Sort with Bentley-McIlroy 3-way Partitioning: Java Implementation
Quick Sort with Bentley-McIlroy 3-way Partitioning | |
---|---|
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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
|
优先队列 ¶
实现 ¶
- 数组结构的二叉堆是一种常用的优先队列实现
- d 叉堆是二叉堆优先队列的一种优化,在一些时候可以达到更好的效果
- 斐波那契堆是一种高级数据结构,可以在常数时间内实现插入、在均摊 $\log N$ 时间内实现删除最大元,但由于实现太复杂而很少在实践中被使用
堆排序 ¶
- 优点:堆排序是一种能在最坏情况下保证 $N\log N$ 复杂度的原地排序
- 缺点:
- 堆排序和归并的循环内操作太多,所以两者的速度都不如快排
- 堆排序需要跳跃式地访问数组,从而不像快排那样具有很好的内存访问局部性(make good use of cache memory)
- 堆排序和快排都是不稳定的
代码 ¶
Max Heap: Java Implementation
Heap Sort: Java Implementation
符号表 ¶
- 符号表是一种关于键值和数据对(key-value pair)的数据结构,支持如下操作:
- 搜索:给定键值,在符号表中查找相应的数据
- 插入:将具有特定键值的数据加入符号表
- 删除:给定键值,删除符号表中相应的键值和数据对
实现 ¶
- 思路一:顺序搜索
- 用链表实现符号表,每个节点都包含一对键值和数据
- 搜索、插入和删除的复杂度均为 $\Omicron(N)$
- 不需要比较操作,只需要判等操作,不支持顺序遍历符号表
- 思路二:二分搜索
- 用可变数组实现符号表,分别维护一个键值数组和数据数组,用相同的数组索引把键值和数据联系起来;数组中键值是有序的,所以可以支持二分搜索
- 搜索的复杂度为 $\Omicron(\log N)$,插入和删除的复杂度为 $\Omicron(N)$
- 支持顺序遍历符号表
- 思路三:二叉搜索树
- 具体分析请移步这里
- 最坏情况下搜索、插入和删除的复杂度均为 $\sim N$
- 平均情况下搜索和插入的复杂度均为 $\sim 1.39\log N$
- 平均情况下删除的复杂度为 $\sim \sqrt{N}$,且支持删除操作的二叉搜索树平均情况下搜索和插入的复杂度会退化为 $\sim \sqrt{N}$
- 支持顺序遍历符号表(二叉搜索树的中序遍历)
- 思路四:红黑树
- 具体分析请移步这里
- 最坏情况下搜索、插入和删除的复杂度均为 $\sim 2\log N$
- 平均情况下搜索、插入和删除的复杂度均为 $\sim 1.00\log N$,常数因子的确切数值还没有被证明,但在实践中表现得非常近似于 $1.00$
- 支持顺序遍历符号表(二叉搜索树的中序遍历)
- 思路五:哈希表
- 具体分析请移步这里
- 平均情况下搜索、插入和删除的复杂度均为常数,约为 $3\sim 5$ 次
- 最坏情况下的分析较为复杂,设计不良的哈希函数会导致最哈希表操作剧烈退化
- 不需要比较操作,只需要判等和哈希操作,不支持顺序遍历符号表
二叉搜索树 ¶
- 搜索和插入的复杂度取决于二叉搜索树的高度
- 将 $N$ 个相异元素以随机顺序插入二叉搜索树,搜索和插入的比较次数为 $\sim 2\ln N \approx 1.39\log N$
- 证明:与快排的划分构成一一映射
- Reed:将 $N$ 个相异元素以随机顺序插入二叉搜索树,树的高度为 $\sim 4.311 \ln N$
- 最坏情况下树的高度为 $N$
删除操作 ¶
- 思路一:懒惰
- 直接将需要删除的节点的数据置为 null
- 如果删除操作很多,会导致内存浪费严重
- 思路二:Hibbard 删除
- 对于没有子节点的节点,直接删除
- 对于有一个子节点的节点,用其子节点替代它
- 对于有两个子节点的节点,用右子树的最小值替代它
- 弊端:Hibbard 删除是非对称的,会导致树趋于不平衡
- 研究发现,经过足够多次的随机插入和删除后,树的高度趋于 $\sqrt{N}$;即使 Hibbard 删除时尝试在左右子树之间随机选择,也仍然没有效果
- 寻找一个简单高效的二叉搜索树删除方法,至今仍然是一个尚未解决的开放性问题
代码 ¶
BST: Java Implementation
Binary Search Tree with All Symbol Table Operations | |
---|---|
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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
|
平衡搜索树 ¶
- 支持 Hibbard 删除操作的二叉搜索树最大的问题在于不平衡,因而树的高度会失去对数保证
- 平衡搜索树可以解决这个问题,有红黑树、B 树等多种实现方式
2-3 树 ¶
- 实现:
- 存在 2-node 和 3-node 两种节点
- 搜索操作相当于结合了二叉搜索和三叉搜索
- 插入操作将叶节点升阶,如果形成 4-node 则分裂该节点并将中间值向上传递
- 如果根节点形成 4-node 则分裂成三个 2-node,当且仅当此时 2-3 树的高度加一
- 这种分裂方法是局部的,只需要常数数量的操作即可实现
- 分析:
- 2-3 树总是保持完全平衡
- 最坏情况下(全部是 2-node)树的高度为 $\log N$
- 最好情况下(全部是 3-node)树的高度为 $\log _{3}N \approx 0.631\log N$
- 搜索、插入和删除的复杂度均为 $\sim c\log N$,常数因子取决于具体实现
- 具体实现:
- 2-3 树的具体实现比较复杂,需要维护两种节点,而且需要在树上上下移动并进行多次比较
- 相较之下,红黑树是一种更加简单高效的实现方法
红黑树 ¶
- 红黑树使用二叉搜索树的结构实现了 2-3 树
- 用左斜的红色链接来表示 3-node
- 所有节点至多有一条红色链接与之相连
- 从根节点到所有叶节点的黑色链接数都是相同的,即“黑色平衡”
- 红黑树与 2-3 树可以构建一一映射
- 实现:
- 搜索操作和二叉搜索树完全一致,但速度更快(因为更加平衡)
- 插入操作需要保持红黑树的特性不变,包括以下三种子操作:
- 与右儿子通过红色链接相连,与左儿子通过黑色链接相连,则向左旋转红色链接
- 与左儿子通过红色链接相连,左儿子与其左儿子通过红色链接相连,则向右旋转红色链接
- 与左右儿子均通过红色链接相连,则将这两个链接变换为黑色,将指向自身的连接变换为红色
- 删除操作更加复杂,但本质上也是需要保持红黑树的特性不变
- 分析:
- 红黑树的高度在最坏情况下不会超过 $2\log N$
- 红黑树的典型实现在平均情况下树的高度约为 $\sim 1.00\log N$,常数因子的确切数值还没有被证明,但在实践中表现得非常近似于 $1.00$
B 树 ¶
- B 树是一种泛化的 2-3 树,或者说 2-3 树是 B 树的特例(4 阶 B 树)
- B 树适合读写相对较大数据块的存储系统,比如数据库和文件系统
- 这种存储系统的特点是,数据块以页(page)为单位存储,寻路(probe,查询特定的某一页)的开销是很大的,而在同一页中读取不同键值的开销相比之下几乎可以忽略;因此提高效率的关键在于减少寻路的次数
- 实现:
- 对于 M 阶 B 树而言,每个节点至多是 M-1 阶的(即有 M-1 个键值和 M 个链接
) ,通常选择一页能存储的键值数作为 M - 根节点至少是 2 阶的
- 其他节点至少是 M/2 阶的(半满)
- 外节点(external node)是 B 树的叶节点,存储数据块的键值
- 内节点(internal node)是 B 树的非叶节点,存储其各个子节点数据块的第一个键值,用于支持 B 树的搜索操作
- 当某个外节点变成 M 阶时(全满
) ,分裂该节点,具体操作类似于 2-3 树;当且仅当根节点分裂时 B 树的高度加一
- 对于 M 阶 B 树而言,每个节点至多是 M-1 阶的(即有 M-1 个键值和 M 个链接
- 分析:
- 对于包含 $N$ 个键值的 $M$ 阶 B 树,搜索和插入只需要 $\log _ {M-1} N \sim \log _ {M/2} N$ 次寻路
- 在实践中,寻路次数通常不超过 4 次,比如 M = 1024,N = 62 billion
- 优化:总是把根节点那一页数据块存储在内存中
- 应用:
- 红黑树被广泛应用在系统的符号表中
- B 树及其变式(B+ 树、B* 树、B# 树等)被广泛应用在数据库和文件系统中
代码 ¶
Red-Black BST: Java Implementation
Left-Leaning Red-Black Binary Search Tree | |
---|---|
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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 |
|
BST 的几何应用 ¶
- 前面讨论的 BST 都是以数值作为键值的,下面讨论的 BST 会以几何对象作为键值,比如点、线段、区间等
一维范围搜索 ¶
- 数据结构:二叉搜索树(BST)
- 一维范围搜索(1d range search)研究的是一维分布的点,每个点对应一个 BST 节点
- 除了基本的搜索、插入和删除操作外,还支持以下两种操作:
- 范围搜索:查询处于区间 [m, n] 之间的所有点
- 范围计数:计算处于区间 [m, n] 之间点的个数
- 复杂度分析,$R$ 表示处于区间之间的点的个数
- 范围搜索复杂度为 $\Omicron(R+\log N)$
- 范围计数复杂度为 $\Omicron(\log N)$,通过 BST 的 rank 操作实现
二维正交线段相交问题 ¶
- 算法:通过扫描线算法(sweep-line)转化为一维范围搜索问题
- 二维正交线段相交问题(orthogonal line segment intersection)研究的是二维空间上正交的线段的交点
- 具体实现:
- 用一条竖直的线自左向右扫描(以左右端点的横坐标定义事件,可以用优先队列或者排序实现)
- 遇到水平线段的左端点,将其纵坐标插入 BST
- 遇到水平线段的右端点,将其纵坐标从 BST 中删除
- 遇到竖直线段,以其两端点纵坐标作为范围,对 BST 进行一维范围搜索,找到所有与其相交的线段
- 在 $N$ 条正交线段中查询 $R$ 个交点的时间复杂度为 $\Omicron(R+N\log N)$
一维区间搜索 ¶
- 数据结构:区间搜索树(BST 的一种变式)
- 一维区间搜索(1d interval search)研究的是一维分布的区间,每个区间对应一个 BST 节点
- 除了基本的搜索、插入和删除操作外,还支持以下操作:
- 相交区间查询:给定一个区间 [lo, hi],找到所有与这个区间有交集的区间
- 具体实现:
- 区间搜索树以区间左端点作为搜索的键值(假设所有区间的左端点都是相异的)
- 区间搜索树上的每个节点需要额外存储一个值,表示其子树中所有区间的右端点的最大值
- 复杂度分析,$R$ 表示与给定区间有交集的区间个数
- 区间搜索树实现的一维区间搜索,查询任一满足条件的区间所需复杂度为 $\Omicron(\log N)$
- 区间搜索树实现的一维区间搜索,查询所有满足条件的区间所需复杂度为 $\Omicron(R\log N)$
- 理论上最优复杂度为 $\Omicron(R+\log N)$,不过 $\Omicron(R\log N)$ 的实践表现也非常有效
二维正交矩形相交问题 ¶
- 算法:通过扫描线算法(sweep-line)转化为一维区间搜索问题
- 二维正交矩形相交问题(orthongonal rectangle intersection)研究的是二维空间上正交的矩形的相交关系
- 具体实现:
- 用一条竖直的线自左向右扫描(以左右边线的横坐标定义事件,可以用优先队列或者排序实现)
- 将与扫描线相交的矩形维护在区间搜索树上(以纵坐标区间来表示)
- 遇到矩形的左边线,以其纵坐标区间作为给定区间,对区间搜索树进行一维区间搜索,找到所有与其相交的矩形,并将其插入区间搜索树
- 遇到矩形的右边线,将其从区间搜索树中删除
- 在 $N$ 个正交矩形中查询 $R$ 对相交关系的时间复杂度为 $\Omicron(R\log N+N\log N)$
Kd 树 ¶
- 前面讨论的符号表都是以数值作为键值的,下面讨论一种以二维空间中的点作为键值的符号表,并进一步推广到 K 维
- 以二维空间中的点作为键值的符号表,除了基本的搜索、插入和删除操作,还支持范围搜索和范围计数
- 实现思路一:网格法
- 将平面分割成 $M\times M$ 的网格(通常取 $M=\sqrt{N}$)
- 对每个网格维护一个列表来存储点,用二维数组直接索引所有网格
- 范围搜索时只对与给定范围有交集的网格进行搜索
- 最好情况下(所有的点均匀分散)插入和范围搜索的复杂度均为 $1$
- 最坏情况下会很糟糕,几乎所有的点都聚集在同一个网格附近(cluster
) ,列表维护和范围搜索会开销很大
- 实现思路二:2d 树
- 递归地将平面不断分割成 2 个半平面
- 维护一个 BST,交替使用横坐标和纵坐标作为键值,奇数层的节点根据横坐标大小分划左右子树(左半平面和右半平面
) ,偶数层的节点根据纵坐标大小分划左右子树(下半平面和上半平面) - 范围搜索:
- 检查当前节点是否满足条件(即处在给定范围之中
) ,然后递归地搜索可能包含满足条件的节点的左子树和右子树 - 平均情况下复杂度为 $\sim R+\log N$,最坏情况下复杂度为 $\sim R+\sqrt{N}$(即使树是平衡的)
- 检查当前节点是否满足条件(即处在给定范围之中
- 最近邻搜索:
- 给定一个查询点,搜索平面中与其距离最近的点
- 搜索思路和范围搜索相似,朝着查询点所在方向不断递归,并不断更新最近邻(额外维护一个数值来存储最近邻的信息)
- 注意对于任一节点,一侧子树是查询点所在方向,需要递归,另一侧子树不是查询点所在方向,可能需要递归(判断方法是,比较以查询点为中心、以当前最近邻距离为半径的圆是否与当前节点表示的平面分割线相交,若相交,则该侧平面可能存在更近邻,需要递归)
- 平均情况下复杂度为 $\sim \log N$,最坏情况下复杂度为 $\sim N$(即使树是平衡的
) ,此时相当于所有节点都被访问了,试考虑一个平面内所有点都在一个圆上且查询点为圆心这一情形
- 思路二本质上是用树的结构不断递归地对平面作划分,除了 2d 树之外,还可以使用四叉树、BSP 树等
- Kd 树:是 2d 树的推广,用以表示 K 维空间中的点作为键值的符号表
- 递归地将 K 维空间不断分割成 2 个半空间
- 维护一个 BST,按照同余关系轮换使用 K 维坐标作为键值,对于一个处于第 level 层的节点而言,设 level $\equiv$ i (mod) K,其左子树表示第 i 维坐标小于该节点的半空间,其右子树表示第 i 维坐标大于该节点的半空间
- 最近邻搜索的递归判断:比较以查询点为中心、以当前最近邻距离为半径的超球面是否与当前节点表示的分割空间的超平面相交
- Kd 树的应用:
- Kd 树将 N 体模拟中的每一步操作时间复杂度降到 $N\log N$,极大促进了 N 体模拟及其衍生研究的发展
哈希表 ¶
- 哈希表是一种通过哈希函数将键值映射到数组的索引值来组织数据的结构
- 哈希表是时间空间权衡的一个典型例子
- 如果空间无限,则可以直接将键值作为索引,通过单次内存访问来检索数据
- 如果时间无限,则可以直接存储数据而不考虑它们的键值,通过顺序搜索来检索数据
- 但现实中时间空间都是有限的,需要精心设计哈希函数,来保证处理效率和数据的均匀分布
- 理想的哈希函数应当将每个键值映射到唯一的索引;但大多数哈希表无法实现这样理想的哈希函数,因而会导致哈希冲突
- 理想的哈希函数应当将每个键值等可能地映射到索引上,即将数据均匀地分布在哈希表中;目前对于均匀哈希函数已经有了很多研究,但在现实应用中如何实现均匀哈希仍然是一个棘手的问题
组合数学 ¶
- 把球逐个随机放进 $M$ 个格子中,首次出现两个球落在同一格子时已经放入的总球数期望为 $\sim \sqrt{\pi M/2}$
- 把球逐个随机放进 $M$ 个格子中,当所有格子中都至少有一个球时已经放入的总球数期望为 $\sim M\ln M$
- 把球逐个随机放进 $M$ 个格子中,放入 $M$ 个球后装载球数最多的格子的球数期望为 $\Theta(\log M / \log \log M)$
- Knuth 停车问题:
- 在一条有 $M$ 个停车位的单行道上寻找停车位,假设每辆车随机从某个位置开始准备找车位,求找到车位前需要经过的已经被停的车位数的期望是多少?
- 如果一共要停入 $M/2$ 辆车,期望为 $\sim 3/2$
- 如果一共要停入 $M$ 辆车,期望为 $\sim \sqrt{\pi M/8}$
冲突解决 ¶
- 思路一:链式地址法(separate chaining)
- 在均匀哈希假设下,每个链表中的键值数是 $N/M$ 的常数倍的概率非常接近 $1$
- 证明:链表中的键值数随 $N/M$ 的分布遵循二项分布,其中 $N$ 是哈希表中的总键值数,$M$ 是哈希表中的链表条数
- 通常取 $M\sim N/5$,均匀哈希假设下的搜索和插入所需要的访问次数均为常数
- 在均匀哈希假设下,每个链表中的键值数是 $N/M$ 的常数倍的概率非常接近 $1$
- 思路二:开放寻址法(open address
) ,线性探测(linear probing)- 在均匀哈希假设下,在大小为 $M$、存储键值数为 $N=\alpha M$ 的哈希表中进行搜索或插入,所需的平均探测次数为:
- 对于成功的搜索:$\sim \frac{1}{2}(1+\frac{1}{1-\alpha})$
- 对于失败的搜索或者插入:$\sim \frac{1}{2}(1+\frac{1}{(1-\alpha)^ 2})$
- 通常取 $\alpha=N/M\sim ½$,成功搜索和失败搜索的平均探测次数分别为 $3/2$ 和 $5/2$
- 在均匀哈希假设下,在大小为 $M$、存储键值数为 $N=\alpha M$ 的哈希表中进行搜索或插入,所需的平均探测次数为:
- 补充:单向哈希函数(one-way hash functions)
- 单向哈希使得给定哈希值求解其哈希原像(能映射成该哈希值的键值)这种行为变得很难实现,从而具有较好的安全性和加密性
- 单向哈希的运算开销很大,并不适用于符号表,但在信息安全领域具有很大的应用价值
- 优化一:双探针哈希(two-probe hashing)
- 是链式地址法的一种变体
- 用两个哈希函数将每个键值映射成两个哈希值,并将其存储到两个链表中较短的一个中
- 减少了链表的长度,最长链表的长度期望减为 $\sim \log \log N$
- 优化二:双哈希(double hashing)
- 是线性探测法的一种变体
- 每次解决冲突时,用另一个哈希函数计算探测距离
- 极大程度上避免了 cluster 的发生,但也增大了操作开销
- 优化三:布谷鸟哈希(Cuckoo hashing)
- 用两个哈希函数将每个键值映射成两个哈希值,并将其插入两个位置中的任意一个;如果该位置被占了,则将占据该位置的键值重新插入到其另一个哈希值对应的位置,并不断重复这样的操作直至不再发生冲突
- 可以将搜索的最坏时间控制在常数
分析 ¶
- 链式地址法与线性探测法比较
- 链式地址法可以减少 cluster 的发生
- 线性探测法可以减少空间损耗,且具有较好的内存访问局部性
- 哈希表与平衡搜索树比较
- 哈希表的代码更简洁,对于简单键值来说速度很快,而且如果键值是不可比较大小的(但可以判断是否相等
) ,则只能使用哈希表 - 平衡搜索树具有很强的性能保证,不容易在最坏情况下发生退化,而且对于可比较大小的键值而言,实现比较操作比起实现判等和哈希操作更加简单自然
- 哈希表的代码更简洁,对于简单键值来说速度很快,而且如果键值是不可比较大小的(但可以判断是否相等
符号表的应用 ¶
查找表 ¶
- 字典查找(dictionary lookup)
- 常用 CSV 文件作为查找表
- 例如:DNS 查找,CSV 同一记录包含域名、IP 地址等字段信息
- 关键字搜索
- 例如:搜索引擎、文件资源管理
稀疏矩阵 ¶
- 一维稀疏向量:用符号表记录非零数值及其位置
- 可以实现常数时间的向量点乘
- 二维稀疏矩阵:用符号表数组记录每行的一维稀疏向量
- 可以实现线性时间的向量点乘
最后更新:
2024年10月14日 08:08:42
创建日期: 2024年2月8日 16:17:32
创建日期: 2024年2月8日 16:17:32