数据结构复习
主要面向做题复健,偏
Data Structure与常用算法模板速查。
语法和STL
std
1 |
|
补充:
- 区间通常写成左闭右开:
[begin, end) end - begin为元素个数- 以
nums.size()为值的下标,指向最后一个元素的下一个位置 v.begin(), v.begin() + k表示前k个元素upper_bound(v.begin(), v.end(), target)的含义是“应插入的位置”- 若数组中有
target,其范围为[lower_bound, upper_bound) - 若数组中没有
target,则up = low,指向第一个比target大的位置
struct
1 | struct Student { |
string 操作
1 |
|
动态数组vector
特点:
- 尾部
O(1)插入删除 - 中间
O(n)插入删除 - 支持随机访问,核心是连续内存空间
- 扩缩容和搬移数据成本较高
1 | vector<int> v; |
环形数组
适合 O(1) 增删头部元素。
当 start, end 移动超出数组边界(< 0 或 >= arr.length)时,我们可以通过求模运算 % 让它们转一圈到数组头部或尾部继续工作,这样就实现了环形数组的效果。
栈队列stack,queue
实际上可以用 vector 代替 stack。
1 | push(1), pop(); |
双向链表list
特点:
O(1)插入删除- 不支持随机访问,只能高效获取头尾元素
- 增删查改指定索引元素是
O(n)
可选优化:
- 哈希链表
- 跳表
跳表相当于在原链表基础上增加多层索引。每向上一层,索引节点数量减少一半,索引间隔变为 2 倍,索引高度是 logN,所以查询时间复杂度是 O(logN)。
1 | list<int> l; |
哈希表 unordered_map<K,V>
Map 键值对是 Java 的接口。根据底层实现不同:
HashMap基于哈希表,O(1)增删改查TreeMap基于红黑树,O(logn)增删改查
- 哈希表O(1)增删改查,因为底层实现就是table数组,只是通过哈希函数把key映射到table数组的索引位置,然后在这个位置上存储value。使用拉链法、开放寻址法解决哈希冲突(冲突时同位置存多个、向下个位置存)。
- 哈希表底层就是操作一个数组,其主要的时间复杂度来自于哈希函数计算索引和哈希冲突。只要保证哈希函数的复杂度在 O(1),且合理解决哈希冲突的问题,那么增删查改的复杂度就都是 O(1)。
- 哈希表中键的遍历顺序是不确定的,不能依赖哈希表的遍历顺序来编写程序。
- key 在底层 table 数组中的分布是随机的,不像数组/链表结构那样有明确的元素顺序。
- 哈希表会自动扩缩容,扩容时会重新计算哈希值,导致键值对的顺序发生变化。
- C++哈希表中,访问一个不存在的键会自动创建,值为默认。这一点和其他语言不同,需要格外注意。记住访问值之前要先判断键是否存在
1 | // 初始化一个空的哈希表 |
哈希集合 unordered_set
本质上还是哈希表,只存 key,不关心 value,主要用于元素去重和 O(1) 判断是否存在。
用链表加强哈希表LinkedHashMap
有序哈希表底层是“哈希表 + 双向链表”,既有哈希表 O(1) 的增删查改效率,又能像链表一样保持插入顺序。
抽象来看:
- 哈希表存
<key, Node> - 链表存
Node Node再存key和value
这样就可以从 key 在 O(1) 时间内找到 Node,同时在链表中增删也是 O(1)。
1 | private final HashMap<K, Node<K, V>> map = new HashMap<>(); |
用数组加强哈希表
如果想基于标准哈希表增加一个新的 randomKey() API,并要求在 O(1) 时间返回随机键,可以这样设计:
- 用数组存储哈希表中的键
- 用哈希表存储键值对
问题在于 remove() 的时间复杂度可能是 O(n),因为需要遍历数组找到要删除的键。
解决办法:
- 哈希表记录每个
Key在数组中的位置,即<Key, 数组下标> - 数组存
pair<Key, Value>
这样就可以在 O(1) 时间内找到要删除键的位置,然后交换并删除。
1 | // 存储 key 和 key 在 arr 中的索引 |
二叉树
链式直接表示 val left right
1 | class TreeNode { |
邻接表实现
1 | unordered_map<int, vector<int>> tree; |
对应的树结构可以理解为:
1 | 1 |
递归遍历DFS
1 | // 二叉树的遍历框架 |
注:二叉搜索树 BST 的中序遍历结果是有序的。
层序遍历BFS
每次把队头元素拿出来,然后把它的左右子节点加入队列。
1 | void levelOrderTraverse(TreeNode* root) { |
如果还想同时维护节点所在层数,可以写成下面这样:
1 | void levelOrderTraverse(TreeNode* root) { |
多叉树
1 | class Node { |
二叉搜索树 BST —— TreeMap / TreeSet
左子树的每个节点的值都要小于这个节点的值,右子树的每个节点的值都要大于这个节点的值。
HashMap 底层把键值对存储在一个 table 数组里,而 TreeMap 底层把键值对存储在一棵二叉搜索树的节点里。
常见能力:
- 增删改查:
O(logn) - 最大最小元素:
O(logn) - 返回全部元素:利用
BST中序遍历有序 rank:O(logn),返回排名第k的元素,但需要额外维护每个节点为根的子树节点个数
注意,BST 的增删改查操作虽然理想情况下是 O(logn),但在极端情况下会退化成链表,时间复杂度变成 O(n)。因此才会进一步引出 AVL 树和红黑树。
Trie树
主要解决字符串相关问题。相同前缀的字符串不会重复存储。
1 | // 基本的多叉树节点 |
堆 ——一种能动态排序的数据结构
能动态排序的常用数据结构其实只有两个:
- 优先级队列,底层一般用二叉堆实现
- 二叉搜索树
二叉搜索树的用途更广,但优先级队列的 API 和代码实现更简单,所以一般能用优先级队列解决的问题,就没必要再上二叉搜索树。
BST:左子树的值都小于当前节点,右子树的值都大于当前节点。- 二叉堆:某个节点的值总是不大于或不小于其父节点的值;堆总是一棵完全二叉树。一般用于优先队列和堆排序。
主要操作就两个:sink(下沉)和 swim(上浮),用来维护二叉堆性质。
- 二叉堆插入:
O(logn) - 二叉堆删除:
O(logn)
堆排序相当于把一个乱序数组都 push 到一个二叉堆里,然后再一个个 pop 出来。不过标准堆排序并不依赖优先级队列,而是直接在数组上原地建堆,所以空间复杂度可以做到 O(1)。
C++ STL 的优先队列默认是最大堆,也就是堆顶为最大元素,可以通过自定义比较函数实现最小堆。
1 | std::priority_queue<TypeName> q; // 数据类型为 TypeName |
线段树
线段树是二叉树结构的衍生,用于高效解决区间查询和动态修改问题:
- 区间查询:
O(logN) - 单点修改:
O(logN)
1 |
|
因为不断把线段二分,所以线段树高度是 O(logN)。
query:遍历节点总数是logN的常数倍,所以时间复杂度是O(logN)update:找到叶子节点并更新路径上的值,所以时间复杂度也是O(logN)
图
图是节点 Vertex 和边 Edge 的集合,可以用邻接矩阵或邻接表表示。
图的存储
1 | // 邻接表 |
图的遍历
图的遍历就是多叉树遍历的延伸,仍然是 DFS 和 BFS。区别在于图中可能存在环,所以需要标记避免死循环。
遍历图的「节点」和「路径」略有不同:
- 遍历「节点」时,需要
visited数组在前序位置标记节点 - 遍历图的所有「路径」时,需要
onPath数组在前序位置标记节点,在后序位置撤销标记
1 | // 多叉树节点 |
对于树结构,遍历所有「路径」和遍历所有「节点」差别不大;而对于图结构,这两者是有区别的。
因为对于树结构来说,只能由父节点指向子节点,所以从根节点 root 出发,到任意一个节点 targetNode 的路径都是唯一的。换句话说,遍历一遍树结构的所有节点之后,必然可以找到 root 到 targetNode 的唯一路径:
1 | // 多叉树的遍历框架,寻找从根节点到目标节点的路径 |
而对于图结构来说,由起点 src 到目标节点 dest 的路径可能不止一条。我们需要一个 onPath 数组,在进入节点时(前序位置)标记为正在访问,退出节点时(后序位置)撤销标记,这样才能遍历图中的所有路径,从而找到 src 到 dest 的所有路径:
1 | // 下面的算法代码可以遍历图的所有路径,寻找从 src 到 dest 的所有路径 |
遍历所有路径的算法复杂度通常较高。
快速排序、快速选择、sort
1 | bool cmp(int a, int b) { |
算法框架
链表双指针
- 双指针:合并两个或
k个有序链表 - 快慢指针:
slow走一步,fast走两步,可以判断链表是否有环、找到链表中点、找到倒数第k个元素
1 | ListNode* fast = head; |
技巧:
- 当需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况处理。
1 | ListNode dummy(0), *p = &dummy; |
- 创建新链表时,要正确终结链表。如果是复用原链表节点创建新链表,要把最后一个节点的
next设为空指针。
反转链表
反转单链表
迭代法:每次修改一个节点,需要维护 pre、cur、next 三个指针。
注意技巧:
- 一旦出现类似
nxt.next这种操作,就要条件反射地先判断nxt是否为null,否则容易出现空指针异常。 - 注意循环的终止条件。你要知道循环终止时,各个指针的位置,这样才能保返回正确的答案。如果你觉得有点复杂想不清楚,那就动手画一个最简单的场景跑一下算法,比如这道题就可以画一个只有两个节点的单链表 1->2,然后就能确定循环终止后各个指针的位置了。
递归法:递归函数的定义是,输入一个节点 head,将“以 head 为起点”的链表反转,并返回反转之后的头结点。
随后,当前节点会接到“后面链表反转后的尾部”,所以要把这个尾节点的 next 指向当前节点,同时把当前节点的 next 设为空,最后返回头节点。
数组双指针
- 快慢指针:原地修改元素,
fast指针在前面探查元素 - 左右指针:如二分。只要数组有序,就应该想到左右指针
滑动窗口
初始化 left = right = 0,把左闭右开区间 [left, right) 称为一个“窗口”。
滑动窗口通过扩大和缩小窗口来解决某些问题,主要用来处理子数组或子串问题,比如寻找符合条件的最长/最短子数组。
注意:
- 滑动窗口并不能穷举出所有子串
- 判断哈希表中元素是否存在,应使用
if (map.count(c)),而不是if (map[c]),因为map[c]可能会创建新的键值对
遇到子数组/子串相关的问题,你只要能回答出来以下几个问题,就能运用滑动窗口算法:
- 什么时候应该扩大窗口?
- 什么时候应该缩小窗口?
- 什么时候应该更新答案?
1 | // 滑动窗口算法伪码框架 |
二叉树与递归
递归算法常见思考顺序:
- 首先,这个问题是否可以抽象成一棵树结构?如果可以,那么就要用递归算法了。
- 如果要用递归算法,那么就思考「遍历」和「分解问题」这两种思维模式,看看哪种更适合这个问题。
- 如果用「分解问题」的思维模式,那么一定要写清楚这个递归函数的定义是什么,然后利用这个定义来分解问题,利用子问题的答案推导原问题的答案;
- 如果用「遍历」的思维模式,那么要用一个无返回值的递归函数,单纯起到遍历递归树,收集目标结果的作用。
其实,“分解问题”的思维模式就对应着后面的动态规划和分治算法,而“遍历”的思维模式则更接近 DFS / 回溯算法。
algorithm
sort
用于数组、vector 等具有随机访问特性的容器。
- 默认以
less<T>作为比较函数,即从小到大排序 cmp比较函数的规则是:当返回true时,表示a排在b前面- 可以重载
<运算符,也可以重载()运算符
1 | bool cmp(node a, node b) |
next_permutation
1 | next_permutation(start, end); // 在 [start, end) 区间内求下一个排列 |

