在算法刷题的世界里,有一类题目从不考验复杂的数学推导,也不依赖精妙的动态规划,只要求你把题目描述的过程原原本本还原出来------这就是模拟算法。从经典的Z字形变换、螺旋矩阵,到日常的日期计算、游戏规则实现,模拟题就像编程世界的「应用题」,考验的是对流程的梳理、边界的把控和代码的严谨性。
题目1:替换所有的问号(LeetCode 1576)
- 题目描述
给你一个仅包含小写英文字母和 '?' 字符的字符串 s,请你将所有的 '?' 转换为若干小写字母,使最终的字符串不包含任何连续重复的字符。
注意:你不能修改非 '?' 字符。
题目测试用例保证 除 '?' 字符之外,不存在连续重复的字符。
在完成所有转换(可能无需转换)后返回最终的字符串。如果有多个解决方案,请返回其中任何一个。可以证明,在给定的约束条件下,答案总是存在的。
• 示例1:
输入:s = "?zs" 输出:"azs" 解释:从 azs 到 yzs 都符合要求,只有 zzs 无效(两个连续z)
• 示例2:
输入:s = "ubv?w" 输出:"ubvaw"
解释:替换成 v 或 w 会导致 ubvvw / ubvww 重复,其他字母都合法
提示: 1 <= s.length <= 100, s 仅包含小写英文字母和 '?' 字符
- 算法思路(纯模拟)
从前往后遍历字符串,遇到 '?' 时,从 'a' 到 'z' 尝试替换,只要满足不与前一个字符重复、也不与后一个字符重复,就替换并跳出循环。
关键条件拆解:
if ((i == 0 || ch != s[i - 1]) && (i == n - 1 || ch != s[i + 1]))
i == 0 || ch != s[i - 1]:如果是第一个字符,不需要检查前一个;否则不能和前一个字符相同。
i == n - 1 || ch != s[i + 1]:如果是最后一个字符,不需要检查后一个;否则不能和后一个字符相同。
cpp
class Solution {
public:
string modifyString(string s) {
int n = s.size(); // 获取字符串长度
for(int i = 0; i < n; i++) // 从前往后遍历每个字符
{
if(s[i] == '?') // 遇到问号才处理
{
// 从 'a' 到 'z' 依次尝试替换
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; // 返回修改后的字符串
}
};
- 易错点 & 优化技巧
边界处理:i == 0 时没有前一个字符,i == n-1 时没有后一个字符,判断条件必须加上,否则会数组越界。
贪心选择:题目不要求字典序,所以直接从 'a' 开始选第一个合法的字母即可,时间复杂度是 O(n * 26) = O(n),效率很高。
无需额外空间:直接在原字符串上修改,空间复杂度 O(1)(不计返回值)。
题目2:提莫攻击(LeetCode 495)
- 题目描述

提示: 1 <= timeSeries.length <= 10^4, 0 <= timeSeries[i], duration <= 10^7, timeSeries 按非递减顺序排列
- 算法思路(模拟+分情况讨论)
核心思想:计算两次攻击的时间差,判断是否重叠。
遍历数组,计算相邻两次攻击的时间差 tmp = timeSeries[i] - timeSeries[i-1]
若 tmp >= duration:说明两次攻击没有重叠,上一次中毒持续完整的 duration 秒
若 tmp < duration:说明两次攻击重叠,上一次中毒只持续 tmp 秒(到下一次攻击时就被重置了)
最后一次攻击的中毒时间一定是完整的 duration 秒,所以最后加上 duration
cpp
class Solution {
public:
int findPoisonedDuration(vector<int>& timeSeries, int duration) {
int ret = 0; // 记录总中毒时间
// 从第2次攻击开始,和前一次攻击对比
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; // 加上最后一次攻击的完整中毒时间
}
};
- 易错点 & 优化技巧
边界情况:timeSeries 长度为1时,循环不执行,直接返回 duration,符合逻辑。
时间复杂度:O(n),仅遍历一次数组,空间复杂度 O(1)。
重叠计算的本质:中毒时间的重叠部分只会被计算一次,所以两次攻击的有效中毒时间就是 min(tmp, duration),代码可以简化为:ret += min(timeSeries[i] - timeSeries[i-1], duration);
题目3:N字形变换(LeetCode 6)
- 题目描述

示例 3: 输入: s = "A", numRows = 1 输出:"A"
提示: 1 <= s.length <= 1000, 1 <= numRows <= 1000
- 算法思路(模拟)
用一个二维数组(或vector)来模拟N字形的格子,大小为 n行 × 足够多的列。
用变量 (x,y) 表示当前要填的格子坐标: x 是行号(从0到n-1), y 是列号(从0开始递增)
用方向变量控制移动:
初始方向向下,行号 x++,列号 y 不变
当 x 走到 n-1(最后一行)时,方向改为向上,行号 x--,列号 y++
当 x 走到 0(第一行)时,方向再次改为向下
依次把字符串的字符填入对应格子,最后按行遍历格子,把非空字符拼接成结果。
时间复杂度:O(len × n)(len 是字符串长度), 因为需要遍历字符串填格子(O(len)),再遍历所有格子(O(len × n),最坏情况列数≈len)
空间复杂度:O(len × n)(存储二维格子)
cpp
class Solution {
public:
string convert(string s, int numRows) {
if (numRows == 1) return s; // 边界情况,直接返回
// 初始化numRows行的空字符串,用来模拟格子
vector<string> grid(numRows);
int x = 0; // 当前行号
int dir = -1; // 方向:-1 代表向上,1 代表向下(初始向下,第一次循环会翻转)
for (char c : s) {
grid[x] += c; // 把字符填入当前行
// 触顶或触底时,翻转方向
if (x == 0 || x == numRows - 1) {
dir *= -1;
}
x += dir; // 按方向移动行号
}
// 按行拼接所有字符
string res;
for (string& row : grid) {
res += row;
}
return res;
}
};
- 算法思路(找规律)


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();
// 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;
}
};
- 易错点 & 优化技巧
边界处理:numRows == 1 时,cycle = 0,会导致死循环,必须单独判断。
时间复杂度:O(n),每个字符只被访问一次;空间复杂度 O(1)(不计返回值)。
索引越界:中间行的循环中,i 和 j 可能超出字符串长度,需要用 if(i < n) 和 if(j < n) 判断再加入结果。
对比模拟法:也可以用二维数组模拟Z字形排列,再逐行读取,但时间复杂度和空间复杂度都是 O(n),不如找规律高效。
题目4:外观数列(LeetCode 38)
- 题目描述

