从麻将算法到特征提取:5个趣味案例带你掌握Python面试必考知识点

📅 发布时间:2026/7/9 1:45:54 👁️ 浏览次数:
从麻将算法到特征提取:5个趣味案例带你掌握Python面试必考知识点
从麻将算法到特征提取5个趣味案例带你掌握Python面试必考知识点最近和几位在大厂做技术面试官的朋友聊天他们不约而同地提到一个现象现在很多候选人的算法题刷得滚瓜烂熟LeetCode周赛排名靠前但一遇到稍微需要抽象建模的现实问题就卡壳。比如给你一个麻将和牌判断的题目或者让你分析猫咪视频中的特征运动轨迹很多人会陷入细节而找不到清晰的解题思路。这恰恰是当前技术面试的一个核心考察点——问题抽象与建模能力。面试官不再满足于你能否写出一个标准的二分查找或动态规划他们更想知道你如何将一个看似复杂的生活场景转化为清晰、可计算的算法模型。这种能力才是区分普通代码执行者与优秀工程师的关键。今天我们就抛开枯燥的教科书例题通过五个源自真实大厂面试题的趣味案例来一场思维训练。这些案例覆盖了递归、动态规划、双指针、哈希映射、状态压缩等核心算法思想但更重要的是它们教会我们如何像侦探一样从问题描述中抽丝剥茧找到那个最本质的数学模型。无论你是正在备战春招的应届生还是希望提升自己解决问题能力的中级开发者相信这篇深度解析都能给你带来不一样的启发。1. 麻将听牌判定递归思想的优雅实践麻将和牌判断是算法面试中的经典题目它完美地将一个复杂的游戏规则转化为了一个清晰的递归搜索问题。很多同学第一次看到题目会感到无从下手14张牌、雀头、顺子、刻子……规则一大堆。但如果我们跳出具体规则思考其本质问题就简化了给定一个多重集合每种牌1-9最多4张能否将其划分为一个“对子”雀头和若干个“顺子”或“刻子”三张一组的组合这就是一个典型的搜索与回溯问题。我们可以尝试每一种可能的划分方式直到找到一种可行的组合或者穷尽所有可能后宣布失败。递归是实现这种搜索最直观的方式。1.1 核心递归模型设计递归函数的核心任务是给定一个已排序的牌型列表判断它能否被完全划分即“和牌”。我们可以定义这样一个函数can_win(hand)其中hand是排序后的牌列表。递归的基线条件很明确如果hand为空说明所有牌都成功划分返回True。这是递归的“胜利出口”。接下来是递归的递推关系也就是如何缩小问题规模。根据麻将规则当前手牌的第一张牌hand[0]只有三种“归宿”作为“雀头”的一部分需要两张相同的牌。作为“刻子”的一部分需要三张相同的牌。作为“顺子”的最小牌需要连续的三张牌如x, x1, x2。我们的递归策略就是尝试这三种可能性。只要其中任意一条路径能走通返回True整个问题就解决了。def can_win(hand): 判断排序后的手牌列表hand是否能和牌。 if not hand: # 基线条件没有牌了成功和牌 return True first_card hand[0] # 情况1尝试作为雀头需要两张相同的牌 if len(hand) 2 and hand[0] hand[1]: # 移除前两张牌递归判断剩余牌 if can_win(hand[2:]): return True # 情况2尝试作为刻子需要三张相同的牌 if hand.count(first_card) 3: # 移除前三张相同的牌 new_hand hand[3:] if can_win(new_hand): return True # 情况3尝试作为顺子的起始牌 if first_card 1 in hand and first_card 2 in hand: # 需要找到并移除这三张牌 new_hand hand[1:] # 先移除第一张 new_hand.remove(first_card 1) # 移除第二张 new_hand.remove(first_card 2) # 移除第三张 if can_win(new_hand): return True # 以上三种情况都不行说明当前牌型无法和牌 return False注意在实际实现中直接使用list.remove()和列表切片会创建大量新列表在牌型较大时可能影响效率。更优的做法是使用计数数组长度为10索引1-9记录每种牌的数量进行回溯通过增减计数来模拟牌的移除与恢复避免频繁的列表拷贝。1.2 解决原题寻找听牌原面试题是给定13张牌求再摸一张哪些牌可以让你和牌。有了上面的can_win函数解题思路就非常清晰了初始化13张牌的计数数组。遍历每一种可能的牌i(1-9)检查该牌是否已经满4张不能再摸。如果没有满则将这张牌加入手牌计数1形成14张牌的“待检验牌型”。调用can_win函数判断这个14张牌的牌型是否能和牌。如果能则i就是一张“听牌”。这个框架将复杂的麻将规则问题分解为了一个通用的搜索验证模块和一个简单的枚举调用模块体现了模块化设计的思想。def find_winning_tiles(hand_13): hand_13: 长度为13的列表表示初始13张牌。 返回一个列表包含所有能使手牌和牌的牌。 from collections import Counter count Counter(hand_13) # 统计每种牌的数量 winning_tiles [] for tile in range(1, 10): # 遍历1-9每种牌 if count[tile] 4: # 这种牌已经有4张不能再摸 continue # 模拟摸到这张牌 count[tile] 1 # 将计数转换为排序后的列表供can_win函数判断 current_hand [] for t in range(1, 10): current_hand.extend([t] * count[t]) if can_win(current_hand): winning_tiles.append(tile) # 回溯恢复状态尝试下一张牌 count[tile] - 1 return winning_tiles if winning_tiles else [0]通过这个案例我们不仅学会了递归在组合问题中的应用更重要的是掌握了如何将一项具象的游戏规则抽象成“集合划分”的数学模型并用递归搜索去验证每一种划分的可能性。这种从具体到抽象的思维能力是面试官尤为看重的。2. 特征运动分析哈希映射与状态转移的妙用第二个案例来自一个非常有趣的场景从猫咪视频的连续帧中追踪特征点的运动轨迹。题目描述可能有点绕但核心问题很简单给定一个序列视频帧每帧包含若干个特征点用二维坐标表示找出同一个特征点在连续帧中出现的最长连续次数。例如特征点(1, 1)在第2、3、4、7、8帧出现那么它形成了两个“特征运动”2-3-4长度3和7-8长度2。我们需要找出所有特征点中最长的那个连续出现长度。这本质上是一个序列状态追踪问题。我们需要为每一个特征点记录一个关键状态它在上一个时刻帧是否出现以及如果出现当前连续的次数是多少。2.1 为什么用哈希表特征点(x, y)可以被视为一个唯一的键Key。我们需要快速查询在上一帧中某个特定特征点的连续出现次数是多少这种“键-值”对的快速查找、插入和更新操作正是哈希表在Python中是字典dict的强项。我们可以设计这样的状态记录键(Key)特征点。为了在字典中方便使用我们可以将二维坐标(x, y)编码成一个一维的唯一值例如x * base ybase是一个比最大y值大的数比如10000或者直接使用元组(x, y)作为键Python中元组是可哈希的。值(Value)该特征点到上一帧为止的连续出现次数。2.2 逐帧更新的动态规划思想处理视频帧是一个时序过程我们可以用动态规划的思路来思考假设我们已经处理完了前i-1帧知道了每个特征点截至上一帧的连续次数dp[feature]。现在处理第i帧我们该如何更新dp并得到当前帧的答案关键在于特征点的连续性是帧间连续。也就是说一个特征点要在当前帧延续它的“运动”它必须在上一帧也出现。如果它在上一帧没出现那么即使当前帧出现了连续次数也要从1重新开始。因此我们不能简单地用一个全局字典从头记到尾。因为对于第i帧我们只关心特征点在第i-1帧的状态。一个常见的技巧是使用两个字典prev_feature_count: 记录特征点在上一帧结束后的连续出现次数。current_feature_count: 正在构建的特征点在当前帧结束后的连续出现次数。每一帧的处理流程如下初始化一个空的current_feature_count字典。遍历当前帧的所有特征点f。如果f在prev_feature_count中存在说明它在上一帧也出现了连续性得以保持current_feature_count[f] prev_feature_count[f] 1。如果f在prev_feature_count中不存在说明它是新开始出现的或断掉后重新出现current_feature_count[f] 1。在更新current_feature_count[f]的同时用这个新值去更新全局的最大连续长度max_len。当前帧处理完毕后将current_feature_count赋值给prev_feature_count作为处理下一帧时的“上一帧状态”。这个过程清晰体现了状态转移当前帧的状态完全由上一帧的状态决定。def longest_feature_movement(frames): frames: 一个列表的列表frames[i]代表第i帧的特征点列表每个特征点是一个(x, y)元组。 返回最长特征运动的长度。 max_len 0 prev_counter {} # 记录上一帧结束后各特征点的连续次数 for current_frame_features in frames: current_counter {} # 记录本帧结束后各特征点的连续次数 for feature in current_frame_features: # 如果上一帧有这个特征则连续次数1否则从1开始 current_len prev_counter.get(feature, 0) 1 current_counter[feature] current_len # 更新全局最大值 max_len max(max_len, current_len) # 当前帧处理完毕状态转移当前帧状态成为下一帧的“上一帧状态” prev_counter current_counter return max_len2.3 空间优化与编码技巧在上面的实现中我们每一帧都创建了一个新的字典current_counter。在帧数M很大但每帧特征数不多时这可能会产生一些开销。一个微小的优化是在每一帧开始时清空一个复用字典但需要注意这样会丢失“上一帧状态”的信息所以必须用另一个字典来保存。另外关于特征点的编码。如果坐标x, y的范围已知且不大使用x * BASE y的整数编码作为字典键通常比使用(x, y)元组更快因为整数哈希和比较的效率更高。def longest_feature_movement_optimized(feature_sequences): 优化版使用整数编码特征点并复用字典需谨慎。 BASE 10001 # 假设y坐标不会超过10000 max_len 0 prev {} curr {} for frame in feature_sequences: curr.clear() # 清空复用字典 for x, y in frame: key x * BASE y # 状态转移核心逻辑 curr[key] prev.get(key, 0) 1 max_len max(max_len, curr[key]) # 关键步骤将curr的内容复制给prev用于下一帧 # 不能直接 prev curr因为curr会被清空复用 prev, curr curr, prev # 交换引用此时prev持有当前帧状态 # 交换后curr指向的是旧的prev字典它将在下一帧被清空复用 return max_len提示prev, curr curr, prev这行代码通过交换引用巧妙地实现了状态的传递同时避免了深层拷贝字典的开销。这是Python中一种常见且高效的技巧。这个案例向我们展示了如何将时序数据中的连续模式识别问题转化为一个基于哈希表的动态状态维护问题。哈希表负责快速定位特征点的历史状态而动态规划的思想则清晰地定义了状态如何随时间帧演变。掌握这种“状态机”式的思考方式对于处理流式数据、用户行为序列等问题都大有裨益。3. 聪明的文本编辑双指针原地修改的艺术第三个案例看起来是个简单的字符串处理问题实现一个自动校对程序纠正两种拼写错误1三个相同字母连写如 “helllo” - “hello”2两对相同字母以 “AABB” 形式连写如 “helloo” - “hello”。规则优先从左到右匹配。很多人的第一反应是使用正则表达式替换。这当然可以但面试官更希望考察你对字符串原地修改算法的掌握以及双指针这一经典技巧的运用。这类问题在内存受限或者要求O(1)额外空间不考虑输入输出占用的空间的场景下非常实用。3.1 双指针算法框架双指针技巧的核心是使用两个指针索引在同一个数组或字符串上协同工作。一个指针通常叫i或fast用于遍历原始数据另一个指针通常叫k或slow用于指向下一个有效字符应该存放的位置。算法框架如下初始化慢指针k 0它指向结果字符串的当前末尾下一个空位。快指针i从0开始遍历原字符串s。将s[i]复制到s[k]然后k自增。在复制后立即检查以k-1为结尾的字符串是否形成了需要纠正的错误模式。如果形成了错误模式则通过回退慢指针k来“删除”多余的字符。遍历结束后s[0:k]就是校正后的字符串。这个过程中慢指针k始终指向一个“已经处理好的、符合规则的结果字符串”的末尾。快指针i不断读取新字符经过规则校验后决定是否将其纳入结果。3.2 错误模式的检测逻辑如何检测“AAA”和“AABB”这两种模式关键在于我们是在写入新字符后进行检查并且检查的是以当前结果字符串末尾为中心的一小段区间。假设慢指针当前指向k即下一个写入位置我们刚刚写入了s[i]所以s[k-1]就是最新写入的字符。我们需要检查的是包含s[k-1]在内的末尾几个字符。检查“AAA”三个相同字母需要检查s[k-3],s[k-2],s[k-1]是否相等。但前提是k 3即结果字符串长度至少为3。检查“AABB”需要检查s[k-4] s[k-3]且s[k-2] s[k-1]。前提是k 4。注意题目要求优先处理“AAA”。在代码中我们先检查“AAA”如果成立就回退k相当于删掉最后一个重复字母。处理完“AAA”后在新的k基础上再检查“AABB”。这个顺序保证了优先匹配最左边的模式。def smart_editor_correction(s): 使用双指针原地修改纠正字符串s。 返回纠正后的字符串。 chars list(s) # Python中字符串不可变先转为列表 k 0 # 慢指针指向下一个有效字符位置 for i in range(len(chars)): # i是快指针遍历原字符 chars[k] chars[i] k 1 # 检查是否出现三个连续相同字符 (AAA) if k 3 and chars[k-3] chars[k-2] chars[k-1]: k - 1 # 回退指针相当于删除最后一个字符 # 检查是否出现AABB模式 if k 4 and chars[k-4] chars[k-3] and chars[k-2] chars[k-1]: k - 1 # 回退指针相当于删除AABB中的最后一个B # 将列表前k个字符转换回字符串 return .join(chars[:k]) # 测试用例 test_cases [helloo, woooooooow, aabbcc, helllo, aaabbb] for tc in test_cases: print(f{tc} - {smart_editor_correction(tc)})3.3 思维延伸更复杂的编辑规则双指针原地修改的技巧不仅限于这道题。它可以推广到一系列字符串过滤、去重、合并的问题上。例如删除排序数组中的重复项保留至多两个这正是本题“AAA”规则的简化版。移除所有相邻重复项每次写入后检查是否与上一个字符相同。根据特定规则合并字符串。这类问题的共同特点是输出是输入的一个子序列且处理每个输入元素时只需要查看输出序列末尾的有限个元素即可做出决策。双指针算法为此类问题提供了时间复杂度O(n)空间复杂度O(1)原地的最优解。理解了这个案例你就掌握了字符串处理中一项非常底层的优化技术。它提醒我们在面对字符串修改问题时不要总想着用replace()或正则表达式先思考一下能否用更基础的指针操作优雅地解决。4. 特工埋伏方案组合数学与滑动窗口的碰撞第四个案例是一个典型的组合计数问题在一条直线上有N个建筑给定特工的最大活动距离D要求计算有多少种选择3个不同建筑作为埋伏点的方法使得最远两个特工的距离不超过D。暴力枚举所有三元组是O(N^3)对于N最大为10^6的数据范围完全不可行。我们需要更聪明的办法。4.1 问题转化与滑动窗口首先因为建筑位置已经排序当我们固定最左边的特工位置i时另外两个特工的位置j和k(i j k) 必须满足坐标[k] - 坐标[i] D。换句话说对于固定的ij和k只能从i后面且坐标不超过坐标[i] D的建筑中选择。如果我们用指针right来表示满足坐标[right] - 坐标[i] D的最右边建筑的下标。那么对于这个固定的i所有合法的j和k的选择范围就是索引区间(i, right]。这个区间里有cnt right - i个建筑可供选择。问题转化为从这cnt个建筑中任意选择2个因为i已经占了一个位置有多少种选法这就是简单的组合数C(cnt, 2)。那么如何高效地为每个i找到对应的right呢因为建筑坐标是递增的当i向右移动时坐标[i]在增大满足条件的最大坐标坐标[i] D也在增大。这意味着right指针也只会随着i的增加而向右移动不会向左回退。这正是使用滑动窗口或同向双指针的完美场景。4.2 滑动窗口结合组合计数我们可以维护一个窗口[i, right]使得对于当前的iright是满足条件的最右边界。那么窗口内的元素个数不包括i就是cnt right - i。算法步骤如下初始化right 0总方案数ans 0。遍历i从0到N-3因为需要选3个点i最多到倒数第三个。在每次循环中向右移动right指针直到right指向最后一个满足坐标[right] - 坐标[i] D的建筑。计算cnt right - i。如果cnt 2至少有2个点可供选择则方案数增加C(cnt, 2)即cnt * (cnt - 1) // 2。由于结果可能很大需要在每次加法后取模。这个算法中i和right指针都只向右移动每个建筑最多被i和right各访问一次因此时间复杂度是O(N)。def埋伏方案数(建筑坐标列表, N, D): 计算在N个已排序的建筑中选择3个使得最远距离不超过D的方案数。 MOD 99997867 ans 0 right 0 for i in range(N - 2): # i作为最左边的特工位置 # 移动右指针找到满足条件的窗口右边界 while right N and 建筑坐标列表[right] - 建筑坐标列表[i] D: right 1 # 此时窗口内可选建筑为 (i, right)共 cnt right - i - 1 个 cnt right - i - 1 if cnt 2: # 从cnt个建筑中选2个与i组成三元组 ans (ans cnt * (cnt - 1) // 2) % MOD return ans4.3 边界处理与模运算这里有几个细节需要注意指针初始位置right可以从i1开始但更常见的写法是让right随着i一起“滑动”即while循环的条件是right N且距离满足。因为i增加后right可能不需要动也可能需要向右移动。组合数计算C(cnt, 2) cnt * (cnt - 1) / 2。在Python中使用整数除法//可以确保结果是整数。取模操作题目要求对99997867取模。为了防止中间结果溢出虽然Python整数不会溢出但符合题目要求应该在每次加法后立即取模。为什么是cnt right - i - 1因为窗口[i, right)是左闭右开的right指向的是第一个不满足条件的建筑。所以窗口内有效的可选建筑索引是从i1到right-1共(right-1) - (i1) 1 right - i - 1个。这个案例是滑动窗口与组合数学结合的典范。滑动窗口高效地维护了满足条件的连续区间而组合数学则让我们避免了在区间内进行两层循环枚举。它考察的是你是否能将一个“选择三元组”的枚举问题巧妙地转化为“固定一点求区间内两点组合数”的求和问题并利用数据的有序性进行优化。这种转化与优化的能力是解决大量算法面试题的关键。5. 毕业旅行与找零两种截然不同的规划思维第五组案例包含两个子问题毕业旅行问题Traveling Salesman Problem, TSP和硬币找零问题。它们看似都是“规划”问题但背后的核心算法思想却截然不同正好代表了算法设计中两个重要的范式。5.1 毕业旅行问题状态压缩动态规划毕业旅行问题是一个经典的NP-hard问题——旅行商问题TSP的简化版。城市数n ≤ 20这暗示我们可以使用状态压缩动态规划。暴力枚举所有路径的排列是O(n!)n20时不可行。动态规划的核心思想是不重复计算子问题。对于TSP一个关键的洞察是从城市0起点出发访问完一个特定的城市集合S并最后停留在城市j的最小花费只依赖于集合S和终点j而与之前访问S内部城市的顺序无关。我们可以定义dp[S][j]表示从起点0出发访问完集合S中的所有城市S是一个二进制掩码表示城市集合并且最后停留在城市j的最小花费。状态转移方程要计算dp[S][j]我们需要考虑到达j之前的上一个城市k。k必须是集合S中除j以外的某个城市。因此dp[S][j] min{ dp[S-{j}][k] cost[k][j] }其中k属于S且k ! j。初始化dp[{0}][0] 0表示从城市0出发只访问了城市0起点停留在0的花费为0。其他状态初始化为无穷大。最终答案我们需要访问所有城市并回到起点0。所以答案是min{ dp[ALL][j] cost[j][0] }其中ALL是包含所有城市的集合j是除0外的任意城市表示从某个城市j回到起点。def min_travel_cost(cost_matrix): cost_matrix: n x n 的矩阵cost_matrix[i][j]表示从城市i到j的花费。 返回从城市0出发访问所有城市一次并回到0的最小花费。 n len(cost_matrix) ALL (1 n) - 1 # 所有城市都访问过的状态二进制位全为1 INF float(inf) # dp[mask][city] 初始化为无穷大 dp [[INF] * n for _ in range(1 n)] dp[1][0] 0 # 从城市0出发状态mask为1只有城市0被访问目前在城市0花费为0 for mask in range(1 n): # 遍历所有状态 for u in range(n): # 当前所在城市u if dp[mask][u] INF: continue # 当前状态不可达跳过 for v in range(n): # 尝试下一个要去的城市v if mask (1 v): # 如果城市v已经在集合中被访问过跳过 continue new_mask mask | (1 v) # 新状态访问了v new_cost dp[mask][u] cost_matrix[u][v] if new_cost dp[new_mask][v]: dp[new_mask][v] new_cost # 最终状态所有城市都访问过(ALL)最后停在哪座城市j然后从j回到起点0 ans INF for j in range(1, n): # j不能是0因为最后一步是从j回到0 if dp[ALL][j] ! INF: ans min(ans, dp[ALL][j] cost_matrix[j][0]) return ans if ans ! INF else 0这个解法的时间复杂度是O(2^n * n^2)对于n202^20 ≈ 1e6n^2400总计算量约4e8在Python中可能处于临界但通常可以通过优化如使用整数运算、注意循环顺序在可接受时间内完成。它展示了如何用二进制位来简洁地表示一个集合并通过动态规划将指数级问题变得可解。5.2 硬币找零问题贪心算法的正确性证明找零问题则简单得多有面值为[64, 16, 4, 1]的硬币用最少的硬币数量凑出给定的金额N(找零金额)。这是一个典型的贪心算法问题。我们每次都选择面值不超过剩余金额的最大硬币直到凑齐金额。对于这个特定的硬币体系[64, 16, 4, 1]贪心算法能得到最优解。def min_coins(amount): coins [64, 16, 4, 1] count 0 for coin in coins: if amount coin: num amount // coin # 最多能用几枚当前面值的硬币 count num amount - num * coin if amount 0: break return count但面试中更重要的一步是为什么贪心算法在这里是最优的你需要能够证明或论证。这是因为这个硬币体系是“规范”的Canonical Coin System每个大面值都是小面值的倍数64是16的4倍16是4的4倍4是1的4倍。在这种体系下使用更大面值的硬币总能减少总硬币数不会出现像[1, 3, 4]凑6元时贪心(4,1,1)不如(3,3)的情况。5.3 思维对比动态规划 vs. 贪心将这两个问题放在一起非常有启发意义毕业旅行(TSP)代表了一类需要全局考虑、前后步骤强相关的优化问题。局部最优的选择比如每次都去最近的城市并不能保证全局最优。解决这类问题通常需要动态规划、回溯搜索等能纵观全局的算法。硬币找零(特定体系)代表了一类具有贪心选择性质的问题。局部最优用最大面值能直接导向全局最优。解决这类问题需要你识别出问题是否具有“贪心选择性”和“最优子结构”。在面试中遇到规划类问题时首先要判断其类型。是像TSP一样需要探索所有可能性并用动态规划记录状态还是像特定硬币找零一样存在一个显而易见的贪心策略判断错误可能会导致算法复杂度过高或者得到错误的结果。理解这两种范式的区别并能根据问题的特征快速判断适用哪种方法是算法能力成熟的一个重要标志。这五个案例从递归搜索、哈希映射与状态机、双指针、滑动窗口与组合数学到动态规划与贪心算法几乎覆盖了面试中除图论和高级数据结构外的大部分核心思想。更重要的是它们都根植于有趣而具体的场景训练着我们剥离表象、直达问题本质的抽象思维能力。下次面试再遇到非常规题目时不妨先问自己这道题的本质模型是什么我见过的哪种算法思想可以套用过来有了这样的思维框架解题之路会清晰很多。