While working on some parsing problems, I encountered an interesting problem that I haven't seen elsewhere and want to share it (with its solution).
When parsing, we often deal with parse tree traversals. Normally, the pre-order traversal is most convenient for processing, but parsers often don't produce parse results in that order.
Let's define a mixed traversal as a sequence of the following kinds of steps (one might also call them events):
n-th last nodepayload is an information necessary to create a node, e.g. its type.
The sequence is processed left-to-right, and the tree is built incrementally, using a stack of nodes:
The “mixed” traversal is a superset of how different kinds of parsing algorithms construct a parse tree:
The problem is as follows: Given a sequence of steps corresponding to a mixed traversal, produce a sequence of steps corresponding to an equivalent pre-order traversal.
Let's consider the following syntax tree that corresponds to a * b + c:
The pre-order traversal (top-down, left-to-right; which is what we want to ideally have) of this tree would be:
Start "+"
Start "*"
Start "a"
End
Start "b"
End
Start "c"
End
End
The bottom-up, left-to-right traversal would be (e.g., how an LR parser would process the string):
Start "a"
End
Start "b"
End
WrapSiblings "*" 2
End
Start "c"
End
WrapSiblings "+" 2
End
The other option for the traversal is (e.g., how a Pratt parser would process the string):
Start "a"
End
WrapSiblings "*" 1
Start "b"
End
End
WrapSiblings "+" 1
Start "c"
End
End
The simplest algorithm is as follows:
S of active nodes. Initially, S contains a sentinel virtual root. Each node in S has id and children, where children is a vector of nodes.StartNode: create a new node and push it into S.EndNode: pop a node from S, and push it into children of the new top node of the stack S.WrapSiblings(n): take last n items from children of the top node of S, create a new node with those n items, and push it into S.R into events by a pre-order traversal of trees in R.This algorithm is fine. It has a run time O(|input|), but it performs dynamic memory-allocations for individual nodes and has to allocate the whole tree.
WrapSiblings comes in later and changes the tree structure.
On the other hand, when processing it right-to-left, the structure we already emitted can'be affected by not-yet-processed events.WrapSiblings(n) means “after emitting n nodes, emit StartNode”.StartNode and WrapSiblings (i.e., for which the corresponding EndNode was not yet observed in left-to-right order) belong to different tree levels.WrapSiblings steps may stack within each other, and their emit order may be reversed to their observation order.StartNode, EndNode) pair may be wrapped by multiple WrapSiblings events, i.e. one node can count towards siblings counts of multiple WrapSiblings events.The algorithm normalizes the sequence using a right-to-left scan of input sequence. It uses a stack as an auxiliary data structure to track information about active events at each tree level.
S[n] - an array of tree traversal steps (as defined previously)O[n] - an array that holds elements of the same type as S (but only items of types StartNode and EndNode may appear).L: array of Option<{ payload, num_nodes }> - growable array of that tracks for each tree depth level a currently active WrapSiblings (i.e., which was observed but which is still waiting for one or more nodes before it can be emitted); L is initially empty and may contain empty slots.
payload is the payload that should be emitted as a part of an equivalent StartNode(payload)num_nodes is the counter of remaining nodes before WrapSiblings can be converted into StartNodeO = []i starting from n - 1 down to 0:
S[i]:
EndNode:
null into SEndNode into OWrapSiblings { payload, num_nodes }:
k - the index of the last null value in L. Such k should always exist for a valid input sequence. Use simple linear search to find k.{ payload = S[i].payload, num_nodes = S[i].num_nodes } into L[k]StartNode { payload }:
StartNode { payload } into OL is not empty and it last value is an empty slot. This holds true for all valid input sequences.LWrapSiblings that have been completed after emitting StartNode:
num_emitted = 1. This variable tracks the number of node we have emitted in this iteration.j from length(L) - 1 down to 0:
L[j] is empty: break the for loopL[j].num_nodes >= num_emitted. This holds true for all valid input sequences.L[j].num_nodes = num_emitted:
StartNode { payload = L[j].payload } into Oj = length(L) - 1. This holds for all valid input sequencesL[j] from Lnum_emittedL[j].num_nodes by num_emittedL is empty. This holds true for all valid input sequences.OThe tricky part of the algorithm is the properly considering stacking of active WrapSiblings:
WrapSiblings are emitted in reverse order, therefore they are added to a stack of level in reverse order (i.e., by looking for a free slot) as wellWrapSiblings steps, and WrapSiblings could also be a child of another node produced by WrapSiblings. As a result of this, when processing events right-to-left, a completed StartNode (including the one produced by completing a WrapSiblings) leads to a decrement of num_nodes for all consecutive slots of L.This algorithm runs in O(n * d) time and uses O(n + d) additional memory, where d is the depth of the tree. In the worst case, d is proportional N, and the runtime of this algorithm may degenerate to O(n^2).
This algorithm can be improved by several simple mechanical optimizations.
L[j].num_nodesEach consecutively stacked WrapSiblings must fully exhaust its num_nodes before the next one can be completed. Therefore we can decrement num_nodes of stacked WrapSiblings in advance, in which case each processed StartNode will only require a single decrement. At the same time we must also consider that completing a pending WrapSiblings produces StartNode event, which also result in further decrements of num_nodes on further levels. The overall result of this is that we need to pre-subtract num_nodes - 1 from each consecutiely-stacked WrapSiblings.
We may observe that the output of the algorithm fits into an already-visited part of the input array S.
And since the structure of the output is the same, we can reuse the input as an output and modify it in-place.
This lets us avoid O(n) of additional memory allocation, but does not change the runtime.
LSearching for k - a pointer to a free slot in L - is O(d), but it can be made O(1).
The search can be optimized, if we consider that L operates in almost (but not quite) a stack-like fashion, as all reads and writes are happening at the end of L.
In addition to L, maintain F: array of { slot_idx, sum_num_nodes_sub_1 } - a stack of indices of all free slots in L. This data structure would let us quickly find the last free slot in L.
Each item of F contains 2 attributes:
slot_idx - index in L of the free slot;sum_num_nodes_sub_1 - sum of (num_nodes - 1) of all occupied slots until the next free slot.This stack is maintained and used in the following way:
L, push { slot_idx: level_idx, sum_num_nodes_sub_1: 0} into FL:
FL:
num_nodes equals 1num_nodes - 1 = 1 - 1 = 0 does not change the value of sum_num_nodes_sub_1v into an empty slot of L:
F only has 1 value: pop the valueF has 2 or more values: merge the last 2 values in F, taking into account v.num_nodes - 1. I.e.:
a from Fb from F{ slot_idx: b.slot_idx, sum_num_nodes_sub_1 = a.sum_num_nodes_sub_1 + b.sum_num_nodes_sub_1 + v.num_nodes - 1 }L:
F; its slot_idx is the last free slot index (i.e., k in the base algorithm), and its sum_num_nodes_sub_1 is the sum of (num_nodes - 1) for the remaining non-empty slots (i.e., equals to r in the base algorithm)With these optimizations, the algorithm runs in O(n) time with O(d) additional memory.
Source code is available at https://codeberg.org/dmitryvk/convert-mixed-order-traversal. It contains the algorithm, as well as benchmarks and tests.