데이터 구조---점프 테이블

스킵 테이블은 왜 있는 걸까요?

이전 학습 과정에서 연결된 목록 컨테이너에 대해 연구했는데 이 컨테이너의 헤드와 테일에 데이터를 삽입하는 시간 복잡도는 O(1)입니다. 그러나 이 컨테이너에는 단점이 있습니다. 데이터의 순서 여부에 관계없이 데이터의 존재 여부 복잡도는 O(N) 폭력적인 루프를 통해서만 데이터의 존재 여부를 알 수 있음 데이터가 정렬되어 있어도 이진 검색을 통해 데이터 조각을 찾을 수 없음 . 그래서 이 문제를 해결하기 위해 누군가가 제안한 것이 바로 점프 목록 컨테이너입니다.

점프 테이블의 원리

이전에 연구한 연결리스트 구조는 다음과 같다.
여기에 이미지 설명을 삽입하세요.
그러면 리스트를 점프하는 아이디어는 인접한 두 노드마다 한 단계씩 올라가고 포인터가 추가되어 포인터가 다음 노드를 가리키도록 하는 것이다. 방법은
여기에 이미지 설명을 삽입하세요.
새로 추가된 모든 포인터가 연결되어 새로운 연결 리스트를 형성하지만 노드 수는 절반만 포함됩니다. 새로 추가된 포인터로 인해 더 이상 연결리스트의 각 노드를 하나씩 비교할 필요가 없으며, 비교해야 하는 노드의 개수는 원래 개수의 절반 정도에 불과합니다. 예를 들어 요소 19를 찾으려면 먼저 헤드 노드의 최상위 포인터가 가리키는 노드(노드의 요소는 6)가 19보다 큰지 비교합니다. 19보다 큰 경우 해당 노드로 점프합니다. 당연하게도 여기보다 크니까 점프하세요 노드 6으로 가서 요소 6(노드 9)의 최상위 포인터가 가리키는 노드가 19보다 큰지 비교해보세요. 따라서 요소 9로 점프한 다음 노드 17로 이동합니다. 노드 17이 최상위이기 때문입니다. 노드가 가리키는 요소는 19보다 21보다 큽니다. 그러면 포인터가 가리키는 노드로 점프할 수 없습니다. 포인터 아래의 포인터(노드 17의 하위 포인터)가 가리키는 노드가 그 노드가 가리키는 노드의 값이 정확히 19이므로 지정된 요소를 찾은 다음 테이블을 점프하여 요소를 찾는 원리입니다.
여기에 이미지 설명을 삽입하세요.

