数据结构总结(待补充)
跳表
由来
二分查找算法的底层依赖是数组的随机访问特性, 所以只能用数组来实现. 但如果数据存储在链表中, 对其稍加改造, 其实也是可以用二分查找的
特性
是各方面都优秀的动态数据结构, 可以支持快速的插入, 删除, 查找操作; 且实现相比于红黑树简单, 甚至某些场合可以代替红黑树
实现
链表 + 多级索引: 每隔两个节点(也可以是多个)都抽出一个节点作为上一级索引的节点!
查找
假设原始链表共有 \(n\) 个节点, 则需要 \(h = \log_2 n - 1\), 而每一层最多需要遍历 3个节点, 则查找一个数据的时间复杂度就是 \(O(3 * \log n) = O(\log n)\)
插入和删除
先用 \(O( \log n)\) 查找, 再用 \(O(1)\) 插入或删除节点
占用空间
沿用上面的结论, 第一级索引大约有 \(\dfrac{n}{2}\) 个节点, 第二级大约有 \(\dfrac{n}{4}\) 个节点, ...(最后一级索引数为2)以此类推构成等比数列; 所以索引的总个数为 \(n-2\)
动态更新
作为一种动态数据结构, 需要某种手段来维护索引与原始链表大小之间的平衡; 也就是链表中节点多了, 索引节点也应相应的多一点, 避免复杂度退化, 以及查找, 插入, 删除操作性能下降
链表同过随机函数来维护所谓的"平衡性": 当往跳表中插入数据时, 我们可以选择同时将这个数据插入到部分索引层中, 而随机函数就是用来决定插入到哪几级索引中; 比如随机函数生成了值 K, 那我们就将这个节点添加到第1级到第K级这K级索引中

适用场景
功力不足, 待补充
散列表
由来
由数组演化而来, 借助数组支持下标随机访问的特性, 通过散列函数把元素的键值映射为下标, 然后将数据存储在数组中对应下标的位置
核心: 散列函数设计 和 散列冲突解决
散列函数
设计散列函数的基本要求:
- 散列函数计算得到的散列值是一个非负整数
- 如果 \(key1 = key2\), 那 \(hash(key1) = hash(key2)\)
- 如果 \(key1 \neq key2\), 那 \(hash(key1) \neq hash(key2)\)
强调一下第三点: 在真实情况下, 要找到一个不同的 key 对应的散列值都不一样的散列函数几乎不可能的. 而且, 因为数组的存储空间有限, 也会增大散列冲突的概率; 所以我们无法找到一个完美的无冲突的散列函数, 所以针对散列冲突问题, 需要通过其它途径来解决.
设计方法:
数据分析法
例如: 通过学生的学号记录学生的信息, 那么一般学号的前几位都是一样的, 可以只取最后几位来作为散列地址
直接寻址法
取key或key的某个线性值作为散列地址: \(hash(key) = a * key + b\)
随机数法
选择一个随机函数, 把key的随机函数值作为散列地址: $hash(key) = myRandom(key) $
散列冲突
常用的解决散列冲突的方法有两种: 开放寻址法和链表法.
开放寻址法
核心: 如果出现了散列冲突, 就重新探测一个空闲位置, 将其插入. 探测的方法有以下几种; 但不管用哪种探测方法, 当散列表中空闲位置不多时, 散列冲突的概率就会大大提高, 为了尽可能保证散列表的操作效率, 需要一个合适的"装载因子"
探测方法:
线性探测:
当往散列表插入数据时, 如果某个数据经过散列函数处理得到的索引位置已经被占用, 那么就从当前位置开始一次往后查找, 直到找到一个空闲位置

二次探测
每次探测的步长为"二次方": \(hash(key) + 0\), \(hash(key) + 1^2\), \(hash(key) + 2^2\)
双重散列
意思是使用一组有优先级的 hash( )方法; 有"优先级"值得是先用 \(hash1(key)\) , 如果计算出的存储位置已经被占, 那么再用 \(hash2(key)\)... 以此类推
链表/红黑树法
这是一种更加常用的散列冲突解决办法; 在散列表中每个"桶(bucket)" 或者 "槽(slot)"会对应一个链表/红黑树, 所有发生哈希冲突的元素都放到这个链表/红黑树里

