拼多多的校招笔试题,一直以来在互联网圈子里都有点“传说”色彩。2018年内推那批题,难度和区分度都做得相当好,既不像普通校招卷那样随便刷刷LeetCode热题就能过,也不像竞赛题那样脱离实际。它考的是很本质的算法功底、代码实现能力和边界条件的敏感度。这套题我反复刷过几遍,每次都有新收获。这篇文章就结合我自己刷题时的踩坑经验,做一次完整的复盘和拆解。
这套题适合谁看?如果你是正在备战大厂校招的研发岗同学,建议逐题精做,因为你很大概率在笔试中遇到同级别的题;如果是工作两三年的开发,想系统回顾一下算法基本功,这五道题也是很好的自测标尺。
1. 整体解题思路与这套题的底层逻辑
1.1 拼多多笔试的出题风格
先说一个很核心的感受:拼多多的笔试题,不绕弯子,就是直白地考你的基础功底。
不像一些公司喜欢出场景题、智力题、甚至阅读理解题,拼多多的题风是相当“硬核”的——大整数乘法、组合数学、贪心模拟、树形结构。题目描述都不长,但每一题的考察点都踩在算法和数据结构的关键位置上。
从2018年内推这套题来看,出题人的逻辑非常清晰:基础的数据结构要熟练,尤其是模拟题要写得又快又准;基础的算法思维要扎实,不要一上来就想复杂的优化,有时候最简单的思路反而是最优解;边界条件处理能力是关键,在笔试环境里没有调试器,一旦边界出错就是挂。
1.2 五道题的类型拆解与能力覆盖
把五道题放在一起看,其实就是一个完整的算法能力测试矩阵:
| 题目 | 考察方向 | 难度评级 | 核心数据结构/算法 |
|---|---|---|---|
| 大整数乘法 | 高精度运算 | 中低 | 数组模拟、进位处理 |
| 数三角形 | 组合数学 | 中 | GCD、去重逻辑 |
| 最大乘积 | 贪心/扫描 | 中 | 极值维护、负负得正 |
| 小熊吃糖 | 模拟题 | 中高 | 多维排序、贪心选择 |
| 选靓号 | 状态枚举 | 高 | 枚举、成本计算、字典序调整 |
前两题是基础中的基础,属于保障分;中间两题开始拉差距,需要你真正理解贪心和模拟的精髓;最后一题是压轴题,它考验的是你在复杂度压力下,能否找到正确的枚举状态,并处理字符串的字典序问题。
1.3 为什么这套题值得反复刷
我的观点是:刷算法题,刷的不是题,是解题的思维惯性。
这套题里几乎没有偏题怪题,每一道都是在训练你面对一个“看着不难,但写起来容易出错”的任务时,如何理清思路、一次通过。这种能力,在真实的开发工作里非常重要——你写的每一段业务逻辑,本质上都是一个需要处理各种边界的“模拟题”。
2. 高精度不犯错的底层技巧:大整数乘法详解
2.1 题目描述与本质分析
这道题的原题是这样的:给定两个非常大的正整数,它们的长度可能达到几千位甚至更多,要求输出它们的乘积。
很多同学第一次看到这道题,心里想的是:“用Python不就直接乘吗?”或者“用Java的BigInteger啊”。这些思路没有错,但注意,笔试出这道题的目的,恰恰是考察你在语言没有大整数支持时的处理能力。
在C++里没有内置的大整数类型,在Java里虽然有BigInteger,但如果你直接用,某种程度上等于放弃了这道题考察的核心——高精度运算本身。在实际工作中,高精度计算也经常出现在金融系统、加密算法、科学计算等场景里。
2.2 竖式模拟法的完整推导
高精度乘法的核心思想,就是把我们在小学数学里学的竖式乘法,用数组和循环来实现。
假设第一个数的每一位是a[0]、a[1]、a[2]...(从低位到高位存储),第二个数同理。那么结果数组的第i+j位,需要累加a[i] * b[j]的结果。
这是关键推导:
def multiply(num1, num2): # 处理特殊情况 if num1 == "0" or num2 == "0": return "0" m, n = len(num1), len(num2) # 两个数相乘,结果的位数不会超过m+n位 result = [0] * (m + n) # 从低位到高位逐位相乘 for i in range(m - 1, -1, -1): for j in range(n - 1, -1, -1): mul = (ord(num1[i]) - ord('0')) * (ord(num2[j]) - ord('0')) # 关键:累加到对应位置 # i+j+1对应当前位的“个位”位置 p1, p2 = i + j, i + j + 1 total = mul + result[p2] result[p2] = total % 10 result[p1] += total // 10 # 去掉前导零 res = ''.join(map(str, result)) return res.lstrip('0')2.3 为什么结果数组长度是m+n
这里有个数学小结论:一个m位数和一个n位数相乘,结果的位数,要么是m+n-1,要么是m+n。
举例来说,99(两位数)乘以99(两位数)等于9801(四位数),是m+n;而10(两位数)乘以10(两位数)等于100(三位数),是m+n-1。所以开m+n长度的数组一定够用,最后去掉前导零就行。
我当年写这道题时,犯过的一个低级错误是:直接在result[p2]位置存放乘积结果,而不是累加。这样第一轮循环没问题,第二轮的时候就覆盖掉了之前的结果。正确做法是:先把mul和result[p2]当前的临时值相加,再拆分个位和进位。
2.4 这道题的三个易错点
字符转数字时,一定要减去字符'0',而不是直接拿字符的ASCII码去算。ASCII码的48到57对应数字0到9,直接取值会得到完全错误的结果。
处理乘数为0的情况。很多实现里,如果不特判0,最后的结果会是一串0而不是一个0。虽然lstrip('0')能处理前导零,但如果所有位都是0,它会把整个字符串变成空串。这就是我前面代码里先判断乘数是否为0的原因。
存储顺序的选择。我习惯从低位到高位存储,这样进位的时候是往高一位进位,符合自然思维。但如果你从高位到低位存储,进位方向就反了,会在取模和整除那里折腾很久。选一种你习惯的、不会搞混的顺序,一以贯之。
注意:高精度加法、乘法、减法在思路上一脉相承,都是“每一位单独运算,然后处理进位/借位”。把大整数乘法的模板背熟,遇到大数相关题目就能快速套用。
3. 最大乘积与去重组合的思路对比
3.1 最大乘积的贪心思维
这道题的描述是:给定一个整数数组,长度至少为3,从中选出三个数,使得它们的乘积最大,输出这个最大乘积。
注意,数组里的整数可能是负数,也可能是0。
很多第一次看到这道题的同学,第一反应是“排序然后取最后三个数相乘”。这个思路在全是正数的情况下没问题,但一旦出现负数和0,就完全失效了。举个例子:数组是[-100, -98, 1, 2, 3],排序后最后三个数是1、2、3,乘积是6。但正确答案应该是(-100)乘以(-98)乘以3等于29400,因为两个负数相乘得到很大的正数。
所以要分情况讨论,最大乘积只有两种可能:
- 最大的三个正数相乘(或者都是负数时,最大的三个数相乘,比如-1、-2、-3的乘积是-6,比-1、-2、-100的乘积-200要大)
- 最小的两个数和最大的那个数相乘
这个结论背后是一个很简单的数学事实:乘积要最大,要么全取正数里最大的,要么取两个绝对值最大的负数来“变正”。
写代码时,一种做法是:排序后,比较nums[n-1] * nums[n-2] * nums[n-3]和nums[0] * nums[1] * nums[n-1],取较大值。
def maximum_product(nums): nums.sort() n = len(nums) return max(nums[0] * nums[1] * nums[-1], nums[-1] * nums[-2] * nums[-3])就这么几行代码,但背后是清晰的贪心分类讨论。我自己的体会是:排序法简单直接,时间复杂度是O(nlogn),在笔试完全够用。如果你追求极致性能,可以用线性扫描维护三个最大值和两个最小值,时间复杂度降到O(n),但代码量和出错概率都会增加。
3.2 数三角形的去重与共线检测
数三角形这道题,描述是这样的:给定平面上若干个点,问能组成多少个不同的三角形。
这道题第一眼看去,很容易想到暴力枚举:任取三个点,判断是否共线,不共线就是三角形。但这里有两个坑:
计算量。如果点是100个,C(100,3)是161700种组合,这个数量级暴力枚举是能过的。但如果点是1000个呢?C(1000,3)约等于1.6亿,暴力枚举就非常吃力了。
共线判断的精度问题。如果用斜率判断共线,在浮点数计算时可能会遇到精度问题。三个点共线的本质是:向量叉积为零。也就是说,点A、B、C共线,等价于(B.x - A.x)乘以(C.y - A.y)减去(B.y - A.y)乘以(C.x - A.x)等于0。用整数运算,就能完全避开浮点误差。
def count_triangles(points): n = len(points) count = 0 for i in range(n): for j in range(i + 1, n): for k in range(j + 1, n): x1, y1 = points[i] x2, y2 = points[j] x3, y3 = points[k] # 向量叉积,为零则共线 cross = (x2 - x1) * (y3 - y1) - (y2 - y1) * (x3 - x1) if cross != 0: count += 1 return count3.3 大数量级时的优化:GCD去重
如果点的数量达到几千甚至上万,三重循环就废了。这时候需要换一个思路:枚举每个点作为三角形的一个顶点,然后计算它到其他所有点的向量,按方向去重。
这个思路的数学基础是:从点A出发,如果有m个点与A的连线方向相同,那么从这m个点中任选两个与A组成的三角形是不成立的(共线),所以以A为顶点的不共线三点组合总数是C(n-1, 2)减去所有共线方向上的C(m, 2)。
方向去重时,需要把向量(x, y)化简为最简分数形式,这里就用到了最大公约数(GCD)。比如(2, 4)和(1, 2)化简后都是(1, 2),代表同一个方向。
关键点在于:向量要统一正负号标准,比如规定x为负时,整体取反;x为0时,规定y为正。否则(1, -2)和(-1, 2)会被当作两个方向,但实际上它们在同一条直线上。
这个题的核心就是去重和共线检测,看起来是几何题,考的是组合数学和数论。想要精刷校招题库的朋友,这道题值得多花点时间。
4. 小熊吃糖的模拟题优化
4.1 初看简单的表象
题目描述大致是:有若干只小熊,它们的战斗力不同,饥饿值也不同。现在有若干颗糖,每颗糖有对应的饱腹度。每只小熊按照战斗力从高到低的顺序依次选择糖果——选择目前剩余的、能让它吃饱且不超过它饥饿值的最大一颗糖。如果吃完一颗糖后还是饿,可以继续选下一颗,直到吃饱或糖果被选完。最后输出每只小熊剩余饥饿值。
这题第一眼看过去,不就是模拟嘛,排序就行了。但实际上手后会发现,情况比想象中复杂,因为每只小熊可以吃多颗糖。
4.2 双排序的解题框架
我的做法分三步走:
第一步,把小熊按战斗力降序排列,同时记录它们原来的索引,因为输出时要按输入顺序输出。
第二步,把所有糖的饱腹度排序。
第三步,按战斗力顺序遍历小熊,对于每只小熊,从大到小遍历剩余的糖,只要这颗糖的饱腹度小于等于它的剩余饥饿值,就吃掉它,同时减少饥饿值。
def bears_and_candies(bears, candies): # bears: [战斗力, 饥饿值] # candies: [饱腹度] n = len(bears) # 记录原始索引 bears = [(bears[i][0], bears[i][1], i) for i in range(n)] # 按战斗力降序排列 bears.sort(key=lambda x: x[0], reverse=True) candies.sort(reverse=True) result = [0] * n used = [False] * len(candies) for power, hunger, idx in bears: remaining = hunger for i in range(len(candies)): if used[i]: continue if candies[i] <= remaining: remaining -= candies[i] used[i] = True # 零饥饿提前退出 if remaining == 0: break result[idx] = remaining return result4.3 复杂度分析与优化方向
最坏情况下,每只小熊都要遍历所有糖果,复杂度是O(n*m),n是小熊数量,m是糖果数量。如果n和m都达到10^4量级,这个复杂度在笔试中可能会卡时间。
怎么优化?关键是可以利用糖果的排序特性。糖果已经按降序排列了,对于一只小熊,找到第一颗不超过当前饥饿值的糖,可以用二分查找快速定位。如果一棵糖被吃掉了,可以用并查集(并查集)跳过已经用过的糖果位置,让查找效率接近线性。
这里的贪心思路是“每次选能吃的最大糖”,这保证了最优解。为什么?因为对于一只固定顺序的小熊来说,它消耗的饥饿值越大,后续剩余饥饿越低。同样的饥饿值下,先吃大糖不会损害后续选择,因为在它选糖期间,没有其他熊能插队。
4.4 模拟题在笔试中的通用套路
小熊吃糖这道题,是我认为整套试卷里最“职场化”的一道题。 它的核心是:在多种排序规则的场景下,找到一种不会冲突的贪心策略。
我做模拟题的经验是:不要急着写代码,先纸上推演一遍完整流程。把“输入排序”、“处理逻辑”、“输出还原”三段式结构理清楚,再动手编码。这样写出来的代码,基本不会出现“明明逻辑对,但输出顺序错”的尴尬局面。
5. 选靓号的复杂枚举与字典序编程
5.1 题目描述与问题转化
选靓号这道题,是整套试卷的压轴题。题目大意是:给定一个手机号(一串数字),你可以把其中任意一个数字改成任意其他数字,每次修改的代价是原始数字与目标数字差的绝对值。现在最多允许修改k次,要求修改后得到的号码中,至少存在一个数字出现次数最多(不是指定数字,是至少有一位数字出现了最大次数),且需要让这个号码的字典序最小。
说实话,我第一次看这道题的时候,人是懵的。因为变量太多了:改哪个位置、改成什么数字、修改次数怎么分配、字典序怎么保证最小。
解题的关键是:不要试图同时处理所有数字,而是固定一个目标数字,然后让最大化它的出现次数。
5.2 核心枚举:固定目标数字
枚举目标数字d(从0到9),把原始号码的每一位数字都看作一个“候选”,计算它变成d的代价。然后选择k次修改中最小的k个代价对应的位置,把这些位置改成d。
这个思路看起来简单,但有一个极其关键的点:这里有一个很反直觉的“越界修改”的问题。举例来说:把8改成0,代价是8,这在“把0变成8”的目标里是合理的。但如果你不限制修改次数,把8改成0后再把0改成8,就浪费了一次修改。
所以要明确:每个位置最多只能被修改一次。在计算代价时,只需要计算|原数字 - d|,修改一次到位,不要出现二次修改。
5.3 处理字典序最小:优先级策略
固定目标数字d之后,字典序最小怎么解决?这个问题的难度甚至比“怎么选修改位置”更大。
核心结论:对于每位数字x,如果它要变成d,当d大于x时,修改后数字变大,这种情况要尽可能把修改机会放在高位;当d小于x时,修改后数字变小,这种情况要尽可能把修改机会放在低位。
为什么?因为字典序是从高位向低位比较的。高位数字变小,会直接让整个号码的字典序变小;高位数字变大,会让字典序变大。所以:
- 如果d < 原数字,也就是要变小,尽量在低位修改,这样高位保持原样,字典序更小
- 如果d > 原数字,也就是要变大,尽量在高位修改,这样高位变大后,整个号码虽然变大了,但如果必须变大,就让它变在更低位
这个优先级关系,可以用一个简单的排序规则实现:按代价升序排列,代价相同的,优先修改更靠后的位置(对于d < 原数字的情况)。但这里有一个特例:如果d > 原数字,反而要优先修改更靠前的位置。所以排序规则实际上是两段的。
这个细节,是选靓号这道题里最容易出错的地方。我当年第一次写的时候,只考虑了代价排序,结果样例过了,但提交后大面积报错。调试了很久才发现是字典序的优先级问题。
5.4 完整实现参考
def beautiful_phone_number(num, k): n = len(num) # 记录每个数字出现的次数 best = -1 best_digit = -1 for d in range(10): target = str(d) # 计算每个位置变成目标数字的代价 changes = [] for i, ch in enumerate(num): cost = abs(int(ch) - d) changes.append((cost, i)) # 按代价排序,代价相同按位置排序 # 这里的排序规则需要根据d和原数字的相对大小来定 # 简化处理:先按代价排序,相同代价按位置降序(优先改低位) changes.sort(key=lambda x: (x[0], -x[1])) cnt = 0 total_cost = 0 tmp = list(num) for cost, pos in changes: if cnt >= k: break if cost == 0: continue total_cost += cost tmp[pos] = target cnt += 1 # 统计tmp中target出现的次数 freq = tmp.count(target) if freq > best: best = freq best_digit = d best_arr = tmp # 这个实现做了一点简化,完整版需要根据字典序精细调整排序方向 return ''.join(best_arr)注意:这个实现是简化的逻辑,完整版还需要处理代价为0的情况、修改次数不足k但已经最大化的边界情况等。这里展示的是核心枚举框架。
5.5 字典序相关的通用技巧
选靓号的字典序处理,包含了一个很重要的通用技巧:枚举+优先级控制。
拿到任何一道“在多种方案里选取字典序最优”的题目,可以先固定方案的关键参数,然后在调整过程中用一个明确的优先级来指导每一步。优先级通常来自“字典序的定义”——从高位到低位逐个比较。
6. 笔试实战:时间分配与自测清单
6.1 考场上的时间分配建议
以2018拼多多内推笔试为例,通常2到3小时做3到5道题。我的建议是:
- 第一题(大整数乘法)属于送分题,15分钟内必须写完并且确保对
- 第二题(最大乘积)排序法5分钟内搞定
- 第三题(数三角形)暴力法先保底,如果时间充裕再优化
- 第四题(小熊吃糖)30分钟到40分钟,这是决定你能不能通过的分水岭
- 第五题(选靓号)最后做,如果前面还有bug要修,果断放弃这题的优化
整体原则:先易后难,先拿到保底分,再挑战高分题。
6.2 提交前的自查清单
我在刷这套题时,总结了一个笔试自测清单,每题提交前逐条对照:
- 全0输入测了吗?空数组/空字符串呢?
- 边界值测了吗?比如最大值、最小值、负数、0。
- 输出格式对吗?题目要求每行一个结果,还是结果用空格分隔?
- 变量类型对吗?大数溢出怎么办?
- 如果有排序,索引对应关系对了吗?
- 时间复杂度和空间复杂度在题目限制下能过吗?
特别是第一题和第五题,边界情况是重灾区。写完代码后,用几个极端用例在脑子里跑一遍,能抓住大量隐藏bug。
6.3 笔试环境与IDE的磨合
还有一个很多人忽略的细节:笔试平台用的是牛客网这类在线评测系统,它的输入输出格式和本地环境不太一样。有的平台要求使用标准输入输出,有的平台会自动读文件。如果你平时习惯本地IDE调试,考前一定要先在模拟环境里做几道题,熟悉打印调试(print调试)的习惯,因为你不能像在IDE里一样设置断点和单步执行。
另外,代码的输入解析也是一大坑。笔试的输入通常是多行文本,需要自己按行读取和拆分。C++的cin和getline混用会出问题,Python的input()和sys.stdin.readline()行为也不一样。我见过太多人因为输入解析卡住,最后白白浪费时间的例子。
7. 从校招笔试到工程能力:这套题的长期价值
7.1 算法题之外的东西
刷完这套题,除了算法本身,还有一些更重要的收获。
第一题大整数乘法,锻炼的是“用基础数据结构实现看似简单但实际有陷阱的功能”的能力。在工作中,处理订单号拼接、金额计算、优惠券叠加时,你经常会遇到类似“直接相加会溢出,需要自己维护精度”的问题。
第三题数三角形,本质上是在训练“把几何问题、组合问题,转化成纯整数计算”的思维。这种能力在图形学、游戏开发、甚至CAD软件的应用中都有用武之地。
7.2 边界条件的工程意义
很多人觉得笔试考边界条件是“应试技巧”,但我不这么认为。
真实的生产环境里,你写的代码要面对各种用户输入的边界情况。一个会传空字符串参数的调用方,一个可能超出整型范围的金额数值,一个因为时区导致的时间边界问题。每一个都是线上事故的隐患。
拼多多这套题里反复出现的“负数、0、重复元素、前导0”,其实就是编程基本功里最核心的几个边界类型。能把这几类边界问题处理得干净利落的人,写工程代码时也会有更强的容错意识。
7.3 后续可以继续扩展的方向
如果你刷完这套题还有余力,我建议按这几个方向继续深入:
- 高精度运算的进一步扩展:大数除法、大数取模、大数的进制转换
- 贪心算法的进阶:区间调度、哈夫曼编码、活动选择问题
- 模拟题与状态机:很多业务逻辑本质上是一个状态机,面试时能画出状态转移图,会让人眼前一亮
- 字典序问题的变体:字典序的第K个排列、下一个排列、字符串字典序比较的变种
这些内容在LeetCode和牛客网上都有大量对应习题,难度覆盖从入门到竞赛级,可以根据自己的水平逐步提高。
7.4 最后再分享一点
刷这套题时,我发现一个很有意思的现象:大多数人卡住的地方,不在算法本身,而在“理解题意”和“处理边界”。比如选靓号,明明枚举目标数字的思路几十行就能写完,但很多人在“字典序最小”这个要求上绕了很久。
我的经验是:面对每一道编程题,先用通俗语言把题目复述一遍,把输入输出样例手动推演一遍,再动手写代码。这个习惯养成后,不仅笔试正确率大幅提升,连日常开发里接到需求时的理解速度都会快不少。
拼多多这套2018校招内推题,放在今天依然是很好的训练材料。如果你能把这几道题吃透,达到看到题目就能快速梳理出“用什么算法、需要维护什么状态、有哪些边界要处理”的水平,那你在技术面试中的算法环节,基本上就有了扎实的底子。