그러나 위의 검색 과정은 효율성을 크게 향상시키지 못했으며, 가장 좋은 경우는 O(N/2)이므로 효율성을 더욱 높이기 위해 새로 생성된 연결 리스트에서 인접한 두 노드를 각각 계속해서 검색합니다. 두 번째 계층. 한 수준을 올리고 포인터를 추가하여 세 번째 수준 연결 목록을 생성하면
여기에 이미지 설명을 삽입하세요.
이때 요소 19에 대한 검색이 더 빨라지고 비교 후에 노드 9로 이동한 다음 노드 17로 점프합니다. , 그리고 마지막으로 노드 19에 도달합니다.
여기에 이미지 설명을 삽입하세요.
노드의 다른 레이어를 추가하면 노드 검색의 효율성이 높아진다는 것을 알 수 있습니다. 같은 방식으로 연결된 목록의 데이터 수가 충분히 길면 다음을 추가할 수 있습니다. 여러 레이어 등으로 노드를 추가할 수 없을 때까지 계속하면 이때 첫 번째 비교에서 요소의 절반이 필터링되고, 두 번째 비교를 통해 요소의 1/4, 요소의 1/8이 필터링됩니다. 이때 요소를 찾는 시간복잡도는 O(logN)임을 알 수 있는데, 여기서 문제가 있다. , 깔끔한 구조로 효율성이 대체됩니다.를 사용하는 경우 프로세스 중에 요소가 삽입되고 삭제되면 구조가 파괴됩니다. 예를 들어 최고 레벨은 10입니다. 중간 노드에 노드를 삽입하려면, 이 노드의 레벨은 무엇입니까? 1부터 10까지 가능하지만 삽입 후 연결된 리스트는 이전 패턴을 유지할 수 없습니다.(인접한 각 노드가 한 단계 올라갑니다.) 이 대응성을 유지하려면 새로 삽입된 노드 뒤에 모든 노드를 추가해야 합니다. 노드(새로 삽입된 노드 포함)가 다시 조정되어 시간 복잡도가 O(n)으로 되돌아갑니다. 이 문제를 피하기 위해 Skiplist의 디자인은 더 이상 엄격하게 대응 비율 관계를 요구하지 않고 노드 삽입 시 무작위로 여러 개의 레이어를 생성하는 과감한 접근 방식을 취했습니다. 이렇게 하면 삽입과 삭제를 할 때마다 다른 노드의 레이어 수를 고려할 필요가 없어 처리가 훨씬 쉬워집니다.
여기에 이미지 설명을 삽입하세요.
그렇다면 여기서 문제가 발생하는데, 레이어의 개수를 무작위로 할당한다면 어떻게 효율성을 확보할 수 있을까요? 아주 많은 난수가 있습니다: 1은 난수이고, 1w도 난수이며, 매우 큰 난수가 여러 개 있을 수 있습니다. 예를 들어 현재 100개의 노드가 있지만 99개의 노드는 높이가 100이지만 우리는 높은 수준의 것들이 많이 필요합니다.노드? 불필요한 것 같죠?이론적으로는 더 높은 레벨의 노드가 나타날 확률이 작아야 하므로 스킵 테이블의 공간 효율성과 시간 효율성을 높이기 위해 스킵 테이블의 최대 레벨 제한을 설계하고, maxLevel을 설정하고 레이어를 하나 더 추가할 확률 P를 설정합니다. 이는 각 노드의 높이가 최소 1임을 의미합니다. 각 상위 레벨의 확률 p, 노드의 높이가 1일 확률은 다음과 같습니다. -P(레이어 높이가 증가하지 않고 증가할 확률은 1-p)이므로 높이가 1일 확률은 1-P이고, 마찬가지로 높이가 한 레이어 증가할 확률은 p입니다. 증가하지 않을 확률은 1-p이므로 높이가 2일 확률은 p*(1-p), 노드 번호가 3일 확률은 p*p*(1-p)입니다. 노드 번호가 높을수록 발생 확률이 낮아지는 것을 어렵지 않게 찾을 수 있습니다. 이것이 스킵
여기에 이미지 설명을 삽입하세요.
리스트의 원리입니다. 다음으로 스킵 테이블을 구현하는 방법을 살펴보겠습니다.

스킵 테이블 시뮬레이션 구현

준비

먼저 각 노드에는 데이터를 저장하는 변수가 있고, 다음 노드의 위치를 ​​기록하기 위해서는 여러 개의 포인터가 필요합니다. 포인터를 저장하려면 여기에서 포인터를 설명하는 클래스를 만들 수 있습니다. 이 클래스의 생성자에는 두 개의 매개변수가 필요합니다. 하나의 매개변수는 노드의 레이어 수를 나타내고 다른 매개변수는 노드에 저장된 데이터를 나타냅니다. 그런 다음 배열 생성자에서 초기화되고 각 포인터가 비어 있도록 초기화된 경우 여기의 코드는 다음과 같습니다.

template<class T>
struct ListNode
{
    
    
	typedef ListNode<T> Node;
	ListNode(T val,int size)
		:_nextV(size,nullptr)
		,_val(val)
	{
    
    }
	T _val;
	vector<Node*> _nextV;
};

그런 다음 점프 테이블 클래스에 포인터 변수를 저장하여 헤드 노드를 기록하고 다른 변수를 생성하여 현재 노드의 최대 레이어 수와 레이어 수가 증가할 확률을 기록합니다.생성자에서 이 포인터는 새로 생성된 Node, 노드의 높이는 1, 저장된 값은 요소의 기본 구조이며 다음 코드에서는 난수를 사용해야 하므로 생성자에 타임스탬프를 추가한 다음 코드 여기는 다음과 같습니다:

template<class T>
class Skiplist
{
    
    
public:
	typedef ListNode<T> Node;
	Skiplist()
	{
    
    
		srand(time(0));
		_head = new Node(T(), 1);
	}

private:
	Node* _head;
	int _maxLevel = 32;
	double _p = 0.5;
};

