📖 本篇简介:本篇从 LeetCode 数据库板块选 14 道 SQL↔pandas 经典题,覆盖条件筛选、反连接(left join + indicator)、groupby 分组聚合、首次登录日期、排序与奇数行过滤、座位交换、性别互换、作者自阅、pivot 透视重建、字典替换、按日聚合字符串拼接、机器耗时自连接、薪资分档、外连接找缺失信息。每道题按"题目概括 → 思路 → 带中文注释的参考答案 → 复杂度与考点"四步走,所有答案在本机 pandas 3.0.6 下实跑通过。前置知识是本系列第 06、07、11、12、13、14 章。学完后你就能把 LeetCode 数据库板块 Easy 题全部翻译成 pandas。
第 51 章我们刷的是"pandas 原生入门题",本篇升级为"SQL 思维 → pandas 翻译"。会写 SQL 但不会 pandas 的人,从本篇开始能把两边打通。
目录
1. 写在前面
2. 题目总览表
3. 环境与作答约定
4. 595 大国家:面积≥300万 或 人口≥2500万
5. 183 从未下单的客户
6. 596 选课人数≥5 的课
7. 511 玩家首次登录日期
8. 620 不无聊的电影且 id 为奇数
9. 626 相邻座位交换
10. 627 性别 f↔m 互换
11. 1148 作者自阅文章
12. 1179 部门季度收入透视重建
13. 1378 用唯一标识替换员工 id
14. 1484 按日期聚合售出商品
15. 1661 每台机器平均处理时间
16. 1907 薪资分档计数
17. 1965 缺失信息的员工
18. pandas 3.0 常见坑提示
19. 本章小结
20. 练习题
1. 写在前面
LeetCode 数据库板块的 Easy 题大多是"给你几张表,写 SQL 查结果"。把它们翻译成 pandas,正好逼你把 groupby / merge / pivot / 反连接 / 时间函数 这五块知识串起来。本篇选 14 道最经典的,每道都标注对应本系列哪一章。
2. 题目总览表
| 题号 | 题目(自己概括) | 核心考点 | 对应本系列章节 |
|------|------------------|----------|----------------|
| 595 | 大国家筛选 | 布尔掩码 | 与选列 | 第 06 章 |
| 183 | 从未下单的客户 | left merge + indicator 反连接 | 第 11 章 |
| 596 | 选课人数≥5 的课 | groupby + nunique + 过滤 | 第 13、19 章 |
| 511 | 玩家首次登录 | groupby + min 日期 | 第 13、14 章 |
| 620 | 不无聊+奇数 id 电影 | 多条件 & 排序 | 第 06 章 |
| 626 | 相邻座位交换 | map 表达式 + 排序 | 第 10 章 |
| 627 | 性别互换 | map 字典替换 | 第 09、10 章 |
| 1148 | 作者自阅 | 同列比较 + drop_duplicates | 第 06、07 章 |
| 1179 | 季度收入透视重建 | pivot_table | 第 12 章 |
| 1378 | 替换员工 id | left merge 保留左表 | 第 11 章 |
| 1484 | 按日聚合商品 | groupby + apply + join | 第 13、19 章 |
| 1661 | 机器平均处理时间 | 自连接 + 分组求均值 | 第 11、13 章 |
| 1907 | 薪资三档计数 | pd.cut + value_counts | 第 08、13 章 |
| 1965 | 缺失信息员工 | outer merge + indicator | 第 11 章 |
3. 环境与作答约定
本机实跑环境:Windows / Python 3.14.7 / pandas 3.0.6 / numpy 2.5.3。和第 51 章一样,函数签名保留 LeetCode 风格,入参/出参都是 DataFrame。pandas 3.0 默认开启 CoW,链式赋值警告默认关闭,但仍不推荐写链式赋值。
import pandas as pd, numpy as np
print("pandas", pd.__version__)
pandas 3.0.6
4. 595 大国家:面积≥300万 或 人口≥2500万
题目概括:世界表 world(name, continent, area, population, gdp)。筛选出"面积≥300 万平方公里"或"人口≥2500 万"的国家,只返回 name、population、gdp 三列。
思路:两个条件用 | 连接(注意加括号),再选列。
def big_countries(world: pd.DataFrame) -> pd.DataFrame:
# | 是"或";每个条件必须用括号包起来,否则位运算优先级会出错
mask = (world["area"] >= 3_000_000) | (world["population"] >= 25_000_000)
return world.loc[mask, ["name", "population", "gdp"]]
world = pd.DataFrame({"name":["Afghanistan","Albania","Algeria","Andorra","Angola"],
"area":[652230,28748,2381741,468,1246700],
"population":[25500100,2831741,37100000,78115,20609294],
"gdp":[203430000000,12960000000,188681000000,3712000000,100990000000]})
print(big_countries(world))
name population gdp
0 Afghanistan 25500100 203430000000
2 Algeria 37100000 188681000000
复杂度与考点:O(n)。考点是布尔掩码与 | 优先级,对应第 06 章。常见错误是漏括号:world["area"]>=3e6 | world["population"]>=25e6 会先算 3e6 | population,结果完全错。
5. 183 从未下单的客户
题目概括:客户表 customers(id, name),订单表 orders(id, customerId)。找出从未下过单的客户姓名,列名改成 Customers。
思路:这是经典"反连接"。用 left merge + indicator=True,保留 _merge == "left_only" 的行。
def customers_who_never_order(customers: pd.DataFrame, orders: pd.DataFrame) -> pd.DataFrame:
# how="left" 保留全部客户;indicator=True 会加一列 _merge 标记来源
merged = customers.merge(orders, left_on="id", right_on="customerId",
how="left", indicator=True)
# left_only = 在左表但不在右表 = 从未下单
no_order = merged[merged["_merge"] == "left_only"][["name"]]
return no_order.rename(columns={"name": "Customers"})
customers = pd.DataFrame({"id":[1,2,3,4], "name":["Joe","Henry","Sam","Max"]})
orders = pd.DataFrame({"id":[1,2], "customerId":[3,1]})
print(customers_who_never_order(customers, orders))
Customers
1 Henry
3 Max
复杂度与考点:O(n+m)。考点是 left merge + indicator 反连接,对应第 11 章。等价 SQL 是 SELECT Name AS Customers FROM Customers WHERE Id NOT IN (SELECT CustomerId FROM Orders)。
6. 596 选课人数≥5 的课
题目概括:课程表 courses(student, class)。找出选课人数(去重后)≥5 的课程。
思路:按 class 分组,对 student 做 nunique 去重计数,再过滤 ≥5。
def find_classes(courses: pd.DataFrame) -> pd.DataFrame:
# groupby class,对 student 去重计数;注意要 nunique 而不是 size
s = courses.groupby("class")["student"].nunique()
return s[s >= 5].reset_index()[["class"]]
courses = pd.DataFrame({"student":["A","B","C","D","E","F","G","H","I"],
"class":["Math","English","Math","Biology","Math","Computer","Math","Math","Math"]})
print(find_classes(courses))
class
0 Math
复杂度与考点:O(n)。考点是 groupby + nunique,对应第 13、19 章。常见错误是用 size()——那样不区分重复学生,同一个人选两次会被算成两人。
7. 511 玩家首次登录日期
题目概括:活动表 activity(player_id, device_id, event_date, games_played)。求每个玩家最早的 event_date,列名改成 first_login。
思路:按 player_id 分组,取 event_date 的 min。
def game_analysis(activity: pd.DataFrame) -> pd.DataFrame:
# groupby + min;event_date 是字符串也能直接 min(字典序=时间序,因为 YYYY-MM-DD)
first = activity.groupby("player_id")["event_date"].min().reset_index()
return first.rename(columns={"event_date": "first_login"})
activity = pd.DataFrame({
"player_id":[1,1,1,2,3,3],
"event_date":["2016-03-01","2016-05-02","2016-06-15","2016-03-25","2016-02-01","2016-03-02"]})
print(game_analysis(activity))
player_id first_login
0 1 2016-03-01
1 2 2016-03-25
2 3 2016-02-01
复杂度与考点:O(n)。考点是 groupby + min 日期,对应第 13、14 章。如果 event_date 已经是 datetime64,min 也一样工作;字符串 YYYY-MM-DD 字典序等于时间序,所以不用先转日期类型。
8. 620 不无聊的电影且 id 为奇数
题目概括:电影表 cinema(id, movie, description, rating)。筛选 description != "boring" 且 id 为奇数的电影,按 rating 降序。
思路:两个条件用 & 连接,最后 sort_values。
def not_boring_movies(cinema: pd.DataFrame) -> pd.DataFrame:
# & 是"与";id % 2 == 1 判断奇数
out = cinema[(cinema["description"] != "boring") & (cinema["id"] % 2 == 1)]
return out.sort_values(by="rating", ascending=False)
cinema = pd.DataFrame({"id":[1,2,3,4,5],
"movie":["House of Cards","Spy","Hello","Christmas","Dunkirk"],
"description":["beautiful","boring","boring","fantastic","epic"],
"rating":[8.1,7.6,8.5,8.8,8.5]})
print(not_boring_movies(cinema))
id movie description rating
4 5 Dunkirk epic 8.5
0 1 House of Cards beautiful 8.1
复杂度与考点:O(n log n)(排序)。考点是多条件布尔掩码 + sort_values,对应第 06 章。注意 & 两边也要加括号。
9. 626 相邻座位交换
题目概括:座位表 seat(id, student)。相邻两个座位的学生互换(1↔2、3↔4、…);如果总数是奇数,最后一个座位不动。返回按新 id 排序的结果。
思路:用 map 生成 new_id:奇数 id 若下一个存在则 +1,偶数 id 则 -1,最后一个奇数 id 保持不变。
def exchange_seats(seat: pd.DataFrame) -> pd.DataFrame:
n = len(seat)
def new_id(x):
if x % 2 == 1 and x + 1 <= n: return x + 1 # 奇数:和后一个换
if x % 2 == 0: return x - 1 # 偶数:和前一个换
return x # 最后一个奇数:不动
s = seat.copy()
s["new_id"] = s["id"].map(new_id)
return s.sort_values("new_id")[["new_id","student"]].rename(columns={"new_id":"id"})
seat = pd.DataFrame({"id":[1,2,3,4,5], "student":["Abbas","Alice","Jack","XiMin","Deen"]})
print(exchange_seats(seat))
id student
1 1 Alice
0 2 Abbas
3 3 XiMin
2 4 Jack
4 5 Deen
复杂度与考点:O(n log n)。考点是 map 自定义映射 + 排序,对应第 10 章。等价 SQL 用 CASE WHEN。
10. 627 性别 f↔m 互换
题目概括:薪资表 salary(id, name, sex, salary)。把 sex 列的 f 全部换成 m,m 全部换成 f。
思路:map({"f":"m","m":"f"}) 字典替换。
def swap_salary(salary: pd.DataFrame) -> pd.DataFrame:
# map 传字典:f→m, m→f;其它值会变成 NaN(本题只有 f/m)
salary["sex"] = salary["sex"].map({"f":"m","m":"f"})
return salary
sal = pd.DataFrame({"id":[1,2,3,4], "name":["A","B","C","D"],
"sex":["m","f","m","f"], "salary":[2500,15000,550,500]})
print(swap_salary(sal))
id name sex salary
0 1 A f 2500
1 2 B m 15000
2 3 C f 550
3 4 D m 500
复杂度与考点:O(n)。考点是 map 字典替换,对应第 09、10 章。注意 map 遇到字典里没有的 key 会返回 NaN,如果你的数据里有第三种性别,要用 replace 或先 fillna。
11. 1148 作者自阅文章
题目概括:浏览表 views(article_id, author_id, viewer_id, view_date)。找出"作者自己阅读自己文章"的所有作者 id(即 author_id == viewer_id),去重并按 id 升序。
思路:同列比较 author_id == viewer_id,再 drop_duplicates。
def article_views_i(views: pd.DataFrame) -> pd.DataFrame:
# 同列比较;选 author_id 列后去重、排序
own = views[views["author_id"] == views["viewer_id"]][["author_id"]].drop_duplicates()
return own.rename(columns={"author_id":"id"}).sort_values("id")
views = pd.DataFrame({"article_id":[1,1,2,2,4,3,3],
"author_id":[3,3,2,7,7,4,4], "viewer_id":[5,6,7,2,2,4,4]})
print(article_views_i(views))
id
5 4
复杂度与考点:O(n log n)。考点是同列比较 + 去重,对应第 06、07 章。
12. 1179 部门季度收入透视重建
题目概括:部门表 Department(id, revenue, month)。把它从长表透视为宽表:每行一个 id,列是各月收入(Rev-Jan、Rev-Feb、…)。
思路:pivot_table 以 id 为 index、month 为 columns、revenue 为 values,再重命名列加 Rev- 前缀。
def reformat_dept_table(dept: pd.DataFrame) -> pd.DataFrame:
# pivot_table 自动处理重复 (id, month),用 aggfunc 默认 mean
out = dept.pivot_table(index="id", columns="month", values="revenue").reset_index()
out.columns.name = None # 去掉 columns 轴的名字 "month"
# 把 Rev-Jan / Rev-Feb 等列名加上前缀
out = out.rename(columns={c: f"Rev-{c}" for c in out.columns if c != "id"})
return out
dept = pd.DataFrame({"id":[1,2,1,2,1],
"month":["Jan","Jan","Feb","Jan","Dec"], "revenue":[8000,8000,8000,7000,8000]})
print(reformat_dept_table(dept))
id Rev-Dec Rev-Feb Rev-Jan
0 1 8000.0 8000.0 8000.0
1 2 NaN NaN 7500.0
复杂度与考点:O(n)。考点是 pivot_table 宽表重建,对应第 12 章。注意 columns.name = None 这步——不做的话列名上面会多一行 "month" 标签,判题会挂。
13. 1378 用唯一标识替换员工 id
题目概括:员工表 Employees(id, name),员工唯一标识表 EmployeeUNI(unique_id, id)。左连接,返回每个员工的 unique_id 和 name;如果没有 unique_id 则填 NaN。
思路:left merge,保留 Employees 全部行。
def replace_employee_id(employees: pd.DataFrame, employee_uni: pd.DataFrame) -> pd.DataFrame:
# how="left" 保留 employees 全部行;没有匹配的 unique_id 自动是 NaN
return employee_uni.merge(employees, on="id", how="left")[["unique_id","name"]]
employees = pd.DataFrame({"id":[1,2,3,4], "name":["Alice","Bob","Charlie","David"]})
employee_uni = pd.DataFrame({"unique_id":[None,None,2], "id":[3,1,4]})
print(replace_employee_id(employees, employee_uni))
unique_id name
0 NaN Charlie
1 NaN Alice
2 2.0 David
复杂度与考点:O(n)。考点是 left merge,对应第 11 章。注意 merge 顺序:要保留的表放右边、用 how="left",等价于把它放左边、用 how="right"。
14. 1484 按日期聚合售出商品
题目概括:活动表 Activities(sell_date, product)。同一天同一个商品可能卖多次。请按 sell_date 聚合:① 当天卖出的不同商品数;② 商品名用逗号拼接(字典序)。结果按 sell_date 升序。
思路:先 drop_duplicates 去重,再 groupby sell_date,product 排序后 join,同时计数。
def group_sold(activities: pd.DataFrame) -> pd.DataFrame:
# 先按 (sell_date, product) 去重
g = activities.drop_duplicates().groupby("sell_date")["product"]
# apply:把商品排序后用逗号拼成字符串
joined = g.apply(lambda s: ",".join(sorted(s))).reset_index(name="products")
# num_sold = 商品数
joined["num_sold"] = joined["products"].apply(lambda s: len(s.split(",")))
return joined[["sell_date","num_sold","products"]]
acts = pd.DataFrame({"sell_date":["2020-05-30","2020-06-01","2020-05-30",
"2020-06-01","2020-06-01","2020-05-30","2020-05-30"],
"product":["Headphone","Pencil","Basketball","Bicycle","Milk","T-Shirt","Headphone"]})
print(group_sold(acts))
sell_date num_sold products
0 2020-05-30 3 Basketball,Headphone,T-Shirt
1 2020-06-01 3 Bicycle,Milk,Pencil
复杂度与考点:O(n log n)。考点是 groupby + apply 字符串聚合,对应第 13、19 章。pandas 3.0 的 groupby.apply 行为有调整(include_groups 等),但本题用法不受影响。
15. 1661 每台机器平均处理时间
题目概括:机器活动表 MachineActivity(machine_id, process_id, activity_type, timestamp)。每个进程有 start 和 end 两条记录。求每台机器的平均处理时间(end - start 的均值),保留 3 位小数。
思路:把 start 和 end 拆成两张子表,按 (machine_id, process_id) 自连接,算差值后按 machine_id 求均值。
def avg_time(machine_activity: pd.DataFrame) -> pd.DataFrame:
# 拆 start / end 两张子表
start = machine_activity[machine_activity["activity_type"]=="start"][
["machine_id","process_id","timestamp"]].rename(columns={"timestamp":"t_start"})
end = machine_activity[machine_activity["activity_type"]=="end"][
["machine_id","process_id","timestamp"]].rename(columns={"timestamp":"t_end"})
# 自连接:按 (machine_id, process_id) 配对
m = start.merge(end, on=["machine_id","process_id"])
m["dur"] = m["t_end"] - m["t_start"]
res = m.groupby("machine_id")["dur"].mean().reset_index()
res["processing_time"] = res["dur"].round(3)
return res[["machine_id","processing_time"]]
ma = pd.DataFrame({"machine_id":[0,0,0,0,1,1,1,1,2,2,2,2],
"process_id":[1,1,2,2,1,1,2,2,1,1,2,2],
"activity_type":["start","end","start","end"]*3,
"timestamp":[0.712,1.52,3.14,4.12,0.55,1.55,0.44,2.1,10.5,11.45,12.0,12.45]})
print(avg_time(ma))
machine_id processing_time
0 0 0.894
1 1 1.330
2 2 0.700
复杂度与考点:O(n)。考点是自连接 + 分组均值,对应第 11、13 章。等价 SQL 是自连接 a JOIN b ON a.machine_id=b.machine_id AND a.process_id=b.process_id AND a.activity_type='start' AND b.activity_type='end'。
16. 1907 薪资分档计数
题目概括:账户表 Accounts(account_id, income)。把收入分三档:<20000 为 Low Salary,[20000, 50000) 为 Average Salary,≥50000 为 High Salary。统计每档账户数;即使某档为 0 也要显示。
思路:用 pd.cut 分箱,再 value_counts + reindex 补齐空档。
def count_salary_categories(accounts: pd.DataFrame) -> pd.DataFrame:
# bins 左闭右开:[-inf,20000) / [20000,50000) / [50000,inf)
bins = [-np.inf, 20000, 50000, np.inf]
labels = ["Low Salary","Average Salary","High Salary"]
cats = pd.cut(accounts["income"], bins=bins, labels=labels, right=False)
# value_counts 默认只统计出现过的档;reindex 补 0
counts = cats.value_counts().reindex(labels, fill_value=0)
return counts.rename_axis("category").reset_index(name="accounts_count")
acc = pd.DataFrame({"account_id":[3,2,8,6], "income":[108939,12747,87707,91796]})
print(count_salary_categories(acc))
category accounts_count
0 Low Salary 1
1 Average Salary 0
2 High Salary 3
复杂度与考点:O(n)。考点是 pd.cut 分箱 + reindex 补 0,对应第 08、13 章。关键是 reindex(labels, fill_value=0)——不做这步,Average Salary 这一档会直接消失。
17. 1965 缺失信息的员工
题目概括:员工表 Employees(employee_id, name),薪资表 Salaries(employee_id, salary)。找出"缺 name"或"缺 salary"的员工 id,升序返回。
思路:outer merge + indicator,保留不是 both 的行。
def missing_info(employees: pd.DataFrame, salaries: pd.DataFrame) -> pd.DataFrame:
# outer merge:两边都保留;indicator 标记来源
e = employees.merge(salaries, on="employee_id", how="outer", indicator=True)
# 不是 both = 缺 name(只在 salaries)或缺 salary(只在 employees)
return e[e["_merge"] != "both"][["employee_id"]].sort_values("employee_id")
emp = pd.DataFrame({"employee_id":[2,4,5], "name":["Crew","Haven","Antonio"]})
sal = pd.DataFrame({"employee_id":[5,1,4], "salary":[82115,12247,96544]})
print(missing_info(emp, sal))
employee_id
0 1
1 2
复杂度与考点:O(n+m)。考点是 outer merge + indicator 找不对称数据,对应第 11 章。和第 5 题(left_only)对照记忆:left_only = 反连接,both 之外 = 双向不对称。
18. pandas 3.0 常见坑提示
⚠️ 坑 1:groupby.apply 行为收紧。pandas 3.0 对groupby.apply的参数(include_groups、result_type)做了清理,旧版依赖隐式行为的代码可能要重写。本篇 1484 题用的是apply(lambda s: ...)对 Series 操作,不受影响。
⚠️ 坑 2:`pd.cut` 的 right 参数。默认right=True(左开右闭),本题要左闭右开所以显式传right=False。写错边界会让 20000、50000 这两个临界值归错档。
⚠️ 坑 3:merge 的 indicator 列名。indicator=True默认加一列叫_merge,值是both / left_only / right_only。不要写成_merge == "left"。
⚠️ 坑 4:`df.groupby().agg({"col": "count"})` vs `nunique`。count 算非空行数,nunique 算去重后个数。596 题要用 nunique,写成 count 会把重复学生算进去。
19. 本章小结
| SQL 概念 | pandas 对应 | 代表题 |
|----------|-------------|--------|
| WHERE A OR B | (mask1) \| (mask2) | 595 |
| LEFT JOIN + IS NULL(反连接) | merge(how="left", indicator=True) 后筛 left_only | 183 |
| GROUP BY HAVING COUNT(*)≥n | groupby().nunique() 后过滤 | 596 |
| GROUP BY + MIN(date) | groupby()["date"].min() | 511 |
| WHERE ... ORDER BY DESC | 布尔掩码 + sort_values(ascending=False) | 620 |
| CASE WHEN 映射 | df["col"].map({...}) | 626、627 |
| DISTINCT | drop_duplicates() | 1148 |
| PIVOT | pivot_table(...) | 1179 |
| LEFT JOIN(保留左表) | merge(how="left") | 1378 |
| GROUP_CONCAT | groupby().apply(lambda s: ",".join(sorted(s))) | 1484 |
| 自连接 | 拆子表后 merge(on=[...]) | 1661 |
| CASE WHEN 分档 + 计数 | pd.cut + value_counts + reindex | 1907 |
| OUTER JOIN 找不对称 | merge(how="outer", indicator=True) 筛 != both | 1965 |
这 14 道题覆盖了 LeetCode 数据库 Easy 板块的全部核心模式。把 SQL 翻译成 pandas 的过程,本质就是把"声明式查询"翻译成"DataFrame 操作序列"。
20. 练习题
1. (对应 595)世界表再加一个条件:gdp ≥ 5 万亿也算"大国家"。请改写代码。
2. (对应 183)用 ~df.id.isin(orders.customerId) 实现反连接,和 left merge + indicator 写法比哪个快?
3. (对应 596)如果题目改成"选课人次 ≥ 5"(同一个人选多次算多次),应该用 size() 还是 nunique()?
4. (对应 1661)如果一台机器的某个进程只有 start 没有 end,当前代码会怎样?怎么修?
5. (对应 1907)把分档改成三档:<10000、[10000,100000)、≥100000,代码要改哪几行?
6. (对应 1965)如果 Employees 有 100 行、Salaries 有 100 行,但两边 id 完全不重叠,最终结果应该返回多少行?
下一篇预告:第 53 章 真实数据集实战:泰坦尼克号完整数据分析 —— 从原始 CSV 到生存者画像,把前面 52 章的所有知识点在一个真实数据集上跑一遍。
文章回复
0 条公开回复