演算法基礎_堆和堆排序
一. 堆的引出
普通佇列:先進先出,後進後出。
優先佇列:出隊順序和入隊順序無關,而和優先順序有關,優先佇列主要用於處理“動態的”(任務數目不斷變化)請求任務。
另一個場景:在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) | 否 |