1. 项目概述一次面试题引发的技术深度思考最近在准备Waymo数据科学岗位的面试刷到一道非常经典的LeetCode 1045题。这道题本身并不复杂但它在SQL和Pandas两种主流数据处理工具上的解法却像一面镜子清晰地映照出两种不同数据处理哲学的核心差异。很多朋友在刷题时可能只是机械地记住了“用GROUP BY加HAVING”或者“用groupby().nunique()”的套路但很少去深究为什么这道题会这样设计HAVING子句和groupby().nunique()在底层逻辑上究竟有何不同在实际的数据科学工作中面对海量的自动驾驶传感器数据我们又该如何根据场景在这两种方案中做出最优选择这道题的核心是找出那些“购买了所有产品”的客户。听起来像是一个简单的集合包含问题但在关系型数据库和内存计算框架中它的实现路径和性能考量截然不同。今天我就结合自己处理大规模数据集尤其是类似Waymo Open Dataset这种多模态时序数据的经验来彻底拆解这道题。我们不止于ACAccept通过更要理解每一种解法背后的“为什么”以及如何将这种理解应用到真实的数据科学流水线中。无论你是正在备战面试还是希望提升自己的数据分析功底相信这篇深度解析都能给你带来新的启发。2. 问题本质与两种技术路径的哲学对比2.1 LeetCode 1045 题意深度解析我们先抛开代码用最直白的业务语言理解一下这道题。假设你是一家电商平台的数据分析师手里有一张Customer表记录了客户ID (customer_id) 和产品ID (product_key)。题目要求你找出那些购买了平台上所有产品的客户。这里的关键在于“所有产品”这个集合。在真实的业务场景中“所有产品”的定义可能是动态的。一种情况是有一个静态的产品总表所有产品是已知且固定的另一种更常见的情况是“所有产品”就是当前Customer表中出现过的所有不重复的product_key的集合。LeetCode 1045通常指的是后者即找出那些购买的产品种类数等于全表产品种类总数的客户。这立刻引出了两个核心计算全局产品总数计算整个数据集中有多少种不同的产品。每个客户购买的产品种类数计算每个客户购买了多少种不同的产品。最后筛选出“客户购买种类数”等于“全局产品总数”的客户。这个“筛选”动作在SQL和Pandas的世界里走上了两条不同的技术路径。2.2 SQL的“声明式”哲学与HAVING子句SQLStructured Query Language是一种声明式查询语言。你的任务是告诉数据库“你想要什么”而不是“如何一步步去获取”。数据库的查询优化器会帮你决定最优的执行计划比如选择哪个索引、采用哪种连接算法。HAVING子句是这种哲学的典型体现。它总是与GROUP BY子句相伴出现用于对分组聚合后的结果集进行过滤。你可以把它理解为针对“分组”这个临时表的WHERE子句。但关键在于WHERE是在分组前对原始行进行过滤而HAVING是在分组后对聚合结果如COUNT,SUM,AVG等进行过滤。对于LeetCode 1045SQL的思路非常符合直觉声明目标“我想知道每个客户购买了多少种不同的产品。”执行分组使用GROUP BY customer_id将数据按客户分组。进行计算在每组内使用COUNT(DISTINCT product_key)计算唯一产品数。声明过滤条件“我只想要那些唯一产品数等于总产品数的客户。” 这个“等于总产品数”的条件无法在分组前用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 1045Pandas的命令式步骤非常清晰计算全局总数total_products df[product_key].nunique()分组并计算客户购买数customer_counts df.groupby(customer_id)[product_key].nunique()过滤结果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和子查询的语句时数据库优化器可能会生成多种执行计划。一个高效的执行计划可能是这样的计算标量子查询首先执行(SELECT COUNT(DISTINCT product_key) FROM Customer)这是一个全局的聚合计算只需要扫描一次表计算出唯一产品总数比如结果是N。构建哈希表进行分组扫描Customer表按customer_id构建哈希表。对于每一行将其product_key添加到一个与该customer_id对应的“唯一值集合”中在内存或临时空间中。这个过程在扫描时就在逐步计算每个客户的唯一产品数。应用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会执行以下操作索引排序/哈希groupby默认会先对customer_id进行排序sortTrue以便将相同ID的行分组在一起。对于非数值型或大数据集这会是一个O(n log n)的操作。你可以通过设置sortFalse来提升速度前提是你不关心输出结果的顺序。迭代与集合去重对于每个分组Pandas会遍历该分组内的所有product_key值并使用一个哈希集合set来记录唯一值最后返回集合的大小。这个过程的时间复杂度大致是O(n)但需要额外的内存来存储这些临时集合。当数据量非常大接近或超过可用内存时原生的Pandasgroupby操作会变得非常缓慢甚至导致内存溢出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, npartitions4) # 分为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我们需要考虑空表或NULL值SQL如果Customer表为空子查询COUNT(DISTINCT product_key)会返回0。HAVING子句会比较每个客户的分组计数与0由于没有分组最终结果为空集。这是符合逻辑的。但如果product_key存在NULL值COUNT(DISTINCT product_key)会忽略NULL。你需要明确业务逻辑NULL是否代表一种特殊产品通常不所以忽略是合理的。Pandasdf[product_key].nunique()默认会忽略NaN。如果整列为NaN或数据框为空nunique()返回0。groupby().nunique()同样会忽略分组内的NaN。你需要确保这与业务定义一致。重复购买记录题目表可能包含同一个客户购买同一个产品的多条记录。这正是我们必须使用COUNT(DISTINCT ...)或.nunique()的原因它们会自动去重。如果错误地使用了COUNT(*)或.size()就会得到错误的结果。客户数量极大当客户数量达到百万甚至千万级时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_customers1, n_rows), product_key: np.random.randint(1, n_products1, 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使用sortFalse (如果不在意顺序) start time.time() total_products df[product_key].nunique() customer_counts df.groupby(customer_id, sortFalse)[product_key].nunique() result2 customer_counts[customer_counts total_products].index.tolist() time2 time.time() - start print(fgroupby(sortFalse)耗时: {time2:.4f}秒 找到{len(result2)}个客户) print(f速度提升: {(time1-time2)/time1*100:.1f}%)这个小实验可以直观地告诉你sortFalse参数在数据量大时的价值。在真实场景中你还可以测试不同的分组列数据类型字符串 vs. 整数带来的性能差异。5. 常见问题与排查技巧实录在实际操作中无论是写SQL还是Pandas都会遇到一些典型的“坑”。这里记录几个我踩过或见别人踩过的坑以及排查思路。5.1 SQL常见错误与排查错误在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子句中。错误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)要么在子查询中先处理好。性能问题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常见错误与排查错误误用size()代替nunique()# 错误示例如果存在重复购买记录这将得到错误结果 customer_counts df.groupby(customer_id)[product_key].size()现象结果中客户的购买数量可能远大于实际购买的产品种类数。排查检查原始数据中是否存在(customer_id, product_key)的重复行。使用df.duplicated(subset[customer_id, product_key]).sum()查看重复数量。始终明确你的业务指标是“购买次数”还是“购买品类数”。错误GroupBy后索引丢失导致后续操作失败customer_counts df.groupby(customer_id)[product_key].nunique() # customer_counts 是一个Series索引是customer_id # 如果想将其转换回一个包含customer_id列的DataFrame需要重置索引 result_df customer_counts.reset_index(nameproduct_count) # 正确 # 如果直接 pd.DataFrame(customer_counts)会得到奇怪的列名技巧时刻关注你操作对象的类型DataFrame还是Series和索引。groupby().agg()默认会将分组列设为索引。使用.reset_index()是将其变回普通列的常用方法。内存溢出MemoryError现象在处理大型DataFrame进行groupby操作时程序崩溃并报MemoryError。排查与解决检查数据大小df.info()查看内存占用df.shape查看行列数。优化数据类型使用df[col].astype(category)将低基数字符串列转为分类类型使用df[col].astype(int32)等向下转换数值类型。采样分析先用df.sample(frac0.1)抽取10%的数据进行逻辑验证。分批处理/核外计算如前所述考虑使用Dask或者手动将数据按customer_id的哈希值分片处理。性能瓶颈GroupBy速度慢排查使用%timeit或line_profiler工具定位慢的具体是哪个步骤。解决思路设置sortFalse如果不需要结果按分组键排序这是最简单的提速方法。使用更高效的聚合函数有些聚合操作有更快的实现。例如对于求和、均值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题就是一个绝佳的起点。下次再遇到它希望你能看到的不仅仅是一行行代码而是背后流淌的数据逻辑与工程权衡。