عرض عملي: لوحة متصدّرين بمقارِنات متعددة المفاتيح
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
Lesson Overview
This lesson is a build-along. You will construct a small leaderboard from scratch: a Player class, a list of players with tied scores, a comparator that ranks them by score (highest first) and then by name, and a method that returns the top N. You will also commit, on purpose, the classic mistake of comparing by subtraction, so that you can see it fail with your own eyes.
Everything here uses the three previous lessons: compareTo and natural order (lesson 1), comparators, reversed(), and sorted collections (lesson 2), and thenComparing, unique tie-breakers, and subList (lesson 3). The race picture still fits: rank by time, and if tied, by name.
Why this matters: this is the exact shape of countless backend tasks, such as "top 10 customers", "latest 5 orders", and "highest-rated products". The finished code is also your starting point for the module lab.
Follow along by typing the code yourself. Any Java 17 or newer JDK works; the output below comes from JDK 21. How you create and run files (command line or an IDE) depends on your setup, so use whatever you set up earlier in the course.
Preview notice: step 4 uses a lambda, (a, b) -> ..., only to show a broken comparator. Lambdas are taught in BEG-035. Read it as a tiny inline compare method with parameters a and b. The Player::getName references are also from that preview.
Learning Objectives
By the end of this lesson you will be able to:
- Build a leaderboard from a list, sorted by score descending then name ascending.
- Observe how a single-key sort leaves ties unordered, and fix it with
thenComparing. - Reproduce the subtraction overflow bug and replace it with
Integer.compareorcomparingInt. - Return the top N of a ranked list safely, including when N is larger than the list.
- Keep the same players in a
TreeSetwith the same comparator, and explain how a unique key avoids lost players.
Written Explanation
The plan
We will take six steps. Keep one source file for Player and one for the leaderboard code.
- Create
Playerwith an id, a name, and a score. - Build a list with tied scores and sort by score only.
- Add the second key.
- Plant the subtraction mistake and watch it fail.
- Write
topN. - Put the players in a
TreeSetusing the same comparator.
Step 1: the Player class
public class Player {
private final int id;
private final String name;
private final int score;
public Player(int id, String name, int score) {
this.id = id; this.name = name; this.score = score;
}
public int getId() { return id; }
public String getName() { return name; }
public int getScore() { return score; }
@Override
public String toString() { return name + ":" + score; }
}Notice what is missing: Player does not implement Comparable. A player has no single obvious "natural" order (by score? by name?), so we keep every order outside the class, in comparators. That is the lesson 2 rule of thumb. The id field is there for the final tie-break later.
Step 2: score only, and the ties
List<Player> players = List.of(
new Player(1, "Mona", 90), new Player(2, "Zed", 90),
new Player(3, "Ali", 70), new Player(4, "Bob", 90),
new Player(5, "Cy", 70), new Player(6, "Dina", 55));
List<Player> scoreOnly = new ArrayList<>(players);
scoreOnly.sort(Comparator.comparingInt(Player::getScore).reversed());
System.out.println("score only: " + scoreOnly);We copy into an ArrayList, because a list from List.of cannot be sorted (it is unmodifiable). Output:
score only: [Mona:90, Zed:90, Bob:90, Ali:70, Cy:70, Dina:55]Scores are in the right order, but the three 90s are in whatever order they were added. That is the stable sort at work: ties keep their input order. A user would find "Mona, Zed, Bob" an arbitrary order.
Step 3: add the second (and third) key
static final Comparator<Player> BY_RANK =
Comparator.comparingInt(Player::getScore).reversed()
.thenComparing(Player::getName)
.thenComparingInt(Player::getId);reversed() is called directly after the score key, so only the score is flipped (lesson 3). The name is the visible tie-break. The id is a last, invisible tie-break for players who share both name and score: it keeps the order fully deterministic and, as you will see in step 6, keeps the TreeSet honest.
Step 4: the planned mistake
A teammate writes the "obvious" descending comparator:
Comparator<Player> risky = (a, b) -> b.getScore() - a.getScore();
List<Player> extremes = new ArrayList<>(List.of(
new Player(8, "High", Integer.MAX_VALUE), new Player(9, "Low", -5)));
extremes.sort(risky);
System.out.println("risky: " + extremes);On ordinary scores (0 to 100) it works, so the tests pass. With extreme values, the real output is:
risky: [Low:-5, High:2147483647]The player with the highest possible score is last. Why? When compare is asked about the two players, it calculates -5 - 2147483647, whose exact value is -2,147,483,652. That does not fit in an int, so it wraps around to a positive 2,147,483,644, and the sort concludes "High goes after Low". The safe versions never subtract:
extremes.sort(Comparator.comparingInt(Player::getScore).reversed());
// safe: [High:2147483647, Low:-5]In your own code, scores may also be points, money in cents, or timestamps in milliseconds, where large values are not exotic. Never subtract in compare.
Step 5: top N
static List<Player> topN(List<Player> players, int n) {
List<Player> sorted = new ArrayList<>(players);
sorted.sort(BY_RANK);
return new ArrayList<>(sorted.subList(0, Math.min(n, sorted.size())));
}Three small choices matter here. We copy the input before sorting, so the caller's list is not rearranged as a side effect. We cap the end index with Math.min, so that asking for 10 from 6 players works. And we copy the subList view into a new list, so the result stays valid even if sorted changes. Running it:
top 3: [Bob:90, Mona:90, Zed:90]
top 10: [Bob:90, Mona:90, Zed:90, Ali:70, Cy:70, Dina:55]The three-way tie at 90 is resolved by name, so the top 3 is the same every run, no matter how the input was ordered.
Step 6: the same ranking in a TreeSet
Set<Player> set = new TreeSet<>(BY_RANK);
set.addAll(players);
set.add(new Player(7, "Bob", 90)); // a second, different Bob with the same score
System.out.println("tree set: " + set + " size=" + set.size());tree set: [Bob:90, Bob:90, Mona:90, Zed:90, Ali:70, Cy:70, Dina:55] size=7Both Bobs are present, because the id separates them. Without thenComparingInt(Player::getId) the second Bob would compare as 0 against the first and the set would silently refuse him, giving size 6. A TreeSet is a handy way to keep a leaderboard sorted as players arrive, since each add puts the player in the right place.
Examples
Example 1: reading the final comparator (simple)
Take [Ali:70, Bob:90, Cy:70] and BY_RANK. Which comes first, and why?
Score descending puts Bob (90) first. Ali and Cy tie at 70, so the name decides: Ali before Cy. The ranked result is [Bob:90, Ali:70, Cy:70].
Example 2: a "most recent first" activity feed (practical)
The same pattern works for any entity. Suppose Post has a long createdMillis and an int id. Newest first, ties broken by id:
Comparator<Post> feed = Comparator.comparingLong(Post::getCreatedMillis).reversed()
.thenComparingInt(Post::getId);Time values in milliseconds are large, so comparingLong (or Long.compare) is essential: subtracting two long timestamps and casting to int would be a serious bug. (This snippet follows the same API as the one that was compiled in the demo; Post is a hypothetical class I did not run.)
Example 3: "bottom 3" without a second comparator (realistic)
To find the three lowest-ranked players, reverse the full ranking:
List<Player> bottom = new ArrayList<>(players);
bottom.sort(Leaderboard.BY_RANK.reversed());
System.out.println(bottom.subList(0, 3));For the demo data the output is [Dina:55, Cy:70, Ali:70]. Here reversed() is applied to the whole comparator on purpose, which also flips the name order inside the tie (Cy before Ali). That is the lesson 3 rule at work: reversed() flips everything before it.
Backend Insight
A leaderboard method like topN maps directly onto an endpoint such as "GET /leaderboard?limit=10". Three habits from this demo carry over to real services. First, validate the input: a negative n would make subList throw, so a real service checks it. Second, never mutate the list handed to you; sort a copy. Third, keep the comparator as a named constant, so the ranking rule exists in exactly one place and can be unit-tested with the tie cases (equal scores, equal names, equal both).
Also note the trade-off in memory: sorting the full list to find the top 3 is fine for hundreds of players but wasteful for millions. At that scale you would let the database sort and limit the results (the SQL ORDER BY and LIMIT you will meet later). For this course's scale, the in-memory approach is right.
Common Mistakes
Mistake 1: Sorting a List.of list
List.of(...) is unmodifiable, so sort throws UnsupportedOperationException. Copy it into an ArrayList first, as topN does.
Mistake 2: Testing only small, friendly numbers
The subtraction bug passed every test with scores from 0 to 100. Add at least one test with extreme values, such as Integer.MAX_VALUE.
Mistake 3: Forgetting the unique tie-break in a TreeSet
Name and score alone are not unique. Without the id, one player disappears with no error.
Mistake 4: Letting a helper method reorder the caller's data
If topN sorted its parameter directly, any other code holding that list would see it change. Sort a copy.
Mistake 5: Not capping N
subList(0, n) throws IndexOutOfBoundsException when n exceeds the size. Use Math.min(n, size).
Exercises
Level A: Foundation
A1. Using BY_RANK, list the order of Zed:90 (id 2), mona:90 (id 1), Bob:90 (id 3), assuming natural String order. Does the lowercase mona come first or last? Why?
أظهر الحل A1
A1. The order is Bob:90, Zed:90, mona:90. Natural String order is case-sensitive: uppercase letters sort before lowercase ones, so mona comes last. All three tie on score, so the names decide. (I ran this with data ids 2, 1, 3.)
A2. Why is Player not Comparable in this demo?
أظهر الحل A2
A2. There is no single obvious order: a player could be sorted by score, name, or time. Keeping orders in comparators lets the same class be sorted many ways without changes.
A3. What does topN(players, 0) return?
أظهر الحل A3
A3. An empty list: Math.min(0, size) is 0, and subList(0, 0) is empty. I ran it and got [].
Level B: Application
B1. Change BY_RANK so names are compared ignoring case, and show the new order for the three players in A1.
أظهر الحل B1
B1.
Comparator<Player> ci = Comparator.comparingInt(Player::getScore).reversed()
.thenComparing(Player::getName, String.CASE_INSENSITIVE_ORDER)
.thenComparingInt(Player::getId);The order is now Bob:90, mona:90, Zed:90.
B2. After sorting, print each player with a rank where tied scores share a rank, and the next rank skips ahead (1, 1, 1, 4, 4, 6).
أظهر الحل B2
B2.
int rank = 0;
for (int i = 0; i < sorted.size(); i++) {
if (i == 0 || sorted.get(i).getScore() != sorted.get(i - 1).getScore()) {
rank = i + 1;
}
System.out.println(rank + " " + sorted.get(i).getName() + " " + sorted.get(i).getScore());
}With the demo data this printed ranks 1 1 1 4 4 6. A new rank starts only when the score changes, and its value is the position plus one.
B3. Write a method bottomN that returns the N lowest-ranked players without writing a second comparator.
أظهر الحل B3
B3. Sort a copy with BY_RANK.reversed() and take subList(0, Math.min(n, size)), as in Example 3. (The reversed order flips the name tie-break too. Say so in the method comment.)
Level C: Challenge
C1. Add a long field lastPlayedMillis to Player, and extend the ranking: score descending, then whoever reached it earliest, then name. Write the comparator chain. Which step would tempt someone into subtraction, and what do you use instead?
أظهر الحل C1
C1.
Comparator<Player> c = Comparator.comparingInt(Player::getScore).reversed()
.thenComparingLong(Player::getLastPlayedMillis)
.thenComparing(Player::getName);The time step is the temptation, because millisecond values are large and a.time - b.time cannot be cast to int safely. thenComparingLong uses Long.compare internally in the standard library (I did not run this example; it uses the same API as the verified ones).
C2. Explain how an entirely correct sort could still produce a page-two result that repeats a player from page one.
أظهر الحل C2
C2. If the sort ends in a tie with no unique key, the order of tied rows can differ between requests, so an item near the page boundary can appear on both pages. A unique last key (the id) makes the order the same every time.
Mini Challenge
This is the heart of the module lab, BEG-032-LAB1, so try it before reading the lab text. Build a Leaderboard class with a main method that:
- creates at least eight players, including two that share a score and a pair that share a name and score;
- prints the full ranking by score descending then name ascending;
- prints the top 3 with a
topNmethod that does not change its input; - puts the same players into a
TreeSetwith the same comparator and prints its size.
Check your work: ties are ordered by name, the same-name-and-score pair either both appear (because of an id tie-break) or you can explain why one is missing, topN works for n larger than the player count, and you used no subtraction anywhere.
Summary & Next Lesson
Key takeaways
- A leaderboard is a sort with two or three keys:
comparingInt(score).reversed().thenComparing(name), plus an id for determinism. - Single-key sorting leaves ties in input order. Add keys until the order is fully determined.
- Subtraction in
comparecan overflow with large values. UseInteger.compare,Long.compare, orcomparingInt. - A top-N helper should sort a copy, cap N with
Math.min, and may copy thesubListview. - A
TreeSetusing the same comparator keeps the ranking sorted, but only a unique final key keeps every distinct player.
Next lesson
This is the last lesson of the module. Next, the lab "Leaderboard" (BEG-032-LAB1) asks you to apply all of this yourself. After the module, the course continues with BEG-033, "Date and time", whose first lesson is "Why time is hard".
Self-check
- In
BY_RANK, which key is checked first, and when is the second key used? - Why did the risky comparator put
HighafterLow? - Why do we copy the list before sorting it in
topN? - Why does the
TreeSetkeep both Bobs? - How do you get the lowest N players without writing another comparator?
أظهر الإجابات
- The score (descending) is first; the name is used only when two scores are equal.
-5 - Integer.MAX_VALUEoverflows and wraps to a positive number, so the comparator reported the wrong order.- Sorting changes the list, and the caller may still be using it, so we sort a copy.
- The
idmakes them compare as different, so neither is treated as a duplicate. - Reverse the full comparator with
BY_RANK.reversed()and take the first N.
References
- Oracle, Java SE 21 API,
Comparator. SupportscomparingInt,thenComparing,reversed, and the sorted-set note on comparators andequals. - Oracle, Java SE 21 API,
List. Supports stablesort,subListbounds and view behaviour. - Oracle, Java SE 21 API,
Integer. SupportsInteger.compareand theintrange behind the overflow. - Oracle, Java SE 21 API,
TreeMap. Supports that sorted maps treat keys that compare as equal as one key. - All program output in this lesson comes from code compiled and run on JDK 21. The wrapping arithmetic follows from Java's
intrange, which I checked by running it.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.