컬렉션 정렬

컬렉션 정렬 (Ordering)

컬렉션 타입에서 요소의 순서는 중요한 측면이에요. 예를 들어 같은 요소를 가진 두 리스트라도 요소가 다르게 정렬되어 있으면 서로 같지 않아요.

Kotlin에서 객체의 순서는 여러 방식으로 정의될 수 있어요. 먼저 자연 순서(natural order)가 있어요. 이는 Comparable 인터페이스의 구현을 위해 정의돼요. 자연 순서는 다른 순서가 지정되지 않았을 때 정렬에 사용돼요.

대부분의 내장 타입은 비교가 가능해요(comparable).

  • 숫자 타입은 전통적인 수치 순서를 사용해요: 10보다 크고, -3.4f-5f보다 크죠.
  • CharString사전식 순서를 사용해요: ba보다 크고, worldhello보다 크죠.

사용자 정의 타입에 자연 순서를 정의하려면 그 타입을 Comparable의 구현자로 만들어요. 이는 compareTo() 함수를 구현해야 함을 요구해요. compareTo()는 같은 타입의 다른 객체를 인자로 받고, 어떤 객체가 더 큰지 보여주는 정수 값을 반환해야 해요.

  • 양수 값은 리시버 객체가 더 크다는 뜻이에요.
  • 음수 값은 인자보다 작다는 뜻이에요.
  • 0은 객체가 서로 같다는 뜻이에요.

다음은 major와 minor 부분으로 이루어진 버전을 정렬하는 클래스예요.

출처: Kotlin 공식 문서

본문

class Version(val major: Int, val minor: Int): Comparable<Version> {
    override fun compareTo(other: Version): Int = when {
        this.major != other.major -> this.major compareTo other.major // 중위 형태의 compareTo() 
        this.minor != other.minor -> this.minor compareTo other.minor
        else -> 0
    }
}

fun main() {    
    println(Version(1, 2) > Version(1, 3))
    println(Version(2, 0) > Version(1, 5))
}

사용자 정의 순서(custom order)는 어떤 타입의 인스턴스든 원하는 방식으로 정렬할 수 있게 해 줘요. 특히 비교 불가능한 객체에 순서를 정의하거나, 비교 가능한 타입에 대해 자연 순서가 아닌 다른 순서를 정의할 수 있어요. 타입에 사용자 정의 순서를 정의하려면 그 타입에 대한 Comparator를 만들어요. Comparatorcompare() 함수를 담고 있는데, 이 함수는 클래스의 두 인스턴스를 받아 두 인스턴스 사이의 비교 결과를 정수로 반환해요. 그 결과는 위에서 설명한 compareTo()의 결과와 같은 방식으로 해석돼요.

fun main() {
//sampleStart
    val lengthComparator = Comparator { str1: String, str2: String -> str1.length - str2.length }
    println(listOf("aaa", "bb", "c").sortedWith(lengthComparator))
//sampleEnd
}

lengthComparator가 있으면 기본 사전식 순서 대신 문자열 길이로 문자열을 정렬할 수 있어요.

Comparator를 정의하는 더 짧은 방법은 표준 라이브러리의 compareBy() 함수를 쓰는 거예요. compareBy()는 인스턴스에서 Comparable 값을 만들어 내는 람다를 받고, 만들어진 값들의 자연 순서를 사용자 정의 순서로 정의해요.

compareBy()를 쓰면 위 예시의 길이 비교자는 이렇게 생겨요.

fun main() {
//sampleStart    
    println(listOf("aaa", "bb", "c").sortedWith(compareBy { it.length }))
//sampleEnd
}

또한 여러 기준에 기반한 순서를 정의할 수도 있어요. 예를 들어 문자열을 길이로 정렬하고 길이가 같을 때는 알파벳 순으로 정렬하려면 이렇게 쓸 수 있어요.

fun main() {
//sampleStart
    val sortedStrings = listOf("aaa", "bb", "c", "b", "a", "aa", "ccc")
        .sortedWith { a, b -> 
           when (val compareLengths = a.length.compareTo(b.length)) {
             0 -> a.compareTo(b)
             else -> compareLengths
           }
         }

    println(sortedStrings)
    // [a, b, c, aa, bb, aaa, ccc]
//sampleEnd
}

여러 기준으로 정렬하는 것은 흔한 시나리오이기 때문에, Kotlin 표준 라이브러리는 보조 정렬 규칙을 추가하는 데 사용할 수 있는 .thenBy() 함수를 제공해요.

예를 들어 compareBy().thenBy()와 결합하면 이전 예시처럼 문자열을 먼저 길이로, 그다음 알파벳 순으로 정렬할 수 있어요.

fun main() {
//sampleStart
    val sortedStrings = listOf("aaa", "bb", "c", "b", "a", "aa", "ccc")
        .sortedWith(compareBy<String> { it.length }.thenBy { it })

    println(sortedStrings)
    // [a, b, c, aa, bb, aaa, ccc]
//sampleEnd
}

Kotlin 컬렉션 패키지는 컬렉션을 자연 순서, 사용자 정의 순서, 심지어 무작위 순서로 정렬하는 함수를 제공해요. 이 페이지에서는 읽기 전용 컬렉션에 적용되는 정렬 함수를 설명할게요. 이 함수들은 원래 컬렉션의 요소를 요청된 순서로 담은 새 컬렉션을 결과로 반환해요. 변경 가능한 컬렉션을 제자리에서 정렬하는 함수를 배우려면 List 특화 연산을 참고하세요.

자연 순서

기본 함수 .sorted().sortedDescending()는 컬렉션의 요소를 자연 순서에 따라 오름차순과 내림차순으로 정렬한 값을 반환해요. 이 함수들은 Comparable 요소의 컬렉션에 적용돼요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")

    println("Sorted ascending: ${numbers.sorted()}")
    println("Sorted descending: ${numbers.sortedDescending()}")
//sampleEnd
}

사용자 정의 순서

사용자 정의 순서로 정렬하거나 비교 불가능한 객체를 정렬하려면 .sortedBy().sortedByDescending() 함수가 있어요. 이 함수들은 컬렉션 요소를 Comparable 값으로 매핑하는 선택자 함수를 받고, 그 값들의 자연 순서로 컬렉션을 정렬해요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")

    val sortedNumbers = numbers.sortedBy { it.length }
    println("Sorted by length ascending: $sortedNumbers")
    val sortedByLast = numbers.sortedByDescending { it.last() }
    println("Sorted by the last letter descending: $sortedByLast")
//sampleEnd
}

컬렉션 정렬을 위한 사용자 정의 순서를 정의하려면 자신만의 Comparator를 제공할 수 있어요. 이렇게 하려면 .sortedWith() 확장 함수를 호출하면서 Comparator를 전달해요. 이 함수를 쓰면 문자열을 길이로 정렬하는 코드는 이렇게 보여요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")
    println("Sorted by length ascending: ${numbers.sortedWith(compareBy { it.length })}")
//sampleEnd
}

정렬된 순서 확인

요소가 이미 지정된 순서를 따르는지 확인하려면 다음 확장 함수를 사용할 수 있어요.

  • .isSorted()
  • .isSortedDescending()
  • .isSortedWith(comparator)
  • .isSortedBy(selector)
  • .isSortedByDescending(selector)

이 확장 함수들은 요소가 지정된 순서에 있거나 두 개 미만의 요소가 있으면 true를 반환해요. 순서가 틀린 쌍을 찾는 즉시 false를 반환하고 확인을 멈춰요.

HashSet처럼 반복 순서가 보장되지 않는 컬렉션에서는 호출할 때마다 결과가 달라질 수 있어요. 일관된 순서로 요소를 만들지 않는 시퀀스에도 마찬가지예요. 호출할 때마다 같은 결과를 얻으려면 List처럼 반복 순서가 보장되는 컬렉션에서만 이 함수들을 사용해요.

DoubleFloat 값을 확인할 때 이 함수들은 NaN을 다른 어떤 값보다 큰 것으로, -0.00.0보다 작은 것으로 취급해요. 추가로 .isSortedBy().isSortedByDescending() 함수는 null 선택자 결과를 non-null 값보다 작은 것으로 취급해요.

이 함수들을 시퀀스에서 호출하면 그 연산은 종단 연산이에요. 다른 시퀀스를 반환하는 대신, 그 시퀀스를 소비해 Boolean 값을 만들어요.

이 정렬 순서 함수들은 배열, 프리미티브 배열, 부호 없는 배열에도 사용할 수 있어요. 부호 없는 배열과 그에 대한 연산은 Experimental이며 @ExperimentalUnsignedTypes 애노테이션으로 opt-in이 필요해요.

.isSorted().isSortedBy() 함수로 정렬 순서를 확인하는 예시를 볼게요.

data class User(val name: String, val age: Int)

fun main() {
//sampleStart
    val numbers = listOf(1, 2, 3, 4)
    println(numbers.isSorted())
    // true

    val users = listOf(
        User("Alice", 24),
        User("Bob", 31),
        User("Charlie", 29),
    )
    println(users.isSortedBy(User::age))
    // false

    val descending = listOf(4, 3, 2, 1)
    println(descending.isSortedDescending())
    // true
   
//sampleEnd
}

역순

.reversed() 함수를 사용해 컬렉션을 뒤집은 순서로 얻을 수 있어요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")
    println(numbers.reversed())
//sampleEnd
}

.reversed() 확장 함수는 요소의 복사본을 담은 새 컬렉션을 반환해요. 그래서 원본 컬렉션을 나중에 변경해도, 이전에 얻은 .reversed()의 결과에는 영향을 주지 않아요.

또 다른 뒤집기 함수 .asReversed()는 같은 컬렉션 인스턴스의 뒤집힌 뷰를 반환해요. 그래서 원본 리스트가 변경되지 않을 것이라면 .reversed()보다 더 가볍고 선호될 수 있어요.

fun main() {
//sampleStart
    val numbers = listOf("one", "two", "three", "four")
    val reversedNumbers = numbers.asReversed()
    println(reversedNumbers)
//sampleEnd
}

원본 리스트가 변경 가능하다면, 그 모든 변경이 뒤집힌 뷰에 반영되고 그 반대도 마찬가지예요.

fun main() {
//sampleStart
    val numbers = mutableListOf("one", "two", "three", "four")
    val reversedNumbers = numbers.asReversed()
    println(reversedNumbers)
    numbers.add("five")
    println(reversedNumbers)
//sampleEnd
}

하지만 리스트가 변경 가능한지 알 수 없거나 소스가 리스트가 아니라면, .reversed()가 더 선호돼요. 그 결과는 미래에 변경되지 않을 복사본이기 때문이에요.

무작위 순서

마지막으로 컬렉션 요소를 무작위 순서로 담은 새 List를 반환하는 함수가 있어요 — .shuffled(). 인자 없이 호출하거나 Random 객체와 함께 호출할 수 있어요.

fun main() {
//sampleStart
     val numbers = listOf("one", "two", "three", "four")
     println(numbers.shuffled())
//sampleEnd
}

더 알아보기 (Learn more)