提示: 1 <= n <= 30
前五项示例:
|---|----------|-------------------------------------------|
| n | 输出 | 解释 |
| 1 | "1" | 起始项 |
| 2 | "11" | 前一项是1个1 → "1"+"1" |
| 3 | "21" | 前一项是2个1 → "2"+"1" |
| 4 | "1211" | 前一项是1个2、1个1 → "1"+"2"+"1"+"1" |
| 5 | "111221" | 前一项是1个1、1个2、2个1 → "1"+"1"+"1"+"2"+"2"+"1" |
- 核心知识点
1) 字符串模拟:核心是遍历字符串,统计连续相同字符的个数,生成新字符串。
2) 迭代/递归思想:数列的每一项都依赖前一项,用迭代实现比递归更直观(避免递归栈溢出风险,不过n≤30时递归也没问题)。
3) 双指针技巧:用left和right两个指针,定位连续相同字符的区间,计算长度。
4) 字符串拼接:to_string() 数字转字符串,+ 或 append() 拼接字符串。
- 算法思路(模拟法)
1) 初始化:因为 n≥1,所以起始字符串 ret = "1"。
2) 迭代生成:循环 n-1 次(因为第1项已经有了,要生成第n项,需要迭代n-1次),每次根据当前字符串生成下一项。
3) 生成下一项的核心逻辑:
用 left 记录当前连续相同字符的起始位置,right 向后移动,直到遇到不同字符或字符串末尾。
统计 right - left 个字符,拼接成 数量 + 字符 的形式,加入临时字符串 tmp。
更新 left = right,继续下一组统计。
4) 更新当前字符串:每次迭代结束后,ret = tmp,进入下一轮循环。
5) 返回结果:循环结束后,ret 就是第n项的结果。
- C++ 代码逐行解析
cpp
class Solution
{
public:
string countAndSay(int n)
{
// 1. 初始化:第1项固定为"1"
string ret = "1";
// 2. 迭代n-1次,生成第2~n项
for(int i = 1; i < n; i++) // 解释:i从1到n-1,共n-1次循环
{
string tmp; // 临时字符串,存储当前轮生成的下一项
int len = ret.size(); // 当前字符串的长度
// 3. 双指针遍历当前字符串,统计连续相同字符
for(int left = 0, right = 0; right < len; )
{
// 让right一直往后走,直到遇到不同字符或末尾
while(right < len && ret[left] == ret[right]) right++;
// 统计right-left个ret[left],拼接到tmp中
tmp += to_string(right - left) + ret[left];
// 更新left到right的位置,处理下一组字符
left = right;
}
// 4. 把本轮生成的tmp赋值给ret,作为下一轮的输入
ret = tmp;
}
// 5. 循环结束后,ret就是第n项的结果
return ret;
}
};
以 n = 5 为例
已知规则:countAndSay(1) = "1",需要迭代 n-1 = 4 次,依次生成第2、3、4、5项
初始状态:n = 5,初始:ret = "1"
第1轮外循环(i=1,生成第2项):当前 ret = "1",len = 1,双指针:left=0,right=0;内层while:ret[0]==ret[0],right++ → right=1,个数:right-left = 1-0 = 1,拼接:"1" + "1" → tmp = "11",更新 ret = "11" 👉 现在得到:第2项:"11"
第2轮外循环(i=2,生成第3项):当前 ret = "11",len = 2,left=0,right=0,一直右移,直到字符不同:right走到2,个数:2个'1',拼接:"2" + "1" → tmp = "21",更新 ret = "21" 👉 现在得到:第3项:"21"
第3轮外循环(i=3,生成第4项):当前 ret = "21",len = 2
第一段:left=0,ret[0]='2',right右移到1,1个'2' → 拼接 "12",left更新为1
第二段:left=1,ret[1]='1',right右移到2,1个'1' → 拼接 "11",最终 tmp = "1211"
更新 ret = "1211" 👉 现在得到:第4项:"1211"
第4轮外循环(i=4,生成第5项):当前 ret = "1211"
分段遍历:开头1个'1' → "11",接着1个'2' → "12",最后2个'1' → "21",拼接结果:tmp = "111221"
更新 ret = "111221" 👉 最终得到:第5项:"111221"
- 复杂度分析
时间复杂度:O(M),其中 M 是第n项字符串的长度。每一轮生成的字符串长度,最多是上一轮的2倍(比如1→11→21→1211...),n≤30时,总长度在可控范围内。
空间复杂度:O(M),主要是存储当前字符串ret和临时字符串tmp的空间开销。
- 易错点&注意事项
1) 循环次数:for(int i=1; i < n; i++),是n-1次,不是n次!第1项已经初始化好了,不需要再循环。
2) 双指针的边界:right初始值和left相同,while循环里要先判断right < len,再判断字符是否相等,否则会越界。
3) 数字转字符串:C++中to_string()可以直接把int转成字符串,不要手动拼接字符(比如'0' + count,容易出错)。
4) 字符串拼接顺序:必须是数量 + 字符,不能写反!比如2个1,要写成"2"+"1",不是"1"+"2"。
题目5:最少数量的青蛙(LeetCode 1419)
- 题目描述

