這是 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;
}
「虛擬線段」其實就是爲了將所有座位表示爲一個線段:
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);
}
至此,算法就基本實現了,代碼雖多,但思路很簡單:找最長的線段,從中間分隔成兩段,中點就是 seat()
的返回值;找 p
的左右線段,合併成一個線段,這就是 leave(p)
的邏輯。
三、進階問題
但是,題目要求多個選擇時選擇索引最小的那個座位,我們剛纔忽略了這個問題。比如下面這種情況會出錯:
現在有序集合裏有線段 [0,4]
和 [4,9]
,那麼最長線段 longest
就是後者,按照 seat
的邏輯,就會分割 [4,9]
,也就是返回座位 6。但正確答案應該是座位 2,因爲 2 和 6 都滿足最大化相鄰考生距離的條件,二者應該取較小的。
遇到題目的這種要求,解決方式就是修改有序數據結構的排序方式。具體到這個問題,就是修改 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;
}
這樣,[0,4]
和 [4,9]
的 distance
值就相等了,算法會比較二者的索引,取較小的線段進行分割。到這裏,這道算法題目算是完全解決了。
四、最後總結
本文聊的這個問題其實並不算難,雖然看起來代碼很多。核心問題就是考察有序數據結構的理解和使用,來梳理一下。
處理動態問題一般都會用到有序數據結構,比如平衡二叉搜索樹和二叉堆,二者的時間複雜度差不多,但前者支持的操作更多。
既然平衡二叉搜索樹這麼好用,還用二叉堆幹嘛呢?因爲二叉堆底層就是數組,實現簡單啊,詳見舊文「二叉堆詳解」。你實現個紅黑樹試試?操作複雜,而且消耗的空間相對來說會多一些。具體問題,還是要選擇恰當的數據結構來解決。