【LeetCode 每日一题】3721. 最长平衡子数组 II ——(解法二)分块

Problem: 3721. 最长平衡子数组 II

文章目录

  • [1. 整体思路](#1. 整体思路)
  • [2. 完整代码](#2. 完整代码)
  • [3. 时空复杂度](#3. 时空复杂度)
      • [时间复杂度: O ( N N ) O(N \sqrt{N}) O(NN )](#时间复杂度: O ( N N ) O(N \sqrt{N}) O(NN ))
      • [空间复杂度: O ( N ) O(N) O(N)](#空间复杂度: O ( N ) O(N) O(N))

1. 整体思路

核心问题

找到最长的子数组,满足 "不同奇数个数" 等于 "不同偶数个数"

算法逻辑:分块 (Square Root Decomposition)

相比于线段树的树形结构,分块将数组分为 N \sqrt{N} N 个块,每个块维护自己的局部信息。

  1. 数据结构设计

    • sum 数组:存储每个位置的基础值(不包含懒标记)。
    • Block :维护每个块的信息。
      • l, r:块的左右边界。
      • todo:懒标记(Lazy Tag),表示整个块都需要加上的数值。
      • pos Map :这是分块的核心优化。它存储该块内 每个数值第一次出现的索引
        • Key: 数值 v v v。
        • Value: 在该块内,值等于 v v v 的最小索引 j j j(即 sum[j] == v)。
        • 这允许我们在 O ( 1 ) O(1) O(1) 时间内判断一个块内是否存在某个目标值,并找到其位置。
  2. 操作逻辑

    • 区间更新 (rangeAdd)
      • 对于中间的完整块 :直接修改 todo 标记。 O ( 1 ) O(1) O(1)。
      • 对于两端的残缺块 :暴力更新 sum 数组,应用之前的 todo,然后重构 该块的 pos Map。这是最耗时的部分,耗时 O ( N ) O(\sqrt{N}) O(N )。
    • 查询 (findFirst)
      • 我们需要找到最小的索引 j j j(且 j < i − a n s j < i - ans j<i−ans),使得该位置的值等于目标值。
      • 遍历每个块:
        • 完整块 :检查 pos Map 中是否有 target - todo。如果有,直接返回。 O ( 1 ) O(1) O(1)。
        • 残缺块 (通常是最后一个块的部分):暴力遍历查找。 O ( N ) O(\sqrt{N}) O(N )。
  3. 业务逻辑

    • 与之前的线段树解法类似,维护子数组的"去重奇偶差"。
    • 如果 x 是奇数,贡献 + 1 +1 +1;偶数贡献 − 1 -1 −1。
    • x 新出现时,在 [i, n] 加上 v v v。
    • x 重复出现时,在 [last, i-1] 减去 v v v(消除旧位置对当前窗口的贡献)。
    • 查询目标:找到最早的 j j j,使得 State[j] == State[i](逻辑上等价于寻找平衡子数组)。

2. 完整代码

java 复制代码
import java.util.*;

class Solution {
    // 分块的核心类
    private static class Block {
        int l, r;      // 块的左右边界 [l, r)
        int todo;      // 懒标记:整个块统一增加的值
        Map<Integer, Integer> pos; // 记录块内每个值第一次出现的下标

        Block(int l, int r, Map<Integer, Integer> pos) {
            this.l = l;
            this.r = r;
            this.pos = pos;
            this.todo = 0;
        }
    }

    private int[] sum;      // 存储实际数值 (不含 todo)
    private Block[] blocks; // 存储块信息
    private int n;

    public int longestBalanced(int[] nums) {
        n = nums.length;
        // 计算块大小,通常为 sqrt(N)。这里除以 2 可能是为了增加块的数量减小块内重建开销
        int bSize = (int) (Math.sqrt(n + 1) / 2) + 1;
        
        sum = new int[n + 1];
        blocks = new Block[(n / bSize) + 1];

        // 1. 初始化分块
        for (int i = 0, idx = 0; i <= n; i += bSize) {
            int r = Math.min(i + bSize, n + 1);
            // 初始时 sum 全为 0,构建初始的 pos 映射
            blocks[idx++] = new Block(i, r, calcPos(i, r));
        }

        Map<Integer, Integer> last = new HashMap<>();
        int ans = 0;

        // 2. 遍历数组
        for (int i = 1; i <= n; i++) {
            int x = nums[i - 1];
            int v = (x % 2 != 0) ? 1 : -1;
            
            // 更新贡献:维护区间去重计数
            if (!last.containsKey(x)) {
                // 第一次出现,影响从 i 开始到最后的所有区间起点
                rangeAdd(i, n + 1, v);
            } else {
                // 重复出现,消除上一次出现位置对 [last, i-1] 范围的影响
                rangeAdd(last.get(x), i, -v);
            }
            last.put(x, i);

            // 获取当前 i 位置的实际值 (基础值 + 所在块的懒标记)
            int s = sum[i] + blocks[i / bSize].todo;
            
            // 查询:在 [0, i - ans) 范围内查找第一个值等于 s 的位置
            // 如果找到了 idx,说明 nums[idx+1...i] 是平衡的
            int firstIdx = findFirst(i - ans, s);
            
            // 更新最大长度
            // 注意:findFirst 返回的是索引,如果没找到返回 n
            // 如果找到,长度为 i - firstIdx
            ans = Math.max(ans, i - firstIdx);
        }

        return ans;
    }

    // 辅助:计算/重构块内的 pos Map
    // key: 数值, value: 该数值在块内第一次出现的下标
    private Map<Integer, Integer> calcPos(int l, int r) {
        Map<Integer, Integer> pos = new HashMap<>();
        // 从右向左遍历,这样 map.put 会覆盖后面的,保留最前面的索引
        for (int j = r - 1; j >= l; j--) {
            pos.put(sum[j], j);
        }
        return pos;
    }

    // 区间更新:[l, r) 加上 v
    private void rangeAdd(int l, int r, int v) {
        for (Block b : blocks) {
            if (b == null || b.r <= l) continue; // 块在区间左侧,跳过
            if (b.l >= r) break; // 块在区间右侧,结束

            // 完整覆盖的块:直接更新 todo
            if (l <= b.l && b.r <= r) {
                b.todo += v;
            } else {
                // 部分覆盖的块:暴力重构
                // 1. 先把旧的 todo 下放 (push down)
                for (int j = b.l; j < b.r; j++) {
                    sum[j] += b.todo;
                    // 2. 对重叠部分应用新的 v
                    if (j >= l && j < r) {
                        sum[j] += v;
                    }
                }
                // 3. 重置 todo
                b.todo = 0;
                // 4. 重新计算该块的 pos Map (耗时 O(BlockSize))
                b.pos = calcPos(b.l, b.r);
            }
        }
    }

    // 查询:找到小于 r 的第一个位置,其值等于 v
    private int findFirst(int r, int v) {
        for (Block b : blocks) {
            if (b == null) break;
            
            // 如果整个块都在查询范围内 [0, r)
            if (b.r <= r) {
                // 检查该块内是否有值等于 v - todo
                // 利用 Map O(1) 查找
                Integer j = b.pos.get(v - b.todo);
                if (j != null) return j;
            } else {
                // 块部分在范围内(最后一个块),暴力查找
                for (int j = b.l; j < r; j++) {
                    if (sum[j] == v - b.todo) {
                        return j;
                    }
                }
                break; // 超过 r,后续块无需再看
            }
        }
        return n; // 未找到
    }
}

3. 时空复杂度

假设数组长度为 N N N,块大小 B ≈ N B \approx \sqrt{N} B≈N 。

时间复杂度: O ( N N ) O(N \sqrt{N}) O(NN )

  • 更新 (rangeAdd)
    • 最多有两个"部分重叠"的块,需要 O ( B ) O(B) O(B) 重构 Map。
    • 中间有 O ( N / B ) O(N/B) O(N/B) 个完整块,仅需 O ( 1 ) O(1) O(1) 更新标记。
    • 单次更新复杂度: O ( B + N / B ) ≈ O ( N ) O(B + N/B) \approx O(\sqrt{N}) O(B+N/B)≈O(N )。
  • 查询 (findFirst)
    • 遍历 O ( N / B ) O(N/B) O(N/B) 个完整块,每个块查 Map 是 O ( 1 ) O(1) O(1)。
    • 最后一个部分块遍历 O ( B ) O(B) O(B)。
    • 单次查询复杂度: O ( N ) O(\sqrt{N}) O(N )。
  • 总复杂度 :总共循环 N N N 次,所以是 O ( N N ) O(N \sqrt{N}) O(NN )。

空间复杂度: O ( N ) O(N) O(N)

  • sum 数组 : O ( N ) O(N) O(N)。
  • blocks 数组 : O ( N ) O(\sqrt{N}) O(N ) 个对象。
  • pos Maps :所有块的 Map 加起来,最坏情况下存储所有位置的信息,总量为 O ( N ) O(N) O(N)。
  • 结论 : O ( N ) O(N) O(N)。
相关推荐
im_AMBER1 小时前
Leetcode 118 从中序与后序遍历序列构造二叉树 | 二叉树的最大深度
数据结构·学习·算法·leetcode
Faker66363aaa1 小时前
YOLOv10n改进实现CFPT-P23456算法——压力容器管道表面轻微锈蚀检测
算法·yolo·计算机视觉
闻缺陷则喜何志丹1 小时前
【动态规划 AC自动机】P9188 [USACO23OPEN] Pareidolia S|普及+
c++·算法·动态规划·洛谷·ac自动机
shehuiyuelaiyuehao1 小时前
21优先级队列
算法
kanhao1001 小时前
时空反向传播 (STBP) 算法
算法
cpp_25011 小时前
P10250 [GESP样题 六级] 下楼梯
数据结构·c++·算法·动态规划·题解·洛谷
m0_528749001 小时前
linux编程----目录流
java·前端·数据库
小范自学编程1 小时前
算法训练营 Day27 - 贪心算法part01
算法·贪心算法
spencer_tseng1 小时前
Thumbnail display
java·minio