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.

How to use this article

  1. Read the statement and the expected output.
  2. Try it for at least 15 minutes before looking at the hint.
  3. 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):

  1. int countWords(String s) — number of words.
  2. int countVowels(String word) — number of vowels (a, e, i, o, u).
  3. 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 String is 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 is length() - 1.

Compare

I want to…CodeNotes
Content equalitya.equals(b)✅ Always this one
Ignoring casea.equalsIgnoreCase(b)"Hello" and "HELLO" → true
Alphabetical ordera.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 == bCompares 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…CodeResult
Lengths.length()12
One characters.charAt(0)'H'
From a positions.substring(7)"World"
A range (end excluded)s.substring(0, 5)"Hello"
First position of…s.indexOf("o")4
If it’s not theres.indexOf("z")-1
Last positions.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…CodeResult
Upper / lower cases.toUpperCase() / s.toLowerCase()"HELLO, WORLD"
Replaces.replace('o', '0')"Hell0, W0rld"
Strip spaces at both ends" x ".trim()"x"
Repeat"ab".repeat(2)"abab"
JoinString.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…CodeResult
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[] → Stringnew String(chars)"ok"
Number → StringString.valueOf(12)"12"
String → numberInteger.parseInt("34")34
  • split takes a regular expression, not a literal text. In regex "." means “any character”, so "2.4.1".split(".") returns an empty array and parts[0] blows up with ArrayIndexOutOfBoundsException: 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 you trim() first and check isEmpty().

Other characters that mean something in regex and need \\: | * + ? ( ) [ ] { } ^ $.

Characters (char)

I want to…CodeResult
Is it a letter?Character.isLetter('a')true
Is it a digit?Character.isDigit('5')true
Letter or digit?Character.isLetterOrDigit('_')false
To lowercaseCharacter.toLowerCase('A')'a'
Next letter(char) ('a' + 1)'b'
Distance between letters'c' - 'a'2
  • A char is a number underneath, so you can add and subtract. To get back to a char you 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. StringBuilder is 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

SymptomLikely 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)
StringIndexOutOfBoundsExceptioncharAt(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 punctuationNormalize before comparing
Cipher gives odd letters with negative shifts% in Java can be negative: ((d % 26) + 26) % 26

Before you submit, ask yourself

  1. Am I comparing content with equals, not ==?
  2. Do I store the result of every method that “transforms”?
  3. What happens with empty text, extra spaces or capitals?
  4. Is my split using 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.

Keep goingAll Java (en) articles

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *