刷题算法总结

一、数据结构的存储方式

数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)。

二、数据结构的基本操作

对于任何数据结构,其基本操作:增删查改。
各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。
线性就是 for/while 迭代为代表,非线性就是递归为代表

  • 数组遍历框架,典型的线性迭代结构:
void traverse(vector<int>& arr) {for (int i = 0; i < arr.size(); i++) {// 迭代访问 arr[i]}
}
  • 链表遍历框架,兼具迭代和递归结构:
/* 基本的单链表节点 */
class ListNode {public:int val;ListNode* next;
};void traverse(ListNode* head) {for (ListNode* p = head; p != nullptr; p = p->next) {// 迭代访问 p->val}
}void traverse(ListNode* head) {// 递归访问 head->valtraverse(head->next);
}
  • 二叉树遍历框架,典型的非线性递归遍历结构
/* 基本的二叉树节点 */
struct TreeNode {int val;TreeNode* left;TreeNode* right;
};void traverse(TreeNode* root) {traverse(root->left);traverse(root->right);
}
  • 二叉树框架可以扩展为 N 叉树的遍历框架:
/* 基本的 N 叉树节点 */
class TreeNode {
public:int val;vector<TreeNode*> children;
};void traverse(TreeNode* root) {for (TreeNode* child : root->children)traverse(child);
}

数据结构的基本存储方式就是链式和顺序两种,基本操作就是增删查改,遍历方式无非迭代和递归。

三、双指针

3.1 数组/单链表系列算法

单链表常考的技巧就是双指针

  • 首先说二分搜索技巧,可以归为两端向中心的双指针。如果让你在数组中搜索元素,一个 for 循环穷举肯定能搞定对吧,但如果数组是有序的,二分搜索不就是一种更聪明的搜索方式么。
  • 滑动窗口算法技巧,典型的快慢双指针,快慢指针中间就是滑动的「窗口」,主要用于解决子串问题。教你聪明的穷举技巧。
  • 还有回文串相关技巧,如果判断一个串是否是回文串,使用双指针从两端向中心检查,如果寻找回文子串,就从中心向两端扩散。
  • 最后说说 前缀和技巧 和 差分数组技巧。如果频繁地让你计算子数组的和,每次用 for 循环去遍历肯定没问题,但前缀和技巧预计算一个 preSum 数组,就可以避免循环。类似的,如果频繁地让你对子数组进行增减操作,也可以每次用 for 循环去操作,但差分数组技巧维护一个 diff 数组,也可以避免循环。

3.2 滑动窗口

解决子串问题

/* 滑动窗口算法框架 */
void slidingWindow(string s) {unordered_map<char, int> window;int left = 0, right = 0;while (right < s.size()) {// c 是将移入窗口的字符char c = s[right];// 增大窗口right++;// 进行窗口内数据的一系列更新.../*** debug 输出的位置 ***/// 注意在最终的解法代码中不要 print// 因为 IO 操作很耗时,可能导致超时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 的尽头。

在我们来看看这个滑动窗口代码框架怎么用:

  • 首先,初始化 window 和 need 两个哈希表,记录窗口中的字符和需要凑齐的字符:
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
  • 然后,使用 left 和 right 变量初始化窗口的两端,不要忘了,区间 [left, right) 是左闭右开的,所以初始情况下窗口没有包含任何元素:
int left = 0, right = 0;
int valid = 0; 
while (right < s.size()) {// 开始滑动
}

其中 valid 变量表示窗口中满足 need 条件的字符个数,如果 valid 和 need.size 的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T。
现在开始套模板,只需要思考以下几个问题:

  • 1、什么时候应该移动 right 扩大窗口?窗口加入字符时,应该更新哪些数据?

  • 2、什么时候窗口应该暂停扩大,开始移动 left 缩小窗口?从窗口移出字符时,应该更新哪些数据?

  • 3、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

回答:如果一个字符进入窗口,应该增加 window 计数器;如果一个字符将移出窗口的时候,应该减少 window 计数器;当 valid 满足 need 时应该收缩窗口;应该在收缩窗口的时候更新最终结果。

3.3 二分查找

分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
另外提前说明一下,计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 left 和 right 太大,直接相加导致溢出的情况。

3.3.1 寻找一个数(基本的二分搜索)

int binarySearch(vector<int>& nums, int target) {int left = 0; int right = nums.size() - 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;
}
  • 1、为什么 while 循环的条件中是 <=,而不是 <?

答:区别是:前者的搜索区间为 [left, right],后者相当 [left, right)。搜索区间为空的时候应该终止。
while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],可见这时候区间为空,所以这时候 while 循环终止是正确的,直接返回 -1 即可。
while(left < right) 的终止条件是 left == right,写成区间的形式就是 [right, right],这时候区间非空,区间 [right, right]被漏掉了,索引 right 没有被搜索,如果这时候直接返回 -1 就是错误的。

  • 2、为什么 left = mid + 1,right = mid - 1?我看有的代码是 right = mid 或者 left = mid,没有这些加加减减,到底怎么回事,怎么判断?

答:发现索引 mid 不是要找的 target 时,下一步应该去搜索区间 [left, mid-1] 或者区间 [mid+1, right] 。因为 mid 已经搜索过,应该从搜索区间中去除。

  • 3、此算法有什么缺陷?
    答:想得到 target 的左侧边界,或右侧边界,算法是无法处理的。而找到一个 target,然后向左或向右线性搜索会破坏二分查找对数级的复杂度。

3.3.2 寻找左侧边界的二分搜索(搜索区间向左靠拢)

int left_bound(vector<int>& nums, int target) {int left = 0;int right = nums.size(); // 注意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;
}
  • 1、为什么 while 中是 < 而不是 <=?

答:用相同的方法分析,因为 right = nums.length 而不是 nums.length - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开。

while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。

  • 2、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?

答:其实很简单,在返回的时候额外判断一下 nums[left] 是否等于 target 就行了,如果不等于,就说明 target 不存在。不过我们得考察一下 left 的取值范围,免得索引越界。由于这里 right 初始化为 nums.length,所以 left 变量的取值区间是闭区间 [0, nums.length],那么我们在检查 nums[left] 之前需要额外判断一下,防止索引越界:

while (left < right) {//...
}
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
  • 3、为什么 left = mid + 1,right = mid ?和之前的算法不一样?

答:这个很好解释,因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步应该去 mid 的左侧或者右侧区间搜索,即 [left, mid) 或 [mid + 1, right)。

  • 4、为什么该算法能够搜索左侧边界?

答:关键在于对于 nums[mid] == target 这种情况的处理:

   if (nums[mid] == target)right = mid;

可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。

  • 5、为什么返回 left 而不是 right?

答:都是一样的,因为 while 终止的条件是 left == right。

  • 6、能不能想办法把 right 变成 nums.length - 1,也就是继续使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了。

答:当然可以,只要你明白了「搜索区间」这个概念,就能有效避免漏掉元素,随便你怎么改都行。下面我们严格根据逻辑来修改:

int left_bound(vector<int>& nums, int target) {int left = 0, right = nums.size() - 1;// 搜索区间为 [left, right]while (left <= right) {int mid = left + (right - left) / 2;if (nums[mid] < target) {// 搜索区间变为 [mid+1, right]left = mid + 1;} else if (nums[mid] > target) {// 搜索区间变为 [left, mid-1]right = mid - 1;} else if (nums[mid] == target) {// 收缩右侧边界right = mid - 1;}}// 判断 target 是否存在于 nums 中// 此时 target 比所有数都大,返回 -1if (left == nums.size()) return -1;// 判断一下 nums[left] 是不是 targetreturn nums[left] == target ? left : -1;
}

3.3.3 寻找右侧边界的二分查找(搜索区间向右靠拢)

int right_bound(vector<int>& nums, int target) {int left = 0, right = nums.size();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; // 注意
}
  • 1、为什么这个算法能够找到右侧边界?
if (nums[mid] == target) {left = mid + 1;

当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的左边界 left,使得区间不断向右靠拢,达到锁定右侧边界的目的。

  • 2、为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对。
// 增大 left,锁定右侧边界
if (nums[mid] == target) {left = mid + 1;// 这样想: mid = left - 1

答:首先,while 循环的终止条件是 left == right,所以 left 和 right 是一样的,你非要体现右侧的特点,返回 right - 1 好了。
什么 left 的更新必须是 left = mid + 1,当然是为了把 nums[mid] 排除出搜索区间

  • 3、为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?
    答:只要在最后判断一下 nums[left-1] 是不是 target 就行了。
    类似之前的左侧边界搜索,left 的取值范围是 [0, nums.length],但由于我们最后返回的是 left - 1,所以 left 取值为 0 的时候会造成索引越界,额外处理一下即可正确地返回 -1:
while (left < right) {// ...
}
// 判断 target 是否存在于 nums 中
// 此时 left - 1 索引越界
if (left - 1 < 0) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left - 1] == target ? (left - 1) : -1;
  • 4、是否也可以把这个算法的「搜索区间」也统一成两端都闭的形式呢?这样这三个写法就完全统一了,以后就可以闭着眼睛写出来了。
    答:当然可以,类似搜索左侧边界的统一写法,其实只要改两个地方就行了:
int right_bound(vector<int>& nums, int target) {int left = 0, right = nums.size() - 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;}}// 最后改成返回 left - 1if (left - 1 < 0) return -1;return nums[left - 1] == target ? (left - 1) : -1;
}

统一一下可以总结为:

int binary_search(vector<int>& nums, int target) {int left = 0, right = nums.size() - 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(vector<int>& nums, int target) {int left = 0, right = nums.size() - 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;}}// 判断 target 是否存在于 nums 中// 此时 target 比所有数都大,返回 -1if (left == nums.size()) return -1;// 判断一下 nums[left] 是不是 targetreturn nums[left] == target ? left : -1;
}int right_bound(vector<int>& nums, int target) {int left = 0, right = nums.size() - 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;}}// 此时 left - 1 索引越界if (right < 0) return -1;// 判断一下 nums[left] 是不是 targetreturn nums[right] == target ? (left - 1) : -1;
}

