1. 项目概述:一次面试题引发的技术深度思考

最近在准备Waymo数据科学岗位的面试,刷到一道非常经典的LeetCode 1045题。这道题本身并不复杂,但它在SQL和Pandas两种主流数据处理工具上的解法,却像一面镜子,清晰地映照出两种不同数据处理哲学的核心差异。很多朋友在刷题时,可能只是机械地记住了“用 GROUP BY HAVING ”或者“用 groupby().nunique() ”的套路,但很少去深究:为什么这道题会这样设计? HAVING 子句和 groupby().nunique() 在底层逻辑上究竟有何不同?在实际的数据科学工作中,面对海量的自动驾驶传感器数据,我们又该如何根据场景在这两种方案中做出最优选择?

这道题的核心是找出那些“购买了所有产品”的客户。听起来像是一个简单的集合包含问题,但在关系型数据库和内存计算框架中,它的实现路径和性能考量截然不同。今天,我就结合自己处理大规模数据集(尤其是类似Waymo Open Dataset这种多模态时序数据)的经验,来彻底拆解这道题。我们不止于AC(Accept,通过),更要理解每一种解法背后的“为什么”,以及如何将这种理解应用到真实的数据科学流水线中。无论你是正在备战面试,还是希望提升自己的数据分析功底,相信这篇深度解析都能给你带来新的启发。

2. 问题本质与两种技术路径的哲学对比

2.1 LeetCode 1045 题意深度解析

我们先抛开代码,用最直白的业务语言理解一下这道题。假设你是一家电商平台的数据分析师,手里有一张 Customer 表,记录了客户ID ( customer_id ) 和产品ID ( product_key )。题目要求你找出那些购买了平台上 所有产品 的客户。

这里的关键在于“所有产品”这个集合。在真实的业务场景中,“所有产品”的定义可能是动态的。一种情况是,有一个静态的产品总表,所有产品是已知且固定的;另一种更常见的情况是,“所有产品”就是当前 Customer 表中出现过的所有不重复的 product_key 的集合。LeetCode 1045通常指的是后者,即找出那些购买的产品种类数等于全表产品种类总数的客户。

这立刻引出了两个核心计算:

  1. 全局产品总数 :计算整个数据集中有多少种不同的产品。
  2. 每个客户购买的产品种类数 :计算每个客户购买了多少种不同的产品。

最后,筛选出“客户购买种类数”等于“全局产品总数”的客户。这个“筛选”动作,在SQL和Pandas的世界里,走上了两条不同的技术路径。

2.2 SQL的“声明式”哲学与HAVING子句

SQL(Structured Query Language)是一种声明式查询语言。你的任务是告诉数据库“你想要什么”,而不是“如何一步步去获取”。数据库的查询优化器会帮你决定最优的执行计划(比如选择哪个索引、采用哪种连接算法)。

HAVING 子句是这种哲学的典型体现。它总是与 GROUP BY 子句相伴出现,用于对 分组聚合后的结果集 进行过滤。你可以把它理解为针对“分组”这个临时表的 WHERE 子句。但关键在于, WHERE 是在分组 对原始行进行过滤,而 HAVING 是在分组 对聚合结果(如 COUNT , SUM , AVG 等)进行过滤。

对于LeetCode 1045,SQL的思路非常符合直觉:

  1. 声明目标 :“我想知道每个客户购买了多少种不同的产品。”
  2. 执行分组 :使用 GROUP BY customer_id ,将数据按客户分组。
  3. 进行计算 :在每组内,使用 COUNT(DISTINCT product_key) 计算唯一产品数。
  4. 声明过滤条件 :“我只想要那些唯一产品数等于总产品数的客户。” 这个“等于总产品数”的条件,无法在分组前用 WHERE 确定,必须在分组计算出每个客户的数量后,才能进行判断。这就是 HAVING 的用武之地。
-- 经典解法
SELECT customer_id
FROM Customer
GROUP BY customer_id
HAVING COUNT(DISTINCT product_key) = (
    SELECT COUNT(DISTINCT product_key)
    FROM Customer
);

这个查询完美体现了SQL的声明性:我们描述了最终结果的特征,数据库引擎负责找出实现路径。

2.3 Pandas的“命令式”哲学与GroupBy操作

Pandas是一个基于Python的库,其操作模式更接近命令式或过程式编程。你需要通过一系列明确的指令(方法调用)来操作一个名为 DataFrame 的内存中的表格对象,一步步地转换数据,直到得到你想要的结果。

Pandas的 groupby() 操作与SQL的 GROUP BY 在概念上相似,但使用体验和底层机制不同。它返回的是一个 DataFrameGroupBy 对象,你可以将它看作一个“惰性”对象,它知道如何对数据进行分组,但直到你对其应用一个聚合函数(如 .sum() , .mean() , .nunique() )时,计算才会真正发生。

.nunique() 是Pandas中用于计算分组内唯一值数量的方法,它直接对应了SQL中的 COUNT(DISTINCT ...) 。对于LeetCode 1045,Pandas的命令式步骤非常清晰:

  1. 计算全局总数 total_products = df['product_key'].nunique()
  2. 分组并计算客户购买数 customer_counts = df.groupby('customer_id')['product_key'].nunique()
  3. 过滤结果 result = customer_counts[customer_counts == total_products].index.tolist()
import pandas as pd

def find_customers(df: pd.DataFrame) -> pd.DataFrame:
    # 步骤1:计算总产品数
    total_products = df['product_key'].nunique()
    
    # 步骤2:按客户分组,计算唯一产品数
    customer_product_count = df.groupby('customer_id')['product_key'].nunique()
    
    # 步骤3:筛选出购买数等于总产品数的客户
    result_ids = customer_product_count[customer_counts == total_products].index
    
    # 步骤4:格式化为题目要求的DataFrame
    result_df = pd.DataFrame({'customer_id': result_ids})
    return result_df

这个过程就像在厨房做菜:你先数一下一共有几种食材(总产品数),然后把食材按菜篮分好(分组),数每个菜篮里有几种不同的食材(分组计数),最后只拿出那些菜篮里食材种类和总种类一样的篮子(过滤)。

注意 :这里有一个非常重要的细节。在SQL的 HAVING 子句中,我们直接将聚合函数 COUNT(DISTINCT ...) 的结果与一个标量子查询进行比较。而在Pandas中,我们分成了两步:先通过 groupby().nunique() 得到一个 Series (客户ID为索引,购买数为值),然后再将这个 Series 与一个标量 total_products 进行比较。这种“先计算,后比较”的模式,是命令式编程的典型特征,它给了我们中间结果,也带来了更大的灵活性(例如,你可以轻松地将这个 Series 保存下来用于其他分析)。

3. 核心细节解析与性能考量

3.1 SQL HAVING子句的执行机制与优化

理解 HAVING 的执行机制,对于写出高效的SQL至关重要。当我们执行上述包含 HAVING 和子查询的语句时,数据库优化器可能会生成多种执行计划。一个高效的执行计划可能是这样的:

  1. 计算标量子查询 :首先执行 (SELECT COUNT(DISTINCT product_key) FROM Customer) ,这是一个全局的聚合计算,只需要扫描一次表,计算出唯一产品总数,比如结果是 N
  2. 构建哈希表进行分组 :扫描 Customer 表,按 customer_id 构建哈希表。对于每一行,将其 product_key 添加到一个与该 customer_id 对应的“唯一值集合”中(在内存或临时空间中)。这个过程在扫描时就在逐步计算每个客户的唯一产品数。
  3. 应用HAVING过滤 :当全表扫描完毕,每个客户的唯一产品数也计算完成。此时,直接对比每个客户的计数是否等于第一步计算出的 N ,将符合条件的 customer_id 输出。

这里的关键优化点在于 COUNT(DISTINCT product_key) 。在分布式数据库或大数据引擎(如Hive, Spark SQL)中, COUNT(DISTINCT) 是一个“重量级”操作,因为它需要在网络间传输大量数据去重。如果 product_key 的基数(唯一值数量)非常大,这个子查询和 HAVING 中的计算都可能成为瓶颈。

