Hash Map을 이용한 Java 개수 세기 문제 풀이
1. 개요
개수 세기 문제(counting problem)는 어디나 나타납니다: 문자 빈도 세기, 가장 자주 나타나는 원소 찾기, 조건을 만족하는 쌍의 개수 세기, 같은 원소들을 그룹화하기. HashMap은 조회와 업데이트 모두 평균 O(1)이기 때문에 이 유형의 문제에 거의 “필수적인” 도구입니다.
이 글을 읽고 나면 HashMap(그리고 Map.merge, getOrDefault)을 사용해 4가지 일반적인 개수 세기 문제를 푸는 방법, 시간/공간 복잡도를 이해하고, 개수 세기 문제에서 HashMap을 사용할 때 흔히 마주치는 몇 가지 오류를 피할 수 있게 될 것입니다.
2. HashMap이 개수 세기에 적합한 이유
개수 세기 문제의 일반적인 아이디어: 각 원소마다 “지금까지 몇 번 나왔는지” 또는 “나왔는지 여부”를 알아야 합니다. HashMap<K, V>는 다음을 제공합니다:
- 평균 O(1)의 키 조회 — 정렬이 필요 없습니다.
- 값 업데이트(카운터 증가)를 평균 O(1)에 처리합니다.
containsKey나 카운트 > 0으로 “본 것” 상태를 저장합니다.
정수 범위가 작고 미리 알 수 있을 때만 사용할 수 있는 카운팅 배열(counting array)과 달리, HashMap은 더 범용적입니다: 키는 String, Integer, 임의의 객체가 될 수 있습니다.
3. 원소의 빈도 세기
3.1. getOrDefault 사용
이것이 리스트의 각 원소 빈도를 세는 전형적인 방법입니다.
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class FrequencyCounter {
public static Map<String, Integer> countFrequency(List<String> words) {
Map<String, Integer> freq = new HashMap<>();
for (String word : words) {
freq.put(word, freq.getOrDefault(word, 0) + 1);
}
return freq;
}
}
freq.getOrDefault(word, 0)은 word가 이미 존재하면 현재 값을 반환하고, 없으면 0을 반환합니다 — if (map.containsKey(...))를 쓸 필요가 없습니다. 리스트 ["cam", "xoai", "cam", "cam", "xoai"]에 대해 결과는:
{cam=3, xoai=2}
3.2. Map.merge 사용(더 짧은 코드)
Java 8부터 Map.merge는 확인과 업데이트를 하나의 호출로 결합합니다.
public static Map<String, Integer> countFrequencyMerge(List<String> words) {
Map<String, Integer> freq = new HashMap<>();
words.forEach(word -> freq.merge(word, 1, Integer::sum));
return freq;
}
freq.merge(word, 1, Integer::sum)의 의미는: word가 없으면 1을 설정하고, 있으면 기존 값에 1을 더합니다(함수 Integer::sum을 통해). 이 방식은 약간 더 빠르고 단순한 누적 로직에서는 더 자연스럽게 읽힙니다.
3.3. JUnit 5 + AssertJ로 테스트
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class FrequencyCounterTest {
@Test
void shouldCountFrequencyCorrectly() {
Map<String, Integer> result = FrequencyCounter.countFrequency(
List.of("cam", "xoai", "cam", "cam", "xoai"));
assertThat(result)
.containsEntry("cam", 3)
.containsEntry("xoai", 2);
}
}
주의:
getOrDefault는 키가 없을 때 맵을 수정하지 않습니다 — 읽기만 합니다.putIfAbsent와 헷갈리지 마세요. 이는 키가 없을 때 기본값을 설정하는 목적으로 사용됩니다.
4. 가장 자주 나타나는 원소 찾기
빈도 테이블을 만든 후 가장 자주 나타나는 원소를 찾는 것은 단지 entrySet()을 한 번 순회하는 것일 뿐입니다.
import java.util.Map;
import java.util.Optional;
public class MostFrequentFinder {
public static Optional<String> findMostFrequent(Map<String, Integer> freq) {
return freq.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey);
}
}
Map.Entry.comparingByValue()는 엔트리를 값(나타난 횟수) 기준으로 비교하고, .max(...)는 가장 큰 값을 가진 엔트리를 가져옵니다. 여러 원소가 동등하게 가장 자주 나타나면 그중 하나만 반환합니다 — HashMap이 삽입 순서를 유지하지 않기 때문에 순서는 보장되지 않습니다.
입력: {cam=3, xoai=2}
출력: Optional[cam]
주의: 동등하게 높은 순위의 여러 원소가 있을 때 결과가 안정적이어야 하면
HashMap대신LinkedHashMap을 사용하세요.
5. 조건을 만족하는 쌍 개수 세기(Two Sum 스타일)
일반적인 변형: arr[i] + arr[j] == target을 만족하는 쌍 (i, j)의 개수를 세기. O(n²) 완전탐색 대신 HashMap을 사용해 O(n)으로 줄입니다.
import java.util.HashMap;
import java.util.Map;
public class PairCounter {
public static int countPairsWithSum(int[] numbers, int target) {
Map<Integer, Integer> seen = new HashMap<>();
int count = 0;
for (int number : numbers) {
int complement = target - number;
count += seen.getOrDefault(complement, 0);
seen.merge(number, 1, Integer::sum);
}
return count;
}
}
각 줄 설명:
complement = target - number: number와 더해 target을 만들 값.count += seen.getOrDefault(complement, 0): complement가 지금까지 나타난 횟수를 더합니다 — 각각이 하나의 유효한 쌍을 만듭니다.seen.merge(number, 1, Integer::sum): 현재 number를 카운팅 테이블에 기록하여 이후 원소들이 이와 쌍을 이룰 수 있도록 합니다.
numbers = [1, 2, 3, 2, 4]이고 target = 5일 때: 쌍은 (1,4), (2,3), (3,2)입니다 — 하지만 우리는 나타난 순서에 따라만 세기 때문에 테스트로 확인하세요.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PairCounterTest {
@Test
void shouldCountPairsWithGivenSum() {
int[] numbers = {1, 2, 3, 2, 4};
int result = PairCounter.countPairsWithSum(numbers, 5);
assertThat(result).isEqualTo(3);
}
}
3개의 쌍은: 숫자 3을 만날 때 (2,3), 두 번째 숫자 2를 만날 때 (2,3)(이미 본 3과 쌍), 그리고 4를 만날 때 (1,4)입니다.
6. 같은 원소 그룹화(애너그램 그룹)
더 고급 개수 세기 문제: 서로 애너그램인 단어들(같은 문자 집합이지만 순서가 다른)을 그룹화한 후 그룹 수 또는 각 그룹의 원소 수를 세기.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class AnagramGrouper {
public static Map<String, List<String>> groupAnagrams(List<String> words) {
Map<String, List<String>> groups = new HashMap<>();
for (String word : words) {
char[] chars = word.toCharArray();
Arrays.sort(chars);
String signature = new String(chars);
groups.computeIfAbsent(signature, key -> new ArrayList<>())
.add(word);
}
return groups;
}
}
핵심은 signature입니다: 각 단어의 문자를 정렬하여 모든 애너그램의 “서명”을 만듭니다. computeIfAbsent(signature, key -> new ArrayList<>())는 시그니처에 해당하는 키가 없으면 새 리스트를 만들고, 현재 단어를 추가합니다.
입력 ["eat", "tea", "tan", "ate", "nat", "bat"]에 대해 결과:
{
"aet"=[eat, tea, ate],
"ant"=[tan, nat],
"abt"=[bat]
}
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AnagramGrouperTest {
@Test
void shouldGroupAnagramsTogether() {
Map<String, List<String>> groups = AnagramGrouper.groupAnagrams(
List.of("eat", "tea", "tan", "ate", "nat", "bat"));
assertThat(groups.values())
.anySatisfy(group -> assertThat(group).containsExactlyInAnyOrder("eat", "tea", "ate"));
}
}
computeIfAbsent는 수동 containsKey + put 확인보다 깔끔하며, 특히 값이 초기화가 필요한 컬렉션일 때 유용합니다(lazy init).
7. 시간 및 공간 복잡도
| 문제 | 시간 | 공간 |
|---|---|---|
| 빈도 세기 | O(n) | O(k) — k는 서로 다른 원소의 개수 |
| 최빈원소 찾기 | O(n) | O(k) |
| 합이 target인 쌍 세기 | O(n) | O(n) |
| 애너그램 그룹화 | O(n · m log m) — m은 가장 긴 단어의 길이 | O(n) |
먼저 정렬한 후 순회하는 해법과(O(n log n)) 비교하면, HashMap은 보통 시간 복잡도에서 더 빠르지만 추가 공간 O(n) 또는 O(k)가 필요합니다. 메모리가 제한적이고 키 범위가 작으면 카운팅 배열이 여전히 더 경제적인 선택입니다.
8. 흔한 실수
- 변경 가능한 객체를 키로 사용하면서
equals/hashCode를 오버라이드하지 않기: 자정의 클래스(예: Point)에 따라 세기를 할 때 이 두 메서드를 오버라이드하지 않으면 HashMap이 두 객체가 “같은”지를 같은 키로 인식하지 못합니다.
public record Point(int x, int y) {}
record를 사용하면 모든 필드를 기반으로 자동으로 equals/hashCode를 생성하여 이 오류를 완전히 피합니다.
-
반복 순서가 안정적이라고 기대하기:
HashMap은 순서를 보장하지 않습니다. 삽입 순서가 필요하면LinkedHashMap을 사용하고, 키의 오름차순이 필요하면TreeMap을 사용하세요. -
entrySet()을 순회하는 중에 맵 수정하기:
ConcurrentModificationException을 발생시킵니다. 순회 중에 항목을 제거해야 하면Iterator.remove()또는removeIf를 사용하세요. -
큰 루프에서 Integer 박싱을 신중하지 않게 사용하기:
getOrDefault++ 1을 할 때마다 자동 박싱으로 새로운 Integer 객체가 만들어집니다. 매우 큰 데이터의 경우(백만 개의 원소),Map<String, int[]>또는 원시 맵 라이브러리(예: Eclipse Collections) 사용을 고려하여 오버헤드를 줄이세요.
주의: 키 범위가 a-z 26자만이거나 작은 고정 정수 범위이면 배열(
int[26])이 항상 더 빠르고 더 적은 메모리를 사용합니다. HashMap은 키 범위가 클 때, 미리 알 수 없을 때, 또는 연속적이지 않을 때만 사용하세요.
9. 결론
HashMap은 개수 세기 문제 — 빈도 세기, 최빈원소 찾기, 쌍 개수 세기, 같은 원소 그룹화 — 을 O(1) 조회 덕분에 단순한 O(n) 루프로 변환합니다. getOrDefault, merge, computeIfAbsent 메서드는 수동 containsKey 확인에 비해 코드를 더 간단하고 명확하게 만듭니다. 기억하세요: HashMap은 순서를 유지하지 않으며, 키 객체에 올바른 equals/hashCode가 필요하고, 키 범위가 작으면 카운팅 배열이 여전히 더 가벼운 선택입니다.