본문 바로가기

문자열 hashCode

단순 문자 합 충돌을 재현하고 31배 누적·음수 인덱스·결정성 조건을 단계별 숫자로 확인합니다.

정수는 자기 값을 해시 재료로 쓸 수 있지만 문자열은 여러 문자를 하나의 int로 요약해야 합니다.

컬렉션 탐색에서는 같은 문자열이 같은 hashCode를 내고, 흔한 입력이 그룹 전체에 비교적 고르게 퍼지는 것이 중요합니다. Java String.hashCode()는 문자와 계산식으로 정해지는 값이며 실행 때마다 새로 뽑는 난수가 아닙니다. 비밀번호 보관이나 파일 변조 검증에 쓰는 암호학적 해시와도 용도가 다릅니다.

hashCode가 같아도 문자열이 같다는 뜻은 아니므로 마지막 equals 비교는 남습니다.


문자 합 해시와 순서 소실

ab와 ba는 문자 코드의 합이 같습니다.

게시글 slug에서 글자 순서가 의미를 가지는데 단순 합은 순열을 모두 같은 결과로 만듭니다.

다음 출력은 문자 합에서 순서 정보가 사라져 서로 다른 두 문자열이 충돌하는 모습을 보여 줍니다.

lab/AdditiveStringHashCollision.java
public final class AdditiveStringHashCollision {
    public static void main(String[] args) {
        String left = "ab";
        String right = "ba";
        int leftHash = additive(left);
        int rightHash = additive(right);
        System.out.println(left + "=" + leftHash);
        System.out.println(right + "=" + rightHash);
        System.out.println(
                "different=" + !left.equals(right) + ", collision=" + (leftHash == rightHash));
    }

    private static int additive(String value) {
        int hash = 0;
        for (int i = 0; i < value.length(); i++) {
            hash += value.charAt(i);
        }
        return hash;
    }
}
관찰
ab=195
ba=195
different=true, collision=true

충돌 자체는 허용되지만 이런 입력 패턴이 많으면 특정 그룹이 길어집니다.

문자를 읽을 때 이전 결과에 31을 곱한 뒤 다음 문자를 더하면 앞 문자의 위치가 이후 계산에 계속 영향을 줍니다.

실패 판단은 충돌 존재가 아니라 편향 정도다

두 값이 충돌했다는 사실 하나만으로 해시 함수가 틀렸다고 판단하지 않습니다.

int 결과보다 가능한 문자열 수가 훨씬 많으므로 충돌은 수학적으로 피할 수 없습니다.

실제 문제 신호는 게시글 제목처럼 자주 등장하는 입력군이 몇 개 그룹에 반복해서 몰려 평균 후보 길이가 커지는 현상입니다.

평가할 때는 입력 표본을 고정하고 그룹별 개수, 최대 길이, 빈 그룹 비율을 함께 출력합니다.

단순 문자 합은 철자 순서를 바꾼 모든 문자열을 같은 코드로 만들기 때문에 이런 분포 검사에서 명확한 편향을 보입니다.

31배 누적은 충돌을 제거하지 않지만 순서 정보를 계산에 반영해 해당 실패 패턴을 줄입니다.

31배 누적에서도 서로 다른 문자열의 해시가 같을 수 있습니다. Aa는 65 × 31 + 97 = 2112, BB는 66 × 31 + 66 = 2112입니다. 두 문자열이 같은 해시라는 이유만으로 Set이 하나를 버리면 안 됩니다.

예시

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

public final class PolynomialCollisionPair {
    public static void main(String[] args) {
        String first = "Aa";
        String second = "BB";
        Set<String> values = new HashSet<>();
        System.out.println("hashes=" + first.hashCode() + "/" + second.hashCode());
        System.out.println("equal=" + first.equals(second));
        System.out.println("first=" + values.add(first));
        System.out.println("second=" + values.add(second));
        System.out.println("size=" + values.size());
    }
}

출력은 hashes=2112/2112, equal=false, first=true, second=true, size=2입니다. 해시가 같은 후보를 equals로 구분한 결과입니다. 이 결과는 입력 두 개의 확인이지 모든 입력의 분포가 좋다는 측정은 아닙니다.


Java 문자열 해시의 중간 계산

문자열 abc는 ((0 * 31 + 'a') * 31 + 'b') * 31 + 'c'로 누적됩니다. charAt으로 읽는 값은 UTF-16 코드 단위이며, 이번 세 입력은 모두 ASCII 문자입니다.

문자 하나가 항상 char 하나인 것은 아닙니다. 예를 들어 "😀"는 UTF-16의 두 코드 단위로 표현되어 length()가 2이며 이 반복문도 두 번 누적합니다. 코드 포인트를 세는 반복으로 임의로 바꾸면 Java String.hashCode()와 다른 계산이 됩니다. 문자열의 인덱스와 해시 공식은 String 공식 문서를 기준으로 확인하세요.

곱셈과 덧셈은 int 범위를 넘을 수 있으며 Java 오버플로를 포함한 결과가 공식의 일부입니다. 이 곱셈·덧셈은 오버플로 때 예외를 던지지 않고 하위 32비트를 남기므로 음수 해시도 나옵니다. 이 장의 학습용 해시 테이블은 그 음수 hashCode도 정상 입력으로 받아 floorMod로 배열 범위에 넣습니다. JLS 정수 곱셈 규칙

src/StringHashWalkthrough.java
public final class StringHashWalkthrough {
    public static void main(String[] args) {
        trace("abc", 8);
        trace("ab", 8);
        trace("ba", 8);
        System.out.println("jdk-match=" + (polynomial("abc") == "abc".hashCode()));
    }

    private static void trace(String value, int bucketCount) {
        int hash = 0;
        for (int index = 0; index < value.length(); index++) {
            char character = value.charAt(index);
            hash = 31 * hash + character;
            System.out.println(
                    value + "[" + index + "] char=" + (int) character + ", hash=" + hash);
        }
        int bucketIndex = Math.floorMod(hash, bucketCount);
        System.out.println(value + " -> hash=" + hash + ", bucket=" + bucketIndex);
    }

    private static int polynomial(String value) {
        int hash = 0;
        for (int index = 0; index < value.length(); index++) {
            hash = 31 * hash + value.charAt(index);
        }
        return hash;
    }
}

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc/ab/ba를 각각 0에서 문자별 hash로 누산하고 floorMod합니다. 독립 세 계산의 중간값을 비교하되 모두 JDK 구현과 대조했다고 만들지 않습니다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다.

문자열 해시 · 31을 곱하는 누적식

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다.

문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스
문자열문자 처리 직후의 누적 계산최종 hash8개 그룹의 index
"abc"
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
963542
"ab"
0×31 + 97 = 97
97×31 + 98 = 3105
31051
"ba"
0×31 + 98 = 98
98×31 + 97 = 3135
31357
"abc"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
최종 hash
96354
8개 그룹의 index
2
"ab"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
최종 hash
3105
8개 그룹의 index
1
"ba"
문자 처리 직후의 누적 계산
0×31 + 98 = 98
98×31 + 97 = 3135
최종 hash
3135
8개 그룹의 index
7

인덱스는 최종 해시에 floorMod(hash, 8)을 적용한 값입니다. 앞의 문자 합 예제에서는 ab와 ba가 모두 195이지만, 여기서는 각각 3105와 3135입니다. 마지막 jdk-match=true는 abc만 비교합니다.

이 예의 조건

  • 세 문자열은 별도 누산이며 jdk-match는 abc만 비교한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
원 도표의 설명과 주석
구분내용
설명세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
전체 읽기 정보같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다. 문자열 해시 · 31을 곱하는 누적식 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다. 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자열 문자 처리 직후의 누적 계산 최종 hash 8개 그룹의 index "abc" 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 96354 2 "ab" 0×31 + 97 = 97 97×31 + 98 = 3105 3105 1 "ba" 0×31 + 98 = 98 98×31 + 97 = 3135 3135 7 "abc" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 최종 hash 96354 8개 그룹의 index 2 "ab" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 최종 hash 3105 8개 그룹의 index 1 "ba" 문자 처리 직후의 누적 계산 0×31 + 98 = 98 98×31 + 97 = 3135 최종 hash 3135 8개 그룹의 index 7 인덱스는 최종 해시에 floorMod(hash, 8) 을 적용한 값입니다. 앞의 문자 합 예제에서는 ab 와 ba 가 모두 195 이지만, 여기서는 각각 3105 와 3135 입니다. 마지막 jdk-match=true 는 abc 만 비교합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 문자

초깃값 0에 각 첫 문자를 적용합니다.

  • abc/ab: 97
  • ba: 98
  • 식: hash×31+char
  • 순서가 다른 첫 문자가 다른 누산 상태를 만든다.
다음 문자

