一、线性表
1. 二分查找
1. 寻找一个数
[left, right] while 终止的条件是 left == right+1
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意
while(left <= right) { // 注意
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
2.寻找左侧边界
[left, right) while 终止的条件是 left == right
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
3.寻找右侧边界
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 注意
}
将「搜索区间」全都统一成两端都闭,只要稍改 nums[mid] == target 条件处的代码和返回的逻辑即可
遇到最大化最小值或最小化最大值,就是二分查找
2. 滑动窗口
只需要思考以下四个问题:
1、当移动 right 扩大窗口,即加入字符时,应该更新哪些数据?
2、什么条件下,窗口应该暂停扩大,开始移动 left 缩小窗口?
3、当移动 left 缩小窗口,即移出字符时,应该更新哪些数据?
4、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
1、我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引左闭右开区间 [left, right) 称为一个「窗口」。
2、我们先不断地增加 right 指针扩大窗口 [left, right),直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
3、此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right),直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
4、重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。
第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解
一个 S 和一个 T,请问你 S 中是否存在一个子串,包含 T 中所有字符且不包含其他字符
一个串 S,一个串 T,找到 S 中所有 T 的排列,返回它们的起始索引
3. 链表
逻辑类似于「拉拉链」,l1, l2 类似于拉链两侧的锯齿,指针 p 就好像拉链的拉索,将两个有序链表合并
代码中还用到一个链表的算法题中是很常见的「虚拟头结点」技巧,也就是 dummy 节点
如何快速得到 k 个节点中的最小节点,接到结果链表上
要用到 优先级队列(二叉堆) 这种数据结构,把链表节点放入一个最小堆
快慢指针
递归:输入一个节点 head,将「以 head 为起点」的链表反转,并返回反转之后的头结点
- 反转部分链表
反转以 head 为起点的 n 个节点,返回新的头结点
or 哑节点
1、先反转以 head 开头的 k 个元素
2、将第 k + 1 个元素作为 head 递归调用 reverseKGroup 函数
3、拼接
回文链表
寻找回文串的核心思想是从中心向两端扩展;判断字符串是不是回文串,从两端向中间逼近
这道题的关键在于,单链表无法倒着遍历,无法使用双指针技巧
更好的思路是这样的: 1、先通过 双指针技巧 中的快慢指针来找到链表的中点 2、如果fast指针没有指向null,说明链表长度为奇数,slow还要再前进一步 3、从slow开始反转后面的链表,现在就可以开始比较回文串了
4. 单调栈
vector<int> nextGreaterElement(vector<int>& nums) {
vector<int> res(nums.size()); // 存放答案的数组
stack<int> s;
// 倒着往栈里放
for (int i = nums.size() - 1; i >= 0; i--) {
// 判定个子高矮
while (!s.empty() && s.top() <= nums[i]) {
// 矮个起开,反正也被挡着了。。。
s.pop();
}
// nums[i] 身后的 next great number
res[i] = s.empty() ? -1 : s.top();
s.push(nums[i]);
}
return res;
}
如果你看到 for 循环嵌套 while 循环,可能认为这个算法的复杂度也是 O(n^2),但是实际上这个算法的复杂度只有 O(n)
建议单调栈中存放的元素最好是下标而不是值,因为有的题目需要根据下标计算leetcode-cn.com/problems/ne…
public int[] dailyTemperatures(int[] temperatures) { Deque<Integer> stack = new LinkedList<>(); int[] ans = new int[temperatures.length]; for(int i=0;i<temperatures.length;i++){ while(!stack.isEmpty()&&temperatures[stack.peek()]<temperatures[i]){ int j = stack.pop(); ans[j] = i-j; } stack.push(i); // 下标入栈 } return ans; }
循环数组
5.单调队列
/* 单调队列的实现 */
class MonotonicQueue {
LinkedList<Integer> q = new LinkedList<>();
public void push(int n) {
// 将小于 n 的元素全部删除
while (!q.isEmpty() && q.getLast() < n) {
q.pollLast();
}
// 然后将 n 加入尾部
q.addLast(n);
}
public int max() {
return q.getFirst();
}
public void pop(int n) {
if (n == q.getFirst()) {
q.pollFirst();
}
}
}
nums 中的每个元素最多被 push 和 pop 一次,没有任何多余操作,所以整体的复杂度还是 O(N)
6.队列与栈
把队列前面的都取出来再加入队尾,让之前的队尾元素排到队头
or 双队列
最坏时间复杂度是 O(N),因为包含 while 循环,可能需要从 s1 往 s2 搬移元素。 但是它们的均摊时间复杂度是 O(1),这个要这么理解:对于一个元素,最多只可能被搬运一次,也就是说 peek 操作平均到每个元素的时间复杂度是 O(1)
7.前缀数组
前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和
在计算,有几个 j 能够使得 preSum[i] 和 preSum[j] 的差为 k
记录下有几个 preSum[j] 和 preSum[i] - k 相等,直接更新结果,就避免了内层的 for 循环。我们可以用哈希表,在记录前缀和的同时记录该前缀和出现的次数
8. 差分数组
差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减
对 nums 数组构造一个 diff 差分数组,diff[i] 就是 nums[i] 和 nums[i-1] 之差
// 差分数组工具类
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;
}
}
9. 数组
顺/逆时针旋转矩阵
类似:原地反转所有单词的顺序
如何结合哈希表和数组,使得数组的删除操作时间复杂度也变成 O(1)
数组存储值(实现O(1)插入、随机读),哈希表存储值对应的索引(实现O(1)查找)
如果用数组存储元素的话,插入,删除的时间复杂度怎么可能是 O(1) 呢?
对数组尾部进行插入和删除操作不会涉及数据搬移,时间复杂度是 O(1)
在 O(1) 的时间删除数组中的某一个元素 val,可以先把这个元素交换到数组的尾部,然后再 pop 掉。
交换两个元素必须通过索引进行交换,要一个哈希表来记录每个元素值对应的索引
一开始把n个元素全部映射,结果66超了,然后只保留黑名单映射即可
单调栈316. 去除重复字母(困难)321. 拼接最大数(困难)402. 移掉 K 位数字(中等)1081. 不同字符的最小子序列(中等)
这四道题都是删除或者保留若干个字符,使得剩下的数字最小(或最大)或者字典序最小(或最大)。而解决问题的前提是要有一定数学前提。而基于这个数学前提,我们贪心地删除栈中相邻的字符
如何在原地修改数组,避免数据的搬移?快慢指针
10. LRU,LFU
哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:哈希链表 LinkedHashMap
为什么要是双向链表,单链表行不行?
删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)
哈希表中已经存了 key,为什么链表中还要存 key 和 val 呢,只存 val 不就行了?
当缓存容量已满,我们不仅仅要删除最后一个 Node 节点,还要把 map 中映射到该节点的 key 同时删除,而这个 key 只能由 Node 得到。如果 Node 结构中只存储 val,那么我们就无法得知 key 是什么
-
class LFUCache { // key 到 val 的映射,我们后文称为 KV 表 HashMap<Integer, Integer> keyToVal; // key 到 freq 的映射,我们后文称为 KF 表 HashMap<Integer, Integer> keyToFreq; // freq 到 key 列表的映射,我们后文称为 FK 表 HashMap<Integer, LinkedHashSet> freqToKeys; // 记录最小的频次 int minFreq; // 记录 LFU 缓存的最大容量 int cap;
public LFUCache(int capacity) { keyToVal = new HashMap<>(); keyToFreq = new HashMap<>(); freqToKeys = new HashMap<>(); this.cap = capacity; this.minFreq = 0; } public int get(int key) {} public void put(int key, int val) {}}
类似前文 手把手实现 LFU 算法
class FreqStack {
// 记录 FreqStack 中元素的最大频率
int maxFreq = 0;
// 记录 FreqStack 中每个 val 对应的出现频率,后文就称为 VF 表
HashMap<Integer, Integer> valToFreq = new HashMap<>();
// 记录频率 freq 对应的 val 列表,后文就称为 FV 表
HashMap<Integer, Stack<Integer>> freqToVals = new HashMap<>();
}
11. 双指针
两数之和:双指针+排序、左右相夹
如何去重?找到target之后,跳过相同的元素
时间复杂度 O(n)
确定第一个元素循环遍历,然后转化成两数之和
如何去重?找到target之后,跳过相同的第一个元素
两层for循环,转化成三数之和问题
时间复杂度O n3
- n数之和
递归,注意先排序
区间问题
就是线段问题,让你合并所有线段、找出线段的交集等等。主要有两个技巧:排序+双指针!
情况一,找到覆盖区间
情况二,找到相交区间,合并,更新终点
情况三,完全不相交,更新起点和终点
对于这两个起点相同的区间,我们需要保证长的那个区间在上面(按照终点降序),这样才会被判定为覆盖,否则会被错误地判定为相交,少算一个覆盖区间
12.快排
法1:优先队列
法2:快排(时间复杂度?)
/* 快速排序主函数 */
void sort(int[] nums) {
// 一般要在这用洗牌算法将 nums 数组打乱,
// 以保证较高的效率,我们暂时省略这个细节
sort(nums, 0, nums.length - 1);
}
/* 快速排序核心逻辑 */
void sort(int[] nums, int lo, int hi) {
if (lo >= hi) return;
// 通过交换元素构建分界点索引 p
int p = partition(nums, lo, hi);
// 现在 nums[lo..p-1] 都小于 nums[p],
// 且 nums[p+1..hi] 都大于 nums[p]
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
int partition(int[] nums, int lo, int hi) {
if (lo == hi) return lo;
// 将 nums[lo] 作为默认分界点 pivot
int pivot = nums[lo];
// j = hi + 1 因为 while 中会先执行 --
int i = lo, j = hi + 1;
while (true) {
// 保证 nums[lo..i] 都小于 pivot
while (nums[++i] < pivot) {
if (i == hi) break;
}
// 保证 nums[j..hi] 都大于 pivot
while (nums[--j] > pivot) {
if (j == lo) break;
}
if (i >= j) break;
// 如果走到这里,一定有:
// nums[i] > pivot && nums[j] < pivot
// 所以需要交换 nums[i] 和 nums[j],
// 保证 nums[lo..i] < pivot < nums[j..hi]
swap(nums, i, j);
}
// 将 pivot 值交换到正确的位置
swap(nums, j, lo);
// 现在 nums[lo..j-1] < nums[j] < nums[j+1..hi]
return j;
}
// 交换数组中的两个元素
void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
13 分治
二、树
1.树的遍历
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的
只需要思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会对所有节点做相同的操作
二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架
前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据
1、如果把根节点看做第 1 层,如何打印出每一个节点所在的层数?
2、如何打印出每个节点的左右子树各有多少节点?
每一条二叉树的「直径」长度,就是一个节点的左右子树的最大深度之和
层次遍历
void levelTraverse(TreeNode root) {
if (root == null) return;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// 从上到下遍历二叉树的每一层
while (!q.isEmpty()) {
int sz = q.size();
// 从左到右遍历每一层的每个节点
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
// 将下一层节点放入队列
if (cur.left != null) {
q.offer(cur.left);
}
if (cur.right != null) {
q.offer(cur.right);
}
}
}
}
写树相关的算法,搞清楚当前 root 节点「该做什么」以及「什么时候做」,然后根据函数定义递归调用子节点
通过前序中序,或者后序中序遍历结果可以确定一棵原始二叉树,但是通过前序后序遍历结果无法确定原始二叉树
1、首先把前序遍历结果的第一个元素或者后序遍历结果的最后一个元素确定为根节点的值。 2、然后把前序遍历结果的第二个元素作为左子树的根节点的值。 3、在后序遍历结果中寻找左子树根节点的值,从而确定了左子树的索引边界,进而确定右子树的索引边界,递归构造左右子树即可。
我们假设前序遍历的第二个元素是左子树的根节点,但实际上左子树可能是空指针,这个元素可能是右子树的根节点。 由于这里无法确切进行判断,所以导致了最终答案的不唯一
二叉树的序列化!
扩展二叉树与其前序或后续序列是一一对应的,中序无法判断哪个是根节点,存在对称问题
若 roo是 p, q的 最近公共祖先 ,则只可能为以下情况之一:
p 和 q 在 root的子树中,且分列 root的 异侧(即分别在左、右子树中);
p = root,且 q 在root 的左或右子树中;
q = root,且 p 在 root 的左或右子树中
算法的时间复杂度是 O(logN*logN)
关键点在于,这两个递归只有一个会真的递归下去,另一个一定会触发 hl == hr 而立即返回,不会递归下去
一棵完全二叉树的两棵子树,至少有一棵是满二叉树
-
当前节点为n,对于当前节点来说,最大值可以为n+left,n+right,n,n+left+right 上面四项中的一个,但是返回是不能返回第四个的,那种路径是不成立的 同时维护一个全局变量,记录最佳答案
2.BST
直接基于 BST 的数据结构有 AVL 树,红黑树等等,拥有了自平衡性质,可以提供 logN 级别的增删查改效率;还有 B+ 树,线段树等结构都是基于 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);
}
查找排名为 k 的元素,当前节点知道自己排名第 m,那么我可以比较 m 和 k 的大小:
1、如果 m == k,显然就是找到了第 k 个元素,返回当前节点就行了。
2、如果 k < m,那说明排名第 k 的元素在左子树,所以可以去左子树搜索第 k 个元素。
3、如果 k > m,那说明排名第 k 的元素在右子树,所以可以去右子树搜索第 k - m - 1 个元素。 这样就可以将时间复杂度降到 O(logN) 了。
那么,如何让每一个节点知道自己的排名呢? 这就是我们之前说的,需要在二叉树节点中维护额外信息。每个节点需要记录,以自己为根的这棵二叉树有多少个节点
利用 BST 的中序遍历特性,想降序打印节点的值怎么办? 很简单,只要把递归顺序改一下就行了
void traverse(TreeNode root) {
if (root == null) return;
// 先递归遍历右子树
traverse(root.right);
// 中序遍历代码位置
print(root.val);
// 后递归遍历左子树
traverse(root.left);
}
通过使用辅助函数,增加函数参数列表,在参数中携带额外信息,将这种约束传递给子树的所有节点
根据 target 和 root.val 的大小比较,就能排除一边
插入一个数,就是先找到插入位置,然后进行插入
一旦涉及「改」,函数就要返回 TreeNode 类型,并且对递归调用的返回值进行接收
因为删除节点的同时不能破坏 BST 的性质。有三种情况
DP:leetcode-cn.com/problems/un…
如果当前节点要做的事情需要通过左右子树的计算结果推导出来,就要用到后序遍历
1、左右子树是否是 BST
2、左子树的最大值和右子树的最小值
3、左右子树的节点值之和。
在中序遍历的过程我们只需要维护当前中序遍历到的最后一个节点 \textit{pred}pred,然后在遍历到下一个节点的时候,看两个节点的值是否满足前者小于后者即可,如果不满足说明找到了一个交换的节点,且在找到两次以后就可以终止遍leetcode-cn.com/problems/re…
3. 二叉树序列化
所谓的序列化不过就是把结构化的数据「打平」,其实就是在考察二叉树的遍历方式。 二叉树的遍历方式有哪些?递归遍历方式有前序遍历,中序遍历,后序遍历;迭代方式一般是层级遍历
中序遍历行不通
4. 前缀树
/* Trie 树节点实现 */
class TrieNode<V> {
V val = null;
TrieNode<V>[] children = new TrieNode[256];
}
Node 节点本身只存储 val 字段,并没有一个字段来存储字符,字符是通过子节点在父节点的 children 数组中的索引确定的。 形象理解就是,Trie 树用「树枝」存储字符串(键),用「节点」存储字符串(键)对应的数据(值)
// 底层用 Trie 树实现的键值映射
// 键为 String 类型,值为类型 V
class TrieMap<V> {
/***** 增/改 *****/
// 在 Map 中添加 key
public void put(String key, V val);
/***** 删 *****/
// 删除键 key 以及对应的值
public void remove(String key);
/***** 查 *****/
// 搜索 key 对应的值,不存在则返回 null
// get("the") -> 4
// get("tha") -> null
public V get(String key);
// 判断 key 是否存在在 Map 中
// containsKey("tea") -> false
// containsKey("team") -> true
public boolean containsKey(String key);
// 在 Map 的所有键中搜索 query 的最短前缀
// shortestPrefixOf("themxyz") -> "the"
public String shortestPrefixOf(String query);
// 在 Map 的所有键中搜索 query 的最长前缀
// longestPrefixOf("themxyz") -> "them"
public String longestPrefixOf(String query);
// 搜索所有前缀为 prefix 的键
// keysWithPrefix("th") -> ["that", "the", "them"]
public List<String> keysWithPrefix(String prefix);
// 判断是和否存在前缀为 prefix 的键
// hasKeyWithPrefix("tha") -> true
// hasKeyWithPrefix("apple") -> false
public boolean hasKeyWithPrefix(String prefix);
// 通配符 . 匹配任意字符,搜索所有匹配的键
// keysWithPattern("t.a.") -> ["team", "that"]
public List<String> keysWithPattern(String pattern);
// 通配符 . 匹配任意字符,判断是否存在匹配的键
// hasKeyWithPattern(".ip") -> true
// hasKeyWithPattern(".i") -> false
public boolean hasKeyWithPattern(String pattern);
// 返回 Map 中键值对的数量
public int size();
}
一个节点如何知道自己是否需要被删除呢?主要看自己的 val 字段是否为空以及自己的 children 数组是否全都是空指针
// 在 Map 中删除 key
public void remove(String key) {
if (!containsKey(key)) {
return;
}
// 递归修改数据结构要接收函数的返回值
root = remove(root, key, 0);
size--;
}
// 定义:在以 node 为根的 Trie 树中删除 key[i..],返回删除后的根节点
private TrieNode<V> remove(TrieNode<V> node, String key, int i) {
if (node == null) {
return null;
}
if (i == key.length()) {
// 找到了 key 对应的 TrieNode,删除 val
node.val = null;
} else {
char c = key.charAt(i);
// 递归去子树进行删除
node.children[c] = remove(node.children[c], key, i + 1);
}
// 后序位置,递归路径上的节点可能需要被清理
if (node.val != null) {
// 如果该 TireNode 存储着 val,不需要被清理
return node;
}
// 检查该 TrieNode 是否还有后缀
for (int c = 0; c < R; c++) {
if (node.children[c] != null) {
// 只要存在一个子节点(后缀树枝),就不需要被清理
return node;
}
}
// 既没有存储 val,也没有后缀树枝,则该节点需要被清理
return null;
}
三、图
1. 回溯DFS
解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题: 1、路径:也就是已经做出的选择。 2、选择列表:也就是你当前可以做的选择。 3、结束条件:也就是到达决策树底层,无法再做选择的条件。
回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高
for 选择 in 选择列表:
# 做选择
将该选择从选择列表移除
路径.add(选择)
backtrack(路径, 选择列表)
# 撤销选择
路径.remove(选择)
将该选择再加入选择列表
-
对每一个空着的格子穷举 1 到 9,如果遇到不合法的数字(在同一行或同一列或同一个 3×3 的区域中存在相同的数字)则跳过,如果找到一个合法的数字,则继续穷举下一个空格子
void backtrack(char[][] board, int i, int j) { int m = 9, n = 9; for (char ch = '1'; ch <= '9'; ch++) { // 做选择 board[i][j] = ch; // 继续穷举下一个 backtrack(board, i, j + 1); // 撤销选择 board[i][j] = '.'; } } 给 j 加一,那如果 j 加到最后一列了,怎么办?对于计算机而言,给的数字越少,反而穷举的步数就越少,得到答案的速度越快
1、一个「合法」括号组合的左括号数量一定等于右括号数量
2、对于一个「合法」的括号字符串组合 p,必然对于任何 0 <= i < len(p) 都有:子串 p[0..i] 中左括号的数量都大于或等于右括号的数量
对于递归相关的算法,时间复杂度这样计算(递归次数)*(递归函数本身的时间复杂度)
形式一、元素无重不可复选,即
nums中的元素都是唯一的,每个元素最多只能被使用一次,backtrack核心代码如下:/* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } } /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 剪枝逻辑 if (used[i]) { continue; } // 做选择 used[i] = true; track.addLast(nums[i]); backtrack(nums); // 取消选择 track.removeLast(); used[i] = false; } }形式二、元素可重不可复选,即
nums中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝,backtrack核心代码如下:Arrays.sort(nums); /* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 剪枝逻辑,跳过值相同的相邻树枝 if (i > start && nums[i] == nums[i - 1]) { continue; } // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } } Arrays.sort(nums); /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 剪枝逻辑 if (used[i]) { continue; } // 剪枝逻辑,固定相同的元素在排列中的相对位置 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; } // 做选择 used[i] = true; track.addLast(nums[i]); backtrack(nums); // 取消选择 track.removeLast(); used[i] = false; } }形式三、元素无重可复选,即
nums中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可,backtrack核心代码如下:/* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i); // 撤销选择 track.removeLast(); } } /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); backtrack(nums); // 取消选择 track.removeLast(); } }子集(元素无重不可复选)
组合(元素无重不可复选)
全排列
子集/组合(元素可重不可复选)
进行剪枝,如果一个节点有多条值相同的树枝相邻,则只遍历第一条,剩下的都剪掉,不要去遍历
排列(元素可重不可复选)
1、对 nums 进行了排序。 2、添加了一句额外的剪枝逻辑。标准全排列算法之所以出现重复,是因为把相同元素形成的排列序列视为不同的序列,但实际上它们应该是相同的;而如果固定相同元素形成的序列顺序,当然就避免了重复
2. BFS
DFS 算法就是回溯算法
BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用「队列」这种数据结构,每次将一个节点周围的所有节点加入队列
BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度可能比 DFS 大很多
从一个起点,走到终点,问最短路径。这就是 BFS 的本质
Dijkstra 算法模板框架 中我们修改了这种代码模式,可以去看看 BFS 算法是如何演变成 Dijkstra 算法在加权图中寻找最短路径的
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
Queue<Node> q; // 核心数据结构
Set<Node> visited; // 避免走回头路
q.offer(start); // 将起点加入队列
visited.add(start);
int step = 0; // 记录扩散的步数
while (q not empty) {
int sz = q.size();
/* 将当前队列中的所有节点向四周扩散 */
for (int i = 0; i < sz; i++) {
Node cur = q.poll();
/* 划重点:这里判断是否到达终点 */
if (cur is target)
return step;
/* 将 cur 的相邻节点加入队列 */
for (Node x : cur.adj()) {
if (x not in visited) {
q.offer(x);
visited.add(x);
}
}
}
/* 划重点:更新步数在这里 */
step++;
}
}
起点就是 root 根节点,终点就是最靠近根节点的那个「叶子节点」
双向BFS优化
计算最小步数的问题,我们就要敏感地想到 BFS 算法
岛屿系列题目的核心考点就是用 DFS/BFS 算法遍历二维数组
// 二叉树遍历框架
void traverse(TreeNode root) {
traverse(root.left);
traverse(root.right);
}
// 二维矩阵遍历框架
void dfs(int[][] grid, int i, int j, boolean[] visited) {
int m = grid.length, n = grid[0].length;
if (i < 0 || j < 0 || i >= m || j >= n) {
// 超出索引边界
return;
}
if (visited[i][j]) {
// 已遍历过 (i, j)
return;
}
// 进入节点 (i, j)
visited[i][j] = true;
dfs(grid, i - 1, j, visited); // 上
dfs(grid, i + 1, j, visited); // 下
dfs(grid, i, j - 1, visited); // 左
dfs(grid, i, j + 1, visited); // 右
}
每发现一个岛屿,岛屿数量加一,然后使用 DFS 将岛屿淹了
靠边的岛屿排除掉
dfs 函数淹没岛屿的同时,还应该想办法记录这个岛屿的面积
只遍历 grid2,并检查岛屿的所有格子是否在 grid1 中也是 1
序列化。形状相同的岛屿,如果从同一起点出发,dfs 函数遍历的顺序肯定是一样的
3. 并查集
动态连通性
class UF {
/* 将 p 和 q 连接 */
public void union(int p, int q);
/* 判断 p 和 q 是否连通 */
public boolean connected(int p, int q);
/* 返回图中有多少个连通分量 */
public int count();
}
使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林
设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己
class UF {
// 记录连通分量
private int count;
// 节点 x 的节点是 parent[x]
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;
}
/* 其他函数 */
}
如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也一样
count--; // 两个分量合二为一
}
/* 返回某个节点 x 的根节点 */
private int find(int x) {
// 根节点的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
}
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
/* 返回当前的连通分量个数 */
public int count() {
return count;
}
算法的复杂度是多少呢?取决于find函数
logN 的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成 N
问题的关键在于,如何想办法避免树的不平衡呢?小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个 size 数组,记录每棵树包含的节点数
class UF {
private int count;
private int[] parent;
// 新增一个数组记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
// 最初每棵树只有一个节点
// 重量应该初始化 1
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
/* 其他函数 */
}
路径压缩
find 就能以 O(1) 的时间找到某一节点的根节点,相应的,connected 和 union 复杂度都下降为 O(1)
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
一些使用 DFS 深度优先算法解决的问题,也可以用 Union-Find 算法解决
先用 for 循环遍历棋盘的四边,用 DFS 算法把那些与边界相连的 O 换成一个特殊字符,比如 #;然后再遍历整个棋盘,把剩下的 O 换成 X,把 # 恢复成 O。这样就能完成题目的要求,时间复杂度 O(MN)。
先处理 == 算式,使得他们通过相等关系各自勾结成门派(连通分量);然后处理 != 算式,检查不等关系是否破坏了相等关系的连通性
4. 图
本质上就是个高级点的多叉树而已,适用于树的 DFS/BFS 遍历算法,全部适用于图
实际上我们很少用这个 Vertex 类实现图,而是用常说的邻接表和邻接矩阵来实现
// 记录被遍历过的节点
boolean[] visited;
// 记录从起点到当前节点的路径
boolean[] onPath;
/* 图遍历框架 */
void traverse(Graph graph, int s) {
if (visited[s]) return;
// 经过节点 s,标记为已遍历
visited[s] = true;
// 做选择:标记节点 s 在路径上
onPath[s] = true;
for (int neighbor : graph.neighbors(s)) {
traverse(graph, neighbor);
}
// 撤销选择:节点 s 离开路径
onPath[s] = false;
}
注意 visited 数组和 onPath 数组的区别
名人节点的出度为 0,入度为 n - 1
解法时间复杂度为 O(N),空间复杂度为 O(1),已经是最优解法了
if (knows(cand, other) || !knows(other, cand)) {
// cand 不可能是名人
} else {
// other 不可能是名人
}
5. 拓扑排序
将后序遍历的结果进行反转,就是拓扑排序的结果
拓扑排序可以等价位是否有环
拓扑排序算法(BFS 版本):建图、计算入度、根据入度初始化队列中的节点、BFS
6. 二分图
如何存储电影演员和电影之间的关系?
判定二分图的算法很简单,就是用代码解决「双色问题」。
说白了就是遍历一遍图,一边遍历一边染色,看看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同。
// 遍历节点 v 的所有相邻节点 neighbor
for (int neighbor : graph.neighbors(v)) {
if (!visited[neighbor]) {
// 相邻节点 neighbor 没有被访问过
// 那么应该给节点 neighbor 涂上和节点 v 不同的颜色
traverse(graph, visited, neighbor);
} else {
// 相邻节点 neighbor 已经被访问过
// 那么应该比较节点 neighbor 和节点 v 的颜色
// 若相同,则此图不是二分图
}
}
BFS,DFS皆可
7. 最小生成树
算法
将所有的边按照权重从小到大排序。
取一条权重最小的边。
使用并查集(union-find)数据结构来判断加入这条边后是否会形成环。若不会构成环,则将这条边加入最小生成树中。 检查所有的结点是否已经全部联通,这一点可以通过目前已经加入的边的数量来判断。若全部联通,则结束算法;否则返回步骤 2
满足两点: 是连通图 不存在环
按照边的权重顺序(从小到大)处理所有的边,将边加入到最小生成树中,加入的边不会与已经加入的边构成环,直到树中含有 n - 1 条边为止
由于我们需要找到从左上角到右下角的最短路径,因此我们可以将图中的所有边按照权值从小到大进行排序,并依次加入并查集中。当我们加入一条权值为 xx 的边之后,如果左上角和右下角从非连通状态变为连通状态,那么 xx 即为答案
8. 最短路径
迪杰斯特拉:
朴素
堆优化
四、动态规划
1.套路框架
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义
当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出
2. 最长递增子序列
dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度 O(n^2)
二维:先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序;之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案
3. 最大子数和
不能用滑动窗口算法,因为数组中的数字可以是负数
4. 子序列
从第一行(matrix[0][..])向下落,落到位置 matrix[i][j] 的最小路径和为 dp(matrix, i, j)
A[r][c] = A[r][c] + min{A[r + 1][c - 1], A[r + 1][c], A[r + 1][c + 1]}
dp[i][j] 表示text1[0i-1]和text2[0j-1]的最长公共子序列长
-
动态规划 + 二分查找
-
718.最长重复子数组 子数组是连续的,与1143的区别
-
1143.最长公共子序列 压缩
- )
5. 目标和
trick:偏移量,避免数组下标为负数
dp[i][k] 当前在第 i 个位置,并且是以步长 k 跳到位置 i 时,是否到达最后一块石子
6.编辑距离
dp[i][j]表示word1的前i个字母转换成word2的前j个字母所使用的最少操作
7. 完全背包
8. 最小路径和
- 最小路径和
- 拯救公主
这道题的dp是倒序的,这点很重要,为什么不能像【最小路径和】一样是正序的?因为【最小路径和】是无状态的,你会发现【最小路径和】倒序dp也是可以的,这道题由于有“加血”的过程,只能依赖后面的值判断需要的血量。所以这里的dp[i][j]表达的意思是:“从(i,j)出发,到达终点需要最少的血量”。因此,正序的含义为“从起点出发,到达位置(i,j)所需要的最少血量”;倒序的含义是“从(i,j)出发,到达终点需要最少的血量”。初始血量本来就是要求的,所以只能倒序dp
dp[i][j]=k∈pos[key[i−1]]min{dp[i−1][k]+min{abs(j−k),n−abs(j−k)}+1}
dp[i][k]表示经过k个中转站后到达站i的最低费用
or dijstra
9. 正则表达式
dp[i][j] 表示 s 的前 i 个是否能被 p 的前 j 个匹配
转移方程?首先想的时候从已经求出了 dp[i-1][j-1] 入手,再加上已知 s[i]、p[j],要想的问题就是怎么去求 dp[i][j]
10.扔鸡蛋
二分查找排除楼层的速度无疑是最快的,那干脆先用二分查找,等到只剩 1 个鸡蛋的时候再执行线性扫描,这样得到的结果是不是就是最少的扔鸡蛋次数呢?
res = min(res, max( dp(K - 1, i - 1), # 碎 dp(K, N - i) # 没碎 ) + 1 # 在第 i 楼扔了一次
记忆化搜索? int handle(int from, int to) ->
dp试试 dp[from][to] = math.max(dp[from][k-1] +dp[k + 1][to] + ... )
pic.leetcode-cn.com/b3b03c58e00…
11. 区间DP
定义 f[l][r]f[l][r] 为考虑区间 [l,r][l,r],在双方都做最好选择的情况下,先手与后手的最大得分差值为多少
根据状态转移方程,我们发现大区间的状态值依赖于小区间的状态值,典型的区间 DP 问题。 按照从小到大「枚举区间长度」和「区间左端点」的常规做法进行求解即可
dp[i][j]表示剩余[i : len - 1]堆时,M = j的情况下,先取的人能获得的最多石子数
递推顺序:最优情况就是让下一个人取的更少
12.打家劫舍
dp[i] = max(dp[i-2]+nums[i], dp[i-1])
- 打家劫舍2
- 打家劫舍3
13.股票
T[i][k][0] 表示在第 i 天结束时,最多进行 k 次交易且在进行操作后持有 0 份股票的情况下可以获得的最大收益;
T[i][k][1] 表示在第 i 天结束时,最多进行 k 次交易且在进行操作后持有 1 份股票的情况下可以获得的最大收益
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i])
前i天的最大收益 = max{前i-1天的最大收益,第i天的价格-前i-1天中的最小价格}
典型
- 冷冻期
- 手续费
五、杂项
-
分割数组位连续子序列leetcode-cn.com/problems/sp…
for (int v : nums) { if (...) { // 将 v 分配到某个子序列中 } else { // 实在无法分配 v return false; } return true; }
找到了前 n 个烧饼中最大的那个,然后设法将这个饼子翻转到最底下
试试BFS?
位置 i 最大的水柱高度就是 min(l_max, r_max)
矩形的高度是由 min(height[left], height[right]) 即较低的一边决定的
遇到左括号就入栈,遇到右括号就去栈中寻找最近的左括号,看是否匹配
treeset
KMP
计算器
对于「任何表达式」而言,我们都使用两个栈 nums 和 ops:
-
nums: 存放所有的数字 -
ops:存放所有的数字以外的操作 -
空格 : 跳过 ( : 直接加入 ops 中,等待与之匹配的 ) ) : 使用现有的 nums 和 ops 进行计算,直到遇到左边最近的一个左括号为止,计算结果放到 nums 数字 : 从当前位置开始继续往后取,将整一个连续数字整体取出,加入 nums + - * / ^ % : 需要将操作放入 ops 中。在放入之前先把栈内可以算的都算掉(只有「栈内运算符」比「当前运算符」优先级高/同等,才进行运算),使用现有的 nums 和 ops 进行计算,直到没有操作或者遇到左括号,计算结果放到
-
2
树状数组
六、贪心
经典区间贪心
DP(最长上升子序列)?贪心?并查集?
经典
- 拼车
差分法
贪心法
贪心?按照先左端点后右端点的排序规则之后, 尽量的向后贪心
dp?
贪心?差分