JavaRush /Courses /Kotlin SELF /Sorting by key and multiple criteria in Kotlin

Sorting by key and multiple criteria in Kotlin

Kotlin SELF
Level 23, Lesson 2
Available

1. Introduction

When you sort a list of numbers, everything is simple: the number itself is the value we compare by. But real life quickly hands you more complex data: “category–amount”, “name–points”, “product–price”, “purchase–cost–comment”. The elements no longer “ask” to be compared directly, and we need to decide: what exactly we sort by.

This “what” is called the sort key: we take an element, compute a comparable value (a key) from it, and sort by that key. Kotlin provides very convenient tools for this: sortedBy and sortedByDescending.

Imagine you have a box of parcels, and each parcel has a sticker that says “weight”. You can sort the parcels (the list elements), but it’s more convenient to compare by the sticker (the key). If the sticker is weight, we sort by weight; if the sticker is city, we sort by city.

2. Sorting by key with sortedBy and sortedByDescending

Let’s start with the most common scenario: we have a list whose elements are not necessarily “comparable on their own”, but from each element we can extract something comparable (a number, a string, a string length). The Kotlin operation sortedBy { selector } takes a key selector function and returns a new list sorted in ascending order by the key.

Ascending sort: sortedBy {...}

First, a mini example outside our project, just to see the mechanics. We sort strings by length (the key is it.length):

fun main() {
    val words = listOf("bbb", "a", "cc")
    val sorted = words.sortedBy { it.length }

    println(sorted) // [a, cc, bbb]
}

Now let’s bring this into a practical application. As a reminder, the data model of our current console expense tracker (so far without classes) is: an expense is Triple(category, amount, note). Category and note are strings, amount is an Int.

Sorting all expenses by amount (ascending) looks like this:

fun main() {
    val expenses = listOf(
        Triple("food", 450, "coffee"),
        Triple("taxi", 1200, "airport"),
        Triple("food", 150, "snack")
    )

    val sortedByAmount = expenses.sortedBy { it.second }
    println(sortedByAmount)
    // [(food, 150, snack), (food, 450, coffee), (taxi, 1200, airport)]
}

Here it’s important to “read” the code correctly: we are sorting the Triple elements themselves, but comparing them by the key it.second, i.e., by the amount.

Descending sort: sortedByDescending {...}

A slightly more “practical” variant is sorting in descending order: in reports we almost always want to see the biggest first. For that, there is sortedByDescending { selector }. It’s the same sort-key principle, just with the reverse order.

For example, we want to show the most expensive purchases at the top:

fun main() {
    val expenses = listOf(
        Triple("food", 450, "coffee"),
        Triple("taxi", 1200, "airport"),
        Triple("books", 700, "kotlin book")
    )

    val sorted = expenses.sortedByDescending { it.second }
    println(sorted)
    // [(taxi, 1200, airport), (books, 700, kotlin book), (food, 450, coffee)]
}

And this naturally leads to the next step: “can we take not the whole list, but only the first N?”. Yes—and that will be our classic top‑N pattern.

3. Top‑N: sortedByDescending {...}.take(n) and why take without sorting is not top

When requirements say “show the top‑3 categories”, they almost always mean: sort by the metric and take the first three. Kotlin lets you express this as a single chain: sortedByDescending(...).take(n).

It’s important not to mix this up: take(n) does not find the best elements; it simply takes the first n elements in the current order. So taking “top‑N” without sorting is literally “take the first N in whatever order you currently have”.

First, a simple numeric example:

fun main() {
    val scores = listOf(10, 70, 50, 20, 90)

    val top3 = scores.sortedDescending().take(3)
    println(top3) // [90, 70, 50]
}

Now let’s make the top‑2 most expensive expenses in our Triple format:

fun main() {
    val expenses = listOf(
        Triple("food", 450, "coffee"),
        Triple("taxi", 1200, "airport"),
        Triple("books", 700, "kotlin book"),
        Triple("food", 150, "snack")
    )

    val top2 = expenses
        .sortedByDescending { it.second }
        .take(2)

    println(top2)
    // [(taxi, 1200, airport), (books, 700, kotlin book)]
}

