力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 15数学与模拟

这个专题没有通用模板,却恰恰是很多学生的失分重灾区——因为考的不是「你会不会算法」,而是 「你能不能把一个朴素的想法,一个坑都不踩地写成代码」。数学题靠观察(找到隐藏的规律),模拟题靠细心(按题意直译 + 边界枚举)。 好消息是:这两类题的解题过程完全可复现,练 10 道就能明显感觉到「手稳了」。

学习目标
能从题目描述里「看出来」隐藏的数学规律,并能不慌不忙地写模拟代码
前置要求
专题 01 双指针(快乐数要用);基本位运算直觉
本章产出
观察类题目的 4 个固定切入点 + 模拟题的三要素写法
建议学时
3~4 小时(5 道题 + 巩固练习)

15.1数学题与模拟题考什么

先把这两类题分清楚,因为对策完全不同:

类型长什么样对策
数学题题面给一个数字游戏,问某个性质/计数/极值找规律:拿小例子手动算 3~5 组,观察「什么东西不变 / 什么单调 / 什么成周期」——规律往往就藏在这些小例子里
模拟题题面把规则讲得很长(游戏、走格子、流程),要求你复现结果直译:题面每句话对应一行代码;先把「状态变量」列出来(位置、方向、计数器),再填循环体
模拟题三要素:① 按题意直译,不要自作聪明「优化」规则;② 边界单独枚举(第一行、最后一列、只剩一行一列);③ 写不下去时把中间状态 System.out.println 出来对比题面例子——模拟题调试靠「对答案」,不靠盯着代码看。

为什么学生容易在这类题翻车?因为没有模板可以背。前面专题里你背了双指针、二分、BFS 的骨架,遇到题先套再调; 数学与模拟题没有骨架,考的是从零搭。所以这一章的重点不是题本身,而是两个可复现的思考切入点:观察小例子找规律(数学),列出状态变量再直译(模拟)。


15.2罗马数字转整数(简单,力扣 13)

简单 核心:观察字符间的位置关系

题意。罗马数字用 7 个符号表示数:I=1、V=5、X=10、L=50、C=100、D=500、M=1000。通常大符号在左、小符号在右,直接加起来; 但恰好有 6 种「小在大左」的减法组合:IV=4、IX=9、XL=40、XC=90、CD=400、CM=900。给你一个罗马数字字符串,转成整数。

思路推导。暴力想法是把 6 种减法组合单独写 if——能做,但啰嗦。停一下,观察减法组合的共同点:

它们都是「小符号出现在大符号的左边」。也就是说,逐位扫描时:

一个判断就覆盖了全部 6 种情况——这就是从「枚举特例」到「观察共性」的典型过程。小例子验证:MCMXCIV = 1000 − 100 + 1000 − 10 + 100 − 1 + 5 = 1994 ✓

class Solution {
    public int romanToInt(String s) {
        // 符号 -> 值 的映射表
        java.util.Map<Character, Integer> map = java.util.Map.of(
            'I', 1, 'V', 5, 'X', 10, 'L', 50,
            'C', 100, 'D', 500, 'M', 1000);

        int ans = 0;
        int n = s.length();
        for (int i = 0; i < n; i++) {
            int cur = map.get(s.charAt(i));
            // 关键判断:如果当前符号比右边的小,说明它是 IV / IX 这类减法前缀
            if (i + 1 < n && cur < map.get(s.charAt(i + 1))) {
                ans -= cur;   // 减掉它,下一轮循环右边那个大符号会被正常加上
            } else {
                ans += cur;   // 正常累加
            }
        }
        return ans;
    }
}

复杂度。时间 O(n),每个字符看一次;空间 O(1),映射表是固定 7 项。

关键点:「比右边小就减」这个判断是对的,因为罗马数字里不存在「两个连续小符号在同一个大符号左边」的合法写法(比如 IIC 不是合法表示)。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 整数转罗马数字(力扣 12,中等)——本题的逆向;共用「枚举所有组合」的思想,差异在贪心地从大到小拼(提示:把 13 种组合做成两张平行数组,每次尽量取大的)。
  • Excel 表列序号(力扣 171,简单)——同样是「自定义进制的逐位解析」,差异在它是 26 进制且没有减法(提示:ans = ans * 26 + 当前位)。

