news 2026/9/30 4:49:43

线段树求解最长奇偶种类平衡子数组:从滑动窗口失效到O(n log n)算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
线段树求解最长奇偶种类平衡子数组:从滑动窗口失效到O(n log n)算法

1. 这题差在哪:滑动窗口和二分答案为什么都失效

先说结论:这题最迷惑人的地方,就是“互不相同的偶数个数”和“互不相同的奇数个数”相等。很多人第一反应是滑动窗口,觉得只要窗口里奇数个数和偶数个数一样就行。但题目要的不是“出现的次数相等”,而是“出现的不同数字的种数相等”。

举个例子,区间[2, 4, 2]里偶数一共出现了 3 次,但互不相同的偶数只有{2, 4}两个。所以这个区间evenDistinct = 2,oddDistinct = 0,并不平衡。同样的,区间[1, 3, 5]奇数出现 3 次,但互不相同的奇数有{1, 3, 5}三个,也没有偶数,不平衡。

这意味着我们维护的不是“奇偶数量差”,而是“奇偶种类差”。一旦从统计数量变成统计种类,很多在普通子数组问题上好用的套路就失灵了。

滑动窗口失效的原因,是条件不具备单调性。普通“奇数个数等于偶数个数”的问题,窗口扩大时奇偶数个数各加一,差值变化是可预测的;但种类差不一样。窗口右边加入一个数,可能让偶数种类 +1,也可能让奇数种类 +1,还可能因为该数字已经出现过而种类不变。窗口左边移出一个数,同样可能让当前类别种类 -1,也可能因为后面还有同值数字而种类不变。差值一会儿增、一会儿减、一会儿不动,根本没有“窗口大了就该收缩,窗口小了就该扩张”的规律。

我用一个具体例子来展示这种反复横跳。数组[2, 3, 4],区间[2,3]是 balance 的:偶数种类{2}一个,奇数种类{3}一个。向右扩展成[2,3,4],偶数种类变成{2,4}两个,奇数种类还是{3}一个,反而不平衡了。如果继续往后加一个1,得到[2,3,4,1],偶数种类{2,4}两个,奇数种类{3,1}两个,又平衡了。同一个方向扩展,平衡状态从 true 到 false 再到 true,双指针自然无从下手。

也有人想到二分答案。二分长度 L,然后用滑动窗口检查是否存在长度为 L 的平衡区间。这个做法的问题在于:句子“存在某个长度至少为 L 的平衡区间”是单调的,但“存在长度恰好为 L 的平衡区间”不是单调的。一个长度更大的平衡区间,并不保证能切出一段长度刚好等于 L 的子区间仍然平衡。

以数组[1, 2, 3, 4]为例,整个数组里偶数种类{2,4}两个,奇数种类{1,3}两个,长度为 4 时平衡。但长度为 3 的连续子区间[1,2,3]偶数种类 1 个、奇数种类 2 个,不平衡;[2,3,4]偶数种类 2 个、奇数种类 1 个,也不平衡。长度为 3 没有任何平衡区间,长度为 4 却存在。所以用“固定长度滑动窗口”做二分检查,会得到错误结论:不存在长度 3 的平衡区间,但确实存在长度大于等于 3 的平衡区间。二分答案这条路,至少不能配合简单的固定长度窗口来走。

因此,我们需要换一种完全不同的视角:不去枚举左端点,也不去猜长度,而是枚举右端点,快速找出当前右端点下最靠左的合法左端点。只要每个右端点能 O(log n) 查询,总复杂度就能压到 O(n log n)。

2. 核心洞察:固定右端点后,左端点移动时“奇偶种类差”不会跳变

我们固定右端点为r,然后考虑所有可能的左端点l(1 <= l <= r)。定义一个关键指标:

  • diff(l) = 区间 [l, r] 内互不相同的偶数个数 - 区间 [l, r] 内互不相同的奇数个数

平衡区间要求diff(l) = 0。对固定的r,我们想知道1..r中哪个l让diff(l)等于 0,并且l要尽量小,因为r - l + 1就是区间长度。

这看起来还是要算很多东西。但有一个非常反直觉的性质:当左端点从l挪到l + 1时,diff的变化量只可能是-1、0、1三选一。

原因很简单。左端点右移一格,等于从区间里删掉了nums[l]这个元素。分两种情况:

  1. nums[l]在[l+1, r]中仍然出现过。也就是说后面还有同样的值。那么它对应的那个奇偶类别里,种类数不会被减少,diff保持不变。因为“互不相同”的数还在。

  2. nums[l]在[l+1, r]中再也没出现过。那么这个数字所在的类别种类数会减一。如果它是偶数,evenDistinct减 1,diff = evenDistinct - oddDistinct就减 1;如果它是奇数,oddDistinct减 1,diff就加 1。

