十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

算法面试高频题精讲:从链表、二叉树到动态规划与滑动窗口

算法面试高频题精讲:从链表、二叉树到动态规划与滑动窗口 1. 项目概述为什么“手撕”面试题如此重要在算法岗的面试里你可能会遇到一种让人手心出汗的环节面试官递过来一张白纸或打开一个共享编辑器说“来我们写一下这道题。” 这就是所谓的“手撕代码”它考察的远不止是你是否背过答案。它是一场综合能力的压力测试——你的逻辑思维是否清晰代码风格是否规范边界条件考虑是否周全以及在紧张环境下解决问题的能力。我见过太多理论基础扎实的候选人因为现场编码时的一个小疏忽比如忘了处理空链表或者循环条件写错而与心仪的Offer失之交臂。“手撕”二字形象地说明了这不仅是“知道”更是“熟练到肌肉记忆”。我整理了25道最高频、最经典的算法面试题它们覆盖了数据结构与算法面试的绝对核心。这些题目就像武侠小说里的基本功看似简单但每一道都暗藏玄机能精准地试探出你的功底深浅。接下来我会带你逐一拆解不仅给出答案更会深入剖析面试官的考察点、常见的思维陷阱以及如何写出让面试官眼前一亮的代码。2. 核心数据结构与算法题精讲2.1 链表指针操作的试金石链表是面试中最喜欢考察的数据结构之一因为它结构简单但指针操作极易出错非常适合在白板上检验基本功。2.1.1 反转单链表这道题堪称链表题的“Hello World”但千万别小看它。class ListNode: def __init__(self, val0, nextNone): self.val val self.next next def reverseList(head: ListNode) - ListNode: prev None curr head while curr: # 暂存下一个节点 next_temp curr.next # 反转指针 curr.next prev # 双指针前移 prev curr curr next_temp # 循环结束时prev指向新的头节点 return prev面试官考察点与避坑指南指针操作的顺序必须先保存curr.next再修改curr.next指向。顺序一错链表就“断”了后续节点全部丢失。这是一个经典的“断链”陷阱。循环终止条件是while curr而非while curr.next。后者会导致最后一个节点未被处理新链表的尾节点会错误地指向原链表的倒数第二个节点。返回值返回的是prev而不是curr。循环结束时curr为Noneprev才是反转后的新头节点。空间复杂度此方法是原地反转只用了几个指针变量空间复杂度为 O(1)。面试官可能会追问递归解法递归虽然简洁但空间复杂度为 O(n)因为需要函数调用栈。注意在白板编码时边写边画图是最佳策略。画出初始状态和每一步指针的变化能极大降低出错率同时向面试官展示清晰的思路。2.1.2 链表中环的检测快慢指针判断链表是否有环并找出环的入口是快慢指针算法的经典应用。def hasCycle(head: ListNode) - bool: if not head or not head.next: return False slow head fast head # 第一阶段检测是否有环 while fast and fast.next: slow slow.next fast fast.next.next if slow fast: return True return False def detectCycle(head: ListNode) - ListNode: slow fast head has_cycle False # 检测环 while fast and fast.next: slow slow.next fast fast.next.next if slow fast: has_cycle True break if not has_cycle: return None # 第二阶段寻找环入口 # 将slow移回链表头fast留在相遇点 slow head while slow ! fast: slow slow.next fast fast.next # 再次相遇的点即为环入口 return slow原理解析与面试要点为什么快慢指针一定能相遇为什么相遇后一个从头开始走一个从相遇点同速走再次相遇的点就是环入口相遇证明假设慢指针每次走1步快指针走2步。在有环的情况下快指针最终会从后面追上慢指针。可以把环内追逐看作快指针相对于慢指针每次靠近1步所以一定能追上。入口推导设链表头到环入口距离为a环入口到相遇点距离为b相遇点再到环入口距离为c环周长 b c。第一次相遇时慢指针走了a b快指针走了a b n*(bc)n为快指针在环内绕的圈数。因为快指针速度是慢指针的2倍所以2*(ab) a b n*(bc)a (n-1)*(bc) c。这个等式意味着从链表头走a步等于从相遇点走c步然后再绕环 (n-1) 圈。所以两个指针分别从头和相遇点同速出发必然在环入口相遇。边界条件务必先判断head和head.next是否为空防止空指针异常。这是代码健壮性的体现。2.2 二叉树递归与迭代的思维体操二叉树相关题目是考察递归思维和栈/队列应用的绝佳场景。2.2.1 二叉树的遍历前序、中序、后序、层序你必须熟练掌握递归和迭代两种写法。递归写法考察对递归的理解迭代写法则考察对栈/队列的运用能力。前序遍历根-左-右# 递归法 def preorderTraversal(root: TreeNode) - List[int]: result [] def traverse(node): if not node: return result.append(node.val) # 访问根 traverse(node.left) # 左子树 traverse(node.right) # 右子树 traverse(root) return result # 迭代法显式栈模拟递归 def preorderTraversalIterative(root: TreeNode) - List[int]: if not root: return [] stack, result [root], [] while stack: node stack.pop() result.append(node.val) # 栈是后进先出所以先右后左 if node.right: stack.append(node.right) if node.left: stack.append(node.left) return result中序遍历左-根-右的迭代法是难点def inorderTraversalIterative(root: TreeNode) - List[int]: result, stack [], [] curr root # 核心思想利用栈来回溯父节点 while curr or stack: # 一路向左将节点入栈 while curr: stack.append(curr) curr curr.left # 弹出栈顶节点当前最左节点并访问 curr stack.pop() result.append(curr.val) # 转向右子树 curr curr.right return result层序遍历广度优先使用队列from collections import deque def levelOrder(root: TreeNode) - List[List[int]]: if not root: return [] result [] queue deque([root]) while queue: level_size len(queue) current_level [] for _ in range(level_size): node queue.popleft() current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(current_level) return result面试实战技巧当面试官要求写遍历时主动问一句“您希望我用递归还是迭代实现” 这展示了你的全面性。对于迭代法中序遍历要向面试官解释清楚curr指针和栈的分工curr负责探索栈负责存储待回溯的路径。层序遍历中使用for _ in range(len(queue))来区分每一层是关键这保证了结果是一个二维列表每层元素分开存储。很多候选人会忽略这一点导致输出是一维的。2.2.2 二叉树的最大深度这道题是理解递归分解问题思路的入门题。def maxDepth(root: TreeNode) - int: # 递归终止条件空节点深度为0 if not root: return 0 # 分解问题当前树深度 1 max(左子树深度 右子树深度) left_depth maxDepth(root.left) right_depth maxDepth(root.right) return max(left_depth, right_depth) 1递归思维训练不要试图在大脑里模拟整个递归栈。要相信递归函数的定义maxDepth(node)就是返回以node为根的子树的最大深度。你只需要处理好当前节点然后信任递归调用能正确计算出子问题的结果。这种“分治”思想是解决所有树形问题的基础。2.3 栈与队列算法世界的“基础设施”栈后进先出和队列先进先出是许多高级算法的基础容器。2.3.1 用栈实现队列要求实现一个队列的四个基本操作push, pop, peek, empty但只能使用栈的标准操作。class MyQueue: def __init__(self): # 输入栈用于接收push操作 self.stack_in [] # 输出栈用于进行pop/peek操作 self.stack_out [] def push(self, x: int) - None: self.stack_in.append(x) def pop(self) - int: # 如果输出栈为空则将输入栈的所有元素倒入输出栈 if not self.stack_out: while self.stack_in: self.stack_out.append(self.stack_in.pop()) # 从输出栈弹出元素 return self.stack_out.pop() def peek(self) - int: # 复用pop的逻辑但不出栈 res self.pop() self.stack_out.append(res) # 再放回去 return res def empty(self) - bool: return not self.stack_in and not self.stack_out设计思路与复杂度分析核心思想利用两个栈一个负责“入队”一个负责“出队”。当需要出队而输出栈为空时一次性将输入栈的所有元素“倒”入输出栈。这样输出栈的栈顶元素就是最早进入输入栈的元素实现了先进先出。均摊时间复杂度每个元素最多经历两次入栈和两次出栈一次进stack_in一次进stack_out所以push是 O(1)pop和peek的均摊时间复杂度也是 O(1)。这是面试官常问的考点你需要能清晰地解释“均摊”的概念。易错点在peek的实现中不能直接访问stack_out[-1]因为可能stack_out为空。必须调用pop再压回或者写一个独立的倒栈逻辑。直接访问可能导致错误。2.3.2 有效的括号给定一个只包含(){}[]的字符串判断是否有效。def isValid(s: str) - bool: stack [] mapping {): (, }: {, ]: [} for char in s: if char in mapping: # 遇到右括号 # 栈顶元素应该是对应的左括号 top_element stack.pop() if stack else # if mapping[char] ! top_element: return False else: # 遇到左括号入栈 stack.append(char) # 最后栈应为空 return not stack面试细节哈希映射使用字典mapping来建立右括号到左括号的映射比写一堆if...elif判断更优雅也更容易扩展。栈顶元素获取stack.pop() if stack else #这个写法很精妙。如果栈为空时遇到右括号显然无效我们弹出一个不可能匹配的字符如#来触发False。最终判断遍历结束后必须检查栈是否为空。如果栈里还有左括号说明有括号没被匹配也是无效的。例如输入((())。3. 高级算法思想实战解析3.1 双指针与滑动窗口高效遍历的利器双指针技巧通过两个指针的协同移动能在一次遍历中完成需要嵌套循环的任务将时间复杂度从 O(n²) 降为 O(n)。3.1.1 盛最多水的容器给你一个整数数组height每个元素代表一条垂直线的长度。找出其中两条线使得它们与 x 轴共同构成的容器可以容纳最多的水。def maxArea(height: List[int]) - int: left, right 0, len(height) - 1 max_water 0 while left right: # 计算当前容器的水量 width right - left h min(height[left], height[right]) current_area width * h max_water max(max_water, current_area) # 移动较短的那条边 if height[left] height[right]: left 1 else: right - 1 return max_water算法正确性证明与面试对答面试官一定会问“为什么移动较短的边是正确的” 你需要准备好解释 容器水量由宽度和最小高度决定。初始时宽度最大。如果我们移动较长的那条边宽度一定会减小而最小高度要么不变移动后遇到更长的边要么变小移动后遇到更短的边所以水量绝不会增加。只有移动较短的那条边才有可能通过增加高度来弥补宽度减少带来的损失从而找到更大的面积。这是一种贪心策略保证了我们不会错过最优解。3.1.2 无重复字符的最长子串滑动窗口给定一个字符串请你找出其中不含有重复字符的最长子串的长度。def lengthOfLongestSubstring(s: str) - int: char_index {} # 记录字符最近一次出现的位置 left 0 # 滑动窗口左边界 max_len 0 for right in range(len(s)): # right是滑动窗口右边界 char s[right] # 如果字符在窗口内出现过更新左边界 if char in char_index and char_index[char] left: left char_index[char] 1 # 更新字符的最新位置 char_index[char] right # 计算当前窗口长度 max_len max(max_len, right - left 1) return max_len滑动窗口的精髓窗口定义[left, right]区间内的子串保证无重复字符。核心操作右扩right指针不断向右移动探索新字符。左缩当s[right]这个字符在当前窗口内已经存在时即char_index[char] left说明出现了重复。为了保持窗口无重复必须将左边界left移动到该重复字符上一次出现位置的下一个位置。哈希表的作用char_index字典存储每个字符最近一次出现的索引。它帮助我们 O(1) 时间判断字符是否重复并快速定位左边界应该收缩到哪里。复杂度左右指针各遍历一次字符串时间复杂度 O(n)。空间复杂度 O(字符集大小)最多 O(n)。心得滑动窗口问题的模板是右指针负责探索左指针负责收缩用一个数据结构如哈希表、数组维护窗口内的状态。理解这个范式很多中等难度的字符串/数组问题都能迎刃而解。3.2 动态规划从暴力递归到最优解动态规划是面试中的重难点核心思想是“记住过去减少重复计算”。3.2.1 爬楼梯假设你正在爬楼梯。需要n阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶def climbStairs(n: int) - int: if n 2: return n # dp[i] 表示爬到第i阶楼梯的方法数 dp [0] * (n 1) dp[1] 1 dp[2] 2 for i in range(3, n 1): # 状态转移方程要到达第i阶可以从第i-1阶爬1步或从第i-2阶爬2步 dp[i] dp[i-1] dp[i-2] return dp[n]优化空间复杂度实际上dp[i]只依赖于前两个状态因此可以用两个变量滚动计算将空间复杂度从 O(n) 降到 O(1)。def climbStairsOpt(n: int) - int: if n 2: return n a, b 1, 2 # a代表dp[i-2], b代表dp[i-1] for _ in range(3, n 1): a, b b, a b # 新的b dp[i-1] dp[i-2] return b面试扩展面试官可能会问如果每次可以爬 1、2 或 3 个台阶呢状态转移方程就变成了dp[i] dp[i-1] dp[i-2] dp[i-3]。这考察你是否真正理解了递推关系。3.2.2 最长递增子序列给你一个整数数组nums找到其中最长严格递增子序列的长度。def lengthOfLIS(nums: List[int]) - int: if not nums: return 0 # dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度 dp [1] * len(nums) for i in range(len(nums)): for j in range(i): if nums[i] nums[j]: # 如果nums[i]可以接在nums[j]后面则更新dp[i] dp[i] max(dp[i], dp[j] 1) return max(dp) # 最终结果是所有dp[i]中的最大值算法剖析与优化提示定义是关键dp[i]的定义必须是以nums[i]结尾的 LIS 长度而不是nums[0...i]中的 LIS 长度。后者很难找到状态转移关系。状态转移对于每个i遍历所有j i如果nums[i] nums[j]说明nums[i]可以接在以nums[j]结尾的 LIS 后面形成一个更长的序列。我们取所有可能中的最大值。时间复杂度O(n²)这是基础解法。面试官常会追问“有没有 O(n log n) 的解法” 这涉及到贪心二分查找的优化方法其核心是维护一个tails数组tails[k]存储长度为k1的递增子序列的最小末尾元素。通过二分查找来更新这个数组。如果你能说出这个优化思路会是很大的加分项。3.3 排序与搜索基础算法的深度考察虽然很多语言内置了排序函数但手写排序算法能直接体现你对基础算法的掌握程度。二分查找则是考察边界处理能力的“照妖镜”。3.3.1 快速排序快速排序是“分治”思想的典型代表平均时间复杂度 O(n log n)。def quick_sort(arr, left, right): if left right: return # 分区操作返回基准值索引 pivot_index partition(arr, left, right) # 递归排序左半部分和右半部分 quick_sort(arr, left, pivot_index - 1) quick_sort(arr, pivot_index 1, right) def partition(arr, left, right): # 选择最右边的元素作为基准值 pivot arr[right] # i 指向小于pivot的区域的右边界 i left - 1 for j in range(left, right): if arr[j] pivot: i 1 arr[i], arr[j] arr[j], arr[i] # 将基准值放到正确位置 arr[i 1], arr[right] arr[right], arr[i 1] return i 1面试要点与陷阱基准值选择上述代码选择最右元素这在数组已排序或逆序时会导致最坏情况 O(n²)。可以随机选择基准值random.randint(left, right)或选择中位数来优化。分区逻辑变量i维护的是“小于等于基准值”区域的边界。j指针遍历遇到小于等于基准值的元素就将其交换到i的后面然后i右移。这个过程保证了arr[left...i]都 pivot。递归终止条件if left right:必须要有否则会无限递归。稳定性快速排序是不稳定的排序算法。面试官可能会问哪些排序是稳定的如归并排序、插入排序。3.3.2 二分查找二分查找的难点在于边界条件是“差一错误”的重灾区。def binary_search(nums: List[int], target: int) - int: left, right 0, len(nums) - 1 # 定义搜索区间为[left, right] while left right: # 当区间有效时 mid left (right - left) // 2 # 防止溢出 if nums[mid] target: return mid elif nums[mid] target: left mid 1 # 目标在右半部分调整左边界 else: # nums[mid] target right mid - 1 # 目标在左半部分调整右边界 return -1 # 未找到“差一错误”完全指南循环条件while left right这意味着搜索区间是[left, right]一个闭区间。当left right时区间内还有一个元素需要检查。如果写成while left right当最后只剩一个元素时循环会提前退出可能漏掉这个元素。边界更新left mid 1和right mid - 1因为mid已经被检查过且不等于target所以新的搜索区间应该排除mid。如果不加±1当left和right相邻时mid会始终等于left如果nums[mid] targetleft mid会导致left不变陷入死循环。计算mid使用mid left (right - left) // 2而不是(left right) // 2是为了防止left right可能发生的整数溢出在语言如C、Java中需注意Python整数无限制但这是好习惯。变体问题面试官常会问“寻找左侧边界”或“寻找右侧边界”的二分查找。例如在有序数组[1,2,2,2,3]中找target2的左侧边界索引1。这需要微调代码找到目标时不立即返回而是收缩右边界继续查找循环条件可能是while left right最终返回left需要检查是否越界和值是否匹配。务必提前准备这些变体。4. 面试实战策略与高频问题延伸4.1 解题思路的系统化训练面对一道陌生的算法题如何快速找到思路我总结了一个四步法澄清问题与边界首先用自己的话复述问题并向面试官确认理解是否正确。主动询问输入输出的格式、数据范围、特殊案例如空输入、极大值、负数等。例如“请问数组是否可能为空元素都是整数吗时间或空间复杂度有没有特别要求” 这体现了你的严谨和沟通能力。列举简单案例不要急于想最优解。先用手动计算几个简单的例子包括正常情况和边界情况。这个过程能帮你理解问题模式并验证后续的思路。画图对于链表、树、指针问题尤其有效。提出暴力解法先给出一个最直观、可能效率不高的解法。并分析其时间复杂度通常是 O(n²) 或指数级。这展示了你的基础思维同时为优化提供了起点。你可以说“最直接的想法是使用两层循环遍历所有可能时间复杂度是 O(n²)空间 O(1)。我们接下来看看如何优化。”优化与寻找模式基于暴力解法思考哪里存在重复计算能否用空间换时间哈希表、数组缓存问题是否具有最优子结构动态规划数据是否有序二分查找是否可以通过排序来简化问题将你的思考过程说出来即使最后没时间写出完美代码清晰的思路也能赢得很多分数。4.2 代码编写与测试的现场技巧在白板或在线编辑器上写代码和在自己IDE里写完全不同。先写框架再填细节先写出函数签名、主要的循环或递归结构用注释标出关键步骤。这能让面试官跟上你的思路即使时间不够框架清晰也能得分。变量命名清晰使用slow,fast,left,right,dp,stack等有意义的名称避免i,j,a,b除非在简单循环中。边写边讲解释每一行代码的意图。“这里我初始化一个哈希表来记录字符出现次数目的是实现 O(1) 时间的查找。” 这既是沟通也是自我检查。完成即测试写完代码后不要等面试官要求主动说“我来测试一下这个代码。” 选取一个中等规模的典型例子不要用最简单的如空数组或单个元素最好再选一个边界例子如全部相同元素、已排序数组。用嘴“运行”代码一步步说明变量的变化。这个过程能发现很多笔误和逻辑漏洞。4.3 25道高频题清单与核心考点以下是除上述详细讲解的题目外同样极高频率出现的题目列表及其核心考点。建议你针对每道题按照上述“四步法”进行练习。题号题目名称核心考点难度关键提示1两数之和哈希表易一遍哈希遍历用空间换时间。2合并两个有序链表链表操作、虚拟头节点易使用dummy节点简化边界处理。3有效的字母异位词哈希表、数组计数易可用长度为26的数组代替哈希表。4两数相加链表、数学、进位处理中注意最后一位进位可能产生新节点。5最长回文子串动态规划、中心扩散中dp[i][j]表示s[i..j]是否为回文。中心扩散法更优。6三数之和双指针、去重中先排序固定一个数转化为两数之和问题。去重逻辑是重点。7寻找两个正序数组的中位数二分查找、分治难转化为寻找第k小数比较两个数组的k/2位置。8最大子数组和动态规划、贪心易dp[i]表示以nums[i]结尾的最大和可优化为 O(1) 空间。9合并区间排序、贪心中按区间起点排序然后逐个合并重叠区间。10LRU缓存哈希表双向链表中哈希表保证 O(1) 查找双向链表保证 O(1) 的节点移动和删除。11字符串解码栈中遇到数字、字母、[、]分别处理用栈保存状态。12买卖股票的最佳时机动态规划、贪心易/中系列题基础版一次遍历找最小价格。含手续费、冷冻期等是DP经典。13二叉树中的最大路径和二叉树、递归难后序遍历计算单边最大贡献同时更新全局最大路径和。14单词拆分动态规划、哈希表中dp[i]表示前i个字符能否被拆分状态转移依赖字典查找。15岛屿数量DFS/BFS、网格遍历中遇到1就进行DFS/BFS将相连的1标记为已访问。16打家劫舍动态规划易dp[i]表示偷前i间房的最大金额状态转移考虑偷或不偷第i间。17课程表拓扑排序、DFS环检测中判断有向图是否有环可用Kahn算法入度表或DFS染色法。18实现 Trie (前缀树)数据结构设计中每个节点包含子节点数组或字典和一个is_end标志。19数组中的第K个最大元素堆、快速选择中维护一个大小为K的小顶堆或者用快排的partition思想。20任务调度器贪心、数学中安排冷却时间公式为(最大任务数-1)*(n1) 并列最大任务数。21编辑距离动态规划难dp[i][j]表示单词1前i个字符转成单词2前j个字符的最小操作数。22滑动窗口最大值单调队列难维护一个双端队列队首始终是当前窗口最大值队尾保持单调递减。23最长公共子序列动态规划中dp[i][j]表示 text1[0:i] 和 text2[0:j] 的 LCS 长度。24接雨水双指针、动态规划、单调栈难双指针法最巧妙计算每个位置能接的水量取决于左右最大高度的较小值。25最小覆盖子串滑动窗口难用两个哈希表记录需要匹配的字符和窗口内的字符移动左右指针寻找最优解。4.4 面试中的软技能与心态调整技术能力过关了临场发挥同样重要。把面试当成技术讨论心态上不要把自己放在被审判的位置。面试官尤其是未来的同事是想看看和你一起解决问题是否愉快。遇到难题时可以尝试说“这道题有点意思我目前的思路是XXX但感觉在YYY地方可能有问题您怎么看” 这展现了你的合作精神。诚实比小聪明更重要如果完全没思路不要硬撑或瞎猜。可以说“这个问题我之前没接触过请给我一点时间思考。” 然后尝试用上面提到的“四步法”进行分析。如果还是想不出可以坦诚地说出你的思考卡点。面试官可能会给予提示。主动思考优化和扩展写完基本解法后如果时间允许主动提出“这个解法的时间复杂度是 O(n²)空间是 O(1)。我在想是否可以用哈希表将时间复杂度降到 O(n)但空间会升到 O(n)。这是一个典型的时空权衡。” 或者问“如果输入数据量非常大无法一次性装入内存这个算法该如何调整” 这种举一反三的能力非常加分。准备你的问题面试最后面试官通常会问你有什么问题。不要问薪资、福利这些后面有HR谈。要问与团队、技术、成长相关的问题例如“我们团队目前遇到的最大的技术挑战是什么”“公司内部的技术分享和学习氛围是怎样的”“这个岗位的后续成长路径大概是怎样的” 这体现了你对工作的真诚和长远考虑。算法面试是一场准备战。这25道题及其变体构成了面试题库的基石。反复练习直到你能在没有任何提示的情况下清晰、准确、健壮地写出代码并流利地解释每一步的意图和背后的原理。记住面试官想看到的不是一个“刷题机器”而是一个思维清晰、基础扎实、能有效解决问题的未来同事。祝你面试顺利。
返回列表