A good string has no two equal characters next to each other. To turn a bad string into a good one in Java, we remove consecutive duplicate characters with a StringBuilder that appends a character only when it differs from the previous one.
The good string puzzle is a classic coding practice problem, and the same operation cleans real input, such as repeated slashes in a URL path, repeated spaces in a search query or stretched words like soooo in chat messages.
The following example cleans three strings with the toGoodString() method from section 2.
String letters = toGoodString("aabbbcdd"); // "abcd"
String word = toGoodString("bookkeeper"); // "bokeper"
String clean = toGoodString("abc"); // "abc"
Notice that a run of three b characters becomes one b, and a string that is already good comes back unchanged. We also write the regex one-liner, fix a common regex bug, and solve the variants that interviewers add after the first answer, such as ignoring case and removing pairs with a stack.
1. Good String vs Bad String
A string is bad when at least one character equals the character right before it. The fix is one operation repeated as often as needed, which deletes one of two equal neighbors. Applying that operation until no equal neighbors remain leaves one copy of each run, so bbb becomes b.

The rule compares neighbors only. In bookkeeper, the letter e appears three times, and the result still has two of them, because the last e is not next to another e. Removing every repeated character, wherever it appears, is a different problem that we cover in the FAQ in section 7.
2. Remove Consecutive Duplicate Characters with StringBuilder
The method reads the string once and compares each character with the character before it in the input. A StringBuilder collects the kept characters, because concatenating String objects in a loop creates a new string on every step. The program with a main() method is in the Core-Java repository.
static String toGoodString(String s) {
if (s == null || s.length() < 2) {
return s; // nothing to compare
}
StringBuilder good = new StringBuilder(s.length());
good.append(s.charAt(0));
for (int i = 1; i < s.length(); i++) {
char c = s.charAt(i);
if (c != s.charAt(i - 1)) {
good.append(c); // keep the first char of each run
}
}
return good.toString();
}
String empty = toGoodString(""); // ""
String single = toGoodString("a"); // "a"
String run = toGoodString("aaaa"); // "a"
String keepsNull = toGoodString(null); // null
The method returns null for null input instead of throwing an exception, which suits a cleanup helper. If a null value is a bug in the caller, Objects.requireNonNull(s) at the start makes the method fail fast.
3. The Regex One-Liner and Its Common Bug
A regular expression can do the same job in one line. The group (.) captures any character, the backreference \1 matches the same character again, and + extends the match over the whole run. The replacement $1 puts back one copy of the captured character.
String regexWord = "bookkeeper".replaceAll("(.)\\1+", "$1"); // "bokeper"
String regexRun = "aaab".replaceAll("(.)\\1+", "$1"); // "ab"
Without the + after the backreference, the regex removes one duplicate per pair, so a run of three characters keeps two of them. The pattern (\p{L})\1 from older versions of the puzzle has that bug, and it also skips repeated digits and symbols, because \p{L} matches letters only.
String pairsOnly = "aaab".replaceAll("(\\p{L})\\1", "$1"); // "aab", the run of three keeps two
String digitsKept = "1122".replaceAll("(\\p{L})\\1+", "$1"); // "1122", digits are not letters
The replaceAll() method compiles the pattern on every call. When a service cleans many strings, we compile the Pattern once and reuse it. The loop from section 2 needs no regex engine at all, which is the better choice in code that runs for every request.
Pattern runs = Pattern.compile("(.)\\1+");
String reused = runs.matcher("bookkeeper").replaceAll("$1"); // "bokeper"
The compiled pattern is thread-safe, so a static final field can hold it.
4. Variants Interviewers Ask Next
After the first answer, interviewers change one rule and ask how the code adapts. The three variants below come up most often, and each one changes only the comparison or the data structure.
4.1. Ignoring Case
In a case-insensitive version, Oo counts as a run. The loop compares lower-case forms, and the regex gets the (?i) flag. Both keep the first character of each run with its original case.
static String toGoodStringIgnoreCase(String s) {
StringBuilder good = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (i == 0 || Character.toLowerCase(c) != Character.toLowerCase(s.charAt(i - 1))) {
good.append(c);
}
}
return good.toString();
}
String mixed = toGoodStringIgnoreCase("Good Oops, Bad Oops"); // "God Ops, Bad Ops"
String regexMixed = "AaBb".replaceAll("(?i)(.)\\1+", "$1"); // "AB"
4.2. Removing Pairs Until None Are Left
A harder variant deletes both characters of an equal pair and repeats until no pair remains, so abbaca becomes ca. Deleting bb brings the two a characters together, and they form a new pair. A stack handles that case, and a StringBuilder works as the stack, because the last character is the top.
static String removeAdjacentPairs(String s) {
StringBuilder stack = new StringBuilder();
for (char c : s.toCharArray()) {
int top = stack.length() - 1;
if (top >= 0 && stack.charAt(top) == c) {
stack.deleteCharAt(top); // the pair cancels out
} else {
stack.append(c);
}
}
return stack.toString();
}
String pairs = removeAdjacentPairs("abbaca"); // "ca"
String allGone = removeAdjacentPairs("abba"); // ""
4.3. Emoji and Other Supplementary Characters
A char holds one UTF-16 code unit, and an emoji such as U+1F600 takes two of them. The char loop never sees two equal neighbors in a repeated emoji, because the high and low halves differ, so the run stays in the result. Looping over code points fixes it.
static String toGoodStringCodePoints(String s) {
StringBuilder good = new StringBuilder(s.length());
int previous = -1;
for (int cp : s.codePoints().toArray()) {
if (cp != previous) {
good.appendCodePoint(cp);
}
previous = cp;
}
return good.toString();
}
String twoSmiles = "\uD83D\uDE00\uD83D\uDE00";
int charLoopLength = toGoodString(twoSmiles).length(); // 4, both emoji kept
int codePointLength = toGoodStringCodePoints(twoSmiles).length(); // 2, one emoji left
5. Loop vs Regex vs Stack
Every solution reads the string once, so all of them run in O(n) time, where n is the length of the string, and the result needs O(n) memory. The table shows where they differ.
| Solution | Handles runs of 3+ | Ignores case | Notes |
|---|---|---|---|
| StringBuilder loop | Yes | With Character.toLowerCase() | Clearest code, no regex engine, the best interview answer |
| replaceAll(“(.)\\1+”, “$1”) | Yes | With (?i) | One line, compiles the pattern on every call |
| replaceAll(“(\\p{L})\\1”, “$1”) | No | With (?i) | Leaves two of three, skips digits and symbols |
| Stack of pairs | Removes pairs, not runs | With a changed comparison | Solves the remove-all-pairs variant |
| Code point loop | Yes | With Character.toLowerCase(int) | Needed for emoji and other supplementary characters |
6. Cleaning URL Paths and Search Text
A REST gateway joins a base path and a resource path, and a trailing slash in the configuration produces /api//users. Some servers reject such a path and some treat it as a different route, so the gateway collapses repeated slashes before routing. The regex targets one character, so letters such as the oo in a resource name stay intact.
String path = "/api//books///42";
String cleanPath = path.replaceAll("/{2,}", "/"); // "/api/books/42"
String query = "java regex tips";
String cleanQuery = query.replaceAll(" {2,}", " "); // "java regex tips"
A search box in a chat app does the same with stretched words. The app collapses runs of three or more letters, with (.)\1{2,}, so soooo and so find the same messages, but book keeps its double o.
String stretched = "soooo cool book".replaceAll("(.)\\1{2,}", "$1"); // "so cool book"
7. Good String Puzzle FAQs
Questions about all duplicates, duplicate words and regex quirks come up right after the good string puzzle.
7.1. How do we remove all duplicate characters, not only consecutive ones?
We keep the first occurrence of each duplicate character with distinct() on the character stream, which works like a LinkedHashSet and keeps the original order.
String unique = "bookkeeper".chars().distinct().collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString(); // "bokepr"
7.2. How do we remove consecutive duplicate words?
We use a word backreference with word boundaries, so the the becomes the but then the stays.
String words = "this is is a test test test".replaceAll("\\b(\\w+)(\\s+\\1\\b)+", "$1"); // "this is a test"
7.3. Why does “aaa”.replaceAll(“(.)\\1”, “$1”) return “aa”?
The regex matches the first pair aa and replaces it with a. The search continues after the matched pair, so the third a has no partner left and stays. Adding + after the backreference matches the whole run in one go.
7.4. How many deletions turn a bad string into a good string?
The difference between the two lengths, which is s.length() – toGoodString(s).length(). For bookkeeper, the answer is 3, one for each of the runs oo, kk and ee.
int deletions = "bookkeeper".length() - toGoodString("bookkeeper").length(); // 3
8. Conclusion
The good string puzzle asks us to keep one character from each run of equal neighbors. A StringBuilder loop that compares each character with the previous one solves it in one pass, and replaceAll(“(.)\\1+”, “$1”) does it in one line.
Watch the + in the regex, because a pattern without it leaves two characters from a run of three. For the variants, compare lower-case forms to ignore case, use a stack when pairs must cancel out, and loop over code points when the text can contain emoji.
9. References
- StringBuilder (Java SE 25 API)
- Pattern, backreferences and flags (Java SE 25 API)
- String.replaceAll() (Java SE 25 API)
- String.codePoints() (Java SE 25 API)
Happy Learning !!
Solution will not work on more than 2 consecutive chars, need to add “+” quantifier to the end:
(?i)(\p{L})\1+
or
StringBuilder sb = new StringBuilder();
char prev = Character.UNASSIGNED;
for (char c : str.toCharArray()) {
if (prev != c || !Character.isLetter(c)) {
sb.append(c);
}
prev = c;
}
return sb.toString();
public class Main { public static void main(String[] args) { System.out.println("Enter the string you want to convert to a good string"); Scanner scanner = new Scanner(System.in); String s = scanner.nextLine(); System.out.println("Good string: " + makeIntoGoodString(s)); } private static String makeIntoGoodString(String s) { String goodString = ""; if (s != null && s.length() > 0) { goodString = goodString + s.charAt(0); for (int i = 1; i < s.length(); i++) { if (goodString.charAt(goodString.length() - 1) != s.charAt(i)) { goodString = goodString + s.charAt(i); } } } return goodString; } }