☰
算法 --模拟
2026/10/3 7:31:56 网站建设 项目流程

什么是模拟算法?

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

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

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

题目一:替换所有的问号

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。

题目二:提莫攻击

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字形变换

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:

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 字形的排列和对应的下标如下:

第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

代码实现对应的逻辑

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]; // 再加上这一周期第二个字符 } }

题目四:外观数列

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. 外层循环控制迭代次数:

    string ret = "1"; for(int i = 1; i < n; i++)

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

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

    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. 更新结果:

    ret = tmp;

    内层循环结束后,tmp存储了当前项的“报数”结果,将其赋值给ret,以便进行下一次迭代。

题目五:数青蛙

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

    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. 遍历字符串,进行状态转移:

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

      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'

      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. 最终合法性检查:

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

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询