기능 찾기

여기서는 이 요소뿐만 아니라 직선 요소의 다른 노드도 찾아야 하기 때문에 find 함수가 이 클래스의 핵심입니다. 예를 들어 요소 21을 찾으려면 노드 21 앞에 많은 노드가 가리키고 있습니다
여기에 이미지 설명을 삽입하세요.
. 노드 19의 세 노드는 모두 노드 21을 가리키므로 find 함수는 21이 존재하는지 여부를 확인할 뿐만 아니라 노드 21을 가리키는 노드도 반환해야 합니다. 그 이유는 다음과 같습니다. 나중에 삽입 기능과 지우기 기능을 활성화할 수 있지만 질문이 있습니다. 어느 노드가 21을 가리키는지는 알지만, 이 노드 중 어느 노드가 21을 가리키는가? 따라서 이때, 높이 포인터에는 특정 노드를 가리키는 포인터가 하나만 있기 때문에 다음 테이블을 배열에 결합해야 하므로 배열의 다음 테이블을 사용하여 보조 판단을 내릴 수 있습니다. 배열의 다음 테이블은 1 에 기록된 노드가 9번 노드라면 9번 노드의 가운데와 아래쪽 테이블의 포인터 1번이 해당 노드를 가리킨다는 의미이므로 find 함수는 쿼리만 할 수는 없다. 노드가 존재하는지 여부뿐만 아니라 나중에 편리할 수 있는 노드를 가리키는 위치도 찾습니다.삽입 함수 및 삭제 함수, 먼저 이 함수에는 T 유형 매개변수가 필요하고 반환 값은 벡터 유형이고 요소 유형은 벡터입니다. 노드*입니다

vector<Node*> find(const T& target)
{
    
    }

그런 다음 함수에서 벡터를 만들고 그 크기를 헤드 노드의 길이로 초기화한 다음 변수 수준을 만들어 헤드 노드에 배열 수를 저장할 수 있습니다. 현재 찾고 있는 노드를 가리키는 노드 유형 포인터를 생성한 다음 while 루프를 생성할 수 있습니다. 루프의 목적은 대상 노드를 가리키는 모든 노드를 기록하는 것이므로 조건은 다음과 같습니다. 루프의 끝에서 레벨이 0보다 크거나 같으면 여기의 코드는 다음과 같습니다.

vector<Node*> find(const T& target)
{
    
    
	int level = _head->_nextV.size()-1;//这里是下表所以要减一
	vector<Node*> tmp(level+1,_head);//这里要加一,并且每个元素都指向头结点
	Node* cur = _head;
	while (level >= 0)
	{
    
    
	}
}

루프에서 현재 cur가 가리키는 노드의 레벨 포인터가 가리키는 요소가 대상보다 작은지 여부를 판단해야 하며, 적다면 cur가 레벨 포인터가 가리키는 요소를 가리킵니다. 대상보다 크거나 현재 포인터가 가리키는 요소가 비어 있는 경우 삽입 위치를 가리키는 이전 노드나 삭제할 요소를 찾았다는 의미이므로 이때 해당 노드의 주소를 채워준다. 배열의 레벨 테이블에 넣은 다음 레벨 값을 줄입니다. 그러면 여기의 할당량 코드는 다음과 같습니다.

vector<Node*> find(const T& target)
{
    
    
	int level = _head->_nextV.size()-1;//这里是下表所以要减一
	vector<Node*> tmp(level+1,_head);//这里要加一,并且每个元素都指向头结点
	Node* cur = _head;
	while (level >= 0)
	{
    
    
		// 目标值比下一个节点值要大,向右走
		// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
		if (cur->_nextV[level] != nullptr && cur->_nextV[level]->_val < target)
		{
    
    
			// 向右走
			cur = cur->_nextV[level];
		}
		else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val >= target)
		{
    
    
			// 更新level层前一个
			tmp[level] = cur;
			// 向下走
			level--;
		}
	}
	return tmp;
}

물론 이러한 찾기 기능은 여전히 ​​사용하기가 너무 어렵기 때문에 찾기 기능의 단순화된 버전을 추가하므로 여기서의 아이디어는 동일합니다. 코드를 살펴보겠습니다.

