
1. 从一道面试题看数据聚合的两种思维最近在帮朋友复盘Waymo数据科学岗位的面试经历其中一道SQL题让我印象挺深。题目本身来自LeetCode 1045核心是找出那些“购买了所有产品”的客户。这听起来像是个典型的集合包含问题但面试官在后续追问中把重点放在了实现路径上除了用SQL的HAVING子句能否用Pandas的GroupBy配合nunique来达成类似逻辑这其实不是一个简单的“翻译”问题而是触及了数据操作中两种截然不同的思维范式——声明式与命令式以及它们在处理集合关系时的效能与表达差异。这道题之所以值得深挖是因为它完美地卡在了数据科学日常工作的交叉点上。一方面SQL是处理大规模结构化数据的基石尤其在数据仓库和ETL流程中HAVING子句是进行分组后过滤的利器。另一方面Pandas作为Python数据分析的事实标准其GroupBy操作提供了极其灵活的内存内数据处理能力。面试官想考察的绝不仅仅是语法记忆而是候选人能否理解这两种工具背后的设计哲学、适用场景以及如何根据数据规模、计算环境和业务需求做出合理的选择。对于正在准备数据科学面试尤其是像Waymo这样注重工程化能力和数据思维的公司理清HAVING与GroupBynunique的异同远比死记硬背LeetCode答案更有价值。这能帮助你在白板编码或系统设计环节清晰地阐述你的技术选型理由展现出你对数据流水线全栈的思考深度。2. LeetCode 1045 问题拆解与SQL的HAVING解法我们先来彻底理解一下LeetCode 1045 “Customers Who Bought All Products”这道题。题目给定了两张表Customer和Product。Customer表结构通常是(customer_id, product_key)记录了每个客户购买了哪些产品产品由product_key标识。Product表则列出了所有存在的产品(product_key)。问题的目标是找出那些购买了所有产品的客户ID。这里的关键在于“所有”这个词。它不是一个简单的计数相等就能解决的。假设Product表里有5种不同的产品一个客户购买了5次但可能只是重复购买了其中3种产品他依然不符合“购买了所有产品”的条件。因此我们需要比较的是每个客户购买的不重复产品集合是否与全量产品集合相等。2.1 HAVING子句的核心逻辑集合的势与比较SQL的HAVING子句是GROUP BY的搭档专门用于对分组后的结果集进行过滤。与WHERE在分组前过滤行不同HAVING在分组后过滤组。解决这道题一个经典且高效的思路是利用计数Count来比较集合的势Cardinality即集合中元素的数量。思路如下计算全量产品数量这是一个标量值可以通过SELECT COUNT(DISTINCT product_key) FROM Product轻松获得。按客户分组计算其购买的不重复产品数量对Customer表按customer_id分组并使用COUNT(DISTINCT product_key)。筛选使用HAVING子句只保留那些不重复产品数量等于全量产品数量的客户组。SELECT customer_id FROM Customer GROUP BY customer_id HAVING COUNT(DISTINCT product_key) ( SELECT COUNT(DISTINCT product_key) FROM Product );2.2 为什么COUNT(DISTINCT) HAVING是高效选择这种写法在大多数关系型数据库如MySQL, PostgreSQL, SQL Server中都非常高效原因在于集合势的比较我们并不关心客户具体买了哪几种产品只关心其种类数是否达标。COUNT(DISTINCT)正是计算集合势的完美操作。利用索引如果Customer表在(customer_id, product_key)上有合适的索引数据库可以非常快地完成分组和去重计数。声明式优势SQL是声明式语言我们只需告诉数据库“我要什么”找出购买种类数等于总数的客户而不需要指定“如何做”。数据库的查询优化器会自主选择最有效的执行计划可能包括哈希聚合、排序合并等。注意这里有一个重要的细节。题目中Product表可能包含重复的product_key虽然理论上主键不应重复所以使用COUNT(DISTINCT product_key)是更严谨的做法。如果确定product_key是主键COUNT(*)也可以。2.3 HAVING的进阶思考处理NULL与复杂条件HAVING的强大之处在于它可以包含复杂的聚合函数条件。例如如果我们想找出“购买了超过一半产品种类”的客户只需将等号改为大于并将子查询结果除以2。它也可以组合多个条件比如找出购买种类数大于3且平均购买金额超过100的客户。然而HAVING也有其局限性。它只能基于当前分组的聚合结果进行过滤。如果我们想比较两个分组之间的集合关系例如找出购买产品集合是另一个客户子集的客户单纯的HAVING就力不从心了可能需要用到自连接或窗口函数。对于LeetCode 1045这种“与固定全集比较”的场景HAVING是简洁而优雅的解决方案。3. Pandas的GroupBy与Nunique策略现在我们把场景切换到Python的数据分析环境。面试官问“用Pandas怎么做” 这不仅仅是语法转换更是思维模式的切换。Pandas提供了强大的DataFrame.groupby()方法和Series.nunique()函数我们可以用命令式编程的方式来实现相同的逻辑。首先我们需要将SQL表的概念映射到Pandas的DataFrame。假设我们有两个DataFramedf_customer和df_product。import pandas as pd # 模拟数据 df_customer pd.DataFrame({ customer_id: [1, 1, 1, 2, 2, 3, 3, 3, 3], product_key: [5, 6, 5, 5, 7, 5, 6, 7, 8] }) df_product pd.DataFrame({ product_key: [5, 6, 7, 8] })3.1 实现步骤分解Pandas的实现通常分为几个清晰的步骤这体现了命令式编程“一步一步来”的特点第一步计算全量产品数量这一步和SQL中的子查询目的相同。total_unique_products df_product[product_key].nunique() # 或者如果product_key是唯一的 # total_unique_products len(df_product)第二步按客户分组并计算不重复购买数这是核心操作使用groupby结合agg或直接使用SeriesGroupBy.nunique。customer_purchase_counts df_customer.groupby(customer_id)[product_key].nunique().reset_index() customer_purchase_counts.columns [customer_id, unique_product_count]这行代码做了以下几件事df_customer.groupby(customer_id): 按客户ID分组生成一个DataFrameGroupBy对象。[product_key]: 从分组对象中选中product_key这一列得到一个SeriesGroupBy对象。.nunique(): 对每个分组内的product_keySeries计算不重复值的数量。.reset_index(): 将分组键customer_id从索引变回列方便后续操作。结果是一个新的DataFrame。第三步筛选符合条件的客户现在我们有了每个客户的不重复购买数筛选就变得非常简单类似于在SQL中使用WHERE因为聚合计算已经完成。result_df customer_purchase_counts[customer_purchase_counts[unique_product_count] total_unique_products] result_customer_ids result_df[customer_id].tolist()3.2 GroupBy Nunique的内部机制与性能考量Pandas的groupby().nunique()在内部通常使用哈希表Hash Table或排序算法来实现。对于中等规模的数据通常指能完全放入内存的数据它的速度非常快。然而与SQL在数据库服务器端优化执行相比Pandas有一些不同的特点内存操作所有数据必须能装入内存。groupby过程会在内存中创建分组数据结构对于超大分组或极大数据集可能遇到内存瓶颈。单机性能Pandas操作是单线程的尽管某些操作如read_csv或groupby的某些后端支持并行计算速度受限于单机CPU和内存。对于远超内存的数据需要借助Dask、Modin等库或分块处理。灵活性代价Pandas极高的灵活性中间变量可随时查看、修改带来了便利但也意味着每一步操作都可能产生完整的数据副本不像SQL优化器可能将多个操作合并为一个执行步骤。在实际面试中如果被问到Pandas实现的性能你可以这样分析对于面试题中的小规模测试数据Pandas方案完全足够。但在Waymo处理真实自动驾驶传感器日志、轨迹数据等海量数据时这种在内存中进行全量groupby的操作可能不是首选更可能使用Spark SQL、BigQuery等分布式SQL引擎或者在数据预处理阶段就利用SQL完成粗粒度的聚合再将小规模结果交给Pandas进行精细分析。3.3 Pandas的另一种思路利用集合操作除了计数比较Pandas还可以更直接地模拟集合包含操作虽然代码稍复杂但逻辑更直观# 获取全量产品集合 all_products_set set(df_product[product_key]) # 定义函数判断每个客户购买集合是否为全集的超集 def bought_all_products(product_keys): return set(product_keys) all_products_set # 使用 判断是否为超集即包含所有元素 # 应用函数 result_series df_customer.groupby(customer_id)[product_key].apply(lambda x: bought_all_products(x)) result_customer_ids result_series[result_series].index.tolist()这种方法直接比较集合避免了先计算数量再比较可能带来的歧义尽管在本题中不会。但apply函数通常比向量化的nunique慢因为它需要为每个分组调用一次Python函数。在数据量大时性能差异会非常明显。4. HAVING 与 GroupBy Nunique的深度对比与应用选型理解了两种实现方式我们现在可以从多个维度进行系统性对比这正是在技术面试中展现你深度思考的关键。4.1 思维范式声明式 vs. 命令式这是最根本的差异。SQL (HAVING) - 声明式你描述你想要的结果——“找出购买种类数等于产品总数的客户”。你不需要指定数据库应该如何遍历数据、如何分组、如何计数。查询优化器会将其编译成物理执行计划可能使用哈希聚合、排序、流处理等。这种抽象层次高将性能优化的责任交给了数据库引擎。Pandas (GroupBy) - 命令式你通过一系列明确的指令来指挥计算机“先按这个列分组然后对那个列进行去重计数把结果存到一个新变量里最后用这个变量去过滤。” 你对计算过程有更细粒度的控制但也承担了更多编写高效代码的责任。4.2 语法与表达能力对比特性SQL HAVINGPandas GroupBy Nunique核心操作GROUP BY ... HAVING COUNT(DISTINCT ...) ...df.groupby(...)[...].nunique()中间状态不可见由数据库内部管理。完全可见可存储为中间DataFrame方便调试和分步验证。条件灵活性HAVING后可使用各种聚合函数(COUNT,SUM,AVG,MAX/MIN等)和比较操作也可使用AND/OR连接复杂条件。筛选在聚合后的DataFrame上进行可使用Pandas强大的布尔索引条件表达式非常灵活。多级聚合配合CASE WHEN或子查询可以实现但语法可能复杂。通过agg()函数传入字典如{product_key: nunique, sales: sum}可轻松实现多列不同聚合非常直观。代码可读性对于熟悉SQL的人意图一目了然。对于Python数据分析师流程清晰易于嵌入到更复杂的数据处理管道中。4.3 性能与适用场景抉择选择哪种方案绝不仅仅是个人喜好而是由数据、环境和任务决定的。选择SQL HAVING的场景数据规模巨大数据存储在数据库或数据仓库如MySQL, PostgreSQL, BigQuery, Snowflake中远超单机内存。SQL引擎擅长利用磁盘I/O、索引、内存缓冲和分布式计算来高效处理。生产环境ETL/ELT在构建数据管道、生成报表、更新物化视图时SQL是标准语言易于集成、调度和维护。并发访问与事务需要处理多用户并发读写或操作需要满足ACID事务特性时必须使用数据库系统。团队技能栈如果团队主要使用SQL进行数据分析那么保持技术栈统一能降低协作成本。选择Pandas GroupBy的场景探索性数据分析数据量适中GB级别以内需要在Jupyter Notebook等交互式环境中快速迭代、可视化、尝试不同分析思路。Pandas的即时反馈和灵活性无可替代。复杂的数据转换需要用到SQL不擅长或表达繁琐的操作如复杂的字符串处理、自定义滚动窗口计算、与机器学习库scikit-learn的无缝衔接等。原型开发与特征工程在构建机器学习模型前进行特征提取、清洗和组合Pandas的链式操作和丰富函数库非常高效。嵌入Python应用分析逻辑需要作为一部分集成到更大的Python应用程序或Web服务后端中。在Waymo数据科学面试的语境下面试官很可能期待你给出一个分层的答案对于离线的大规模历史数据分析任务会优先使用SQL可能是Spark SQL或内部查询引擎进行预处理和聚合。对于聚合后的结果数据、抽样数据或者在模型训练前的特征工程阶段则会使用Pandas进行更灵活的处理。能够清晰阐述这种“SQL用于大数据粗加工Pandas用于小数据精加工”的协作模式会大大加分。5. 面试实战延伸常见变体与陷阱LeetCode 1045是一个经典的模板题。有经验的面试官不会只满足于标准答案他们会通过变体问题来考察你的思维是否缜密是否真正理解了底层逻辑。5.1 变体一购买“至少”所有产品但可能有额外购买原题是“购买了所有产品”。如果问题变为“购买了所有产品但可能还买了别的”我们的解法依然有效吗答案是完全有效。因为COUNT(DISTINCT product_key) total_count这个条件只关心购买的独特产品种类数是否等于总数不关心是否有重复购买或其他多余记录。集合的势相等是“包含所有”的充分必要条件。5.2 变体二Product表可能为空或Customer表有NULL值这是一个重要的边界情况考察。Product表为空如果产品表里没有记录那么total_unique_products 0。根据题意“购买了所有0种产品”在逻辑上可能被视为真空集是任何集合的子集。此时SQL查询中的子查询(SELECT COUNT(...) FROM Product)会返回0。那么HAVING COUNT(DISTINCT ...) 0会筛选出那些没有购买任何产品的客户即COUNT(DISTINCT product_key)为0的客户。这需要与面试官确认业务逻辑是否合理。Customer表的product_key存在NULL在SQL中COUNT(DISTINCT column)会忽略NULL值。如果一个客户的所有购买记录中product_key都是NULL那么他的COUNT(DISTINCT product_key)结果为0。在Pandas中nunique()默认也会忽略NaNPandas中的NULL。这一点两者行为一致。但你必须意识到这一点并思考NULL在业务中代表什么未知产品数据缺失这会影响结果解读。5.3 变体三如何找出“只”购买了所有产品的客户即购买集合恰好等于产品集合无多余这是一个更严格的集合相等问题。原题的解法计数相等无法区分“购买了所有产品A,B,C”和“购买了所有产品A,B,C外加一个D”。因为两者的不重复计数都是3如果总产品是A,B,C。要解决这个问题我们需要真正的集合相等检查。在SQL中这会变得复杂可能需要用到GROUP_CONCAT排序后拼接字符串然后比较或者更高级的集合运算符但标准SQL对集合的直接操作支持有限。在Pandas中我们可以用之前提到的集合方法将条件从“是否为超集”改为“是否相等”。def bought_exactly_all_products(product_keys): return set(product_keys) all_products_set这种变体题考察的是你是否能跳出“计数”的思维定式深入到“集合内容”的比较这对数据科学家的严谨性要求很高。5.4 性能陷阱COUNT(DISTINCT) vs. 子查询 vs. JOIN即使在SQL范畴内LeetCode 1045也有多种写法。除了HAVING与子查询对比还可以用JOIN和GROUP BY来实现。面试官可能会问“为什么选择子查询而不是JOIN” 这涉及到对执行计划的理解。-- 使用JOIN的写法可能低效 SELECT c.customer_id FROM Customer c LEFT JOIN Product p ON c.product_key p.product_key GROUP BY c.customer_id HAVING COUNT(DISTINCT c.product_key) (SELECT COUNT(*) FROM Product) AND COUNT(DISTINCT p.product_key) (SELECT COUNT(*) FROM Product); -- 确保每个product_key都能join上这种写法通常更低效因为它可能先进行一个不必要的笛卡尔积或大表连接尤其是当Customer表很大时。而原解法中的标量子查询(SELECT COUNT(DISTINCT ...) FROM Product)通常只执行一次成本极低。在面试中能指出不同写法的潜在性能差异说明你对数据库查询优化有实际经验。6. 从解题到工程数据科学工作流中的思考通过一道LeetCode题我们实际上串联了数据科学工作中的多个核心环节。在Waymo这样的公司数据科学家处理的数据可能来自数百万英里的路测视频、激光雷达点云和传感器融合数据。相关的元数据或行为日志比如“某段路测数据中系统识别出了所有预定义类别的物体吗”就可能抽象成类似LeetCode 1045的问题。首先数据理解与清洗。在写任何聚合代码之前我们需要确认product_key的取值是否完整一致是否有重复或无效值在Pandas中我们会用df[product_key].isnull().sum()、df[product_key].nunique()来探查。在SQL中则是SELECT COUNT(*), COUNT(DISTINCT product_key) FROM Customer;。这一步确保我们聚合的基础是可靠的。其次验证逻辑的正确性。无论是SQL还是Pandas写出代码后要用小样本数据验证。在Pandas中可以手动构造一个包含边界案例的小DataFrame。在SQL中可以先用LIMIT子句查看中间分组结果。例如在执行完整HAVING筛选前先跑一下SELECT customer_id, COUNT(DISTINCT product_key) as cnt FROM Customer GROUP BY customer_id ORDER BY cnt DESC;看看计数分布是否符合预期。再者关注可解释性与协作。你的代码不仅是给机器跑的也是给人包括未来的你看的。在SQL查询中清晰的别名和注释很重要。在Pandas脚本中有意义的变量名和分步骤操作而不是一个超长的链式调用能极大提升可读性。在团队中如果下游同事习惯用SQL查看数据那么即使你用Pandas完成了分析也可能需要将关键结果写回数据库或者提供可复用的SQL脚本。最后也是最重要的理解业务本质。这道题的本质是“集合包含”或“完全覆盖”。在自动驾驶领域类似的逻辑可能出现在评估一个感知模型在特定测试集上是否检测到了所有类型的障碍物分析某辆测试车在所有预设路况下是否都采集到了有效数据。将抽象的算法题与具体的业务场景关联起来是面试中展现你业务理解力和解决问题能力的高光时刻。你可以补充说“在实际工作中我可能会进一步分析这些‘购买了所有产品’的客户群体有什么其他特征用户画像或者对于没有购买全的客户是哪些产品缺失从而指导营销策略。” 这体现了从“解决问题”到“产生商业洞察”的飞跃。