1996. The Number of Weak Characters in the Game
Problem Description
You have a game with multiple characters where each character has two properties: attack and defense. These properties are given as a 2D array properties
where properties[i] = [attack_i, defense_i]
represents the attack and defense values for the i-th character.
A character is considered weak if there exists at least one other character that has both higher attack AND higher defense values. In other words, character i
is weak if there's another character j
such that:
attack_j > attack_i
defense_j > defense_i
Your task is to count how many weak characters exist in total.
The solution uses a clever sorting strategy. By sorting characters with attack in descending order and defense in ascending order (when attacks are equal), we can process characters in a specific order. The key insight is that when we sort by (-attack, defense)
:
- Characters are grouped by attack value (highest to lowest)
- Within the same attack value, characters are ordered by defense (lowest to highest)
As we traverse through the sorted list:
- We maintain a variable
mx
to track the maximum defense seen so far - For each character, if its defense is less than
mx
, it means there's a previously seen character with both higher attack (because we're moving from higher to lower attacks) and higher defense - The sorting ensures we don't incorrectly count characters with the same attack value as dominating each other
For example, with properties = [[5,5],[6,3],[3,6]]
:
- After sorting:
[[6,3],[5,5],[3,6]]
- Process
[6,3]
:mx = 3
, no weak character - Process
[5,5]
: defense5
>mx
3
, so not weak, updatemx = 5
- Process
[3,6]
: defense6
>mx
5
, so not weak, updatemx = 6
- Result: 1 weak character (the one with
[5,5]
is dominated by[3,6]
)
Intuition
The key challenge is determining which characters are weak without comparing every pair, which would be inefficient. We need a way to systematically check if any character dominates another.
Let's think about what makes a character weak: it needs to have both lower attack AND lower defense than some other character. If we randomly check characters, we'd need to compare each one against all others, leading to O(n²) comparisons.
The breakthrough comes from realizing that if we process characters in a specific order, we can keep track of just one value (maximum defense) instead of comparing with all previous characters.
Why sort by attack descending? When we process characters from highest to lowest attack, any character we've already seen is guaranteed to have higher or equal attack. This handles one condition automatically.
But what about characters with the same attack value? They can't dominate each other (since domination requires strictly greater values). To avoid false positives, we sort characters with the same attack by defense in ascending order. This ensures that when we encounter a character with the same attack value, the maximum defense we've tracked so far doesn't include characters with the same attack level that have higher defense.
Here's the clever part: as we traverse the sorted list:
- If current defense < maximum defense seen so far, then there must be a previous character with both higher attack (due to our traversal order) and higher defense
- If current defense ≥ maximum defense, this character isn't weak (at least not by any character we've seen so far)
The sorting pattern (-attack, defense)
creates this perfect processing order where we only need to track one variable (mx
for maximum defense) and make one comparison per character, reducing complexity from O(n²) to O(n log n).
Learn more about Stack, Greedy, Sorting and Monotonic Stack patterns.
Solution Approach
The implementation follows these steps:
Step 1: Sort the characters
properties.sort(key=lambda x: (-x[0], x[1]))
We sort the properties
array using a custom key. The lambda function (-x[0], x[1])
creates a tuple where:
-x[0]
sorts by attack in descending order (negative value reverses the order)x[1]
sorts by defense in ascending order when attacks are equal
This ensures characters are ordered from highest to lowest attack, and within the same attack level, from lowest to highest defense.
Step 2: Initialize tracking variables
ans = mx = 0
ans
: Counter for weak charactersmx
: Tracks the maximum defense value seen so far
Step 3: Traverse and count weak characters
for _, x in properties:
ans += x < mx
mx = max(mx, x)
For each character in the sorted list:
- We only care about the defense value
x
(attack is handled by sorting) x < mx
: If current defense is less than the maximum defense seen, this character is weakans += x < mx
: In Python,True
equals 1 andFalse
equals 0, so this incrementsans
when the character is weakmx = max(mx, x)
: Update the maximum defense for future comparisons
Why this works:
- When we check
x < mx
, we know thatmx
comes from characters with higher attack values (due to our traversal order) - The ascending defense sort within same attack values prevents false positives
- Each character is processed exactly once, making this O(n) for traversal after O(n log n) sorting
Time Complexity: O(n log n) for sorting + O(n) for traversal = O(n log n)
Space Complexity: O(1) if we don't count the sorting space, otherwise O(log n) for sorting
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example with properties = [[2,2],[3,3],[1,5],[4,1]]
.
Step 1: Initial Setup
- Original array:
[[2,2],[3,3],[1,5],[4,1]]
- We need to find characters where another character has BOTH higher attack AND defense
Step 2: Sort by (-attack, defense)
- Apply sorting key:
(-x[0], x[1])
[2,2]
→ key:(-2, 2)
[3,3]
→ key:(-3, 3)
[1,5]
→ key:(-1, 5)
[4,1]
→ key:(-4, 1)
- Sorted:
[[4,1],[3,3],[2,2],[1,5]]
Step 3: Process each character
- Initialize:
ans = 0
,mx = 0
Processing [4,1]
:
- Defense = 1, check if
1 < 0
? No - Not weak, update
mx = max(0, 1) = 1
ans = 0
Processing [3,3]
:
- Defense = 3, check if
3 < 1
? No - Not weak, update
mx = max(1, 3) = 3
ans = 0
Processing [2,2]
:
- Defense = 2, check if
2 < 3
? Yes! - This is weak! (There's a character with attack ≥ 3 and defense = 3)
- Update
mx = max(3, 2) = 3
ans = 1
Processing [1,5]
:
- Defense = 5, check if
5 < 3
? No - Not weak, update
mx = max(3, 5) = 5
ans = 1
Result: 1 weak character ([2,2]
is dominated by [3,3]
)
Why the sorting works:
- When we checked
[2,2]
, themx = 3
came from[3,3]
which has attack 3 > 2 - The descending attack sort guarantees any
mx
value comes from higher attack characters - The ascending defense sort (within same attack) prevents same-attack characters from incorrectly dominating each other
Solution Implementation
1class Solution:
2 def numberOfWeakCharacters(self, properties: List[List[int]]) -> int:
3 # Sort characters by attack in descending order,
4 # and by defense in ascending order for same attack values
5 # This ensures when we traverse, characters with higher attack come first
6 # For same attack, weaker defense comes first (prevents false counting)
7 properties.sort(key=lambda character: (-character[0], character[1]))
8
9 # Initialize counter for weak characters and max defense seen so far
10 weak_count = 0
11 max_defense = 0
12
13 # Traverse through sorted characters
14 for attack, defense in properties:
15 # If current defense is less than max defense seen,
16 # this character is weak (attack is guaranteed to be less due to sorting)
17 if defense < max_defense:
18 weak_count += 1
19
20 # Update the maximum defense value encountered
21 max_defense = max(max_defense, defense)
22
23 return weak_count
24
1class Solution {
2 public int numberOfWeakCharacters(int[][] properties) {
3 // Sort characters by attack in descending order
4 // If attacks are equal, sort by defense in ascending order
5 // This ensures when we traverse, characters with same attack won't count each other as weak
6 Arrays.sort(properties, (a, b) -> {
7 if (b[0] - a[0] == 0) {
8 return a[1] - b[1]; // Same attack: sort defense ascending
9 }
10 return b[0] - a[0]; // Different attack: sort attack descending
11 });
12
13 // Count weak characters
14 int weakCharacterCount = 0;
15 int maxDefenseSoFar = 0;
16
17 // Traverse sorted array
18 for (int[] character : properties) {
19 int currentDefense = character[1];
20
21 // If current defense is less than max defense seen so far,
22 // this character is weak (attack is already less due to sorting)
23 if (currentDefense < maxDefenseSoFar) {
24 weakCharacterCount++;
25 }
26
27 // Update the maximum defense value seen
28 maxDefenseSoFar = Math.max(maxDefenseSoFar, currentDefense);
29 }
30
31 return weakCharacterCount;
32 }
33}
34
1class Solution {
2public:
3 int numberOfWeakCharacters(vector<vector<int>>& properties) {
4 // Sort characters by attack in descending order
5 // If attacks are equal, sort by defense in ascending order
6 // This ensures when we traverse, characters with same attack won't be counted as weak
7 sort(properties.begin(), properties.end(), [](const vector<int>& a, const vector<int>& b) {
8 if (a[0] == b[0]) {
9 return a[1] < b[1]; // Same attack: sort defense ascending
10 }
11 return a[0] > b[0]; // Different attack: sort attack descending
12 });
13
14 int weakCharacterCount = 0;
15 int maxDefenseSeen = 0;
16
17 // Traverse through sorted characters
18 // Since we process in descending attack order, any character with defense
19 // less than maxDefenseSeen is weak (has both lower attack and defense)
20 for (const auto& character : properties) {
21 if (character[1] < maxDefenseSeen) {
22 weakCharacterCount++;
23 }
24 maxDefenseSeen = max(maxDefenseSeen, character[1]);
25 }
26
27 return weakCharacterCount;
28 }
29};
30
1/**
2 * Counts the number of weak characters in the game.
3 * A character is weak if there exists another character with both higher attack and defense.
4 *
5 * @param properties - 2D array where properties[i] = [attack_i, defense_i]
6 * @returns The number of weak characters
7 */
8function numberOfWeakCharacters(properties: number[][]): number {
9 // Sort characters by attack in descending order
10 // If attack values are equal, sort by defense in ascending order
11 // This ensures when we traverse, characters with same attack won't interfere
12 properties.sort((a: number[], b: number[]): number => {
13 if (a[0] === b[0]) {
14 return a[1] - b[1]; // Same attack: ascending defense
15 }
16 return b[0] - a[0]; // Different attack: descending attack
17 });
18
19 let weakCharacterCount: number = 0;
20 let maxDefenseSoFar: number = 0;
21
22 // Traverse the sorted array
23 // Since attack is sorted descending, any character we've seen before has attack >= current
24 for (const property of properties) {
25 const currentDefense: number = property[1];
26
27 // If current defense is less than max defense seen so far,
28 // this character is weak (there exists a character with higher attack and defense)
29 if (currentDefense < maxDefenseSoFar) {
30 weakCharacterCount++;
31 } else {
32 // Update the maximum defense value seen
33 maxDefenseSoFar = currentDefense;
34 }
35 }
36
37 return weakCharacterCount;
38}
39
Time and Space Complexity
The time complexity is O(n × log n)
, where n
is the number of characters (length of the properties list). This is dominated by the sorting operation properties.sort()
, which uses Python's Timsort algorithm with O(n × log n)
complexity. The subsequent iteration through the sorted array takes O(n)
time, but this is absorbed by the sorting complexity.
The space complexity is O(log n)
. While the sorting is done in-place (modifying the original list), Python's Timsort algorithm requires O(log n)
space for its internal operations, specifically for maintaining the stack used in the merge process. The additional variables ans
and mx
only require O(1)
space, so the overall space complexity remains O(log n)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Incorrect Sorting Order for Defense Values
The Problem: A common mistake is sorting defense values in descending order when attacks are equal, like this:
properties.sort(key=lambda x: (-x[0], -x[1])) # WRONG!
This creates a critical issue where characters with the same attack value can incorrectly dominate each other.
Example of the Bug:
Consider properties = [[7,7],[7,3],[7,5]]
With incorrect sorting (-attack, -defense)
:
- Sorted:
[[7,7],[7,5],[7,3]]
- Process
[7,7]
:mx = 7
- Process
[7,5]
:5 < 7
, counted as weak (WRONG!) - Process
[7,3]
:3 < 7
, counted as weak (WRONG!) - Result: 2 weak characters (incorrect)
The Fix: Sort defense in ascending order when attacks are equal:
properties.sort(key=lambda x: (-x[0], x[1])) # CORRECT!
With correct sorting:
- Sorted:
[[7,3],[7,5],[7,7]]
- Process
[7,3]
:mx = 3
- Process
[7,5]
:5 > 3
, not weak,mx = 5
- Process
[7,7]
:7 > 5
, not weak,mx = 7
- Result: 0 weak characters (correct)
Pitfall 2: Not Understanding Why Defense Must Be Ascending
The Problem: Some might think sorting order doesn't matter as long as we group by attack. This misunderstanding leads to counting characters with the same attack as dominating each other.
Key Insight: By processing lower defense values first within the same attack group:
- The
mx
variable only gets updated after processing all characters with the current attack level - This prevents any character from being dominated by another character with the same attack value
- When we move to a lower attack group,
mx
already contains the highest defense from all higher attack groups
Pitfall 3: Using Nested Loops Instead of Sorting
The Problem: Attempting a brute force O(n²) solution:
def numberOfWeakCharacters(self, properties):
count = 0
for i in range(len(properties)):
for j in range(len(properties)):
if i != j:
if properties[j][0] > properties[i][0] and properties[j][1] > properties[i][1]:
count += 1
break
return count
Issues:
- Time complexity is O(n²), which times out for large inputs
- Less elegant and harder to optimize
The Fix: Use the sorting approach which reduces complexity to O(n log n) and elegantly handles the comparison logic through ordering.
The three-steps of Depth First Search are:
- Identify states;
- Draw the state-space tree;
- DFS on the state-space tree.
Recommended Readings
Stack Intro Imagine you have a pile of books on your desk If you want to add a new book you place it on top If you want to read a book you take it from the top And if you simply want to see which book is on the top you
Greedy Introduction div class responsive iframe iframe src https www youtube com embed WTslqPbj7I title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture web share allowfullscreen iframe div When do we use greedy Greedy algorithms tend to solve optimization problems Typically they will ask you to calculate the max min of some value Commonly you may see this phrased in the problem as max min longest shortest largest smallest etc These keywords can be identified by just scanning
Sorting Summary Comparisons We presented quite a few sorting algorithms and it is essential to know the advantages and disadvantages of each one The basic algorithms are easy to visualize and easy to learn for beginner programmers because of their simplicity As such they will suffice if you don't know any advanced
Want a Structured Path to Master System Design Too? Don’t Miss This!