动态规划、回溯、BFS、二分、滑动窗口总结
动态规划
- 动态规划的核心问题:重叠子问题,最优子结构,状态转移方程
- 动态规划与记忆递归的区别:记忆递归为自顶而上的递归剪枝,动态规划为自底向上的循环迭代;
- 正确的状态转移方程+dp[]数组:
确定状态(原问题和子问题中变化的变量)
->确定dp数组的定义dp[i]
-> 确定当前状态的'选择'并确定最优条件 (状态转移方程)
-> 确定初始化状态
-> 根据状态转移方程确定遍历顺序 - 动态规划的核心方法:如何聪明的穷举
- 代码框架:
# 自顶向下递归的动态规划(记忆递归) 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...)
回溯算法
- 回溯问题的本质:决策树的遍历过程:路径 + 选择列表 + 结束条件;
- 回溯的核心:使用深搜遍历决策树;
- 前序遍历和后续遍历的区别:前序在进入节点执行,后续在离开节点时执行,因此,递归的本质就是在递归之前作出选择,同时在递归之后撤销选择;
- 其核心就是多叉树的遍历问题,可以看做DFS算法
- 代码框架:
void backtracking(参数,路径、选择列表) { if (终止条件) { 存放结果; return; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 } } ![image](https://img2024.cnblogs.com/blog/3027925/202404/3027925-20240416191328110-24279464.png)
BFS算法
- DFS本质就是多叉树的遍历,只不过将条件变为图;
- BFS的优势:找到最短路径,但是空间复杂度大(核心就是层序遍历)
- BFS解决问题的本质:在一个图中,寻找从起点到终点的最短距离
- 算法框架:
int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 表示四个方向 // grid 是地图 // visited标记访问过的节点,不要重复访问 // x,y 表示开始搜索节点的下标 void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) { queue<pair<int, int>> que; // 定义队列 que.push({x, y}); // 起始节点加入队列 visited[x][y] = true; // 只要加入队列,立刻标记为访问过的节点 int step = 0;步数 while(!que.empty()) { // 开始遍历队列里的元素 pair<int ,int> cur = que.front(); que.pop(); // 从队列取元素 int curx = cur.first; int cury = cur.second; // 当前节点坐标 if (cur == target)return step//在这里判断是否到达终点 for (int i = 0; i < 4; i++) { // 开始想当前节点的四个方向左右上下去遍历(如果是图的话直接循环现有方向) int nextx = curx + dir[i][0]; int nexty = cury + dir[i][1]; // 获取周边四个方向的坐标 if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 坐标越界了,直接跳过 if (!visited[nextx][nexty]) { // 如果节点没被访问过 que.push({nextx, nexty}); // 队列添加该节点为下一轮要遍历的节点(将相邻节点加入队列) visited[nextx][nexty] = true; // 只要加入队列立刻标记,避免重复访问 } } step++; } }
- 优化:双向BFS,已知target的情况下,不断交换,从目标和源同时搜索,同时保证搜索较小的集合
二分查找
-
核心是二分的一致性,即开闭区间的一致性
-
搜单一元素:双闭 + while等 + if相等返回 + mid直接加减1 + 出while -1
-
搜左右边界:左闭右开 + while小于 + if相等不返回(判断情况) + mid闭区间加减1,开区间不加减 + 出while根据返回值半段是否-1
-
算法框架:
int binarySearch(int[] &nums, int target) { int left = 0, right = ...; while(...) { int mid = left + (right - left)/2; if(nums[mid] == target) { } else if(nums[mid] < target) { left = ...; } else if(nums[mid] > target) { right = ...; } } return...; }
详细模板
int binary_search(int[] nums, int target) { int left = 0, right = nums.length - 1; while(left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if(nums[mid] == target) { // 直接返回 return mid; } } / //直接返回 return -1; } int left_bound(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] == target) { // 别返回, 锁定左侧边界 right = mid - 1; } } / / 最后要检查 left 越界的情况 if (left >= nums.length || nums[left] != target) return -1; return left; } int right_bound(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else if (nums[mid] == target) { // 别返回, 锁定右侧边界 left = mid + 1; } } / / 最后要检查 right 越界的情况 if (right < 0 || nums[right] != target) return -1; return right; }
-
while判断的核心:<= 为搜索区间为空,如[3,2], < 为搜索区间差一个/为空,如[2,2]、[2,2);
-
mid加减的核心:搜索区间,去除mid
滑动窗口
- 核心:左右指针确定窗口(左闭右开)-> 增加right指针扩大窗口找到可行解 -> 缩小 left指针寻找最优解 ->重复知道right到达字符串头
- 四个问题:
1、当移动right扩大窗口,即加入字符时,应该更新哪些数据?
2、什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?
3、当移动left缩小窗口,即移出字符时,应该更新哪些数据?
4、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
- 滑动窗口模板
/* 滑动窗口算法框架 */
void slidingWindow(string s) {
// 用合适的数据结构记录窗口中的数据,根据具体场景变通
// 比如说,我想记录窗口中元素出现的次数,就用 map
// 我想记录窗口中的元素和,就用 int
unordered_map<char, int> window;
int left = 0, right = 0;
while (right < s.size()) {
// 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++;
// 进行窗口内数据的一系列更新
...
}
}
}
股票买卖
- 股票买卖的核心就是状态转移,即在第i天持有股票的状态为j(k次交易,是否持有股票),手中资金为dp。用三维数组表达就是dp[i][k][0/1],可以通过状态压缩,将持有股票的状态压缩到k次交易中,即dp[i][j]
- 算法框架
dp[i][k][0 or 1] 0 <= i <= n - 1, 1 <= k <= K n 为天数,大 K 为交易数的上限,0 和 1 代表是否持有股票。 此问题共 n × K × 2 种状态,全部穷举就能搞定。 for 0 <= i < n: for 1 <= k <= K: for s in {0, 1}: dp[i][k][s] = max(buy, sell, rest) dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
打家劫舍
- 其核心也是状态转移,选择是抢/不抢;而状态就是房子的索引;
- 题目的变种是房子的构造,即分情况讨论:
如果房子为循环数组,其核心是是否抢首/尾房间,即抢首房间/抢尾房间。求最大值。
如果房子为树形,结合后续遍历,一样的,根据选择偷/不偷进行递归,其本质是抢->下家不抢 ,不抢->下家抢/不抢的最大值 - 算法模板
int rob(vector<int> &nums) { int n = nums.size(); // dp[i] = x 表示: // 从第 i 间房子开始抢劫,最多能抢到的钱为 x // base case: dp[n] = 0,dp[1] = max(nums[0], nums[1]) vector<int> dp = dp(nums.size()); for (int i = 2; i < n; i--) { dp[i] = max(dp[i - 1], nums[i] + dp[i - 2]); } return dp[nums.size() - 1];
n-sum问题
-
核心是排序后,进行双指针搜索,小于总和时右移左指针,大于总和时候左移右指针,同时要注意跳过重复元素
-
对于超过2个的num问题,采用固定一个数的策略,之后逐级调用2-sum函数(可以递归),注意,如果递归的话需要先排序,再递归。
-
算法模板:
vector<vector<int>> twoSumTarget(vector<int>& nums, int target) { // nums 数组必须有序 sort(nums.begin(), nums.end()); int lo = 0, hi = nums.size() - 1; vector<vector<int>> res; while (lo < hi) { int sum = nums[lo] + nums[hi]; int left = nums[lo], right = nums[hi]; if (sum < target) { while (lo < hi && nums[lo] == left) lo++; } else if (sum > target) { while (lo < hi && nums[hi] == right) hi--; } else { res.push_back({left, right}); while (lo < hi && nums[lo] == left) lo++; while (lo < hi && nums[hi] == right) hi--; } } return res; }
递归函数
/* 注意:调用这个函数之前一定要先给 nums 排序 */ vector<vector<int>> nSumTarget( vector<int>& nums, int n, int start, int target) { int sz = nums.size(); vector<vector<int>> res; // 至少是 2Sum,且数组大小不应该小于 n if (n < 2 || sz < n) return res; // 2Sum 是 base case if (n == 2) { // 双指针那一套操作 int lo = start, hi = sz - 1; while (lo < hi) { int sum = nums[lo] + nums[hi]; int left = nums[lo], right = nums[hi]; if (sum < target) { while (lo < hi && nums[lo] == left) lo++; } else if (sum > target) { while (lo < hi && nums[hi] == right) hi--; } else { res.push_back({left, right}); while (lo < hi && nums[lo] == left) lo++; while (lo < hi && nums[hi] == right) hi--; } } } else { // n > 2 时,递归计算 (n-1)Sum 的结果 for (int i = start; i < sz; i++) { vector<vector<int>> sub = nSumTarget(nums, n - 1, i + 1, target - nums[i]); for (vector<int>& arr : sub) { // (n-1)Sum 加上 nums[i] 就是 nSum arr.push_back(nums[i]); res.push_back(arr); } while (i < sz - 1 && nums[i] == nums[i + 1]) i++; } } return res; }