提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
一、双指针
双指针(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;
}
};
二、动态规划
动态规划(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,一个是取dp[j - weight[i]] + value[i],即放物品i,所以取最大的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
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];
}
};
三、二分算法
二分法(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)。
2.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;
}
};
2.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 需要被手动添加。