본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
15장 : 해시·HashSet·Set

Study Log 집합 연산

합집합·교집합·차집합의 파괴적 변경을 복사본으로 격리하고 태그·수강 과제에 적용합니다.

Set은 중복 제거 도구를 넘어 “어느 그룹에 속하는가”를 직접 표현합니다. addAll은 합집합, retainAll은 교집합, removeAll은 차집합을 만들지만 수신 Set을 변경합니다. 원본을 이후에도 써야 한다면 먼저 복사본을 만들고 연산해야 합니다. 이 절은 두 학습 트랙의 주제 집합을 기준으로 API의 변경 효과와 결과 검산을 연결합니다.

retainAll의 원본 훼손

공통 주제를 구하려고 완료된에 직접 retainAll을 호출하면 완료 이력 자체가 교집합으로 축소됩니다. 다음 단계에서 전체 완료 수를 출력할 때 잘못된 값이 나옵니다.

lab/DestructiveIntersectionBug.java
import java.util.HashSet;
import java.util.Set;

public final class DestructiveIntersectionBug {
    public static void main(String[] args) {
        Set<String> completed = new HashSet<>(Set.of("array", "hash", "thread"));
        Set<String> required = Set.of("hash", "thread", "lock");
        completed.retainAll(required);
        System.out.println("common=" + completed);
        System.out.println("completed-count=" + completed.size());
    }
}

공통 값 두 개는 맞지만 원래 완료 수 3을 잃었습니다. API 이름만 보고 새 Set을 반환한다고 가정한 것이 원인입니다. new HashSet<>(completed) 복사본에 retainAll을 적용하면 원본 소유권을 보존할 수 있습니다.

세 연산의 입력·출력 불변식

src/SetOperationToolkit.java
import java.util.HashSet;
import java.util.Set;

public final class SetOperationToolkit {
    public static void main(String[] args) {
        Set<String> java = Set.of("array", "hash", "thread");
        Set<String> spring = Set.of("http", "thread", "database");
        System.out.println("union=" + sorted(union(java, spring)));
        System.out.println("intersection=" + sorted(intersection(java, spring)));
        System.out.println("java-only=" + sorted(difference(java, spring)));
        System.out.println("inputs=" + java.size() + "/" + spring.size());
    }

    static <E> Set<E> union(Set<? extends E> left, Set<? extends E> right) {
        Set<E> result = new HashSet<>(left);
        result.addAll(right);
        return Set.copyOf(result);
    }

    static <E> Set<E> intersection(Set<? extends E> left, Set<?> right) {
        Set<E> result = new HashSet<>(left);
        result.retainAll(right);
        return Set.copyOf(result);
    }

    static <E> Set<E> difference(Set<? extends E> left, Set<?> right) {
        Set<E> result = new HashSet<>(left);
        result.removeAll(right);
        return Set.copyOf(result);
    }

    private static <E extends Comparable<? super E>> java.util.List<E> sorted(Set<E> values) {
        return values.stream().sorted().toList();
    }
}

세 메서드는 입력을 변경하지 않고 불변 결과를 반환합니다. 합집합 크기는 두 입력 크기의 합보다 클 수 없고, 교집합은 더 작은 입력보다 클 수 없으며, 차집합과 교집합은 left를 겹치지 않게 나눕니다. 출력 정렬은 테스트 가능한 표현을 위한 것입니다.

중복 제거 시 순서 보존 기준

new HashSet<>(list)는 중복을 제거하지만 첫 등장 순서를 약속하지 않습니다. 입력 순서를 보존한 결과가 필요하면 LinkedHashSet을 거쳐 새 List로 만듭니다. 정렬 결과가 목표면 TreeSet 또는 distinctsorted를 사용합니다.

중복 판단은 equals/hashCode를 따릅니다. 주제의 대소문자와 공백을 무시하려면 Set에 넣기 전에 정규화합니다. 원본 문자열을 보존해야 한다면 정규화 키와 표시 값을 Map으로 나누는 편이 낫습니다.

src/OrderedDuplicateRemoval.java
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;

public final class OrderedDuplicateRemoval {
    public static void main(String[] args) {
        List<String> input = List.of("array", "hash", "array", "set", "hash");
        List<String> unique = new ArrayList<>(new LinkedHashSet<>(input));
        System.out.println("input=" + input);
        System.out.println("unique=" + unique);
        System.out.println("first-array=" + unique.indexOf("array"));
    }
}

