LeetCode数学算法全解:从数论到数值计算,C++实现与面试技巧
2026/7/25 6:18:44 网站建设 项目流程

1. 项目概述:为什么LeetCode数学题是算法面试的“定海神针”?

如果你刷过一段时间LeetCode,可能会发现一个现象:那些纯靠数据结构“硬解”的题目,比如复杂的图论或者动态规划,面试官有时会放你一马,毕竟场景复杂。但一旦遇到数学相关的算法题,比如让你判断一个数是不是快乐数,或者计算x的平方根,如果你思路卡壳或者代码写得拖泥带水,面试官皱起的眉头往往会更深。这不是错觉,数学算法题在面试中扮演着“基本功试金石”的角色。它不追求你掌握多么冷门的数据结构,而是直指核心——考察你是否具备将数学逻辑转化为清晰、高效代码的能力,以及你的思维严谨性。

这份“LeetCode数学算法技巧全解”的初衷,就是帮你系统性地攻克这个关键领域。我见过太多朋友在“两数之和”上栽跟头,不是因为他们不懂哈希表,而是没转过“用哈希表来模拟数学互补关系”这个弯。也有朋友被“阶乘后的零”搞得晕头转向,其实背后就是一个“数论5的因子”的简单事实。本系列将聚焦LeetCode上经典的数学算法题,用C++逐一拆解实现,并持续更新。我的目标不是罗列答案,而是带你穿透题目描述,直抵背后的数学原理,然后手把手教你写出既优雅又高效的C++代码。无论你是正在准备面试的求职者,还是希望夯实算法基础的开发者,相信这些从实战中提炼的技巧和“踩坑”心得,都能让你在遇到下一个数学问题时,心中更有底气。

2. 数学算法核心思想与C++实现范式

在动手刷题之前,我们先建立两个核心认知:数学算法题的本质是什么?以及用C++实现时,有哪些必须遵循的“最佳实践”?这能让你从“凭感觉写”上升到“有策略地设计”。

2.1 化繁为简:数学建模是解题的第一把钥匙

LeetCode上的数学题,很少直接让你推导公式。它通常是将一个生活化或抽象的问题,包装成一个需要计算或判断的程序任务。解题的第一步,也是最关键的一步,就是数学建模——将问题描述转化为一个或一组数学关系。

举个例子,LeetCode 202题“快乐数”。题目描述是:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程,如果最终可以变为1,则是快乐数。如果陷入一个不包含1的循环,则不是。刚看可能有点绕。但如果你把它建模成“检测链表环”的数学问题,思路就打开了。我们把每一次计算得到的数字看作链表的一个节点,下一次计算的结果就是它的next指针。那么,问题就等价于:在一个由“数字转换函数”生成的隐式链表中,判断是否存在一个值为1的节点,或者链表是否存在环。这就是一个经典的“快慢指针”判环问题。你看,通过数学建模,一个看似新颖的问题,瞬间链接到了你已有的数据结构知识上。

在C++实现时,这个建模过程直接决定了你的函数签名和核心逻辑。对于“快乐数”,我们首先需要实现那个核心的“数字平方和”计算函数,然后才是快慢指针的遍历逻辑。记住,先想清楚数学关系,再动手写代码,能避免大量的无效调试。

2.2 C++实现数学算法的四项基本原则

用C++解数学题,有几点需要特别注意,这关乎代码的鲁棒性和效率。

原则一:警惕整数溢出这是数学题最高发的“坑”。比如LeetCode 7题“整数反转”,题目假设环境只能存储32位有符号整数。如果你在反转过程中,直接用int类型累加计算,很可能在反转过程中,中间结果就已经超出了int的范围,导致溢出,结果是未定义的。正确的做法是在计算下一次结果前,预判是否会导致溢出。通常我们会使用long long类型作为中间计算载体,或者在int范围内通过比较INT_MAX/10和当前值来进行预判。

