堆排序与PriorityQueue

堆是一种非线性结构,常用于实现优先队列。堆排序利用堆的特性进行排序,时间复杂度为O(NlogN)。Java中的PriorityQueue通过小顶堆实现,保证每次取出的元素是最小的。PriorityQueue提供了add、offer、element、peek、remove等方法,插入和删除元素时会自动调整堆结构以保持堆属性。

在做leetcode时碰到了应用PriorityQueue,巩固一下相关知识

  堆是一种非线性结构,可以把堆看作一个数组,也可以被看作一个完全二叉树。堆其实就是利用完全二叉树的结构来维护的一维数组,按照堆的特点可以把堆分为大顶堆和小顶堆。堆的这种特性非常的有用,堆常常被当做优先队列使用,因为可以快速的访问到“最重要”的元素。
构建堆的过程,O(N)(调用一次)
堆排序,每次交换堆顶的元素和结尾的元素,调整堆,每次O(logN)
堆插入,每次将元素放在结尾,将结尾元素向上查找更大或更小的元素下沉,每次O(logN)

大顶堆:每个结点的值都大于或等于其左右子结点的值
在这里插入图片描述
小顶堆:每个结点的值都小于或等于其左右子结点的值
在这里插入图片描述
堆和普通树的区别
1、内存占用:普通树占用的内存空间比它们存储的数据要多。你必须为节点对象以及左/右子节点指针分配额外的内存。堆仅仅使用数组,且不使用指针。
2、平衡:二叉搜索树必须是“平衡”的情况下,其大部分操作的复杂度才能达到O(nlog2n)。可以按任意顺序位置插入/删除数据,或者使用 AVL 树或者红黑树,但是在堆中实际上不需要整棵树都是有序的。
3、搜索:在二叉树中搜索会很快,但是在堆中搜索会很慢。在堆中搜索不是第一优先级,因为使用堆的目的是将最大(或者最小)的节点放在最前面,从而快速的进行相关插入、删除操作。
堆排序的过程
堆排序的基本思想:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值,如此反复执行,便能得到一个有序序列了,建立最大堆时是从最后一个非叶子节点开始从下往上调整的。升序----使用大顶堆;降序----使用小顶堆。

/* Function: 构建大顶堆 */
void BuildMaxHeap(int[] heap, int len)
{
    int i,temp;
    for (i = len/2-1; i >= 0; i--)
    {
        if ((2*i+1) < len && heap[i] < heap[2*i+1])    /* 根节点大于左子树 */
        {
            temp = heap[i];
            heap[i] = heap[2*i+1];
            heap[2*i+1] = temp;
            /* 检查交换后的左子树是否满足大顶堆性质 如果不满足 则重新调整子树结构 */
            if ((2*(2*i+1)+1 < len && heap[2*i+1] < heap[2*(2*i+1)+1]) || (2*(2*i+1)+2 < len && heap[2*i+1] < heap[2*(2*i+1)+2]))
            {
                BuildMaxHeap(heap, len);
            }
        }
        if ((2*i+2) < len && heap[i] < heap[2*i+2])    /* 根节点大于右子树 */
        {
            temp = heap[i];
            heap[i] = heap[2*i+2];
            heap[2*i+2] = temp;
            /* 检查交换后的右子树是否满足大顶堆性质 如果不满足 则重新调整子树结构 */
            if ((2*(2*i+2)+1 < len && heap[2*i+2] < heap[2*(2*i+2)+1]) || (2*(2*i+2)+2 < len && heap[2*i+2] < heap[2*(2*i+2)+2]))
            {
                BuildMaxHeap(heap, len);
            }
        }
    }
}
/* Function: 交换交换根节点和数组末尾元素的值*/
void Swap(int[] heap, int len)
{
    int temp;
    temp = heap[0];
    heap[0] = heap[len-1];
    heap[len-1] = temp;
}

PriorityQueue

  Java中PriorityQueue通过二叉小顶堆实现,可以用一棵完全二叉树表示。PriorityQueue,即优先队列的作用是能保证每次取出的元素都是队列中权值最小的。这里牵涉到了大小关系,元素大小的评判可以通过元素本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator)。Java中PriorityQueue实现了Queue接口,不允许放入null元素;其通过堆实现,具体说是通过完全二叉树(complete binary tree)实现的小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值),也就意味着可以通过数组来作为PriorityQueue的底层实现。
