十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

神策数据秋招技术岗笔试全解析:算法、SQL与业务思维

神策数据秋招技术岗笔试全解析:算法、SQL与业务思维 考完走出考场的时候我脑子里反复转着同一句话神策的笔试考的不是你会不会写代码而是你有没有用数据思维去解问题的习惯。2023年神策数据秋招技术岗第一批笔试整体给人的感觉是题目本身没有特别偏难怪的但每一道题都透着这家公司做用户行为分析的基因——算法题和业务场景贴得很近SQL题和埋点数据直接挂钩简答题更是不跟你兜圈子问的就是真实生产环境里一定会遇到的事。这篇文章我会把这场笔试的题型分布、算法题的完整推导过程、SQL和系统设计题的答题思路以及考后怎么把这些经验转化成面试筹码全部梳理一遍。想投神策或者其他数据类公司技术岗的朋友可以作为参考。1. 考完的真实感受神策这批笔试题量、形式与筛选逻辑1.1 笔试流程与整体感受先说流程。2023年神策秋招技术岗第一批笔试是在线上进行的限时作答整体时间大概是120分钟。题型分为三大块选择题、编程题、问答题。选择题大概有三十到四十道覆盖面很广数据结构、计算机网络、操作系统、数据库原理都有涉及有些题会结合大数据组件来考比如Kafka的消费模型、Spark的宽窄依赖这类。编程题是两道一道偏经典算法另一道是贴近数据统计场景的设计类题目。问答题则是SQL和系统设计相关和神策的业务强相关。整体体量不算小选择题如果不控制时间后面大题很容易写不完。我当时给自己定的节奏是选择题最多花四十分钟编程题每道三十分钟左右剩下的时间全部给SQL和简答。实测下来这个节奏是合理的至少能保证每一道大题都有完整作答而不是前面选择抠太细后面大题被迫草草写完。1.2 从题目设置反推神策在筛选什么人如果你只是把这场笔试当成普通的刷题考试那可能会吃暗亏。我的感受是神策的笔试题非常看重业务理解力。所谓的数据分析公司核心产品是用户行为分析平台也就是帮客户做埋点、采集用户行为事件、分析转化漏斗和留存。这些业务特征会渗透到技术题里。比如选择题里会问一个日活百万级别的埋点系统事件数据应该选择什么样的存储引擎这就不是单纯考数据库知识而是考你是否了解列式存储和分析型数据库的基本选型逻辑。再比如问答题里直接给了用户事件表让你统计各渠道的转化漏斗这几乎就是神策日常工作中最常做的事情。所以准备这场笔试的时候光刷LeetCode是不够的还得对埋点数据流、事件模型、漏斗分析这些概念有一个基本认知。换句话说神策希望招进来的人不是只会做题的而是能快速理解业务、把技术落地到业务场景里的工程师。2. 算法题第一道合并K个升序链表别小看这道基础题2.1 题目描述与最初的第一反应第一道编程题是经典的合并K个升序链表。题目给了一个链表数组每个链表都是升序排列的要求把所有链表合并成一个升序链表。说实话看到这道题的时候我稍微松了一口气因为它属于面试中高频出现的基础题绝大部分刷过题的人都应该有印象。但同样因为是基础题考察的点往往不在能不能写出来而在能不能写出复杂度最优的版本以及边界条件处理得干不干净。我最初的第一反应就是用小顶堆把所有链表的头节点放进堆里每次弹出一个最小节点接在结果链表的尾部然后把这个节点的下一个节点入堆。这是最标准的解法时间复杂度O(NlogK)N是所有节点的总数K是链表的条数。2.2 优先队列解法的完整推导这里我把完整的思路和代码写一下方便没刷过这道题的朋友直接参考。首先堆里放的是链表节点但是Java的PriorityQueue默认是小顶堆所以如果节点类型是自定义的ListNode需要传入一个比较器按照节点的val值排序。每次从堆里poll出来的就是当前K个链表中头部最小的那个节点。把它接到结果链表的尾部之后如果它后面还有节点就把后一个节点offer进堆里。这样循环直到堆为空。class ListNode { int val; ListNode next; ListNode(int val) { this.val val; } } public ListNode mergeKLists(ListNode[] lists) { if (lists null || lists.length 0) return null; PriorityQueueListNode heap new PriorityQueue((a, b) - a.val - b.val); for (ListNode head : lists) { if (head ! null) { heap.offer(head); } } ListNode dummy new ListNode(-1); ListNode cur dummy; while (!heap.isEmpty()) { ListNode node heap.poll(); cur.next node; cur cur.next; if (node.next ! null) { heap.offer(node.next); } } return dummy.next; }这个解法的关键点有三个第一堆的大小始终不超过K所以每次poll和offer的复杂度都是O(logK)第二每个节点只会入堆一次、出堆一次所以总复杂度是O(NlogK)第三dummy节点的使用可以避免处理头节点为空的特殊情况。我写这段代码的时候额外小心了一个点入堆之前一定要判断链表头节点是否为null否则堆里会混入空指针。如果是空数组或者数组里全是空链表直接返回null就行。2.3 分治合并的另一种路径除了堆解法分治合并也是这道题的标准解法之一。思路是把K个链表两两配对每一轮合并之后链表数量减半直到最后只剩一条链表。每一轮合并的总复杂度是O(N)一共合并logK轮所以总复杂度同样是O(NlogK)。我在笔试时先写了堆解法分治版本没有在代码里实现但在注释里简单提了一嘴。如果你在面试里被问到还有没有其他解法分治合并是个很好的补充答案。两种方法复杂度一样堆解法的优势是实现在直觉上更直接分治解法的优势是空间复杂度更稳定不依赖堆结构而且如果这个题目变成了合并K个有序数组分治的思路同样适用。提示笔试时如果时间充裕建议在写完堆解法之后简单写一下分治思路的复杂度推导。这会让面试官看到你对问题有体系化的理解而不是只背了一个答案。3. 算法题第二道滑动窗口内的数据计数一道贴着业务考的设计题3.1 题目背景与需求拆解第二道编程题比较有意思它没有再考纯粹的链表、二叉树这类经典结构而是给了一个贴近真实业务的场景假设系统在源源不断地接收用户行为事件每个事件包含事件类型和发生时间。现在需要支持一个查询——统计最近5分钟内某种事件发生的总次数。要求设计一个数据结构让事件写入和窗口查询都尽可能高效。这道题本质上是一个滑动窗口计数问题。刚看到的时候我意识到它考的不仅仅是数据结构本身还考察你对流式数据时间窗口这些概念的敏感度。做用户行为分析的公司几乎处处都在处理这类问题比如统计最近5分钟的活跃用户数、最近1小时的点击量、漏斗分析里的时间窗口转化率。所以这道题背后的潜台词是你能不能在真实业务里设计出一个高效的计数模块。3.2 时间分桶与环形数组的解法最简单的思路是用一个队列每来一个事件就把时间戳放入队尾查询时把队头所有超出5分钟的数据全部弹出剩下的就是窗口内的事件。这个方案写起来非常快但问题也很明显如果事件量很大队列里会积压海量数据每个事件都存储完整时间戳和事件信息内存消耗会非常大。最优的解法是时间分桶。以秒为粒度把5分钟的窗口分成300个桶每个桶只保存这一秒内的事件计数。用一个环形数组来实现这300个桶同时维护一个当前时间对应的桶索引。新事件到达时根据它的时间戳计算属于哪个桶对应计数加一查询时遍历所有桶把窗口内的时间戳对应的桶计数累加起来。这样做的内存消耗是固定的只存储300个整数计数和一个小时间段的状态与事件总量无关。事件写入的时间复杂度是O(1)查询的时间复杂度是O(300)在固定窗口下就是O(1)。class SlidingWindowCounter { private int[] bucketCounts; private long[] bucketTimes; private int bucketSize; private long windowSizeInSeconds; public SlidingWindowCounter(long windowSizeInSeconds, int bucketSize) { this.windowSizeInSeconds windowSizeInSeconds; this.bucketSize bucketSize; int bucketNum (int) (windowSizeInSeconds / bucketSize); this.bucketCounts new int[bucketNum]; this.bucketTimes new long[bucketNum]; } public void addEvent(long timestampSeconds) { int idx (int) (timestampSeconds % bucketCounts.length); if (bucketTimes[idx] ! timestampSeconds) { bucketTimes[idx] timestampSeconds; bucketCounts[idx] 0; } bucketCounts[idx]; } public long query(long currentTimestampSeconds) { long start currentTimestampSeconds - windowSizeInSeconds; long total 0; for (int i 0; i bucketCounts.length; i) { if (bucketTimes[i] start) { total bucketCounts[i]; } } return total; } }这里有一个需要注意的地方时间戳对桶数量取模之后同一个index会在不同时间被重复使用所以每次写入前要检查bucketTimes里存的旧时间是否等于当前时间如果不等于说明这个桶已经被覆盖了需要重置计数。这个细节如果不处理窗口查询会直接算错。3.3 跨桶查询与内存控制的边界问题如果把上面的代码放进真实系统还有几个边界问题需要考虑。首先是跨桶查询。比如窗口是5分钟但有一个事件的时间戳比当前时间早很多那么它所在的桶已经不在有效窗口内了查询的时候通过start时间判断过滤掉即可。但如果事件的时间戳比当前时间还晚也就是乱序事件那就要额外讨论是直接丢弃、延迟处理还是放到单独的缓冲区。我在笔试答案里主动提了一句真实环境中埋点数据会因为网络延迟出现乱序所以系统设计时要预留乱序容忍度。其次是内存控制。上面代码里每个桶只存一个long和一个int300个桶的内存消耗可以忽略。但如果要按用户维度统计UV比如说最近5分钟活跃用户数那每个桶里就不能只存一个计数了而是需要一个HashSet来保存用户ID。这会带来内存压力此时通常的工程做法是牺牲一定精度使用HyperLogLog这类基数估算算法。我在笔试时把这个问题也写进去了这属于额外加分项。4. SQL与大数据一道转化漏斗题把用户行为分析的底子全考了4.1 题目原貌与第一反应问答题里有一道SQL题大致是这样的给定一张用户行为事件表字段包括user_id、event_name、event_time、channel其中event_name包含曝光、点击、下单三种类型。要求统计每个渠道下从曝光到点击再到下单的转化漏斗人数也就是每个环节的独立用户数以及相邻环节的转化率。看到这道题我脑海里闪过的第一个念头是这不就是神策自己产品每天都在算的东西吗。漏斗分析是用户行为分析最核心的功能之一把这道题放进笔试里明显是想看候选人能不能理解事件模型、会不会用SQL做多步聚合。4.2 MySQL写法与Hive/Spark写法的差异如果是小数据量场景最直接的想法是把事件表按用户和渠道做行转列用条件聚合把三个环节变成三列再分别对每个环节求COUNT(DISTINCT user_id)。SELECT channel, COUNT(DISTINCT CASE WHEN event_name 曝光 THEN user_id END) AS expose_users, COUNT(DISTINCT CASE WHEN event_name 点击 THEN user_id END) AS click_users, COUNT(DISTINCT CASE WHEN event_name 下单 THEN user_id END) AS order_users FROM user_behavior_events GROUP BY channel;然后根据这个结果在外部或子查询里计算转化率。这里容易踩的坑是直接用COUNT(DISTINCT CASE WHEN ...)时如果两个环节的事件发生在不同时间你无法保证用户确实是先曝光、再点击、再下单的顺序这个SQL只是统计分别干过这三件事的人数而不是真正的漏斗转化人数。在MySQL这种简单的聚合场景下这算是一种可接受的简化但如果要严格按用户行为顺序来就需要使用窗口函数或者按照事件时间做多表自连接。在Hive/Spark SQL里更推荐用窗口函数取出每个用户在每个环节的最小事件时间然后比较先后顺序WITH ordered_events AS ( SELECT user_id, channel, event_name, event_time, ROW_NUMBER() OVER(PARTITION BY user_id, channel ORDER BY event_time) AS rn FROM user_behavior_events ) SELECT channel, COUNT(DISTINCT CASE WHEN event_name 曝光 THEN user_id END) AS expose_users, COUNT(DISTINCT CASE WHEN event_name 点击 THEN user_id END) AS click_users, COUNT(DISTINCT CASE WHEN event_name 下单 THEN user_id END) AS order_users FROM ordered_events GROUP BY channel;这样的写法只能保证每个用户事件按时间排序后能被取到但要精确描述曝光之后的点击、点击之后的下单还是要分步join。我在笔试时选择的是一个更严谨的思路先筛选出有曝光行为的用户再关联点击事件并确保点击时间大于曝光时间再关联下单事件并确保下单时间大于点击时间。这样查出来的漏斗人数才是真正符合行为顺序的。4.3 漏斗查询为什么会慢以及生产环境怎么优化这道题还有一个隐含考点在大数据量场景下直接对全表做COUNT(DISTINCT)是很昂贵的。尤其是埋点事件表动辄几亿行每个人可能都产生几十条甚至上百条事件记录COUNT(DISTINCT user_id)会触发大量的shuffle和去重计算。笔试时我在答案后面补充了一段优化思路第一是分区裁剪。事件表通常按天分区先通过event_time限制查询范围避免扫描全表。第二是预聚合。如果每次漏斗分析都要跑全量明细性能一定扛不住生产环境里一般会把事件数据清洗成用户-环节-首次时间的汇总表或者用物化视图做预处理。第三是用位图索引或ClickHouse这类分析型存储来加速去重和分组计算。我在作答时没有说太多因为笔试题量不小但这道题给了我一个很明显的信号神策对候选人SQL的考察不是停留在能不能写出来而是能不能写出在大数据环境下还能跑的SQL。这也是后来面试时让我印象很深的一个点。5. 简答题里的一致性问题埋点数据不丢不重不是想当然那么简单5.1 题目埋点上报如何保证数据不丢不重最后一道简答题非常神策。题目大意是客户端采集用户行为事件之后需要通过HTTP上报到服务端但移动网络环境不稳定可能出现请求超时、断网、进程被杀等情况。为了保证数据不丢不重应该怎么设计这个上报链路这道题没有标准答案但考察的是候选人对分布式系统里数据一致性的理解。我当时在答题时没有急着写方案而是先在心里把链路拆成了三段客户端采集与缓存、客户端上报、服务端接收与去重。三段每一段都有各自的坑。5.2 端到端的幂等方案拆解先看客户端采集与缓存。客户端产生事件后不能立刻认为上报成功了否则进程一旦被杀事件就没了。所以在客户端本地必须有一个可靠的缓存机制把事件先持久化到本地数据库比如SQLite、MMKV或LevelDB。批量上报时从缓存里取出事件上报成功并收到服务端ack后才从本地删除。这个动作保证了不丢。再看上报时机。移动端不可能每产生一条事件就发一次HTTP请求这样既费电又费流量服务端也扛不住。所以必须做批量上报比如攒够50条或者每10秒上报一次。但这个批量上报引入了一个新问题如果服务端收到了这批数据但ack回包丢了客户端会认为上报失败于是重新上报服务端就会收到重复数据。解决重复的核心就是幂等。每一条事件在客户端生成时携带一个全局唯一的event_id服务端收到事件后在写入存储之前先检查这个event_id是否已经处理过如果是就丢弃。这个去重表可以用Redis也可以直接存在数据库里加唯一索引。只要key是唯一的重复上报就会被数据库唯一约束挡住。在笔试里我明确写了最终采用客户端本地缓存批量上报服务端event_id幂等去重这套组合方案可以做到at-least-once语义下的不丢不重。5.3 我在笔试时的答题框架这道题我用了三段式来组织答案第一段讲客户端事件先写本地缓存采用批量上报收到ack才删除本地记录进程重启后从缓存恢复未上报的数据。第二段讲服务端通过event_id做幂等写入前先查重存储引擎加唯一索引兜底如果使用消息队列做削峰还要考虑消费者重复消费的问题同样用event_id去重。第三段讲异常场景如果服务端接口长时间不可用客户端要设置重试上限和退避策略超过上限的数据进入失败队列等待网络恢复后补偿上报。这样组织的好处是逻辑清晰面试官一眼就能看出你对整个链路有全局观。另外我还补充了一点如果对数据实时性要求不高可以通过本地日志文件异步同步的方式来做兜底但这会引入额外的数据管道复杂度属于最后一道防线一般不会作为主方案。注意面试和笔试里遇到这种开放性问题最忌讳一上来就写方案。先把链路拆出来再说每一环怎么设计、怎么兜底这才是有经验的工程师回答问题的方式。6. 笔试后的复盘清单如何把一场笔试变成面试的弹药6.1 错题整理与知识点补漏笔试结束不等于这件事结束了。我的习惯是当天晚上趁着记忆还热乎把所有题目重新回忆一遍整理成一份个人复盘文档。格式很简单题目考点、我的答案、出错的点、正确思路、需要补充的知识点。比如这次笔试里选择题中有一道关于Kafka消费者组rebalance的题我记得当时在两个选项之间犹豫了很久最后虽然答对了但并没有真正理解rebalance的触发条件。笔试结束后我专门把Kafka消费者的分区分配策略、以及什么情况下会触发rebalance又重新啃了一遍。这样的针对性补漏比漫无目的地刷题高效得多因为每一道题都代表着公司认为重要的知识点方向感非常明确。6.2 把笔试代码改造成面试讲稿笔试里的两道编程题我在复盘时都重新写了一遍并且认真推演了如果面试官追问我该怎么解释。比如说合并K个升序链表这道题我给自己准备的追问清单包括堆的大小为什么是K而不是N分治合并的额外空间复杂度是多少如果链表是降序的怎么办如果没有提供比较器PriorityQueue怎么用滑动窗口计数那道题我准备的问题包括为什么用秒做粒度如果用毫秒会有什么后果窗口大小能不能动态调整如果要统计UV而不是PV怎么做这些追问不一定会被问到但准备的过程本身就是加深理解的过程。而且如果你把这些思考写进简历里比如笔试题中设计了一个滑动窗口计数结构支持O(1)时间内完成5分钟窗口内事件数统计这会成为面试时一个很好的谈资。6.3 写在最后的一点体会参加完这场笔试我的整体评价是神策的笔试题不是那种刷题狠人就能碾压的类型它对业务理解和系统设计的权重相当高。如果你打算投递数据基建、后端开发、数据分析平台相关的岗位建议在准备阶段除了常规的算法刷题之外把用户行为分析的基本概念、埋点数据流转链路、SQL在大数据场景下的优化思路都过一遍。笔试是一次双向筛选它不只是在考你会不会也在帮你判断这家公司做的事情和你的兴趣方向是否匹配。如果你正在准备类似的数据类公司秋招最后再分享一个个人体会的小技巧笔试题里所有跟业务相关的场景题不要只写答案尽量把答案背后为什么这么设计的逻辑写出来。面试官看的不只是结果更是你的思考过程。毕竟真正的工作里不会有人给你一套标准输入输出更多时候是你从一堆模糊需求里自己找到那条最合理的路。
返回列表