目录

题目描述

196. 删除重复的电子邮箱

题意分析

Person 表有两列:主键 id 和邮箱 email。同一个邮箱可能出现在多行里,要求删除重复行,每个邮箱只保留 id 最小的那一行。注意这题要写的是 DELETE 而不是 SELECT,判定看的是执行之后表里剩下什么,而不是查询返回什么。

「保留 id 最小」这条规则把「该删哪些」变成了一个可以逐行独立判定的谓词:一行该被删,当且仅当存在另一行与它邮箱相同且 id 更小。这个表述很关键——它不需要先算出「每组的最小 id」再回头比对,只需要判断「有没有比我更小的同伴」。

id 是主键,所以互不相同,「更小」这个比较不会出现平局,每组必然恰好留下一行,不会误删光也不会漏删。

另一个必须留意的是 MySQL 的限制:不能在删除一张表的同时,在子查询里直接读取同一张表(报 1093 错误)。这条限制决定了写法只能走连接,或者套两层派生表把子查询的结果先物化。

边界包括:表中没有任何重复(一行都不删);某个邮箱出现三次及以上(要删掉除最小 id 外的全部);以及表只有一行。

解法:SQL 查询建模

核心思路

最直觉的写法是「先查出每个邮箱的最小 id,再删掉 id 不在这个集合里的行」。语义完全正确,但在 MySQL 上直接写 DELETE FROM Person WHERE id NOT IN (SELECT MIN(id) FROM Person GROUP BY email) 会被拒绝——子查询读的正是要删的那张表。要救它得再套一层派生表把中间结果物化,写法变得臃肿。

换个角度就干净了。前面已经把删除条件重述成「存在另一行与我邮箱相同且 id 更小」,而「存在另一行满足某关系」在 SQL 里的自然表达就是自连接:把同一张表以两个别名引入,让「我」和「那个更小的同伴」在同一行里相遇。

ON 中的两个条件各有职责:p1.email = p2.email 限定同一邮箱,p1.id > p2.id 证明 p1 存在更小 id 的同伴。凡是能连接成功的 p1 都应删除。

DELETE p1 FROM ... JOIN ... 这个语法的含义是「按连接结果删除 p1 所代表的那张表里的行」。别名 p1 出现在 DELETE 之后,明确指定了删哪一侧——这是多表删除语法的核心,写漏了数据库不知道该动谁。

为什么不会误删最小 id 的那一行?因为对它而言不存在 id 更小的同伴,p1.id > p2.id 对所有候选 p2 都不成立,它压根不会出现在连接结果里。

为什么同一行被匹配多次也没问题?一个邮箱出现三次时,最大 id 的那行会同时和另外两行配对,产生两条连接结果,但 DELETE 对同一行的重复命中是幂等的——删一次就不在了,不会报错也不会重复计数。

比较用严格大于而不是不等号:写成 p1.id != p2.id 会让每一行都能找到同伴,整组被删光。

解题步骤

  • 确认要删的是哪一侧,并把它的别名写在 DELETE 之后。这里删的是「有更小同伴」的那一行,所以是 DELETE p1。这一步定错,整条语句的方向就反了。
  • 用两个别名把 Person 引入两次p1 代表「被审视的行」,p2 代表「潜在的更小同伴」。同一张表出现两次必须起别名,否则列引用有歧义。
  • 连接条件同时限定同邮箱和 id 大小p1.email = p2.email AND p1.id > p2.idp1 只要能连接到一条 p2,就证明它不是本邮箱的最小 id。
  • 不需要 GROUP BYMIN() 或任何子查询。整个判定是逐行的存在性检查,连接本身已经把它表达完整了。

以一张具体的表走一遍:Person 中有 (1, 'john@example.com')(2, 'bob@example.com')(3, 'john@example.com')

连接阶段按邮箱配对。john@example.com 这一组有 id 为 1 和 3 的两行,两两组合出四对:(p1=1, p2=1)(p1=1, p2=3)(p1=3, p2=1)(p1=3, p2=3)bob@example.com 只有一行,只能自己和自己配成 (p1=2, p2=2)

再应用 id 大小条件:(1,1)(1,3)(3,3)(2,2) 都不成立,只有 (3,1) 满足 $3 > 1$,所以删除侧只命中 id 3。

于是只有 p1.id = 3 这一行被标记删除。执行后表里剩下 (1, 'john@example.com')(2, 'bob@example.com'),正是预期结果。

再看三份重复的情形:邮箱相同的行 id 分别是 1、4、7。满足 p1.id > p2.id 的组合有 (4,1)(7,1)(7,4),被标记的 p1 是 4 和 7 两行(7 被命中两次,但只删一次)。id 为 1 的行找不到更小的同伴,得以保留。

