加油站问题:环形数组中的良好出发点
在算法面试中,加油站问题是一个经典的环形数组题目。今天我们要解决的是找出所有良好出发点的问题,这比LeetCode上只要求返回一个出发点的版本更加复杂和有趣。
问题描述
有 N 个加油站组成一个环形路线,给定两个长度都是 N 的非负数组:
oil[i]:第 i 个加油站存储的油量可以跑多少千米dis[i]:第 i 个加油站到下一个加油站的距离(千米)
假设你有一辆油箱足够大的车,初始时车里没有油。如果从第 i 个加油站出发,最终能够回到这个加油站,那么第 i 个加油站就是良好出发点。
要求:返回长度为 N 的 boolean 数组 res,其中 res[i] 表示第 i 个加油站是否是良好出发点。
直观理解
想象你开着一辆车在一个环形路线上行驶:
- 每到一个加油站,你可以加满该站的所有油
- 从一个站到下一个站需要消耗对应的距离油量
- 如果在任何时刻油量变成负数,说明无法继续前进
- 只有能完整绕一圈回到起点的出发点才算"良好"
暴力解法分析
最直接的想法是对每个加油站都尝试一次:
// 暴力解法(时间复杂度 O(N²))
public boolean[] bruteForce(int[] oil, int[] dis) {
int n = oil.length;
boolean[] result = new boolean[n];
for (int start = 0; start < n; start++) {
int currentOil = 0;
boolean canComplete = true;
// 尝试从start出发绕一圈
for (int i = 0; i < n; i++) {
int station = (start + i) % n;
currentOil += oil[station] - dis[station];
if (currentOil < 0) {
canComplete = false;
break;
}
}
result[start] = canComplete;
}
return result;
}
问题:时间复杂度 O(N²),对于大数组效率太低。
优化思路:前缀和 + 单调队列
核心观察
- 净油量数组:定义
arr[i] = oil[i] - dis[i],表示在第 i 个站的净收益 - 环形处理:将数组复制一份,变成长度为 2N 的数组,这样就不用处理模运算
- 前缀和:计算前缀和数组,
prefix[i]表示从位置 0 到位置 i 的累计净油量 - 关键条件:从位置 i 出发能完成一圈,当且仅当在区间
[i, i+N-1]内,任意位置 j 的前缀和都满足:prefix[j] - prefix[i-1] >= 0
数学转换
从位置 i 出发,到达位置 j 时的油量为:
oil[i] - dis[i] + oil[i+1] - dis[i+1] + ... + oil[j] - dis[j]
= prefix[j] - prefix[i-1]
要保证整个过程中油量不为负,需要:
min{prefix[j] - prefix[i-1]} >= 0, 其中 j ∈ [i, i+N-1]
等价于:
min{prefix[j]} >= prefix[i-1], 其中 j ∈ [i, i+N-1]
算法步骤
- 构建扩展数组:将净油量数组复制一份,长度变为 2N
- 计算前缀和:得到长度为 2N 的前缀和数组
- 滑动窗口最小值:使用单调队列维护每个长度为 N 的窗口内的最小前缀和
- 判断条件:对于每个起始位置 i,检查窗口最小值是否 >= prefix[i-1]
详细代码解析
public static boolean[] goodArray(int[] g, int[] c) {
int N = g.length;
int M = N << 1; // M = 2 * N
// 步骤1:构建扩展的净油量数组并计算前缀和
int[] arr = new int[M];
for (int i = 0; i < N; i++) {
arr[i] = g[i] - c[i]; // 原数组
arr[i + N] = g[i] - c[i]; // 复制一份
}
// 计算前缀和
for (int i = 1; i < M; i++) {
arr[i] += arr[i - 1];
}
// 现在arr就是前缀和数组
// 步骤2:初始化单调递增队列(维护窗口最小值)
LinkedList<Integer> window = new LinkedList<>();
// 初始化单调递增队列,用来维护第一个滑动窗口 [0, N-1] 中的最小值
for (int i = 0; i < N; i++) {
while (!window.isEmpty() && arr[window.peekLast()] >= arr[i]) {
window.pollLast();
}
window.addLast(i);
}
// 步骤3:滑动窗口,检查每个起始位置
boolean[] ans = new boolean[N];
// offset = arr[i-1],即prefix[i-1]
// i 是当前考虑的起始位置
// j 是当前窗口的右边界
for (int offset = 0, i = 0, j = N; j < M; offset = arr[i++], j++) {
// 检查当前窗口 [i, j-1] 的最小值是否 >= offset
if (arr[window.peekFirst()] - offset >= 0) {
ans[i] = true;
}
// 移除窗口左边界的元素(如果它在队列头部)
if (window.peekFirst() == i) {
window.pollFirst();
}
// 添加新元素到窗口右边界
while (!window.isEmpty() && arr[window.peekLast()] >= arr[j]) {
window.pollLast();
}
window.addLast(j);
}
return ans;
}
执行过程演示
假设 oil = [1, 2, 3, 4, 5], dis = [3, 4, 5, 1, 2]
- 净油量数组:
[-2, -2, -2, 3, 3] - 扩展数组:
[-2, -2, -2, 3, 3, -2, -2, -2, 3, 3] - 前缀和数组:
[-2, -4, -6, -3, 0, -2, -4, -6, -3, 0]
现在检查每个起始位置:
- 起始位置 0:窗口
[0,4],最小值-6,offset = 0,-6 - 0 < 0→ false - 起始位置 1:窗口
[1,5],最小值-6,offset = -2,-6 - (-2) = -4 < 0→ false - 起始位置 2:窗口
[2,6],最小值-6,offset = -4,-6 - (-4) = -2 < 0→ false - 起始位置 3:窗口
[3,7],最小值-6,offset = -6,-6 - (-6) = 0 >= 0→ true - 起始位置 4:窗口
[4,8],最小值-6,offset = -3,-6 - (-3) = -3 < 0→ false
所以只有位置 3 是良好出发点。
算法复杂度分析
-
时间复杂度:O(N)
- 构建数组:O(N)
- 计算前缀和:O(N)
- 单调队列操作:每个元素最多入队出队一次,O(N)
-
空间复杂度:O(N)
- 扩展数组:O(N)
- 单调队列:O(N)
相比暴力解法的 O(N²),这是一个显著的优化!
关键要点总结
- 环形数组处理:通过复制数组避免模运算
- 前缀和转换:将路径问题转化为区间最小值问题
- 单调队列应用:高效维护滑动窗口最小值
- 数学条件转换:
min - offset >= 0是核心判断条件
扩展思考
这种思路还可以应用到其他环形数组问题:
- 环形数组的最大子数组和
- 环形数组的最小平均值子数组
- 其他需要在环形结构中找满足条件区间的题目
掌握这种"扩展数组 + 前缀和 + 单调队列"的组合技巧,会让你在解决环形数组问题时更加游刃有余!


2171

被折叠的 条评论
为什么被折叠?



