纲领
几句话总结一切数据结构和算法
种种数据结构,皆为数组(顺序存储)和链表(链式存储)的变换。
数据结构的关键点在于遍历和访问,即增删查改等基本操作。
种种算法,皆为穷举。
穷举的关键点在于无遗漏和无冗余。熟练掌握算法框架,可做到无遗漏;充分利用信息,可做到无冗余。
各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。
线性就是 for/while 迭代为代表,非线性就是递归为代表
数组链表
数组(顺序存储)基本原理
memset初始化
Java静态数组创建出来后会自动帮你把元素值都初始化为 0
扩容:重新申请一块更大的内存空间,把原来的数据复制过去,再插入新的元素
动态数组ArrayList,
在末尾追加元素,arr.add(i)
在索引 2 的位置插入元素 666 arr.add(2, 666);
删除末尾元素arr.remove(arr.size() - 1);
根据索引查询元素,时间复杂度 O(1) int a = arr.get(0);
根据索引修改元素,时间复杂度 O(1) arr.set(0, 100);
根据元素值查找索引,时间复杂度 O(N) int index = arr.indexOf(666);
链表基本原理
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
// 输入一个数组,转换为一条单链表
ListNode createLinkedList(int[] arr) {
if (arr == null || arr.length == 0) {
return null;
}
ListNode head = new ListNode(arr[0]);
ListNode cur = head;
for (int i = 1; i < arr.length; i++) {
cur.next = new ListNode(arr[i]);
cur = cur.next;
}
return head;
}
环形数组技巧
用求模(余数)运算,将普通数组变成逻辑上的环形数组
// 长度为 5 的数组
int[] arr = new int[]{1, 2, 3, 4, 5};
int i = 0;
// 模拟环形数组,这个循环永远不会结束
while (i < arr.length) {
System.out.println(arr[i]);
i = (i + 1) % arr.length;
}
核心原理
上面只是让大家对环形数组有一个直观地印象,环形数组的关键在于,它维护了两个指针 start 和 end,start 指向第一个有效元素的索引,end 指向最后一个有效元素的下一个位置索引。
这样,当在数组头部添加或删除元素时,只需移动 start 索引,而在数组尾部添加或删除元素时,只需移动 end 索引。
当 start, end 移动超出数组边界(< 0 或 >= arr.length)时,可通过求模运算 % 让它们转一圈到数组头部或尾部继续工作,这样就实现了环形数组的效果。
关键点、注意开闭区间
在我的代码中,环形数组的区间被定义为左闭右开的,即 [start, end) 区间包含数组元素。所以其他的方法都是以左闭右开区间为基础实现的。
那么肯定就会有读者问,为啥要左闭右开,我就是想两端都开,或者两端都闭,不行么?
在
滑动窗口算法核心框架 中定义滑动窗口的边界时也会有类似的问题,这里我也解释一下。
理论上,你可随意设计区间的开闭,但一般设计为左闭右开区间是最方便处理的。
因为这样初始化 start = end = 0 时,区间 [0, 0) 中没有元素,但只要让 end 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。
如果你设置为两端都开的区间,那么让 end 向右移动一位后开区间 (0, 1) 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就已经包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦,如果你非要使用的话,需在代码中做一些特殊处理
队列
ArrayDeque
是Deque接口的一个实现,使用了可变数组,所以无容量上的限制
可作为栈来使用,效率高于Stack
也可作为队列来使用,效率高于LinkedList。
注:ArrayDeque不支持null值。
Java 中常用的双端队列实现类
接口方法
描述
addFirst(E e)
将元素添加到双端队列的头部
addLast(E e)
将元素添加到双端队列的尾部
offerFirst(E e)
将元素添加到双端队列的头部,成功返回true,如果队列已满返回false(在有容量限制的情况下)
offerLast(E e)
将元素添加到双端队列的尾部,成功返回true,如果队列已满返回false(在有容量限制的情况下)
removeFirst()
移除并返回双端队列头部的元素,如果队列为空则抛出异常
removeLast()
移除并返回双端队列尾部的元素,如果队列为空则抛出异常
pollFirst()
移除并返回双端队列头部的元素,如果队列为空则返回null
pollLast()
移除并返回双端队列尾部的元素,如果队列为空则返回null
getFirst()
返回双端队列头部的元素,但不移除,如果队列为空则抛出异常
getLast()
返回双端队列尾部的元素,但不移除,如果队列为空则抛出异常
peekFirst()
返回双端队列头部的元素,但不移除,如果队列为空则返回null
peekLast()
返回双端队列尾部的元素,但不移除,如果队列为空则返回null
size()
返回双端队列中的元素数量
isEmpty()
如果双端队列不包含元素,则返回true,否则返回false
Stack
接口方法
描述
push(E item)
将元素压入堆栈顶部,也就是将元素添加到堆栈中。
pop()
移除并返回堆栈顶部的元素,即最后压入堆栈的元素。如果堆栈为空,调用此方法会抛出EmptyStackException异常。
peek()
返回堆栈顶部的元素,但并不将其从堆栈中移除。如果堆栈为空,调用此方法会抛出EmptyStackException异常。
search(Object o)
返回对象在堆栈中的位置,以 1 为基数。如果对象不在堆栈中,则返回 - 1。
empty()
测试堆栈是否为空。如果堆栈中没有元素,则返回true,
哈希表
哈希表核心原理
哈希表的底层实现就是一个数组(不妨称之为 table)。它先把这个 key 通过一个哈希函数(不妨称之为 hash)转化成数组里面的索引,然后增删查改操作和数组基本相同
// 哈希表伪码逻辑
class MyHashMap {
private Object[] table;
// 增/改,复杂度 O(1)
public void put(K key, V value) {
int index = hash(key);
table[index] = value;
}
// 查,复杂度 O(1)
public V get(K key) {
int index = hash(key);
return table[index];
}
// 删,复杂度 O(1)
public void remove(K key) {
int index = hash(key);
table[index] = null;
}
// 哈希函数,把 key 转化成 table 中的合法索引
// 时间复杂度必须是 O(1),才能保证上述方法的复杂度都是 O(1)
private int hash(K key) {
// ...
}
}
key 是唯一的,value 可重复
哈希函数的作用是把任意长度的输入(key)转化成固定长度的输出(索引)
如何把 key 转化成整数
这个问题可有很多种答案,不同的哈希函数设计会有不同的方法,我这里就结合 Java 语言说一个简单的办法。其他编程语言也是类似的,可参考这个思路,查询相关的标准库文档。
任意 Java 对象都会有一个 int hashCode() 方法,在实现自定义的类时,如果不重写这个方法,那么它的默认返回值可认为是该对象的内存地址。一个对象的内存地址显然是全局唯一的一个整数。
所以只要调用 key 的 hashCode() 方法就相当于把 key 转化成了一个整数,且这个整数是全局唯一的。
当然,这个方法也有一些问题,下面会讲解,但现在至少找到了一种把任意对象转化为整数的方法。
如何保证索引合法
1、补码
2、环形数组
哈希冲突
哈希冲突是否可避免?
哈希冲突不可能避免,只能在算法层面妥善处理出现哈希冲突的情况。
哈希冲突是一定会出现的,因为这个 hash 函数相当于是把一个无穷大的空间映射到了一个有限的索引空间,所以必然会有不同的 key 映射到同一个索引上。
就好比三维物体映射到二维影子一样,这种有损压缩必然会出现信息丢失,有损信息本就无法和原信息一一对应。
出现哈希冲突的情况怎么解决?两种常见的解决方法,一种是拉链法,另一种是线性探查法(也经常被叫做开放寻址法)。
名字听起来高大上,说白了就是纵向延伸和横向延伸两种思路嘛:

拉链法相当于是哈希表的底层数组并不直接存储 value 类型,而是存储一个链表,当有多个不同的 key 映射到了同一个索引上,这些 key -> value 对儿就存储在这个链表中,这样就能解决哈希冲突的问题。
而线性探查法的思路是,一个 key 发现算出来的 index 值已经被别的 key 占了,那么它就去 index + 1 的位置看看,如果还是被占了,就继续往后找,直到找到一个空的位置为止。
扩容和负载因子
相信大家都听说过「负载因子」这个专业术语,现在你明白了哈希冲突的问题,就能理解负载因子的意义了。
拉链法和线性探查法虽然能解决哈希冲突的问题,但是它们会导致性能下降。
比如拉链法,你算出来 index = hash(key) 这个索引了,结果过去查出来的是个链表,你还得遍历一下这个链表,才能在里面找到你要的 value。这个过程的时间复杂度是
O(K),K 是这个链表的长度。
线性探查法也是类似的,你算出来 index = hash(key) 这个索引了,你去这个索引位置查看,发现存储的不是要找的 key,但由于线性探查法解决哈希冲突的方式,你并不能确定这个 key 真的不存在,你必须顺着这个索引往后找,直到找到一个空的位置或者找到这个 key 为止,这个过程的时间复杂度也是 O(K),K 为连续探查的次数。
所以说,如果频繁出现哈希冲突,那么 K 的值就会增大,这个哈希表的性能就会显著下降。这是需避免的。
那么为什么会频繁出现哈希冲突呢?两个原因呗:
1、哈希函数设计的不好,导致 key 的哈希值分布不均匀,很多 key 映射到了同一个索引上。
2、哈希表里面已经装了太多的 key-value 对了,这种情况下即使哈希函数再完美,也没办法避免哈希冲突。
对于第一个问题没什么好说的,开发编程语言标准库的大佬们已经帮你设计好了哈希函数,你只要调用就行了。
对于第二个问题是可控制的,即避免哈希表装太满,这就引出了「负载因子」的概念。
负载因子
负载因子是一个哈希表装满的程度的度量。一般来说,负载因子越大,说明哈希表里面存储的 key-value 对越多,哈希冲突的概率就越大,哈希表的操作性能就越差。
负载因子的计算公式也很简单,就是 size / table.length。其中 size 是哈希表里面的 key-value 对的数量,table.length 是哈希表底层数组的容量。
你不难发现,用拉链法实现的哈希表,负载因子可无限大,因为链表可无限延伸;用线性探查法实现的哈希表,负载因子不会超过 1。
像 Java 的 HashMap,允许创建哈希表时自定义负载因子,不设置的话默认是 0.75,这个值是经验值,一般保持默认就行了。
当哈希表内元素达到负载因子时,哈希表会扩容。和之前讲解
动态数组的实现 是类似的,就是把哈希表底层 table 数组的容量扩大,把数据搬移到新的大数组中。size 不变,table.length 增加,负载因子就减小了。
用链表加强哈希表(LinkedHashMap)
用数组加强哈希表(ArrayHashMap)
二叉树基础及遍历
二叉树的遍历,是入门递归思维关键
二叉树不单纯是一种数据结构,更代表着递归的思维方式。一切递归算法,比如
回溯算法、
BFS 算法、
动态规划 本质上也是把具体问题抽象成树结构,你只要抽象出来了,这些问题最终都回归二叉树的问题。同样看一段算法代码,在别人眼里是一串文本,每个字都认识,但连起来就不认识了;而在你眼里的代码就是一棵树,想咋改就咋改,咋改都能改对,实在是太简单了
平衡二叉树
「每个节点」的左右子树的高度差不超过 1。
二叉搜索树
左子树的每个节点的值都要小于这个节点的值,右子树的每个节点的值都要大于这个节点的值
BST 是非常常用的数据结构。因为左小右大的特性,可让在 BST 中快速找到某个节点,或者找到某个范围内的所有节点,这是 BST 的优势所在。
二叉搜索树(BST) 的中序遍历结果是有序的
二叉树的实现方式
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { this.val = x; }
}
TreeNode root = new TreeNode(1);
递归遍历(DFS)
DFS 常用来寻找所有路径
// 基本的二叉树节点
class TreeNode {
int val;
TreeNode left, right;
}
// 二叉树的遍历框架
void traverse(TreeNode root) {
if (root == null) {
return;
}
traverse(root.left);
traverse(root.right);
}
层序遍历(BFS)
BFS 常用来寻找最短路径
void levelOrderTraverse(TreeNode root) {
if (root == null) {
return;
}
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// 记录当前遍历到的层数(根节点视为第 1 层)
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
// 访问 cur 节点,同时知道它所在的层数
System.out.println("depth = " + depth + ", val = " + cur.val);
// 把 cur 的左右子节点加入队列
if (cur.left != null) {
q.offer(cur.left);
}
if (cur.right != null) {
q.offer(cur.right);
}
}
depth++;
}
}

