資料結構初階--堆排序+TOPK問題

一隻少年a 發表於 2022-11-29
資料結構

堆排序

堆排序的前提

堆排序:是指利用堆這種資料結構所設計的一種排序演算法。堆排序透過建大堆或者小堆來進行排序的演算法。

舉個例子:給定我們一個陣列{2, 3,4, 2,4,7},我們可把這個陣列在邏輯上看成是一種堆的結構,然後進行建堆,建大堆(或建小堆)我們就可以在堆頂選出一個最大(最小)的數,透過不斷的選數,我們就可以把順序弄出來了。
如何建堆?在上一篇部落格中我已經跟大家說過了,就是這樣的:

堆的構建有兩種方法:
第一種:從第二個節點往後開始向上調整
第二種:從最後一個非葉子節點開始向下調整

第一種:從第二個葉子節點開始向上調整,把前面兩個節點構成的堆建成大堆(小堆),如何依次調整第三個節點,第四個節點……直到調整最後一個,與堆的插入有些相似,只不過我們原來是有一組數,用一個動圖給大家演示一下:

資料結構初階--堆排序+TOPK問題

程式碼實現如下:

int i = 0;
//建小堆 排降序  建大堆 排升序
for (i = 1; i < n; i++)
{
	//建大堆 向下調整
	AdjustUp(i);
}

第二種:從最後一個非葉子節點開始向下調整,從下往上,先把下面的子樹建成大堆(小堆),最後就是堆頂向下調整了,看一下動圖演示:

資料結構初階--堆排序+TOPK問題

程式碼實現如下:

//找到最後一個父親節點
int parent = (n - 2) / 2;
int i = 0;
//建小堆 排降序  建大堆 排升序
for (i = parent; i >= 0; i--)
{
	//建大堆 向下調整
	AdjustDown(n, i);
}

堆排序的思想

如果把待排序序列分為未排序區間和有序區間,堆排序大的思想是每次選一個數放到有序區間,沒經歷一個迴圈有序區間就會加一,無序區間減一,迴圈結束序列也就有序了,像這樣:

資料結構初階--堆排序+TOPK問題

可以發現堆排序的思路和選擇排序很像,沒錯,思路確實一樣,只不過選擇排序每次要遍歷無序區間去找當前無序區間的最大值(升序找最大值,降序找最小值),而堆排序呢是把無序區間看做一個堆,堆頂自然是這個堆的最值了,每次迴圈只需要將堆頂元素取出來和無序區間最後一個數交換以達到有序區間加一的目的,然後在對這個堆(注意此時堆的size減一)向下調整,這樣做之後下次迴圈繼續取堆頂元素和無序區間最後一個元素交換然後繼續迴圈,直到無序區間就剩一個元素,此時整個序列就有序了。

對於堆排序在實現的時候要知道:

  1. 待排序序列需要升序排列那麼要建大堆
  2. 待排序序列需要降序排列那麼要建小堆

為什麼要有上面的兩條規定呢?要升序排列就不能建小堆,要降序排列就不能建大堆嗎?

答案是可以,但是不推薦,請看下圖:

2

相反如果要透過建小堆完成讓陣列升序排列的話,因為小堆堆頂元素是最小值,而堆頂這個位置也是排序之後最小值的位置就是說堆頂元素不用在移動了,那麼我們的堆要從後面一位重新建立,在建立一個小堆,找出最小值,再往後面一位找出最小值直到整個陣列成升序排列,乍一看好像沒有問題,但是和建大堆不同的是建小堆每次都要從下一位重新建堆才能選出最值,這個操作的複雜度為O(nlogn),要比建大堆每次只用將堆頂元素向下調整的時間複雜度為O(logn)慢很多,所以雖然可以升序建小堆但是因為相對沒有建大堆速度快所以我們選擇建大堆。
對應的降序建小堆也是同理。

總結

  1. 待排序序列需要升序排列那麼要建大堆
  2. 待排序序列需要降序排列那麼要建小堆

堆排序的基本步驟以及程式碼

步驟一 :構造初始堆。將給定無序序列構造成一個大頂堆(一般升序採用大頂堆,降序採用小頂堆)。

a.假定給定的無序序列結構如下,將透過方法二從最後一個非葉子節點開始向下調整,將該堆變成一個大堆

3

b.我們對整個無序序列進行調整,將其建立成大堆的形式,此時我們從最後一個非葉子結點開始進行調整,找到第一個非葉子結點8,比較它的左右子節點,找出左右子節點的最大值,與父結點進行比較,如果比父節點大就交換,得到調整後的結構

資料結構初階--堆排序+TOPK問題

c.找到第二個非葉子結點16,由於它的左右子結點25和18中25的元素大,所以將16和25進行交換,得到如下序列,此時向下調整還沒有結束,以16為目標結點,比較其與左右子節點的大小關係,發現已經成堆,結束調整(切記向下調整還沒有完畢,需要以交換的結點為目標繼續檢視是否需要繼續向下調整)

資料結構初階--堆排序+TOPK問題

步驟二 將堆頂元素與末尾元素進行交換,使末尾元素最大。然後繼續調整堆,再將堆頂元素與末尾元素交換,得到第二大元素。如此反覆進行交換、重建、交換。

a.將堆頂元素25和末尾元素8進行交換,此時25為有序序列,前面為無序

資料結構初階--堆排序+TOPK問題

b.重新調整結構,使其繼續滿足堆定義,以16為第一個非葉子結點進行向下調整,緊接著以8為第二個非葉子結點進行調整,將18和8互換,此時18是左右孩子最大的值,不需要再向下調整

資料結構初階--堆排序+TOPK問題

c.將堆頂元素18和末尾元素15進行交換,此時18,25為有序序列,前面為無序

資料結構初階--堆排序+TOPK問題

d.重新調整結構,使其繼續滿足堆定義,以15為第一個非葉子結點進行向下調整,將16和15進行交換

資料結構初階--堆排序+TOPK問題

e.將堆頂元素16和末尾元素8進行交換,此時16,18,25為有序序列,前面為無序

資料結構初階--堆排序+TOPK問題

d.重新調整結構,使其繼續滿足堆定義,以8為第一個非葉子結點進行調整,將15和8互換,調整完畢之後,交換15和8的值,得到最終的有序序列,堆排序過程結束

資料結構初階--堆排序+TOPK問題

再簡單總結下堆排序的基本思路:

  a.將無需序列構建成一個堆,根據升序降序需求選擇大頂堆或小頂堆;

  b.將堆頂元素與末尾元素交換,將最大元素"沉"到陣列末端;

  c.重新調整結構,使其滿足堆定義,然後繼續交換堆頂元素與當前末尾元素,反覆執行調整+交換步驟,直到整個序列有序。

堆排序的程式碼實現

typedef int HPDataType;
//小堆的實現
typedef struct Heap
{
	HPDataType* a;
	int capacity;
	int size;
}HP;
void HeapSort(HPDataType* a, int n)
{
	//找到最後一個父親節點
	int parent = (n - 1 - 1) / 2;
	int i = 0;
	//建小堆 排降序  建大堆 排升序
	for (i = parent; i >= 0; i--)
	{
		//建大堆 向下調整
		AdjustDown(a, n, i);
	}

	i = n - 1;
	while (i >= 0)
	{
		Swap(&a[0], &a[i]);
		AdjustDown(a, i, 0);
		i--;
	}
}

堆排序時間複雜度分析

這裡時間複雜度分析分為兩部分——建堆n次向下調整
建堆:時間複雜度是O(n)
n次向下調整:向下調整一次是O(logn),n次就是O(n*logn)
n*logn+n≈n*logn
綜上,堆排序時間複雜的是O(n*logn)

TOPK問題

TOPK問題的概念

TOPK問題:找出N個數裡面最大/最小的前K個問題。

比如:專業前10名、世界500強、富豪榜、遊戲中前100的活躍玩家等。

