Generic Trees

Download exercises zip

(before editing read whole introduction sections 0.x)

Browse files online

0. Introduction

Before tackling generic trees please see first binary trees

What to do

  • unzip exercises in a folder, you should get something like this:

trees
   gen_tree.py
   gen_tree_sol.py
   gen_tree_test.py
   bin_tree_test.py
   bin_tree.py
   bin_tree_sol.py
   jupman.py
   sciprog.py
   trees.ipynb
  • open the editor of your choice (for example Visual Studio Code, Spyder or PyCharme), you will edit the files ending in .py files

  • Go on reading this notebook, and follow instuctions inside.

0.1 References

See

labeled iiuiue9

In this worksheet we are going to provide an implementation of a GenericTree class:

  • Why GenericTree ? Because many object hierarchies in real life tend to have many interlinked pointers this, in one form or another

  • Differently from the LinkedList, which actually had two classes Node and LinkedList that was pointing to the first node, in this case we just have one GenericTree class. So to grow a tree like the above one in the picture, for each of the boxes that you see we will need to create one instance of GenericTree and link it to the other instances.

  • Ordinary simple trees just hold pointers to the children. In this case, we have an enriched tree which holds ponters also up to the parent and on the right to the siblings. Whenever we are going to manipulate the tree, we need to take good care of updating these pointers.

Do we need sidelinks and backlinks ?:

Here we use sidelinks and backlinks like _sibling and _parent for exercise purposes, but keep in mind such extra links need to be properly managed when you write algorithms and thus increase the likelihood of introducing bugs.

As a general rule of thumb, if you are to design a data structure, always first try to start making it unidirectional (like for example the BinaryTree we’ve seen before). Then, if you notice you really need extra links (for example to quickly traverse a tree from a node up to the root), you can always add them in a later development iteration.

ROOT NODE: In this context, we call a node root if has no incoming edges and it has no parent nor sibling

DETACHING A NODE: In this context, when we detach a node from a tree, the node becomes the root of a new tree, which means it will have no link anymore with the tree it was in.

0.2 Code skeleton

Look at the files:

  • trees/gen_tree.py : the exercise to edit

  • trees/gen_tree_test.py: the tests to run. Do not modify this file.

Before starting to implement methods in GenericTree class, read all the following sub sections (starting with ‘0.x’)

0.3 Building trees

Let’s learn how to build GenericTree. For these trials, feel free to launch a Python 3 interpreter and load this module:

[2]:
from gen_tree_sol import *

0.3.1 Pointers

A GenericTree class holds 3 pointers that link it to the other nodes: _child, _sibling and _parent. So this time we have to manage more pointers, in particular beware of the _parent one which as a matter of fact creates cycles in the structure.

It also holds a value data which is provided by the user to store arbitrary data (could be ints, strings, lists, even other trees, we don’t care):

class GenericTree:

    def __init__(self, data):
        self._data = data
        self._child = None
        self._sibling = None
        self._parent = None

To create a tree of one node, just call the constructor passing whatever you want like this:

[3]:
tblah = GenericTree("blah")
tn = GenericTree(5)

Note that with the provided constructor you can’t pass children.

0.3.2 Building with insert_child

To grow a GenericTree, as basic building block you will have to implement insert_child:

def insert_child(self, new_child):
    """ Inserts new_child at the beginning of the children sequence. """

WARNING: here we insert a node !!

Differently from the BinaryTree, this time instead of passing data we pass a node. This can cause more troubles than before, as when we add a new_child we must be careful it doesn’t have wrong pointers. For example, think the case when you insert node B as child of node A, but by mistake you previously set B _child field to point to A. Such a cycle would not be a tree anymore and would basically disrupt any algorithm you would try to run.

You can call it like this:

[4]:

ta = GenericTree('a')
print(ta)   # 'a' is the root

a
[5]:
tb = GenericTree('b')
ta.insert_child(tb)
print(ta)
a
└b
a     'a' is the root
└b    'b' is the child . The '└' means just that it is also the last child of the siblings sequence
[6]:
tc = GenericTree('c')
ta.insert_child(tc)
print(ta)
a
├c
└b
a          # 'a' is the root
├c         # 'c' is inserted as the first child (would be shown on the left in the graph image)
└b         # 'b' is now the next sibling of c  The '\' means just that it
           #  is also the last child of the siblings sequence
