模拟算法学习记录
模拟算法是算法竞赛和工程开发中非常基础但又极其重要的一类思想。很多初学者会把"模拟"和"暴力"混为一谈,但实际上它们有本质区别。
1. 模拟的核心思想:还原过程
按照现实(或题目)规定的规则,用代码一步步还原整个过程,观察最终状态,即为模拟算法的本质。
模拟算法的关键认知在于:状态、规则、推进
任何模拟问题都可以抽象成这三个要素:
| 要素 | 含义 | 示例 |
|---|---|---|
| 状态 (State) | 描述系统某一时刻的全部信息 | 小球的位置、速度;当前时间;棋盘布局 |
| 规则 (Rule) | 状态如何随时间/事件变化 | 每秒速度+1;碰到边界反弹;轮到下一个人出牌 |
| 推进 (Step) | 按什么节奏让时间/事件前进 | 按秒推进;按事件发生的先后顺序推进 |
模拟的过程,就是不断执行:读取当前状态 → 应用规则 → 得到新状态 → 推进到下一步。
2. 模拟与枚举的根本区别
| 枚举/暴力 | 模拟 | |
|---|---|---|
| 思维方向 | 遍历所有可能的解,验证哪个对 | 遍历所有时间/步骤,看过程走到哪 |
| 目标 | 找到满足条件的答案 | 还原规则执行后的结果 |
| 代码形态 | 通常是多层循环 + 判断 | 通常是单循环/事件驱动 + 状态更新 |
| 举例 | 从1到100试哪个数是质数 | 小球每秒按规则运动,100秒后在哪 |
枚举是"猜答案",模拟是"演过程"。
1. LeetCode 1576. 替换所有的问号

题目的要求很简单,当字符串中出现问号,要将它变为小写字母,不能与前一个或后一个字符重复。
所以这道题的解题思路很清晰:
- 遍历字符串,如果是 ?,则从 a ~ z 尝试匹配,如果不与前一个或后一个字符重复,直接修改。
但是隐含的边界条件需要处理:
- 第一个字符是没有前一个字符的,只能与后一个字符进行比较
- 最后一个字符是没有后一个字符的,只能与前一个字符进行比较
综合以上思路,进行代码实现:
cpp
class Solution {
public:
string modifyString(string s) {
for (int i = 0; i < s.size(); i++)
{
char& str = s[i];
if (str == '?')
{
for (char alph = 'a'; alph < 'z'; alph++)
{
if ((i == 0 || s[i - 1] != alph) && (i == s.size() - 1 || s[i + 1] != alph))
str = alph;
}
}
}
return s;
}
};
2. LeetCode 495. 提莫攻击

题目链接:LeetCode 495. 提莫攻击
此题的难点在于,攻击时对象有可能仍然为中毒属性,此时就要重置计时器。
此题给出的 timeSeries 数组表示的是:发动攻击的时刻,所以每个时刻间的间隔就可以与 duration(中毒持续时间) 进行判断。
将最终返回的变量,命名为 ret(总中毒秒数)
- 如果大于等于 duration 则说明:下一次攻击不会与中毒状态产生重叠,ret 直接加上 duration 即可。
- 如果小于 duration 则说明:下一次攻击会与中毒状态产生重叠,ret 只能加上两次攻击的间隔时间(两次攻击时刻相减)。
这里也有隐藏的边界条件:
- 最后一次攻击时,不会再产生下一次攻击,所以中毒状态会完成持续 duration,所以 ret 直接加上 duration。
依据上述思路,代码实现为:
cpp
class Solution {
public:
int findPoisonedDuration(vector<int>& timeSeries, int duration) {
if (timeSeries.size() == 1)
return duration;
int ret = 0;
for(int i = 0; i < timeSeries.size() - 1; i++)
{
if (timeSeries[i + 1] - timeSeries[i] >= duration)
ret += duration;
else
ret += timeSeries[i + 1] - timeSeries[i];
if (i == timeSeries.size() - 2)
ret += duration;
}
return ret;
}
};
3. LeetCode 6. Z 字形变换

