如何用算法調度考生的座位

這是 LeetCode 第 885 題,有趣且具有一定技巧性。這種題目並不像動態規劃這類算法拼智商,而是看你對常用數據結構的理解和寫代碼的水平,個人認爲值得重視和學習。

另外說句題外話,很多讀者都問,算法框架是如何總結出來的,其實框架反而是慢慢從細節裏摳出來的。希望大家看了我們的文章之後,最好能抽時間把相關的問題親自做一做,紙上得來終覺淺,絕知此事要躬行嘛。

先來描述一下題目:假設有一個考場,考場有一排共 N 個座位,索引分別是 [0..N-1],考生會陸續進入考場考試,並且可能在任何時候離開考場。

你作爲考官,要安排考生們的座位,滿足:每當一個學生進入時,你需要最大化他和最近其他人的距離;如果有多個這樣的座位,安排到他到索引最小的那個座位。這很符合實際情況對吧,

也就是請你實現下面這樣一個類:

class ExamRoom {
    // 構造函數,傳入座位總數 N
    public ExamRoom(int N);
    // 來了一名考生,返回你給他分配的座位
    public int seat();
    // 坐在 p 位置的考生離開了
    // 可以認爲 p 位置一定坐有考生
    public void leave(int p);
}

比方說考場有 5 個座位,分別是 [0..4]

第一名考生進入時(調用 seat()),坐在任何位置都行,但是要給他安排索引最小的位置,也就是返回位置 0。

第二名學生進入時(再調用 seat()),要和旁邊的人距離最遠,也就是返回位置 4。

第三名學生進入時,要和旁邊的人距離最遠,應該做到中間,也就是座位 2。

如果再進一名學生,他可以坐在座位 1 或者 3,取較小的索引 1。

以此類推。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

剛纔所說的情況,沒有調用 leave 函數,不過讀者肯定能夠發現規律:

如果將每兩個相鄰的考生看做線段的兩端點,新安排考生就是找最長的線段,然後讓該考生在中間把這個線段「二分」,中點就是給他分配的座位。leave(p) 其實就是去除端點 p,使得相鄰兩個線段合併爲一個

核心思路很簡單對吧,所以這個問題實際上實在考察你對數據結構的理解。對於上述這個邏輯,你用什麼數據結構來實現呢?

一、思路分析

根據上述思路,首先需要把坐在教室的學生抽象成線段,我們可以簡單的用一個大小爲 2 的數組表示。

另外,思路需要我們找到「最長」的線段,還需要去除線段,增加線段。

但凡遇到在動態過程中取最值的要求,肯定要使用有序數據結構,我們常用的數據結構就是二叉堆和平衡二叉搜索樹了。二叉堆實現的優先級隊列取最值的時間複雜度是 O(logN),但是隻能刪除最大值。平衡二叉樹也可以取最值,也可以修改、刪除任意一個值,而且時間複雜度都是 O(logN)。

綜上,二叉堆不能滿足 leave 操作,應該使用平衡二叉樹。所以這裏我們會用到 Java 的一種數據結構 TreeSet,這是一種有序數據結構,底層由紅黑樹維護有序性。

這裏順便提一下,一說到集合(Set)或者映射(Map),有的讀者可能就想當然的認爲是哈希集合(HashSet)或者哈希表(HashMap),這樣理解是有點問題的。

因爲哈希集合/映射底層是由哈希函數和數組實現的,特性是遍歷無固定順序,但是操作效率高,時間複雜度爲 O(1)。

而集合/映射還可以依賴其他底層數據結構,常見的就是紅黑樹(一種平衡二叉搜索樹),特性是自動維護其中元素的順序,操作效率是 O(logN)。這種一般稱爲「有序集合/映射」。

我們使用的 TreeSet 就是一個有序集合,目的就是爲了保持線段長度的有序性,快速查找最大線段,快速刪除和插入。

二、簡化問題

首先,如果有多個可選座位,需要選擇索引最小的座位對吧?我們先簡化一下問題,暫時不管這個要求,實現上述思路。

這個問題還用到一個常用的編程技巧,就是使用一個「虛擬線段」讓算法正確啓動,這就和鏈表相關的算法需要「虛擬頭結點」一個道理。

// 將端點 p 映射到以 p 爲左端點的線段
private Map<Integer, int[]> startMap;
// 將端點 p 映射到以 p 爲右端點的線段
private Map<Integer, int[]> endMap;
// 根據線段長度從小到大存放所有線段
private TreeSet<int[]> pq;
private int N;

