공부/Java

자바 컬렉션 프레임워크 - Set

Stair 2024. 10. 1. 11:31
반응형

Set의 이론은 이전 포스팅을 참고하도록 하자.

https://surrealcode.tistory.com/80

 

컬렉션 프레임워크 - HashSet

직접 구현하는 Set - MyHashSetV1https://surrealcode.tistory.com/79 자바 컬렉션 프레임워크 - 해시(Hash)컬렉션 프레임워크 - Set1 리스트(List) vs 세트(Set)자료구조에서의 List와 Set은 각각 특정한 방식으로 데

surrealcode.tistory.com

 

자바가 제공하는 Set1 - HashSet, LinkedHashSet

set : 중복을 허용하지 않고, 순서를 보장하지 않는 구조이다.

Collection 인터페이스

Collection 인터페이스는 java.util 패키지의 컬렉션 프레임워크의 핵심 인터페이스 중 하나이다. 이 인터페이스는 자바에서 다양한 컬렉션, 즉 데이터 그룹을 다루기 위한 메서드를 정의한다. Collection 인터페이스는 List,Set Queue와 같은 다양한 하위 인터페이스와 함께 사용되며, 이를 통해 데이터를 리스트, 세트 큐 등의 형태로 관리할 수 있다.

 

Set 인터페이스

자바의 Set 인터페이스는 java.util패키지의 컬렉션 프레임워크에 속하는 인터페이스 중 하나이다. Set 인터페이스는 중복을 허용하지 않는 유일한 요소의 집합을 나타낸다. 즉, 어떤 요소도 같은 Set 내에 두번 이상 나타날 수 없다. Set은 수학적 집합 개념을 구현한 것으로, 순서를 보장하지 않으며, 특정 요소가 집합에 있는지 여부를 확인하는데 최적화 되어있다.

 

Set 인터페이스의 주요 메서드

 

Set의 주요 구현체는 다음과 같다

HashSet, LinkedHashSet, TreeSet

 

 

1. HashSet

구현 : 해시 자료 구조를 사용해서 요소를 저장한다.

순서 : 요소들은 특정한 순서 없이 저장된다. 즉, 요소를 추가한 순서를 보장하지 않는다.

시간 복잡도 : HashSet의 주요 연산(추가, 삭제, 검색)은 평균적으로 O(1)시간 복잡도를 가진다.

용도 : 데이터의 유일성만 중요하고, 순서가 중요하지 않은 경우에 적합하다.

 

앞서 우리가 구현한 MyHashSet이 바로 HashSet이다.

hashCode()와 equals()를 모두 사용한다.

 

 

2. LinkedHashSet

구현 : LinkedHashSet은 HashSet에 연결 리스트를 추가해서 요소들의 순서를 유지한다.

순서 : 요소들은 추가된 순서대로 유지된다. 즉, 순서대로 조회 시 요소들이 추가된 순서대로 반환된다.

시간복잡도 : LinkedHashSet도 HashSet과 마찬가지로 주요 연산에 대해 평균 O(1)시간 복잡도를 가진다.

용도 : 데이터의 유일성과 함께 삽입 순서를 유지해야할 때 적합하다.

참고 : 연결 링크를유지해야 하기 때문에 HashSet보다는 조금 더 무겁다.

 

 

- LinkedHashSet은 HashSet에 연결 링크만 추가한 것이다.

- HashSet에 LinkedList를 합친 것으로 이해하면 된다.

- 이 연결 링크는 데이터를 입력한 순서대로 연결된다.

  - head(first)부터 순서대로 링크를 따라가면 입력 순서대로 데이터를 순회할 수 있다.

  - 양방향으로 연결된다.(기존 LinkedList처럼 next, prev 가능)

 

 

 

자바가 제공하는 Set2 - TreeSet

3. TreeSet

구현 : TreeSet은 이진 탐색 트리를 개선한 레드-블랙 트리를 내부에서 사용한다.

순서 : 요소들은 정렬된 순서로 저장된다. 순서의 기준은 비교자(Comparator)로 변경할 수 있다. 비교자는 뒤에서 다룬다.

시간 복잡도 : 주요 연산들은 O(log n)의 시간 복잡도를 가진다. 따라서 HashSet보다는 느리다.

용도 : 데이터들을 정렬된 순서로 유지하면서 집합의 특성을 유지해야 할 때 사용한다. 예를 들어, 범위 검색이나 정렬된 데이터가 필요한 경우에 유용하다. 참고로 입력된 순서가 아니라 데이터 값의 순서이다. 예를 들어 3, 1, 2를 순서대로 입력해도 1, 2, 3 순서로 출력된다.

 

- 트리는 부모 노드와 자식 노드로 구성된다.

- 가장 높은 조상을 루트(root)라 한다. 이 그림을 뒤집어보면 왜 트리라고 하는지, 처음을 루트라고 하는지 이해가 될 것이다.

- 자식이 2개까지 올 수 있는 트리를 이진트리라 한다.

