LeetCode #345 in Go: reverse vowels of a string, and how strings, bytes, and runes work in Go
Why I started this series A while back, I went through a job interview that included a coding challenge. It looked pretty easy, so I finished in like 90 seconds, and all tests passed! Then, the (non-technical) interviewer said to me: Interviewer: Why did you choose Golang? Do you actually know how to code in Golang? Me: I said, yes, that challenge was easy, and all tests passed!. Interviewer: Well, you only got 43% on that challenge. I'm going to be honest with you. I've been writing about Go for a while: Lambda functions, Kubernetes tooling, HTTP middleware, client-go. But there's a difference between using Go and actually understanding Go. I decided to fix that by doing LeetCode challenges. Not to grind interview prep. Not to get a FAANG job. Just to force myself to use the language in ways I wouldn't naturally reach for on a normal workday as a Senior SRE. And something unexpected happened: every challenge I struggled with exposed a Go concept I hadn't fully understood. This is the first one. The challenge LeetCode #345 https://leetcode.com/problems/reverse-vowels-of-a-string/description/ The problem requires us to reverse the vowels in a string. Note that letter case matters. Input: "IceCreAm" Output: "AceCreIm" Solution Approach The solution approach for this problem is to use 2 pointers, the two-pointer collision approach, to continuously swap the elements at the beginning and end. The solution approach was extrated from this [amazing book](http:// https://books.halfrost.com/leetcode/en/ ), please consider reading this book if you want to improve your Golang coding skills like me. The core idea: two pointers moving toward each other First, let's try to understand how the solution approach works. Imagine the string as a row of people standing in a line, numbered from left to right. You place one finger at the very first person (index left = 0) and another finger at the very last person (index right = len - 1). Then you move both fingers toward the middle, one step at a time, until they meet or cross. This is different from two pointers moving in the same direction (like for finding duplicates in a sorted array). Here they move toward each other, which is why it's called the "collision" pattern; they're on a collision course toward the center. Why this pattern fits "reverse vowels" Think about what "reversing only the vowels" really means: the vowels that appear in the string should end up in reverse order, but every consonant should stay exactly where it is. The trick is: you don't need to know the positions of all the vowels ahead of time. You can discover them as you scan from both ends, because: The leftmost vowel needs to be swapped with the rightmost vowel. Then the second leftmost vowel needs to be swapped with the second rightmost vowel. And so on, working inward. That's exactly what the collision pointers give you for free, if you use them correctly. Step-by-step logic Set left at the start of the string, right at the end. Move left forward until it lands on a vowel (skip consonants). Move right backward until it lands on a vowel (skip consonants). Once both left and right are sitting on vowels, swap them. Move left one step further right, and right one step further left (since those two are now "done"). Repeat until left >= right (the pointers meet or cross). Notice the elegance: consonants are never touched or moved; the pointers just glide past them. Only vowels get swapped, and because you're always taking the current outermost unprocessed vowel from each side, they naturally end up reversed relative to each other. Walking through an example Let's use "leetcode". l e e t c o d e 0 1 2 3 4 5 6 7 left = 0 → 'l' is not a vowel → move left to 1. Index 1 → 'e' is a vowel → stop left here. right = 7 → 'e' is a vowel → stop right here. Both are vowels → swap index 1 and index 7. Since both are 'e', string looks unchanged, but conceptually a swap happened. Move left to 2, right to 6. Index 2 → 'e' is a vowel → stop left. Index 6 → 'd' is not a vowel → move right to 5. Index 5 → 'o' is a vowel → stop right. Swap index 2 ('e') and index 5 ('o') → now index 2 is 'o', index 5 is 'e'. Move left to 3, right to 4. Now left (3) < right (4) still true, but index 3 is 't' (not vowel) → move left to 4. Now left == right, loop stops. Result: "leotcede". That's the correct answer; the vowels e, e, o, e have been reversed to e, o, e, e while consonants l, t, c, d stayed in place. A second, trickier example: mixed case Try "Aa", both are vowels ('A' and 'a', the problem says vowels can be uppercase or lowercase). left=0 is 'A', right=1 is 'a', both vowels immediately, so you swap. Result: "aA". This tests that your vowel-check needs to handle both cases, not just lowercase. Checklist for how you'd think about implementing it When you're ready to write this yourself, you'll want to think through: How do I quickly check whether a character is a vowel?: Hint: a small lookup set/map is cleaner than a long chain of if statements comparing against a, e, i, o, u, A, E, I, O, U. Can I swap characters directly in a Go string?: This is an important Go-specific gotcha: strings in Go are immutable, so you cannot do s[i], s[j] = s[j], s[i] directly on a string. You'll need to convert it into something mutable first. []byte vs []rune: Since this problem only deals with ASCII vowels, a []byte conversion works fine and is efficient. (If the problem involved non-ASCII/multi-byte characters, you'd need []rune instead.) Loop condition: When do you stop? (left < right) Two separate inner loops or one combined check?: You need to advance left past consonants and advance right past consonants, independently, before doing the swap. Let's dig into the Go-specific mechanics that matter for this problem. Why Go strings are immutable In Go, a string is internally a small struct; conceptually, it looks like this: type stringHeader struct { data uintptr // pointer to the underlying byte array len int // length in bytes } It's a read-only view into a sequence of bytes. The language spec deliberately does not let you write to that underlying array through a string variable. So this: s := "leetcode" s[0] = 'L' // compile error! ...will not even compile. Go gives you: cannot assign to s[0] (neither addressable nor a map index expression). This immutability exists for good reasons; it lets multiple strings safely share the same underlying byte array (e.g., substring operations like s[2:5] don't copy memory; they just create a new header pointing into the same data). If strings were mutable, that sharing would be dangerous; modifying one substring could silently corrupt another. How to get mutability: convert to []byte Since you can mutate a slice, the standard trick is to convert the string into a []byte: b := []byte(s) This conversion copies the string's bytes into a brand-new, independent slice. Now you're free to do: b[0] = 'L' ...because b is a slice, a mutable, addressable view over its own memory. When you're done modifying, you convert back: result := string(b) That conversion also copies the bytes back into a new immutable string. So the full round trip (string → []byte → string) does involve two copies total, but for a single-pass algorithm like this one, that's totally acceptable and is the idiomatic way to do in-place-style string manipulation in Go. []byte vs []rune: why it matters This is one of the most important Go concepts for string problems, so let's be precise about it. A byte is Go's alias for uint8, one 8-bit unit. []byte(s) gives you the raw UTF-8 encoded bytes of the string. A rune is Go's alias for int32, it represents a single Unicode code point, which might be encoded using 1 to 4 bytes in UTF-8. For plain ASCII characters, like the letters a-z, A-Z that this problem deals with, each character is exactly one byte. So []byte gives you a clean one-index-per-character view, which is exactly what the two-pointer approach needs (b[left] and b[right] each represent one whole letter). If the string contained non-ASCII characters (like é, 日, or emoji), a single character could span multiple bytes, and indexing into []byte at arbitrary positions could land you in the middle of a multi-byte character, corrupting it. In that scenario, you'd need []rune(s) instead, which decodes the UTF-8 bytes into a slice where each element is one full code point, safe to index individually, at the cost of an extra decoding pass. For this exercise, the constraints guarantee the input consists only of printable ASCII characters, so []byte is the correct and more efficient choice. Good habit to build: always check the problem's constraints for character set before deciding between []byte and []rune. Checking "is this a vowel" idiomatically A few ways this is commonly done in Go, from least to most idiomatic for this case: Option A: long boolean chain (works, but verbose and easy to mistype): c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' || c == 'A' || ... Option B: a map used as a set: vowels := map[byte]bool{ 'a': true, 'e': true, 'i': true, 'o': true, 'u': true, 'A': true, 'E': true, 'I': true, 'O': true, 'U': true, } // then: vowels[c] This is very readable. Map lookups are O(1) on average, so it doesn't hurt performance meaningfully for such a small, fixed set. Option C: strings.ContainsRune: strings.ContainsRune("aeiouAEIOU", rune(c)) Short and clean, though technically does a linear scan over 10 characters each time; negligible here, but worth knowing it's not a true O(1) lookup like the map. For a problem like this with a tiny, fixed alphabet, either B or C is considered clean, idiomatic Go. The map is generally preferred when you want to signal "this is a set membership check" clearly and efficiently. Bringing it together conceptually (still no full code) So your mental model in Go terms becomes: Convert s to b := []byte(s) to get a mutable working copy. Run your left/right collision pointers over b, using your vowel-check function/map on b[left] and b[right]. When both point to vowels, do a byte swap: b[left], b[right] = b[right], b[left]; this works fine because b is a slice, and Go allows this kind of tuple-assignment swap directly, no temp variable needed. After the loop finishes, convert back: string(b). A quick introduction to Big-O notation, and why this matter Good, let's tackle Big-O intuition first since it'll come up constantly in LeetCode, according to wikipedia: Big O notation is a mathematical framework used in computer science to describe how an algorithm's execution time or memory usage scales as the input size grows. What "O(1)" actually means Big-O notation describes how the cost of an operation grows as the input size grows. The letter usually used for input size is n. O(n) means: if you double the input size, the work roughly doubles too. A linear scan through an array is O(n), checking every element once. O(1) means: the amount of work stays constant, no matter how big the input is. It doesn't mean "one operation" literally, it means "a fixed, bounded number of operations that doesn't depend on n." Think of it like this analogy: imagine you have a giant phone book with a million names. O(n) approach: You start at page 1 and flip through every single page looking for "Smith." If the book had 10 names, that'd be fast. With a million names, it's painfully slow; the work scales with the size of the book. O(1) approach: Instead, imagine you have a magic index card that instantly tells you "Smith is on page 4213", you jump straight there. Whether the book has 10 names or 10 million, that lookup takes the same tiny amount of time. Why map lookups are O(1) A Go map is implemented as a hash table. When you do vowels[c], Go doesn't loop through every key checking for a match. Instead: It runs the key (c) through a hash function; a formula that converts the key into a number very quickly. That number tells it almost directly which "bucket" in memory to look in. It checks that bucket (usually just one item, sometimes a couple if there's a hash collision) and returns the result. This process takes roughly the same amount of time whether your map has 5 entries or 5 million, that's why it's called O(1) on average (technically "amortized average case," since hash collisions can rarely cause it to degrade, but for practical purposes, treat it as constant time). Compare that to strings.ContainsRune("aeiouAEIOU", rune(c)); internally, this does loop through the characters of that 10-character string comparing one by one until it finds a match or exhausts the string. It's technically O(k) where k is the length of that fixed string (10). Since 10 never changes, people often casually call it "O(1) too" because it's a bounded constant; but it's doing more actual comparisons than a map lookup, just not enough to matter for a 10-character set. The key exam-style distinction to remember: O(1) doesn't mean "instant" or "zero cost"; it means "cost that doesn't grow with your input size n." Now, the tuple swap Go has a really elegant built-in feature for swapping: multiple assignment. When you write: b[left], b[right] = b[right], b[left] Here's what happens under the hood, step by step: Go first evaluates the entire right-hand side of the assignment, in order, before assigning anything. So it reads the current value of b[right] and the current value of b[left], and holds both of them temporarily (essentially in a tuple, like (valueOfRight, valueOfLeft)). Then it assigns those held values to the left-hand side, in order: b[left] gets the first held value (old b[right]), and b[right] gets the second held value (old b[left]). Because Go evaluates the whole right-hand side first, there's no risk of accidentally overwriting b[left] before you've read its original value to place into b[right]. This is exactly why you don't need a temporary variable, unlike languages such as C, where a swap traditionally requires: temp = b[left]; b[left] = b[right]; b[right] = temp; In Go, the language does that temporary-holding for you internally, and lets you express it in one clean line. Remember: this works because slice indexing (b[left]) is addressable, meaning you can actually assign to that memory location. Recall this is exactly why we converted our string to a []byte earlier; string indexing (s[left]) is not addressable, so s[left], s[right] = s[right], s[left] would fail to compile, but b[left], b[right] = b[right], b[left] works perfectly because b is a slice. Writing the full solution You now have all the conceptual pieces: two-pointer collision logic, why/how to convert string ↔ []byte, []byte vs []rune, O(1) vowel checks, and how Go's swap syntax works. func reverseVowels(s string) string { vowels := map[byte]bool{ 'a': true, 'e': true, 'i': true, 'o': true, 'u': true, 'A': true, 'E': true, 'I': true, 'O': true, 'U': true, } b := []byte(s) left, right := 0, len(b) - 1 for left < right { if !vowels[b[left]] { left++ continue } if !vowels[b[right]] { right-- continue } b[left], b[right] = b[right], b[left] left++ right-- } return string(b) } Summary Three things worth keeping from this problem: Two-pointer collision is the pattern to reach for whenever a problem wants you to work from both ends of a sequence toward the middle, reversing, palindrome checks, and partitioning problems all lean on this same idea. Go strings are immutable. Any in-place-style string manipulation goes through a string → []byte → string round trip. A map is your O(1) toolbox for set membership, and Go's tuple assignment (a, b = b, a) gives you a clean swap for free, as long as both sides are addressable. This problem is small, but it's a solid vehicle for internalizing patterns that show up constantly in string and array problems on LeetCode. Let's connect! One of the best parts of writing in public is the people you meet along the way, engineers at different stages of their journey, working on similar problems from completely different angles. If something in this post resonated, if you spotted a bug, or if you just want to talk Go, Kubernetes, Platform Engineering, DevOps, or whatever, I'm always happy to hear from you. LinkedIn GitHub Twitter / X Building from Asunción, Paraguay 🇵🇾
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to