bool search(T target) 
{
    
    
	Node* cur = _head;
	int level = _head->_nextV.size() - 1;
	while (level >= 0)
	{
    
    
		// 目标值比下一个节点值要大,向右走
		// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
		if (cur->_nextV[level] && cur->_nextV[level]->_val < target)
		{
    
    
			// 向右走
			cur = cur->_nextV[level];
		}
		else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val > target)
		{
    
    
			// 向下走
			--level;
		}
		else
		{
    
    
			return true;
		}
	}
	return false;
}

삽입 기능

중복된 값이 나타날 경우 삽입에 실패할 수 있으므로 삽입 함수의 반환값은 bool, 함수의 매개변수는 T 타입이다.

bool insert(T num)
{
    
    
}

그런 다음 함수 시작 부분에서 find 함수의 반환 값을 수신할 배열을 생성한 다음 숫자의 높이를 기록하기 위해 난수를 생성해야 하므로 여기에 함수를 작성해야 합니다. 무작위로 트리의 높이를 생성한 다음 여기의 코드는 다음과 같습니다.

int RandomLevel()
{
    
    
	size_t level = 1;
	// rand() ->[0, RAND_MAX]之间
	while (rand() <= RAND_MAX*_p && level < _maxLevel)
	{
    
    
		++level;
	}
	return level;
}

우리는 rand 함수에 의해 생성된 데이터의 크기에 범위가 있다는 것을 알고 있으므로 이 범위를 사용하여 임의의 높이를 생성할 수 있습니다. 이 함수를 사용하면 높이를 얻은 다음 노드를 새로 꺼내서 높이를 설정할 수 있습니다. 노드를 함수 값의 반환으로 사용하는 경우 여기의 코드는 다음과 같습니다.

bool insert(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
}

노드를 삽입한 후 노드의 높이가 연결리스트 전체의 높이를 새로 고칠 수 있기 때문에 새로 생성된 노드의 높이가 헤드 노드의 높이보다 큰지 판단해야 하며, 헤드 노드와 위의 prevV를 확장해야 하며 여기에 if 문을 추가해야 합니다.

bool insert(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
	if (height > _head->_nextV.size())
	{
    
    
		_head->_nextV.resize(height, nullptr);
		prevV.resize(height, _head);
	}
}

그런 다음 새로 삽입된 노드가 이전에 가리킨 노드를 가리키도록 prevV 배열의 노드 높이 요소의 포인터를 변경한 다음 이 노드가 새로 삽입된 노드를 가리키도록 해야 합니다. 예를 들어 다음과 같습니다. 그림에 삽입하려면 요소 15를 삽입해야 합니다 여기에 이미지 설명을 삽입하세요.
. 그런 다음 다음과 같이 됩니다.
여기에 이미지 설명을 삽입하세요.
prevV 배열은 이 위치를 가리키는 모든 노드를 기록하므로 여기에서 while 루프를 만들어 변경 작업을 수행할 수 있으며 전체 코드는 다음과 같습니다.

