“47.最大值 48.数列1”这个标题,第一眼看上去像是某个课程讲义里的两行目录,或者是一套数学题集里连续的两道题。但我敢说,这其实是整个初等数学到算法思维过渡阶段最值得认真拆解的两个专题。最大值是优化意识的起点,数列是递推思想的源头。把这两个专题放在一起学,是我见过最高效的打基础方式,因为它们几乎覆盖了从“观察规律”到“建立模型”再到“设计求解策略”的完整思维链路。
这篇内容我打算换个讲法:不把“最大值”和“数列”当成两个孤立的数学知识点,而是把它们并在一起,从数学原理一路聊到编程落地。无论你是备考阶段的学生、带竞赛队的老师,还是刚接触算法的程序员,都能从这里拿到可以直接用的思路、模板和避坑清单。
1. 为什么要把“最大值”和“数列1”放到一起学
这两个标题摆在一起,最初看起来有点跳跃,但我做了多年题目研究和算法训练之后发现,它们的内在联系比表面看上去要紧密得多。可以说,“最大值”代表的是静态问题里的极端分析,而“数列1”代表的则是动态问题里的规律推演,两者正好互补。
1.1 最大值与数列之间的底层关联
先说“最大值”。最大值问题本质上是研究一个集合或一个函数在给定范围内的“极端表现”。比如在一组数里找最大的那个,求二次函数在某个区间上的最大值,或者在约束条件下求目标函数的最大取值。这些问题看起来五花八门,但核心逻辑是一致的:先界定范围,再比较候选,最后确认边界。序列、区间、约束条件,这些关键词天然就和数列绑定在一起。
再说“数列1”。数列就是按顺序排列的一列数,但排列只是表象,规律才是核心。等差、等比、递推、周期,这些都是规律的具体形式。当你面对一个数列问题时,第一步永远是找相邻项之间的关系,这个关系一旦确定,后续所有计算都有了抓手。
两件事的连接点就在这里:数列本身就是一个“取值集合”,数列的最大项、前n项和的最大值、单调性与最值的关系,这些内容直接把数列和最大值焊在了一起。比如讨论一个等差数列的前n项和何时最大,你必须先理解通项公式、求和公式,再结合函数单调性和二次函数的最值。两个专题的知识在这一刻完全打通。
1.2 这套内容到底解决什么问题
对于学习者来说,这个组合最直接的价值是:让你建立起“式子是死的,方法是活的”这种观念。只会背公式,遇到“求数列{|an|}前n项和的最值”这种综合题就会卡壳;只会套模板,遇到“滑动窗口最大值”这种算法题也会无从下手。
这套内容适合三类人:
- 正在系统学习函数与数列的高中生,需要吃透最值和递推背后的共性逻辑;
- 准备算法竞赛或大厂笔试的程序员,需要把数学建模能力和代码实现能力对齐;
- 任何对“怎么把一个看似模糊的问题拆成清晰步骤”感兴趣的人,因为这两个专题就是最经典的思维训练素材。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最大值问题:一个极端求法背后的完整逻辑链
“最大值”这个标题看起来是在讲一个结论,但真正要理解的是一个完整的分析框架。我见过太多人一看到“最大值”就往导数或二次函数上套,结果题目一变就懵。下面我把这个框架从数学到算法逐层拆开。
2.1 数学视角下的最值求解
在纯数学场景里,求最大值有几种经典工具。第一是二次函数配方法,比如给定 f(x) = ax² + bx + c,通过配方变成 f(x) = a(x + b/2a)² + (4ac - b²)/4a,开口向上有最小值,开口向下有最大值。这个方法的本质是把“到顶点距离”显式化,最关键的判断点是区间端点与对称轴的相对位置。
第二是均值不等式法,适用于形如“和为定值求积最大”的问题。经典结论是:若 x + y = S,则 xy ≤ (S/2)²,当且仅当 x = y 时取等号。这个工具在数列最值题里特别常用,比如已知等比数列的若干项之和,要求某项乘积的最大值。
第三是导数法。导数为零的点是极值候选点,但极值不等于最值,必须把极值点和区间端点放在一起比较。很多初学者容易漏掉这一步,我在后面的问题排查部分会专门说。
这些工具背后有一个共通的决策逻辑:不要一上来就算,先判断研究对象是离散的还是连续的。离散情形(比如数列中的最大项)考虑作差、作商判断单调性;连续情形(比如函数在区间上的最值)考虑导数与端点。选错方向是失分的主要原因。
2.2 算法视角下的最值求解
把问题搬到计算机里,情况会复杂一些,因为机器不会“一眼看出”最大值,只能通过比较操作来逐步逼近。算法中的最值问题有几个经典层次。
最基础的是暴力遍历。用一个变量记录当前最大值,逐个比较,时间复杂度 O(n)。这个做法虽然朴素,但它是一切优化方案的正确性基准。
再进一步是分治与线段树。如果只是求一次最大值,暴力就够了;但如果要求“反复查询任意区间[l, r]的最大值”,暴力一次查询 O(n),在 m 次查询下就会退化成 O(mn)。这时候线段树或稀疏表(ST表)可以把单次查询降到 O(log n) 或 O(1)。线段树解决的是“带修改的区间最值查询”,ST表解决的是“只查不改的区间最值查询”。
还有一类高频场景是滑动窗口最大值。给你一个数组和一个窗口大小 k,窗口每次向右移动一格,要求输出每个窗口内的最大值。暴力做法的复杂度是 O(nk),数据量一大立刻超时。标准解法是用双端队列维护一个单调递减的候选集,每个元素最多入队出队一次,整体复杂度 O(n)。这个题是“最大值”从数学题变成算法题的典型代表。
2.3 容易被忽略的三个细节
第一个细节是初始值。求最大值时,变量初始值应该设为理论最小值,比如 Integer.MIN_VALUE,或者数组的第一个元素,而不是0。因为如果所有数都是负数,初始值为0会让答案变成0,而正确答案应该是那个绝对值最小的负数。
第二个细节是边界区间。无论是数学题还是算法题,端点永远是最大值的候选地。函数在闭区间 [a, b] 上的最大值,断然不能只看极值点,f(a) 和 f(b) 必须参与比较。算法里就是循环的边界条件要覆盖到最后一个位置,单调队列的过期判断要用上越界检查。
第三个细节是相等元素的处理。数值重复出现时,最大值可能对应多个位置,如果题目要求输出“一个最大值的位置”,需要明确约定输出最先出现的还是最后出现的;如果使用单调队列,队首出队的条件里是否允许相等值,会直接影响结果正确性。
3. 最大值实操:从暴力遍历到 O(1) 查询的完整演进
光说不练没有意义。这里我给出一个从暴力到优化再到“可处理大数据”的完整实现路径,每个阶段都附上代码、复杂度分析和适用场景。以下代码示例我用 Java 写,逻辑上换成 C++、Python 没有任何障碍。
3.1 暴力遍历:所有方案的正确性基准
先看最直接的写法。假设有一个整数数组 nums,要求返回最大值:
java复制public int findMax(int[] nums) {
int maxVal = Integer.MIN_VALUE;
for (int num : nums) {
if (num > maxVal) {
maxVal = num;
}
}
return maxVal;
}
这段代码的逻辑没有任何技巧,但它是检验所有优化方案是否正确的最重要参照物。时间复杂度 O(n),空间复杂度 O(1)。如果问题只要求“求一次最大值”,这就是最优解,没必要上任何高级结构。我在实际笔试中经常看到有人对单次查询的问题强行写线段树,反而增加了出错概率和代码量。
3.2 ST表:静态区间最值查询的 O(1) 方案
当场景变成“一个数组,m 次询问任意区间 [l, r] 的最大值”,暴力解法就是 O(nm),几乎不可用。此时 ST表是最优雅的方案。它的思路是预处理所有长度为 2^k 的区间最大值,然后用两个可重叠的区间拼出任意查询区间。
java复制int[] log = new int[n + 1];
int[][] st = new int[n][log[n] + 1];
// 预处理 log
for (int i = 2; i <= n; i++) log[i] = log[i >> 1] + 1;
// 初始化长度为 1 的区间
for (int i = 0; i < n; i++) st[i][0] = nums[i];
// 动态规划生成所有长度为 2^k 的区间最大值
for (int k = 1; (1 << k) <= n; k++) {
for (int i = 0; i + (1 << k) <= n; i++) {
st[i][k] = Math.max(st[i][k - 1], st[i + (1 << (k - 1))][k - 1]);
}
}
// 查询 [l, r]
public int query(int l, int r) {
int k = log[r - l + 1];
return Math.max(st[l][k], st[r - (1 << k) + 1][k]);
}
这段代码要特别注意两点:一是 log 数组要用整数移位来计算,不要直接用 Math.log 再强转,浮点误差会让你在边界数据上栽跟头;二是两个被拼接的区间允许重叠,因为求的是最大值,重叠不会影响结果。这个“可重叠”性质只对最值类查询成立,区间和问题不能用 ST表。
3.3 滑动窗口最大值:单调队列的经典应用
另一类极其常见的问题是滑动窗口最大值。我在编辑器题目、股票价值分析模拟、传感器数据流处理上都遇到过它的变形。核心是用双端队列维护“窗口内候选最大值的递减序列”,队首永远是对应窗口的最大值。
java复制public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
int n = nums.length;
int[] result = new int[n - k + 1];
Deque<Integer> deque = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 删除队首出窗口的元素
while (!deque.isEmpty() && deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 从队尾移除所有小于当前元素的值
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
deque.offerLast(i);
// 窗口形成后开始记录结果
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
这段代码有两个容易出错点。第一个是队列里存的是下标而不是值,因为需要通过下标判断元素是否还在窗口内。第二个是第二个 while 里的判断条件是 <= 还是 <:用 <= 意味着遇到相等值会把旧的弹出去,保留靠右的新值;用 < 则会在相等时保留旧值。两个都对,但会影响“最大值第一次出现的位置”这种延伸问题的结果。我习惯用 <=,因为旧元素的位置更早、生命周期更短,保留新值在大多数扩展场景里更稳妥。之后如果你要输出窗口最大值对应的索引,这就是一个容易被忽略的坑。
3.4 方案选型对比
我把上面几个方案放在一张表里,方便按场景直接选型。
| 场景 | 推荐方案 | 预处理复杂度 | 单次查询复杂度 | 适用条件 |
|---|---|---|---|---|
| 单次求最大值 | 暴力遍历 | O(n) | 无需查询 | 任意数据 |
| 静态数组区间最值 | ST表 | O(n log n) | O(1) | 不可修改,查询次数大 |
| 动态修改加区间最值 | 线段树 | O(n) | O(log n) | 需要支持点更新 |
| 滑动窗口最值 | 单调队列 | O(n) | 全程O(n) | 窗口固定滑动 |
这个表是我做算法选型时最常用的决策依据。如果你的场景是数据分析中反复查询某一段历史数据的峰值,ST表是首选;如果数据源实时追加且需要维护最新一段时间的最大值,滑动窗口单调队列更合适。别把所有问题都堆到线段树上,那是过度设计。
4. 数列1:递推才是数列的灵魂
说完了最大值,再来啃“数列1”这块硬骨头。这个标题里有个“1”,暗示这是数列专题的开篇。我在实际教学中发现,数列入门最容易犯的错误就是死记通项公式,却不知道通项公式是怎么来、为什么要这么求。数列的核心不是通项本身,而是相邻项之间、项与序号之间的关系,也就是递推关系。
4.1 递推式、通项式与和式
先理清三个基本概念。递推式是数列中某一项用前面若干项表示的等式,比如 a(n) = a(n-1) + 2。通项式是直接用序号 n 表示第 n 项的式子,比如 a(n) = 2n + 1。和式是前 n 项和 S(n) 的表达式。三个概念之间的关系是层层递进的:通常先观察出递推式,再解出通项式,最后通过通项求前 n 项和。
递推关系是“动态”的,它描述的是项与项之间的转移规则;通项公式是“静态”的,它给出任意位置的确定值。打个比方:递推式是“你每天存的钱比前一天多 2 块”,通项公式是“第 100 天你有 201 块”。前者是一种过程,后者是一个快照。
在算法题中,递推关系还有一个变种叫状态转移方程。动态规划里最常见的一维状态转移方程,本质上就是数列递推式。比如爬楼梯问题 dp[n] = dp[n-1] + dp[n-2],这就是斐波那契数列的递推式。所以把数列递推吃透,等于给动态规划打了底子。
4.2 一阶和二阶线性递推的核心解法
先看最简单的一阶线性递推,形式是 a(n) = p * a(n-1) + q,p 和 q 为常数,通常还伴随一个初始值 a(1)。
当 q = 0 时,这是一个等比数列,通项公式是 a(n) = a(1) * p^(n-1)。
当 p = 1 时,这是一个等差数列,通项公式是 a(n) = a(1) + (n-1) * q。
当 p ≠ 1 且 q ≠ 0 时,用不动点法。设 x = p*x + q,解得不动点 x0 = q / (1 - p)。然后两边同时减去 x0,得到 a(n) - x0 = p * (a(n-1) - x0),这样 {a(n) - x0} 就变成了等比数列,公比是 p。于是 a(n) = x0 + (a(1) - x0) * p^(n-1)。
这个方法非常实用。比如处理存款模型:账户初始 1000 元,每月固定存入 200 元,同时月收益率为 0.5%,求第 n 个月的余额。这就是 a(n) = 1.005 * a(n-1) + 200 的一阶线性递推,用上面方法三步就能给出通项公式。
再看二阶线性递推,经典形式是 a(n) = c1 * a(n-1) + c2 * a(n-2),初始值为 a(1) 和 a(2)。这里要用到特征方程法:把递推式改写为 x² = c1*x + c2,解出两个特征根。
如果两个特征根不相等,比如 λ1 和 λ2,那么通项形式是 a(n) = A * λ1^(n-1) + B * λ2^(n-1),用两个初始值解出 A 和 B。如果特征根是重根 λ,那么通项形式是 a(n) = (A + B*(n-1)) * λ^(n-1),同样用初始值确定系数。
斐波那契数列就是典型例子。递推式 f(n) = f(n-1) + f(n-2),特征方程为 x² = x + 1,两个根分别为 (1+√5)/2 和 (1-√5)/2,代入初始条件后得到 Binet 公式。这个公式在算法分析里非常常用,因为黄金比例 (1+√5)/2 约等于 1.618,它直接决定斐波那契数列的增长速度。
4.3 从通项推导到编程实现的落地细节
当你拿到一个递推式,在程序里实现时有三条路。
- 递归实现、记忆化搜索:直接照搬递推式,加一个数组做缓存。这是最容易写对的方式,时间复杂度 O(n),但需要递归栈空间。
- 迭代滚动数组:用三个变量滚动更新,空间复杂度 O(1)。
- 矩阵快速幂:时间复杂度 O(log n),适合 n 高达 10^18 的情形。
很多人一上来就想用矩阵快速幂,我建议先想清楚数据范围。如果 n 只有 10^7,直接迭代就是最优解,矩阵快速幂反而因为常数大更慢。n 超过 10^9 再上快速幂。
以下是斐波那契数列三种写法的核心部分,都按 n 从 1 开始计数:
java复制// 方法一:迭代滚动
public long fibIter(int n) {
if (n <= 2) return 1;
long a = 1, b = 1;
for (int i = 3; i <= n; i++) {
long temp = a + b;
a = b;
b = temp;
}
return b;
}
// 方法二:矩阵快速幂
public long fibMatrix(int n) {
if (n <= 2) return 1;
long[][] base = {{1, 1}, {1, 0}};
long[][] result = matrixPow(base, n - 1);
return result[0][0];
}
public long[][] matrixPow(long[][] mat, int power) {
long[][] res = {{1, 0}, {0, 1}};
while (power > 0) {
if ((power & 1) == 1) {
res = multiply(res, mat);
}
mat = multiply(mat, mat);
power >>= 1;
}
return res;
}
特别注意一点:斐波那契数列增长极快,第 93 项就已经超过 long 的范围。我见过无数人在这里越界,导致输出负数还以为是递推写错了。实际业务里如果数值很大,要取模运算,比如 mod 10^9+7。矩阵乘法里的每一步都要取模,不能只最后取一次,否则中间结果照样爆。
4.4 前n项和与“数列1”的隐藏考查点
数列专题的第一讲,通常还会涉及前n项和的求法。这也是“数列1”里的一个隐藏重点,因为通项公式只是第一步,和式才是综合题的主战场。
对于等差数列,前 n 项和 S(n) = n*a(1) + n(n-1)d/2,这本质上是一个关于 n 的二次函数。一旦你意识到这一点,很多问题立刻转化为二次函数最值问题,这就和前面的最大值章节呼应上了。比如求 S(n) 最大值时,关键看二次项系数 d 的正负,再结合对称轴判断取最大值的位置。
这里的核心技巧是“错位相减法”和“裂项相消法”。错位相减用于等差乘以等比构成的差比数列;裂项相消用于分式型通项,比如 1/[n*(n+1)] 可以拆成 1/n - 1/(n+1),求和时中间项全部抵消。这个思路在算法中对应的是“差分与累加”,处理区间增量更新时特别有用。
5. 常见问题与排查技巧实录
这两个专题无论是作为数学题还是算法题,都有几个反复出现的坑。我把这些年收集到的典型问题和排查思路整理成下面的速查表,有些是初学者很容易犯的,有些是资深开发者也会偶发的。
5.1 最大值问题的四类高频错误
第一类错误:初始值设置不当。前面提到过,初始值设成 0 在求最大值时是危险的,遇到全负数组会把结果错算成 0。如果你不确定当前数据范围,最稳妥的方式是用 Integer.MIN_VALUE,或者直接取数组首元素作为初始值。这个错误在笔试里出现率极高。
第二类错误:只考虑极值点,忽略区间端点。数学题里,求闭区间上函数最大值,很多学生费尽力气求导、解极值点,最后忘了把端点代回去。考试时这题白算。算法题里对应的是滑动窗口的“窗口形成”条件,i < k - 1 时不产生合法结果,很容易在写循环时没有把这一段的逻辑分开处理。
第三类错误:数据类型溢出。数组元素本身没有爆 int,但“比较”“累加”过程中可能先爆掉。比如元素都是 1000000000,比较最大值的逻辑没问题,但如果在同一题里要求先求和再比较大小,就会溢出。排查方法是全局搜索是否有加法、乘法运算,确认每一步的运算结果是否有上一级更大的类型兜底。
第四类错误:把数组当成有序数据用二分法。有一类题看起来像最大值,实际是“搜索旋转排序数组中的最小值”或者是“峰值元素”问题,它们可以二分,但没有任何一个题告诉你“最大值”就允许二分。只有数据具备单调性,二分才成立。这是我把最大值和数列放在一起学的另一个原因:列是单调性最天然的载体。
5.2 数列问题的四类典型翻车现场
翻车现场一:递推公式给了 n ≥ 1 还是 n ≥ 2,边界没对齐。比如斐波那契数列,有的题规定 f(0)=0, f(1)=1,有的题规定 f(1)=1, f(2)=1。矩阵快速幂的幂次 n-1 还是 n-2、迭代循环从 3 开始还是从 2 开始,差一个数结果就完全不对。正确做法是先确认定义域,再写代码注释里把这个边界条件注明。
翻车现场二:特征方程解出的根是复数或者重根,不知道通项怎么设。复根虽然在实际竞赛里不常见,但重根非常常见。比如递推式 a(n) = 4a(n-1) - 4a(n-2),特征方程 x² = 4x - 4 只有一个重根 x = 2,此时通项形式必须是 (A + B*(n-1))2^(n-1),而不是简单设成 A2^(n-1)。不少人在这一步漏乘多项式部分,导致后面全错。
翻车现场三:求和时忽略了项号从 1 开始还是从 0 开始。很多公式在书本里默认从 1 开始,但程序里数组下标从 0 开始,转换的时候忘了对通项里的 n 做替换。比如通项为 2n+1 的数列,数组下标 i 对应的项应该是 a(i+1),直接用 2i+1 算出来的结果和你手算的数列对应不上。
翻车现场四:动态规划里用了递推式,但没考虑初始状态的依赖顺序。比如 dp[i] 依赖 dp[i-1] 和 dp[i-2],必须保证 i 从小到大的遍历方向。哪天改了遍历顺序(比如倒序遍历),会得到完全错误的答案。这属于非常隐蔽的问题,调试时可以先打印前几项对比手算结果。
5.3 一份可以“抄作业”的调试清单
如果你在做题或开发时遇到最值或数列相关的问题,按下面这个清单排查,能省下大量时间。
- 确认输入范围,特别是 n 的上限和数值是否可能为负;
- 打印前 5 项递推结果,与手算或已知答案对比;
- 检查循环边界,尤其是否漏掉最后一个元素或第一项;
- 检查初始变量是否为 Integer.MIN_VALUE 而不是 0;
- 检查是否有多处运算可能溢出,必要时全部统一转 long;
- 对于滑窗类问题,手动模拟一次窗口从形成到滑出的完整过程;
- 对于递归转递推的问题,确认状态转移方程是否完全忠实于原递推关系。
我在真实开发里碰过一次印象很深的坑:一个数据统计分析模块,需要计算 24 小时窗口内传感器读数的最大值,我用单调队列实现了,但一直比朴素方案少一个值。最后发现是窗口形成后的结果数组长度算错了,n - k + 1 和循环里 i >= k - 1 的记录条件不匹配,导致最后一个合法窗口没有输出。这类边界问题,光靠眼睛很难看出来,必须手动构造小样例去套。
6. 延伸思路:最大值与数列的组合玩法
在这两个专题掌握扎实之后,你可以尝试一些组合玩法,这也是“47.最大值 48.数列1”这套编号放到后续课程里一定会展开的方向。
第一个组合是“数列的单调性与最大项”。给定一个通项公式,不用求导,通过作差 a(n+1) - a(n) 的符号判断单调性,从而确定最大项出现的位置。这本质上是用离散工具解决连续问题中的最值,是考试中最常见的综合题。
第二个组合是“移动窗口内的极端值统计”,在时间序列分析里非常常见。比如一个电商平台要看近 7 天每日订单量的最大值变化趋势,这就是滑动窗口最大值的现实版本。理解了单调队列,你会对“数据流中的在线统计”这件事有全新的感觉。
第三个组合是“递推式与最优化问题的结合”,也就是动态规划。动态规划的状态转移方程本质是一个递推关系,而目标函数通常就是求最大值或最小值。从“数列1”到动态规划,只隔着一层“把阶段和状态定义清楚”的窗户纸。我在带学员时,从来没让他们直接背动态规划的套路,都是先确保他们能把递推式写对,再谈优化。
7. 写在最后的实际操作体会
如果只让我说一句话总结这两个专题,我会说:最大值教会你“选什么”,数列教会你“怎么推”。两者结合,你就拥有了分析和建模的最小闭环。
我自己的学习路径是:先把常见的数列递推式手动推到通项公式,把特征方程法练到条件反射;再用程序实现一遍,包括迭代、记忆化、矩阵快速幂三种写法;然后回到最大值专题,用暴力、ST表、单调队列分别解决几道题。这个流程走完,两个专题就不再是两个孤立的标题,而会内化成一整套思考工具。
最后分享一个小技巧:无论是做数学题还是写算法题,拿到题目后先别急着动笔,用 30 秒把“已知条件、目标、约束”三个东西写在草稿纸上。这个习惯我保持了十几年,极大减少了我因为看错条件而返工的情况。最大值题的目标是“极值”,约束是“范围或窗口”;数列题的目标是“通项或和式”,约束是“初始值和递推规则”。把这两组关键词写清楚,题目就已经解开一半了。
