目录

题目描述

1679. K 和数对的最大数目

题意分析

给定数组 nums 和整数 k,一次操作是从数组里挑出两个和为 k 的数并把它们移除,问最多能操作多少次。要返回的是操作次数,不是被移除的元素个数,这两个数差一倍,是最容易在最后一行写错的地方。

「移除」这个动作决定了整道题的性质:元素一旦被用掉就不能再参与后续操作,所以每个下标最多出现在一个数对里。也就是说,题目要求的是把数组元素两两配对、且每对之和恰为 k最大匹配数。如果把每个元素看成一个点、把每一对和为 k 的元素看成一条边,这就是一般图上的最大匹配,本身并不是一个能随手写出来的问题。

但「和为定值」给这张图加了极强的结构:一个值为 x 的元素只可能和值为 k - x 的元素连边,绝不可能和第三种值连边。于是整张图被劈成一堆互不相干的小块,每一块只涉及 xk - x 两种取值,块与块之间不会互相抢元素。因此不需要真的跑匹配算法,只要在每个小块内部把能配的数量算对,再把各块的结果相加就是全局最优 —— 局部最优可以直接拼成全局最优,这正是允许我们用贪心 / 计数来做的根本原因。

再看约束:1 <= nums.length <= 10^5 说明 $O(n)$ 与 $O(n \log n)$ 都能过,排序不是负担;1 <= nums[i] <= 10^91 <= k <= 10^9 说明值域极大、不能开桶数组计数,只能用哈希表或者排序,而且两数之和最大 2 * 10^9,虽然逼近 32 位上限但仍在 int 范围内,不必额外处理。另外元素全为正数,所以 k - x 若不在 [1, 10^9] 内直接就是无解,不会有负数搅局。

最需要警惕的边界是 k 为偶数、且数组里存在多个 k / 2 的情形:此时 xk - x同一种值,配对发生在同一个值的内部。这时 mk / 2 只能凑出 m / 2 对(向下取整),而不是 m 对 —— 任何把 xk - x 当成两个独立集合来取 min 的写法都会在这里翻车。此外还有两类平凡边界:数组长度为 1 时一次操作都做不了,必须返回 0;完全没有任何一对和为 k 时也返回 0

解法一:哈希表边遍历边配对

核心思路

既然值域大到没法开桶,就用哈希表记录「已经出现、但目前还没找到搭子」的元素及其个数。整个过程只扫一遍数组:拿到当前元素 x,先算出它需要的补数 want = k - x,如果哈希表里还有等待中的 want,就当场把这两个元素配成一对、把 want 的计数减一、答案加一;否则说明 x 暂时无人可配,把它自己放进哈希表里等后来者。

关键在于「边遍历边消耗」而不是「先统计完再配对」。哈希表里存的从来不是「某个值出现了多少次」,而是「某个值还剩多少个可用」。一旦某个元素被配走,它的计数立刻减一,之后不会再被任何人看见,因此同一个元素物理上不可能被用两次。也正因为哈希表里只装「已经扫过的元素」,当前元素 x 在被查询时还没有被记账,它绝不可能查到自己 —— 这就自动解决了 k 为偶数时 k / 2 与自身配对的陷阱:两个 k / 2 必须是先后出现的两个不同下标,才会被配成一对。

那配对的顺序会不会影响最优性?不会。回到题意分析里的结论:能和 x 配对的只有 k - x,所以整个问题被拆成一堆独立的小块。在只涉及 xk - x(且 2x != k)的块里,两种值的元素彼此完全等价 —— 谁配谁没有区别,答案恒为两者个数的较小值,而「见到一个能配的就配」恰好会一直配到较少的那一方用尽。在 2x == k 的块里,所有元素同值、也完全等价,「攒够两个就消掉一对」得到的就是 m / 2。既然每一步的选择都在等价元素之间进行,就没有任何一步会让后面变差,贪心即最优。

不变量:扫完前 i 个元素时,ans 是这前 i 个元素能凑出的最大操作次数,且哈希表中每个键的计数之和恰好等于 i - 2 * ans(即所有还没被配走的元素个数),每个键的计数都非负。

