目录

题目描述

1107. 每日新用户统计

题意分析

Traffic 表记录了每个用户每一天做过的行为(activity 可能是 loginlogoutjobsgroups 等),要求统计从 2019-06-30 往前数 90 天这个窗口内,每一天有多少个「首次登录」的用户,输出 login_dateuser_count 两列。

「新用户」这个说法必须翻译准确:一个用户只可能是一天的新用户,也就是他所有登录记录里最早的那一天。同一个用户在后面又登录了很多次,那些记录都不算数。所以问题的核心不是「按天统计登录人数」,而是先把每个用户压缩成一行「首登日期」,再按这个日期分组计数。搞混这两者是本题最常见的读题错误。

第二个要点是筛选范围。表里混杂着非登录行为,activity != 'login' 的行完全不参与首登日期的计算,这个过滤必须发生在求最小值之前

第三个要点是时间窗口的位置。窗口限制的是「首登日期」而不是「原始记录日期」——一个用户可能在窗口内登录过,但他的首登发生在更早,那他就不是这段时间的新用户。所以日期过滤必须发生在求出首登日期之后,作用在聚合结果上。

两个「先后」加起来,天然把查询分成了两层:内层过滤 + 分组求最小,外层再过滤 + 分组计数。

边界:窗口内可能一个新用户都没有,此时该日期不应出现在结果里(不需要补零行);题目未要求排序,任意顺序均可。

解法:SQL 查询建模

核心思路

“新用户”由历史首次登录日期决定,不能直接统计每天出现过多少个登录用户。先构造中间关系:

first_login(user_id, login_date):每个登录过的用户恰好一行,login_date 是该用户所有登录记录的最小日期。

内层先用 activity = 'login' 排除其他行为,再按 user_id 分组取 MIN(activity_date)。顺序不能颠倒:若先限制日期窗口,窗口外首次登录、窗口内再次登录的老用户会被误判为新用户。

中间关系的不变量是 user_id 唯一。外层只需筛选 login_date 落在 [DATE_SUB('2019-06-30', INTERVAL 90 DAY), '2019-06-30'],再按日期 COUNT(*);由于一行对应一个用户,计数不会重复。

正确性可以从用户逐个说明:内层 MIN 为每个用户保留且只保留真实首登日;窗口过滤恰好保留首登日满足要求的用户;最后按该日期分组,所以每个保留用户只给自己的首登日贡献一次计数。查询因此既不会漏计符合条件的新用户,也不会把老用户或非登录行为计入。

解题步骤

  1. 在子查询中用 WHERE activity = 'login',让后续聚合只观察登录记录。
  2. GROUP BY user_id 并取 MIN(activity_date) AS login_date,把每个用户压缩为一行。
  3. 在外层过滤首登日期。DATE_SUB('2019-06-30', INTERVAL 90 DAY)2019-04-01,两端都包含。
  4. login_date 分组,用 COUNT(*) AS user_count 统计新用户数。

关键反例:用户 5 在 2019-03-01 首登,又在 2019-06-21 登录。必须先对完整历史取最小值,得到窗口外的 2019-03-01,然后排除该用户;若先把原表裁到窗口内,他会被错误计入 2019-06-21

代码实现

SELECT
    login_date,
    COUNT(*) AS user_count
FROM (
    SELECT user_id, MIN(activity_date) AS login_date
    FROM Traffic
    WHERE activity = 'login'
    GROUP BY user_id
) first_login
WHERE login_date BETWEEN DATE_SUB('2019-06-30', INTERVAL 90 DAY) AND '2019-06-30'
GROUP BY login_date;

复杂度分析

  • 时间复杂度:逻辑上扫描明细一次并完成两次分组。哈希聚合的期望时间为 $O(n)$;若执行计划使用排序聚合,最坏为 $O(n\log n)$,其中 $n$ 是 Traffic 行数。
  • 空间复杂度:$O(u)$,其中 $u$ 是登录过的不同用户数;实际开销取决于数据库执行计划与可用索引。

关键点总结

  • “首次发生”应先按主体取完整历史的最早时间,再应用统计窗口。
  • 明细条件 activity = 'login' 在聚合前过滤;首登日期条件在聚合后过滤。
  • 子查询保证每个 user_id 只有一行,因此外层 COUNT(*) 就是用户数,不需要 DISTINCT
  • 日期区间两端都要明确:下界防止旧用户进入,上界防止未来日期进入。
  • 本题只需要最早日期,MIN 比窗口排名更直接;只有需要携带首登记录的其他列时,才考虑 ROW_NUMBER()

易错点总结

  • 直接按登录日期统计 COUNT(DISTINCT user_id) 得到的是日活登录人数,不是首登人数。一个老用户会在多个日期重复出现。
  • 在取 MIN 前过滤 90 天窗口。反例中用户 5 会把窗口内的再次登录伪装成首次登录。
  • 在过滤 activity = 'login' 前取 MIN(activity_date)。若用户先做 jobs、后登录,非登录日期会被误当成首登日。
  • 忘记 GROUP BY user_id 会得到全表唯一的最早登录日期,而不是每个用户的首登日期。
  • 只写日期下界会纳入 2019-06-30 之后的数据;完整条件应同时限制上下界。
  • 若内层不能保证一人一行,外层 COUNT(*) 就会重复计数;这里的唯一性来自 GROUP BY user_id

相似题目

题目 难度 考察点
1126. 查询活跃业务 中等 同为「先聚合出基准值再回过头筛选明细」,基准是每类事件的平均出现次数
197. 上升的温度 简单 日期运算的入门题,用 DATEDIFF 关联相邻两天而非划定窗口
185. 部门工资前三高的所有员工 困难 分组内取前 N,MIN 换成排名函数,处理并列是额外难点
184. 部门工资最高的员工 中等 分组取极值后回连明细表,与本题「先聚合再筛选」的骨架同源
182. 查找重复的电子邮箱 简单 分组后用 HAVING COUNT(*) > 1 过滤,练习 WHEREHAVING 之别
176. 第二高的薪水 中等 子查询取极值的变体,重点在空结果时必须返回 NULL 而非空表