해시 테이블 연습: 정규화 문자열 빈도와 등장 순서

··설명 약 6분·작성: 해비·블로그 소개

읽기 전에 알아두면 좋아요

아래 개념이 익숙하면 본문을 바로 읽으세요. 낯선 개념만 링크에서 복습하면 됩니다.

개념과 예제는 실행하지 않아도 읽을 수 있습니다. 내 컴퓨터·터미널에서 직접 실행하는 과정은 선택 실습입니다.

과정 위치와 선택 복습 안내

과정 위치: 자료구조 개념과 문제 · 문제

과정 위치는 이 글이 속한 묶음입니다. 이 단계의 모든 개념을 먼저 읽을 필요는 없습니다. 위에 연결한 개념 중 낯선 내용만 복습하세요. 문제의 조건·상태·갱신 순서는 익숙한 언어로 설명하면 됩니다.

JavaScript로 문법·실행 복습하기 (선택)

개념 글의 코드는 JavaScript 예시입니다. 문제 화면에서는 JavaScript·Python·Java·C++·C# 중 익숙한 언어의 함수를 작성하세요. 본문의 Node.js 입출력 예시는 로컬 실행을 위한 별도 안내입니다.

문자열 빈도·등장 순서 문제에서는 어떤 정보를 관리해 조회 결과를 반환하나요?

입력 문자열의 빈도와 등장 순서를 관리해 조회 결과를 반환하는 문제입니다. 문자열·정규화·해시 테이블의 기본을 준비하고, 아래 입력·반환 계약을 확인해 직접 풀어보세요.

적용 조건·범위: Q는 입력 단어 수, U는 서로 다른 NFC 키 수, Lmax는 정규화 후 최대 키 길이입니다. 문자열 처리와 비교를 길이에 비례한다고 보는 모델 및 고른 해시 분포 아래 기대 상환 시간은 O(Q·Lmax), 출력 조회는 O(U·Lmax)로 포함됩니다. 키 길이가 제한된 이 문제에서는 보통 기대 O(Q)로 표현할 수 있지만 문자열 길이와 분포 가정을 생략한 최악 보장은 아닙니다.

본문 근거: Princeton Algorithms — Hash Tables · MDN — String.prototype.normalize()

독립 창작 문제

이 문제의 상황·입출력 계약·예시는 이 글을 위해 직접 구성했습니다. 공식 문제를 번역하거나 복제한 지문이 아닙니다. 구현 개념은 JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화에서 먼저 확인할 수 있습니다.

문자열 목록 words를 앞에서부터 읽습니다. 각 문자열을 NFC로 정규화한 결과가 같으면 같은 단어로 간주합니다. 서로 다른 정규화 단어마다 [정규화된 단어,등장 횟수]를 출력하되 그 단어가 처음 등장한 순서를 지키세요. 대소문자나 공백을 제거하지 않습니다.

예를 들어 e와 결합 악센트로 표현한 문자열은 é와 합쳐지지만 E는 별개입니다. 빈 문자열도 하나의 단어이고 이모지도 일반 키입니다. 출력 순서는 알파벳순도 해시 버킷 번호순도 아닙니다. 이 규칙 때문에 해시 테이블 하나만 순회해 출력하면 요구를 만족하지 못할 수 있습니다.

입력·출력과 제약

표준 입력은 JSON 객체 {words}이며 words는 문자열 배열입니다. 단어 수 Q는 0~20000, 각 문자열은 최대 100개 Unicode 코드 포인트, 입력 문자열의 코드 포인트 수 합은 최대 200000입니다. 입력은 잘 구성된 Unicode 문자열이며 문자열 이외의 값은 없습니다. 출력은 각 정규화 키와 양의 정수 빈도를 담은 JSON 배열입니다.

정답은 고정 버킷 테이블이 아니라 적재율이 높아지면 리사이즈하는 문자열 해시 테이블을 사용합니다. 내장 Map은 독립 테스트의 기준으로 사용할 수 있지만 아래 구현 학습에서는 직접 만든 테이블의 충돌 처리와 재배치를 확인합니다.

입력 예시

{"words":["é","é","🙂","","🙂","E"]}

출력 예시

[["é",2],["🙂",2],["",1],["E",1]]

