目录

题目描述

1673. 找出最具竞争力的子序列

题意分析

nums 里挑出长度恰好为 k 的子序列,要求它「最具竞争力」。题面对竞争力的定义是:两个等长序列比较时,在第一个不同的位置上数字更小的那个更具竞争力。这其实就是字典序最小

子序列意味着只能删不能换位,剩下元素的相对顺序必须保持原样。长度恰好k,不是至多也不是至少,等价于「从 n 个元素里删掉 n - k 个」。

「字典序最小」这个目标有一个很强的性质:它是逐位贪心的。第一位取到的数字越小,整个序列就越优,无论后面几位如何——因为比较在第一个不同的位置就分出胜负了。所以决策可以从左往右逐位敲定,不存在「牺牲前面换后面」的权衡。

但逐位取最小要受一个约束:选完第 t 位之后,右边必须还留有足够多的元素来填满剩下的 k - t 位。这条「够不够用」的约束是本题全部难点所在。

数据规模是 $n \le 10^5$,k 不超过 n。这个量级排除了 $O(nk)$ 的逐位扫描($k$ 可达 $10^5$ 时退化为 $10^{10}$),要求单趟线性解法。

边界包括:k == n(只能原样返回);数组严格递减(一个都不能删,因为删了就凑不够);数组严格递增(只能保留前 k 个);以及大量重复元素时相等的两个数不该互相替换。

解法:单调栈选最小字典序

核心思路

长度必须为 k,等价于恰好删除 remove = n-k 个元素。扫描数组时,用数组模拟答案栈。

若当前值 value 小于栈顶,且仍有删除额度,就删除栈顶。这个交换会让当前尚未确定的最靠前位置变小,字典序严格改善;删除次数和元素顺序都仍合法。只要条件持续成立就继续弹栈,因此可能一次替换多个较大的尾部元素。

当栈顶不大于当前值时,保留更早的栈顶不会变差;当删除额度为 0 时,更不能再替换。若栈尚未达到 k 就压入当前值;若栈已满,当前值只能被删除,并消耗一次额度。

不变量是:已扫描元素要么在栈中,要么已经计入删除额度,且栈是这些元素在当前删除约束下能得到的最小字典序前缀。每次弹出较大栈顶都是必然改进;不能弹时保留现有前缀是最优的。扫描结束后共处理 n 个元素并恰好删除 n-k 个,所以栈长度恰好为 k

解题步骤

  1. 初始化长度为 k 的栈数组、size = 0remove = n-k
  2. 对每个值,只要栈非空、栈顶更大且 remove > 0,就弹栈并减少删除额度。
  3. 若栈未满则压入当前值;否则删除当前值并减少额度。
  4. 扫描结束后直接返回栈数组。

[3,5,2,6], k = 2 的删除额度为 2。遇到 2 时依次弹出 5、3,用完额度后压入 2,最后压入 6,得到 [2,6]

k = n 时删除额度为 0,任何元素都不能弹出,结果保持原数组。对于 [1,1,2], k = 2,相等的 1 不应互相替换,最终删除末尾 2,得到 [1,1]

代码实现

class Solution {
    public int[] mostCompetitive(int[] nums, int k) {
        int[] stack = new int[k];
        int size = 0;
        int remove = nums.length - k;

        for (int value : nums) {
            while (size > 0 && remove > 0 && stack[size - 1] > value) {
                size--;
                remove--;
            }
            if (size < k) {
                stack[size++] = value;
            } else {
                remove--;
            }
        }
        return stack;
    }
}
func mostCompetitive(nums []int, k int) []int {
	stack := make([]int, 0, k)
	remove := len(nums) - k

	for _, value := range nums {
		for len(stack) > 0 && remove > 0 && stack[len(stack)-1] > value {
			stack = stack[:len(stack)-1]
			remove--
		}
		if len(stack) < k {
			stack = append(stack, value)
		} else {
			remove--
		}
	}
	return stack
}

复杂度分析

  • 时间复杂度:$O(n)$。每个元素至多入栈一次、出栈一次。
  • 空间复杂度:$O(k)$,栈数组就是最终返回结果;不计输出时额外空间为 $O(1)$。

关键点总结

  • 长度 k 转换成删除额度 n-k,可直接控制单调栈的弹出次数。
  • 后来的更小值可以替换栈顶,使第一个不同位置更小。
  • 弹栈条件必须使用严格大于;相等时替换不改善字典序。
  • 删除额度耗尽后不能再弹,即使后续值更小。
  • 栈已满而当前值无法改善前缀时,删除当前值。

易错点总结

  • 弹出相等元素会浪费删除额度;[1,1,2], k=2 应保留两个 1。
  • 忘记检查 remove > 0,在 k = n 时会弹出元素并导致结果不足 k 个。
  • 只用 if 弹一次不足以移除连续较大栈顶;[3,5,2,6], k=2 需要连续弹两次。
  • 栈满后覆盖栈顶会改变已确定前缀;无法改善时应删除当前值。
  • 先排序再取最小值会破坏子序列的原始相对顺序。
  • k 当删除数会返回错误长度;删除额度应是 n-k

相似题目

题目 难度 考察点
402. 移掉 K 位数字 中等 与本题同构,但给的是删除个数,守卫改成剩余删除额度,还要处理前导零
316. 去除重复字母 中等 额外要求每个字母恰好出现一次,弹栈前必须确认该字母后面还会再出现
1081. 不同字符的最小子序列 中等 与 316 同题,可直接套用带「后续出现次数」守卫的模板
321. 拼接最大数 困难 把本题当子过程调用两次再枚举分配方案合并,考点升级为多序列组合
738. 单调递增的数字 中等 同样是逐位贪心构造,但只能减小某一位并把后面全置 9,无需栈
739. 每日温度 中等 单调栈的另一大用途:求下一个更大元素,弹栈时结算答案而非丢弃
84. 柱状图中最大的矩形 困难 栈里存下标而非值,弹栈时用左右边界计算面积,展示同一结构的不同结算方式
946. 验证栈序列 中等 栈的行为由输入序列指定而非贪心决定,考点是模拟而非最优化