写在本书之前
本书约定
- 一切以可读性为目标:Python、C++ 和 Java 混用
- 最小化语言特性,专注算法思维:使用内置数据结构
数据结构
LeetCode
- 二叉树节点
TreeNode
- 单链表节点
ListNode
C++
函数参数默认传值:&
引用容器
-
动态数组
vector
:避免从其中间或头部增删元素的低效操作 -
字符串
string
:直接用if(s1 == s2)
判断相等 -
哈希表
unordered_map
:- 键一般为
int
或string
类型 - 方括号
[]
访问键时,若键不存在,则自动创建
- 键一般为
- 哈希集合
unordered_set
- 队列
queue
-
堆栈
stack
-
pop
方法是void
类型的,需要先存待删除元素
-
Java
- 数组 :非空检查
-
字符串
string
:- 不能直接修改,要用
toCharArray
转化成char[]
类型数组再修改 -
+
拼接效率低,推荐使用StringBuilder
的append
方法 -
if(s1.equals(s2))
判断相等
- 不能直接修改,要用
- 动态数组
ArrayList
- 双链表
LinkedList
- 哈希表
HashMap
- 哈希集合
HashSet
-
队列
queue
:Queue<String> q = new LinkedList<>()
- 堆栈
stack
Python 3
-
列表
list
:数组、堆栈和队列 - 元组
tuple
-
字典
dict
- 动态规划的备忘录 :
memo = dict(), memo[(i, j)]
- 动态规划的备忘录 :
第 1 章 / 核心套路
-
算法:
- 通过合适的工具(数据结构)
- 解决特定问题的方法
1.1. 框架思维
- 从整体到细节
- 自顶向下
- 从抽象到具体
1.1.1. 数据结构的存储方式
- 数组(顺序存储)
- 随机访问
- 复制扩容
- 移动插入删除
- 链表(链式存储)
- 顺序访问
- 插入扩容
- 直接插入删除
数组 | 链表 | |
---|---|---|
队列/栈 | 需处理扩容和缩容 | 需存储节点指针 |
图 | 邻接矩阵 | 邻接表(稀疏矩阵) |
哈希表(处理哈希冲突) | 线性探查法 | 拉链法 |
树 | 堆(完全二叉树) | 二叉搜索树、AVL 树、红黑树、区间树、B 树等 |
1.1.2. 数据结构的基本操作
数据结构的使命:
- 在不同的应用场景下尽可能 高效 地 增、删、查、改
遍历 + 访问:
-
线性:for/while 迭代
- 数组迭代 遍历框架
void traverse(int [] arr) { for (int i = 0; i < arr.length; i++) { // 迭代遍历 arr[i] } }
- 链表迭代 遍历框架
/* 单链表节点 */ 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); // 后序遍历 head.val }
- 二叉树递归 遍历框架
/* 二叉树节点 */ class TreeNode { int val; TreeNode left, right; } void traverse(TreeNode root) { // 前序遍历(根左右) traverse(root.left); // 中序遍历(左根右) traverse(root.right); // 后序遍历(左右根) }
- N 叉树递归 遍历框架 → 图的遍历(多个 N 叉树 + 布尔数组 visited)
/* N 叉树节点 */ class TreeNode { int val; TreeNode[] children; } void traverse(TreeNode root) { for (TreeNode child : root.children) { traverse(child); } }
算法刷题第一步:二叉树
- 最容易培养框架思维
- 大部分常考算法(回溯、动态规划、分治)本质上是树的遍历问题(递归)
1.2. 动态规划解题套路框架
- 一般形式:求最值(如 最长 递增子序列、最小 编辑距离)
↓
- 核心问题:穷举
-
动态规划三要素
- 存在“重叠子问题”:优化穷举效率(备忘录、DP table)
- 具备“最优子结构”:子问题的最值 → 原问题的最值
- 正确的“状态转移方程”
- 问题的 base case(最简单情况)
- “状态”空间
- “选择”使“状态”改变
- dp 数组表示“状态”和“选择”
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态 1 in 状态 1 的所有取值:
for 状态 2 in 状态 2 的所有取值:
for ...
dp[状态 1][状态 2][...] = 求最值 (选择 1, 选择 2, ...)
1.2.1. 斐波那契数列:重叠子问题
斐波那契数列:1, 1, 2, 3, 5, 8, ...
int fib(int N) {
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
- 递归算法 的递归树:时间复杂度 O(2N),空间复杂度 O(1)
↓“剪枝”
- 带“备忘录”的递归算法(自顶向下) 的递归图:时间复杂度 O(N),空间复杂度 O(N)
↓
- dp 数组的迭代算法(自底向上) 的 DP table 图:时间复杂度 O(N),空间复杂度 O(N)
int fib(iny N) {
if (N == 0) 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 数组的迭代算法:时间复杂度 O(N),空间复杂度 O(1)
int fib(int N) {
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
int pre2 = 1, pre1 = 1;
for (int i = 3; i <= N; i++) {
int cur = pre2 + pre1;
pre2 = pre1;
pre1 = cur;
}
return pre1;
}
1.2.2. 凑零钱问题:状态转移方程
k 种面值的硬币:c1, c2, ..., ck,以最少的硬币数凑出总金额 amount
算法的函数签名:
// coins 可选硬币面值,amount 目标金额
int coinChange(int[] coins, int amount);
-
暴力递归:确定状态转移方程
- 时间复杂度 O(knk),空间复杂度 O(1)
- 确定 base case:amount 为 0 时,算法返回 0
- 确定“状态”:amount
- 确定“选择”:coins
- 确定 dp 函数/数组的定义:输入 amount,输出凑出 amount 的最少硬币数量
int coinChange(int[] coins, int amount) {
return dp(amount, coins);
}
int dp(int n, int[] coins) {
// base case
if (n == 0) return 0;
if (n < 0) return -1;
int res = Integer.MAX_VALUE;
for (int coin : coins) {
int subproblem = dp(n - coin, coins);
// 子问题不能凑出,跳过这一面值
if (subproblem == -1) continue;
// 取子问题中各面值的最少硬币数
res = Math.min(res, 1 + subproblem);
}
// 原问题不能凑出,则返回 -1
return (res != Integer.MAX_VALUE) ? res : -1;
}
结果:递归超时
-
带“备忘录”的递归:自顶向下,消除重叠子问题
- 时间复杂度 O(kn),空间复杂度 O(n)
int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
Arrays.fill(memo, 0);
return helper(memo, amount, coins);
}
int helper(int[] memo, int n, int[] coins) {
// base case
if (n == 0) return 0;
if (n < 0) return -1;
if (memo[n] != 0) return memo[n];
int res = Integer.MAX_VALUE;
for (int coin : coins) {
int subproblem = helper(memo, n - coin, coins);
// 子问题不能凑出,则跳过这一面值
if (subproblem == -1) continue;
// 取子问题中各面值的最少硬币数
res = Math.min(res, 1 + subproblem);
}
// 原问题不能凑出,则设置备忘录值为 -1
memo[n] = (res != Integer.MAX_VALUE) ? res : -1;
return memo[n];
}
-
dp 数组的迭代:自底向上
- 时间复杂度 O(kn),空间复杂度 O(n)
int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
if (amount < 0) return -1;
int[] dp = new int[amount + 1];
// 初始化为 amount + 1,最多用 amount + 1 枚硬币凑出
Arrays.fill(dp, amount + 1);
// base case
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
// 子问题超出范围,跳过
if (i - coin < 0) continue;
dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] != amount + 1) ? dp[amount] : -1;
}