PriorityQueue的方法
add()和offer()
add(Object o)和offer(Object o)的语义相同,都是向优先队列中插入元素,只是Queue接口规定二者对插入失败时的处理不同,add方法在插入失败时抛出异常,offer方法则会返回false。
在这里插入图片描述
新加入的元素可能会破坏小顶堆的性质,因此需要进行调整。调整的过程为:从k指定的位置开始,将x逐层与当前点的parent进行比较并交换,直到满足x >= queue[parent]为止。注意这里的比较可以是元素的自然顺序,也可以是依靠比较器的顺序。

//offer(Object o)
public boolean offer(Object o) {
    if (o == null)//不允许放入null元素
        throw new NullPointerException();
    modCount++;
    int i = size;
    if (i >= queue.length)
        grow(i + 1);//自动扩容
    size = i + 1;
    if (i == 0)//队列原来为空,这是插入的第一个元素
        queue[0] = o;
    else
        siftUp(i, o);//调整
    return true;
}

上述代码中,扩容函数grow()类似于ArrayList里的grow()函数,就是再申请一个更大的数组,并将原数组的元素复制过去。需要注意的是siftUp(int k, Object o)方法,该方法用于插入元素x并维持堆的特性。

//siftUp()
private void siftUp(int k,Object x) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;//parentNo = (nodeNo-1)/2
        Object e = queue[parent];
        if (comparator.compare(x,e) >= 0)//调用比较器的比较方法
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = x;
}

element()和peek()
element()和peek()的语义完全相同,都是获取但不删除队首元素,也就是队列中权值最小的那个元素,二者唯一的区别是当方法失败时element()方法抛出异常,poll()方法返回null。根据小顶堆的性质,堆顶那个元素就是全局最小的那个;由于堆用数组表示,根据下标关系,0下标处的那个元素既是堆顶元素。所以直接返回数组0下标处的那个元素即可。

//peek()
public Object peek() {
    if (size == 0)
        return null;
    return queue[0];//0下标处的那个元素就是最小的那个
}

remove()和poll()
remove()和poll()方法的语义也完全相同,都是获取并删除队首元素,区别是当方法失败时remove()方法抛出异常,poll()方法返回null。由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。
在这里插入图片描述

public Object poll() {
    if (size == 0)
        return null;
    int s = --size;
    modCount++;
    Object result = queue[0];//0下标处的那个元素就是最小的那个
    Object x = queue[s];
    queue[s] = null;
    if (s != 0)
        siftDown(0, x);//调整
    return result;
}

上述代码首先记录0下标处的元素,并用最后一个元素替换0下标位置的元素,之后调用siftDown()方法对堆进行调整,最后返回原来0下标处的那个元素(也就是最小的那个元素)。重点是siftDown(int k, Object x)方法,该方法的作用是从k指定的位置开始,将x逐层向下与当前点的左右孩子中较小的那个交换,直到x小于或等于左右孩子中的任何一个为止。

//siftDown()
private void siftDown(int k, Object x) {
    int half = size >>> 1;
    while (k < half) {
        //首先找到左右孩子中较小的那个,记录到c里,并用child记录其下标
        int child = (k << 1) + 1;//leftNo = parentNo*2+1
        Object c = queue[child];
        int right = child + 1;
        if (right < size &&
            comparator.compare(c,queue[right]) > 0)
            c = queue[child = right];
        if (comparator.compare(x,c) <= 0)
            break;
        queue[k] = c;//然后用c取代原来的值
        k = child;
    }
    queue[k] = x;
}

remove(Object o)方法用于删除队列中跟o相等的某一个元素(如果有多个相等,只删除一个),该方法不是Queue接口内的方法,而是Collection接口的方法。由于删除操作会改变队列结构,所以要进行调整;又由于删除元素的位置可能是任意的,所以调整过程比其它函数稍加繁琐。具体来说,remove(Object o)可以分为2种情况:1. 删除的是最后一个元素。直接删除即可,不需要调整。2. 删除的不是最后一个元素,从删除点开始以最后一个元素为参照调用一次siftDown()即可。

