9

I have implemented a simple graph data structure in Python with the following structure below. The code is here just to clarify what the functions/variables mean, but they are pretty self-explanatory so you can skip reading it.

# Node data structure
class Node: 

    def __init__(self, label):        
        self.out_edges = []
        self.label = label
        self.is_goal = False


    def add_edge(self, node, weight = 0):          
        self.out_edges.append(Edge(node, weight))


# Edge data structure
class Edge:

    def __init__(self, node, weight = 0):          
        self.node = node
        self.weight = weight

    def to(self):                                  
        return self.node


# Graph data structure, utilises classes Node and Edge
class Graph:    

    def __init__(self):                             
        self.nodes = []

    # some other functions here populate the graph, and randomly select three goal nodes.

Now I am trying to implement a uniform-cost search (i.e. a BFS with a priority queue, guaranteeing a shortest path) which starts from a given node v, and returns a shortest path (in list form) to one of three goal node. By a goal node, I mean a node with the attribute is_goal set to true.

This is my implementation:

def ucs(G, v):
    visited = set()                  # set of visited nodes
    visited.add(v)                   # mark the starting vertex as visited
    q = queue.PriorityQueue()        # we store vertices in the (priority) queue as tuples with cumulative cost
    q.put((0, v))                    # add the starting node, this has zero *cumulative* cost   
    goal_node = None                 # this will be set as the goal node if one is found
    parents = {v:None}               # this dictionary contains the parent of each node, necessary for path construction

    while not q.empty():             # while the queue is nonempty
        dequeued_item = q.get()        
        current_node = dequeued_item[1]             # get node at top of queue
        current_node_priority = dequeued_item[0]    # get the cumulative priority for later

        if current_node.is_goal:                    # if the current node is the goal
            path_to_goal = [current_node]           # the path to the goal ends with the current node (obviously)
            prev_node = current_node                # set the previous node to be the current node (this will changed with each iteration)

            while prev_node != v:                   # go back up the path using parents, and add to path
                parent = parents[prev_node]
                path_to_goal.append(parent)   
                prev_node = parent

            path_to_goal.reverse()                  # reverse the path
            return path_to_goal                     # return it

        else:
            for edge in current_node.out_edges:     # otherwise, for each adjacent node
                child = edge.to()                   # (avoid calling .to() in future)

                if child not in visited:            # if it is not visited
                    visited.add(child)              # mark it as visited
                    parents[child] = current_node   # set the current node as the parent of child
                    q.put((current_node_priority + edge.weight, child)) # and enqueue it with *cumulative* priority

Now, after lots of testing and comparing with other alogrithms, this implementation seemed to work pretty well - up until I tried it with this graph:

Graph one

For whatever reason, ucs(G,v) returned the path H -> I which costs 0.87, as opposed to the path H -> F -> I, costing 0.71 (this path was obtained by running a DFS). The following graph also gave an incorrect path:

Graph two

The algorithm gave G -> F instead of G -> E -> F, obtained again by the DFS. The only pattern I can observe among these rare cases is the fact that the chosen goal node always has a loop. I can't figure out what is going wrong though. Any tips will be much appreciated.

Luke Collins
  • 1,433
  • 3
  • 18
  • 36
  • 3
    You consider a node "visited" before you actually visit it and before you can be sure you've found the cheapest path there. – user2357112 Apr 11 '17 at 19:39
  • 2
    ... to expand that: If there are two paths to a node, you only consider one of them, because you mark a node visited when you find the first path without checking if there isn't another (cheaper) path. This also clashes with "parent", where there's only ever one parent for each node, which is only fine, when it is the parent on the cheapest path – dhke Apr 11 '17 at 19:41
  • I see what you mean... but in the first example I gave, why did the algorithm choose the path `H -> I`, if it is meant to choose the cheapest path? Shouldn't the priority queue rank take care of that? How would I go about fixing the visited/parent thing? – Luke Collins Apr 11 '17 at 19:47
  • "why did the algorithm choose the path H -> I, if it is meant to choose the cheapest path" - because it's buggy in the ways that dhke and I just described. If things always did what they were meant to do instead of what you actually wrote, programming would be a lot easier. – user2357112 Apr 11 '17 at 19:58
  • If you start at `H` and expand `H -> I`, you are never going to consider any later edge to `I` again, because `I` is already on your visited list. Hence you block yourself from finding a shorter path later once you've seen a node for the first time. – dhke Apr 11 '17 at 20:04
  • @dhke But wouldn't the priority queue insist that we start from `H -> F` first, since the cumulative cost is less? – Luke Collins Apr 11 '17 at 20:14
  • @LukeCollins Yes, but after the first expansion, your queue is `[(.56, F), (.87, I)]` with your visited set `[H, F, I]`. Now you expand, `F`, but you ignore the path `[H, F, I]`, because `I` is already in the visited set. Colloquially, the visited set indicates "I have seen the shortest paths to these nodes", but that is not true in your algorithm, where it's only "I have seen any path to these nodes" – dhke Apr 11 '17 at 20:18
  • @dhke I see now. So what you suggested in your answer should be enough to fix it? I'm worried that cycles might cause infinite loops (that's the reason I've been using visited in the first place) – Luke Collins Apr 11 '17 at 20:21
  • @LukeCollins Cycles can only cause infinite loops when they have a non-positive path length. The algorithm works fine without a visited set, since traversing a cycle monotonically always increases the total path length. It's just nicer to have a visited set, since a dead-end cycle with short paths right at the start can fill up your queue. – dhke Apr 11 '17 at 20:24
  • 2
    It's not related to your issues, but the classes from the `queue` module do a bunch of thread-synchronization stuff that you don't need. For a basic priority queue that's only being used in one thread, use `heapq` instead (it's what `queue.PriorityQueue` uses internally for its implementation). – Blckknght Apr 11 '17 at 20:26

