LeetCode動畫 | 218.天際線問題

今天分享一個LeetCode題,題號是218,標題是天際線問題,題目標籤是線段樹和Line Sweep [ 掃描線算法 ] ,題目難度是困難。最近新學了Go語言,來嘗試一下效果,同時後面也貼出了Java代碼【線段樹和線掃描】。java

題目描述

城市的天際線是從遠處觀看該城市中全部建築物造成的輪廓的外部輪廓。如今,假設您得到了城市風光照片(圖A)上顯示的全部建築物的位置和高度,請編寫一個程序以輸出由這些建築物造成的天際線(圖B)。算法

天際線

每一個建築物的幾何信息用三元組 [Li,Ri,Hi] 表示,其中 Li 和 Ri 分別是第 i 座建築物左右邊緣的 x 座標,Hi 是其高度。能夠保證 0 ≤ Li, Ri ≤ INT_MAX, 0 < Hi ≤ INT_MAX 和 Ri - Li > 0。您能夠假設全部建築物都是在絕對平坦且高度爲 0 的表面上的完美矩形。segmentfault

例如,圖A中全部建築物的尺寸記錄爲:[ [2 9 10], [3 7 15], [5 12 12], [15 20 10], [19 24 8] ] 。數組

輸出是以 [ [x1,y1], [x2, y2], [x3, y3], ... ] 格式的「關鍵點」(圖B中的紅點)的列表,它們惟一地定義了天際線。關鍵點是水平線段的左端點。請注意,最右側建築物的最後一個關鍵點僅用於標記天際線的終點,並始終爲零高度。此外,任何兩個相鄰建築物之間的地面都應被視爲天際線輪廓的一部分。微信

例如,圖B中的天際線應該表示爲:[ [2 10], [3 15], [7 12], [12 0], [15 10], [20 8], [24, 0] ]。app

說明:函數

任何輸入列表中的建築物數量保證在 [0, 10000] 範圍內。

輸入列表已經按左 x 座標 Li  進行升序排列。

輸出列表必須按 x 位排序。

輸出天際線中不得有連續的相同高度的水平線。例如 [...[2 3], [4 5], [7 5], [11 5], [12 7]...] 是不正確的答案;三條高度爲 5 的線應該在最終輸出中合併爲一個:[...[2 3], [4 5], [12 7], ...]

解題

光看題目描述彷佛以爲很難,可是學習過線段樹就會以爲這道題變得很容易。不信,來試試看下面的圖:學習

線段樹

咱們能夠把輸入列表做爲一個頂點,按照輸入列表的長度選取中間的值,建議使用這個方式: mid := l + (r-l)/2 選擇中間值,而後進行分治算法。動畫

直到當前輸入列表的長度爲1,說明不能再分了,在這個地方做爲結束條件,而後返回到另外路徑劃分其它的輸入列表。ui

例如咱們劃分到 [[2 9 10]] 的時候,當前輸入列表的長度爲1,不能再進行分治了。

[[2 9 10]]表示的是一個建築物,分別是左右邊緣的橫座標和高度。題目已經將天際線定義爲水平線左端點的集合,如[[2 9 10]]關鍵點集合爲[[2 10] [9 0]],分別是一個建築物上的左上端點和右下端點。

同理,[[3 7 15]]的關鍵點集合爲[[3 15] [7 0]]。

關鍵的一點來了,咱們獲得了[[2 9 10]] 和 [[3 7 15]] 兩個集合以後,要求在知足題目天際線狀況下,怎麼把這兩個集合進行合併呢?意思是合併以後的集合,也是知足天際線的,以下面合併的過程:

合併

其實咱們在題目標籤看到了Line Sweep,[ 線掃描或掃描線 ] ,掃描線能夠想象成一條向右掃過平面的豎直線,也是一個算法,通常是玩圖形學的。

接着上面的步驟,能夠經過掃描線算法將兩個關鍵點集合進行合併。

