Skip to content
Ameen Abozaid
PublishedBEG-032-L03

Multi-key sorting

  • Lesson 3 of 4
  • 10 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Back to the race. Three runners finish with the same time. The judges cannot leave them in random order, so they add a rule: "if the times tie, order by name". That is multi-key sorting: a first key decides, and a second key is consulted only when the first one is a tie.

In lesson 2 you built comparators from one field. A single key leaves ties in whatever order the data arrived, and in a TreeSet a tie means a lost element, as you saw in lesson 1. This lesson chains comparators with thenComparing, shows the one trap with reversed() in a chain, and explains how to make an ordering precise enough for a sorted set.

Why this matters: real listings always have ties. "Orders by status, then newest first", "products by category, then price, then name". A listing that reshuffles tied rows between requests looks buggy, and the SQL ORDER BY you will learn later takes the same list of keys.

This lesson builds on lessons 1 and 2. As before, Player::getName style method references are a preview of BEG-035.

Learning Objectives

By the end of this lesson you will be able to:

  • Chain keys with thenComparing and its primitive versions such as thenComparingInt.
  • Build "score descending, then name ascending" and read a multi-key comparator from left to right.
  • Explain where reversed() applies in a chain, and place it correctly.
  • Use comparing(keyExtractor, keyComparator) to choose how one key is compared.
  • Make an ordering precise enough that a TreeSet or TreeMap does not drop distinct elements.
  • Take the top N elements of a sorted list safely.

Written Explanation

Why ties need a second key

Sort these players by score, highest first:

text
Mona:90  Zed:90  Ali:70  Bob:90  Cy:70

With only the score key, Mona, Zed, and Bob tie at 90. List sorting is stable (lesson 2), so tied players keep the order they had before. That is predictable for one run but meaningless: change the input order and the output changes. Real output from my test:

text
[Mona:90, Zed:90, Bob:90, Ali:70, Cy:70]

Bob should come before Mona if the tie-break is "name, A to Z".

thenComparing

thenComparing is a method on any Comparator. It returns a new comparator that first uses the original one, and if the result is zero, asks the second:

java
Comparator<Player> byRank =
        Comparator.comparingInt(Player::getScore).reversed()
                  .thenComparing(Player::getName);

Read it left to right: score first, highest at the front; if two scores tie, name A to Z. You can chain as many thenComparing calls as there are keys.

The overloads you will use:

CallMeaning
thenComparing(Player::getName)Next key, compared by its natural order (the key must be Comparable)
thenComparingInt(Player::getAge)Next key is a primitive int; also thenComparingLong and thenComparingDouble
thenComparing(anotherComparator)Next step is a whole comparator
thenComparing(Player::getName, keyComparator)Next key, compared with a chosen comparator for that key

Where reversed() applies

reversed() flips the whole comparator it is called on, not just the last key. Compare:

java
// score descending, THEN name ascending:
Comparator.comparingInt(Player::getScore).reversed()
          .thenComparing(Player::getName);

// the whole chain reversed (score descending AND name descending):
Comparator.comparingInt(Player::getScore)
          .thenComparing(Player::getName).reversed();

In the first form, reversed() is called when the comparator contains only the score key, so only the score is flipped. In the second form it is called after thenComparing, so the combined order is flipped: score descending and name descending (Z to A). Real results on the five players above:

text
first form:   [Bob:90, Mona:90, Zed:90, Ali:70, Cy:70]
second form:  [Zed:90, Mona:90, Bob:90, Cy:70, Ali:70]

The rule: reversed() flips everything before it. To reverse just one later key, give that key its own comparator, for example thenComparing(Player::getName, Comparator.reverseOrder()).

Choosing how a single key is compared

Sometimes the natural order of a key is not what the screen needs: names that should ignore capitals, or text that should be compared by length first.

thenComparing(key, keyComparator) and comparing(key, keyComparator) take a comparator for the key itself. This is how you ask for a case-insensitive name tie-break:

java
.thenComparing(Player::getName, String.CASE_INSENSITIVE_ORDER)

Ties in sorted sets and maps

TreeSet and TreeMap keep an element only if the comparator separates it from every element already in the set. After the full comparator byRank, two players survive as separate elements unless they have the same score and the same name. If your data can contain two different people with the same name and score, they collapse into one entry.

The remedy is to end the chain with a key that is unique, such as an id:

java
.thenComparing(Player::getName)
.thenComparingInt(Player::getId)

