해시 테이블 조회의 시간복잡도는 O(1)~
머리말
해시 개념:
![](https://img-blog.csdnimg.cn/17231795fa974347961ed4a62fa46f87.png)
이 방법으로 검색하면 여러 키 코드를 비교할 필요가 없으므로 검색 속도가 비교적 빠릅니다 .
1. 해시 충돌 및 해시 함수
해시 충돌:
위에서 삽입한 숫자에 44를 삽입하면 어떻게 될까요? 44%10==4인데 4의 위치는 이미 점유되어 있는 해시 충돌이다.
키는 다르지만 해시 주소는 같은 데이터 요소를 " 동의어 " 라고 합니다 .
해시 함수:
![](https://img-blog.csdnimg.cn/910eecff45264b8bbc3d5c258b859421.png)
숫자 분석 방법은 일반적으로 키워드의 분포를 미리 알고 있고 키워드의 비트 수가 고르게 분포된 경우 키워드 수가 상대적으로 많은 상황을 처리하는 데 적합합니다.
해시 충돌 해결:
![](https://img-blog.csdnimg.cn/c55a06a068d2440bbe97f1d24e7271f9.png)
선형 프로빙의 장점: 구현이 매우 간단합니다.
선형 감지의 단점: 해시 충돌이 발생하면 모든 충돌이 함께 연결되어 데이터가 " 쌓이는 " 경향이 있습니다 . 즉, 다른 키가 사용 가능한 빈 위치를 차지하므로 위치를 찾기 위해 많은 비교가 필요합니다. 특정 키의 결과로 검색 효율성이 감소합니다 . 그것을 완화하는 방법? 보조 탐지 방법 사용:
![](https://img-blog.csdnimg.cn/4d55326f784540f185701c5bc6440a12.png)
![](https://img-blog.csdnimg.cn/896bdff512d64094a98e5ea78e55bdf0.png)
위의 그림에서 볼 수 있듯이 열린 해시의 각 버킷에는 해시 충돌이 있는 요소가 포함되어 있습니다 .
개방 및 폐쇄 해시 비교:
둘째, 해시 테이블의 기본 구현
1. 오픈주소 방식
먼저 네임스페이스에 코드를 넣어 나중에 네이밍 충돌을 방지한 다음 구조를 사용하여 각 위치에 어떤 종류의 데이터가 저장되는지 저장합니다.여기서는 kv 구조를 예로 들어 보겠습니다.
enum State
{
EMPTY,
DELETE,
EXIST
};
template <class K, class V>
struct HashDate
{
pair<K, V> _kv;
State _state = EMPTY;
};
우리가 정의한 열거 유형은 비어 있음, 삭제 및 세 가지 상태가 있습니다.해시 테이블의 데이터를 직접 삭제하는 대신 상태 표현을 사용하는 이유는 개방형 주소 방법이 충돌을 해결하기 때문에 모든 사람이 답을 가지고 있어야 합니다. 간혹 이 위치에 이미 숫자가 있으면 나중에 검색을 해야 하는데 이 위치를 삭제하면 다음 숫자를 어떻게 찾을 수 있을까요? HashDate를 초기화할 때, 나중에 상태에 따라 삽입하고 삭제할 것이기 때문에 처음에 각 위치를 EMPTY 상태로 설정해야 합니다.
template <class K, class V>
class HashTable
{
public:
private:
vector<HashDate<K, V>> _tables;
size_t _n = 0; //记录插入了多少个元素
};
벡터의 기능은 매우 완벽하고 우리가 직접 구현하면 더 번거롭기 때문에 해시 테이블의 본체에 벡터를 직접 사용합니다. 각 벡터는 HashDate 유형의 데이터를 저장하고(템플릿 매개변수를 추가해야 함) 변수를 사용하여 테이블에 삽입되는 데이터의 양을 기록합니다. 여기서는 벡터의 size()를 직접 사용할 수 없습니다. 상태를 삭제합니다. size()로 상태를 삭제하면 함께 기록됩니다.
bool insert(const pair<K, V>& kv)
{
size_t hashi = kv.first % _tables.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;
}
위는 해시 테이블을 삽입하기 위한 코드인데, 용량 확장 문제는 고려하지 말자. 여기서는 %capacity()에 매핑되는 요소의 위치를 계산하면 안 된다.
벡터를 사용하려면 [] 연산자를 사용해야 하는데 이 연산자는 size()의 값에만 접근할 수 있고 size()를 초과하면 array size() = 10과 같은 오류가 발생한다. , capacity() = 20 , 우리는 [5]에 접근할 수 있지만 [15]에 접근할 수 없으므로 매핑을 계산하는 위치는 %size()여야 합니다. 그런 다음 매핑 위치에 요소가 있는지 판단해야 합니다. 는 요소이므로 역방향으로 검출해야 합니다. Empty position, 인덱스를 사용하는 목적은 향후 2차 검출을 변경하는 것이 매우 간단할 것이라는 점입니다. 역방향으로 검색하는 과정에서 인덱스가 경계, 우리는 매번 해시 테이블의 실제 용량을 %로 할 것입니다.위치를 찾은 후 키-값 쌍을 삽입하고 존재하도록 상태를 변경한 다음 카운터를 증가시킵니다. 다음으로 용량 확장 문제를 고려합니다. 확장하기 전에 개념을 알아야 합니다.
bool insert(const pair<K, V>& kv)
{
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
//扩容
size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
HashTable<K, V> newtable;
newtable._tables.resize(newsize);
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newtable.insert(e._kv);
}
}
_tables.swap(newtable._tables);
}
size_t hashi = kv.first % _tables.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;
}
즉, 부하율이 무엇인지 확인해야 하는데, 부하율은 테이블의 실제 크기를 테이블에 실제로 삽입된 숫자로 나눈 값(실제 크기는 size()임을 기억하십시오)이지만 두 값이 컴퓨터의 정수는 어떻게 나누어도 변하지 않기 때문에 십진법으로 양변에 10을 곱하면 이 문제가 해결되며, 강제로 두 배로 제거할 수도 있습니다. 0으로 나누는 문제를 방지하기 위해 해쉬 테이블이 0인지 아니면 부하율이 0.7보다 큰지 판단하여 새로운 공간이 매번 원래 공간의 2배로 확장된다. 원래 어레이를 직접 확장할 수 있습니까? 원래 매핑된 위치는 확장 후에 변경되기 때문에 답은 아니오입니다.예를 들어 원래 size()는 10이고 숫자 11은 1의 위치에 배치됩니다. 숫자 11은 11의 위치가 정확합니다. 이 문제를 방지하기 위해 해시 테이블 객체를 직접 재생성하고 이 해시 테이블 객체의 테이블을 새로운 공간 크기로 확장합니다. 용량을 변경하려면 실제로 size()를 사용하므로 size()를 변경해야 합니다. 공간을 연 후 이전 테이블의 데이터를 탐색하여 각 위치에 요소가 있는지 확인하고 있는 경우 새 테이블에 삽입합니다(여기서 삽입을 호출하면 용량이 확장되지 않습니다. 삽입이 완료되면 원본 벡터와 새 테이블의 벡터를 직접 교환할 수 있습니다.
HashDate<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = key % _tables.size();
size_t index = hashi;
size_t i = 1;
while (_tables[index]._state != EMPTY)
{
if (_tables[index]._state == EXIST
&& _tables[index]._kv.first == key)
{
return &_tables[index];
}
index = hashi + i;
index %= _tables.size();
if (index == hashi)
{
break;
}
++i;
}
return nullptr;
}
Find 인터페이스는 구현하기가 비교적 간단합니다. 테이블이 비어 있으면 빈 값을 반환합니다. 그런 다음 매핑된 위치를 계산하고 이 위치로 직접 이동하여 요소가 존재하는지 확인합니다. 이 위치가 삭제된 상태일 수 있으므로 검색할 때 이 위치가 비어 있지 않은 한 검색한다는 점에 유의해야 합니다. 삭제된 상태는 이 위치 뒤에서 검색해야 하므로 조건이 비어 있지 않습니다.루프에 진입한 후 현재 요소가 찾고 있는 요소의 키와 같은지 여부를 판단해야 하며 이 위치도 존재해야 합니다. 이 조건이 맞아야 이 위치에 데이터를 반환하게 된다(여기서는 참조를 사용하고 반환값은 포인터형인데 참조를 참조할 때 말했듯이 참조는 포인터에 의해 구현되기 때문에 존재한다) 여기서 반환 값에는 문제가 없습니다.) 주변을 검색하고 원래 매핑 위치로 돌아갈 때 시간이 확실히 발견되지 않으면 루프를 직접 종료하십시오.
bool eraser(const K& key)
{
HashDate<K, V>* tmp = Find(key);
if (tmp)
{
tmp->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
삭제 인터페이스는 직접 찾기 기능을 사용하여 찾으면 현재 위치의 상태를 삭제로 설정한 다음 카운터를 감소시켜 true로 되돌립니다. 삽입할 때 Find를 이용하여 판단할 수도 있는데, 삽입할 값이 이미 존재하는 경우에는 삽입하지 않습니다.
위는 공개 주소 방식의 세 가지 중요한 인터페이스입니다. 아래에서 테스트해 보겠습니다.
void TeshHashTable1()
{
int a[] = { 3,33,2,13,5,12,102 };
HashTable<int, int> ht;
for (auto& e : a)
{
ht.insert(make_pair(e, e));
}
ht.insert(make_pair(16, 16));
auto t = ht.Find(13);
if (t)
{
cout << "13在" << endl;
}
else
{
cout << "13不在" << endl;
}
ht.eraser(13);
t = ht.Find(13);
if (t)
{
cout << "13在" << endl;
}
else
{
cout << "13不在" << endl;
}
}
문제 없습니다. 확장하는 동안 매핑이 성공했는지 확인해 보겠습니다.
실행 결과는 괜찮습니다.다음으로 체인 주소 방법을 구현합니다.
2. 체인 주소 방식(해시 버킷)
마찬가지로 네임스페이스에 코드를 넣은 다음 struct를 사용하여 노드를 구현해야 합니다. 이 노드는 향후 해시 테이블의 특정 위치에 매달릴 것입니다.
template <class K, class V>
struct HashNode
{
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
:_kv(kv)
, _next(nullptr)
{
}
};
노드는 다른 노드를 가리키는 다음 포인터만 있으면 키-값 쌍이 이루어집니다. 이 노드는 쌍을 통해.수 있습니다.
template <class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
private:
vector<Node*> _tables;
size_t _n = 0;
};
본문은 또한 노드에 대한 포인터를 저장하는 벡터를 사용하고 삽입된 요소 수를 기록하는 카운터도 필요합니다.
bool insert(const pair<K, V>& kv)
{
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
마찬가지로 확장 문제를 고려하지 않고 매핑 위치를 직접 계산한 다음 새 노드를 만든 다음 헤드를 삽입하고 새 노드의 다음 노드가 원래 테이블의 헤드 노드에 연결되도록 한 다음 새로운 노드를 매핑 위치로 하여 헤더 노드를 완성하고 헤더를 삽입한 후 카운터 ++로 설정합니다.확장 문제를 살펴보겠습니다.
bool insert(const pair<K, V>& kv)
{
if (_n == _tables.size())
{
//扩容
size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
vector<Node*> newtable(newsize, nullptr);
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newtable.size();
cur->_next = newtable[hashi];
newtable[hashi] = cur;
cur = next;
}
}
_tables.swap(newtable);
}
size_t hashi = kv.first % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
해시 버킷의 확장은 각 버킷의 데이터가 유사하도록 각 버킷에 요소가 있는 경우에만 확장하면 됩니다. 삽입된 요소를 실제 요소로 나누었을 때, 즉 부하율이 1일 때 해시 버킷의 용량을 확장할 수 있으며, 해시 버킷 확장을 위해 위와 같이 새로운 해시 테이블을 열 수도 있습니다. 주소 방식이지만 이것은 너무 비효율적입니다.버킷에 연결된 노드를 다시 삽입하고 삽입이 성공한 후 하나씩 공간을 해제하는 것은 너무 비효율적이므로 직접 벡터를 다시 열고 직접 다시 매핑하십시오. 이전 해시 테이블의 노드를 벡터에 하나씩 , 매핑이 완료된 후 이전 노드를 사용하여 다시 매핑하고 새 노드가 없기 때문에 매핑이 완료된 후 노드의 공간을 해제할 필요가 없도록 합니다. 리매핑도 매우 간단합니다 즉, 이전 해시 테이블을 순회하는 것입니다.이 위치의 노드가 비어 있지 않으면 이 노드의 다음 노드를 저장한 다음 이 노드의 새 매핑 위치를 계산합니다(여기서 계산은 매핑할 새 크기() 공간, 리매핑이라고 함), 현재 노드를 매핑 위치의 헤드 노드에 연결한 다음 현재 노드를 매핑 위치의 헤드 노드가 되도록 하여 헤드 삽입을 완료합니다. . 삽입이 완료되면 벡터를 교환할 수 있습니다.
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
조회 함수는 또한 먼저 테이블이 비어 있는지 확인하고 비어 있으면 널 포인터를 반환합니다. 그런 다음 매핑 위치를 계산하고 이 위치에서 헤드 노드를 직접 가져온 다음 헤드 노드에서 트래버스하여 찾고 있는 요소를 찾으면 현재 노드로 돌아갑니다. 루프가 끝나면 null 포인터를 반환합니다.
bool eraser(const K& key)
{
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
Node* prev = nullptr;
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
Node* next = cur->_next;
_tables[hashi] = next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
삭제 인터페이스는 먼저 매핑할 위치를 계산한 다음 이 위치의 헤드 노드를 가져오고, 변수를 사용하여 이전 노드를 저장하고, 헤드 노드가 비어 있지 않으면 루프에 들어가고, 삭제할 노드가 있으면 계속 트래버스합니다. not found, 순회하기 전에 prev 노드에 현재 위치를 주어 이전 위치를 기록하고 삭제할 노드를 찾으면 현재 노드가 헤드 노드인지 판단해야 합니다. 헤드 노드의 다음 헤드 노드는 직접 헤드 노드가 되며 원래 헤드 노드를 해제할 수 있습니다. 삭제할 노드가 헤드 노드가 아닌 경우 이전 노드의 다음 링크가 다음 노드를 삭제하도록 한 다음 노드를 해제하십시오.
아래 코드를 테스트해 보겠습니다.
void TeshHashTable2()
{
int a[] = { 3,33,2,13,5,12,1002 };
HashTable<int, int> ht;
for (auto& e : a)
{
ht.insert(make_pair(e, e));
}
ht.insert(make_pair(16, 16));
ht.insert(make_pair(14, 14));
ht.insert(make_pair(15, 15));
ht.insert(make_pair(17, 17));
auto t = ht.Find(13);
if (t)
{
cout << "13在" << endl;
}
else
{
cout << "13不在" << endl;
}
ht.eraser(13);
t = ht.Find(13);
if (t)
{
cout << "13在" << endl;
}
else
{
cout << "13不在" << endl;
}
}
인터페이스에는 문제가 없습니다. 확장 문제를 살펴보겠습니다.
위는 확장이 없는 해시 테이블입니다. 확장 후 모습을 살펴보겠습니다.
확장 후 모든 값이 다시 매핑된 것을 볼 수 있습니다.다음으로 소멸자를 구현해 보겠습니다. 프로그램이 종료되면 벡터는 자신의 공간만 해제하고 각 위치에 대한 연결 목록의 공간은 해제되지 않기 때문입니다. 해제해야 합니다. 수동으로 해제해야 하는 모든 것:
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
파괴할 때는 직접 순회하며, 이 위치의 헤드 노드가 비어 있지 않으면 이 위치에 다음 노드를 저장한 다음 현재 노드를 해제하고 cur을 방금 저장한 노드로 두고 삭제 작업을 다시 실행합니다. 버킷의 모든 데이터가 해제되면 현재 버킷의 포인터를 null로 설정하기만 하면 됩니다.
요약하다
위는 해시 테이블의 기본 구현입니다.다음 기사에서는 해시 버킷을 캡슐화한 다음 unordered_map 및 unordered_set의 기본 레이어가 됩니다.우리는 전에 red-black 트리를 캡슐화했습니다.이번 캡슐화는 여전히 빨간색입니다. -검은 나무 차이는 별로 없지만 레드-블랙 트리보다 조금 더 번거로울 것입니다.