多叉树的递归/层序遍历
void traverse(Node root) {
if (root == null) {
return;
}
// 前序位置
for (Node child : root.children) {
traverse(child);
}
// 后序位置
}
class State {
Node node;
int depth;
public State(Node node, int depth) {
this.node = node;
this.depth = depth;
}
}
void levelOrderTraverse(Node root) {
if (root == null) {
return;
}
Queue<State> q = new LinkedList<>();
// 记录当前遍历到的层数(根节点视为第 1 层)
q.offer(new State(root, 1));
while (!q.isEmpty()) {
State state = q.poll();
Node cur = state.node;
int depth = state.depth;
// 访问 cur 节点,同时知道它所在的层数
System.out.println("depth = " + depth + ", val = " + cur.val);
for (Node child : cur.children) {
q.offer(new State(child, depth + 1));
}
}
}
二叉堆原理
二叉堆的关键应用是优先级队列
优先级队列是一种能够自动排序的数据结构,增删元素的复杂度是
O(logN),底层使用二叉堆实现。
二叉堆的主要应用有两个,首先是一种很有用的数据结构优先级队列(Priority Queue),第二是一种排序方法堆排序(Heap Sort)。
线段树使用场景
线段树是
二叉树结构 的衍生,用于高效解决区间查询和动态修改的问题,其中区间查询的时间复杂度为
O(logN),动态修改单个元素的时间复杂度为
O(logN)。
图结构
图结构就是 多叉树结构 的延伸。图结构逻辑上由若干节点(Vertex)和边(Edge)构成,一般用邻接表、邻接矩阵等方式来存储图。
在树结构中,只允许父节点指向子节点,不存在子节点指向父节点的情况,子节点之间也不会互相链接;而图中没有那么多限制,节点之间可相互指向,形成复杂的网络结构。
两种存储方式,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵。
// 图的遍历框架
// 需一个 visited 数组记录被遍历过的节点
// 避免走回头路陷入死循环
void traverse(Vertex s, boolean[] visited) {
// base case
if (s == null) {
return;
}
if (visited[s.id]) {
// 防止死循环
return;
}
// 前序位置
visited[s.id] = true;
System.out.println("visit " + s.id);
for (Vertex neighbor : s.neighbors) {
traverse(neighbor, visited);
}
// 后序位置
}
贪心算法
每次都选择最有利的
一般的算法问题,需要暴力穷举所有解,从中找到最优解。
而有些算法问题,如果你充分利用信息,不需要用暴力穷举所有解,就能找到最优解,这就叫贪心选择性质,这种算法叫贪心算法。
所以贪心算法没有固定的套路,它的关键在于细心观察,看看是否能够充分利用信息,提前排除一些不可能是最优解的情况。
单链表常考的技巧
双指针快慢指针
1、合并两个有序链表
当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理
2、链表的分解
新建两个链表
3、合并 k 个有序链表
优先级队列
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) return null;
// 虚拟头结点
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
// 优先级队列,最小堆
PriorityQueue<ListNode> pq = new PriorityQueue<>(
lists.length, (a, b)->(a.val - b.val));
// 将 k 个链表的头结点加入最小堆
for (ListNode head : lists) {
if (head != null) {
pq.add(head);
}
}
while (!pq.isEmpty()) {
// 获取最小节点,接到结果链表中
ListNode node = pq.poll();
p.next = node;
if (node.next != null) {
pq.add(node.next);
}
// p 指针不断前进
p = p.next;
}
return dummy.next;
}
}
4、寻找单链表的倒数第 k 个节点
快指针先走k步
5、寻找单链表的中点
每当慢指针 slow 前进一步,快指针 fast 就前进两步,这样,当 fast 走到链表末尾时,slow 就指向了链表中点。
6、判断单链表是否包含环并找出环起点
当快慢指针相遇时,让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。
7、判断两个单链表是否相交并找出交点
以让 p1 遍历完链表 A 之后开始遍历链表 B,让 p2 遍历完链表 B 之后开始遍历链表 A
class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// p1 指向 A 链表头结点,p2 指向 B 链表头结点
ListNode p1 = headA, p2 = headB;
while (p1 != p2) {
// p1 走一步,如果走到 A 链表末尾,转到 B 链表
if (p1 == null) {
p1 = headB;
} else {
p1 = p1.next;
}
// p2 走一步,如果走到 B 链表末尾,转到 A 链表
if (p2 == null) {
p2 = headA;
} else{
p2 = p2.next;
}
}
return p1;
}
}
递归
判断回文单链表
把链表节点放入一个栈,然后再拿出来,这时候元素顺序就是反的,只不过我们利用的是递归函数的堆栈而已
反转单链表
迭代:逐个结点反转
递归:将第一个节点和剩下的节点反转
class Solution {
// 定义:输入一个单链表头结点,将该链表反转,返回新的头结点
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode last = reverseList(head.next);
head.next.next = head;
head.next = null;
return last;
}
}
数组/字符串常用的技巧
双指针:左右指针和快慢指针。
二分搜索:左右指针
滑动窗口:快慢指针
// 滑动窗口算法伪码框架
void slidingWindow(String s) {
// 用合适的数据结构记录窗口中的数据,根据具体场景变通
// 比如说,我想记录窗口中元素出现的次数,就用 map
// 如果我想记录窗口中的元素和,就可以只用一个 int
Object window = ...
int left = 0, right = 0;
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s[right];
window.add(c)
// 增大窗口
right++;
// 进行窗口内数据的一系列更新
...
// *** debug 输出的位置 ***
// 注意在最终的解法代码中不要 print
// 因为 IO 操作很耗时,可能导致超时
printf("window: [%d, %d)\n", left, right);
// ***********************
// 判断左侧窗口是否要收缩
while (left < right && window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
window.remove(d)
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
前缀和技巧
频繁地让你计算子数组的和
预计算一个 preSum 数组
class NumArray {
// 前缀和数组
private int[] preSum;
// 输入一个数组,构造前缀和
public NumArray(int[] nums) {
// preSum[0] = 0,便于计算累加和
preSum = new int[nums.length + 1];
// 计算 nums 的累加和
for (int i = 1; i < preSum.length; i++) {
preSum[i] = preSum[i - 1] + nums[i - 1];
}
}
// 查询闭区间 [left, right] 的累加和
public int sumRange(int left, int right) {
return preSum[right + 1] - preSum[left];
}
}
差分数组技巧
差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。
频繁地让你对子数组进行增减操作
维护一个 diff 数组
// 差分数组工具类
class Difference {
// 差分数组
private int[] diff;
// 输入一个初始数组,区间操作将在这个数组上进行
public Difference(int[] nums) {
assert nums.length > 0;
diff = new int[nums.length];
// 根据初始数组构造差分数组
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
diff[i] = nums[i] - nums[i - 1];
}
}
// 给闭区间 [i, j] 增加 val(可以是负数)
public void increment(int i, int j, int val) {
diff[i] += val;
if (j + 1 < diff.length) {
diff[j + 1] -= val;
}
}
// 返回结果数组
public int[] result() {
int[] res = new int[diff.length];
// 根据差分数组构造结果数组
res[0] = diff[0];
for (int i = 1; i < diff.length; i++) {
res[i] = res[i - 1] + diff[i];
}
return res;
}
}
其它
二叉树系列算法
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现,这叫「遍历」的思维模式。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。
二叉树的构造问题一般都是使用「分解问题」的思路:构造整棵树 = 根节点 + 构造左子树 + 构造右子树。
构造最大二叉树
通过前序和中序遍历结果构造二叉树
二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着
回溯算法核心框架 和
动态规划核心框架。
前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,绝不仅仅是三个顺序不同的 List:
前序位置的代码在刚刚进入一个二叉树节点的时候执行;
后序位置的代码在将要离开一个二叉树节点的时候执行;
中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。

二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。
中序位置主要用在 BST 场景中,你完全可以把 BST 的中序遍历认为是遍历有序数组。
只有后序位置才能通过返回值获取子树的信息。
回溯算法-遍历
遍历」的思维
本质:遍历多叉树
result = []
// 回溯算法把「做选择」「撤销选择」的逻辑放在 for 循环里面
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
回溯算法必须把「做选择」和「撤销选择」的逻辑放在 for 循环里面,否则怎么拿到「树枝」的两个端点
DFS-遍历一棵决策树
抽象地说,解决一个回溯问题,实际上就是遍历一棵决策树的过程,树的每个叶子节点存放着一个合法答案。你把整棵树遍历一遍,把叶子节点上的答案都收集起来,就能得到所有的合法答案。
用一个 traverse 函数配合外部变量来实现,这叫「遍历」的思维模式
DFS 算法和回溯算法非常类似,只是在细节上有所区别。
这个细节上的差别是什么呢?其实就是「做选择」和「撤销选择」到底在 for 循环外面还是里面的区别,DFS 算法在外面,回溯算法在里面。
动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
动态规划算法属于分解问题(分治)的思路,它的关注点在整棵「子树」。
回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
DFS 算法属于遍历的思路,它的关注点在单个「节点」。
// DFS 算法把「做选择」「撤销选择」的逻辑放在 for 循环外面
void dfs(Node root) {
if (root == null) return;
// 做选择
print("enter node %s", root);
for (Node child : root.children) {
dfs(child);
}
// 撤销选择
print("leave node %s", root);
}
站在回溯树的一个节点上,你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
分支-分解
动态规划-分解子问题
「分解问题」的思维:你看那棵树,回答我,树上有多少片叶子?树上只有一片叶子,和剩下的叶子。
解题过程,无非就是先写出暴力穷举解法(状态转移方程),加个备忘录就成自顶向下的递归解法了,再改一改就成自底向上的递推迭代解法了
明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。
# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
for 选择 in 所有可能的选择:
# 此时的状态已经因为做了选择而改变
result = 求最值(result, dp(状态1, 状态2, ...))
return result
# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
递归出口 状态转移方程 备忘录
动态规划本质上也是暴力穷举,只不过有些问题的穷举过程中存在重叠子问题,所以可以通过备忘录进行优化,对于这类算法,我们通常称为动态规划算法。
动态规划的暴力穷举解法一般是递归形式,优化方法非常固定,要么就是添加备忘录,要么就是改写成迭代形式。
动态规划的难点在于那个暴力解(状态转移方程)怎么写,请你阅读下面的文章,尤其注意得到状态转移方程的思维过程。
层序遍历
// 从 s 开始 BFS 遍历图的所有节点,且记录遍历的步数
// 当走到目标节点 target 时,返回步数
int bfs(int s, int target) {
boolean[] visited = new boolean[graph.size()];
Queue<Integer> q = new LinkedList<>();
q.offer(s);
visited[s] = true;
// 记录从 s 开始走到当前节点的步数
int step = 0;
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
int cur = q.poll();
System.out.println("visit " + cur + " at step " + step);
// 判断是否到达终点
if (cur == target) {
return step;
}
// 将邻居节点加入队列,向四周扩散搜索
for (int to : neighborsOf(cur)) {
if (!visited[to]) {
q.offer(to);
visited[to] = true;
}
}
}
step++;
}
// 如果走到这里,说明在图中没有找到目标节点
return -1;
}
二叉搜索树
1、对于 BST 的每一个节点 node,左子树节点的值都比 node 的值要小,右子树节点的值都比 node 的值大。
2、对于 BST 的每一个节点 node,它的左侧子树和右侧子树都是 BST。
二叉搜索树并不算复杂,但我觉得它可以算是数据结构领域的半壁江山,直接基于 BST 的数据结构有 AVL 树,红黑树等等,拥有了自平衡性质,可以提供 logN 级别的增删查改效率;还有 B+ 树,线段树等结构都是基于 BST 的思想来设计的。
BST 的中序遍历结果是有序的(升序)
对于 BST 相关的问题,你可能会经常看到类似下面这样的代码逻辑:
void BST(TreeNode root, int target) {
if (root.val == target)
// 找到目标,做点什么
if (root.val < target)
BST(root.right, target);
if (root.val > target)
BST(root.left, target);
}
队列/栈
对于栈这种数据结构的考察,主要考察先进后出特点的运用,比如表达式运算、括号合法性检测等问题
数据结构设计
LRU