15.3快乐数(简单,力扣 202)

简单 核心:把「循环检测」迁移到数字上

题意。对一个数反复做「各位平方和」变换:比如 19 → 1²+9²=82 → 64+4=68 → 36+64=100 → 1。若最终变到 1,就是快乐数; 若永远变不到 1(陷入一个不含 1 的循环),就不是。判断给定数字是否为快乐数。

思路推导。先想暴力:不断变换,那循环到什么时候停?题面没说步数上限。直觉上变换结果不会无限增大(位数多了平方和会被压回去, 比如 999...9 的平方和远小于自身),所以要么到 1,要么陷入循环。「判断一个过程是否陷入循环」——你在链表专题见过完全同构的问题: 环形链表判断。链表里节点用 next 连接,这里「下一个数」就是 next。

于是两种现成武器都能搬过来:

class Solution {
    // 计算「各位平方和」,相当于链表的 next 指针
    private int next(int n) {
        int sum = 0;
        while (n > 0) {
            int d = n % 10;   // 取出个位
            sum += d * d;     // 累加平方
            n /= 10;          // 去掉个位
        }
        return sum;
    }

    public boolean isHappy(int n) {
        // 快慢指针:slow 一步,fast 两步
        int slow = n, fast = next(n);
        // 只要 fast 还没到 1 且两者没相遇,就继续追
        while (fast != 1 && slow != fast) {
            slow = next(slow);        // 慢指针走一步
            fast = next(next(fast));  // 快指针走两步
        }
        // 循环退出只有两种可能:fast 到了 1(快乐),或 slow==fast 相遇(死循环)
        return fast == 1;
    }
}

复杂度。时间 O(log n) 每次变换,循环长度经验上很短;空间 O(1)(快慢指针版)。

关键点:这不是巧合式套用——任何「状态按固定规则不断转移」的问题(链表、数字变换、数组下标跳转)都可以用快慢指针判环。记住这个「同构迁移」的视角,比记住本题更重要。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 环形链表(力扣 141,简单)——回到源头,确认你已经能脱手写快慢指针判环(提示:相遇后用第三个指针从头走,与慢指针同速,相遇点即入口)。
  • 循环数组是否存在环(力扣 457,中等)——同构迁移的进阶版:下标按 nums[i] 跳转,方向还必须一致(提示:快慢指针 + 额外判断方向不变、环长大于 1)。

15.4计数质数:埃氏筛(中等,力扣 204)

中等 核心:筛法的「排除」视角

题意。统计小于 n 的质数个数。比如 n=10 时,质数有 2、3、5、7,答案是 4。

思路推导。逐个数判断是不是质数:对每个 i 试除到 √i,整体 O(n√n)。n=10⁷ 时约 3×10¹⁰ 次操作——必超时。 优化点在哪?观察:判断 4、6、8 时我们在重复排除 2 的倍数。换个视角——与其「逐个判断谁质数」,不如「一次性排除所有合数」:

从 2 开始,把 2 的所有倍数标记成合数;遇到下一个没被标记的数(3),它必然是质数(因为它的所有更小因子都被筛过了), 再把 3 的倍数全标记……这样每个合数只被它的质因子碰到一次,整体约 O(n log log n)。

还有一个细节:筛 2 的倍数时从 4 开始还是从 2×2=4 开始?从 i*i 开始——小于 i² 的 i 的倍数(如 2i、3i…(i−1)·i)已经被更小的质数筛过了,再筛一遍是重复劳动。

class Solution {
    public int countPrimes(int n) {
        if (n <= 2) return 0;
        // isComposite[i] = true 表示 i 是合数(被筛掉的)
        boolean[] isComposite = new boolean[n];
        int count = 0;
        for (int i = 2; i < n; i++) {
            if (isComposite[i]) continue;  // 已被更小的质数筛掉,跳过
            count++;                       // 没被筛掉 => i 是质数
            // 从 i*i 开始筛 i 的倍数:更小的倍数已被筛过
            // 用 long 防止 i*i 溢出 int
            for (long j = (long) i * i; j < n; j += i) {
                isComposite[(int) j] = true;
            }
        }
        return count;
    }
}

