题外话:
前段时间参加校园招聘,经常在一些公司的笔试或者面试中遇到一些不错的算法题,回到宿舍和同学进行交流,收获许多。这段时间,工作定下来后,整天闲着没事,就整理之前一些不错的算法题及其想法。下面这个算法题是一个同学去参加百度校园招聘面试时遇到的题目,当时他写了一篇日志。看到他那篇日志,我和舍友小平同学讨论了两三个小时。下面对当时的想法进行一些整理。
问题:
给定n个int型的数和一个空的集合,每次往集合中插入一个数,每次插入之后给出这个集合的中位数。(中位数的概念是:如果集合有奇数个数,给出排序后处在最中间的那个数;如果是偶数个数,给出排序后最中间两个数的均值。)
分析:
该同学在日记里写到,他看到题目时想到的是O(N*N)的算法,但是没有说具体的算法步骤和思想。我猜想,他可能是想在插入时是采用插入排序的思想,则在集合中插入一个数的复杂度为O(N),从排好序的集合中选出中位数则是一个复杂度为O(1)的过程,总共插入N次,所以总复杂度为O(N*N)。
小平和我在看到题目时,第一时间想到的都是“二分”。在每次将数插入集合时,采用“二分查找”寻找该数在集合中的位置,这个过程的复杂度是O(log(N)),得出中位数的过程则是O(1),插入了N次,所以复杂度为O(N*log(N))。
关于二分插入的思想,上面的复杂度分析好像毋庸置疑。但是,上面的分析过程是不对的。采用二分查找,则集合必须能随机访问,而这只能使用数组来实现。然而,在数组插入一个数,则需要将该位置后面的所有元素向后移动。上面的思想中,在二分查找到一个数的位置,并将该数插入集合中,则需要后移该位置后的所有元素。在最坏情况下,每次插入的位置都是第一个,则“移动元素”操作的复杂度为O(N),那么该思想的复杂度仍然是O(N*N)。
有个同学在该日记的回复给了我思路,该同学的回复是:
“感觉用堆也是可以的,所有比中位数大的组成一个最小堆,比中位数小的组成一个最大堆,每次插入只进行一个堆的插入,然后中位数一定是原中位数、最大堆的最大值和最小堆最小值中产生,再进行堆的调整就可以了,不过复杂度也是NlogN”
刚开始,小平和我对这个回复进行了一下讨论,但是没讨论出一个结果。但是,这个“最大堆和最小堆”却一直萦绕在我的潜意识中。躺在床上,准备睡觉的时候,恍然大悟:可以使用两个变量来控制最大堆和最小堆中元素的个数差。
具体的思路如下:
集合中元素,前一半存储在一个最大堆中,后一半存储在一个最小堆中。使用变量MaxHeapNum记录最大堆元素的个数,使用变量MinHeapNum记录最小堆元素的个数。控制MaxHeapNum与MinHeapNum的差不能超过1。每次将要插入的元素Num与最大堆顶部元素MaxHeapTop和最小堆的顶部元素MinHeapTop将进行比较,根据具体情况进行插入:
1.如果Num < MaxHeapTop,则
1.1 如果MaxHeapNum <= MinHeapNum,将Num插入最大堆;
1.2 如果MaxHeapNum == MinHeapNum + 1,将MaxHeapTop从最大堆中移到最小堆,并将Num插入最大堆。
2.如果MaxHeapTop <= Num <= MinHeapTop,则
2.1 如果MaxHeapNum <= MinHeapNum,将Num插入最大堆;
2.2 如果MaxHeapNum == MinHeapNum + 1,将Num插入最小堆;
3.如果Num > MinHeapTop,则
3.1 如果MinHeapNum <= MaxHeapNum,将Num插入最小堆;
3.2 如果MinHeapNum == MaxHeapNum + 1,将MinHeapTop移到最大堆中,将Num插入最小堆。
在每次插入后,都要根据情况对MaxHeapNum和MinHeapNum进行变更,并将有改动的堆进行堆调整。
上面的插入情况会保证最大堆和最小堆的元素个数差小于1,中位数就只在最大堆和最小堆的顶部元素中产生:如果最大堆和最小堆的元素个数相等,则中位数为最大堆和最小堆的顶部元素的平均值;否则,中位数为元素个数多的那个堆的堆顶元素。
复杂度分析:每次插入元素时的堆调整平均复杂度为O(log(N/2)),插入N次,所以总的复杂度为O(N*log(N/2))。
总结:
前面插入排序的思想和二分查找的思想之所以复杂度高,是因为其做了许多寻找中位数之外的操作,即排序。题目只是要求每次插入集合时,求出集合的中位数,而对集合中的元素是否排序没有要求。寻找中位数,我只需要知道中间的数就OK了,没有必要对所有元素排好序。这正是最后一种思想的精髓:尽量减少额外操作的消耗。