[JAVA] HashMap 정렬하기

세하·2025년 5월 3일

JAVA

목록 보기
12/17

HashMap 문법에 대한 정리는 아래 포스트를 참조
https://velog.io/@seha01130/HashMap이란-메소드-종류-정리

기본적으로 HashMap은 순서를 보장하지 않기때문에 정렬이 필요하다면 별도의 조치가 필요하다.
아래처럼 <String, Integer> 해시맵이라고 가정하고 진행

Map<String, Integer> map = new HashMap<>();

map.put("banana", 3);
map.put("apple", 5);
map.put("cherry", 1);

ArrayList 형태로 변경

List<Map.Entry<String, Integer>> list = new ArrayList<>(map.entrySet());

객체 정렬

key/value 기준에 따라 .getKey() ↔ .getValue()만 바꾸면 됨

⬆️ 오름차순 정렬

  1. 내장 Comparator 사용
    • 대소문자 구분
      list.sort(Comparator.comparing(Map.Entry::getKey));
    • 대소문자 무시
      list.sort(Comparator.comparing(Map.Entry::getKey, String.CASE_INSENSITIVE_ORDER));
  2. Map.Entry 전용 메서드 (해당 Entry의 Key 또는 Value가 Comparable을 구현하고 있을 경우 자동으로 해당 기준으로 정렬 수행. String, Integer, Double, Long, LocalDate, BigDecimal 등은 다 Comparable 구현체)
    list.sort(Map.Entry.comparingByKey());
    list.sort(Map.Entry.comparingByValue());
  1. 🌟 람다식 직접 작성
    • 대소문자 구분
    	list.sort((a, b) -> a.getKey().compareTo(b.getKey()));
    • 대소문자 무시
    	list.sort((a, b) -> a.getKey().compareToIgnoreCase(b.getKey()));
    • 정렬 방향은 a, b 위치만 바꾸면 됨

for (Map.Entry<String, Integer> entry : list) {
	System.out.println(entry.getKey() + " = " + entry.getValue());
}
-----출력결과-----
apple = 5
banana = 3
cherry = 1

⬇️ 내림차순 정렬

list.sort(Map.Entry.comparingByKey(Comparator.reverseOrder()));
list.sort(Map.Entry.comparingByValue(Comparator.reverseOrder()));

또는

🌟 list.sort((a, b) -> b.getKey().compareTo(a.getKey()));
🌟 list.sort((a, b) -> b.getKey().compareToIgnoreCase(a.getKey()));

숫자 정렬

💡 b - a 방식은 int 오버플로우 위험이 있어 Comparator.comparingInt() 또는 Integer.compare() 사용이 안전하다.

b - a 방식 :

// 내림차순 (큰 값 먼저)
list.sort((a, b) -> b.getValue() - a.getValue());
// 오름차순 (작은 값 먼저)
list.sort((a, b) -> a.getValue() - b.getValue());

⬆️ 오름차순 정렬

list.sort(Comparator.comparingInt((Map.Entry<String, Integer> e) -> e.getValue()));
  1. 🌟
list.sort((a, b) -> Integer.compare(a.getValue(), b.getValue()));

⬇️ 내림차순 정렬

list.sort(Comparator.comparingInt((Map.Entry<String, Integer> e) -> e.getValue()).reversed());
  1. 🌟
list.sort((a, b) -> Integer.compare(b.getValue(), a.getValue()));

근데 사실 현재 예시에서는 getValue()가 Comparable을 구현한 Integer 객체를 반환하기 때문에, 그 객체의 인스턴스 메서드인 .compareTo()를 사용해도 되긴 한다.

만약 정렬된 순서를 유지한 채로 다시 맵으로 저장하고 싶다면?

Map<String, Integer> sortedMap = new LinkedHashMap<>();

for (Map.Entry<String, Integer> entry : list) {
    sortedMap.put(entry.getKey(), entry.getValue());
}

✔ LinkedHashMap은 삽입 순서 유지
✔ 정렬된 순서로 put() 하면 그 정렬 순서가 유지된다.
이렇게 하면 sortedMap은 정렬된 순서를 유지하는 맵이 된다.

여러 개의 조건을 순서대로 적용해서 정렬하기

방법2 를 사용하는걸 추천한다.
방법1은 그냥 방법2을 설명하기 위한 부가요소정도의 느낌으로 작성했다.

방법 1. Comparator chaining

여러 개의 조건을 순서대로 적용해서 정렬할 때는 List로 변환한 뒤, Comparator를 연쇄적으로 연결(chaining) 하는 방식이 있다.

그러나 primitive 타입, 객체 타입을 신경써야하고 reversed()의 함정 등 신경쓸게 많다. 아래 내용에서 다 소개하고있다. 그럴떄는 2번째 방법인 if문을 사용하여 명시적으로 람다식을 써주는 방식을 사용하자.

  1. HashMap을 List로 변환한다. 정렬은 리스트 구조에서 수행
  2. list.sort() 메서드에 Comparator를 전달하여 정렬 규칙을 정해준다.
  3. Comparator.comparing()thenComparing() 을 사용하여 여러 조건의 규칙을 순서대로 연결한다.

