面试常考算法题精讲:Python十大经典题型与解题模板
作为一个在技术圈摸爬滚打多年、也面过不少候选人的老工程师看到“面试常考算法题”这个系列标题我第一反应是这玩意儿真是常青树。不管你是校招、社招还是跳槽去大厂或独角兽算法题几乎永远是第一道坎。系列第二篇我打算不玩虚的直接把那些反复出现、值得反复咀嚼的经典题目拎出来拆开揉碎讲清楚“为什么这么考”“考场上怎么破局”再配合一些我实际刷题和面试别人时积累下来的经验。这篇文章覆盖的核心关键词是“算法题”和“python算法思维题”也就是说我会尽量用Python的视角来展开帮你在准备面试时快速建立一套可复用的思路框架。很多人刷题有个误区追求数量上来就按题号顺序刷结果刷了三百题遇到新题还是慌。真正有效的做法是建立“题型—策略—编码模板”的映射。面试官考察的不是你会不会这道题而是你在限制时间和压力下能不能快速找到切入点、写干净代码、并清楚讲出复杂度。这篇文章就是围绕这个目标来组织的。1. 内容整体设计与思路拆解1.1 为什么是这十道题我挑选的这十道题覆盖面尽量广但又不至于太偏。它们分别对应了面试中最高频的几类核心能力链表指针操作、双指针与滑动窗口、二叉树递归模板、动态规划状态定义、栈的单调性应用、二分答案边界处理、图论与拓扑排序。这些能力几乎就是算法面试的“基础语法”无论你面的是后端、前端还是数据岗都逃不过。具体题目如下合并两个有序链表、反转链表系列局部反转、环形链表检测快慢指针、两数之和与其进阶版三数之和、最长无重复子串滑动窗口、二叉树的层序遍历、二叉树最近公共祖先、爬楼梯与打家劫舍DP入门、每日温度单调栈、以及拓扑排序课程表。有人说这些题太经典经典到烂大街。但我想说经典之所以是经典是因为它们背后藏着最实用的思维模型。比如“反转链表”考的其实是对指针和引用的掌控能力“最近公共祖先”考的是对递归返回值和边界条件的理解。同一个模型换个皮就能出十道新题而你把模型吃透了十道新题就变成了同一道题。1.2 面试考算法的真实意图面试官出算法题表面上考“你会不会解”实际上考的是四件事第一你遇到问题有没有清晰的思路流程第二你能不能把思路转化成可运行的代码第三你写代码时是否考虑边界条件和异常输入第四你有没有能力评估自己的方案说出时间复杂度和空间复杂度。很多候选人栽在第三和第四点上代码能跑通主流程但一问“如果链表为空怎么办”就卡壳或者写完了说不上来复杂度怎么算。所以我在下面每个章节里都会刻意强调边界处理、复杂度分析和“如果你的思路走不通还有什么退路”。这其实也是在模拟真实面试中面试官不断追问“还能更好吗”的场景。准备面试练的就是这种被追问时的从容感。2. 链表类题目的核心细节与实操要点2.1 链表指针基本功链表题在大厂面试中出现频率极高因为它考察的是指针操作和空间思维这两项是写底层代码、做系统设计的基本功。Python里面没有显式指针但每个变量本质上是对象的引用所以链表操作的思想是相通的。比如“反转链表”最经典的非递归写法是这样class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def reverseList(head): prev None curr head while curr: next_temp curr.next curr.next prev prev curr curr next_temp return prev这段代码的关键点就三个保存下一个节点不然指针一改就找不到了、把当前节点的next指向前一个、移动prev和curr。我面试时经常问这道题能一次写对、且解释清楚每个变量变化的人基本功基本是扎实的。比较进阶的考法是“反转链表II”只反转从第m到第n个节点。这时候需要先把头处理到m位置记录好m位置前一个节点和m位置的节点反转完成后把链表重新接起来。一个容易踩的坑是如果m1那m位置前一个节点是None所以很多老手会在链表前面加一个dummy节点这样就不用单独处理头部边界了。提示链表题里dummy节点是永不失业的伙伴。凡是涉及头节点可能被修改的题目先new一个dummy指向头最后返回dummy.next能省掉一多半边界烦恼。2.2 快慢指针的套路“环形链表检测”是快慢指针的经典应用。两个指针一个每次走一步一个每次走两步如果链表有环它们一定会在环中相遇。这个结论不难证明假设没环快指针先到null有环进入环后每次移动快慢指针之间的距离就会缩近1最终追上。def hasCycle(head): slow head fast head while fast and fast.next: slow slow.next fast fast.next.next if slow fast: return True return False注意这里循环条件的写法fast和fast.next必须同时判空否则访问fast.next.next会报错。很多人在这个细节上翻车。进阶问题是“找到环的入口位置”。思路是快慢指针第一次相遇后把慢指针重新放回头节点然后两个指针都每次走一步再次相遇的位置就是环入口。这个结论背后的数学推导是通过速度差和周长关系推出来的面试时如果能主动提一句这个证明过程会明显加分。我实际刷题时发现链表题考得深了就会和递归结合比如“合并两个有序链表”除了迭代写法还可以用递归def mergeTwoLists(l1, l2): if not l1: return l2 if not l2: return l1 if l1.val l2.val: l1.next mergeTwoLists(l1.next, l2) return l1 else: l2.next mergeTwoLists(l1, l2.next) return l2递归写法的好处是代码短、语义清晰但面试时得能说清楚递归深度和空间复杂度是O(nm)。如果面试官要求O(1)空间那就老老实实用迭代法。3. 数组、双指针与滑动窗口实战3.1 两数之和的双指针变体“两数之和”几乎是所有刷题人的第一个朋友。原题思路是哈希表一遍遍历一遍查表时间复杂度O(n)空间复杂度O(n)。但如果是“三数之和”题目要求返回不重复的三元组哈希表就会比较麻烦因为要去重。三数之和的最优解法是排序双指针。排序时间复杂度O(n log n)在排序后的数组里固定一个数然后剩下两个数用左右指针向中间逼近。去重的关键是指针移动时如果和上一个数相同就继续移动跳过重复值。我见过太多人在去重逻辑上写错导致输出重复数组或者漏掉答案。def threeSum(nums): nums.sort() res [] n len(nums) for i in range(n - 2): if i 0 and nums[i] nums[i - 1]: continue left, right i 1, n - 1 while left right: total nums[i] nums[left] nums[right] if total 0: res.append([nums[i], nums[left], nums[right]]) while left right and nums[left] nums[left 1]: left 1 while left right and nums[right] nums[right - 1]: right - 1 left 1 right - 1 elif total 0: left 1 else: right - 1 return res这套模板非常好记值得背下来。而且它代表了双指针题的通用思路先让数据有序再根据当前和与目标的大小关系决定指针移动方向。3.2 滑动窗口写法的坑“最长无重复子串”是滑动窗口的代表题。基本思路是用一个set或字典记录窗口内的字符右指针不断扩展发现重复字符就把左指针右移直到窗口内无重复。def lengthOfLongestSubstring(s): char_set set() left 0 max_len 0 for right in range(len(s)): while s[right] in char_set: char_set.remove(s[left]) left 1 char_set.add(s[right]) max_len max(max_len, right - left 1) return max_len这里最需要注意的坑是右指针每次移动都要保证窗口内无重复字符如果重复了不是简单把左指针往右挪一位而是不停挪直到把那个重复字符移出窗口为止。用while而不是if这是滑动窗口题的通病。滑动窗口类题目还有一个常见变体比如“子数组最大平均数”、“字符串排列判断”它们的内核都是维护一个窗口区别在于窗口大小固定还是可变以及判断条件是什么。刷题时可以归成一个“滑动窗口对照组”一次性刷完效率远高于零散地做。4. 二叉树与递归面试中的送命题与送分题4.1 层序遍历与队列的配合二叉树题目看起来变化多端但本质上就两类一类是基于递归的深度优先遍历一类是基于队列的广度优先遍历。层序遍历属于后者面试时常常要求按层输出结果也就是把每一层的节点值放到一个列表里最后返回一个嵌套列表。实现的核心是每轮循环开始前先记录当前队列的长度这个长度就是当前层的节点数。然后只处理这个数量内的节点节点出队后把自己的左右孩子入队这样就天然地把不同层分开了。from collections import deque def levelOrder(root): if not root: return [] q deque([root]) res [] while q: level_size len(q) cur_level [] for _ in range(level_size): node q.popleft() cur_level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) res.append(cur_level) return res这个“先记len再循环”的操作非常经典很多BFS变体题比如“二叉树右视图”“之字形遍历”都基于它。只要吃透了这层套路BFS类题目基本可以套模板。4.2 最近公共祖先的递归思维“二叉树最近公共祖先”是递归题里最考思维的一道。题目给两个节点p和q要求找到它们的最近公共祖先。递归函数的定义是在以root为根的子树中寻找p和q的最近公共祖先如果root是p或q之一直接返回root如果左子树和右子树都有返回值说明p和q分别位于两侧root就是最近公共祖先否则哪个子树有返回值就返回哪个。def lowestCommonAncestor(root, p, q): if not root or root p or root q: return root left lowestCommonAncestor(root.left, p, q) right lowestCommonAncestor(root.right, p, q) if left and right: return root return left if left else right这段代码只有几行信息量却很大。面试时特别适合让候选人逐行解释为什么返回root时是最终答案为什么有一个子树返回None时继续向上传递把这些讲清楚面试官立刻就能判断出你的“递归直觉”水平。我在实际辅导中经常提醒二叉树递归题有一个统一的思考模板先定义清楚函数的意义输入什么输出什么再考虑最小规模情况递归基然后假设左右子树已经正确求出了结果思考如何基于它们组装出当前层的结果。只要按这个模板走“二叉树最大深度”“判断平衡二叉树”“路径总和”这些题全都能迎刃而解。5. 动态规划从状态定义到边界补全5.1 爬楼梯与打家劫舍的最简模型动态规划题是面试中的分水岭有些人觉得很难但只要掌握了“状态定义”和“状态转移方程”这两个抓手大部分入门题都是套路。拿“爬楼梯”来说一次可以爬1阶或2阶问到第n阶有多少种方法。设dp[i]为到第i阶的方法数则dp[i] dp[i-1] dp[i-2]因为最后一步要么从i-1跨上来要么从i-2跨上来。def climbStairs(n): if n 2: return n a, b 1, 2 for _ in range(3, n 1): a, b b, a b return b这里可以发现dp数组其实可以用两个变量滚动更新空间复杂度从O(n)降到O(1)。面试时主动做这种优化会给人留下“代码有味道”的好印象。“打家劫舍”稍微复杂一点因为多了一个约束不能偷相邻房屋。设dp[i]表示前i个房屋能偷到的最大金额状态转移时要考虑偷不偷第i间房如果偷收益是dp[i-2]加上当前房屋金额如果不偷收益是dp[i-1]。取两者最大值。def rob(nums): if not nums: return 0 if len(nums) 1: return nums[0] dp [0] * len(nums) dp[0] nums[0] dp[1] max(nums[0], nums[1]) for i in range(2, len(nums)): dp[i] max(dp[i-1], dp[i-2] nums[i]) return dp[-1]这道题的变体还有“打家劫舍II”房屋围成环形和“打家劫舍III”房屋是二叉树结构。环形版本的做法很典型把问题拆成“偷第一间不偷最后一间”和“偷最后一间不偷第一间”两种情况分别跑一次线性DP取最大值。从这道题上能看出动态规划考到后面考的更多是“如何把一个复杂问题简化成多个已解决子问题”这也是面试官最想看到的素质。5.2 二维DP和状态压缩的思路再进一档二维DP的代表是“不同路径”和“最小路径和”。“最小路径和”里dp[i][j]表示从左上角到(i,j)的最小路径和由于只能向右和向下走所以状态转移很简单dp[i][j] grid[i][j] min(dp[i-1][j], dp[i][j-1])。这种题在面试中经常出现的目的是考察你对空间复杂度的敏感度。你会发现更新dp[i][j]的时候只需要上一行的数据因此可以用一维数组滚动更新。刷题的时候每做完一个二维DP题都问自己一句“能否只保留一行数据”这种习惯会帮你在面试中多拿一个好评。5.3 DP题目的边界条件与初始化动态规划题最大的坑就是数组越界和初始状态设置错误。我见过一个很典型的场景候选人主转移方程写得飞快但一遇到n0或n1这类特例就处理错了或者是dp数组长度定义错了导致后面访问dp[i-2]的时候负数下标直接报错。所以做DP题第一步永远不是写转移方程而是把输入的最短情况列出来手动推一遍结果。比如爬楼梯的n1、n2打家劫舍的nums为空、只有一个元素、有两个元素。把边界情况先跑通再写代码心理状态会稳很多。注意面试时如果时间紧张可以先说清楚边界情况的处理策略再写主逻辑。面试官不怕你考虑得慢怕的是你考虑得不全。6. 单调栈、拓扑排序与实战复盘6.1 每日温度单调栈的直觉理解“每日温度”这道题要求找出每一天之后要等多少天才会出现更高的温度。最笨的办法是两重循环O(n^2)但如果要求O(n)解法就要用单调栈。这个栈里存的是“还没找到更高温度的那天的下标”并且这些下标对应的温度是递减的。每遍历一个新的温度就检查它是否比栈顶温度高如果高就说明栈顶那一天等到了答案弹出并计算距离。def dailyTemperatures(temperatures): n len(temperatures) ans [0] * n stack [] for i in range(n): while stack and temperatures[i] temperatures[stack[-1]]: idx stack.pop() ans[idx] i - idx stack.append(i) return ans单调栈这类题的难点不在代码而在“什么情况下用”。我的经验是当题目要求找“下一个更大/更小元素”或“间隔距离”时第一反应就应该是单调栈。类似题目包括“下一个更大元素I/II”“接雨水”虽然接雨水还有双指针解法。把“每日温度”吃透这类题的模板就掌握了。6.2 课程表与拓扑排序的实现细节“课程表”这道题本质是判断一个有向图是否存在环。用它来考察候选人的图论基础和BFS/DFS能力非常合适。经典做法是Kahn算法也就是基于入度的拓扑排序。思路统计每个节点的入度把入度为0的节点都放进队列。不断从队列中取出节点把它所有邻居的入度减1如果邻居入度变成0就继续入队。最终如果处理过的节点总数不等于总节点数说明图里有环。def canFinish(numCourses, prerequisites): indegree [0] * numCourses graph [[] for _ in range(numCourses)] for cur, pre in prerequisites: graph[pre].append(cur) indegree[cur] 1 from collections import deque q deque([i for i in range(numCourses) if indegree[i] 0]) count 0 while q: node q.popleft() count 1 for nxt in graph[node]: indegree[nxt] - 1 if indegree[nxt] 0: q.append(nxt) return count numCourses这道题高频的原因在于它虽然属于图论但难度适中不需要复杂的Dijkstra或者并查集却又足够考验候选人对数据结构的组织能力建图、入度表、队列。如果面试时间宽裕面试官还经常追加一问“如果要输出一个具体的上课顺序怎么办”那就是要求你记录拓扑排序的结果代码框架几乎不用改。我在实际面试旁听中经常看到候选人一听到“图论”先怯场但其实大多数面试里的图论题都不会真的考到特别深掌握拓扑排序、图的遍历、最短路径模板这“三板斧”已经完全够用。6.3 实战复盘把“想”转成“写”最后说点实战层面的东西。很多人刷题有个坏毛病看着题有思路但不肯动手写代码直接看题解。这样的结果是“眼高手低”面试时手生。我比较推荐的刷题流程是先花五分钟独立思考至少想出一版brute force然后不要急着看题解先尝试把手写的思路转换成一版可运行代码哪怕代码很啰嗦、复杂度很高也要让它跑通接着再尝试优化分析哪里是瓶颈能不能用哈希表/双指针/二分等常用手段降复杂度最后才去看更优解法对比自己的思路去理解差异。还有个容易被忽略的点面试时写代码不要追求“一行流”式的炫技写法。虽然Python确实支持很多巧妙的表达式但在白板或在线编辑器中代码的可读性和可解释性远比简洁度重要。你用while循环一点一点遍历面试官能看懂你也能边写边讲思路这比写出个超难读的generator表达式更有价值。拿“三数之和”来说如果不加去重逻辑你能轻松跑通大部分测试用例但面试官只要在题面里加上“不能包含重复三元组”很多人就慌了。我的建议是做任何一道题都先自己列三个测试用例正常输入、边界输入空数组、单元素数组、重复元素较多的输入。把这三个用例走完再提交错误概率会大幅下降。7. 常见问题与排查技巧实录7.1 高频错误速查表我把自己和身边人刷题时最容易犯的错误整理成了一张表面试前翻一遍能少踩不少坑错误类型典型场景应对策略边界条件空链表、空数组、n1写码前先列边界输入手动走一遍指针丢失链表反转时没有保存next永远先保存next再改指向死循环双指针移动条件写错明确指针何时left、何时right--去重不彻底三数之和结果有重复排序后跳过相邻重复值状态转移遗漏打家劫舍第2个房屋最大值手写推导n2的情况队列弹出方向错误BFS用了pop而不是popleft记住队列是先进先出右进左出建图方向搞反课程表边方向搞错明确pre→cur还是cur→pre复杂度分析失败二分查找忘了前提是有序使用二分前先说明为什么可二分这张表里的每一项几乎都是我见过真实面试中候选人翻车的点。特别说明一下队列弹出方向Python的list用pop()会弹出最后一个元素也就是LIFO而deque的popleft()才是真正的BFS队列语义。很多转Python的候选人会在这一点上卡壳。7.2 面试现场的自救思路如果真的在面试中遇到完全没思路的题不要直接说“我不会”也不要默不作声盯着屏幕。我建议按下面的顺序做第一步把题目的输入、输出、约束条件复述一遍确认自己没有理解偏。很多题目看不明白其实是漏看了重要条件比如“数组已排序”“只能使用常数空间”这些字眼。第二步问自己几个问题暴力解法是什么暴力解法里有哪些重复计算能不能用哈希表缓存结果能不能用两个指针把嵌套循环降一层能不能先排序如果这四板斧都试了还不行就问面试官要一个提示这比硬耗时间强得多。第三步写代码的时候边写边念思路不要让面试官猜你在干嘛。很多写代码本身不太熟的人只要把思路说出来面试官就会觉得你有逻辑、能沟通得分点反而更多。第四步写完后主动做复杂度分析然后说“我可以再优化一下空间”。哪怕你没真的优化出来面试官也看到了你的思维方向。7.3 时间规划和题目优先级建议最后聊一下刷题规划。对于面试时间还有三个月的同学我建议按类型刷而不是按题号刷。比如第一周搞链表第二周搞二叉树第三周搞动态规划。每个类型先把基础题做熟再做同类型变体最后做综合题。对于面试时间只剩两周的同学请直接刷高频题每天至少亲手写三道不要只看题解。手写和看题解之间的差距只有自己体验过才知道。另外还能提一个比较小的建议建一个自己的“错题本”不用很精美就记录每题为什么错、卡在哪里、下次应该怎么避免。这其实比刷题数量更重要因为每次记录都是在帮你强化弱项。这套系统的核心心法就是“把高频题练成肌肉记忆把思考过程变成模板化反应”。面试考的不是你记忆力有多好而是遇到没见过的题能不能快速复用到已知模板。希望这篇文章能帮你在刷题路上少走些弯路把每一次练习都变成真正能迁移到面试场上的能力。