Notice a nice detail: if there are fewer elements than n, take(n) simply returns “as many as there are”, without crashes or drama.

4. Multiple criteria: sortedWith and compareBy

One of the most common “everyday” tasks is sorting by multiple rules. For example, we want to sort a list of pairs (category, total) so that categories are ordered by name first (for nice output), and if the name is the same, then the second key is amount or age.

Sorting by multiple criteria is like sorting documents in a folder: first by “Last name”, within equal last names by “First name”, and within equal first names by “Date”. One criterion is no longer enough.

In Kotlin, for cases where “you need to define an explicit comparison rule” there is sortedWith(comparator), and to avoid writing a comparator by hand there is the comparator factory compareBy(...). The standard approach looks like this: sortedWith(compareBy {...}).

sortedWith(comparator): sort by an explicit rule

sortedWith(...) takes a Comparator<T> object and returns a new sorted list. This is useful when:

  • the element is not Comparable,
  • you have multiple criteria,
  • you have tricky comparison logic (for example, “VIP first, then by amount”).

First, a simpler example: sort strings by length via a comparator:

fun main() {
    val words = listOf("aaa", "bb", "c")
    val sorted = words.sortedWith(compareBy { it.length })

    println(sorted) // [c, bb, aaa]
}

Technically, this is similar to sortedBy { it.length }, but the idea is different: we explicitly say “here is the comparison rule”. This is especially convenient when there are multiple criteria.

compareBy(...): a comparator by one key

compareBy is a way to say: “build me a comparator that compares elements by these keys”. We are not diving into how the comparator works internally; we use it as a ready-made tool.

For example, sort our expenses (Triple) by category (alphabetically):

fun main() {
    val expenses = listOf(
        Triple("taxi", 1200, "airport"),
        Triple("food", 450, "coffee"),
        Triple("books", 700, "kotlin book")
    )

    val sorted = expenses.sortedWith(compareBy { it.first })
    println(sorted)
    // [(books, 700, kotlin book), (food, 450, coffee), (taxi, 1200, airport)]
}

compareBy({ ... }, { ... }): a comparator by multiple keys

compareBy can take multiple selectors, and then sorting works like this: first by the first key, if equal—by the second key, and so on.

Let’s do an example on pairs (category, total) and sort first by category name, then by amount (ascending):

fun main() {
    val totals = listOf(
        "food" to 200,
        "taxi" to 250,
        "food" to 150
    )

    val sorted = totals.sortedWith(
        compareBy<Pair<String, Int>>({ it.first }, { it.second })
    )

    println(sorted) // [(food, 150), (food, 200), (taxi, 250)]
}

Here I specified the type Pair<String, Int> explicitly to make it easier for the compiler (and for the reader). If you’re not yet confident about type inference, don’t hesitate to write types: it’s not a “weakness”, it’s insurance against mysterious errors that at 11:00 PM feel like mysticism.

5. Mini integration: a top‑N categories report by amount

Now let’s assemble a mini scenario that closely resembles a real feature in an expense tracker: we have a Map<String, Int> with totals by category, and we want to print the top‑N categories by amount.

We are not using groupBy (that comes later), so assume that totalsByCategory has already been computed earlier by our code (for example, in the handler for the total command). After that, it’s pure sorting mechanics.

Pipeline outline:

Map<String, Int>
    → entries
    → List<Pair<String, Int>>
    → sortedByDescending { sum }
    → take(n)
    → print

Code (we’ll split it into small steps so it reads like a human wrote it):

fun main() {
    val totalsByCategory = mapOf(
        "food" to 1200,
        "taxi" to 2500,
        "books" to 900
    )

    val topN = 2

    val pairs = totalsByCategory.entries.map { it.key to it.value }
    val top = pairs.sortedByDescending { it.second }.take(topN)

    println(top) // [(taxi, 2500), (food, 1200)]
}

