使数组唯一的最小增量 图解题解
这道题到底在问什么
- 输入
- nums=[1,2,2]
- 输出
- 1 (把一个 2 抬成 3)
- 输入
- nums=[3,2,1,2,1,7]
- 输出
- 6 (最终变成 [1,2,3,4,5,7])
最优解:为什么这么做
一句话答案:LeetCode 945 使数组唯一的最小增量:排序后从左到右,每个数至少比前一个占用值大 1,不够就抬到刚好够,抬升量累加就是最少操作次数,时间 O(n log n)、空间 O(1)。
每次把某个数加 1,最少几次能让值都不同
给整数数组 nums,一次操作把某个下标的值加 1,只能加不能减。要让每个值都唯一,问最少操作多少次。题面例子 nums=[1,2,2] 只需 1 次,把一个 2 加成 3;nums=[3,2,1,2,1,7] 要 6 次,最终变成 [1,2,3,4,5,7]。难点在于重复的数不止一个,往哪顶、顶到几还互相牵扯。
见一个重复就去找空位,为什么会越找越慢
先想到的多半是扫出重复的数,挑一个没被占用的新值。可「没被占用」得对着整个数组查,通常拿集合记下已用的值,遇到重复就从它往上一格格试到空位。数据一大,最坏每个重复数都要探很多格,退化成平方级,几万个数就超时。而且顶到哪个值才不多花,散着看理不清。
先排好序,每个数只跟前一个占用值较劲
把 nums 从小到大排序,相同的数就挨在一起,重复只发生在相邻位置。这样每个数不必顾全局,只要保证比左边刚定下来的占用值大就行。
记左边最后占用的值为 y,轮到当前数 x:若 x 已经比 y 大,它自己就够,原地占用、一步不花;若 x ≤ y,会撞上前面,得把它抬到 y+1,这是躲开重复的最小合法值。为什么抬到刚好 y+1、不多抬?多抬一格,当前多花代价,还把后面所有数的下限顶高,只会更贵;抬到 y+1 既躲开重复,又给后面留最大空间。每步取最小可用值,合起来抬升量最小。
把「抬到刚好」压成一行递推
用变量 y 记上一个占用值,初始设成 -1,让第一个数的可用下限落在 0,非负的数都不用抬。ans 记累计代价从 0 起。遍历排序后每个 x:占用值 y = max(y+1, x),要么被前一个逼到 y+1、要么就是 x 自己,取大的那个;代价 y − x 累加进 ans。走完返回 ans。x > y 时代价 0,x ≤ y 时补上和 y+1 的差距。
[3,2,1,2,1,7] 排完序,6 是怎么一步步加出来的
排序后是 [1,1,2,2,3,7],起手 y=-1、ans=0。第一个 1:y=max(0,1)=1,代价 1−1=0,ans=0。第二个 1:y=max(2,1)=2,顶到 2,代价 2−1=1,ans=1。
第一个 2:y=max(3,2)=3,顶到 3,代价 3−2=1,ans=2。第二个 2:y=max(4,2)=4,顶到 4,代价 4−2=2,ans=4。3:y=max(5,3)=5,顶到 5,代价 5−3=2,ans=6。7:y=max(6,7)=7,自己够大,代价 0,ans 停在 6。数组落成 [1,2,3,4,5,7],六个值互不相同,累计正是 6。
三个会让你多花步数或直接超时的地方
不排序直接贪心是头号坑:一个数得和前面所有已定的值逐一比较,逻辑绕不清、容易漏判撞车。抬过头是第二个:只要抬到 max(y+1, x),多抬哪怕一格,当前多花一步,后面所有数的下限被顶高,代价滚成雪球。第三个是拿集合暴力找空位,一格格往上探,最坏退化成平方级就卡死。
时间上,排序 O(n log n),之后只线性扫一趟,仍是 O(n log n);除了 y 和 ans 没另开空间,O(1)。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这套「新占用值 = max(上一个+1, x),代价 = 新值 - x」,下面每一帧都在套它。
- 4先看原始数组 [3,2,1,2,1,7],里面有两个 1、两个 2,存在重复,需要把它们抬开。
- 5排序后变成 [1,1,2,2,3,7]。相同的数挨在一起,从左往右处理就只需盯住「前一个占用值」。y 先设成 -1,这样第 0 个数的下一个可用值是 0,不会误抬。
- 6轮到第 0 个数 1(紫色)。左边绿色的都是已经定下来、互不重复的值。前一个占用到 -1,所以现在能用的最小值是 0。
- 71 已经不小于 0,自己就够大,直接占用 1,这一步代价是 0,一步都不用花。
- 81 原地锁定为绿色,代价 0,总代价还是 0。下一个数要比 1 更大。
- 9轮到第 1 个数 1(紫色)。左边绿色的都是已经定下来、互不重复的值。前一个占用到 1,所以现在能用的最小值是 2。
- 102 比 1 大,说明 1 占不下,会和前面重复(标红)。只能把它抬到刚好不撞的 2,多走 1 步。
- 11把它从 1 抬到 2,绿色锁定。总代价累加到 1,下一个数要比 2 更大才行。
- 12轮到第 2 个数 2(紫色)。左边绿色的都是已经定下来、互不重复的值。前一个占用到 2,所以现在能用的最小值是 3。
- 133 比 2 大,说明 2 占不下,会和前面重复(标红)。只能把它抬到刚好不撞的 3,多走 1 步。
- 14把它从 2 抬到 3,绿色锁定。总代价累加到 2,下一个数要比 3 更大才行。
- 15轮到第 3 个数 2(紫色)。左边绿色的都是已经定下来、互不重复的值。前一个占用到 3,所以现在能用的最小值是 4。
- 164 比 2 大,说明 2 占不下,会和前面重复(标红)。只能把它抬到刚好不撞的 4,多走 2 步。
- 17把它从 2 抬到 4,绿色锁定。总代价累加到 4,下一个数要比 4 更大才行。
- 18轮到第 4 个数 3(紫色)。左边绿色的都是已经定下来、互不重复的值。前一个占用到 4,所以现在能用的最小值是 5。
- 195 比 3 大,说明 3 占不下,会和前面重复(标红)。只能把它抬到刚好不撞的 5,多走 2 步。
- 20把它从 3 抬到 5,绿色锁定。总代价累加到 6,下一个数要比 5 更大才行。
- 21轮到第 5 个数 7(紫色)。左边绿色的都是已经定下来、互不重复的值。前一个占用到 5,所以现在能用的最小值是 6。
- 227 已经不小于 6,自己就够大,直接占用 7,这一步代价是 0,一步都不用花。
- 237 原地锁定为绿色,代价 0,总代价还是 6。下一个数要比 7 更大。
- 24全部处理完,数组变成 [1,2,3,4,5,7],每个值都不一样。把每一步的代价加起来正好是 6,这就是答案。
⚠️ 容易写错的地方
✗ 错:不排序直接贪心
✓ 对:必须先排序
只有排序后,每个数才只需和「前一个占用值」比较,否则要兼顾全局会出错
✗ 错:抬过头,不是刚好比前一个大 1
✓ 对:抬到 max(y+1, x) 这个最小合法值
抬到更大的值只会多花代价,贪心要取最小可用值
✗ 错:用集合暴力找下一个空位
✓ 对:排序后线性递推 y
每次从某值往上找空位,最坏会退化成平方级,数据大就超时
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def minIncrementForUnique(self, nums: List[int]) -> int:
nums.sort()
ans, y = 0, -1
for x in nums:
y = max(y + 1, x)
ans += y - x
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int minIncrementForUnique(vector<int>& nums) {
sort(nums.begin(), nums.end());
int ans = 0, y = -1;
for (int x : nums) {
y = max(y + 1, x);
ans += y - x;
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int minIncrementForUnique(int[] nums) {
Arrays.sort(nums);
int ans = 0, y = -1;
for (int x : nums) {
y = Math.max(y + 1, x);
ans += y - x;
}
return ans;
}
}复杂度
时间
O(n log n)
排序占主导,之后一遍线性扫描
空间
O(1)
只用 y、ans 两个变量;排序自身栈开销另算
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 使数组唯一的最小增量 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
不排序能不能做,有没有更快的写法?+
能。有一种计数法:先统计每个值出现几次,再从小到大扫过整个值域,某个值上多出来的重复个数就往后一格「顺延」,同样把顺延产生的抬升量累加起来。它是 O(n + 值域) 的,当值都不大、值域有限时比排序还快。排序贪心则是 O(n log n),但代码最短、最不容易写错,值域很大时也只能用它。
凭什么保证这样一步步抬出来的总代价最小?+
排序后从左到右看,第 i 个数能落脚的最小合法值,必然不小于前一个占用值加 1,取这个最小值本步花得最少。而且它同时是后面所有数下限里最松的一个,不会逼着后面多花。反证一下也通:假定另有一个更省的方案,若它某步把某个数抬得比 y+1 高,把多出来的那截削掉,当前这步立刻更省、后面各数的下限又都不受影响,于是它并不比逐步取最小的做法更优。
y 为什么要从 -1 开始,写成 0 会怎样?+
y 表示「上一个已占用的值」,第一个数应当允许落在 0(或它自己)而不被强行抬走。y=-1 时,第一个数算的下限是 max(-1+1, x)=max(0, x),恰好等于 x 本身,代价 0,符合预期。若把 y 初始写成 0,第一个数会被算成 max(0+1, x)=max(1, x),哪怕它本来是 0 也会被平白抬到 1,凭空多花一步、答案偏大。初值 -1 就是为了让第一个数的下限落回 0。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 使数组唯一的最小增量 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。