[7]:
td = GenericTree('d')
tc.insert_child(td)
print(ta)
a
├c
│└d
└b
a         # 'a' is the root
├c        # 'c' is the first child of 'a'
|└d       # 'd' is the first child of 'c'
└b        # 'b' is the next sibling of c

0.3.3 Building with gt

If you need to test your data structure, we provide you with this handy function gt in gen_tree_test module that allows to easily construct trees from other trees.

WARNING: DO NOT USE gt inside your implementation code !!!! gt is just meant for testing.

def gt(*args):
    """ Shorthand function that returns a GenericTree containing the provided
        data and children. First parameter is the data, the following ones are the children.
[8]:
# first remember to import it from gen_tree_test:

from gen_tree_test import gt

# NOTE: this function is _not_ a class method, you can directly invoke it like this:
print(gt('a'))
a
[9]:
# NOTE: the external call gt('a', ......... )  INCLUDES gt('b') and gt('c') in the parameters !

print(gt('a', gt('b'), gt('c')))

a
├b
└c

0.4 Displaying trees side by side with str_trees

If you have a couple of trees, like the actual one you get from your method calls and the one you expect, it might be useful to display them side by side with the str_trees method in gen_tree_test module:

[10]:
# first remember to import it:

from gen_tree_test import str_trees

# NOTE: this function is _not_ a class method, you can directly invoke it like this:
print(str_trees(gt('a', gt('b')), gt('x', gt('y'), gt('z'))))
ACTUAL    EXPECTED
a         x
└b        ├y
          └z

0.5 Look at the tests

Have a look at the gen_tree_test.py file header, notice it imports GenericTree class from exercises file gen_tree:

from gen_tree import *
import unittest

0.6 Look at gen_tree_test.GenericTreeTest

Have a quick look at GenericTreeTest definitions inside gen_tree_test :

class GenericTreeTest(unittest.TestCase):

    def assertReturnNone(self, ret, function_name):
        """ Asserts method result ret equals None """

    def assertRoot(self, t):
        """ Checks provided node t is a root, if not raises Exception """

    def assertTreeEqual(self, t1, t2):
        """ Asserts the trees t1 and t2 are equal """

We see we added extra asserts you will later find used around in test methods. Of these ones, the most important is assertTreeEqual: when you have complex data structures like trees, it is helpful being able to compare the tree you obtain from your method calls to the tree you expect. This assertion we created provides a way to quickly display such differences.

1 Implement basic methods

labeled 99f9guggo

Start editing gen_tree.py, implementing methods in GenericTree in the order you find them in the next points.

IMPORTANT: All methods and functions without written inside raise Exception("TODO IMPLEMENT ME!") are already provided and you don’t need to edit them !

1.1 insert_child

Implement method insert_child, which is the basic building block for our GenericTree:

WARNING: here we insert a node !!

Differently from the BinaryTree, this time instead of passing data we pass a node. This implies that inside the insert_child method you will have to take care of pointers of new_child: for example, you will need to set the _parent pointer of new_child to point to the current node you are attaching to (that is, self)

def insert_child(self, new_child):
    """ Inserts new_child at the beginning of the children sequence. """

IMPORTANT: before proceding, make sure the tests for it pass by running:

python3 -m unittest gen_tree_test.InsertChildTest

QUESTION: Look at the tests, they are quite thourough and verbose. Why ?

1.2 insert_children

Implement insert_children:

def insert_children(self, new_children):
    """ Takes a list of children and inserts them at the beginning of the
        current children sequence,

        NOTE: in the new sequence new_children appear in the order they
              are passed to the function!


        For example:
            >>> t = gt('a', gt('b'), gt('c))
            >>> print t

            a
            ├b
            └c

            >>>  t.insert_children([gt('d'), gt('e')])
            >>> print t

            a
            ├d
            ├e
            ├b
            └c
    """

HINT 1: try to reuse insert_child, but note it inserts only to the left. Calling it on the input sequence you would get wrong ordering in the tree.

WARNING: Function description does not say anything about changing the input new_children, so users calling your method don’t expect you to modify it ! However, you can internally produce a new Python list out of the input one, if you wish to.

Testing: python3 -m unittest gen_tree_test.InsertChildrenTest

1.3 insert_sibling

Implement insert_sibling:

def insert_sibling(self, new_sibling):
    """ Inserts new_sibling as the *immediate* next sibling.

        If self is a root, raises an Exception
    """

Testing: python3 -m unittest tree_test.InsertSiblingTest

Examples:

[11]:
tb = gt('b')
ta = gt('a', tb, gt('c'))
print(ta)
a
├b
└c
[12]:
tx = gt('x', gt('y'))
print(tx)
x
└y
[13]:
tb.insert_sibling(tx)
print(ta)
a
├b
├x
│└y
└c

QUESTION: if you call insert_sibling an a root node such as ta, you should get an Exception. Why? Does it make sense to have parentless brothers ?

ta.insert_sibling(g('z'))
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
<ipython-input-35-a1e4ba8b1ee5> in <module>()
----> 1 ta.insert_sibling(gt('z'))

~/Da/prj/sciprolab2/prj/trees/tree_sol.py in insert_sibling(self, new_sibling)
    128         """
    129         if (self.is_root()):
--> 130             raise Exception("Can't add siblings to a root node !!")
    131
    132         new_sibling._parent = self._parent

Exception: Can't add siblings to a root node !!

1.4 insert_siblings

Testing: python3 -m unittest tree_test.InsertSiblingsTest

1.5 detach_child

QUESTION: does a detached child have still any parent or sibling ?

Testing: python3 -m unittest tree_test.DetachChildTest

1.6 detach_sibling

Testing: python3 -m unittest tree_test.DetachSiblingTest

1.7 detach

Testing: python3 -m unittest tree_test.DetachTest

1.8 ancestors

labeled iu9ug8g9

Implement ancestors:

def ancestors(self):
        """ Return the ancestors up until the root as a Python list.
            First item in the list will be the parent of this node.

            NOTE: this function return the *nodes*, not the data.
        """

Testing: python3 -m unittest gen_tree_test.AncestorsTest

Examples:

  • ancestors of p: f, b, a

  • ancestors of h: c, a

  • ancestors of a: empty list

2 Implement more complex functions

After you understood well and implemented the previous methods, you can continue with the following ones:

2.1 grandchildren

Implement the grandchildren method. NOTE: it returns the data inside the nodes, NOT the nodes !!!!!

def grandchildren(self):
    """ Returns a python list containing the data of all the
        grandchildren of this node.

        - Data must be from left to right order in the tree horizontal
          representation (or up to down in the vertical representation).
        - If there are no grandchildren, returns an empty array.

        For example, for this tree:

        a
        ├b
        │├c
        │└d
        │ └g
        ├e
        └f
         └h

        Returns ['c','d','h']
    """

Testing: python3 -m unittest gen_tree_test.ZagTest

Examples:

[14]:
ta = gt('a', gt('b', gt('c')))
print(ta)
a
└b
 └c
[15]:
print(ta.grandchildren())
['c']
[16]:
ta = gt('a', gt('b'))
print(ta)
a
└b
[17]:
print(ta.grandchildren())
[]
[18]:
ta = gt('a', gt('b', gt('c'), gt('d')), gt('e', gt('f')) )
print(ta)
a
├b
│├c
│└d
└e
 └f
[19]:
print(ta.grandchildren())
['c', 'd', 'f']

2.2 Zig Zag

Here you will be visiting a generic tree in various ways.

labeled jii4u43

2.2.1 zig

The method zig must return as output a list of data of the root and all the nodes in the chain of child attributes. Basically, you just have to follow the red lines and gather data in a list, until there are no more red lines to follow.

Testing: python3 -m unittest tree_test.ZigTest

Examples: in the labeled tree in the image, these would be the results of calling zig on various nodes:

From a: ['a','b', 'e']
From b: ['b', 'e']
From c: ['c', 'g']
From h: ['h']
From q: ['h']

2.2.2 zag

This function is quite similar to zig, but this time it gathers data going right, along the sibling arrows.

Testing: python3 -m unittest gen_tree_test.ZagTest

Examples: in the labeled tree in the image, these would be the results of calling zag on various nodes:

From a : ['a']
From b : ['b', 'c', 'd']
From o : ['o', 'p']

2.2.3 zigzag

As you are surely thinking, zig and zag alone are boring. So let’s mix the concepts, and go zigzaging. This time you will write a function zigzag, that first zigs collecting data along the child vertical red chain as much as it can. Then, if the last node links to at least a sibling, the method continues to collect data along the siblings horizontal chain as much as it can. At this point, if it finds a child, it goes zigging again along the child vertical red chain as much as it can, and then horizontal zaging, and so on. It continues zig-zaging like this until it reaches a node that has no child nor sibling: when this happens returns the list of data found so far.

Testing: python3 -m unittest tree_test.ZigZagTest

Examples: in the labeled tree in the image, these would be the results of calling zigzag on various nodes:

From a: ['a', 'b', 'e', 'f', 'o']
From c: ['c', 'g', 'h', 'i', 'q'] NOTE: if node h had a child z, the process would still proceed to i
From d: ['d', 'm', 'n']
From o: ['o', 'p']
From n: ['n']

2.3 uncles

Implement the uncles method:

def uncles(self):
    """ RETURN a python list containing the data of all the uncles
        of this node (that is, *all* the siblings of its parent).

        NOTE: returns also the father siblings which are *BEFORE*
              the father !!

        - Data must be from left to right order in the tree horizontal
          representation (or up to down in the vertical representation)
        - If there are no uncles, returns an empty array.

        For example, for this tree:

        a
        ├b
        │├c
        │└d
        │ └g
        ├e
        │└h
        └f

        calling this method on 'h' returns ['b','f']
    """

Testing: python3 -m unittest gen_tree_test.UnclesTest

Example usages:

[20]:
td = gt('d')
tb = gt('b')
ta = gt('a', tb,  gt('c', td), gt('e'))
print(ta)
a
├b
├c
│└d
└e
[21]:
print(td.uncles())
['b', 'e']
[22]:
print(tb.uncles())
[]

2.4 common_ancestor

labeled iiug9f9

Implement the method common_ancestor:

def common_ancestor(self, gt2):
    """ RETURN the first common ancestor of current node and the provided
        gt2 node

        - If gt2 is not a node of the same tree, raises LookupError

        NOTE: this function returns a *node*, not the data.

        Ideally, this method should perform in O(h) where h is the height
        of the tree.

        HINT: you should use a Python Set). If you can't figure out how
              to make it that fast, try to make it at worst O(h^2)

    """

Testing: python3 -m unittest gen_tree_test.CommonAncestorTest

Examples:

  • common ancestor of g and i: tree rooted at c

  • common_ancestor of g and q: tree rooted at c

  • common_ancestor of e and d: tree rooted at a

2.5 mirror

def mirror(self):
    """ Modifies this tree by mirroring it, that is, reverses the order
        of all children of this node and of all its descendants

        - MUST work in O(n) where n is the number of nodes
        - MUST change the order of nodes, NOT the data (so don't touch the
               data !)
        - DON'T create new nodes
        - It is acceptable to use a recursive method.


        Example:

        a     <-    Becomes:    a
        ├b                      ├i
        │├c                     ├e
        │└d                     │├h
        ├e                      │├g
        │├f                     │└f
        │├g                     └b
        │└h                      ├d
        └i                       └c

    """

Testing: python3 -m unittest gen_tree_test.MirrorTest

2.6 clone

Implement the method clone:

def clone(self):
    """ Clones this tree, by returning an *entirely* new tree which is an
        exact copy of this tree (so returned node and *all* its descendants
        must be new).

        - MUST run in O(n) where n is the number of nodes
        - a recursive method is acceptable.
    """

Testing: python3 -m unittest gen_tree_test.CloneTest

2.7 rightmost

labeled i99kfdf

In the example above, the rightmost branch of a is given by the node sequence a,d,n

Implement this method:

def rightmost(self):
        """ RETURN a list containing the *data* of the nodes
            in the *rightmost* branch of the tree.

            Example:

            a
            ├b
            ├c
            |└e
            └d
             ├f
             └g
              ├h
              └i

            should give

            ['a','d','g','i']
        """

Testing: python3 -m unittest gen_tree_test.RightmostTest

2.8 fill_left

Open tree.py and implement fill_left method:

def fill_left(self, stuff):
    """ MODIFIES the tree by filling the leftmost branch data
        with values from provided array 'stuff'

        - if there aren't enough nodes to fill, raise ValueError
        - root data is not modified
        - *DO NOT* use recursion

    """

Testing: python3 -m unittest gen_tree_test.FillLeftTest

Example:

[23]:
from gen_tree_test import gt
from gen_tree_sol import *
[24]:
t  = gt('a',
            gt('b',
                    gt('e',
                            gt('f'),
                            gt('g',
                                    gt('i')),
                    gt('h')),
            gt('c'),
            gt('d')))

[25]:
print(t)
a
└b
 ├e
 │├f
 │├g
 ││└i
 │└h
 ├c
 └d
[26]:
t.fill_left(['x','y'])
[27]:
print(t)
a
└x
 ├y
 │├f
 │├g
 ││└i
 │└h
 ├c
 └d
[28]:
t.fill_left(['W','V','T'])
print(t)
a
└W
 ├V
 │├T
 │├g
 ││└i
 │└h
 ├c
 └d

2.9 follow

Open tree.py and implement follow method:

def follow(self, positions):
        """
            RETURN an array of node data, representing a branch from the
            root down to a certain depth.
            The path to follow is determined by given positions, which
            is an array of integer indeces, see example.

            - if provided indeces lead to non-existing nodes, raise ValueError
            - IMPORTANT: *DO NOT* use recursion, use a couple of while instead.
            - IMPORTANT: *DO NOT* attempt to convert siblings to
                         a python list !!!! Doing so will give you less points!

        """

Example:

              level  01234

                     a
                     ├b
                     ├c
                     |└e
                     | ├f
                     | ├g
                     | |└i
                     | └h
                     └d

                    RETURNS
t.follow([])        [a]          root data is always present
t.follow([0])       [a,b]        b is the 0-th child of a
t.follow([2])       [a,d]        d is the 2-nd child of a
t.follow([1,0,2])   [a,c,e,h]    c is the 1-st child of a
                                 e is the 0-th child of c
                                 h is the 2-nd child of e
t.follow([1,0,1,0]) [a,c,e,g,i]  c is the 1-st child of a
                                 e is the 0-th child of c
                                 g is the 1-st child of e
                                 i is the 0-th child of g

Testing: python3 -m unittest gen_tree_test.FollowTest

2.10 is_triangle

A triangle is a node which has exactly two children.

Let’s see some example:

      a
    /   \
   /     \
  b ----- c
 /|\     /
d-e-f   g
       / \
      h---i
         /
        l

The tree above can also be represented like this:

a
├b
│├d
│├e
│└f
└c
 └g
  ├h
  └i
   └l
  • node a is a triangle because has exactly two children b and c, note it doesn’t matter if b or c have children)

  • b is not a triangle (has 3 children)

  • c and i are not triangles (have only 1 child)

  • g is a triangle as it has exactly two children h and i

  • d, e, f, h and l are not triangles, because they have zero children

Now implement this method:

def is_triangle(self, elems):
    """ RETURN True if this node is a triangle matching the data
        given by list elems.

        In order to match:
        - first list item must be equal to this node data
        - second list item must be equal to this node first child data
        - third list item must be equal to this node second child data

        - if elems has less than three elements, raises ValueError
    """

Testing: python -m unittest gen_tree_test.IsTriangleTest

Examples:

[29]:
from gen_tree_test import gt
[30]:

# this is the tree from the example above

tb = gt('b', gt('d'), gt('e'), gt('f'))
tg = gt('g', gt('h'), gt('i', gt('l')))
ta = gt('a', tb, gt('c', tg))

ta.is_triangle(['a','b','c'])
[30]:
True
[31]:
ta.is_triangle(['b','c','a'])
[31]:
False
[32]:
tb.is_triangle(['b','d','e'])
[32]:
False
[33]:
tg.is_triangle(['g','h','i'])
[33]:
True
[34]:
tg.is_triangle(['g','i','h'])
[34]:
False

2.11 has_triangle

Implement this method:

def has_triangle(self, elems):
    """ RETURN True if this node *or one of its descendants* is a triangle
        matching given elems. Otherwise, return False.

        - a recursive solution is acceptable
    """

Testing: python -m unittest gen_tree_test.HasTriangleTest

Examples:

[35]:

# example tree seen at the beginning

tb = gt('b', gt('d'), gt('e'), gt('f'))
tg = gt('g', gt('h'), gt('i', gt('l')))
tc = gt('c', tg)
ta = gt('a', tb, tc)

ta.has_triangle(['a','b','c'])

[35]:
True
[36]:
ta.has_triangle(['a','c','b'])

[36]:
False
[37]:
ta.has_triangle(['b','c','a'])

[37]:
False
[38]:
tb.is_triangle(['b','d','e'])

[38]:
False
[39]:
tg.has_triangle(['g','h','i'])

[39]:
True
[40]:
tc.has_triangle(['g','h','i'])  # check recursion

[40]:
True
[41]:
ta.has_triangle(['g','h','i'])  # check recursion
[41]:
True
[ ]: