看到“单调递增或单调递减的数组”这个标题,我第一反应是LeetCode 896:Monotonic Array。这道题在算法题库里属于“送分题”级别,可恰恰是这种题,面试翻车率极高。前阵子一个朋友面完回来跟我说,他判断一个数组是否单调递增或单调递减时,居然写出了一个只检查“递增”却忘记“递减”也是合法选项的函数。最气人的是,他写完自己还觉得挺对,直到面试官问了一句“如果是单调递减呢”才反应过来。
这其实不是个例。单调数组问题难的不是遍历,而是“定义”和“边界”。在你动手写代码之前,你先得搞清楚题目说的“单调递增”允不允许相等元素,空数组算不算满足条件,浮点数数组里的NaN怎么处理。这些细节,才是这类题真正想考的。下面我把这个问题从定义、核心算法、边界条件、进阶用法到工程场景完整拆一遍,希望能帮你一次踩平所有的坑。
1. 单调数组到底在问什么:定义、变体与判断基准
1.1 从数学定义到题目约定
先说数学上的定义。一个数组是单调递增的,通常指对任意相邻两个元素,都有 a[i] <= a[i+1],也就是非严格递增。如果要求 a[i] < a[i+1],那叫严格递增。单调递减同理,非严格递减是 a[i] >= a[i+1],严格递减是 a[i] > a[i+1]。
LeetCode 896 里给的更正式一点:对所有 i <= j,如果 a[i] <= a[j],数组就是单调递增;如果 a[i] >= a[j],就是单调递减。这个全局定义看起来严格,但实际不需要每一对都去比较,因为大小关系的传递性决定了,只要所有相邻元素都不违背方向,整个数组就一定满足全局定义。
这句话才是判断算法的核心。我们不需要双重循环去比较任意两个元素,只需要在相邻元素之间做判断。这也是整个问题时间复杂度能做到 O(n) 的根本原因。
1.2 四种组合一眼看清
很多人在这个表上栽过跟头。同样是“单调”,严格和非严格是两种完全不同的判定标准。
| 数组示例 | 非严格递增 | 严格递增 | 非严格递减 | 严格递减 |
|---|---|---|---|---|
| [1, 2, 3] | true | true | false | false |
| [3, 2, 1] | false | false | true | true |
| [1, 2, 2, 3] | true | false | false | false |
| [1, 1, 1] | true | false | true | false |
| [1, 3, 2] | false | false | false | false |
| [] | true | true | true | true |
| [1] | true | true | true | true |
空数组和单元素数组的情况比较特殊。按数学定义,“不存在反例”就应当视为满足条件,所以 LeetCode 默认它们返回 true。但真实业务里,空数据往往意味着“没有采集到数据”,此时返回 true 可能会引发错误的预警。这个我在第 5 节还会展开说。
1.3 为什么一道“送分题”能卡住这么多人
因为题目越短,隐藏约定越多。很多人在草稿纸上写 for i in range(1, n): if nums[i] < nums[i-1]: return False,这个函数顶多能判断“非递减”,完全没考虑题目允许“单调递减”这个方向。还有一类人默认“递增”就是严格递增,于是看到 [1,2,2,3] 就直接返回 false,而实际如果题目没强调 strictly,非严格递增往往是合法答案。
这类题目在面试里频繁出现,不是因为它难,而是因为它能快速考察一个人的工程习惯:先澄清需求,再考虑边界,最后才写主逻辑。你在算法题里怎么处理相等元素,跟你平时写业务代码时怎么处理“连续上涨多少天算上涨”是同一个能力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 判断单调数组的两种核心写法:双标记法与方向锁定法
2.1 最直觉的写法为什么不对
先把反面教材放出来。很多人第一个版本是这样写的:
python复制def check_non_decreasing(nums):
for i in range(1, len(nums)):
if nums[i] < nums[i-1]:
return False
return True
这个函数判断的是“数组是否非递减”,它只承认一种单调方向。对于 [3,2,1],它直接返回 false,可 [3,2,1] 明明是一个合法的单调递减数组。如果你在面试现场出现这个问题,暴露的不是算法能力,而是审题能力。
正确做法不是只盯一个方向,而是同时维护“数组仍可能是递增序列”和“数组仍可能是递减序列”两个状态。
2.2 双标记法:一次循环同时记录两个可能性
核心思路非常简单。初始化两个布尔值:
inc = True,表示目前没有证据证明数组不是单调递增。dec = True,表示目前没有证据证明数组不是单调递减。
遍历数组时,对每一对相邻元素:
- 如果
nums[i] > nums[i-1],那数组肯定不是递减序列,把dec置为False。 - 如果
nums[i] < nums[i-1],那数组肯定不是递增序列,把inc置为False。 - 如果两者相等,什么都不用改。
只要 inc 和 dec 中有一个仍然是 True,就说明数组没有同时出现过上升和下降,也就是单调的。
用代码写出来就是:
python复制def is_monotonic(nums):
inc = True
dec = True
for i in range(1, len(nums)):
if nums[i] > nums[i-1]:
dec = False
elif nums[i] < nums[i-1]:
inc = False
if not inc and not dec:
return False
return True
这里加了一个提前退出:当 inc 和 dec 同时变成 False 时,说明数组里既出现过上升又出现过下降,方向已经反转,不可能再回到单调状态,直接返回 False。
如果你用 JavaScript,写法几乎一模一样:
javascript复制function isMonotonic(nums) {
let inc = true, dec = true;
for (let i = 1; i < nums.length; i++) {
if (nums[i] > nums[i - 1]) dec = false;
else if (nums[i] < nums[i - 1]) inc = false;
if (!inc && !dec) return false;
}
return true;
}
C++ 版本也就是换个类型声明的事:
cpp复制bool isMonotonic(vector<int>& nums) {
bool inc = true, dec = true;
for (int i = 1; i < nums.size(); ++i) {
if (nums[i] > nums[i - 1]) dec = false;
else if (nums[i] < nums[i - 1]) inc = false;
if (!inc && !dec) return false;
}
return true;
}
这段代码的时间复杂度是 O(n),空间复杂度是 O(1)。在绝大多数情况下,只要数组中间出现一次方向反转,提前退出机制就会让实际运行时间远小于最坏情况。
2.3 方向锁定法:另一种更贴近业务直觉的写法
双标记法是从“证伪”的角度做的:你还可能是递增,你还可能是递减。另一种常见写法是“方向锁定法”:先记录已经观察到的趋势方向,一旦出现相反的方向就返回 false。
python复制def is_monotonic(nums):
trend = 0 # 0: 未确定, 1: 递增, -1: 递减
for i in range(1, len(nums)):
if nums[i] > nums[i-1]:
if trend < 0:
return False
trend = 1
elif nums[i] < nums[i-1]:
if trend > 0:
return False
trend = -1
return True
这段代码的思维模型是:我一边走一边记录方向,前面告诉我应该在往上走,结果下一步突然往下走,那就说明方向变了,直接判负。
两种方法都正确。我个人更推荐双标记法,因为两个布尔变量的状态转换更直白,别人 review 代码时不容易产生歧义。方向锁定法在“实时流式判断”场景下更自然,我后面讲业务场景时会再提到它。
3. 最容易被判错的边界条件:相等元素、空数组、浮点数与超长输入
3.1 相等元素:面试里最大的隐藏分歧点
前面表格提到,[1,1,1] 在非严格定义下既是“非递减”又是“非递增”的。而 LeetCode 896 的默认结论是:只要数组不出现方向反转,它就返回 true。所以 [1,1,1] 这种全相等数组应该返回 true。
但很多题如果明确写了 strictly increasing,那 [1,1,1] 就必须返回 false。这时候双标记法的逻辑也要调整,要额外判断 nums[i] == nums[i-1] 时直接返回 false,或者换用别的写法。
我的建议是,拿到题目后第一件事不是上手写代码,而是确认三件事:
- 题目要求的是“单调不减/不增”,还是“严格递增/递减”?
- 空数组和单元素数组的返回值是什么?
- 相等元素是否被允许?
如果面试官自己也说不清,那就选一个合理假设并明确说出来,这比闷头写半天然后写错要好得多。真实业务场景同理,先问需求方“连续涨多少天算趋势”,再动手写代码。
3.2 浮点数数组:NaN 和精度问题
普通整数数组的单调判断非常干净,但换成浮点数数组,事情就会变得微妙。先看 NaN,NaN 和任何数字比较都是 false,包括 NaN < x、NaN > x、NaN == x 全是 false。如果你的数组里混入了一个 NaN,双标记法在遇到 NaN 的那一轮循环里,既不会把 dec 置为 false,也不会把 inc 置为 false,但它也不会做任何判断,数据可能带着错误状态继续跑。
更稳妥的做法是在判断之前先做数据清洗,把 NaN 替换成前一个有效值,或者在发现 NaN 时直接返回一个业务侧约定的结果,比如返回 false 表示“数据异常,不做趋势判断”。
浮点数精度的隐患则主要体现在“相等判断”上。比如 0.1 + 0.2 == 0.3 在大多数语言里是 false,这在单调判断中影响不大,因为我们比较的是 < 和 >,只关心大小关系,不关心是否精确等于。但如果你要定义“上涨幅度超过某个阈值才算上涨”,那阈值边界上的浮点误差就要用容差处理。
3.3 空数组、单元素数组:算法题和业务题的差异
算法题里,空数组没有相邻元素,不存在反例,所以返回 true。单元素数组同理,一个元素谈不上方向,也返回 true。LeetCode 896 在这个约定上很明确。
业务场景里这个默认值就不一定合理了。比如监控系统每隔一分钟采集一次 CPU 使用率,如果采集器故障导致数组为空,你返回 true 就相当于告诉告警系统“CPU 一切正常”,这显然不对。所以我在写监控类工具时,通常会先判空,如果数组长度小于 2,直接返回 false 或者抛一个“数据不足”的状态,而不是沿用算法题的约定。
3.4 超长输入:提前退出和全相等数组的特殊情况
双标记法的提前退出机制对“已经出现方向反转”的数组很友好,随机数据通常在第几次比较就能被揪出来。但有一种数组会让这个优化完全失效:全相等数组。比如 [5,5,5,5,...,5],长度一万,它全程不触发任何 dec = False 或 inc = False 的赋值,循环必须跑到底才能返回 true。
这里没有捷径,因为单调性是一个全局性质,你想证明“单调”,就必须检查完所有相邻对;想证伪则只需要一个反例。全相等数组的反例不存在,所以只能跑完。你没办法靠比较首尾元素来提前判断,因为 [1,3,2,4] 首元素小于尾元素,但中间有反转,照样不是单调数组。
所以写算法时,可以把这个边界记住了:均等数组是双标记法的“最坏情况”,但即便跑完整轮,时间复杂度依然是 O(n),对现代 CPU 来说完全不是问题。
3.5 一组可以直接抄的测试用例
我平时写完这类函数,会用下面这组用例快速验证,你可以直接复制过去当测试样例:
python复制test_cases = [
([], True),
([1], True),
([1, 1], True),
([1, 2, 3], True),
([3, 2, 1], True),
([1, 2, 2, 3], True),
([1, 3, 2], False),
([1, 2, 1, 2], False),
([5, 4, 3, 4], False),
]
每一条我都标注了预期结果。跑一遍这组用例,基本能覆盖递增、递减、全相等、先增后减、先减后增、空数组、单元素这些最常见的坑。
4. 从“判断单调”到“利用单调”:单调栈、LIS 与树状数组的进阶路径
4.1 单调栈与单调队列:用单调性换时间复杂度
判断一个数组是不是单调数组,是在“观察形状”。而单调栈和单调队列则是主动“维护形状”,利用单调性把很多暴力解法从 O(n²) 优化到 O(n)。
以经典的“下一个更大元素”为例。朴素做法对每个元素往右扫描,复杂度 O(n²)。用单调栈时,栈底到栈顶保持严格递减,新元素入栈前,把所有比它小的元素弹出去。被弹出的元素,其“下一个更大元素”就是当前这个新元素。整个过程每个元素最多入栈一次、出栈一次,总复杂度 O(n)。
另一个常见工具是单调队列。滑动窗口最大值问题中,维护一个双端队列,队首永远是当前窗口的最大值,队尾到队首保持单调递减。每次窗口滑动,把过期下标从队首弹出,把比新元素小的队尾元素全部弹出,再在队尾插入新元素。这样不用每次重新扫描窗口,也能在 O(n) 时间内跑完所有窗口。
这些题目都有一个共同点:一旦你发现“序列的极值”或“元素间的相对大小关系”是关键条件,就可以试着用单调结构维护它,把原来反复扫描的部分变成一次维护。
4.2 从“判断”到“求解”:最长连续单调子数组
实际业务里,你不会只想知道整个数组是否单调,更常见的是想知道“最长连续上涨了多久”。这种问题本质上就是找最长连续递增子数组。
一次遍历就能求解:
python复制def longest_increasing_streak(nums):
if not nums:
return 0
streak = 1
max_streak = 1
for i in range(1, len(nums)):
if nums[i] > nums[i-1]:
streak += 1
else:
streak = 1
max_streak = max(max_streak, streak)
return max_streak
这个思路和“连续上涨天数”是同一个逻辑。股票行情页里写的“连涨 5 天”,背后就是类似实现。要注意的是,这里如果要求严格递增,就用 >;如果只要求不跌,就用 >=。又是一个先确认定义再写代码的例子。
4.3 最长递增子序列(LIS):从 O(n²) 到 O(n log n)
如果题目从“连续子数组”变成“子序列”,难度立刻上了一个台阶。最长递增子序列是另一道经典题。最朴素的动态规划写法是:
python复制def length_of_lis(nums):
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp) if dp else 0
复杂度 O(n²),几千个元素还能接受,一旦数据量上来就吃力了。优化方案是维护一个 tails 数组,tails[k] 表示长度为 k+1 的递增子序列的最小末尾值。这个数组天然满足单调递增,所以每次处理一个新元素时,只需要在 tails 里二分查找第一个大于等于它的位置,替换或追加。整体复杂度 O(n log n)。
这里能优化成功,依赖的正是“有序数组可以二分”这一性质。单调性不是终点,而是通往二分和树状数组这些高级工具的钥匙。
4.4 树状数组、逆序对与值域单调性
树状数组和单调数组表面上关系不大,但逆序对问题里,树状数组的核心能力是“在值域上做前缀查询”。我们把原始数组的值离散化后,从左到右遍历,每个元素在树状数组里对应位置加 1,查询时统计值域上比自己小的元素有多少个。这个过程依赖的是一个有序的值域空间,而这个空间本身就是单调递增的。
热搜词里能看到“逆序对v2树状数组”“树状数组上二分”,这些都是在讨论树状数组怎么配合二分在值域上快速定位。LIS 的树状数组优化也是同一套思路:按值域维护“以该值为结尾的最长递增子序列长度”,查询时找小于当前值的最大值。
如果刚开始接触这些,我建议你先不要直接背模板,而是把“单调数组判断”这个最基础的问题吃透。因为树状数组、线段树这些结构再怎么复杂,也是在利用“有序”这个前提做加速。
4.5 旋转有序数组:部分单调也能二分
还有一种很常见的题,数组整体不是单调的,但分段单调,比如 [4,5,6,1,2,3]。这种旋转有序数组依然可以用二分查找,因为每一次二分,左右两半中至少有一半是有序的。通过比较 nums[mid] 和 nums[left] 是否满足 <=,就能确定左半段是否有序,进而判断目标值落在哪一段。
这类题是“单调性”的另一种应用:不要求全局单调,只要局部有序,就能利用有序性做区间收缩。面试中遇到搜索类题目,第一反应就是找单调性,找不到全局单调就找局部单调,局部还没有就考虑排序或数据结构了。
5. 工程里的真实场景:传感器数据、行情曲线与实时流处理
5.1 监控系统里的“连续上涨”判断
我在做服务监控时,遇到过判断 CPU 占用率是否持续上涨的需求。一开始直接拿整段历史数据调用 is_monotonic,结果告警频繁误触。原因很简单:监控数据有噪声,比如 60%、60.5%、60.2%、61%,四舍五入后 60.2% 比 60.5% 稍微低一点点,整个数组就不再是严格递增了。
后来改成“连续 N 个采样点均高于前一个点”才算上涨,或者先做滑动平均再判断。单纯判断整体单调性,在真实数据里几乎不适用。推荐做法是:先对数据做平滑,比如 5 点移动平均,然后在这个平滑序列上计算上涨或下跌趋势,并且要求趋势持续至少 M 个周期。
5.2 行情曲线和价格趋势
股票、数字货币、商品期货的价格曲线天然是“噪声 + 趋势”的混合体。判断“今日是否持续走强”如果只是简单比较相邻收盘价,很容易被一根插针K线骗过去。
我在实际项目里更倾向用“窗口内回归斜率”或“高低点突破”来判断趋势,而不是精确的单调数组判断。但单调数组的思想仍然有用:极端行情下,比如连续涨停或连续阴跌,价格曲线就非常接近一个单调数组。此时用单调判断做预警是合理的。
如果你要做一个“连续上涨天数”指标,可以参考 4.2 节的最长连续递增子数组代码,把条件改成“当日收盘价高于昨日收盘价”,返回的就是最近一次连续上涨的持续天数。
5.3 实时流式数据:不一定非得存数组
前面的所有代码都默认数据已经在数组里了。但真实业务里数据往往是流式到达的,比如消息队列里每秒来一条监控数据,不可能把所有历史都存下来再判断。这时可以把双标记法改造成“状态机”:
python复制class TrendDetector:
def __init__(self):
self.trend = 0 # 0: 未知, 1: 递增, -1: 递减
self.prev = None
def add(self, x):
if self.prev is None:
self.prev = x
return True
if x > self.prev:
if self.trend < 0:
return False
self.trend = 1
elif x < self.prev:
if self.trend > 0:
return False
self.trend = -1
self.prev = x
return True
这个类只保存前一个值和方向状态,内存占用是 O(1)。每条新数据进来调用一次 add,返回 false 就说明当前数据流已经打破了单调性。如果需求是“最近 N 个点单调”,那就得用一个固定大小的队列,每来一个新点就入队、弹出队首,然后对队列重新跑一遍双标记法,或者维护队列内上升和下降的次数来加速判断。
5.4 把数组基本功补牢:从去重到二维数组再到字符串初始化
最后说点“周边”。我一直觉得,单调数组这道题虽然简单,但它背后的数组基础操作,反倒是很多人的短板。你看网上那些高频搜索:数组去重、对象数组去重、JS 扩展运算符合并数组、数组转字符串、二维数组定义与初始化、C 语言数组和指针、字符串数组初始化……这些全是“数组”这个大主题下最常踩坑的地方。
以 JS 为例,合并数组最简洁的方式是 [...arr1, ...arr2],普通数组去重用 new Set(arr),对象数组去重就得用 Map 按唯一键过滤。C/C++ 里数组名在表达式求值时会退化为指针,所以二维数组作为参数传递时必须带上第二维的长度,否则编译器根本算不出行的偏移量。字符串数组初始化时还要给结尾的 \0 留空间,很多人在这上面翻过车。
这些基础知识和单调数组判断不是直接相关,但它们决定了你写遍历代码时会不会被语言细节卡住。我见过有人用 C++ 写 vector<int> 挺好,一换 C 风格数组就忘了数组形参退化成指针的问题;也见过有人用 Python 列表很顺手,但遇到多维数组就分不清深拷贝和浅拷贝。这些基础如果不牢,遇到题目本身不难的单调数组,也可能被周边语法拖累。
我在实际编码中养成了一个习惯:每写一个数组相关函数,就顺手把空数组、单元素、全相等、全部随机这四类测试用例放在旁边,跑完再提交。单调数组的判断代码本身不到十行,真正拉开差距的,永远是对定义的理解和边界条件的敏感度。这个习惯,比背住任何一种解法都值钱。