R-B Tree
由来
由于二叉查找树在频繁的动态更新中, 可能会出现数的高度远大于 \(\log_2 n\), 接近 \(n\) 的情况, 从而导致各个操作的效率下降. 极端情况下会退化为链表, 查找操作会退化到 \(O(n)\), 而解决这个问题就需要这个"二叉查找树"维持"平衡", 即平衡二叉查找树
平衡二叉查找树的严格定义: 二叉树中的任意一个节点的左右子树的高度相差不能大于1; 例如 AVL树
但很多平衡二叉查找树其实并没有严格符合上述定义, 例如红黑树等;
解释: AVL树是一种高度平衡的二叉树, 所以查找的效率非常高, 但是为了维持这种高度的平衡, 就需要付出更多的代价, 每次插入, 删除都需要做调整, 复杂又耗时. 而红黑树只是做到了近似平衡: 高度近似 \(2\lg(n + 1)\), 维护成本比 AVL树低
证明:
首先, 由性质4得任一节点的左子树个右子树具有相同的黑节点. 而且和父节点含有的黑节点数的关系是 相等(红)或者 -1(黑)
假设 \(bh(x)\) 表示 \(x\) 节点到叶子节点所含的黑节点个数(不包含当前节点), 那么\(x\) 节点的左子树和右子树包含的黑节点个数(不包含子树的根节点)都是 \(bh(x)\) 或 \(bh(x)-1\)
那么可以推得整个红黑树的节点个数 \(n\) 至少为 \(2^{bh(t)} - 1\), 也即 $ n \ge 2^{bh(t)} - 1$ , 其中 \(t\) 是红黑树的根节点
假设红黑树整体的高度为 \(h(t)\) , 那么一定有 \(h(t) \le 2*bh(t)\)
联系上面两个式子, 有 $ n \ge 2^{bh(t)} - 1 \ge 2^{\frac{h(t)}{2}} - 1$
解得 $ h(t) \le 2\log_2 (n + 1)$
特性
- 根节点是黑色的;
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
适用场景
红黑树是一种性能非常稳定的二叉查找树, 插入, 删除, 查找等操作都是\(O(logn)\) 所以在工程中, 但凡用到动态插入, 删除, 查找数据的场景都可以用到它. 不过它的实现比较复杂, 某些场景下, 更倾向于用跳表来替代它
Heap
由来
堆是一个完全二叉树,且每个节点值都必须大于等于(或小于等于)其子树中每个节点的值。
完全二叉树适合用数组来存储,单纯通过数组的下标就可找到节点的左右节点和父节点,节省空间。例如:

可以看到:数组中下标为 \(i\) 的节点,左节点为 \(i * 2\),右节点为 \(i * 2 + 1\) 父节点为 \(\frac{i}{2}\) 。且从 \(\frac{n}{2} + 1\) 到 \(n\) 都是叶子节点
堆排序
// n为数据的个数,在 a 中是从下标 1 到下标 n(大顶堆)
public static void sort(int[] a, int n) {
buildHeap(a, n); // 建堆 O(n)
int k = n;
while (k > 1) {
swap(a, 1, k); // 将堆顶最大数移到最后一位
heapify(a, --k, 1); // 从堆顶开始,对 k - 1 个元素堆化 O(log n)
}
}
private static void bulidHeap(int[] a, int n) {
for (int i = n / 2; i >= 1; --i) { // 从后往前(除叶子节点外)
heapify(a, n, i);
}
}
private static void heapify(int[] a, int n, int i) {
while (true) { // 这里是 while !!
int maxPos = i;
if (i * 2 <= n && a[i] < a[i * 2]) maxPos = i * 2;
if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1]) maxPos = i * 2 + 1;
if (maxPos == i) break;
swap(a, i, maxPox);
i = maxPos;
}
}
其中堆化的过程如图:

时间复杂度:\(O(n * \log n) = O(n\log n)\)
不稳定:堆顶节点的值可能和最后一位节点的值相同,这时再交换就改变了这两个数据的原始相对顺序
缺点:
- 堆排序的数据访问方式是跳着访问的,对 CPU缓存不太友好
- 同样的数据,排序过程中数据交换的次数多于快速排序
堆化
堆化是指将一个无序的完全二叉树转化为一个堆的过程,有两种方式实现:从上往下的下滤和从下往上的上滤。
以下滤为例:
下滤是将一个节点与其子节点进行比较,如果不满足堆的性质,就交换节点的位置,然后递归地对交换后的节点进行下滤操作,直到整个堆满足堆的性质为止的过程。
具体的操作过程如下:
- 从堆的根节点开始,将根节点与其左右子节点进行比较,找出最大的节点。
- 如果根节点不是最大的节点,就将根节点与最大的子节点进行交换。
- 然后将当前节点指向交换后的子节点,并重复步骤1,直到节点的子节点都比当前节点小或者已经到达叶子节点为止。
以上是对于现有的完全二叉树转化为堆的过程,那么对于一个堆,在添加或者删除元素时,是如何维护大顶堆/小顶堆的性质呢?以大顶堆为例:
插入操作:向大顶堆中插入一个新的元素时,首先将新元素插入到堆的最后一个位置(保持完全二叉树的结构),然后通过上滤操作将其上移,直到满足大顶堆的性质。
删除操作:从大顶堆中删除根节点(即删除最大值)时,首先将堆的最后一个元素移动到根节点的位置,然后通过下滤操作将其下移,直到满足大顶堆的性质。
应用
- 优先级队列:合并文件、高性能定时器
- 求 Top K:维护大小为 K 的小顶堆,让其它元素与堆顶比较
- 求中位数,或者90百分位数据,99百分位数据:维护两个元素数量成比例的大顶堆和小顶堆,返回大顶堆的根节点即可
例子: 前 k 个高频元素 (注意集合的运用)
Trie 树
由来
也叫“字典树”,是一个树形结构,专门用来处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题
Trie树的本质:利用字符串之间的公共前缀,将重复的前缀合并在一起。例如;

存储方式
有很多种,最经典的:借助散列表的思想,用一个下标与一一字符映射的数组来存储子节点的指针:

具体实现:
class TrieNode {
char data;
TrieNode children[26];
}
public class Trie {
private TrieNode root = new TrieNode('/'); // 存储无意义字符
// 往 Trie 树中插入一个字符串
public void insert(char[] text) {
TrieNode p = root;
for (int i = 0; i < text.length; ++i) {
int index = text[i] - 'a';
if (p.children[index] == null) {
TrieNode newNode = new TrieNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
}
// 在 Trie 树中查找一个字符串 O(k)
public boolean find(char[] pattern) {
TrieNode p = root;
for (int i = 0; i < pattern.length; ++i) {
int index = pattern[i] - 'a';
if (p.children[index] == null) {
return false; // 不存在 pattern
}
p = p.children[index];
}
if (p.isEndingChar == false) return false; // 不能完全匹配,只是前缀
else return true; // 找到 pattern
}
public class TrieNode {
public char data;
public TrieNode[] children = new TrieNode[26];
public boolean isEndingChar = false;
public TrieNode(char data) {
this.data = data;
}
}
}
此种实现方式的缺点:耗费内存,每一个节点都需要维护一整个长度为 \(26\) 的数组,且数组元素是一个 \(8\) 字节的指针(不一定)。
优化:可以将每个节点替换成其他数据结构:跳表,红黑树,散列表等。或者也可以采用缩点优化:对只有一个子节点,且该子节点不是结束节点的节点,可以将其与子节点合并:

缺点
- 字符串中包含的字符集不能太大,导致存储空间的浪费,几遍可以优化,也需要付出牺牲查询和插入的代价。
- 要求字符串的重合前缀尽可能的多
- Trie树中用到了指针,而指针在内存串起来的数据块对 CPU缓存 不友好,性能要差一点
应用场景
自动输入补全功能,例如:浏览器中的自动补全,IDE中的代码自动补全,输入法自动补全等等


浙公网安备 33010602011771号