Finding an Eulerian cycle in a graph
Clash Royale CLAN TAG#URR8PPP
.everyoneloves__top-leaderboard:empty,.everyoneloves__mid-leaderboard:empty margin-bottom:0;
up vote
2
down vote
favorite
Given a graph that has an Eulerian cycle, write a function which returns the cycle in tuple form.
I came up with following solution for this problem and am stuck trying to make it faster. Do you have any tips?
def eulerian_cycle_1(data):
graph, edges_amount = get_graph(data) #graph:: source:[destination]
cycle = deque()
cur = 0
while edges_amount > 0:
choices = graph[cur]
while choices:
cycle.append(cur)
edges_amount -= 1
cur = choices.pop()
choices = graph.get(cur, None)
if edges_amount == 0:
break
rotate = 0
for cur in cycle:
if graph[cur]:
break
rotate += 1
cycle.rotate(-rotate)
cycle.rotate(-cycle.index(0))
cycle.append(0)
return tuple(cycle)
Pseudocode
EulerianCycle($G$):
- form a cycle $c$ by randomly walking in graph $G$ (don't visit the same edge twice!)
- while there are unexplored edges in graph $G$
- select a node $n$ in cycle $c$ with still unexplored edges
- form cycle $câ²$ by traversing cycle $c$ (starting at node $n$) and then randomly walking
- $c â câ²$
- return $c$
python performance graph
add a comment |Â
up vote
2
down vote
favorite
Given a graph that has an Eulerian cycle, write a function which returns the cycle in tuple form.
I came up with following solution for this problem and am stuck trying to make it faster. Do you have any tips?
def eulerian_cycle_1(data):
graph, edges_amount = get_graph(data) #graph:: source:[destination]
cycle = deque()
cur = 0
while edges_amount > 0:
choices = graph[cur]
while choices:
cycle.append(cur)
edges_amount -= 1
cur = choices.pop()
choices = graph.get(cur, None)
if edges_amount == 0:
break
rotate = 0
for cur in cycle:
if graph[cur]:
break
rotate += 1
cycle.rotate(-rotate)
cycle.rotate(-cycle.index(0))
cycle.append(0)
return tuple(cycle)
Pseudocode
EulerianCycle($G$):
- form a cycle $c$ by randomly walking in graph $G$ (don't visit the same edge twice!)
- while there are unexplored edges in graph $G$
- select a node $n$ in cycle $c$ with still unexplored edges
- form cycle $câ²$ by traversing cycle $c$ (starting at node $n$) and then randomly walking
- $c â câ²$
- return $c$
python performance graph
6
Hey Sam, will be good if you append some tests where you are profiling the code, and see where the bottlenecks are, to know which kind of delays you are experiencing
â A. Romeu
Mar 1 at 16:46
4
Can you please provide some sample input and output? You mention a function,get_graph
, that isn't present - can you include that as well please?
â Dannnno
Mar 1 at 21:23
1
@A.Romeu: see my answer for the worst case graphs and a demonstration of the performance problem.
â Gareth Rees
Mar 4 at 10:55
add a comment |Â
up vote
2
down vote
favorite
up vote
2
down vote
favorite
Given a graph that has an Eulerian cycle, write a function which returns the cycle in tuple form.
I came up with following solution for this problem and am stuck trying to make it faster. Do you have any tips?
def eulerian_cycle_1(data):
graph, edges_amount = get_graph(data) #graph:: source:[destination]
cycle = deque()
cur = 0
while edges_amount > 0:
choices = graph[cur]
while choices:
cycle.append(cur)
edges_amount -= 1
cur = choices.pop()
choices = graph.get(cur, None)
if edges_amount == 0:
break
rotate = 0
for cur in cycle:
if graph[cur]:
break
rotate += 1
cycle.rotate(-rotate)
cycle.rotate(-cycle.index(0))
cycle.append(0)
return tuple(cycle)
Pseudocode
EulerianCycle($G$):
- form a cycle $c$ by randomly walking in graph $G$ (don't visit the same edge twice!)
- while there are unexplored edges in graph $G$
- select a node $n$ in cycle $c$ with still unexplored edges
- form cycle $câ²$ by traversing cycle $c$ (starting at node $n$) and then randomly walking
- $c â câ²$
- return $c$
python performance graph
Given a graph that has an Eulerian cycle, write a function which returns the cycle in tuple form.
I came up with following solution for this problem and am stuck trying to make it faster. Do you have any tips?
def eulerian_cycle_1(data):
graph, edges_amount = get_graph(data) #graph:: source:[destination]
cycle = deque()
cur = 0
while edges_amount > 0:
choices = graph[cur]
while choices:
cycle.append(cur)
edges_amount -= 1
cur = choices.pop()
choices = graph.get(cur, None)
if edges_amount == 0:
break
rotate = 0
for cur in cycle:
if graph[cur]:
break
rotate += 1
cycle.rotate(-rotate)
cycle.rotate(-cycle.index(0))
cycle.append(0)
return tuple(cycle)
Pseudocode
EulerianCycle($G$):
- form a cycle $c$ by randomly walking in graph $G$ (don't visit the same edge twice!)
- while there are unexplored edges in graph $G$
- select a node $n$ in cycle $c$ with still unexplored edges
- form cycle $câ²$ by traversing cycle $c$ (starting at node $n$) and then randomly walking
- $c â câ²$
- return $c$
python performance graph
edited Mar 2 at 10:12
Gareth Rees
41.1k394167
41.1k394167
asked Mar 1 at 16:39
Sam Coutteau
133
133
6
Hey Sam, will be good if you append some tests where you are profiling the code, and see where the bottlenecks are, to know which kind of delays you are experiencing
â A. Romeu
Mar 1 at 16:46
4
Can you please provide some sample input and output? You mention a function,get_graph
, that isn't present - can you include that as well please?
â Dannnno
Mar 1 at 21:23
1
@A.Romeu: see my answer for the worst case graphs and a demonstration of the performance problem.
â Gareth Rees
Mar 4 at 10:55
add a comment |Â
6
Hey Sam, will be good if you append some tests where you are profiling the code, and see where the bottlenecks are, to know which kind of delays you are experiencing
â A. Romeu
Mar 1 at 16:46
4
Can you please provide some sample input and output? You mention a function,get_graph
, that isn't present - can you include that as well please?
â Dannnno
Mar 1 at 21:23
1
@A.Romeu: see my answer for the worst case graphs and a demonstration of the performance problem.
â Gareth Rees
Mar 4 at 10:55
6
6
Hey Sam, will be good if you append some tests where you are profiling the code, and see where the bottlenecks are, to know which kind of delays you are experiencing
â A. Romeu
Mar 1 at 16:46
Hey Sam, will be good if you append some tests where you are profiling the code, and see where the bottlenecks are, to know which kind of delays you are experiencing
â A. Romeu
Mar 1 at 16:46
4
4
Can you please provide some sample input and output? You mention a function,
get_graph
, that isn't present - can you include that as well please?â Dannnno
Mar 1 at 21:23
Can you please provide some sample input and output? You mention a function,
get_graph
, that isn't present - can you include that as well please?â Dannnno
Mar 1 at 21:23
1
1
@A.Romeu: see my answer for the worst case graphs and a demonstration of the performance problem.
â Gareth Rees
Mar 4 at 10:55
@A.Romeu: see my answer for the worst case graphs and a demonstration of the performance problem.
â Gareth Rees
Mar 4 at 10:55
add a comment |Â
1 Answer
1
active
oldest
votes
up vote
4
down vote
accepted
1. Review
The code returns the wrong result when the graph has no Eulerian cycle. For example, if we give it the graph
0:[1], 1:
then the code returns the tuple(0, 0)
, which does not correspond to any legal path in the graph. It would be better to raise an exception if the graph has no Eulerian cycle.I know that the problem description says that the graph has an Eulerian cycle, but in real life data is sometimes incorrect and it is a good idea to write code so that it is robust.
Similarly, the code goes into an infinite loop if the graph is disconnected:
>>> eulerian_cycle_1(0:[0], 1:[1], 2)
^C
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 21, in eulerian_cycle_1
if graph[cur]:
KeyboardInterruptIt would be more robust to raise an exception in this case.
The code doesn't work if the graph doesn't have a node 0:
>>> eulerian_cycle_1(1:[1], 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 8, in eulerian_cycle_1
choices = graph[cur]
KeyError: 0It would be more robust to pick a starting node from the graph.
The number of edges can be easily computed from the graph like this:
sum(map(len, graph.values()))
so it would simplify the interface if the code only required the graph, and computed the number of edges itself.
But in fact there is no need to know the number of edges: you can exit the main loop if the search along the cycle for a node with an edge fails.
2. Performance
The problem with the performance is that whenever it reaches a point where the cycle cannot be extended, the code searches along the cycle to try to find a node which still has edges. In the worst case, a graph with $n$ nodes and $O(n)$ edges might have $ÃÂ(n)$ points where the cycle cannot be extended, and at each of these points the code will have to search $ÃÂ(n)$ nodes in the cycle. In this case the overall runtime will be $ÃÂ(n^2)$, that is quadratic.
The worst case arises in graphs like this:
and we can build these graphs systematically if we define get_graph
like this:
def get_graph(n):
"Return a worst-case graph with n nodes and 2n edges."
return i:[min(i + 1, n - 1), max(i - 1, 0)] for i in range(n), 2 * n
Now, if we increase the size of the graph by 10 times, it takes 100 times as long to find an Eulerian cycle:
>>> from timeit import timeit
>>> timeit(lambda:eulerian_cycle_1(10**3), number=1)
0.08308156998828053
>>> timeit(lambda:eulerian_cycle_1(10**4), number=1)
8.778133336978499
To make the runtime linear in the number of edges, we have to avoid traversing or rotating the whole cycle, except possibly once at the end. There are two approaches we might take:
Represent the cycle as a linked list (not a deque) so that we can efficiently insert new items at any point, without having to rotate.
Don't try to join the cycles as we go along, but instead keep a collection of cycles and stitch them together at the end using a depth-first search.
I'll show here how to implement the first approach (see this answer for how to implement the second approach).
Python doesn't provide a linked list implementation, but we can easily make one:
class Link:
"A link in a linked list."
def __init__(self, node):
self.node = node
self.next = self
def insert(self, link):
"Insert link after self in the linked list and return link."
link.next = self.next
self.next = link
return link
Now it's just a matter of keeping track of the Link
objects corresponding to the nodes in the graph that still have edges, so that we can efficiently find the place to continue extending the cycle.
def eulerian_cycle_2(graph):
"""Return an Eulerian cycle in graph, if there is one, as a list of
nodes. If there are no Eulerian cycles, raise ValueError.
"""
# Take a copy of the graph so that we can modify it.
graph = k:v[:] for k, v in graph.items()
# Start at any node in the graph that has an edge.
node = next(node for node, neighbours in graph.items() if neighbours)
choices = graph[node]
start = cur = Link(node)
# Map from node we've visited (that still has edges) to a link for
# that node in the linked list.
visited =
while True:
# Extend current cycle until no more edges can be followed.
cycle_start = node
while choices:
visited[node] = cur
node = choices.pop()
choices = graph[node]
cur = cur.insert(Link(node))
if node != cycle_start:
raise ValueError("graph has no Eulerian cycle")
# Find a visited node which still has edges, if any.
while visited:
node, cur = visited.popitem()
choices = graph[node]
if choices:
break
else:
break
if any(graph.values()):
raise ValueError("graph has no Eulerian cycle")
# Reconstruct cycle by following linked list.
cycle =
cur = start
while True:
cycle.append(cur.node)
cur = cur.next
if cur == start:
break
return cycle
We should check that the performance is linear in the number of edges. Here you can see that when the graph is 10 times as big, the runtime is roughly 10 times as long, as required:
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**3)[0]), number=1)
0.0049093629932031035
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**4)[0]), number=1)
0.045048179978039116
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**5)[0]), number=1)
0.42420267598936334
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**6)[0]), number=1)
4.303449199011084
For comparison, by extrapolating the timings from earlier in this answer, we would have expected eulerian_cycle_1
to take about 24 hours to find a cycle in the worst-case graph with $10^6$ notes.
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
 |Â
