题目编号跳到47、48,很多刷题或者跟课程的朋友走到这个阶段会明显感觉到一个变化:前面还在学基础数据结构,到这里开始真正碰“算法思维”了。第47讲“最大值”、第48讲“数列1”,听起来是两个独立的知识点,但实际上它们在面试和竞赛里经常成对出现——一个管“目标”,一个管“序列”。今天这篇就把这两讲掰开揉碎,结合我自己的实操经验,讲讲最大值问题怎么切入、数列题怎么建模,以及两者如何互相配合。
1. 内容整体设计与思路拆解
1.1 为什么最大值和数列总是被放在一起讲
很多初学算法的朋友看到课表里“最大值”和“数列1”挨在一起,会觉得有点突兀:最大值不是用一个for循环遍历就能解决吗?数列不是高中数学里的东西吗?这两个怎么还各占一讲?
说实话,我最初也有这个疑惑。但真正带过训练营、陪跑过一批又一批学员之后,我发现这两个知识点放在一起是很有讲究的,因为它们本质上是同一类问题的一体两面。
最大值问题,尤其是“在一定约束条件下的最大值”,几乎不可能脱离序列来讨论。比如滑动窗口最大值,你得先有一个数组、一个窗口长度,才能讨论“窗口内的最大值”;比如最大子段和,你得先给定一个数列,才能讨论“连续一段的和最大是多少”;再比如股票买卖最大利润,本质上是在一个价格序列里找“某个位置之后的最大值减去当前值”的最大差。这些题目,目标都是最值,但载体都是数列。
而数列1这讲,恰恰是在帮你打“序列建模”的基本功。递推公式怎么写、前n项和怎么算、某个通项怎么求,这些能力不是单独存在的,它们会在后续几乎所有与“区间、前缀、滑动、动态规划”相关的题目里反复用到。所以两讲连在一起,实际上是完成了一次从“单点极值”到“序列分析”的思维跃迁。
1.2 这两讲在一个完整刷题路线里的位置
如果按系统学习的顺序来划分,第47讲“最大值”和第48讲“数列1”更像是“算法工具初步成型”阶段的内容。前期的线性表、哈希表、栈和队列是“零件”,到了这两讲,你开始用这些零件组装出第一个真正有算法味道的解决方案。
我个人的理解是,学完这两讲之后,你应该至少具备以下三项能力:
- 看到“最大/最小”题目时,能快速判断它属于哪种类型(纯遍历、Top-K、滑动窗口、二分答案、动态规划),并选择对应的数据结构;
- 面对一个看起来没有头绪的数列问题,能尝试把它改写成递推关系式,再转化为程序可执行的循环或迭代逻辑;
- 能把“最大值”和“数列”结合起来,处理类似“前缀和取差求极值”这样的综合题目。
这也是为什么很多面试题库会把这两类题放在“必刷入门组”的原因。它们不像图论、字符串匹配那样一上来就劝退,但也不是无脑暴力一把梭能拿下的,恰好卡在从“会写代码”到“会解题”的转折点上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 最大值问题:先判断数据形态,再决定用什么工具
“求最大值”是一个看似简单、实则分支很多的话题。很多人的第一反应是:一个变量从第一个元素开始逐个比较,不就行了吗?是的,对于无序数组的整体最大值,O(n)遍历就够了。但面试和实际开发里的“最大值”,往往在前面加了很多限制条件,常见的几类形态如下。
第一类是“带滑动窗口的区间最大值”,比如给定数组 [1,3,-1,-3,5,3,6,7] 和窗口大小 k = 3,要求输出每个窗口内的最大值。这种题如果每轮都重新扫描窗口,时间复杂度是O(nk),数据量一大就会超时。正确姿势是用单调队列,让队头到队尾保持递减,队头就是当前窗口最大值。
第二类是“最大子段和”型问题,核心是把连续子数组的和最大问题转化为“前缀和相减”问题。用 pre[i] 表示前i项的和,那么区间 [j+1, i] 的和就是 pre[i] - pre[j],想让这个差值最大,就需要在遍历过程中维护 pre[j] 的最小值。
第三类比较隐蔽,是“二分答案型最大值最小化”问题,比如把数组分成m段,求每段和的最大值的最小可能值。这种题表面上在求最小值,实际上核心判断函数却基于“段数是否满足约束”,是典型的二分套路。
这三类问题我在实战中都遇到过,建议学习时不要只记代码模板,而是形成一张“数据形态—数据结构”的映射表,遇到题先归类,再动手。
| 题目特征 | 典型代表 | 推荐数据结构/方案 | 时间复杂度 |
|---|---|---|---|
| 无序数组整体最大 | 直接找最大/最小值 | 遍历+临时变量 | O(n) |
| 区间多次查询最大 | RMQ问题 | ST表、线段树 | O(1)查询 / O(log n)查询 |
| 滑动窗口最大 | 窗口固定长度的最大值 | 单调队列(deque) | O(n) |
| 最大差值 / 最大子段和 | 前缀和取差 | 前缀最小/最大值维护 | O(n) |
| 最大值最小化 | 分段问题/分配问题 | 二分答案+贪心校验 | O(n log S) |
2.2 数列1:递推关系是贯穿始终的命脉
数列这一讲虽然叫“数列1”,通常是基础篇,但它的核心思想在后续课程里会反复出现。我个人认为,数列题的解题能力本质上就是“把第n项和第n-1项、第n-2项的关系式写出来”的能力。
以斐波那契数列为例,定义式是 F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)。这是最简洁的递推关系。但很多初学者直接就写递归:
python复制def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
这个写法逻辑完全正确,但性能非常差,递归树里有大量重复计算,n为40以上就开始明显卡顿。所以数列题的第一个进阶点,是用“记忆化”或“动态规划循环”代替朴素的递归。实际上,只要画一遍递归树,你就会发现同一个子问题被反复求解了多少遍,这时候自然能理解“自顶向下备忘录”和“自底向上填表”的关系。
数列1里还应该掌握的东西是等差数列和等比数列的求和公式,以及它们的程序化写法。很多人会背公式,但一写代码就翻车,比如乘法溢出、除法的取模问题。尤其是涉及大数的时候,公式里有除法,如果题目要求对结果取模,不能简单除以2再取模,而要乘上2的逆元,这在后面数论部分才会细讲,但基础阶段至少要有这个意识。
2.3 最大值与数列的缝合点:前缀和与单调结构
这两讲之所以值得一起复习,是因为很多综合题需要同时用上两边的知识。
举一个非常经典的例子:给定一个整数数组,求连续子数组和的最大值。从“数列”角度看,我们要处理的就是前缀和序列;从“最大值”角度看,我们要找的是 pre[i] - min(pre[0..i-1]) 的最大值。这两个视角缺一不可。
再比如滑动窗口最大值,窗口里的数字本质上是一个“不断弹出的子数列”,而单调队列维护的正是这个子数列递减结构。你如果不熟悉数列的顺序关系,很难写出正确的出队条件;如果不理解最大值查找的目的,又容易把单调队列写成普通队列。我见过不少学员单独做“最大值的题”没问题,单独做“数列题”也没问题,一合起来就懵,原因就是没有把这两讲的知识点做交叉连接。
所以我现在带学生的时候,会特意在学完这两讲之后安排一组“缝合题”:最大子段和、滑动窗口最大值、股票买卖一次最大利润、最长连续递增序列。每一道题都要求他们先写出数列模型(比如用pre数组还是用窗口索引),再套最大值方案(维护什么东西的最值),写完再复盘。这个习惯一旦养成,后面学动态规划、学单调栈都会轻松很多。
3. 实操过程与核心环节实现
3.1 滑动窗口最大值:从暴力到单调队列的完整推进
拿一个非常常见的题来讲:给定数组 nums 和滑动窗口大小 k,输出每个窗口的最大值。我第一次写这个题的时候,第一反应就是每个窗口单独扫描。代码很简单,但提交后遇到大数据直接超时。
暴力版本是这样:
python复制def max_sliding_window_bruteforce(nums, k):
n = len(nums)
if n == 0 or k == 0:
return []
result = []
for i in range(n - k + 1):
window = nums[i:i + k]
result.append(max(window))
return result
这个写法的时间复杂度是O((n-k+1)*k),当 n 和 k 都达到10^5量级时,基本跑不动。
正确解法是维护一个“单调递减的双端队列”,队列里存的是元素下标而不是值。为什么存下标?因为窗口移动的时候,我们需要判断队头的元素是不是已经不在当前窗口里了,存下标才能准确判断。为什么维护递减?因为我们要的永远是窗口内最大值,一旦队尾元素比新元素小,那它在后面就再也不可能成为最大值,可以提前丢掉。
python复制from collections import deque
def max_sliding_window(nums, k):
n = len(nums)
if n == 0 or k == 0:
return []
result = []
dq = deque()
for i in range(n):
# 移除超出窗口范围的队头
if dq and dq[0] < i - k + 1:
dq.popleft()
# 从队尾弹出比当前元素小的元素
while dq and nums[dq[-1]] <= nums[i]:
dq.pop()
dq.append(i)
# 窗口形成后开始记录结果
if i >= k - 1:
result.append(nums[dq[0]])
return result
这段代码的关键细节有三个。第一,dq[0] < i - k + 1 这个判断,i - k + 1 是当前窗口的起始下标,队头下标比它小说明已经滑出窗口,要弹出。第二,弹出的条件是 nums[dq[-1]] <= nums[i],注意这里有等号。如果相等,我们选择保留后出现的元素,因为它在窗口里存活时间更长。第三,i >= k - 1 之后才开始记录结果,因为窗口只有达到长度k才完整。
我调试这段代码时发现一个常见错误:把“队尾弹小值”和“队头出窗口”顺序写反。正确顺序是先出窗口,再弹小值,否则刚加入的新元素可能被自己误伤。这个顺序问题理解起来不难,但写代码时很容易顺手写错,建议多跑几个用例验证。
下面用一个例子手动走一遍:
| 步骤 | 遍历到的数值 | 队列变化(存下标,括号里是值) | 当前窗口最大值 |
|---|---|---|---|
| i=0 | nums[0]=1 | [0(1)] | 窗口未满 |
| i=1 | nums[1]=3 | 弹出0,加入1,队列 [1(3)] | 窗口未满 |
| i=2 | nums[2]=-1 | [1(3), 2(-1)] | 3 |
| i=3 | nums[3]=-3 | [1(3), 2(-1), 3(-3)] | 3 |
| i=4 | nums[4]=5 | 弹出3、2、1,加入4,队列 [4(5)] | 5 |
| i=5 | nums[5]=3 | [4(5), 5(3)] | 5 |
通过手动模拟,你会发现单调队列里剩下的永远是一个“递减序列”,队头就是当前窗口最大值的下标。这个方法的总时间复杂度是O(n),因为每个元素最多入队一次、出队一次。
3.2 数列1的编程实现:斐波那契数列的三种正确姿势
斐波那契数列是“数列1”里绕不开的例题。前面提到朴素递归性能极差,那么正确的实现有哪几种?我按从易到难的顺序说。
第一种,带备忘录的自顶向下递归。本质上是在递归外面套一个缓存,避免重复计算:
python复制def fib_memo(n, memo=None):
if memo is None:
memo = {}
if n <= 1:
return n
if n in memo:
return memo[n]
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
第二种,自底向上的动态规划。用一个长度为 n+1 的数组逐步填表。这个写法的好处是思路直观,状态转移一目了然:
python复制def fib_dp(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
第三种,滚动数组优化空间。因为斐波那契某一项只依赖前两项,不需要保留整个数组,两个变量交替更新就够了:
python复制def fib_rolling(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
实战中,我个人最推荐第三种写法,省空间并且代码简洁。但要注意,如果题目要求n很大(比如n=10^18),上面三种O(n)的写法都会超时,这时候要用矩阵快速幂优化到O(log n)。那是“数列2”或者“数学专题”的内容,但至少你要知道有这条路。
3.3 缝合实战:最大子段和的完整推导与代码
接下来把最大值和数列的思维缝合起来。最大子段和问题用“前缀和”视角来解,就能把两个知识点同时练到。
思路如下:定义 pre[i] 为数组前 i 个元素的和(pre[0] = 0),那么任意连续子数组 [j, i] 的和就是 pre[i] - pre[j-1]。要让这个差值最大,我们可以固定右端点 i,然后在 pre[0..i-1] 里找到最小值,用 pre[i] 减去这个最小值。随着 i 向右移动,我们实时维护“遇到过的最小前缀和”。
python复制def max_subarray_sum(nums):
pre = 0 # 当前前缀和
min_pre = 0 # 遇到过的最小前缀和
ans = float('-inf')
for x in nums:
pre += x
ans = max(ans, pre - min_pre)
min_pre = min(min_pre, pre)
return ans
这段代码跑一遍 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4],会得到正确答案 6,也就是子数组 [4, -1, 2, 1] 的和。这个解法的精妙之处在于:我们不是真的去枚举所有子区间,而是通过前缀和的差值,把所有可能区间的最值问题转化成了“前缀最小值维护”问题,时间复杂度O(n)。
我当时第一次看到这个写法只觉得漂亮,但自己写的时候踩过一个坑:min_pre 一开始必须是0而不是正无穷,因为空数组的前缀和是0,子数组可以从第一个元素开始,不减去任何前缀。如果初始化错了,遇到全正数数组就会出错。
4. 常见问题与排查技巧实录
4.1 最大值题目的边界坑与调试方法
最大值题目看起来简单,但真写起来,坑都在边角料上。
第一个最常见的坑就是“临时变量的初始值”。很多人找最大值时顺手把 ans 初始化为0,结果数组里全是负数,最终返回0,答案完全错误。正确做法是用数组第一个元素初始化,或者用 float('-inf')。同理,找最小值时用 float('inf')。
第二个坑出现在单调队列里,弹出条件和入队顺序问题。我建议调试时在循环里临时打印 dq 的内容,看每一步的队头、队尾下标,确认窗口合法性和单调性。自己写几个随机小数组,和暴力版本的结果比对,这是最快定位问题的方法。
第三个坑是下标偏移。很多人在判断“窗口是否形成”时写成 i >= k,导致漏掉第一个窗口。窗口形成的条件是 i >= k-1,因为第一个窗口覆盖下标0到k-1,到 i 等于k-1时就已经完整了。这个 -1 的偏移非常隐蔽,我用过好几回都栽在这里。
4.2 数列题目最容易被低估的取模与溢出问题
数列题在面试和在线评测里,通常会要求对结果取模,比如模 10^9+7。这里有一个非常容易被新手忽略的细节:中间计算也要取模,不是只有最后结果取模。
以斐波那契数列为例,如果只对 dp[n] 取模,但 dp[i] = (dp[i-1] + dp[i-2]) % MOD 的每一个中间项都不取模,在 n 很大的时候,dp[i-1] + dp[i-2] 本身就会溢出语言的数据范围。C++里更明显,int 和 long long 都有上限。
还有一种情况是“等差求和公式除以2”的取模问题。假设要求 1 + 2 + ... + n 对 MOD 取模,如果写成 n * (n+1) / 2 % MOD,在取模背景下是错误的,因为 n*(n+1) 可能已经溢出,而且2不一定是模意义下的可整除关系。正确做法是使用模逆元,或者先判断 n 和 n+1 哪个是偶数,先除以2再乘,避免大数相乘溢出。这个细节在基础阶段可以当作一个“知识钩子”,留到学数论时再补全。
4.3 高频报错速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 全负数数组时最大值返回0 | 最大值变量初始化为0 | 改为用数组首元素初始化或负无穷 |
| 滑动窗口少一个结果 | 窗口形成条件写成 i >= k |
改为 i >= k - 1 |
| 单调队列结果偏大 | 队头过期元素没弹出 | 先判断 dq[0] < i-k+1 再出队 |
| 递归数列n=50时卡死 | 朴素递归指数爆炸 | 改用记忆化或DP循环 |
| 数组越界 | 递推下标从1开始但数组长度没+1 | 明确边界项,n=0单独处理 |
| 取模结果不一致 | 中间过程未取模 | 每一步加法乘法都取模 |
4.4 面试现场的一个实用技巧
刷题时练习的时间久了,你会发现“最大值”和“数列1”这两讲的内容经常成为面试官抛出的热身题。我的建议是,面对这类题,先说暴力解,说清楚复杂度,再说优化方案。这不仅是向面试官展示思维过程,更重要的是给自己争取时间理清思路。
举一个我自己临场用过的方法:如果题目要求求“最大差值”但又不知道从哪入手,就在纸上写下第一项到当前项的前缀信息,比如当前最小值、当前最大值、当前前缀和,然后观察这些值能不能组合出答案。最大子段和需要的是“前缀和的最小值”,股票买卖需要的是“历史价格的最小值”,最长连续递增序列需要的是“前一个数”,三者看起来不同,但本质上都是在遍历过程中维护一个“与截止到当前位置有关的最值信息”。这类题练熟了,你会在面试时有更强的掌控感。
数列题也一样,面试官如果让你写斐波那契,不要只丢一个递归。可以先说朴素递归的缺点,再给出滚动数组的写法,然后谈一下如果n很大可以用矩阵快速幂。这种从“会做”到“懂原理”的表达,往往比闷头写代码更能加分。
5. 从实操中总结的几条心得
这类“最大值+数列”的组合题,我自己从一开始暴力超时,到后来能熟练写出单调队列、前缀和最值,中间踩的坑不少。回想起来有三条体会特别深。
第一,代码模板不是背出来的,是“推导”出来的。比如单调队列为什么队尾要弹出较小的值?因为它们在窗口内永远不会成为最大值,这是基于“单调性”的必然结论。只要你理解了这一点,即使忘了模板,也能现场推出来。
第二,边界条件永远是第一杀手。无论是窗口下标、前缀和数组长度还是递推的初始项,都要在写代码前用最小用例先算一遍。我习惯在写完代码后,立刻用 nums = [1]、k = 1、n = 0 这类极端小用例自测一遍,很多bug都能被提前拦截。
第三,综合题的练习价值远大于孤立题。如果你刷完第47讲只做最大值的题,刷完第48讲只做数列的题,那你收获的是两个孤岛。真正让水平提升的,是那些“既要维护序列信息、又要回答最值问题”的题目。它们是桥梁,把孤岛连成大陆。
最后再分享一个小经验:当你碰到一个陌生题目,如果它同时出现了“连续区间”“最大或最小”“前缀”这几个词,大概率就是“前缀和+最值维护”的套路;如果还带“窗口长度固定”,那就往单调队列的方向想。这个判断不一定百分百准确,但能帮你快速找到一个正确的起点。做题这种事,最怕的就是拿到题不知道从哪下手,有了方向,剩下的大不了就是多写几行代码的事。
