1.堆排序
基数排序适用于大小有界的东西,除了他之外,还有一种你可能遇到的其它专用排序算法:有界堆排序。如果你在处理非常大的数据集,你想要得到前 10 个或者前k
个元素,其中k
远小于n
,它是很有用的。
例如,假设你正在监视一 个Web 服务,它每天处理十亿次事务。在每一天结束时,你要汇报最大的k
个事务(或最慢的,或者其它最 xx 的)。一个选项是存储所有事务,在一天结束时对它们进行排序,然后选择最大的k
个。需要的时间与nlogn
成正比,这非常慢,因为我们可能无法将十亿次交易记录在单个程序的内存中。我们必须使用“外部”排序算法。
我们首先了解一下堆,这是一个类似于二叉搜索树(BST)的数据结构。有一些区别:
- 在 BST 中,每个节点
x
都有“BST 特性”:x
左子树中的所有节点都小于x
,右子树中的所有节点都大于x
。 - 在堆中,每个节点
x
都有“堆特性”:两个子树中的所有节点都大于x
。 - 堆就像平衡的 BST;当你添加或删除元素时,他们会做一些额外的工作来重新使树平衡。因此,可以使用元素的数组来有效地实现它们。
现在讨论的是小根堆。如果子树中的节点都小于根节点,则为大根堆。
堆中最小的元素总是在根节点,所以我们可以在常数时间内找到它。在堆中添加和删除元素需要的时间与树的高度h
成正比。而且由于堆总是平衡的,所以h
与log n
成正比。
JavaPriorityQueue
使用堆实现。PriorityQueue
提供Queue
接口中指定的方法,包括offer
和poll
:
offer
:将一个元素添加到队列中,更新堆,使每个节点都具有“堆特性”。需要logn
的时间。poll
:从根节点中删除队列中的最小元素,并更新堆。需要logn
的时间。
给定一个PriorityQueue
,你可以像这样轻松地排序的n
个元素的集合 :
- 使用
offer
,将集合的所有元素添加到PriorityQueue
。 - 使用
poll
从队列中删除元素并将其添加到List
。
因为poll
返回队列中剩余的最小元素,所以元素按升序添加到List
。这种排序方式称为堆排序 。
向队列中添加n
个元素需要nlogn
的时间。删除n
个元素也是如此。所以堆排序的运行时间是O(n logn)
。
2.代码实现:
/**
* @Author Ragty
* @Description 堆排序
* @Date 19:15 2019/6/12
**/
public void heapSort(List<T> list,Comparator<T> comparator) {
PriorityQueue<T> heap = new PriorityQueue<T>(list.size(),comparator);
heap.addAll(list);
list.clear();
while(!heap.isEmpty()) {
list.add(heap.poll());
}
}
测试代码:
list = new ArrayList<Integer>(Arrays.asList(3, 5, 1, 4, 2));
sorter.heapSort(list, comparator);
System.out.println(list);
3.有界堆排序
有界堆是一个限制为最多包含k
个元素的堆。如果你有n
个元素,你可以跟踪这个最大的k
个元素:
最初堆是空的。对于每个元素x
:
- 分支 1:如果堆不满,请添加
x
到堆中。 - 分支 2:如果堆满了,请与堆中
x
的最小元素进行比较。如果x
较小,它不能是最大的k
个元素之一,所以你可以丢弃它。 - 分支 3:如果堆满了,并且
x
大于堆中的最小元素,请从堆中删除最小的元素并添加x
。
使用顶部为最小元素的堆,我们可以跟踪最大的k
个元素。我们来分析这个算法的性能。对于每个元素,我们执行以下操作之一:
- 分支 1:将元素添加到堆是
O(log k)
。 - 分支 2:找到堆中最小的元素是
O(1)
。 - 分支 3:删除最小元素是
O(log k)
。添加x
也是O(log k)
。
在最坏的情况下,如果元素按升序出现,我们总是执行分支 3。在这种情况下,处理n
个元素的总时间是O(n log k)
,对于n
是线性的。
4.代码实现:
/**
* @Author Ragty
* @Description 有界堆排序
* @Date 19:49 2019/6/12
**/
public List<T> topK(int k,List<T> list,Comparator<T> comparator) {
PriorityQueue<T> heap = new PriorityQueue<T>(list.size(),comparator);
for (T element : list) {
if (heap.size() < k) {
heap.offer(element);
continue;
}
int cmp = comparator.compare(element,heap.peek());
if (cmp>0) {
heap.poll();
heap.offer(element);
}
}
List<T> res = new LinkedList<T>();
while (!heap.isEmpty()) {
res.add(heap.poll());
}
return res;
}
测试代码:
list = new ArrayList<Integer>(Arrays.asList(6, 3, 5, 8, 1, 4, 2, 7));
List<Integer> queue = sorter.topK(4, list, comparator);
System.out.println(queue);
5.空间复杂性
到目前为止,我们已经谈到了很多运行时间的分析,但是对于许多算法,我们也关心空间。例如,归并排序的一个缺点是它会复制数据。在我们的实现中,它分配的空间总量是O(n log n)
。通过优化,可以将空间降至O(n)
。
相比之下,插入排序不会复制数据,因为它会原地排序元素。它使用临时变量来一次性比较两个元素,并使用一些其它局部变量。但它的空间使用不取决于n
。
我们的堆排序实现创建了新PriorityQueue
,来存储元素,所以空间是O(n)
; 但是如果你能够原地对列表排序,则可以使用O(1)
的空间执行堆排序 。
刚刚实现的有界堆栈算法的一个好处是,它只需要与k
成正比的空间(我们要保留的元素的数量),而k
通常比n
小得多 。
软件开发人员往往比空间更加注重运行时间,对于许多应用程序来说,这是适当的。但是对于大型数据集,空间可能同等或更加重要。例如:
- 如果一个数据集不能放入一个程序的内存,那么运行时间通常会大大增加,或者根本不能运行。如果你选择一个需要较少空间的算法,并且这样可以将计算放入内存中,则可能会运行得更快。同样,使用较少空间的程序,可能会更好地利用 CPU 缓存并运行速度更快。
- 在同时运行多个程序的服务器上,如果可以减少每个程序所需的空间,则可以在同一台服务器上运行更多程序,从而降低硬件和能源成本。