优化思路

  • 预先计算总产品数 :如果总产品列表相对稳定,可以将其作为一个常量或从维度表中预先查询出来,避免在每次查询时都进行全表 COUNT(DISTINCT) 扫描。
  • 使用窗口函数(高级解法) :在某些数据库(如PostgreSQL, MySQL 8.0+)中,可以使用窗口函数进行更优雅(有时也更高效)的求解。思路是先计算每个客户购买的唯一产品数,同时计算总的产品唯一数,然后直接筛选。
    WITH customer_stats AS (
        SELECT 
            customer_id,
            COUNT(DISTINCT product_key) OVER (PARTITION BY customer_id) as cust_cnt,
            COUNT(DISTINCT product_key) OVER () as total_cnt
        FROM Customer
    )
    SELECT DISTINCT customer_id
    FROM customer_stats
    WHERE cust_cnt = total_cnt;
    
    这种方法可能只需要一次对表的扫描,并利用窗口函数同时完成分组聚合和全局聚合。

3.2 Pandas GroupBy Nunique的内存与计算策略

Pandas的操作完全在内存中进行,因此其性能主要受数据大小和操作方式的影响。 df.groupby('customer_id')['product_key'].nunique() 这行代码背后,Pandas会执行以下操作:

  1. 索引排序/哈希 groupby 默认会先对 customer_id 进行排序( sort=True ),以便将相同ID的行分组在一起。对于非数值型或大数据集,这会是一个 O(n log n) 的操作。你可以通过设置 sort=False 来提升速度,前提是你不关心输出结果的顺序。
  2. 迭代与集合去重 :对于每个分组,Pandas会遍历该分组内的所有 product_key 值,并使用一个哈希集合( set )来记录唯一值,最后返回集合的大小。这个过程的时间复杂度大致是 O(n) ,但需要额外的内存来存储这些临时集合。

当数据量非常大,接近或超过可用内存时,原生的Pandas groupby 操作会变得非常缓慢甚至导致内存溢出( MemoryError )。

优化思路

  • 使用更高效的数据类型 :如果 customer_id product_key 是整数或可以映射为整数,使用 np.int32 等类型可以大幅减少内存占用和计算时间。
  • 分块处理 :对于超大数据集,可以使用 pandas.read_csv(chunksize=...) 进行分块读取和处理,但 groupby 操作在分块场景下会变得复杂,通常需要自己维护一个跨块的字典来累加结果。
  • 借助Dask或Modin :这些库提供了类似Pandas的API,但支持并行计算和核外运算(处理比内存大的数据)。你可以将 df 转换为 dask.dataframe ,然后使用几乎相同的 groupby 语法。
    import dask.dataframe as dd
    ddf = dd.from_pandas(df, npartitions=4) # 分为4个分区
    total_products = ddf['product_key'].nunique().compute()
    customer_counts = ddf.groupby('customer_id')['product_key'].nunique().compute()
    
  • 利用 value_counts 的替代方案 :对于某些特定情况,如果只是想计算每个客户购买了多少种产品(而不关心具体是什么产品),且数据是“客户-产品”的购买记录(没有重复购买同一产品的记录),那么 df.groupby('customer_id').size() 会比 nunique() 更快,因为它避免了去重操作。但本题中,同一个客户购买同一产品可能有多次记录(尽管题目示例数据可能没有),所以必须用 nunique()

3.3 场景化选型:SQL vs. Pandas

理解了底层机制,我们就能在真实工作中做出明智的选择:

  • 选择SQL的场景

    • 数据存储在数据库中 :这是最直接的理由。数据在MySQL、PostgreSQL、BigQuery等数据库中,直接使用SQL查询是最自然、最高效的方式,避免了数据移动的成本。
    • 数据量极大 :数据库引擎为处理海量数据而优化,拥有索引、查询优化器、并行处理等能力。对于TB/PB级数据,在数据库内完成聚合过滤,再将少量结果导出,是标准做法。
    • 需要实时或频繁查询 :如果这是一个需要嵌入到报表或应用中的查询,将其作为SQL视图或存储过程封装在数据库端,性能和维护性更好。
    • 数据一致性要求高 :数据库的事务特性可以保证查询时看到的数据是一致的快照。
  • 选择Pandas的场景

    • 数据探索与分析 :你已经将数据(或数据的样本/聚合结果)加载到内存中,需要进行快速的、交互式的探索。Pandas的链式操作和丰富的数据处理函数(如 pivot_table , merge )非常灵活。
    • 复杂的数据转换流水线 :你的分析涉及多个步骤,包括自定义函数、复杂的条件逻辑、与其他Python库(如 scikit-learn , statsmodels )的集成。Pandas在Python生态中无缝衔接。
    • 数据量适中(通常小于内存) :这是Pandas发挥最佳性能的前提。现代笔记本电脑通常有16-32GB内存,处理几GB的数据集是Pandas的舒适区。
    • 原型开发与快速验证 :在将最终逻辑固化到SQL或生产系统之前,用Pandas快速验证业务逻辑和算法是否正确,迭代速度更快。

