删除最短的子数组使剩余数组有序 图解题解
这道题到底在问什么
- 输入
- arr=[1,2,3]
- 输出
- 0
- 输入
- arr=[5,4,3,2,1]
- 输出
- 4
- 输入
- arr=[1,2,3,10,4,2,3,5]
- 输出
- 3
先想最直接的笨办法
第五次比较。l 走到下标 3 值是 10,r 在下标 6 值是 3,10 比 3 大,接不上。前缀尾巴这个 10 太大了,后缀里得找个不小于 10 的数才能接。继续把 r 右移试试。(动画第 21 步)
最优解:为什么这么做
一句话答案:LeetCode 1574 删除最短的子数组使剩余数组有序,用双指针:删一段连续区间后剩下的必是前缀加后缀,先框出最长非递减前缀和后缀,再让两个指针在接缝处对接、删得最少。时间 O(n)、空间 O(1)。
只能删掉连续的一段,这道题要你删哪里
给你一个数组 arr,只能删掉其中连续一段子数组,把左右剩下的接起来、要求整体非递减(后一个不小于前一个,相等允许)。返回这段的最短长度,删空段也算,本就有序时答案 0。题面 arr=[1,2,3,10,4,2,3,5] 的答案是 3。
为什么不去枚举每一种能删的区间
枚举被删区间的左右端点约 n² 对,每挖一段还要扫一遍验剩下是否非递减、又一趟 O(n),合起来 n³ 量级。数组长到 10⁵ 跑不完,得找扫一两遍就定答案的办法。
删完剩下的,为什么一定是前缀接后缀
只能删一段连续区间,挖掉后左边留开头一段前缀、右边留结尾一段后缀,中间空了。形状锁死成前缀接后缀,就不必枚举所有删法,只要分别求最长非递减前缀和后缀、在接缝处对接。对接用双指针:两个下标各从一端往里挪,一个盯前缀留到哪、一个盯后缀从哪接。
前后缀怎么框出来,两个指针又各往哪挪
从左扫最长非递减前缀、末尾记成 i:arr[k] ≤ arr[k+1] 就延伸,一断就停。再从右扫最长非递减后缀、起点记成 j。若 i 已不小于 j,整条本就非递减,直接返回 0。
否则先备两个保底:只留前缀删 n−i−1、只留后缀删 j,取较小的。再上双指针,l 从前缀头、r 从后缀头出发。arr[l] ≤ arr[r] 时接缝能拼,删中间 r−l−1 个、右移 l 多留一格前缀;接不上就右移 r 丢掉太小的后缀头。两指针都只右走,取全程最短删法。
拿 arr = 1,2,3,10,4,2,3,5 走一遍
从左扫前缀,1≤2≤3≤10 都成立,到 10 比后面的 4 大就断,前缀 1,2,3,10、末尾下标 3。从右扫后缀,5 起步、3≤5、2≤3 成立,到 4 比后面的 2 大就断,后缀 2,3,5、起点下标 5。两个保底:只留前缀删 8−3−1=4、只留后缀删 5,取小得 4。
再走双指针。l=0(值 1)配 r=5(值 2)能拼、删 4 个;l 右移到 1(值 2)配 r=5(值 2)仍能拼,删 3,10,4 三个、答案降到 3。l 到 2(值 3)比 r 的 2 大接不上,r 右移到 6(值 3)又能拼、还是删 3 个。l 到 3(值 10)比后缀的 3、5 都大、接不上,r 走出界结束。最短删 3 个:留 1,2 接 2,3,5,拼成 1,2,2,3,5,非递减。
前后缀用严格小于去扩,相等的那一截就被冤枉删掉
时间上 l 和 r 各自只右走、合起来最多 2n 步,加前后缀两遍扫描,整体 O(n);空间只几个下标变量,O(1)。页面参考代码是等价写法,对前缀里每个 l 在后缀区间做二分、找第一个不小于 arr[l] 的位置,答案与双指针一致。它每处一趟二分、O(n log n),比双指针慢一档。
还有几个地方要核。前后缀延伸要用「不大于」(arr[k] ≤ arr[k+1])、别写成严格小于,相等本就合法,像 1,2,2 用小于号会在两个 2 之间断开、把相等段也算进删除。i 是否已不小于 j 要先判、成立就返回 0,否则会对有序数组做无谓归并。别忘保底 min(n−i−1, j),像 5,4,3,2,1 双指针接不上,全靠它给 4。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记牢:剩下的一定是前缀加后缀。先求最长非递减前缀末尾 i、后缀起点 j;保底取 min(n-i-1, j);再双指针归并,arr[l] ≤ arr[r] 可拼、删 r-l-1 并 l 右移,接不上就 r 右移。
- 4先看清画面。上面这排格子是 arr = 1,2,3,10,4,2,3,5,一共 8 个数。我们要删掉中间连续的一段,让剩下的从左到右非递减,并且删的这段越短越好。右边面板记三件事:非递减前缀到哪结束、非递减后缀从哪开始、目前找到的最短删除长度,现在都还没定。我们分两步先把前缀和后缀框出来。
- 5先从左往右找最长的非递减前缀。第 0 个数 1 自己就是一段合法的非递减序列,标成绿色,绿色代表「保留下来的前缀」。接着我们一个个往右看,只要后一个不小于前一个,前缀就能继续延伸。
- 6看下标 1 的 2,它和前一个 1 比,1 不大于 2,非递减没断,前缀往右长一格,现在是 1,2。绿色段也跟着多盖一格。继续往后看。
- 7下标 2 的 3,和前一个 2 比,2 不大于 3,还在非递减,前缀延伸到 1,2,3。一路绿到下标 2。再看下一个。
- 8下标 3 的 10,和前一个 3 比,3 不大于 10,非递减依然成立,前缀延伸到 1,2,3,10。这个 10 暂时被收进前缀,但它会不会成为麻烦,接着看。
- 9到下标 4 的 4,和前一个 10 比,10 比 4 大,非递减在这里断了,标红提醒。所以最长非递减前缀就到下标 3 结束,记 i 等于 3,前缀是 1,2,3,10。前缀框定,接下来换个方向找后缀。
- 10现在从右往左找最长的非递减后缀。最右边下标 7 的 5 自成一段,标成蓝色,蓝色代表「保留下来的后缀」。我们往左走,只要前一个不大于后一个,后缀就能往左延伸。
- 11看下标 6 的 3,它的后一个是 5,3 不大于 5,非递减成立,后缀往左长到 3,5。蓝色段往左多盖一格。继续往左看。
- 12下标 5 的 2,后一个是 3,2 不大于 3,还在非递减,后缀延伸到 2,3,5。蓝色盖到下标 5。再往左看一个。
- 13到下标 4 的 4,它的后一个是 2,4 比 2 大,非递减断开,标红。所以最长非递减后缀从下标 5 开始,记 j 等于 5,后缀是 2,3,5。现在前缀末尾 i 是 3、后缀起点 j 是 5,i 小于 j,说明中间确实有东西要删。
- 14动手归并之前,先准备两个保底答案。第一手:干脆只保留前缀 1,2,3,10,把后面 arr 下标 4 到 7 这一整段灰掉删掉,长度是 n 减 i 减 1,也就是 8 减 3 减 1 等于 4。这是一定可行的删法,先记下候选 4。
- 15第二手:只保留后缀 2,3,5,把前面 arr 下标 0 到 4 灰掉删掉,长度就是 j 等于 5。这一手要删 5 个,比第一手差。两个保底取小,min(4,5) 等于 4,所以当前最短删除先定成 4。下面看双指针能不能把它压得更短。
- 16关键一步来了。我们想保留更多两端、删更少中间。摆两个指针:l 从前缀的头部下标 0 出发,r 从后缀的头部下标 5 出发。思路是,保留前缀的 0 到 l 这一段,接上后缀的 r 到末尾这一段,只要接缝处不降,中间的 l 加 1 到 r 减 1 就是要删的部分,长度 r 减 l 减 1。开始归并。
- 17第一次比较。l 在下标 0 值是 1,r 在下标 5 值是 2,1 不大于 2,接缝成立。保留前缀到下标 0,也就是 1,接上后缀 2,3,5,中间删 arr 下标 1 到 4,长度 r 减 l 减 1 等于 4。和保底一样,没变短。我们让 l 右移,尝试多保留一点前缀。
- 18第二次比较。l 走到下标 1 值是 2,r 还在下标 5 值是 2,2 不大于 2,接缝成立。保留前缀 1,2,接上后缀 2,3,5,中间删 arr 下标 2 到 4,也就是 3,10,4,长度等于 3。比 4 更短,最短删除刷新成 3。l 继续右移。
- 19第三次比较。l 在下标 2 值是 3,r 在下标 5 值是 2,这回 3 比 2 大,接缝降下来了,拼不上。问题出在后缀头那个 2 太小,没法接在 3 后面。办法是把 r 右移,等于把后缀头的 2 也删掉,换个更大的数来接。
- 20第四次比较。r 右移到下标 6 值是 3,l 还在下标 2 值是 3,3 不大于 3,接缝成立,非递减允许相等。保留前缀 1,2,3,接上后缀 3,5,中间删 arr 下标 3 到 5,长度等于 3,和当前最短一样。l 再右移。
- 21第五次比较。l 走到下标 3 值是 10,r 在下标 6 值是 3,10 比 3 大,接不上。前缀尾巴这个 10 太大了,后缀里得找个不小于 10 的数才能接。继续把 r 右移试试。
- 22第六次比较。r 右移到下标 7 值是 5,l 还在下标 3 值是 10,10 比 5 大,还是接不上。后缀里再没有更大的数了,r 走到末尾外面,归并到此结束。那个 10 注定保不住,必须被删。
- 23把归并里最短的那次删法摆出来。保留前缀 1,2,接上后缀 2,3,5,删掉中间灰色的 3,10,4 这三个数,剩下的 1,2,2,3,5 从左到右完全非递减。删 10,4,2 那一种也是删 3 个、剩 1,2,3,3,5,同样成立,两种都对、长度都是 3。
- 24收个尾。整道题先用一次左右扫描框出最长非递减前缀和后缀,再用双指针归并在接缝处贪心地保留尽量多两端、删尽量少中间。arr = 1,2,3,10,4,2,3,5 最终最短删除长度是 3。两个指针都只往前走,线性时间、常数额外空间,既快又省。
⚠️ 容易写错的地方
✗ 错:把「非递减」当成「严格递增」,用大于号判前后缀
✓ 对:前后缀延伸条件用「不大于」,也就是 arr[k] ≤ arr[k+1]
题目要的是非递减,相等是允许的。若用严格小于去扩前缀,像 1,2,2 这种会在两个 2 之间错误地断开,把本可保留的相等段也算进删除,答案偏大
✗ 错:忘了「子数组可以为空」,数组本就有序时还去删
✓ 对:先判 i 是否已不小于 j,是则整体非递减、直接返回 0
当前缀一路扫到底、和后缀连上时 i 会越过 j,此时一个都不用删。漏掉这个判断会去做无谓的归并甚至返回一个大于 0 的错误答案
✗ 错:只想着双指针,忘了两个保底:只留前缀或只留后缀
✓ 对:归并前先令 ans = min(n-i-1, j)
像 5,4,3,2,1 这种,前缀和后缀都只有一个元素,双指针一次都接不上,答案全靠保底的 min(n-i-1, j) 给出 4。不设保底会漏掉「删光一整边」这种最优情形
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def findLengthOfShortestSubarray(self, arr: List[int]) -> int:
n = len(arr)
i, j = 0, n - 1
while i + 1 < n and arr[i] <= arr[i + 1]:
i += 1
while j - 1 >= 0 and arr[j - 1] <= arr[j]:
j -= 1
if i >= j:
return 0
ans = min(n - i - 1, j)
for l in range(i + 1):
r = bisect_left(arr, arr[l], lo=j)
ans = min(ans, r - l - 1)
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int findLengthOfShortestSubarray(vector<int>& arr) {
int n = arr.size();
int i = 0, j = n - 1;
while (i + 1 < n && arr[i] <= arr[i + 1]) {
++i;
}
while (j - 1 >= 0 && arr[j - 1] <= arr[j]) {
--j;
}
if (i >= j) {
return 0;
}
int ans = min(n - 1 - i, j);
for (int l = 0; l <= i; ++l) {
int r = lower_bound(arr.begin() + j, arr.end(), arr[l]) - arr.begin();
ans = min(ans, r - l - 1);
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int findLengthOfShortestSubarray(int[] arr) {
int n = arr.length;
int i = 0, j = n - 1;
while (i + 1 < n && arr[i] <= arr[i + 1]) {
++i;
}
while (j - 1 >= 0 && arr[j - 1] <= arr[j]) {
--j;
}
if (i >= j) {
return 0;
}
int ans = Math.min(n - i - 1, j);
for (int l = 0; l <= i; ++l) {
int r = search(arr, arr[l], j);
ans = Math.min(ans, r - l - 1);
}
return ans;
}
private int search(int[] arr, int x, int left) {
int right = arr.length;
while (left < right) {
int mid = (left + right) >> 1;
if (arr[mid] >= x) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
}复杂度
时间
O(n log n)
n 是数组长度。求前缀、求后缀各扫一遍是 O(n);参考代码对前缀里最多 n 个元素,每个都在后缀里做一次二分查找,每次 O(log n),合起来 O(n log n),这是代码区展示的写法的复杂度。动画演示的双指针归并写法,l 和 r 都只往前走、各最多 n 步,可以把找候选这步降到 O(n),整体 O(n)。两者答案一致
空间
O(1)
按峰值算。除了输入数组,只用了 i、j、ans、l、r 这几个下标和一个答案变量,都是常数个,不随 n 增长。无论二分版还是双指针版,都没有额外开数组或拷贝,所以额外空间是常数 O(1)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 删除最短的子数组使剩余数组有序 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么删完剩下的部分,一定是前缀加后缀这种形状?+
因为题目限定只能删一段连续的子数组。把这一整段连续区间从中间挖走,左边剩的必然是数组开头起的一段(前缀),右边剩的必然是到结尾为止的一段(后缀),中间被掏空,不可能出现「留一段、跳过几个、再留一段」的碎片。正因为形状被锁成前缀接后缀,我们才只需要找最长非递减前缀和最长非递减后缀,再处理接缝怎么对接,而不必去枚举所有可能删的区间。
既然前后缀都找到了,为什么不能直接拼起来,还要双指针?+
因为前缀的最后一个数可能比后缀的第一个数大,直接拼、接缝处就降了下来,破坏非递减。像演算里前缀尾是 10、后缀头是 2,10 后面跟 2 是下降的,拼不成。双指针做的就是在接缝处取舍:要么从前缀尾部多删掉几个偏大的,要么从后缀头部多删掉几个偏小的,找出删得最少又能顺接的那一种。只有当前缀尾本就不大于后缀头时,才轮得到直接拼。
双指针为什么是 O(n)?它和页面参考代码里的二分是什么关系?+
双指针里 l 只会右移、r 也只会右移,两个加起来最多走 2n 步,配上前后缀各一遍扫描,整体线性 O(n)。页面参考代码走的是等价思路,对前缀里每个 l,在后缀区间里做二分、找第一个不小于 arr[l] 的位置当作候选。这样每处二分是 O(log n)、合起来 O(n log n),枚举的候选和双指针完全相同、答案一致,区别只在双指针利用了 r 随 l 单调不减,把每处的二分省成了顺着走的线性扫描。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 删除最短的子数组使剩余数组有序 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。