show 2 more comments
1 Answer
1
active
oldest
votes
1 Answer
1
active
oldest
votes
active
oldest
votes
active
oldest
votes
up vote
4
down vote
accepted
1. Review
The code returns the wrong result when the graph has no Eulerian cycle. For example, if we give it the graph
0:[1], 1:
then the code returns the tuple(0, 0)
, which does not correspond to any legal path in the graph. It would be better to raise an exception if the graph has no Eulerian cycle.I know that the problem description says that the graph has an Eulerian cycle, but in real life data is sometimes incorrect and it is a good idea to write code so that it is robust.
Similarly, the code goes into an infinite loop if the graph is disconnected:
>>> eulerian_cycle_1(0:[0], 1:[1], 2)
^C
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 21, in eulerian_cycle_1
if graph[cur]:
KeyboardInterruptIt would be more robust to raise an exception in this case.
The code doesn't work if the graph doesn't have a node 0:
>>> eulerian_cycle_1(1:[1], 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 8, in eulerian_cycle_1
choices = graph[cur]
KeyError: 0It would be more robust to pick a starting node from the graph.
The number of edges can be easily computed from the graph like this:
sum(map(len, graph.values()))
so it would simplify the interface if the code only required the graph, and computed the number of edges itself.
But in fact there is no need to know the number of edges: you can exit the main loop if the search along the cycle for a node with an edge fails.
2. Performance
The problem with the performance is that whenever it reaches a point where the cycle cannot be extended, the code searches along the cycle to try to find a node which still has edges. In the worst case, a graph with $n$ nodes and $O(n)$ edges might have $ÃÂ(n)$ points where the cycle cannot be extended, and at each of these points the code will have to search $ÃÂ(n)$ nodes in the cycle. In this case the overall runtime will be $ÃÂ(n^2)$, that is quadratic.
The worst case arises in graphs like this:
and we can build these graphs systematically if we define get_graph
like this:
def get_graph(n):
"Return a worst-case graph with n nodes and 2n edges."
return i:[min(i + 1, n - 1), max(i - 1, 0)] for i in range(n), 2 * n
Now, if we increase the size of the graph by 10 times, it takes 100 times as long to find an Eulerian cycle:
>>> from timeit import timeit
>>> timeit(lambda:eulerian_cycle_1(10**3), number=1)
0.08308156998828053
>>> timeit(lambda:eulerian_cycle_1(10**4), number=1)
8.778133336978499
To make the runtime linear in the number of edges, we have to avoid traversing or rotating the whole cycle, except possibly once at the end. There are two approaches we might take:
Represent the cycle as a linked list (not a deque) so that we can efficiently insert new items at any point, without having to rotate.
Don't try to join the cycles as we go along, but instead keep a collection of cycles and stitch them together at the end using a depth-first search.
I'll show here how to implement the first approach (see this answer for how to implement the second approach).
Python doesn't provide a linked list implementation, but we can easily make one:
class Link:
"A link in a linked list."
def __init__(self, node):
self.node = node
self.next = self
def insert(self, link):
"Insert link after self in the linked list and return link."
link.next = self.next
self.next = link
return link
Now it's just a matter of keeping track of the Link
objects corresponding to the nodes in the graph that still have edges, so that we can efficiently find the place to continue extending the cycle.
def eulerian_cycle_2(graph):
"""Return an Eulerian cycle in graph, if there is one, as a list of
nodes. If there are no Eulerian cycles, raise ValueError.
"""
# Take a copy of the graph so that we can modify it.
graph = k:v[:] for k, v in graph.items()
# Start at any node in the graph that has an edge.
node = next(node for node, neighbours in graph.items() if neighbours)
choices = graph[node]
start = cur = Link(node)
# Map from node we've visited (that still has edges) to a link for
# that node in the linked list.
visited =
while True:
# Extend current cycle until no more edges can be followed.
cycle_start = node
while choices:
visited[node] = cur
node = choices.pop()
choices = graph[node]
cur = cur.insert(Link(node))
if node != cycle_start:
raise ValueError("graph has no Eulerian cycle")
# Find a visited node which still has edges, if any.
while visited:
node, cur = visited.popitem()
choices = graph[node]
if choices:
break
else:
break
if any(graph.values()):
raise ValueError("graph has no Eulerian cycle")
# Reconstruct cycle by following linked list.
cycle =
cur = start
while True:
cycle.append(cur.node)
cur = cur.next
if cur == start:
break
return cycle
We should check that the performance is linear in the number of edges. Here you can see that when the graph is 10 times as big, the runtime is roughly 10 times as long, as required:
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**3)[0]), number=1)
0.0049093629932031035
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**4)[0]), number=1)
0.045048179978039116
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**5)[0]), number=1)
0.42420267598936334
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**6)[0]), number=1)
4.303449199011084
For comparison, by extrapolating the timings from earlier in this answer, we would have expected eulerian_cycle_1
to take about 24 hours to find a cycle in the worst-case graph with $10^6$ notes.
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
 |Â
