什么是模拟算法?
模拟算法 ,顾名思义,就是按照题目描述的规则,一步一步地用代码"扮演"或"重演"整个过程,最终得到结果。
它通常没有特别高深的数学公式或复杂的算法推导,核心难点在于理清逻辑、处理边界、将自然语言描述的流程准确翻译成代码。就像拍电影一样,题目是剧本,你的代码就是演员,必须严格按照剧本一步步演下去。
在算法竞赛和面试中,模拟题往往被视为"基础题"或"细节题",它不考验你背诵了多少高级模板,而是考验你的代码实现能力 和逻辑严密性。
题目一:替换所有的问号
cpp
class Solution {
public:
string modifyString(string s) {
int n = s.size();
for (int i = 0; i < n; i++) {
if (s[i] == '?')
{
for (char 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;
}
};
-
遍历整个字符串 :使用一个
for循环,从左到右扫描字符串中的每一个字符。 -
判断是否为
'?':如果当前字符不是'?',直接跳过(题目要求不能修改非?字符)。 -
如果是
'?',开始贪心寻找替换字母:-
让一个变量
ch从'a'遍历到'z'。 -
对于每一个
ch,检查它是否与左邻居 和右邻居相同。-
左邻居检查 :如果当前不是第一个字符(
i > 0),则要求ch != s[i-1];如果是第一个字符,左边没有邻居,直接满足条件。 -
右邻居检查 :如果当前不是最后一个字符(
i < n - 1),则要求ch != s[i+1];如果是最后一个字符,右边没有邻居,直接满足条件。
-
-
如果
ch同时满足了左右邻居的条件,说明找到了一个合法的字母,将其填入s[i],并 立刻break跳出内层循环 ,继续处理下一个?。
-
-
返回结果 :遍历结束后,原字符串
s已经被修改完毕,直接返回s。
题目二:提莫攻击
cpp
class Solution {
public:
int findPoisonedDuration(vector<int>& timeSeries, int duration)
{
int ret=0;
for(int i=1;i<timeSeries.size();i++)
{
int tmp=timeSeries[i]-timeSeries[i-1];
if(tmp>=duration) ret+=duration;
else ret+=tmp;
}
return ret+duration;
}
};
核心思想是累加每次攻击的"有效持续时间" ,最后再加上最后一次攻击的完整 duration。
-
int ret=0;:初始化总中毒时间为 0。 -
for(int i=1; i<timeSeries.size(); i++):从第二次攻击开始遍历。 -
int tmp = timeSeries[i] - timeSeries[i-1];:计算当前攻击与上一次攻击的时间间隔。 -
核心判断逻辑:
-
if(tmp >= duration) ret += duration;:如果时间间隔大于等于中毒持续时间,说明上一次中毒已经结束,本次攻击贡献了完整的duration秒。 -
else ret += tmp;:如果时间间隔小于中毒持续时间,说明上一次中毒还没结束就被刷新了,上一次攻击实际只贡献了tmp秒的有效中毒时间。
-
-
return ret + duration;:循环只计算了前n-1次攻击的贡献,最后一次攻击必定会带来完整的duration秒中毒时间,所以最后加上去。
题目三:Z字形变换
cpp
class Solution {
public:
string convert(string s, int numRows) {
if (numRows == 1)
return s;
string ret;
int d = 2 * numRows - 2, n = s.size();
// 第一行
for (int i = 0; i < n; i += d)
ret += s[i];
// 中间行
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];
}
}
// 最后一行
for (int i = numRows - 1; i < n; i += d)
ret += s[i];
return ret;
}
};
核心思路解析:
这种解法的关键是找到每一行字符在原字符串 s 中的下标规律。
计算周期 d:
cpp
int d = 2 * numRows - 2;
这是 Z 字形一个完整周期(从第一行往下走到最后一行,再走回第一行上方)所包含的字符数量。例如 numRows = 3,周期 d = 2 * 3 - 2 = 4。其下标走向是 0 -> 1 -> 2 -> 1 -> 0,确实是 4 步一个循环。
下面我以 numRows = 4 为例,为你详细拆解每一行的规律。
当 numRows = 4 时,周期 d = 2 * 4 - 2 = 6。
Z 字形的排列和对应的下标如下:
cpp
第0行: 0 6 12
第1行: 1 5 7 11 13
第2行: 2 4 8 10 14
第3行: 3 9 15
1. 第一行(第 0 行)的规律
-
序列 :
0, 6, 12, ... -
规律:每个周期只有一个字符。
-
步长 :每次下标直接增加一个周期长度
d。 -
公式 :下标为
0, 0+d, 0+2d, ...。即从0开始,每次加d。
2. 最后一行(第 numRows-1 行,这里是第 3 行)的规律
-
序列 :
3, 9, 15, ... -
规律:和第一行一样,每个周期也只有一个字符,出现在 Z 字形的折返点。
-
步长 :每次下标增加一个周期长度
d。 -
公式 :下标为
numRows-1, numRows-1+d, numRows-1+2d, ...。即从numRows-1开始,每次加d。
3. 中间行(第 1 行到第 numRows-2 行)的规律 ------ 最复杂的部分
中间行是这道题的难点,因为每一行在一个周期内会出现两个字符(除了第一行和最后一行只有一个)。
以第 1 行为例:
-
序列 :
1, 5, 7, 11, 13, ... -
分组看 :
(1, 5), (7, 11), (13, ...)每个括号是一个周期内的两个字符。 -
规律:
-
第一个字符的下标是:
1, 7, 13...。它是从当前行号1开始,每次增加周期d(即 1 + 6 = 7)。 -
第二个字符的下标是:
5, 11, ...。它是从5开始,每次增加周期d(即 5 + 6 = 11)。 -
那么第二个字符的起始下标
5是怎么来的呢?5 = d - 当前行号 = 6 - 1 = 5。
-
以第 2 行为例:
-
序列 :
2, 4, 8, 10, 14, ... -
分组看 :
(2, 4), (8, 10), (14, ...) -
规律:
-
第一个字符下标:从当前行号
2开始,每次加d(2 + 6 = 8)。 -
第二个字符下标:从
d - 当前行号 = 6 - 2 = 4开始,每次加d(4 + 6 = 10)。
-
总结中间行的通用公式:
对于任意一个中间行 k(1 <= k <= numRows - 2):
-
在一个周期内,它对应两个下标:
-
第一个下标:
i = k -
第二个下标:
j = d - k
-
-
进入下一个周期,这两个下标都加上
d:-
i = i + d -
j = j + d
-
代码实现对应的逻辑
cpp
for(int k = 1; k < numRows - 1; k++) // 枚举中间的每一行 k
{
// i 就是上面说的第一个下标,j 是第二个下标
// i 每次加 d,j 也每次加 d
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]; // 再加上这一周期第二个字符
}
}
题目四:外观数列
cpp
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;)
{
while(right < len && ret[left]==ret[right]) right++;
tmp+=to_string(right-left) + ret[left];
left=right;
}
ret=tmp;
}
return ret;
}
};
核心思路解析:
-
外层循环控制迭代次数:
cppstring ret = "1"; for(int i = 1; i < n; i++)因为题目已知
countAndSay(1) = "1",所以ret初始化为"1"。我们需要从第 2 项开始计算,一直计算到第n项,因此循环执行n - 1次。 -
内层双指针统计连续字符:
cppint len = ret.size(); for(int left = 0, right = 0; right < len; ) { while(right < len && ret[left] == ret[right]) right++; tmp += to_string(right - left) + ret[left]; left = right; }这是这段代码最核心、最优雅的部分。
left和right指针初始都指向当前字符串的开头。-
while循环 :right指针不断向右移动,直到遇到与ret[left]不同的字符,或者到达字符串末尾。此时,right - left就是这组连续相同字符的个数。 -
拼接结果 :使用
to_string(right - left)将个数转换为字符串,再加上字符本身ret[left],拼接到临时字符串tmp中。这正是"报数"的规则。 -
移动
left:将left移动到right的位置,开始统计下一组连续字符。
-
-
更新结果:
cppret = tmp;内层循环结束后,
tmp存储了当前项的"报数"结果,将其赋值给ret,以便进行下一次迭代。
题目五:数青蛙
cpp
class Solution {
public:
int minNumberOfFrogs(string croakOfFrogs) {
string t = "croak";
int n = t.size();
vector<int> hash(n);
unordered_map<char, int> index; //[x, x这个字符对应的下标]
for (int i = 0; i < n; i++)
index[t[i]] = i;
for (auto ch : croakOfFrogs) {
if (ch == 'c') {
if (hash[n - 1] != 0)
hash[n - 1]--;
hash[0]++;
} else {
int i = index[ch];
if (hash[i - 1] == 0)
return -1;
hash[i - 1]--;
hash[i]++;
}
}
for (int i = 0; i < n - 1; i++)
if (hash[i] != 0)
return -1;
return hash[n - 1];
}
};
核心思路解析:
这道题的难点在于:同一时间可以有多只青蛙在叫,我们需要判断当前的字符应该接在哪只青蛙的后面,从而最小化青蛙的总数。代码通过维护一个"状态数组"完美解决了这个问题。
-
状态定义(哈希表
hash):cppstring t = "croak"; vector<int> hash(n); // n = 5 unordered_map<char, int> index; // 记录 'c','r','o','a','k' 对应的下标 0~4hash[i]表示当前正处于第i个发声阶段 (即刚刚叫完t[i]这个字母)的青蛙数量。-
hash[0]:刚叫完 'c' 的青蛙数量 -
hash[1]:刚叫完 'r' 的青蛙数量 -
...
-
hash[4]:刚叫完 'k'(即完成一次鸣叫)的青蛙数量
-
-
遍历字符串,进行状态转移:
cppfor(auto ch : croakOfFrogs) { ... }-
情况 A:遇到字符
'c'(新一轮鸣叫的开始)cppif(ch == 'c') { if(hash[n - 1] != 0) hash[n - 1]--; // 关键优化:复用已经叫完的青蛙 hash[0]++; }当遇到
'c'时,说明有一只青蛙要开始叫了。此时有两种选择:-
找一只已经叫完(
hash[4] > 0)的青蛙,让它接着叫。这叫"复用",能节省青蛙总数。 -
如果没有空闲的青蛙,就只能增加一只新青蛙(
hash[0]++)。您的代码优先选择复用:
if(hash[4] != 0) hash[4]--;,然后再hash[0]++。这行代码等价于把一只完成状态的青蛙变成了准备开始的状态。
-
-
情况 B:遇到其他字符
'r', 'o', 'a', 'k'cppelse { int i = index[ch]; if(hash[i - 1] == 0) return -1; // 非法情况:没有青蛙处于前一个阶段 hash[i - 1]--; hash[i]++; }例如遇到
'r',它必须由一只刚叫完'c'(hash[0])的青蛙来发出。如果hash[0] == 0,说明前面没有青蛙叫过'c',字符串非法,直接返回-1。否则,将一只青蛙从状态i-1转移到状态i。
-
-
最终合法性检查:
cppfor(int i = 0; i < n - 1; i++) if(hash[i] != 0) return -1; return hash[n - 1];遍历结束后,除了状态 4(叫完
'k')之外,其他状态hash[0]到hash[3]必须全部为 0,否则说明有青蛙"卡"在了半路,叫声不完整(例如"cro"或"croakc"这种缺少后续字母的情况)。最后返回
hash[4],即处于完成状态的青蛙数量,也就是所需的最少青蛙总数。
模拟算法的常见类型
根据模拟对象的不同,常见的模拟题可以分为以下几类:
1. 状态机模拟(如:数青蛙 )
这是模拟算法中最经典、也最考验逻辑的一类。
-
特征:系统中的元素会在不同状态之间转移(例如青蛙从"没叫" -> "叫了c" -> "叫了r" ... -> "叫完k")。
-
解题套路 :定义状态,记录每种状态的数量。
-
代码体现 :用
hash[0]到hash[4]记录处于不同发声阶段的青蛙数量。遍历字符串时,遇到字符就进行状态转移(hash[i-1]--; hash[i]++;)。遇到'c'时优先复用叫完的青蛙(状态4转移到状态0)。
2. 过程/规则模拟(如:外观数列 )
-
特征:按照明确的规则,不断迭代生成新的结果。
-
解题套路 :双指针 / 遍历统计。
-
代码体现 :利用
left和right双指针,模拟"报数"的过程,统计连续相同字符的个数,然后拼接成新的字符串,循环往复。
3. 图形/路径模拟(如:Z字形变换)
-
特征:在二维网格或特定路径上移动,或者按照特定几何规律排列字符。
-
解题套路:
-
方法一(纯模拟) :开一个二维数组,用变量控制方向(如
flag = 1向下,flag = -1向右上),一步步填字符。 -
方法二(找规律优化) :跳过模拟过程,直接推导出每一行字符下标的数学公式(周期
d = 2*numRows-2),按行直接读取。这属于"降维打击",将模拟题做成了数学找规律题。
-
4. 时间轴/生命值模拟(如:提莫攻击 )
-
特征:涉及时间推移、状态持续、状态刷新等。
-
解题套路 :计算区间覆盖 / 累加增量。
-
代码体现 :通过比较两次攻击的时间差
tmp和持续时间duration,来决定是累加duration还是累加tmp。这也是模拟的一种,但使用了贪心的局部计算优化。
识别信号
以下是几种最典型的、需要使用模拟算法的场景:
1. 题目描述了明确的"游戏规则"或"物理过程"
当题目像是在描述一个游戏、一个物理现象或一个操作流程时,通常就是模拟题。
-
特征:题目会详细告诉你每一步该怎么做,状态如何改变。
-
例子 :提莫攻击。题目明确描述了"中毒持续 duration 秒,如果期间再次攻击,中毒时间重置"。你需要模拟这个过程,计算总的中毒时间。
-
判断标准:如果你能在纸上按照题目描述,一步一步画出状态变化图,那么基本就可以用模拟算法。
2. 题目涉及"状态机"或"多阶段状态转移"
当某个事物需要按照固定顺序,从一个状态转移到下一个状态时,非常适合用模拟(特别是状态计数法)。
-
特征:存在固定的阶段序列,必须按顺序完成。
-
例子 :数青蛙 。青蛙必须依次叫出
c -> r -> o -> a -> k。你需要模拟每只青蛙当前处于哪个阶段。我们不需要真的去模拟每一只具体的青蛙,而是通过记录"处于每个阶段的青蛙数量"来完成状态转移的模拟。 -
判断标准:题目中有"必须按顺序"、"前一个状态完成后才能进入下一个状态"等字眼。
3. 题目要求"按特定规律生成/构造"结果
当题目要求你按照某种迭代规则,生成一系列数据,或者构造一个满足特定条件的字符串/数组时。
-
特征:有明确的生成公式或构造规则,需要循环迭代。
-
例子:
-
外观数列:规则是"对上一项进行报数"。你需要模拟这个"统计连续字符并拼接"的过程,迭代 n-1 次。
-
替换所有的问号:规则是"替换 ? 且不能与左右相同"。你需要模拟这个"尝试替换"的过程。
-
-
判断标准:题目要求你"生成第 n 项"或"构造一个满足条件的字符串"。
4. 题目涉及"二维网格"或"路径移动"
当题目要求你在一个矩阵、网格中按照特定方向移动,或者按照特定几何规律排列元素时。
-
特征:有空间位置的变化,通常需要控制方向变量(如上下左右)。
-
例子 :Z字形变换。如果你选择最直观的解法,就是开一个二维数组,用一个变量控制方向(向下走,碰到底部就向右上走),一步步把字符填进去,最后按行读取。这就是典型的路径模拟。
-
判断标准:题目描述了一个在二维空间中的运动轨迹或排列方式。