提示:1 <= croakOfFrogs.length <= 105;字符串中的字符只有 'c', 'r', 'o', 'a' 或者 'k'
- 核心知识点
1) 状态模拟:用数组模拟每只青蛙的叫声状态,统计处于c/r/o/a/k各阶段的青蛙数量。
2) 哈希映射/数组映射:把'c','r','o','a','k'映射到索引0,1,2,3,4,方便数组操作。
3) 贪心思想:遇到'c'时,优先复用刚叫完k的青蛙;遇到其他字符时,优先让处于上一阶段的青蛙继续叫,否则序列不合法。
4) 序列合法性校验:
每个非'c'字符,必须有上一阶段的青蛙存在,否则返回-1。
遍历结束后,所有青蛙都必须处于k阶段(即所有叫声都完整结束),否则返回-1。
- 算法思路(模拟+分情况讨论)
1) 预处理映射:把"croak"的每个字符映射到索引0~4,方便数组操作。
2) 状态数组初始化:hash[5],hash[i]表示处于第i个阶段(对应'c','r','o','a','k')的青蛙数量。
3) 遍历字符串的每个字符:
遇到'c'(索引0):优先复用刚叫完k的青蛙(即hash[4] > 0时,hash[4]--,hash[0]++);如果没有,就新增一只青蛙,hash[0]++。
遇到其他字符ch(索引i):必须有处于上一阶段i-1的青蛙(hash[i-1] > 0),否则序列不合法,返回-1。然后让这只青蛙进入当前阶段:hash[i-1]--,hash[i]++。
4) 合法性校验:遍历结束后,检查hash[0]~hash[3]是否都为0(所有青蛙都叫完了k),如果有不为0的,说明有不完整的叫声,返回-1。
5) 返回结果:最少青蛙数量就是hash[4](叫完的青蛙数量,也就是总青蛙数)。
- C++ 代码逐行解析
cpp
class Solution
{
public:
int minNumberOfFrogs(string croakOfFrogs)
{
string t = "croak"; // 青蛙叫声的顺序
int n = t.size(); // n=5,对应'c','r','o','a','k'
vector<int> hash(n); // hash[0~4]分别表示处于c/r/o/a/k阶段的青蛙数
// 1. 建立字符到索引的映射:'c'→0, 'r'→1, 'o'→2, 'a'→3, 'k'→4
unordered_map<char, int> index;
for(int i = 0; i < n; i++)
index[t[i]] = i;
// 2. 遍历字符串的每个字符
for(auto ch : croakOfFrogs)
{
if(ch == 'c') // 情况1:遇到起始字符'c'
{
// 优先复用刚叫完k的青蛙(hash[4]>0)
if(hash[n - 1] != 0) hash[n - 1]--;
// 新增一只处于c阶段的青蛙
hash[0]++;
}
else // 情况2:遇到r/o/a/k
{
int i = index[ch]; // 当前字符对应的索引
// 必须有处于上一阶段i-1的青蛙,否则序列不合法
if(hash[i - 1] == 0) return -1;
// 上一阶段的青蛙数量减1,当前阶段加1
hash[i - 1]--;
hash[i]++;
}
}
// 3. 合法性校验:遍历结束后,不能有处于c/r/o/a阶段的青蛙(叫声不完整)
for(int i = 0; i < n - 1; i++)
if(hash[i] != 0)
return -1;
// 4. 最少青蛙数就是处于k阶段的青蛙数量(所有青蛙都叫完了)
return hash[n - 1];
}
};
- 复杂度分析
时间复杂度:O(N),其中N是字符串长度。只需要遍历一次字符串,预处理和校验都是O(1)的操作。
空间复杂度:O(1),因为hash数组和index映射的大小都是固定的5,和N无关。
- 易错点&注意事项
1) 字符映射的顺序:必须和"croak"的顺序一致,'c'是0,'k'是4,否则上一阶段的判断会出错。
2) 'c'的处理逻辑:优先复用hash[4]的青蛙,而不是直接新增,这样才能保证青蛙数量最少。
3) 上一阶段的判断:遇到'r'时,必须hash[0] > 0;遇到'o'时,必须hash[1] > 0,否则直接返回-1,这是判断序列是否合法的关键。
4) 结束校验:遍历完整个字符串后,必须保证hash[0]~hash[3]都为0,否则说明有青蛙叫了一半没叫完,序列不合法,比如"croakcro"这种情况。
5) 总青蛙数的返回值:不是遍历过程中hash[0]的最大值,而是hash[4],因为叫完k的青蛙总数就是最少需要的青蛙数。