Shopee 2019校招的编程题,放到今天看依旧是一套非常标准的“算法能力体检表”。当年这批题传出来之后,很多刷题群都在分析,结论出奇一致:没有偏题怪题,全是 LeetCode 上被人反复做烂了的经典题型,但加了一些电商业务里常见的“小变形”。这也恰恰是校招笔试最真实的模样——不是考你会不会做难题,而是考你在有限时间内,能不能把基础算法用熟、用准、讲清楚。这篇文章我把真题里最高频的几个套路挑出来,逐题拆解法思路、代码细节和面试官追问的角度。正在准备秋招的人、刷题遇到瓶颈的人,以及想知道电商公司笔试到底在考什么的人,都可以拿这份复盘当作参考坐标。
1. 题目整体观感:这批真题在筛选什么能力
1.1 题型分布:高频考点集中在四个方向
2019年前后电商公司的校招笔试,题型基本稳定在四类:字符串处理、线性数据结构(栈、队列、链表)、动态规划、二叉树遍历。这套题目里的栈模拟、滑动窗口、路径DP、层序遍历变体,几乎就是这四个方向的代表。
| 考察方向 | 代表题型 | 核心算法模型 |
|---|---|---|
| 字符串处理 | 最长无重复子串 | 滑动窗口、哈希集合 |
| 线性数据结构 | 简化路径 | 栈模拟 |
| 动态规划 | 最小路径和 | 状态转移、滚动数组 |
| 二叉树 | 之字形打印 | BFS、层序遍历 |
为什么是这四个方向,而不是更多冷门算法?我个人的理解是:电商业务里最常打交道的无非是订单状态流转(状态机,用栈或队列模拟状态变化)、用户行为序列(字符串处理、滑窗统计)、路径和资源规划(DP、贪心)。算法题出得偏基础,恰恰是面试官在考察候选人的基本功下限——一个连这些高频模型都不能熟练驾驭的人,放到真实业务里大概率会在抽象建模环节卡住。
从难度曲线上看,这批题大概有六成是中等难度、三成简单题、剩下可能有一道偏难的压轴,但再难也基本不会超出“经典题加一个变换”的范围。这和后来很多互联网公司动辄上困难题的做法形成了鲜明对比。不过不要因此就低估它,简单题不等于容易全对,笔试判分往往是看测试用例通过率,边界条件写漏一个空指针,该扣的分一分不少。
1.2 从电商业务反推考法:这些题不是随便选的
很多刷题的人只关注“怎么解”,很少有人去想“为什么出这道题”。放在Shopee这类电商公司的场景里,题目和业务其实是能对上的。
举个例子,简化路径那道题,本质是在做系统里最常遇到的路径规范化,用户在网页端上传文件、后端拼接存储路径,到处是脏路径处理;合并区间对应的是把多个数据源返回的时间段、库存区间、价格区间合并成一个干净的集合;最长无重复子串这种滑窗题,往业务上靠就是针对用户点击序列、搜索词序列做无重复行为窗口分析。面试官出题时不见得真想让候选人联系业务,但这些题确实是电商后端开发中最常见的算法原型。
所以复盘真题最有效的方式,不是背答案,而是看自己能不能在解题时自然联想到业务里的同类场景。能联想到的,说明抽象能力在起作用;联想不到的,也别着急,先把套路练熟,抽象能力会在大量做题后慢慢长出来。
2. 经典题拆解:五道题吃透五种必考套路
为什么挑这五道题?因为我发现它们恰好覆盖了五个最高频的算法套路:滑动窗口、单序列动态规划、栈模拟、二叉树层序遍历、排序贪心。下面逐一拆解,代码部分是我在本地跑过验证的版本,重点讲“为什么这么写”,而不是“怎么写”。
2.1 最长无重复子串:滑动窗口的“右扩左缩”心法
题目描述很简单:给定一个字符串,找出其中不含有重复字符的最长子串的长度。比如输入"abcabcbb",答案是 3,因为"abc"是最长的无重复子串。
最直觉的做法是枚举所有起点和终点,逐字符检查有没有重复,复杂度 O(n^2),数据量大一点就超时。优化的关键点在于想明白一件事:当右指针往右走遇到重复字符时,左指针不需要一步一步试探,而是可以直接跳到重复字符第一次出现位置的下一位。这个“右指针负责扩展、左指针负责收缩”的思路,就是滑动窗口。
代码实现可以这么写:
def length_of_longest_substring(s: str) -> int: left = 0 max_len = 0 seen = set() for right in range(len(s)): while s[right] in seen: seen.remove(s[left]) left += 1 seen.add(s[right]) max_len = max(max_len, right - left + 1) return max_len这里有几个细节值得展开讲。第一,set 只负责判断“窗口里有没有这个字符”,因为题目只需要长度,不需要知道位置,所以 set 是合适的选择;但如果面试官追问“如果需要输出子串本身怎么办”,就要换成字典,记录每个字符最近一次出现的位置,左指针直接跳到max(left, 该位置 + 1)。第二,while 里的 remove 操作是 O(1) 的,每个字符最多进窗口一次、出窗口一次,整体复杂度仍是 O(n)。
现场讲题的时候,我会先说出这三句话:“右指针每走一步把新字符加进窗口;如果发现重复,左指针不断右移直到窗口恢复合法;每一步都更新答案。”三句话说完,面试官就能确定你是真懂,而不是背代码。这道题还有一个常见的变体是“最长无重复子序列”,允许删字符,那就完全不是滑窗的思路了,而要用动态规划,审题的时候千万别混淆。
2.2 最小路径和:动态规划的“原地滚动”技巧
题目描述:给定一个 m 行 n 列的网格,每个格子有一个非负整数,找出一条从左上角到右下角的路径,要求只能向右或向下移动,使得路径上数字的总和最小。
这道题是动态规划里最典型的“最短路径类”问题。定义一个状态dp[i][j]表示从起点走到(i, j)的最小代价,因为只能从上方或左方过来,所以递推关系是dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])。边界上第一行只能从左往右累计,第一列只能从上往下累计。
如果允许修改原数组,最省空间的写法是原地更新:
def min_path_sum(grid): if not grid or not grid[0]: return 0 m, n = len(grid), len(grid[0]) for i in range(1, m): grid[i][0] += grid[i - 1][0] for j in range(1, n): grid[0][j] += grid[0][j - 1] for i in range(1, m): for j in range(1, n): grid[i][j] += min(grid[i - 1][j], grid[i][j - 1]) return grid[m - 1][n - 1]很多同学第一次看到这种写法会问:改了原数组,会不会影响后续计算?不会,因为每一格计算完之后,它的值就变成了“到达该格子的最小代价”,后续格子只需要读取这两个值即可,不需要还原。
如果面试官不允许修改原数组,就用一维滚动数组来优化空间。核心想法是:计算当前行时,只需要上一行的完整结果,更早的行都不需要保留。所以用一个长度为 n 的 dp 数组,每次更新时,dp[j]代表“上一行到达第 j 列的最小代价”,dp[j-1]更新后代表“当前行到达第 j-1 列的最小代价”,两者取 min 再加上当前格子的值即可。从 O(mn) 空间降到 O(n),是这类题在面试中常见的加分点。
这道题也可以反过来想,从右下角往左上角倒推,状态转移方程镜像对称,答案是一样的。这种“正向递推还是逆向递推”的选择,在很多 DP 题里都存在,考的就是你对状态定义的理解深不深。我在复盘时发现,很多人刷了十几道 DP 还在套模板,就是因为在“逆向”这件事上没想透——其实只要状态定义清晰,正推和倒推都只是同一枚硬币的两面。
2.3 简化路径:栈模拟的“拆分-入栈-出栈”三步法
题目描述:给定一个 Unix 风格的绝对路径,例如"/a/./b/../../c/",要求返回规范路径。规范路径要求以/开头,两个目录名之间只有一个/,.表示当前目录需要忽略,..表示返回上一级目录。比如输入"/a/./b/../../c/",输出"/c"。
这道题最漂亮的地方在于它把字符串处理和栈结合得天衣无缝。看到..这种“撤销上一步”的语义,应该立刻联想到栈——因为栈天然支持回退操作。
第一反应是用双端队列然后模拟路径层级,但实际上一个普通栈就够了。思路是先把路径按/拆成若干部分,遍历每个部分:遇到空字符串或.就跳过;遇到..就让栈顶出栈;其余字符串入栈。最后把栈里的目录名用/拼接,前面再加上根目录的/。
def simplify_path(path: str) -> str: stack = [] for part in path.split('/'): if part == '' or part == '.': continue if part == '..': if stack: stack.pop() else: stack.append(part) return '/' + '/'.join(stack)这里有一个很容易被忽略的细节:split('/')之后,连续斜杠会产生空字符串,比如"/a//b"会拆出["", "a", "", "b"],所以代码里第一步就是跳过空字符串。很多人在现场调半天,问题就出在没处理空字符串上。
边界情况也要格外注意:输入是"/"时,栈为空,拼出来是"/",正确;输入包含路径到达根目录之后的..,比如"/../../abc",栈空时不弹出,输出"/abc",也正确。这类题满分很容易写出来,但能不能把所有边界条件都覆盖到,才是拉开差距的地方。
面试官如果追问变体,很可能会问“如果路径不是绝对路径而是相对路径”,或者“如果不允许输出..这种回退操作怎么办”,本质上就是在考察你有没有真正理解栈在这里扮演的角色,而不是死记答案。
2.4 之字形打印二叉树:层序遍历的标志位翻转
题目描述:请实现一个函数按层打印二叉树,第一层从左到右,第二层从右到左,第三层再从左到右,交替进行。
这道题的题眼在于“层序遍历”,只要你熟悉 BFS,剩下的就是每层方向的控制问题。层序遍历的标准做法是用队列,把根节点入队,每次取出当前层的所有节点,把它们的子节点放入下一层。之字形只需要加一个方向标志位,偶数层(从0开始的奇数索引)把结果反转一下即可。
from collections import deque def zigzag_level_order(root): if not root: return [] res = [] queue = deque([root]) left_to_right = True while queue: level_vals = [] for _ in range(len(queue)): node = queue.popleft() level_vals.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) if not left_to_right: level_vals.reverse() res.append(level_vals) left_to_right = not left_to_right return res很多人在这一步会卡住,不是不会 BFS,而是用list.pop(0)去模拟队列,导致复杂度变成 O(n^2)。现场要注意用collections.deque的popleft(),这是 O(1) 的操作。这个细节本身不难,但能在高压环境下条件反射地用对的人并不多。
关于翻转时机,我建议采用“先攒完整层,再统一反转”的方式,逻辑清晰,代码不容易出错。还有一种常见写法是维护一个双端队列,奇数层从尾部塞节点、偶数层从头部塞节点,不用最后 reverse,但理解成本更高,现场容易把自己绕晕。我个人的习惯是优先保证正确性和可讲性,再考虑代码精致度。
二叉树这类题还有一个高频变体是“从底部向上层序遍历”,做法是把最终结果res整个反转,或者用collections.deque的appendleft逐层插入头部,面试时可以主动提一下,能体现你对 BFS 的掌握程度。
2.5 合并区间:排序贪心的“重叠判定”边界
题目描述:给出若干区间[start, end],请合并所有有重叠的区间。比如[[1,3], [2,6], [8,10], [15,18]],合并后是[[1,6], [8,10], [15,18]]。
合并区间是排序加贪心的经典代表,也是电商场景里很常用的抽象模型,比如把多个时间窗口合并、把多段价格区间合并成最终价格。解法非常固定:先按每个区间的 start 升序排序,然后遍历区间,如果当前区间的 start 小于等于结果中最后一个区间的 end,说明有重叠,合并时取两者 end 的较大值;否则直接加入结果。
def merge(intervals): if not intervals: return [] intervals.sort(key=lambda x: x[0]) merged = [intervals[0]] for cur in intervals[1:]: last = merged[-1] if cur[0] <= last[1]: last[1] = max(last[1], cur[1]) else: merged.append(cur) return merged这里最容易踩的坑有两个。第一,排序时如果用 lambda 只按 start 排序,end 顺序无所谓,因为合并逻辑里会重新计算 end;第二,判断重叠时,边界条件取不取等于号很关键,题目如果说“区间相切也算重叠”,那cur[0] <= last[1]就是对的,如果说“相切不合并”,就要把<=改成<。很多笔试用例就是在这种边界上卡人,建议把“重叠判定条件”这句单独讲给面试官听。
另外一个隐蔽的坑是:如果 intervals 的元素是不可变的 tuple,直接写last[1] = max(...)会报错。笔试环境里通常给的是 list,但保险起见可以先转成可修改的结构再做合并。这个细节在 LeetCode 上没什么问题,但在某些自定义测试容器里可能让你浪费五分钟。
做完这道题,我一般会主动说一句“排序复杂度是 O(n log n),遍历是 O(n),总复杂度 O(n log n),空间是 O(n)”。说出来不是为了炫技,而是让面试官知道你对自己的代码有全局认知,这是所有刷题人应该在现场养成的习惯。
3. 笔试和面试之间:为什么能把思路讲清楚比做对更值钱
3.1 面试官围绕真题的四种追问方式
2019年这批校招题在笔试阶段只需要交代码,但等到现场面试,很多题会被翻出来二次追问。面试官手里拿着一道你已经做过的题,通常有四条追问路径。
第一种是让你证明复杂度,问“这个解法为什么是 O(n),有没有可能退化”,比如滑窗口法的 set 操作明明是常数级,但如果字符集特别大,哈希碰撞会不会变慢。第二种是问边界情况,空输入、单元素、重复值、超大数组,每道题都要自己主动讲一遍。第三种是问变体解法,把题目条件改一个词,比如“最长无重复子串”改成“至少包含两个重复字符的最长子串”,看你能不能快速迁移思路。第四种最狠,直接拷问业务映射:你说这个算法在系统里有什么用?
这四种追问没有一种是靠背题能应付的。所以我在复习阶段给自己定了一个规矩:每做完一道题,不看题解先自言自语把思路讲一遍,再尝试回答至少两个“如果……会怎样”的追问。练得多了,现场被追问时反而会觉得是在聊题,而不是在被审问。
3.2 现场讲题的“三段式表达法”
刷题群里有句话叫“刷题五分钟,讲题两小时”,虽然夸张,但道理是真的。面试时的算法题,代码只占一部分评分,表达占另一半。
我推荐的表达框架是三段式:
- 第一段先说思路,不要急着写代码:“这是一道经典的滑动窗口题,我用左右两个指针维护一个无重复窗口,右指针扩展,遇到重复就收缩左指针,整个过程每个字符最多进出一次,所以时间复杂度是 O(n)。”
- 第二段说复杂度:“空间上用一个 set 存窗口内字符,最坏情况是整个字符串无重复,所以是 O(n)。”
- 第三段才开始写代码,写的时候把关键判断用简短注释标出来,重点解释那一行的作用。
看起来很简单,但大多数候选人实际操作时都是先动手写,写一半发现和思路对不上又开始打补丁。正确的顺序应该是先想清楚再动笔,代码一蹴而就,这种稳定感本身就会给面试官留下好印象。
3.3 笔试环境的时间分配与取舍策略
笔试和现场面试还有一点不同:笔试没有人和你互动,时间一到系统自动交卷,所以策略更重要。我的经验是,拿到题先花两分钟把全部题浏览一遍,按难度和熟练度排个序。会做的、有思路的先写,卡住超过15到20分钟就果断跳过,回头再补。
笔试判分通常按测试用例通过率计算,所以“部分正确”也值得拿分。哪怕只能写出暴力解法,也要先交上去,把 60% 的测试用例保住,再去优化。很多同学有一个错误习惯:一道题非要一次写对才继续下一道,结果前面的题全空,最后分数难看。事实上,校招笔试的时间设计本来就预留了“先用暴力拿基础分,再优化拿进阶分”的空间。
另外要注意的是,笔试环境没有智能提示,也没有单元测试帮你验证。写完代码后,一定要在草稿纸上模拟几个典型用例手算一遍再提交。我吃过一次亏,某道栈模拟题在本地 IDE 能跑,在线环境因为输入格式多了一个换行符,导致所有用例全挂,从那以后我再也不敢跳过“手推边界用例”这一步。
4. 复盘与实战心得:那些复习时容易被忽略的细节
4.1 刷题框架化,而不是刷题数量化
现在