LeetCode 56:合并区间复盘|从排序思维到 List<int[]> 的简洁写法

一、题目

给定一个区间数组:

复制代码
intervals[i] = [starti, endi]

要求合并所有重叠区间,并返回一个互不重叠的区间数组。

例如:

复制代码
输入:
[[1,3],[2,6],[8,10],[15,18]]

输出:

复制代码
[[1,6],[8,10],[15,18]]

因为:

复制代码
[1,3]
和
[2,6]

发生重叠,所以合并为:

复制代码
[1,6]

二、这道题最关键的一步:先排序

一开始这道题容易不知道从哪里入手。

真正的突破口是:

先按照区间左端点从小到大排序。

例如原始区间:

复制代码
[[8,10],[1,3],[2,6]]

排序以后:

复制代码
[[1,3],[2,6],[8,10]]

排序之后有一个非常重要的性质:

当前区间只需要和前面已经合并好的最后一个区间比较。

因为所有区间的左端点已经递增,后面的区间不可能重新跑到更前面去。

因此问题就从:

复制代码
"当前区间和前面所有区间比较"

变成:

复制代码
"当前区间只和 ans 最后一个区间比较"

这就是整道题的核心优化思想。


三、最终采用的代码

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

class Solution {

    public int[][] merge(int[][] intervals) {

        // 按照区间左端点升序排序
        Arrays.sort(
            intervals,
            (a, b) -> Integer.compare(a[0], b[0])
        );

        // 保存最终合并结果
        List<int[]> ans = new ArrayList<>();

        // 依次遍历每一个区间
        for (int[] p : intervals) {

            int m = ans.size();

            // ans 不为空,并且当前区间和最后一个区间重叠
            if (m > 0 && p[0] <= ans.get(m - 1)[1]) {

                // 更新最后一个合并区间的右端点
                ans.get(m - 1)[1] =
                    Math.max(ans.get(m - 1)[1], p[1]);

            } else {

                // 不重叠,直接作为新区间加入答案
                ans.add(p);
            }
        }

        // List<int[]> 转换为 int[][]
        return ans.toArray(new int[ans.size()][]);
    }
}

这是我目前更推荐面试时使用的版本。


四、第一步:按照左端点排序

代码:

复制代码
Arrays.sort(
    intervals,
    (a, b) -> Integer.compare(a[0], b[0])
);

这里:

复制代码
a
b

分别代表两个区间。

例如:

复制代码
a = [1,3]
b = [8,10]

那么:

复制代码
a[0]

就是:

复制代码
1

而:

复制代码
b[0]

就是:

复制代码
8

所以:

复制代码
Integer.compare(a[0], b[0])

就是按照:

复制代码
区间第 0 个元素
也就是左端点

进行升序排序。


五、为什么用 Integer.compare?

有些答案会写:

复制代码
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);

这种写法通常也能通过。

但更规范的是:

复制代码
Integer.compare(a[0], b[0])

因为:

复制代码
a[0] - b[0]

理论上可能发生整数溢出。

所以面试时更推荐:

复制代码
Arrays.sort(intervals,
    (a, b) -> Integer.compare(a[0], b[0]));

六、List<int[]> ans 是什么意思?

代码:

复制代码
List<int[]> ans = new ArrayList<>();

这里需要理解:

复制代码
List<int[]>

表示:

List 里面每一个元素都是一个 int[]

而每个区间刚好就是:

复制代码
int[]

例如:

复制代码
[1,6]

在 Java 中实际上可以表示为:

复制代码
new int[]{1, 6}

所以:

复制代码
ans

最终可能是:

复制代码
[
    [1,6],
    [8,10],
    [15,18]
]

只是它此时的 Java 类型是:

复制代码
List<int[]>

七、为什么不用 int\[\]\[\] 直接保存答案?

因为合并之前我们不知道最终会有多少个区间。

比如输入 10 个区间:

复制代码
可能全部重叠
→ 最终只有 1 个

也可能:

复制代码
全部不重叠
→ 最终还是 10 个

所以使用:

复制代码
ArrayList

这种可以动态扩容的集合更加方便。


八、增强 for 循环

代码:

