1
常见数据结构和算法
数据结构的存储方式
数据结构的底层存储方式只有两种:数组(顺序存储)和链表(链式存储)
- 队列、栈 这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,
就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间
存储节点指针 - 图 两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连
通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空
间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵 - 散列表 通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法
,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需
要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些 - 树 用数组实现就是堆,因为堆是一个完全二叉树,用数组存储不需要节点指针
,操作也比较简单;用链表实现就是很常见的那种树,因为不一定是完全二叉树,
所以不适合用数组存储。为此在这种链表树结构之上,又衍生出各种巧妙的设计,
比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等
数据结构的基本操作
各种数据结构的遍历+访问有两种形式:线性的和非线性的。线性就是 for/while
迭代为代表,非线性就是递归为代表。线性表应满足的条件是:除了头尾两个节点
以外,每个节点有且只有一个前节点和一个后节点
- 数组遍历,线性迭代结构
1
2
3
4
5void traverse(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 迭代访问 arr[i]
}
} - 链表遍历,兼具迭代和递归结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/* 基本的单链表节点 */
class ListNode {
int val;
ListNode next;
}
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
// 迭代访问 p.val
}
}
void traverse(ListNode head) {
// 递归访问 head.val
traverse(head.next);
} - 二叉树遍历,典型的非线性递归遍历二叉树框架可以扩展为 N 叉树的遍历
1
2
3
4
5
6
7
8
9
10/* 基本的二叉树节点 */
class TreeNode {
int val;
TreeNode left, right;
}
void traverse(TreeNode root) {
traverse(root.left);
traverse(root.right);
}1
2
3
4
5
6
7
8
9
10/* 基本的 N 叉树节点 */
class TreeNode {
int val;
TreeNode[] children;
}
void traverse(TreeNode root) {
for (TreeNode child : root.children)
traverse(child);
}
动态规划详解
动态规划问题的一般形式就是求最值,比如说让你求最长递增子序列最小编辑
距离等。动态规划问题一定会具备最优子结构,才能通过子问题的最值得到原
问题的最值。虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万
化,穷举所有可行解其实并不是一件容易的事,只有列出正确的状态转移方
程,才能正确地穷举。重叠子问题、最优子结构、状态转移方程就是动态规划
三要素
1 | #明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义 |
斐波那契数列
- 暴力递归 斐波那契数列的数学形式就是递归的,存在大量重复计算
1
2
3
4int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
} - 即然耗时的原因是重复计算,那么我们可以造一个备忘录,每次算出某个子
问题的答案后别急着返回,先记到备忘录里再返回。这就是动态规划问题的第一
个性质:重叠子问题1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int fib(int N) {
if (N < 1) return 0;
// 备忘录全初始化为 0
vector<int> memo(N + 1, 0);
// 进行带备忘录的递归
return helper(memo, N);
}
int helper(vector<int>& memo, int n) {
// base case
if (n == 1 || n == 2) return 1;
// 已经计算过
if (memo[n] != 0) return memo[n];
memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
return memo[n];
} - dp数组的迭代解法 带备忘录的递归解法的效率已经和迭代的动态规划解法
一样了。实际上这种解法和迭代的动态规划已经差不多了,只不过这种方法叫
做自顶向下,动态规划叫做自底向上,把这个备忘录独立出来成为一张表1
2
3
4
5
6
7
8
9
10int fib(int N) {
if (N < 1) return 0;
if (N == 1 || N == 2) return 1;
vector<int> dp(N + 1, 0);
// base case
dp[1] = dp[2] = 1;
for (int i = 3; i <= N; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[N];
} - 状态压缩 根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态
有关,其实并不需要那么长的一个DP table 来存储所有的状态,只要想办法存
储之前的两个状态就行了1
2
3
4
5
6
7
8
9
10
11
12int fib(int n) {
if (n < 1) return 0;
if (n == 2 || n == 1)
return 1;
int prev = 1, curr = 1;
for (int i = 3; i <= n; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
凑零钱问题
这个问题是动态规划问题,因为它具有最优子结构的。要符合最优子结构,子问
题间必须互相独立。回到凑零钱问题,为什么说它符合最优子结构呢?比如你想
求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10
的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为1的
硬币)就是原问题的答案
1 | # 伪码框架 |
根据伪码,我们加上 base case 即可得到最终的答案。显然目标金额为
0 时,所需硬币数量为0;当目标金额小于0 时,无解返回-1
1 | def coinChange(coins: List[int], amount: int): |
也可以自底向上使用 dp table 来消除重叠子问题
1 | int coinChange(vector<int>& coins, int amount) { |
回溯算法详解
回溯算法其实就是我们常说的DFS 算法,本质上就是一种暴力穷举算法。解决一
个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
- 路径:也就是已经做出的选择
- 选择列表:也就是你当前可以做的选择
- 结束条件:也就是到达决策树底层,无法再做选择的条件
其核心就是for 循环里面的递归,在递归调用之前做选择,在递归调用之后撤销
选择
1 | result = [] |
全排列问题
比如找[1,2,3]的全排列,[2] 就是「路径」,记录你已经做过的选择;[1,3] 就
是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层,
在这里就是选择列表为空的时候
1 | for 选择 in 选择列表: |
我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节
点的选择列表和路径
1 | List<List<Integer>> res = new LinkedList<>(); |
不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举
整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子
问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高
N皇后问题
给你一个 N×N 的棋盘,让你放置 N 个皇后,使得它们不能互相攻击。皇后可以攻
击同一行、同一列、左上左下右上右下四个方向的任意单位
1 | vector<vector<string>> res; |
这部分主要代码,其实跟全排列问题差不多
1 | /* 是否可以在 board[row][col] 放置皇后? */ |
回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做
一些操作
1 | def backtrack(...): |
BFS算法
BFS就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们
写BFS 算法都是用队列这种数据结构,每次将一个节点周围的所有节点加入队列
BFS 找到的路径一定是最短的,但代价就是空间复杂度比DFS 大很多
1 | // 计算从起点 start 到终点 target 的最近距离 |
二叉树的最小高度
显然起点就是root 根节点,终点就是最靠近根节点的那个叶子节点
1 | int minDepth(TreeNode root) { |
解开密码锁的最少次数
双向 BFS 优化
传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向BFS则
是从起点和终点同时开始扩散,当两边有交集的时候停止
二分查找
二分查找框架
1 | int binarySearch(int[] nums, int target) { |
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if
写清楚,这样可以清楚地展现所有细节
寻找一个数
1 | int binarySearch(int[] nums, int target) { |
寻找左侧边界的二分搜索
1 | int left_bound(int[] nums, int target) { |
寻找右侧边界的二分查找
1 | int right_bound(int[] nums, int target) { |
滑动窗口算法
这个算法技巧的思路非常简单,就是维护一个窗口,不断滑动,然后更新答案。
如何向窗口中添加新元素,如何缩小窗口,在窗口滑动的哪个阶段更新结果。
1 | /* 滑动窗口算法框架 */ |