unique는 배열, 해시, set 순서입니다. Set으로 바꾼 순간 index 기반 목록 규칙은 사라지고, 다시 List로 만들 때 LinkedHashSet의 반복 순서가 사용됩니다.

Study Log 학습 계획의 남은 주제

필수와 완료된의 차집합이 남은 주제입니다. 완료된에 교육과정 밖 주제가 있을 수 있으므로 단순히 크기만 빼면 안 됩니다. 교집합으로 유효 완료를 구하고 필수 차집합으로 todo를 만듭니다.

app/StudyPlanSetCli.java
import java.util.LinkedHashSet;
import java.util.Set;

public final class StudyPlanSetCli {
    public static void main(String[] args) {
        Set<String> required =
                new LinkedHashSet<>(java.util.List.of("array", "hash", "set", "map"));
        Set<String> completed = Set.of("array", "hash", "extra");
        Set<String> validCompleted = copy(required);
        validCompleted.retainAll(completed);
        Set<String> todo = copy(required);
        todo.removeAll(completed);
        Set<String> outside = copy(completed);
        outside.removeAll(required);
        System.out.println("done=" + validCompleted);
        System.out.println("todo=" + todo);
        System.out.println("outside=" + outside);
        System.out.println("progress=" + validCompleted.size() + "/" + required.size());
    }

    private static <E> Set<E> copy(Set<E> source) {
        return new LinkedHashSet<>(source);
    }
}

LinkedHashSet 복사본은 교육과정 순서를 유지합니다. extra는 진행률에 포함되지 않고 outside 진단으로 분리됩니다. 원본 필수와 완료된은 어느 연산에서도 변경되지 않습니다.

구현 선택의 최종 점검

검색과 중복 방지가 중심이고 순서가 없다면 HashSet, 최초 등장 순서를 결과 형식으로 보존하면 LinkedHashSet, 정렬·범위 질의가 지속적으로 필요하면 TreeSet을 선택합니다. 집합 연산은 대부분 수신 객체를 바꾸므로 소유권이 불명확한 입력에는 복사본을 사용합니다.

equals와 hashCode 규칙이 잘못되면 합집합에서 중복이 남고 교집합에서 같은 값을 놓칠 수 있습니다. 따라서 집합 연산 결과 오류는 연산 코드뿐 아니라 원소의 동등성 구현까지 거슬러 확인합니다.

연습 문제

Set 모두에 포함된 주제와 적어도 한 명이 완료한 전체 주제를 구하세요. 원본 세 Set은 연산 뒤에도 같은 크기여야 합니다.

정답과 해설

교집합은 첫 Set 복사본에 나머지 두 Set을 차례로 retainAll합니다. 합집합은 빈 LinkedHashSet에 순서대로 addAll해 첫 등장 순서를 보존합니다.

exercise/ThreeStudentSetSolution.java
import java.util.LinkedHashSet;
import java.util.Set;

public final class ThreeStudentSetSolution {
    public static void main(String[] args) {
        Set<String> a = Set.of("array", "hash", "set");
        Set<String> b = Set.of("hash", "set", "map");
        Set<String> c = Set.of("set", "map", "queue");
        Set<String> common = new LinkedHashSet<>(a);
        common.retainAll(b);
        common.retainAll(c);
        Set<String> all = new LinkedHashSet<>();
        all.addAll(a);
        all.addAll(b);
        all.addAll(c);
        System.out.println("common=" + common);
        System.out.println("all=" + all.stream().sorted().toList());
        System.out.println("unchanged=" + a.size() + "/" + b.size() + "/" + c.size());
    }
}

공통은 set 하나이고 전체는 배열, 해시, map, 큐, set입니다. 입력 크기는 모두 3으로 유지됩니다. 복사 위치가 소유권 범위를 결정한다는 점까지 답에 포함합니다.

15장의 종합 기준은 해시 코드와 그룹 index를 구분하고, 충돌 후보를 equals로 판단하며, 순서·정렬·가변성 요구에 맞는 Set 구현을 고르는 것입니다. Study Log의 todo 계산이 원본 이력을 보존하면 이 세 축이 함께 작동합니다.