所以左边界的相邻两个位置,diff的差值永远落在[-1, 0, 1]。这个性质看起来不起眼,但它直接决定了我们可以用一个很简单的线段树去查“最左边的 0”。

为什么这么说?考虑一个连续的左端点区间[L, R],如果这个区间上的diff值最小值小于等于 0,最大值大于等于 0,而相邻两个位置的变化幅度不可能超过 1,那么这一个区间里一定存在某个位置diff正好等于 0。因为从最小值位置走到最大值位置,每一步最多跨 1,不可能在中间跨过 0 却不踩到它。

你可以把它想象成走台阶:每一步要么不动、上一级、下一级。如果你从负数台阶走到正数台阶,中间必然有一步踩在 0 级台阶上。

这个结论就是线段树能用的关键。线段树维护区间最小值min和最大值max之后,判断某一段有没有 0,不再需要额外维护哈希表或者有序数组。只要这个节点的min <= 0 && max >= 0,就说明它内部一定有 0。想要找最左边的 0,递归时优先走左子树即可。

3. 维护 diff 数组的区间加:每个数字只用关心“最后一次出现的位置”

固定右端点r,我们需要动态维护所有l的diff(l)。当右端点从r - 1扩展到r的时候,新加入的元素是x = nums[r]。我们要弄清楚:对于哪些左端点l,diff(l)会发生变化?

关键在于x上一次出现在哪里。记pre = lastPos[x],也就是在r之前x最后一次出现的位置。如果没有出现过,pre = 0。

如果左端点l <= pre,说明区间[l, r]里在加入r之前就已经包含过x了,因为pre是[l, r-1]内的一个位置。那么对x所属的奇偶类别来说,x并不是新出现的,种类数不变,所以diff(l)不需要变。

如果左端点l > pre,注意pre是r之前最后一次出现的位置,那么[l, r-1]中不包含x,加入r后x是第一次出现。它所属的奇偶类别种类数加 1,于是diff(l)也要跟着变化:

  • x是偶数:evenDistinct加 1,diff加 1。
  • x是奇数:oddDistinct加 1,diff减 1。

把上面的分析翻译成操作,就是在数组diff上做一次区间加:

  • 区间范围:[pre + 1, r]
  • 加上的值:偶数+1,奇数-1

这里最容易被忽略的是:区间右端点必须包含r本身。因为新增的左端点l = r对应单元素区间[r, r],它本来没有被初始化。我们可以把线段树初始全部置为 0,然后在处理第r个元素时,把[pre + 1, r]加一遍,这样位置r就会从 0 变成+1或-1,正好等于单元素区间的diff。

更新完diff数组后,我们只需要在线段树里查询区间[1, r]内最左边的 0。如果找到了位置l,那么[l, r]就是当前右端点下的最长平衡区间,答案更新为r - l + 1。

这里还有一个细节:pre是x上一次出现的位置,对于第一次出现的数字pre = 0,区间就成了[1, r],说明当前所有左端点对应的区间里,x都是新出现的。这个边界很好处理,因为pre + 1 = 1。

线段树需要支持两件事:

  • 区间加,更新时用懒标记维护,复杂度 O(log n)。
  • 查询最左的值为 0 的的下标,利用前面提到的 min / max 性质递归剪枝,复杂度 O(log n)。

所以整体复杂度就是 O(n log n),空间 O(n)。即使数组长度到几十万,也能轻松跑完。

4. Go 语言完整实现:线段树 + 最后一次出现位置

下面给出完整的 Go 实现。代码里包含两个部分:一个是 O(n log n) 的longestBalancedSubarray,另一个是暴力的brute,方便对拍验证。

线段树我用数组实现,存储每个节点的区间最小值、最大值和懒标记。节点编号从 1 开始,左右孩子分别是idx*2和idx*2+1。

package main import ( "fmt" ) type SegTree struct { n int min []int max []int lazy []int } func NewSegTree(n int) *SegTree { size := 4*n + 5 return &SegTree{ n: n, min: make([]int, size), max: make([]int, size), lazy: make([]int, size), } } func (seg *SegTree) apply(idx, delta int) { seg.min[idx] += delta seg.max[idx] += delta seg.lazy[idx] += delta } func (seg *SegTree) push(idx int) { if seg.lazy[idx] != 0 { seg.apply(idx*2, seg.lazy[idx]) seg.apply(idx*2+1, seg.lazy[idx]) seg.lazy[idx] = 0 } } func (seg *SegTree) pull(idx int) { seg.min[idx] = seg.min[idx*2] if seg.min[idx*2+1] < seg.min[idx] { seg.min[idx] = seg.min[idx*2+1] } seg.max[idx] = seg.max[idx*2] if seg.max[idx*2+1] > seg.max[idx] { seg.max[idx] = seg.max[idx*2+1] } } func (seg *SegTree) add(idx, l, r, ql, qr, delta int) { if ql <= l && r <= qr { seg.apply(idx, delta) return } seg.push(idx) mid := (l + r) / 2 if ql <= mid { seg.add(idx*2, l, mid, ql, qr, delta) } if qr > mid { seg.add(idx*2+1, mid+1, r, ql, qr, delta) } seg.pull(idx) } func (seg *SegTree) Add(l, r, delta int) { if l > r { return } seg.add(1, 1, seg.n, l, r, delta) } // findFirstZero 返回 [ql, qr] 中最左边的 0 的下标;如果不存在,返回 -1。 func (seg *SegTree) findFirstZero(idx, l, r, ql, qr int) int { if ql > r || qr < l { return -1 } if ql <= l && r <= qr { // 整个节点都在查询区间内,用 min/max 判断有没有 0 if seg.min[idx] > 0 || seg.max[idx] < 0 { return -1 } if l == r { return l } seg.push(idx) mid := (l + r) / 2 if res := seg.findFirstZero(idx*2, l, mid, l, mid); res != -1 { return res } return seg.findFirstZero(idx*2+1, mid+1, r, mid+1, r) } seg.push(idx) mid := (l + r) / 2 if qr <= mid { return seg.findFirstZero(idx*2, l, mid, ql, qr) } if ql > mid { return seg.findFirstZero(idx*2+1, mid+1, r, ql, qr) } // 跨左右孩子,优先左边 if res := seg.findFirstZero(idx*2, l, mid, ql, mid); res != -1 { return res } return seg.findFirstZero(idx*2+1, mid+1, r, mid+1, qr) } func (seg *SegTree) FindFirstZero(l, r int) int { if l > r { return -1 } return seg.findFirstZero(1, 1, seg.n, l, r) } func longestBalancedSubarray(nums []int) int { n := len(nums) if n == 0 { return 0 } seg := NewSegTree(n) lastPos := make(map[int]int) ans := 0 for i, x := range nums { r := i + 1 pre := lastPos[x] lastPos[x] = r delta := 1 // 偶数:evenDistinct 增加,diff 加 1 if x&1 == 1 { delta = -1 // 奇数:oddDistinct 增加,diff 减 1 } seg.Add(pre+1, r, delta) l := seg.FindFirstZero(1, r) if l != -1 && r-l+1 > ans { ans = r-l+1 } } return ans } // brute 是 O(n^2) 的暴力实现,用于对拍。 func brute(nums []int) int { n := len(nums) ans := 0 for i := 0; i < n; i++ { even := make(map[int]bool) odd := make(map[int]bool) for j := i; j < n; j++ { if nums[j]&1 == 0 { even[nums[j]] = true } else { odd[nums[j]] = true } if len(even) == len(odd) { if j-i+1 > ans { ans = j-i+1 } } } } return ans } func main() { cases := [][]int{ {2, 3, 4}, {2, 2, 3, 3}, {1, 1}, {1, 2, 3, 4, 1, 2}, {-2, -4, 1, 3, 7}, } for _, nums := range cases { fmt.Printf("nums=%v, 线段树结果=%d, 暴力结果=%d\n", nums, longestBalancedSubarray(nums), brute(nums)) } }

这里特别说明一下findFirstZero的写法。对于一个完全被查询区间覆盖的节点,我先看min > 0 || max < 0。如果为真,说明这个节点里绝对没有 0,直接返回 -1。如果min <= 0 && max >= 0,结合前面证明的“相邻 diff 变化幅度不超过 1”,可以确定这个节点内一定有 0,于是往下递归,先查左孩子,再查右孩子,找到最左边的 0。

在部分覆盖的情况下,函数会按照查询区间的边界分裂到对应的子节点,避免遍历整棵树。整个查询过程平均只需要访问 O(log n) 个节点。

5. 复杂度、边界条件与踩坑记录

这个算法的时间复杂度是 O(n log n),空间复杂度 O(n)。每个元素进线段树的时候做一次区间加,每次查询最左 0 也是 O(log n),所以总复杂度非常稳定。即使数组长度到 10^6,在 Go 里也就是一次轻松的遍历加几次二分下降。

下面是我实际实现和测试过程中踩过的几个坑,每一个都值得单独提醒。

第一个坑是 Go 语言判断奇偶。很多人在别的语言里写习惯了x % 2 == 1表示奇数。但 Go 的%运算结果符号和左操作数一致,负数的时候会有问题:-3 % 2的结果是-1,不是1。如果用x % 2 == 1判断奇数,负数全都会被当成偶数。稳妥的做法是用位运算:x & 1 == 1判断奇数,x & 1 == 0判断偶数。二进制的最低为 1 就是奇数,这跟正负无关。