show 2 more comments
up vote
4
down vote
accepted
1. Review
The code returns the wrong result when the graph has no Eulerian cycle. For example, if we give it the graph
0:[1], 1:
then the code returns the tuple(0, 0)
, which does not correspond to any legal path in the graph. It would be better to raise an exception if the graph has no Eulerian cycle.I know that the problem description says that the graph has an Eulerian cycle, but in real life data is sometimes incorrect and it is a good idea to write code so that it is robust.
Similarly, the code goes into an infinite loop if the graph is disconnected:
>>> eulerian_cycle_1(0:[0], 1:[1], 2)
^C
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 21, in eulerian_cycle_1
if graph[cur]:
KeyboardInterruptIt would be more robust to raise an exception in this case.
The code doesn't work if the graph doesn't have a node 0:
>>> eulerian_cycle_1(1:[1], 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 8, in eulerian_cycle_1
choices = graph[cur]
KeyError: 0It would be more robust to pick a starting node from the graph.
The number of edges can be easily computed from the graph like this:
sum(map(len, graph.values()))
so it would simplify the interface if the code only required the graph, and computed the number of edges itself.
But in fact there is no need to know the number of edges: you can exit the main loop if the search along the cycle for a node with an edge fails.
2. Performance
The problem with the performance is that whenever it reaches a point where the cycle cannot be extended, the code searches along the cycle to try to find a node which still has edges. In the worst case, a graph with $n$ nodes and $O(n)$ edges might have $ÃÂ(n)$ points where the cycle cannot be extended, and at each of these points the code will have to search $ÃÂ(n)$ nodes in the cycle. In this case the overall runtime will be $ÃÂ(n^2)$, that is quadratic.
The worst case arises in graphs like this:
and we can build these graphs systematically if we define get_graph
like this:
def get_graph(n):
"Return a worst-case graph with n nodes and 2n edges."
return i:[min(i + 1, n - 1), max(i - 1, 0)] for i in range(n), 2 * n
Now, if we increase the size of the graph by 10 times, it takes 100 times as long to find an Eulerian cycle:
>>> from timeit import timeit
>>> timeit(lambda:eulerian_cycle_1(10**3), number=1)
0.08308156998828053
>>> timeit(lambda:eulerian_cycle_1(10**4), number=1)
8.778133336978499
To make the runtime linear in the number of edges, we have to avoid traversing or rotating the whole cycle, except possibly once at the end. There are two approaches we might take:
Represent the cycle as a linked list (not a deque) so that we can efficiently insert new items at any point, without having to rotate.
Don't try to join the cycles as we go along, but instead keep a collection of cycles and stitch them together at the end using a depth-first search.
I'll show here how to implement the first approach (see this answer for how to implement the second approach).
Python doesn't provide a linked list implementation, but we can easily make one:
class Link:
"A link in a linked list."
def __init__(self, node):
self.node = node
self.next = self
def insert(self, link):
"Insert link after self in the linked list and return link."
link.next = self.next
self.next = link
return link
Now it's just a matter of keeping track of the Link
objects corresponding to the nodes in the graph that still have edges, so that we can efficiently find the place to continue extending the cycle.
def eulerian_cycle_2(graph):
"""Return an Eulerian cycle in graph, if there is one, as a list of
nodes. If there are no Eulerian cycles, raise ValueError.
"""
# Take a copy of the graph so that we can modify it.
graph = k:v[:] for k, v in graph.items()
# Start at any node in the graph that has an edge.
node = next(node for node, neighbours in graph.items() if neighbours)
choices = graph[node]
start = cur = Link(node)
# Map from node we've visited (that still has edges) to a link for
# that node in the linked list.
visited =
while True:
# Extend current cycle until no more edges can be followed.
cycle_start = node
while choices:
visited[node] = cur
node = choices.pop()
choices = graph[node]
cur = cur.insert(Link(node))
if node != cycle_start:
raise ValueError("graph has no Eulerian cycle")
# Find a visited node which still has edges, if any.
while visited:
node, cur = visited.popitem()
choices = graph[node]
if choices:
break
else:
break
if any(graph.values()):
raise ValueError("graph has no Eulerian cycle")
# Reconstruct cycle by following linked list.
cycle =
cur = start
while True:
cycle.append(cur.node)
cur = cur.next
if cur == start:
break
return cycle
We should check that the performance is linear in the number of edges. Here you can see that when the graph is 10 times as big, the runtime is roughly 10 times as long, as required:
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**3)[0]), number=1)
0.0049093629932031035
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**4)[0]), number=1)
0.045048179978039116
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**5)[0]), number=1)
0.42420267598936334
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**6)[0]), number=1)
4.303449199011084
For comparison, by extrapolating the timings from earlier in this answer, we would have expected eulerian_cycle_1
to take about 24 hours to find a cycle in the worst-case graph with $10^6$ notes.
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
 |Â
