「力扣」第 56 题:合并区间(贪心算法)

分析:

  • 首先画图理解题意;
    「力扣」第 56 题:合并区间(贪心算法)_第1张图片
    「力扣」第 56 题:合并区间(贪心算法)_第2张图片

经验:区间类的问题,一般而言是需要画图思考的。因为只有建立直观的感觉,才能更有效的去思考解决问题的方案。

还有需要画图思考的相关算法问题有(其实绝大部分都需要打草稿,大神除外):

  • 和物理现象相关的:第 42 题:接雨水问题、第 11 题:盛最多水的容器、第 218 题:天际线问题;
  • 本身问题描述就和图形相关的问题:第 84 题:柱状图中最大的矩形;
  • 链表问题:穿针引线如果不画图容易把自己绕晕;
  • 回溯算法问题:根据示例画图发现每一步的选择和剪枝的条件;
  • 动态规划问题:画示意图发现最优子结构。

得出结论:可以被合并的区间一定是有交集的区间,前提是区间按照左端点排好序,这里的交集可以是一个点(例如例 2)。

因此,直觉上,只需要对所有的区间按照左端点升序排序,然后遍历。

  • 如果当前遍历到的区间的左端点 > 结果集中最后一个区间的右端点,说明它们没有交集,此时把区间添加到结果集;
  • 如果当前遍历到的区间的左端点 <= 结果集中最后一个区间的右端点,说明它们有交集,此时产生合并操作,即:对结果集中最后一个区间的右端点更新(取两个区间的最大值)。

参考代码

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Stack;


public class Solution {

    public int[][] merge(int[][] intervals) {
        int len = intervals.length;
        if (len < 2) {
            return intervals;
        }

        // 按照起点排序
        Arrays.sort(intervals, Comparator.comparingInt(o -> o[0]));

        // 也可以使用 Stack,因为我们只关心最后一个区间
        List res = new ArrayList<>();
        res.add(intervals[0]);

        for (int i = 1; i < len; i++) {
            int[] curInterval = intervals[i];

            // 每次新遍历到的列表与当前结果集中的最后一个区间的末尾端点进行比较
            int[] peek = res.get(res.size() - 1);

            if (curInterval[0] > peek[1]) {
                res.add(curInterval);
            } else {
                // 注意,这里应该取最大
                peek[1] = Math.max(curInterval[1], peek[1]);
            }
        }
        return res.toArray(new int[res.size()][]);
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        int[][] intervals = {{1, 3}, {2, 6}, {8, 10}, {15, 18}};
        int[][] res = solution.merge(intervals);
        for (int i = 0; i < res.length; i++) {
            System.out.println(Arrays.toString(res[i]));
        }
    }
}

复杂度分析

  • 时间复杂度: O ( N log ⁡ N ) O(N \log N) O(NlogN),这里 N N N 是区间的长度;
  • 空间复杂度: O ( N ) O(N) O(N),保存结果集需要的空间,这里计算的是最坏情况,也就是所有的区间都没有交点的时候。

这里用到的算法思想是:贪心算法。

在具体的算法描述中:

  • 前提:区间按照左端点排序;
  • 贪心策略:在右端点的选择中,如果产生交集,总是将右端点的数值更新成为最大的,这样就可以合并更多的区间,这种做法是符合题意的。

这道题的证明请见 「官方题解」。

这里用到的算法是「贪心算法」,「贪心算法」是在基础算法领域真正很「玄」的算法。很难也很简单。它简单在只要能想到,就不难写出来,且代码一般来说逻辑都比较简单,难在证明的算法的合理性,好在绝大多数情况下不要求证明。

贪心算法(Greedy Algorithm)是指:在对问题求解时,总是做出在当前看来是最好的选择。也就是不从整体最优上加以考虑,贪心算法所做出决策是在某种意义上的局部最优解。

贪心策略适用的前提是:局部最优策略能导致产生全局最优解。

可以适用贪心的问题就是每一步局部最优,最后导致结果全局最优。

重点:贪心策略可以使用的前提是和要解决的问题相关的。不是所有的问题都适合使用贪心算法。而判断一个问题是否可以应用贪心算法,可以从以下两个角度:

  • 直觉,根据直觉描述出来的算法,具备「只考虑当前,不考虑全局」的特点,那可能就是「贪心算法」;
  • 如果不能举出反例,那多半这个问题就具有「贪心算法性质」,可以使用贪心算法去做。

要严格证明「贪心算法」有效,必须使用数学相关的理论,常见的方法有:

  • 数学归纳法;
  • 反证法。

贪心算法的证明比较难,并且就算看证明也会给人一头雾水的感觉,就像是让你证明 2 \sqrt{2} 2 是无理数一样,但是推翻「贪心算法」很简单。在这里不展开。

经验:由于贪心算法适用的场景一般都是在一组决策里选择最大或者最小值,因此常常在使用贪心算法之前,需要先对数据按照某种规则排序。

一个最简单的理解贪心算法的例子就是「选择排序」,算法描述是:每一轮选择未排定部分里最小的元素交换到未排定部分的开头。

说明:对于「选择排序」是否是贪心算法,我查过资料,这一点有争议。我个人认为「选择排序」的算法描述符合「局部最优,则整体最优」,即每一步的决策并不考虑全局,只考虑当下,选这个例子的愿意只是因为它足够简单。
证明「贪心算法」在「选择排序」上有效需要使用「循环不变量」,在这里不展开。

贪心算法不是对所有问题都能够每一步只看当下,选择最好的策略,就得到整体最优解,关键是贪心策略的选择。选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。

具备「无后效性」其实在「动态规划」这一类问题里体现得特别明显,大家可以通过贪心算法的学习在具体去理解「无后效性」的意思。

  • 当前决策对后面的决策不产生影响;
  • 当前决策只需要记录一个结果,而这个决策是怎么来的不重要。

一旦贪心选择性质不成立,可以考虑的另一种算法思想就是「动态规划」。「动态规划」在每一步做决策的时候,就不只考虑当前步骤的最优解。

贪心算法的应用

  • 对数据压缩编码的霍夫曼编码(Huffman Coding)
  • 求最小生成树的 Prim 算法和 Kruskal 算法
  • 求单源最短路径的Dijkstra算法

贪心算法典型问题

说明:如果是准备普通公司算法面试的朋友,不建议画太多时间去研究「贪心算法」有效性的证明,有可以使用「贪心算法」的直觉,举不出反例,并且编码可以通过搜友测试用例即可。

  • 「力扣」第 12 题:整数转罗马数字,贪心思想更多来源于直觉。
  • 「力扣」第 452 题:用最少数量的箭引爆气球,画图发现贪心策略。
  • 「力扣」第 122 题:买卖股票的最佳时机 II,需要简单推导。
  • 「力扣」第 55 题: 跳跃游戏,画图思考。
  • 「力扣」第 435 题: 无重叠区间,画图思考。
  • 「力扣」第 455 题:分发饼干
  • 「力扣」第 343 题: 整数拆分,需要简单推导。
  • 「力扣」第 300 题:最长上升子序列,本质上还是动态规划,只不过在推导的过程中发现决策的过程可以贪心进行(具有贪心选择性质)。

你可能感兴趣的:(力扣)