Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

qing的世界發表於2019-04-07

Android程式設計師面試會遇到的演算法系列:

Android程式設計師面試會遇到的演算法(part 1 關於二叉樹的那點事) 附Offer情況

Android程式設計師面試會遇到的演算法(part 2 廣度優先搜尋)

Android程式設計師面試會遇到的演算法(part 3 深度優先搜尋-回溯backtracking)

Android程式設計師面試會遇到的演算法(part 4 訊息佇列的應用)

Android程式設計師會遇到的演算法(part 5 字典樹)

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

Android程式設計師會遇到的演算法(part 7 拓撲排序)

又是隔了四個多月才更新,從十月底來到美國開始上班,中間雜七雜八的事情很多,加上感恩節聖誕節放假出去玩了幾趟,一直拖到現在。

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

這一次我想講一個比較經典的Java裡面的資料結構。PriorityQueue,優先順序佇列的一些對應的演算法題。

優先順序佇列聽起來很唬,其實就是一個幫助大家排序的資料結構而已,只不過它把插入->push,和獲取佇列頭結點->poll()給封裝起來了而已。

在很多面試的演算法輪中,直接要求面試者去寫排序演算法的已經很少很少了,第一是排序演算法其實寫起來其實不簡單。。。。。 :joy::joy::joy::joy::joy::joy: ,第二是現在排序演算法已經很成熟,問也問不出什麼門道來。所以很多情況下面試官會更加傾向於問面試者對於排序場景下,一些子場景的演算法。在Java裡面,PriorityQueue已經提供了大部分我們需要的api,所以接下里我們就看看有哪些經典的優先順序佇列的演算法題。

1. 會議室問題

會議室問題可以說是排序/優先順序佇列應用的最具代表性的題目之一了。問題很簡單,就是在給定一組會議的資料之後,判斷某一個人能否完整的參加完所有會議,或者換個角度,會議安排者最少需要安排多少會議室,才能讓所有會議都照常舉辦(沒有會議室衝突)。

假設給定一個資料結構,


public class Interval {
        /**
        會議開始時間
        **/
        int start;
        
        /**
        會議結束時間
        **/
        int end;

        Interval() {
            start = 0;
            end = 0;
        }

        Interval(int s, int e) {
            start = s;
            end = e;
        }
    }


複製程式碼

我們要實現一個boolean返回值的方法

public boolean canAttendMeetings(Interval[] intervals)

在給定一個List of Interval的情況下,判斷一個人能不能完整的參於所有list裡面的會議。比如:

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

兩個箭頭線段代表一個會議的跨越時長,在上圖裡面,兩個會議直接沒有重疊,正如圖中的紅線所示,就算紅線一直平行的從左往右移動,也不會橫截超過一個會議的箭頭線段。所以在上圖的情況,一個人是可以參與所有會議的。

但是下圖所示:

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

這些情況下,一個人就不能參與所有的會議了,很明顯紅線可以同時穿過兩個會議的箭頭線段。

那麼判斷的方法是什麼呢?

以正常的思維去想,肯定會覺得,我們是不是要去寫一個迴圈,按照時間沒走一秒就去迴圈判斷所有的會議是不是在這個時間上有會議,如果超過一個就返回false?

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

這樣做是肯定不行的,因為你不確定時間的細粒度,是秒呢?還是毫秒?還是分鐘?在不確定這個的情況下,我們是沒法寫for 迴圈的。

那麼我們可以換一種思路,既然不能for 迴圈,那能不能把每次某個會議開始或者結束當成一個事件Event,每種事件Event可以分兩種型別,一種是開始start,一種是結束end,我們只需要對當前所有的全部事件進行排序之後分析,而不需要對時間本身進行迴圈。

比如:

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

按照時間線來排序的話,我們會先後有三個會議,這三個會議的起始start以此排列,從此圖的示例我們可以輕鬆的看出,同時會有三個會議進行。但是理由呢?理由是因為你看到了線段的重疊麼?真正的理由是當三個start事件進入之後,我們的第一個end事件才進入。

所以,再對所有事件排序好之後,每當我們有一個start事件,會議室數量就需要+1,每當我們有一個end事件的時候,會議室數量就-1.因為end代表一個會議結束,因此所需要的會議室數量可以減少。

有了這個前提之後,我們就可以寫程式碼了。

先定義一個事件:



 public class TimeEvent {
        /**start型別
        **/
        public static final int start = 0;
        /**end型別
        **/
        public static final int end = 1;
        /**該事件發生的時間
        **/
        public int time;
        /**該事件的型別,是開始還是結束
        **/
        public int type;

        public TimeEvent(int type, int time) {
            this.type = type;
            this.time = time;
        }
    }


複製程式碼

public boolean canAttendMeetings(Interval[] intervals) {
		if (intervals.length == 0) {
			return true;
		} else {
            /**
            **定義一個優先順序佇列,事件按照時間從小到大排列
            **/
			PriorityQueue<TimeEvent> priorityQueue = new PriorityQueue<>(new Comparator<TimeEvent>() {
				@Override
				public int compare(TimeEvent o1, TimeEvent o2) {
					// TODO Auto-generated method stub
					if (o1.time == o2.time) {
						/**
                        **這裡兩個if暫時可能很難理解,我在下面會解釋
                        **/
						if (o1.type == TimeEvent.start && o2.type == TimeEvent.end) {
							return 1;
						}
						if (o2.type == TimeEvent.start && o1.type == TimeEvent.end) {
							return -1;
						}
					}
					return o1.time - o2.time;
				}
			});
			for (int i = 0; i < intervals.length; i++) {
                /**
                 **把事件的起始和結束事件建立出來並且放入優先順序佇列
                 **/
				priorityQueue.add(new TimeEvent(TimeEvent.start, intervals[i].start));
				priorityQueue.add(new TimeEvent(TimeEvent.end, intervals[i].end));
			}

			int max = 0;
			int current = 0;
			while (!priorityQueue.isEmpty()) {
				TimeEvent event = priorityQueue.poll();
				if (event.type == TimeEvent.start) {
                    /**如果是開始事件,會議室數量+1,只要會議室數量大於等於2,返回false
                    /
					current = current + 1;
					if (current >= 2) {
						return false;
					}
				} else {
                     /**如果是開始事件,會議室數量-1.代表到這個時間為止,一個會議結束了。雖然我們
                     **並不在乎是哪一個會議結束了。
                      **/
					current = current - 1;
				}
			}
			return true;
		}
	}


