算法 --模拟

什么是模拟算法?

模拟算法 ,顾名思义,就是按照题目描述的规则,一步一步地用代码"扮演"或"重演"整个过程,最终得到结果。

它通常没有特别高深的数学公式或复杂的算法推导,核心难点在于理清逻辑、处理边界、将自然语言描述的流程准确翻译成代码。就像拍电影一样,题目是剧本,你的代码就是演员,必须严格按照剧本一步步演下去。

在算法竞赛和面试中,模拟题往往被视为"基础题"或"细节题",它不考验你背诵了多少高级模板,而是考验你的代码实现能力 和逻辑严密性。

题目一:替换所有的问号

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;
    }
};
  1. 遍历整个字符串 :使用一个 for 循环,从左到右扫描字符串中的每一个字符。

  2. 判断是否为 '?' :如果当前字符不是 '?',直接跳过(题目要求不能修改非 ? 字符)。

  3. 如果是 '?',开始贪心寻找替换字母:

    • 让一个变量 ch 从 'a' 遍历到 'z'。

    • 对于每一个 ch,检查它是否与左邻居 和右邻居相同。

      • 左邻居检查 :如果当前不是第一个字符(i > 0),则要求 ch != s[i-1];如果是第一个字符,左边没有邻居,直接满足条件。

      • 右邻居检查 :如果当前不是最后一个字符(i < n - 1),则要求 ch != s[i+1];如果是最后一个字符,右边没有邻居,直接满足条件。

    • 如果 ch 同时满足了左右邻居的条件,说明找到了一个合法的字母,将其填入 s[i],并 立刻 break 跳出内层循环 ,继续处理下一个 ?。

  4. 返回结果 :遍历结束后,原字符串 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。

  1. int ret=0;:初始化总中毒时间为 0。

  2. for(int i=1; i<timeSeries.size(); i++):从第二次攻击开始遍历。

  3. int tmp = timeSeries[i] - timeSeries[i-1];:计算当前攻击与上一次攻击的时间间隔。

  4. 核心判断逻辑:

    • if(tmp >= duration) ret += duration;:如果时间间隔大于等于中毒持续时间,说明上一次中毒已经结束,本次攻击贡献了完整的 duration 秒。

    • else ret += tmp;:如果时间间隔小于中毒持续时间,说明上一次中毒还没结束就被刷新了,上一次攻击实际只贡献了 tmp 秒的有效中毒时间。

  5. 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):

  1. 在一个周期内,它对应两个下标:

    • 第一个下标:i = k

    • 第二个下标:j = d - k

  2. 进入下一个周期,这两个下标都加上 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;
    }
};

核心思路解析:

  1. 外层循环控制迭代次数:

    cpp 复制代码
    string ret = "1";
    for(int i = 1; i < n; i++)

    因为题目已知 countAndSay(1) = "1",所以 ret 初始化为 "1"。我们需要从第 2 项开始计算,一直计算到第 n 项,因此循环执行 n - 1 次。

  2. 内层双指针统计连续字符:

    cpp 复制代码
    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;
    }

    这是这段代码最核心、最优雅的部分。left 和 right 指针初始都指向当前字符串的开头。

    • while 循环 :right 指针不断向右移动,直到遇到与 ret[left] 不同的字符,或者到达字符串末尾。此时,right - left 就是这组连续相同字符的个数。

    • 拼接结果 :使用 to_string(right - left) 将个数转换为字符串,再加上字符本身 ret[left],拼接到临时字符串 tmp 中。这正是"报数"的规则。

    • 移动 left :将 left 移动到 right 的位置,开始统计下一组连续字符。

  3. 更新结果:

    cpp 复制代码
    ret = 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];
    }
};

核心思路解析:

这道题的难点在于:同一时间可以有多只青蛙在叫,我们需要判断当前的字符应该接在哪只青蛙的后面,从而最小化青蛙的总数。代码通过维护一个"状态数组"完美解决了这个问题。

  1. 状态定义(哈希表 hash):

    cpp 复制代码
    string t = "croak";
    vector<int> hash(n); // n = 5
    unordered_map<char, int> index; // 记录 'c','r','o','a','k' 对应的下标 0~4

    hash[i] 表示当前正处于第 i 个发声阶段 (即刚刚叫完 t[i] 这个字母)的青蛙数量。

    • hash[0]:刚叫完 'c' 的青蛙数量

    • hash[1]:刚叫完 'r' 的青蛙数量

    • ...

    • hash[4]:刚叫完 'k'(即完成一次鸣叫)的青蛙数量

  2. 遍历字符串,进行状态转移:

    cpp 复制代码
    for(auto ch : croakOfFrogs) { ... }
    • 情况 A:遇到字符 'c'(新一轮鸣叫的开始)

      cpp 复制代码
      if(ch == 'c') {
          if(hash[n - 1] != 0) hash[n - 1]--; // 关键优化:复用已经叫完的青蛙
          hash[0]++;
      }

      当遇到 'c' 时,说明有一只青蛙要开始叫了。此时有两种选择:

      1. 找一只已经叫完(hash[4] > 0)的青蛙,让它接着叫。这叫"复用",能节省青蛙总数。

      2. 如果没有空闲的青蛙,就只能增加一只新青蛙(hash[0]++)。

        您的代码优先选择复用:if(hash[4] != 0) hash[4]--;,然后再 hash[0]++。这行代码等价于把一只完成状态的青蛙变成了准备开始的状态。

    • 情况 B:遇到其他字符 'r', 'o', 'a', 'k'

      cpp 复制代码
      else {
          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。

  3. 最终合法性检查:

    cpp 复制代码
    for(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字形变换。如果你选择最直观的解法,就是开一个二维数组,用一个变量控制方向(向下走,碰到底部就向右上走),一步步把字符填进去,最后按行读取。这就是典型的路径模拟。

  • 判断标准:题目描述了一个在二维空间中的运动轨迹或排列方式。

相关推荐
青山是哪个青山1 小时前
LeetCode 300:最长递增子序列
算法
hetao17338371 小时前
2026-10-01~02 hetao1733837 的刷题记录
c++·算法
小虎牙^O^1 小时前
模糊数学综合评价:从模糊数学到建模评价类模型
人工智能·算法
随意起个昵称2 小时前
【单调队列优化DP】U301134 绿色通道
算法·动态规划
郝学胜-神的一滴2 小时前
AI 编程智能体 03:拆解当下主流智能体能力
开发语言·c++·人工智能·python·程序人生·游戏
Nil2082 小时前
leetcode 739每日温度
算法·leetcode·职场和发展
lvwangshu2 小时前
势能分析(摊还分析)
数据结构·数学·算法
盘古开天16663 小时前
PPO算法代码实战(三):PyTorch从零实现PPO求解CartPole
人工智能·pytorch·算法
yyy(十一月限定版)3 小时前
CF2138A Cake Assignment
算法