
前阵子帮一个学弟做算法面试复盘发现他排序题能背出快排模板动态规划也能写二维数组但面试官一问“01背包的dp数组为什么要倒序遍历”他当场卡住了。这种情况我见过太多次很多人的算法知识停留在“会写模板”的层面离“讲清楚原理”还差着一段距离。这正好印证了我一直强调的一个观点——面试考察的从来不是背题能力而是拆解问题的底层逻辑。今天这篇就围绕两个高频模块展开一是排序相关的面试题到底怎么考、怎么答二是动态规划空间压缩的原理、适用边界和实战技巧。不管是正在准备面试还是刷题刷到瓶颈的学习者这篇都值得认真看完。1. 排序面试题的实际考法面试官到底在考察什么1.1 排序题不只是在考“会不会背”很多人以为排序题就是“手写一个快排”其实这是低估了面试官的意图。排序算法作为数据结构与算法的基础模块面试官真正想考察的有三件事第一你是否理解排序算法的核心机制包括比较、交换、分治、堆化这些操作背后的数据流动方式第二你是否能说清楚时间复杂度和空间复杂度尤其是最好、最坏、平均情况分别是什么第三你是否能在变体题中举一反三比如从快排延伸到TopK问题从归并排序延伸到逆序对统计。我整理了面试中常见的排序算法对比表建议收藏起来反复看算法平均时间复杂度最坏时间复杂度空间复杂度稳定性冒泡排序O(n^2)O(n^2)O(1)稳定选择排序O(n^2)O(n^2)O(1)不稳定插入排序O(n^2)O(n^2)O(1)稳定希尔排序O(n^1.3)左右O(n^2)O(1)不稳定归并排序O(n log n)O(n log n)O(n)稳定堆排序O(n log n)O(n log n)O(1)不稳定快速排序O(n log n)O(n^2)O(log n)栈空间不稳定计数/桶/基数排序O(nk)O(nk)O(nk)取决于实现这张表你要能做到随时默写出来而且要能解释清楚“为什么”。比如堆排序空间复杂度为什么是O(1)——因为堆化过程是在原数组上原地进行的不需要额外数组归并排序为什么稳定——因为合并两个有序子数组时左边元素相等时优先取左边这等于保留了原始相对顺序。1.2 高频手写题快排、归并、堆排手写基础排序是面试的保留项目但三个算法的考察重点完全不同。快排的核心是partition函数面试现场最常见的写法是Lomuto分区代码短、容易写对def quick_sort(arr, left, right): if left right: return pivot arr[right] # 选最右元素作为基准 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] pos i 1 quick_sort(arr, left, pos - 1) quick_sort(arr, pos 1, right)注意如果你用这个写法面试官可能会追问“基准选择对性能的影响”——当数组已经有序时选最右元素会导致退化成O(n^2)。这是快排最大的坑也是面试官最爱挖的细节。备用方案是随机选基准或三数取中。归并排序的考点在merge过程需要额外O(n)空间def merge_sort(arr): if len(arr) 1: return arr mid len(arr) // 2 left merge_sort(arr[:mid]) right merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): i j 0 res [] while i len(left) and j len(right): if left[i] right[j]: res.append(left[i]) i 1 else: res.append(right[j]) j 1 res.extend(left[i:]) res.extend(right[j:]) return res这里有个细节left[i] right[j]这个等号是稳定性的关键。如果写成相等元素会优先取右侧的稳定性就破坏了。面试时写归并面试官极大概率会问这行代码。堆排序的实现相对复杂heapify是核心def heapify(arr, n, i): largest i left 2 * i 1 right 2 * i 2 if left n and arr[left] arr[largest]: largest left if right n and arr[right] arr[largest]: largest right if largest ! i: arr[i], arr[largest] arr[largest], arr[i] heapify(arr, n, largest) def heap_sort(arr): n len(arr) for i in range(n // 2 - 1, -1, -1): heapify(arr, n, i) for i in range(n - 1, 0, -1): arr[i], arr[0] arr[0], arr[i] heapify(arr, i, 0)堆排序的考察重点是建堆过程的时间复杂度这里有个反直觉的结论建堆不是O(n log n)而是O(n)。原因是越底层的节点数量越多但它们做heapify时下降高度越小整体求和后是O(n)。这个结论建议自己推导一遍面试时能讲出来会让面试官高看一眼。1.3 容易忽略的非比较排序当一个排序问题要求“线性时间复杂度”或者数据范围有限时比较排序就不适用了。计数排序、桶排序、基数排序这类非比较排序在面试中虽然出现频率不如快排归并但在特定场景题里很能拉开差距。计数排序要求数据范围k不要太大做一次统计加一次前缀和时间复杂度O(nk)。比如“对一个年龄数组排序年龄范围0-150”用计数排序就是最优解。桶排序的思想是均匀分布到多个桶内桶内各自排序适合数据范围大但分布相对均匀的场景。基数排序则按位比较从低位到高位依次排序适合整数和短字符串排序。我在实际面试里观察到能主动说出“这个场景可以用计数排序因为数据范围有限”的候选人通常会被认为算法思维更灵活。这比盲目写一个快排更能体现水平。2. 几道经典排序面试题的拆解与参考答案2.1 题型一寻找第K大元素快速选择题目在一个无序数组中找出第K大的元素。要求时间复杂度O(n)不能直接排序。这道题是快排partition的直接变体。思路是通过partition确定一个基准元素的最终位置pos如果pos正好是第K大的位置直接返回如果pos在目标位置左边就在右侧区间继续找否则在左侧区间找。平均时间复杂度O(n)因为每次只需要处理一半数据n n/2 n/4 ... 2n。def find_kth_largest(nums, k): target_index len(nums) - k def quick_select(left, right): pivot nums[right] i left - 1 for j in range(left, right): if nums[j] pivot: i 1 nums[i], nums[j] nums[j], nums[i] nums[i 1], nums[right] nums[right], nums[i 1] pos i 1 if pos target_index: return nums[pos] elif pos target_index: return quick_select(pos 1, right) else: return quick_select(left, pos - 1) return quick_select(0, len(nums) - 1)注意LeetCode的经典题里 pivot能保证重复数字的分布更均匀。这道题最容易犯的错误是忘了把“第K大”转换成“升序数组中第(len-K)小”的下标转换错了结果必然错。面试时建议先画一个简单的数组手动模拟一遍partition过程再写代码。2.2 题型二逆序对计数归并排序题目给定一个数组求逆序对的数量。比如[7, 5, 6, 4]中逆序对有(7,5)、(7,6)、(7,4)、(5,4)、(6,4)一共5个。暴力法是O(n^2)不够看。归并排序可以在合并左右两个有序数组时顺便统计逆序对时间复杂度降到O(n log n)。核心思想合并时如果右侧数组的元素小于左侧数组的元素那么左侧数组中当前元素到左侧末尾的所有元素都和这个右侧元素构成逆序对。def reverse_pairs(nums): count 0 def merge_sort(arr): nonlocal count if len(arr) 1: return arr mid len(arr) // 2 left merge_sort(arr[:mid]) right merge_sort(arr[mid:]) # 合并并统计 merged [] i j 0 while i len(left) and j len(right): if left[i] right[j]: merged.append(left[i]) i 1 else: count len(left) - i merged.append(right[j]) j 1 merged.extend(left[i:]) merged.extend(right[j:]) return merged merge_sort(nums) return count关键点在count len(left) - i这一行理解了它这道题就彻底拿下了。面试官还可能追问如果数组很大内存放不下怎么办这就是外排序的思路把数组分块到磁盘上用多路归并处理同时统计逆序对。2.3 题型三自定义排序规则题目把数组排成最小的数。给定一个非负整数数组将所有数字拼接起来返回最小的拼接结果。这题考察的不是某种经典排序算法而是对排序规则的理解。我们需要自定义比较器对两个字符串a、b如果ab ba那么a应该排在b前面。from functools import cmp_to_key def min_number(nums): strs list(map(str, nums)) def compare(a, b): if a b b a: return -1 elif a b b a: return 1 else: return 0 strs.sort(keycmp_to_key(compare)) result .join(strs) return result.lstrip(0) or 0这道题的高频追问是“为什么直接按字典序排序不对”。比如[3, 30]字典序“30”在“3”前面拼出来是“303”但“330”才更小。所以必须走自定义规则。这种题非常能区分“背过排序模板”和“真正理解排序逻辑”的候选人。2.4 题型四TopK问题与海量数据排序TopK是排序模块里最常考的应用题。如果是“求最大K个数”维护一个大小为K的最小堆堆顶就是当前K个数里最小的来了更大的数就替换堆顶最终堆里就是最大的K个。时间复杂度O(n log K)。如果K很小比全排序O(n log n)要高效得多。面试中可能升级成海量数据场景比如有10亿个整数内存只有几百MB怎么找前100大的数答案还是最小堆遍历一遍数据流堆的大小只维持100内存占用非常小。如果更进一步要求排序输出这100个数对堆做一次性排序即可。海量数据完整排序的解法是外部排序也就是先分块排序各归各再用多路归并合并。这里归并排序的思想又一次派上用场。面试官问到这种题其实已经从“排序算法”考到“系统设计”了能把这个链路讲清楚已经是资深工程师的思维水平。3. 动态规划空间压缩的前提从二维状态表到滚动数组3.1 空间压缩的本质是看状态依赖方向动态规划的空间压缩本质上不是魔法而是在问一个问题当前状态的计算到底依赖哪些历史状态如果当前状态只依赖上一层的部分状态那我们就不需要把所有状态都存下来。先用一个最基础也最经典的例子开场——斐波那契数列。常规DP写法def fib(n): dp [0] * (n 1) dp[1] 1 for i in range(2, n 1): dp[i] dp[i - 1] dp[i - 2] return dp[n]这个O(n)空间的数组其实只需要存两个变量def fib_optimized(n): if n 1: return n prev2, prev1 0, 1 for i in range(2, n 1): cur prev1 prev2 prev2, prev1 prev1, cur return prev1因为每一项只依赖前两项更早的状态永远不会再用到。这就是空间压缩最朴素的形态。理解了这个再去看二维DP的空间压缩就不难了。3.2 滚动数组从O(n^2)到O(n)在二维DP中最常见的形式是dp[i][j]只依赖dp[i-1][...]。也就是说计算第i层的所有状态时只有第i-1层的数据是需要的第i-2层及之前的数据都可以扔掉。这时可以用滚动数组只保留两行# 假设原状态转移依赖上一行 dp [[0] * n for _ in range(2)] # 只保留两行 now, pre 0, 1 for i in range(1, m): for j in range(n): dp[now][j] f(dp[pre][j-1], dp[pre][j]) now, pre pre, now # 交换两行很多初学者觉得滚动数组不好理解其实它就是一个“双层缓冲”的思路像GPU的两帧缓冲正在渲染的是前一层结果翻页之后原来的前层变成后层。代码上通过两个临时变量交换行省掉一整块二维空间复杂度从O(m*n)降到O(n)。3.3 什么时候不能压缩或者压缩要格外小心空间压缩不是万能的有些DP题硬压缩会把正确性压没。我总结了三种情况状态依赖跨越两层以上。如果dp[i][j]依赖dp[i-1][j]和dp[i-2][j]只保留两行就不够需要保留三行。这时要用环状数组或固定大小的数组滚动。需要回溯路径。比如“矩阵中的最短/最长路径”要求打印路径只保留最后一行的最优值中间决策信息就丢了。解决办法通常是保留完整DP表或额外开一个parent数组记录转移方向。状态本身代表的不只是一层结果。比如区间DP、树形DP依赖关系不是线性分层不能用简单滚动数组处理。空间压缩的判断逻辑可以总结成一句话先看依赖层数再看是否需要追溯决策。这两点想清楚压缩与否自己就能拿主意。4. 01背包的二维降一维为什么必须倒序遍历4.1 从二维DP说起先理清转移方程01背包问题是动态规划里最经典的入门模型也是面试出现频率相当高的题。题目描述一般是有n个物品每个物品有重量w[i]和价值v[i]背包容量为W每个物品只能取一次求能装入背包的最大价值。二维DP定义dp[i][j]表示前i个物品中背包容量为j时能获得的最大价值。转移方程if j w[i]: dp[i][j] dp[i-1][j] # 放不下只能不取 else: dp[i][j] max(dp[i-1][j], dp[i-1][j-w[i]] v[i])核心判断是取第i个物品需要从容量j中减去w[i]加上v[i]和不取该物品保持dp[i-1][j]比较谁更大。用二维数组写出来def knapsack_2d(weights, values, W): n len(weights) dp [[0] * (W 1) for _ in range(n 1)] for i in range(1, n 1): for j in range(W 1): if weights[i-1] j: dp[i][j] dp[i-1][j] else: dp[i][j] max(dp[i-1][j], dp[i-1][j-weights[i-1]] values[i-1]) return dp[n][W]这个版本很好理解但空间复杂度是O(n*W)当容量是10000、物品数是100时就要存10^6个数内存压力不小。面试官一定会追问能不能优化空间4.2 一维数组正序遍历的典型错误既然dp[i][j]只依赖dp[i-1][...]那自然想到开一个一维数组dp[j]每轮更新时覆盖为新一轮的值。很多人写了下面这个版本def knapsack_wrong(weights, values, W): dp [0] * (W 1) for i in range(len(weights)): for j in range(weights[i], W 1): # 正序遍历 dp[j] max(dp[j], dp[j - weights[i]] values[i]) return dp[W]这段话跑起来的结果往往不对但有些新手没意识到错在哪。核心问题是正序遍历导致当前物品被重复使用。举个例子只有一个物品重量2价值3背包容量4。正序遍历流程j2时dp[2] max(dp[2], dp[0]3) 3j3时dp[3] max(dp[3], dp[1]3) 3j4时dp[4] max(dp[4], dp[2]3) 6看dp[2]已经在当前物品的同一轮更新中被赋值为3更新dp[4]时用了这个被“污染”的状态相当于把同一个物品取了两次。但01背包要求每件物品最多取一次这个结果就错了。4.3 倒序遍历为什么是对的改成倒序就解决了def knapsack_1d(weights, values, W): dp [0] * (W 1) for i in range(len(weights)): for j in range(W, weights[i] - 1, -1): # 倒序遍历 dp[j] max(dp[j], dp[j - weights[i]] values[i]) return dp[W]关键在于倒序遍历时更新dp[j]使用的dp[j - weights[i]]还没有被当前轮更新过仍然是上一轮前i-1个物品的结果。因为j从大到小走j - weights[i]一定小于j在倒序遍历顺序下这个更小的下标一定比j更晚被更新所以它保留的是旧值。这本质上是以时间顺序换取空间一维数组在更新过程中“半新半旧”倒序保证了每个状态更新时引用的依赖都来自旧的一轮。我在面试复盘时经常和学弟说理解了这个“半新半旧”的画面就永远不会把遍历顺序写反。4.4 完全背包为什么反过来要正序遍历如果题目改成“每个物品可以无限取”也就是完全背包问题代码只剩一处不同——遍历顺序变成正序def unbounded_knapsack(weights, values, W): dp [0] * (W 1) for i in range(len(weights)): for j in range(weights[i], W 1): # 正序 dp[j] max(dp[j], dp[j - weights[i]] values[i]) return dp[W]正序为什么在这个场景下是对的因为完全背包本来就允许重复使用同一个物品正序遍历时dp[j - weights[i]]已经被当前物品更新过再次参与比较相当于在当前容量下再次尝试“加一件当前物品”这正好实现了无限取用的效果。对比一下01背包和完全背包的代码只有遍历顺序不同但背后的语义完全相反。面试时能把这个对比讲清楚基本就能证明你真正理解DP压缩而不只是背模板。5. 空间压缩实战常见DP题目的压缩思路与易错点5.1 最长公共子序列LCS的降维LCS的状态转移方程dp[i][j] dp[i-1][j-1] 1, 如果 text1[i-1] text2[j-1] dp[i][j] max(dp[i-1][j], dp[i][j-1]), 否则依赖来自三个方向上方、左方、左上。普通二维数组是O(m*n)空间可以压缩成一维。直接用一个一维数组dp[j]表示当前行的状态但更新时有个细节由于依赖dp[i-1][j-1]左上角更新dp[j]之前必须先把旧的dp[j]上一行的dp[i-1][j]和旧的dp[j-1]上一行的dp[i-1][j-1]保存下来否则推下一格时左上角被覆盖了结果就错了。def longest_common_subsequence(text1, text2): m, n len(text1), len(text2) dp [0] * (n 1) for i in range(1, m 1): prev 0 # 保存 dp[i-1][j-1] for j in range(1, n 1): temp dp[j] # 保存当前 dp[i-1][j] if text1[i-1] text2[j-1]: dp[j] prev 1 else: dp[j] max(dp[j], dp[j-1]) prev temp # 下一轮的左上角是当前轮的“上” return dp[n]这里的prev和temp就是压缩版本用来补足“左上角依赖”的临时变量。踩过坑的人就知道把这两行变量搞丢了调试到怀疑人生。5.2 编辑距离的降维编辑距离和LCS非常像dp[i][j]表示把word1[0:i]变成word2[0:j]的最小操作数转移同样依赖上方、左方、左上三个方向。压缩成一维数组时同样需要一个保存对角线值的变量def edit_distance(word1, word2): m, n len(word1), len(word2) dp list(range(n 1)) # 第0行空串变成word2[0:j]需要j次插入 for i in range(1, m 1): prev dp[0] # dp[i-1][-1] 的“虚拟”值等于 i-1 时的编辑距离 dp[0] i # 第0列word1[0:i]变成空串需要i次删除 for j in range(1, n 1): temp dp[j] # 保存 dp[i-1][j] if word1[i-1] word2[j-1]: dp[j] prev else: dp[j] min(dp[j], dp[j-1], prev) 1 prev temp return dp[n]这里最容易出错的是dp[0]的更新。从第二行开始dp[0]不再是0而是i——因为前i个字符全部删除需要i步。很多人在压缩时忘记更新这一列导致第一列计算结果始终错误。5.3 空间压缩时的几个高频坑位结合我的刷题和辅导经验DP空间压缩的坑主要集中在表格里这几类易错点具体表现解决办法更新顺序不对01背包写成正序物品被重复取用明确题目是01背包还是完全背包再决定遍历方向左上角依赖没保存LCS/编辑距离结果偏小用临时变量在覆盖前保存旧值边界列/行忘记初始化dp[0]没有随行数更新每轮循环开始前手动设置对应边界依赖两层以上却只压缩到一维结果出现随机性错误先判断依赖层数不够就保留三行滚动多个维度混在一起压缩找不到对应关系代码可读性差先写二维版本再逐步降维不要一步到位这些坑我自己全踩过尤其是LCS的prev变量刚开始压缩时常常忘记导致结果和二维版本对不上。排查方法也很简单小规模用例把二维DP表的每个值和一维压缩版本逐个对比很快就能定位到哪个状态被错误覆盖。6. 刷题和面试中的几条实在建议6.1 从“背模板”到“讲原理”的转变关键去年开始我帮不少人做过面试模拟发现了一个规律能顺利通过算法面的候选人并不是刷题量最大的而是能像老师一样把题目讲明白的人。尤其是排序和DP很多题目之间是共通的你能否在纸上画出状态转移的方向能否解释清楚代码里每个循环为什么这样写这比记住十几种模板更能打动面试官。一个很实用的训练方法是每写完一道题强制自己用三句话概括这道题的核心考点。比如快排分治思想加基准元素分区01背包空间压缩只保留上一轮结果倒序遍历防止重复取物品。能说出这种浓缩版总结说明你已经把原理内化了。6.2 面试时怎么把DP压缩思路讲清楚面试写DP题时建议按这个顺序表达先定义dp[i][j]的含义再讲转移方程然后说明初始化最后再提优化。很多人一上来就写一维数组面试官根本跟不上思路反而让人觉得你在背代码。更稳妥的做法是先写二维版本解释清楚依赖关系后再补充一句“因为每层只依赖上一层所以可以滚动数组压到一维”然后现场改写成压缩版本。这样既展示了正确性又展示了优化意识在面试官眼里是加分项。如果被问到“为什么必须倒序遍历”别急着念结论。找一个最小例子比如刚才说的重量2、价值3、容量4手动推两步正序的结果对比倒序的结果面试官一眼就能看出你理解到位了。6.3 最后分享一个刷题习惯我在实际刷题中养成的一个习惯是同一道DP题故意写三个版本——完整二维版、滚动数组版、完全一维版。每个版本都跑同一组测试用例对比结果。这个方法帮我发现了很多隐患也让我在面试时对“空间压缩”这类话题极其从容。排序题也一样同一组随机数据分别用快排、归并、堆排跑一遍观察耗时差异和数据特性对算法的影响。动手做这些对比实验比单纯刷题数量重要得多。这套方法同样适合算法基础薄弱、正在准备面试的学习者建议直接拿去用。