「力扣」第 56 題:合併區間(貪心算法)

分析:

  • 首先畫圖理解題意;
    在這裏插入圖片描述
    在這裏插入圖片描述

經驗:區間類的問題,一般而言是需要畫圖思考的。因爲只有建立直觀的感覺,才能更有效的去思考解決問題的方案。

還有需要畫圖思考的相關算法問題有(其實絕大部分都需要打草稿,大神除外):

  • 和物理現象相關的:第 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<int[]> 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(NlogN)O(N \log N),這裏 NN 是區間的長度;
  • 空間複雜度:O(N)O(N),保存結果集需要的空間,這裏計算的是最壞情況,也就是所有的區間都沒有交點的時候。

這裏用到的算法思想是:貪心算法。

在具體的算法描述中:

  • 前提:區間按照左端點排序;
  • 貪心策略:在右端點的選擇中,如果產生交集,總是將右端點的數值更新成爲最大的,這樣就可以合併更多的區間,這種做法是符合題意的。

這道題的證明請見 「官方題解」

這裏用到的算法是「貪心算法」,「貪心算法」是在基礎算法領域真正很「玄」的算法。很難也很簡單。它簡單在只要能想到,就不難寫出來,且代碼一般來說邏輯都比較簡單,難在證明的算法的合理性,好在絕大多數情況下不要求證明。

貪心算法(Greedy Algorithm)是指:在對問題求解時,總是做出在當前看來是最好的選擇。也就是不從整體最優上加以考慮,貪心算法所做出決策是在某種意義上的局部最優解。

貪心策略適用的前提是:局部最優策略能導致產生全局最優解。

可以適用貪心的問題就是每一步局部最優,最後導致結果全局最優。

重點:貪心策略可以使用的前提是和要解決的問題相關的。不是所有的問題都適合使用貪心算法。而判斷一個問題是否可以應用貪心算法,可以從以下兩個角度:

  • 直覺,根據直覺描述出來的算法,具備「只考慮當前,不考慮全局」的特點,那可能就是「貪心算法」;
  • 如果不能舉出反例,那多半這個問題就具有「貪心算法性質」,可以使用貪心算法去做。

要嚴格證明「貪心算法」有效,必須使用數學相關的理論,常見的方法有:

  • 數學歸納法;
  • 反證法。

貪心算法的證明比較難,並且就算看證明也會給人一頭霧水的感覺,就像是讓你證明 2\sqrt{2} 是無理數一樣,但是推翻「貪心算法」很簡單。在這裏不展開。

經驗:由於貪心算法適用的場景一般都是在一組決策裏選擇最大或者最小值,因此常常在使用貪心算法之前,需要先對數據按照某種規則排序。

一個最簡單的理解貪心算法的例子就是「選擇排序」,算法描述是:每一輪選擇未排定部分裏最小的元素交換到未排定部分的開頭。

說明:對於「選擇排序」是否是貪心算法,我查過資料,這一點有爭議。我個人認爲「選擇排序」的算法描述符合「局部最優,則整體最優」,即每一步的決策並不考慮全局,只考慮當下,選這個例子的願意只是因爲它足夠簡單。
證明「貪心算法」在「選擇排序」上有效需要使用「循環不變量」,在這裏不展開。

貪心算法不是對所有問題都能夠每一步只看當下,選擇最好的策略,就得到整體最優解,關鍵是貪心策略的選擇。選擇的貪心策略必須具備無後效性,即某個狀態以前的過程不會影響以後的狀態,只與當前狀態有關。

具備「無後效性」其實在「動態規劃」這一類問題裏體現得特別明顯,大家可以通過貪心算法的學習在具體去理解「無後效性」的意思。

  • 當前決策對後面的決策不產生影響;
  • 當前決策只需要記錄一個結果,而這個決策是怎麼來的不重要。

一旦貪心選擇性質不成立,可以考慮的另一種算法思想就是「動態規劃」。「動態規劃」在每一步做決策的時候,就不只考慮當前步驟的最優解。

貪心算法的應用

  • 對數據壓縮編碼的霍夫曼編碼(Huffman Coding)
  • 求最小生成樹的 Prim 算法和 Kruskal 算法
  • 求單源最短路徑的Dijkstra算法

貪心算法典型問題

說明:如果是準備普通公司算法面試的朋友,不建議畫太多時間去研究「貪心算法」有效性的證明,有可以使用「貪心算法」的直覺,舉不出反例,並且編碼可以通過搜友測試用例即可。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章