예시
1순위: value 기준 숫자 내림차순
2순위: key 기준 문자열 오름차순
즉, value 기준 내림차순 후 값이 같을 경우 key 알파벳 순서대로 정렬

list.sort(Comparator
	.comparingInt((Map.Entry<String, Integer> e) -> e.getValue()).reversed() // 1순위: value 내림차순
    .thenComparing(Map.Entry::getKey) // 2순위: key 오름차순
);

❓ 왜 처음 비교 조건인.comparingInt()는 제너릭 타입을 명시해야하는데 두 번째 비교조건인 .thenComparing는 명시하지 않아도 되나?

Comparator.comparingInt(Map.Entry::getValue) // ❌ 오류
.comparingInt()은 int 값을 리턴하는 함수를 요구함. (ToIntFunction<T>)
그런데 Map.Entry::getValue는 Integer 객체를 리턴한다. (int 아님!)
Java는 Integer → int로 바꿔야 하는데, 메서드 참조에서는 타입이 명확하지 않으면 이걸 못 함.
그래서 "타입을 추론할 수 없다"고 오류가 난다.

따라서

Comparator.comparingInt((Map.Entry<String, Integer> e) -> e.getValue())
이렇게 명시해줘야함!
이건 Java에게 "나는 Map.Entry<String, Integer> 타입의 e에서 int 값 하나 꺼낼 거야!"라고 명확하게 알려주는 것.
그럼 Java가 "이건 int로 쓰이는구나!" 라고 제대로 인식할 수 있다.

❓ 그럼 왜 그 이후 .thenComparing() 에서는 제너릭 타입을 명시 안하는데?

Comparator.comparingInt(...)가 실행된 결과는 Comparator<Map.Entry<String, Integer>> 라는 타입이 명확한 객체(인스턴스) 이다.
thenComparing은 바로 이 '타입이 확정된 객체'에 대해 호출되는 인스턴스 메서드이다. 따라서 컴파일러는 당연히 다음에 올 메서드 참조(Map.Entry::getKey)도 Map.Entry에서 가져와야 한다는 것을 아주 쉽게 추론할 수 있으므로 즉, 제너릭 타입이 확정됐기 때문에 따로 명시할 필요가 없다.
* Map.Entry::getKey를 넣었을 때 getKey()가 String을 리턴하는 메서드인 걸 자동으로 추론함

❓ 그럼 comparingInt 대신 객체를 다루는 comparing() 을 사용하면 어떨까?

comparing()은 Integer 같은 객체를 직접 다룰 수 있으므로, 컴파일러가 타입을 추론하는 데 아무런 문제가 없다.
즉, 메서드 참조를 그대로 사용할 수 있다!

list.sort(Comparator
    // 1. comparing()으로 Integer 객체를 직접 비교
    .comparing(Map.Entry::getValue, Comparator.reverseOrder()) // value 내림차순
    .thenComparing(Map.Entry::getKey)                     // key 오름차순
);
  • Comparator.comparing(Map.Entry::getValue): getValue()가 반환하는 Integer 객체를 기준으로 오름차순 정렬하는 Comparator를 만든다.
  • Comparator.reverseOrder(): 위에서 만든 오름차순을 뒤집어 내림차순으로 만든다.

comparingInt와 람다 조합 대신 comparing과 메서드 참조 조합을 사용하는 것이 코드가 더 간결하고 직관적이게 된다.

예시

예시를 보면 더 잘 이해가 될 것이다.
https://www.acmicpc.net/problem/20920
위의 백준 문제를 보면
1. 자주 나오는 단어일수록 앞에 배치한다.
2. 해당 단어의 길이가 길수록 앞에 배치한다.
3. 알파벳 사전 순으로 앞에 있는 단어일수록 앞에 배치한다

이렇게 3가지의 조건을 순서대로 우선순위를 적용하여 정렬해야한다.
단어와 빈도수를 HashMap hm에다가 넣어준 상태라고 가정하자.
그럼 위의 정렬을 위해 아래처럼 코드를 짤 수 있다.

Comparator 체이닝

  • HashMap의 키(단어)들을 List로 변환. 정렬은 리스트 구조에서 수행해야 함
  • list.sort() 메서드에 Comparator를 전달하여 정렬 규칙을 정해준다
  • Comparator.comparing()thenComparing() 을 사용하여 3가지 규칙을 순서대로 연결
List<String> words = new ArrayList<>(hm.keySet());

words.sort(Comparator
        // 조건 1: 빈도수 내림차순
        .comparingInt((String word) -> hm.get(word))
        // 조건 2: 단어 길이 내림차순
        .thenComparingInt(String::length).reversed()
        // 조건 3: 알파벳 오름차순
        .thenComparing(word -> word)
);

