記事ディレクトリ
ヒープとは何ですか
ヒープは特別なデータ構造であり、完全なバイナリ ツリーであり、ヒープ プロパティを満たす、つまり、親ノードの値が常にその子ノードの値より大きいか小さいです。親ノードの値が常に子ノードの値より大きい場合は、それをラージ ルート ヒープと呼びます。逆に、親ノードの値が常に子ノードの値より小さい場合は、ラージ ルート ヒープと呼びます。それは小さなルートヒープです。ヒープ内では、ルート ノードの値が最大 (大きいルート ヒープ) または最小値 (小さいルート ヒープ) になるため、ヒープの先頭とも呼ばれます。ヒープは、並べ替えや topK 問題などのシナリオでよく使用されます。
ヒープの実装
この記事は C 言語で実装されており、ヘッダー ファイルとソース ファイルに分けて、各インターフェイスの実装アイデアとリファレンス コードを段階的に紹介します。
ヒープ構造の定義
ヒープの構造定義は、実際にはスタックに似た特殊なシーケンス テーブルです。したがって、動的に開かれたメモリを指すポインタ、現在の添字位置を指す変数、および現在の動的メモリを記録する容量を使用する必要があります。
ヒープ初期化インターフェイス
ヒープ初期化インターフェイスの実装アイデアは次のとおりです まず、ヒープを変更するには、そのアドレスを渡す必要があります。したがって、パラメータ部分は Hp* と記述する必要があります。インターフェイスの開始時に、ポインターの正当性を判断します。次に、動的メモリをオープンして、動的メモリの有効性を判断します。最後に、構造体のメンバーを初期化します。
ヒープ破壊インターフェース
動的アプリケーション用にスペースを解放し、解放後は時間内に空にするという良い習慣を身につけるべきです。最後に、サイズと容量をゼロに設定します。
ヒープ挿入データインターフェイス
ヒープ挿入インターフェースの実装アイデアは以下の通りです ポインタの正当性をアサートで判断します これはプログラミングの良い習慣ですので、普段からこの習慣を身につけておくことをお勧めします まず容量がいっぱいかどうかを判断し、いっぱいの場合は容量を拡張します。この場合、直下にデータを挿入するロジックは、実際にはシーケンス テーブルと似ています。size 添字の位置にデータを直接挿入するには、++size を使用します。最後に、上方調整ヒープ構築インターフェイスを呼び出して、ヒープ構造を変更しないようにします。
ヒープインターフェイスを上方に調整します
まず、親ノードの添字位置を子の添字位置に基づいて推定する必要があります。その後、上方調整を開始します。上方調整のプロセスは循環プロセスです。ループの反復条件は、子がルート ノードの添え字より大きい場合、ループはサポートされ続けることです。子ノードが親ノードより小さい場合、ループは終了します。親ノードが子ノードより小さい場合は、対応する添字のデータ交換を実行し、子ノードの添字と親ノードの添字を繰り返します。
ヒープが空かどうかを確認する
ヒープが空であるかどうかの判断の考え方は、シーケンステーブルの空であるかどうかを判断する考え方と同様で比較的単純で、次にデータに挿入できる添字が0の場合、ヒープが空であることを意味します。
ヒープ削除データインターフェイス
ヒープ内のデータを削除するには、ヒープの一番上のデータを削除するべきですか、それともヒープの一番下のデータを削除するべきですか? 答えは、ヒープの最上部にあるデータを削除することです。ヒープの最下部にあるデータを削除してもほとんど意味がありません。また、ヒープの先頭を削除すると、上位 K データの並べ替えや収集など、何らかの値が生成される可能性があります。たとえば、ショッピング アプリでコンピューターを選択する場合、売上高で並べ替えることができます。これはヒープ アプリケーションのシナリオでもあります。話に戻りますが、ヒープの先頭を削除する実装の考え方は次のとおりです. ヒープの先頭のデータを最後のデータと交換し、サイズを使用して、ヒープの先頭のデータを削除する効果を実現します。ヒープの最上部に配置され、効率が大幅に向上します。最後に、ヒープを下方に調整します。
ヒープインターフェイスを下方に調整する
下方調整ヒープ構築の実装思想は、まず、下方調整の処理はサイクルであり、その終了条件は親>サイズである。ループ本体の内部には、下方調整の核となるアイデアがあり、親は左右の子よりも大きく (小さく) なります。この記事では、大きな山の実現を例として取り上げます。ここでさらに重要な概念ですが、ヒープの最下層はシーケンシャルテーブルに格納されるため、同じ父親の左右の子が隣接して格納されます。つまり、左の子の添え字 + 1 が右の子の添え字になります。父親を左右の子の大きい方と比べて、小さい場合は位置を入れ替えて繰り返します。注: 下方調整の条件は、左右のサブツリーがヒープである必要があることです。
ヒープトップデータの取得
実際、これはアクセス シーケンス テーブルの最初の要素です。ただし、この方法でインターフェイスを提供すると、インターフェイスとの一貫性が非常に高まり、コードの可読性が大幅に向上します。
ヒープ内の有効なデータの数を取得します
サイズは 0 から始まるので、サイズを直接返すだけです。
完全な実装コード
//Heap.h文件
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
//默认起始容量
#define DefaultCapacity 4
//存储的数据类型
typedef int HpDataType;
typedef struct Heap
{
HpDataType* data;
int size;//可以插入数据的下标
int capacity;//容量
}Hp;
//初始化
void HpInit(Hp* pHp);
//堆的销毁
void HpDestroy(Hp* pHp);
//插入数据
void HpPush(Hp* pHp, HpDataType x);
//向上调整建堆
void AdjustUp(HpDataType* data, int child);
//判断是否为空
bool HpEmpty(Hp* pHp);
//删除数据
void HpPop(Hp* pHp);
//向下调整建堆
void AdjustDown(HpDataType* data,int size, int parent);
// 取堆顶的数据
HpDataType HpTop(Hp* pHp);
// 堆的数据个数
int HpSize(Hp* pHp);
// Heap.c文件
#include"Heap.h"
//初始化
void HpInit(Hp* pHp)
{
//判断合法性
assert(pHp);
//开辟动态空间
HpDataType* tmp = (HpDataType*)malloc(sizeof(HpDataType) * DefaultCapacity);
if (tmp == NULL)//判断合法性
{
perror("malloc fail");
return;
}
//初始化
pHp->data = tmp;
pHp->size = 0;
pHp->capacity = DefaultCapacity;
}
//堆的销毁
void HpDestroy(Hp* pHp)
{
//判断合法性
assert(pHp);
//释放内存和清理
free(pHp->data);
pHp->data = NULL;
pHp->size = pHp->capacity = 0;
}
void Swap(HpDataType* p1, HpDataType* p2)
{
HpDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整建堆
void AdjustUp(HpDataType* data, int child)
{
//判断指针有效性
assert(data);
int parent = (child - 1) / 2;
while (child > 0)
{
//向上调整呢
if (data[child] > data[parent])
{
Swap(&data[child], &data[parent]);
}
else
{
break;
}
//迭代
child = parent;
parent = (child - 1) / 2;
}
}
//插入数据
void HpPush(Hp* pHp, HpDataType x)
{
//判断指针有效性
assert(pHp);
//判断容量是否满了
if (pHp->size == pHp->capacity)
{
HpDataType* tmp = (HpDataType*)realloc(pHp->data,sizeof(HpDataType) * pHp->capacity * 2);
if (tmp == NULL)//判断空间合法性
{
perror("malloc fail");
return;
}
//扩容后
pHp->data = tmp;
pHp->capacity *= 2;
}
//数据入堆
pHp->data[pHp->size] = x;
pHp->size++;
//向上调整建堆
AdjustUp(pHp->data, pHp->size - 1);
}
void AdjustDown(HpDataType* data, int size, int parent)
{
//断言检查
assert(data);
int child = parent * 2 + 1;
while (child < size)
{
//求出左右孩子较大的那个下标
if (child + 1 < size && data[child + 1] > data[child])
{
child++;
}
//父亲比孩子小就交换位置
if (data[child] > data[parent])
{
//交换
Swap(&data[child], &data[parent]);
//迭代
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HpPop(Hp* pHp)
{
//断言检查
assert(pHp);
//删除数据
Swap(&pHp->data[0], &pHp->data[pHp->size-1]);
pHp->size--;
//向下调整建堆
AdjustDown(pHp->data,pHp->size-1,0);
}
//判断是否为空
bool HpEmpty(Hp* pHp)
{
assert(pHp);
return pHp->size == 0;
}
// 取堆顶的数据
HpDataType HpTop(Hp* pHp)
{
assert(pHp);
return pHp->data[0];
}
// 堆的数据个数
int HpSize(Hp* pHp)
{
assert(pHp);
return pHp->size;
}
まとめ
ヒープのデータ構造を操作するのは、妻のケーキを食べるようなもので、甘いケーキを食べますが、それを妻が作ったかどうかは定かではありません。しかし、食べてみると、奥さんの作ったケーキは格別の味わいがあるのではないかと想像してしまいます。ヒープの論理構造上で操作するのはツリーであり、その下にあるストレージ上で操作するのはシーケンス テーブルです。これは比較的抽象的な場所であり、絵を描いたりコードをデバッグしたりする能力をテストする必要があります。
ヒープソート
ヒープのソートは、実際にはヒープ データ構造の一般的な用途です。ヒープ ソートの中心となるアイデアは、ヒープ削除のアイデアを使用してソート操作を実行することです。ヒープ ソートは、時間計算量が O(N*logN) の不安定なソートです。ソートの安定性の説明については、以下のブログで紹介させていただきます。
ヒープソートの実装
ヒープソートの実装考え方は、まずソート順を決めてデータをヒープに構築し、大きなヒープは昇順に構築し、小さなヒープは降順に構築します。ヒープを構築するには、下方調整を使用することをお勧めします。時間計算量は O(logN) であるため、上方調整を使用してヒープを構築すると、時間計算量は O(N*logN) になります。この種の時間計算量は、ヒープの最上位データを見つけるのにコストがかかりすぎるため、ヒープを直接走査することをお勧めします (時間計算量)。
次に、ヒープ削除のアイデアを使用して並べ替えます。以下は昇順にソートする例です。
//堆排序--排升序建大堆
void HeapSort(int* arr, int n)
{
//向下建堆,效率更高
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(arr,n-1,i);
}
//排序
//利用堆删除的思想进行排序
int end = n - 1;
while (end > 0)
{
//交换
int tmp = arr[0];
arr[0] = arr[end];
arr[end] = tmp;
//调整堆
AdjustDown(arr, end-1, 0);
end--;
}
}
ヒープ構築とヒープソートの時間計算量の解析
ビルドダウンを調整する
ヒープ ソートの以前の実装では、ヒープの下方調整の時間計算量が O(N) であるため、ヒープの下方調整の方が効率的であると述べました。次に、ヒープを下方に調整する際の時間の複雑さを簡単に分析します。
ビルドアップを調整する
上方調整ヒープ構築の時間計算量は O(N*logN) です。上方調整の時間計算量の問題を見てみましょう。
ヒープソート
ヒープソートの時間計算量は O(N logN) です。ヒープダウンの調整の複雑さは O(N) であり、上で分析しました。ソート部分は、最初の非リーフ ノードから下方向へのヒープ調整と組み合わせたO(N logN)です。
まとめ
ヒープ構築の時間の複雑さと、上で説明したヒープのソートの複雑さについては、結論を書き留めるだけで十分です。もちろん、実施の観点から、杭建設の上方調整と下方調整の間のおおよその効率ギャップを分析することは困難ではありません。下方調整は最初の非リーフ ノードから開始されるため、最悪のケースでは、ノードの半分が上方調整よりも少なく調整されることになります。これは効率の点ですでに大きな成果を上げています。
TOPK 問題の概要
TOPK 問題とは、一連のデータの中から上位 K 個の最大または最小のデータを見つける問題を指します。一般的な解決策には、ヒープ ソート、クイック ソート、マージ ソートなどが含まれます。この問題はデータ分析や機械学習などの分野でよく発生します。もちろん、TOK スクリーニングにヒープを使用することが非常に素晴らしい特別なシナリオがあります。現在 100 億の整数があり、最初の 50 個の数値が必要であると仮定すると、小さなヒープを構築し、走査されたデータがヒープの先頭データよりも大きい限り、それをヒープに置き換えることができます (下方調整) 、そして最終的に最大のトップ50の数字を取得します。それを感じてもらうために、簡単な例を挙げてみましょう。
void AdjustDownSH(HpDataType* data, int size, int parent)
{
//断言检查
assert(data);
int child = parent * 2 + 1;
while (child < size)
{
//求出左右孩子较大的那个下标
if (child + 1 < size && data[child + 1] < data[child])
{
child++;
}
//父亲比孩子小就交换位置
if (data[child] < data[parent])
{
//交换
Swap(&data[child], &data[parent]);
//迭代
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void PrintTopK(const char* file, int k)
{
// 1. 建堆--用a中前k个元素建小堆
int* topk = (int*)malloc(sizeof(int) * k);
assert(topk);
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 读出前k个数据建小堆
for (int i = 0; i < k; ++i)
{
fscanf(fout, "%d", &topk[i]);
}
for (int i = (k - 2) / 2; i >= 0; --i)
{
AdjustDownSH(topk, k, i);
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
int val = 0;
int ret = fscanf(fout, "%d", &val);
while (ret != EOF)
{
if (val > topk[0])
{
topk[0] = val;
AdjustDownSH(topk, k, 0);
}
ret = fscanf(fout, "%d", &val);
}
for (int i = 0; i < k; i++)
{
printf("%d ", topk[i]);
}
printf("\n");
free(topk);
fclose(fout);
}
void CreateNDate()
{
// 造数据
int n = 10000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (size_t i = 0; i < n; ++i)
{
int x = rand() % 10000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
int main()
{
CreateNDate();
PrintTopK("data.txt", 10);
return 0;
}