引言
面试手撕算法的过程中,应该主动在编写代码的时候同步埋点打log,不仅仅为了更好的Debug, 而且为了更好的展示代码运行过程和算法执行。在上一篇更好的观察递归:防止手撕算法翻车的嵌入式调试设计 - juejin.cn中,我们探讨了如何通过嵌入式调试让递归过程变得可观测。今天,我们将这个思路扩展到更广泛的算法场景,特别是数据结构操作中的log设计艺术。
以一道算法题为例
/*
* @lc app=leetcode.cn id=347 lang=cpp
* @lcpr version=30204
*
* [347] 前 K 个高频元素
*
* https://leetcode.cn/problems/top-k-frequent-elements/description/
*
* algorithms
* Medium (64.98%)
* Likes: 2083
* Dislikes: 0
* Total Accepted: 814.5K
* Total Submissions: 1.3M
* Testcase Example: '[1,1,1,2,2,3]\n2'
*
* 给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
*
*
*
* 示例 1:
*
*
* 输入:nums = [1,1,1,2,2,3], k = 2
*
* 输出:[1,2]
*
*
* 示例 2:
*
*
* 输入:nums = [1], k = 1
*
* 输出:[1]
*
*
* 示例 3:
*
*
* 输入:nums = [1,2,1,2,1,2,3,1,3,2], k = 2
*
* 输出:[1,2]
*
*
*
*
* 提示:
*
*
* 1 <= nums.length <= 10^5
* k 的取值范围是 [1, 数组中不相同的元素的个数]
* 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的
*
*
*
*
* 进阶:你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n 是数组大小。
*
*/
// @lcpr-template-start
using namespace std;
#include <algorithm>
#include <array>
#include <bitset>
#include <climits>
#include <deque>
#include <functional>
#include <iostream>
#include <list>
#include <queue>
#include <stack>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
// @lcpr-template-end
// @lc code=start
class Solution {
public:
// !!诚恳的友情提示:
// 写算法的时候,最好自己推演一遍,如果没有草稿纸,就在代码里用注释推演
// [1,2,1,2,1,2,3,1,3,2]
// 1 1+1+1+1
// 2 1+1+1+1
// 3 1+1
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> counter;
for(int num: nums){
counter[num] += 1;
}
for(auto &item: counter){
cout << "item is " << item.first << ", " << item.second << endl;
}
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int,int>>> helper;
for(auto &item: counter){
if(helper.size() >= k){
cout << "full, try evict one" << endl;
auto &top = helper.top();
if(item.second > top.first){
cout << "full, meet bigger one, evit " << top.second << ", " << top.first << endl;
helper.pop();
cout << "emplace bigger " << item.first << ", " << item.second << endl;
helper.emplace(item.second, item.first);
}else{
cout << "skip " << item.first << ", " << item.second << endl;
}
}else{
cout << "emplace " << item.first << ", " << item.second << endl;
helper.emplace(item.second, item.first);
}
}
vector<int> ret;
while(helper.size() > 0){
auto &top = helper.top();
cout << "queue item " << top.second << endl;
ret.push_back(top.second);
helper.pop();
}
return ret;
}
};
// @lc code=end
/*
// @lcpr case=start
// [1,1,1,2,2,3]\n2\n
// @lcpr case=end
// @lcpr case=start
// [1]\n1\n
// @lcpr case=end
// @lcpr case=start
// [1,2,1,2,1,2,3,1,3,2]\n2\n
// @lcpr case=end
----------------------
log is
item is 3, 1
item is 2, 2
item is 1, 3
emplace 3, 1
emplace 2, 2
full, try evict one
full, meet bigger one, evit 3, 1
emplace bigger 1, 3
queue item 2
queue item 1
*/
通过上面的日志输出,我们可以看到一种全新的调试设计理念:从"状态记录"到"决策追踪"的思维升级。
从树形追踪到线性追踪
递归算法的log设计关注的是"树形结构"的追踪,而数据结构操作的log设计则更关注"线性流程"的可观测性。
递归log vs 数据结构log
| 维度 | 递归log | 数据结构log |
|---|---|---|
| 关注点 | 递归层级、状态回溯 | 数据结构维护、比较决策 |
| 复杂度 | 树形结构追踪 | 线性流程追踪 |
| 调试重点 | 递归边界、状态恢复 | 堆性质维护、元素淘汰 |
| 可视化 | 递归树路径 | 数据流转过程 |
堆操作的Log设计艺术
让我们深入分析上面代码中的log设计精妙之处:
1. 数据统计阶段:建立认知基础
for(auto &item: counter){
cout << "item is " << item.first << ", " << item.second << endl;
}
设计亮点:
- 清晰展示频率统计结果
- 为后续堆操作提供数据基础
- 让面试官理解问题的数据特征
2. 堆构建阶段:展示填充逻辑
emplace 3, 1 // 堆未满,直接插入(频率1, 元素3)
emplace 2, 2 // 堆未满,直接插入(频率2, 元素2)
设计亮点:
- 展示堆的初始填充过程
- 清晰记录每个元素的插入决策
- 为后续的淘汰逻辑做铺垫
3. 堆维护阶段:决策过程可视化(核心!)
full, try evict one // 堆已满,需要淘汰
full, meet bigger one, evit 3, 1 // 遇到更大频率,淘汰最小元素
emplace bigger 1, 3 // 插入新的大频率元素
这是最精彩的部分! 它清晰展示了:
- 状态检测:堆满状态的识别
- 决策逻辑:何时触发淘汰机制
- 淘汰策略:如何选择被淘汰的元素
- 插入过程:新元素的正确插入
4. 结果输出阶段:展示最终成果
queue item 2 // 输出元素2
queue item 1 // 输出元素1
设计亮点:
- 展示最终结果的生成过程
- 验证算法的正确性
- 提供完整的执行轨迹
Log设计的分类体系
基于实践总结,我们可以将log设计分为几个类型:
1. 递归类:树形结构追踪
- 特点:关注递归层级、状态回溯
- 关键点:递归边界、状态恢复
- 示例:排列组合、树遍历
2. 数据结构类:线性流程追踪
- 特点:关注数据结构维护、比较决策
- 关键点:堆性质维护、元素淘汰
- 示例:堆操作、队列维护
3. 动态规划类:状态转移追踪
- 特点:关注状态转移、最优子结构
- 关键点:状态定义、转移方程
- 示例:背包问题、最长子序列
4. 图算法类:路径探索追踪
- 特点:关注路径探索、节点访问
- 关键点:访问标记、路径记录
- 示例:DFS、BFS、最短路径
面试实战策略
1. 快速识别关键点
在面试中,你需要快速识别需要log的关键决策点:
- 条件判断:if/else分支的选择逻辑
- 状态变化:数据结构的状态维护
- 循环控制:循环的终止和继续条件
- 递归边界:递归的终止条件
2. 不同算法类型的log模板
堆操作模板
// 状态检测
cout << "heap size: " << heap.size() << ", capacity: " << k << endl;
// 决策过程
if(heap.size() >= k) {
cout << "heap full, try evict" << endl;
if(newItem > heap.top()) {
cout << "evict " << heap.top() << ", add " << newItem << endl;
heap.pop();
heap.push(newItem);
} else {
cout << "skip " << newItem << endl;
}
} else {
cout << "add " << newItem << endl;
heap.push(newItem);
}
递归模板
void dfs(int level, string path) {
cout << "level " << level << ", path: " << path << endl;
if(终止条件) {
cout << "found result: " << path << endl;
return;
}
for(每个选择) {
cout << "try choice: " << choice << endl;
dfs(level + 1, path + choice);
cout << "backtrack from: " << choice << endl;
}
}
3. 面试官最想看到的调试信息
- 思维过程:你的分析思路和决策逻辑
- 状态变化:关键变量的变化轨迹
- 错误处理:异常情况的处理方式
- 优化思考:算法复杂度的考虑
深层价值思考
1. 认知负荷降低
这种设计将复杂的算法逻辑从"黑盒"变成"白盒",显著降低了面试者的认知负荷:
- 压力缓解:通过log展示思维过程,减少紧张感
- 错误预防:在关键点添加检查,提前发现潜在问题
- 信心建立:清晰的执行轨迹增强自信心
2. 沟通效率提升
从面试官的角度,这种log设计带来了:
- 理解加速:快速理解候选人的思路
- 评估准确:基于清晰的思维过程进行评估
- 引导简化:减少无效的提问和引导
3. 工程实践价值
这种"可观测性设计"的思想在工程实践中同样有价值:
- 调试效率:复杂系统的调试和监控
- 代码维护:提高代码的可读性和可维护性
- 团队协作:便于团队成员理解复杂逻辑
方法论提炼
Log设计的通用原则
- 决策导向:记录关键决策点,而非所有状态
- 层次清晰:不同层级的log要有明确的标识
- 信息完整:包含足够的上下文信息
- 可读性强:使用清晰的语言描述
可复用的设计模式
- 状态机模式:记录状态转换过程
- 决策树模式:记录分支选择逻辑
- 流水线模式:记录数据处理流程
- 回溯模式:记录搜索和回溯过程
总结
"Log的艺术"不仅仅是调试技巧,更是一种思维展示的方法论。它让我们从"会写代码"升级到"会展示思维",从"黑盒实现"升级到"白盒设计"。
在技术面试中,这种设计让你能够:
- 快速理解复杂算法的执行过程
- 有效防止逻辑错误和思维混乱
- 清晰展示你的分析思路和决策能力
- 在高压环境下保持稳定的发挥
记住:最好的代码不仅是功能实现,更是知识传递的载体。通过这种设计,我们不仅解决了技术问题,更提升了面试的成功率。
这种"嵌入式调试"的设计思路,将调试思维前置到了编码阶段,让算法实现从"黑盒"变成"白盒",从"结果导向"变成"过程导向"。
本文基于实际面试经验和算法学习实践总结而成,希望对正在准备技术面试的朋友有所帮助。