Android程序员会遇到的算法(part 6 优先级队列PriorityQueue)

2,443 阅读8分钟

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 拓扑排序)

又是隔了四个多月才更新,从十月底来到美国开始上班,中间杂七杂八的事情很多,加上感恩节圣诞节放假出去玩了几趟,一直拖到现在。

这一次我想讲一个比较经典的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里面的会议。比如:

两个箭头线段代表一个会议的跨越时长,在上图里面,两个会议直接没有重叠,正如图中的红线所示,就算红线一直平行的从左往右移动,也不会横截超过一个会议的箭头线段。所以在上图的情况,一个人是可以参与所有会议的。

但是下图所示:

这些情况下,一个人就不能参与所有的会议了,很明显红线可以同时穿过两个会议的箭头线段。

那么判断的方法是什么呢?

以正常的思维去想,肯定会觉得,我们是不是要去写一个循环,按照时间没走一秒就去循环判断所有的会议是不是在这个时间上有会议,如果超过一个就返回false?

这样做是肯定不行的,因为你不确定时间的细粒度,是秒呢?还是毫秒?还是分钟?在不确定这个的情况下,我们是没法写for 循环的。

那么我们可以换一种思路,既然不能for 循环,那能不能把每次某个会议开始或者结束当成一个事件Event,每种事件Event可以分两种类型,一种是开始start,一种是结束end,我们只需要对当前所有的全部事件进行排序之后分析,而不需要对时间本身进行循环。

比如:

按照时间线来排序的话,我们会先后有三个会议,这三个会议的起始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;
}


其实是处理这样的一种边界情况:

当后一个事件的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

第三种情况,没变化:

这里的解题思路和上面一样,先把每个线段安装开始时间排序,不同的是,每次在处理当前线段的时候,需要和上一个线段做对比,看看有没有重叠,如果重叠了,则需要删除上一个线段并且生成新的线段。

这里,一个栈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更好一些。。。。可以不需要翻转一次。读者可以自行摸索。。。

2. 城市天际线问题

最后一个问题留给读者们自己去思考,城市天际线问题:

image

在给出若干组城市建筑的坐标和高度之后,返回最后应该画出来的天际线的样子,这题也是需要对数据进行排序,按照事件来处理的题目。属于稍微复杂一点的问题,但是原则还是一样,需要用到优先级队列来处理。