실행 방법

Node.js CommonJS 환경에서 정답의 전체 코드를 hash-table-problem.cjs로 저장합니다. 입력 예시를 UTF-8 input.json 파일로 저장한 뒤 Windows PowerShell에서는 아래 명령을 실행하세요. 내장 node:fs 외에 다른 파일이나 패키지가 필요하지 않습니다.

Get-Content -Raw -Encoding UTF8 ./input.json | node ./hash-table-problem.cjs

macOS·Linux의 Bash에서는 아래 입력 리다이렉션을 사용할 수 있습니다. 이 < 문법은 PowerShell 명령이 아닙니다.

node ./hash-table-problem.cjs < input.json

힌트

처음 본 정규화 키일 때만 order 배열에 추가하고, 빈도는 테이블에 갱신하세요. get의 found를 확인하면 값이 0이거나 undefined인 일반 사전 API에서도 존재 여부와 값의 참·거짓을 혼동하지 않을 수 있습니다.

직접 풀어보기

먼저 선택한 언어의 입력·반환 계약에 맞춰 작성해 보세요. 힌트 받기와 정답과 비교하기는 코드를 실행하지 않는 AI 분석입니다. 해답·해설은 아래에서 필요할 때 펼칠 수 있습니다.

0 / 6,000바이트

원문 JSON 객체를 파싱한 객체 input을 solve(input)에 전달하고 배열을 반환합니다. 표준 입출력은 포함하지 않습니다. Tab은 다음 항목으로 이동합니다. 입력 영역 오른쪽 아래를 끌어 높이를 조절할 수 있습니다.

비밀키·개인정보는 입력하지 마세요. 코드는 이 페이지에만 유지됩니다.

해답·해설 보기
JavaScript 해답
'use strict';
class StringHashTable {
    constructor(capacity = 4) {
        if (! Number.isInteger(capacity) || capacity < 1) {
            throw new RangeError('capacity must be a positive integer');
        }
        this.buckets = Array.from( {
            length : capacity
        },() => []);
        this.size = 0;
    }
    normalize(key) {
        if (typeof key !== 'string') throw new TypeError('key must be a string');
        return key.normalize('NFC');
    }
    index(key, capacity = this.buckets.length) {
        let hash = 2166136261;
        for (const character of key) {
            hash = Math.imul(hash ^ character.codePointAt(0), 16777619) >>> 0;
        }
        return hash % capacity;
    }
    resize(capacity) {
        const next = Array.from( {
            length : capacity
        },() => []);
        for (const bucket of this.buckets) {
            for (const entry of bucket) {
                next[this.index(entry.key, capacity)].push(entry);
            }
        }
        this.buckets = next;
    }
    set(rawKey, value) {
        const key = this.normalize(rawKey);
        let bucket = this.buckets[this.index(key)];
        for (const entry of bucket) {
            if (entry.key === key) {
                entry.value = value;
                return this;
            }
        }
        if ((this.size + 1) / this.buckets.length > 0.75) {
            this.resize(this.buckets.length * 2);
            bucket = this.buckets[this.index(key)];
        }
        bucket.push( {
            key, value
        });
        this.size += 1;
        return this;
    }
    get(rawKey) {
        const key = this.normalize(rawKey);
        for (const entry of this.buckets[this.index(key)]) {
            if (entry.key === key) return {
                found : true, value : entry.value
            };
        }
        return {
            found : false
        };
    }
    delete(rawKey) {
        const key = this.normalize(rawKey);
        const bucket = this.buckets[this.index(key)];
        const index = bucket.findIndex(entry => entry.key === key);
        if (index === - 1) return false;
        bucket.splice(index, 1);
        this.size -= 1;
        return true;
    }
    entries() {
        const result = [];
        for (const bucket of this.buckets) {
            for (const entry of bucket) result.push([entry.key, entry.value]);
        }
        return result;
    }
}
function solve( {
    words
}) {
    const table = new StringHashTable();
    const order = [];
    for (const word of words) {
        const key = table.normalize(word);
        const previous = table.get(key);
        if (! previous.found) order.push(key);
        table.set(key, previous.found ? previous.value + 1 : 1);
    }
    return order.map(key => [key, table.get(key).value]);
}

