前几天刷 LeetCode 每日一题的时候,碰到一道 3010。这道题在“数组、子数组”这类题目里算比较简单的,但它的解法一“复制 + 排序”给我留下了很深的印象。因为它代表了一类特别实用的做题策略:当你想不清楚复杂数据结构的时候,先别硬凹优化,用最笨、最直接、最不可能写错的办法把题 AC 掉,然后再回头想有没有更漂亮的解法。这篇文章就把这道题的完整思路、代码实现、常见坑和优化方向一次性聊透,适合刚开始刷 LeetCode 的读者,也适合想在周赛里提高稳定性的老手参考。
1. 题目理解与解题思路拆解
1.1 题目到底在算什么
题目大意是这样的:给定一个整数数组 nums,长度记为 n,需要把数组切分成两个连续的子数组,第一个子数组必须包含原数组的第一个元素,也就是说第一个子数组必须是数组的一个前缀,第二个子数组就是剩下的后缀。两个子数组都不能为空。
总代价的定义是:第一个子数组的长度,加上第二个子数组中最小元素的值。我们要在所有合法切法中,找到总代价最小的一种,返回这个最小值。
举个例子,如果 nums = [3, 1, 2, 5],那么合法的切分点只有 1、2、3 三个位置:
- 切在索引 1:第一个子数组是 [3],长度为 1;第二个子数组是 [1, 2, 5],最小值是 1,总代价 1 + 1 = 2。
- 切在索引 2:第一个子数组是 [3, 1],长度为 2;第二个子数组是 [2, 5],最小值是 2,总代价 2 + 2 = 4。
- 切在索引 3:第一个子数组是 [3, 1, 2],长度为 3;第二个子数组是 [5],最小值是 5,总代价 3 + 5 = 8。
所以答案是 2。这里有一个很有意思的规律:第一个子数组越长,代价里的“长度部分”越大,同时第二个子数组的范围变小,它的最小值只会变大或不变。所以在当前这个代价定义下,最优切法其实就是只让第一个子数组包含 nums[0] 这一个元素。不过,如果不刻意去证明这个性质,直接枚举所有切法也完全能算出正确答案,这就引出了解法一。
1.2 为什么先想到暴力枚举
刷题的时候,尤其是每日一题这种场景,我通常建议先做一个“正确性优先”的版本。不要一上来就想着单调栈、线段树、动态规划,先看数据范围,再看问题本身。
这道题第一版的约束里,n 的范围并不大。枚举所有可能的切分点,最多就是 n - 1 种情况。对于每一种切分,都需要求第二个子数组的最小值。求最小值最直接的办法,就是把第二个子数组复制出来,排个序,取第一个元素。这就是标题里说的“复制 + 排序”。
为什么这个办法值得写?因为它的思路完全线性,没有任何需要推理的跳步。你只需要老老实实枚举分割点,剩下的计算全部交给排序,不太容易出错。在周赛或者每日一题这种对时间要求不苛刻的场景里,这种“笨办法”反而很稳。
暴力枚举的核心逻辑就三句话:
- 分割点是唯一的变量,从 1 枚举到 n - 1。
- 每个分割点对应一个第一段和一段第二段。
- 对第二段复制、排序、取最小值,带入公式更新答案。
这三句话写下来,代码就已经成型了。很多人觉得排序很慢,但在这个题目规模下,排序的开销完全能接受。这其实也提醒我们:复杂度分析不能脱离数据范围空谈。
1.3 “复制”到底在防什么
我见过不少初学者在写这类题的时候,会顺手在原数组上调用排序函数。比如 C++ 里直接 sort(nums.begin() + i, nums.end()),这样确实能求出从 i 到末尾的最小值,但后果是原数组 nums 被改了。问题是,后续枚举其他分割点时,你依赖的还是原始数组的顺序吗?如果原数组被排序过,整个数组的顺序就变了,第一段也不再是原来的前缀,后面所有分割点的计算都会错。
所以“复制”这一步不是多余的,它是为了保证“只读原数组,只在副本上做修改”。这在实际开发里也是一条通用原则:当你需要基于一份原始数据做多次、互不影响的局部计算时,千万别在原数据上直接改,除非你确定这个修改是无害的。复制一份再操作,永远是安全的选择。
理解了题目和为什么用复制 + 排序之后,下面直接把三种主流语言的实现都过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一完整实现:复制 + 排序
2.1 C++ 实现与逐行解析
C++ 是我刷题时最常用的语言,先看它的写法:
cpp复制class Solution {
public:
int minimumCost(vector<int>& nums) {
int n = nums.size();
int ans = INT_MAX;
for (int i = 1; i < n; i++) {
vector<int> second(nums.begin() + i, nums.end());
sort(second.begin(), second.end());
int cost = i + second[0];
ans = min(ans, cost);
}
return ans;
}
};
这段代码有四个关键点。
第一,循环从 i = 1 开始,而不是 0。因为第一个子数组必须以 nums[0] 开头,而且要求非空。如果 i = 0,第一个子数组就是空数组,不合法。i = n 也不行,因为那样第二个子数组为空。所以合法分割点就是 1 到 n - 1。
第二,i 本身恰好就是第一个子数组的长度。当分割点选在索引 i 时,第一个子数组包含的是 nums[0] 到 nums[i-1],正好 i 个元素。这个巧合让代码很干净,cost 直接写 i + second[0] 就行。
第三,vector<int> second(nums.begin() + i, nums.end()); 这行是把从索引 i 开始到数组末尾的所有元素复制到一个新数组里。nums.begin() + i 指向的元素是 nums[i],nums.end() 是末尾的哨兵迭代器,区间是左闭右开,所以恰好不包含 end,复制出来的 second 就是第二段的所有元素。
第四,sort(second.begin(), second.end()) 之后,second[0] 就是第二段的最小值。因为 second 非空,访问 second[0] 不会越界。ans 初始化为 INT_MAX,保证第一次更新能成功。
2.2 Python 实现与简洁之道
Python 的切片语法让这个解法更短:
python复制class Solution:
def minimumCost(self, nums: List[int]) -> int:
n = len(nums)
ans = float("inf")
for i in range(1, n):
second = sorted(nums[i:])
cost = i + second[0]
ans = min(ans, cost)
return ans
需要注意一个细节:nums[i:] 本身就会生成一个新的列表,所以这里的复制是隐式的。sorted(nums[i:]) 又基于这个切片生成一个排序后的新列表,原来的 nums 完全不受影响。Python 写这个解法几乎是零心智负担。
不过 Python 版本的代价是,每次循环都产生了两个新列表,一个切片列表、一个排序列表,内存开销比 C++ 略大一点。在这道题的数据范围下完全无所谓,但如果 n 很大,这种写法就需要优化了。
2.3 Java 实现与工具类使用
Java 的写法和 C++ 类似,只是复制数组的方式不同:
java复制class Solution {
public int minimumCost(int[] nums) {
int n = nums.length;
int ans = Integer.MAX_VALUE;
for (int i = 1; i < n; i++) {
int[] second = Arrays.copyOfRange(nums, i, n);
Arrays.sort(second);
int cost = i + second[0];
ans = Math.min(ans, cost);
}
return ans;
}
}
Arrays.copyOfRange(nums, i, n) 的区间也是左闭右开,复制 nums[i] 到 nums[n-1],正好是第二段。Arrays.sort(second) 是原地排序,所以必须先 copy。Java 里很多人误以为 Arrays.sort(nums) 之后可以用 nums[0] 当最小值,但那样会破坏原数组,后续分割点全乱套,这个坑要格外小心。
2.4 三种语言的横向对比
| 语言 | 复制第二段的方式 | 排序方式 | 关键注意点 |
|---|---|---|---|
| C++ | vector<int> second(nums.begin() + i, nums.end()) |
sort(second.begin(), second.end()) |
注意迭代器区间左闭右开 |
| Python | nums[i:] 切片自动生成新列表 |
sorted(nums[i:]) 返回新列表 |
不要在 nums.sort() 上操作原列表 |
| Java | Arrays.copyOfRange(nums, i, n) |
Arrays.sort(second) |
先复制再排序,不能原地排原数组 |
三种语言在思路上完全一致,区别只是语法糖和工具类的不同。从做题角度来说,随便选一种你能写顺的语言,把这个暴力版本快速实现出来,就已经拿下这道题了。
3. 从暴力到优化:后缀最小值思路
3.1 发现重复计算
暴力版本虽然正确,但仔细看会发现一个问题:每次枚举新的分割点,都要把第二段重新排序一次。第二段和前一次的第二段高度重叠,只是少了一个开头的元素。我们对这些高度重叠的区间反复排序,做了大量无用功。
这个场景很容易让人想到预处理。如果我们能提前知道“从某个位置 i 开始到数组末尾的最小值是多少”,那么枚举分割点时直接查表,就不需要排序了。这个预处理的数组通常叫后缀最小值数组,记作 suffixMin。suffixMin[i] 表示 nums[i] 到 nums[n-1] 的最小值。
从后往前扫描一遍就能得到这个数组:
- suffixMin[n-1] = nums[n-1]
- suffixMin[i] = min(nums[i], suffixMin[i+1])
有了 suffixMin,对于分割点 i,第二段的最小值就是 suffixMin[i],不再需要复制和排序。
3.2 优化后的代码实现
C++ 版本可以写成这样:
cpp复制class Solution {
public:
int minimumCost(vector<int>& nums) {
int n = nums.size();
vector<int> suffixMin(n);
suffixMin[n - 1] = nums[n - 1];
for (int i = n - 2; i >= 0; i--) {
suffixMin[i] = min(nums[i], suffixMin[i + 1]);
}
int ans = INT_MAX;
for (int i = 1; i < n; i++) {
ans = min(ans, i + suffixMin[i]);
}
return ans;
}
};
这个版本的时间复杂度是 O(n),空间复杂度是 O(n)。后面的枚举循环和暴力版本几乎一样,只是把 second[0] 换成了 suffixMin[i],一举消灭了排序。
如果还想省掉后缀数组,可以倒着枚举分割点,同时维护“当前位置到末尾的最小值”。但是这种写法对索引边界的敏感度很高,很容易在 i + 1、i - 1 之类的地方出错。我的建议是,面试里优先写出清晰的后缀数组版本,不要为了省一个数组把代码写复杂。除非你在比赛里真的差这点内存,否则可读性更重要。
3.3 两种解法的取舍节奏
暴力版本和后缀最小值版本是两种思路的代表:
| 解法 | 时间复杂度 | 空间复杂度 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|
| 枚举 + 复制 + 排序 | O(n^2 log n) | O(n) | 低 | 数据范围小、求快速 AC、帮助理解题意 |
| 枚举 + 后缀最小值 | O(n) | O(n) | 中 | 数据范围大、面试中展示优化能力 |
很多人在面试里一上来就写最优解,反而会让面试官觉得你背题了。更自然的节奏是:先给出复制 + 排序的暴力版本,说明正确性,然后主动提出“这里有重复排序,可以用后缀最小值优化”,再写出优化版本。整个过程像是有思考地在解决问题,而不是在默写答案。
4. 实操经验:常见错误与避坑指南
4.1 边界条件最容易翻车
这种数组切分题,边界条件是重灾区。第一个常见问题是循环范围。分割点必须保证两个子数组都非空,所以分割点的取值范围是 [1, n-1]。如果把 i = 0 也算进去,第一个子数组为空;如果把 i = n 也算进去,第二个子数组为空。
第二个常见问题是当 n = 2 时,循环只执行一次,这是正常的。如果代码在 n = 2 时出现数组越界,多半是提前访问了 suffixMin[i+1] 或者 second[1]。记住,第二段至少有一个元素,所以取 second[0] 或者 suffixMin[i] 都一定存在。
第三个注意点是负数情况。如果题目允许负数,排序后取第二段最小值依然是正确的,因为排序后的第一个元素就是最小值。后缀最小值也同样处理负数。所以这两种解法对负数天然免疫,不需要额外讨论。
4.2 复制和排序的经典翻车现场
我先说一个我见过很多次的错误写法。有人图省事,直接在原数组上排序:
cpp复制for (int i = 1; i < n; i++) {
sort(nums.begin() + i, nums.end());
int cost = i + nums[i];
ans = min(ans, cost);
}
前面已经解释过,这段代码会修改原数组,导致第一次循环之后,nums 就不再是原来的数组了。后续的枚举基于一个被排序过的数组,得到的“第一段”根本不是原始数组的前缀,结果一定错。
还有人在 Java 里写出 Arrays.sort(nums) 然后取 nums[i] 当第二段最小值,同样是破坏原数组的问题。正确的做法永远是先复制出第二段,在副本上排序。
Python 里比较容易踩的坑是 sorted 和 nums.sort() 的区别。nums.sort() 是原地排序,会改变 nums;sorted(nums[i:]) 会返回新列表。如果你用 arr = nums 再 arr.sort(),因为 Python 的赋值是引用传递,nums 也会跟着变。这种引用问题在 Python 新手里非常常见。
4.3 答案初始化别乱来
ans 的初始值必须足够大。C++ 用 INT_MAX,Java 用 Integer.MAX_VALUE,Python 用 float('inf')。有的同学喜欢初始化成 nums[0] 或者 0,这在某些用例下会出错,因为第一个分割点的代价可能比 0 大,也可能比 nums[0] 大。
这里顺便说一个细节:如果数组元素最大值本身接近 INT_MAX,而数组很长,i + suffixMin[i] 可能溢出吗?在这道题的范围内 n 很小,不会溢出。但如果 n 很大,建议用 long long 计算再比较,避免中间结果溢出。刷题的时候养成“计算前先看范围”的习惯,能少踩很多坑。
4.4 打印中间结果验证正确性
如果你写了暴力版本但答案不对,我建议在循环里临时打印每次的分割点、第一段、第二段和 cost。比如在 C++ 里加几行输出:
cpp复制cout << "i = " << i << ", first size = " << i
<< ", second min = " << second[0]
<< ", cost = " << cost << endl;
打印出来之后,基本一眼就能看出是枚举范围错了,还是第二段复制错了,还是最小值取错了。这种方法虽然土,但在调试阶段远比盯着代码发呆高效。
如果是随机大数组,还可以写一个三重循环的暴力验证函数,对每个分割点线性扫描求第二段最小值,和你的排序解法对比结果。两边都跑一遍,结果一致就说明代码大概率没问题。这种“对拍”的思路在竞赛和工程里都非常实用。
5. 举一反三:从这道题能学到什么
5.1 子数组分割题型的通用套路
3010 虽然简单,但它代表了一类非常高频的题型:“数组切分 + 某种代价函数”。这类题的核心变量几乎都是分割点。一旦确定了分割点,问题就被拆成了前后两段,分别求某种性质。常见的套路有:
- 前缀和:用于快速求某一段的和。
- 前缀最大值/最小值:用于快速求某一段的极值。
- 后缀最大值/最小值:用于快速求分割点右侧的极值。
- 左右扫描数组:很多题需要从两个方向各扫一遍,合并信息。
复制 + 排序这类暴力方法,放在这个框架里就是“未知极值就先排序”。虽然不够高效,但它能帮你在第一时间锁定正确思路,避免卡在优化细节上浪费太久。
5.2 什么时候真的适合“复制 + 排序”
我根据自己的刷题经验,总结了一个比较实用的选择标准:
| 场景 | 建议做法 |
|---|---|
| 需要求子数组最小值,且 n <= 1000 | 复制 + 排序,直接 AC |
| 需要求子数组最小值,且 n 很大,分割点固定枚举 | 后缀/前缀最值预处理 |
| 需要频繁求任意区间的极值,且会多次查询 | 线段树或 ST 表 |
| 需要维护窗口内极值,且窗口大小变化 | 单调队列 |
排序法在小数据规模下往往是最稳的。它不需要你额外维护任何结构,也不依赖单调性,写出来就是正确答案。数据规模一大,就要果断换思想,不要跟复杂度硬碰硬。
5.3 从“I”到“II”的升级方向
题目名里有“I”,通常意味着后面还有升级版。升级方向可能包括:分成 k 个子数组而不是两个;代价函数从“第二段最小值”变成“每段最小值之和”;数组顺序可调整;或者要求每个子数组满足长度下限。
如果遇到这些变式,解题思路也要跟着变。比如分成若干段并且代价是每段的最小值之和,那很可能用动态规划;如果数组可以重新排列,那通常会先排序再做贪心。养成从简单版本出发,主动思考“这个限制如果改掉,解法会怎么变”的习惯,对提升刷题水平非常有帮助。
我自己的体会是,每日一题的价值不在于“这道题我见过”,而在于通过一道题把一类题型的思考路径串起来。3010 这道题,暴力解法三分钟能写完,优化解法五分钟能写完,但它背后“先暴力、再预处理、最后优化”的思路,可以迁移到很多中等难度的子数组题上。所以别看它简单,认真走一遍完整流程,收获比想象中大。最后分享一个我常用的做題小习惯:碰到任何子数组极值问题,先问自己一句“能不能通过一次扫描预处理,让子数组的极值 O(1) 拿到”,能的话,这道题的复杂度基本就稳了。