b 또는 a를 적용합니다.

  • abc/ab: 3105
  • ba: 3135
  • 이전 누산값의 31배가 문자 순서 차이를 보존한다.
마지막/인덱스

abc만 c를 더하고 각 hash를 8로 분류합니다.

  • abc: 96354 → index2
  • ab: 3105 → index1
  • ba: 3135 → index7
  • 같은 문자가 있어도 순서와 길이에 따라 최종 탐색 위치가 달라진다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc/ab/ba를 각각 0에서 문자별 hash로 누산하고 floorMod합니다. 독립 세 계산의 중간값을 비교하되 모두 JDK 구현과 대조했다고 만들지 않습니다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다.

문자열 해시 · 31을 곱하는 누적식

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다.

문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스
문자열문자 처리 직후의 누적 계산최종 hash8개 그룹의 index
"abc"
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
963542
"ab"
0×31 + 97 = 97
97×31 + 98 = 3105
31051
"ba"
0×31 + 98 = 98
98×31 + 97 = 3135
31357
"abc"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
최종 hash
96354
8개 그룹의 index
2
"ab"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
최종 hash
3105
8개 그룹의 index
1
"ba"
문자 처리 직후의 누적 계산
0×31 + 98 = 98
98×31 + 97 = 3135
최종 hash
3135
8개 그룹의 index
7

인덱스는 최종 해시에 floorMod(hash, 8)을 적용한 값입니다. 앞의 문자 합 예제에서는 ab와 ba가 모두 195이지만, 여기서는 각각 3105와 3135입니다. 마지막 jdk-match=true는 abc만 비교합니다.

이 예의 조건

  • 세 문자열은 별도 누산이며 jdk-match는 abc만 비교한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
원 도표의 설명과 주석
구분내용
설명세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
전체 읽기 정보같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다. 문자열 해시 · 31을 곱하는 누적식 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다. 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자열 문자 처리 직후의 누적 계산 최종 hash 8개 그룹의 index "abc" 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 96354 2 "ab" 0×31 + 97 = 97 97×31 + 98 = 3105 3105 1 "ba" 0×31 + 98 = 98 98×31 + 97 = 3135 3135 7 "abc" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 최종 hash 96354 8개 그룹의 index 2 "ab" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 최종 hash 3105 8개 그룹의 index 1 "ba" 문자 처리 직후의 누적 계산 0×31 + 98 = 98 98×31 + 97 = 3135 최종 hash 3135 8개 그룹의 index 7 인덱스는 최종 해시에 floorMod(hash, 8) 을 적용한 값입니다. 앞의 문자 합 예제에서는 ab 와 ba 가 모두 195 이지만, 여기서는 각각 3105 와 3135 입니다. 마지막 jdk-match=true 는 abc 만 비교합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 문자

초깃값 0에 각 첫 문자를 적용합니다.

  • abc/ab: 97
  • ba: 98
  • 식: hash×31+char
  • 순서가 다른 첫 문자가 다른 누산 상태를 만든다.
다음 문자

b 또는 a를 적용합니다.

  • abc/ab: 3105
  • ba: 3135
  • 이전 누산값의 31배가 문자 순서 차이를 보존한다.
마지막/인덱스

abc만 c를 더하고 각 hash를 8로 분류합니다.

  • abc: 96354 → index2
  • ab: 3105 → index1
  • ba: 3135 → index7
  • 같은 문자가 있어도 순서와 길이에 따라 최종 탐색 위치가 달라진다.

31은 홀수 소수이고 31 * value를 JVM이 shift와 subtraction으로 최적화할 여지도 있습니다.

숫자 31 자체를 외우는 것보다 이전 상태를 곱해 위치 정보를 남긴다는 구조가 핵심입니다.

마지막 jdk-match는 abc의 직접 계산 결과와 Java String.hashCode() 결과를 비교합니다.

위 세 입력은 양수 해시이므로 음수 인덱스 처리는 다음 입력으로 따로 확인합니다.

src/NegativeStringHash.java
public final class NegativeStringHash {
    public static void main(String[] args) {
        int hash = "hash-index".hashCode();
        System.out.println("hash=" + hash);
        System.out.println("remainder=" + (hash % 8));
        System.out.println("bucket=" + Math.floorMod(hash, 8));
    }
}

출력은 hash=-432506029, remainder=-5, bucket=3입니다. 해시 자체는 음수여도 유효하고, 배열에 접근하는 인덱스만 0 이상 8 미만이어야 합니다. long으로 누적한 수학적 결과를 그대로 쓰거나 오버플로마다 예외를 던지면 이 int 해시 규칙과 달라집니다.


