模拟算法专题总结:直接按题意实现的艺术
模拟算法专题总结:直接按题意实现的艺术
目录
- 刷题过程
- 模拟算法核心概念
- 题目详解
- 1. 替换所有的问号 (LeetCode 1576)
- 2. 提莫攻击 (LeetCode 495)
- 3. Z字形变换 (LeetCode 6) ⭐⭐⭐
- 4. 外观数列 (LeetCode 38)
- 5. 数青蛙 (LeetCode 1419) ⭐⭐
- 核心收获
- 易错点总结
刷题过程
模拟算法专题集中刷题,涵盖字符串模拟、数组模拟、状态转换三大类型:
- 字符串模拟:替换问号、外观数列
- 数组模拟:提莫攻击
- 状态转换:Z字形变换、数青蛙
- 第1题踩坑
=
vs==
,第3题对比了模拟法和优化法两种实现
模拟算法核心概念
什么是模拟算法?
直接按照题目描述的逻辑实现代码,不需要复杂的算法技巧。
关键是:
- 理解题意,抓住核心规律
- 处理好边界条件
- 注意细节实现
模拟算法 vs 其他算法
与双指针、滑动窗口的区别:
- 双指针/滑动窗口:有固定的套路和模板
- 模拟算法:每道题都是独立的逻辑,没有通用模板
模拟算法的特点:
- 直观性:代码逻辑和题目描述一一对应
- 细节性:重点在各种边界情况的处理
- 实现性:考验代码实现能力而非算法设计
模拟算法的常见类型
通过这5道题,我总结出以下类型:
1. 字符串处理模拟
特点: 按规则处理字符串,逐字符遍历
典型题目:
- 替换所有的问号(LeetCode 1576)
- 外观数列(LeetCode 38)
关键点: 字符边界检查、字符串拼接
2. 数组/时间区间模拟
特点: 模拟时间流逝、事件发生
典型题目:
- 提莫攻击(LeetCode 495)
关键点: 区间重叠处理
3. 状态转换模拟
特点: 模拟状态机、方向切换等复杂逻辑
典型题目:
- Z字形变换(LeetCode 6)
- 数青蛙(LeetCode 1419)
关键点: 状态切换时机、状态统计
题目详解
1. 替换所有的问号 (LeetCode 1576)
难度: Easy
耗时: 20分钟(包含调试)
题目描述
给你一个仅包含小写英文字母和 ‘?’ 的字符串 s,请你将所有的 ‘?’ 转换为某些小写字母,使得最终的字符串不包含任何连续重复的字符。
解题思路
遍历字符串,遇到’?'就尝试用’a’到’z’的字母替换,找到第一个不与前后字符相同的字母即可。
踩坑记录
第一次提交:Runtime Error!
// 错误代码
if((i = 0 || ch != s[i-1]) && ...) // ❌ 赋值运算符!
错误原因:
i = 0
是赋值操作,把i改成了0- 然后访问
s[i-1]
就是s[-1]
,数组越界!
正确写法:
if((i == 0 || ch != s[i-1]) && ...) // ✅ 比较运算符
AC代码
class Solution {
public:string modifyString(string s) {int n = s.size();for(int i = 0; i < n; i++) {if(s[i] == '?') {for(int ch = 'a'; ch <= 'z'; ch++) {if((i == 0 || ch != s[i-1]) && ((i == n-1) || ch != s[i+1])) {s[i] = ch;break;}}}}return s;}
};
关键理解
- 贪心策略:每次选择第一个可用字母即可,不需要考虑全局最优
- 边界检查:访问
s[i-1]
前确保i > 0
,访问s[i+1]
前确保i < n-1
- 经典错误:
=
vs==
的区别,赋值会改变变量值并可能导致越界
2. 提莫攻击 (LeetCode 495)
难度: Easy
耗时: 17分钟
题目描述
提莫的攻击可以让艾希进入中毒状态,每次攻击持续 duration 秒。给你一个攻击时间数组 timeSeries 和持续时间 duration,返回艾希处于中毒状态的总秒数。
解题思路
关键在于处理时间区间的重叠:
- 如果两次攻击间隔 ≥ duration,累加完整的 duration
- 如果两次攻击间隔 < duration,累加实际间隔(有重叠)
AC代码
class Solution {
public:int findPoisonedDuration(vector<int>& timeSeries, int duration) {int ret = 0;for(int i = 1; i < timeSeries.size(); i++) {if((timeSeries[i] - timeSeries[i-1]) > duration) ret += duration;else ret += (timeSeries[i] - timeSeries[i-1]);}return ret + duration; // 最后一次攻击的完整duration}
};
关键理解
- 时间区间重叠处理:比较相邻两次攻击的时间间隔与duration的大小
- 两种遍历思路:
- 向后看:
for(i=1; i<n; i++)
计算timeSeries[i] - timeSeries[i-1]
- 向前看:
for(i=0; i<n-1; i++)
计算timeSeries[i+1] - timeSeries[i]
- 两种方法等价,都需要最后单独处理最后一次攻击
- 向后看:
- 边界处理:无论哪种写法,最后一次攻击都要加上完整的duration时间
3. Z字形变换 (LeetCode 6) ⭐⭐⭐
难度: Medium
耗时: 35分钟
题目描述
将字符串 s 根据给定的行数 numRows,以从上往下、从左到右进行 Z 字形排列,然后按行读取。
例如 “PAYPALISHIRING”,numRows = 3:
P A H R
A P L S I I G
Y I N
输出:“PAHNAPLSIIGYIR”
解题思路
解法1:二维数组模拟
直接模拟Z字形的填充过程:
- 创建二维数组存储字符
- 用方向标记控制向下/斜向上的移动
- 按行遍历输出结果
解法2:找规律优化
观察每行字符在原串中的下标规律,直接按行输出。
AC代码(解法1:模拟)
class Solution {
public:string convert(string s, int numRows) {if(numRows == 1) return s; // 特殊情况vector<vector<char>> ret(numRows, vector<char>(s.size(), ' '));int row = 0, col = 0;bool goingDown = true;for(char c : s) {ret[row][col] = c;// 移动前判断方向切换(避免越界后回退)if(row == 0) {goingDown = true;} else if(row == numRows - 1) {goingDown = false;}// 根据方向移动if(goingDown) {row++; // 向下:行+1,列不变} else {row--; // 斜向上:行-1,列+1col++;}}// 按行遍历拼接结果string result = "";for(int i = 0; i < numRows; i++) {for(int j = 0; j < s.size(); j++) {if(ret[i][j] != ' ') {result += ret[i][j];}}}return result;}
};
优化解法(找规律)
class Solution {
public:string convert(string s, int numRows) {if(numRows == 1) return s;string ret;int d = 2 * numRows - 2; // 周期:每个完整Z字的字符数int n = s.size();// 1. 处理第一行for(int i = 0; i < n; i += d)ret += s[i];// 2. 处理中间行for(int k = 1; k < numRows - 1; k++) {for(int i = k, j = d - k; i < n || j < n; i += d, j += d) {if(i < n) ret += s[i];if(j < n) ret += s[j];}}// 3. 处理最后一行for(int i = numRows - 1; i < n; i += d)ret += s[i];return ret;}
};
关键理解
-
模拟解法:
- 空间复杂度 O(n×numRows),逻辑直观
- 方向切换时机:移动前判断避免越界
- 边界情况:
numRows == 1
时需要特殊处理
-
优化解法:
- 空间复杂度 O(1),直接按行输出
- 核心:周期
d = 2×numRows - 2
- 第一行和最后一行:等差数列
- 中间第k行:两组下标交替(竖线 + 斜线)
-
两种解法对比:
- 模拟法:易于理解和实现,适合初学
- 规律法:需要观察和推导,但效率更高
4. 外观数列 (LeetCode 38)
难度: Medium
耗时: 20分钟
题目描述
外观数列是一个整数序列,每一项都是对前一项的描述:
- 1
- 11(一个1)
- 21(两个1)
- 1211(一个2,一个1)
- 111221(一个1,一个2,两个1)
解题思路
从"1"开始,每次对当前字符串进行"读数"描述,生成下一项。用双指针统计连续相同字符的个数。
AC代码
class Solution {
public:string countAndSay(int n) {string ret = "1";for(int i = 1; i < n; i++) {string tmp; // 每次循环重新创建,保证清空int len = ret.size();for(int left = 0, right = 0; right < len;) {// right向右扫描,找到第一个不同字符while(right < len && ret[left] == ret[right]) right++;// 统计:right-left个ret[left]tmp += to_string(right - left) + ret[left];left = right;}ret = tmp;}return ret;}
};
关键理解
-
双指针统计:
left
指向连续段起点,right
扫描到第一个不同字符- 连续段长度:
right - left
- 连续段字符:
ret[left]
- 连续段长度:
-
tmp的作用:每次循环都要对新的
ret
进行描述,所以tmp
必须在循环内定义(或手动清空) -
时间复杂度:O(2^n),因为字符串长度随 n 指数增长
执行过程示例(n=4)
初始: ret = "1"
第1次: "1" → 1个'1' → "11"
第2次: "11" → 2个'1' → "21"
第3次: "21" → 1个'2' + 1个'1' → "1211"
5. 数青蛙 (LeetCode 1419) ⭐⭐
难度: Medium
耗时: 30分钟
题目描述
给你一个字符串 croakOfFrogs,表示不同青蛙发出的蛙鸣声(“croak”)的组合。返回模拟字符串所需不同青蛙的最少数目。
青蛙必须依序输出 ‘c’, ‘r’, ‘o’, ‘a’, ‘k’。
解题思路
用状态机思想,记录处于每个状态的青蛙数量。每只青蛙按顺序经历:
空闲 → c → r → o → a → k → 空闲(可复用)
踩坑记录
第一次尝试:编译错误
unordered_map hash{'c','r','o','a','k'}; // ❌ 语法错误
错误原因:
unordered_map
需要指定键值对类型<char, int>
- 变量名冲突:如果用
c, r, o, a, k
作为变量名,就不能用字符'c'
索引
AC代码
class Solution {
public:int minNumberOfFrogs(string croakOfFrogs) {int cnt_c = 0, cnt_r = 0, cnt_o = 0, cnt_a = 0, cnt_k = 0;if (croakOfFrogs[0] != 'c') return -1;cnt_c++;int maxFrogs = 1;for(int i = 1; i < croakOfFrogs.size(); i++) {char cur = croakOfFrogs[i];if(cur != 'c') {if((cur == 'r') && cnt_c != 0) {cnt_c--; cnt_r++;}else if((cur == 'o') && cnt_r != 0) {cnt_r--; cnt_o++;} else if((cur == 'a') && cnt_o != 0) {cnt_o--; cnt_a++;} else if((cur == 'k') && cnt_a != 0) {cnt_a--; cnt_k++;}else return -1; } else {if(cnt_k != 0) {cnt_k--; // 复用空闲青蛙cnt_c++;} else {cnt_c++; // 新青蛙}}// 统计当前正在叫的青蛙数int current = cnt_c + cnt_r + cnt_o + cnt_a;maxFrogs = max(maxFrogs, current);}return (cnt_c == 0 && cnt_r == 0 && cnt_o == 0 && cnt_a == 0) ? maxFrogs : -1;}
};
关键理解
-
状态机思想:每只青蛙按顺序经历 5 个状态
-
青蛙复用:叫完’k’的青蛙变为空闲,遇到新的’c’时优先复用
-
统计技巧:
cnt_c + cnt_r + cnt_o + cnt_a
:当前正在叫的青蛙数cnt_k
:已完成、空闲的青蛙(可复用,不计入当前占用)maxFrogs
:同一时刻最多有几只青蛙在叫(即答案)
-
为什么不直接返回cnt_k?
- 例如 “croakcroak”:1只青蛙叫两次,maxFrogs=1 ✅
- 例如 “crcoakroak”:2只青蛙交错叫,maxFrogs=2
- 题目求的是"同时最多需要几只",而非"一共完成了几次"
数据结构选择:哈希表 vs 简单变量
三种实现方式:
方案 | 时间复杂度 | 空间复杂度 | 代码可读性 | 效率 |
---|---|---|---|---|
简单变量 | O(n) | O(1) | ⭐⭐⭐⭐⭐ | 最高 |
数组模拟 | O(n) | O(128) | ⭐⭐⭐⭐ | 高 |
unordered_map | O(n) | O(1) | ⭐⭐⭐ | 中等 |
选择建议:
- 状态≤10个:简单变量(最优)
- 状态10-100个:数组模拟(平衡)
- 状态不固定/稀疏:unordered_map(灵活)
核心收获
1. 模拟算法的本质
模拟算法不是"套模板",而是:
- 理解题意:抓住题目描述的核心规律
- 直接实现:按照逻辑一步步翻译成代码
- 处理细节:注意各种边界情况
2. 常见模拟类型总结
类型 | 特点 | 代表题目 | 关键点 |
---|---|---|---|
字符串处理 | 逐字符遍历 | 替换问号、外观数列 | 边界检查、字符串拼接 |
区间模拟 | 时间/事件模拟 | 提莫攻击 | 重叠处理 |
状态转换 | 方向/状态切换 | Z字形、数青蛙 | 切换时机、状态统计 |
3. 解题思路对比
模拟法 vs 优化法(以Z字形为例):
- 模拟法:易于理解,适合初学,空间换时间
- 优化法:需要观察规律,空间优,但不易想到
选择策略:
- 面试时先用模拟法快速AC
- 有时间再考虑优化
4. 数据结构的选择智慧
不同场景选择不同数据结构:
- 状态少且固定(≤10个)→ 简单变量
- 状态多但连续(字母等)→ 数组模拟哈希
- 状态不连续或动态 → unordered_map
易错点总结
1. 经典低级错误
=
vs ==
if(i = 0 || ...) // ❌ 赋值,改变了i的值
if(i == 0 || ...) // ✅ 比较
启示:这种错误很隐蔽,编译器不报错,但会导致逻辑错误甚至数组越界
2. 边界条件处理
访问数组前的检查
if(i > 0 && s[i-1] == ...) // ✅ 先判断i>0
if(i < n-1 && s[i+1] == ...) // ✅ 先判断i<n-1
特殊情况的处理
if(numRows == 1) return s; // Z字形变换的边界
3. 状态切换的时机
移动前判断 vs 移动后判断
- 移动前判断:逻辑清晰,不会越界
- 移动后判断:需要回退,容易出错
推荐:移动前判断方向切换
4. 临时变量的作用域
tmp必须在循环内定义
for(int i = 1; i < n; i++) {string tmp; // ✅ 每次循环都是空的// 对ret进行描述,存到tmpret = tmp;
}
如果定义在外面,需要手动清空:
string tmp;
for(int i = 1; i < n; i++) {tmp.clear(); // 或 tmp = "";// ...
}
5. 理解题目的真正含义
数青蛙问题:
- 问的是"最少需要几只青蛙"
- 而不是"一共完成了几次叫声"
- 要统计的是同时存在的青蛙数的最大值
总结
模拟算法看似简单,但细节很重要。这5道题让我学会了:
- 仔细审题:理解题意是第一步
- 注重细节:边界条件、特殊情况都要考虑
- 先实现再优化:模拟法保证AC,有余力再优化
- 数据结构选择:根据实际情况选择最合适的
- 避免低级错误:
=
vs==
、数组越界等
模拟算法是算法学习的基础,虽然没有固定套路,但通过大量练习可以培养出良好的代码实现能力和细节处理能力。