Implementing the General Tree and Depth-First-Search (DFS) in python! (from scratch) | by Jake | Towards Data Science

Recently, I published a story with Towards Data Science about implementing the Linked List from scratch. Reading that previous story isn’t necessary to absorb the core concepts here, but code used previously will be referenced; as such, I’ll linking GitHub Gists to both the Linked List and General Tree implementations.
As a graduate student in the data science domain, I often find that the statistical concepts receive an extensive amount of curriculum representation but fundamental CS concepts tend to be abstracted away. Perhaps for good reason; knowing how to implement a tree isn’t necessary to train a random forest. Higher level APIs allow us to leverage fundamental concepts without getting bogged down by the details.
By far, the binary tree is much, much more common in CS fundamentals. It’s marked by each tree having three mandatory elements: a value, a left child reference, and a right child reference. Anything else implemented is done so to meet the unique purposes foreseen by the programmer.
The general tree, on the other hand, is not limited to having exactly two children. This is especially useful because hierarchical data is extremely common on the web. For example, consider the task of exploring API query results. XML and JSON data alike are tree-like data. A general tree would give you the tools to quickly search the query results for specific strings, numeric values, etc.
Without being limited to precisely two or zero children, our data structure will be far more flexible than the binary tree, but it will also force us to make important design decisions. The most important of which is — a tree node must have a finite number of attributes. If any node can have any number of children, how do we navigate our way out of this conundrum? Lists, tuples, and dictionaries are a natural choice; a single general tree node could set its children attribute to a single variable, which could reference a variable number of general tree nodes.
But I promised to implement this from scratch and because I recently implemented the linked list from scratch, we’ll continue charging ahead with that same momentum. As a quick recap — linked lists are composed of nodes. Each node references the next node in sequence (and optionally the previous node — aka the doubly linked list.) The first node is the head and as such, it’s necessary to traverse from the head to any other node in the sequence. Insertions at the head position are trivial (compared to a dynamic array), you simply instantiate a new node, which references the head, and the head attribute is set as the linked list’s new head.
We’ll build upon the linked list in two ways: We’ll directly use the linked list to keep track of tree nodes, which we’ve already visited; and we’ll borrow the linked list in spirit to guide our design choices.
Consider the below diagram. A typical general tree will allow any child node to be accessed a given parent node. Because our data structure will build upon a linked list, a given parent will only reference one child, specifically, the leftmost child. To access its other children, we must traverse siblings. This isn’t an issue so long as we’re aware of it and our design choices account for it. Plus, we’re not borrowing from python’s built in data-types, which means we’re maximizing our learning experience.
Let’s start with the general tree node, which will have the following parameters: value, parent, left sibling, right sibling, and child. It’s essentially a doubly linked list on steroids because it is doubly linked in both horizontal and vertical terms. Much like the linked list, we’ll need to define getters and setters. These functions will set a node’s reference attribute to another node, or get the value of this value (if it exists).
So far we’ve talked about architecture but the real utility of a general tree comes from the ability to search it. There are multiple strategies to traverse a general tree; the two most common are breadth-first-search (BFS) and depth-first-search (DFS). We’ll only be implementing the latter today. At a high level, the strategy is to go as far down the first branch as we possibly can; when we can go down no further, we retreat to the preceding parent, and traverse to its first sibling. Can we go down? If so, we explore the depth of this branch. If we cannot, we explore the next sibling. This sounds extremely complicated, but it’s much easier when we think of it in terms of condition based policy.
Five scenarios of DFS:
If a child node exists and it has not yet been explored, traverse to the child.If a right sibling exists and it has not yet been explored, traverse to the right.If we are at the rightmost child and we can retreat to the left, traverse to the left. (retreat)If we are not at the leftmost child but we have already explored siblings to the right, traverse to the left. (retreat)Otherwise, traverse to the parent. Note: If none of the previous conditions are true, we must be at the leftmost child and all siblings to the right have already been explored. (retreat)
If a child node exists and it has not yet been explored, traverse to the child.
If a right sibling exists and it has not yet been explored, traverse to the right.
If we are at the rightmost child and we can retreat to the left, traverse to the left. (retreat)
If we are not at the leftmost child but we have already explored siblings to the right, traverse to the left. (retreat)
Otherwise, traverse to the parent. Note: If none of the previous conditions are true, we must be at the leftmost child and all siblings to the right have already been explored. (retreat)
The real trick here is using recursion, which is a skill every programmer should seek to master. A recursive function has two elements: a terminating condition and everything else. If the terminating condition is triggered, the function will exist and return some desired output. If the terminating condition is not met, the function will take steps to advance itself towards the terminating condition then call itself.
For our purposes we need to have some concept of where our program is at, for this we’ll define current_node and current_value variables. Every time we traverse to another node, we update these values. Our terminating condition is this: If the current value is the root node value or the search value, we exit. (Obviously, it’s important to have one unique name per node, let we exit prematurely.) If this condition is not met, we observe the five conditional rules above, execute whichever rule/strategy is appropriate according to the current node and repeat until we the terminating condition is met. That’s all there is to it.
We’re going to define three distinct, but very similar methods below. The first is depth_first_traversal. Here, we will explore the entire tree according to DFS protocol. There is no search value and so we only terminate when we reach the root node (i.e. we’ve explored all children of all children.) Second we’ll define depth_first_search. Here, we will supply a search value. If this value is reached, we’ll exit the loop. If we make it all the ways back to the root node, the value does not exist in the tree. Of note, these two functions will inform the programmer of the order in which the nodes were actually traversed. In other words, sibling traversals will be accounted for.
We will need to define a third function that’s useful on real, hierarchical data. For example, in a file storage system. Once you change directories to a given folder, you don’t need to traverse all files and folders until you’ve reached the desired folder before opening it. Rather, you simply traverse parent -> child and repeat until you’ve reached the desired sub-directory. The impact to us: Using our architecture, we need to purge sibling traversals, such that our output is identical to that of any other general tree.
This change isn’t as difficult as you might imagine. But before we discuss it, we need to talk about the commonalities of all three methods discussed above. A visited class attribute needs to be created. As discussed much earlier, this will be a linked list, where once a tree node has been visited, its value will be appended to (end of) the linked list. This allows all three methods to avoid infinitely traversing from parent to child to parent...again and again.
The latter two methods use variations of a path class attribute, which is — you guessed it — another linked list (the gift that keeps on giving!) The idea here is that only nodes, which do not involve retreats are maintained, unlike the visited attribute. The biggest difference between path and child_path is that the former includes traversal of siblings and the latter does not. The child path will of most interest as it will be useful in real world data, whereas the path attribute is only useful within the context of our program. In either case, using our five DFS conditional rules/strategies above, conditions 3,4,&5 are all retreats, meaning we purge these node values from our path/child path when retreating. (If the reasoning isn’t obvious yet, it’ll make more sense when we review the code. Simply put, these branches are dead ends, which we prune from our path/child path variables.)
Revisiting the terminating condition, the last thing that we do is reset for future iterations. This is accomplished by copying over our path to a temporary variable, redefining all other variables to their starting conditions, and returning the temporary variable. This will make much more sense in a moment — I promise! (If we don’t reset, we would need to re-instantiate our General Tree Class in order to search for a different value, as our variables would exist in non-starting conditions, triggering an error.)
And now — the code! Let’s review the linked list.
And on to the General Tree (finally!!)
Consider the below tree:
a1|b1 - b2 - b3 |         |d1        c1
If we run the below cells we’ll instantiate this tree into the General Tree class object then find the shortest paths (with and without siblings) from the root to the node, c1.
a1 = GeneralTreeNode(value='a1')b1 = GeneralTreeNode(value='b1')b2 = GeneralTreeNode(value='b2')b3 = GeneralTreeNode(value='b3')a1.set_child(b1)b1.set_parent(a1)b1.set_right(b2)b2.set_left(b1)b2.set_right(b3)b3.set_left(b2)c1 = GeneralTreeNode(value='c1')c1.set_parent(b3)b3.set_child(c1)d1 = GeneralTreeNode(value='d1')d1.set_parent(b1)b1.set_child(d1)r = GeneralTree(root=a1)r.depth_first_search(search_val='c1')>>>>Node:  a1Node:  b1Node:  b2Node:  b3Node:  c1r.child_depth_first_search(search_val='c1')>>>>Node:  a1Node:  b3Node:  c1
See the difference between the depth_first_search and child_depth_first_search? In the latter, the nodes b1 and b2 are omitted because they are not necessary traversals in the traditional definition of a general tree. This latter method is the one that you’ll want to use on real world problems (like our file system navigation example from earlier.)
Please contact me if you have thoughts on how this tutorial could be more helpful, ideas for expansion, request to collaborate etc. Notionally, I plan to either create an XML parser, JSON parser, or use trees to search nested dictionaries, etc.
I hope you enjoyed diving deep into the fundamentals with me today!