1. 问题背景与题目解析
今天我们来拆解一道LeetCode中等难度题目——3507. 移除最小数对使数组有序 I。这道题在周赛中出现时让不少选手栽了跟头,其核心在于理解"非递减"数组的定义和最小操作次数的计算逻辑。
题目要求:给定一个整数数组nums,每次操作可以删除数组中任意一个数对(即两个相邻元素),求使剩余数组成为非递减数组所需的最小操作次数。这里的"非递减"指的是对于数组中所有i,都有nums[i] <= nums[i+1]。
举个例子:
输入:[5,2,3,4,5,1]
输出:2
解释:第一次删除[5,2],数组变为[3,4,5,1];第二次删除[5,1],得到[3,4]是非递减数组。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 非递减数组的本质理解
2.1 数学定义与边界条件
非递减数组的严格定义是:对于所有0 ≤ i < n-1,都有nums[i] ≤ nums[i+1]。这与严格递增数组(nums[i] < nums[i+1])不同,允许相等元素存在。
关键边界情况需要考虑:
- 空数组和单元素数组视为非递减
- 全等数组如[1,1,1]是非递减的
- 已经有序的数组如[1,2,3]不需要任何操作
2.2 与类似概念的对比
这道题容易与以下概念混淆:
- 最长非递减子序列(LNDS):不要求连续,通过删除任意位置元素获得
- 使数组有序的最小交换次数:通过交换而非删除操作
- 删除单个元素使数组有序:每次只能删一个元素而非数对
理解这些区别对设计正确算法至关重要。
3. 暴力解法与初步优化
3.1 递归回溯思路
最直观的方法是尝试所有可能的删除组合:
python复制def minOperations(nums):
if isNonDecreasing(nums):
return 0
min_ops = float('inf')
for i in range(len(nums)-1):
new_nums = nums[:i] + nums[i+2:]
ops = 1 + minOperations(new_nums)
if ops < min_ops:
min_ops = ops
return min_ops
这种解法时间复杂度为O(2^n),对于n=100的约束条件完全不可行。
3.2 贪心策略的局限性
一个常见的错误思路是每次遇到逆序对就删除它。例如:
[3,1,4,2,5]
- 删除[3,1]得到[4,2,5]
- 删除[4,2]得到[5]
这需要2次操作,但实际上最优解是1次(删除[1,4]得到[3,2,5],再删除[3,2]得到[5])
这说明局部最优不一定全局最优。
4. 动态规划解法
4.1 状态定义与转移方程
定义dp[i]表示前i个元素形成非递减序列所需的最小操作次数。关键观察:
- 如果nums[i] >= nums[i-1],可以选择不操作:dp[i] = dp[i-1]
- 或者删除nums[i-1]和nums[i]:dp[i] = dp[i-2] + 1
- 如果nums[i] < nums[i-1],必须删除这对:dp[i] = dp[i-2] + 1
初始条件:
- dp[0] = 0(空数组)
- dp[1] = 0(单元素数组)
4.2 实现代码
python复制def minOperations(nums):
n = len(nums)
dp = [0] * (n + 1)
dp[0], dp[1] = 0, 0
for i in range(2, n+1):
if nums[i-1] >= nums[i-2]:
dp[i] = min(dp[i-1], dp[i-2] + 1)
else:
dp[i] = dp[i-2] + 1
return dp[n]
4.3 复杂度分析
时间复杂度O(n),空间复杂度O(n),可以通过滚动数组优化到O(1)空间:
python复制def minOperations(nums):
prev_prev, prev = 0, 0
for i in range(1, len(nums)):
current = min(prev, prev_prev + 1) if nums[i] >= nums[i-1] else prev_prev + 1
prev_prev, prev = prev, current
return prev
5. 常见错误与调试技巧
5.1 边界条件处理
容易忽略的特殊情况:
- 空数组输入[]
- 全等数组[1,1,1]
- 已经有序数组[1,2,3]
- 需要全部删除的数组[5,4,3,2]
5.2 状态转移错误
一个典型错误是只在逆序时操作:
python复制if nums[i] < nums[i-1]:
dp[i] = dp[i-2] + 1
else:
dp[i] = dp[i-1]
这会错过某些可以优化操作次数的场景。
5.3 调试方法
建议的调试步骤:
- 打印dp数组查看状态变化
- 对失败用例手动模拟dp过程
- 检查初始条件和循环边界
6. 算法优化与变种
6.1 空间优化版本
如前所示,可以用两个变量代替dp数组,空间复杂度降为O(1)。
6.2 返回具体删除位置
如果需要输出删除的数对而不仅仅是次数,可以记录决策路径:
python复制def minOperationsWithPath(nums):
n = len(nums)
dp = [0]*(n+1)
decision = [[] for _ in range(n+1)]
for i in range(2, n+1):
if nums[i-1] >= nums[i-2]:
if dp[i-1] < dp[i-2]+1:
dp[i] = dp[i-1]
decision[i] = decision[i-1].copy()
else:
dp[i] = dp[i-2]+1
decision[i] = decision[i-2] + [(i-2,i-1)]
else:
dp[i] = dp[i-2]+1
decision[i] = decision[i-2] + [(i-2,i-1)]
return dp[n], decision[n]
6.3 相关题目拓展
- 删除单个元素使数组有序(LeetCode 1574)
- 最长非递减子序列(经典DP问题)
- 最小交换次数使序列递增(LeetCode 801)
7. 实际应用场景
这类数组操作问题在实际中有多种应用:
- 数据清洗:去除异常数据点使时序数据单调
- 版本控制:最小化回滚操作使版本号有序
- 游戏AI:最少步骤整理卡牌顺序
- 金融分析:调整交易记录使价格序列合理
理解这类算法有助于处理需要保持数据有序性的各种场景。
8. 不同语言实现要点
8.1 C++实现注意事项
cpp复制int minOperations(vector<int>& nums) {
int prev_prev = 0, prev = 0;
for (int i = 1; i < nums.size(); ++i) {
int current = (nums[i] >= nums[i-1]) ? min(prev, prev_prev + 1) : prev_prev + 1;
prev_prev = prev;
prev = current;
}
return prev;
}
注意vector的size()返回size_t,与int比较时可能产生警告。
8.2 Java实现要点
java复制public int minOperations(int[] nums) {
int prevPrev = 0, prev = 0;
for (int i = 1; i < nums.length; i++) {
int current = (nums[i] >= nums[i-1]) ? Math.min(prev, prevPrev + 1) : prevPrev + 1;
prevPrev = prev;
prev = current;
}
return prev;
}
Java数组没有Python的切片操作,需要特别注意索引处理。
8.3 JavaScript实现
javascript复制function minOperations(nums) {
let prevPrev = 0, prev = 0;
for (let i = 1; i < nums.length; i++) {
const current = nums[i] >= nums[i-1] ? Math.min(prev, prevPrev + 1) : prevPrev + 1;
prevPrev = prev;
prev = current;
}
return prev;
}
注意JS的数字比较中NaN的特殊情况。
9. 测试用例设计
全面的测试应该包含:
-
基础用例
- 输入:[1,2,3] → 输出:0
- 输入:[3,1] → 输出:1
-
边界用例
- 输入:[] → 输出:0
- 输入:[1] → 输出:0
- 输入:[1,1,1] → 输出:0
-
复杂用例
- 输入:[5,2,3,4,5,1] → 输出:2
- 输入:[1,3,2,4,5,3] → 输出:1
-
性能用例
- 长10^5的随机数组
- 长10^5的完全逆序数组
10. 性能优化与进阶思考
对于极大数组(n>10^5),可以考虑:
- 并行处理:将数组分块,分别处理后再合并
- 流式处理:对于无法全部加载到内存的数据,设计流式算法
- 近似算法:在允许近似解时,设计更快的启发式方法
一个有趣的变种问题是:如果每次可以删除任意长度的连续子数组(而不仅是数对),如何求解?这需要完全不同的DP状态设计。
