一. 堆的引出
普通队列:先进先出,后进后出。
优先队列:出队顺序和入队顺序无关,而和优先级有关,优先队列主要用于处理“动态的”(任务数目不断变化)请求任务。
另一个场景:在N个元素中选出前M个元素,若通过排序进行选取,时间复杂度为O(nlogn),而使用优先队列的话可以降低为O(nlogm).(构建一个容量为M的最小堆来实现)
优先队列的主要操作: 1>.入队; 2>.按优先级出队;
优先队列的实现:
入队时间复杂度 出队时间复杂度 普通数组 O(1) O(n) 顺序数组 O(n) (入队时保持好顺序) O(1) (出队时取队首即可) 堆 O(lgn) O(lgn) 使用堆实现优先队列:对于总共N个请求,若使用普通数组或顺序数组,最差情况时的时间复杂度为O(n^2),而使用堆最差为O(nlgn)。
二. 堆的基本实现
最大堆(根节点的值是最大值):
- 首先,二叉堆总是一棵完全二叉树
- 其次,堆中某个节点的值总是不大于其父亲节点的值(并不能说明层数高的值一定大于层数低的值)。
通过数组来存储二叉堆:堆是一种树形结构,且是完全二叉树,所以可以很经典的通过数组来存储二叉堆。
- 经典方法是因为将根节点索引标记为1,然后依次由上向下由左向右标记节点,则若父节点索引为k,则其左子节点索引为2*k,右子节点索引为2*k+1; 若知子节点索引为i,则其父节点为i/2.
- 若将根节点索引标记为0,然后依次由上向下由左向右标记节点,则若知父节点索引为k,则其左子节点索引为2*k+1,右子节点索引为2*k+2; 若知子节点索引为i,则其父节点为(i-1)/2.
1>. 最大二叉堆的实现:shiftUp, shiftDown的实现
//堆这种数据结构主要用来对动态数据进行维护,优先队列的底层实现就是堆。
public class MaxHeap <E extends Comparable>{
private int capacity=0;
private int size=0;
private E[] data;
//构造方法
public MaxHeap(int capacity){
data=(E[])new Comparable[capacity+1]; //这里数组大小为capacity+1,是由于堆的根节点对应的是数组索引为1的位置,数组索引为0的地方是空出来的
this.capacity = capacity;
}
public MaxHeap(){
this(10);
}
//其他方法
public boolean isEmpty(){
return size==0;
}
public int getSize(){
return size;
}
//在向堆中添加元素时,需要有一个“shift up”的过程,即所添加的元素若大于其父节点的值则要和父节点交换位置直至小于父节点.
public void insert(E e){
assert (capacity>=size+1);
data[size+1] = e; //堆中已存储元素的区间为[1, size], 所以新添加的元素应存在size+1处.
size++;
shiftUp(size);
}
//从堆中取数据,每次自能取堆顶的数据
public E extractMax(){
E e= data[1];
data[1] = data[size];
size--;
shiftDown(1);
return e;
}
//入堆时,在堆的最末端添加一个元素,将该元素依次和它的父辈节点作比较,大于父节点则和父节点交换位置,从而保持堆的特性
private void shiftUp(int k){
E v= data[k];
int j;
for(j=k; j>1 && v.compareTo(data[j/2])>0; j/=2){
data[j]=data[j/2];
}
data[j]=v;
}
//出堆时,只能取出堆顶的元素(值最大/最小)。
// 首先,移除堆顶元素,将堆的最末端的元素"v" 补到堆顶(size--操作),从而先保证堆的完全二叉树结构特征。
//其次,对移到堆顶的元素"v"作shiftDown, 不断比较"v"与其左右子节点的值,将“v”与大于它本身的较大的子节点交换位置,从而保持堆的特性
private void shiftDown(int k){
while (2*k<=size){ //存在子节点的时候进入循环
int j=2*k; // “j” 为可能交换的下标,初始化为左子节点
if(2*k+1<=size && data[2*k+1].compareTo(data[2*k])>0){ //若右子节点存在,且大于左子节点的值,更新j为右子节点下标
j=2*k+1;
}
if(data[k].compareTo(data[j])>0){ //若该节点大于其子节点,则不需要交换,直接退出循环
break;
}
E temp = data[k];
data[k] = data[j];
data[j] = temp;
//循环退出控制条件
k = j;
}
}
public void print(){
System.out.print("[");
for(int i=1;i<=size; i++){
System.out.print(data[i]);
if(i!=size){
System.out.print(", ");
}
}
System.out.println("]");
}
}
2>. 索引堆
普通的二叉堆有两个缺陷:
- 当存储的元素十分复杂时,比如每个位置上存的是一篇10万字的文章。那么普通二叉堆中交换它们之间的位置将产生大量的时间消耗。
- 由于数组元素的位置在构建成堆时会发生改变,之后很难对元素进行索引,很难改变元素的值。例如我们在构建成堆后,想去改变一个原来元素的优先级(值),将会变得非常困难。虽然我们可以在每一个元素上再加上一个属性来表示原来位置,但是这样的话,我们必须将这个数组遍历一下才能解决。(性能低效)
第一个缺陷还能用类似指针排序的技术解决,但是第二个缺陷不采用特殊的技术是没办法解决的。然而在一些场合,堆中元素的值确实需要改变。因此索引堆(index heap)闪亮登场。
索引堆:
简单地说,就是在堆里头存放的不是数据,而是数据所在数组的索引,根据数据的某种优先级来调整各个元素对应的下标在堆中的位置。由于索引堆最终用来实现优先队列,所以又可以叫索引优先队列(index priority queue)。
对于索引堆来说,数据和索引这两部分是分开存储的。真正表征堆的这个数组是由索引这个数组构建成的。(像下图中那样,每个结点的位置写的是索引号)。在构建堆的时候,比较的是data中的值(即原来数组中对应索引所存的值),构建成堆的却是index域,构建完之后,data域并没有发生改变,位置改变的是index域。由于构建堆的过程就是简单地索引之间的交换,而索引就是简单的int型,所以效率很高。
经典的 “反向查找” 思路:
rev与indexes保持关系 :rev[i]=j, indexes[j]=i ===> rev[indexes[i]]=i, indexes[rev[j]]=j
如,当我们修改了data[4], 那么我们需要改变indexes数组中值为4的元素的位置,即需要找到 j, 令indexes[ j ]=4,
若不用反向查找的思路,我们需要遍历一次indexes数组来找到 j. 而采用反向查找的思路,则直接可以找到 j=rev[4]=9
最大索引堆的实现:
//相比普通最大堆使用的技术点:1.将数据与其索引分离开 2.使用反向查找表
//相比普通最大堆:可以很容易地通过数据的索引实现数据的修改和数据的获取
public class IndexMaxHeap <E extends Comparable>{
private E[] data; //存放元素
private int[] indexes; //存放元素的索引,实现堆结构
private int[] rev; //经典的 “反向查找” 思路,rev[i]=j,indexes[j]=i ==> rev[indexes[i]]=i,indexes[rev[j]]=j
private int size; //indexes数组的指针,指向下一个待存放元素的位置,表示堆中当前存储元素的个数 [0, size)为存储的元素
private int capacity; //堆最多容纳元素的个数
//构造函数
public IndexMaxHeap(int capacity){
this.capacity=capacity;
this.size = 0;
data = (E[])new Comparable[capacity];
indexes = new int[capacity];
//初始化数组rev的值为-1,表示indexes还未与rev建立关系。之后indexes值的每次变动都需要更新其与rev的对应关系
//rev[k] == -1,说明data[k]未存放元素; rev[k] != -1,说明data[k]已经添加了元素。
rev = new int[capacity];
for(int i=0; i<capacity; i++){
rev[i] = -1;
}
}
//插入元素 在data[index]处插入元素e
public void insert(int index, E e){
assert (size<capacity);
assert (rev[index]==-1); //rev[index]=-1,说明rev还未与indexes关联,indexes数组中还未存储index值,data[index]处还未添加元素
data[index] = e;
indexes[size] = index; //indexes数组是一个堆结构,维护数组data的索引
rev[index] = size; //indexes的每次变动都要更新rev
shiftUp(size); //indexes数组新增了元素index,需要对indexes数组进行堆结构的维护
size++;
}
//取出元素(堆顶元素),并返回该元素
public E extractMax(){
assert (size>0);
E v = data[indexes[0]];
indexes[0] = indexes[size-1];
rev[indexes[0]] = 0;
size--;
rev[indexes[size-1]] = -1; //注 :indexes[size-1]的值被删除,则对应的rev置为-1
shiftDown(0);
return v;
}
//取出堆顶元素,返回该元素的索引值 普通索引堆很难实现
public int extractMaxIndex(){
assert (size>0);
int v = indexes[0];
indexes[0] = indexes[size-1];
rev[indexes[0]] = 0;
size--;
rev[indexes[size-1]] = -1; //注 :indexes[size-1]的值被删除,则对应的rev置为-1
shiftDown(0);
return v;
}
//通过元素索引获取元素值 普通索引堆很难实现
public E getItem(int i){
assert (i>=0 && i<size);
assert (rev[i]!=-1); //确定data[i]存在
return data[i];
}
//修改索引为k的元素的值为e 普通索引堆很难实现
public void change(int k, E e){
assert (k>=0 && k<size);
assert (rev[k]!=-1); //rev[k]!=-1,说明indexes中存在值为k的元素,即data[k]存在。
data[k] = e;
//修改后需要维护indexes的堆的结构,找到indexes中被修改的地方,即寻找i使得indexes[i]=k,然后对i处的值进行shiftUp,shiftDown.
// for(int i=0; i<indexes.length; i++){
// if(indexes[i] == k){
// shiftDown(i);
// shiftUp(i);
// return;
// }
// }
//通过“反向查找”对以上for循环优化,降低时间复杂度
int j = rev[k]; //indexes[i]=k
shiftUp(j);
shiftDown(j);
}
//shiftUp k 表示indexes数组的索引 对indexes数组做shiftUp,对indexes的元素作交换,只不过在比较时比较的是data中的元素.
private void shiftUp(int k){
E v = data[indexes[k]];
int t = indexes[k];
int j;
for(j=k; j>0 && v.compareTo(data[indexes[(j-1)/2]])>0; j=(j-1)/2){
indexes[j] = indexes[(j-1)/2];
rev[indexes[j]] = j;
}
indexes[j] = t;
rev[t] = j;
}
//shiftDown k 表示indexes数组的索引 对indexes数组做shiftDown,对indexes的元素作交换,只不过在比较时比较的是data中的元素.
private void shiftDown(int k){
while(2*k+1<size){
int j = 2*k+1;
if(j+1<size && data[indexes[j+1]].compareTo(data[indexes[j]])>0){
j++;
}
if(data[indexes[k]].compareTo(data[indexes[j]])>0){
break;
}
int temp = indexes[k];
indexes[k] = indexes[j];
indexes[j] = temp;
rev[indexes[k]] = k;
rev[indexes[j]] = j;
k = j;
}
}
//print
public void print(){
StringBuilder sb = new StringBuilder();
sb.append("[");
for(int i=0; i<size; i++){
sb.append(data[indexes[i]]);
if(i!=size-1){
sb.append(", ");
}
}
sb.append("]");
String res = sb.toString();
System.out.println(res);
}
}
三. 堆排序
1>. 数组的Heapify (将一个数组堆结构化):依次由下层到上层,由右至左对非叶子节点进行ShiftDown操作
//直接将待排序的数组进行Heapify,起始索引为1,需要开辟辅助数组,可优化为起始索引为0,从将空间复杂度将为O(n)
//将数组进行Heapify的过程 : 依次由下层到上层由右至左对非叶子节点进行ShiftDown操作
//注:倒数第一个非叶子节点为: data[size/2]
//将一个数组进行Heapify过程的时间复杂度为O(n)
//而将一个数组的元素逐一的插入一个空的堆中,时间复杂度为O(nlgn).
public T[] heapify(T[] arr){
//造成空间复杂度为O(n),可以优化
T[] data = (T[])new Comparable[arr.length+1]; //从data[1]开始对arr进行储存,data的长度应比arr多1.
int size;
for(int i=0; i<arr.length; i++){
data[i+1] = arr[i];
}
size = arr.length;
//从最后一个非叶子节点开始,依次对每个非叶子节点作ShiftDown
for(int i = size/2; i>=1; i--){ //最后一个非叶子节点为: data[size/2]
//ShiftDown操作
while (2*i<=size){
int j=2*i;
if(2*i+1<=size && data[2*i+1].compareTo(data[2*i])>0){
j=2*i+1;
}
if(data[i].compareTo(data[j])>0){
break;
}
T temp = data[i];
data[i] = data[j];
data[j] = temp;
i = j;
}
}
for(int i = 0; i<arr.length; i++){
arr[i] = data[i+1];
}
return arr;
}
2>. 直接利用堆的特性实现排序:将待排序的数组加入堆中,再从堆中取出(从堆中取数据时是由大到小以此取出的(最大堆))
//直接利用堆的特性,将待排序的数组加入堆中,再从堆中取出(从堆中取数据时是由大到小以此取出的(最大堆))
//将一个数组进行Heapify过程的时间复杂度为O(n)
//而将一个数组的元素逐一的插入一个空的堆中,时间复杂度为O(nlgn).
public T[] heapSort(T[] arr){
//构造一个最大堆
MaxHeap<T> mh = new MaxHeap<T>(arr.length);
for(int i=0; i<arr.length; i++){
mh.insert(arr[i]);
}
for(int i=arr.length-1; i>=0; i--){
arr[i] = mh.extractMax();
}
return arr;
}
3>. 原地堆排序算法:
- 将待排序的数组进行Heapify,形成堆结构
- 将堆顶元素(最大的元素)与最末端元素交换位置,此时最大的元素被移到了最后,然后在对除最末端元素外的部分的堆顶元素作shiftDown,以保持除最末端元素外部分的堆结构
- 重复2,将第二大元素移到倒数第二的位置,并保持除了最末两个元素外的部分保持堆结构
- 继续重复,直至排序完成
//原地堆排序:
//1.将待排序的数组进行Heapify,形成堆结构
//2.将堆顶元素(最大的元素)与最末端元素交换位置,此时最大的元素被移到了最后,然后在对堆顶元素作shiftDown,除最末端元素外的部分保持堆结构
//3.重复2,将第二大元素移到倒数第二的位置,并保持除了最末两个元素外的部分保持堆结构
//4.继续重复,直至排序完成
//Heapify 待排序的数组作起始索引为0的Heapify.
//此时,若知父节点索引为k,则左子节点为2*k+1,右子节点为2*k+2; 而若知子节点为k,则父节点为(k-1)/2
private void heapify(T[] arr){
for(int i=(arr.length-1-1)/2; i>=0; i--){ //最末的非叶子节点为最末节点的父节点,最末节点的索引为arr.length-1
shiftDown(arr, arr.length,i);
}
}
//shiftDown 对数组的前n个元素arr[0, n)中的第k个元素shiftDown,索引从0开始
private void shiftDown(T[] arr, int n, int k){
while(2*k+1<n){
int j = 2*k+1;
if(j+1<n && arr[j+1].compareTo(arr[j])>0){
j++;
}
if(arr[k].compareTo(arr[j])>0){
break;
}
T temp = arr[j];
arr[j] = arr[k];
arr[k] = temp;
k = j;
}
}
//原地堆排序的实现
public T[] heapSortII(T[] arr){
heapify(arr);
for(int i=arr.length-1; i>0; i--){
T temp = arr[i];
arr[i] = arr[0];
arr[0] = temp;
shiftDown(arr, i, 0);
}
return arr;
}
四.堆的一些应用
- 利用堆实现多路归并排序,将多路数据放入一个最小堆中,通过堆以此选出最小的元素。试想若将N个数据的数组通过堆进行N路归并排序,此时归并排序就变成了堆排序。
- 除了最经典的二叉堆,还可以实现三叉堆甚至d叉堆。
- 最大最小队列的实现,通过同时创建一个最大堆和一个最小堆,可以同时快速的找到最大元素和最小元素。
- 其他的堆类型:二项堆,斐波那契堆....
五.几种排序算法的比较
排序算法的稳定性:稳定排序指对于相等的元素,在排序后原来靠前的元素依然靠前,相等元素的相对位置没有发生改变。
平均时间复杂度 | 原地排序 | 额外空间 | 稳定排序 | |
插入排序 | O(n^2) | 是 | O(1) | 是 |
归并排序 | O(nlogn) | 否 | O(n)(有递归过程,实际上是O(n+logn),近似为O(n)) | 是 |
快速排序 | O(nlogn) | 是 | O(logn)(递归过程需要额外的存储空间) | 否 |
堆排序 | O(nlogn) | 是 | O(1) | 否 |