LeetCode 321. 拼接最大数
题目描述
题意分析
给定两个由一位数字构成的数组,要从它们中一共挑出 $k$ 个数字拼成一个长度为 $k$ 的序列,使这个序列在字典序意义下最大。约束是每个数组内部被选中的数字必须保持原有先后顺序,但两个数组的数字可以任意交错。
「保持相对顺序」这几个字说明从单个数组里挑出来的是子序列而非子集,位置不能重排;「可以交错」说明最终答案是两个子序列的一次归并。两条合起来意味着答案的形态被限定得很死:先决定从第一个数组取几个、从第二个取几个,再决定各自取哪几个,最后决定交错的方式。
由于每个元素都是 $0$ 到 $9$ 的一位数且两个候选序列等长,比较字典序等价于比较把它们当成数字时的大小,逐位比较即可,不必担心位数不同带来的干扰。
数据范围里两个数组长度都不超过 $500$,$k$ 不超过两者长度之和,这个规模明确允许一个带三重代价的枚举:外层可以枚举 $O(k)$ 种分配方式,内层再花线性甚至平方的代价,总量仍然可以接受。
边界情形有几类:某个数组一个都不取,此时它贡献空序列;$k$ 恰好等于两数组长度之和,此时两边都必须全取,没有挑选余地;数字大量重复,比如两个数组里全是同一个数字,这时归并阶段的比较会退化成「一直相等」,必须有明确的定夺规则;以及某个数组本身为空。
解法:枚举分配 + 单调栈 + 后缀贪心归并
核心思路
最终答案由两边各取多少、每边取哪些、两段如何交错三个决策组成。先枚举从
nums1取take1个,那么nums2必须取k - take1个;对每种分配,问题拆成两个独立子问题。固定长度的最大子序列:设数组长度为 $n$、目标长度为 $t$,允许删除
drop = n - t个元素。顺序扫描,用单调栈保存答案;当栈顶比当前数字小且仍有删除额度时,弹出栈顶。把更大的数字放到更靠前的位置一定能增大字典序,drop则保证最后仍能凑够 $t$ 位。两个子序列的最大归并:每步应选择剩余后缀中字典序更大的一侧。首位不同时直接选较大数字;首位相同时必须继续比较后缀,因为第一处不同的位置才决定整体大小。例如
[6,7]与[6,0]应先取第一侧的6,不能固定在相等时选某一数组。对固定分配,最大子序列和最大归并分别保证局部最优;枚举覆盖所有合法分配,因此所有候选中的最大值就是全局答案。
解题步骤
- 枚举
take1,范围为max(0, k - nums2.length)到min(k, nums1.length)。- 分别用单调栈求两数组的定长最大子序列。
- 用后缀比较贪心归并两个子序列,得到长度为 $k$ 的候选。
- 将候选与当前最优答案按字典序比较并更新。
- 枚举结束后返回最优答案。
单调栈中的弹出条件必须同时满足“栈顶更小”和“还有删除额度”;数字相等时保留靠前者即可。归并时若一侧耗尽,另一侧后缀自然更大。
代码实现
class Solution {
public int[] maxNumber(int[] nums1, int[] nums2, int k) {
int lower = Math.max(0, k - nums2.length);
int upper = Math.min(k, nums1.length);
int[] best = new int[k];
for (int take1 = lower; take1 <= upper; take1++) {
int[] part1 = maxSubsequence(nums1, take1);
int[] part2 = maxSubsequence(nums2, k - take1);
int[] candidate = merge(part1, part2);
if (greater(candidate, 0, best, 0)) {
best = candidate;
}
}
return best;
}
private int[] maxSubsequence(int[] nums, int length) {
int[] stack = new int[length];
int top = 0;
int drop = nums.length - length;
for (int num : nums) {
while (top > 0 && drop > 0 && stack[top - 1] < num) {
top--;
drop--;
}
if (top < length) {
stack[top++] = num;
} else {
drop--;
}
}
return stack;
}
private int[] merge(int[] nums1, int[] nums2) {
int[] merged = new int[nums1.length + nums2.length];
int index1 = 0;
int index2 = 0;
for (int i = 0; i < merged.length; i++) {
if (greater(nums1, index1, nums2, index2)) {
merged[i] = nums1[index1++];
} else {
merged[i] = nums2[index2++];
}
}
return merged;
}
private boolean greater(
int[] nums1,
int index1,
int[] nums2,
int index2
) {
while (index1 < nums1.length
&& index2 < nums2.length
&& nums1[index1] == nums2[index2]) {
index1++;
index2++;
}
return index2 == nums2.length
|| (index1 < nums1.length
&& nums1[index1] > nums2[index2]);
}
}
func maxNumber(nums1 []int, nums2 []int, k int) []int {
lower := 0
if k-len(nums2) > lower {
lower = k - len(nums2)
}
upper := k
if len(nums1) < upper {
upper = len(nums1)
}
best := make([]int, k)
for take1 := lower; take1 <= upper; take1++ {
part1 := maxSubsequence(nums1, take1)
part2 := maxSubsequence(nums2, k-take1)
candidate := mergeMax(part1, part2)
if greaterSeq(candidate, 0, best, 0) {
best = candidate
}
}
return best
}
func maxSubsequence(nums []int, length int) []int {
stack := make([]int, 0, length)
drop := len(nums) - length
for _, num := range nums {
for len(stack) > 0 && drop > 0 &&
stack[len(stack)-1] < num {
stack = stack[:len(stack)-1]
drop--
}
if len(stack) < length {
stack = append(stack, num)
} else {
drop--
}
}
return stack
}
func mergeMax(nums1 []int, nums2 []int) []int {
merged := make([]int, len(nums1)+len(nums2))
index1, index2 := 0, 0
for i := range merged {
if greaterSeq(nums1, index1, nums2, index2) {
merged[i] = nums1[index1]
index1++
} else {
merged[i] = nums2[index2]
index2++
}
}
return merged
}
func greaterSeq(nums1 []int, index1 int, nums2 []int, index2 int) bool {
for index1 < len(nums1) && index2 < len(nums2) &&
nums1[index1] == nums2[index2] {
index1++
index2++
}
return index2 == len(nums2) ||
(index1 < len(nums1) && nums1[index1] > nums2[index2])
}
复杂度分析
设两数组长度为 $m$、$n$。合法分配至多 $k+1$ 种。
- 时间复杂度:$O(k(m+n+k^2))$。每种分配求子序列需要 $O(m+n)$;归并 $k$ 位时,每次后缀比较最坏扫描 $O(k)$,因此为 $O(k^2)$。
- 空间复杂度:$O(k)$,两个子序列、候选和最优答案的总长度都与 $k$ 同阶;不计返回结果仍为 $O(k)$。
关键点总结
- 枚举“各取多少”后,选子序列与归并两个决策才能独立求最优。
- 定长最大子序列使用删除配额控制单调栈,不能为了更大数字无限弹栈。
- 归并比较的是完整剩余后缀,而不是只比较当前数字。
- 字典序由第一处不同决定;一侧先耗尽时,非空后缀更大。
- 这是困难题,面试时应先讲三层拆分,再分别证明单调栈和后缀贪心。
易错点总结
- 枚举范围固定写成
0...k:可能要求某个数组取出超过自身长度的元素。- 栈满后丢弃当前数字却不减少
drop:后面会错误地继续弹栈。- 相等首位时固定选某一侧:如
[6,7]与[6,0]会得到更小结果。- 后缀比较把空后缀判为更大:归并时可能访问已经耗尽的数组。
- 只保留某一种分配:最优答案可能来自另一种取数比例。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 402. 移掉 K 位数字 | 中等 | 只有一个序列且目标是字典序最小,是本题单调栈模板的最简形态 |
| 1673. 找出最具竞争力的子序列 | 中等 | 同为定长最小子序列,直接对应本题的单侧子问题,没有枚举与归并 |
| 316. 去除重复字母 | 中等 | 单调栈之外还要保证每个字符恰好出现一次,弹栈条件多了「后面是否还有」 |
| 179. 最大数 | 中等 | 同样追求拼接后最大,但元素可任意重排,靠自定义比较器排序而非子序列 |
| 88. 合并两个有序数组 | 简单 | 归并的基础形态,比较规则只看单个元素,不涉及后缀定夺 |
| 738. 单调递增的数字 | 中等 | 同为逐位贪心构造数字,但约束是结果本身单调且不超过给定上界 |