解题步骤

  • 建一个哈希表 need,语义固定为「值 → 还剩几个该值的元素在等待配对」。为什么不用「出现次数」:因为配对要消耗元素,只有「剩余可用数」这个语义才能让减一操作有意义。
  • 答案 ans 初始化为 0
  • 从左到右遍历每个元素 x,计算它需要的补数 want = k - x。为什么只需要看一个补数:和为定值意味着 x 的搭子取值唯一。
  • need[want] > 0,说明前面有个还没配上的 want 正好在等着:把 need[want] 减一表示消耗掉它,ans 加一。为什么必须判「计数大于 0」而不是「键存在」:某个键的计数可能已经被减到 0,键还在但人已经没了。
  • 否则把 need[x] 加一,让 x 进入等待池。为什么这一步要放在 else 分支里:如果无条件先记账再查询,x 就会在 2x == k 时查到刚记进去的自己,一个元素被当成两个用。
  • 遍历结束返回 ans。为什么不用管等待池里的残余:留在池子里的元素都是找不到搭子的,对答案没有贡献。

nums = [3,1,3,4,3]k = 6 走一遍(这一组里 k 是偶数且 k / 2 = 3 出现了三次,正是自配对用例):

  • 初始:need = {}ans = 0
  • x = 3want = 3need[3] = 0 配不上 → 记账,need = {3:1}ans = 0。注意此刻查询发生在记账之前,所以这个 3 没有配到自己。
  • x = 1want = 5need[5] = 0 配不上 → 记账,need = {3:1, 1:1}ans = 0
  • x = 3want = 3need[3] = 1 > 0 配上了 → need = {3:0, 1:1}ans = 1。消耗的是第一个 3,两个下标不同。
  • x = 4want = 2need[2] = 0 配不上 → 记账,need = {3:0, 1:1, 4:1}ans = 1
  • x = 3want = 3need[3] = 0 配不上 → 记账,need = {3:1, 1:1, 4:1}ans = 1。三个 3 只能凑出一对,第三个只能落单。
  • 返回 ans = 1,与官方样例一致。

代码实现

class Solution {
    public int maxOperations(int[] nums, int k) {
        // need 的语义:值 -> 还剩几个该值的元素在等待配对
        Map<Integer, Integer> need = new HashMap<>();
        int ans = 0;

        for (int x : nums) {
            int want = k - x;
            int count = need.getOrDefault(want, 0);
            if (count > 0) {
                // 前面有个还没配上的补数在等,当场配成一对并消耗掉它
                need.put(want, count - 1);
                ans++;
            } else {
                // 暂时无人可配,x 自己进等待池;查询在记账之前,所以配不到自己
                need.put(x, need.getOrDefault(x, 0) + 1);
            }
        }

        return ans;
    }
}
func maxOperations(nums []int, k int) int {
    // need 的语义:值 -> 还剩几个该值的元素在等待配对
    need := make(map[int]int)
    ans := 0

    for _, x := range nums {
        want := k - x
        if need[want] > 0 {
            // 前面有个还没配上的补数在等,当场配成一对并消耗掉它
            need[want]--
            ans++
        } else {
            // 暂时无人可配,x 自己进等待池;查询在记账之前,所以配不到自己
            need[x]++
        }
    }

    return ans
}

复杂度分析

  • 时间复杂度:$O(n)$。数组只被扫一遍,每个元素做常数次哈希查询与更新,哈希操作均摊 $O(1)$。
  • 空间复杂度:$O(n)$。最坏情况下没有任何一对能配上(例如所有元素互不相同且都找不到补数),哈希表会装下全部 n 个元素。

