破解代码难题:一次任务调度优化如何让我对“双指针”有了顿悟 😎
大家好,我是你们的老朋友,一个在代码世界里摸爬滚打N年的开发者。今天,我想和大家分享一个最近项目中遇到的真实挑战,以及我是如何从一个看似棘手的性能瓶颈中,挖掘出算法的乐趣,并最终用一个非常优雅的技巧解决它的。
我遇到了什么问题?🤔
在我负责的一个云服务平台上,我们有一个核心功能:任务调度器。用户可以提交一批计算任务(比如数据处理、模型训练等),我们的平台会智能地将它们组合成“任务批次”来执行。
最近,产品经理提出了一个新需求:为了保证系统稳定运行,防止因内存分配跨度过大导致的资源抖动,我们需要对任务批次加一个稳定性约束。具体来说:
在任意一个“任务批次”中,内存需求最小的任务 和 内存需求最大的任务,它们的内存之和不得超过一个预设的
STABILITY_THRESHOLD
。
在上线这个约束之前,我们需要给用户提供一个“预分析”功能:用户上传一个任务列表,我们的系统要能快速计算出,根据这个列表,总共能组成多少种满足上述约束的、非空的任务批次。
举个例子,假设用户有4个任务,内存需求分别是 [3G, 5G, 6G, 7G]
,而我们的稳定性阈值 STABILITY_THRESHOLD
是 9G
。那么:
- 批次
[3G, 5G]
是合法的,因为min(3,5) + max(3,5) = 3 + 5 = 8 <= 9
。 - 批次
[3G, 7G]
是非法的,因为min(3,7) + max(3,7) = 3 + 7 = 10 > 9
。
任务就是统计所有这些合法批次的数量。
初次尝试与“踩坑”经验 😫
我的第一反应非常直接:暴力出奇迹!一个任务批次不就是原任务列表的一个子序列嘛。我可以用递归(或者迭代)生成所有的非空子序列,然后对每一个子序列检查它是否满足 min + max <= threshold
的条件。
我兴致勃勃地写下了伪代码:
function countValidBatches(tasks, threshold):
all_subsequences = generate_all_subsequences(tasks)
count = 0
for seq in all_subsequences:
if not seq.is_empty():
if min(seq) + max(seq) <= threshold:
count++
return count
在我的小规模测试集上,它跑得很好。但当我把这个方案提交给测试时,问题来了。一个用户可能会提交多达 100,000
个任务!一个含有 N
个任务的列表,它的非空子序列数量是 2^N - 1
。2^100000
是一个天文数字,我的程序还没开始计算 min
和 max
,光是生成子序列就已经让服务器的内存和CPU都“原地爆炸”了。💥
这就是我踩的第一个大坑:忽略了问题的规模,无脑选择了指数级复杂度的暴力解法。
“恍然大悟”的瞬间:LeetCode 带来的灵感 ✨
在性能优化的泥潭里挣扎时,我突然想起了之前在 LeetCode 上刷过的一道题,它和我的问题简直一模一样!那就是 1498. 满足条件的子序列数目。
我开始重新审视这个问题:
- 子序列的位置不重要:一个批次
[3G, 6G]
和[6G, 3G]
是一样的,我们只关心批次里有哪些任务。这意味着,我可以对任务列表按内存大小进行排序,而不会影响最终结果! - 核心约束:
min + max <= target
。 - 提示解读:
nums.length <= 10^5
:这直接判了O(N^2)
和更高复杂度的死刑,O(N log N)
或O(N)
才是出路。结果对 10^9 + 7 取余
:这表明结果会非常大,很可能涉及到2
的幂次计算,需要预处理来防止溢出和重复计算。
“Aha!” Moment 来了!
如果我把任务列表 tasks
按内存从小到大排序。当我固定一个任务 tasks[i]
作为批次的最小值时,为了满足 tasks[i] + max_task <= threshold
,我需要的最大值 max_task
必须小于等于 threshold - tasks[i]
。
假设我找到了一个 tasks[j]
(其中 j >= i
),它满足 tasks[i] + tasks[j] <= threshold
。这时,最神奇的事情发生了:
所有在 tasks[i]
和 tasks[j]
之间的任务(即 tasks[i+1], ..., tasks[j-1]
),我可以任意挑选它们加入到批次中!为什么?因为无论我怎么选,这个批次的最小值永远是 tasks[i]
,最大值不会超过 tasks[j]
,所以 min + max
的和肯定还是小于等于 threshold
的!
这一下就把问题从一个复杂的组合问题,变成了一个清晰的计数问题。
排序 + 双指针
这个“恍然大悟”的瞬间,直接把我引向了那个传说中非常优雅的技巧——双指针。
<解法1:排序 + 双指针>
- 排序:先把任务列表按内存大小升序排列。
- 预计算:题目要求对
10^9 + 7
取余,而且我们发现需要计算2
的幂(k
个元素有2^k
种选择),所以我们提前把2^0, 2^1, ...
的结果算好存起来,避免重复计算。 - 双指针:设置左指针
left
从0
开始,右指针right
从末尾开始。 - 移动与计数:
- 如果
tasks[left] + tasks[right] <= threshold
:- 太棒了!这意味着以
tasks[left]
为最小值的批次,最大值可以是tasks[left]
到tasks[right]
中的任何一个。 - 我们固定了
tasks[left]
,那么在tasks[left+1]
到tasks[right]
这right - left
个任务中,我们可以任意选择。有多少种选择呢?2^(right - left)
种! - 把这个数量加到总数里,然后移动
left++
,因为我们已经把tasks[left]
作为最小值的所有可能性都算完了。
- 太棒了!这意味着以
- 如果
tasks[left] + tasks[right] > threshold
:- 说明
tasks[right]
这个任务的内存太大了,它和当前的tasks[left]
没法组成合法批次。 - 我们只能放弃它,尝试一个内存小一点的最大值,所以
right--
。
- 说明
- 如果
这个过程一直持续到 left
和 right
相遇,我们就把所有情况都统计完了。
/*
* 思路:排序+双指针。这是本题最优解,时间复杂度 O(N log N)。
* 为什么用 Arrays.sort()?排序是使用双指针的前提,它创建了单调性。
* 为什么预计算 powers 数组?因为会频繁用到 2 的幂次,预计算是 O(1) 查询,比Math.pow快且能处理模运算。
*/
import java.util.Arrays;
class Solution {
public int numSubseq(int[] nums, int target) {
final int MOD = 1_000_000_007;
int n = nums.length;
// 排序是关键第一步,它让双指针移动有了明确的方向
Arrays.sort(nums);
// 预计算 2 的幂次,避免重复计算和浮点数问题
long[] powers = new long[n];
powers[0] = 1;
for (int i = 1; i < n; i++) {
powers[i] = (powers[i - 1] * 2) % MOD;
}
long ans = 0;
int left = 0, right = n - 1;
while (left <= right) {
if (nums[left] + nums[right] <= target) {
// 以 nums[left] 为最小值的合法子序列有 2^(right-left) 个
ans = (ans + powers[right - left]) % MOD;
// left 的所有可能性已统计完,考察下一个可能的最小值
left++;
} else {
// nums[right] 太大,尝试一个更小的最大值
right--;
}
}
return (int) ans;
}
}
时间复杂度:O(N log N)
,瓶颈在排序。空间复杂度:O(N)
,用于存储powers
数组。
这个解法不仅高效,而且代码极其简洁,充满了逻辑之美!😉
还有别的路吗?排序 + 二分查找
当然,解决问题的路不止一条。如果我当时没能立刻想到双指针,我可能会走另一条路:
遍历每个任务 tasks[i]
,假设它就是批次的最小值。然后,用二分查找去寻找一个最远的 j
,使得 tasks[j] <= threshold - tasks[i]
。找到了这个边界 j
之后,同样,tasks[i+1]
到 tasks[j]
之间的任务可以任意组合,贡献 2^(j-i)
种方案。
/*
* 思路:排序+二分查找。对每个元素作为最小值,二分找到最大值的边界。
* 为什么用二分查找?它能以 O(log N) 的效率在有序数组中定位边界。
* if (nums[i] * 2 > target) { break; } 是一个关键优化,因为如果连最小元素自己和自己相加都超了,
* 后续更大的元素更不可能满足条件了,可以直接剪枝。
*/
import java.util.Arrays;
class Solution2 {
public int numSubseq(int[] nums, int target) {
final int MOD = 1_000_000_007;
int n = nums.length;
Arrays.sort(nums);
long[] powers = new long[n];
powers[0] = 1;
for (int i = 1; i < n; i++) {
powers[i] = (powers[i - 1] * 2) % MOD;
}
long ans = 0;
for (int i = 0; i < n; i++) {
// 关键优化点!
if (nums[i] * 2 > target) {
break;
}
int rightBoundary = findRightmost(nums, i, target - nums[i]);
if (rightBoundary != -1) {
ans = (ans + powers[rightBoundary - i]) % MOD;
}
}
return (int) ans;
}
// 二分查找,找到最后一个 <= val 的元素位置
private int findRightmost(int[] nums, int startIdx, int val) {
// ... (二分查找实现,此处省略)
int left = startIdx, right = nums.length - 1, boundary = -1;
while(left <= right){
int mid = left + (right - left) / 2;
if(nums[mid] <= val){
boundary = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return boundary;
}
}
时间复杂度:O(N log N)
。空间复杂度:O(N)
。
虽然两种方法时间复杂度级别相同,但双指针的实际运行效率通常更高,因为它的内循环是 O(N)
的线性扫描,而二分查找是 N
次 O(log N)
的操作,常数因子更大。
举一反三:这些思想还能用在哪?
这个 "排序 + (双指针/二分)" 的思想模型,其实在很多场景下都非常有用:
- 电商促销:设计“超值捆绑包”。从一堆商品中,挑选任意几件组成一个捆绑包,要求包里最便宜的商品和最贵的商品的价格之和不能超过某个促销上限。我们要计算能组成多少种这样的捆绑包。
- 物流装箱:往一个集装箱里装载一批货物,每件货物有重量。为了保持负载均衡,要求装入的任意一批货物中,最轻的和最重的货物重量之和不能超过一个安全阈值。计算有多少种合法的装载组合。
- 团队组建:从一个候选人池中组建项目团队,每位候选人有一个“能力分” (1-10分)。为了避免能力断层过大,要求组建的团队中,能力分最低和最高的成员,分数之和不能超过15。计算有多少种组队方案。
你看,一个算法思想,可以解决不同领域中结构相似的问题。这就是学习算法的魅力所在!
更多练习,加深理解
如果你也对这类问题产生了兴趣,强烈推荐你去 LeetCode 上挑战一下这些题目,它们都能用到“排序 + 双指针”的思想:
- 167. 两数之和 II - 输入有序数组:最经典的双指针入门题。
- 15. 三数之和:双指针的绝佳应用,固定一个数,然后用双指针找另外两个。
- 11. 盛最多水的容器:双指针的另一种经典模式,从两端向内收缩。
希望这次的分享能对你有所启发。下一次当你在项目中遇到性能瓶颈时,不妨退一步,看看问题背后是否隐藏着一个经典的算法模型。有时候,最优雅的解决方案,就藏在这些基础而强大的思想之中。祝大家编码愉快!🚀