以下圖,掃描線從兩個集合的起始點,同時向右移動,接觸到第一個關鍵點,則判斷這一個關鍵點是否是知足天際線的,若是是,則將這個關鍵點添加到「父」集合中;若是不是,則繼續同時移動到下一個關鍵點。

掃描線

但如何判斷是不是屬於「父」集合中的關鍵點呢?能夠建立兩個集合(「子」)的目前高度,而後多方角度找到知足關鍵點的條件。

掃描線移到[2 10]關鍵點時,10要大於rpre的,能夠知足;

掃描線移到[3 15]關鍵點時,lpre此時目前的高度爲10,而15要大於10的,能夠知足;

掃描線移到[7 10]關鍵點時,rpre大於lpre能夠知足,反之就不知足;

接着有一個集合已經遍歷完了,剩下的集合的關鍵點確定是知足的,由於沒有其它的集合能夠阻擋到這個集合,因此直接就是知足。

Go語言代碼,使用線段樹
package main
// 粘貼到LeetCode代碼控制檯時,須要刪去main包和main入口函數
import (
    "fmt"
)

// 線段樹
func getSkyline(buildings [][]int) [][]int {
    len := len(buildings)
    if len == 0 {
        return nil
    }
    return segment(buildings, 0, len-1)
}

func segment(buildings [][]int, l int, r int) [][]int {
    // 建立返回值
    var res [][]int
    // 結束條件
    if l == r {
        return [][]int{{buildings[l][0], buildings[l][2]}, {buildings[l][1], 0}}
    }
    // 取中間值
    mid := l + (r-l)/2
    // 左遞歸
    left := segment(buildings, l, mid)
    // 右遞歸
    right := segment(buildings, mid+1, r)
    // 左右合併
    // 建立left 和 right 的索引位置
    var m int = 0
    var n int = 0
    // 建立 left 和 right 的目前高度
    var lpre int = 0
    var rpre int = 0
    for m < len(left) || n < len(right) {
        // 一邊遍歷完,則所有添加另外一邊
        if m >= len(left) {
            res = append(res, right[n])
            n++
        } else if n >= len(right) {
            res = append(res, left[m])
            m++
        } else { // swip line
            if left[m][0] < right[n][0] {
                if left[m][1] > rpre {
                    res = append(res, left[m])
                } else if lpre > rpre {
                    res = append(res, []int{left[m][0], rpre})
                }
                lpre = left[m][1]
                m++
            } else if right[n][0] < left[m][0] {
                if right[n][1] > lpre {
                    res = append(res, right[n])
                } else if rpre > lpre {
                    res = append(res, []int{right[n][0], lpre})
                }
                rpre = right[n][1]
                n++
            } else { // left 和 right橫座標相等
                if left[m][1] >= right[n][1] && left[m][1] != max(lpre, rpre) {
                    res = append(res, left[m])
                } else if left[m][1] <= right[n][1] && right[n][1] != max(lpre, rpre) {
                    res = append(res, right[n])
                }
                lpre = left[m][1]
                rpre = right[n][1]
                m++
                n++
            }
        }
    }
    return res
}

func max(l, r int) int {
    if l > r {
        return l
    }
    return r
}

func main() {
    buildings := [][]int{{2, 9, 10}, {3, 7, 15}, {5, 12, 12}, {15, 20, 10}, {19, 24, 8}}
    fmt.Println(getSkyline(buildings))
}
線段樹執行結果
執行用時 : 20 ms , 在全部 Go 提交中擊敗了 93.75% 的用戶
內存消耗 : 6.7 MB , 在全部 Go 提交中擊敗了 72.73% 的用戶

其實,這道題能夠不用線段樹,單獨用掃描線算法能夠解決這道題的。不過,線段樹由於分治算法的關係,時間複雜度要比沒有線段樹的小。

具體怎麼作能夠看下面的動畫:

掃描線算法動畫

使用掃描線,從左向右掃過,若是遇到左端點,將高度入堆;若是遇到右端點,將高度從堆中刪除。

這樣作有什麼意義呢?