复制代码
for (int[] p : intervals) {

表示:

依次取出 intervals 中的每一个区间,当前区间保存到 p 中。

例如:

复制代码
intervals =
[
    [1,3],
    [2,6],
    [8,10]
]

那么遍历过程:

复制代码
第一次:
p = [1,3]

第二次:
p = [2,6]

第三次:
p = [8,10]

这里:

复制代码
p[0]

表示当前区间左端点。

复制代码
p[1]

表示当前区间右端点。


九、int m = ans.size()

复制代码
int m = ans.size();

表示:

当前已经合并完成多少个区间。

例如:

复制代码
ans =
[
    [1,6],
    [8,10]
]

那么:

复制代码
ans.size()

就是:

复制代码
2

所以:

复制代码
m = 2

十、ans.get(m - 1) 为什么是最后一个区间?

Java List 下标从:

复制代码
0

开始。

如果:

复制代码
ans.size() = 3

那么三个元素下标:

复制代码
0
1
2

最后一个下标就是:

复制代码
size - 1

也就是:

复制代码
m - 1

所以:

复制代码
ans.get(m - 1)

表示:

ans 中最后一个区间。

例如:

复制代码
ans =
[
    [1,6],
    [8,10]
]

那么:

复制代码
ans.get(1)

就是:

复制代码
[8,10]

十一、为什么只需要和 ans 最后一个比较?

这是这版代码最重要的思想。

因为已经按照左端点排序。

例如:

复制代码
ans =
[
    [1,6],
    [8,10]
]

当前:

复制代码
p = [9,15]

只需要检查:

复制代码
[8,10]

因为:

复制代码
[1,6]

已经结束得更早。

当前左端点:

复制代码
9

已经大于:

复制代码
6

所以不可能再和 [1,6] 合并。

因此:

当前区间只需要和 ans 最后一个区间比较。


十二、最核心的重叠判断

代码:

复制代码
if (m > 0 && p[0] <= ans.get(m - 1)[1]) {

拆成两个条件。

第一部分:

复制代码
m > 0

意思:

ans 里面至少已经有一个区间。

否则不能直接调用:

复制代码
ans.get(m - 1)

因为第一次:

复制代码
m = 0

会变成:

复制代码
ans.get(-1)

显然不合法。


第二部分:

复制代码
p[0] <= ans.get(m - 1)[1]

含义:

复制代码
当前区间左端点
<=
答案最后一个区间右端点

说明两个区间发生重叠。

例如:

复制代码
最后一个区间:
[1,6]

当前区间:
[2,8]

因为:

复制代码
2 <= 6

所以重叠。


十三、为什么 && 不会导致 get(-1)?

Java 的:

复制代码
&&

具有短路特性。

代码:

复制代码
m > 0 && p[0] <= ans.get(m - 1)[1]

如果:

复制代码
m > 0

已经为:

复制代码
false

Java 不会继续执行右边。

所以第一次:

复制代码
m = 0

不会真的执行:

复制代码
ans.get(-1)

这也是 Java 中:

复制代码
短路与

的典型使用场景。


十四、重叠以后怎么合并?

代码:

复制代码
ans.get(m - 1)[1] =
    Math.max(ans.get(m - 1)[1], p[1]);

例如:

复制代码
最后一个区间:
[1,6]

当前区间:
[2,8]

那么:

复制代码
旧右端点 = 6
当前右端点 = 8

更新:

复制代码
Math.max(6, 8)

得到:

复制代码
8

最终:

复制代码
[1,6]

变成:

复制代码
[1,8]

十五、为什么要 Math.max?

例如:

复制代码
最后一个区间:
[1,10]

当前区间:
[2,5]

当前区间完全包含在前一个区间中。

如果直接:

复制代码
right = p[1];

就会错误变成:

复制代码
[1,5]

因此必须:

复制代码
Math.max(10,5)

仍然得到:

复制代码
10

所以:

复制代码
ans.get(m - 1)[1] =
    Math.max(ans.get(m - 1)[1], p[1]);

是标准写法。


十六、为什么只更新右端点?

因为已经按照左端点升序排序。

例如:

复制代码
[1,6]
[2,10]

第二个区间左端点一定不会比第一个更小。

所以合并:

复制代码
[1,10]

左端点:

复制代码
1

不用修改。

只需要扩大右端点。


十七、不重叠怎么办?

代码:

复制代码
else {
    ans.add(p);
}

例如:

复制代码
ans 最后一个:
[1,6]

当前:
[8,10]

因为:

复制代码
8 > 6

所以不重叠。

此时:

复制代码
[1,6]

已经不可能再被后面的区间影响。

于是当前:

复制代码
[8,10]

直接作为一个新的合并区间加入:

复制代码
ans.add(p);

得到:

复制代码
ans =
[
    [1,6],
    [8,10]
]

十八、第一次循环是怎么处理的?

一开始:

复制代码
ans = []

第一个区间:

复制代码
p = [1,3]

那么:

复制代码
m = ans.size();

得到:

复制代码
m = 0

判断:

复制代码
m > 0

false。

所以直接进入:

复制代码
else

执行:

复制代码
ans.add(p);

得到:

复制代码
ans = [[1,3]]

所以这个版本不需要单独初始化:

复制代码
start
end

这也是它比另一种写法更简洁的原因。


十九、为什么这一版不用循环结束后再处理最后一个区间?

这是我学习过程中比较容易困惑的地方。

另一种写法会自己维护:

复制代码
start
end

例如:

复制代码
int start = intervals[0][0];
int end = intervals[0][1];

然后只有遇到新区间时才:

复制代码
ans.add(new int[]{start,end});

所以最后一个正在维护的区间可能还没有正式加入 ans

因此需要循环结束后:

复制代码
ans.add(new int[]{start,end});

补一次。


但是这一版不同。

这一版对于每一个:

复制代码
p

都会立即做两种操作之一:

复制代码
重叠
→ 直接合并进 ans 最后一个区间

不重叠
→ 直接 ans.add(p)

也就是说:

每个区间在遍历过程中就已经进入了 ans 的状态。

所以最后一个区间无论:

复制代码
重叠

还是:

复制代码
不重叠

都会在循环内部完成处理。

因此不需要循环外额外补。

可以简单记:

复制代码
start/end 版本:
延迟提交
→ 最后需要补

ans 最后一个版本:
边遍历边直接维护 ans
→ 不需要补

二十、最后一行:toArray

代码:

复制代码
return ans.toArray(new int[ans.size()][]);

这是 Java 类型转换。

当前:

复制代码
ans

类型:

复制代码
List<int[]>

但题目要求:

复制代码
int[][]

所以需要转换:

复制代码
List<int[]>
↓
int[][]

二十一、new int[ans.size()][] 是什么?

假设:

复制代码
ans.size() = 3

那么:

复制代码
new int[ans.size()][]

等于:

复制代码
new int[3][]

表示创建一个:

第一维长度为 3,每一个位置未来放一个 int[] 的二维数组。

可以暂时理解为:

复制代码
[
    null,
    null,
    null
]

然后:

复制代码
ans.toArray(...)

把:

复制代码
[1,6]
[8,10]
[15,18]

放进去。

最终得到:

复制代码
int[][] result = {
    {1,6},
    {8,10},
    {15,18}
};

二十二、完整执行过程

输入:

复制代码
[[1,3],[2,6],[8,10],[15,18]]

排序后:

复制代码
[[1,3],[2,6],[8,10],[15,18]]

初始:

复制代码
ans = []

第一次

复制代码
p = [1,3]
m = 0

无法比较。

直接:

复制代码
ans.add(p);

得到:

复制代码
ans = [[1,3]]

第二次

复制代码
p = [2,6]
m = 1

最后一个区间:

复制代码
[1,3]

判断:

复制代码
2 <= 3

成立。

所以:

复制代码
右端点 = max(3,6)
       = 6

得到:

复制代码
ans = [[1,6]]

第三次

复制代码
p = [8,10]

最后一个:

复制代码
[1,6]

判断:

复制代码
8 <= 6

false。

所以:

复制代码
ans.add(p);

得到:

复制代码
ans =
[
    [1,6],
    [8,10]
]

第四次

复制代码
p = [15,18]

最后一个:

复制代码
[8,10]

判断:

复制代码
15 <= 10

false。

加入:

复制代码
ans =
[
    [1,6],
    [8,10],
    [15,18]
]

最后转换为:

复制代码
int[][]

返回。


二十三、复杂度分析

排序:

复制代码
O(n log n)

遍历:

复制代码
O(n)

所以总时间复杂度:

复制代码
O(n log n)

主要瓶颈是排序。

结果集合最多存:

复制代码
n

个区间。


二十四、面试讲法

如果面试官让我讲,可以这样回答:

我先按照所有区间的左端点进行升序排序。排序之后,对于当前区间,只需要判断它是否和结果集中最后一个区间重叠。

如果当前区间左端点小于等于最后一个区间的右端点,说明存在重叠,此时更新最后一个区间的右端点为两个右端点的最大值。

如果不重叠,就直接把当前区间加入结果集合。

由于每个区间在遍历时就已经被加入或合并到结果集合中,所以循环结束以后不需要额外处理最后一个区间。

排序时间复杂度为 O(n log n),遍历为 O(n),总体时间复杂度为 O(n log n)。


二十五、这道题的通用思维

以后看到:

复制代码
区间
合并
重叠
覆盖

第一反应可以考虑:

复制代码
按照左端点排序

排序以后经常会变成:

复制代码
从左往右扫描
↓
只和前一个 / 当前合并区间比较

这是一类非常常见的区间题套路。


二十六、面试前 30 秒速记

看到「合并区间」:

复制代码
1. 按 start 排序

2. ans 保存合并结果

3. 当前区间 p
   只和 ans 最后一个区间比较

4. 如果:
   p[0] <= last[1]

   说明重叠

   last[1] = max(last[1], p[1])

5. 否则:
   ans.add(p)

代码模板:

java 复制代码
Arrays.sort(
    intervals,
    (a, b) -> Integer.compare(a[0], b[0])
);

List<int[]> ans = new ArrayList<>();

for (int[] p : intervals) {

    int m = ans.size();

    if (m > 0 && p[0] <= ans.get(m - 1)[1]) {

        ans.get(m - 1)[1] =
            Math.max(ans.get(m - 1)[1], p[1]);

    } else {

        ans.add(p);
    }
}

return ans.toArray(new int[ans.size()][]);

Java API:

复制代码
Arrays.sort()
数组排序

List<int[]>
保存多个 int[] 区间

ans.size()
List 元素数量

ans.get(i)
获取第 i 个元素

ans.add(p)
添加元素

Math.max(a,b)
取较大值

toArray()
List 转数组

二十七、最终总结

这道题真正需要记住的不是某一行 API,而是:

复制代码
区间不好直接处理
↓
先按照左端点排序
↓
区间关系变得有序
↓
当前区间只需要和最后一个合并区间比较

排序以后,每个新区间只有两种情况:

复制代码
能合并
→ 更新 ans 最后一个区间右端点

不能合并
→ 直接加入 ans

最终核心判断:

复制代码
p[0] <= ans.get(m - 1)[1]

核心更新:

复制代码
ans.get(m - 1)[1] =
    Math.max(ans.get(m - 1)[1], p[1]);

这道题同时也顺带巩固了:

复制代码
二维数组
增强 for
Lambda 排序
List<int[]>
ArrayList
get()
size()
add()
Math.max()
toArray()
短路 &&

对于面试来说,这是一道非常典型的:

排序 + 贪心扫描

题目。

以后遇到类似区间问题,可以优先往这个方向思考。其他写法:

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

class Solution {
    public int[][] merge(int[][] intervals) {

        Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));

        List<int[]> ans = new ArrayList<>();

        int start = intervals[0][0];
        int end = intervals[0][1];

        for (int i = 1; i < intervals.length; i++) {

            int nextStart = intervals[i][0];
            int nextEnd = intervals[i][1];

            if (nextStart <= end) {
                end = Math.max(end, nextEnd);
            } else {
                ans.add(new int[]{start, end});

                start = nextStart;
                end = nextEnd;
            }
        }

        ans.add(new int[]{start, end});

        return ans.toArray(new int[ans.size()][]);
    }
}
相关推荐
M78佐菲28 分钟前
Linux学习笔记:网络通信
linux·笔记·学习·算法
漂流瓶jz31 分钟前
UVA-1609 不公平竞赛 题解答案代码 算法竞赛入门经典第二版
数据结构·算法·链表·贪心·aoapc·算法竞赛入门经典·uva
学习星球1 小时前
贪心算法精讲——贪心策略的“局部最优“如何通向全局最优?
算法·贪心算法
MuMuMu12231 小时前
文旅户外场景智能回收终端工程难点解析:越华环保集团碳惠小屋耐候与供电系统实践
java·大数据·算法
星恒随风1 小时前
C++11详解(一):统一初始化——列表初始化与 initializer_list
c++·笔记·学习·list·状态模式
玖玥拾1 小时前
LeetCode 205 同构字符串
算法·leetcode
影视飓风TIM1 小时前
C++哈希表:unordered_set / unordered_map底层原理
c++·算法·哈希算法·散列表
在所不辞兄1 小时前
【零基础学智能仿真-06】决策树——让模型自动发现力学失效阈值
算法·决策树·机器学习