The Java documentation puts the general rule like this: the ordering should be consistent with equals, meaning that the comparator returns zero exactly when equals is true. In a list you can sort without a perfect tie-break. In a TreeSet, the ordering is the identity of the element.

Top N

Once a list is sorted, the best N are the first N. subList(0, n) takes the first n elements, where the end index is exclusive. The documentation says a sublist is a view of the original list, not a copy. If the list may be shorter than n, cap the index:

java
List<Player> top = ranked.subList(0, Math.min(n, ranked.size()));

Without the cap, asking for more elements than exist throws IndexOutOfBoundsException. If you need an independent list, wrap it: new ArrayList<>(ranked.subList(0, k)).

Examples

Example 1: score then name (simple)

java
List<Player> ps = new ArrayList<>(List.of(
        new Player("Mona", 90), new Player("Zed", 90), new Player("Ali", 70),
        new Player("Bob", 90), new Player("Cy", 70)));
ps.sort(byRank);
System.out.println(ps);

Output:

text
[Bob:90, Mona:90, Zed:90, Ali:70, Cy:70]

The 90s come first, and inside each tie the names run A to Z. Shuffle the input and the output stays the same.

Example 2: employees by department, salary, name (practical)

Sort Emp objects by department A to Z, then salary from highest, then name:

java
Comparator<Emp> c = Comparator.comparing(Emp::getDept)
        .thenComparing(Emp::getSalary, Comparator.reverseOrder())
        .thenComparing(Emp::getName);

Here the second key is reversed by itself, using a key comparator, so the department and the name stay ascending. For the data (IT Zoe 5000, HR Sam 4000, IT Amy 6000, IT Ben 6000, HR Lee 4500) the output was:

text
[HR/Lee/4500, HR/Sam/4000, IT/Amy/6000, IT/Ben/6000, IT/Zoe/5000]

Amy and Ben tie on salary within IT, so name decides.

Example 3: a sorted set that needs a unique last key (realistic)

Add a second employee called Ben with salary 6000 in IT (a different person, id 6). With a comparator of salary descending then name, the set drops the new Ben:

java
Set<Emp> s1 = new TreeSet<>(Comparator.comparingInt(Emp::getSalary).reversed()
                                      .thenComparing(Emp::getName));
s1.addAll(employees);   // size 5 of 6 employees

Appending .thenComparingInt(Emp::getId) makes the ordering separate every employee, and the set holds all six. (Both counts come from a real run: 5 of 6, then 6 of 6.)

Backend Insight

Whenever a list is shown or paged, give it a deterministic order: a sort whose result does not depend on input order. If page 1 and page 2 are each sorted by a key with ties, an item can appear on both pages or on neither, because the tied rows may be rearranged between requests. The cure is to end every sort with a unique key, usually the id. The same rule appears when you use SQL's ORDER BY later in the course.

Multi-key comparators also make business rules readable. "Customers by tier, then total spend, then name" becomes three short lines you can review and test. Name such comparators (byRank) and keep them next to the class or in a small utility class, so the rule exists once.

Finally, sort in memory only data that is already there and reasonably small. For a big table, ask the database for the first rows in the right order instead of loading everything to find the top 10.

Common Mistakes

Mistake 1: Putting reversed() at the end of the chain

a.thenComparing(b).reversed() flips both keys. If only the first key should go descending, call reversed() right after it, or give the later key its own comparator.

Mistake 2: Stopping at a key that still has ties

If two rows can tie and the order matters, add another key. Rely on stability only when you really do not care.

Mistake 3: A TreeSet that drops distinct elements

An incomplete ordering merges elements that differ. End with a unique key such as an id, or use a List if duplicates are allowed.

Mistake 4: Taking more than size() elements

subList(0, 10) on a five-element list throws IndexOutOfBoundsException. Use Math.min.

Mistake 5: Forgetting the keys' own order

thenComparing(Player::getName) uses the natural String order, which is case-sensitive: "Zed" before "adam". Pass String.CASE_INSENSITIVE_ORDER if that is not what you want.

Exercises

Level A: Foundation

A1. When does thenComparing use its second key?

Show solution A1

A1. Only when the first comparator returns zero, meaning the first keys tie.

A2. Write a comparator for Task objects: priority descending, then title ascending. Assume the getters getPriority() and getTitle().

Show solution A2

A2.

java
Comparator<Task> c = Comparator.comparingInt(Task::getPriority).reversed()
                               .thenComparing(Task::getTitle);