由於高度入堆的時候,獲取這個堆的最大值,判斷一下最大值是否和前一關鍵點的當前高度是否相等,若是不相等,說明這是一個拐點,也是天際線的關鍵點,而後更新當前高度,即當前高度等於最大值;

高度出堆的時候,將這個高度從堆中刪除,接着獲取這個堆中的最大值,判斷一下這個最大值和前一關鍵點的當前高度是否相等,若是不相等,說明這也是一個拐點。

可是如何區分左右端點的高度呢?由於遇左端點要將高度入堆,遇右端點要將高度出堆。

咱們能夠這樣設計,將左端點的高度設置成負數,右端點的高度仍是原來值。這樣出入堆的時候,能夠根據正負數來決定入堆仍是出堆。

由於高度能夠有重複性,並且咱們要最大堆,因此這個堆要設定成能夠有重複數字的最大堆。

Go語言代碼,單獨使用線掃描法
import (
    "container/heap"
    "fmt"
    "sort"
)

// 線掃描法
func getSkyline(buildings [][]int) [][]int {
    // 建立返回值
    var res [][]int
    // 保存全部可能的拐點
    var pairs = make([][2]int, len(buildings)*2) // 切片 相似動態數組
    index := 0
    // 將每個建築分紅兩個部分
    for _, build := range buildings {
        pairs[index][0] = build[0]
        pairs[index][1] = -build[2]
        index++
        pairs[index][0] = build[1]
        pairs[index][1] = build[2]
        index++
    }
    // pairs進行升序
    sort.Slice(pairs, func(i, j int) bool {
        if pairs[i][0] != pairs[j][0] {
            return pairs[i][0] < pairs[j][0]
        }
        return pairs[i][1] < pairs[j][1]
    })
    // 最大堆?
    maxHeap := &IntHeap{}
    // 記錄以前的高度
    prev := 0
    // 遍歷
    for _, pair := range pairs {
        if pair[1] < 0 {
            heap.Push(maxHeap, -pair[1])
        } else {
            for i := 0; i < maxHeap.Len(); i++ {
                if maxHeap.Get(i) == pair[1] {
                    heap.Remove(maxHeap, i)
                    break
                }
            }
        }
        top := maxHeap.Top()
        if top != prev {
            res = append(res, []int{pair[0], top})
            prev = top
        }
    }
    return res
}

// Go語言中沒有像Java語言同樣有這個PriorityQueue類的結構體的,須要本身實現
// 定義堆
type IntHeap []int

func (h IntHeap) Len() int          { return len(h) }
func (h IntHeap) Get(index int) int { return h[index] }
func (h IntHeap) Less(i, j int) bool {
    if i < len(h) && j < len(h) {
        return h[i] > h[j] // > 表示最大堆,< 表示最小堆
    }
    return true
}

func (h IntHeap) Swap(i, j int) {
    if i < len(h) && j < len(h) {
        h[i], h[j] = h[j], h[i]
    }
}

func (h *IntHeap) Push(x interface{}) { *h = append(*h, x.(int)) }

func (h *IntHeap) Pop() interface{} { // 去掉最後一個數,要注意指針
    old := *h
    l := len(old)
    *h = old[0 : l-1]
    return h
}

func (h IntHeap) Top() int {
    if len(h) != 0 {
        return h[0]
    }
    return 0
}
線掃描法執行結果
執行用時 : 52 ms , 在全部 Go 提交中擊敗了 65.63% 的用戶
內存消耗 : 6.4 MB , 在全部 Go 提交中擊敗了 72.73% 的用戶
Java代碼,使用線段樹
import java.util.*;

class Solution {

    // 線段樹
    public List<List<Integer>> getSkyline(int[][] buildings) {
        int len = buildings.length;
        if (len == 0) return new ArrayList<>();
        return segment(buildings, 0, len - 1);
    }

    private List<List<Integer>> segment(int[][] buildings, int l, int r) {
        // 建立返回值
        List<List<Integer>> res = new ArrayList<>();

        // 找到樹底下的結束條件 -> 一個建築物
        if (l == r) {
            res.add(Arrays.asList(buildings[l][0], buildings[l][2])); // 左上端座標
            res.add(Arrays.asList(buildings[l][1], 0)); // 右下端座標
            return res;
        }

        int mid = l + (r - l) / 2; // 取中間值

        // 左邊遞歸
        List<List<Integer>> left = segment(buildings, l, mid);

        // 右邊遞歸
        List<List<Integer>> right = segment(buildings, mid + 1, r);

        // 左右合併

        // 建立left 和 right 的索引位置
        int m = 0, n = 0;
        // 建立left 和 right 目前的高度
        int lpreH = 0, rpreH = 0;
        // 兩個座標
        int leftX, leftY, rightX, rightY;
        while (m < left.size() || n < right.size()) {

            // 當有一邊徹底加入到res時,則加入剩餘的那部分
            if (m >= left.size()) res.add(right.get(n++));
            else if (n >= right.size()) res.add(left.get(m++));

            else { // 開始判斷left 和 right
                leftX = left.get(m).get(0); // 不會出現null,能夠直接用int類型
                leftY = left.get(m).get(1);
                rightX = right.get(n).get(0);
                rightY = right.get(n).get(1);

                if (leftX < rightX) {
                   if (leftY > rpreH) res.add(left.get(m));
                   else if (lpreH > rpreH) res.add(Arrays.asList(leftX, rpreH));
                    lpreH = leftY;
                    m++;
                } else if (leftX > rightX) {
                   if (rightY > lpreH) res.add(right.get(n));
                   else if (rpreH > lpreH) res.add(Arrays.asList(rightX, lpreH));
                    rpreH = rightY;
                    n++;
                } else { // left 和 right 的橫座標相等
                    if (leftY >= rightY && leftY != (lpreH > rpreH ? lpreH : rpreH))
                        res.add(left.get(m));
                    else if (leftY <= rightY && rightY != (lpreH > rpreH ? lpreH : rpreH))
                        res.add(right.get(n));
                    lpreH = leftY;
                    rpreH = rightY;
                    m++;
                    n++;
                }
            }
        }
        return res;
    }
}
Java執行結果
執行用時 : 6 ms , 在全部 Java 提交中擊敗了 99.53% 的用戶
內存消耗 : 44 MB , 在全部 Java 提交中擊敗了 57.65% 的用戶
Java代碼單獨使用掃描線法
// 線掃描法
public List<List<Integer>> getSkyline2(int[][] buildings) {
    // 建立返回值
    List<List<Integer>> res = new ArrayList<>();
    // 保存全部的可能拐點
    Set<Pair<Integer, Integer>> pairs = new TreeSet<>(
        (o1, o2) -> !o1.getKey().equals(o2.getKey()) ? o1.getKey() - o2.getKey() : o1.getValue() - o2.getValue()); // 二元組
    // 將每個建築分紅兩個部分
    for (int[] build : buildings) {
        pairs.add(new Pair<>(build[0], -build[2]));
        pairs.add(new Pair<>(build[1], build[2]));
    }
    // 優先隊列的最大堆
    PriorityQueue<Integer> queue = new PriorityQueue<>((o1, o2) -> o2 - o1); // 最大堆
    // 記錄以前的高度
    int prev = 0;
    // 遍歷
    for (Pair<Integer, Integer> pair : pairs) {
        if (pair.getValue() < 0) queue.offer(-pair.getValue()); // 左端點 高度入堆
        else queue.remove(pair.getValue()); // 右端點 高度出堆
        Integer cur = queue.peek() == null ? 0 : queue.peek(); // 獲取最大堆的當前頂點,當null時置爲0
        if (prev != cur) {
            res.add(new ArrayList<Integer>() {{
                add(pair.getKey());
                add(cur);
            }});
            prev = cur;
        }
    }
    return res;
}

關注「算法無遺策」,一塊兒領悟算法的魅力,你們加油 (●'◡'●)

喜歡本文的朋友,微信搜索「算法無遺策」公衆號,收看更多精彩的算法動畫文章

相關文章
相關標籤/搜索