2 Answers2

2

Usually for searches, I tend to keep the path to a node part of the queue. This is not really memory efficient, but cheaper to implement.

If you want the parent map, remember that it is only safe to update the parent map when the child is on top of the queue. Only then has the algorithm determined the shortest path to the current node.

def ucs(G, v):
    visited = set()                  # set of visited nodes
    q = queue.PriorityQueue()        # we store vertices in the (priority) queue as tuples 
                                     # (f, n, path), with
                                     # f: the cumulative cost,
                                     # n: the current node,
                                     # path: the path that led to the expansion of the current node
    q.put((0, v, [v]))               # add the starting node, this has zero *cumulative* cost 
                                     # and it's path contains only itself.

    while not q.empty():             # while the queue is nonempty
        f, current_node, path = q.get()
        visited.add(current_node)    # mark node visited on expansion,
                                     # only now we know we are on the cheapest path to
                                     # the current node.

        if current_node.is_goal:     # if the current node is a goal
            return path              # return its path
        else:
            for edge in in current_node.out_edges:
                child = edge.to()
                if child not in visited:
                    q.put((current_node_priority + edge.weight, child, path + [child]))

Note: I haven't really tested this, so feel free to comment, if it doesn't work right away.

dhke
  • 15,008
  • 2
  • 39
  • 56
  • This is going to perform duplicate visits, potentially a lot of them, since you don't check if a node has already been visited before trying to enqueue its children and you don't do anything to deduplicate queue entries for the same node. – user2357112 Apr 11 '17 at 20:09
  • Deduplicating queue entries would probably require some sort of decrease-key functionality, which we don't know if these priority queues have, but it's simple to add a `visited` check to make sure you don't re-expand a visited node on a revisit. – user2357112 Apr 11 '17 at 20:12
  • 1
    and what if we have loops? – Luke Collins Apr 11 '17 at 20:16
  • @user2357112 From experience: Leave deduplication to the implementation of the queue, don't clutter the search algorithm with it. And yes, you only need to keep the shortest path to every node on the queue. But the problem that you can only update the visited list when you expand a node remains. – dhke Apr 11 '17 at 20:28
  • @dhke I think I'd rather keep the parent map. If I simply change where the `visited.add(...)` is happening, then leaving `parents[child] = current_node` where it is should always give the child node the right parent -- right? – Luke Collins Apr 11 '17 at 20:31
  • @LukeCollins Unless you have cycles with negative path lengths, loops do not cause non-termination. Keeping the visited list is already an optimization, since you will get repeated additions of the same node with different paths lengths and the visited list can catch some of them. If you push the optimization even more, you need a "unique" priority queue, where a node can only appear once (with its shortest possible path). – dhke Apr 11 '17 at 20:32
  • @LukeCollins Yes, that should be sufficient. The parent map than basically says "for this node, the shortest possible path is incoming via parent". Note that this is then an implementation of [Dijkstra's algorithm](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm). – dhke Apr 11 '17 at 20:33
  • @dhke Thanks for all the help! – Luke Collins Apr 11 '17 at 20:34
  • @dhke I have another problem :( What happened here? http://i.imgur.com/u635IlM.png I moved the `visited()` thing to when the queue is popped. – Luke Collins Apr 11 '17 at 20:50
0

A simple check before expanding the node can save you duplicate visits.

while not q.empty():             # while the queue is nonempty
    f, current_node, path = q.get()
    if current_node not in visited: # check to avoid duplicate expansions
       visited.add(current_node)    # mark node visited on expansion,
                                    # only now we know we are on the cheapest path to
                                    # the current node.
       if current_node.is_goal:     # if the current node is a goal
          return path               # return its path
       ...
Ajmal
  • 484
  • 1
  • 7
  • 14