什麼是堆(二叉堆)?
堆,這裏是二叉堆的簡稱,其實就是一棵二叉樹,還是二叉樹裏比較特殊的完全二叉樹。如果不熟悉的二叉樹的相關概念的朋友,需要先複習一下,起碼要知道二叉樹的概念,比如子節點,葉子節點,樹的層級這些概念。
完全二叉樹:除了最後一層以外的所有層的節點都是被填滿的,最後一層的葉子節點如果沒有被填滿,那麼葉子節點的順序是從左向右排列的。
當然,堆也有區分是 大頂堆 、 小頂堆 :
- 大頂堆:根節點是整棵二叉樹中最大的數字。
- 小頂堆:根節點是整棵二叉樹中最大的數字。
用堆來實現什麼?
有的時候,我們需要利用有限的空間來獲取一些Top K這種數據,這種數據可以是最大的,也可以是最小的。
爲啥說是有限空間?比如我們有個10G的數據,但是我們只有200M的內存,我們要取出前一萬個最大的數字。常規的做法就是排序唄,但是這個內存太小,不足以讓你在內存裏面排序。
那咋整,每次拿個幾萬數據來排序,然後再和原來的那個一萬個逐個比較?這是一種辦法,但是時間複雜度太高。每次排序都要,然後還要算上和原來的排好序的數組的進行比較,交換,搬移的時間,時間太久了。
那麼有沒有一個比較好的方法來完成這個任務呢?
答案就是利用優先隊列,也就是堆這個數據結構,至於如何操作我們等等再講。
爲什麼要用堆?
如果不是堆這個數據結構,那麼可以用哪些來實現優先隊列呢?
- 數組:入隊(插入)時候,時間複雜度;出隊(刪除)的時候,需要遍歷整個數組,刪除,並且把後面的元素前移,時間複雜度爲。
- 鏈表:入隊時間複雜度爲;出隊的時候,需要遍歷整個數組,並且刪除,但是不用進行數據搬移。
- 二叉查找樹:入隊的時間複雜度爲;出隊時候時間複雜度爲。但是有個弊端,就是在出隊時候,會出現子樹不平衡的現象,容易變成單鏈表,比如,如果是小頂堆,那麼左子樹就會一直減少;如果是大頂堆,那麼右子樹就會一直減少。
- 平衡二叉查找樹:入隊時間,出隊的時間複雜度。但是平衡二叉查找樹也支持許多不需要的特性,使用起來顯得有點浪費,特別是不管哪種平衡二叉查找樹的實現都是複雜的實現。
如何實現二叉堆?
(1)實現哪些接口
首先,我們要想清楚,這個二叉堆要什麼功能?
一般數據結構個,增加,刪除,查詢,修改,這幾個基本操作是必須的。
同樣,身爲數據結構,二叉堆也有以上幾種操作,不過,不同的是,它 “變異” 了。
二叉堆的實現是接口:
public interface Heap<E extends Comparable> {
/**
* add element.
* @param e element
*/
void add(E e);
/**
* delete max element or min element.
* @return max element or min element.
*/
E delete();
/**
* find max element or min element.
* @return max element or min element.
*/
E find();
/**
* replace max element or min element.
* @param e to replacing-element.
*/
void replace(E e);
}
爲什麼用Comparable這個排序接口,主要是有compareTo這個接口,我們的這個二叉堆肯定是要去有比較前後大小性質的,所以需要進行排序。
add: 這個不用說,就是增加節點的操作。
注意:以下都是“變異”後的操作了
delete: 這個操作有點特殊,不是刪除任意的節點,而是刪除二叉堆中最大的或者是最小的點 。至於說到底是最大還是最小,這個取決於你實現的是最大堆還是最小堆。
find: 這個不是按照查找任意節點,而是查找最大的或者最小的點。至於說到底是最大還是最小,這個取決於你實現的是最大堆還是最小堆。
replace: 就是把最大的,或者最小的那個節點替換掉。至於說到底是最大還是最小,這個取決於你實現的是最大堆還是最小堆。
(2)用最大堆作爲例子
首先我們需要明白一個概念,雖然二叉堆是個二叉樹,但是它如果用樹的結構來存儲數據是很耗費空間;因爲它是個完全的二叉樹,所以我們可以用數組來存儲數據。
要理解這段話並不容易,首先我們要知道,之所以二叉樹樹的節點的數據結構,一般都要存一個左節點和右節點,我們可以根據當前節點來獲取左節點和右節點,有的二叉樹的節點結構有獲取父節點的需求,還會增加一個父節點的點。所以,我們可以知道一條信息,就是你如果獲取了某個樹的節點,那麼相應的,你需要獲取這個二叉樹的左孩子的節點的值,右孩子的節點的值。
因爲二叉堆是個滿二叉樹,所以,我們只要知道某個索引,我們就可以根據索引來算出這個座標。
先來看看下面這張圖:
其實就是把完全二叉樹給塞進數組裏面。
這裏我們要知道計算左孩子,右孩子,父親節點的索引幾個公式。
父親節點:
左孩子:
右孩子:
我們用19這個節點來作爲例子:
19的索引 :2
父親的索引:0
左孩子的索引:5
右孩子的索引:6
可以帶入公式,看看是否正確。
知道如何去保存數據之後,我們再來看看堆需要幾個基本操作。
增加:
首先就是我們無論如何都要保存堆的一個性質,我們假設我們增加了一個元素,我們要保證增加元素過後,依然保證最大堆的性質不變,就是隨便在什麼地方,父親節點大於孩子節點。
那麼這個增加元素在最後,我們要怎麼修改呢?
把增加的元素和他的父親節點去比,如果比他父親大,就交換,如果比父親小,那麼就結束。代碼實現就是:
public void add(E e) {
data.add(e);
size++;
shiftUp(size - 1);
}
/**
* 上移操作:在底層,開始往上移動。
* @param index 下移的座標
*/
private void shiftUp(int index) {
int parentIndex = (index - 1) / 2;
// 與父親節點比較,如果大於父親節點,就交換,
while (parentIndex >= 0 && data.get(parentIndex).compareTo(data.get(index)) < 0) {
swap(index, parentIndex);
index = parentIndex;
parentIndex = (index - 1) / 2;
}
}
刪除
把最大的節點刪除,那麼就會空出一個節點,別的節點也要相應的佔位上來;其實這樣看來,是比較複雜的,但是我們可以吧最後一個節點賦值到第一個節點,然後把最後一個結點刪除,
然後去吧第一個節點進行比較,然後歸位。
@Override
public E delete() {
if (size <= 0) {
throw new IllegalArgumentException("size under 0.");
}
E ans = data.get(0);
data.set(0, data.get(size - 1));
data.remove(size - 1);
size--;
shiftDown(0);
return ans;
}
/**
* 下移操作
* @param index 下移的座標
*/
private void shiftDown(int index) {
int maxIndex = index;
while (true) {
int leftChildIndex = 2 * index + 1;
if (leftChildIndex < size && data.get(leftChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = leftChildIndex;
}
int rightChildIndex = 2 * index + 2;
if (rightChildIndex < size && data.get(rightChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = rightChildIndex;
}
if (maxIndex == index) {
break;
}
swap(maxIndex, index);
index = maxIndex;
}
}
其實堆裏面,最重要就是兩個操作,一個上移,一個下沉,只要搞懂了這個兩個操作,堆你就拿下了。
我們來看看完整的代碼。
public class MaxHeapMine<E extends Comparable> implements Heap<E> {
private ArrayList<E> data;
private int size;
public MaxHeapMine() {
data = new ArrayList<>();
size = 0;
}
public MaxHeapMine(E[] elements) {
data = new ArrayList<>();
size = 0;
for (E item : elements) {
add(item);
}
}
@Override
public void add(E e) {
data.add(e);
size++;
shiftUp(size - 1);
}
@Override
public E delete() {
if (size <= 0) {
throw new IllegalArgumentException("size under 0.");
}
E ans = data.get(0);
data.set(0, data.get(size - 1));
data.remove(size - 1);
size--;
shiftDown(0);
return ans;
}
@Override
public E find() {
if (size <= 0) {
throw new IllegalArgumentException("size under 0.");
}
return data.get(0);
}
@Override
public void replace(E e) {
data.set(0, e);
shiftDown(0);
}
/**
* 上移操作
* @param index 下移的座標
*/
private void shiftUp(int index) {
int parentIndex = (index - 1) / 2;
while (parentIndex >= 0 && data.get(parentIndex).compareTo(data.get(index)) < 0) {
swap(index, parentIndex);
index = parentIndex;
parentIndex = (index - 1) / 2;
}
}
/**
* 下移操作
* @param index 下移的座標
*/
private void shiftDown(int index) {
int maxIndex = index;
while (true) {
int leftChildIndex = 2 * index + 1;
if (leftChildIndex < size && data.get(leftChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = leftChildIndex;
}
int rightChildIndex = 2 * index + 2;
if (rightChildIndex < size && data.get(rightChildIndex).compareTo(data.get(maxIndex)) > 0) {
maxIndex = rightChildIndex;
}
if (maxIndex == index) {
break;
}
swap(maxIndex, index);
index = maxIndex;
}
}
private void swap(int a, int b) {
E temp = data.get(a);
data.set(a, data.get(b));
data.set(b, temp);
}
public static void main(String[] args) {
Integer[] test = {1, 2, 3, 4, 0, 9, 8, 7, 6, 5, 20, 19, 18, 17, 16};
MaxHeapMine<Integer> maxHeapMine = new MaxHeapMine<>(test);
for (int i = 0, len = test.length; i < len; ++i) {
System.out.println(maxHeapMine.delete());
System.out.println(maxHeapMine.find());
}
}
}
你可以把代碼直接拷貝運行,看看效果如何,最後會打印出一組排好序的數字的。
其實上面的代碼可以繼續優化,減少交換的次數。不過有些不太好理解。有興趣的同學可以是試試。