这个需求借助链表很自然就能实现,你一直从链表头部加入元素的话,越靠近头部的元素就是新的数据,越靠近尾部的元素就是旧的数据,我们进行缓存淘汰的时候只要简单地将尾部的元素淘汰掉就行了。
// 双向链表节点
class Node {
public int key, val;
public Node next, prev;
public Node(int k, int v) {
this.key = k;
this.val = v;
}
}
// 双向链表
class DoubleList {
// 头尾虚节点
private Node head, tail;
// 链表元素数
private int size;
public DoubleList() {
// 初始化双向链表的数据
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
size = 0;
}
// 在链表尾部添加节点 x,时间 O(1)
public void addLast(Node x) {
x.prev = tail.prev;
x.next = tail;
tail.prev.next = x;
tail.prev = x;
size++;
}
// 删除链表中的 x 节点(x 一定存在)
// 由于是双链表且给的是目标 Node 节点,时间 O(1)
public void remove(Node x) {
x.prev.next = x.next;
x.next.prev = x.prev;
size--;
}
// 删除链表中第一个节点,并返回该节点,时间 O(1)
public Node removeFirst() {
if (head.next == tail)
return null;
Node first = head.next;
remove(first);
return first;
}
// 返回链表长度,时间 O(1)
public int size() { return size; }
}
class LRUCache {
// key -> Node(key, val)
private HashMap<Integer, Node> map;
// Node(k1, v1) <-> Node(k2, v2)...
private DoubleList cache;
// 最大容量
private int cap;
public LRUCache(int capacity) {
this.cap = capacity;
map = new HashMap<>();
cache = new DoubleList();
}
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
// 将该数据提升为最近使用的
makeRecently(key);
return map.get(key).val;
}
public void put(int key, int val) {
if (map.containsKey(key)) {
// 删除旧的数据
deleteKey(key);
// 新插入的数据为最近使用的数据
addRecently(key, val);
return;
}
if (cap == cache.size()) {
// 删除最久未使用的元素
removeLeastRecently();
}
// 添加为最近使用的元素
addRecently(key, val);
}
private void makeRecently(int key) {
Node x = map.get(key);
// 先从链表中删除这个节点
cache.remove(x);
// 重新插到队尾
cache.addLast(x);
}
private void addRecently(int key, int val) {
Node x = new Node(key, val);
// 链表尾部就是最近使用的元素
cache.addLast(x);
// 别忘了在 map 中添加 key 的映射
map.put(key, x);
}
private void deleteKey(int key) {
Node x = map.get(key);
// 从链表中删除
cache.remove(x);
// 从 map 中删除
map.remove(key);
}
private void removeLeastRecently() {
// 链表头部的第一个元素就是最久未使用的
Node deletedNode = cache.removeFirst();
// 同时别忘了从 map 中删除它的 key
int deletedKey = deletedNode.key;
map.remove(deletedKey);
}
}
LFU
LFU 算法的淘汰策略是 Least Frequently Used,也就是每次淘汰那些使用次数最少的数据。
见公众号
排序
排序稳定性
如果单单排序 int 数组,那么稳定性没有什么意义。但如果排序一些结构比较复杂的数据,那么稳定排序就会有一定的优势。
选择排序
先遍历一遍数组,找到数组中的最小值,然后把它和数组的第一个元素交换位置;接着再遍历一遍数组,找到第二小的元素,和数组的第二个元素交换位置;以此类推,直到整个数组有序。
首先,如果代码没有写错,算法时间复杂度还是太高,那只有一种可能,就是存在冗余计算。
冒泡排序
冒泡算法是对
选择排序 的一种优化,通过交换 nums[sortedIndex] 右侧的逆序对完成排序,是一种稳定排序算法。
这个算法的名字叫做冒泡排序,因为它的执行过程就像从数组尾部向头部冒出水泡,每次都会将最小值顶到正确的位置。
// 进一步优化,数组有序时提前终止算法
void sort(int[] nums) {
int n = nums.length;
int sortedIndex = 0;
while (sortedIndex < n) {
// 加一个布尔变量,记录是否进行过交换操作
boolean swapped = false;
for (int i = n - 1; i > sortedIndex; i--) {
if (nums[i] < nums[i - 1]) {
// swap(nums[i], nums[i - 1])
int tmp = nums[i];
nums[i] = nums[i - 1];
nums[i - 1] = tmp;
swapped = true;
}
}
// 如果一次交换操作都没有进行,说明数组已经有序,可以提前终止算法
if (!swapped) {
break;
}
sortedIndex++;
}
}
插入排序
插入排序是基于
选择排序 的一种优化,将 nums[sortedIndex] 插入到左侧的有序数组中。对于有序度较高的数组,插入排序的效率比较高。
// 对选择排序进一步优化,向左侧有序数组中插入元素
// 这个算法有另一个名字,叫做插入排序
void sort(int[] nums) {
int n = nums.length;
// 维护 [0, sortedIndex) 是有序数组
int sortedIndex = 0;
while (sortedIndex < n) {
// 将 nums[sortedIndex] 插入到有序数组 [0, sortedIndex) 中
for (int i = sortedIndex; i > 0; i--) {
if (nums[i] < nums[i - 1]) {
// swap(nums[i], nums[i - 1])
int tmp = nums[i];
nums[i] = nums[i - 1];
nums[i - 1] = tmp;
} else {
break;
}
}
sortedIndex++;
}
}
初始有序度越高,效率越高
希尔排序
希尔排序是基于
插入排序 的简单改进,通过预处理增加数组的局部有序性,突破了插入排序的
O(N 2
) 时间复杂度
快速排序-妙用二叉树前序位置
一句话总结:快速排序的核心思路需要结合 二叉树的前序遍历 来理解:在二叉树遍历的前序位置将一个元素排好位置,然后递归地将剩下的元素排好位置。
快速排序的逻辑是,若要对 nums[lo..hi] 进行排序,我们先找一个分界点 p,通过交换元素使得 nums[lo..p-1] 都小于等于 nums[p],且 nums[p+1..hi] 都大于 nums[p],然后递归地去 nums[lo..p-1] 和 nums[p+1..hi] 中寻找新的分界点,最后整个数组就被排序了。
快速排序的代码框架如下:
void sort(int[] nums, int lo, int hi) {
// ****** 前序遍历位置 ******
// 通过交换元素构建分界点 p
int p = partition(nums, lo, hi);
// ************************
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
归并排序
归并排序的逻辑,若要对 nums[lo..hi] 进行排序,我们先对 nums[lo..mid] 排序,再对 nums[mid+1..hi] 排序,最后把这两个有序的子数组合并,整个数组就排好序了。
归并排序的代码框架如下:
// 定义:排序 nums[lo..hi]
void sort(int[] nums, int lo, int hi) {
int mid = (lo + hi) / 2;
// 排序 nums[lo..mid]
sort(nums, lo, mid);
// 排序 nums[mid+1..hi]
sort(nums, mid + 1, hi);
// ****** 后序位置 ******
// 合并 nums[lo..mid] 和 nums[mid+1..hi]
merge(nums, lo, mid, hi);
// *********************
}
先对左右子数组排序,然后合并(类似合并有序链表的逻辑),你看这是不是二叉树的后序遍历框架?另外,这不就是传说中的分治算法嘛,不过如此呀。
图算法
Union Find 代码模板
class UF {
// 连通分量个数
private int count;
// 存储每个节点的父节点
private int[] parent;
// n 为图中节点的个数
public UF(int n) {
this.count = n;
parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
// 将节点 p 和节点 q 连通
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
parent[rootQ] = rootP;
// 两个连通分量合并成一个连通分量
count--;
}
// 判断节点 p 和节点 q 是否连通
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
// 返回图中的连通分量个数
public int count() {
return count;
}
}
并查集(Union Find)结构是
二叉树结构 的衍生,用于高效解决无向图的连通性问题,可以在
O(1) 时间内合并两个连通分量,在
O
(
1
)
O(1) 时间内查询两个节点是否连通,在
O
(
1
)
O(1) 时间内查询连通分量的数量。
它也是最小生成树算法的前置知识
最小生成树(Minimum Spanning Tree)算法
最小生成树算法主要有 Prim 算法(普里姆算法)和 Kruskal 算法(克鲁斯卡尔算法)两种
Dijkstra 算法
数学
一行代码就能解决的算法题
Nim 游戏
boolean canWinNim(int n) {
// 如果上来就踩到 4 的倍数,那就认输吧
// 否则,可以把对方控制在 4 的倍数,必胜
return n % 4 != 0;
}
石头游戏
电灯开关问题
有 n 盏电灯,最开始时都是关着的。现在要进行 n 轮操作:
第 1 轮操作是把每一盏电灯的开关按一下(全部打开)。
第 2 轮操作是把每两盏灯的开关按一下(就是按第 2,4,6... 盏灯的开关,它们被关闭)。
第 3 轮操作是把每三盏灯的开关按一下(就是按第 3,6,9... 盏灯的开关,有的被关闭,比如 3,有的被打开,比如 6)
int bulbSwitch(int n) {
return (int)Math.sqrt(n);
}
素数筛选法
class Solution {
public int countPrimes(int n) {
boolean[] isPrime = new boolean[n];
Arrays.fill(isPrime, true);
for (int i = 2; i * i < n; i++) {
if (isPrime[i]) {
for (int j = i * i; j < n; j += i) {
isPrime[j] = false;
}
}
}
int count = 0;
for (int i = 2; i < n; i++) {
if (isPrime[i]) count++;
}
return count;
}
}