掃描線專題
leetcode 掃描線專題 06-掃描線演算法(Sweep Line Algorithm)
leetcode 掃描線專題 06-leetcode.218 the-skyline-problem 力扣.218 天際線問題
leetcode 掃描線專題 06-leetcode.252 meeting room 力扣.252 會議室
leetcode 掃描線專題 06-leetcode.253 meeting room ii 力扣.253 會議室 II
題目
給定一個會議時間安排的陣列 intervals ,每個會議時間都會包括開始和結束的時間 intervals[i] = [starti, endi] ,請你判斷一個人是否能夠參加這裡面的全部會議。
示例 1:
輸入:intervals = [[0,30],[5,10],[15,20]]
輸出:false
示例 2:
輸入:intervals = [[7,10],[2,4]]
輸出:true
提示:
0 <= intervals.length <= 10^4
intervals[i].length == 2
0 <= starti < endi <= 10^6
整體思路
一般這種區間的題目,常見的有下面的解決方案:
-
暴力
-
排序
-
掃描線
-
優先佇列
v1-暴力
思路
直接兩層迴圈,核心是如何判斷兩個會議室重疊?
重疊條件解釋
兩個會議的重疊定義為:會議 i 的時間段與會議 j 的時間段有交集。為了檢查這種情況,我們考慮以下四種情形:
會議 i 在會議 j 之前結束(沒有重疊):
若 intervals[i][1] <= intervals[j][0],則會議 i 在會議 j 開始之前結束,說明沒有重疊。
會議 i 在會議 j 之後開始(沒有重疊):
若 intervals[j][1] <= intervals[i][0],則會議 j 在會議 i 開始之前結束,也說明沒有重疊。
會議 i 在會議 j 期間發生(有重疊):
如果 intervals[i][0] < intervals[j][1] 且 intervals[i][1] > intervals[j][0],則會議 i 的時間段與會議 j 有交集,說明有重疊。
會議 j 在會議 i 期間發生(有重疊):
如果 intervals[j][0] < intervals[i][1] 且 intervals[j][1] > intervals[i][0],則會議 j 的時間段與會議 i 有交集,同樣說明有重疊。
實現
因為 i,j 是相互的,我們只需要判斷一次即可。
public static boolean canAttendMeetings(int[][] intervals) {
int n = intervals.length;
// 雙重迴圈檢查每一對會議是否重疊
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
// 檢查會議 intervals[i] 和 intervals[j] 是否重疊
if (intervals[i][1] > intervals[j][0] && intervals[j][1] > intervals[i][0]) {
return false; // 找到重疊會議,返回 false
}
}
}
// 沒有重疊會議
return true;
}
小結
暴力演算法比較容易想到,但是重疊的概念還是需要我們多思考。
v2-排序
思路
我們對數字進行排序。
- 按照開始時間排序
2)如果當前的開始時間小於上一次的結束時間。則存在重複
現在都開始了,上一次還沒有結束。
實現
public static boolean canAttendMeetings(int[][] intervals) {
int n = intervals.length;
Arrays.sort(intervals, Comparator.comparingInt(o -> o[0]));
// 雙重迴圈檢查每一對會議是否重疊
for (int i = 1; i < n; i++) {
// 本次開始 上一次還沒有結束
if(intervals[i][0] < intervals[i-1][1]) {
return false;
}
}
// 沒有重疊會議
return true;
}
v3-掃描線演算法 sweep line
思路
掃描線演算法可以幫助我們處理區間類問題。
我們可以將每個會議的開始時間和結束時間分別標記為 +1 和 -1,然後對所有時間點進行排序並掃描,檢視在任一時刻是否有超過一個會議正在進行。
如果有,則表示衝突,無法參加所有會議。
步驟:
-
將每個會議的開始時間標記為 +1,結束時間標記為 -1,然後合併到一個事件列表 events 中。
-
按時間順序對事件進行排序,如果時間相同,則優先處理結束事件(即 -1)。
-
遍歷事件列表,累加標記值,檢視任意時刻是否有超過一個會議正在進行。
-
如果任意時刻會議數量超過 1,則返回 false;否則返回 true。
實現
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class MeetingScheduler {
public static boolean canAttendMeetings(int[][] intervals) {
List<int[]> events = new ArrayList<>();
// 將每個會議的開始和結束時間作為事件儲存
for (int[] interval : intervals) {
events.add(new int[]{interval[0], 1}); // 會議開始時間 +1
events.add(new int[]{interval[1], -1}); // 會議結束時間 -1
}
// 按時間排序,如果時間相同,優先處理結束事件
Collections.sort(events, (a, b) -> (a[0] == b[0]) ? Integer.compare(a[1], b[1]) : Integer.compare(a[0], b[0]));
int ongoingMeetings = 0;
// 掃描事件列表,檢查會議的重疊情況
for (int[] event : events) {
ongoingMeetings += event[1]; // 加上當前事件的標記值
if (ongoingMeetings > 1) {
return false; // 有重疊會議
}
}
return true; // 沒有重疊會議
}
}
為什麼要這樣排序?
在掃描線演算法中,我們對事件的排序方式有特定的規則,這樣排序是為了正確處理時間相同的開始和結束事件,避免錯誤的計數。
排序規則解釋
在掃描線問題中,我們通常將每個區間拆分為兩個事件,一個開始事件和一個結束事件。我們按照以下規則對所有事件進行排序:
- 按時間(
a[0]
)升序排列:這保證了我們按時間順序掃描所有事件。 - 時間相同時,優先處理結束事件(即
a[1]
進行二次排序):如果兩個事件的時間相同,我們透過讓結束事件優先於開始事件來避免計數錯誤。
為什麼時間相同時優先處理結束事件?
在掃描線演算法中,優先處理結束事件可以避免錯誤的重疊判斷。
例如:
-
如果兩個會議的結束時間和下一個會議的開始時間相同,那麼優先處理結束事件可以讓我們在下一個會議開始前,結束當前會議,確保不會被錯誤地計為重疊。
-
如果我們不優先處理結束事件,在計數上可能會把開始事件算在前一個活動上,從而導致錯誤的結果(如錯誤地增加了計數)。
具體排序語句解釋
以下程式碼是具體的排序語句:
Collections.sort(events, (a, b) -> (a[0] == b[0]) ? Integer.compare(a[1], b[1]) : Integer.compare(a[0], b[0]));
a[0] == b[0]
:首先比較事件發生的時間。如果兩個事件的時間相同,則a[1]
和b[1]
的比較決定優先順序。Integer.compare(a[1], b[1])
:時間相同的情況下,a[1]
和b[1]
的值決定排序。通常,我們用+1
表示開始事件,-1
表示結束事件,因此結束事件優先順序高(-1
小於+1
)。Integer.compare(a[0], b[0])
:時間不同的情況下,按照事件的時間排序(從小到大)。
示例說明
假設有三個會議時間:[5, 10]
, [5, 12]
, [10, 15]
。拆解事件後:
[(5, +1), (10, -1), (5, +1), (12, -1), (10, +1), (15, -1)]
排序結果為:
[(5, +1), (5, +1), (10, -1), (10, +1), (12, -1), (15, -1)]
綜上所述
按這種順序排序,可以確保時間相同的情況下優先結束事件,從而正確地處理重疊區間並維護掃描線的計數。
小結
看起來這個掃描線演算法還是比較有趣的,後面我們專門一個系列學習一下。
v4-使用優先佇列(最小堆)
思路
這種方法透過維護一個最小堆來跟蹤當前正在進行的會議,適合在更復雜的情況下進一步擴充套件。
例如,計算一個人最多能同時參加的會議數目等。
步驟:
- 按開始時間排序
intervals
。 - 使用最小堆(優先佇列)來儲存當前正在進行的會議結束時間。
- 對於每個會議,檢查堆頂的結束時間(即最早結束的會議)是否小於等於當前會議的開始時間:
- 如果是,則說明前一個會議已結束,可以移除堆頂。
- 否則,將當前會議的結束時間加入到堆中。
- 如果堆中有多個會議的結束時間存在,則表示衝突,返回
false
;否則返回true
。
實現
import java.util.Arrays;
import java.util.PriorityQueue;
public class MeetingScheduler {
public static boolean canAttendMeetings(int[][] intervals) {
// 按開始時間排序
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
// 最小堆用於跟蹤會議的結束時間
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int[] interval : intervals) {
// 如果堆頂的會議結束時間小於等於當前會議的開始時間,彈出堆頂
if (!minHeap.isEmpty() && minHeap.peek() <= interval[0]) {
minHeap.poll();
}
// 將當前會議的結束時間加入堆中
minHeap.add(interval[1]);
// 如果堆中有超過一個會議的結束時間,說明有重疊
if (minHeap.size() > 1) {
return false;
}
}
return true;
}
}
為啥要用優先順序佇列?
優先順序佇列(PriorityQueue
)在掃描線演算法中的作用主要是動態地管理和維護當前活動的區間或事件。
即使我們先對所有事件進行了排序,使用優先順序佇列在掃描過程中仍然可以有效地處理區間重疊、活動計數等問題。
為什麼排序之後不直接對比而要用優先順序佇列?
雖然排序能夠幫助我們按時間順序處理事件,但在掃描過程中,動態維護當前活動的區間或事件是很重要的。
這就是優先順序佇列的作用。它可以在掃描過程中高效地維護哪些區間或事件仍然處於“活躍”狀態。
優先順序佇列的關鍵作用
-
動態更新活動狀態:
-
在掃描過程中,我們需要保持一個活躍的集合,即哪些區間正在被處理(比如會議正在進行中)。這些區間的狀態會隨著時間推移而變化。
-
優先順序佇列可以讓我們在每個事件發生時快速地進行新增、刪除和更新操作。例如,當會議開始時,我們將其新增到優先順序佇列中;當會議結束時,我們將其從佇列中移除。
-
-
高效獲取當前活動狀態:
- 在掃描過程中,每當遇到一個事件時,我們需要判斷當前活動的區間或事件是否滿足特定條件(例如當前同時進行的會議數量,或者判斷是否有重疊的區間)。使用優先順序佇列可以讓我們在
O(log n)
時間內快速地插入和刪除事件,並且始終能夠訪問當前最優的區間或狀態。
- 在掃描過程中,每當遇到一個事件時,我們需要判斷當前活動的區間或事件是否滿足特定條件(例如當前同時進行的會議數量,或者判斷是否有重疊的區間)。使用優先順序佇列可以讓我們在
-
保持區間順序:
- 雖然我們已經對所有事件進行了排序,但在掃描過程中,活動區間的結束時間是動態變化的。因此,使用優先順序佇列可以幫助我們動態地維護一個按結束時間排序的集合。這樣每次我們需要知道最早結束的區間時,可以在
O(1)
時間內獲取。
- 雖然我們已經對所有事件進行了排序,但在掃描過程中,活動區間的結束時間是動態變化的。因此,使用優先順序佇列可以幫助我們動態地維護一個按結束時間排序的集合。這樣每次我們需要知道最早結束的區間時,可以在
典型的使用場景
以經典的 會議室問題(LC 253)為例:
1. 事件排序:
我們首先將所有的開始事件和結束事件按照時間進行排序。如果時間相同,我們優先處理結束事件。排序後的事件列表可能如下:
[(5, +1), (5, +1), (10, -1), (10, +1), (12, -1), (15, -1)]
2. 使用優先順序佇列維護活動會議:
在掃描事件時,我們需要維護當前活躍的會議。例如,我們可能希望知道目前有多少會議在同時進行。每當遇到一個開始事件(+1
),我們將其加入優先順序佇列;每當遇到一個結束事件(-1
),我們將其從優先順序佇列中移除。
使用優先順序佇列可以確保:
- 對於每個會議,按開始時間和結束時間進行正確排序。
- 在遇到結束事件時,能夠及時從佇列中移除已經結束的會議。
3. 最小會議室數:
每次掃描到一個開始事件時,我們會將當前活動的會議數增加;每次掃描到結束事件時,我們會減少當前活動的會議數。我們透過維護當前活動會議的數量,最終可以得到最大併發的會議數,也就是所需的最小會議室數。
這時,優先順序佇列的作用是:
- 插入操作:在遇到一個開始事件時,向佇列中插入新的會議。
- 刪除操作:在遇到一個結束事件時,從佇列中刪除結束的會議。
4. 具體實現細節:
假設我們使用一個優先順序佇列來儲存當前正在進行的會議的結束時間。每當遇到開始事件時,我們將該會議的結束時間加入佇列;每當遇到結束事件時,我們將其從佇列中移除。
優先順序佇列在這裡的作用:
-
維護活動會議:優先順序佇列根據結束時間維護當前正在進行的會議的結束時間。在掃描事件時,能夠高效地新增、刪除結束的會議。
-
統計活動會議數:在掃描過程中,佇列的大小代表當前正在進行的會議數,最終輸出的最大佇列大小即為所需的最小會議室數。
總結
排序後的事件只是幫助我們按時間順序掃描事件。
優先順序佇列則透過動態管理活躍區間(如會議的結束時間等),保證我們能高效地判斷每個時刻的活動狀態,快速獲得當前活躍區間的數量或其它資訊。
在這種問題中,排序加優先順序佇列的組合為掃描線演算法提供了高效且靈活的解決方案。
小結
不得不說,用陣列替代 map 的方法確實令人歎為觀止。
那麼,我們前面的 two-sum 是不是也可以這樣最佳化?
開源地址
為了便於大家學習,所有實現均已開源。歡迎 fork + star~
https://github.com/houbb/leetcode