Even Tree [HackerRank]

Hi all, I’m sorry about not posting very frequently – thankfully, however, my exams will be coming to a close in the next two weeks, so I will be able to return to regular posting soon.

If you are unfamiliar with graph theory, I recommend that you read my previous post on the subject – it gives an introduction into the topic and the ways in which it appears in competitive programming.

Today, we will solve a relatively simple problem on graph theory which can be found on the ‘HackerRank’ website – given a tree (a graph with no cycles and where any node can be reached from any other node) of N nodes numbered from 1 (which is the root node) to N, our program must find the maximum number of edges it can remove from this tree to obtain a set of trees (commonly called a ‘forest’) where every tree has an even number of nodes.

The source code of my final solution to this problem can be found here.

The problem statement states that it is always possible to remove edges from the main tree to create a forest consisting of only trees with even numbers of nodes, so we do not need to concern ourselves with difficult edge cases. This brings us to a relatively simple solution: any ‘subtree’ (a smaller tree which is part of the main tree – it may have its own root separate from the main root) with an even number of nodes can be separated from the node connecting to its root – therefore, the maximum number of edges which can be removed from the tree to satisfy the problem statement is equal to the number of subtrees with even numbers of nodes. We have now reduced the problem to counting the number of nodes in each subtree.

Our first course of action is to devise a method for storing the tree – the technique I used is known as an ‘adjacency list’. In C++, this is essentially a vector where each element represents a node. For simple graphs like the ones used in this problem, each node can be represented by a vector of integers, with each integer representing a node which can be reached from the node represented by the vector itself. Initially our adjacency list will be empty, however, we will fill it by taking each edge from input as two integers (which represent the two nodes connected by the edge in question) connecting the deeper of the two nodes to the node closer to the root (not vice-versa! The edges in our list only need to be unidirectional).

EvenTree1.png

After creating the adjacency list, we can write a function which performs a depth-first traversal of the tree, counting the number of nodes in each subtree by travelling to each node in turn and counting the number of children it has and adding 1 to this value (this addition represents the current node, which acts as the root). We can do this elegantly using recursion – the numbers of nodes in the subtrees rooted at the deepest points of the tree are always 1 (as these nodes have no child nodes). By this logic, we can have our recurring function return 1 when it reaches a node with no child nodes, and in order to calculate the numbers of nodes in the other subtrees, we recur for every child node of each potential root node and save the sum of the results.

EvenTree2.png

Once we have calculated the number of nodes in every possible subtree of the main tree, we can just iterate through our results and increment our final answer by 1 every time we find a subtree with an even number of nodes. Once this process is complete, we can print out our final value.

EvenTree3.png

The Coin Change Problem Solved with Dynamic Programming

The ‘Coin Change Problem’ is a well-known exercise in computer science. The idea is simple – given a certain number amount of money in cents (we will call this amount n), and infinite numbers of defined coin denominations, we want to calculate the number of ways we can make n by combining the coins we have been given.

For example, if we want to make 4 cents (n = 4), and the denominations available to us are 1, 2 and 3, we could make 4 cents in the following four ways:

{2,2}, {1,1,1,1}, {1,3}, {1,1,2}

Another example – if we want to make 10 cents (n = 10), and the denominations available to us are 2, 3, 5 and 6, we could make 10 cents in the following five ways:

{2,2,2,2,2}, {2,2,3,3}, {2,2,6}, {2,3,5}, {5,5}

The code I have written which solves the Coin Change Problem can be found here.

The most effective solution design for this problem uses a technique known as dynamic programming (we can also develop a program that finds solutions by brute force, but this is incredibly slow). The key idea to this technique is that certain large problems (say, finding the number of ways to make a large number of cents from a wide variety of different coin denominations) can be broken down into smaller sub-problems. These sub-problems can then be broken down further, until very small, easily-solvable problems with optimal solutions are obtained. From this logic, problems of this nature can be tackled by finding and solving the most trivial sub-problems, and extending the solutions to these sub-problems to more complex sub-problems until the final solution is obtained.

To apply dynamic programming to the Coin Change Problem, we first need to observe that there is only 1 way of making a sum of 0 (no matter what denominations we have available to us). Moreover, if we only have access to 1 coin denomination, then it is clear that there can be no more than 1 possible way to make every single sum (if a given number is divisible by the denomination, then there is 1 way to make that number, and if the number is not divisible by the denomination, then there is no possible way to make that number).

The next observation we can make is that we can represent the problem as a table – with a row for each coin denomination and (n+1) columns. The numbers in a given column represent the number of ways the index value of the column can be made using the coin denominations from each number’s row and the rows above. To solve the problem, we need to find the value at the bottom right of this table. We already know that there is only 1 way of making a sum of 0, so the first column can be filled with ones. Additionally, we have also already proven that there can be a maximum of 1 way of making every possible sum using only one coin denomination – therefore, the first row can be filled with either ones (for the columns with indices which are divisible by the first coin denomination) or zeroes (for the columns with indices which are not divisible by the first coin denomination).

After filling in the first column and the first row, we can progress onto filling the rest of the table, row-by-row. When we fill the second row of the table, we find the number of ways to make each column index using the first two coin denominations available to us. If the second coin denomination is greater than a given column index, we can set the value of the cell to the corresponding cell value in the row above (because the second coin denomination has no effect on the number of ways we can make the sum, since it has a greater value). However, if the second coin denomination is equal to or less than a given column index, we need to look at the number of ways we can include the second coin denomination in the sum. If the second coin denomination is equal to the sum, then we can set the value of the cell to the value of the corresponding value in the cell above incremented by 1 (since one new possible way of making the sum has been found – a single coin of the denomination equal to the target). Furthermore, if the second coin denomination is of a smaller value than the sum, we need to find the number of ways we can group the coins of the first two denominations together such that they sum to the difference between the sum and the second coin denomination. The key insight here is that we have already calculated this value previously (since we are iterating through the columns from left-to-right, with smaller sums being positioned towards the left). Therefore, we can set the value of our current cell to the sum of the value of the corresponding cell in the row above added to the value of the cell in the current row in the column indexed by the difference between the value of the current column and the value of the second coin denomination. This set of operations can be repeated for every single row in order to generate a full table. We can express this algorithm in a more concise form below (note that since every value in the first column is 1, we do not need to program any additional functionality for when the values of a given combination and the desired sum are equal):

  • For every row r:
    • Save the value of the coin denomination represented by the current row as v.
    • If r is the first row in the table:
      • For every column c:
        • If the index of c is evenly divisible by v:
          • Set table[r][c] to 1.
        • Else:
          • Set table[r][c] to 0.
    • Else
      • For every column c:
        • If v > column index:
          • Set table[r][c] to table[r-1][c].
        • Else if <= column index:
          • Set table[r][c] to (table[r-1][c] + table[r][cv]

 

When this algorithm is used to solve our first example (where we attempt to make 4 cents using coins with the denominations for 1, 2 and 3), the following table is generated:

CoinChangeTable.png

We can see that the value of table[2][2] is set to (table [1][2] + table[2][0]). This shows that it is possible to make a sum of 2 cents using two 1-cent coins, or one 2-cent coin. This algorithm is repeated to construct the whole table, resulting in the value of table[3][4] being set to (table[2][4] + table[3][1]). The final value for table[3][4] shows that there are 3 possible ways to use only 1-cent coins and 2-cent coins to create a sum of 4, along with another single possible way which includes a 3-cent coin (resulting in the coin set {3,1}).