1. 加油站问题解析与贪心策略实战
1.1 问题本质与暴力解法
加油站问题(LeetCode 134)要求我们找到一个起始加油站,使得汽车能够绕环形路线行驶一周。每个加油站提供的油量gas[i]和到下一站的消耗cost[i]都是已知的。暴力解法直接模拟从每个加油站出发的情况:
cpp复制class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
for (int i = 0; i < cost.size(); i++) {
int rest = gas[i] - cost[i]; // 当前剩余油量
int index = (i + 1) % cost.size();
while (rest > 0 && index != i) {
rest += gas[index] - cost[index];
index = (index + 1) % cost.size();
}
if (rest >= 0 && index == i) return i;
}
return -1;
}
};
这个解法的时间复杂度是O(n²),在LeetCode上会超时。它的核心思路是:对每个加油站都尝试作为起点,模拟行驶过程,直到油量耗尽或回到起点。
注意:当rest正好等于0时,题目认为也是可行的,但这种情况在实际驾驶中风险很大,建议在面试时与面试官确认边界条件。
1.2 贪心算法优化思路
贪心算法的核心观察点:
- 如果从A站无法到达B站(油量在途中耗尽),那么A和B之间的任何站都不能作为起点
- 总油量必须大于等于总消耗,否则无论如何都无法完成环形路线
优化后的贪心解法:
cpp复制class Solution {
public:
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int totalGas = 0, curGas = 0;
int start = 0;
for (int i = 0; i < gas.size(); i++) {
totalGas += gas[i] - cost[i];
curGas += gas[i] - cost[i];
if (curGas < 0) { // 当前起点无法到达i+1
start = i + 1; // 尝试从下一站开始
curGas = 0; // 重置当前油量
}
}
return totalGas >= 0 ? start : -1;
}
};
这个解法的时间复杂度是O(n),空间复杂度是O(1)。关键点在于:
- totalGas记录全程的油量净增益
- curGas记录从当前start开始的油量净增益
- 当curGas<0时,说明从当前start无法到达i+1,因此将start设为i+1
实战技巧:在面试中,可以先提出暴力解法,然后逐步优化到贪心算法,展示你的思考过程。
2. 分发糖果问题的双向处理策略
2.1 问题分析与初步思路
分发糖果问题(LeetCode 135)要求根据孩子的评分分配糖果,满足:
- 每个孩子至少一个糖果
- 评分更高的孩子比相邻孩子获得更多糖果
直观想法是遍历一次,当发现右边孩子评分更高时,就给右边孩子多一个糖果。但这样无法处理左边孩子评分更高的情况。
2.2 双向遍历解法
正确的做法是进行两次遍历:
cpp复制class Solution {
public:
int candy(vector<int>& ratings) {
int n = ratings.size();
vector<int> candies(n, 1);
// 从左到右处理右边>左边的情况
for (int i = 1; i < n; i++) {
if (ratings[i] > ratings[i-1]) {
candies[i] = candies[i-1] + 1;
}
}
// 从右到左处理左边>右边的情况
for (int i = n-2; i >= 0; i--) {
if (ratings[i] > ratings[i+1]) {
candies[i] = max(candies[i], candies[i+1] + 1);
}
}
return accumulate(candies.begin(), candies.end(), 0);
}
};
这个解法的时间复杂度是O(n),空间复杂度是O(n)。关键点在于:
- 第一次遍历确保右边高分孩子比左边多
- 第二次遍历确保左边高分孩子比右边多,同时不破坏第一次遍历的结果
常见错误:忘记第二次遍历时需要取max,直接赋值会导致破坏第一次遍历的结果。
2.3 空间复杂度优化思路
理论上可以将空间复杂度优化到O(1),但实现较为复杂,在面试中通常不需要展示。实际工程中,O(n)的空间开销在大多数情况下是可接受的。
3. 柠檬水找零的贪心策略实现
3.1 问题建模与解法
柠檬水找零问题(LeetCode 860)模拟了用5元、10元、20元找零的场景。贪心策略的关键在于:
- 遇到5元直接收下
- 遇到10元找一张5元
- 遇到20元优先找10+5,其次找5+5+5(因为10元更有用)
cpp复制class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
int five = 0, ten = 0;
for (int bill : bills) {
if (bill == 5) five++;
else if (bill == 10) {
if (five == 0) return false;
five--;
ten++;
}
else { // 20元
if (ten > 0 && five > 0) {
ten--;
five--;
}
else if (five >= 3) {
five -= 3;
}
else return false;
}
}
return true;
}
};
3.2 贪心选择证明
为什么优先使用10+5而不是5+5+5?因为10元只能用于20元的找零,而5元可以用于10元和20元的找零。保留更多的5元可以增加后续找零的灵活性。
实际应用:这种策略类似于现实中的零钱管理,大面额钞票应该优先用于大额找零,小额钞票保留用于小额找零。
4. 根据身高重建队列的贪心策略
4.1 问题分析与排序策略
根据身高重建队列问题(LeetCode 406)要求将人们按特定规则排序:
- 每个人用[h,k]表示,h是身高,k是前面有k个身高≥h的人
- 需要重建队列使得这些条件都满足
贪心策略分为两步:
- 先按身高从高到低排序,身高相同的按k从小到大排序
- 然后按k值将每个人插入到队列的k位置
cpp复制class Solution {
public:
static bool cmp(const vector<int>& a, const vector<int>& b) {
if (a[0] == b[0]) return a[1] < b[1];
return a[0] > b[0];
}
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
sort(people.begin(), people.end(), cmp);
vector<vector<int>> que;
for (const auto& p : people) {
que.insert(que.begin() + p[1], p);
}
return que;
}
};
4.2 算法正确性证明
为什么这样排序后直接插入是正确的?
- 高个子先排,这样后面插入的矮个子不会影响高个子的k值
- 当插入一个高个子时,前面已经排好的都是比他高或相等的,所以直接插入到k位置即可
- 相同身高按k从小到大排序,确保先处理k小的,这样后处理的可以插入到前面而不影响已经处理好的
性能优化:使用链表而不是vector可以提高插入效率,但在LeetCode上vector的实现已经足够高效。
4.3 实际应用场景
这种问题在实际中类似于:
- 排队系统中有优先级和先后顺序约束
- 任务调度中有依赖关系和优先级
- 任何需要满足局部约束的全局排序问题
5. 贪心算法解题方法论总结
5.1 贪心算法的适用场景
贪心算法通常适用于具有"最优子结构"的问题,即局部最优解能导致全局最优解。常见的特征包括:
- 问题可以分解为一系列子问题
- 每个子问题的最优解可以组合成全局最优解
- 没有后效性,即当前选择不会影响之前的选择
5.2 贪心算法的解题步骤
- 问题分析:明确问题的约束条件和目标
- 贪心选择:确定每一步的最优选择标准
- 正确性证明:说明为什么局部最优能导致全局最优
- 算法实现:将策略转化为代码
- 复杂度分析:评估算法的时间和空间复杂度
5.3 贪心算法的局限性
贪心算法并不总是有效,它适用于特定类型的问题。当遇到以下情况时,可能需要考虑动态规划等其他方法:
- 问题不具有最优子结构
- 贪心选择不能保证全局最优
- 需要考虑所有可能的组合时
在刷题和面试中,识别一个问题是否适合贪心算法是关键。通常的线索包括:
- 题目中提到"最小"、"最大"、"最优"等字眼
- 问题可以分解为一系列选择
- 前面的选择不影响后面的选择(无后效性)
通过这四个问题的练习,我对贪心算法有了更深的理解:它不仅仅是选择当前最优,更重要的是找到那个能保证全局最优的局部选择标准。在实际编码时,边界条件的处理和贪心策略的正确性证明同样重要。