Waymo数据科学面试的启示 :Waymo处理的是自动驾驶车辆产生的海量传感器数据(激光雷达、摄像头、毫米波雷达等)。这类数据的分析流水线通常是混合架构:原始数据存储在分布式文件系统(如Google Cloud Storage)或数据库(如BigQuery)中,使用SQL进行大规模的数据清洗、聚合和特征提取。然后,将聚合后的、规模较小的特征数据集或样本数据加载到内存(如Pandas DataFrame或NumPy数组)中,供数据科学家进行深入的统计分析、模型训练和可视化。因此,面试官通过这道题,考察的正是你是否能清晰地理解这两种工具在不同层级数据流水线中的定位和优劣。

4. 从解题到实战:构建健壮的数据处理逻辑

4.1 边界条件与异常处理

无论是SQL还是Pandas,写出能处理各种边角情况的健壮代码,是数据科学家必备的素质。对于LeetCode 1045,我们需要考虑:

  1. 空表或NULL值

    • SQL :如果 Customer 表为空,子查询 COUNT(DISTINCT product_key) 会返回0。 HAVING 子句会比较每个客户的分组计数与0,由于没有分组,最终结果为空集。这是符合逻辑的。但如果 product_key 存在 NULL 值, COUNT(DISTINCT product_key) 会忽略 NULL 。你需要明确业务逻辑: NULL 是否代表一种特殊产品?通常不,所以忽略是合理的。
    • Pandas df['product_key'].nunique() 默认会忽略 NaN 。如果整列为 NaN 或数据框为空, nunique() 返回0。 groupby().nunique() 同样会忽略分组内的 NaN 。你需要确保这与业务定义一致。
  2. 重复购买记录 :题目表可能包含同一个客户购买同一个产品的多条记录。这正是我们必须使用 COUNT(DISTINCT ...) .nunique() 的原因,它们会自动去重。如果错误地使用了 COUNT(*) .size() ,就会得到错误的结果。

  3. 客户数量极大 :当客户数量达到百万甚至千万级时:

    • SQL :确保 customer_id 字段上有索引。 GROUP BY 操作可以利用索引进行高效排序或哈希聚合。没有索引的全表扫描在数据量大时会非常慢。
    • Pandas :内存可能成为瓶颈。考虑是否真的需要将所有客户ID和去重后的产品集合同时保存在内存中。对于仅需判断“是否等于总数”的场景,其实有更节省内存的算法(例如,可以边分组计算边与总数比较,但Pandas的API是批量的)。这时可能需要回到SQL处理,或者使用迭代器与字典手动实现。

实操心得 :在真实项目中,我总是在数据处理脚本的开头或关键聚合函数周围添加断言( assert )或日志,来验证中间结果。例如,在Pandas中,计算 total_products 后,可以打印一下它的值,确保它不是0或异常大。在SQL开发中,先运行子查询看看结果是否正确,再组装完整查询,是一个好习惯。

4.2 代码可读性与维护性

写出机器能执行的代码容易,写出人能轻松理解的代码难。

  • SQL可读性技巧

    • 使用CTE(公用表表达式) :将子查询用 WITH 子句定义,可以极大地提升复杂查询的可读性。
      WITH total_products AS (
          SELECT COUNT(DISTINCT product_key) AS cnt
          FROM Customer
      ),
      customer_products AS (
          SELECT 
              customer_id,
              COUNT(DISTINCT product_key) AS product_count
          FROM Customer
          GROUP BY customer_id
      )
      SELECT cp.customer_id
      FROM customer_products cp
      CROSS JOIN total_products tp
      WHERE cp.product_count = tp.cnt;
      
      这样,逻辑步骤一目了然,也便于后续单独调试每个CTE。
    • 赋予有意义的别名 COUNT(DISTINCT product_key) AS unique_products_bought 比单纯的 cnt 要好得多。
  • Pandas可读性技巧

    • 避免过长的链式调用 :虽然链式调用很酷,但超过3个操作后,调试和阅读就会变得困难。将中间结果赋值给有意义的变量名。
      # 更清晰的做法
      grouped = df.groupby('customer_id')
      unique_counts = grouped['product_key'].nunique()
      total_unique = df['product_key'].nunique()
      result_series = unique_counts[unique_counts == total_unique]
      result_ids = result_series.index.tolist()
      
    • 添加注释 :特别是对于复杂的 groupby 操作或 apply 自定义函数,简要注释其目的。