hashCode·index·equals의 역할

hashCode는 객체를 int로 요약합니다.

index는 그룹 수에 맞춰 그 int의 범위를 줄입니다.

equals는 같은 그룹 후보 중 논리적으로 같은 값을 판단합니다.

index가 같다는 이유로 equals를 생략하면 충돌한 문자열을 같은 원소로 오판합니다.

같은 문자열의 hashCode가 호출할 때마다 바뀌면 add와 포함 여부가 서로 다른 그룹을 봅니다.

hashCode 계산에 현재 시간이나 난수를 넣어서는 안 됩니다.

문자열은 불변이므로 저장 뒤 코드가 바뀌지 않는 안전한 키입니다.

빈 문자열의 누적 결과는 0입니다.

null은 String 인스턴스가 아니므로 메서드 호출 전에 별도 정책이 필요합니다.

표준 HashSet은 null 하나를 허용하지만 직접 구현은 금지할 수도 있으며, 어느 쪽이든 API 사용 규칙에 명시해야 합니다.


게시글 저장소 태그는 정규화한 뒤 집합에 포함

Java, java, JAVA를 같은 태그로 볼지 결정하는 일은 해시 함수가 아니라 도메인 정책입니다.

CLI는 앞뒤 공백을 제거하고 소문자로 바꾼 뒤 HashSet에 저장합니다.

정규화 전 원문을 키로 쓰면 hashCode가 올바르게 달라도 논리 중복이 남습니다.

app/NormalizedTagCli.java
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

public final class NormalizedTagCli {
    public static void main(String[] args) {
        Set<String> tags = new HashSet<>();
        for (String raw : new String[] {"Java", " hash ", "JAVA", "Hash"}) {
            String normalized = normalize(raw);
            System.out.println(raw + " -> " + normalized + ":" + tags.add(normalized));
        }
        System.out.println("tags=" + tags.stream().sorted().toList());
    }

    private static String normalize(String raw) {
        if (raw == null) throw new IllegalArgumentException("tag");
        String value = raw.strip().toLowerCase(Locale.ROOT);
        if (value.isEmpty()) throw new IllegalArgumentException("blank tag");
        return value;
    }
}

정규화한 태그를 추가할 때의 실제 결과

네 입력을 정규화하여 JAVA/Hash는 이미 존재하는 java/hash로 중복 거절됩니다. 입력별 정규화와 size 변화의 실제 순서입니다.

정규화한 태그를 추가할 때의 실제 결과

네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다.

입력 정규화 · 중복 키

정규화한 태그를 추가할 때의 실제 결과

공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다.

정규화된 두 키에 모이는 네 호출원입력Java/공백hash/JAVA/Hash를strip과Locale.ROOT소문자로정규화하면java/hash/java/hash입니다. add결과true/true/false/false와size1/2/2/2이며실선은원처리순서입니다. sorted는표현순서입니다.
입력 Javastrip·소문자화 → javaadd=true / size=1
연결 관계입력 Java · strip·소문자화 → java · add=true / size=1 → 입력 hash (공백) · strip·소문자화 → hash · add=true / size=2
입력 hash (공백)strip·소문자화 → hashadd=true / size=2
연결 관계입력 hash (공백) · strip·소문자화 → hash · add=true / size=2 → 입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2
입력 JAVAstrip·소문자화 → java기존 값 / false / size=2
연결 관계입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2 → 입력 Hash · strip·소문자화 → hash · 기존 값 / false / size=2
입력 Hashstrip·소문자화 → hash기존 값 / false / size=2

실선: 원 네 입력의 처리 순서소문자는 Locale.ROOT 사용최종 sorted 출력: hash, java

정규화한 태그를 추가할 때의 실제 결과
원문 입력정규화된 키add 반환값호출 뒤 size
"Java""java"true1
" hash ""hash"true2
"JAVA""java"false2
"Hash""hash"false2
"Java"
정규화된 키
"java"
add 반환값
true
호출 뒤 size
1
" hash "
정규화된 키
"hash"
add 반환값
true
호출 뒤 size
2
"JAVA"
정규화된 키
"java"
add 반환값
false
호출 뒤 size
2
"Hash"
정규화된 키
"hash"
add 반환값
false
호출 뒤 size
2

입력·정규화된 키·add 결과는 실제 출력에 있고, 각 행의 size는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java]는 sorted()의 결과이며 HashSet의 반복 순서를 뜻하지 않습니다.