Two things are especially important here.

First, we don’t sort the Map directly, but its elements converted into a list of pairs. This is normal practice: sorting is an operation on a sequence (a list), while a Map is primarily about “lookup by key”.

Second, top‑N must be done strictly in this order: sort first, then take the first n. If you swap the steps, you’ll get “the first N according to the Map’s internal order”, which is almost never top (except for random coincidences that later break trust in the program).

6. How to choose a tool and read sortedBy

At some point students start asking: “Which is better—sortedBy or sortedWith(compareBy(...))?”. It’s a fair question: both options often look similar.

Below is a small table to reinforce the choice:

Task What to write Why
One simple key, ascending
sortedBy { ... }
The shortest and most readable option.
One simple key, descending
sortedByDescending { ... }
The same principle, just “biggest first”.
Multiple criteria
sortedWith(compareBy({ ... }, { ... }))
You can clearly see the set of criteria and the order they are applied.
Non-standard comparison rule
sortedWith { a, b -> ... }
When you need to describe the comparison logic manually.

And a small “human rule”: if you have one key, use sortedBy. If you have multiple criteria, most of the time sortedWith(compareBy(...)) reads better and scales more calmly.

Before we wrap up, let’s explicitly state the common confusion. In sortedBy {...}, inside the lambda it is the list element, and the lambda result is the sort key.

An example where people often get confused (because Pair is already short):

fun main() {
    val totals = listOf("food" to 1200, "taxi" to 2500)

    val sorted = totals.sortedBy { it.second }
    println(sorted) // [(food, 1200), (taxi, 2500)]
}

it is a pair (String, Int). it.second is an Int, the sort key. We sort pairs, but compare numbers.

If you catch yourself thinking “why am I sorting an Int if I have a list of pairs?”—congratulations: you’ve just noticed the sort key, which means your brain is doing it right.

7. Common mistakes

Mistake #1: using take(n) without sorting and calling it “top‑N”.
take(n) is not “pick the best”; it’s “cut off the first n”. If the original order is insertion order, you’ll get “the first added”, not “the largest”. The fix is almost always the same: sort by the metric first, then take.

Mistake #2: sorting by the wrong key and mixing up first/second/third.
When data is stored in Pair/Triple, it’s very easy to sort by the wrong field and then stare at the result for a few minutes feeling like the program is “messing with you”. A good habit is to name the meaning of fields in your head before sorting (“first=category, second=amount”), and sometimes temporarily print a couple of elements to verify.

Mistake #3: trying to sort a Map “as is”, and then being surprised.
A Map is not a list; its main purpose is key-based access, not order. For sorting, you almost always need to do entries.map { ... }, get a list, and then sort that list. This isn’t a hack; it’s a normal container transformation for the task.

Mistake #4: trying to cram multiple criteria into one “monstrous” lambda.
When there are two or three criteria, beginners sometimes write a huge sortedWith { a, b -> ... }, where comparisons are mixed, parentheses live their own life, and the whole thing looks like an incantation. In most cases it’s more readable and safer to use sortedWith(compareBy({ ... }, { ... })), where you can clearly see the order in which keys are applied.

Mistake #5: “losing” the result of sorting.
Sorts like sortedBy and sortedWith return a new list and do not modify the original. If you write expenses.sortedBy { it.second } and then print expenses, nothing “magical” happened. The fix is simple: store the result in a val or continue the chain immediately (for example, sortedByDescending { ... }.take(3)), as we did above.

1
Task
Kotlin SELF, level 23, lesson 2
Locked
Words by length
Words by length
1
Task
Kotlin SELF, level 23, lesson 2
Locked
Top numbers
Top numbers
1
Task
Kotlin SELF, level 23, lesson 2
Locked
Player leaderboard
Player leaderboard
1
Task
Kotlin SELF, level 23, lesson 2
Locked
Sorting expenses
Sorting expenses
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION