Skip to content

数据结构&算法之美

基础

Remember: 所有数据的存取只有两种方式:线性和非线性。也就是数组和链表。所以不可能有其他数据结构的。高级的数据结构都是这二者的算法组合。

散列表HashTable

就是 JS 的 Map! 或是 Java 的 HashMap!

实质

将key值通过散列函数映射进对应的数组 index,存入数组中。

重难点

  • 装载因子
    $散列表里的数据个数 \over 散列表长度$
  • 散列冲突
    通过散列函数到数组 index 的映射处理结果可能碰撞,尤其是数组长度越小,碰撞可能性越大。
  • 数组存储 > 开发寻址法: 数据较小
    对应实际情况,插入时,找到计算过后的数组对应位置。若该位已被占有,则会往后顺序查看,将数据放到第一顺位空位中。
    取值时,找到数组对应位置,若读出来 key 不是当前取值 key,则会往后继续寻找,若遇到第一个空位(p.s. 数据被删除后留下的空位会有特殊标记 不算)还没有找到,则说明当前数组没有此 key。
  • 链表存储 > 链表法: 数据较多
    数组的每一位/槽 (slot) 里以链表的形式存储所有值。
    在链表到达一定长度时,可升级为红黑树~
  • 动态扩容
    有个小优化的点。可以不一次性搬移数据到新数组,而是将阈值设为多个,慢慢的搬数据。因此在某个时间段上可能维护两个数组。

复杂度

所以严格意义上并不是O(1),按存储方式,均需要加上散列冲突带来的多次寻址消耗。

实际应用

LRU缓存淘汰算法 / LinkedHashMap

两者都在 hashmap 的基础上搭配了链表:

其实可以想成是对于链表的扩充。正因为链表不好以 O(1) 查找元素,所以才想办法通过固定来算法来决定链表元素的存储位置,实现以数组的方式来定位链表。当然反向来说,双向链表能以 O(1) 增删改元素,这也是对 hashmap 的反向加强。

Redis 有序集合

一个数据可能由多种数据结构同时维护,比如 redis 的跳表和 hashmap。前者适合以值区间查找,后者适合以键精确查找。

哈希算法

原则

将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。 根据鸽巢理论,即无限的东西不能放进有限的坑里,所以一定会有碰撞。

用处
  • 加密 //md5摘要
  • 唯一标志 比如搜图片,不能拿图片名,拿图片二进制又比较耗时。所以我们可以给每一个图片取一个唯一标识,或者说信息摘要。可以从图片的二进制码串开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法得到一个哈希字符串,用它作为图片的唯一标识。
  • 数据校验
  • 散列函数
  • 分布式系统:比如负载均衡、数据分片、分布式存储 关键在于需要将内容存储在不同的机器上,那么为了保持内容在机器上的存储一致性,需要确定哪块内容需要放到哪个机器上。通常会将内容哈希后对机器个数取模,结果即是。这样每次查找时都可以明确知道要去哪个机器上查找了,避免遍历所有机器。 但有个缺陷是所谓「雪崩效应」,即如果扩/缩容后,内容的存储机器可能会发生很大的不一致。为了避免每块内容的存储位置都发生变化,需要「一致性哈希算法」,真实做法是想成一个圆环,数据存储于顺时针遇到的第一个机器上。同时为了避免机器在圆环上分布偏向一角,可启用虚拟节点,即子节点的n个copy。ref

(二叉)树

基础

术语

(节点的)高度、深度、层数(从1开始)

分类
  • 满二叉树
  • 完全二叉树。除了最后一层的右侧可以有空位,其余位置都不行。 因为按数组存的时候不会有空位。p.s. 对于完全二叉树来说,下标从 2n+1 到 n 的都是叶子节点。
存储
  • 链式
  • 数组
遍历
  • 前序、中序、后序 其中中序遍历可以实现以 O(n) 的形式输出有序数据序列
  • 层级

二叉查找树

定义:左侧 < 中侧 < 右侧,若有重复数据,可以选择节点链表式存储,亦可以当做比当前节点小于等于的方式往右节点存。

操作
  • 查找、新增都简单
  • 删除:
  • 如果目标节点是叶子节点直接删;
  • 如果目标节点下只有一个节点,直接更新父节点的指针指向子节点;
  • 如果目标节点下有两个节点,找到右侧节点链里最小的节点A,替换到目标节点。A必定没有左子节点,所以再按上述规则删除A;
  • 时间复杂度 即 O(height)。最坏情况(只有单边链)下退化成链表 O(N),最好情况(完全二叉树)下是 O($log_2n$),看图即知和二分法查找很类似。
v.s. O(N)的散列表
  • 排序方便:中序遍历 O(N) 即可有序输出;散列表排序难
  • 散列表更复杂,比如扩缩容散列冲突

红黑树

近似于平衡二叉树(树的高度约为 $log_2n$),避免普通二叉树在频繁更新下,复杂度退化的现象。

定义
  • 根节点是黑色节点、叶子节点为空且也是黑色节点
  • 每条路的黑色节点数一致
  • 一条路上的红色节点需被黑色节点隔开

如何去思考?如果把红色节点都移除掉就相当于四叉树,高度肯定 < $log_2n$,那红色节点的高度没有黑色高,所以加起来也肯定 < $2log_2n$ 即符合要求。

实现

分为插入和删除,前者相对简略一些。和魔方公式很像,case by case来,就不背啦!

使用二叉树直观化复杂度

如果每层的计算复杂度一致,则是 每层复杂度*height;如果每层不一致,则每层复杂度+ ... +每层复杂度(一共height个)。 举个快排的例子,如果每次数据分为 $1/ 9$和$8/9$时,最短的一边是每次都$1/ 9$的那边,一共 $nlog_9n$;最长的那边则是 $nlog_{9/8}n$。

每个节点都比子节点大/小的完全二叉树

操作
  • 插入:插入到最后,从下到上堆化 复杂度logn
  • 删除:将最后一个元素移至该位,从上到下堆化 复杂度logn
基于堆的排序

可以先将数组原地堆化…再排序 疯狂交换。整个复杂度也是$nlogn$。

实践

在我心中,这些实践=方案+存储方式。方案是最重要的,存储方式上堆可以被数组替代,只是效率要从 logn 降到 n。(总之…我不是很喜欢堆…可能暂时还不理解真实使用场景。)

比如合并有序小文件,动态求Top k数据,动态求中位数、90百分位数等。原理都是一样的。利用两个堆,一个大顶堆,一个小顶堆,随着数据的动态添加,动态调整两个堆中的数据,最后大顶堆的堆顶元素就是要求的数据。

术语

入度、出度、有向图、无向图、带权图

存储方式

  • 二维数组:邻接矩阵(Adjacency Matrix)两个有联系的点分别作为数组的index值。比较直接,但耗空间。
  • 散列表:邻接表 每个index项都会以链表/二叉树的形式存储与这个点有关的其他点

实践

好友关系、交通图、思维导图