원문 JSON 객체를 파싱한 객체 input을 solve(input)에 전달하고 배열을 반환합니다. 표준 입출력은 포함하지 않습니다.

한 파일로 실행하는 전체 정답

'use strict';

class StringHashTable {
  constructor(capacity = 4) {
    if (!Number.isInteger(capacity) || capacity < 1) {
      throw new RangeError('capacity must be a positive integer');
    }
    this.buckets = Array.from({ length: capacity }, () => []);
    this.size = 0;
  }

  normalize(key) {
    if (typeof key !== 'string') throw new TypeError('key must be a string');
    return key.normalize('NFC');
  }

  index(key, capacity = this.buckets.length) {
    let hash = 2166136261;
    for (const character of key) {
      hash = Math.imul(hash ^ character.codePointAt(0), 16777619) >>> 0;
    }
    return hash % capacity;
  }

  resize(capacity) {
    const next = Array.from({ length: capacity }, () => []);
    for (const bucket of this.buckets) {
      for (const entry of bucket) {
        next[this.index(entry.key, capacity)].push(entry);
      }
    }
    this.buckets = next;
  }

  set(rawKey, value) {
    const key = this.normalize(rawKey);
    let bucket = this.buckets[this.index(key)];
    for (const entry of bucket) {
      if (entry.key === key) {
        entry.value = value;
        return this;
      }
    }
    if ((this.size + 1) / this.buckets.length > 0.75) {
      this.resize(this.buckets.length * 2);
      bucket = this.buckets[this.index(key)];
    }
    bucket.push({ key, value });
    this.size += 1;
    return this;
  }

  get(rawKey) {
    const key = this.normalize(rawKey);
    for (const entry of this.buckets[this.index(key)]) {
      if (entry.key === key) return { found: true, value: entry.value };
    }
    return { found: false };
  }

  delete(rawKey) {
    const key = this.normalize(rawKey);
    const bucket = this.buckets[this.index(key)];
    const index = bucket.findIndex(entry => entry.key === key);
    if (index === -1) return false;
    bucket.splice(index, 1);
    this.size -= 1;
    return true;
  }

  entries() {
    const result = [];
    for (const bucket of this.buckets) {
      for (const entry of bucket) result.push([entry.key, entry.value]);
    }
    return result;
  }
}

function solve({ words }) {
  const table = new StringHashTable();
  const order = [];
  for (const word of words) {
    const key = table.normalize(word);
    const previous = table.get(key);
    if (!previous.found) order.push(key);
    table.set(key, previous.found ? previous.value + 1 : 1);
  }
  return order.map(key => [key, table.get(key).value]);
}

module.exports = { solve };

if (require.main === module) {
  const fs = require('node:fs');
  const input = JSON.parse(fs.readFileSync(0, 'utf8').replace(/^\uFEFF/, ''));
  process.stdout.write(JSON.stringify(solve(input)) + '\n');
}

정답 해설: 개수와 순서를 다른 상태에 맡깁니다

table은 정규화 키→빈도 관계를 담당하고 order는 서로 다른 키의 최초 등장 순서를 담당합니다. 리사이즈로 버킷 위치가 달라져도 order 배열은 건드리지 않으므로 출력 순서가 보존됩니다. 테이블의 entries가 삽입 순서를 보장한다고 가정하지 않는 것이 핵심입니다.

단어를 하나 읽을 때 get 결과가 없으면 order에 추가하고 빈도 1을 저장합니다. 있으면 기존 값에 1을 더합니다. 매 단계 “지금까지 읽은 접두 목록의 빈도”를 유지하므로 마지막에는 전체 빈도가 됩니다. 같은 NFC 키를 table 내부에서 다시 정규화해도 결과는 같으며 이 중복 처리는 점근 비용을 바꾸지 않습니다.

원래 문자열 NFC 키 order 변경 빈도
e + 결합 악센트 é é 추가 é:1
é é 없음 é:2
🙂 🙂 🙂 추가 🙂:1
빈 문자열 빈 문자열 빈 키 추가 빈 키:1
🙂 🙂 없음 🙂:2
E E E 추가 E:1