复杂度。时间 O(n log log n)——每个合数被 Σ(1/p) 覆盖,p 取遍质数,这个级数增长极慢;空间 O(n) 的标记数组。

关键点:筛法快在「共享排除工作」——试除法是每个数独立判断,筛法是所有质数的倍数一次性排除。这种「把重复劳动摊平」的思想,和前缀和「一次预处理多次查询」是同一族。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 质数排列(力扣 1175,简单)——筛法算出质数个数后接一个排列计数(提示:质数个数的阶乘 × 合数个数的阶乘)。
  • 二进制表示中质数个计算置位(力扣 762,简单)——把「统计 1 的个数」与「小范围质数判断」组合,数据范围小到可以直接试除(提示:L ≤ 10⁶,试除到 √L 就够)。

15.5阶乘后的零(中等,力扣 172)

中等 核心:把问题翻译成因子计数

题意。给定整数 n,返回 n! 结果末尾 0 的个数。比如 5! = 120,末尾 1 个零。

思路推导。直接算 n! 再数零?n=30 时阶乘就超出 long 范围,不可行。观察末尾零是怎么来的:每个末尾零对应一个因子 10, 而 10 = 2 × 5。所以问题等价于:n! 的质因子分解里,2 和 5 各出现多少次,取较小值。

谁更少?在阶乘里,每隔 2 个数就有个偶数(贡献 2),每隔 5 个数才有 5 的倍数(贡献 5)——2 的因子远多于 5。所以答案 = 5 的因子总数。

那 5 的因子怎么数?n/5 数出「至少含一个 5」的数(5, 10, 15, …);但 25 含两个 5,只数一次不够——再除:n/25 数「至少含两个 5」的, 再 n/125……直到商为 0。每一层都给答案贡献 1(对每个被数到的数)。小例子:n=100 → 100/5=20,100/25=4,100/125=0 → 20+4=24 个零 ✓

class Solution {
    public int trailingZeroes(int n) {
        int ans = 0;
        long p = 5;              // 用 long 防止 p*5 溢出
        while (p <= n) {
            ans += n / p;        // 「至少含 p 一个因子 5」的数的个数
            p *= 5;              // 下一轮数 25、125……每个这样的数额外贡献一个 5
        }
        return ans;
    }
}

复杂度。时间 O(log₅ n),循环次数约 13 次就到 10⁹ 以上;空间 O(1)。

别掉进「模拟阶乘」的坑。这道题的面试价值在于:题面诱导你算出 n! 再数零(不可行),而正确路径是把「数零」翻译成「数因子」。看到「巨大数字的性质」类题,第一反应应该是质因子分解,而不是硬算。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 阶乘后的 K 个零(力扣 793,困难)——本题反过来:给零的个数问有几个 n;用同样的因子计数 + 二分(提示:答案要么 0 要么 5 个连续,因为零的个数在 5 的倍数处跳变)。
  • 约数个数(力扣 1390 不存在,改练 507 完美数,简单)——同属「因子视角」:枚举到 √n 成对收集约数(提示:i 和 n/i 一起收)。

15.6螺旋矩阵:模拟题怎么写(中等,力扣 54)

中等 核心:边界收缩的四指针法

题意。给定 m×n 矩阵,按顺时针螺旋顺序返回所有元素。比如:

输入:            螺旋顺序:
 1  2  3        1 -> 2 -> 3 -> 6 -> 9 -> 8 -> 7 -> 4 -> 5
 4  5  6
 7  8  9

思路推导。这是模拟题的标准样本。按题意直译:「顺时针转圈」= 依次走上边(左→右)、右边(上→下)、下边(右→左)、左边(下→上), 每走完一条边就把对应边界往里收缩一格。用四个变量维护当前未走区域的边界:top / bottom / left / right。

每条边走之前先判断边界还有没有剩余(防止在只剩一行/一列时重复走),这是本题唯一的坑点:

