我当年准备秋招的时候,刷过不少大厂的笔试真题,拼多多2019秋招这套编程题合集,给我留下的印象特别深。它不像有些公司那样上来就甩一道动态规划压轴题,而是把贪心、字符串处理、排序、模拟这些基础考点翻来覆去地揉进题目里,难度梯度拉得很开,既有让大部分人能拿分的“签到题”,也有需要冷静推导才能啃下来的“拉分题”。这篇文章我就按自己记忆里的版本,把这份合集里三道很有代表性的题目拆开揉碎讲一遍,不管你是正在准备秋招的应届生,还是想练手基础算法的工程师,都能从里面捞到点东西。
先说明一点:题目描述是我按当年看到的版本整理归纳的,细节可能和原题有出入,但核心考点和做题思路是完全一致的。我尽量把“为什么这么想”讲透,而不是只贴一段代码让你背。
1. 拼多多2019秋招编程题的核心考点拆解
1.1 为什么这套题偏爱“贪心+字符串”
看完整份合集,你会发现一个很明显的倾向:题目非常喜欢把贪心策略藏在字符串或者数组操作里面。表面上是让你处理一串数字、一堆数,实际上考的是你能不能找到一个“局部最优能推出全局最优”的结论。这其实很符合笔试的筛选逻辑——在两个小时左右的时间里,面试官没指望你能写出多么精巧的复杂算法,他更想看到你面对一个不熟悉的业务场景时,能不能快速抽象出问题本质,然后用最常见的枚举、排序、滑动窗口、堆这些工具把它解决掉。
贪心之所以被偏爱,是因为它的思维门槛和代码量都比较适合笔试场景。一个贪心策略想清楚了,代码往往二十行就写完;想不清楚,你可能会写一个DFS或者动态规划,一样能做,但调试时间成倍上涨。生活里也有类似的经验:餐厅高峰时段出餐,考的不是厨师会不会做满汉全席,而是能不能在菜品固定、资源有限的情况下,快速判断先做哪桌、后做哪桌,让翻台率最高。笔试里的贪心题,考的就是这种“快速判断先后顺序”的能力。
1.2 难度分层与答题顺序
这套题目的难度大致可以分为三档。第一档是简单模拟题,基本不需要什么算法思想,照着规则写循环就能过,这种题的目标是“必须全对”,不能因为粗心丢分。第二档是中等贪心题,需要你先发现一个规律,比如“局部最优能推出全局最优”,再结合排序或者滑动窗口去实现,这类题决定了你笔试能不能过线。第三档是相对综合的题,可能要用到堆、优先队列,或者更复杂的枚举优化,这类题是用来区分高分的,能写出暴力解拿到部分分也是好的。
我自己的做题顺序是:先把所有题目都读一遍,按照“模拟题 > 贪心题 > 综合题”的顺序做。模拟题最快拿分,先把心态稳住;贪心题需要一点灵感和推导,放到中间;综合题如果十分钟内没有思路,先写一个暴力版本保住部分分,绝不要在一道题上死磕到时间用完。这个策略在拼多多这套题里特别适用,因为它的题目之间并没有互相依赖,每一道都是独立的得分点。
2. 经典题目一:靓号方案的最少修改次数
2.1 题意还原:把号码改成连续m个相同数字
这道题是典型的生活化包装。题目说,手机号码里出现连续相同数字,比如“888”“666”,会被当成靓号。现在你有一个长度为n的数字串,目标是让这个串中某一段连续m个位置的数字变得完全相同,问你最少需要修改其中的多少位。每次修改可以把任意一位数字改成0到9之间的任意数字,修改一位计一次操作。
举个例子,假设数字串是“12893”,m=3。你会发现原串里没有任何连续3个相同的数字。如果目标数字是8,我们可以把第1位1改成8,得到“82893”,这里“88”只有2个连续,不满足;再把第2位2改成8,得到“88893”,连续3个8,修改了2位,这就是一个可行方案。但有没有可能只改1位就凑出连续3个相同数字?你看“12893”里,位置2、3、4分别是2、8、9,没有相同;位置1、2、3分别是1、2、8,也没有相同;位置3、4、5分别是8、9、3,还是没有相同。所以这个例子最少需要修改2位。
这种题如果直接上手去模拟“改哪几位”,很容易绕晕。正确的打开方式是反过来想:先锁定连续m个位最终要变成哪个数字,再看哪些位本来就是这个数字,剩下的就是必须改的位。
2.2 解题思路:枚举目标数字+滑动窗口
因为数字只有0到9十种可能,所以连续相同数字的目标数字一定是这十个数字之一。我们可以依次假设目标数字是c,然后遍历所有长度为m的连续窗口,看每个窗口里有多少个位置的数字不等于c,这些位置就是需要修改的位置。窗口内不等于c的位置数量越少,说明修改次数越少。
为了快速求出每个窗口里不等于c的个数,可以用前缀和数组。定义一个数组pre,pre[i]表示字符串前i个字符中不等于c的字符个数。那么从位置l开始、长度为m的窗口,也就是区间[l, l+m-1],里面不等于c的个数就是pre[l+m] - pre[l]。我们只需要枚举所有合法的起始位置l,取这个差的最小值,然后对十个c都做一遍同样的操作,全局最小值就是答案。
这个思路的时间复杂度是O(10 * n),也就是O(n),对于笔试的数据范围来说非常稳。空间上用一个前缀和数组就够,如果要求更省内存,也可以直接维护一个滑动窗口的计数器,在移动窗口的时候更新,把空间降到O(1)。不过笔试里O(n)空间完全可以接受,我建议先用最清晰的前缀和写法,保证正确性优先。
2.3 完整代码实现(C++)
下面这段代码是按上面的思路实现的,可以直接在在线评测系统里编译运行。
#include <bits/stdc++.h> using namespace std; int main() { int n, m; string s; cin >> n >> m >> s; int ans = n; for (char c = '0'; c <= '9'; c++) { vector<int> pre(n + 1, 0); for (int i = 0; i < n; i++) { pre[i + 1] = pre[i] + (s[i] != c ? 1 : 0); } for (int i = 0; i + m <= n; i++) { int need = pre[i + m] - pre[i]; ans = min(ans, need); } } cout << ans << endl; return 0; }代码本身不长,但有几个细节值得展开说。第一个细节是外层循环遍历的是字符‘0’到‘9’,不是数字0到9,写的时候要注意字符字面量的引号。第二个细节是前缀和数组pre的长度是n+1,pre[0]始终为0,这样处理区间和的时候不需要额外判空。第三个细节是初始ans直接设成n,因为最坏情况下整个串全改一遍,最多也就n次,这个初值一定不会让答案漏掉。
2.4 这道题最容易踩的三个坑
第一个坑是“目标数字”想当然地认为一定是原串里出现过的数字。这是最容易犯的错误。原串里没有‘0’,但把某一段全改成‘0’的修改次数可能比改成任意一个出现过数字都少。所以枚举目标数字时,十个数字都要遍历,不能偷懒。
第二个坑是忽略了窗口可以不是原串中已经存在相同数字的起始位置。有些同学为了少改几步,只去看原串中“已经有两个连续相同数字”的地方,然后试图在这个基础上扩展。这在笔试里会漏掉很多情况,因为最佳窗口可能恰好落在原串里完全没有任何重复数字的位置。滑动窗口枚举所有起始位置才能保证不漏解。
第三个坑是边界条件。如果m大于n,理论上不可能存在长度为m的字串,但题目一般会保证m<=n。如果遇到不保证的情况,我建议在开头加一个判断,直接输出-1或者按题目要求处理。另外,如果原串本身就满足条件,ans会被更新成0,这是正确的结果,不要觉得答案必须是正数。
3. 经典题目二:最大拼接数
3.1 题意还原:n个数拼成最大值
这道题在拼多多这套题里算比较“友好”的,但也是很多人在排序比较器上栽跟头的一道题。题目大意是:给定n个非负整数,你可以改变它们的排列顺序,要求把它们全部拼接成一个数,输出这个数能得到的最大值。比如给出[3, 30, 34, 5, 9],最大的拼接结果是“9534330”;如果只是按数字大小降序排列,得到“5343309”,显然不是最优。
为什么不能直接按数字大小排序?因为数字大小和拼接后的字符串长度、前缀都有关系。比如3和30,3比30小,但拼接时“330”比“303”大,所以3应该排在30前面。这说明决定顺序的关键不是数值大小,而是“谁放在前面能让拼接结果更大”。
这种题的标准解法是自定义排序比较器:比较两个数a和b时,把a和b都转成字符串,然后比较a+b和b+a的字典序,如果a+b更大,就让a排在b前面。这个比较器看着简单,但它背后的直觉很重要——两个数拼接的结果只有两种可能,要么a在前,要么b在前,二选一里取字典序更大的那个,就能保证整体的最终结果最优。
3.2 排序比较器的选择:为什么不能直接按数字大小
很多第一次做这道题的同学会问:为什么贪心策略是成立的?两个数之间选更优的拼接方式,局部最优真的能推出全局最优吗?答案是能,但这个结论并不显然。严格证明需要说明这个比较器满足传递性,也就是说如果a应该排在b前面,b应该排在c前面,那么a也应该排在c前面。这个性质可以通过字符串拼接的数学性质来证明,笔试的时候不需要写证明,但心里要明白这不是“碰巧管用”。
我个人建议在用Python刷题时,用functools.cmp_to_key来传入自定义比较器;如果用C++,就在sort的第三个参数里写一个lambda表达式。无论哪种语言,都要关注比较的逻辑:返回-1表示a应该排在b前面,返回1表示b应该排在a前面,这和Java里Comparator的compare方法语义一致。最容易出错的地方是直接把比较器写成a > b这种数字大小比较,那就完全背离了题意。
3.3 完整代码实现(Python)
Python写这道题非常顺手,因为字符串拼接和排序都是内置能力。完整代码如下:
import sys from functools import cmp_to_key def solve(): data = sys.stdin.read().strip().split() if not data: return n = int(data[0]) nums = data[1:1 + n] if all(num == '0' for num in nums): print("0") return def cmp(a, b): if a + b > b + a: return -1 elif a + b < b + a: return 1 else: return 0 nums.sort(key=cmp_to_key(cmp)) print(''.join(nums)) if __name__ == "__main__": solve()这里有个小细节:我一开始就把输入转成了字符串列表,而不是整数列表,这样后面排序和拼接都不需要反复做类型转换。cmp函数里a+b和b+a都是字符串拼接,所以比较的是字典序,恰好和数字大小一致。排序完成后用join方法把所有字符串拼起来,输出就是最终答案。
3.4 边界情况:全零输入
这道题最经典的边界情况就是全零输入。如果输入是[0, 0, 0],排序后拼接出来的字符串是“000”,但数字形式的答案应该是0。如果不做特殊处理,程序就会输出“000”,在OJ上直接判错。
处理方式就是在排序前先判断一下,如果所有数都是0,直接输出单个“0”。也可以拼完之后判断结果字符串是否全是0,再决定输出“000”还是“0”。我更喜欢前者,因为更早返回,逻辑更清晰。另外还有一种小坑是输入里可能有前导零的字符串形式,比如“007”,虽然题目一般不会出,但万一遇到,按字符串处理仍然能正确拼接,因为拼接时“007”和“7”的字典序是符合数字直觉的。
4. 经典题目三:收银台排队模拟
4.1 题意还原:多个窗口的最短排队
拼多多这套题里还有一类很实际的场景题,我印象里是模拟超市收银台或者食堂打饭窗口。题目大意:有m个收银台,n个顾客按顺序到达,每个顾客的服务时间是t[i]。顾客到达后会选择当前排队人数最少,或者说最先空闲的收银台去排队,问你所有顾客服务完成的总时间是多少。这类题考察的其实不是算法,而是你能否把一个真实的排队过程用数据结构准确建模。
最直观的模拟方式是开一个数组记录每个收银台当前排队的总耗时,来一个顾客就把他加到耗时最少的窗口后面。但如果每次都用循环去找耗时最少的窗口,复杂度是O(n*m)。当n和m都到十万级别时,这个复杂度就撑不住了。正确的做法是用优先队列,也就是小顶堆,来维护每个收银台当前完成所有已分配顾客的时间戳。
我们可以想象一下真实的排队场景:收银台不是“窗口数组”,而是一个个正在运行的线程,谁先处理完手头的顾客,谁就有空接收下一个人。所以我们需要快速找到“当前最早空闲”的那个收银台,优先队列天生就是干这个的。
4.2 用堆模拟的完整流程
算法的流程可以拆成几步。第一步,准备一个小顶堆,堆里放的是每个收银台“完成当前所有已分配顾客后的时间”。初始时堆是空的。第二步,依次遍历每个顾客,如果当前堆的大小还不到m,说明还有完全空闲的收银台,直接把这个顾客分配到任意一台空闲台,并把它变成该台的结束时间t[i],也就是把这个t[i]压入堆。第三步,如果堆的大小已经等于m,说明所有收银台都在忙,我们取出堆顶元素cur,它表示最早空闲的收银台的完成时间,把当前顾客安排给它,那么该收银台新的完成时间就是cur + t[i],再把这个新时间压回堆里。
这个过程不断重复,最后堆里保存的是每个收银台最终完成所有顾客的时间。答案就是堆中的最大值。为什么不是最小值?因为总时间取决于最晚完成的那台收银台,而不是最早的。这里又有不少人会搞混。
4.3 完整代码实现(C++)
我一般用C++写这类模拟题,因为priority_queue用起来很方便。完整代码如下:
#include <bits/stdc++.h> using namespace std; int main() { int n, m; cin >> n >> m; vector<int> t(n); for (int i = 0; i < n; i++) { cin >> t[i]; } priority_queue<int, vector<int>, greater<int>> pq; for (int i = 0; i < n; i++) { if ((int)pq.size() < m) { pq.push(t[i]); } else { int cur = pq.top(); pq.pop(); pq.push(cur + t[i]); } } long long ans = 0; while (!pq.empty()) { ans = max(ans, (long long)pq.top()); pq.pop(); } cout << ans << endl; return 0; }注意priority_queue默认是大顶堆,这里必须用greater 把它变成小顶堆,否则取出的就是最晚完成的收银台,整个逻辑就错了。另外当m大于等于n时,每个顾客都有独立收银台,答案就是所有t[i]的最大值,代码里的if分支天然会处理这种情况,不需要额外判断。
4.4 模拟题的通用套路
这种排队模拟题在笔试题里出现频率非常高,而且变种很多。有的题目会让顾客按不同时间点到达,这时候你不能简单按输入顺序处理,而是要维护一个“当前时间”和“顾客到达时间”的双指针,先处理已经到达的顾客,再模拟时间推进。有的题目会让你输出每个收银台的排队人数分布,这时候除了存完成时间,还要额外维护人数。还有的题目会把服务时间改成随机的概率事件,那就是另一套高级玩法了。
遇到这类题,我的经验是先画一条时间轴,把“什么时候有人到达”“什么时候有人离开”标清楚,再决定用优先队列还是普通队列。千万不要一上来就写代码,模拟题的逻辑一旦理错,后面全是白搭。优先队列能解决“每次找最早结束”的问题,普通队列能解决“先来先服务”的问题,两者结合几乎能覆盖所有的排队场景题。
5. 笔试实战:编程题答题的几条经验
5.1 合理分配做题时间
回到这份拼多多2019秋招部分编程题合集本身,我认为它最有价值的不是某一道题的答案,而是整份卷子的节奏感。拿到题目后,我建议先花三到五分钟快速浏览所有题,把每道题按“读懂了”“有点思路”“完全没思路”分三类。第一类立刻做,第二类先写下核心思路再动手,第三类放到最后。在单题上设置一个硬性的时间上限,比如二十分钟,超过这个时间还没调通,果断写一个暴力版本或者直接跳到下一题。笔试不要求满分,过线靠的是拿到所有该拿的分。
5.2 输入输出与边界细节
很多同学在本地IDE里跑得好好的,一上在线评测系统就编译错误或者答案错误,大部分问题出在输入输出处理上。比如题目说“多组测试数据”,就需要while(cin>>n)循环处理;如果字符串里可能包含空格,就不要用cin >> s,改用getline;如果结果可能超过2的31次方,记得用long long。这些都不是算法问题,但每一条都可能导致整题零分。我在写的时候会先把数据范围圈出来,凡是有加法、乘法的地方都用long long规避风险,哪怕题目给的范围看起来不大。这点投入在笔试里非常划算。
5.3 常见问题速查表
下面这个表是我在刷题和实际笔试中总结出来的高频问题,每一条都真实踩过坑,你可以直接当成检查单来用。
| 现象 | 常见原因 | 解决建议 |
|---|---|---|
| 本地运行正常,OJ显示编译错误 | 用了C++11特性但编译器版本低 | 尽量不使用太新的语法,或者确认平台支持的版本 |
| 答案和样例不一致 | 输入格式看错,比如漏读了换行 | 用断言或临时打印检查读入 |
| 大量数据时超时 | 用了O(n^2)暴力循环 | 换成前缀和、滑动窗口或优先队列优化 |
| 字符串排序结果不对 | 比较器写成了数字比较 | 用a+b和b+a做字典序比较 |
| 输出“000”而不是“0” | 全零数字没有特判 | 排序前或输出前判断是否全零 |
| 整数溢出导致答案诡异 | int范围不够 | 统一用long long存储中间结果 |
| 优先队列取出的不是最早结束 | 默认大顶堆 | 使用greater 或存入负值 |
这个表没有覆盖所有场景,但覆盖了这份合集中三道题最容易出现的问题。你在做题时遇到奇怪的现象,优先往输入输出格式和数据类型上排查,大概率能找到原因。
5.4 写在最后的一点个人体会
刷完这套题,我最大的体会是:大厂笔试并不追求知识点的数量,而是追求每个基础工具使用的熟练度。滑动窗口、前缀和、优先队列、自定义排序,这几个东西翻来覆去地考,但每次包装的业务场景都不一样。你需要的不是背题,而是建立一种“读到问题就能联想到对应工具”的条件反射。我在实际准备中会刻意把常见工具分类整理成模板,比如“求连续区间最优解,优先想滑动窗口”“每次找最值并更新,优先想堆”“两个元素按规则比较排序,优先想自定义比较器”。拼多多这套题正好是检验这些模板的最好素材,有时间的话不妨反复做两遍,第一遍看重解法,第二遍刻意不看答案自己限时重写,效果会明显不一样。