이 예의 조건

  • 소문자화는 Locale.ROOT이며 sorted는 표현 순서이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
5add=true
6size=1
9add=true
10size=2
13기존 값
15size=2
18기존 값
20size=2
원 도표의 설명과 주석
구분내용
설명원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
전체 읽기 정보정규화한 태그를 추가할 때의 실제 결과 정규화한 태그를 추가할 때의 실제 결과 네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다. 입력 정규화 · 중복 키 정규화한 태그를 추가할 때의 실제 결과 공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다. 정규화한 태그를 추가할 때의 실제 결과 원문 입력 정규화된 키 add 반환값 호출 뒤 size "Java" "java" true 1 " hash " "hash" true 2 "JAVA" "java" false 2 "Hash" "hash" false 2 "Java" 정규화된 키 "java" add 반환값 true 호출 뒤 size 1 " hash " 정규화된 키 "hash" add 반환값 true 호출 뒤 size 2 "JAVA" 정규화된 키 "java" add 반환값 false 호출 뒤 size 2 "Hash" 정규화된 키 "hash" add 반환값 false 호출 뒤 size 2 입력·정규화된 키· add 결과는 실제 출력에 있고, 각 행의 size 는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java] 는 sorted() 의 결과이며 HashSet 의 반복 순서를 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 정규화

Java를 java로 만들어 등록합니다.

  • add: true
  • 집합: java
  • size: 1
  • 원시 문자열 대신 정규화한 키로 식별한다.
다른 키

공백 hash를 strip하여 추가합니다.

  • add: true
  • 집합: java,hash
  • size: 2
  • 새 정규화 값이므로 개수가 증가한다.
두 중복

JAVA/Hash는 기존 키가 되어 거절됩니다.

  • 결과: false,false
  • size: 2 유지
  • sorted: hash,java
  • 대소문자 차이가 식별 차이로 남지 않는다.

정규화한 태그를 추가할 때의 실제 결과

네 입력을 정규화하여 JAVA/Hash는 이미 존재하는 java/hash로 중복 거절됩니다. 입력별 정규화와 size 변화의 실제 순서입니다.

정규화한 태그를 추가할 때의 실제 결과

네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다.

입력 정규화 · 중복 키

정규화한 태그를 추가할 때의 실제 결과

공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다.

정규화된 두 키에 모이는 네 호출원입력Java/공백hash/JAVA/Hash를strip과Locale.ROOT소문자로정규화하면java/hash/java/hash입니다. add결과true/true/false/false와size1/2/2/2이며실선은원처리순서입니다. sorted는표현순서입니다.
입력 Javastrip·소문자화 → javaadd=true / size=1
연결 관계입력 Java · strip·소문자화 → java · add=true / size=1 → 입력 hash (공백) · strip·소문자화 → hash · add=true / size=2
입력 hash (공백)strip·소문자화 → hashadd=true / size=2
연결 관계입력 hash (공백) · strip·소문자화 → hash · add=true / size=2 → 입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2
입력 JAVAstrip·소문자화 → java기존 값 / false / size=2
연결 관계입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2 → 입력 Hash · strip·소문자화 → hash · 기존 값 / false / size=2
입력 Hashstrip·소문자화 → hash기존 값 / false / size=2

실선: 원 네 입력의 처리 순서소문자는 Locale.ROOT 사용최종 sorted 출력: hash, java

정규화한 태그를 추가할 때의 실제 결과
원문 입력정규화된 키add 반환값호출 뒤 size
"Java""java"true1
" hash ""hash"true2
"JAVA""java"false2
"Hash""hash"false2
"Java"
정규화된 키
"java"
add 반환값
true
호출 뒤 size
1
" hash "
정규화된 키
"hash"
add 반환값
true
호출 뒤 size
2
"JAVA"
정규화된 키
"java"
add 반환값
false
호출 뒤 size
2
"Hash"
정규화된 키
"hash"
add 반환값
false
호출 뒤 size
2

입력·정규화된 키·add 결과는 실제 출력에 있고, 각 행의 size는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java]는 sorted()의 결과이며 HashSet의 반복 순서를 뜻하지 않습니다.

이 예의 조건

  • 소문자화는 Locale.ROOT이며 sorted는 표현 순서이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