- 여기에 노드의 왼쪽 자손은 더 작은 값을 가지고, 오른쪽 자손은 더 큰값을 가지는 것을 이진 탐색 트리라 한다.

- TreeSet은 이진 탐색 트리를 개선한 레드-블랙 트리를 사용한다. 기본 개념은 비슷하므로 이진 탐색 트리의 원리를 알아보자.

class Node{

    Object item;

    Node left;

    Node right;

}

트리 구조는 왼쪽, 오른쪽 노드를 알고 있으면 된다.

앞서 다룬 연결 리스트의 구현을 떠올려보면 쉽게 이해가 될 것이다.

 

이진탐색 트리 - 입력 예시

이진 탐색 트리의 핵심은 데이터를 입력하는 시점에 정력해서 보관한다는 점이다.

그리고 작은 값은 왼쪽에 큰 값은 오른쪽에 저장한다.

데이터를 10,5,15,1,6,11,16 순대로 입력한다고 가정한다.

 

이진 탐색 트리 - 검색

- 여기에는 총 15개의 데이터가 들어있다. 여기서 숫자 35를 검색한다고 가정해보자.

- 1번 : 루트인 20과 35를 비교한다. 35가 더 크므로 오른쪽으로 찾아간다.

- 2번 : 40과 35를 비교한다. 35가 더 작으므로 왼쪽으로 찾아간다.

- 3번 : 30과 35를 비교한다. 35가 더 크므로 오른쪽으로 찾아간다.

- 4번 : 노드의 값과 같으므로 35를 찾는다.

 

데이터가 총 15개인데 4번의 계산으로 필요한 결과를 얻을 수 있었다. 이것은 O(n)인 리스트의 검색보다는 빠르고, O(1)인 해시의 검색보다는 느리다.

- 리스트의 경우 O(n)이므로 15번의 연산이 필요하다

- 해시 검색은 O(1)이므로 1번의 연산이 필요하다.

 

이진 탐색 트리 계산의 핵심은 한전에 절반을 날린다는 점이다. 계산을 단순화 하기 위해 16개가 있다고 가정하자.

16 -> 8 -> 4 -> 2 -> 1

1이 남았으므로 이 값이 맞는지 확인하기만 하면 된다.

1024개의 데이터를 단 10번의 계산으로 원하는 결과를 찾을 수 있다. 데이터의 크기가 늘어나도 늘어난 만큼 한번의 계산에 절반을 날려버리기 때문에, O(n)과 비교해서 데이터의 크기가 클 수록 효과적이다.

 

이것을 수학으로 log2(n)으로 표현된다.

 

이진 탐색 트리와 성능

이진 탐색 트리의 검색, 삽입, 삭제의 평균 성능은 O(log n)이다. 하지만 트리의 균형이 맞지 않으면 최악의 경우 O(n)의 성능이 나온다.

이렇게 오른쪽 또는 왼쪽으로 치우치게 되면, 결과적으로 15를 검색했을 때 데이터의 수인 5만큼 검색해야 한다.

따라서 이런 최악의 경우 O(n)의 성능이 나온다.

 

 

이진 탐색 트리 개선

이런 문제를 해결하기 위한 다양한 해결 방안이 있는데 트리의 균형이 너무 깨진 경우 동적으로 균형을 다시 맞추는 것이다.

 

- 앞서 중간에 있는 6을 기준으로 다시 정렬한다.

- AVL 트리, 레드-블랙 트리 같은 균형을 맞추는 다양한 알고리즘이 존재한다.

- 자바의 TreeSet은 레드-블랙 트리를 사용해서 균형을 지속해서 유지한다. 따라서 최악의 경우에도 O(log n)의 성능을 제공한다.

 

이진 탐색 트리 - 순회

- 이진 탐색 트리의 핵심은 입력 순서가 아니라, 데이터의 값을 기준으로 정렬해서 보관한다는 점이다.

- 따라서 정렬된 순서로 데이터를 차례로 조회할 수 있다.(순회할 수 있다)

- 데이터를 차례로 순회하려면 중위 순회 라는 방법을 사용하면 된다. 왼쪽 서브트리를 방문한 다음, 현재 노드를 처리하고, 마지막으로 오른쪽 서브트리를 방문한다. 이 방식은 이진 탐색 트리의 특성상, 노드를 오름차순으로 방문한다.

중위 순회 순서

쉽게 이야기 해서 자신의 왼쪽 모든 노드를 처리하고, 자신의 노드를 처리하고, 자신의 오른쪽 모든 노드를 처리하는 방식이다.

1 -> 5 -> 6 -> 10 -> 11 -> 15 -> 16

 

 

 

자바가 제공하는 Set3 - 예제

다음과 같은 코드를 확인해보자.

public class JavaSetMain {

    public static void main(String[] args) {

        run(new HashSet<>()); //해시 코드 기반
        run(new LinkedHashSet<>()); // 링크구조로 인한 입력 순서 기반
        run(new TreeSet<>()); // 트리 구조로 인한 데이터의 순서 기반
    }