题目链接:LeetCode 6. Z 字形变换
此题算是模拟中较难的一类,我们先对题意进行分析。
假设输入数组为 "abcdefgk",numRows = 4 我们先画出排列:

在掌握变换顺序后,便可引出此题的第一种做题思路:模拟。
- 创建出一个二维数组,按照题目要求进行转换,将元素插入二维数组中,最后遍历二维数组得出返回数组。
但空间复杂度来到了 O(n^2),时间复杂度来到了 O(n),显然还有更优的解法。
模拟题目的优化方法大多为找规律,所以找一下这道题的规律,将数组拉长,且将元素替换为在输入数组中的下标,方便我们观察规律。
numRows = 4:

numRows = 3:

可以对数组中的元素分为两种状况:
-
第 零 或最后一行的元素:下一个元素与第一个元素相隔 numRow * 2 - 2,这显然不是巧合,在 NumRows = 3 的情况下也适用。
-
其余行的元素:在 numRows = 4 的情况下,1 与 7 相差一个 numRow * 2 - 2,5 与 11 相差一个 numRow * 2 - 2,显然出现了一对与下一个元素相差 numRow * 2 - 2,第零行与最后一行在 Z 字形中只出现了一个。
为什么是 numRows * 2 - 2:numRows * 2 - 2 并不是一个凭空出现的魔数,它有明确的几何含义。观察 Z 字形的排列规律可以发现,字符串的字符会按照「竖直向下 → 斜向右上 → 竖直向下」的方式循环往复。一个完整的周期由两部分组成:从第一行竖直走到最后一行,恰好经过 numRows 个字符;再从最后一行斜着走回第一行(不含两端的端点),经过 numRows - 2 个字符。两者相加,一个周期的长度就是 numRows + numRows - 2 = numRows * 2 - 2。因此这个表达式本质上描述的是 Z 字形的最小循环单元长度,用它来对字符下标取模,就能把一维的字符串下标映射到 Z 字形的周期结构上。
为什么除首末行外,中间行每个周期会多新增一个元素:在一个完整周期内,字符的行号先从 0 递增到 numRows - 1(竖直下降阶段),再从 numRows - 1 递减回 0(斜向回升阶段)。首行(行 0)和末行(行 numRows - 1)是转折的端点,每个周期里只会被经过一次;而中间的所有行号在下降阶段出现一次、在回升阶段还会再出现一次,即每个周期内被命中两次。换句话说,首末行的元素是「竖列上的点」,而中间行除了竖列上的点之外,还会额外接收到一个来自「斜线上的点」的字符。所以除首末行外,其余每一行在一个周期中都会多新增一个元素,这就是为什么遍历周期时中间行需要额外处理一个下标为 i + (cycle - 2 * row) 的字符。
所以在编程时,就可以利用观察到的规律,进行优化,不用创建出二维数组,只需按照一下规律对返回字符串进行处理即可:
- 先处理首行元素
- 处理其余行元素,其中元素要成对更新
- 最后处理末行元素
需要注意的边界问题:
当 numRows = 1 时,numRows * 2 - 2 = 0,会造成死循环导致程序崩溃,所以要特殊判断,直接返回输入数组即可。
代码实现:
cpp
class Solution {
public:
string convert(string s, int numRows) {
if (numRows == 1)
return s;
string ret;
int tol = numRows * 2 - 2;
//处理第一行
for (int i = 0; i < s.size(); i += tol)
{
ret += s[i];
}
//处理其余行
for (int i = 1; i < numRows - 1; i++)
{
for (int n = i, m = tol - n; n < s.size() || m < s.size(); n += tol, m += tol)
{
if (n < s.size())
ret += s[n];
if (m < s.size())
ret += s[m];
}
}
//处理最后一行
for (int i = numRows - 1; i < s.size(); i += tol)
{
ret += s[i];
}
return ret;
}
};
4. LeetCode 38. 外观数列