第二个坑是lastPos的初始化。map 的零值是 0,所以某个数字第一次出现时pre = 0。此时区间加的范围是[1, r],正好覆盖所有左端点。这个边界看起来简单,但如果你在代码里写pre+1时漏掉了+1,就会变成[0, r],线段树会越界或更新到不存在的下标。同理,如果旧位置pre已经等于r之前的位置,区间加范围是[pre+1, r],一定是一个非空区间。

第三个坑是线段树初始全 0 的含义。初始时所有位置都是 0,但这不代表所有位置都有实际意义。查询的时候必须只查[1, r],不能图省事查整个[1, n]。因为r之后的那些位置还没有参与到当前右端点的问题里,它们的 diff 是“空区间”的 0,是无效状态。如果查全树,线段树可能会找到一个排在很前面的无效 0,导致答案错误。

我在写第一版的时候,确实犯过这个错误:FindFirstZero(1, n)比FindFirstZero(1, r)看起来更“统一”,结果在数组长度比当前右端点大的情况下,把还没用到的未来位置当成合法左端点,答案疯狂出错。后来用暴力对拍一眼就看出了问题。

第四个坑是代码里的push和pull配对。区间加操作中,add走到部分覆盖节点时必须先push,把父节点的懒标记下传给子节点,然后用子节点更新完的值pull回父节点。查询操作里只需要push,不需要pull,因为查询不修改任何值。如果你在查询里多加了一个pull,会破坏节点原有的懒标记结构,导致后续更新错乱。

我把线段树方案和暴力方案跑了一些随机数据,主要测试负数、重复数字、连续递增数字等场景。比如nums = [-2, -4, 1, 3, 7],暴力结果是 2,线段树结果也是 2。原因是[-2, -4]里偶数种类 2 个,奇数 0,不平衡;[-2, 1]偶数 1 个、奇数 1 个,平衡,长度 2。负数值在这个算法里没有任何额外问题,只要位运算判断奇偶正确即可。

另外,如果你要处理的数据值域很大,比如超出 int 范围,那map[int]int依然没问题,因为 map 的 key 直接存具体整数值。Go 的 int 在 64 位系统上是 64 位,存普通整数完全够用。

这套思路还可以推广到很多类似问题。只要统计指标是“某个类别中不同元素的个数”,并且类别只有两三种,就可以把每个元素映射成权值,用“最后一次出现位置”做区间加,再用线段树维护一段序列的极值来查目标值。核心就是抓住相邻位置指标变化幅度不超过 1 这条隐藏性质。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:48:43

专科生毕业论文AI写作工具TOP10测评与实用指南

又到毕业季了&#xff0c;专科生写毕业论文这件事&#xff0c;真不是光靠努力就能扛过去的。我们学校正文要求八千字&#xff0c;还带开题报告、中期检查表、答辩PPT&#xff0c;数据要自己跑&#xff0c;图表要自己画&#xff0c;格式改了整整三天。第一次写论文的人&#xff…

作者头像 李华
网站建设 2026/9/30 4:47:54

LeetCode 1416 恢复数组:字符串分割中的计数型动态规划

第一次见到 1416 这道题时&#xff0c;我其实是被题目描述里“恢复数组”这个说法吸引的。LeetCode 上很多困难题难点都藏在边界和状态设计里&#xff0c;这道题也不例外&#xff1a;表面上是字符串分割&#xff0c;本质上却是一道非常经典的计数型动态规划&#xff0c;而且一不…

作者头像 李华
网站建设 2026/9/30 4:47:42

Linux Java开发环境配置:从原理到可复现的四层隔离方案

1. 为什么在Linux上装Java开发环境不是“点下一步”那么简单很多人第一次在Linux上配Java开发环境&#xff0c;以为就是下载个JDK、解压、改下PATH——结果跑个HelloWorld都报错&#xff1a;command not found: javac&#xff0c;或者IDEA里提示“Cannot determine path to too…

作者头像 李华
网站建设 2026/9/30 4:47:26

手机检测数据集实战:2800张YOLO格式数据从训练到部署

1. 手机检测数据集到底解决什么问题1.1 从一次产线误检说起去年帮一个做手机回收分拣的朋友看他们线上的视觉系统&#xff0c;场景很典型&#xff1a;传送带上跑着各种型号的旧手机&#xff0c;摄像头拍图&#xff0c;后端判断"有没有手机""手机在哪个位置"…

作者头像 李华
网站建设 2026/9/30 4:47:26

基于YOLO的8300张头盔检测数据集全流程实战:从数据体检到部署落地

头盔检测这个方向&#xff0c;我在智慧交通和工地安全两个场景里都实际跑过模型&#xff0c;从最早拿YOLOv5凑数据&#xff0c;到后来专门整理数据集、调anchor、处理小目标漏检&#xff0c;踩过的坑不算少。这次拿到的是一份8300张规模的YOLO格式头盔检测数据集&#xff0c;标…

作者头像 李华