若把条件误写成 p1.id != p2.id,第一个例子里 (1,3)(3,1) 都会成立,两行全被删光,正确答案是保留 id 为 1 的那行。

代码实现

-- DELETE 后的 p1 指定删哪一侧;自连接找出「存在更小同伴」的行。
DELETE p1
FROM Person AS p1
JOIN Person AS p2
ON p1.email = p2.email
AND p1.id > p2.id;

复杂度分析

  • 时间复杂度:设每个邮箱组大小为 $g_i$,连接匹配数为 $O(\sum g_i^2)$,最坏所有邮箱相同则为 $O(n^2)$。(email, id) 索引能降低定位同组记录的成本,但不能消除同组内实际存在的匹配对。
  • 空间复杂度:由执行计划决定;流式连接只需连接状态,若物化匹配结果,最坏可达 $O(n^2)$。

关键点总结

  • 「每组保留一个」类的删除题,最省事的建模是把它翻成逐行的存在性谓词:「存在另一行与我同组且更优」。这样就不需要先求组内极值再回连,一次自连接即可表达。
  • DELETE 别名 FROM 表 JOIN ... 是 MySQL 多表删除的标准语法,DELETE 后的别名指定了删哪一侧。这是本题唯一必须记住的方言细节,写不出来语句直接报错。
  • MySQL 的目标表限制会拒绝直接从 Person 子查询读取后再删除 Person;多表自连接删除在同一连接计划中确定目标行,避开了错误 1093。
  • 比较运算符的方向直接决定保留哪一份。> 保留最小、< 保留最大、!= 会把整组删光——这三种结果差别巨大,写完务必回读一遍语义。
  • 同一行被连接结果多次命中不会造成问题,DELETE 天然幂等。理解这一点就不必画蛇添足地加 DISTINCT(多表删除语法里也不允许加)。
  • 面试表达要从删除谓词出发:「存在同邮箱且 id 更小的行就删除」,再说明 DELETE p1 指定目标侧,以及为什么该写法不会触发 MySQL 1093 限制。

易错点总结

  • 错误写法:条件写成 WHERE p1.id != p2.id。用例:(1, 'a@x.com')(3, 'a@x.com') → 两行互相匹配、双双被删,正确答案是保留 id 为 1 的那行。
  • 错误写法:条件写成 WHERE p1.id < p2.id。用例:(1, 'a@x.com')(3, 'a@x.com') → 删掉的是 id 为 1 的行,保留了 3,正确答案是保留最小的 1。
  • 错误写法:直接写 DELETE FROM Person WHERE id NOT IN (SELECT MIN(id) FROM Person GROUP BY email)。用例:任意数据 → MySQL 报错 1093「You can't specify target table 'Person' for update in FROM clause」,语句无法执行。
  • 错误写法:写成 SELECT 查询返回保留下来的行。用例:任意数据 → 表本身没有被修改,判定按执行后的表内容比对,直接失败。
  • 错误写法:连接条件漏写,用逗号写成 DELETE p1 FROM Person p1, Person p2 WHERE p1.id > p2.id。用例:(1,'a@x.com')(2,'b@x.com')(3,'a@x.com') → 邮箱不同的行也被配对,id 为 2 和 3 的行都被删掉,正确答案只该删 id 为 3 的行。
  • 错误写法:连接条件写成 ON p1.id = p2.id。用例:任意数据 → 每行只与自己配对,p1.id > p2.id 永远不成立,一行都删不掉,重复行原样保留。
  • 错误写法:先用 GROUP BY email 聚合后再删,把 HAVING COUNT(*) > 1 当成删除条件。用例:(1,'a@x.com')(3,'a@x.com') → 该条件筛出的是「有重复的邮箱组」,按它删除会把该组两行全部删光,正确答案是保留一行。

相似题目

题目 难度 考察点
182. 查找重复的电子邮箱 简单 只需查出重复的邮箱值,用 GROUP BYHAVING 即可,不涉及删除语法
181. 超过经理收入的员工 简单 同为自连接,但连接的是外键指向的层级关系而非同组内的兄弟行
178. 分数排名 中等 用窗口函数为每行附加组内名次,是「每组保留第一」的另一条实现路径
184. 部门工资最高的员工 中等 分组内取最大值并保留明细,考察聚合结果如何回连到原表
185. 部门工资前三高的所有员工 困难 每组保留前 N 名,用 PARTITION BYDENSE_RANK,是本题的推广形态
177. 第N高的薪水 中等 全局取第 N 档,用 DISTINCT 压平并列后按偏移量定位
1204. 最后一个能进入巴士的人 中等 自连接用于累加前缀,展示同一技巧如何表达「本行与之前所有行」的关系
610. 判断三角形 简单 纯逐行判定,用 CASE WHEN 派生一列,是无连接无聚合的最基础形态