show 2 more comments
up vote
4
down vote
accepted
up vote
4
down vote
accepted
1. Review
The code returns the wrong result when the graph has no Eulerian cycle. For example, if we give it the graph
0:[1], 1:
then the code returns the tuple(0, 0)
, which does not correspond to any legal path in the graph. It would be better to raise an exception if the graph has no Eulerian cycle.I know that the problem description says that the graph has an Eulerian cycle, but in real life data is sometimes incorrect and it is a good idea to write code so that it is robust.
Similarly, the code goes into an infinite loop if the graph is disconnected:
>>> eulerian_cycle_1(0:[0], 1:[1], 2)
^C
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 21, in eulerian_cycle_1
if graph[cur]:
KeyboardInterruptIt would be more robust to raise an exception in this case.
The code doesn't work if the graph doesn't have a node 0:
>>> eulerian_cycle_1(1:[1], 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 8, in eulerian_cycle_1
choices = graph[cur]
KeyError: 0It would be more robust to pick a starting node from the graph.
The number of edges can be easily computed from the graph like this:
sum(map(len, graph.values()))
so it would simplify the interface if the code only required the graph, and computed the number of edges itself.
But in fact there is no need to know the number of edges: you can exit the main loop if the search along the cycle for a node with an edge fails.
2. Performance
The problem with the performance is that whenever it reaches a point where the cycle cannot be extended, the code searches along the cycle to try to find a node which still has edges. In the worst case, a graph with $n$ nodes and $O(n)$ edges might have $ÃÂ(n)$ points where the cycle cannot be extended, and at each of these points the code will have to search $ÃÂ(n)$ nodes in the cycle. In this case the overall runtime will be $ÃÂ(n^2)$, that is quadratic.
The worst case arises in graphs like this:
and we can build these graphs systematically if we define get_graph
like this:
def get_graph(n):
"Return a worst-case graph with n nodes and 2n edges."
return i:[min(i + 1, n - 1), max(i - 1, 0)] for i in range(n), 2 * n
Now, if we increase the size of the graph by 10 times, it takes 100 times as long to find an Eulerian cycle:
>>> from timeit import timeit
>>> timeit(lambda:eulerian_cycle_1(10**3), number=1)
0.08308156998828053
>>> timeit(lambda:eulerian_cycle_1(10**4), number=1)
8.778133336978499
To make the runtime linear in the number of edges, we have to avoid traversing or rotating the whole cycle, except possibly once at the end. There are two approaches we might take:
Represent the cycle as a linked list (not a deque) so that we can efficiently insert new items at any point, without having to rotate.
Don't try to join the cycles as we go along, but instead keep a collection of cycles and stitch them together at the end using a depth-first search.
I'll show here how to implement the first approach (see this answer for how to implement the second approach).
Python doesn't provide a linked list implementation, but we can easily make one:
class Link:
"A link in a linked list."
def __init__(self, node):
self.node = node
self.next = self
def insert(self, link):
"Insert link after self in the linked list and return link."
link.next = self.next
self.next = link
return link
Now it's just a matter of keeping track of the Link
objects corresponding to the nodes in the graph that still have edges, so that we can efficiently find the place to continue extending the cycle.
def eulerian_cycle_2(graph):
"""Return an Eulerian cycle in graph, if there is one, as a list of
nodes. If there are no Eulerian cycles, raise ValueError.
"""
# Take a copy of the graph so that we can modify it.
graph = k:v[:] for k, v in graph.items()
# Start at any node in the graph that has an edge.
node = next(node for node, neighbours in graph.items() if neighbours)
choices = graph[node]
start = cur = Link(node)
# Map from node we've visited (that still has edges) to a link for
# that node in the linked list.
visited =
while True:
# Extend current cycle until no more edges can be followed.
cycle_start = node
while choices:
visited[node] = cur
node = choices.pop()
choices = graph[node]
cur = cur.insert(Link(node))
if node != cycle_start:
raise ValueError("graph has no Eulerian cycle")
# Find a visited node which still has edges, if any.
while visited:
node, cur = visited.popitem()
choices = graph[node]
if choices:
break
else:
break
if any(graph.values()):
raise ValueError("graph has no Eulerian cycle")
# Reconstruct cycle by following linked list.
cycle =
cur = start
while True:
cycle.append(cur.node)
cur = cur.next
if cur == start:
break
return cycle
We should check that the performance is linear in the number of edges. Here you can see that when the graph is 10 times as big, the runtime is roughly 10 times as long, as required:
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**3)[0]), number=1)
0.0049093629932031035
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**4)[0]), number=1)
0.045048179978039116
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**5)[0]), number=1)
0.42420267598936334
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**6)[0]), number=1)
4.303449199011084
For comparison, by extrapolating the timings from earlier in this answer, we would have expected eulerian_cycle_1
to take about 24 hours to find a cycle in the worst-case graph with $10^6$ notes.
1. Review
The code returns the wrong result when the graph has no Eulerian cycle. For example, if we give it the graph
0:[1], 1:
then the code returns the tuple(0, 0)
, which does not correspond to any legal path in the graph. It would be better to raise an exception if the graph has no Eulerian cycle.I know that the problem description says that the graph has an Eulerian cycle, but in real life data is sometimes incorrect and it is a good idea to write code so that it is robust.
Similarly, the code goes into an infinite loop if the graph is disconnected:
>>> eulerian_cycle_1(0:[0], 1:[1], 2)
^C
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 21, in eulerian_cycle_1
if graph[cur]:
KeyboardInterruptIt would be more robust to raise an exception in this case.
The code doesn't work if the graph doesn't have a node 0:
>>> eulerian_cycle_1(1:[1], 1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cr188627.py", line 8, in eulerian_cycle_1
choices = graph[cur]
KeyError: 0It would be more robust to pick a starting node from the graph.
The number of edges can be easily computed from the graph like this:
sum(map(len, graph.values()))
so it would simplify the interface if the code only required the graph, and computed the number of edges itself.
But in fact there is no need to know the number of edges: you can exit the main loop if the search along the cycle for a node with an edge fails.
2. Performance
The problem with the performance is that whenever it reaches a point where the cycle cannot be extended, the code searches along the cycle to try to find a node which still has edges. In the worst case, a graph with $n$ nodes and $O(n)$ edges might have $ÃÂ(n)$ points where the cycle cannot be extended, and at each of these points the code will have to search $ÃÂ(n)$ nodes in the cycle. In this case the overall runtime will be $ÃÂ(n^2)$, that is quadratic.
The worst case arises in graphs like this:
and we can build these graphs systematically if we define get_graph
like this:
def get_graph(n):
"Return a worst-case graph with n nodes and 2n edges."
return i:[min(i + 1, n - 1), max(i - 1, 0)] for i in range(n), 2 * n
Now, if we increase the size of the graph by 10 times, it takes 100 times as long to find an Eulerian cycle:
>>> from timeit import timeit
>>> timeit(lambda:eulerian_cycle_1(10**3), number=1)
0.08308156998828053
>>> timeit(lambda:eulerian_cycle_1(10**4), number=1)
8.778133336978499
To make the runtime linear in the number of edges, we have to avoid traversing or rotating the whole cycle, except possibly once at the end. There are two approaches we might take:
Represent the cycle as a linked list (not a deque) so that we can efficiently insert new items at any point, without having to rotate.
Don't try to join the cycles as we go along, but instead keep a collection of cycles and stitch them together at the end using a depth-first search.
I'll show here how to implement the first approach (see this answer for how to implement the second approach).
Python doesn't provide a linked list implementation, but we can easily make one:
class Link:
"A link in a linked list."
def __init__(self, node):
self.node = node
self.next = self
def insert(self, link):
"Insert link after self in the linked list and return link."
link.next = self.next
self.next = link
return link
Now it's just a matter of keeping track of the Link
objects corresponding to the nodes in the graph that still have edges, so that we can efficiently find the place to continue extending the cycle.
def eulerian_cycle_2(graph):
"""Return an Eulerian cycle in graph, if there is one, as a list of
nodes. If there are no Eulerian cycles, raise ValueError.
"""
# Take a copy of the graph so that we can modify it.
graph = k:v[:] for k, v in graph.items()
# Start at any node in the graph that has an edge.
node = next(node for node, neighbours in graph.items() if neighbours)
choices = graph[node]
start = cur = Link(node)
# Map from node we've visited (that still has edges) to a link for
# that node in the linked list.
visited =
while True:
# Extend current cycle until no more edges can be followed.
cycle_start = node
while choices:
visited[node] = cur
node = choices.pop()
choices = graph[node]
cur = cur.insert(Link(node))
if node != cycle_start:
raise ValueError("graph has no Eulerian cycle")
# Find a visited node which still has edges, if any.
while visited:
node, cur = visited.popitem()
choices = graph[node]
if choices:
break
else:
break
if any(graph.values()):
raise ValueError("graph has no Eulerian cycle")
# Reconstruct cycle by following linked list.
cycle =
cur = start
while True:
cycle.append(cur.node)
cur = cur.next
if cur == start:
break
return cycle
We should check that the performance is linear in the number of edges. Here you can see that when the graph is 10 times as big, the runtime is roughly 10 times as long, as required:
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**3)[0]), number=1)
0.0049093629932031035
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**4)[0]), number=1)
0.045048179978039116
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**5)[0]), number=1)
0.42420267598936334
>>> timeit(lambda:eulerian_cycle_2(get_graph(10**6)[0]), number=1)
4.303449199011084
For comparison, by extrapolating the timings from earlier in this answer, we would have expected eulerian_cycle_1
to take about 24 hours to find a cycle in the worst-case graph with $10^6$ notes.
edited Mar 6 at 16:58
answered Mar 4 at 10:55
Gareth Rees
41.1k394167
41.1k394167
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
 |Â