4.3 性能测试与对比

对于重要的数据流水线,进行简单的性能测试是值得的。我们可以用Python的 timeit 模块来比较不同Pandas写法的效率,或者用 EXPLAIN ANALYZE (在支持它的数据库中)来查看SQL查询计划。

Pandas性能小实验

import pandas as pd
import numpy as np
import time

# 创建一个较大的测试数据集
np.random.seed(42)
n_rows = 1_000_000
n_customers = 10000
n_products = 1000

df = pd.DataFrame({
    'customer_id': np.random.randint(1, n_customers+1, n_rows),
    'product_key': np.random.randint(1, n_products+1, n_rows)
})

# 方法1:标准写法
start = time.time()
total_products = df['product_key'].nunique()
customer_counts = df.groupby('customer_id')['product_key'].nunique()
result1 = customer_counts[customer_counts == total_products].index.tolist()
time1 = time.time() - start
print(f"标准方法耗时: {time1:.4f}秒, 找到{len(result1)}个客户")

# 方法2:使用sort=False (如果不在意顺序)
start = time.time()
total_products = df['product_key'].nunique()
customer_counts = df.groupby('customer_id', sort=False)['product_key'].nunique()
result2 = customer_counts[customer_counts == total_products].index.tolist()
time2 = time.time() - start
print(f"groupby(sort=False)耗时: {time2:.4f}秒, 找到{len(result2)}个客户")
print(f"速度提升: {(time1-time2)/time1*100:.1f}%")

这个小实验可以直观地告诉你 sort=False 参数在数据量大时的价值。在真实场景中,你还可以测试不同的分组列数据类型(字符串 vs. 整数)带来的性能差异。

5. 常见问题与排查技巧实录

在实际操作中,无论是写SQL还是Pandas,都会遇到一些典型的“坑”。这里记录几个我踩过或见别人踩过的坑,以及排查思路。

5.1 SQL常见错误与排查

  1. 错误:在WHERE子句中使用聚合函数

    -- 错误示例
    SELECT customer_id
    FROM Customer
    WHERE COUNT(DISTINCT product_key) = (SELECT ...) -- 这里会报错
    GROUP BY customer_id;
    

    错误信息 :通常会提示“Invalid use of group function”。 原因与解决 WHERE 子句在 GROUP BY 之前执行,此时还没有分组,自然无法使用 COUNT 等聚合函数。必须将过滤条件移到 HAVING 子句中。

  2. 错误:HAVING子句中引用了非聚合列或未在GROUP BY中的列

    -- 错误示例
    SELECT customer_id, customer_name
    FROM Customer
    GROUP BY customer_id
    HAVING customer_name LIKE 'A%'; -- customer_name未在GROUP BY中
    

    原因与解决 :在 GROUP BY 聚合后,每个分组只输出一行。对于 customer_id 相同的多行, customer_name 可能有多个值,数据库不知道在 HAVING 中该用哪一个。标准SQL要求 SELECT 列表中非聚合的列必须出现在 GROUP BY 中。要么将 customer_name 加入 GROUP BY ,要么在 HAVING 中使用聚合函数(如 MAX(customer_name) ),要么在子查询中先处理好。

  3. 性能问题:COUNT(DISTINCT) 在大数据上慢 排查 :使用 EXPLAIN 命令查看查询计划。关注是否有全表扫描( FULL TABLE SCAN )而不是索引扫描( INDEX SCAN )。 解决思路

    • product_key customer_id 创建合适的索引。对于本例,一个覆盖索引 (customer_id, product_key) 可能对查询最有帮助,因为数据库可能直接从索引中获取数据而无需回表。
    • 考虑是否能用近似计数(如 APPROX_COUNT_DISTINCT ,在Hive/Spark SQL或一些数据库中可用)替代精确计数,以换取性能大幅提升。
    • 如果总产品数不常变化,将其物化(Materialized)到一个单独的统计表中,查询时直接关联。