题目链接:LeetCode 38. 外观数列
在理解题意后其实这道题的做法分为两种:
- 递归
- 迭代
但 LeetCode 中函数是必须要显示写出返回的,作者写递归时就被这个问题困扰了很久,最终选择了以迭代方式写出。
在解题时注意以下几点:
- 输入的数字 n,可以理解为转换的次数,但是要减去 1,因为 1 转换一次后还是 1,在示例中已经给出
- 统计相同值的个数,可以使用滑动窗口算法统计,这正好是滑动窗口擅长的领域
- 在将数字塞入字符串中,不要忘记使用 to_string() 函数,否则按照 ASCII 码表映射,可能并不是想要的结果
代码实现:
cpp
class Solution {
public:
string countAndSay(int n) {
string s = {'1'};
while(n > 1)
{
string ret;
int left = 0, right = 0;
while (right < s.size())
{
while (s[right] == s[left] && right < s.size())
right++;
int count = right - left;
ret += to_string(count);
ret += s[left];
left = right;
}
s = ret;
n--;
}
return s;
}
};
5. LeetCode 1419. 数青蛙

题目链接:LeetCode 1419. 数青蛙
对于这道题中,难点有以下几个:
- 如何判断是有效的字符串
- 如何判断需要多只青蛙
- 边界问题
- 效率问题
1. 如何判断是有效的字符串:
题中告诉青蛙必须按序发出 'c', 'r', 'o', 'a', 'k' 这五个字母,说明当字符串中出现其中某个字母时(c 除外,稍后讲解),这只青蛙在上一次必然叫的是前一个字母,比如出现 'r',上一次叫的肯定是 'c'
所以创建哈希表,将字母与出现的次数映射,判断到某个字母时,如果上一个字母出现的次数不为 0,则使上一个字母出现次数 -1,判断字母的次数 +1,若上一个字母出现的次数为 0,则说明字符串无效。
2. 如何判断需要多只青蛙:
当有判断的字符来到 'c' 时,此时 'c' 没有上一个字母,所以无法走有效字符串的逻辑,'c' 也是判断的重点,因为此时要决定是否需要添加一只青蛙(每只青蛙从 'c' 处开始叫),不添加青蛙意味一定有叫完的青蛙,那 'k' 处哈希表的值一定大于 0,所以
- 如果其值大于 0,则不用添加新青蛙。
- 如果等于 0,说明没有青蛙可以复用,需要添加新青蛙。
通过判断 'k' 处哈希表的值,就可以解决是否需要多只青蛙的痛点。
3. 边界问题
题中回出现如下测试用例:
croakcroa
注意到青蛙并没有叫完,而是停止了,题中将此字符串划分为无效字符串,按上面的逻辑最终会返回哈希表中 'k' 映射的值,而不是返回 -1,解决方法为:当遍历字符串完毕后
- 判断最后哈希表中,除了 k 以外映射的值是否均为 0
- 为 0 正常返回
- 不为 0 返回 -1
在上述测试用例中,最后哈希表中 a 映射的值为 1,不为 0,将返回 -1。
4. 效率问题
如何处理 5 种字符不同的操作呢?可以写出 5 个 if else 来分别处理。但这并不优秀,可以将字母('c', 'r', 'o', 'a', 'k')与下标建立映射,字符串中遍历到字母,便取查找字母对应的下标,如果下标不为 0(即不为 c),就可以正常处理,如果为 0,就需要特殊处理,这样就算青蛙以后叫的是 hello,代码都不需要大改。
如果硬编码到程序中,那么输出字母更换时,整个程序就要进行大改,代码的耦合性太高,十分不便
代码实现:
cpp
class Solution {
public:
int minNumberOfFrogs(string croakOfFrogs) {
string croak = {"croak"};
int size = croak.size();
vector<int> countMap(size);
unordered_map<char, int> indexMap;
for (int i = 0; i < size; i++)
{
indexMap[croak[i]] = i;
}
for (auto str : croakOfFrogs)
{
int num = indexMap[str];
int last_num = num - 1;
if (num != 0 && countMap[last_num] <= 0)
return -1;
else if(num == 0)
{
if (countMap[size - 1] > 0)
countMap[size - 1]--;
countMap[0]++;
}
else
{
countMap[last_num]--;
countMap[num]++;
}
}
for (int i = 0; i < size - 1; i++)
{
if (countMap[i] != 0)
return -1;
}
return countMap[size - 1];
}
};