The valid parentheses problem is an easy problem to solve. The algorithm is:
stack = []
for every char in str
if char is an opening parenthesis
stack.push (char)
if char is a closing parenthesis
if stack is empty
error ()
top = stack.pop ()
if char does not match top
error ()
if stack is not empty
error ()
success ()
When I thought of ways to reduce the memory usage of this algorithm, bit-packing came to my mind.
Using a rune
(32-bit integer) for each parenthesis seemed wasteful. So, I
decided to use an 8-bit integer, but the problem was, I'd need to use at least
7 bits to represent a parenthesis, because the biggest ASCII code-point I needed
to store was 123, and it required 7 bits (01111011
). Then I quickly realized I
didn't need to use ASCII. I can encode them however I want. I gave each kind of parenthesis a number:
1 for round, 2 for braces, and 3 for square brackets
The next task was to fit multiple parens inside an integer. I chose []uint8
as the stack.
Since each paren used only 2 bits, I could fit 4 parens inside an 8 bit number. When a number
was used up, I pushed a new number.
In the end, I successfully wrote the program.
Here's the Go code followed by a brief explanation.
func isValid(s string) bool {
const (
paren uint8 = 1
curly uint8 = 2
square uint8 = 3
)
stack := []uint8{0}
shift := 0
for _, c := range s {
if shift == 8 {
stack = append(stack, 0)
shift = 0
}
var kind uint8
switch c {
case '(', ')':
kind = paren
case '{', '}':
kind = curly
case '[', ']':
kind = square
}
switch c {
case '(', '{', '[':
stack[len(stack)-1] |= (kind << shift)
shift += 2
default:
shift -= 2
if shift < 0 {
if len(stack) > 1 {
stack = stack[:len(stack)-1]
}
shift = 6
}
top := stack[len(stack)-1]
if (top>>shift)&kind != kind {
return false
}
}
}
return len(stack) == 1 && shift == 0
}
The shift
variable is important. It keeps track of the next bit position in the "top" number.
When the program encounters an opening paren, it places the 2-bit representation of the paren where
shift
points to. It does this by combining two bit patterns using the bitwise OR operator (|
).
In the default
case, shift
is immediately reduced by 2. This is equivalent to top = stack.pop ()
.
Then the program checks whether the top number is still needed or can be discarded.
The shift
variable is set to 6
if we pop the stack. This ensures the next bit-pattern is placed
in the most significant 2 bits of the number. To check whether the parens match, we perform a mask on
the number. The mask is the opening paren we are looking for.
If the loop terminates without errors, then we check if we have matched all opening parens with their lovers
by checking that the stack contains a single number and that shift
is 0.
Notes
I used to zero-out the opening paren's bit-pattern when it was matched using this line of code:
stack[len(stack)-1] &^= kind << shift
. It was redundant. Just overwrite the number.shift
is guiding you anyways.The solution beats
98.52%
(using 3.95 MB) of solutions in memory consumption. Not always; sometimes it uses 4.14 MB, which beats63.49%
of solutions. The difference is small, but the change in percentage is big.
Experienced programmers might see a lot of other opportunities for improvement or even different ways of solving this problem.
It was a fun bit-packing exercise.
Have a nice day.