複製程式碼

上面程式碼裡面註釋的這一段:


if (o1.type == TimeEvent.start && o2.type == TimeEvent.end) {
	return 1;
}
if (o2.type == TimeEvent.start && o1.type == TimeEvent.end) {
	return -1;
}


複製程式碼

其實是處理這樣的一種邊界情況:

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

當後一個事件的start和前一個事件的end是同一時間的時候,這種情況算是需要兩個會議室還是一個?

答案是看情況。。。。。

假如題目要求這種情況只需要一個會議室,那麼,假如兩個TimeEvent,分別是start與end,time也相同,我們必須優先處理end,因為先處理end,我們的會議室數量就會先做-1.

按照圖中的例子會議室數量會:1,0,1,0這樣的變化。

假如題目要求這種情況只需要兩個個會議室,那麼,假如兩個TimeEvent,分別是start與end,time也相同,我們必須優先處理start,因為先處理start,我們的會議室數量就會先做+1.

按照圖中的例子會議室數量會:1,2,1,0這樣的變化。

兩種情況會議室的峰值不一樣。所以再回到上段程式碼,相信你可以理解程式碼中的if對應哪種情況了吧?

2. 合併線段的問題

假設給定一組線段,要求把重疊在一起的線段整合成新的線段返回,比如:

一種情況

Screen Shot 2019-01-06 at 5.28.09 PM.png

第二種情況

Screen Shot 2019-01-06 at 5.27.54 PM.png

第三種情況,沒變化:

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

這裡的解題思路和上面一樣,先把每個線段安裝開始時間排序,不同的是,每次在處理當前線段的時候,需要和上一個線段做對比,看看有沒有重疊,如果重疊了,則需要刪除上一個線段並且生成新的線段。

這裡,一個棧Stack可以完美的處理!

image

步驟如下,

1.線段在優先順序佇列裡面排好序

2.每次提取佇列第一個線段

3.與stack中的棧頂線段作對比,

4.如果有重疊,pop棧頂線段,生成新的線段放入棧頂,重複第一步

我們每次只需要處理棧頂線段的原因是,如果棧頂線段和棧頂線段之前的棧內線段有衝突的話,我們在之前的一步已經處理好了,所以當前佇列的第一個線段,是絕對不可能和非棧頂線段有重疊的。

程式碼如下:

public List<Interval> insert(List<Interval> intervals, Interval newInterval) {
        /**
        **用優先順序佇列把所有線段排好序,按照起始時間
        **/
		PriorityQueue<Interval> priorityQueue = new PriorityQueue<Interval>(new Comparator<Interval>() {
			public int compare(Interval o1, Interval o2) {
				return o1.start - o2.start;
			};
		});
		for (int i = 0; i < intervals.size(); i++) {
			priorityQueue.add(intervals.get(i));
		}
		priorityQueue.add(newInterval);

        /**
        **用棧儲存處理過的線段
        **/
		Stack<Interval> stack = new Stack<>();
		stack.push(priorityQueue.remove());
        /**
        **while迴圈處理線段
        **/
		while (!stack.isEmpty() && !priorityQueue.isEmpty()) {
			Interval inItem = priorityQueue.remove();
			Interval originalItem = stack.pop();
            /**
            **當線段不完全重疊的時候,取兩者的最小開始時間和最大結束時間,生成新的線段
            **/
			if (inItem.start <= originalItem.end && inItem.end > originalItem.end) {
				stack.push(new Interval(originalItem.start, inItem.end));
                /**
            **當線段完全重疊的時候,取兩者的中覆蓋面最大的那一線段
            **/
			} else if (inItem.start <= originalItem.end && originalItem.end >= inItem.end) {
				stack.push(originalItem);
			} 
               /**
            **當線段沒有重疊的時候,兩者一起入棧
            **/
            else {
				stack.push(originalItem);
				stack.push(inItem);
			}
		}
         /**
            **因為stack的輸出是倒著來的,所以翻轉一次。。。
            **/
		Stack<Interval> stack2 = new Stack<>();
		while (!stack.isEmpty()) {
			stack2.push(stack.pop());
		}
		ArrayList<Interval> arrayList = new ArrayList<>();
		while (!stack2.isEmpty()) {
			arrayList.add(stack2.pop());
		}
		return arrayList;

	}

複製程式碼

PS:其實筆者在寫完之後才發現其實用一個LinkedList來代替程式碼中的stack更好一些。。。。可以不需要翻轉一次。讀者可以自行摸索。。。

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

2. 城市天際線問題

最後一個問題留給讀者們自己去思考,城市天際線問題:

image

在給出若干組城市建築的座標和高度之後,返回最後應該畫出來的天際線的樣子,這題也是需要對資料進行排序,按照事件來處理的題目。屬於稍微複雜一點的問題,但是原則還是一樣,需要用到優先順序佇列來處理。

相關文章