show 2 more comments
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
"Given a graph that has an Eulerian cycle,"
â Sam Coutteau
Mar 6 at 9:08
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
@SamCoutteau: See §1.1 where I discuss that point.
â Gareth Rees
Mar 6 at 9:20
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
This graph, produces an output of (0, ...., 0, 0, ..., 52) instead of (0, ..., 52, 0, ..., 0) any ideas why?, have been trying to figure out what's happening here
â Sam Coutteau
Mar 6 at 15:51
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
That's not the issue here there is no edge 0 -> 0, your code normally adds the first node on the end except in this case. the 0,0 is not the only error it also has (...,23,23,...) whilst there is no edge 23 -> 23
â Sam Coutteau
Mar 6 at 16:26
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
this seems to fix my issues, some minor changes, like removing the self reference on the end of the list
â Sam Coutteau
Mar 6 at 16:57
 |Â
show 2 more comments
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
StackExchange.ready(
function ()
StackExchange.openid.initPostLogin('.new-post-login', 'https%3a%2f%2fcodereview.stackexchange.com%2fquestions%2f188627%2ffinding-an-eulerian-cycle-in-a-graph%23new-answer', 'question_page');
);
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Post as a guest
Sign up or log in
StackExchange.ready(function ()
StackExchange.helpers.onClickDraftSave('#login-link');
);
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
Sign up using Google
Sign up using Facebook
Sign up using Email and Password
6
Hey Sam, will be good if you append some tests where you are profiling the code, and see where the bottlenecks are, to know which kind of delays you are experiencing
â A. Romeu
Mar 1 at 16:46
4
Can you please provide some sample input and output? You mention a function,
get_graph
, that isn't present - can you include that as well please?â Dannnno
Mar 1 at 21:23
1
@A.Romeu: see my answer for the worst case graphs and a demonstration of the performance problem.
â Gareth Rees
Mar 4 at 10:55