최종 결과는 order를 순회하며 table.get으로 현재 빈도를 읽습니다. 버킷을 직접 출력하지 않으므로 강제 충돌이나 리사이즈 횟수와 관계없이 동일한 결과를 냅니다. 갱신은 size를 증가시키지 않고 새 키만 size를 증가시킨다는 클래스 불변식도 유지되어야 합니다.

복잡도와 해시 가정

풀이 직접 실행

본문 풀이에 예제 입력을 넣고, 입력을 바꿔 계산 결과를 비교합니다.

예제 크게 보기 (새 탭)

Q는 입력 단어 수, U는 서로 다른 NFC 키 수, Lmax는 정규화 후 최대 키 길이입니다. 문자열 처리와 비교를 길이에 비례한다고 보는 모델 및 고른 해시 분포 아래 기대 상환 시간은 O(Q·Lmax), 출력 조회는 O(U·Lmax)로 포함됩니다. 키 길이가 제한된 이 문제에서는 보통 기대 O(Q)로 표현할 수 있지만 문자열 길이와 분포 가정을 생략한 최악 보장은 아닙니다.

충돌이 집중되면 전체 처리 최악 O(Q·U·Lmax)가 될 수 있고, 단일 리사이즈는 저장된 키 전체를 재해시합니다. C를 입력 및 정규화 문자열 총 저장량으로 두면 입력·버킷·순서·출력을 포함한 공간은 O(C+Q+U)입니다. 이 클래스는 삭제 시 축소하지 않지만 이 문제는 증가만 하므로 버킷 수가 U에 비례합니다. 고정 개수 버킷에 무조건 O(1) 조회를 주장하지 않습니다.

직접 확인할 경계

words=[], 빈 문자열 반복, 조합형 한글과 완성형 한글, 대소문자 차이, 이모지, 리사이즈를 여러 번 일으키는 서로 다른 문자열을 확인하세요. 내장 Map에 NFC 키를 누적한 결과와 비교하면 빈도와 최초 등장 순서를 함께 검증할 수 있습니다.

함께 연습하고 확인할 자료

Design HashMap: 정수 키 사전을 구현하는 외부 연습입니다. 이 글의 NFC 문자열 키 정책과는 입력 계약이 다릅니다.

관련 개념 참고: Princeton Algorithms — Hash Tables, MDN — String.prototype.normalize() 공식 자료와 문제 페이지는 2026년 9월 10일 확인했습니다. 외부 문제의 지문·예시를 복제하지 않고 이 글의 예제와 창작 문제를 별도로 구성했습니다.

이 문제를 푼 뒤 확인하기

NFC로 합쳐지는 조합형·완성형 한글을 한 키로 세되 A와 a, a와 앞에 공백이 있는 a는 서로 다른 키로 유지하세요. 첫 등장 순서도 확인하세요.

본문의 예제와 이 반례를 직접 실행해 예상 출력과 비교하고, 결과가 다르면 입력 조건과 반복 범위를 다시 확인하세요.

글 소개·학습 준비·전체 목차
해시 테이블의 핵심 구조를 표현한 개념 표지
구조의 특징을 표현한 개념 이미지입니다. 정확한 동작은 아래 코드와 추적 표를 함께 확인하세요.

핵심 요약

NFC로 같은 문자열을 하나의 키로 묶고 빈도와 최초 등장 순서를 출력합니다. 해시 버킷의 순서와 문제에서 요구하는 순서를 분리하는 독립 창작 문제입니다.

이 글이 도움이 되었나요?

조회 중

이어서 공부하기

체크포인트: 해설을 가리고 다시 풀고, 경계 입력을 하나 바꾸어 예상 결과와 실제 결과를 비교하세요. 삽입·제거 전후의 상태를 그리고, 내장 도구를 쓰는 풀이와 직접 구현의 목적을 구분하세요.

다음 추천 · 자료구조 개념: 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기

같은 주제에서 다음 개념과 예제로 이해를 넓힙니다.

권장 순서는 학습 가이드와 같습니다. 다른 순서는 접힌 참고 목록에서 확인할 수 있습니다. 읽음 표시는 숙달이나 채점 결과가 아닙니다.

원래 과정 목록 순서 (선택)

읽음 기록

읽음 — / 전체 58개 · 필수 58개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기