5.2 Pandas常见错误与排查

  1. 错误:误用 size() 代替 nunique()

    # 错误示例:如果存在重复购买记录,这将得到错误结果
    customer_counts = df.groupby('customer_id')['product_key'].size()
    

    现象 :结果中客户的购买数量可能远大于实际购买的产品种类数。 排查 :检查原始数据中是否存在 (customer_id, product_key) 的重复行。使用 df.duplicated(subset=['customer_id', 'product_key']).sum() 查看重复数量。始终明确你的业务指标是“购买次数”还是“购买品类数”。

  2. 错误:GroupBy后索引丢失导致后续操作失败

    customer_counts = df.groupby('customer_id')['product_key'].nunique()
    # customer_counts 是一个Series,索引是customer_id
    # 如果想将其转换回一个包含customer_id列的DataFrame,需要重置索引
    result_df = customer_counts.reset_index(name='product_count') # 正确
    # 如果直接 pd.DataFrame(customer_counts),会得到奇怪的列名
    

    技巧 :时刻关注你操作对象的类型( DataFrame 还是 Series )和索引。 groupby().agg() 默认会将分组列设为索引。使用 .reset_index() 是将其变回普通列的常用方法。

  3. 内存溢出(MemoryError) 现象 :在处理大型 DataFrame 进行 groupby 操作时,程序崩溃并报 MemoryError 排查与解决

    • 检查数据大小 df.info() 查看内存占用, df.shape 查看行列数。
    • 优化数据类型 :使用 df['col'].astype('category') 将低基数字符串列转为分类类型,使用 df['col'].astype('int32') 等向下转换数值类型。
    • 采样分析 :先用 df.sample(frac=0.1) 抽取10%的数据进行逻辑验证。
    • 分批处理/核外计算 :如前所述,考虑使用Dask,或者手动将数据按 customer_id 的哈希值分片处理。
  4. 性能瓶颈:GroupBy速度慢 排查 :使用 %timeit line_profiler 工具定位慢的具体是哪个步骤。 解决思路

    • 设置 sort=False :如果不需要结果按分组键排序,这是最简单的提速方法。
    • 使用更高效的聚合函数 :有些聚合操作有更快的实现。例如,对于求和、均值, np.sum 可能比内置的 .sum() 快。但 .nunique() 通常已经优化过。
    • 避免在 groupby 后使用 apply 自定义函数 apply 是通用的,但通常很慢。如果能用内置的聚合函数(如 sum , mean , nunique )或向量化操作实现,性能会好很多。

5.3 思维误区:HAVING与WHERE的混淆

这是初学者最容易混淆的点,也是面试常考点。记住一个简单的原则: WHERE 是“行级过滤器”,作用于每一行数据; HAVING 是“组级过滤器”,作用于每一个分组聚合后的结果。

一个经典的例子是:找出总销售额超过10000的部门。

  • WHERE 无法解决,因为“总销售额”不是某一行数据的属性,而是多行数据聚合后的结果。
  • 必须先 GROUP BY department ,然后 SUM(sales) ,最后用 HAVING SUM(sales) > 10000 来过滤。

理解了这个,就能明白为什么LeetCode 1045必须用 HAVING (过滤条件是聚合后的计数),而像“找出购买了产品A的客户”这样的问题,用 WHERE product_key = 'A' 就够了。

最后,无论是面对Waymo这样的顶尖公司面试,还是处理日常的数据分析任务,对工具的理解深度决定了你能走多远。SQL的 HAVING 和Pandas的 GroupBy 不仅仅是两个语法知识点,它们代表了数据处理中“集合思维”和“迭代思维”的碰撞。掌握其本质,你就能在合适的场景选择最合适的工具,写出既高效又优雅的代码。这道LeetCode 1045题,就是一个绝佳的起点。下次再遇到它,希望你能看到的不仅仅是一行行代码,而是背后流淌的数据逻辑与工程权衡。

更多推荐