看到“360公司2016研发工程师内推笔试编程题”这个标题,可能有人会觉得这是份过期的考古资料。但如果你经历过那个阶段的校招,或者正在准备今年的秋招,我建议你认真把这类老题翻出来过一遍。原因很简单:这类笔试编程题的考点和套路,至今还在各大厂的笔试题里反复出现,只是换了个壳。
360当年的研发工程师内推笔试,整体难度在互联网公司里属于中上。它的编程题不像现在很多公司那样动不动就上困难级别的动态规划加状态压缩,而是更偏向考察基础算法的扎实程度和边界条件的处理能力。说白了,就是看你能不能把数据结构的基础题写出干净、稳定、不超时的解法。
这篇我就以当年这套题的常见题型为主线,结合我后来自己刷题、出题、面人的经验,把每一类题的考察点、解题思路、代码实现和踩坑点全部拆开讲透。不管你是准备笔试的在校生,还是工作几年想跳槽的老兵,这篇都值得你花二十分钟认真读完。
1. 整套卷子先拆个底朝天:考什么、怎么考
1.1 当年的笔试场景和竞争环境
2016年前后,正好是移动互联网公司校招最火热的阶段,360作为头部互联网公司,研发岗的简历投递量非常大。内推笔试和统考笔试不一样的地方在于,内推本身就是筛过一轮简历的,所以笔试题的区分度会更高,目的很明确:把真正能写代码的人挑出来。
当时很多公司采用的是在线笔试系统,摄像头监控,题目从题库里抽取,题型包括选择题、简答题和编程题。编程题通常是两道到三道,难度递增,时间大概是一个小时到两个小时。你不仅要写对,还得在有限时间内跑通所有测试用例,这对熟练度要求很高。
也正因为如此,这套题不算偏门怪题,基本不会出现那种需要灵光一现的脑筋急转弯,更多的是“你平时有没有认真刷过基础题”的检验。换句话说,套路性很强,准备好的人能稳定拿分,裸考的人基本会挂在第一道题上。
1.2 题目难度分布和三种典型风格
从我接触到的信息和周围同学的反馈来看,360这套题的编程部分大致可以归成三类:一类是数组和哈希表的应用,一类是字符串处理,还有一类是动态规划或者递推。这三类基本覆盖了大多数公司笔试的编程题范围。
第一类题,通常会给你一个数组,要求找重复元素、找缺失数字、统计频次之类的。这类题考的是你对哈希表这种基础结构的敏感度,以及能不能把时间复杂度从O(n^2)降到O(n)。
第二类题,字符串处理,会涉及到反转、去重、子串匹配、按规则转换等。这类题看起来简单,但实际上特别容易在边界条件上翻车,比如空字符串、只有一个字符、大小写混排、包含空格或标点等。
第三类题,动态规划或者递推,常见的有最长上升子序列、背包问题、斐波那契数列变种。这类题考察的是你对状态转移的理解程度。很多人在考场上一看到动态规划就发怵,但实际上这类题往往是最高频的送分题,前提是你真的懂套路,而不是死记代码。
2. 高频题型的解题思路:从读题到写码
2.1 数组加哈希表:找出第一个重复元素
这类题是笔试里的常客,题目描述一般是这样:给你一个整数数组,从前往后找出第一个重复出现的数字,如果没有则输出-1。
很多人第一反应是两层循环暴力解,这当然能做,但效率太低。如果数组长度是10万,那要比较的次数就到了亿级,肯定会超时。正确做法是用哈希表:遍历数组,把每个数字存入set或者map,如果当前数字已经在集合里了,那它就是第一个重复的元素,直接返回。
这个思路背后其实是一种空间换时间的权衡。你可以把哈希表理解成一个登记簿:每来一个数字,先查一下登记簿里有没有,没有就登记,有就说明这家伙重复了。这样一趟走完就能出结果,时间复杂度变成了O(n),代价是额外付出了O(n)的空间。
我在实际笔试中遇到过类似题,当时用的是C++的unordered_set。选择它而不是set的原因是它的哈希表实现平均O(1)查找,而set底层是红黑树,虽然也能用,但平均复杂度是O(log n)。在笔试场景里,性能差这一点点可能就决定了你能不能过某些大数据量的测试点。
2.2 字符串处理:看似简单却最容易翻车
字符串题是笔试里的一大坑。看起来逻辑很直白,写起来却往往因为各种边界条件挂掉。举个例子,有一道经典题:把一句话里的单词顺序反转,但是每个单词内部的字母顺序不变。输入“I am a student”,输出“student a am I”。
这题最朴素的解法是先按空格把字符串拆成单词数组,然后倒序遍历数组拼接结果。听起来很简单对吧?但问题往往出在细节上:如果输入开头或结尾有空格怎么办?如果有连续多个空格怎么办?如果字符串是空的怎么办?这些都是测试用例可能会覆盖的情况。
我当时踩过的坑就是split函数在不同语言里的默认行为不一样。比如在C++里,标准的istringstream配合getline可以自动跳过连续空格,但在有些语言里split之后会产生空字符串项,得额外过滤。
这类题真正的考察点其实是字符串处理的基本功和容错意识。面试官和出题人不会指望你在十秒钟内写出一个优雅的解决方案,他们更想看到的是:你能不能把各种边角情况考虑到位,而不是只搞定一个“标准输入”。
2.3 动态规划:LIS题背后的常规套路
动态规划在笔试题里几乎是必考的,最常见的有一道最长上升子序列(LIS)。题目让你在一个无序数组里找出最长的严格递增子序列的长度,不需要连续。
这类题的解法有两个层次。第一层是O(n^2)的做法:定义dp[i]表示以第i个元素结尾的最长上升子序列长度,对于每个i,去遍历它前面所有的j,如果nums[j] < nums[i],就尝试用dp[j]+1更新dp[i]。这个思路直观,写完也不难,适合作为保底方案。
第二层是O(n log n)的优化做法:维护一个tails数组,tails[k]表示长度为k+1的上升子序列里,结尾元素的最小值。遍历原数组时,用二分查找找到当前元素在tails中的位置,然后更新它。这个做法的核心思想是通过贪心让每个长度的子序列结尾尽可能小,从而为后续增加长度留出空间。
说实话,O(n log n)的版本在笔试里不一定需要写出来,因为大多数题目的数据范围在O(n^2)可以承受的范围内。但如果你能在考场上写出这个优化版本,绝对能跟其他候选人拉开差距。我当时练LIS题的时候,花了一个晚上把这两种写法都写熟了,后来在不止一家公司的笔试题里都用上了。
3. 实操复现:用Python还原三道典型题
3.1 题目一:第一个重复元素
题目描述:给定一个长度为n的整数数组(n在1到100000之间),请从前往后找出第一个重复出现的数字,若存在则输出该数字,否则输出-1。
输入输出格式:
- 输入:第一行是数字n,第二行是n个空格分隔的整数。
- 输出:一个整数,表示第一个重复出现的数字,或-1。
示例:
6 1 3 4 2 3 1输出:
3注意这里为什么不是输出1:因为下标从0开始,第一个重复出现的元素指的是第二次出现的元素中下标最小的那个。数组里3第一次出现在下标1,第二次出现在下标4;1第一次出现在下标0,第二次出现在下标5。3的第二次出现比1的第二次出现更靠前,所以答案是3。
3.2 代码实现与逐行讲解
def find_first_duplicate(arr): seen = set() for num in arr: if num in seen: return num seen.add(num) return -1 def main(): n = int(input().strip()) arr = list(map(int, input().strip().split())) print(find_first_duplicate(arr)) if __name__ == "__main__": main()这段代码的逻辑非常直接:遍历数组,把每个数字往set里塞。在塞之前先检查一下set里有没有这个数字,有就说明它是第一个重复的元素,直接返回;遍历完都没有重复,就返回-1。
这里有一个很重要的细节:为什么用set而不是list来记录已出现元素?因为set的in操作是O(1)的,而list的in操作是O(n)。如果用list,外层遍历O(n),里层判断O(n),整体又退回到O(n^2)了,等于白优化。
3.3 复杂度分析与优化空间
时间复杂度和空间复杂度都是O(n)。从理论上讲,这已经是这道题的最优解了,因为不管怎样,你至少得把数组遍历一遍才能知道哪些重复了。
但笔试里有个细节值得注意:如果数组中所有数字的范围很小(比如都在0到100之间),可以考虑用一个布尔数组替代set来记录出现情况,这样能在常数上省掉哈希的计算开销。不过实际测评中,这种优化对结果没什么影响,因为n的规模通常不会大到让哈希set变慢的程度。
我后来在出题的时候也最喜欢用这类题做热场,因为它能很有效地检验候选人是否具备“先用大脑估算复杂度再去写代码”的习惯。如果一个人一上来就写两层循环,哪怕最后结果对,我也能判断他对数据结构的敏感度还不够。
3.4 题目二:单词反转
题目描述:给定一个字符串str,包含若干个以空格分隔的单词,请将单词顺序反转,单词内部字符保持原样。多个连续空格视为一个分隔符,首尾空格忽略。
输入输出格式:
- 输入:一行字符串。
- 输出:反转后的字符串。
示例:
I am a student输出:
student a am I3.5 Python实现与关键细节
def reverse_words(s): words = s.strip().split() return ' '.join(reversed(words)) def main(): s = input() print(reverse_words(s)) if __name__ == "__main__": main()这里Python占了很大的便宜:split()在没有参数的时候会自动按连续空白字符分割,并且自动过滤掉首尾和中间多余的空格,省掉了很多C++里需要手动处理的逻辑。这也是为什么我建议现在准备笔试的人至少掌握一门脚本语言,因为有些题用脚本语言写能省一半时间。
但需要注意的是,split()的参数和默认行为在不同语言中不一样。比如Java的split方法如果传入单个空格,就不会自动处理连续空格,需要传正则表达式"\s+"。如果你平时用Java刷题,必须熟悉这个区别。
这道题的变种也很多,比如不允许使用额外的数组存储单词,那就得先反转整个字符串,再逐个反转每个单词。这种思路在C++面试里会更受欢迎,因为它体现了对字符串内存操作的理解。但笔试场景下,能用简单方法正确解决问题才是第一位的。
3.6 题目三:最长上升子序列
题目描述:给定一个长度为n的整数数组,求其最长严格递增子序列的长度。
输入输出格式:
- 输入:第一行是数字n,第二行是n个空格分隔的整数。
- 输出:一个整数,表示最长上升子序列的长度。
示例:
8 10 9 2 5 3 7 101 18输出:
4一个最长的上升子序列是[2,3,7,101],长度为4。
3.7 从O(n^2)到O(n log n)的完整实现
先写O(n^2)的版本,便于理解:
def length_of_lis(nums): if not nums: return 0 n = len(nums) dp = [1] * n for i in range(n): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)这个版本是标准的动态规划解法。dp[i]的含义是以nums[i]结尾的上升子序列的最大长度。初始化为1是因为每个元素本身可以单独构成一个长度为1的上升子序列。然后对于每个i,扫描它前面的所有j,如果前面某个元素小于当前元素,说明可以接在后面,于是用dp[j]+1来尝试更新dp[i]。
再看O(n log n)的版本:
import bisect def length_of_lis(nums): tails = [] for num in nums: pos = bisect.bisect_left(tails, num) if pos == len(tails): tails.append(num) else: tails[pos] = num return len(tails)这段代码用了一个trick:tails数组不一定是真实的子序列,但它的长度就是LIS的长度。bisect_left找到第一个大于等于num的位置,如果num比所有尾数都大,就说明它可以扩展一个更长的上升子序列;否则它就替换掉那个位置的尾数,因为它比原来的数更小,更有利于后续扩展。
这个替换逻辑是第一眼看上去比较费解的地方。我当初学的时候也绕了很久,后来自己想了一个类比才转过来:这个过程就像你在维护一个“员工列表”,如果来了一个新员工能力比某个职位的在职者更强,就把他替换上去,这样整支队伍的平均水平会越变越高,但队伍人数才是最终要看的指标。
在笔试中,如果n不超过5000,O(n^2)版本足够应对。但如果n到了10万,O(n^2)一定会超时,必须用二分优化版。所以两个版本最好都熟练掌握。
4. 笔试现场最容易踩的坑
4.1 输入输出格式的坑
在线笔试的输入输出格式是固定的,不按格式来就直接判零分,哪怕你的算法再对也没用。常见的坑有:读整数时没有处理换行符、读字符串时把整行读成了单词、输出的时候多了空格或换行等。
我记得有个同学考360的时候,第一题明明写对了,但输出的时候多打了一个空格,导致全组测试用例都匹配不上。这种失误是最冤枉的。所以交卷前一定要仔细检查输出逻辑,尤其是涉及循环打印的场景,最后一个元素后面不能有空格。
如果用的是Python,建议就用sys.stdin.read()一次性读入,再按空白字符拆分,这样能避免很多input()在行尾和空行上的小毛病。我自己面过的候选人里,能用好这一招的,笔试通过率明显更高。
4.2 边界条件的坑
边界条件是编程题最大的失分点。比如第一道题里,数组长度为1时不会重复,必须返回-1;空数组也不能崩溃。第三道题里,数组为空时LIS长度是0,数组只有一个元素时长度是1。
这些情况在题目的示例里通常不体现,但后端的测试用例一定会覆盖。写代码的时候,我建议养成一个习惯:写完主逻辑后,立刻用三个特殊用例自我验证一下——空输入、最小规模输入、所有元素相同。这三个用例过了,大部分边界问题就不会漏。
所有元素相同是一个很经典的坑。比如输入[2,2,2,2],最长严格上升子序列长度应该是1。如果实现时不小心用了等号,写成nums[j] <= nums[i],那就会错误地得到4。题目里写了“严格递增”,就意味着不包含相等的情况。
4.3 时间不够时的提交策略
考场上时间紧张是常态,尤其是前面选择题磨了太久,留给编程题的时间只剩二十分钟。这时候我建议按照“最优解优先,暴力解保底”的原则来安排。
如果一道题你能想出最优解,直接写,不需要犹豫。如果暂时没思路,暴力解一定要先写出来,哪怕复杂度很差,也能拿到一部分测试用例的分数。很多在线笔试系统是分测试点计分的,能过几个算几个,比交白卷强得多。
还有一个小技巧:如果题目给出的数据范围里n非常小(比如小于100),那暴力解可能本身就是出题人预期内的解法,不需要强行优化。先看清楚数据范围再动手,有时候反而能节省很多时间。
5. 这套老题对今天的面试还有多少参考价值
5.1 题型没有过时,考法在升级
这几年各大厂的笔试题目确实在变难,但底层的核心考点并没有本质变化。数组、哈希表、字符串处理、基础动态规划,依然是出现频率最高的几类问题。360这套2016年的题目里出现的考点,放到今天的笔试题里同样成立。
变化的是什么呢?考法更灵活了,比如把字符串处理包装成一个实际的业务场景,或者把动态规划隐藏在“求最少操作次数”这类问题里。但只要你基础够扎实,剥开外壳看到内核的时候,会发现还是那些老朋友。
所以我一直建议准备笔试的人不要一上来就刷难题怪题。把基础题吃透,做到看一眼就能写出代码的程度,比囫囵吞枣刷三百道难题有用得多。这套2016年的题就是一个很好的基础训练素材。
5.2 语言选择:C++还是Python
回到复习策略上,我当时是用C++刷题的,现在回头看,觉得Python其实对笔试更友好。原因有三个:一是代码量少,同样的逻辑用Python写可能只有C++的一半长度,这在时间紧张的笔试里有明显优势;二是内置库强大,字符串处理和排序等功能开箱即用;三是可读性好,写完自己复查起来也快。
但如果你投的岗位明确要求C++或者Java,比如底层开发或者客户端开发,那用C++笔试反倒更贴合岗位要求。这时候还是投其所好比较好。
一个折中的建议是:笔试用你最有把握的语言,但一定要会读至少两门语言的代码。因为有些公司在笔试后面试阶段会给你一段别的语言的代码让你分析,如果完全看不懂就比较被动了。
不过说到底,语言只是工具,能不能写出正确的解题思路才是关键。别在语言选择上内耗太久,选一个你用得最顺的,然后把精力花在算法本身。