目次
1. ハッシュの概念
シーケンシャル構造およびバランスツリーでは、要素のキーとその格納場所の間に対応関係がないため、要素を検索する際にはキーの複数の比較を経る必要があります。逐次探索の時間計算量は O(N)、バランスツリーの木の高さは O(logN) であり、探索の効率は探索プロセス中の要素の比較回数に依存します。
理想的な検索方法: 検索対象の要素を比較せずに一度にテーブルから直接取得できます。格納構造を構築し、ある関数(hashFunc)によって要素の格納場所とそのキーコードとの間に1対1のマッピング関係を確立できれば、検索時にこの関数を通じて要素を素早く見つけることができます。 。
この構造に追加する場合:
- 要素の挿入:
挿入する要素のキーコードに従って、この関数を使用して要素の格納場所を計算し、この場所に従って要素を格納します。 - 要素の検索:
要素のキーコードに対して同じ計算を実行し、取得した関数値を要素の格納位置として取得し、構造内のこの位置に従って要素を比較し、キーコードが等しい場合に検索します。成功。
この手法がハッシュ(hash)法であり、ハッシュ法で用いられる変換関数をハッシュ(hash)関数と呼び、構築された構造をハッシュテーブル(またはハッシュテーブル)と呼びます。
例: データセット {1, 7, 6, 4, 5, 9};
ハッシュ関数は次のように設定されます: hash(key) = key % Capacity; Capacity は 、ストレージ要素の基礎となるスペースの合計サイズです。
この方法で検索すると、複数のキーコードを比較する必要がないため、検索速度が比較的速くなります。ただし、ハッシュの衝突が発生する可能性があります。
ハッシュ全体のコード構造:
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K>
struct HashFunc
{
//key本身可以进行隐式类型转换
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
//BKDR哈希
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31; //通过这种方式减少不同字符串的ascll码值相同造成的冲突问题
//这个值可以取31、131、1313、131313等等
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; //存储的数据个数
};
テンプレートパラメーターのうち、Hash はキーの値を整数に変換するために使用されるファンクターです。キーが文字列型の場合、特殊化を使用して BKDR によって整数に変換されます。
2. ハッシュの実装
2 つのデータ要素のキーワード k_i と k_j(i != j) については、k_i != k_j がありますが、Hash(k_i) ==Hash(k_j) になります。つまり、異なるキーワードが同じハッシュによって計算されます。同じハッシュアドレスが生成される現象を、ハッシュ衝突またはハッシュ衝突といいます。キーは異なるがハッシュ アドレスは同じであるデータ要素は「シノニム」と呼ばれます。
ハッシュの衝突の理由は、ハッシュ関数の設計が十分に合理的ではないことです。ハッシュ関数の設計原則:
- ハッシュ関数の定義ドメインには、保存する必要があるすべてのキー コードが含まれている必要があり、ハッシュ テーブルで m 個のアドレスが許可されている場合、その値の範囲は 0 ~ m-1 でなければなりません。
- ハッシュ関数によって計算されたアドレスは空間全体に均等に分散されます。
- ハッシュ関数は比較的単純である必要があります。
一般的なハッシュ関数:
- 直接アドレス指定方法 -- (一般的に使用される)
キーワードの一次関数をハッシュ アドレスとして受け取ります: ハッシュ (キー) = A*Key + B。
利点: シンプルで均一。
欠点: キーワードの分布を事前に知る必要があります。
使用シナリオ: 比較的小規模で継続的な状況を見つけるのに適しています。 - 剰余法 -- (一般的に使用されます)
ハッシュ テーブルで許可されるアドレスの数を m とし、ハッシュ関数に従って、m 以下で m に最も近い素数 p を除数としてとります。 Hash(key) = key% p(p<=m)、キーコードをハッシュアドレスに変換します。
ハッシュの競合を解決するには、クローズド ハッシュとオープン ハッシュという 2 つの主な方法があります。
1. クローズドハッシュ
クローズド ハッシュ: オープン アドレス指定方法とも呼ばれます。ハッシュの競合が発生した場合、ハッシュ テーブルがいっぱいではない場合、ハッシュ テーブルに空きスペースが存在する必要があり、キーはハッシュ テーブルの「下部」に格納できます。競合する位置 a" を空のスロットに入れます。では、次の空いたポジションを見つけるにはどうすればよいでしょうか?
1.1. 線形検出
線形検出: 競合が発生した位置から開始し、次の空の位置が見つかるまで逆方向に調査します。
挿入:
- ハッシュ関数を使用して、ハッシュテーブルに挿入する要素の位置を取得します。
- この位置に要素がない場合は直接新しい要素を挿入します。この位置に要素がある場合はハッシュ衝突が発生するため、線形検出を使用して次の空の位置を見つけて新しい要素を挿入します。
コードを挿入:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
size_t hashi = hash(kv.first) % _tables.size(); //这里是size,而不是capacity
//因为 [] 无法访问 size 外的数值
//线性检测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{
index = hashi + i;
index %= _tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
サイズが0 であるか、容量が不足している可能性があるため、拡張操作が必要です。
//size为0,或者负载因子超过 0.7 就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<HashData> newtables(newsize); //创建一个新的vector对象
//遍历旧表,重新映射到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
{
//重新算在新表中的位置
size_t hashi = hash(data.kv.first) % newtables.size();
size_t i = 1;
size_t index = hashi;
while (newtables[index]._state == EXIST)
{
index = hashi + i;
index %= newtables.size();
++i;
}
newtables[index]._kv = data.kv;
newtables[index]._state = EXIST;
}
}
_tables.swap(newtables);
}
容量を拡張する場合、ベクターオブジェクトを再度開き、すべてのデータを再挿入する必要があることに注意してください。元のベクターオブジェクトの容量を拡張することはできません。このように拡張すると、マッピングの位置関係が変化し、元の競合しない値が競合する可能性があり、元の競合する値が競合しない可能性があるためです。対立。
上記の書き方ではコードの冗長性があるため、以下のような書き方でコードを簡略化することができます。
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
//负载因子超过 0.7 就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht; //创建一个新的哈希表对象
newht._tables.resize(newsize); //扩容
//遍历旧表,重新映射到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
{
newht.Insert(data._kv);
}
}
_tables.swap(newht._tables);
}
size_t hashi = hash(kv.first) % _tables.size(); //这里是size,而不是capacity
//线性检测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{
index = hashi + i;
index %= _tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
消去:
クローズド ハッシュを使用してハッシュの競合を処理する場合、ハッシュ テーブル内の既存の要素を物理的に削除することはできず、要素を直接削除すると、他の要素の検索に影響が及びます。たとえば、要素 4 を削除する場合、要素 4 を直接削除すると、44 の検索が影響を受ける可能性があります。したがって、線形プローブでは、マークされた擬似削除を使用して要素を削除します。
コードを削除します:
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t hashi = hash(key) % _tables.size();
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state != EMPTY)
{
if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
{
return &_tables[index];
}
index = hashi + i;
index %= _tables.size();
++i;
//如果找了一圈,那么说明全是存在或删除
if (index == hashi)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
なお、ハッシュテーブルの存在と削除のみによる無限ループ問題を防ぐためには、関数内に検索回数を制限する判定を追加する必要がある。
線形検出の利点: 実装は非常に簡単です。
線形検出の欠点: ハッシュの競合が発生すると、すべての競合が結合され、データの「蓄積」が容易に生成されます。つまり、異なるキー コードが利用可能な空の位置を占有します。特定のキーコードを見つけることが困難になる の位置を何度も比較する必要があり、検索効率が低下します。
1.2. 2 番目の検出
線形検出の欠点は、矛盾するデータが積み重なることです。これは、次の空き位置を見つけることに関係しています。空き位置を見つける方法は、次々に見つけることであるため、この問題を回避するには、2 番目の空の位置の検出方法は、H_i = (H_0 + i^2) % m、または H_i = (H_0 - i^2) % m です。このうち、i = 1, 2, 3...、H_0 は要素のキーコード key をハッシュ関数 Hash(x) で計算して得られる位置、m はテーブルのサイズです。
研究によると、テーブルの長さが素数でテーブルの負荷係数 a が 0.5 を超えない場合、新しいエントリを挿入する必要があり、どの位置も 2 回プローブされることはありません。したがって、テーブルに空きポジションが半分あれば、テーブルがいっぱいになるという問題は発生しません。検索時は表の満杯を無視できますが、挿入時は表の負荷率aが0.5を超えないようにする必要があり、超えた場合は容量の増加を検討する必要があります。したがって、ハッシュ化の最大の欠点は、スペース使用率が比較的低いことであり、これはハッシュ化の欠点でもあります。
2. オープンハッシュ
2.1. オープンハッシュの概念
オープンハッシュ方式はチェーンアドレス方式(オープンチェーン方式)とも呼ばれ、まずハッシュ関数を用いてキーコードセットのハッシュアドレスを計算し、同じアドレスを持つキーコードは同じサブセットに属します。各サブセットはバケットと呼ばれ、バケット内の要素は単一リンク リストを通じてリンクされ、各リンク リストのヘッド ノードがハッシュ テーブルに格納されます。
上の図からわかるように、オープン ハッシュ内の各バケットには、ハッシュ衝突のある要素が含まれています。
2.2、オープンなハッシュ構造
template<class K, class V>
struct HashNode
{
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_next(nullptr)
,_kv(kv)
{}
};
template<class K>
struct HashFunc
{
//key本身可以进行隐式类型转换
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
//BKDR哈希
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31; //通过这种方式减少不同字符串的ascll码值相同造成的冲突问题
//这个值可以取31、131、1313、131313等等
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{}
private:
vector<Node*> _tables;
size_t _n = 0;
};
2.3. オープンハッシュルックアップ
Node* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
return cur;
cur = cur->_next;
}
return nullptr;
}
2.4. オープンハッシュ挿入
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
//头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
ハッシュ テーブルのサイズが 0 であるか、拡張する必要がある可能性があるためです。したがって、ハッシュ テーブルを拡張する必要があります。負荷率が大きいほど競合の可能性が高くなり、検索効率が低下し、スペース使用率が高くなります。
元のテーブルのノードはすべてカスタム タイプであるため、自動的には破棄されません。元のテーブル内のノードの位置を再計算し、それらを新しいテーブルに移動するだけです。
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
Hash hash;
//负载因子为1时,扩容
if (_n == _tables.size())
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newtables(newsize, nullptr);
for (Node*& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newtables.size();
//头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
}
_tables.swap(newtables);
}
size_t hashi = hash(kv.first) % _tables.size();
//头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
2.5. オープンハッシュの削除
bool Erase(const K& key)
{
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
オーバーフローに対処するためにチェーン アドレス方式を適用するには、リンク ポインターを追加する必要があり、ストレージのオーバーヘッドが増加するようです。実際: オープン アドレス方式では、検索効率を確保するために大量の空き領域を維持する必要があるため、二次検出方式では a <= 0.7 の負荷係数が必要であり、テーブル エントリが占める領域はポインタよりもはるかに大きくなります。そのため、代わりにチェーン アドレス方式を使用すると、オープン アドレス方式と比較してストレージ スペースを節約できます。
3. パフォーマンス分析
オープン ハッシュのハッシュの場合、追加、削除、確認、変更の時間計算量はO(1) ですが、最悪の場合 (すべての値が同じ添字、つまり同じバケット内にハングされる) になります。 、時間計算量は O(N) ですが、拡張演算があるため、この最悪のケースはほぼ不可能です。
極端な状況では、すべてのデータが 1 つのバケットに格納されます。次に、単一のバケットが特定の長さを超えると、バケットを赤黒ツリーに変更します。つまり、ハッシュ データ型を、リンク リスト ポインター、バケットの長さ、およびツリーへのポインターを含む構造体に設定します。バケットの長さが指定値を超える場合はツリーのポインタが使用され、それ以外の場合はリンク リストのポインタが使用されます。
ハッシュの基礎構造の内容は以上です、たくさん応援していただければ幸いです、何か間違っていたら修正してください、ありがとうございます!