提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
一、双指针
双指针(Two Pointers)是一种常用的算法技巧,主要用于处理数组或字符串中的某些问题。它通过维护两个指针来遍历数据结构,从而高效地解决问题。根据指针的移动方向和位置,双指针技术可以分为同向双指针和对向双指针。
1.1 有序数组的合并
这里以88.合并两个有序数组为例,这里的思路为先复制的一个数组p1_copy,然后用两个指针分别指向p1_copy和p2,最后依次比较两个数组里的元素大小并填充。
class Solution {
public:
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
int* p1_copy = new int[m];
std::copy(nums1.begin(), nums1.begin() + m, p1_copy);
int p1 = 0, p2 = 0, cur = 0;
while (p1 < m && p2 < n) {
if (p1_copy[p1] <= nums2[p2]) nums1[cur++] = p1_copy[p1++];
else nums1[cur++] = nums2[p2++];
}
// 如果 p1_copy 中还有剩余元素,拷贝到 nums1
while (p1 < m) {
nums1[cur++] = p1_copy[p1++];
}
// 如果 nums2 中还有剩余元素,拷贝到 nums1
while (p2 < n) {
nums1[cur++] = nums2[p2++];
}
// 释放动态分配的内存
delete[] p1_copy;
}
};
1.2 快慢指针/删除有序数组中的重复项
除了进行插入操作,双指针也可以用作快慢指针,例如26.删除有序数组中的重复项,这里的主要思路是利用两个指针,快指针用于判断每一个元素的值,慢指针用于进行赋值。
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
int len = 1; // 因为至少有一个元素不会被移除
for (int i = 1; i < n; i++) {
if (nums[i] != nums[len - 1]) {
nums[len] = nums[i];
len++;
}
}
return len;
}
};
1.3 求和
167.两数之和和15.三数之和是双指针另一种常用方式,二者的本质其实都是对向双指针(有点类似二分法),判断和与目标数的大小关系进行指针的调整。三数之和是在二数的基础上多加了一层遍历,将每次取得的数作为目标数,再转化成二数之和。值得注意的是需要跳过重复数字。
//二数之和
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int n = numbers.size();
int i = n - 1;
int j = 0;
while(numbers[i] + numbers[j] != target){
if(numbers[i] + numbers[j] < target) j++;
else if(numbers[i] + numbers[j] > target) i--;
else break;
}
return {j + 1, i + 1};
}
};
//三数之和
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<vector<int>> ans;
int n = nums.size();
for (int i = 0; i < n - 2; i++) {
if (i > 0 && nums[i] == nums[i - 1]) continue; // 跳过重复数字
int target = -nums[i];
int l = i + 1;
int r = n - 1;
while (l < r) {
int sum = nums[l] + nums[r];
if (sum > target) r--;
else if (sum < target) l++;
else {
ans.push_back({nums[i], nums[l], nums[r]});
while (l < r && nums[l] == nums[l + 1]) l++; // 跳过重复数字
while (l < r && nums[r] == nums[r - 1]) r--; // 跳过重复数字
l++;
r--;
}
}
}
return ans;
}
};
1.4 滑动窗口
滑动窗口算法可以被视为双指针技术的一种特殊应用或变形。滑动窗口通常使用两个指针(左指针和右指针)来表示当前窗口的边界,并通过移动这两个指针来动态调整窗口的大小和位置,特别适用于处理子数组或子串问题。滑动窗口算法的核心思想是使用两个指针(通常称为“左指针”和“右指针”)来表示当前窗口的边界,并根据问题的需求动态调整这两个指针的位置。具体步骤如下:
- 初始化两个指针 left 和 right,分别指向窗口的左右边界。
- 移动右指针 right 来扩展窗口,直到满足某个条件(如窗口内的元素之和大于目标值、窗口内的字符集包含所有目标字符等)。
- 当窗口满足条件后,尝试移动左指针 left 来收缩窗口,以寻找更小的满足条件的窗口或记录当前窗口的状态。
- 不断重复扩展和收缩窗口的过程,直到右指针遍历完整个数组或字符串。
- 根据具体问题的要求,在每次窗口满足条件时记录结果(如最小长度、最大和等),并在最后返回所需的结果。
这里以3.无重复字符的最长子串为例,不断移动右指针,并将该位置元素放入哈希映射,如果检索到已经存在相同元素则移动左指针收缩窗口。
class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_set<char> uset;
int n = s.size();
int l = 0;
int ans = 0;
for(int r = 0; r < n; r++){
while(uset.find(s[r]) != uset.end()){
uset.erase(s[l]);
l++;
}
uset.insert(s[r]);
ans = max(ans, r - l + 1);
}
return ans;
}
};
二、动态规划
动态规划(Dynamic Programming,简称 DP)是一种用于解决具有重叠子问题和最优子结构性质的问题的算法设计技术。动态规划通过将问题分解成更小的子问题,并保存这些子问题的解决方案,以避免重复计算,从而提高算法效率。
2.1 自底向上和自顶向下(带备忘录)
这里以70.爬楼梯为例,我们用 f(x) 表示爬到第 x 级台阶的方案数,考虑最后一步可能跨了一级台阶,也可能跨了两级台阶,所以我们可以列出动态规划的转移方程:f(x)=f(x−1)+f(x−2),即爬到第 x 级台阶的方案数是爬到第 x−1 级台阶的方案数和爬到第 x−2 级台阶的方案数的和。
class Solution {
public:
int climbStairs(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int* num = new int[n + 1];
num[1] = 1;
num[2] = 2;
for (int i = 3; i <= n; i++) {
num[i] = num[i - 1] + num[i - 2];
}
int result = num[n];
delete[] num;
return result;
}
};
118.杨辉三角也是一个很好的自顶向下示例,如下图所示。可以看到其规律为:(1)每一排的第一个数和最后一个数都是 1,即 c[i][0]=c[i][i]=1;(2)其余数字,等于左上方的数,加上正上方的数,即 c[i][j]=c[i−1][j−1]+c[i−1][j]。
我们简单地抽象一下这个三角形,即{[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]}
,那么我们可以将其看作是二维数组,然后每一层比上面一层多一个元素,利用resize()函数扩充一个元素。
class Solution {
public:
vector<vector<int>> generate(int numRows) {
vector<vector<int>> dp(numRows); // 正确初始化二维向量
for(int i = 0; i < numRows; i++){
dp[i].resize(i + 1, 1); // 调整第i行的大小为i+1,并填充1
for(int j = 1; j < i; j++){
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; // 计算杨辉三角的值
}
}
return dp;
}
};
2.2 带有当前状态
2.3 背包问题
常见的背包问题分为0/1背包和完全背包,遍历顺序也有两种:如果求组合数就是外层for循环遍历物品,内层for遍历背包;如果求排列数就是外层for遍历背包,内层for循环遍历物品。值得注意的是,背包问题的本质其实是二维问题,但是我们在处理时常常利用滚动数组的方法降为一维。
2.3.1 0/1背包问题
0/1完全背包问题可以简单总结为:有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
- dp[j]为 容量为j的背包所背的最大价值;
- dp[0]表示背包容量为0时的总价值,这时候什么都放不下,所以dp[0]=0,且由于每次都去最大值,则使得整个数组初始值为0;
- 此时dp[j]有两个选择:
- 取自己dp[j],即不放物品i;
- 一个是即选择了第 i 件物品,那么剩余容量 j - w[i] 时的最大价值再加上 i 物品的价值;
所以取最大的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
- 我们有两个主要的遍历方向:物品 和 容量。
- 物品外层遍历,容量内层遍历(从后向前遍历容量)----这种遍历方式可以确保每个物品只被选择一次。
for (int i = 0; i < n; i++) { // 遍历物品:对于每一个物品 i,我们尝试放入背包中,更新相应容量下的最大价值
for (int j = C; j >= w[i]; j--) { // 遍历容量:我们从大到小遍历容量 j,这样可以确保每个物品在每次决策中只被计算一次。
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
值得注意的是:在完全背包问题中,我们可以采用外层遍历,物品内层遍历(从前向后遍历容量),而在0/1背包问题中确不可以,这是由于它会使同一个物品在一次决策中被多次选择。
2.3.2 完全背包
完全背包问题和0/1背包问题的最多差别在于每件物品都有无限个(也就是可以放入背包多次),我们这里以322.零钱兑换为例,其思路大致如下:
- 将dp[j]视为凑足总额为j所需钱币的最少个数;
- dp[0] = 0,但为了防止min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖,将dp[i]赋值为INT_MAX;
- 递推公式:dp[i] = min(dp[i - coins[j]] + 1, dp[i]);
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包
if (dp[j - coins[i]] != INT_MAX) { // 如果dp[j - coins[i]]有效
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
}
}
}
if (dp[amount] > amount) return -1; // 若硬币数超出amount则说明出错
return dp[amount];
}
};
2.4 子序列问题
解决子序列问题时,通常可以采用动态规划或递归的方法,我们这里以300.最长递增子序列为例,最长递增子序列(LIS)问题是一个经典的动态规划问题。给定一个数组,你需要找到其中最长的子序列(子序列不需要是数组中的连续元素),使得该子序列中的元素按升序排列。
- dp[i]为以nums[i]为结尾的子列最长长度;
- 由于每个元素本身可以算作长度为 1 的子序列,每个位置的 dp[i] 至少为 1;
- 对于每个元素 nums[i],我们遍历它之前的所有元素 nums[j](j < i),如果 nums[i] 能够接在 nums[j] 之后形成一个递增子序列(即 nums[j] < nums[i]),那么我们就更新 dp[i,即dp[i]=max(dp[i],dp[j]+1);
- 这里可以利用双重循环遍历:
- 外层循环遍历数组的每一个元素 i,内层循环遍历当前元素 i 之前的所有元素 j,如果 nums[j] < nums[i],就更新 dp[i] 的值;
- 内层循环通过比较 nums[i] 和之前的所有 nums[j],逐步找到以 nums[i] 为结尾的最长递增子序列。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if(nums.size() < 2) return nums.size(); // 数组个数小于2,返回其本身
vector<int> dp(nums.size(), 1); // 初始化为1,默认每一个子列至少有一个元素
for(int i = 1; i < nums.size(); i++){
for(int j = 0; j < i; j++){
if(nums[j] < nums[i]) // 满足递增条件
dp[i] = max(dp[i], dp[j] + 1);
}
}
// 返回dp[i]最大值
return ranges::max(dp);
}
};
2.5 多维动态规划
多维动态规划(Multidimensional Dynamic Programming)是动态规划的一种特例,用于处理状态空间涉及多个维度或参数的问题。在多维动态规划中,DP数组的维度(通常使用多维数组或表来存储)对应问题中的状态变量的个数。每个状态的值通过在前一状态的基础上进行计算,通常需要根据不同的维度依次遍历并更新状态。
2.5.1 最小路径和
这里以64.最小路径和为例,这其实可以看作一个二维矩阵,而我们所构建的dp[i][j]表示当前的最短路径之和,如下图所示。这时候我们可以很容易得到(i+1,j+1)是(i+1,j)或者(i,j+1)的最小路径和+自己的值,即dp[i + 1][j + 1] = min(dp[i+1][j], dp[i][j+1]) + grid[i][j]。但是这时候会发现当dp[i][j]在上、左边界时只有一个来源,为例简化代码我们可以再扩充两个索引行/列并将其初始化为最大值。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
// dp[i+1][j+1]表示到达grid[i][j]的最小路径和
vector<vector<int>> dp(m+1, vector<int>(n+1, INT_MAX));
dp[1][1] = grid[0][0];
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
if( i == 1 && j == 1) continue;
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1];
}
}
return dp[m][n];
}
};
2.5.2 路径规划问题
路径规划问题是很经典的二维动态规划,我们以63.不同路径II为例,这里我们采用和最小路径和一样的思路,dp[i][j]表示到达(i-1,j-1)时的路径数总和,遍历方式和扩充方式索引行、列的思路都一样。不同之处在于初始化,我们将扩充第0行和第0列的所有元素均取为0,并从dp[1][1]开始遍历。当遍历到原数组的值为1时,令对应的dp为0,表示此路不通。
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
if (obstacleGrid[0][0] == 1) return 0;
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0)); // 初始化多一行多一列
dp[1][1] = 1; // 起始位置设为1
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
// 跳过起始位置,因为dp[1][1]已经被初始化为1
if(i == 1 && j == 1) continue;
// 若不考虑障碍则不需要这个判断
if(obstacleGrid[i-1][j-1] == 1)
dp[i][j] = 0;
else
dp[i][j] = dp[i-1][j] + dp[i][j-1]; // 统一使用递推公式
}
}
return dp[m][n]; // 返回终点位置的路径数
}
};
2.5.3 编辑距离
这里以72.编辑距离为例,本题的思路和前两题基本一致,仍然是在原二维数组的基础上,以其修改所需的操作数为dp[i][j],扩充最上和最左两行方便代码统一。不同之处在于dp的理解上,本题目二维是不太好理解的,我们认为dp[i]表示word1,dp[j]表示word2,此时对于各对应元素有两种情况,即二者相等(此时dp[i][j] = dp[i-1][j-1])或者不同(此时将word1的某元素修改为word2的某元素就需要将+1)。但是存在三种修改方式:增、减、替换,那么可以视作:
- 替换操作:dp[i][j] = dp[i-1][j-1] + 1,将 word1[i-1] 替换为 word2[j-1];
- 删除操作:dp[i][j] = dp[i-1][j] + 1,删除 word1[i-1];
- 插入操作:dp[i][j] = dp[i][j-1] + 1,在 word1 后插入 word2[j-1]。
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size();
int n = word2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
// 初始化:这两行表示将字符串重置为空字符需要的操作数
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
//遍历
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
if(word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else
dp[i][j] = min({dp[i - 1][j - 1] + 1, // 替换
dp[i - 1][j] + 1, // 删除
dp[i][j - 1] + 1}); // 插入
}
}
return dp[m][n];
}
};
2.5.4 最长回文串
这种题型和常见的多维模型不同,本质上他是一维的但是需要我们进行一个逆向读取子串的操作,所以还是使用双向遍历(正、反方向)。以5.最长回文串为例,具体思路如下所示
- dp[i][j] 表示字符串从第 i 个字符到第 j 个字符的子串是否是回文子串;
- 在外层循环从字符串的末尾向前遍历;内层循环从当前字符 i 开始向后遍历;
- 通过判断条件 s[i] == s[j] 用于检查当前子串的两端字符是否相同;
- 通过比较 result 和 j - i 的值,更新最长回文子串的长度。
class Solution {
public:
string longestPalindrome(string s) {
// 1. 初始化 DP 表
vector<vector<int>> dp(s.size(), vector<int>(s.size(), false));
// 2. 初始化存储最长回文子串的长度
int result = 0;
// 3. 初始化存储最长回文子串的字符串
string str;
// 4. 从字符串的末尾向前遍历
for(int i = s.size() - 1; i >= 0; i--) {
// 5. 从当前字符 i 开始向右遍历
for(int j = i; j < s.size(); j++) {
// 6. 判断当前子串是否为回文
if(s[i] == s[j] && (j - i <= 1// 一个元素或两个相同的元素必是回文
|| dp[i + 1][j - 1] == true)) { // 内部是回文串
// 7. 如果是回文,则更新 DP 表
dp[i][j] = true;
// 8. 更新最长回文子串的长度
result = max(result, j - i);
// 9. 更新最长回文子串
if(j - i >= result) str = s.substr(i, j - i + 1);
}
}
}
return str;
}
};
2.5.5 最长公共子序列
LCS 问题是指在两个字符串中找出长度最长的公共子序列,子序列不要求在原字符串中是连续的,但要求相对顺序一致。这里以1143.最长公共子序列为例,本题的思路和编辑距离的方法其实大差不差,视两个字符串构成了一个二维数组,dp[i][j]表示[i-1][j-1]最长公共子序列,dp[i]表示对text1的各个元素进行检索,dp[j]表示对text2进行检索。在循环中,我们判断text1[i - 1] == text2[j - 1]是否成立,若成功则更新dp的值。
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size();
int n = text2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(int i = 1; i <= m; i++){
for(int j = 1; j <= n; j++){
if(text1[i - 1] == text2[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m][n];
}
};
2.6 Kadane 算法
Kadane 算法的核心思想在于遍历数组时维护当前最大/最小子数组和,实时更新全局最优解。这里以918. 环形子数组的最大和为例,根据题目要求我们知道存在两种可能性:1.子数组是连续的;2.子数组是跨边界的。这个时候我们如果死磕“回环”就会发现这个题很麻烦,但是我们可以换位思考子数组从跨边界连接的,则只需要将数组减去中间部分即可达到相同的效果,也就是将回环的最大不连续子串变成了连续的最小子串,这里借用一下灵神的示例图方便大家理解:
这样一来我们就可以只使用一个循环完成两种情况的同时统计:
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int maxSubarraySumCircular(vector<int>& nums) {
int n = nums.size();
if(n == 0) return 0 ;
// 情况一:最大连续子数组
int sum_max = nums[0], ans_max = nums[0];
// 情况一:最大跨边界子数组(即最小的中间连续子数组)
int sum_min = nums[0], ans_min = nums[0];
// 将nums[0]添加
int total = nums[0];
for(int i = 1; i < n; i++){
// 第一种
sum_max = max(nums[i], sum_max + nums[i]);
ans_max = max(ans_max, sum_max);
// 第二种
sum_min = min(nums[i], sum_min + nums[i]);
ans_min = min(ans_min, sum_min);
// 统计数组之和
total += nums[i];
}
// 如果中间的连续子串之和等于总和,则说明中间的最小连续子串就是完整的数组
if(total == sum_min) return ans_max;
// 比较两种情况下的子数组和
else return max(total - ans_min, ans_max);
}
};
三、二分算法
二分法(Binary Search)是一种在有序数组中查找目标值的高效算法。它通过不断将查找范围减半,从而在每次比较后将查找范围缩小一半,最终找到目标值或确定目标值不存在。其基本思路如下所示:
- 初始化: 设定查找范围的左边界 left 和右边界 right。
- 计算中间点: 计算中间点 mid = left + (right - left) / 2。
- 比较中间点的值与目标值:
- 如果 nums[mid] == target,则找到目标值,返回 mid。
- 如果 nums[mid] < target,则目标值在右半部分,调整左边界 left = mid + 1。
- 如果 nums[mid] > target,则目标值在左半部分,调整右边界 right = mid - 1。
- 重复步骤 2 和 3,直到找到目标值或查找范围为空(left > right)。
3.1 经典二分查找
这里以34.在排序数组中查找元素的第一个和最后一个位置,这里可以简单改写一下这个问题,将其改写成寻找第一个target的位置和第一个target+1的位置,相当于两次利用二分。
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
int begin = findborder(nums, target);
if (begin == nums.size() || nums[begin] != target) return {-1, -1};
int end = findborder(nums, target + 1);
return {begin, end - 1};
}
int findborder(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
int mid;
while (left <= right) {
mid = left + (right - left) / 2;
if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return left;
}
};
3.2 寻找峰值
以162.寻找峰值为例,本题的目标是在一个无序数组中找到一个“峰值”元素的索引。与上题不同的是无序数组没有全局的有序性,因此需要通过局部性质来判断搜索的方向:如果中间元素比右侧元素大,那么峰值可能在左侧(包括中间元素),因此缩小搜索范围到左半部分;如果中间元素比右侧元素小,那么峰值一定在右侧,因此缩小搜索范围到右半部分。
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while(left < right){
int mid = left + (right - left) / 2;
if(nums[mid] > nums[mid + 1]) right = mid; // 峰值在左半部分(包括 mid)
else left = mid + 1; // 峰值在右半部分(不包括 mid)
}
return left;
}
};
3.3 寻找选择排序数组中的最小值
&esmp;这种类型的题目最为关键的一点在于它的数组不是升序的,例如,原数组 nums = [0,1,2,4,5,6,7] 若旋转 4 次,则可以得到 [4,5,6,7,0,1,2],这样的话我们就不能简单地将left和righ定义在数组的两端,然后逐次逼近,需要多考虑将原数组分化成两个数组,且在哪个数组中是有序的,以便我们进行分类处理。以153.寻找选择排序数组中的最小值为例,我们的解决思路大致如下:
- 按照惯例定义left,righ及mid;
- 取右半部分(mid -> right)进行判别,若left < right,则说明在该部分是有序的,则说明最小值不可能在(mid,righ]之间,故将righ移动到mid(防止最小值就是mid);
- 与此同时,若left >= right,则证明该部分是无序的,结合示例数组可知最小值一定在该部分内。
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < nums[right]) {
right = mid; // 最小值在右半部分
} else {
left = mid + 1; // 最小值在左半部分或者就是 mid
}
}
return nums[left];
}
};
四、贪心算法
贪心算法(Greedy Algorithm)是一种在求解优化问题时所采用的方法。贪心算法的核心思想是:在每一步选择中,选择当前状态下最好(即最优)的选择,期望通过局部最优选择达到全局最优。贪心算法通常用于解决一些特定类型的问题,这些问题具有“贪心选择性质”和“最优子结构性质”。贪心算法的基本步骤为:
- 建立数学模型来描述问题。
- 将问题分解为若干个子问题。
- 对每个子问题求解,得到局部最优解。
- 将所有子问题的局部最优解合并成一个全局解。
通常情况下,有两种基本贪心策略:从最小/最大开始贪心,优先考虑最小/最大的数。在此基础上,衍生出了反悔贪心;从最左/最右开始贪心,思考第一个数/最后一个数的贪心策略,把 n 个数的原问题转换成n−1 个数(或更少)的子问题。
4.1 从最小/最大开始贪心
我们以55.跳跃游戏为例,根据题目要求我们在规定的可选步长(step)内选择合适的长度,最终到达最后一个元素即可,但是实际上如果我们陷入这样的思维就很难继续了,例如对于给出的[2,3,1,1,4],我可以选择分别走2->1->1,也可以走1->3,但是我们可以发现一个现象如果我们在每步都取最大步长,且各个部分的最大步长合起来可以覆盖到终点就可以代表能走到最后,这样就不需要考虑各地方到底是怎么走的了,如图所示。
class Solution {
public:
bool canJump(vector<int>& nums) {
if(nums.size() == 1) return true;
int step = 0;
for(int i = 0; i <= step; i++){
step = max(i + nums[i], step);
if(step >= nums.size() - 1) return true;
}
return false;
}
};
4.2 从最左/右开始贪心
这里以134.加油站为例,通过在遍历过程中动态调整起点来找到唯一的可行起点。具体来说,贪心策略体现在每次遇到当前油量不足的情况时,立即放弃从当前起点到当前加油站之间的所有加油站作为起点,因为如果从这些加油站中的任何一个出发,都无法到达当前加油站。
class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int current = 0;
int total = 0;
int start = 0;
for(int i = 0; i < gas.size(); i++){
current = current + (gas[i] - cost[i]);
total = total + (gas[i] - cost[i]);
if(current < 0){
start = i + 1;
current = 0;
}
}
if(total < 0) return -1;
return start;
}
};
4.3 区间
在计算机中区间和数学中一样,均表示一个范围,通常情况下会写成字符串数组的形式。以例57.插入区间为例,这里我们就可以使用贪心算法,其体现在两个方面:不重叠的区间直接添加;发现当前区间 x 与 newInterval 重叠则选择合并。
class Solution {
public:
vector<vector<int>> insert(vector<vector<int>>& intervals, vector<int>& newInterval) {
// 如果 intervals 为空,直接返回 newInterval 作为唯一的结果
if(intervals.empty()) return {newInterval};
vector<vector<int>> res;
bool inserted = false;
for(auto x : intervals){
if(x[1] < newInterval[0]){
// 当前区间在 newInterval 左侧,无重叠
res.push_back(x);
}
else if(newInterval[1] < x[0]){
// 当前区间在 newInterval 右侧,无重叠
if(!inserted) {
res.push_back(newInterval);
inserted = true;
}
res.push_back(x);
}
else{
// 当前区间与 newInterval 有重叠,合并
newInterval[0] = min(newInterval[0], x[0]);
newInterval[1] = max(newInterval[1], x[1]);
}
}
// 如果 newInterval 还未插入,则插入它
if(!inserted) {
res.push_back(newInterval);
}
return res;
}
};
值得注意的是if (!inserted) 语句的作用是在处理所有的区间后,确保新区间 newInterval 被正确地插入到结果中。假设 intervals 中的所有区间都在 newInterval 的左侧且不重叠,循环中每次都会走到 if (x[1] < newInterval[0]) 分支,将所有的原始区间直接添加到 res,但 newInterval 并没有被插入;另一种情况是 newInterval 小于所有区间且不重叠,在这种情况下,newInterval 应该在最前面插入。但如果不检查 !inserted,newInterval 可能会被忽略,因此在遍历完成后,newInterval 需要被手动添加。
五、回溯算法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式,其本质是穷举。回溯是递归的副产品,只要有递归就会有回溯,二者相辅相成。常见的应用场景为组合问题、切割问题、子集问题、排列问题和棋盘问题。这里摘取了代码随想录提供的回溯函数(递归函数)模板:
// 这里采用的是void类型,如果想在主函数里获取递归结果需要在外部定义全局变量
void backtracking(参数) {
if (终止条件) {
存放结果;
// 确保递归在合适的时间停止
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
5.1 组合问题
这里以17.电话号码的字母组合为例,这里的号码和字母遵循9键输入法的映射规则,那么当我们在输出其组合逻辑时需要对由号码构成的字符串进行逐步拆分,然后采用树形图的逻辑进行遍历。这里我们输入23进行举例,则回溯函数的处理逻辑为:
- 先通过拆解得到digitals[0] = 2,查询映射得出此时的字母串为"abc";
- 进入循环中,再次递归寻找digitals[1] = 3,此时查询映射的字母串为"def";
- 此时的索引大小等于输入字符串的长度,即表示都找到了对应字母,将其并回溯到上一次递归;
- 当所有以处理 "a"开头的字符串均存入后,回到第一层递归,我们开始处理 “b”;
- 重复步骤 2 至步骤 3,生成 “bd”, “be”, “bf”,继续回到第一层,处理 “c”。
class Solution {
private:
vector<string> result;
unordered_map<int, string> map{
{'2', "abc"},
{'3', "def"},
{'4', "ghi"},
{'5', "jkl"},
{'6', "mno"},
{'7', "pqrs"},
{'8', "tuv"},
{'9', "wxyz"}
};
void letterhelper(const string digits, int index, string s){
if(index == digits.size()){
result.push_back(s);
return;
}
int digit = digits[index];
string temp = map[digit];
for(int i = 0; i < temp.size(); i++){
letterhelper(digits, index + 1, s + temp[i]);
}
}
public:
vector<string> letterCombinations(string digits) {
if(digits.empty()) return {};
letterhelper(digits, 0, "");
return result;
}
};
5.2 子集问题
其实子集问题和组合问题类似,都是对给定的字符串进行拆分再逐步整合,不同之处在于不需要考虑是否达到要求的长度,而是每种情况都予以返回。这里以78.子集为例,回溯函数的处理逻辑为:
- 每次进入均存储当前temp(这里的temp可以当作栈来看,用于存储各个元素,在要溢出的时候弹出最后的元素);
- 逐步增加元素个数直到等于s.size();
- 弹出temp的最后一个元素;
- 重复2,3步直到所有元素均遍历完成且无其他排列方式。
class Solution {
private:
vector<vector<int>> res;
vector<int> temp;
void helper(vector<int>& nums, int index){
res.push_back(temp);
for(int i = index; i < nums.size(); i++){
temp.push_back(nums[i]);
helper(nums, i + 1);
temp.pop_back();
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
helper(nums, 0);
return res;
}
};
5.3 单词搜索
&esmp;&esmp;79.单词搜索本题的目标主要为在矩阵中检索目标字符串(可以从上下左右四个方向进行检索),我们在主函数中主要对将矩阵内元素逐个设置为起始点,并由此开始检索。在回溯函数中,我们主要负责对子集的寻找和判断,其回溯函数的处理逻辑为:
- 判断是否越界或不匹配,若成功匹配到最后一个字符则返回true;
- 标记当前字符为已访问,递归搜索四个方向,如果在任何一个方向上找到匹配的路径,立即返回 true;
- 如果所有方向都没有找到匹配的路径,则进行回溯,将 board[i][j] 恢复为原来的字符,并返回 false。
class Solution {
public:
bool exist(vector<vector<char>>& board, string word) {
int m = board.size();
int n = board[0].size();
// 遍历每个位置作为起点
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (backtrack(board, word, i, j, 0)) return true;
}
}
return false;
}
private:
bool backtrack(vector<vector<char>>& board, const string& word, int i, int j, int index) {
// 判断当前坐标是否越界,或者当前字符是否匹配
if(i < 0 || i >= board.size() ||
j < 0 || j >= board[0].size() ||
board[i][j] != word[index]) {
return false;
}
// 如果所有字符都匹配,返回true
else if (index == word.size() - 1) return true;
// 临时保存当前字符,并标记为访问过
char temp = board[i][j];
// 表示这个位置已经被使用过
board[i][j] = ' ';
// 递归搜索四个方向
if(backtrack(board, word, i + 1, j, index + 1) || // 向下
backtrack(board, word, i - 1, j, index + 1) || // 向上
backtrack(board, word, i, j + 1, index + 1) || // 向右
backtrack(board, word, i, j - 1, index + 1)) // 向左
return true;
// 回溯,恢复当前字符
board[i][j] = temp;
return false;
}
};
5.4 解数独
解数独算是回溯算法的难题之一,它的核心在于要从子九宫格、行、列同时判断是否满足要求,我们可以将其看为是一个树形结构,当某一个空位被填充时,相当于进入一个分支,如果分支经检查均满足要求,则表面该输入正确,反之则回溯将该位置重新标记为空。考虑到实际上我们只需要满足一个分支的所有元素均满足调节即可完成目标,所以这里使用bool类型。
- 先编写检查函数,满足上面提到的三个要求即可;
bool isValid(int row, int col, char num, vector<vector<char>>& board) {
// 检查当前行是否有重复数字
for (int i = 0; i < 9; i++) {
if (board[row][i] == num)
return false;
}
// 检查当前列是否有重复数字
for (int i = 0; i < 9; i++) {
if (board[i][col] == num)
return false;
}
// 检查当前 3x3 子网格是否有重复数字
int startRow = 3 * (row / 3);
int startCol = 3 * (col / 3);
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (board[startRow + i][startCol + j] == num)
return false;
}
}
return true;
}
- 编写回溯函数(填充函数)
bool backtracking(vector<vector<char>>& board) {
for (int row = 0; row < 9; row++) {
for (int col = 0; col < 9; col++) {
// 如果当前位置为空(用 '.' 表示)
if (board[row][col] != '.')
continue;
// 尝试填入数字 '1' 到 '9'
for (char num = '1'; num <= '9'; num++) {
if (isValid(row, col, num, board)) {
// 填入数字
board[row][col] = num;
// 递归求解
if (backtracking(board))
return true; // 找到解,返回 true
// 撤销选择(回溯)
board[row][col] = '.';
}
}
return false; // 如果所有数字都不合法,返回 false
}
}
return true; // 如果所有位置都填满,返回 true
}
六、图论
6.1 Floyd-Warshall 算法
Floyd-Warshall 算法是一种用于找到加权图中所有节点对之间的最短路径的算法。该算法使用动态规划的思想,通过逐步更新每一对节点之间的最短路径来解决问题。这里以743.网络延迟算法为例,其大致思路如下所示:
- 建图,并令graph[i][i] = 0,其余网格赋初值INF(代表无穷大,如1e9);
- 进行三重循环,分别逐步改变转节点、纵坐标和横坐标,并比对经过转节点和直达;
// 示例:Floyd-Warshall 算法
for (int m = 1; m <= n; ++m) {
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
if (graph[i][m] < INF && graph[m][j] < INF) { // 避免溢出
graph[i][j] = min(graph[i][j], graph[i][m] + graph[m][j]);
}
}
}
}
- 遍历graph找到目标节点的最小(最佳)路径
class Solution {
public:
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
const int INF = 1e9;
vector<vector<int>> graph(n + 1, vector<int>(n + 1, INF));
// 初始化邻接矩阵
for (int i = 1; i <= n; ++i)
graph[i][i] = 0; // 对角线元素为 0
for(auto& x : times){
int u = x[0];
int v = x[1];
int t = x[2];
graph[u][v] = t;
}
for(int m = 1; m <= n; m++){
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
if(graph[i][j] > graph[i][m] + graph[m][j])
graph[i][j] = graph[i][m] + graph[m][j];
}
}
}
int res = 0;
for(int i = 1; i <= n; i++){
if (graph[k][i] == INF) return -1;
res = max(res, graph[k][i]);
}
return res;
}
};