|
| 1 | +### 题目描述 |
| 2 | + |
| 3 | +这是 LeetCode 上的 **[239. 滑动窗口最大值]()** ,难度为 **困难**。 |
| 4 | + |
| 5 | +Tag : 「优先队列(堆)」、「线段树」、「分块」、「单调队列」、「RMQ」 |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +给你一个整数数组 `nums`,有一个大小为 `k` 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 `k` 个数字。滑动窗口每次只向右移动一位。 |
| 10 | + |
| 11 | +返回滑动窗口中的最大值 。 |
| 12 | + |
| 13 | +示例 1: |
| 14 | +``` |
| 15 | +输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 |
| 16 | + |
| 17 | +输出:[3,3,5,5,6,7] |
| 18 | + |
| 19 | +解释: |
| 20 | +滑动窗口的位置 最大值 |
| 21 | +--------------- ----- |
| 22 | +[1 3 -1] -3 5 3 6 7 3 |
| 23 | + 1 [3 -1 -3] 5 3 6 7 3 |
| 24 | + 1 3 [-1 -3 5] 3 6 7 5 |
| 25 | + 1 3 -1 [-3 5 3] 6 7 5 |
| 26 | + 1 3 -1 -3 [5 3 6] 7 6 |
| 27 | + 1 3 -1 -3 5 [3 6 7] 7 |
| 28 | +``` |
| 29 | +示例 2: |
| 30 | +``` |
| 31 | +输入:nums = [1], k = 1 |
| 32 | + |
| 33 | +输出:[1] |
| 34 | +``` |
| 35 | + |
| 36 | +提示: |
| 37 | +* 1ドル <= nums.length <= 10^5$ |
| 38 | +* $-10^4 <= nums[i] <= 10^4$ |
| 39 | +* 1ドル <= k <= nums.length$ |
| 40 | + |
| 41 | +--- |
| 42 | + |
| 43 | +### 优先队列(堆) |
| 44 | + |
| 45 | +根据题意,容易想到优先队列(大根堆),同时为了确保滑动窗口的大小合法性,我们以二元组 $(idx, nums[idx])$ 的形式进行入队。 |
| 46 | + |
| 47 | +当下标达到首个滑动窗口的右端点后,每次尝试从优先队列(大根堆)中取出最大值(若堆顶元素的下标小于当前滑动窗口左端点时,则丢弃该元素)。 |
| 48 | + |
| 49 | +代码: |
| 50 | +```Java |
| 51 | +class Solution { |
| 52 | + public int[] maxSlidingWindow(int[] nums, int k) { |
| 53 | + PriorityQueue<int[]> q = new PriorityQueue<>((a,b)->b[1]-a[1]); |
| 54 | + int n = nums.length, m = n - k + 1, idx = 0; |
| 55 | + int[] ans = new int[m]; |
| 56 | + for (int i = 0; i < n; i++) { |
| 57 | + q.add(new int[]{i, nums[i]}); |
| 58 | + if (i >= k - 1) { |
| 59 | + while (q.peek()[0] <= i - k) q.poll(); |
| 60 | + ans[idx++] = q.peek()[1]; |
| 61 | + } |
| 62 | + } |
| 63 | + return ans; |
| 64 | + } |
| 65 | +} |
| 66 | +``` |
| 67 | +* 时间复杂度:$O(n\log{n})$ |
| 68 | +* 空间复杂度:$O(n)$ |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +### 线段树 |
| 73 | + |
| 74 | +容易将问题转换为「区间求和」问题:使用原始的 `nums` 构建线段树等价于在位置 $i$ 插入 $nums[i],ドル即单点操作,而查询每个滑动窗口最大值,则对应的区间查询。 |
| 75 | + |
| 76 | +由于只涉及单点修改,无须实现懒标记 `pushdown` 操作,再结合 $n$ 的数据范围为 10ドル^5,ドル无须进行动态开点。 |
| 77 | + |
| 78 | +直接写 `build` 四倍空间的线段树数组实现即可。 |
| 79 | + |
| 80 | +代码: |
| 81 | +```Java |
| 82 | +class Solution { |
| 83 | + class Node { |
| 84 | + int l, r, val; |
| 85 | + Node (int _l, int _r) { |
| 86 | + l = _l; r = _r; val = Integer.MIN_VALUE; |
| 87 | + } |
| 88 | + } |
| 89 | + Node[] tr = new Node[100010 * 4]; |
| 90 | + void build(int u, int l, int r) { |
| 91 | + tr[u] = new Node(l, r); |
| 92 | + if (l == r) return ; |
| 93 | + int mid = l + r >> 1; |
| 94 | + build(u << 1, l, mid); |
| 95 | + build(u << 1 | 1, mid + 1, r); |
| 96 | + } |
| 97 | + void update(int u, int x, int v) { |
| 98 | + if (tr[u].l == x && tr[u].r == x) { |
| 99 | + tr[u].val = Math.max(tr[u].val, v); |
| 100 | + return ; |
| 101 | + } |
| 102 | + int mid = tr[u].l + tr[u].r >> 1; |
| 103 | + if (x <= mid) update(u << 1, x, v); |
| 104 | + else update(u << 1 | 1, x, v); |
| 105 | + pushup(u); |
| 106 | + } |
| 107 | + int query(int u, int l, int r) { |
| 108 | + if (l <= tr[u].l && tr[u].r <= r) return tr[u].val; |
| 109 | + int mid = tr[u].l + tr[u].r >> 1, ans = Integer.MIN_VALUE; |
| 110 | + if (l <= mid) ans = query(u << 1, l, r); |
| 111 | + if (r > mid) ans = Math.max(ans, query(u << 1 | 1, l, r)); |
| 112 | + return ans; |
| 113 | + } |
| 114 | + void pushup(int u) { |
| 115 | + tr[u].val = Math.max(tr[u << 1].val, tr[u << 1 | 1].val); |
| 116 | + } |
| 117 | + public int[] maxSlidingWindow(int[] nums, int k) { |
| 118 | + int n = nums.length, m = n - k + 1; |
| 119 | + int[] ans = new int[m]; |
| 120 | + build(1, 1, n); |
| 121 | + for (int i = 0; i < n; i++) update(1, i + 1, nums[i]); |
| 122 | + for (int i = k; i <= n; i++) ans[i - k] = query(1, i - k + 1, i); |
| 123 | + return ans; |
| 124 | + } |
| 125 | +} |
| 126 | +``` |
| 127 | +* 时间复杂度:建立线段树复杂度为 $O(n\log{n})$;构建答案复杂度为 $O(n\log{n})$。整体复杂度为 $O(n\log{n})$ |
| 128 | +* 空间复杂度:$O(n)$ |
| 129 | + |
| 130 | +--- |
| 131 | + |
| 132 | +### 分块 |
| 133 | + |
| 134 | +另外一个做法是分块,又名「优雅的暴力」,也是莫队算法的基础。 |
| 135 | + |
| 136 | +具体的,除了给定的 `nums` 以外,我们构建一个分块数组 `region`,其中 `region[idx] = x` 含义为块编号为 `idx` 的最大值为 `x`,其中一个块对应一个原始区间 $[l, r]$。 |
| 137 | + |
| 138 | +如何定义块大小是实现分块算法的关键。 |
| 139 | + |
| 140 | +对于本题,本质是求若干个大小为 $k$ 的区间最大值。 |
| 141 | + |
| 142 | +我们可以设定块大小为 $\sqrt{k},ドル这样所需创建的分块数组大小为 $\frac{n}{\sqrt{k}}$。分块数组的更新操作为 $O(1),ドル而查询则为 $\sqrt{k}$。 |
| 143 | + |
| 144 | +容易证明查询操作的复杂度:对于每个长度为 $k$ 的 $[l, r]$ 查询操作而言,最多遍历两个(左右端点对应的块)的块内元素,复杂度为 $O(\sqrt{k}),ドル同时最多遍历 $\sqrt{k}$ 个块,复杂度同为 $O(\sqrt{k})$。 |
| 145 | + |
| 146 | +因此最多两步复杂度为 $O(\sqrt{k})$ 的块内操作,最多 $\sqrt{k}$ 步复杂度为 $O(1)$ 的块间操作,整体复杂度为 $O(\sqrt{k})$。 |
| 147 | + |
| 148 | +因此使用分块算法总的计算量为 $n\times\sqrt{k} = 10^6,ドル可以过。 |
| 149 | + |
| 150 | +分块算法的几个操作函数: |
| 151 | + |
| 152 | +* `int getIdx(int x)` :计算原始下标对应的块编号; |
| 153 | +* `void add(int x, int v)` :计算原始下标 `x` 对应的下标 `idx`,并将 `region[idx]` 和 `v` 取 `max` 来更新 `region[idx]`; |
| 154 | +* `int query(int l, int r)` :查询 $[l, r]$ 中的最大值,如果 $l$ 和 $r$ 所在块相同,直接遍历 $[l, r]$ 进行取值;若 $l$ 和 $r$ 不同块,则处理 $l$ 和 $r$ 对应的块内元素后,对块编号在 $(getIdx(l), getIdx(r))$ 之间的块进行遍历。 |
| 155 | + |
| 156 | +代码: |
| 157 | +```Java |
| 158 | +class Solution { |
| 159 | + int n, m, len; |
| 160 | + int[] nums, region; |
| 161 | + int getIdx(int x) { |
| 162 | + return x / len; |
| 163 | + } |
| 164 | + void add(int x, int v) { |
| 165 | + region[getIdx(x)] = Math.max(region[getIdx(x)], v); |
| 166 | + } |
| 167 | + int query(int l, int r) { |
| 168 | + int ans = Integer.MIN_VALUE; |
| 169 | + if (getIdx(l) == getIdx(r)) { |
| 170 | + for (int i = l; i <= r; i++) ans = Math.max(ans, nums[i]); |
| 171 | + } else { |
| 172 | + int i = l, j = r; |
| 173 | + while (getIdx(i) == getIdx(l)) ans = Math.max(ans, nums[i++]); |
| 174 | + while (getIdx(j) == getIdx(r)) ans = Math.max(ans, nums[j--]); |
| 175 | + for (int k = getIdx(i); k <= getIdx(j); k++) ans = Math.max(ans, region[k]); |
| 176 | + } |
| 177 | + return ans; |
| 178 | + } |
| 179 | + public int[] maxSlidingWindow(int[] _nums, int k) { |
| 180 | + nums = _nums; |
| 181 | + n = nums.length; len = (int) Math.sqrt(k); m = n / len + 10; |
| 182 | + region = new int[m]; |
| 183 | + Arrays.fill(region, Integer.MIN_VALUE); |
| 184 | + for (int i = 0; i < n; i++) add(i, nums[i]); |
| 185 | + int[] ans = new int[n - k + 1]; |
| 186 | + for (int i = 0; i < n - k + 1; i++) ans[i] = query(i, i + k - 1); |
| 187 | + return ans; |
| 188 | + } |
| 189 | +} |
| 190 | +``` |
| 191 | +* 时间复杂度:数组大小为 $n,ドル块大小为 $\sqrt{k},ドル分块数组大小为 $\frac{n}{\sqrt{k}}$。预处理分块数组复杂度为 $O(n)$(即 `add` 操作复杂度为 $O(1)$ );构造答案复杂度为 $O(n\times\sqrt{k})$(即 `query` 操作复杂度为 $O(\sqrt{k}),ドル最多有 $n$ 次查询) |
| 192 | +* 空间复杂度:$\frac{n}{\sqrt{k}}$ |
| 193 | + |
| 194 | +--- |
| 195 | + |
| 196 | +### 单调队列 |
| 197 | + |
| 198 | +关于 `RMQ` 的另外一个优秀做法通常是使用「单调队列/单调栈」。 |
| 199 | + |
| 200 | +当然,我们也能不依靠经验,而从问题的本身出发,逐步分析出该做法。 |
| 201 | + |
| 202 | +假设我们当前处理到某个长度为 $k$ 的窗口,此时窗口往后滑动一格,会导致后一个数(新窗口的右端点)添加进来,同时会导致前一个数(旧窗口的左端点)移出窗口。 |
| 203 | + |
| 204 | +随着窗口的不断平移,该过程会一直发生。**若同一时刻存在两个数 $nums[j]$ 和 $nums[i]$($j < i$)所在一个窗口内,下标更大的数会被更晚移出窗口,此时如果有 $nums[j] <= nums[i]$ 的话,可以完全确定 $nums[j]$ 将不会成为后续任何一个窗口的最大值,此时可以将必然不会是答案的 $nums[j]$ 从候选中进行移除**。 |
| 205 | + |
| 206 | +不难发现,当我们将所有必然不可能作为答案的元素(即所有满足的小于等于 $nums[i]$ )移除后,候选集合满足「单调递减」特性,即集合首位元素为当前窗口中的最大值(为了满足窗口长度为 $k$ 的要求,在从集合头部取答案时需要先将下标小于的等于的 $i - k$ 的元素移除)。 |
| 207 | + |
| 208 | +为方便从尾部添加元素,从头部获取答案,我们可使用「双端队列」存储所有候选元素。 |
| 209 | + |
| 210 | +代码: |
| 211 | +```Java |
| 212 | +class Solution { |
| 213 | + public int[] maxSlidingWindow(int[] nums, int k) { |
| 214 | + Deque<Integer> d = new ArrayDeque<>(); |
| 215 | + int n = nums.length, m = n - k + 1; |
| 216 | + int[] ans = new int[m]; |
| 217 | + for (int i = 0; i < n; i++) { |
| 218 | + while (!d.isEmpty() && nums[d.peekLast()] <= nums[i]) d.pollLast(); |
| 219 | + d.addLast(i); |
| 220 | + if (i >= k - 1) { |
| 221 | + while (!d.isEmpty() && d.peekFirst() <= i - k) d.pollFirst(); |
| 222 | + ans[i - k + 1] = nums[d.peekFirst()]; |
| 223 | + } |
| 224 | + } |
| 225 | + return ans; |
| 226 | + } |
| 227 | +} |
| 228 | +``` |
| 229 | +* 时间复杂度:$O(n)$ |
| 230 | +* 空间复杂度:$O(n)$ |
| 231 | + |
| 232 | +--- |
| 233 | + |
| 234 | +### 最后 |
| 235 | + |
| 236 | +这是我们「刷穿 LeetCode」系列文章的第 `No.239` 篇,系列开始于 2021年01月01日,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。 |
| 237 | + |
| 238 | +在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。 |
| 239 | + |
| 240 | +为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:https://github.com/SharingSource/LogicStack-LeetCode 。 |
| 241 | + |
| 242 | +在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。 |
| 243 | + |
0 commit comments