跳转到内容

好数对:枚举右,维护左

约 7 分钟原样导入

← 枚举与状态维护(Java)专题目录

第一课 · 把重复寻找,变成一次查询

给你一排数字,找出值相同、位置不同的数对。先亲手检查,再看看:能不能只记住左边出现过几次?

开始推演 · 错误实验 · 查看 Java 代码 · 换样例自测

给定数组 nums,统计满足 i < j 且 nums[i] == nums[j] 的下标对数量。下标从 0 开始,同一个位置不能与自己配对。

贯穿本课的默认主样例是 [1, 2, 1, 1]。合法下标对为 (0,2)、(0,3)、(2,3),所以答案为 3。即使数值都是 (1,1),位置不同仍是不同数对;题目问的是数量,不是去重后的数值组合。

自定义输入只改变下方主演示及联动代码的状态解释;本节默认例题、错误实验和自测各自保留明确标出的样例。

“枚举”就是按顺序逐个尝试。这里固定右端点 j,把它指向的数称为当前元素 x。在默认样例中,当 j=3 时,x=1。

历史区是已处理的左侧:下标 0、1、2,值为 [1,2,1]。频次就是出现次数,值 1 出现了 2 次。把值 1 当作键,用 cnt[1]=2 记下来,就能直接回答“左边有几个 1”。

随用随查:查询、更新与枚举状态表

查询只读已有次数;没有这个键时读到 0,不把新键写入表。更新才改变次数,把当前元素也记进去。

“枚举状态表”是维护已处理信息的问题模型。本课使用 HashMap 保存“值 → 次数”;小而连续的值域也可以使用频次数组。前后缀数组用于其他问题的左右信息预处理,本课不需要它们。

固定右端点 j,从左到右,让 i 检查 0…j-1 的每个位置。默认样例依次检查:

(0,1) 不同 → (0,2) 相同 → (1,2) 不同 → (0,3) 相同 → (1,3) 不同 → (2,3) 相同。

6 次检查得到 3 对。它不会漏掉候选,但每来一个新的 j,都重新扫描左侧,重复做“哪些历史元素等于当前值”的寻找工作。下方切到“暴力枚举”,每一步都检查一对,而不只是展示成功配对。

默认样例处理最后一个 1 时,我们并不需要重新找出左边的每个 1,只需要知道已经出现了 2 次。每一个历史中的 1,都恰好与当前元素组成一个新数对,于是本轮直接增加 2。

把左侧的出现次数持续记下来,就能把内层逐个扫描替换成一次频次查询:先查询 → 累加贡献 → 再把当前元素加入历史。不是仅仅换成 Map,而是改变了重复寻找历史元素的方式。

正确性也由此得到:每个合法数对都有唯一的右端点。处理到它时,左端点已在历史中,因此不会漏算;只在这个右端点处理一次,因此不会重复。

默认样例逐轮核对表(加入历史之后)
j x 查询到的左侧次数 本轮新增 累计答案 更新后的频次表
0 1 0 0 0 1 → 1
1 2 0 0 0 1 → 1,2 → 1
2 1 1 1 1 1 → 2,2 → 1
3 1 2 2 3 1 → 3,2 → 1

手动推演:一个动作,一次状态变化

Section titled “手动推演:一个动作,一次状态变化”

先点“下一步”。优化扫描每轮分成选中、查询、累加、加入历史四个动作。上一步恢复完整状态;播放约每秒前进一个动作。切换解法会保留已应用输入并回到初始状态。

交互演示尚未初始化;若此提示持续显示,表示脚本未能加载。你仍可阅读课程讲解和下方 Java 代码。

主演示控制
交互实验室 · 亲手推演0 / 16 步

当前演示数据 [1, 2, 1, 1] 优化扫描

尚未处理任何元素。

初始状态

准备好了,从第一个动作开始

点击“下一步”,观察每个动作如何改变状态。

累计答案 ans0
本轮查询 left未查询
本动作新增—
状态详情 · 频次表与配对

频次表 cnt

值 → 出现次数

配对记录(下标)

尚未选中候选对

本动作尚未计入数对

已计入:无

配对下标仅作教学辅助,Java 频次表只存次数。键按数值升序显示,非 HashMap 的实际遍历顺序。

复杂度账本

频次查询 0 次 · 频次更新 0 次

这里计的是教学步骤中的具体操作,不是 JVM 指令或运行耗时。

1~12 个整数,每项 0~99;支持中英文逗号、空格与一对方括号。这是教学画面的限制。

查看 Java 代码 · 与当前步骤联动

高亮表示当前动作刚执行完成的语句,可包含多行。初始状态高亮初始化语句;最后一个动作也包含返回结果。

优化扫描 · 完整示例

import java.util.HashMap;import java.util.Map; class Solution {    public long countGoodPairs(int[] nums) {        Map<Integer, Integer> cnt = new HashMap<>();        long ans = 0L;        for (int x : nums) {            int left = cnt.getOrDefault(x, 0);            ans += left;            cnt.put(x, left + 1);        }        return ans;    }}

