Strings in Java — exercises with palindromes, word counting and the Caesar cipher (and an == trap I didn’t see coming)
Some bugs don’t crash anything. They just quietly give you the wrong answer and let you believe it’s right.
Mine was an anagram checker. I sorted the letters of both words, compared the results with ==, and it returned false for "Rome" and "more". I stared at it for a good while: both sorted arrays were clearly e, m, o, r. The letters were identical and the answer was still wrong. If you’ve read the theory article, you already know where this is going: == doesn’t compare text, it compares references. That’s exactly the first fragment in the What’s wrong here? section below.
In this article you’ll practice what the theory and practice articles covered: charAt, substring, split, trim, equals, StringBuilder. There are three levels of difficulty, a section of broken code to debug, commented solutions, and a cheat sheet at the end. Try each exercise on your own before opening the solution.
Table of Contents
How to use this article
- Read the statement and the expected output.
- Try it for at least 15 minutes before looking at the hint.
- Compare with the solution and read the why: the decisions matter more than the code.
All solutions are static methods so you can paste them into one class and call them from main. Imports needed: java.util.Arrays (only for the anagram fragment).
Level 1 — Basic: palindrome checker
Statement. Write boolean isPalindrome(String s) that returns true if the text reads the same forwards and backwards. Ignore case, spaces and punctuation.
Expected output:
"Ana" -> true "Anita lava la tina" -> true "A man, a plan, a canal: Panama" -> true "racecar" -> true "hello" -> false
Do it in two steps. First, a method that normalizes the text (keeps only letters and digits, all lowercase). Then compare characters from both ends moving inwards.
Level 2 — Intermediate: counting words, vowels and the longest word
Statement. Given the text " The cat eats fresh salmon " (extra spaces on purpose):
int countWords(String s)— number of words.int countVowels(String word)— number of vowels (a, e, i, o, u).String longestWord(String s)— the longest word.
Expected output:
5 salmon The 1 cat 1 eats 2 fresh 1 salmon 2
Also, countWords(" ") and countWords("") must both return 0.
trim() first, then check isEmpty(), then split("\\s+"). For vowels, "aeiou".indexOf(c) != -1 tells you if a character is a vowel.
Level 3 — Final challenge: Caesar cipher
Statement. Implement String encrypt(String s, int shift) and String decrypt(String s, int shift). Each letter moves shift positions in the alphabet, wrapping around (z → a). Uppercase stays uppercase. Anything that isn’t a letter (spaces, commas, digits, accents) stays unchanged. It must work with negative shifts and shifts bigger than 26.
Expected output:
Khoor, Zruog! → Hello, World! (encrypt("Hello, World!", 3), then decrypt)
encrypt("xyz", 3) → abc
encrypt("abc", -1) → zab
encrypt("abc", 29) → def (29 behaves like 3)
encrypt("Canción", 1) → Dbodjóo (the "ó" is left untouched)
'c' - 'a' gives the letter’s position (0–25). Add the shift, take % 26, and add 'a' back. The tricky part: in Java, % can return a negative number. Normalize the shift first.
What’s wrong here?
Three fragments that look fine and aren’t. Predict what happens before reading the explanation.
Fragment 1 — The anagram that wasn’t
static boolean isAnagram(String a, String b) {
char[] x = a.toLowerCase().toCharArray();
char[] y = b.toLowerCase().toCharArray();
Arrays.sort(x);
Arrays.sort(y);
return new String(x) == new String(y);
}
// isAnagram("Rome", "more") → ???
It returns false. new String(x) and new String(y) are two different objects that happen to contain the same text, and == asks “are they the same object?”. The fix is new String(x).equals(new String(y)), which returns true.
Comparing with == a text you just built or transformed (new String, substring, toUpperCase…). Sometimes == seems to work with literals, and that’s what makes it treacherous.
Fragment 2 — The cleaner that cleans nothing
static String clean(String s) {
s.trim();
s.toLowerCase();
return s;
}
// clean(" HELLO ") → ???
It returns " HELLO ", untouched. Strings are immutable: trim() and toLowerCase() don’t modify s, they return a new String that here is thrown away. The fix is return s.trim().toLowerCase();, which gives "hello".
Fragment 3 — The version number that crashed
String[] parts = "2.4.1".split(".");
System.out.println(parts[0]);
It crashes with ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0. split takes a regular expression, and in a regex . means “any character”. Every character is a separator, so everything is discarded and the array is empty. For a literal dot: split("\\."), which returns [2, 4, 1].
The same applies to | * + ? ( ) [ ] { } ^ $. If you want them literally, escape them with \\.
Solutions
Level 1 — Palindrome
// Step 1: keep only letters and digits, all lowercase
static String normalize(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (Character.isLetterOrDigit(c)) { // drops spaces, commas, colons...
sb.append(Character.toLowerCase(c)); // "Ana" and "ana" are the same
}
}
return sb.toString();
}
// Step 2: compare from both ends towards the middle
static boolean isPalindrome(String s) {
String t = normalize(s);
int i = 0, j = t.length() - 1;
while (i < j) { // when they meet, we're done
if (t.charAt(i) != t.charAt(j)) return false; // chars: != is fine, not equals
i++;
j--;
}
return true; // also true for "" and one char
}
Why this way. We could build the reversed text with StringBuilder.reverse() and compare with equals, and it would work. We use two pointers because it doesn’t create a second String and because the pattern (one index from each end) shows up again and again. Note that charAt returns a char, a primitive, so != is correct here; equals is for objects.
Why normalize first. Without it, "Ana" fails (A ≠ a) and "Anita lava la tina" fails on the spaces. Normalizing is the step that turns a naive checker into a useful one.
Level 2 — Words, vowels and longest word
static int countWords(String s) {
String t = s.trim(); // removes the extra spaces at both ends
if (t.isEmpty()) return 0; // "".split(...) returns [""], length 1, not 0!
return t.split("\\s+").length; // \\s+ = one or more whitespace characters
}
static int countVowels(String word) {
int n = 0;
for (char c : word.toLowerCase().toCharArray()) {
if ("aeiou".indexOf(c) != -1) n++; // indexOf returns -1 if it's not there
}
return n;
}
static String longestWord(String s) {
String best = "";
String t = s.trim();
if (t.isEmpty()) return best;
for (String p : t.split("\\s+")) {
if (p.length() > best.length()) best = p; // strict >: first one wins a tie
}
return best;
}
Why trim() before split. If the text starts with a space, split produces an empty first element and your count is off by one. And "".split("\\s+") gives [""], an array of length 1, so without the isEmpty() check an empty text would count as one word.
Why "aeiou".indexOf(c). The alternative is a chain of five comparisons (c == 'a' || c == 'e' || …). This is shorter and easier to extend. Note that "Canción" gives 3 (a, i, and o are counted, but the accented ó isn’t in "aeiou"); add the accented vowels to the string if you want them.
Output: 5, salmon, and the vowel counts shown above.
Level 3 — Caesar cipher
static String encrypt(String s, int shift) {
int k = ((shift % 26) + 26) % 26; // normalizes any shift to 0..25
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (c >= 'a' && c <= 'z') {
sb.append((char) ('a' + (c - 'a' + k) % 26)); // wrap with % 26
} else if (c >= 'A' && c <= 'Z') {
sb.append((char) ('A' + (c - 'A' + k) % 26)); // keep uppercase
} else {
sb.append(c); // spaces, punctuation, digits, accents: unchanged
}
}
return sb.toString();
}
static String decrypt(String s, int shift) {
return encrypt(s, -shift); // decrypting is encrypting the other way
}
Why ((shift % 26) + 26) % 26. In Java, -1 % 26 is -1, not 25. Adding 26 before the second % pushes any result back into 0..25. Without it, encrypt("abc", -1) would produce characters before 'a'.
Why decrypt calls encrypt. One piece of logic, one place to fix bugs. It also shows that the cipher is symmetric: shifting by -3 undoes shifting by 3.
Why (char). 'a' + number is an int. append would write the number, not the letter, so you need the cast.
Output:
Khoor, Zruog! Hello, World! abc zab def def Dbodjóo
Cheat sheet: Strings in Java
The essentials in 30 seconds
- A
Stringis immutable: no method modifies it, each returns a new one. If you don’t store the result, nothing happened. - To compare content, use
equals, never==. length()with parentheses (arrays don’t).- Indexes start at
0; the last one islength() - 1.
Compare
| I want to… | Code | Notes |
|---|---|---|
| Content equality | a.equals(b) | ✅ Always this one |
| Ignoring case | a.equalsIgnoreCase(b) | "Hello" and "HELLO" → true |
| Alphabetical order | a.compareTo(b) | <0 before, 0 equal, >0 after. "a".compareTo("b") → -1 |
| Empty? | s.isEmpty() / s.isBlank() | isBlank is also true for only spaces |
❌ a == b | Compares references. Sometimes true (reused identical literals), sometimes false: that’s why it’s treacherous |
Using == on a text you built or transformed (new String(...), sorted letters, substring…). Two identical texts can be different objects, and == gives false.
Query and extract (with s = "Hello, World")
| I want to… | Code | Result |
|---|---|---|
| Length | s.length() | 12 |
| One character | s.charAt(0) | 'H' |
| From a position | s.substring(7) | "World" |
| A range (end excluded) | s.substring(0, 5) | "Hello" |
| First position of… | s.indexOf("o") | 4 |
| If it’s not there | s.indexOf("z") | -1 |
| Last position | s.lastIndexOf('o') | 8 |
| Contains? | s.contains("Wor") | true |
| Starts / ends with…? | s.startsWith("He") / s.endsWith("ld") | true |
Transform (always return a new String)
| I want to… | Code | Result |
|---|---|---|
| Upper / lower case | s.toUpperCase() / s.toLowerCase() | "HELLO, WORLD" |
| Replace | s.replace('o', '0') | "Hell0, W0rld" |
| Strip spaces at both ends | " x ".trim() | "x" |
| Repeat | "ab".repeat(2) | "abab" |
| Join | String.join("-", "a", "b") | "a-b" |
String clean = " HELLO ".trim().toLowerCase(); // ✅ we store the result text.trim(); // ❌ does nothing visible: the new value is lost
Calling text.trim() or text.toLowerCase() without assigning. clean(" HELLO ") kept returning " HELLO "; with return s.trim().toLowerCase(); it returns "hello".
split and conversions
| I want to… | Code | Result |
|---|---|---|
| Split on comma or semicolon | "a,b;c".split("[,;]") | [a, b, c] |
| Split on dots | "2.4.1".split("\\.") | [2, 4, 1] |
| Split on (several) spaces | " a b ".trim().split("\\s+") | [a, b] |
| String → char[] | "hola".toCharArray() | [h, o, l, a] |
| char[] → String | new String(chars) | "ok" |
| Number → String | String.valueOf(12) | "12" |
| String → number | Integer.parseInt("34") | 34 |
splittakes a regular expression, not a literal text. In regex"."means “any character”, so"2.4.1".split(".")returns an empty array andparts[0]blows up withArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0. For a literal dot:"\\."."".split("\\s+")returns[""](length 1, not 0). And with a leading space the first element is empty. That’s why youtrim()first and checkisEmpty().
Other characters that mean something in regex and need \\: | * + ? ( ) [ ] { } ^ $.
Characters (char)
| I want to… | Code | Result |
|---|---|---|
| Is it a letter? | Character.isLetter('a') | true |
| Is it a digit? | Character.isDigit('5') | true |
| Letter or digit? | Character.isLetterOrDigit('_') | false |
| To lowercase | Character.toLowerCase('A') | 'a' |
| Next letter | (char) ('a' + 1) | 'b' |
| Distance between letters | 'c' - 'a' | 2 |
- A
charis a number underneath, so you can add and subtract. To get back to acharyou need the(char)cast.
StringBuilder (to build text in a loop)
StringBuilder sb = new StringBuilder();
for (char c : "abc".toCharArray()) sb.append(c).append('-');
sb.reverse(); // "-c-b-a"
String result = sb.toString();
- Concatenating with
+inside a loop creates a new String on every iteration.StringBuilderis mutable and avoids that cost.
Templates for the exercises
Normalize (keep letters and digits, lowercase):
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray())
if (Character.isLetterOrDigit(c)) sb.append(Character.toLowerCase(c));
return sb.toString();
Palindrome (two pointers):
String t = normalize(s);
int i = 0, j = t.length() - 1;
while (i < j) {
if (t.charAt(i) != t.charAt(j)) return false;
i++; j--;
}
return true;
Normalize first: that way "Ana" and "Anita lava la tina" are palindromes. "hello" gives false.
Count words:
String t = s.trim();
if (t.isEmpty()) return 0;
return t.split("\\s+").length;
" The cat eats fresh salmon " → 5
Count vowels:
int n = 0;
for (char c : s.toLowerCase().toCharArray())
if ("aeiou".indexOf(c) != -1) n++;
"Canción" → 3 (the accented ó is not in "aeiou"; add áéíóú to count it).
Longest word:
String best = "";
for (String p : s.trim().split("\\s+"))
if (p.length() > best.length()) best = p;
For the example above → "salmon".
Caesar cipher (shift d, negative and over-26 safe):
int k = ((d % 26) + 26) % 26; // normalizes d to 0..25
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (c >= 'a' && c <= 'z') sb.append((char) ('a' + (c - 'a' + k) % 26));
else if (c >= 'A' && c <= 'Z') sb.append((char) ('A' + (c - 'A' + k) % 26));
else sb.append(c); // punctuation, spaces, accents: same
}
encrypt("Hello, World!", 3) → "Khoor, Zruog!" · decrypt = encrypt with -d · "xyz"+3 → "abc" · "abc"−1 → "zab" · 29 is the same as 3.
Anagram:
char[] x = a.toLowerCase().toCharArray(); char[] y = b.toLowerCase().toCharArray(); Arrays.sort(x); Arrays.sort(y); return new String(x).equals(new String(y)); // ✅ equals, not ==
isAnagram("Rome", "more") → true.
Most repeated errors
| Symptom | Likely cause |
|---|---|
| Comparison that works “sometimes” | You used == instead of equals |
Text doesn’t change after trim()/replace()/toLowerCase() | You didn’t store the result (immutability) |
StringIndexOutOfBoundsException | charAt(length()) or substring with out-of-range indexes |
split(".") returns an empty array / Index 0 out of bounds for length 0 | . is regex: use "\\." |
split("\\s+") gives 1 word on empty text | "".split(...) returns [""]: trim() + isEmpty() first |
| Palindrome fails with capitals, spaces or punctuation | Normalize before comparing |
| Cipher gives odd letters with negative shifts | % in Java can be negative: ((d % 26) + 26) % 26 |
Before you submit, ask yourself
- Am I comparing content with
equals, not==? - Do I store the result of every method that “transforms”?
- What happens with empty text, extra spaces or capitals?
- Is my
splitusing a valid (and escaped) regex?
Wrapping up
Strings look simple until you meet immutability, references and regular expressions all in the same line. The three traps in this article (==, ignoring the returned value, and an unescaped dot) cover most of the bugs I’ve seen in my own code and my classmates’. If you can explain why each fragment failed, you’ve understood the topic better than if you’d just memorized the methods.
Next up: regular expressions, which is where that mysterious split("\\s+") comes from.
Did this article help you? Here is how you can support SergioLearns →