코드 설명

comparingInt, thenComparingInt, thenComparing의 인수는 "이걸 기준으로 비교해 줘!" 하고 비교할 대상을 꺼내오는 방법(함수) 을 넣어주는 것
각 메서드에 "어떤 키를 뽑아낼지"에 대한 방법(함수)을 인수로 전달하면, Java가 알아서 그 키들을 순서대로 비교해 전체 리스트를 정렬해 준다~

  • .comparingInt((String word) -> hm.get(word))
    • 1순위 정렬 기준을 설정
    • hm.get(word)를 통해 각 단어의 빈도수(int)를 가져와 기준으로 삼는다.
    • .reversed()를 붙이지 않은 이유: reversed() 메서드는 바로 앞의 comparing에만 적용되는 것이 아니라, 지금까지 만들어진 전체 비교 체인(chain)을 통째로 뒤집는다. 따라서 조건2를 끝내고 한 번에 reversed()를 써주는 야매 방식을 사용한 것이다.
  • .thenComparingInt(String::length)
    • 2순위 정렬 기준을 설정 -> 1순위 기준(빈도수)이 같을 경우에만 사용됨
    • String::length 메서드 참조를 통해 각 단어의 길이(int)를 기준으로 삼음
    • 이 때 .reversed()를 붙여 조건 1과 2를 모두 내림차순으로 만듦
  • .thenComparing(word -> word)
    • 3순위 정렬 기준을 설정 -> 1, 2순위 기준이 모두 같을 경우에만 사용됨
    • word -> word는 단어 자체(String)를 기준으로 삼으라는 의미
    • String은 기본적으로 알파벳 오름차순으로 비교되므로, 원하는 결과가 나오게된다.

🌟 방법 2. if문 활용

하나의 sort 안에서 if문으로 우선순위 가르기

여러 조건을 처리하는 가장 기본적인 방법인 if문을 사용하여 명시적인 람다식을 작성하는 것이다.
이 방식은 위의 Comparator chaining에서 신경써야하는 primitive 타입과 객체 타입의 차이, .reversed()의 함정 등 복잡한 규칙을 신경 쓸 필요 없이 가장 직관적으로 코드를 작성할 수 있게 해준다.

이 또한 https://www.acmicpc.net/problem/20920 백준 문제를 예시로 들어 설명하겠다.

위의 백준 문제를 보면
1. 자주 나오는 단어일수록 앞에 배치한다.
2. 해당 단어의 길이가 길수록 앞에 배치한다.
3. 알파벳 사전 순으로 앞에 있는 단어일수록 앞에 배치한다

이렇게 3가지의 조건을 순서대로 우선순위를 적용하여 정렬해야한다.
단어와 빈도수를 HashMap hm에다가 넣어준 상태라고 가정하자.
그럼 위의 정렬을 위해 아래처럼 코드를 짤 수 있다.

ArrayList<Map.Entry<String, Integer>> list = new ArrayList<>(hm.entrySet());

list.sort((a,b) -> {
	// 빈도수가 다르면 빈도수 내림차순
    if (!a.getValue().equals(b.getValue())){
           return b.getValue().compareTo(a.getValue());
    }

	// 빈도수가 같으면 길이가 긴 순서대로 내림차순
    if (a.getKey().length() != b.getKey().length()){
           return Integer.compare(b.getKey().length(), a.getKey().length());
    }

    // 앞의 두 조건이 모두 같으면 알파벳 사전 순 (오름차순)
    return a.getKey().compareTo(b.getKey());
});

코드 설명

❓ 비교 결과를 즉시 return하고 정렬 로직을 종료?
두 단어 사이의 한 번의 비교가 종료되는 것.
sort 메서드는 리스트에 있는 모든 단어들을 토너먼트처럼 계속해서 비교한다. 우리가 만든 람다식은 그 토너먼트의 심판 역할을 한다.

  1. sort 메서드는 리스트에서 두 단어를 뽑아서 누가 앞에 와야 하는지 알려달라고 심판에게 보낸다.
  2. 심판 역할의 람다식은 두 단어를 받아서, 우리가 정한 규칙(빈도수 > 길이 > 사전순)에 따라 승부를 가린다.
  3. return : 판정이 난 것
    만약 빈도수에서 승부가 갈렸다면 바로 판정(return) 을 내리고 심판의 역할을 끝낸다. 더 이상 길이나 사전순은 볼 필요가 없음.
    빈도수가 같아서 길이로 승부를 봤다면 길이 비교 결과를 판정(return) 하고 역할을 끝낸다.

sort는 람다식(심판)의 판정 결과를 받아서 두 단어의 순서를 정리한 뒤, 다시 리스트에서 다른 두 단어를 뽑아 람다식(심판)에게 또 보낸다. 이 과정을 리스트 전체가 정렬될 때까지 계속 반복한다.

0개의 댓글