电商校招编程题实战:从状态压缩DP到TopK堆的Python解法

发布时间:2026/9/1 20:21:04
电商校招编程题实战:从状态压缩DP到TopK堆的Python解法 秋招那阵子我整理过不少电商公司的编程题Shopee 2019校招这批题目尤其有意思——题目本身不挂公司Logo但你一眼就能看出它是照着电商业务出的满减券、热销榜、仓配路径、商品ID校验。很多平时在LeetCode上刷得飞起的同学一到这种业务场景题反而卡壳问题往往不是算法基本功不行而是没有把业务语言翻译成算法条件。这篇文章我想从几道典型的业务场景编程题出发讲讲出题逻辑、Python实现细节以及笔试现场最容易翻车的隐藏扣分点。无论你是正在准备校招还是想转做业务后端这组思路都可以直接拿来参考。1. 先搞明白Shopee这类电商公司的笔试题为什么长这样1.1 从2019年校招特点聊起2019年前后正是东南亚电商高速扩张的阶段Shopee这类平台对后端研发、算法岗的需求量很大。和国内大厂喜欢出纯数据结构题不同Shopee笔试题有一个明显倾向把电商真实业务里每天都会遇到的问题抽象成一道可以在45分钟内写完的编程题。它不是单纯考你会不会背红黑树而是考你在业务约束下能不能快速建模、写出可运行的代码。我当时第一感觉是题目看起来都不难比如给你一个商品价格数组、一个满减规则让你算最优支付金额再比如给你一批销量数据让你输出热销TopK。难的是你能否一眼看穿它背后对应哪个经典算法同时把业务里那些模糊条件转化成明确的输入输出约束。笔试时间有限如果读题阶段就卡住了后面基本没戏。1.2 业务场景直接映射到算法考点我整理过这批题目的考点分布发现规律比较清晰业务场景考点典型数据结构满减券计价动态规划 / 子集枚举数组、位运算热销商品TopK堆 / 快速选择优先队列仓库配送成本动态规划 / 图最短路径二维数组、邻接表商品ID校验字符串处理 / 状态机哈希集合、正则订单库存扣减贪心 / 二分答案数组、双指针看懂这张表你会发现它跟LeetCode热门题型的分布基本一致只不过套了一层业务外壳。所以准备这类笔试核心不是去背业务背景而是把常规算法练到闭眼就能写的熟练度。下面我挑几类高频题型展开讲每道题都给出可复现的Python解法。2. 第一类必考题折扣与结算顺序考的不是贪心是建模2.1 还原一道满减券分组结算题这道题的原型大概长这样购物车里有 n 件商品价格存在数组prices里平台有 m 张满减券第 i 张券的规则是“订单金额达到threshold_i时减免discount_i”。每张券只能用一次每笔订单最多使用一张券。你可以把任意几件商品合并成一笔订单也可以把一件商品单独下一单问最低总支付金额是多少。约束是 n 不超过 15价格和门槛都是正整数。很多同学拿到题的第一反应是“按折扣力度从大到小排序能凑单就凑单”这是一个典型的错误示范。满减券不是无门槛红包它要求你先凑到门槛才能减免所以最优策略往往是先把小额商品组合起来“够到门槛”而不是把优惠券硬套在大额商品上。举个例子A商品120元B商品80元券是满100减20如果直接对A用券B单独支付总价是10080180但把A和B合并成200元的订单用券后总价是180一样。换一组数据A商品150元B商品30元同样满100减20分开付款是13030160合单是180-20160还是一样。别急着下结论真实场景里多张券叠加时问题会突然变复杂。我把数据加到一个临界点商品价格 [70, 60, 50]三张券分别是满100减20、满80减10、满50减5。如果直接按门槛从高到低凑单7050用满100减20支付10060用满80减10不满足门槛只能用满50减5支付55合计155。但实际上最优分组是7060用满100减20支付11050用满50减5支付45合计155再换一种分组70单独用满50减5支付656050用满100减20支付90合计155。可以看出答案不再那么直观必须枚举所有可能的分组方式。2.2 Python解法与复杂度分析n 不超过 15这个约束是一个非常明确的信号可以用状态压缩枚举子集。我们把每件商品看成一个二进制位用一个整数 mask 表示一组商品的集合group_price[mask]表示这个集合的总价。再用best_discount[mask]表示这个集合作为一笔订单时能享受到的最大减免金额。剩下的问题就是把所有商品划分成若干组让总支付最少。这是一个典型子集DP。状态dp[mask]表示已经处理完mask中这些商品时的最低总支付转移时枚举mask的子集作为“新开的一笔订单”from typing import List def min_payment(prices: List[int], coupons: List[List[int]]) - int: n len(prices) m 1 n group_price [0] * m for mask in range(m): s 0 for i in range(n): if mask i 1: s prices[i] group_price[mask] s best_discount [0] * m for mask in range(m): for threshold, discount in coupons: if group_price[mask] threshold: best_discount[mask] max(best_discount[mask], discount) dp [float(inf)] * m dp[0] 0 for mask in range(1, m): sub mask while sub: prev mask ^ sub cost max(0, group_price[sub] - best_discount[sub]) dp[mask] min(dp[mask], dp[prev] cost) sub (sub - 1) mask return dp[m - 1]这里面有一个细节best_discount[sub]可能大于group_price[sub]比如一张满50减100的异常券但实际支付金额不能是负数所以外面套了一层max(0, ...)。笔试里这种边界条件如果没加样例过了换一组数据就会WA。while sub枚举子集的写法是sub (sub - 1) mask这个技巧必须熟练它的时间复杂度是 O(3^n)n15 时大约 1400 万次枚举Python 在笔试时间内可以跑完但不能再大了。2.3 这道题真正想看的三个能力第一个是识别小数据范围的能力。看到 n 15 就要立刻想到枚举所有组合而不是纠结贪心是否正确。第二个是状态压缩DP的熟练度子集枚举的代码必须默写。第三个是把业务规则翻译成程序约束的能力“每张券只能用一次”“每笔订单最多用一张券”这些条件如果漏掉任何一个答案就会偏。这个题还有一个变体商品必须按原始顺序切分成连续区间。一旦加了顺序约束问题就退化成区间DP状态从dp[mask]变成dp[i]表示前 i 件商品的最优解转移时枚举最后一段的起点。我建议你把这两个版本都写一遍对“约束如何影响算法选型”会有更直观的感受。3. 第二类必考题海量数据下的TopK别一上来就排序3.1 热销商品TopK的经典长相第二类高频题是热销榜。题目描述通常是给定 N 条商品销量记录格式是商品ID, 销量输出销量最大的 K 个商品ID按销量从高到低排序。N 可能很大比如 10^7 级别但 K 比较小比如 20。大部分学校课程里教的是“全部排序后取前K个”这个方法在数据量小的时候没问题但 10^7 条记录全排一遍时间和内存都不划算。TopK 的正确思路是维护一个大小为 K 的最小堆遍历数据时如果当前元素比堆顶大就把堆顶替换掉。这样遍历一遍就能拿到最大的 K 个时间复杂度 O(N log K)内存占用 O(K)。3.2 最小堆的Python实现细节Python 里直接用heapq模块但有几个坑必须注意。堆元素是元组(cnt, item_id)排序优先级是先比较 cnt再比较 item_id。如果你想要销量大的排前面堆里存原始销量即可让最小的销量在堆顶方便替换最后输出时再反转。import heapq def top_k_sales(records, k): heap [] for item_id, cnt in records: if len(heap) k: heapq.heappush(heap, (cnt, item_id)) elif cnt heap[0][0]: heapq.heapreplace(heap, (cnt, item_id)) res [] while heap: res.append(heapq.heappop(heap)[1]) return res[::-1]可能有同学会问为什么不用heappushpopheapreplace和heappushpop在堆大小为 k 时的效果几乎一样区别在于当新元素不小于堆顶时heappushpop会先 push 再 pop而heapreplace是先 pop 再 push。两者在这道题里结果一致但heapreplace更高效。这个细节面试官追问起来能答出区别的人不多属于加分项。3.3 快速选择与堆排序的实战取舍除了堆TopK 还有一个经典方案是快速选择Quickselect平均时间复杂度 O(N)最坏 O(N²)。笔试中我会优先写堆原因是快速选择属于“期望复杂度”优秀但代码里涉及随机选 pivot、边界递归出错率比堆高不少。堆方案虽然多一个 log K 的因子但 K 通常很小实际耗时完全可以接受而且代码稳定。再补充一个真实业务中会遇到的问题如果商品 ID 不是简单的整数而是长字符串比如SPU20250316ABC123那么排序时的比较操作会比整数慢。堆里存(cnt, item_id)时item_id只会在销量相同的情况下参与比较正常业务里销量相同的商品不会太多所以影响不大。这个观察在系统设计面试里同样适用排序字段的选择要优先避免长字符串的频繁比较。4. 第三类必考题最短配送路径把DFS优化成DP的过程4.1 题目还原网格配送成本第三类题型是仓配路径。题目原型是给定一个 m 行 n 列的矩阵每个格子表示一个仓库或配送节点格子里的数字表示经过这个节点需要的配送成本。机器人从左上角(0,0)出发只能向右或向下走最终要到达右下角(m-1,n-1)问最小总成本是多少。这是最经典的网格DPLeetCode 64题的原型。但它在校招笔试里的出现率极高因为代码短、考点清晰而且可以追问空间优化和路径还原。4.2 从暴力递归到状态转移我见过不少同学一上来就写DFS递归提交后发现超时。如果题目没有特别说明数据量小网格题多半要往DP想dp[i][j]表示从(0,0)走到(i,j)的最小成本。由于只能向右或向下走(i,j)的前一个位置只可能是(i-1,j)或(i,j-1)所以状态转移方程是dp[i][j] min(dp[i-1][j], dp[i][j-1]) grid[i][j]边界位置单独处理第一行只能从左往右累加第一列只能从上往下累加。下面是完整实现def min_delivery_cost(grid): m, n len(grid), len(grid[0]) dp [[0] * n for _ in range(m)] dp[0][0] grid[0][0] for i in range(1, m): dp[i][0] dp[i-1][0] grid[i][0] for j in range(1, n): dp[0][j] dp[0][j-1] grid[0][j] for i in range(1, m): for j in range(1, n): dp[i][j] min(dp[i-1][j], dp[i][j-1]) grid[i][j] return dp[m-1][n-1]如果面试需要还原具体路径可以额外开一个pre[i][j]记录每个格子是从上方还是左方来的最后从右下角反推。4.3 笔试里的DP代码怎么写不容易翻车DP 题翻车通常翻在初始化上。第一行和第一列的初始化必须在主循环之前完成否则dp[0][0]漏掉或者dp[i-1][j]越界。我的习惯是先把边界情况全部显式写出来再去写主循环哪怕是简单的 dp[i][j] grid[0][0]。再就是空间优化。dp[i][j]只依赖当前行的左边和上一行的同一列所以可以把二维数组压缩成一维def min_delivery_cost_1d(grid): m, n len(grid), len(grid[0]) dp [0] * n dp[0] grid[0][0] for j in range(1, n): dp[j] dp[j-1] grid[0][j] for i in range(1, m): dp[0] grid[i][0] for j in range(1, n): dp[j] min(dp[j], dp[j-1]) grid[i][j] return dp[-1]笔试时先用二维版本保证正确性最后有时间再优化。我不会建议一上来就写一维滚动数组因为空间优化版的初始化逻辑更容易出错一旦写错Debug 花费的时间远大于省下的那点内存。5. 字符串与输入输出笔试现场最容易拖垮你的隐藏扣分点5.1 商品ID校验题的正确打开方式还有一类看着简单、实际很阴的题是字符串处理常见场景是商品ID校验。题目可能会要求你判断一个ID是否合法规则有好几条比如长度8到20位、必须同时包含字母和数字、不能出现AAA这种连续重复三次的字符。题目本身不涉及高深算法但考察的是细心程度和对字符串API的熟练度。我的实现思路是这样先做长度判断再维护三个布尔标记分别记录是否出现数字、是否出现字母、以及是否出现连续重复字符。注意连续重复三次不能只看一个方向我习惯在遍历时同时检查s[i] s[i-1] and s[i] s[i-2]这样只用一次循环就能完成。def valid_product_id(s: str) - bool: if not (8 len(s) 20): return False has_digit False has_alpha False for i, ch in enumerate(s): if ch.isdigit(): has_digit True if ch.isalpha(): has_alpha True if i 2 and s[i] s[i-1] s[i-2]: return False return has_digit and has_alpha这道题最常见的扣分点是漏掉“必须包含大写字母”里的“大写”或者把“不能连续重复三次”误解成“不能出现任何重复字符”。读题慢一点、逐条核对规则比写代码手速快更重要。5.2 本地跑通但OJ报错的常见原因字符串题牵出的另一个大坑是输入输出。笔试题经常给多行输入如果你用for i in range(n):来读但题目第一行给的是测试用例总数不是数据条数就会多读或少读一行。我建议在笔试前固定一套输入输出模板比如用sys.stdin.read().split()一次性把所有token读进来再按顺序取用。这样最稳不容易被空白行和换行符干扰。import sys def solve(): data sys.stdin.read().split() idx 0 n int(data[idx]); idx 1 # 后面继续按顺序取用 if __name__ __main__: solve()另一个高频报错是 Python 的round()行为。它采用的是银行家舍入不是我们数学课学的四舍五入。比如round(2.5)结果是2round(3.5)结果是4。如果题目要求精确到小数点后两位计算金额时建议用Decimal转成整数分再运算别依赖浮点数。5.3 关于Python版本的几个细节顺便说一下Python环境。2025年再看这套题Python 3仍然是主流但不同OJ的Python小版本可能有差异。比如int.bit_count()在 Python 3.8 里没有3.10 之后才有字典的dict保持插入顺序从 3.7 开始是语言规范。这些细节平时写业务代码无感但在笔试环境里可能直接影响你能不能AC。我自己的建议是用你能接受的最保守的Python 3语法写核心逻辑避免使用太新、太花哨的语法糖。校招笔试不是为了炫技而是为了在有限时间内写出稳定的正确答案。6. 刷题路线的复盘建议6.1 按题型而不是按数量来刷从这套2019年Shopee编程题能看出来电商公司笔试的题型高度集中DP、堆、字符串、二分、基础图论。我在辅导学弟学妹时会建议他们先按题型做横向刷题也就是把同一类型的题集中刷10到15道而不是今天做链表、明天做DP、后天又跳到回溯。举一个具体的安排例子第一周专攻DP从斐波那契数列、爬楼梯、打家劫舍这类入门题开始逐步过渡到编辑距离、背包问题、区间DP第二周专攻TopK和堆把heapq的用法练熟第三周专攻字符串写5到8道字符串模拟题。按题型走每次练完后对这类题的套路会有体感到了考场上遇到新题也能快速归类。6.2 做一题要有一题的沉淀我当初刷题时有个习惯每做完一道题会在题目旁边记下三个东西考点、时间复杂度、我犯过的错。不要小看这个动作它是把“刷题量”转化成“解题能力”的关键。比如做完那道满减券分组结算题我会写“子集DP 最低支付金额注意 max(0, cost)”做完TopK我会写“堆替换使用 heapreplace输出前反转”。这些笔记在笔试前一周复习时帮助巨大。与其把LeetCode前300题全部重刷一遍不如只看笔记里的易错点和考点效率高很多。而且很多题你第二次刷的时候大脑会产生“熟悉感”而不是“理解感”如果只看答案不记笔记很容易陷入假性掌握。6.3 我的一点个人体会说回这套2019年校招编程题。我后来跟几个参加过笔试的同学聊发现大家最大的共识是这些题放在今天依然不过时不是因为题目本身有多难而是因为它考察的东西是业务后端每天都要面对的基础能力——如何在约束下建模、如何选择合适的数据结构、如何写出边界正确的代码。编程题之外技术面还会问项目、问系统设计但代码能力始终是第一道门槛。如果你正在准备类似的校招笔试我的建议是别把希望寄托在“背题”上而是把一个题型的底层逻辑吃透。比如满减券分组那道题你理解了“n小就枚举子集”这个判断以后遇到类似的组合优化问题你就能举一反三。再比如TopK那道题你理解了“数据量大、K小时用堆”这个原则以后遇到实时排行榜、热点统计思路自然就有了。编程题其实是在训练一种思维习惯这种习惯不只在笔试时有用在真实工程里同样值钱。