Project 1: Adjacency Lists
Due: Tuesday, February 21, 8:59:59pm
Addendum
[Wednesday Feb 15, 22:35pm] Fixed typo in operator!=.
[Monday Feb 6, 9:50am] Changes in orange.
- Added requirement to compile on GL.
- Added running time requirement for iterators.
- Added running time requirement for addEdge().
- Clarified that addEdge() does not have to check for duplicates.
Objectives
The objective of this programming assignment is to have you review C++ programming using following features: object-oriented design, dynamic memory allocation, pointer manipulation, exceptions and nested classes.Introduction
In computer science and discrete mathematics, a graph is not a plot of y = f(x). A graph has vertices and edges. Here's a simple example:
The circles are the vertices and the edges are the lines between the vertices. (It is common to use the words "node" and "vertex" interchangeably, but for this project we will also have linked lists with nodes. So, we will only use "vertex" for graphs and reserve "node" for linked lists.)
In the example above, the set of vertices is {0,1,2,3,4} and the set of edges is {(3,4), (4,1), (3,0), (0,4), (0,1), (2,1), (4,2)}. It is common to use the ordered pair notation (u, v) to represent an edge, but in an undirected graph, the edges are not ordered. Thus, (2,1) and (1,2) are the same edge.
One common way to store a graph is using an adjacency list data structure. This data structure is just an array of linked lists. For example, the graph above can be stored as:
Here the first linked list, indexed by 0, has the neighbors of of vertex 0. The neighbors of a vertex v are the vertices in the graph that are connected to v by an edge. Vertices 1, 4 and 3 are the neighbors of vertex 0, so they go in the linked list indexed by 0. Similarly, vertices 2, 0 and 4 are the neighbors of vertex 1.
Note that each edge is represented twice in this data structure. For example, edge (0,3) appears once as vertex 3 in the linked list for vertex 0 and another time as vertex 0 in the linked list for vertex 3.
Assignment
Your assignment is to implement an adjacency list data structure Graph that is defined in the header file Graph.h. The Graph class provides two iterators. One iterator produces the neighbors for a given vertex. The second iterator produces each edge of the graph once.
Additionally, you must implement a test program that fully exercises your implementation of the Graph member functions. Place this program in the main() function in a file named Driver.cpp.
The purpose of an iterator is to provide programmers a uniform way to iterate through all items of a data structure using a for loop. For example, using the Graph class, we can iterate thru the neighbors of vertex 4 using:
The idea is that nit (for neighbor iterator) starts at the beginning of the list for vertex 4 and is advanced to the next neighbor by the ++ operator. The for loop continues as long as we have not reached the end of the list. We check this by comparing against a special iterator for the end, nbEnd(4). This requires the NbIterator class to implement the ++, != and * (dereference) operators.
Similarly, the Graph class allows us to iterate through all edges of a graph using a for loop like:
Note that each edge is printed only once, even though it is represented twice in the adjacency list data structure.
Since a program may use many data structures and each data structure might provide one or more iterators, it is common to make the iterator class for a data structure an inner class. Thus, in the code fragments above, nit and eit are declared as Graph::NbIterator and Graph::EgIterator objects, not just NbIterator and EgIterator objects.
If you have not used nested class declarations before, here's an example: nested.cpp and sample output. (For convenience, the class declarations and implementation are provided in one file, contrary to course coding standards.)
Specifications
Here are the specifics of the assignment, including a description for what each member function must accomplish.
Requirement: other than the templated pair class, you must not use any classes from the Standard Template Library or other sources, including vector and list. All of the data structure must be implemented by your own code.
Requirement: your code must compile with the original Graph.h header file. You are not allowed to make any changes to this file. Yes, this prevents you from having useful helper functions. This is a deliberate limitation of this project. You may have to duplicate some code.
Requirement: per our course coding standards, your code must compile with g++ on the GL servers without using any compilation flags.
Requirement: a program fragment with a for loop that uses your NbIterator must have worst case running time that is proportional to the number of neighbors of the given vertex.
Requirement: a program fragment with a for loop that uses your EgIterator must have worst case running time that is proportional to the number of vertices in the graph plus the number of edges in the graph.
These are the member functions of the Graph class (not including the member functions of the inner classes).
-
Graph(int n) ; This is the constructor for the Graph class. The Graph class does not have a default constructor, because we want the programmer to specify the number of vertices in the graph using the parameter n. If the n given is zero or negative, throw an out_of_range exception.
The constructor must dynamically allocate space for the adjacency list and allow edges to be added using addEdge().
-
Graph(const Graph& G) ; This is the copy constructor for the Graph class. It should make a complete (deep) copy of the Graph object G given in the parameter. The target of the copy is the host object.
You should not call the assignment operator from the copy constructor (or vice versa). The objective of these two member functions are sufficiently different that you should implement them separately.
-
const Graph& operator= (const Graph& rhs) ; This is the overloaded assignment operator for the Graph class. It is called when the compiler sees an assignment like:
A = B ; where both A and B are Graph objects. The object A becomes the host object of the function and B is passed to the function as rhs.
Remember to check for self-assignment and to free all dynamically allocated data members of the host. You should not use the copy constructor in the implementation of the assignment operator.
-
~Graph() ; This is the destructor for the Graph class. All dynamically allocated memory associated with the host object must be deallocated. You should use valgrind on GL to check for memory leaks.
-
int size() ; This function returns the number of vertices in the graph.
-
void addEdge(int u, int v) ; This function should add an edge between vertices u and v. Note that you have to add v to the linked list for u and you have to add u to the linked list for v. If the values for u and v are out of bounds, throw an out_of_range exception.
Your addEdge() function must run in constant time.
You do not have to worry about duplicate edges being added. In computer science, graphs with multiple edges between the same pair of vertices is allowed in some situations. (They are called multi-graphs.) The client programmers who use your Graph class must check for duplicate edges if they do not want to have duplicate edges in the graph.
-
void dump() ; This member function is used for debugging. It should print out the linked lists in the adjacency lists for every vertex. See the sample outputs below for the suggested format of the output of the dump() function.
Note: you should NOT use the neighbor iterator in dump(), because dump() is supposed to help you debug your program when the iterators are not working!
-
EgIterator egBegin() ; EgIterator egEnd() ; These two functions call the EgIterator constructor to create iterators that can be used in for loops to iterate through the edges of the graph. (See example above.)
-
NbIterator nbBegin(int v) ; NbIterator nbEnd(int v) ; These two functions call the NbIterator constructor to create iterators that can be used in for loops to iterate through the neighbors of vertex v. (See example above.)
These are the member functions of the edge iterator class EgIterator:
-
EgIterator(Graph *Gptr = NULL, bool isEnd = false) ; This is the constructor for the EgIterator class. It is a default constructor, since each parameter has a default value. The constructor should allow an EgIterator to be declared without any parameters.
If isEnd is true, then the constructor should make an end iterator. This can be represented by having the m_source be the number of vertices (which is larger than any vertex number) and m_where be NULL.
Otherwise, the constructor should put the iterator in a "good" state and have m_source and m_where indicate the first edge in the graph. (I.e., can be used for egBegin.)
-
bool operator!= (const EgIterator& rhs) ; This overloaded operator compares two EgIterator objects. It will only be used to compare an EgIterator against the end iterator and must return
truefalse when the end is reached. All other uses are unspecified. -
void operator++(int dummy) ; The post increment ++ operator should advance the iterator to the next "viable" edge. The dummy parameter indicates this is a post increment operator and not a preincrement operator. The dummy parameter is not actually used as a parameter.
A simple way to make sure that each edge is only visited once by an EgIterator is to consider only those edges where the source vertex (the index of the array) is smaller than or equal to the vertex in the linked list. For example, if the iterator is currently located on the edge (2,4) in red below:
then the next viable edge is (3,4) in orange above. This is because we consider (2,1) and (3,0) not viable. These edges were already visited by the iterator as (1,2) and (0,3). So, we should skip over them when we see their alternative representation. Thus the ++ operator should figure out that m_source should become 3 and that m_where should point to the AdjListNode for vertex 4 in the linked list for vertex 3 (the orange node above).
The ++ operator should have no effect if applied to an iterator that is already at the end.
-
std::pair operator*() ; This is the overloaded dereference operator. It returns a pair of integers using the pair class from the standard library. The pair returned is the edge that the iterator is currently "visiting". Throw an out_of_range exception if the dereference operator is applied to an iterator that has reached the end. See Implementation Notes below for further comments.
These are the member functions of the neighbor iterator class NbIterator. They are analogous to the functions for EgIterator.
-
NbIterator(Graph *Gptr = NULL, int v = -1, bool isEnd = false) ; This is the constructor and default constructor. The constructor should allow the creation of an NbIterator object without any parameters given.
If isEnd is true, then object created should be an end iterator.
The parameter v indicates that we want to iterate over the neighbors of v.
-
bool operator!= (const NbIterator& rhs) ; As before, the only intended use of the != operator is to compare an NbIterator object against the end iterator.
-
void operator++(int dummy) ; The ++ operator advances the iterator to the next neighbor of m_source. All neighbors are viable, so this ++ operator should be simpler to implement than the one for EgIterator.
The ++ operator should have no effect if applied to an iterator that is already at the end.
-
int operator*() ; This dereference operator should return the neighbor that the iterator is currently "visiting". Throw an out_of_range exception if the dereference operator is applied to an iterator that has reached the end.
Test Programs
The following test programs may be used to check the compatibility of your implementation. These programs do not check the correctness of your implementation. Even if your implementation compiles and runs correctly with these programs, it does not mean your implementation is error-free. Grading will be done using programs that exercise your implementation much more thoroughly. You must do the testing yourself --- testing is part of programming. Conversely, if your implementation does not compile or does not run correctly with these test programs, then it is unlikely that it will compile or run correctly with the grading programs.
- test1.cpp
- test1.txt (sample output)
- test1v.txt (sample output with valgrind)
- test2.cpp
- test2.txt (sample output)
- test2v.txt (sample output with valgrind)
These files are also available on GL in the directory:
Implementation Notes
- Approach this assignment using incremental development. Implement the Graph constructor, addEdge() and dump() functions first. Then write a driver program that creates a graph, adds a few edges and calls dump(). When you have debugged these functions, implement the NbIterator class (it's easier). When you have those functions fully debugged, then implement the EgIterator class.
- The EgIterator and NbIterator classes do not need destructors, copy constructors or overloaded assignment operators because they do not have any dynamically allocated data members. The compiler supplied destructor, copy constructor and assignment operator will do the right thing.
- Graphs are allowed to have vertices that do not have any edges. The EgIterator constructor should work correctly even if vertex 0, 1, 2 ... have empty adjacency lists. Similarly, the ++ operator for the EgIterator should be able to handle empty adjacency lists.
- If you are still confused about what an iterator is supposed to do,
consider this implementation of the dereference operator for
NbIterator:
int Graph::NbIterator::operator*() { if (m_where == NULL) { throw out_of_range("NbIterator dereference error.") ; } return m_where->m_vertex ; } The main purpose of an iterator is to give a programmer using the Graph class access to the neighbors of a vertex. The dereference operator is where the rubber meets the road. When you dereference the iterator, you get an actual neighbor vertex. That means the iterator should always be in a state where dereferencing gives you the current neighbor vertex (unless you have reached the end). That tells you what nbBegin() and the ++ operator has to do. The nbBegin() object must put m_where over the first neighbor. The ++ operator must advance the iterator to the next neighbor.
The dereference operator for the EgIterator is similar:
std::pair Graph::EgIterator::operator*() { if (m_where == NULL) { throw out_of_range("EgIterator dereference error.") ; } return std::pair (m_source, m_where->m_vertex) ; } In order for this dereference operator to work, the EgIterator member functions and operators must set the m_source and m_where data members correctly. Look at the example adjacency list above. Now think about what the data members of the iterators should hold as you advance through the linked list using the ++ operator. Think about what happens after ++ is applied to an iterator visiting the last viable edge in the graph.
-
The reason that egBegin() and nbBegin() are members of Graph rather than the iterator classes is that the iterators have to be told which graph they are working with. Since egBegin() and nbBegin() are members of Graph, they can give their host pointers (the this pointer) to the iterator constructors.
-
The "normal" iterator constructors (not creating the end iterator or the empty iterator) should create an iterator compatible with egBegin() and nbBegin().
-
There is a certain amount of inefficiency inherent in the for loop idiom:
Graph::EgIterator eit ; for (eit = G.egBegin() ; eit != G.egEnd() ; eit++) { ... } because we create an iterator with egBegin() just to assign it to eit. Also, we create an end iterator for each iteration using egEnd() just so we can compare it to eit.
-
Test your programs for memory leaks (dynamically allocated memory that was never released) using valgrind. The valgrind command is available on GL. Just compile your test program and run:
valgrind ./Driver.out This is assuming that your executable file is named Driver.out. If that run did not leak memory, the output from valgrind will say:All heap blocks were freed -- no leaks are possible As usual, the fact that a single run of your implementation did not leak memory does not mean that it will never leak memory.
What to Submit
You must submit the following files to the proj1 directory.
- Graph.cpp
- Driver.cpp
You do not need to submit Graph.h because it should not have changed. If you do happen to place a copy of Graph.h in your submission directory, it will be replaced by a copy of the original version.
If you followed the instructions in the Project Submission page to set up your directories, you can submit your code using this Unix command command.