5add=true
6size=1
9add=true
10size=2
13기존 값
15size=2
18기존 값
20size=2
원 도표의 설명과 주석
구분내용
설명원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
전체 읽기 정보정규화한 태그를 추가할 때의 실제 결과 정규화한 태그를 추가할 때의 실제 결과 네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다. 입력 정규화 · 중복 키 정규화한 태그를 추가할 때의 실제 결과 공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다. 정규화한 태그를 추가할 때의 실제 결과 원문 입력 정규화된 키 add 반환값 호출 뒤 size "Java" "java" true 1 " hash " "hash" true 2 "JAVA" "java" false 2 "Hash" "hash" false 2 "Java" 정규화된 키 "java" add 반환값 true 호출 뒤 size 1 " hash " 정규화된 키 "hash" add 반환값 true 호출 뒤 size 2 "JAVA" 정규화된 키 "java" add 반환값 false 호출 뒤 size 2 "Hash" 정규화된 키 "hash" add 반환값 false 호출 뒤 size 2 입력·정규화된 키· add 결과는 실제 출력에 있고, 각 행의 size 는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java] 는 sorted() 의 결과이며 HashSet 의 반복 순서를 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 정규화

Java를 java로 만들어 등록합니다.

  • add: true
  • 집합: java
  • size: 1
  • 원시 문자열 대신 정규화한 키로 식별한다.
다른 키

공백 hash를 strip하여 추가합니다.

  • add: true
  • 집합: java,hash
  • size: 2
  • 새 정규화 값이므로 개수가 증가한다.
두 중복

JAVA/Hash는 기존 키가 되어 거절됩니다.

  • 결과: false,false
  • size: 2 유지
  • sorted: hash,java
  • 대소문자 차이가 식별 차이로 남지 않는다.

Locale.ROOT는 실행 환경의 언어 설정에 따라 소문자 결과가 달라지지 않게 합니다.

예를 들어 기본 언어가 터키어일 때 인자 없는 "TITLE".toLowerCase()는 영문 i 대신 점 없는 ı를 만들 수 있습니다. 언어와 무관한 태그 키라는 정책에 맞춰 toLowerCase(Locale.ROOT)를 명시한 이유입니다. 다만 소문자화와 앞뒤 공백 제거가 유니코드의 모든 표기 차이를 통합한다는 뜻은 아닙니다. String.toLowerCase 공식 문서

HashSet에는 정규화된 값만 들어가므로 equals와 hashCode가 도메인 동등성에 맞게 작동합니다.

출력 정렬은 표현을 안정시키기 위한 별도 단계이며 HashSet 순서 규칙이 아닙니다.


연습 문제

각 문자 코드와 처리 직후 해시를 출력하고 마지막 값을 Java String.hashCode()와 비교하세요.

이 연습의 입력은 문자열 cat입니다. 세 문자 c, a, t를 이 순서로 처리하고 초기 누적값은 0으로 두세요. 해설을 열기 전에 세 중간 해시와 마지막 인덱스를 적습니다.

그룹 16개일 때 index도 구합니다.

정답과 해설

c=99, a=97, t=116을 차례로 사용합니다.

이전 결과를 31배 하므로 같은 문자 집합을 다른 순서로 배치하면 보통 다른 값이 됩니다.

exercise/StringHashTraceSolution.java
public final class StringHashTraceSolution {
    public static void main(String[] args) {
        String value = "cat";
        int hash = 0;
        for (int i = 0; i < value.length(); i++) {
            int code = value.charAt(i);
            hash = 31 * hash + code;
            System.out.println(i + ": code=" + code + ", hash=" + hash);
        }
        System.out.println("same=" + (hash == value.hashCode()));
        System.out.println("bucket=" + Math.floorMod(hash, 16));
    }
}

직접 계산과 JDK 결과가 같아야 합니다.

중간값은 0 × 31 + 99 = 99, 99 × 31 + 97 = 3166, 3166 × 31 + 116 = 98262입니다. 마지막으로 98262 = 6141 × 16 + 6이므로 bucket=6, 비교 결과는 same=true입니다. 문자 처리 중간값과 마지막 그룹 인덱스를 각각 검산하세요.

그룹 수가 달라지면 마지막 index를 다시 계산해야 하지만, 결과가 항상 달라지는 것은 아닙니다. 문자열 hashCode는 그룹 수와 무관합니다.

이 구분이 rehash 때 모든 키의 index를 다시 계산하는 이유입니다.

문자열 해시 절을 마쳤다면 순서 누적이 필요한 이유, 음수 hashCode가 유효한 이유, 충돌 뒤 equals가 필요한 이유를 별개의 문장으로 설명할 수 있어야 합니다.