티스토리 뷰
728x90
반응형
Hash Set
- Set은 중복 원소를 저장할 수 없으며 하나의 NULL값만 저장할 수 있습니다. 또한 순서를 보장하지 않기 때문에 순서 보장이 필요한 경우에는 LinkedHashSet을 사용해야 합니다.
- 우선 Set에 대해 살펴보기 전에 Hash란 친구는 어떤 역할을 하는지 알아보겠습니다.
Hash 란?
- 어떠한 데이터를 Hash Function의 input으로 넣어 output으로 나오는 결과가 Hash라고 생각하면 됩니다. 그리고 이 도출된 Hash를 사용해 데이터 저장위치의 Key가 됩니다.
- 그렇다면 왜 Hash Function을 사용해 Hash를 만들고 이를 사용할까요? 그 이유는 효율적인 데이터 탐색이라고 생각할 수 있습니다. 해시를 사용한다면 자료구조(ex: List)를 순회하지 않고 효율적으로 데이터를 탐색할 수 있습니다.
💡 Hash의 활용
- Set은 중복을 허용하지 않는다고 언급을 하였습니다. 그럼 이제 어떻게 중복을 허용하지 않는지 살펴보겠습니다.
- Hash Function을 통해 만들어진 Hash를 사용하여 그 값에 대응하는 index를 찾아서 해당 인덱스에 있는 요소만을 검사하게 됩니다.
- 참고로 자바에서의 Hash는 hashCode 메서드를 통해 얻을 수 있으므로 따로 구현을 하지 않아도 됩니다. hashCode 메서드는 객체의 주소값을 이용해 해시 알고리즘에 의해 생성된 고유의 정수값을 반환하며, 객체가 같은 메모리를 참조하고 있다면 같은 정수값을 반환하게 됩니다.
💡 Hash 충돌
- Hash Function에서 서로 다른 데이터가 해싱 후 동일한 Hash값이 나오는 경우가 있는데 이를 해시 충돌이라 합니다. 이러한 해시 충돌은 발생확률이 적을수록 좋습니다.
public class Main {
public static void main(String[] args) {
String str1 = "0-42L";
String str2 = "0-43-";
System.out.println("str1 : " + str1.hashCode()); // str1 : 45721201
System.out.println("str2 : " + str2.hashCode()); // str2 : 45721201
System.out.println((str1.hashCode() == str2.hashCode())); // true
}
}
💡 Hash 충돌의 해결방법 - Open Addressing
- Open Addressing이란 충돌이 발생한 경우 다른 자리에 대신 저장한다는 의미입니다.
- Linear Probing, Quadratic Probing이 있으며 두가지 동일한 문제점이 있어 가장 이상적인 해결방법은 이중 해시를 사용하는 방법이라 합니다.
Linear Probing
- 충돌이 발생한 경우 그 옆자리가 비었는지 확인하고 비어있을 경우 그 자리에 대신 저장하는 방식입니다.
- f(k) + 1 -> f(k) + 2 -> f(k) + 3 ...방식이므로 충돌의 횟수가 증가함에 따라 특정 영역에 데이터가 몰릴 가능성이 높아집니다.
Quadratic Probing
- Linear Probing의 대안으로 나온곳이 조금 더 멀리서 빈 공간을 찾는 방식입니다.
- f(k) + 1제곱 -> f(k) + 2제곱 -> f(k) + 3제곱 ... 방식이므로 출동 발생시 n제곱 칸 옆의 슬롯을 검사하게 됩니다.
💡 Hash 충돌의 해결방법 - Separate Chaining
- Separate Chaining은 저장소(Buckets)에서 충돌이 발생한다면 기존 값과 새로운 값을 연결 리스트로 연결하는 방법입니다.
- 자바에서 Separate Chaining와 보조 해시 함수를 사용하여 해시 충돌을 방지하고 있습니다.
장점
- 미리 충돌을 대비해서 공간을 만들어 놓을 필요가 없습니다. 충돌이 발생하면 그때 공간을 만들어 연결을 해줍니다.
단점
- 같은 해시에 자료들이 많이 연결되면 검색시 효율이 낮아질 수 있습니다.
구현하고자 하는 구조
Bucket의 동적 확장
- 구현에 앞서 중요한 동적 확장에 대해 살펴보겠습니다.
- 데이터가 저장되는 공간인 Bucket의 길이를 적게하면 메모리를 아낄 수 있지만 해시코드의 충돌 가능성은 높아집니다. 이러한 이유로 인해 저장된 데이터의 개수가 일정 개수 이상이된다면 버킷의 길이를 2배로 늘리게됩니다. 또한 이러한 동적 확장은 버킷이 75% 차게되면 발생하게 됩니다.
- 버킷의 최대 개수는 2의 30승이라 합니다.
💡 로드 팩터(부하율)
- 로드 팩터란 쉽게 설명하면 부하율이라 생각할 수 있습니다. 예를들어 HashSet의 기본 버킷 수는 16입니다. 이때 16개 보다 더 많은 데이터가 들어오게 된다면 버킷의 수를 증가시켜 더 많은 데이터를 받을 수 있도록 해야합니다. 이때 부하율을 몇 %로 잡느냐를 정하는게 로드 팩터입니다.
- HashSet을 기본적으로 선언하면 디폴트 로드 팩터는 0.75입니다. 즉 75%의 버킷이 차면 추가적으로 데이터를 저장할 공간을 확보하게 됩니다. 이때 용량은 현재 버킷 개수의 2배만큼 용량이 증가하게 됩니다.
- 이 과정에서 순서 보장이 이루어지지 않게 됩니다. 쉽게 표현하면 기본 버킷의 수 16개 이전에는 데이터가 차곡차곡 쌓아졌더라도
버킷의 용량이 2배만큼 증가하게 되면서 기존의 데이터는 새롭게 해시를 얻어 이곳저곳에 저장이 됩니다. 그렇기 때문에 이러한 과정에서 순서가 보장이 안됩니다.
Hash Set 구현
💡 구현하고자하는 메서드
public interface Set<E> {
/**
* 데이터가 Set에 없는 경우 데이터를 추가합니다.
*/
void add(E element);
/**
* 특정 데이터를 Set에서 삭제합니다.
*/
void remove(E element);
/**
* 특정 데이터가 포함되어 있는지 확인합니다.
*/
boolean contains(E element);
/**
* 특정 데이터가 현재 집합과 같은지 여부를 확인합니다.
*/
boolean equals(Object o);
/**
* 현재 Set의 모든 데이터를 삭제합니다.
*/
void clear();
}
💡 Node 구현
- Node는 그림에서 설명한 내용을 토대로 구현합니다.
public class Node<E> {
final int hash;
final E key;
Node<E> next;
public Node(int hash, E key, Node<E> next) {
this.hash = hash;
this.key = key;
this.next = next;
}
}
💡 HashSet의 기본 토대 구현
- HashSet의 가장 중요한 기본 토대입니다.
- DEFAULT_CAPACITY는 데이터를 담을 배열의 기본 용적입니다. 또한 데이터의 개수와는 다른 의미이므로 주의해야 합니다.
- LOAD_FACTOR는 부하율입니다. 테이블의 크기에 비해 데이터의 양이 일정 이상 많아지면 성능이 저하되므로 그 기준점을 75%로 잡고, 75%가 넘으면 용적을 늘릴 수 있도록 합니다.
- size는 배열의 총 데이터 수입니다.
- tables는 데이터를 담을 배열입니다.
- hash 메서드는 최대한의 해시 충돌을 피하기 위한 보조 해시함수입니다.
public class HashSet<E> implements Set<E> {
private static final int DEFAULT_CAPACITY = 16; // 최소 기본 용적
private static final float LOAD_FACTOR = 0.75f; // 3/4 이상 채워질 경우 resize하기 위한 변수
private int size; // 데이터의 수
private Node<E>[] tables; // 데이터의 정보를 담고 있는 배열
@SuppressWarnings("unchecked")
public HashSet() {
this.tables = (Node<E>[]) new Node[DEFAULT_CAPACITY];
this.size = 0;
}
static final int hash(Object key) {
int hash;
if (key == null) {
return 0;
} else {
return Math.abs(hash = key.hashCode()) ^ (hash >>> 16);
}
}
}
💡 resize 메서드
- 새로운 배열의 크기는 현재 배열이 가지고 있는 데이터의 개수 * 2를 하여 크기를 지정합니다.
- 현재 테이블의 크기만큼 반복문을 순회하여 새로운 테이블에 하나씩 옮겨줍니다. 이때 해시 충돌이 발생하는 경우와 발생하지 않는 경우가 있으므로 주의깊게 살펴봐야 합니다.
private void resize() {
int newCapacity = this.tables.length * 2;
final Node<E>[] newTables = (Node<E>[]) new Node[newCapacity];
for (int i = 0; i < tables.length; i++) {
Node<E> value = tables[i];
if (value == null) continue;
tables[i] = null; // help gc
Node<E> nextNode;
while (value != null) {
int index = value.hash % newCapacity; // 새로운 인덱스 생성
nextNode = value.next;
// 새로 담을 index에 데이터가 존재하는 경우
// 즉 새로담을 newTables에 index의 값이 겹치는 경우(해시 충돌)
if (newTables[index] != null) {
Node<E> tail = newTables[index];
// while문을 사용하여 가장 마지막 노드로 이동
while (tail.next != null) {
tail = tail.next;
}
// 충돌 발생하는 데이터의 끝지점에 데이터 추가
tail.next = value;
} else {
// 해시 충돌이 발생하지 않았다면
newTables[index] = value;
}
value.next = null;
value = nextNode; // 다음 노드로 이동
}
}
this.tables = null; // 명시적 gc
this.tables = newTables;
}
💡 add 메서드
- 데이터를 추가할 때 해시 충돌이 발생하지 않았다면 정상적으로 데이터가 삽입이 되고, 해시 충돌이 발생한 경우 충돌한 노드의 다음 노드에 새로운 데이터가 삽입될 수 있도록 조정이 필요합니다.
@Override
public void add(E element) {
add(hash(element), element);
}
private void add(int hash, E key) {
int index = hash % this.tables.length;
// tables[index]가 비어있는 경우 새로운 노드를 생성합니다.
if (tables[index] == null) {
tables[index] = new Node<>(hash, key, null);
} else {
// tables[index]가 비어있지 않은 경우는 해시 충돌이 발생한 자리입니다.
Node<E> conflictNode = tables[index]; // 충돌이 발생한 현재 노드
Node<E> prev = null; // 충돌이 발생한 현재 노드의 이전 노드
while (conflictNode != null) {
// 동일한 데이터라면 넣지 않음
if ((conflictNode.hash == hash) && (conflictNode.key == key) && (conflictNode.key.equals(key))) {
return;
}
prev = conflictNode;
conflictNode = conflictNode.next; // 다음 노드로 이동
}
// 마지막 노드에 새로운 노드 연결
prev.next = new Node<>(hash, key, null);
}
size++;
// 데이터의 개수가 현재 용적의 75%를 넘어가는 경우 용적 재할당
if (size >= LOAD_FACTOR * tables.length) {
resize();
}
}
💡 remove 메서드
@Override
public void remove(E element) {
remove(hash(element), element);
}
private void remove(int hash, E key) {
int index = hash % this.tables.length;
Node<E> node = tables[index];
Node<E> prev = null;
if (node == null) {
return;
}
while (node != null) {
// 같은 노드를 발견했다면
if (node.hash == hash && (node.key == key && node.key.equals(key))) {
// 해당 노드의 이전 노드가 없다면 즉 head 노드인 경우
if (prev == null) {
tables[index] = node.next; // 현재 노드에 다음 노드 연결
node = null; // 현재 노드 삭제
} else {
// 이전 노드의 next와 삭제할 노드의 다음 노드를 연결합니다.
prev.next = node.next;
node = null;
}
size--;
break;
}
prev = node;
node = node.next;
}
}
💡 contains 메서드
@Override
public boolean contains(E element) {
int index = hash(element) % tables.length;
Node<E> node = tables[index];
while (node != null) {
if (element == node.key || (element != null && (element.equals(node.key)))) {
return true;
}
node = node.next;
}
return false;
}
💡 equals 메서드
@Override
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (!(obj instanceof HashSet)) {
return false;
}
try {
HashSet<E> set;
set = (HashSet<E>) obj;
if (set.size != size) {
return false;
}
for (int i = 0; i < set.tables.length; i++) {
Node<E> node = set.tables[i];
while (node != null) {
if (!contains((E) node)) {
return false;
}
node = node.next;
}
}
} catch (ClassCastException e) {
return false;
}
return false;
}
💡 clear 메서드
@Override
public void clear() {
if (tables != null && size > 0) {
for (int i = 0; i < tables.length; i++) {
tables[i] = null;
}
size = 0;
}
}
✔️ 정리
- 자바 8에서는 Separate Chaining에서 Linked List 대신 Tree를 사용하기도 한다고합니다. 또한 String 클래스의 hashCode 메서드에서 31를 사용하는 이유는 성능 향상 도모를 위한 것이라고 할 수 있습니다.
- HashSet은 어렵다...
깃 허브)
참고자료)
- https://st-lab.tistory.com/240
- https://go-coding.tistory.com/30
- https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=wpdls6012&logNo=220482453361
- https://d2.naver.com/helloworld/831311
728x90
반응형
'자료구조' 카테고리의 다른 글
자료구조 - LinkedHashSet (0) | 2022.12.10 |
---|---|
자료구조 - Array List (0) | 2022.12.04 |
자료구조 - Doubly Linked List (0) | 2022.12.03 |
자료구조 - Singly Linked List (0) | 2022.12.02 |
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
TAG
- microkernel architecture
- redis sorted set
- 레이어드 아키텍처란
- spring boot excel download oom
- spring boot poi excel download
- JDK Dynamic Proxy와 CGLIB의 차이
- 트랜잭셔널 아웃박스 패턴 스프링부트
- spring boot 엑셀 다운로드
- service based architecture
- 자바 백엔드 개발자 추천 도서
- transactional outbox pattern spring boot
- pipeline architecture
- java userThread와 DaemonThread
- @ControllerAdvice
- 공간 기반 아키텍처
- spring boot redisson 분산락 구현
- redis 대기열 구현
- polling publisher spring boot
- 트랜잭셔널 아웃박스 패턴 스프링 부트 예제
- 서비스 기반 아키텍처
- spring boot redisson sorted set
- java ThreadLocal
- space based architecture
- redis sorted set으로 대기열 구현
- spring boot redisson destributed lock
- 람다 표현식
- spring boot excel download paging
- transactional outbox pattern
- spring boot redis 대기열 구현
- pipe and filter architecture
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
글 보관함