// 以整数反转为例,安全的做法 int reverse(int x) { int rev = 0; while (x != 0) { int pop = x % 10; x /= 10; // 预判正数溢出:rev > INT_MAX/10 或 (rev == INT_MAX/10 && pop > 7) // 预判负数溢出:rev < INT_MIN/10 或 (rev == INT_MIN/10 && pop < -8) if (rev > INT_MAX/10 || (rev == INT_MAX/10 && pop > 7)) return 0; if (rev < INT_MIN/10 || (rev == INT_MIN/10 && pop < -8)) return 0; rev = rev * 10 + pop; } return rev; }

原则二:善用标准库和语言特性C++标准库提供了强大的数学函数()和算法(),不要重复造轮子。计算绝对值用abs()(注意对于intabs中;对于浮点数,用`fabs`在中),计算最大值最小值用std::max/min,进行泛型操作时std::accumulate可能比手写循环更清晰。C++11后的constexpr如果能在编译期计算数学常量(如π),也能提升性能。

原则三:注意浮点数比较的精度问题数学题中有时会涉及浮点数,比如计算平方根、幂运算。由于浮点数在计算机中的表示存在精度限制,直接使用==进行比较是危险的。正确的做法是定义一个极小的误差范围epsilon(如1e-9),然后判断两数之差的绝对值是否小于这个误差。

bool isEqual(double a, double b) { return fabs(a - b) < 1e-9; // 使用 <cmath> 中的 fabs }

原则四:选择合适的数据类型除了防止溢出,数据类型也影响表达意图。表示集合、检查存在性,用std::unordered_set(哈希集合)通常比std::set(红黑树)更快。进行位运算相关的数学操作(如“只出现一次的数字”系列),要熟练使用int的位操作。当需要高精度整数时(如“字符串相乘”大数运算),则直接用std::stringvector<int>来模拟。

3. 数论基础题精讲与C++实战

数论是数学算法中最常见的考点之一,主要围绕整数的性质展开。下面我们通过几道经典题目,来深入理解如何用C++实现数论算法。

3.1 质数判定与计数:从暴力到高效筛法

LeetCode 204题“计数质数”是数论的入门经典。要求统计所有小于非负整数 n 的质数的数量。

思路演进与C++实现:

  1. 暴力法(不可取):对每个数i,尝试用2到sqrt(i)之间的数去除。时间复杂度O(n√n),在n较大时必然超时。
  2. 埃拉托斯特尼筛法(埃氏筛):这是必须掌握的高效算法。其核心思想是:如果i是质数,那么i的所有倍数(ii, ii+i, ...)都不是质数。我们可以用一个布尔数组isPrime来标记。
    int countPrimes(int n) { if (n <= 2) return 0; vector<bool> isPrime(n, true); // 初始假设所有数都是质数 isPrime[0] = isPrime[1] = false; // 0和1不是质数 // 只需遍历到 sqrt(n)。因为如果 n 有一个大于 sqrt(n) 的因子,必然对应一个小于 sqrt(n)的因子。 for (int i = 2; i * i < n; ++i) { if (isPrime[i]) { // 如果i是质数,筛掉它的倍数 // 从 i*i 开始筛,因为 2*i, 3*i, ..., (i-1)*i 已经被之前的质数筛过了 for (int j = i * i; j < n; j += i) { isPrime[j] = false; } } } // 统计标记为 true 的个数 return count(isPrime.begin(), isPrime.end(), true); }
    时间复杂度:经过数学分析,埃氏筛的时间复杂度约为O(n log log n),空间复杂度O(n)。这已经能通过本题。
  3. 线性筛(欧拉筛):埃氏筛的一个小缺点是,有些合数会被多个质数重复标记(例如6会被2和3都标记)。线性筛通过“每个合数只被其最小质因子筛掉”的规则,实现了严格的O(n)时间复杂度。虽然代码稍复杂,但在对性能要求极致的场景下值得了解。

实操心得

  • 在埃氏筛中,内层循环的起始点j = i * i是一个重要优化。务必理解为什么可以从这里开始,而不是2*i
  • vector<bool>在空间上可能经过特殊优化(位存储),但访问效率可能略低于vector<char>。在普通算法题中,用vector<bool>没问题。

3.2 最大公约数与最小公倍数:欧几里得算法的妙用

最大公约数(GCD)和最小公倍数(LCM)是另一组核心概念。LeetCode 914题“卡牌分组”就用到了GCD。

辗转相除法(欧几里得算法): 这是计算两个正整数a和b的最大公约数的最经典方法。其原理基于一个关键等式:gcd(a, b) = gcd(b, a mod b)。当余数为0时,除数即为最大公约数。 C++实现极其简洁:

// 递归版本 int gcd_recursive(int a, int b) { return b == 0 ? a : gcd_recursive(b, a % b); } // 迭代版本(更推荐,避免递归栈开销) int gcd(int a, int b) { while (b != 0) { int temp = a % b; a = b; b = temp; } return a; }

最小公倍数可以通过最大公约数快速求得:lcm(a, b) = a * b / gcd(a, b)。但要注意先做除法再乘法,防止a*b可能溢出。可以写成a / gcd(a, b) * b

实战应用(LeetCode 914): 题目大意:给定一副牌,每张牌上有一个整数。你需要选定一个数字 X(X >= 2),将整副牌分成若干组,每组都有 X 张牌,并且每组内的牌数字都相同。判断是否可行。建模与求解

  1. 统计每个数字出现的频率。
  2. 问题转化为:判断所有频率是否有一个大于1的公因数X。因为如果X=2,就是每组2张;X=3就是每组3张。
  3. 因此,我们只需要计算所有频率的最大公约数。如果这个GCD大于等于2,就可行。
bool hasGroupsSizeX(vector<int>& deck) { unordered_map<int, int> countMap; for (int card : deck) { countMap[card]++; } int g = -1; for (const auto& pair : countMap) { if (g == -1) { g = pair.second; } else { g = gcd(g, pair.second); } } return g >= 2; }

3.3 进制转换与位运算:计算机的“母语”

进制转换和位运算本质上是相通的,都是对数字底层表示的操纵。

进制转换(LeetCode 504. 七进制数): 给定一个整数,将其转化为7进制,并以字符串形式输出。对于负数,我们通常先处理符号,对绝对值进行转换。

string convertToBase7(int num) { if (num == 0) return "0"; bool isNegative = num < 0; long long n = abs(num); // 防止负数取余的麻烦 string res; while (n > 0) { res.push_back((n % 7) + '0'); // 取得当前最低位,转为字符 n /= 7; // 去掉已处理的最低位 } if (isNegative) { res.push_back('-'); } reverse(res.begin(), res.end()); // 余数是从低位到高位得到的,需要反转 return res; }

核心要点:“除基取余,逆序排列”。这个方法适用于任何进制转换。注意处理0和负数的情况。

位运算的经典应用

  1. 判断奇偶n & 1。结果为1是奇数,0是偶数。比n % 2更快。
  2. 获取最低位的1n & (-n)。这在树状数组等数据结构中常用。
  3. 消去最低位的1n & (n - 1)。这个操作太有用了!LeetCode 191“位1的个数”和231“2的幂”都靠它。
    • 计算汉明权重(位1的个数):
      int hammingWeight(uint32_t n) { int count = 0; while (n) { n &= (n - 1); // 每次操作消去二进制表示中最低位的一个1 count++; } return count; }
    • 判断是否为2的幂:2的幂的二进制表示中只有一个1。所以n > 0 && (n & (n - 1)) == 0
  4. 异或(XOR)的魔法:异或运算满足交换律、结合律,且a ^ a = 0,a ^ 0 = a。LeetCode 136“只出现一次的数字”(其他数字出现两次)直接全部异或即可。268“丢失的数字”也可以用异或巧妙解决。

4. 数值计算类算法:逼近与迭代的艺术

这类问题要求我们实现一些常见的数学函数,如平方根、幂运算,通常不允许直接调用库函数。其核心思想是迭代逼近

4.1 平方根计算:二分法与牛顿迭代法

LeetCode 69题“x的平方根”,要求实现int sqrt(int x),只保留整数部分。

方法一:二分查找因为平方根函数是单调递增的,我们可以在[0, x](实际上[0, x/2+1]更优)这个有序区间内进行二分查找。

int mySqrt(int x) { if (x <= 1) return x; // 处理0和1 int left = 1, right = x / 2; // 对于x>=2,其平方根不会超过x/2 int ans = 0; while (left <= right) { int mid = left + (right - left) / 2; // 防止溢出 long long square = (long long)mid * mid; // 注意用long long防溢出 if (square == x) { return mid; } else if (square < x) { ans = mid; // mid可能是答案,先记录下来 left = mid + 1; } else { right = mid - 1; } } return ans; }

要点:循环条件是left <= right,在square < x时更新ans,因为我们要找的是最后一个满足mid*mid <= xmid。注意中间计算的溢出问题。

方法二:牛顿迭代法这是一种更高效、更数学化的方法。目标是求f(r) = r^2 - x = 0的根。牛顿迭代公式为:r_{n+1} = r_n - f(r_n)/f'(r_n) = r_n - (r_n^2 - x)/(2*r_n) = (r_n + x/r_n) / 2

int mySqrt(int x) { if (x == 0) return 0; long r = x; // 初始猜测值,选x本身就可以 while (r * r > x) { // 当r的平方大于x时,继续迭代 r = (r + x / r) / 2; } return (int)r; }

牛顿迭代法收敛速度非常快,通常几次迭代就能得到非常精确的结果。代码比二分法更简洁。注意:这里使用long类型是为了防止r * r溢出,并且迭代终止条件是r * r <= x,因为我们要求整数部分。

4.2 幂运算:快速幂算法

LeetCode 50题“Pow(x, n)”要求实现pow(x, n),即计算x的n次幂。最笨的方法是连乘n次,时间复杂度O(n)。而快速幂算法可以优化到O(log n)。

快速幂的核心思想是分治x^n = x^(n/2) * x^(n/2)(如果n是偶数),x^n = x^(n/2) * x^(n/2) * x(如果n是奇数)。我们可以递归或迭代地计算。

递归实现(直观)

double myPow(double x, int n) { long long N = n; // 防止n=-2147483648取负号时溢出 if (N < 0) { x = 1 / x; N = -N; } return fastPow(x, N); } double fastPow(double x, long long n) { if (n == 0) return 1.0; double half = fastPow(x, n / 2); if (n % 2 == 0) { return half * half; } else { return half * half * x; } }

迭代实现(更高效,推荐): 迭代法的思路基于二进制。例如计算x^13,13的二进制是1101,即13 = 8+4+1。那么x^13 = x^8 * x^4 * x^1。我们在循环中,如果n的当前二进制位为1,就将对应的x的幂乘到结果中。

double myPow(double x, int n) { long long N = n; if (N < 0) { x = 1 / x; N = -N; } double result = 1.0; double current_product = x; for (long long i = N; i > 0; i /= 2) { if (i % 2 == 1) { // 如果当前二进制位是1 result *= current_product; } current_product *= current_product; // x -> x^2 -> x^4 -> x^8... } return result; }

注意事项

  • 必须处理指数n为负数的情况。
  • 特别注意n = -2147483648(即INT_MIN)的情况,直接取负号会溢出,所以先转为long long
  • 迭代法比递归法省去了函数调用开销,且思路巧妙,是必须掌握的写法。

5. 组合数学与概率问题建模

这类问题通常不是直接计算,而是需要你发现题目背后的组合数学模型,或者用模拟(如随机抽样)来逼近概率。

5.1 排列组合计算:阶乘、乘法原理与避免溢出

LeetCode 62题“不同路径”是一个经典的组合问题:机器人从m×n网格的左上角走到右下角,每次只能向右或向下,问有多少条不同路径。

建模:机器人一共需要走(m-1) + (n-1) = m+n-2步,其中向右走n-1步,向下走m-1步。路径总数就等于从m+n-2步中,选择m-1步(或n-1步)向下(或向右)走的方案数,即组合数C(m+n-2, m-1)

直接计算组合数公式C(n, k) = n! / (k! * (n-k)!)。但直接计算阶乘极易溢出,即使使用long long20!就已经超出了其范围。

优化计算:我们可以利用组合数的递推关系或简化计算过程来避免溢出。一种常见的方法是边乘边除:C(n, k) = n*(n-1)*...*(n-k+1) / (1*2*...*k)计算时,从1到k遍历i,每次计算result = result * (n - k + i) / i。由于每一步除法都是整除(组合数一定是整数),所以可以保证中间结果始终是整数且不会太大。

int uniquePaths(int m, int n) { // 计算 C(m+n-2, min(m-1, n-1)) 计算量更小 int N = m + n - 2; int K = min(m - 1, n - 1); long long result = 1; // 用long long防止中间乘法溢出 for (int i = 1; i <= K; ++i) { result = result * (N - K + i) / i; } return (int)result; }

5.2 随机抽样与拒绝采样:等概率生成的技巧

LeetCode 470题“用Rand7()实现Rand10()”是概率抽样问题的代表。你有一个可以生成1到7均匀随机整数的函数rand7(),要求用它实现一个生成1到10均匀随机整数的函数rand10()

核心思想:拒绝采样

  1. rand7()可以生成7个数,概率各1/7。一次调用不够。
  2. 调用两次rand7(),可以看作生成一个7进制的两位数,取值范围是[1, 49](即(rand7()-1)*7 + rand7()),并且这49个数每个出现的概率都是1/49,是均匀的。
  3. 我们只需要前40个数(1-40)来映射到1-10(每个数对应(num-1)%10+1即可)。如果得到的数在41-49之间,就拒绝这次采样,重新生成。这样,我们保证了1-10每个数字的生成概率都是4/49,严格相等。
// 预先声明的 rand7() API // int rand7(); // @return 一个在 [1,7] 范围内的随机整数 int rand10() { int num; do { num = (rand7() - 1) * 7 + rand7(); // 生成1-49的均匀随机数 } while (num > 40); // 拒绝41-49,直到落在1-40内 return (num - 1) % 10 + 1; // 将1-40均匀映射到1-10 }

为什么是40?因为40是小于49且能被10整除的最大整数。这样可以保证映射后每个数字的概率严格相等,且拒绝采样的概率(9/49)相对较小,效率可以接受。

优化思路:被拒绝的9个数(41-49)其实还包含信息。可以将其减去40,得到1-9,这相当于一个rand9()。再调用一次rand7(),可以组合出1-63的数,再取前60个……如此可以进一步提高采样利用率,减少调用rand7()的期望次数。但初次理解时,掌握基础的拒绝采样方法就足够了。

6. 几何与模拟类问题:将规则转化为代码

有些数学题源于几何或简单的模拟规则,关键在于如何将文字描述精确地翻译成代码逻辑。

6.1 直线与点:斜率与最大公约数的表示

LeetCode 149题“直线上最多的点数”是几何类的一个难题。给定二维平面上的一些点,求最多有多少个点在同一条直线上。

难点:浮点数斜率可能存在精度问题。例如,斜率k = (y2-y1)/(x2-x1),用double存储比较时,可能因为精度导致误判。

解决方案:用最简分数表示斜率核心思路是,不直接计算浮点数斜率,而是用一组“标准化”的整数对(Δx, Δy)来表示方向向量。为了唯一表示,我们需要将ΔxΔy约分到最简形式(即除以它们的最大公约数),并统一符号(例如保证Δx非负,如果Δx为0则保证Δy为正)。 这样,只要方向向量相同,点就在同一条直线上。此外,还需要考虑重复点和垂直线(Δx=0)的情况。

int maxPoints(vector<vector<int>>& points) { int n = points.size(); if (n <= 2) return n; // 两点或一点必然共线 int maxCount = 0; for (int i = 0; i < n; ++i) { // 以points[i]为基准点 unordered_map<string, int> slopeMap; // 用字符串编码的斜率作为key int duplicate = 1; // 记录与i点重合的点的数量(包括自己) int currentMax = 0; for (int j = i + 1; j < n; ++j) { int dx = points[j][0] - points[i][0]; int dy = points[j][1] - points[i][1]; if (dx == 0 && dy == 0) { duplicate++; continue; } // 计算dx, dy的最大公约数,并约分 int g = gcd(dx, dy); dx /= g; dy /= g; // 标准化,保证唯一性:例如让dx非负,如果dx为0则让dy为正 if (dx < 0 || (dx == 0 && dy < 0)) { dx = -dx; dy = -dy; } string key = to_string(dx) + "_" + to_string(dy); slopeMap[key]++; currentMax = max(currentMax, slopeMap[key]); } // 最终以i为起点的直线上点数 = 相同斜率的最大点数 + 重复点 maxCount = max(maxCount, currentMax + duplicate); } return maxCount; } // 需要实现或使用标准库的gcd函数(C++17起在<numeric>中)

关键点:使用unordered_map来统计以点i为起点时,各个“最简化方向向量”出现的次数。内层循环从i+1开始,避免重复计算。时间复杂度是O(n^2),因为要枚举所有点对。

6.2 模拟类问题:遵循规则,逐步推进

LeetCode 258题“各位相加”是一个简单的模拟题:给定一个非负整数,反复将它的各位数字相加,直到结果为一位数。例如:38 -> 3+8=11 -> 1+1=2。

模拟法:完全按照题目描述,写一个循环,直到数字小于10。

int addDigits(int num) { while (num >= 10) { int sum = 0; while (num > 0) { sum += num % 10; num /= 10; } num = sum; } return num; }

数学法(数根):这个问题其实有数学规律,结果就是数根。有一个公式:dr(n) = 0 if n == 0; dr(n) = 9 if n % 9 == 0; dr(n) = n % 9 otherwise.可以用一行代码解决:

int addDigits(int num) { return 1 + (num - 1) % 9; } // 注意:这个公式对0需要特殊处理,但题目说非负整数,且0的结果是0。 // 更严谨的写法: if (num == 0) return 0; return num % 9 == 0 ? 9 : num % 9;

在面试中,如果你能先给出模拟法,再指出其数学本质和公式,会是很大的加分项。它展示了你的思维深度。

7. 常见“陷阱”与调试技巧实录

即便理解了算法,实现时也常常会遇到各种边界条件和意想不到的“坑”。这里分享几个高频的陷阱和调试方法。

7.1 整数溢出的花式“埋雷”

溢出问题在数学题中防不胜防,除了之前提到的反转整数,还有:

  • 中间计算溢出:比如计算组合数C(n, k)时,即使结果在int范围内,n!的中间值也可能溢出。必须用边乘边除的方法。
  • 乘法溢出mid * mida * b在二分查找、判断条件中非常常见。解决方法是使用long long或进行预判。
    // 错误: if (mid * mid > x) ... // mid较大时溢出 // 正确: if ((long long)mid * mid > x) ... // 或: if (mid > x / mid) ... // 利用除法,但要注意mid为0的情况
  • 负数取模:在C++和Java中,-3 % 2的结果是-1,而不是1。这在处理涉及负数的循环或计算时可能导致错误。通常的做法是先对负数取绝对值处理,最后再考虑符号。

7.2 浮点数比较的“精度刺客”

判断两个浮点数ab是否相等,绝对不要用a == b。要使用一个极小的误差epsilon

bool isEqual(double a, double b) { const double eps = 1e-9; return fabs(a - b) < eps; }

在涉及浮点数二分查找时,循环条件通常写成while (right - left > eps),而不是比较中点。

7.3 边界条件与特殊输入

这是面试官最喜欢考察的地方,也是代码鲁棒性的体现。

  • 零和负数:计算平方根、除法、取模时,0和负数往往是特殊用例。比如mySqrt(0),myPow(2, -2147483648)
  • 最小值INT_MIN的绝对值比INT_MAX大1,直接取负会溢出。如前文所述,先转为long long
  • 空输入和单元素输入:虽然数学题较少,但在处理数组或容器时(如计算众数、GCD等),要检查输入是否为空或只有一个元素。

7.4 调试与验证技巧

  1. 小数据测试:不要一上来就用大数据。用几个典型的、边界的小例子手动模拟你的算法,看输出是否符合预期。例如,测试mySqrt(4),mySqrt(8),mySqrt(0),mySqrt(1)
  2. 打印中间变量:在循环或递归的关键步骤,打印出关键变量(如left,right,mid,result等),观察其变化是否符合逻辑。
  3. 对比暴力法:对于优化算法(如快速幂),可以同时写一个朴素的for循环版本,用随机数据对比结果,确保优化算法的正确性。
  4. 使用单元测试框架:如果环境允许,可以简单写几个测试用例。例如:
    assert(mySqrt(4) == 2); assert(mySqrt(8) == 2); // 因为2*2=4, 3*3=9>8 assert(myPow(2.0, 10) == 1024.0); assert(myPow(2.0, -2) == 0.25);

8. 进阶挑战与综合应用

掌握了基础题型后,可以尝试一些综合性强或需要巧妙数学转换的题目,锻炼将复杂问题分解、建模的能力。

8.1 随机数生成与概率分布

除了之前的Rand10(),还有更一般的“随机权重抽样”问题,如LeetCode 528“按权重随机选择”。给定一个正整数数组w,其中w[i]代表下标i的权重,要求实现一个函数,随机地返回下标i,返回下标i的概率与w[i]成正比。

解决方案:前缀和 + 二分查找

  1. 计算权重的前缀和数组prefixprefix[i]表示前i个元素的权重和(通常prefix[0]=0prefix[1]=w[0])。
  2. 生成一个[1, totalWeight]范围内的随机数target
  3. 在前缀和数组中找到第一个大于等于target的元素下标。因为前缀和是单调递增的,所以可以用二分查找。
class Solution { private: vector<int> prefixSums; int totalSum; public: Solution(vector<int>& w) { prefixSums.resize(w.size()); prefixSums[0] = w[0]; for (int i = 1; i < w.size(); ++i) { prefixSums[i] = prefixSums[i-1] + w[i]; } totalSum = prefixSums.back(); } int pickIndex() { // 生成 [1, totalSum] 的随机数 int target = rand() % totalSum + 1; // 注意:rand()质量不高,实际可用<random> // 二分查找第一个 >= target 的位置 int left = 0, right = prefixSums.size() - 1; while (left < right) { int mid = left + (right - left) / 2; if (prefixSums[mid] < target) { left = mid + 1; } else { right = mid; } } return left; } };

要点prefixSums数组是单调递增的,所以可以用二分查找将每次pick的时间复杂度降到O(log n)。初始化构造函数的时间复杂度是O(n)。

8.2 数位操作与状态模拟

LeetCode 292题“Nim游戏”是一个简单的博弈论问题,但背后是数学归纳的思想。你和朋友轮流拿石头,每次可以拿1-3块,拿掉最后一块石头的人获胜。给定石头总数n,判断你是否能赢(假设你们都发挥最佳水平)。

分析:如果场上剩下1-3块石头,你先手,全拿走就赢。如果剩下4块,你怎么拿(1,2,3),对方都能把剩下的全拿走,你必输。如果剩下5块,你拿1块,让对方面对4块(必输局面),你就赢了。以此类推,只要n不是4的倍数,先手必胜。因为先手总可以拿走n % 4块石头,让对方面对4的倍数这个必败局面。

bool canWinNim(int n) { return n % 4 != 0; }

这类问题关键在于识别出“必胜态”和“必败态”,并找到其规律。它考察的是逻辑推理和归纳能力,代码反而简单。

8.3 当数学遇上其他数据结构

很多题目是数学思想和数据结构的结合。例如,利用“异或”性质找单个数字(哈希集合也能做,但空间复杂度高);利用“摩尔投票法”找众数(本质是抵消计数);利用“等差数列求和公式”找缺失的数字(可以用哈希表,也可以用数学公式sum = n*(n+1)/2)。

心得:刷题时,多问自己一句“这个问题有没有更数学化的看法?”。比如“两数之和”用哈希表是O(n)空间,但如果数组有序,可以用双指针,这背后是“有序区间内利用大小关系逼近目标”的数学思想。养成这种思维习惯,能让你在面试中脱颖而出。

数学算法题就像一把锋利的解剖刀,它剥离了复杂业务的外衣,直指计算效率和逻辑严谨性的核心。我自己的体会是,刷这类题目时,切忌死记硬背答案。一定要把原理推导边界处理这两个环节吃透。每次遇到溢出、精度、特殊输入导致的错误,都是加深对计算机数字系统理解的宝贵机会。把每一道题都当作一个微型项目,从建模、选算法、写代码、测边界到优化,走完完整流程,你的内功才会扎实。最后,别忘了在真实的编程环境(如VS Code或CLion)里亲手敲一遍代码,编译器给出的警告和错误信息,是最好的老师之一。

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

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

立即咨询