关键点总结

  • 哈希表里存的是「剩余可用数」而不是「出现次数」,语义一旦定死,减一操作才有意义,代码也不会在两种解释之间摇摆。
  • 「边遍历边消耗」把「不重复使用同一元素」这件事变成了结构性保证,而不是靠额外判断去打补丁 —— 用掉就减掉,减掉就再也看不见。
  • 先查询、后记账是这份代码最要紧的一行顺序。它同时解决了自配对陷阱和元素自己配自己的陷阱,颠倒过来就会在 2x == k 时多算。
  • 判断条件写「计数大于 0」而不是「键存在」,因为计数减到 0 后键仍然留在表里,这是哈希计数类题目的通用坑。
  • 「和为定值」把配对图切成互不相干的小块,块内元素完全等价,这是贪心正确性的来源。以后遇到「和 / 差为定值的配对」类问题都可以先找这个结构。
  • 面试视角:这份写法只有八行、一次遍历、没有第二个循环,是白板上最不容易写错的版本。写完主动补一句「查询放在记账之前,所以 k 为偶数时两个 k / 2 必须来自不同下标」,等于替面试官把最想追问的边界先答了。

解法二:排序后双指针对撞

核心思路

换一个角度:如果数组是有序的,那么「和为 k」这件事就有了单调性 —— 固定左端往右走,和只会变大;固定右端往左走,和只会变小。于是可以让 left 从最小值出发、right 从最大值出发向中间对撞,用当前和与 k 的大小关系来决定移动哪一侧,命中就把两个元素一起消掉。

为什么这样贪心是对的?关键是两端必须配对,否则谁都配不上这个交换论证。考察当前区间 [left, right]

  • nums[left] + nums[right] < k,那么 nums[left] 加上区间内任何元素都不超过这个和(因为 nums[right] 已经是区间最大值),所以 nums[left] 在这个区间里永远配不出 k,可以永久丢弃、left 右移。
  • nums[left] + nums[right] > k,同理 nums[right] 加上区间内任何元素都不小于这个和(nums[left] 是区间最小值),nums[right] 也永远配不出 k,丢弃、right 左移。
  • nums[left] + nums[right] == k,则把这两个配成一对不会让答案变差。设某个最优方案里 nums[left] 配的是 bnums[right] 配的是 aab 都在区间内),因为和都等于 k,有 nums[left] + b = k = nums[left] + nums[right],故 b 的值等于 nums[right] 的值;同理 a 的值等于 nums[left] 的值。把这两对换成 (nums[left], nums[right])(a, b),对数不变、和依然都是 k,所以存在一个同样最优的方案包含这一对。若最优方案里只有其中一个被配走、另一个落单,直接让落单的那个替换掉搭子即可,对数也不变。因此总存在一个最优解在 leftright 相等于 k 时把它俩配在一起,贪心地取走它是安全的。

这三条合起来说明:每一步要么删掉一个不可能配对的元素,要么取走一对可以出现在某个最优解里的元素,所以循环结束时累计的对数就是最大值。

不变量ans 是区间 [left, right] 之外的元素已经贡献的最大操作次数,且区间外的元素要么已被配对消耗、要么已被证明在剩余元素中无法配出 k;同时 nums[left]nums[right] 始终是当前区间的最小值与最大值。

解题步骤

  • 先把 nums 升序排序。为什么必须排序:整套判断完全依赖「nums[left] 是区间最小、nums[right] 是区间最大」,无序数组里这两个前提都不成立。
  • left = 0right = nums.length - 1ans = 0
  • left < right 时循环。为什么是严格小于:left == right 表示只剩一个元素,一个元素不能自己配自己凑成一对。
  • 计算 sum = nums[left] + nums[right]
  • sum == kans 加一,同时 left++right--。为什么两个指针都要动:这一对里的两个元素都被移除了,只动一个等于把另一个留下来重复使用。
  • sum < kleft++。为什么丢的是左边:nums[left] 已经配上了区间里最大的数还嫌小,它在这个区间里没救了。
  • sum > kright--。为什么丢的是右边:nums[right] 已经配上了区间里最小的数还嫌大,同理无解。
  • 循环结束返回 ans

nums = [1,3,4,5,5,6,9,9]k = 10 走一遍(排序后即为它本身,含 5 + 5 = 10 的自配对,且三个分支都会走到):

  • left = 0(值 1),right = 7(值 9),sum = 10 == kans = 1,两个指针同时内移到 left = 1right = 6
  • left = 1(值 3),right = 6(值 9),sum = 12 > kright 左移到 5。这个 9 被丢弃是对的:区间里最小的 3 都配它超了。
  • left = 1(值 3),right = 5(值 6),sum = 9 < kleft 右移到 2。这个 3 被丢弃也是对的:区间里最大的 6 都配它不够。
  • left = 2(值 4),right = 5(值 6),sum = 10 == kans = 2left = 3right = 4
  • left = 3(值 5),right = 4(值 5),sum = 10 == kans = 3left = 4right = 3。两个 5 是不同下标,合法自配对;注意此处指针交叉,left < right 不再成立。
  • 循环结束,返回 ans = 3。对应的三对是 (1,9)(4,6)(5,5),与暴力枚举最大匹配的结果一致。

代码实现

class Solution {
    public int maxOperations(int[] nums, int k) {
        Arrays.sort(nums); // 排序后才有「左端最小、右端最大」的单调性
        int left = 0;
        int right = nums.length - 1;
        int ans = 0;

        while (left < right) { // 严格小于:剩一个元素配不成对
            int sum = nums[left] + nums[right];
            if (sum == k) {
                // 两个元素一起被移除,所以两个指针都要内移
                ans++;
                left++;
                right--;
            } else if (sum < k) {
                // 左端配上最大值都嫌小,它永远配不出 k,丢弃
                left++;
            } else {
                // 右端配上最小值都嫌大,它永远配不出 k,丢弃
                right--;
            }
        }

        return ans;
    }
}
func maxOperations(nums []int, k int) int {
    sort.Ints(nums) // 排序后才有「左端最小、右端最大」的单调性
    left, right, ans := 0, len(nums)-1, 0

    for left < right { // 严格小于:剩一个元素配不成对
        sum := nums[left] + nums[right]
        if sum == k {
            // 两个元素一起被移除,所以两个指针都要内移
            ans++
            left++
            right--
        } else if sum < k {
            // 左端配上最大值都嫌小,它永远配不出 k,丢弃
            left++
        } else {
            // 右端配上最小值都嫌大,它永远配不出 k,丢弃
            right--
        }
    }

    return ans
}

复杂度分析

  • 时间复杂度:$O(n \log n)$。瓶颈完全在排序上;对撞过程中 left 只增、right 只减,两者一共移动不超过 n 步,这部分是 $O(n)$。
  • 空间复杂度:$O(\log n)$。只用了几个下标变量,额外开销来自排序的递归栈(Java 的 Arrays.sort 对基本类型是双轴快排,Go 的 sort.Ints 是 pdqsort);若输入本身有序、不需要排序,则为 $O(1)$。

关键点总结

  • 排序的价值在于制造单调性:让「区间左端是最小值、右端是最大值」成立,从而能用一次比较就永久排除一个元素,把 $O(n^2)$ 的配对枚举压成一次线性扫描。
  • 双指针贪心的正确性靠交换论证支撑,而不是靠「看起来对」。落笔前先想清楚「不动它会不会更好」,能不能构造出一个同样最优的方案包含当前选择。
  • 命中后两个指针都要移动,这是「配对消耗两个元素」这一题意的直接翻译。指针移动次数应当和被移除的元素个数对齐。
  • 排序 + 双指针的额外空间只有 $O(1)$(不计排序栈),当题目卡内存、或者输入已经保证有序时,它比哈希更划算。
  • 面试视角:面试官问完哈希解法几乎必然追问「不用额外空间能做吗」,此时给出这一版就是标准答卷。讲的时候一定要把交换论证说出口 —— 双指针题的区分度往往不在能不能写出代码,而在能不能证明贪心不漏解。

解法对比

两种解法都能通过本题,取舍点在于时间与空间谁更紧,以及输入是否已经有序

哈希解法胜在时间:一次遍历、不需要任何预处理,是本题的渐进最优时间。代价是要开一张最坏能装下全部元素的哈希表,而且哈希的常数不小 —— 在 10^5 这个规模上,装箱后的 Integer 键与哈希冲突带来的开销,实测常常不比先排序再扫一遍快多少。它的另一个优势是只需要一遍流式访问,如果数据是从流里读进来、无法回头或不允许改动原数组,哈希是唯一可行的那个。

双指针解法胜在空间:除了排序自身的递归栈之外不占额外内存,也没有哈希的常数问题,代码更短、分支更直白,白板上手写不易出错。代价是必须能够修改(或复制)输入数组,并且要付出排序的代价。但如果题目保证输入已经有序,排序这一步直接省掉,它就退化成纯粹的一次对撞扫描 —— 此时它在时间上与哈希持平、在空间上完胜,是无争议的更优解。

面试里的标准答法是两个都讲,并且讲清楚触发条件:先给哈希版作为主解,说明它一次遍历就能出答案;再主动补一句「如果面试官在意额外空间,或者输入已经有序,我会改成排序加双指针」,并顺手点出双指针需要写入原数组这个副作用。如果面试官进一步限制「不许改动原数组且不许开额外空间」,就要老实说明这两个条件同时成立时排序方案也失效了,此时只能退回哈希 —— 能识别出约束之间的冲突,比硬凑一个方案更能加分。

至于本题的语义边界,两种解法都要在同一个地方小心:k 为偶数时 k / 2 的自配对。哈希靠「先查询后记账」自动规避,双指针靠「命中后两指针同时内移」自动规避,机制不同但都必须写对。

易错点总结

  • 先统计完出现次数再遍历配对,对 2x == k 的值没有除以 2:用 [2,2,2,2], k = 4 触发 → 四个 2 被当成「四个可配的元素」,返回 4;用 [3,1,3,4,3], k = 6 触发 → 三个 3 全被计入,返回 3,而正确答案分别是 21。这正是应当改用「边遍历边配对」一次遍历写法的理由。
  • 双指针命中后只移动一个指针sum == k 时只写了 left++):用 [2,2,2,2], k = 4 触发 → right 始终停在末尾那个 2 上被反复使用,返回 3 而不是 2。注意用 [1,2,3,4], k = 5 是测不出来的,它照样返回 2,必须用含重复值的用例。
  • 哈希配对成功后忘记把补数计数减一:用 [3,1,3,4,3], k = 6 触发 → 第一个 3 被后面两个 3 各配一次,返回 2 而不是 1;用 [2,2,2,2], k = 4 触发 → 返回 3 而不是 2
  • containsKey / map[want] != 0 之外的存在性判断代替「计数大于 0:用 [1,4,4], k = 5 触发 → 1 的计数已被减到 0,键还留在表里,于是第二个 4 又「配」了一次已经消失的 1,返回 2 而不是 1
  • Set 代替计数哈希表:用 [1,1,4,4], k = 5 触发 → 第二个 1Set 去重后丢失,只剩一次配对机会,返回 1 而不是 2。本题元素可以重复,必须计数而非去重。
  • 哈希先把 x 记进表再查补数:用 [3,1,3,4,3], k = 6 触发 → 第一个 3 刚记进表就查到「表里有个 3」,自己和自己配成一对,返回 2 而不是 1。查询与记账的先后顺序不能颠倒。
  • 双指针循环条件写成 left <= right:用 [5], k = 10 触发 → 唯一的元素自己和自己相加等于 k,返回 1 而不是 0;用 [2,2,2], k = 4 触发 → 返回 2 而不是 1
  • 双指针忘记先排序:用 [4,1,2,3], k = 5 触发 → 首轮 4 + 3 = 7 > 5 就把 3 丢了,之后再也凑不出第二对,返回 1 而不是 2
  • 双指针的移动方向写反sum < k 时移动 right):用 [1,3,4,5,6,9], k = 10 触发 → 和偏小时反而去砍最大值,越走越小,返回 1 而不是 2
  • 返回被移除的元素个数而不是操作次数:用 [1,2,3,4], k = 5 触发 → 返回 4 而不是 2。收尾时对一眼返回值语义,别让整题的正确逻辑倒在最后一行。

相似题目

题目 难度 考察点
面试题 16.24. 数对和 中等 同为最大匹配,但要返回数对本身而非个数,需在配对时记录具体元素
1. 两数之和 简单 只找一对且返回下标,元素不被消耗,哈希存的是下标而不是剩余可用数
167. 两数之和 II - 输入有序数组 中等 输入已有序可直接对撞省去排序,但命中即返回,不需要继续统计对数
15. 三数之和 中等 从两数扩展到三数,外层枚举加内层对撞,重点变成跳过重复值去重