暴力枚举 · 完整示例

class Solution {    public long countGoodPairs(int[] nums) {        long ans = 0L;        for (int j = 0; j < nums.length; j++) {            for (int i = 0; i < j; i++) {                if (nums[i] == nums[j]) {                    ans++;                }            }        }        return ans;    }}

cnt 保存出现次数;left 保存本轮查询结果;ans += left 累加贡献;cnt.put(x, left + 1) 才把当前元素加入历史。

优化模板使用增强 for,画面中的 j 是教学定位下标,并非该 Java 模板声明的局部变量。暴力版本中 j 和 i 是实际循环变量。

累计数量使用 long:长度为 100,000 的全相同数组有 4,999,950,000 对,超出 int。当前演示最多 12 项,保留 long 是为了说明通用代码的数量边界。

本页展示与 Java 示例对应的算法教学推演,不是在线执行任意 Java 程序。

长度为 n 时,暴力检查 0 + 1 + … + (n-1) = n(n-1)/2 个候选数对,时间复杂度 O(n²),除输出外额外空间 O(1)。

优化扫描做 n 次频次查询、n 次频次更新。HashMap 的查改平均为 O(1),所以平均时间复杂度是 O(n)。保存 k 个不同值的次数需要 O(k) 额外空间,k ≤ n;最坏需要 O(n) 空间。这是用额外空间换掉重复扫描。

不同类型操作不能直接相加来宣称“快几倍”,也不能把动画播放时长当成 JVM 性能。页面为了讲解显示的配对列表和步骤快照,不属于 Java 算法的额外空间账本。

错误顺序实验:为什么会数到自己

Section titled “错误顺序实验:为什么会数到自己”

本实验固定使用独立输入 [1],不改变主输入和主演示位置。进入实验会暂停主演示,返回后也不自动播放。

正确顺序查询空历史,贡献为 0,再把 1 加入历史,结果 0。故意错误的顺序先更新再查询,读到 1 次,把 (0,0) 算进去,结果 1。违规的是 i < j,不是“数值不能相等”。

交互演示尚未初始化;若此提示持续显示,表示脚本未能加载。你仍可阅读课程讲解和下方 Java 代码。

实验控制
独立实验 · 固定输入 [1]0 / 4 步

当前演示数据 [1] 优化扫描

尚未处理任何元素。

初始状态

准备好了,从第一个动作开始

点击“下一步”,观察每个动作如何改变状态。

累计答案 ans0
本轮查询 left未查询
本动作新增—
状态详情 · 频次表与配对

频次表 cnt

值 → 出现次数

配对记录(下标)

尚未选中候选对

本动作尚未计入数对

已计入:无

配对下标仅作教学辅助,Java 频次表只存次数。键按数值升序显示,非 HashMap 的实际遍历顺序。

复杂度账本

频次查询 0 次 · 频次更新 0 次

这里计的是教学步骤中的具体操作,不是 JVM 指令或运行耗时。

这里的顺序要求只适用于本课在线扫描解法,不代表所有计数题或窗口题都必须先查再更新。

展开主演示下方的“查看 Java 代码”,切换两种正确解法并单步观察高亮。代码始终可以提前查看,不需要先完成演示。

先解释你要记什么,再看容器:cnt 记历史频次,left 暂存本轮读到的次数,ans 记累计结果。查询时表不变;累加时表也不变;只有更新语句把当前元素纳入历史。

这三道题独立使用新样例 [2,1,2],不会替换主演示输入。完成演示只表示演示结束,自测也只反馈本轮答对数量。

自测尚未初始化。请先用纸笔作答;若提示持续显示,刷新页面重试。

独立样例:[2,1,2]

2. 完成 j=2 后,累计答案与 cnt[2] 分别是多少?

3. 本题为什么不能先加入频次表再查询?

本课无需登录,不保存长期学习记录。刷新页面后恢复默认主输入、初始步骤和空白自测。页面加载成功后,手推与自测不依赖实时网络。

  • S1《枚举与观察力-能力拆解分析报告.md》:§1 固定变量、§3 常见卡点、§4 贡献流水账、§5 暴力到空间换时间、§7 最小落地清单。
  • S2《枚举与观察力-能力拆解分析报告-白话版算法学习文档.md》:前置知识、“枚举右,维护左”的推导、默认样例、易错点及 countGoodPairs Java 模板。
  • S3《枚举与观察力-数据结构基础模板文档(Java).md》:§1 问题模型、§2 查询与更新、§3.4 平均操作复杂度、§5 顺序与溢出、§7 容器选择。

本课为教学整理,交互、输入限制、错误实验与自测为新增设计。没有直接改写三份原稿。

适用范围修订: 不采用 S2 中“有 i < j 就不能用全局频次一次性计算”的通用说法。本题也可以统计每个值的全局次数 c,再求和 c(c-1)/2;首课只讲在线扫描时先查询再更新的原因。S2 山形三元组中的右侧最小值下标口径,留待后续课程制作时核对,不引用为本课结论。