Pay attention to Constraints
While cracking algorithmic problems, pay very close attention to the constraints of the problem at hand. They can often give valuable clues on how to attack the problem.
If you're attending an interview, and the interviewer didn't mention any input constraints, it's always good to raise a question about it.
According to the problem statement on Leetcode the input string is guaranteed to contain only lowercase English letters. I.e. we have only up to 26
unique characters in the string.
And also each character is guaranteed to be represented by a single char
, which is not the case with some languages, but you're unlikely to encounter such scenario when tasked with an algorithmic problem.
This method has linear time complexity (not constant) and requires additional O(n) space.
Before Java 9 when the strings were backed by a char[]
, the method toCharArray()
would return its copy (because exposing the internal storage will break the encapsulation and undermine the immutability of String
). And starting from Java 9 so-called compact strings backed by byte[]
were introduced, and toCharArray()
generates char[]
on demand based on the encoding of a particular string (which for now is either UTF-16
or LATIN1
).
For a large string, the memory footprint of the code you shared will be dominated by the char[]
produced by toCharArray()
, not by the map of characters which is confined to at most 26
entries.
Instead of paying this cost, you can simply iterate over the character indices and reduce the space complexity from O(n) to O(1).
Leveraging the problem constraints
This problem can be solved in a single iteration over the given string and by utilizing O(1) additional space. More precisely, by using a single integer array that has 26
elements, storing character indices.
Here's how it can be implemented:
private static final int NOT_PRESENT = -1;
private static final int DUPLICATE = -2;
private static final char MIN_LETTER = 'a';
public static int firstUniqueChar(String input) {
return getFirst(letterIndices(input));
}
private static int[] letterIndices(String input) {
final int[] indices = initializeIndices();
for (int i = 0; i < input.length(); i++) {
int position = input.charAt(i) - MIN_LETTER;
indices[position] = indices[position] == NOT_PRESENT ? i : DUPLICATE;
}
return indices;
}
private static int[] initializeIndices() {
int[] indices = new int[26];
Arrays.fill(indices, NOT_PRESENT);
return indices;
}
private static int getFirst(final int[] indices) {
int first = NOT_PRESENT;
for (int i: indices) {
if (i == DUPLICATE || i == NOT_PRESENT) continue;
first = first == NOT_PRESENT ? i : Math.min(first, i);
}
return first;
}
Note: one might ask what about all these method calls, aren't they causing performance overhead? (and the interviewer might ask it as well to test your knowledge)
To understand how to answer this question, you need to have some knowledge about JIT compilation. In short, JIT compiler identifies the most frequently utilized code parts (hotspots), then inlines method calls and reorders instructions to optimize performance. The shorter a method is, the quicker it gets optimized. There's no advantage neither in piling code into one method, no in making use of built-in functionality offered by the JDK (such as Math.min()
and Arrays.fill()
).
Minor points
- "Never abbrev" (a joke from Martin Fowler). Don't skimp on characters, use proper names, code quality has no nothing to do with number of keystrokes spared.
firstUniqChar
-> firstUniqueChar
Leetcode platform will not allow you this change, since it'll be looking for the method firstUniqChar
. But it doesn't invalidate the point. You can define a properly named method and call it from the one declared by Leetcode. Naming code elements is not an easy task, and requires practice.
- There's no reason is for
firstUniqChar
to be an instance method (again, Leetcode specific thing, the remedy is the same as mentioned above).
- There's nothing in bad performing an explicit null-check
Please, don't confuse what I'm saying with a situation when application is suffering from null-infestation and there are null-checks all over the place. The point is that by itself null-check is not evil, the many JDK methods returning null
and we need to dial with it.
Method Objects.isNull()
along with some other methods was introduced in Java 8 to serve as a predicate Objects::isNull
. It's not useful when you're not dialing with functional interfaces.
Compare this
if (Objects.isNull(map.get(c))) { ... }
with this
if (map.get(c) == null) { ... }
And it becomes clear more clear that it's basically a Map.containsKey()
check in disguise, and there's no need to mess with null
.
Whenever you find yourself writing the following conditional logic: if a key is absent, then put some value, otherwise update existing one; then it's good use case for Map.merge()
.
So, this
LinkedHashMap<Character, Integer> map = new LinkedHashMap<>();
for (char c : s.toCharArray()) {
if (Objects.isNull(map.get(c))) {
map.put(c, 1);
} else {
int count = (Integer) map.get(c);
map.put(c, ++count);
}
}
can be changed to this
var charCount = new LinkedHashMap<Character, Integer>();
for (int i = 0; i < s.length(); i++) {
charCount.merge(s.charAt(i), 1, Integer::sum);
}
The name of the code element should communicate its purpose to the reader of the code.
The name map
fails to do so. It doesn't reveal the purpose of the variable. No doubt, it's a map, it has no chance to be anything but a map. It's redundant to include type in the name, Java is a statically typed language, hence if you'll to treat this variable as, let's LocalTime
, Java compiler will tell you (and your IDE will tell as well).
Instead, the name should tell what this map is intended to store. Therefore, consider names like charCount
, countByChar
, etc.
string contain only lowercase letters
- Constraints are not aligned with the original problem statement on Leetcode and stated in a way which is slightly confusing. Also, technically, it's not a quote. \$\endgroup\$