A3. Is x.thenComparing(y).reversed() the same as x.reversed().thenComparing(y)? Answer in one sentence.

Show solution A3

A3. No: x.thenComparing(y).reversed() flips both keys, while x.reversed().thenComparing(y) flips only x and leaves y ascending.

Level B: Application

B1. Employees have dept, name, and int salary. Write a comparator for dept ascending, salary descending, name ascending, and sort five employees of your own choosing. Show the result.

Show solution B1

B1.

java
Comparator<Emp> c = Comparator.comparing(Emp::getDept)
        .thenComparing(Emp::getSalary, Comparator.reverseOrder())
        .thenComparing(Emp::getName);

With IT Zoe 5000, HR Sam 4000, IT Amy 6000, IT Ben 6000, HR Lee 4500, I ran this and got [HR/Lee/4500, HR/Sam/4000, IT/Amy/6000, IT/Ben/6000, IT/Zoe/5000].

B2. Write the same comparator in a second way, so that the salary step uses Comparator.comparingInt(...).reversed() as its own comparator.

Show solution B2

B2.

java
Comparator<Emp> c2 = Comparator.comparing(Emp::getDept)
        .thenComparing(Comparator.comparingInt(Emp::getSalary).reversed())
        .thenComparing(Emp::getName);

The result is identical to B1 (I compared the two sorted lists and they were equal).

B3. A list ranked has an unknown number of players, and a user asks for the top n. Write one line that returns the first n players, or all of them when there are fewer than n.

Show solution B3

B3. List<Player> top = ranked.subList(0, Math.min(n, ranked.size()));

Level C: Challenge

C1. You put employees in a TreeSet with the comparator from B1, and the set is smaller than the list. Give one possible cause, and fix the comparator.

Show solution C1

C1. Two different employees share the same dept, salary, and name, so the comparator returns 0 and the set keeps only one. Append a unique key: .thenComparingInt(Emp::getId).

C2. Explain why a paged list sorted only by salary can show the same employee on two pages.

Show solution C2

C2. If many employees share a salary, their relative order is not defined by the sort key, so each request may order them differently (for example if the data source returns them in a different order). An employee can slip across the page boundary. Add a unique final key, such as the id, for a stable order.

Mini Challenge

Build a small catalogue of at least six products with category, price (in whole cents as an int), and name. Write one comparator that sorts by category A to Z, then price from lowest, then name A to Z, and a second that is the same except price is highest first. Sort a list with each and print both. Include two products with the same category and price.

Check your work: the two same-priced products are ordered by name in both outputs, you called reversed() only on the price key (or used a key comparator), and you used no subtraction.

Summary & Next Lesson

Key takeaways

  • thenComparing adds a tie-breaker: its key is consulted only when all earlier keys tie. Chain as many as needed.
  • Read a chain left to right: "score descending, then name ascending".
  • reversed() flips everything before it, so place it carefully or give one key its own comparator.
  • A sorted set uses the comparator as identity, so finish the chain with a unique key when different elements could tie.
  • For the top N, use subList(0, Math.min(n, size)).

Next lesson

You now have every tool for the leaderboard. The next lesson, "Demo: a leaderboard with multi-key comparators", builds it step by step, including the planned subtraction mistake, and prepares the lab.

Self-check

  1. What does thenComparing do?
  2. What does reversed() flip in a chain?
  3. How do you make a comparator safe for a TreeSet of people who can share name and score?
  4. What exception can subList(0, n) throw, and when?
  5. Why should paged lists end with a unique key?
Show answers
  1. It compares by another key when the earlier keys tie.
  2. Everything built up to that point in the chain.
  3. Add a unique final key, such as an id.
  4. IndexOutOfBoundsException, when n is larger than the list's size.
  5. Without a unique key, tied rows have no fixed order, so the same item can appear on two pages or be skipped.

References

  • Oracle, Java SE 21 API, Comparator. Supports thenComparing as a tie-breaker, its overloads including thenComparingInt, reversed, comparing with a key comparator, and the consistency-with-equals note for sorted sets.
  • Oracle, Java SE 21 API, List. Supports stable sort, the exclusive end index of subList, that a sublist is a view, and IndexOutOfBoundsException for a bad range.
  • Oracle, Java SE 21 API, TreeMap. Supports that sorted maps treat keys that compare as equal as the same key.
  • All outputs and counts come from code compiled and run on JDK 21. The paging argument (Backend Insight and C2) is a general reasoning point I did not verify against a database manual.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.