1. 米哈游笔试真题解析:三道算法题的解题思路与实现
最近参加了米哈游的笔试,题目质量相当高,尤其是第三题很有区分度。作为过来人,我把自己对这三道题的解题思路和实现方法整理出来,希望能帮助到正在准备面试的朋友们。这三道题分别考察了哈希统计、贪心算法和树上前缀异或的应用,都是非常经典的算法题型。
2. 题目一:展墙与橱窗配对问题解析
2.1 问题本质与建模
这道题表面上是关于展馆布置的问题,实际上是一个典型的统计匹配问题。题目大意是给定一个矩阵,我们需要判断行和与列和能否完美配对。关键在于理解:配对只与行和列的总和数值有关,而与矩阵内部的具体分布无关。
举个例子,假设我们有以下3x3矩阵:
code复制1 2 3
4 5 6
7 8 9
行和分别是6、15、24,列和分别是12、15、18。我们需要找出行和与列和中相等的数值对。
2.2 解题思路与算法设计
解决这个问题的核心思路可以分为三步:
- 计算所有行和(row_sums)和列和(col_sums)
- 统计行和和列和中每个数值出现的频率
- 对于每个数值,取行和和列和中出现次数的较小值,累加得到最终答案
具体实现时,我们可以使用哈希表(在Python中是字典)来高效地统计频率。以下是Python实现的关键代码:
python复制def count_matching_pairs(matrix):
if not matrix or not matrix[0]:
return 0
# 计算行和
row_sums = [sum(row) for row in matrix]
# 计算列和
col_sums = [0] * len(matrix[0])
for row in matrix:
for j in range(len(row)):
col_sums[j] += row[j]
# 统计频率
row_counts = {}
for s in row_sums:
row_counts[s] = row_counts.get(s, 0) + 1
col_counts = {}
for s in col_sums:
col_counts[s] = col_counts.get(s, 0) + 1
# 计算匹配对数
result = 0
for num in row_counts:
if num in col_counts:
result += min(row_counts[num], col_counts[num])
return result
2.3 复杂度分析与优化
这个算法的时间复杂度是O(m*n),其中m是行数,n是列数。这是因为我们需要遍历整个矩阵来计算行和和列和。空间复杂度是O(m+n),用于存储行和、列和以及它们的频率统计。
提示:在实际笔试中,要注意处理边界情况,比如空矩阵或非矩形矩阵。虽然题目通常会保证输入有效,但写出健壮的代码会给面试官留下好印象。
3. 题目二:错峰站位问题解析
3.1 问题重述与理解
这道题初看像是要求删除最少数量的元素,使得剩下的序列满足某种条件。但关键在于理解题目要求的条件:保留的序列必须是摆动序列(相邻元素的差值符号交替变化)。
例如,对于序列[1,7,4,9,2,5],就是一个摆动序列,因为相邻差值(6,-3,5,-7,3)的正负交替出现。我们的目标是找到最长的这样的子序列,然后用总长度减去这个长度得到需要删除的元素数量。
3.2 最长摆动子序列算法
这个问题可以转化为经典的"最长摆动子序列"问题。解决这个问题的贪心算法非常巧妙:
- 初始化摆动序列的长度为1(至少一个元素)
- 遍历数组,记录当前的趋势(上升、下降或无趋势)
- 每当趋势发生变化时,增加摆动序列的长度
- 最后用总长度减去最长摆动序列长度即为需要删除的元素数量
以下是Python实现:
python复制def min_deletions_for_wiggle(nums):
if len(nums) < 2:
return 0
prev_diff = nums[1] - nums[0]
count = 1 if prev_diff != 0 else 0
for i in range(2, len(nums)):
diff = nums[i] - nums[i-1]
if (diff > 0 and prev_diff <= 0) or (diff < 0 and prev_diff >= 0):
count += 1
prev_diff = diff
return len(nums) - (count + 1)
3.3 算法正确性证明与边界情况
这个贪心算法的正确性基于以下观察:我们只需要保留序列中的"转折点"(即趋势发生变化的点),因为这些点已经足够构成最长的摆动序列。删除其他点不会影响结果。
边界情况需要考虑:
- 空数组或单元素数组:不需要删除任何元素
- 所有元素相同:需要删除n-1个元素,只保留一个
- 序列开始时可能有多个相同元素:需要跳过这些元素直到找到第一个非零差值
注意:在实际编码时,prev_diff的初始化和更新需要特别小心。我最初实现时就因为没处理好初始零差值的情况而出现了错误。
4. 题目三:秘境回路异纹和问题解析
4.1 问题描述与建模
这道题是三道题中最有挑战性的一道,考察的是树结构和异或操作的高级应用。题目大意是给定一棵树,每条边有一个权值,要求计算所有简单路径的异或和的总和。
简单路径是指不重复经过任何节点的路径。异或和是指路径上所有边权值的异或结果。我们需要计算所有这样的路径的异或和的总和。
4.2 关键思路:前缀异或与点对贡献
直接计算所有路径的异或和显然不可行,因为路径数量是O(n^2)的。我们需要一个更聪明的方法:
- 定义每个节点到根节点的前缀异或值(类似于树上前缀和)
- 任意两点u和v之间路径的异或和就是prefix[u] ^ prefix[v]
- 因此,总和可以转化为计算所有点对的prefix异或的异或和
但是计算所有点对的异或和仍然是O(n^2)的。我们需要进一步优化:
- 考虑异或操作的性质:每一位可以独立计算
- 对于第k位,统计有多少个点的前缀异或值在这一位上是1(设为cnt)
- 那么这一位对总和的贡献就是cnt*(n-cnt)*(1<<k)
- 将所有位的贡献相加就是最终结果
4.3 算法实现与复杂度分析
以下是Python实现的关键部分:
python复制def total_xor_path_sum(n, edges):
# 构建邻接表
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
adj[v].append((u, w))
# 计算每个节点的前缀异或
prefix = [0] * n
visited = [False] * n
stack = [(0, -1)]
visited[0] = True
while stack:
u, parent = stack.pop()
for v, w in adj[u]:
if v != parent and not visited[v]:
prefix[v] = prefix[u] ^ w
visited[v] = True
stack.append((v, u))
# 统计每一位的1的个数
bit_counts = [0] * 30
for num in prefix:
for k in range(30):
if num & (1 << k):
bit_counts[k] += 1
# 计算总和
total = 0
for k in range(30):
total += bit_counts[k] * (n - bit_counts[k]) * (1 << k)
return total
这个算法的时间复杂度是O(n*30),因为我们需要遍历所有节点和所有位。空间复杂度是O(n)用于存储树结构和前缀异或值。
4.4 常见错误与调试技巧
在实现这道题时,容易犯的错误包括:
- 没有正确处理树的遍历(特别是非二叉树的情况)
- 忘记异或操作的性质,试图直接计算所有点对
- 位运算处理不正确,特别是移位操作的方向和范围
调试技巧:
- 先用小规模的树手动计算验证
- 打印出前缀异或值检查是否正确
- 逐位验证贡献计算是否正确
个人经验:这类树上前缀和/异或的问题,通常都可以通过一次DFS/BFS遍历来计算前缀值,然后利用数学性质优化计算。掌握这个模式可以解决很多类似问题。
5. 总结与面试准备建议
这三道题目涵盖了算法面试中的几个重要领域:哈希统计、贪心算法和树结构处理。准备这类笔试时,建议:
- 熟练掌握基础数据结构(数组、哈希表、树等)的操作和应用
- 理解常见算法范式(贪心、分治、动态规划等)的适用场景
- 练习将实际问题抽象为数学模型的能力
- 注意代码的边界条件和健壮性
在实际面试中,除了写出正确的代码外,清晰地解释思路和复杂度分析同样重要。我建议在练习时养成边写代码边解释的习惯,这样在实际面试中会更加从容。
最后,对于树类问题,多练习各种变形(路径和、直径、最近公共祖先等),掌握它们的共性和特性,这样在遇到新问题时能够快速联想到相似的解法。