TOPK問題實現的原理

原理:TOPK問題採用堆來實現,用一個大小為K的小堆,然後往堆頂插入資料,如果當前的數比堆頂的數大就把堆頂的數換下來並進行向下調整,否則就不做處理。

如果要選取n個數中最大/最小的前k個值,步驟如下:

1.先用前k個數建成k個數的小堆。
2.剩下n-k個數,依次跟堆頂的資料進行比較,如果比堆頂的資料大,就進堆進行向下調整。
3.最後堆裡的k個數就是最大的k個數。

舉個例子:10選5

資料結構初階--堆排序+TOPK問題

TOPK問題程式碼實現

核心程式碼

void PrintTopK(int* a, int n, int k)
{
	assert(a);
	HP hp;
	HeapInit(&hp);
	// 1. 建堆--用a中前k個元素建堆
	for (int i = 0; i < k; ++i)
	{
		HeapPush(&hp, a[i]);
	}
	// 2. 將剩餘n-k個元素依次與堆頂元素交換,不滿則則替換
	for (int i = k; i < n; i++)
	{
		if (a[i] > hp.a[0])
		{
			hp.a[0] = a[i];
			AdjustDown(hp.a, k, 0);
		}
	}
	for (int i = 0; i < k; i++)
	{
		printf("%d ", hp.a[i]);
	}
}

完整程式碼以及測試

#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	int capacity;
	int size;
}HP;
void Swap(int* p1, int* p2)
{
	int tmp;
	tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

void AdjustUp(HPDataType* a, int child)
{
	assert(a);

	int parent = (child - 1) / 2;
	while (child >= 0)
	{
		if (a[child] < a[parent])//< 建小堆   > 建大堆
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}
void HeapPush(HP* hp, HPDataType x)
{
	assert(hp);

	if (hp->capacity == hp->size)
	{
		int newCapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
		HPDataType* tmp = (HPDataType*)realloc(hp->a, newCapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			printf("realloc fail\n");
			exit(-1);
		}

		hp->a = tmp;
		hp->capacity = newCapacity;
	}
	hp->size++;
	hp->a[hp->size - 1] = x;

	//向上調整
	AdjustUp(hp->a, hp->size - 1);
}
void HeapInit(HP* hp)
{
	assert(hp);

	hp->a = NULL;
	hp->capacity = hp->size = 0;
}
void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		//選小孩子
		if (child + 1 < n && a[child + 1] < a[child])//< 建小堆   > 建大堆
		{
			child++;
		}
		if (a[child] < a[parent])//< 建小堆   > 建大堆
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}

	}
}
void PrintTopK(int* a, int n, int k)
{
	assert(a);
	HP hp;
	HeapInit(&hp);
	// 1. 建堆--用a中前k個元素建堆
	for (int i = 0; i < k; ++i)
	{
		HeapPush(&hp, a[i]);
	}
	// 2. 將剩餘n-k個元素依次與堆頂元素交換,不滿則則替換
	for (int i = k; i < n; i++)
	{
		if (a[i] > hp.a[0])
		{
			hp.a[0] = a[i];
			AdjustDown(hp.a, k, 0);
		}
	}
	for (int i = 0; i < k; i++)
	{
		printf("%d ", hp.a[i]);
	}
}
void TestTopk()
{
	int n = 10000;
	int* a = (int*)malloc(sizeof(int) * n);
	srand(time(0));
	for (int i = 0; i < n; ++i)
	{
		a[i] = rand() % 1000000;
	}
	a[5] = 1000000 + 1;
	a[1231] = 1000000 + 2;
	a[531] = 1000000 + 3;
	a[5121] = 1000000 + 4;
	a[115] = 1000000 + 5;
	a[2335] = 1000000 + 6;
	a[9999] = 1000000 + 7;
	a[76] = 1000000 + 8;
	a[423] = 1000000 + 9;
	a[3144] = 1000000 + 10;
	PrintTopK(a, n, 10);
}
int main()
{
	TestTopk();
	return 0;
}