数据结构与算法复习
主要面向做题的总结和复习,因此不涉及初级内容
时空复杂度
数据结构复习
动态数组vector
尾部O1插入删除,中间On插入删除
支持随机访问(核心是连续的内存空间!)
扩缩容、搬移数据难
1 | vector<int> v; |
环形数组 O(1) 增删头部元素
维护两个指针 start 和 end,start 指向第一个有效元素的索引,end 指向最后一个有效元素的下一个位置索引。即[start, end) 区间包含数组元素
当 start, end 移动超出数组边界(< 0 或 >= arr.length)时,我们可以通过求模运算 % 让它们转一圈到数组头部或尾部继续工作,这样就实现了环形数组的效果。
双链表list
O(1)插入删除,不支持随机访问,只能获取头尾元素
增删查改指定索引的元素O(n),需要从头开始遍历到索引处拿到那个节点(优化:1. 哈希链表 2.跳表)
跳表:跳表相当于在原链表的基础上,增加了多层索引,每向上一层,索引节点的数量减少一半,索引的间隔变为 2 倍,索引的高度是 logN,所以跳表的查询时间复杂度是O(logN)
1 | list<int> l; |
栈队列stack,queue
1 | push(1),pop(); |
哈希表 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 | // 初始化一个空的哈希表 map |
哈希集合 unordered_set
就是哈希表,只存key,不关心value,主要用于元素去重和O(1)增删查判断是否存在
用链表加强哈希表LinkedHashMap
有序哈希表,底层是哈希表+双向链表,有哈希表 O(1) 的增删查改效率,同时又可以像链表一样保持键的插入顺序。
抽象来看,哈希表本质上就是一个键值映射,链表本质上就是一个顺序存储元素的容器。现在我就是想让这个键值映射中的键按照插入顺序排列,怎么把哈希表和链表结合起来?
哈希表存<key,Node>,链表存Node,Node再存key和value。这样就可以从keyO(1)找到Node,因为拿到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 中的索引 |
string
1 | size()/length() 返回字符串长度 |
二叉树
实现方式:
链式直接表示 val left right
1 | // 基本的二叉树节点 |
邻接表实现
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)
- 最大最小元素:O(logn)
- 返回全部元素:利用BST中序遍历有序
- rank:O(logn) 返回排名第 k 的元素 需要额外维护每个节点为根的子树的节点个数
注意,BST的增删改查操作都是 O(logn) 的时间复杂度,但是在极端情况下,BST 会退化成链表,此时时间复杂度会退化成 O(n)。因此引出平衡二叉树AVL树和红黑树。
Trie树
解决字符串相关问题。相同前缀的字符串不会重复存储
1 | // 基本的多叉树节点 |
二叉堆 ——一种能动态排序的数据结构
能动态排序的常用数据结构其实只有两个:
一个是优先级队列(底层用二叉堆实现),另一个是二叉搜索树。二叉搜索树的用途更广泛,优先级队列能做的事情,二叉搜索树其实都能做。但优先级队列的 API 和代码实现相较于二叉搜索树更简单,所以一般能用优先级队列解决的问题,我们没必要用二叉搜索树。
BST:左子树的每个节点的值都要小于这个节点的值,右子树的每个节点的值都要大于这个节点的值。
二叉堆:堆中某个节点的值总是不大于或不小于其父节点的值;堆总是一棵完全二叉树。一般用于优先队列和堆排序。
主要操作就两个,sink(下沉)和 swim(上浮),用以维护二叉堆的性质。
二叉堆(优先队列)的插入和删除操作的时间复杂度都是 O(logn) 的,其中 n 是二叉堆中元素的个数。
堆排序相当于把一个乱序的数组都 push 到一个二叉堆(优先级队列)里面,然后再一个个 pop 出来,就得到了一个有序的数组。不过,正常的堆排序算法的代码并不依赖优先级队列,且空间复杂度是 O(1),因为它把 push 和 pop 的代码逻辑展开了,再加上直接在数组上原地建堆,这样就不需要额外的空间了。
C++ STL的优先队列默认是最大堆(堆顶为最大元素),可以通过自定义比较函数来实现最小堆。
1 |
线段树
线段树是 二叉树结构 的衍生,用于高效解决区间查询和动态修改的问题,其中区间查询的时间复杂度为 O(logN),动态修改单个元素的时间复杂度为 O(logN)。
1 |
|
因为不断把线段二分,所以线段树的高度是 O(logN)
查询 query 方法(找两个点)遍历的节点总数是 logN 的常数倍,时间复杂度是 O(logN)
更新 update (找叶子节点,并更新这条路径的值)的时间复杂度是 O(logN)
图
节点Vertex和边Edge的集合,可以用邻接矩阵或邻接表表示。
图的存储


邻接表:把每个节点 x 的邻居都存到一个列表里,然后把 x 和这个列表映射起来,这样就可以通过一个节点 x 找到它的所有相邻节点。
邻接矩阵则是一个二维布尔数组,我们权且称为 matrix,如果节点 x 和 y 是相连的,那么就把 matrix[x][y] 设为 true(上图中绿色的方格代表 true)。如果想找节点 x 的邻居,去扫一圈 matrix[x][..] 就行了。
注:有向加权图的实现:
如果是邻接表,我们不仅仅存储某个节点 x 的所有邻居节点,还存储 x 到每个邻居的权重,就实现加权有向图了。
如果是邻接矩阵,matrix[x][y] 不再是布尔值,而是一个 int 值,0 表示没有连接,其他值表示权重,就变成加权有向图了
对于无向图中的边ab,存储两条有向边a->b, b->a。因此只考虑有向图。
图的遍历
图的遍历就是 多叉树遍历 的延伸,主要遍历方式还是深度优先搜索(DFS)和广度优先搜索(BFS)。
唯一的区别是,树结构中不存在环,而图结构中可能存在环,所以我们需要标记遍历过的节点,避免遍历函数在环中死循环。
遍历图的「节点」和「路径」略有不同,遍历「节点」时,需要 visited 数组在前序位置标记节点;遍历图的所有「路径」时,需要 onPath 数组在前序位置标记节点,在后序位置撤销标记。
算法框架
链表双指针
- 双指针:合并两个/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
cmp比较函数的编写:return true时a排在b前面
可以重载<运算符,也可以重载()运算符
1 | bool cmp(node a, node b) |
next_permutation
1 | next_permutation(start, end);//在[start, end)区间内求下一个排列 |
