LeetCode 模拟算法:用「还原过程」搞定编程题的入门钥匙

在算法刷题的世界里,有一类题目从不考验复杂的数学推导,也不依赖精妙的动态规划,只要求你把题目描述的过程原原本本还原出来------这就是模拟算法。从经典的Z字形变换、螺旋矩阵,到日常的日期计算、游戏规则实现,模拟题就像编程世界的「应用题」,考验的是对流程的梳理、边界的把控和代码的严谨性。


题目1:替换所有的问号(LeetCode 1576)

  1. 题目描述

给你一个仅包含小写英文字母和 '?' 字符的字符串 s,请你将所有的 '?' 转换为若干小写字母,使最终的字符串不包含任何连续重复的字符。

注意:你不能修改非 '?' 字符。

题目测试用例保证 除 '?' 字符之外,不存在连续重复的字符。

在完成所有转换(可能无需转换)后返回最终的字符串。如果有多个解决方案,请返回其中任何一个。可以证明,在给定的约束条件下,答案总是存在的。

• 示例1:

输入:s = "?zs" 输出:"azs" 解释:从 azs 到 yzs 都符合要求,只有 zzs 无效(两个连续z)

• 示例2:

输入:s = "ubv?w" 输出:"ubvaw"

解释:替换成 v 或 w 会导致 ubvvw / ubvww 重复,其他字母都合法

提示: 1 <= s.length <= 100, s 仅包含小写英文字母和 '?' 字符

  1. 算法思路(纯模拟)

从前往后遍历字符串,遇到 '?' 时,从 '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; // 返回修改后的字符串
    }
};
  1. 易错点 & 优化技巧

边界处理:i == 0 时没有前一个字符,i == n-1 时没有后一个字符,判断条件必须加上,否则会数组越界。

贪心选择:题目不要求字典序,所以直接从 'a' 开始选第一个合法的字母即可,时间复杂度是 O(n * 26) = O(n),效率很高。

无需额外空间:直接在原字符串上修改,空间复杂度 O(1)(不计返回值)。


题目2:提莫攻击(LeetCode 495)

  1. 题目描述

提示: 1 <= timeSeries.length <= 10^4, 0 <= timeSeries[i], duration <= 10^7, timeSeries 按非递减顺序排列

  1. 算法思路(模拟+分情况讨论)

核心思想:计算两次攻击的时间差,判断是否重叠。

遍历数组,计算相邻两次攻击的时间差 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; // 加上最后一次攻击的完整中毒时间
    }
};
  1. 易错点 & 优化技巧

边界情况:timeSeries 长度为1时,循环不执行,直接返回 duration,符合逻辑。

时间复杂度:O(n),仅遍历一次数组,空间复杂度 O(1)。

重叠计算的本质:中毒时间的重叠部分只会被计算一次,所以两次攻击的有效中毒时间就是 min(tmp, duration),代码可以简化为:ret += min(timeSeries[i] - timeSeries[i-1], duration);


题目3:N字形变换(LeetCode 6)

  1. 题目描述

示例 3: 输入: s = "A", numRows = 1 输出:"A"

提示: 1 <= s.length <= 1000, 1 <= numRows <= 1000

  1. 算法思路(模拟)

用一个二维数组(或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;
    }
};
  1. 算法思路(找规律)
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;
    }
};
  1. 易错点 & 优化技巧

边界处理:numRows == 1 时,cycle = 0,会导致死循环,必须单独判断。

时间复杂度:O(n),每个字符只被访问一次;空间复杂度 O(1)(不计返回值)。

索引越界:中间行的循环中,i 和 j 可能超出字符串长度,需要用 if(i < n) 和 if(j < n) 判断再加入结果。

对比模拟法:也可以用二维数组模拟Z字形排列,再逐行读取,但时间复杂度和空间复杂度都是 O(n),不如找规律高效。


题目4:外观数列(LeetCode 38)

  1. 题目描述

提示: 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. 核心知识点

1) 字符串模拟:核心是遍历字符串,统计连续相同字符的个数,生成新字符串。

2) 迭代/递归思想:数列的每一项都依赖前一项,用迭代实现比递归更直观(避免递归栈溢出风险,不过n≤30时递归也没问题)。

3) 双指针技巧:用left和right两个指针,定位连续相同字符的区间,计算长度。

4) 字符串拼接:to_string() 数字转字符串,+ 或 append() 拼接字符串。

  1. 算法思路(模拟法)

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项的结果。

  1. 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"

  1. 复杂度分析

时间复杂度:O(M),其中 M 是第n项字符串的长度。每一轮生成的字符串长度,最多是上一轮的2倍(比如1→11→21→1211...),n≤30时,总长度在可控范围内。

空间复杂度:O(M),主要是存储当前字符串ret和临时字符串tmp的空间开销。

  1. 易错点&注意事项

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. 题目描述

提示:1 <= croakOfFrogs.length <= 105;字符串中的字符只有 'c', 'r', 'o', 'a' 或者 'k'

  1. 核心知识点

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. 算法思路(模拟+分情况讨论)

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](叫完的青蛙数量,也就是总青蛙数)。

  1. 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];
    }
};
  1. 复杂度分析

时间复杂度:O(N),其中N是字符串长度。只需要遍历一次字符串,预处理和校验都是O(1)的操作。

空间复杂度:O(1),因为hash数组和index映射的大小都是固定的5,和N无关。

  1. 易错点&注意事项

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的青蛙总数就是最少需要的青蛙数。

相关推荐
小小de风呀1 小时前
de风——【从零开始学C++】(二):类和对象入门(一)
开发语言·c++
圣保罗的大教堂1 小时前
leetcode 1722. 执行交换操作后的最小汉明距离 中等
leetcode
t-think1 小时前
操作符详解-C语言(下)
c语言·算法
澈2071 小时前
C++面向对象编程:从封装到实战
开发语言·c++
阿Y加油吧1 小时前
算法二刷复盘|旋转排序数组二分双杀(LeetCode 33 & 153)
算法·leetcode·职场和发展
skywalker_111 小时前
力扣hot100(9-找到字符串中所有字母异位词;10-和为K的子数组)
算法·leetcode·职场和发展
无敌昊哥战神1 小时前
【LeetCode 491】递增子序列:不能排序怎么去重?一文讲透“树层去重”魔法!
c语言·c++·python·算法·leetcode
阿Y加油吧1 小时前
算法二刷复盘|LeetCode 34&74 二分查找双杀(区间边界 + 二维矩阵)
算法·leetcode·矩阵
TSINGSEE1 小时前
零代码自动化AI算法训练革命:企业级私有化部署DLTM自动化AI训练服务器,告别算法依赖
人工智能·深度学习·算法·机器学习·自动化·ai大模型