数据结构与算法
综述
从内存的角度看,数据的存储方式只有两种:数组(连续存储)和链表(链式存储)。
数组需要开辟一块连续的内存空间,
- 可以使用数组下标以 O(1) 的时间复杂度访问元素
- 如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N)
- 尾部插入/删除 O(1)
- 如果想在数组中间进行插入和删除,每次必须搬移后面的 所有数据以保持连续,时间复杂度 O(N)
- 搜索需要 O(N) 遍历,排序数组可以用二分查找 O(logN)
链表的内存空间是不连续的,不能随机访问,不存在扩容问题。
- 操作指针即可插入/删除元素,时间复杂度 O(1)
- 搜索需要 O(N) 遍历
哈希表,就是通过哈希函数把键映射到一个大数组里。
- 插入、删除、搜索的时间复杂度都是 O(1),最坏情况下是 O(N),但只要哈希函数写对了这种情况很少见
- n 是当前元素个数,装载因子
load factor = n / size
。当负载因子超过了某个阈值,意味着表中的元素太多,哈希冲突可能会增加,从而影响性能 - 当哈希函数把两个元素放入同一个槽时,就发生了哈希冲突。处理冲突的方式包括线性探测和链接法等
- 线性探测:顺序遍历哈希表的下一个槽,直到找到一个空槽。若所有槽都满了,则需要对表进行扩容
- 链接法:让每个槽有一个指向元素集合(链表或红黑树)的引用
树的常见实现是用链表,衍生出各种设计如二叉搜索树、AVL 树、红黑树等等,以应对不同的问题。有一种特殊的树会用数组来实现(见下面“堆”)。
图的两种实现:
- 邻接表就是链表的数组、或者数组的数组;邻接表比较节省空间,但是很多操作的效率比不上邻接矩阵。
- 邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是绝大多数情况下图都是很稀疏的,非常耗费空间。
苹果提供的集合类型
类 | 底层存储 |
---|---|
NSArray , NSPointerArray | 数组 |
NSSet , NSHashTable | 哈希表(一个数组) |
NSDictionary , NSMapTable | 哈希表(两个等长的数组) |
NSOrderedSet | 红黑树 |
NSCountedSet | 哈希表(CFBagRef ) |
NSCache | 哈希表+双向链表 |
Set 和 Dictionary 是如何实现的,可以参考 CoreFoundation 源码:
#if CFDictionary
CFHashRef CFDictionaryCreate(CFAllocatorRef allocator, const_any_pointer_t *klist, const_any_pointer_t *vlist, CFIndex numValues, const CFDictionaryKeyCallBacks *keyCallBacks, const CFDictionaryValueCallBacks *valueCallBacks) {
#endif
#if CFSet || CFBag
CFHashRef CFDictionaryCreate(CFAllocatorRef allocator, const_any_pointer_t *klist, CFIndex numValues, const CFDictionaryKeyCallBacks *keyCallBacks) {
const_any_pointer_t *vlist = klist;
const CFDictionaryValueCallBacks *valueCallBacks = 0;
#endif
CFTypeID typeID = CFDictionaryGetTypeID();
CFAssert2(0 <= numValues, __kCFLogAssertion, "%s(): numValues (%ld) cannot be less than zero", __PRETTY_FUNCTION__, numValues);
CFBasicHashRef ht = __CFDictionaryCreateGeneric(allocator, keyCallBacks, valueCallBacks, CFDictionary);
if (!ht) return NULL;
if (0 < numValues) CFBasicHashSetCapacity(ht, numValues);
for (CFIndex idx = 0; idx < numValues; idx++) {
CFBasicHashAddValue(ht, (uintptr_t)klist[idx], (uintptr_t)vlist[idx]);
}
CFBasicHashMakeImmutable(ht);
_CFRuntimeSetInstanceTypeIDAndIsa(ht, typeID);
if (__CFOASafe) __CFSetLastAllocationEventName(ht, "CFDictionary (immutable)");
return (CFHashRef)ht;
}
算法分析
算法分析关心的,一是解决问题时要占用的空间;二是算法执行所需的时间。
当问题规模为 n 时,解决问题所需的时间是 T(n)
。数量级描述的是,当 n 增长时,T(n)
增长最快的部分,常被称作大 O 记法,记作 O(T(n))
。算法的性能有时不仅依赖于问题规模,还依赖于数据值。要用最坏情况、最好情况、普通情况来描述性能,以免被某个特例误导。
字母异位词的四种解法:给定两个字符串 s 和 t,判断 t 是否是 s 的字母异位词。
- 暴力穷举法:用 s 中的字符生成所有的可能,看是否跟 t 匹配,复杂度为 n!,当 n=20 时,有 2432902008176640000 种排列,这是一个比 2^n 还要快得多的增长!!
- 双层循环:s 的字符逐个与 t 比较,复杂度为 O(n^2)。
- 排序法:s 和 t 分别排序,再逐个比较。复杂度为 O(nlogn)。
- 计数法:两个异序词必有相同数目的 a,b,c,... 使用计数器统计每个字符出现的次数,复杂度为 O(n)。
排序
归并排序:将列表一分为二,排序左半边,再排序右半边,最后合并,递归进行。
快速排序:与归并排序的等分操作不同,先选取基准值并分区,对左半区、右半区递归排序。
排序算法的稳定性是指在排序过程中,具有相等键值的元素之间的相对顺序在排序前后是否保持不变。稳定性的其中一个重要意义是保持多重排序的结果。如果数据集根据一个属性已经排序,你可能想根据第二个属性重新排序,同时保持第一个属性的排序结果。
二叉堆、优先队列
优先队列是一种特殊的队列数据结构,其中每个元素都有一个优先级。元素的添加和移除是基于优先级进行的,通常情况下,优先级最高的元素会最先被移除。
它的一个经典实现是二叉堆(通常我们直接用“堆”来代替“二叉堆”),二叉堆使用数组存储元素,并可以以时间复杂度 O(logN) 实现这两个操作。
作为对比,我们考虑除了堆以外的优先队列的实现,可以看到,无论下面哪种实现,都只能保证其中一个操作是 O(1)、另一个操作是 O(N)。
实现方式 | 插入 | 移除当前最大元素 |
---|---|---|
无序数组 | O(1),插入到数组末尾 | O(N) 遍历找到最大元素、与数组末尾元素交换,删除并返回(选择排序的思想) |
有序数组 | O(logN) 二分查找合适的位置将元素插入、O(N) 将其后的元素全部后移一个位置,保持数组有序(插入排序的思想) | O(1) 删除末尾元素并返回 |
无序链表 | O(1) 插入到链表头部 | O(N) 找到最大元素并删除 |
有序链表 | O(N) 找到合适位置并插入,保持链表有序(头部最大) | O(1) 删除链表头部 |
定义:一个二叉树是堆有序的,当它每个节点的值都大于(小于)等于它左右子节点的值。
定义:一个二叉堆按照“堆有序的”“完全二叉树”来组织它的节点,并将节点以层序放到数组里(但不使用数组的第一个元素)。
堆的特性:nums[k] 的父节点位于 nums[k/2];nums[k] 的左右子节点分别是 nums[2k], nums[2k+1]。
为了实现优先队列的两个操作,需要先学习堆的 reheapifying,即,当堆的某些节点违反条件时,我们调整堆使它重新变得有序。
如果一个子节点比父节点大,那么它违反了堆顺序,我们需要将它向上与父节点交换、直到找到比它大的父节点或到达根节点,感官上看这个节点在向上浮,因此这个操作称为 swim
。
void swim(int k) {
while (k > 1 && nums[k/2] < nums[k]) {
std::swap(nums[k/2], nums[k]);
k = k / 2;
}
}
如果一个父节点比任意一个子节点小,那么它违反了堆顺序,我们将它与子节点中较大的一个交换、直到两个子节点都比它小或到达叶子节点,感官上看这个节点在向下沉,因此这个操作称为 sink
。
void sink(int k) {
while (2*k <= N) {
int j = 2 * k;
if (j < N) {
j = nums[j] > nums[j+1] ? j : j+1;
}
if (nums[k] < nums[j]) {
std::swap(nums[k], nums[j]);
k = j;
}
}
}
有了 swim
和 sink
,实现优先队列的两个操作变得特别简单!
insert
就是在数组末尾增加新元素,然后将新元素上浮到合适的位置;delMax
就是将堆顶元素移除,将数组末尾的元素放到堆顶,再 将它下沉到合适的位置。
void insert(int v) {
++N; // 新的数组大小
nums[N] = v;
swim(N);
}
int delMax() {
int m = nums[1];
std::swap(nums[1], nums[N]);
nums[N] = -1; // 数组末尾元素置为一个非法值
--N; // 新的数组大小
sink(1);
return m;
}
堆排序
一种重要的排序算法,堆排序,分为两步,第一步将数组原地构建成堆;第二步排序。
构建堆有两种可能的方案。第一种我们从左往右扫描,将每个元素 swim
到合适的位置,使得当前扫描指针的左边总是堆有序的。还有另一种更聪明的方案,因为数组的每一个位置都可以看作是一个子堆的根节点,所以我们可以从右往左扫描、对每个节点调用 sink
,就可以构建以该节点为根的有序子堆。巧妙的是,从数组的中间位置往右,都是大小为 1 的子堆,可以直接跳过,因此我们从数组的中间位置开始向左遍历即可!
排序时,从右往左遍历,每次将当前元素与堆顶元素(它最大)交 换,然后将堆顶元素下沉。
void sort(vector<int> &nums) {
// 1. 构造堆
int N = nums.size();
for (int i = N/2; i >= 1; --i) {
sink(nums, i, N);
}
// 2. 排序
while (N > 1) {
std::swap(nums[1], nums[N]);
--N;
sink(nums, 1, N);
}
}
快速排序在平均情况下通常比堆排序快,因为它的分区操作在很多情况下能够更好地利用缓存,且实际的操作次数可能更少。快速排序在最坏情况下的性能(O(n^2))远不如堆排序稳定,但这可以通过选择一个好的枢轴(如随机枢轴或三数中值分割法)来缓解。
- 如果你需要稳定的性能(即避免最坏情况的时间复杂度),如实时系统,堆排序可 能是更好的选择。
- 如果你追求平均情况下的性能,并且可以接受偶尔出现的性能下降,快速排序通常是首选。
树
- 定义一:树由节点及连接节点的边构成。树有以下属性:有一个根节点;除根节点外,其余节点都与唯一的父节点相连;从根节点到其它每个节点有且仅有一条路径。
- 定义二:一棵树要么为空,要么由一个根节点和零或多颗子树构成。每棵子树的根节点通过一条边连接到父树的根节点。
二叉树
对于一棵树,如果每个节点最多有两个子节点,我们就称这样的树是二叉树。
在一棵树中,根节点的深度为 0,每向下一层,深度就增加 1。因此,树的总深度等于其所有叶子节点中深度最大的值。
满二叉树:Full Binary Tree is a Binary Tree in which every node has 0 or 2 children.
完全二叉树:Complete Binary Tree has all levels completely filled with nodes except the last level and in the last level, all the nodes are as left side as possible.
完美二叉树:Perfect Binary Tree is a Binary Tree in which all internal nodes have 2 children and all the leaf nodes are at the same depth or same level.
平衡二叉树:Balanced Binary Tree is a Binary tree in which height of the left and the right sub-trees of every node may differ by at most 1.
退化二叉树(链表):Degenerate Binary Tree is a Binary Tree where every parent node has only one child node.
二叉搜索树 (BST)
二叉搜索树:指一棵空树或者具有下列性质的二叉树
- 若任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值;
- 若任意节点的右子树不为空,则右子树上所有节点的值均大于或等于它的根节点的值;
- 任意节点的左、右子树也分别为二叉查找树;
既有链表高效插入的特性,又有有序数组二分查找的特性,但最坏情况下退化成链表。
- 平衡的 BST 确保了树的高度尽可能低,从而减少在搜索过程中需要比较的节点数。对搜索、找最小或最大元素、前驱后继查找、范围查询等操作能够提供对数时间复杂度的性能。
- 如果 BST 不平衡,它可能会退化成一个链表结构,特别是在连续插入有序数据时。在这种情况下,树的高度与节点数相同,导致所有操作的时间复杂度退化为 O(n)。
红黑树
AVL tree (named after inventors Adelson-Velsky and Landis) is a self-balancing binary search tree. AVL 树的查找效率非常高,因为它是高度平衡的,但是在插入和删除操作时可能需要多次旋转来维持这种严格的平衡,这可能会导致这些操作相对较慢。
B 树:
- 2-3 树是一种多路搜索树,不是二叉树。它的每个节点可以有两个孩子(称为 2 节点)或三个孩子(称为 3 节点)。2-3 树通过节点分裂和合并来维持平衡,确保所有的叶子节点都在同一层。
- 一个 B 树的节点可以有多个键值和子节点,但是有一个上限(通常称为树的阶)。
- 2-3 树可以看作是 B 树的一种特例,即最简单的 B 树,它的阶是 3。2-3 树在实际应用中较少直接使用,但它的概念和平衡策略对理解其他自平衡树结构非常有帮助。
B+ 树:
- B+ 树是 B 树的变体,它具有 B 树的所有特性,但在其结构上做了一些调整,使其更适合用于适合文件系统和数据库索引。
- 在 B+ 树中,所有的数据记录都存储在叶子节点,并且叶子节点之间是相互链接的,这使得顺序访问变得非常高效。
红黑树:
- 红黑树是一种近似平衡的二叉搜索树,它通过确保从根到叶子的所有路径上不会有两个连续的红节点来弱化平衡条件。
- 红黑树的平衡操作相对简单,任何不平衡都会在三次旋转之内解决,通常只需要一次或两次旋转。
- 红黑树在插入和删除操作上比 AVL 树更高效,因为它不需要严格的平衡,但查找操作可能比 AVL 树慢,因为它允许更大的高度差。
- 红黑树的所有操作、在最坏的情况下都可以在对数时间复杂度完成。
// 定义节点颜色的枚举
enum Color { RED, BLACK };
// 红黑树的节点结构
template <typename T>
struct RBTreeNode {
T value; // 节点存储的值
Color color; // 节点的颜色
RBTreeNode *left; // 指向左子节点的指针
RBTreeNode *right; // 指向右子节点的指针
RBTreeNode *parent; // 指向父节点的指针
// 构造函数
RBTreeNode(T val) : value(val), color(RED), left(nullptr), right(nullptr), parent(nullptr) {}
};
红黑树如何保持弱平衡(黑色完美平衡):
- 红黑树的每个节点都有一个颜色属性,红色或黑色。颜色本身并不代表节点的具体特性,而是作为维护树结构平衡的一种标记。
- 红黑树的任意一个节点到叶子节点的所有路径上的黑色节点数量必须相同。这个性质被称为“黑色完美平衡”。通过这个性质,红黑树保证了最长路径不会超过最短路径的两倍,避免了最坏情况下的线性时间操作。
- 如果没有红色节点,每次插入或删除操作都可能需要大量的节点旋转来维持树的平衡,因为每次变动都必须严格遵守黑色节点的平衡规则。
- 红色节点的引入减少了必要的旋转次数,因为它们允许在局部区域暂时违反黑色完美平衡的性质。这种违反可以在后续的操作中通过颜色变化和少量的旋转来修正。