LeetCode 1107. 每日新用户统计
题目描述
题意分析
Traffic表记录了每个用户每一天做过的行为(activity可能是login、logout、jobs、groups等),要求统计从2019-06-30往前数 90 天这个窗口内,每一天有多少个「首次登录」的用户,输出login_date与user_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为每个用户保留且只保留真实首登日;窗口过滤恰好保留首登日满足要求的用户;最后按该日期分组,所以每个保留用户只给自己的首登日贡献一次计数。查询因此既不会漏计符合条件的新用户,也不会把老用户或非登录行为计入。
解题步骤
- 在子查询中用
WHERE activity = 'login',让后续聚合只观察登录记录。GROUP BY user_id并取MIN(activity_date) AS login_date,把每个用户压缩为一行。- 在外层过滤首登日期。
DATE_SUB('2019-06-30', INTERVAL 90 DAY)为2019-04-01,两端都包含。- 按
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 过滤,练习 WHERE 与 HAVING 之别 |
| 176. 第二高的薪水 | 中等 | 子查询取极值的变体,重点在空结果时必须返回 NULL 而非空表 |