给定数组 nums,统计满足 i < j 且 nums[i] == nums[j] 的下标对数量。下标从 0 开始,同一个位置不能与自己配对。
贯穿本课的默认主样例是 [1, 2, 1, 1]。合法下标对为 (0,2)、(0,3)、(2,3),所以答案为 3。即使数值都是 (1,1),位置不同仍是不同数对;题目问的是数量,不是去重后的数值组合。
自定义输入只改变下方主演示及联动代码的状态解释;本节默认例题、错误实验和自测各自保留明确标出的样例。
必要概念:先读懂一轮
Section titled “必要概念:先读懂一轮”“枚举”就是按顺序逐个尝试。这里固定右端点 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,都重新扫描左侧,重复做“哪些历史元素等于当前值”的寻找工作。下方切到“暴力枚举”,每一步都检查一对,而不只是展示成功配对。
为什么可以优化
Section titled “为什么可以优化”默认样例处理最后一个 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 代码。
查看 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 程序。
复杂度:究竟省掉了什么
Section titled “复杂度:究竟省掉了什么”长度为 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 代码。
这里的顺序要求只适用于本课在线扫描解法,不代表所有计数题或窗口题都必须先查再更新。
Java 分析:把动作落到语句上
Section titled “Java 分析:把动作落到语句上”展开主演示下方的“查看 Java 代码”,切换两种正确解法并单步观察高亮。代码始终可以提前查看,不需要先完成演示。
先解释你要记什么,再看容器:cnt 记历史频次,left 暂存本轮读到的次数,ans 记累计结果。查询时表不变;累加时表也不变;只有更新语句把当前元素纳入历史。
换样例自测:先预测,再核对
Section titled “换样例自测:先预测,再核对”这三道题独立使用新样例 [2,1,2],不会替换主演示输入。完成演示只表示演示结束,自测也只反馈本轮答对数量。
本课无需登录,不保存长期学习记录。刷新页面后恢复默认主输入、初始步骤和空白自测。页面加载成功后,手推与自测不依赖实时网络。
来源与教学修订
Section titled “来源与教学修订”- S1《枚举与观察力-能力拆解分析报告.md》:§1 固定变量、§3 常见卡点、§4 贡献流水账、§5 暴力到空间换时间、§7 最小落地清单。
- S2《枚举与观察力-能力拆解分析报告-白话版算法学习文档.md》:前置知识、“枚举右,维护左”的推导、默认样例、易错点及
countGoodPairsJava 模板。 - S3《枚举与观察力-数据结构基础模板文档(Java).md》:§1 问题模型、§2 查询与更新、§3.4 平均操作复杂度、§5 顺序与溢出、§7 容器选择。
本课为教学整理,交互、输入限制、错误实验与自测为新增设计。没有直接改写三份原稿。
适用范围修订: 不采用 S2 中“有 i < j 就不能用全局频次一次性计算”的通用说法。本题也可以统计每个值的全局次数 c,再求和 c(c-1)/2;首课只讲在线扫描时先查询再更新的原因。S2 山形三元组中的右侧最小值下标口径,留待后续课程制作时核对,不引用为本课结论。