程序员面试刷题从基础算法到高频面试题一网打尽零基础也能高效准备一次拿下心仪offer
一、别慌,先认清现实
面试这件事,说到底就是一场”信息差游戏”。很多小伙伴一看到LeetCode两千多道题目就放弃了,觉得”我怎么可能全做完”。但真相是:面试官根本不指望你刷完所有题,他们只是想看看你的基础牢不牢、思路清不清楚、能不能快速写出能跑的代码。
我曾经带过几个实习生,有的算法零基础,三个月后照样进了大厂;也见过刷了两千题的,一到现场手写代码就脑子空白。区别在哪?在于有没有系统地建立知识体系,以及有没有真正理解而不是死记硬背。
这篇文章我会把整个准备路线拆给你看,从最基础的数据结构到最棘手的高频题,再到面试当天的实战技巧,保证你看完就知道自己该往哪走。
二、先补基础:数据结构与算法必知必会
数组和字符串
这两个是最简单的入门题材,但里面藏着不少经典套路。
两数之和(LeetCode 1)——面试第一题常客,考察哈希表的应用。
class Solution:
def twoSum(self, nums: list[int], target: int) -> list[int]:
"""
思路:用一个字典记录已遍历的数字及其索引
对于当前数字,检查 target - 当前数字 是否已在字典中
时间复杂度: O(n)
空间复杂度: O(n)
"""
hash_map = {}
for i, num in enumerate(nums):
complement = target - num
if complement in hash_map:
return [hash_map[complement], i]
hash_map[num] = i
return []
# 测试用例
print(twoSum([2, 7, 11, 15], 9)) # 输出: [0, 1]
print(twoSum([3, 2, 4], 6)) # 输出: [1, 2]
滑动窗口是字符串/数组题的核心技巧,本质是用两个指针维护一个区间。
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
"""
无重复字符的最长子串
滑动窗口经典题:右指针不断扩展,遇到重复则左指针收缩
"""
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
# 解释一下为什么滑动窗口有效:
# 假设字符串是 "abcabcbb"
# 右指针走到第二个 'a' 时,发现窗口中已有 'a',于是左指针向右移直到去掉那个 'a'
# 这样窗口始终保持"无重复字符",同时记录最大长度
print(lengthOfLongestSubstring("abcabcbb")) # 输出: 3 ("abc")
print(lengthOfLongestSubstring("bbbbb")) # 输出: 1 ("b")
print(lengthOfLongestSubstring("pwwkew")) # 输出: 3 ("wke")
链表操作
链表题考察的是指针操作能力,很多初学者在这里容易晕。我的建议是画图,画出来就好理解了。
两数相加(LeetCode 2)——链表加法,面试高频。
# 链表节点定义
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def addTwoNumbers(self, l1: ListNode, l2: ListNode) -> ListNode:
"""
两个逆序链表表示的数字相加
例如: 342 + 465 = 807
输入: (2->4->3) + (5->6->4)
输出: 7->0->8
核心思路:模拟竖式加法,注意进位
"""
dummy = ListNode(0) # 哨兵节点,简化边界处理
curr = dummy
carry = 0
while l1 or l2 or carry:
# 获取当前节点的值,如果节点为空则取0
val1 = l1.val if l1 else 0
val2 = l2.val if l2 else 0
total = val1 + val2 + carry
carry = total // 10 # 进位
curr.next = ListNode(total % 10) # 当前位的值
curr = curr.next
if l1: l1 = l1.next
if l2: l2 = l2.next
return dummy.next # 跳过哨兵节点
# 辅助函数:链表转数组(方便测试)
def list_to_arr(head):
result = []
while head:
result.append(head.val)
head = head.next
return result
# 辅助函数:数组转链表
def arr_to_list(arr):
dummy = ListNode(0)
curr = dummy
for val in arr:
curr.next = ListNode(val)
curr = curr.next
return dummy.next
# 测试
l1 = arr_to_list([2, 4, 3]) # 表示 342
l2 = arr_to_list([5, 6, 4]) # 表示 465
result = addTwoNumbers(l1, l2)
print(list_to_arr(result)) # 输出: [7, 0, 8] 表示 807
反转链表(LeetCode 206)——这道题太经典了,八股文必考。
class Solution:
def reverseList(self, head: ListNode) -> ListNode:
"""
迭代法反转链表
核心:维护三个指针 prev, curr, next_temp
每次把当前节点的指针指向前一个节点
"""
prev = None
curr = head
while curr:
next_temp = curr.next # 先保存下一个节点
curr.next = prev # 反转指针
prev = curr # prev 前进
curr = next_temp # curr 前进
return prev # prev 最终指向新的头节点
def reverseList_recursive(self, head: ListNode) -> ListNode:
"""
递归法反转链表(面试可能会让你写两种)
递归到最后一个节点后,逐层反转指针
"""
# 终止条件:空节点或只有一个节点
if not head or not head.next:
return head
# 递归反转后半部分
new_head = self.reverseList_recursive(head.next)
# 反转当前节点和下一个节点的连接
head.next.next = head
head.next = None
return new_head
# 测试
head = arr_to_list([1, 2, 3, 4, 5])
print(list_to_arr(reverseList(head))) # 输出: [5, 4, 3, 2, 1]
栈和队列
栈是”后进先出”(LIFO),队列是”先进先出”(FIFO)。这两个结构在算法题里经常以不同形式出现。
有效的括号(LeetCode 20)——栈的经典应用。
class Solution:
def isValid(self, s: str) -> bool:
"""
判断括号字符串是否有效
思路:遇到左括号入栈,遇到右括号检查栈顶是否匹配
如果栈为空或者不匹配,返回False
最后栈必须为空才算有效
"""
stack = []
# 建立映射关系
bracket_map = {')': '(', '}': '{', ']': '['}
for char in s:
if char in bracket_map:
# 是右括号,检查栈顶
top_element = stack.pop() if stack else '#'
if bracket_map[char] != top_element:
return False
else:
# 是左括号,入栈
stack.append(char)
return not stack # 栈为空才有效
# 测试
print(isValid("()")) # True
print(isValid("()[]{}")) # True
print(isValid("(]")) # False
print(isValid("([)]")) # False
print(isValid("{[]}")) # True
二叉树
树是面试中最常考的专题之一,DFS和BFS两种遍历方式必须熟练掌握。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class Solution:
def maxDepth(self, root: TreeNode) -> int:
"""
二叉树的最大深度
递归思路:树的最大深度 = max(左子树深度, 右子树深度) + 1
时间复杂度: O(n) 每个节点访问一次
空间复杂度: O(h) h为树的高度
"""
if not root:
return 0
return max(self.maxDepth(root.left), self.maxDepth(root.right)) + 1
def invertTree(self, root: TreeNode) -> TreeNode:
"""
反转二叉树(这道题很有意思,Google面试经常被问)
把每个节点的左右子树交换
"""
if not root:
return None
# 交换左右子树
root.left, root.right = root.right, root.left
# 递归处理左右子树
self.invertTree(root.left)
self.invertTree(root.right)
return root
def levelOrder(self, root: TreeNode) -> list[list[int]]:
"""
层序遍历(BFS)——必须掌握
按照从上到下、从左到右的顺序遍历二叉树
"""
if not root:
return []
result = []
queue = [root] # 用列表模拟队列
while queue:
level_size = len(queue) # 当前层的节点数
current_level = []
for _ in range(level_size):
node = queue.pop(0)
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
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode',
q: 'TreeNode') -> 'TreeNode':
"""
二叉树的最近公共祖先(LeetCode 236)
递归思路:
- 如果当前节点是p或q,返回当前节点
- 如果p和q分别在左右子树,当前节点就是LCA
- 否则返回非空的那一侧
"""
if not root or root == p or root == q:
return root
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if left and right:
return root # p和q分别在两侧
return left if left else right
# 测试:构建二叉树 [3,9,20,null,null,15,7]
# 3
# / \
# 9 20
# / \
# 15 7
root = TreeNode(3)
root.left = TreeNode(9)
root.right = TreeNode(20)
root.right.left = TreeNode(15)
root.right.right = TreeNode(7)
print(maxDepth(root)) # 输出: 3
print(levelOrder(root)) # 输出: [[3], [9, 20], [15, 7]]
二分查找
二分查找虽然简单,但边界条件很容易写错,面试时写对需要练习。
class Solution:
def search(self, nums: list[int], target: int) -> int:
"""
二分查找标准模板
在有序数组中查找目标值的索引,不存在返回-1
"""
left, right = 0, len(nums) - 1
while left <= right: # 注意是 <=,不是 <
mid = left + (right - left) // 2 # 防止溢出
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
def searchInsert(self, nums: list[int], target: int) -> int:
"""
搜索插入位置
如果目标不在数组中,返回它应该被插入的位置
"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return left # 关键:循环结束时left就是插入位置
def searchRange(self, nums: list[int], target: int) -> list[int]:
"""
在排序数组中查找元素的第一个和最后一个位置
需要两次二分查找:一次找左边界,一次找右边界
"""
def findLeftBound(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return left
def findRightBound(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] <= target:
left = mid + 1
else:
right = mid - 1
return right
left = findLeftBound(nums, target)
right = findRightBound(nums, target)
if left <= right and right < len(nums) and nums[left] == target:
return [left, right]
return [-1, -1]
# 测试
nums = [5, 7, 7, 8, 8, 10]
print(searchRange(nums, 8)) # 输出: [3, 4]
print(searchRange(nums, 6)) # 输出: [-1, -1]
动态规划
DP是面试中最让人头疼的部分,但其实只要掌握了套路,就没那么难。核心思想是把大问题拆成小问题,记住之前算过的结果避免重复计算。
爬楼梯(LeetCode 70)——最入门的DP题。
class Solution:
def climbStairs(self, n: int) -> int:
"""
爬楼梯:每次可以爬1或2个台阶,问爬到第n层有多少种方法
状态转移方程:dp[i] = dp[i-1] + dp[i-2]
这其实就是斐波那契数列!
"""
if n <= 2:
return n
# 只需保存前两个状态,空间优化到O(1)
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
curr = prev1 + prev2
prev2, prev1 = prev1, curr
return prev1
def minCostClimbingStairs(self, cost: list[int]) -> int:
"""
最小费用爬楼梯:到达第i层可以支付cost[i],从第i层可以爬1或2步
求到达楼顶(最后一级台阶之后)的最小费用
"""
n = len(cost)
# dp[i]表示到达第i级台阶的最小费用
dp = [0] * (n + 1)
for i in range(2, n + 1):
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
return dp[n]
# 空间优化版本
prev2, prev1 = 0, 0
for i in range(2, n + 1):
curr = min(prev1 + cost[i-1], prev2 + cost[i-2])
prev2, prev1 = prev1, curr
return prev1
0-1背包问题——这是所有背包问题的基础,必须理解透。
class Solution:
def findTargetSumWays(self, nums: list[int], target: int) -> int:
"""
目标和(LeetCode 494)
本质上是0-1背包的变种
设P为正数集合的和,N为负数集合的和
P - N = target
P + N = sum(nums)
所以 P = (target + sum(nums)) / 2
问题转化为:从nums中选出一些数,使它们的和等于P
"""
total = sum(nums)
if (target + total) % 2 != 0 or abs(target) > total:
return 0
capacity = (target + total) // 2
# 0-1背包:dp[j]表示容量为j时能装的最大价值
# 这里价值和重量相同
dp = [0] * (capacity + 1)
for num in nums:
# 从后往前遍历,避免重复使用同一个物品
for j in range(capacity, num - 1, -1):
dp[j] = max(dp[j], dp[j - num] + num)
return dp[capacity]
def change(self, amount: int, coins: list[int]) -> int:
"""
零钱兑换 II(LeetCode 518)
完全背包问题:每种硬币数量无限
求组成amount的组合数
"""
dp = [0] * (amount + 1)
dp[0] = 1 # 凑成0元只有一种方式:什么都不选
for coin in coins:
for j in range(coin, amount + 1):
dp[j] += dp[j - coin]
return dp[amount]
def coinChange(self, coins: list[int], amount: int) -> int:
"""
零钱兑换(LeetCode 322)
完全背包求最小数量
"""
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for j in range(coin, amount + 1):
dp[j] = min(dp[j], dp[j - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
三、高频面试真题精选
链表部分
合并K个升序链表(LeetCode 23)——难度:困难,但思路清晰。
import heapq
class Solution:
def mergeKLists(self, lists: list[ListNode]) -> ListNode:
"""
合并K个升序链表
方法:最小堆(优先队列)
每次从K个链表的头部取最小值,时间复杂度 O(NlogK)
N是所有节点的总数,K是链表个数
"""
# 定义最小堆的比较规则:按节点值比较
heap = []
# 将每个链表的头节点加入堆
for i, node in enumerate(lists):
if node:
heapq.heappush(heap, (node.val, i, node))
dummy = ListNode(0)
curr = dummy
while heap:
val, list_idx, node = heapq.heappop(heap)
curr.next = node
curr = curr.next
# 如果该链表还有下一个节点,加入堆
if node.next:
heapq.heappush(heap, (node.next.val, list_idx, node.next))
return dummy.next
def mergeKLists_merge_sort(self, lists: list[ListNode]) -> ListNode:
"""
分治法合并K个升序链表
每次两两合并,时间复杂度同样是 O(NlogK)
但不用额外空间(除了递归栈)
"""
if not lists:
return None
if len(lists) == 1:
return lists[0]
# 两两合并
def mergeTwoLists(l1, l2):
dummy = ListNode(0)
curr = dummy
while l1 and l2:
if l1.val <= l2.val:
curr.next = l1
l1 = l1.next
else:
curr.next = l2
l2 = l2.next
curr = curr.next
curr.next = l1 if l1 else l2
return dummy.next
# 分治合并
k = len(lists)
while k > 1:
for i in range(k // 2):
lists[i] = mergeTwoLists(lists[i], lists[k - 1 - i])
k = (k + 1) // 2
return lists[0]
LRU缓存(LeetCode 146)——中等难度,但考察内容很全面。
from collections import OrderedDict
class LRUCache:
"""
LRU缓存:最近最少使用缓存
要求get和put操作的时间复杂度都是O(1)
数据结构:哈希表 + 双向链表
Python中用OrderedDict可以很方便地实现
"""
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = OrderedDict()
def get(self, key: int) -> int:
"""
如果key存在,返回对应值并将其移到末尾(最新使用)
如果不存在,返回-1
"""
if key not in self.cache:
return -1
# move_to_end将已存在的key移到末尾(表示最近使用)
self.cache.move_to_end(key)
return self.cache[key]
def put(self, key: int, value: int) -> None:
"""
如果key已存在,更新值并移到末尾
如果key不存在:
- 如果缓存已满,删除最久未使用的(头部)
- 添加新key到末尾
"""
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.capacity:
# popitem(last=False)删除最旧的(头部)
self.cache.popitem(last=False)
# 测试
cache = LRUCache(2)
cache.put(1, 1) # 缓存: {1=1}
cache.put(2, 2) # 缓存: {1=1, 2=2}
print(cache.get(1)) # 返回 1,缓存变为 {2=2, 1=1}
cache.put(3, 3) # 淘汰key 2,缓存变为 {1=1, 3=3}
print(cache.get(2)) # 返回 -1(未找到)
cache.put(4, 4) # 淘汰key 1,缓存变为 {3=3, 4=4}
print(cache.get(1)) # 返回 -1
print(cache.get(3)) # 返回 3
print(cache.get(4)) # 返回 4
树的部分
二叉树的层序遍历和二叉搜索树的最近公共祖先在前面的代码里已经展示了,这里再补充一个高频题。
从前序与中序遍历构造二叉树(LeetCode 105)。
class Solution:
def buildTree(self, preorder: list[int], inorder: list[int]) -> TreeNode:
"""
根据前序遍历和中序遍历构造二叉树
核心思路:
- 前序遍历的第一个元素是根节点
- 在中序遍历中找到根节点,左边是左子树,右边是右子树
- 递归构造左右子树
时间复杂度: O(n)
空间复杂度: O(n) 用于存储inorder的索引映射
"""
# 建立中序遍历的值到索引的映射,加速查找
inorder_index_map = {val: i for i, val in enumerate(inorder)}
pre_index = [0] # 用列表存储,方便在递归中修改
def build(pre_start, pre_end, in_start, in_end):
if pre_start > pre_end or in_start > in_end:
return None
# 前序遍历的第一个元素是根节点
root_val = preorder[pre_index[0]]
pre_index[0] += 1
root = TreeNode(root_val)
# 在中序遍历中找到根节点的位置
in_root_index = inorder_index_map[root_val]
# 左子树的节点数
left_size = in_root_index - in_start
# 递归构造左子树
# 前序遍历:[pre_start+1, pre_start+left_size]
# 中序遍历:[in_start, in_root_index-1]
root.left = build(pre_start + 1, pre_start + left_size,
in_start, in_root_index - 1)
# 递归构造右子树
# 前序遍历:[pre_start+left_size+1, pre_end]
# 中序遍历:[in_root_index+1, in_end]
root.right = build(pre_start + left_size + 1, pre_end,
in_root_index + 1, in_end)
return root
return build(0, len(preorder) - 1, 0, len(inorder) - 1)
def validateBST(self, root: TreeNode) -> bool:
"""
验证二叉搜索树(LeetCode 98)
BST的性质:左子树所有节点 < 根节点 < 右子树所有节点
用递归方式验证,每个节点都有一个合法的取值范围
"""
def helper(node, lower, upper):
if not node:
return True
val = node.val
if val <= lower or val >= upper:
return False
# 左子树的上界是当前节点的值,下界不变
# 右子树的下界是当前节点的值,上界不变
return helper(node.left, lower, val) and \
helper(node.right, val, upper)
return helper(root, float('-inf'), float('inf'))
排序和搜索
排序链表(LeetCode 148)——要求 O(n log n) 时间复杂度。
class Solution:
def sortList(self, head: ListNode) -> ListNode:
"""
排序链表:归并排序(链表最适合用归并排序)
时间复杂度: O(n log n)
空间复杂度: O(log n) 递归栈空间
为什么不用快速排序?链表随机访问效率低,归并更适合
"""
# 终止条件:空链表或只有一个节点
if not head or not head.next:
return head
# 快慢指针找中点
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 从中间断开
mid = slow.next
slow.next = None
# 递归排序左右两半
left = self.sortList(head)
right = self.sortList(mid)
# 合并两个有序链表
return self.mergeTwoLists(left, right)
def mergeTwoLists(self, l1: ListNode, l2: ListNode) -> ListNode:
"""合并两个有序链表"""
dummy = ListNode(0)
curr = dummy
while l1 and l2:
if l1.val <= l2.val:
curr.next = l1
l1 = l1.next
else:
curr.next = l2
l2 = l2.next
curr = curr.next
curr.next = l1 if l1 else l2
return dummy.next
# 测试
head = arr_to_list([4, 2, 1, 3])
result = sortList(head)
print(list_to_arr(result)) # 输出: [1, 2, 3, 4]
四、零基础准备计划:三个月高效提分
很多小伙伴问我”从零开始怎么准备”,我给过一个最实在的路线规划:
第一个月:打基础
先把数组、字符串、链表、栈、队列这些基础数据结构搞明白。每学一个概念就手写实现一遍,别只是看。比如链表,自己实现一个链表类,包含增删改查操作;比如栈,自己用数组实现一个栈。这个阶段的目标是:看到题目知道用什么数据结构。
我推荐从LeetCode简单难度的前50题开始刷,重点刷这些:
- 两数之和、两数相加、反转链表、回文链表
- 有效的括号、最小栈
- 爬楼梯、斐波那契数列
第二个月:专项突破
进入中等难度的刷题阶段。每周专注一个专题:
- 第一周:二分查找 + 滑动窗口
- 第二周:二叉树(DFS/BFS)
- 第三周:动态规划入门(背包问题、子序列问题)
- 第四周:回溯算法(排列组合、子集)
每个专题刷15-20道题,同一类型的题思路是相通的,刷多了就会发现套路。
第三个月:模拟实战
找往年真题进行模拟面试。限时45分钟做一套题,包括算法题和系统设计题。同时准备自我介绍和项目介绍,这部分经常被忽略但其实很重要。
五、面试当天的几个小细节
拿到题目先确认边界条件:空输入怎么处理?数据范围多大?这些问清楚再写代码,能避免很多返工。
先说思路再写代码:口头描述你的解题思路,让面试官了解你的思考过程。这比直接闷头写代码要好得多。
代码写完后主动分析复杂度:时间复杂度和空间复杂度分别是什么?有没有优化空间?这体现了你的工程素养。
遇到不会的题别慌:可以坦诚地说”这个题我目前不太熟悉,但我会尝试从XX角度来分析”。面试官更看重你的思考能力而不是会不会做某道具体题目。
六、最后说几句心里话
说实话,刷题这件事最难的从来不是题目本身,而是坚持。每天刷2-3道题,三个月下来就是一百多道,覆盖了面试90%以上的考点。很多人不是不会,而是没能坚持下来。
我见过太多人,刚开始信心满满,刷了两天就退缩了。但只要你按部就班地走,每天进步一点点,面试的时候你会发现——那些你以为很难的题,其实也就那么回事。
记住,算法题不是靠智商刷的,是靠肌肉记忆刷的。写多了,看到题目就能反应过来该用什么套路,这才是真正的熟练。
加油,等你拿到offer那天,你会感谢现在努力的自己。