二分查找某个值,左边界,右边界的区别主要是两个地方不同:

  1. 当 nums[mid] == target时
  • 寻找某个值
return mid;
  • 寻找左边界时,搜索区间向左靠拢
right = mid - 1;
  • 寻找右边界,搜索区间向右靠拢
left = mid + 1;
  1. 当结束循环时
  • 寻找某个值
return -1
  • 寻找左边界
if(left > nums.size()) return -1;
return nums[left] == target? left : -1;
  • 寻找右边界
if(right < 0) return -1;
return nums[right] == target? right : -1;

3.3.4 二分查找的泛化

因为算法题一般都让你求最值,比如让你求吃香蕉的「最小速度」,让你求轮船的「最低运载能力」,求最值的过程,必然是搜索一个边界的过程。

  1. 首先,要从题目中抽象出
  • 一个自变量 x
  • 一个关于 x 的函数 f(x)
  • 一个目标值
    同时,满足以下条件:
  • f(x) 是在 x 上的单调函数
  • 题目要求是让计算满足约束条件 f(x) == targer 时 x 的值
    如图所示:
    在这里插入图片描述
    对应的代码如下:
    在这里插入图片描述

3.3.5 二分搜索的框架

在这里插入图片描述
确定可以用双指针且对应的算法流程分为三步:

  1. 确认x , f(x), target分别是什么,并写出函数 f 的代码
  2. 找到 x 的取值范围作为二分搜索的区间,初始化 left 和 right 变量
  3. 根据题目要求,确定使用左边界搜索还是右边界搜索,并写出解法代码

3.4 两数之和

一、twoSum 问题

  • 先对 nums 排序,基本思路还是排序加双指针:
vector<vector<int>> twoSumTarget(vector<int>& nums, int target {// 先对数组排序sort(nums.begin(), nums.end());vector<vector<int>> res;int lo = 0, hi = nums.size() - 1;while (lo < hi) {int sum = nums[lo] + nums[hi];// 根据 sum 和 target 的比较,移动左右指针if      (sum < target) lo++;else if (sum > target) hi--;else {res.push_back({lo, hi});lo++; hi--;}}return res;
}
  • sum == target 条件的 if 分支,当给 res 加入一次结果后,lo 和 hi 不应该改变 1 的同时,还应该跳过所有重复的元素,可修改如下:
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;
}

3.5 nSum 问题

3.5.1 2sum

  • 于第一个数字,nums[i] 都有可能。确定了第一个数字之后,剩下的两个数字就是和为 target - nums[i] 的两个数字,也就是 twoSum问题。