//remove(Object o)
public boolean remove(Object o) {
    //通过遍历数组的方式找到第一个满足o.equals(queue[i])元素的下标
    int i = indexOf(o);
    if (i == -1)
        return false;
    int s = --size;
    if (s == i) //情况1
        queue[i] = null;
    else {
        E moved = (E) queue[s];
        queue[s] = null;
        siftDown(i, moved);//情况2
        ......
    }
    return true;
}

手写Heap

为什么堆排序构建堆的时间复杂度是N,而重调堆的时间复杂度是logN?
调堆的时间复杂度为logN毋庸置疑。对于建堆的时间复杂度,如果从底部最后的父节点开始建堆,那么:假如有N个节点,那么高度为H=logN,最后一层每个父节点最多只需要下调1次,倒数第二层最多只需要下调2次,顶点最多需要下调H次,而最后一层父节点共有2^ (H-1)个,倒数第二层公有2^ (H-2),顶点只有1(2^0)个,所以总共的时间复杂度为s = 1 * 2^(H-1) + 2 * 2^(H-2) + … + (H-1) * 2^1 + H * 2^0。将H代入后s= 2N - 2 - log2(N),近似的时间复杂度就是O(N)。

public class MaxHeap<E extends Comparable<E>> {
	private List<E> data;//维护一个自定义数组
	public MaxHeap(int capacity) {
		data =new ArrayList<>(capacity);
	}
	public MaxHeap() {
		data=new ArrayList<>();
	}
	public int size() {
		return data.Size();
	}
	public boolean isEmpty() {
		return data.isEmpty();
	}
	//heapify操作:将数组转化为堆
	/**
	 * Heapify是指将数组转化为堆,这里先将数组直接看成是一个完全二叉树,
	 * 然后找到这棵二叉树的最后一个非叶子节点的节点,
	 * 也就是该树的最后一个节点的父节点。
	 * 然后从这个节点开始到根节点结束,执行sift down操作。
	 * 这样的时间复杂度为O(n)
	 */
	public MaxHeap(E[] arrs) {
	    data = new ArrayList<>(Arrays.asList(arrs));
	    for (int i = parent(size() - 1); i >= 0; i--) {
	        siftDown(i);
	    }
	}
	/**
	 * 返回完全二叉树的数组表示中,一个索引所表示的元素的父亲节点的索引
	 */
	private int parent(int index) {
		if(index==0) {
			throw new IllegalArgumentException("索引为0,没有父节点");
		}
		return (index-1)/2;
	}
	/**
	 * 返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引
	 */
	private int leftChild(int index) {
		return index*2+1;
	}
	/**
	 * 返回完全二叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引
	 */
	private int rigthChild(int index) {
		return index*2+2;
	}
	/**
	 * 向堆中添加元素。
	 */
	public void add(E e) {
		data.addLast(e);
		siftUp(Size()-1);
	}
	private void siftUp(int index) {
		while(index>0 && data.get(parent(index)).compareTo(data.get(index))<0) {
			data.swap(index, parent(index));
			index=parent(index);
		}
	}
	/**
	 * 查看堆中的最大元素
	 */
	public E findMax() {
		if(data.getSize() == 0) {
            throw new IllegalArgumentException("堆为空");
		}
		return data.get(0);
	}
	/**
	 * 取出堆中最大元素
	 */
	public E extractMax() {
		E e=findMax();
		data.swap(0, size()-1);
		data.removeLast();
		siftDown(0);
		return e;
	}
	private void siftDown(int i) {
		while(leftChild(i)<size()) {
			int k=leftChild(i);
			if(k+1<size()&& data.get(k+1).compareTo(data.get(k))>0) {
				k++;
			}
			//此时,data[k]是leftChild和rightChild中最大值
			if(data.get(i).compareTo(data.get(k))>=0) {
				break;
			}
			data.swap(k, i);
			i=k;	
		}
	}
	public void swap(int i,int j) {
		E e=data[i];
		data[i]=data[j];
		data[j]=e;
	}
	/**
	 * 取出堆中最大的元素,并且替换成元素e
	 * Replace是指将堆中的最大元素取出,替换另一个进去。
	 * 因此可以直接将堆顶元素替换以后执行sift down操作,
	 * 这样时间复杂度就只有O(log n)。
	 */
	public E replace(E e) {
		E ret=findMax();
		data.set(0, e);
		siftDown(0);
		return ret;	
	}	
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值