    private static void run(Set<String> set){
        System.out.println("set = " + set.getClass());
        set.add("C");
        set.add("B");
        set.add("A");
        set.add("1");
        set.add("2");

        Iterator<String> iterator = set.iterator();
        while (iterator.hasNext()){
            System.out.print(iterator.next() + " ");
        }
        System.out.println();

    }
}

- HashSet, LinkedHashSet, TreeSet 모두 Set 인터페이스를 구현하기 때문에 구현체를 변경하면서 실행할 수 있다.

- iterator()를 호출하면 컬렉션을 반복해서 출력할 수 있다.

  - iterator.hasNext() : 다음 데이터가 있는지 확인한다.

  - iterator.next() : 다음 데이터를 반환한다.

 

실행 결과는 아래와 같다.

 

set = class java.util.HashSet
A 1 B 2 C 
set = class java.util.LinkedHashSet
C B A 1 2 
set = class java.util.TreeSet
1 2 A B C 

 

HashSet : 입력한 순서를 보장하지 않는다. O(1)

LinkedHashSet : 입력한 순서를 정확히 보장한다. O(1)

TreeSet : 데이터 값을 기준으로 정렬한다. O(log N)

 

참고 - TreeSet의 정렬 기준

TreeSet을 사용할 때 데이터를 정렬하려면 크다, 작다라는 기준이 필요하다. 1, 2, 3이나 "A", "B", "C"같은 기본 데이터는 크다 작다라는 기준이 명확하기 때문에 정렬할 수 있다. 하지만 우리가 직접 만든 Member와 같은 객체는 크다 작다는 기준을 어떻게 알 수 있을까? 이런 기준을 제공하려면 Comparable, Comparator 인터페이스를 구현해야 한다. 이 부분은 뒤에서 설명한다.

 

 

 

자바가 제공하는 Set4 - 최적화

자바 HashSet과 최적화

자바의 HashSet은 우리가 직접 구현한 내용과 거의 같지만 다음과 같은 최적화를 추가로 진행한다.

 

최적화

- 해시 기반 자료 구조를 사용하는 경우 통계적으로 입력한 데이터의 수가 배열의 크기를 75%정도를 넘어서면 해시 인덱스가 자주 충돌한다. 따라서 75%가 넘어가면 성능이 떨어지기 시작한다.

  - 해시 충돌로 같은 해시 인덱스에 들어간 데이터를 검색하려면 모두 탐색해야 한다. 따라서 성능이 O(n)으로 좋지 않다.

- 하지만 데이터가 동적으로 계속 추가 되기 때문에 적절한 배열의 크기를 정하는 것은 어렵다.

- 자바의 HashSet은 데이터의 양이 배열 크기의 75%를 넘어가면 배열의 크기를 2배로 늘리고 2배 늘어난 크기를 기준으로 모든 요소에 해시 인덱스를 다시 적용한다.

  - 해시 인덱스를 다시 적용하는 시간이 걸리지만, 결과적으로 해시 충돌이 줄어든다.

- 자바 HashSet의 기본 크기는 16이다.

 

위 그림과 같이 75%가 넘어 충돌이 증가하면

 

CAPACITY를 2배로 증가하고, 모든 데이터의 해시 인덱스를 커진 배열에 맞추어 다시 계산한다. 이 과정을 재해싱(rehashing)이라 한다.

재해싱을 통해 충돌 가능성이 줄어든다.

여기서 데이터가 다시 75% 이상 증가하면 다시 2배 증가와 재계산을 반복한다.

 

정리

실무에서는 Set이 필요한 경우 HashSet을 가장 많이 사용한다. 그리고 입력 순서 유지, 값 정렬의 필요에 따라서 LinkedHashSet, TreeSet을 선택하면 된다.

 

 

참고

public class UniqueNamesTest2 {
    public static void main(String[] args) {
        Integer[] inputArr = {30, 20, 20, 10, 10};

        LinkedHashSet<Integer> set = new LinkedHashSet<>(List.of(inputArr));
        for (Integer integer : set) {
            System.out.println(integer);
        }

    }
}

 

배열을 Set에 입력할 때 직접 배열을 반복하면서 Set에 입력하는 방법도 있지만, 더 간단하게 해결하는 방법이 있다.

Set 구현체의 생성자에 배열은 전달할 수 없지만 List는 전달할 수 있다. 다음과 같이 배열을 List로 변환한다.

 

배열을 리스트로 변환하기

List<Integer> list = Arrays.asList(inputArr);

List<Integer> list = List.of(inputArr);

 

편리한 리스트 생성

List<Integer> list = Arrays.asList(1, 2, 3);

List<Integer> list = List.of(1, 2, 3);

 

자바의 Arrays.asList()는 자바 1.2부터 존재했다. 자바 9 이상을 사용한다면 List.of()를 권장한다.

 

 

 

 

반응형