Skip to content

Commit bcb5300

Browse files
colinleachBethanyG
andauthored
[Matching Brackets] draft approaches (#3670)
* [Matching Brackets] draft approaches * [Matching Brackets] Approaches Review & Edits * Additional grammar and spelling edits * Final Edits Hopefully, the final edits. 😄 * Un crossed left vs right --------- Co-authored-by: BethanyG <[email protected]>
1 parent e06436a commit bcb5300

File tree

11 files changed

+480
-0
lines changed

11 files changed

+480
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"introduction": {
3+
"authors": [
4+
"colinleach",
5+
"BethanyG"
6+
]
7+
},
8+
"approaches": [
9+
{
10+
"uuid": "449c828e-ce19-4930-83ab-071eb2821388",
11+
"slug": "stack-match",
12+
"title": "Stack Match",
13+
"blurb": "Maintain context during stream processing by use of a stack.",
14+
"authors": [
15+
"colinleach",
16+
"BethanyG"
17+
]
18+
},
19+
{
20+
"uuid": "b4c42162-751b-42c8-9368-eed9c3f4e4c8",
21+
"slug": "repeated-substitution",
22+
"title": "Repeated Substitution",
23+
"blurb": "Use substring replacement to iteratively simplify the string.",
24+
"authors": [
25+
"colinleach",
26+
"BethanyG"
27+
]
28+
}
29+
]
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Introduction
2+
3+
The aim in this exercise is to determine whether opening and closing brackets are properly paired within the input text.
4+
5+
These brackets may be nested deeply (think Lisp code) and/or dispersed among a lot of other text (think complex LaTeX documents).
6+
7+
Community solutions fall into two main groups:
8+
9+
1. Those which make a single pass or loop through the input string, maintaining necessary context for matching.
10+
2. Those which repeatedly make global substitutions within the text for context.
11+
12+
13+
## Single-pass approaches
14+
15+
```python
16+
def is_paired(input_string):
17+
bracket_map = {"]" : "[", "}": "{", ")":"("}
18+
tracking = []
19+
20+
for element in input_string:
21+
if element in bracket_map.values():
22+
tracking.append(element)
23+
if element in bracket_map:
24+
if not tracking or (tracking.pop() != bracket_map[element]):
25+
return False
26+
return not tracking
27+
```
28+
29+
The key in this approach is to maintain context by pushing open brackets onto some sort of stack (_in this case appending to a `list`_), then checking if there is a corresponding closing bracket to pair with the top stack item.
30+
31+
See [stack-match][stack-match] approaches for details.
32+
33+
34+
## Repeated-substitution approaches
35+
36+
```python
37+
def is_paired(text):
38+
text = "".join(item for item in text if item in "()[]{}")
39+
while "()" in text or "[]" in text or "{}" in text:
40+
text = text.replace("()","").replace("[]", "").replace("{}","")
41+
return not text
42+
```
43+
44+
In this approach, we first remove any non-bracket characters, then use a loop to repeatedly remove inner bracket pairs.
45+
46+
See [repeated-substitution][repeated-substitution] approaches for details.
47+
48+
49+
## Other approaches
50+
51+
Languages prizing immutibility are likely to use techniques such as `foldl()` or recursive matching, as discussed on the [Scala track][scala].
52+
53+
This is possible in Python, but can read as unidiomatic and will (likely) result in inefficient code if not done carefully.
54+
55+
For anyone wanting to go down the functional-style path, Python has [`functools.reduce()`][reduce] for folds and added [structural pattern matching][pattern-matching] in Python 3.10.
56+
57+
Recursion is not highly optimised in Python and there is no tail call optimization, but the default stack depth of 1000 should be more than enough for solving this problem recursively.
58+
59+
60+
## Which approach to use
61+
62+
For short, well-defined input strings such as those currently in the test file, repeated-substitution allows a passing solution in very few lines of code.
63+
But as input grows, this method could become less and less performant, due to the multiple passes and changes needed to determine matches.
64+
65+
The single-pass strategy of the stack-match approach allows for stream processing, scales linearly (_`O(n)` time complexity_) with text length, and will remain performant for very large inputs.
66+
67+
Examining the community solutions published for this exercise, it is clear that many programmers prefer the stack-match method which avoids the repeated string copying of the substitution approach.
68+
69+
Thus it is interesting and perhaps humbling to note that repeated-substitution is **_at least_** as fast in benchmarking, even with large (>30 kB) input strings!
70+
71+
See the [performance article][article-performance] for more details.
72+
73+
[article-performance]:https://exercism.org/tracks/python/exercises/matching-brackets/articles/performance
74+
[pattern-matching]: https://docs.python.org/3/whatsnew/3.10.html#pep-634-structural-pattern-matching
75+
[reduce]: https://docs.python.org/3/library/functools.html#functools.reduce
76+
[repeated-substitution]: https://exercism.org/tracks/python/exercises/matching-brackets/approaches/repeated-substitution
77+
[scala]: https://exercism.org/tracks/scala/exercises/matching-brackets/dig_deeper
78+
[stack-match]: https://exercism.org/tracks/python/exercises/matching-brackets/approaches/stack-match
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Repeated Substitution
2+
3+
4+
```python
5+
def is_paired(text):
6+
text = "".join([element for element in text if element in "()[]{}"])
7+
while "()" in text or "[]" in text or "{}" in text:
8+
text = text.replace("()","").replace("[]", "").replace("{}","")
9+
return not text
10+
```
11+
12+
In this approach, the steps are:
13+
14+
1. Remove all non-bracket characters from the input string (_as done through the filter clause in the list-comprehension above_).
15+
2. Iteratively remove all remaining bracket pairs: this reduces nesting in the string from the inside outwards.
16+
3. Test for a now empty string, meaning all brackets have been paired.
17+
18+
19+
The code above spells out the approach particularly clearly, but there are (of course) several possible variants.
20+
21+
22+
## Variation 1: Walrus Operator within a Generator Expression
23+
24+
25+
```python
26+
def is_paired(input_string):
27+
symbols = "".join(char for char in input_string if char in "{}[]()")
28+
while (pair := next((pair for pair in ("{}", "[]", "()") if pair in symbols), False)):
29+
symbols = symbols.replace(pair, "")
30+
return not symbols
31+
```
32+
33+
The second solution above does essentially the same thing as the initial approach, but uses a generator expression assigned with a [walrus operator][walrus] `:=` (_introduced in Python 3.8_) in the `while-loop` test.
34+
35+
36+
## Variation 2: Regex Substitution in a While Loop
37+
38+
Regex enthusiasts can modify the previous approach, using `re.sub()` instead of `string.replace()` in the `while-loop` test:
39+
40+
```python
41+
import re
42+
43+
def is_paired(text: str) -> bool:
44+
text = re.sub(r'[^{}\[\]()]', '', text)
45+
while text != (text := re.sub(r'{\}|\[]|\(\)', '', text)):
46+
continue
47+
return not bool(text)
48+
```
49+
50+
51+
## Variation 3: Regex Substitution and Recursion
52+
53+
54+
It is possible to combine `re.sub()` and recursion in the same solution, though not everyone would view this as idiomatic Python:
55+
56+
57+
```python
58+
import re
59+
60+
def is_paired(input_string):
61+
replaced = re.sub(r"[^\[\(\{\}\)\]]|\{\}|\(\)|\[\]", "", input_string)
62+
return not input_string if input_string == replaced else is_paired(replaced)
63+
```
64+
65+
Note that solutions using regular expressions ran slightly *slower* than `string.replace()` solutions in benchmarking, so adding this type of complexity brings no benefit to this problem.
66+
67+
[walrus]: https://martinheinz.dev/blog/79/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def is_paired(text):
2+
text = "".join(element for element in text if element in "()[]{}")
3+
while "()" in text or "[]" in text or "{}" in text:
4+
text = text.replace("()","").replace("[]", "").replace("{}","")
5+
return not text
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Stack Match
2+
3+
4+
```python
5+
def is_paired(input_string):
6+
bracket_map = {"]" : "[", "}": "{", ")":"("}
7+
stack = []
8+
9+
for element in input_string:
10+
if element in bracket_map.values():
11+
stack.append(element)
12+
if element in bracket_map:
13+
if not stack or (stack.pop() != bracket_map[element]):
14+
return False
15+
return not stack
16+
```
17+
18+
The point of this approach is to maintain a context of which bracket sets are currently "open":
19+
20+
- If a left bracket is found, push it onto the stack (_append it to the `list`_).
21+
- If a right bracket is found, **and** it pairs with the last item placed on the stack, pop the bracket off the stack and continue.
22+
- If there is a mismatch, for example `'['` with `'}'` or there is no left bracket on the stack, the code can immediately terminate and return `False`.
23+
- When all the input text is processed, determine if the stack is empty, meaning all left brackets were matched.
24+
25+
In Python, a [`list`][concept:python/lists]() is a good implementation of a stack: it has [`list.append()`][list-append] (_equivalent to a "push"_) and [`lsit.pop()`][list-pop] methods built in.
26+
27+
Some solutions use [`collections.deque()`][collections-deque] as an alternative implementation, though this has no clear advantage (_since the code only uses appends to the right-hand side_) and near-identical runtime performance.
28+
29+
The default iteration for a dictionary is over the _keys_, so the code above uses a plain `bracket_map` to search for right brackets, while `bracket_map.values()` is used to search for left brackets.
30+
31+
Other solutions created two sets of left and right brackets explicitly, or searched a string representation:
32+
33+
```python
34+
if element in ']})':
35+
```
36+
37+
Such changes made little difference to code length or readability, but ran about 5-fold faster than the dictionary-based solution.
38+
39+
At the end, success is an empty stack, tested above by using the [False-y quality][falsey] of `[]` (_as Python programmers often do_).
40+
41+
To be more explicit, we could alternatively use an equality:
42+
43+
```python
44+
return stack == []
45+
```
46+
47+
[list-append]: https://docs.python.org/3/tutorial/datastructures.html#more-on-lists
48+
[list-pop]: https://docs.python.org/3/tutorial/datastructures.html#more-on-lists
49+
[collections-deque]: https://docs.python.org/3/library/collections.html#collections.deque
50+
[falsey]: https://docs.python.org/3/library/stdtypes.html#truth-value-testing
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
bracket_map = {"]" : "[", "}": "{", ")":"("}
2+
stack = []
3+
for element in input_string:
4+
if element in bracket_map.values(): tracking.append(element)
5+
if element in bracket_map:
6+
if not stack or (stack.pop() != bracket_map[element]):
7+
return False
8+
return not stack
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"articles": [
3+
{
4+
"uuid": "af7a43b5-c135-4809-9fb8-d84cdd5138d5",
5+
"slug": "performance",
6+
"title": "Performance",
7+
"blurb": "Compare a variety of solutions using benchmarking data.",
8+
"authors": [
9+
"colinleach",
10+
"BethanyG"
11+
]
12+
}
13+
]
14+
}

0 commit comments

Comments
 (0)