public ExamRoom(int N) {
    this.N = N;
    startMap = new HashMap<>();
    endMap = new HashMap<>();
    pq = new TreeSet<>((a, b) -> {
        // 算出兩個線段的長度
        int distA = distance(a);
        int distB = distance(b);
        // 長度更長的更大,排後面
        return distA - distB;
    });
    // 在有序集合中先放一個虛擬線段
    addInterval(new int[] {-1, N});
}

/* 去除一個線段 */
private void removeInterval(int[] intv) {
    pq.remove(intv);
    startMap.remove(intv[0]);
    endMap.remove(intv[1]);
}

/* 增加一個線段 */
private void addInterval(int[] intv) {
    pq.add(intv);
    startMap.put(intv[0], intv);
    endMap.put(intv[1], intv);
}

/* 計算一個線段的長度 */
private int distance(int[] intv) {
    return intv[1] - intv[0] - 1;
}

「虛擬線段」其實就是爲了將所有座位表示爲一個線段:

d45b3c2d0a2b63402309731ff9bccfc4.jpeg

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

有了上述鋪墊,主要 API seat 和 leave 就可以寫了:

public int seat() {
    // 從有序集合拿出最長的線段
    int[] longest = pq.last();
    int x = longest[0];
    int y = longest[1];
    int seat;
    if (x == -1) { // 情況一
        seat = 0;
    } else if (y == N) { // 情況二
        seat = N - 1;
    } else { // 情況三
        seat = (y - x) / 2 + x;
    }
    // 將最長的線段分成兩段
    int[] left = new int[] {x, seat};
    int[] right = new int[] {seat, y};
    removeInterval(longest);
    addInterval(left);
    addInterval(right);
    return seat;
}

public void leave(int p) {
    // 將 p 左右的線段找出來
    int[] right = startMap.get(p);
    int[] left = endMap.get(p);
    // 合併兩個線段成爲一個線段
    int[] merged = new int[] {left[0], right[1]};
    removeInterval(left);
    removeInterval(right);
    addInterval(merged);
}

08751e7056215b32c59671ae9786d054.jpeg

至此,算法就基本實現了,代碼雖多,但思路很簡單:找最長的線段,從中間分隔成兩段,中點就是 seat() 的返回值;找 p 的左右線段,合併成一個線段,這就是 leave(p) 的邏輯。

三、進階問題

但是,題目要求多個選擇時選擇索引最小的那個座位,我們剛纔忽略了這個問題。比如下面這種情況會出錯:

d9e32202df95c5300142e6f87bd8a702.jpeg

現在有序集合裏有線段 [0,4] 和 [4,9],那麼最長線段 longest 就是後者,按照 seat 的邏輯,就會分割 [4,9],也就是返回座位 6。但正確答案應該是座位 2,因爲 2 和 6 都滿足最大化相鄰考生距離的條件,二者應該取較小的。

421956a5ee061e0fd5c69173e65d49c9.jpeg

遇到題目的這種要求,解決方式就是修改有序數據結構的排序方式。具體到這個問題,就是修改 TreeMap 的比較函數邏輯:

pq = new TreeSet<>((a, b) -> {
    int distA = distance(a);
    int distB = distance(b);
    // 如果長度相同,就比較索引
    if (distA == distB)
        return b[0] - a[0];
    return distA - distB;
});

除此之外,還要改變 distance 函數,不能簡單地讓它計算一個線段兩個端點間的長度,而是讓它計算該線段中點和端點之間的長度

private int distance(int[] intv) {
    int x = intv[0];
    int y = intv[1];
    if (x == -1) return y;
    if (y == N) return N - 1 - x;
    // 中點和端點之間的長度
    return (y - x) / 2;
}

c06ceaccbac79e74308d509199ddef1b.jpeg

這樣,[0,4] 和 [4,9] 的 distance 值就相等了,算法會比較二者的索引,取較小的線段進行分割。到這裏,這道算法題目算是完全解決了。

四、最後總結

本文聊的這個問題其實並不算難,雖然看起來代碼很多。核心問題就是考察有序數據結構的理解和使用,來梳理一下。

處理動態問題一般都會用到有序數據結構,比如平衡二叉搜索樹和二叉堆,二者的時間複雜度差不多,但前者支持的操作更多。

既然平衡二叉搜索樹這麼好用,還用二叉堆幹嘛呢?因爲二叉堆底層就是數組,實現簡單啊,詳見舊文「二叉堆詳解」。你實現個紅黑樹試試?操作複雜,而且消耗的空間相對來說會多一些。具體問題,還是要選擇恰當的數據結構來解決。


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