bool insert(T num)
{
    
    
	vector<Node*> prevV = find(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
	if (height > _head->_nextV.size())
	{
    
    
		_head->_nextV.resize(height, nullptr);
		prevV.resize(height, _head);
	}
	for (int i = 0; i < height; i++)
	{
    
    
		newnode->_nextV[i] = prevV[i]->_nextV[i];
		prevV[i]->_nextV[i] = newnode;
	}
}

지우기 기능

삭제 함수의 구현 아이디어도 동일하다.먼저 삭제하려는 요소가 현재 존재하는지 판단해야 하고, 존재하지 않으면 false를 직접 반환하고, 존재한다면 먼저 배열을 생성하여 기록한다. find 함수로 노드를 가리키는 노드를 지정한 후 insert 함수를 따르고, 노드에 있는 포인터의 포인팅을 같은 방식으로 수정하고, 노드를 가리키는 포인터가 다음 노드를 가리키도록 하면 여기의 코드는 다음과 같습니다. 다음과 같이:

bool erase(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	// 第一层下一个不是val,val不在表中
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
	{
    
    
		return false;
	}
	else
	{
    
    
		Node* del = prevV[0]->_nextV[0];
		// del节点每一层的前后指针链接起来
		for (size_t i = 0; i < del->_nextV.size(); i++)
		{
    
    
			prevV[i]->_nextV[i] = del->_nextV[i];
		}
		delete del;
	}
}

그런데 여기서 문제가 발생하는데, 최상위 노드를 삭제하면 다른 노드도 변경해야 하는 걸까요? 따라서 여기서는 헤드 노드에서 null이 아닌 포인터의 수를 확인한 다음 길이를 줄여야 합니다. 전체 코드는 다음과 같습니다.

bool erase(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	// 第一层下一个不是val,val不在表中
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
	{
    
    
		return false;
	}
	else
	{
    
    
		Node* del = prevV[0]->_nextV[0];
		// del节点每一层的前后指针链接起来
		for (size_t i = 0; i < del->_nextV.size(); i++)
		{
    
    
			prevV[i]->_nextV[i] = del->_nextV[i];
		}
		delete del;		
	}
	int i = _head->_nextV.size() - 1;
	while (i >= 0)
	{
    
    
		if (_head->_nextV[i] == nullptr)
			--i;
		else
			break;
	}
	_head->_nextV.resize(i + 1);
	return true;
}

시험

테스트를 용이하게 하기 위해 인쇄 기능을 추가할 수 있습니다:

void Print()
{
    
    
	Node* cur = _head;
	while (cur)
	{
    
    
		printf("%2d\n", cur->_val);
		// 打印每个每个cur节点
		for (auto e : cur->_nextV)
		{
    
    
			printf("%2s", "↓");
		}
		printf("\n");
		cur = cur->_nextV[0];
	}
}

그런 다음 다음 코드를 사용하여 테스트합니다.

int main()
{
    
    
	Skiplist<int> tmp;
	tmp.insert(11);
	tmp.insert(5);
	tmp.insert(6);
	tmp.insert(12);
	tmp.insert(2);
	tmp.insert(3);
	tmp.insert(7);
	tmp.insert(17);
	tmp.insert(19);
	cout << tmp.search(11) << endl;
	cout << tmp.search(20) << endl;
	tmp.erase(11);
	cout << tmp.search(11) << endl;
	cout << endl;
	tmp.Print();
	return 0;
}

코드의 실행 결과는 다음과 같습니다.
여기에 이미지 설명을 삽입하세요.
코드의 실행 결과가 우리의 기대와 일치하는 것을 확인할 수 있습니다.

효율성 비교

  1. 균형 검색 트리(AVL 트리 및 레드-블랙 트리)와 비교할 때 Skiplist는 데이터를 순서대로 탐색할 수 있으며 시간 복잡도도 비슷합니다. Skiplist의 장점은 다음과 같습니다: a. Skiplist는 구현이 간단하고 제어가 쉽습니다. 균형 트리에서 추가, 삭제, 확인 및 수정 순회는 더 복잡합니다. b. Skiplist의 추가 공간 소비가 더 낮습니다. 균형 트리 노드는 세 갈래 체인, 균형 요소/색상 등 소비와 함께 각 값을 저장합니다. Skiplist에서 p=1/2일 때 각 노드에 포함된 평균 포인터 수는 2이고, Skiplist에서 p=1/4일 때 각 노드에 포함된 평균 포인터 수는 1.33입니다.
  2. Skiplist는 해시 테이블에 비해 큰 장점이 없습니다. 이에 비해 해시 테이블의 평균 시간 복잡도는 O(1)로 Skiplist보다 빠릅니다. b. 해시 테이블 공간 소비량이 약간 더 많습니다. Skiplist의 장점은 다음과 같습니다: a. 순회 데이터가 순서대로 유지됩니다. b. Skiplist 공간 소비가 약간 적고 해시 테이블에 링크 포인터와 테이블 공간 소비가 있습니다. c. 해시 테이블 확장에는 성능 손실이 있습니다. d. 해시 테이블은 극단적인 시나리오에서 해시 충돌이 높으며 효율성이 급격히 떨어집니다. 릴레이를 구성하려면 레드-블랙 트리가 필요합니다.

추천

출처blog.csdn.net/qq_68695298/article/details/131965609