/* 从 nums[start] 开始,计算有序数组* nums 中所有和为 target 的二元组 */
vector<vector<int>> twoSumTarget(vector<int>& nums, int start, int target) {// 左指针改为从 start 开始,其他不变int lo = start, 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 中所有和为 target 的三元组 */
vector<vector<int>> threeSumTarget(vector<int>& nums, int target) {// 数组得排个序sort(nums.begin(), nums.end());int n = nums.size();vector<vector<int>> res;// 穷举 threeSum 的第一个数for (int i = 0; i < n; i++) {// 对 target - nums[i] 计算 twoSumvector<vector<int>> tuples = twoSumTarget(nums, i + 1, target - nums[i]);// 如果存在满足条件的二元组,再加上 nums[i] 就是结果三元组for (vector<int>& tuple : tuples) {tuple.push_back(nums[i]);res.push_back(tuple);}// 跳过第一个数字重复的情况,否则会出现重复结果while (i < n - 1 && nums[i] == nums[i + 1]) i++;}return res;
}

需要注意的是,类似 twoSum,3Sum 的结果也可能重复。关键点在于,不能让第一个数重复,至于后面的两个数,我们复用的 twoSum 函数会保证它们不重复。所以代码中必须用一个 while 循环来保证 3Sum 中第一个元素不重复。

3.5.2 Sum和问题

vector<vector<int>> fourSum(vector<int>& nums, int target) {// 数组需要排序sort(nums.begin(), nums.end());int n = nums.size();vector<vector<int>> res;// 穷举 fourSum 的第一个数for (int i = 0; i < n; i++) {// 对 target - nums[i] 计算 threeSumvector<vector<int>> triples = threeSumTarget(nums, i + 1, target - nums[i]);// 如果存在满足条件的三元组,再加上 nums[i] 就是结果四元组for (vector<int>& triple : triples) {triple.push_back(nums[i]);res.push_back(triple);}// fourSum 的第一个数不能重复while (i < n - 1 && nums[i] == nums[i + 1]) i++;}return res;
}/* 从 nums[start] 开始,计算有序数组* nums 中所有和为 target 的三元组 */
vector<vector<int>> threeSumTarget(vector<int>& nums, int start, int target) {int n = nums.size();vector<vector<int>> res;// i 从 start 开始穷举,其他都不变for (int i = start; i < n; i++) {// 对 target - nums[i] 计算 twoSumvector<vector<int>> tuples = twoSumTarget(nums, i + 1, target - nums[i]);// 如果存在满足条件的二元组,再加上 nums[i] 就是结果三元组for (vector<int>& tuple : tuples) {tuple.push_back(nums[i]);res.push_back(tuple);}// 跳过第一个数字重复的情况,否则会出现重复结果while (i < n - 1 && nums[i] == nums[i + 1]) i++;}return res;

3.5.3 100Sum问题

可统一的函数为:

/* 注意:调用这个函数之前一定要先给 nums 排序 */
vector<vector<int>> nSumTarget(vector<int>& nums, int n, int start, int target) {vector<vector<int>> res;// 至少是 2Sum,且数组大小不应该小于 nif (n < 2 || nums.size() < n) return res;// 2Sum 是 base caseif (n == 2) {// 双指针那一套操作int lo = start, hi = nums.size() - 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 if(n > 2){// n > 2 时,递归计算 (n-1)Sum 的结果for (int i = start; i < nums.size(); i++) {vector<vector<int>> sub = nSumTarget(nums, n - 1, i + 1, target - nums[i]);for (vector<int>& arr : sub) {// (n-1)Sum 加上 nums[i] 就是 nSumarr.push_back(nums[i]);res.push_back(arr);}while (i < nums.size() - 1 && nums[i] == nums[i + 1]) i++;}}return res;
}

如果在 nSum 函数里调用排序函数,那么每次递归都会进行没有必要的排序,效率会非常低。因此,在调用函数之前,应该先给 nums 进行排序。例如:

vector<vector<int>> fourSum(vector<int>& nums, int target) {sort(nums.begin(), nums.end());// n 为 4,从 nums[0] 开始计算和为 target 的四元组return nSumTarget(nums, 4, 0, target);
}

四、二叉树系列算法

对递归的理解不到位,更应该好好刷二叉树相关题目。
二叉树题目的递归解法可以分两类思路:

  • 第一类是遍历一遍二叉树得出答案?—— 回溯算法核心框架
    如果可以,用一个 traverse 函数配合外部变量来实现,这叫「遍历」的思维模式。

  • 第二类是通过分解问题计算出答案?—— 动态规划核心框架。
    即是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。

  • PS:无论使用哪种思维模式,你都需要思考:
    如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用操心,递归函数会帮你在所有节点上执行相同的操作。

4.1 深入理解前中后序(递归之前——前序位置;递归之后——后序位置)

void traverse(TreeNode* root) {if (root == nullptr) {return;}// 前序位置traverse(root->left);// 中序位置traverse(root->right);// 后序位置
}//递归遍历单链表
void traverse(ListNode* head) {if (head == nullptr) {return;}//前序位置traverse(head -> next);//后序位置
}

在这里插入图片描述

  • 单链表和数组的遍历可以是迭代的(从前往后单向传递),也可以是递归的(从前往后传递,再从后往前传递),二叉树的遍历框架都是指递归的形式。
  • 只要是递归形式的遍历,都可以有前序位置和后序位置,分别在递归之前和递归之后。
  • 所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候
  • 前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点
  • 二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。

综上,遇到一道二叉树的题目时的通用思考过程是:

  • 1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现。

  • 2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。

  • 3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。

什么叫通过遍历一遍二叉树得出答案? 计算二叉树最大深度

class Solution {
public:// 记录最大深度int res = 0;int depth = 0;// 主函数int maxDepth(TreeNode* root) {traverse(root);return res;}// 二叉树遍历框架void traverse(TreeNode* root) {if (root == NULL) {// 到达叶子节点return;}// 前序遍历位置depth++;if(root->left == nullptr && root->right == nullptr){res = max(res, depth);}traverse(root->left);traverse(root->right);// 后序遍历位置depth--;}
};

回溯算法的时候就告诉你回溯算法本质就是遍历一棵多叉树

class Solution {
public:// 记录所有全排列vector<vector<int>> res;vector<int> track;/* 主函数,输入一组不重复的数字,返回它们的全排列 */vector<vector<int>> permute(vector<int>& nums) {backtrack(nums);return res;}// 回溯算法框架void backtrack(vector<int>& nums) {if (track.size() == nums.size()) {// 穷举完一个全排列res.push_back(track);return;}for (int i = 0; i < nums.size(); i++) {if (find(track.begin(), track.end(), nums[i]) != track.end()) continue;// 前序遍历位置做选择track.push_back(nums[i]);backtrack(nums);// 后序遍历位置取消选择track.pop_back();}}
};
  • 那什么叫通过分解问题计算答案?
    也就是利用子问题的答案来推导出父问题的答案。
    同样是计算二叉树最大深度这个问题,你也可以写出下面这样的解法:
// 定义:输入根节点,返回这棵二叉树的最大深度
int maxDepth(TreeNode* root) {if (root == nullptr) {return 0;}// 递归计算左右子树的最大深度int leftMax = maxDepth(root->left);int rightMax = maxDepth(root->right);// 整棵树的最大深度int res = max(leftMax, rightMax) + 1;return res;
}

同样的,前序遍历也可以从两种思路来进行求解:

  • 遍历二叉树的思路:
#include <iostream>
#include <vector>using namespace std;vector<int> res;// 前序遍历结果
vector<int> preorderTraverse(TreeNode* root) {traverse(root);return res;
}// 二叉树遍历函数
void traverse(TreeNode* root) {if (root == nullptr) {return;}// 前序位置res.push_back(root->val);traverse(root->left);traverse(root->right);
}
  • 分解子问题的思路:
// 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
vector<int> preorderTraverse(TreeNode* root) {vector<int> res;if (root == nullptr) {return res;}// 前序遍历的结果,root->val 在第一个res.push_back(root->val);// 利用函数定义,后面接着左子树的前序遍历结果vector<int> leftRes = preorderTraverse(root->left);res.insert(res.end(), leftRes.begin(), leftRes.end());// 利用函数定义,最后接着右子树的前序遍历结果vector<int> rightRes = preorderTraverse(root->right);res.insert(res.end(), rightRes.begin(), rightRes.end());return res;
}

不信你看 动态规划核心框架 中凑零钱问题的暴力穷举解法:

// 定义:输入金额 amount,返回凑出 amount 的最少硬币个数
int coinChange(vector<int>& coins, int amount) {// base caseif (amount == 0) return 0;if (amount < 0) return -1;int res = INT_MAX;for (int coin : coins) {// 递归计算凑出 amount - coin 的最少硬币个数int subProblem = coinChange(coins, amount - coin);if (subProblem == -1) continue;// 凑出 amount 的最少硬币个数res = min(res, subProblem + 1);}return res == INT_MAX ? -1 : res;
}
  • 分治算法详解 中说到的运算表达式优先级问题,其核心依然是大问题分解成子问题,只不过没有重叠子问题,不能用备忘录去优化效率罢了。
  • 比如 图论基础, 环判断和拓扑排序 和 二分图判定算法 就用到了 DFS 算法;再比如 Dijkstra 算法模板,就是改造版 BFS 算法加上一个类似 dp table 的数组。

二叉树题目的思考思路为:

  • 1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现。

  • 2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。

  • 3、无论使用哪一种思维模式,都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。

4.2 后序位置的特殊之处:

  1. 中序位置主要用在 BST 场景中,完全可以把 BST 的中序遍历认为是遍历有序数组。
  2. 前序位置本身其实没有什么特别的性质,对前中后序位置不敏感的代码写在前序位置罢了。
  3. 前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的: 意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。

具体的例子,现在给你一棵二叉树,我问你两个简单的问题:
1、如果把根节点看做第 1 层,如何打印出每一个节点所在的层数?

// 二叉树遍历函数
void traverse(TreeNode* root, int level) {if (root == NULL) {return;}// 前序位置printf("节点 %s 在第 %d 层", root, level);traverse(root->left, level + 1);traverse(root->right, level + 1);
}// 这样调用
traverse(root, 1);

2、如何打印出每个节点的左右子树各有多少节点?

// 定义:输入一棵二叉树,返回这棵二叉树的节点总数
int count(TreeNode* root) {if (root == nullptr) {return 0;}int leftCount = count(root->left);int rightCount = count(root->right);// 后序位置printf("节点 %s 的左子树有 %d 个节点,右子树有 %d 个节点",root->val, leftCount, rightCount);return leftCount + rightCount + 1;
}

根本区别在于:

  • 一个节点在第几层,从根节点遍历过来的过程就能顺带记录;
  • 而以一个节点为根的整棵子树有多少个节点,需要遍历完子树之后才能数清楚。

后序位置的特点,只有后序位置才能通过返回值获取子树的信息。
也就是说,一旦发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。

五、回溯算法

回溯算法和常说的 DFS 算法非常类似,本质上就是一种暴力穷举算法。回溯算法和 DFS 算法的细微差别是:回溯算法是在遍历「树枝」,DFS 算法是在遍历「节点」。
解决一个回溯问题,实际上就是一个决策树的遍历过程,站在回溯树的一个节点上,你只需要思考 3 个问题:

  • 1、路径:也就是已经做出的选择。

  • 2、选择列表:也就是你当前可以做的选择。

  • 3、结束条件:也就是到达决策树底层,无法再做选择的条件。

result = []
def backtrack(路径, 选择列表):if 满足结束条件:result.add(路径)returnfor 选择 in 选择列表:做选择backtrack(路径, 选择列表)撤销选择

核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」

5.1 全排列问题

只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。我们不妨把这棵树称为回溯算法的「决策树」。
请添加图片描述
你现在就在做决策,可以选择 1 那条树枝,也可以选择 3 那条树枝。为啥只能在 1 和 3 之中选择呢?因为 2 这个树枝在你身后,这个选择你之前做过了,而全排列是不允许重复使用数字的。
现在可以解答开头的几个名词:[2] 就是「路径」,记录你已经做过的选择;[1,3] 就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层叶子节点,这里也就是选择列表为空的时候。
请添加图片描述
定义的 backtrack 函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层叶子节点,其「路径」就是一个全排列。
多叉树的遍历框架就是这样:

void traverse(TreeNode* root) {for (TreeNode* child : root->childern) {// 前序位置需要的操作traverse(child);// 后序位置需要的操作}
}

而所谓的前序遍历和后序遍历,他们只是两个很有用的时间点请添加图片描述
前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。「路径」和「选择」是每个节点的属性,函数在树上游走要正确处理节点的属性,那么就要在这两个特殊时间点搞点动作:请添加图片描述

for 选择 in 选择列表:# 做选择将该选择从选择列表移除路径.add(选择)backtrack(路径, 选择列表)# 撤销选择路径.remove(选择)将该选择再加入选择列表

只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径。

class Solution {private:vector<vector<int>> res;/* 主函数,输入一组不重复的数字,返回它们的全排列 */vector<vector<int>> permute(vector<int>& nums) {// 记录「路径」vector<int> track;//「路径」中的元素会被标记为true,避免重复使用vector<bool> used(nums.size(), false);backtrack(nums, track, used);return res;}// 路径:记录在 track 中// 选择列表:nums 中不存在于 track 的那些元素// 结束条件:nums 中的元素全都在 track 中出现void backtrack(vector<int>& nums, vector<int>& track, vector<bool>& used) {// 触发结束条件if (track.size() == nums.size()) {res.push_back(track);return;}for (int i = 0; i < nums.size(); i++) {// 排除不合法的选择if (used[i]) {//nums[i] 已经在 track中,跳过continue;}// 做选择track.push_back(nums[i]);used[i] = true;// 进入下一层决策树backtrack(nums, track, used);// 取消选择track.pop_back();used[i] = false;}}public:vector<vector<int>> permute(vector<int>& nums) {return permute(nums);}
};

这里稍微做了些变通,没有显式记录「选择列表」,而是通过 used 数组排除已经存在 track 中的元素,从而推导出当前的选择列表:
请添加图片描述

5.2 n皇后问题

请添加图片描述

  • 1.列出n皇后所有的解法
class Solution {
public:vector<vector<string>> res;/* 输入棋盘边长 n,返回所有合法的放置 */vector<vector<string>> solveNQueens(int n) {// vector<string> 代表一个棋盘// '.' 表示空,'Q' 表示皇后,初始化空棋盘vector<string> board(n, string(n, '.'));backtrack(board, 0);return res;}// 路径:board 中小于 row 的那些行都已经成功放置了皇后// 选择列表:第 row 行的所有列都是放置皇后的选择// 结束条件:row 超过 board 的最后一行void backtrack(vector<string>& board, int row) {// 触发结束条件if (row == board.size()) {res.push_back(board);return;}int n = board[row].size();for (int col = 0; col < n; col++) {// 排除不合法选择if (!isValid(board, row, col)) {continue;}// 做选择board[row][col] = 'Q';// 进入下一行决策backtrack(board, row + 1);// 撤销选择board[row][col] = '.';}}
}
  • 2.列出一个解法:满足条件后直接停止递归
bool found = false;
// 函数找到一个答案后就返回 true
bool backtrack(vector<string>& board, int row) {if (found) {// 已经找到一个答案了,不用再找了return;}// 触发结束条件if (row == board.size()) {res.push_back(board);// 找到了第一个答案found = true;return;}...
}
  • 3.存储有多少个解
// 仅仅记录合法结果的数量
int res = 0;void backtrack(vector<string>& board, int row) {if (row == board.size()) {// 找到一个合法结果res++;return;}// 其他都一样
}

5.3 排列-组合-子树问题

无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可。
请添加图片描述
请添加图片描述
首先,组合问题和子集问题是等价的,问题的变化形式,就是在这两棵树上剪掉或者增加一些树枝。

5.4 子集(元素无重不可复选)—— 返回递归路径

请添加图片描述

public: vector<vector<int>> res 存放结果vector<int> track 存放路径返回值 主函数名字(...){backtrack(nums, 0);return res;}backtrack(vector<int>& nums, int start){//满足条件,更新结果,结束本次递归res.push_back(track);//回溯算法框架for(int i = start; i < nums.size(); i++){//做出选择track.push_back(nums[i]);backtrack(nums, i + 1);//撤销选择track.pop_back();    }}

5.5 组合(元素无重不可复选)——返回递归路径

组合和子集是一样的:大小为 k 的组合就是大小为 k 的子集。
给你输入一个数组 nums = [1,2…,n] 和一个正整数 k,请你生成所有大小为 k 的子集。
还是以 nums = [1,2,3] 为例,刚才让你求所有子集,就是把所有节点的值都收集起来;现在你只需要把第 2 层(根节点视为第 0 层)的节点收集起来,就是大小为 2 的所有组合:
请添加图片描述
只需要稍改 base case,控制算法仅仅收集第 k 层节点的值,也就是判断当前路径中的元素个数 == k 时返回单次递归结果即可:

// 注意:cpp 代码由 chatGPT🤖 根据我的 java 代码翻译,旨在帮助不同背景的读者理解算法逻辑。
// 本代码还未经过力扣测试,仅供参考,如有疑惑,可以参照我写的 java 代码对比查看。class Solution {
public:vector<vector<int>> res;// 记录回溯算法的递归路径deque<int> track;// 主函数vector<vector<int>> combine(int n, int k) {backtrack(1, n, k);return res;}void backtrack(int start, int n, int k) {// base caseif (k == track.size()) {// 遍历到了第 k 层,收集当前节点的值res.push_back(vector<int>(track.begin(), track.end()));return;}// 回溯算法标准框架for (int i = start; i <= n; i++) {// 选择track.push_back(i);// 通过 start 参数控制树枝的遍历,避免产生重复的子集backtrack(i + 1, n, k);// 撤销选择track.pop_back();}}
};

5.6 排列(元素无重不可复选)——可选列表

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
组合/子集问题使用 start 变量保证元素 nums[start] 之后只会出现 nums[start+1…] 中的元素,通过固定元素的相对位置保证不出现重复的子集。
排列问题本身就是让你穷举元素的位置,nums[i] 之后也可以出现 nums[i] 左边的元素,所以之前的那一套玩不转了,需要额外使用 used 数组来标记哪些元素还可以被选择。
请添加图片描述

class Solution {
public:// 存储所有排列结果的列表vector<vector<int>> res;// 记录回溯算法的递归路径list<int> track;// 标记数字使用状态的数组,0 表示未被使用,1 表示已被使用bool* used;/* 主函数,输入一组不重复的数字,返回它们的全排列 */vector<vector<int>> permute(vector<int>& nums) {used = new bool[nums.size()]();// 满足回溯框架时需要添加 bool 类型默认初始化为 falsebacktrack(nums);return res;}// 回溯算法核心函数void backtrack(vector<int>& nums) {// base case,到达叶子节点if (track.size() == nums.size()) {// 收集叶子节点上的值res.push_back(vector<int>(track.begin(), track.end()));return;}// 回溯算法标准框架for (int i = 0; i < nums.size(); i++) {// 已经存在 track 中的元素,不能重复选择if (used[i]) {continue;}// 做选择used[i] = true;track.push_back(nums[i]);// 进入下一层回溯树backtrack(nums);// 取消选择track.pop_back();used[i] = false;}}
};

算元素个数为 k 的排列,怎么算?
改下 backtrack 函数的 base case,仅收集第 k 层的节点值即可:

// 回溯算法核心函数
void backtrack(vector<int>& nums, int k, vector<vector<int>>& res, vector<int>& track) {// base case,到达第 k 层,收集节点的值if (track.size() == k) {// 第 k 层节点的值就是大小为 k 的排列res.push_back(track);return;}// 回溯算法标准框架for (int i = 0; i < nums.size(); i++) {// ...backtrack(nums, k, res, track);// ...}
}

5.7 子集/组合(元素可重不可复选)

刚才讲的标准子集问题输入的 nums 是没有重复元素的,但如果存在重复元素,需要进行剪枝,如果一个节点有多条值相同的树枝相邻,则只遍历第一条,剩下的都剪掉,不要去遍历:请添加图片描述

请添加图片描述
体现在代码上,需要先进行排序,让相同的元素靠在一起,如果发现 nums[i] == nums[i-1],则跳过:

class Solution {vector<vector<int>> res; // 输出结果vector<int> track; // 搜索路径
public:vector<vector<int>> subsetsWithDup(vector<int>& nums) {sort(nums.begin(), nums.end()); // 排序,让相同的元素靠在一起backtrack(nums, 0);return res; // 返回结果}void backtrack(vector<int>& nums, int start) { // start 为当前的枚举位置res.emplace_back(track); // 前序位置,每个节点的值都是一个子集for(int i = start; i < nums.size(); i++) {if (i > start && nums[i] == nums[i - 1]) { // 剪枝逻辑,值相同的相邻树枝,只遍历第一条continue;}track.emplace_back(nums[i]); // 添加至路径backtrack(nums, i + 1); // 进入下一层决策树track.pop_back(); // 回溯}}
};

这样剪枝,带重复元素的子集问题也解决了。

5.8 组合/子集(元素可重不可复选)

组合问题和子集问题是等价的。对比子集问题的解法,只要额外用一个 trackSum 变量记录回溯路径上的元素和,然后将 base case 改一改即可解决这道题:

// 注意:cpp 代码由 chatGPT🤖 根据我的 java 代码翻译,旨在帮助不同背景的读者理解算法逻辑。
// 本代码还未经过力扣测试,仅供参考,如有疑惑,可以参照我写的 java 代码对比查看。class Solution {
public:vector<vector<int>> res;//记录回溯的路径vector<int> track;//记录 track 中的元素之和int trackSum = 0;vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {// 先排序,让相同的元素靠在一起sort(candidates.begin(), candidates.end());backtrack(candidates, 0, target);return res;}//回溯算法主函数void backtrack(vector<int>& nums, int start, int target) {//base case,达到目标和,找到符合条件的组合if(trackSum == target) {res.push_back(track);return;}//base case,超过目标和,直接结束if(trackSum > target) {return;}//回溯算法标准框架for(int i = start; i < nums.size(); i++) {//剪枝逻辑,值相同的树枝,只遍历第一条if(i > start && nums[i] == nums[i-1]) {continue;}//做选择track.push_back(nums[i]);trackSum += nums[i];//递归遍历下一层回溯树backtrack(nums, i+1, target);//撤销选择track.pop_back();trackSum -= nums[i];}}
};

5.9 排列(元素可重不可复选)

对比一下之前的标准全排列解法代码,这段解法代码只有两处不同:

  • 1、对 nums 进行了排序。
  • 2、添加了一句额外的剪枝逻辑。 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; }
    标准全排列算法之所以出现重复,是因为把相同元素形成的排列序列视为不同的序列,但实际上它们应该是相同的;而如果固定相同元素形成的序列顺序,当然就避免了重复。
    进一步,如果 nums = [1,2,2’,2’‘],我只要保证重复元素 2 的相对位置固定,比如说 2 -> 2’ -> 2’',也可以得到无重复的全排列结果。

进一步,如果 nums = [1,2,2’,2’‘],我只要保证重复元素 2 的相对位置固定,比如说 2 -> 2’ -> 2’',也可以得到无重复的全排列结果。之所以出现重复,是因为把相同元素形成的排列序列视为不同的序列,但实际上它们应该是相同的;而如果固定相同元素形成的序列顺序,当然就避免了重复。

// 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {// 如果前面的相邻相等元素没有用过,则跳过continue;
}
// 选择 nums[i]

当出现重复元素时,比如输入 nums = [1,2,2’,2’‘],2’ 只有在 2 已经被使用的情况下才会被选择,同理,2’’ 只有在 2’ 已经被使用的情况下才会被选择,这就保证了相同元素在排列中的相对位置保证固定。

两种剪枝方法对比:用绿色树枝代表 backtrack 函数遍历过的路径,红色树枝代表剪枝逻辑的触发

  • !used[i - 1]
    请添加图片描述

  • used[i - 1]
    请添加图片描述
    !used[i - 1] 这种剪枝逻辑剪得干净利落,而 used[i - 1] 这种剪枝逻辑虽然也能得到无重结果,但它剪掉的树枝较少,存在的无效计算较多,所以效率会差一些。

5.10 排列/组合/子集问题区别

形式一、元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次,backtrack 核心代码如下:

void backtrack(vector<int>& nums, int start) {// 回溯算法标准框架for (int i = start; i < nums.size(); i++) {// 做选择track.push_back(nums[i]);// 注意参数backtrack(nums, i + 1);// 撤销选择track.pop_back();}
}void backtrack(vector<int>& nums) {for (int i = 0; i < nums.size(); i++) {// 剪枝逻辑if (used[i]) {continue;}// 做选择used[i] = true;track.push_back(nums[i]);backtrack(nums);// 撤销选择track.pop_back();used[i] = false;}
}

形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝,backtrack 核心代码如下:


sort(nums.begin(), nums.end());/* 组合/子集问题回溯算法框架 */
void backtrack(vector<int>& nums, int start) {//回溯算法标准框架for (int i = start; i < nums.size(); i++) {// 剪枝逻辑,跳过值相同的相邻树枝if (i > start && nums[i] == nums[i - 1]) {continue;}// 做选择track.push_back(nums[i]);// 注意参数backtrack(nums, i + 1);// 撤销选择track.pop_back();}
}sort(nums.begin(), nums.end());/* 排列问题回溯算法框架 */
void backtrack(vector<int>& nums, vector<bool>& used) {for (int i = 0; i < nums.size(); i++) {// 剪枝逻辑if (used[i]) {continue;}// 剪枝逻辑,固定相同的元素在排列中的相对位置if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {continue;}// 做选择used[i] = true;track.push_back(nums[i]);backtrack(nums, used);// 撤销选择track.pop_back();used[i] = false;}
}

形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可,backtrack 核心代码如下:

/* 组合/子集问题回溯算法框架 */
void backtrack(vector<int>& nums, int start, vector<int>& track) {// 回溯算法标准框架for (int i = start; i < nums.size(); i++) {// 做选择track.push_back(nums[i]);// 注意参数backtrack(nums, i+1, track);// 撤销选择track.pop_back();}
}/* 排列问题回溯算法框架 */
void backtrack(vector<int>& nums, vector<int>& track) {if (track.size() == nums.size()) {// 输出结果// ...return;}for (int i = 0; i < nums.size(); i++) {// 排除不合法的选择if (find(track.begin(), track.end(), nums[i]) != track.end()) {continue;}// 做选择track.push_back(nums[i]);backtrack(nums, track);// 撤销选择track.pop_back();}
}

六、BFS算法框架

问题的本质就是让你在一幅「图」中找到从起点 start 到终点 target 的最近距离。

// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {queue<Node> q; // 核心数据结构unordered_set<Node> visited; // 避免走回头路q.push(start); // 将起点加入队列visited.insert(start);int step = 0; // 记录扩散的步数while (!q.empty()) {int sz = q.size();/* 将当前队列中的所有节点向四周扩散 */for (int i = 0; i < sz; i++) {Node cur = q.front();q.pop();/* 划重点:这里判断是否到达终点 */if (cur == target)return step;/* 将 cur 的相邻节点加入队列 */for (Node x : cur.adj()) {if (visited.count(x) == 0) {q.push(x);visited.insert(x);}}}/* 划重点:更新步数在这里 */step++;}
}

七、动态规划问题

动态规划问题的一般形式就是求最值。
求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值。

列出正确的「状态转移方程」,才能正确地穷举判断算法问题是否具备「最优子结构」,是否能够通过子问题的最值得到原问题的最值。
另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

框架如下:

# 自顶向下递归的动态规划
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...)

性质一 重叠子问题(重叠子问题和状态方程)

  1. 暴力递归
    斐波那契数列的数学形式就是递归的,写成代码就是这样:
int fib(int N) {if (N == 1 || N == 2) return 1;return fib(N - 1) + fib(N - 2);
}

递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。请添加图片描述
2. 带备忘录的递归解法
耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典)


int fib(int N) {// 备忘录全初始化为 0int memo[N + 1];memset(memo, 0, sizeof(memo));// 进行带备忘录的递归return dp(memo, N);
}// 带着备忘录进行递归int dp(int memo[], int n) {if (n == 0 || n == 1) return n;// 已经计算过,不用再计算了if (memo[n] != 0) return memo[n];memo[n] = dp(memo, n - 1) + dp(memo, n - 2);return memo[n];
}

请添加图片描述
从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 这两个 base case,然后逐层返回答案,这就叫「自顶向下」。
请添加图片描述
3. dp 数组的迭代(递推)解法
可以把这个「备忘录」独立出来成为一张表,通常叫做 DP table,在这张表上完成「自底向上」的推算

// 注意:cpp 代码由 chatGPT🤖 根据我的 java 代码翻译,旨在帮助不同背景的读者理解算法逻辑。
// 本代码还未经过力扣测试,仅供参考,如有疑惑,可以参照我写的 java 代码对比查看。int fib(int N) {if (N == 0) return 0;int* dp = new int[N + 1];// base casedp[0] = 0; dp[1] = 1;// 状态转移for (int i = 2; i <= N; i++) {dp[i] = dp[i - 1] + dp[i - 2];}return dp[N];
}

请添加图片描述
引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:请添加图片描述
f(n) 的函数参数会不断变化,所以你把参数 n 想做一个状态,这个状态 n 是由状态 n - 1 和状态 n - 2 转移(相加)而来,这就叫状态转移,它是解决问题的核。动态规划问题最困难的就是写出这个暴力解,即状态转移方程。只要写出暴力解,优化方法无非是用备忘录或者 DP table,再无奥妙可言。

性质二 凑零钱问题(最优子结构)

  1. 暴力递归

这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立。
假设你有面值为 1, 2, 5 的硬币,你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10, 9, 6 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1, 2, 5 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的。

  1. 动态规划问题的一般思路:
  • 确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。

  • 确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。

  • 确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。

  • 明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。

  1. 这样定义 dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量。
class Solution {private:vector<int> memo;/*** memo 数组记录了 amount 对应的最优解,-666 代表还未被计算。* 因此,在计算 amount 对应的最优解时需要先查找 memo。*/int dp(vector<int>& coins, int amount){//1.确定Base Caseif(amount == 0) return 0; // 如果 amount = 0,那么硬币数量为 0if(amount < 0) return -1; // 如果 amount < 0,那么无解if(memo[amount] != -666) return memo[amount]; // 查备忘录,如果有最优解则直接返回int res = INT_MAX;/*** 在硬币数组 coins 中依次选取每个硬币。* 对于当前选择的硬币,计算是包含该硬币所需的子问题的最优解 subProblem。* 如果子问题无解,则直接跳过该硬币。* 在所有子问题中,选取最优解,并加上该硬币的数量。* 最终的结果 res 即为 amount 对应的最优解,该结果存入 memo 中。*///2.确定状态1: coin (原问题和子问题中的变量)for(int coin : coins){//3.定义行为(引起状态转移的行为): dp(coins, amount - coin)int subProblem = dp(coins, amount - coin);if(subProblem == -1) continue;//4.明确dp: 求最值res = min(res, subProblem + 1);}memo[amount] = res == INT_MAX ? -1 : res;return memo[amount];}public:int coinChange(vector<int>& coins, int amount) {/*** 初始化备忘录 memo,memo 数组的长度为 amount+1。* memo 数组记录了 amount 对应的最优解,-666 代表还未被计算。* 因此,在计算 amount 对应的最优解时需要先查找 memo,再根据结果进行计算。*/memo = vector<int>(amount + 1, -666);return dp(coins, amount);}
};
  1. dp 数组的迭代解法,自底向上使用 dp table 来消除重叠子问题,关于「状态」「选择」和 base case 与之前没有区别,dp 数组的定义和刚才 dp 函数类似,也是把「状态」,也就是目标金额作为变量。不过 dp 函数体现在函数参数,而 dp 数组体现在数组索引:

dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。

int coinChange(vector<int>& coins, int amount) {vector<int> dp(amount + 1, amount + 1);// 数组大小为 amount + 1,初始值也为 amount + 1dp[0] = 0;// 外层 for 循环在遍历所有状态的所有取值for (int i = 0; i < dp.size(); i++) {// 内层 for 循环在求所有选择的最小值for (int coin : coins) {// 子问题无解,跳过if (i - coin < 0) {continue;}dp[i] = min(dp[i], 1 + dp[i - coin]);}}return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

请添加图片描述

动态规划问题的一般思路

  1. 列出状态转移方程,解决“如何穷举”的问题。
  • 首先定义bace case
  • 其次定义状态(子问题和父问题中变化的变量)
  • 然后定义行为(进行子问题和夫问题之间状态转移的行为)
  • 最后根据问题定义dp(求最值获其他)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/57361.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【回答问题】ChatGPT上线了!推荐40个以上比较好的目标检测模型

推荐40个以上比较好的目标检测模型? 目标检测是指在图像中找到并标识出特定目标的计算机视觉任务。近年来,机器学习技术的发展使得目标检测取得了长足进步。目前有许多优秀的目标检测模型,下面是推荐的40个以上的比较好的目标检测模型: R-CNN (Regions with CNN features…

足球推荐预测

彼得堡联 - 雷丁 我看好让负&#xff0c;看好大家私&#xff0c;两队在前段时间都是很少有胜绩&#xff0c;竞技状态都比较低迷&#xff0c;而且前不久遇到了同一支球队考文垂&#xff0c;战绩都不漂亮&#xff0c;两队实力相差程度不大&#xff0c;所以让负几率大一些

世预赛乌拉圭VS哥伦比亚最新赛事解析:客场保平属性强怎么下单

乌拉圭VS哥伦比亚南美预选赛临场交流:苏亚雷斯或可出场瓦尔韦德身陷囹吾&#xff0c;萨帕塔来袭乌拉圭能否首回合取胜&#xff01;10月开门红&#xff0c;英锦赛吉灵汉姆负加米尔顿让平和林肯城负全部拿捏&#xff0c;希望跟上的朋友能够点个赞支持一下&#xff0c;同时相信失利…

搜球半,看免费足球分析,6月29日今日竞彩三连推荐

日职联&#xff1a;大阪钢巴 VS 广岛三箭 周三001 06-29 18:00 大阪钢巴执教更换为上赛季带领大分三神打进天皇杯决赛的片野坂知宏&#xff0c;此前他带领大分三神从日职乙升级&#xff0c; 球队本赛季表现非常不理想&#xff0c;过去17轮比赛只拿到了4场胜局&#xff0c;球队方…

支持电竞比分实时查询的软件~和比分网之间的对比

其实我是搞脚本开发的~~~~对于电竞方面的了解还较少&#xff0c;但是今天我们要为大家带来一个另类的话题&#xff0c;那就是“电竞比分”、“战绩查询”&#xff0c;之前在此博客更新的都是脚本开发之类的&#xff0c;电竞类的还真没发表过&#xff0c;最近想开发一款此类的电…

Meta欲关闭Echo VR,游戏引擎大神卡马克发长文批判!

"即使只有一万个活跃用户&#xff0c;如果可能的话&#xff0c;也应该避免破坏这种用户价值。" 整理 | 邓晓娟 责编 | 梦依丹 出品 | CSDN&#xff08;ID&#xff1a;CSDNnews&#xff09; 上周&#xff0c;Meta 正式宣布将于今年 8 月初关闭零重力射击 VR 射击…

【编程语言】Scala 函数式编程

函数是Scala 中的一等公民。 本文讨论Scala函数式编程的一些基本原理。你将会学到如何使用高阶函数&#xff0c;以及重用已有代码时&#xff0c;遵守 DRY 原则。 Scala 的集合库很棒 # So what does the above buy you anyway? The following are some examples from Scala’s…

电量使用情况android系统,使用 Battery Historian 分析耗电情况

您可以通过 Battery Historian 工具了解设备随时间的耗电情况。在系统级别&#xff0c;该工具以 HTML 的形式可视化来自系统日志的电源相关事件。在具体应用级别&#xff0c;该工具可提供各种数据&#xff0c;帮助您识别耗电的应用行为。 本文档介绍了使用 Battery Historian 了…

android app耗电分析方法

这是一篇讲述应用耗电的文章&#xff0c;围绕 Android 电量采集机制及第二代 Battery Historian 分析工具讲述。文从数据采集、导出、环境搭建、解读报告的角度出发&#xff0c;从细节讲解整个流程。和大谈概念的文章不同&#xff0c;这里将进行实际操作及分析。 写作动机来源…

Android App 耗电的测试方法

这是一篇讲述应用耗电的文章&#xff0c;围绕 Android 电量采集机制及第二代Battery Historian分析工具讲述。文从数据采集、导出、环境搭建、解读报告的角度出发&#xff0c;从细节讲解整个流程。和大谈概念的文章不同&#xff0c;这里将进行实际操作及分析。 电量统计模块概…

一种Android应用耗电定位方案

背景 通常来说&#xff0c;app耗电相比于其他的性能问题&#xff08;Crash&#xff0c;Anr&#xff09;等&#xff0c;会受到比较少的关注&#xff0c;耗电通常是一个app隐藏的性能问题&#xff0c;同时又由于手机性能不同&#xff0c;使用时长不同&#xff0c;使用习惯不同&a…

如何降低android应用程序的耗电量

转自&#xff1a;http://www.apkbus.com/forum.php?modviewthread&tid5459&extrapage%3D3 如果手机&#xff08;移动设备&#xff09;没电了&#xff0c;你的程序还能运行吗&#xff1f; 哈哈&#xff0c;这是地球人都知道的问题&#xff0c;那么如何才能降低androi…

IOS耗电量测试(一)耗电量数据获取

转载&#xff1a;https://blog.csdn.net/redcard0/article/details/89030124 随着游戏越来越重度&#xff0c;游戏耗电太高造成游戏发烫的投诉量已经仅次于帧率&#xff0c;高于针对内存崩溃的投诉。优化的前提是耗电量数据可以度量&#xff0c;本文主要阐述耗电量数据如何获取…

APP专项测试之耗电量测试

一、耗电量测试分析 相对于PC端来说&#xff0c;移动设备的电池电量是非常有限的&#xff0c;保持持久的续航能力尤为重要。Android的很多特性都比较耗电&#xff08;如屏幕、GPS、sensor传感器、唤醒机制、CPU、连网等的使用&#xff09;&#xff0c;我们必须要慎重检查APP的…

如何测试Android APP的耗电量?

现在可以使用google提供的battery-historian来测试&#xff0c;适用条件&#xff1a;5.0及以上手机。 battery-historian链接&#xff1a;google/battery-historian android吧 所以的android都自带的功能 设置--->电池/电源管理/ MQC在兼容性测试、功能测试、稳定性测试中都…

app耗电量测试

目录 目录 1. 引言 2. 测试方法 2.1. 直接观察 2.2. 使用adb命令进行统计 3. 典型的耗电场景 3.1. 定位 3.2. 网络传输 3.3. 音视频播放 4. app电量分析工具 4.1. Batterystats 4.2. Battery Historian 5. 环境安装 5.1. adb命令 5.2. 安装go 5.3. 安装git 5.4…

盘点COVID-19新冠药物和疫苗研发进展

COVID-19是由严重急性呼吸系统综合症冠状病毒2&#xff08;SARS-CoV-2&#xff09;引起的一种传染病&#xff0c;这是一种单股正链RNAβ冠状病毒&#xff0c;它是Beta-CoV谱系B&#xff08; Sarbecovirus亚属&#xff09;。 COVID-19代表着全球健康威胁&#xff0c;并且是可能引…

药物临床试验数据递交FDA的规定

信息来源&#xff1a; https://www.fda.gov/industry/fda-data-standards-advisory-board/study-data-standards-resources STUDY DATA TECHNICAL CONFORMANCE GUIDE v4.9 (March 2022) (研究数据技术一致性指南) 仅提取该文档中的部分内容加以翻译&#xff0c;以下中文都是…

姜敬哲/孙燕妮/原丽红合作开发可用于病毒快速分类的工具PhaGCN2

南海水产研究所姜敬哲团队、香港城市大学孙燕妮团队、广东药科大学原丽红合作开发的可用于病毒快速分类生信工具 使用PhaGCN2对病毒基因组片段分类 Virus classification for viral genomic fragments using PhaGCN2 文章链接&#xff1a;https://www.researchsquare.com/artic…

多组学在药物机制解析和诊断标志物开发中的应用

链接&#xff1a;多组学在药物机制解析和诊断标志物开发中的应用_哔哩哔哩_bilibili 药物研发流程和多组学前沿技术 药物研发流程遇到的挑战 流程&#xff1a;新药的发现——临床前研究——临床研究 挑战&#xff1a; 诊断是否清晰、机制是否明确、靶点是否可靠、药物是否有…