提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 一、双指针
- 1.1 有序数组的合并
- 1.2 快慢指针/删除有序数组中的重复项
- 1.3 求和
- 二、动态规划
- 2.1 自底向上和自顶向下(带备忘录)
- 2.2 带有当前状态
- 三、二分算法
- 四、贪心算法
- 4.1 从最小/最大开始贪心
- 4.2 从最左/右开始贪心
- 4.3 区间
一、双指针
双指针(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 带有当前状态
三、二分算法
二分法(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)。
这里以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;
}
};
四、贪心算法
贪心算法(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 需要被手动添加。