class Solution {
    public List<Integer> spiralOrder(int[][] matrix) {
        List<Integer> ans = new ArrayList<>();
        int top = 0, bottom = matrix.length - 1;
        int left = 0, right = matrix[0].length - 1;

        while (top <= bottom && left <= right) {
            for (int j = left; j <= right; j++) ans.add(matrix[top][j]);    // 上边:左 -> 右
            top++;                                                          // 上边界收缩
            for (int i = top; i <= bottom; i++) ans.add(matrix[i][right]);  // 右边:上 -> 下
            right--;                                                        // 右边界收缩

            // 剩余不止一行时才走下边,否则 top 收缩后可能越界重复
            if (top <= bottom) {
                for (int j = right; j >= left; j--) ans.add(matrix[bottom][j]); // 下边:右 -> 左
                bottom--;
            }
            // 剩余不止一列时才走左边
            if (left <= right) {
                for (int i = bottom; i >= top; i--) ans.add(matrix[i][left]);   // 左边:下 -> 上
                left++;
            }
        }
        return ans;
    }
}

复杂度。时间 O(m·n),每个元素恰好访问一次;空间 O(1)(不算返回列表)。

关键点:两个 if 是全部难点。走完上、右两条边后 top 和 right 已收缩,若此时只剩一行(top == bottom),下边会从右往左把刚走过的又加一遍。用「走之前重新判断边界」这个习惯,可以防御几乎所有模拟题的重复访问 bug。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 螺旋矩阵 II(力扣 59,中等)——同一套四边界框架,只是从「读出」变成「按序填入 1~n²」(提示:一个计数器代替读取)。
  • 对角线遍历(力扣 498,中等)——另一版走格子模拟;方向变量 + 越界反弹(提示:用 (dir==up) 切换,碰边时修正 i/j)。

15.7本专题常见坑

  • 整型溢出:i * i、p * 5 这类乘法,操作数稍大(i 到 46341 以上)就会溢出 int 且不报错、结果变负数——先转 long 再乘:long j = (long) i * i。
  • 模拟题边界收缩顺序:收缩边界(top++)与走下一条边(for 循环)的先后写错,就在只剩一行/一列时重复访问。习惯「每条边走之前重新判断边界」。
  • 埃氏筛从 2*i 开始:能过题但白做一倍功;从 i*i 开始才是标准写法。同理,内层循环下标用 long 存,n 取到 10⁷ 时 i*i 会溢出。
  • 数学题硬模拟:172 这类题的信号词是「结果可能非常大 / 只问性质不问值」——先把问题翻译成计数,别上来就算大数。
  • 字符与数字混用:'5' - '0' 才是 5;直接 (int) '5' 得到的是 ASCII 53。

15.8自测题

Q1. 埃氏筛为什么内层循环从 i*i 开始,而不是从 2*i 开始?

小于 i² 的 i 的倍数形如 k·i(k < i),它必然有一个小于 i 的质因子,所以早已被那个质数筛掉。再筛一遍是重复劳动。从 i*i 开始筛是「第一次可能出现未被标记的 i 的倍数」的位置。

Q2. 快乐数里为什么快慢指针一定会相遇(或 fast 到 1)?

因为变换的值域有限(位数多的数平方和会收缩,最终在有限范围内循环),转移是确定的,所以序列最终进入环。环上快指针每轮比慢指针多走一步,距离差每轮减 1,必然追上。这与链表判环的数学完全一致。

Q3. n! 末尾零的个数为什么只数因子 5,不用数 2?

每个末尾零 = 一个因子 10 = 一个 2 配一个 5。阶乘里 2 的因子个数远多于 5(每两个连续数就有一个偶数,每五个数才有一个 5 的倍数),短板是 5。答案由较少的那个决定,所以只数 5。

Q4.【动手题】不开数组,判断 1~100 里哪些数是快乐数,只打印结果。

对每个数跑快慢指针判环(复用 15.3 的 next 与 isHappy 逻辑),空间 O(1)。写完对照:1, 7, 10, 13, 19, 23, 28, 31, 32, 44… 是快乐数。
学完这章,你应该能回答:「拿到一个没有模板的题,第一步做什么?」 答案:先判断它是观察题(拿小例子手动算几组找规律,如罗马数字、阶乘零)还是模拟题(列出状态变量与四条边界,按题意直译); 数学题警惕「硬算大数」的诱导,模拟题靠「每条边走之前重新判边界」防重复;整型乘法先转 long。