Algorithms 2022-2023
Algorithms 2022-2023
Giacomo Fiumara
[email protected]
1 / 258
Section 1
Introduction
Introduction to Algorithms (1)
3 / 258
Introduction to Algorithms (2)
4 / 258
Introduction to Algorithms (3)
Example: sorting
Input:
A sequence composed of n number a1 , a2 , . . . , an
Output:
A permutation a1′ , a2′ , . . . , an′ of the input sequence such that
5 / 258
Insertion sort
6 / 258
Insertion sort
7 / 258
Insertion sort
def InsertionSort(a):
for j in range(1, len(a)):
key = a[j]
i = j
while i > 0 and a[i-1] > key:
a[i] = a[i-1]
i = i - 1
a[i] = key
print(a)
return a
ins-sort.py
8 / 258
Insertion sort
– At the beginning of the for loop, a[1, ... j-1] contains all
sorted elements, while a[j+1 ... n] contains unsorted
elements
9 / 258
Insertion sort
Algorithm analysis
10 / 258
Insertion sort
Algorithm analysis
Input size
– Number of elements composing the input data
11 / 258
Insertion sort
Algorithm analysis
Computational cost
– Number of elementary instructions to be executed
12 / 258
Insertion sort
Algorithm analysis
n−1
X
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 tj
j=1
n−1
X n−1
X
+ c5 (tj − 1) + c6 (tj − 1) + c7 (n − 1)
j=1 j=1
13 / 258
Insertion sort
Algorithm analysis
n−1
X
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 tj
j=1
n−1
X n−1
X
+ c5 (tj − 1) + c6 (tj − 1) + c7 (n − 1)
j=1 j=1
14 / 258
Insertion sort
Algorithm analysis
The equation:
n−1
X
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 tj
j=1
n−1
X n−1
X
+ c5 (tj − 1) + c6 (tj − 1) + c7 (n − 1)
j=1 j=1
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 (n − 1) + c7 (n − 1)
= (c1 + c2 + c3 + c4 + c7 )n − (c2 + c3 + c4 + c7 )
15 / 258
Insertion sort
Algorithm analysis
The equation:
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 (n − 1) + c7 (n − 1)
= (c1 + c2 + c3 + c4 + c7 )n − (c2 + c3 + c4 + c7 )
T (n) = an + b
16 / 258
Insertion sort
Algorithm analysis
n−1 n−1
X X n(n − 1)
tj = j=
2
j=1 j=1
n−1 n−1
X X (n − 1)2
(tj − 1) = (j − 1) =
2
j=1 j=1
17 / 258
Mathematical detour
Arithmetic progression
an = a1 + (n − 1)d
ak = aj + (k − j)d
18 / 258
Mathematical detour
Arithmetic progression
1
Sn = n(a1 + an )
2
Indeed, we can write:
Sn = a1 + a2 + a3 + · · · + an−2 + an−1 + an
Sn = an + an−1 + an−2 + · · · + a3 + a2 + a1
If we sum the LHSs and the RHSs of the two above
expressions:
Quantities in parentheses
2Sn = (a1 + an ) + (a2 + an−1 ) + (a3 + an−2 ) + · · ·
+ (a3 + an−2 ) + (a2 + an−1 ) + (a1 + an )
a2 + an−1 = a1 + d + an − d = a1 + an
a3 + an−2 = a1 + 2d + an − 2d = a1 + an
20 / 258
Mathematical detour
Arithmetic progression
Therefore:
1
Sn = n(a1 + an )
2
21 / 258
Insertion sort
Algorithm analysis
n−1
X (n − 1)(1 + n − 1) n(n − 1)
j= =
2 2
j=1
n−1
X (n − 1)(1 − 1 + n − 1) (n − 1)2
(j − 1) = =
2 2
j=1
22 / 258
Insertion sort
Algorithm analysis
Equation
n−1
X
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 tj
j=1
n−1
X n−1
X
+ c5 (tj − 1) + c6 (tj − 1) + c7 (n − 1)
j=1 j=1
becomes:
n−1
X
T (n) = c1 n + c2 (n − 1) + c3 (n − 1) + c4 j
j=1
n−1
X n−1
X
+ c5 (j − 1) + c6 (j − 1) + c7 (n − 1)
j=1 j=1
23 / 258
Insertion sort
Algorithm analysis
T (n) = an2 + bn + c
Concluding remark
Divide
Conquer
Combine
26 / 258
Divide and conquer
Merge sort
– Divide
Problems are divided in a number of subproblems, of the same
kind but having a smaller input data size
– Conquer
A recursive approach is adopted for subproblems. When their
input data size is small enough, subproblems are eventually
solved
– Combine
Solutions of subproblems are eventually combined to obtain
the solution of the original problem
27 / 258
Divide and conquer
Merge sort
– Divide
If S contains at least two elements its elements must be
copied in two sequences S1 and S2 containing ⌈n/2⌉ and
⌊n/2⌋ elements respectively
– Conquer
Recursively sort sequences S1 and S2
– Combine
Copy elements in S merging the two sorted sequences S1 ed S2
in a sorted sequence
28 / 258
Divide and conquer
Merge sort
29 / 258
Divide and conquer
Merge sort
def merge(s1,s2,s):
i = j = 0
while i+j < len(s):
if j==len(s2) or (i<len(s1) and s1[i]<s2[j]):
#print("IF: ",i,’\t’,j,’\t’)
s[i+j] = s1[i]
i += 1
else:
#print("ELSE: ",i,’\t’,j,’\t’)
s[i+j] = s2[j]
j += 1
30 / 258
Divide and conquer
Merge sort
def mergesort(s):
n = len(s)
if n < 2:
return
mid = n // 2
s1 = s[0:mid]
s2 = s[mid:n]
print("Prima: ",s1,s2,s)
mergesort(s1)
mergesort(s2)
merge(s1,s2,s)
print(s1,s2,s)
31 / 258
Divide and conquer
Merge sort
Array:
[85, 24, 63, 45, 17, 31, 96, 50]
[24, 85]
[45, 63]
[24, 45, 63, 85]
[17, 31]
[50, 96]
[17, 31, 50, 96]
[17, 24, 31, 45, 50, 63, 85, 96]
32 / 258
Divide and conquer
Merge sort
33 / 258
Divide and conquer
Merge sort
Θ(1) se n ≤ c
T (n) =
aT (n/b) + D(n) + C (n) negli altri casi
Where
34 / 258
Divide and conquer
Merge sort
– T (n/b)
The current problem is divided in two subproblems each
working on n/2 input data. Therefore the Conquer phase
costs 2T (n/2)
– D(n)
In the Division phase the median element of the subarray must
be identified. This operation has a constant computational
cost, D(n) = Θ(1)
– C (n)
merge function costs Θ(n)
35 / 258
Divide and conquer
Merge sort
Therefore:
Θ(1) se n = 1
T (n) =
2T (n/2) + Θ(n) se n > 1
c se n = 1
T (n) =
2T (n/2) + cn se n > 1
36 / 258
Divide and conquer
Merge sort
37 / 258
Divide and conquer
Merge sort
38 / 258
Divide and conquer
Merge sort
39 / 258
Divide and conquer
Merge sort
n = 2i
i = log2 n = lg n
40 / 258
Divide and conquer
Merge sort
41 / 258
Section 3
Growth of Functions
Algorithm Analysis
Θ-notation
A function f (n) belongs to the set Θ(g (n)) if there exist positive
constants c1 and c2 such that it can be “sandwiched” between
c1 g (n) and c2 g (n), for sufficiently large n
43 / 258
Algorithm Analysis
Θ-notation
44 / 258
Algorithm Analysis
Θ-notation
45 / 258
Algorithm Analysis
Θ-notation
f (n) = an2 + bn + c
46 / 258
Algorithm Analysis
Θ-notation
d
X
p(n) = ai ni
i=0
47 / 258
Algorithm Analysis
O-notation
48 / 258
Algorithm Analysis
O-notation
49 / 258
Algorithm Analysis
O-notation
50 / 258
Algorithm Analysis
Ω-notation
51 / 258
Algorithm Analysis
Ω-notation
52 / 258
Algorithm Analysis
Ω-notation
53 / 258
Algorithm Analysis
An example: prefix averages
54 / 258
Algorithm Analysis
An example: prefix averages (first implementation)
def prefix_average1(S):
n = len(S)
A = [0] * n
for j in range(n):
total = 0
for i in range(j+1):
total += S[i]
A[j] = total / (j + 1)
return A
55 / 258
Algorithm Analysis
An example: prefix averages (first implementation)
56 / 258
Algorithm Analysis
An example: prefix averages (first implementation)
57 / 258
Algorithm Analysis
An example: prefix averages (second implementation)
def prefix_average2(S):
n = len(S)
A = [0] * n
for j in range(n):
A[j] = sum(S[0:j+1]) / (j + 1)
return A
58 / 258
Algorithm Analysis
An example: prefix averages (second implementation)
59 / 258
Algorithm Analysis
An example: prefix averages (third implementation)
def prefix_average3(S):
n = len(S)
A = [0] * n
for j in range(n):
total += S[j]
A[j] = total / (j + 1)
return A
60 / 258
Algorithm Analysis
An example: prefix averages (third implementation)
(
1 se n = 0
n! =
n · (n − 1) · (n − 2) · · · 3 · 2 · 1 se n ≥ 1
63 / 258
Recursion
The Factorial Function
(
1 se n = 0
n! =
n · (n − 1)! se n ≥ 1
64 / 258
Recursion
The Factorial Function
def factorial(n):
if n == 0:
return 1
else:
return n*factorial(n-1)
65 / 258
Recursion
The Factorial Function
66 / 258
Recursion
The Factorial Function
67 / 258
Recursion
Binary Search
68 / 258
Recursion
Binary Search
70 / 258
Recursion
Binary Search
Three cases:
72 / 258
Recursion
Binary Search
binarysearch.py
73 / 258
Recursion
Binary Search
74 / 258
Recursion
Binary Search
n
<1 , cioè r = ⌊log n + 1⌋
2r
75 / 258
Section 5
Stack
Stack
77 / 258
Stack: ADT
78 / 258
Stack: ADT
79 / 258
Stack: ADT (1/2)
def create():
return []
def pop(mylist):
return mylist.pop()
def top(mylist):
return mylist[-1]
80 / 258
Stack: ADT (2/2)
def is_empty(mylist):
# return False if mylist
if mylist:
return False
else:
return True
def length(mylist):
return len(mylist)
81 / 258
Stack: ADT
– Computational costs
push(e) O(1)
pop() O(1)
top() O(1)
is_empty() O(1)
length(S) O(1)
82 / 258
Section 6
Queues
Queues
84 / 258
Queues
85 / 258
Queues: ADT
86 / 258
Queues: ADT
87 / 258
Double-ended queues
88 / 258
Double-ended queues: ADT (1/2)
89 / 258
Double-ended queues: ADT (2/2)
90 / 258
Double-ended queues
reverse() rotate(n)
91 / 258
Double-ended queues: ADT (1/2)
def del_first(dq):
if len(dq):
return dq.pop(0)
return False
def del_last(dq):
if len(dq):
return dq.pop()
return False
92 / 258
Double-ended queues: ADT (2/2)
def first(dq):
if dq:
return dq[0]
return False
def last(dq):
if dq:
return dq[-1]
return False
def is_empty(dq):
return len(dq) == 0
93 / 258
Section 7
Linked lists
Linked Lists
95 / 258
Singly Linked Lists
96 / 258
Singly Linked Lists
– The first element of a list is called head, the last is the tail
– By starting at the head and moving from one node to another
by following the references, it is possible to reach the tail of
the list (traversing)
97 / 258
Singly Linked Lists
def create():
llist = dict()
llist[’head’] = ’tail’
llist[’tail’] = 0
return llist
98 / 258
Singly Linked Lists
Inserting an Element at the Head
99 / 258
Singly Linked Lists
Inserting an Element at the Head
def add_first(llist,element):
llist[element] = llist[’head’]
llist[’head’] = element
return llist
100 / 258
Singly Linked Lists
Inserting an Element at the Tail
101 / 258
Singly Linked Lists
Inserting an Element at the Tail
def add_last(llist,element):
curr = ’head’
while curr != ’tail’:
next = llist[curr]
if next == ’tail’:
llist[e] = llist[curr]
llist[curr] = e
break
curr = next
return llist
102 / 258
Singly Linked Lists
Removing an Element from a Singly Linked List
103 / 258
Singly Linked Lists
Removing an Element from a Singly Linked List
def del_first(llist):
x = llist[’head’]
llist[’head’] = llist[x]
del llist[x]
104 / 258
Singly Linked Lists
Removing the Last Element from a Singly Linked List
– Therefore, the only way consists in starting from the head and
search through the list
105 / 258
Singly Linked Lists 1/5
def create():
llist = dict()
llist[’head’] = ’tail’
llist[’tail’] = 0
return llist
def add_first(llist,element):
llist[element] = llist[’head’]
llist[’head’] = element
return llist
106 / 258
Singly Linked Lists 2/5
def add_last(llist,e):
curr = ’head’
while curr != ’tail’:
next = llist[curr]
if next == ’tail’:
llist[e] = llist[curr]
llist[curr] = e
break
curr = next
return llist
def del_first(llist):
x = llist[’head’]
llist[’head’] = llist[x]
del llist[x]
107 / 258
Singly Linked List 3/5
Iterative implementation of traversal
def itraverse(llist):
curr = ’head’
path = []
path.append((0,curr))
k = 1
while curr != ’tail’:
next = llist[curr]
path.append((k,next))
curr = next
k += 1
return path
108 / 258
Singly Linked Lists 4/5
Recursive implementation of traversal
109 / 258
Singly Linked Lists 5/5
def printall(llist):
curr = ’head’
while curr != ’tail’:
next = llist[curr]
print(curr, "\t-->\t", next)
curr = next
110 / 258
Doubly Linked Lists
111 / 258
Doubly Linked Lists
112 / 258
Doubly Linked Lists
Inserting and Removing Elements
113 / 258
Doubly Linked Lists
def create():
llist = dict()
llist[’head’] = {’prev’: None,’next’: ’tail’}
llist[’tail’] = {’prev’: ’head’,’next’: None}
return llist
def printall(llist):
curr = ’head’
while curr != ’tail’:
next = llist[curr][’next’]
print(curr, "\t<==>\t", next)
curr = next
114 / 258
Doubly Linked Lists
115 / 258
Doubly Linked Lists
116 / 258
Section 8
Trees
Trees
118 / 258
Trees
119 / 258
Trees
120 / 258
Trees
121 / 258
Trees
– Two nodes sharing the same parent nodes are said sibling
123 / 258
Trees
Example: hierarchy of Python exceptions
124 / 258
Trees
Example: hierarchy of trees
125 / 258
Trees
Ordered trees
126 / 258
Binary trees
Implementation 1/2
def add_root(e):
tree = dict()
tree[e] = {’P’: None, ’L: None’, ’R’: None}
return tree
127 / 258
Binary trees
Implementation 2/2
if __name__ == ’__main__’:
mytree = add_root(’root’)
add_child(mytree, ’root’, ’lft1’, ’L’)
add_child(mytree, ’root’, ’lft2’, ’L’)
add_child(mytree, ’root’, ’rgt1’, ’R’)
add_child(mytree, ’rgt1’, ’rgt2’, ’R’)
for k,v in mytree.items():
print(k, "\t",v)
128 / 258
Trees
Computing Depth
129 / 258
Trees
Computing Depth
def is_root(tree,node):
return tree[node][’P’] == None
def depth(tree,start):
if is_root(tree,start):
return 1
else:
return 1 + depth(tree, parent(tree, start))
130 / 258
Trees
Computing Depth
131 / 258
Trees
Computing height
132 / 258
Trees: altezza
133 / 258
Binary Trees
134 / 258
Binary Trees
135 / 258
Binary Trees
Decision Tree
136 / 258
Binary Trees
Arithmetic expression
(3 + 1) × 3
− ((3 × (7 − 4)) + 6)
(9 − 5) + 2
137 / 258
Binary Trees
Recursive definition
138 / 258
Binary Trees
Abstract Data Types
139 / 258
Binary Trees
Implementation 1/3
def add_root(e):
tree = dict()
tree[e] = {’P’:None, ’L’:None, ’R’:None}
return tree
140 / 258
Binary Trees
Implementation 2/3
def is_root(tree,node):
return tree[node][’P’] == None
def depth(tree,start):
if is_root(tree,start):
return 1
else:
return 1 + depth(tree,tree[start][’P’])
binarytree.py
141 / 258
Binary Trees
Implementation 3/3
binarytree.py
142 / 258
Binary Trees
Properties
– In a binary tree, level 0 has at most one node (the root), level
1 has at most two nodes, level 2 has at most 4 nodes, and so
on
143 / 258
Binary Trees
Properties
144 / 258
Binary Trees
Properties
1 h + 1 ≤ n ≤ 2h+1 − 1
2 1 ≤ nE ≤ 2h
3 h ≤ nI ≤ 2 h − 1
4 log(n + 1) − 1 ≤ h ≤ n − 1
145 / 258
Binary Trees
Properties
1 2h + 1 ≤ n ≤ 2h+1 − 1
2 h + 1 ≤ nE ≤ 2h
3 h ≤ nI ≤ 2 h − 1
4 log(n + 1) − 1 ≤ h ≤ (n − 1)/2
146 / 258
Binary Trees
Properties
147 / 258
Binary Trees
Properties
148 / 258
Binary Trees
Properties
– This operation can be repeated until the final tree will consist
of a single node, which will eventually be an external node
– Thus, the proposition is justified
149 / 258
Binary Trees
Implementation
150 / 258
Binary Trees
Implementation
151 / 258
Trees: preorder traversal
152 / 258
Trees
Preorder traversal
153 / 258
Trees
Preorder traversal
root node
Root level 1 lft
level 2 lft lft
level 2 lft lft
level 1 rgt
level 2 rgt lft
L1 lft L1 rgt level 2 rgt rgt
154 / 258
Trees
Preorder traversal
def preorder(tree,node,visited):
if node != None:
visited.append(node)
lc = tree[node][’L’]
rc = tree[node][’R’]
if lc != None or rc != None:
preorder(tree, lc, visited)
preorder(tree, rc, visited)
return visited
155 / 258
Trees
Postorder traversal
156 / 258
Trees
Postorder traversal
157 / 258
Trees
Postorder traversal
158 / 258
Trees
Postorder traversal
def postorder(tree,node,visited):
if node != None:
lc = tree[node][’L’]
rc = tree[node][’R’]
if lc != None or rc != None:
postorder(tree, lc, visited)
postorder(tree, rc, visited)
visited.append(node)
return visited
159 / 258
Trees
Traversals Computational Complexity
160 / 258
Trees
Breadth-First Tree Traversal
161 / 258
Trees binari: breadth first traversal
root node
Root level 1 lft
level 1 rgt
level 2 lft lft
level 2 lft rgt
level 2 rgt lft
L1 lft L1 rgt level 2 rgt rgt
162 / 258
Trees binari: breadth first traversal
def bfs(tree,node):
"Breadth First Search"
queue,visited = [],[]
if node == root(tree):
queue.append(node)
while queue:
current = queue.pop(0)
visited.append(current)
if tree[current][’L’]:
queue.append(tree[current][’L’])
if tree[current][’R’]:
queue.append(tree[current][’R’])
return visited
163 / 258
Trees: inorder traversal
164 / 258
Trees: inorder traversal
165 / 258
Trees: inorder traversal
def inorder(tree,node,visited):
if node != None:
print(node,visited)
lc = tree[node][’L’]
rc = tree[node][’R’]
if lc != None:
inorder(tree, lc, visited)
visited.append(node)
if rc != None:
inorder(tree, rc, visited)
return visited
166 / 258
Trees: inorder traversal
level 2 sx sx
Root level 1 sx
level 2 sx dx
root node
level 2 dx sx
level 1 dx
L1 sx L1 dx level 2 dx dx
L2 sx sx L2 sx dx L2 dx sx L2 dx dx
167 / 258
Section 9
169 / 258
Binary Search Trees
170 / 258
Binary Search Trees
171 / 258
Binary Search Trees
172 / 258
Binary Search Trees
def inorder(tree,node,visited):
if node != None:
print(node,visited)
lc = tree[node][’L’]
rc = tree[node][’R’]
if lc != None:
inorder(tree, lc, visited)
visited.append(node)
if rc != None:
inorder(tree, rc, visited)
return visited
173 / 258
Binary Search Trees
Query
def tree_search(tree,root,val):
if root == None:
print("Non existent node")
return root
if root == val:
print("Found")
return root
if val < root:
return tree_search(tree,tree[root][’L’],val)
else:
return tree_search(tree,tree[root][’R’],val)
174 / 258
Binary Search Trees
Query
175 / 258
Binary Search Trees
Query: iterative implementation
def iterative_tree_search(tree,root,val):
while root is not None and root != val:
if val < root:
root = tree[root][’L’]
else:
root = tree[root][’R’]
return root
176 / 258
Binary Search Trees
Searching for min/max
def tree_min(tree,root):
while tree[root][’L’] is not None:
root = tree[root][’L’]
return root
def tree_max(tree,root):
while tree[root][’R’] is not None:
root = tree[root][’R’]
return root
177 / 258
Binary Search Trees
Searching for next/previous
def tree_successor(tree,node):
if tree[node][’R’] is not None:
return tree_min(tree,tree[node][’R’])
par = tree[node][’P’]
while par is not None and node == tree[par][’R’]:
node = par
par = tree[par][’P’]
return par
178 / 258
Binary Search Trees
Inserting an element
179 / 258
Binary Search Trees
Inserting an element
def tree_insert(tree,node):
y = None
x = root(tree)
while x is not None:
y = x
if node < x:
x = tree[x][’L’]
else:
x = tree[x][’R’]
tree[node] = {’P’: y, ’L’:None, ’L’:None}
if y is not None and node < y:
tree[y][’L’] = node
else:
tree[y][’R’] = node
180 / 258
Binary Search Trees
Deleting an element
181 / 258
Binary Search Trees
Deleting an element
def transplant(tree,old,new):
oldparent = tree[old][’P’]
if oldparent == None:
tree[new][’P’] = None
if tree[old][’L’]:
tree[new][’L’] = tree[old][’L’]
if tree[old][’R’]:
tree[new][’R’] = tree[old][’R’]
elif old == tree[oldparent][’L’]:
tree[oldparent][’L’] = new
else:
tree[oldparent][’R’] = new
if new != None:
tree[new][’P’] = tree[old][’P’]
del tree[old]
182 / 258
Binary Search Trees
Deleting an element
183 / 258
Binary Search Trees
Deleting an element
184 / 258
Binary Search Trees
Deleting an element
185 / 258
Section 10
Quicksort
Quicksort
187 / 258
Quicksort: how it works 1/2
188 / 258
Quicksort: how it works 2/2
189 / 258
Quicksort
quicksort.py
190 / 258
Quicksort
quicksort.py
191 / 258
Quicksort
– a) Initial condition
– b) The value 2 is placed in the partition containing numbers
smaller than x
192 / 258
Quicksort
194 / 258
Quicksort
– i) x is placed in t
195 / 258
Mathematical detour
Sum of a finite number of elements of a geometric progression
Sn = a1 + a2 + · · · + an−1 + an
q · Sn = q · a1 + q · a2 + · · · + q · an−2 + q · an−1 + q · an =
= a2 + a3 + · · · + an + q · an
q · Sn − Sn = q · an − a1
Sn · (q − 1) = q · an − a1 =
= q n · a1 − a1 =
= a1 · (q n − 1)
qn − 1
Sn = a1 ·
q−1
196 / 258
Quicksort: computational complexity
197 / 258
Quicksort: computational complexity (worst case)
n
T (n) ≤ 2T ( ) + Θ(n)
2
T (n) = O(nlgn)
199 / 258
Quicksort: computational complexity (average case)
– In this case:
9n n
T (n) ≤ T +T + cn
10 10
200 / 258
Quicksort: computational complexity (average case)
9n n
T (n) ≤ T +T + cn
10 10
201 / 258
Section 11
Graphs
Graphs
Introduction
203 / 258
Graphs
204 / 258
Graphs
Some definitions
206 / 258
Graphs
Some definitions
207 / 258
Graphs
Some definitions
208 / 258
Graphs
Some definitions
209 / 258
Graphs
Some definitions
210 / 258
Graphs
Some definitions
211 / 258
Graphs
Some definitions
212 / 258
Graphs
Some important properties
Proposition 1
If G is a graph with m edges and vertex set V , then
X
deg (V ) = 2m
v in V
213 / 258
Graphs
Some important properties
Proposition 2
If G is a directed graph with m edges and vertex set V , then
X X
indeg (V ) = outdeg (V ) = m
v in V v in V
214 / 258
Graphs
Some important properties
Proposition 3
Let G be a simple graph with n vertices and m edges. If G is
undirected, then
m ≤ n(n − 1)/2
m ≤ n(n − 1)
215 / 258
Graphs
Some important properties
216 / 258
Data Structures for Graphs
Edge list
Adjacency list
Adjacency matrix
217 / 258
Data Structures for Graphs: Edge List
Edge List
218 / 258
Data Structures for Graphs: Edge List
Edge List
In a simpler way:
e ==> u - v
f ==> v - w
g ==> w - u
h ==> w - z
219 / 258
Data Structures for Graphs: Edge List
Edge List
220 / 258
Data Structures for Graphs: Edge List
Edge List: performances
222 / 258
Data Structures for Graphs: Adjacency List
Adjacency List
223 / 258
Data Structures for Graphs: Adjacency List
Adjacency List
224 / 258
Data Structures for Graphs: Adjacency List
Adjacency List
225 / 258
Data Structures for Graphs: Adjacency List
Adjacency List Structure
226 / 258
Data Structures for Graphs: Adjacency List
Adjacency List Structure
228 / 258
Data Structures for Graphs: Adjacency List
Adjacency List
229 / 258
Data Structures for Graphs: Adjacency Matrix
Adjacency Matrix
230 / 258
Data Structures for Graphs: Adjacency Matrix
Adjacency Matrix
231 / 258
Data Structures for Graphs: Adjacency Matrix
Adjacency Matrix
232 / 258
Data Structures for Graphs: Adjacency Matrix
Adjacency Matrix Structure
233 / 258
Data Structures for Graphs: Adjacency Matrix
Ajacency Matrix Structure
234 / 258
Data Structures for Graphs: Adjacency Matrix
Adjacency Matrix Structure
— Advantages
The most significant advantage of an adjacency matrix is that
any edge (u, v ) can be accessed in worst-case O(1) time
— Disadvantages
To find edges incident to vertex v we must examine all n
entries in the row associated with v . An adjacency list can
locate those edges in optimal O(deg (V )) time
Adding or removing vertices from a graph is problematic, as
the matrix must be resized
The O(n2 ) space usage of an adjacency matrix is usually worse
than the O(n + m) space required for adjacency lists, although
the number of edges in a dense graph is proportional to n2
235 / 258
Data Structures for Graphs
236 / 258
Graph ADT: Implementation of the Adjacency List /1
def create_graph():
return dict()
237 / 258
Graph ADT: Implementation of the Adjacency List /2
238 / 258
Graph ADT: Implementation of the Adjacency List /3
Implementation
def degree(graph):
# dizionario con il degree di ogni nodo del grafo
deg = dict()
for node in graph:
deg[node] = len(graph[node])
return deg
def is_complete(graph):
# restituisce True se il grafo e’ completo
n = len(graph)
for node in graph:
if len(graph[node]) < n - 1:
return False
return True
239 / 258
Graph ADT: Implementation of the Adjacency List /4
Implementation
def nodes(graph):
# restituisce una lista dei nodi del grafo
return list(graph.keys())
240 / 258
Graph ADT: Implementation of the Adjacency List /5
Implementation
def edges(graph):
# restituisce una lista degli archi del grafo
Edges = []
for k, v in graph.items():
for u in v:
if (k,u) not in Edges and (u,k) not in Edges:
Edges.append((k,u))
return Edges
def vertex_count(graph):
# restituisce il numero di vertici del grafo
return len(graph)
def edge_count(graph):
# restituisce il numero di archi del grafo
return len(edges(graph))
241 / 258
Graph ADT: Implementation of the Adjacency List /6
Implementation
242 / 258
Graph ADT: Implementation of the Adjacency List /7
Implementation
243 / 258
Graph ADT: Implementation of the Adjacency List /8
Implementation
244 / 258
Graph Traversals
245 / 258
Graph Traversals
246 / 258
Graph Traversals
247 / 258
Depth-First Search: Running Time of DFS
248 / 258
Depth-First Search: Running Time of DFS
...
249 / 258
Breadth-First Search
bfs.py
251 / 258
Breadth-First Search
252 / 258
Breadth-First Search
253 / 258
Breadth-First Search
254 / 258
Breadth-First Search
Glossary
For BFS:
255 / 258
Breadth-First Search
Properties
Proposition
Let G be an undirected or directed graph on which a BFS traversal
starting at vertex s has been performed. Then
The traversal visits all vertices of G that are reachable from s
For each vertex v at level i, the path of the BFS tree T
between s and v has i edges, and any other path of G from s
to v has at least i edges
If (u, v ) is an edge that is not in the BFS tree, then the level
number of v can be at most 1 greater than the level number
of u
256 / 258
Breadth-First Search
257 / 258
Breadth-First Search
258 / 258
Graphs that allow self-loops or parallel edges, such as multigraphs, present unique challenges and design considerations, notably in optimizing space and ensuring proper function of algorithms assuming simple graphs (e.g., pathfinding ignoring cycles). These edge cases can introduce scenarios where standard algorithms require enhancements to handle or ignore redundant/self-referential connections effectively, potentially increasing computational overhead .
A binary tree is complete if all levels are fully filled except possibly the last, which must be filled from left to right . The challenge in verifying completeness lies in ensuring each node is checked systematically to confirm this level-wise filling order, which may involve iterative or recursive depth-first checks over potentially large node collections, increasing complexity .
The recursive method to determine sibling nodes in a binary tree identifies a node's sibling by checking whether it is a left or right child of its parent, and then returning the parent's other child. This can have practical applications in navigating tree-like structures in databases where relationships between nodes (e.g., finding related data entries) are important. In binary trees, given the limited number of children (at most two), this operation is efficient, typically running in constant time for each node lookup . Such methods allow for efficient data retrieval and manipulation, essential in applications like parsing expressions, organizing hierarchical data, and supporting file systems .
A proper binary tree is characterized by every internal node having exactly two children, whereas an improper binary tree can have nodes with only one or no children . In a proper binary tree, the relationship between external nodes (leaves) and internal nodes is such that the number of external nodes (nE) equals the number of internal nodes (nI) plus one, i.e., nE = nI + 1 . This ensures a specific balance and structure, maximizing the number of nodes up to the potential of the binary tree's height, achieving a more uniform distribution of nodes. In contrast, an improper binary tree can be less balanced with potential uneven distribution of nodes . Consequently, this uniformity in proper binary trees impacts tree operations such as searching, insertion, and deletion, often resulting in more predictable and stable performance metrics compared to improper binary trees which can be skewed or unbalanced .
Determining the depth for all nodes requires traversing from each node to the root, resulting in a complexity of O(n) in the worst-case scenario, aligning with the height of the tree . Calculating the height involves each node computing its height based on child nodes, requiring bottom-up traversal, also resulting in O(n) complexity due to recursive exploration of each path .
In a proper binary tree, the number of external nodes nE equals the number of internal nodes nI plus one, nE = nI + 1 . This results from the structural property where each internal node connects two external nodes, implying a traversal operation involving external nodes will inherently contribute to counting internal nodes under efficient algorithms such as DFS .
Calculating the height of a node in a tree involves determining the number of edges on the longest path from that node to a leaf. If the node is a leaf, its height is zero. Otherwise, it is one more than the maximum height of its children . The depth of a node, conversely, is the number of edges from the root to the node itself. The root has a depth of zero, and any other node's depth is one more than its parent's depth . Hence, height is computed based on the path to the deepest leaf, whereas depth is based on the path from the root.
Sibling node relationships impact operations such as deletion, re-balancing, and traversal path optimizations in computing systems. For instance, deletion of a node might necessitate adjustments or rotations among siblings in binary trees to maintain balanced structures requisite for efficient searching algorithms . Moreover, considering siblings during traversal can aid in parallel processing or load distributions in tree-based system architectures .
Using an adjacency matrix allows constant-time O(1) access to any edge due to its indexed structure, whereas an adjacency list requires scanning, potentially leading to O(dv) time for edge retrieval . The trade-off lies in adjacency matrix's higher space complexity, often O(n^2), versus adjacency list's O(n + m) space, making adjacency lists preferable for sparse graphs .
DFS is efficient because it visits each vertex exactly once, marking nodes to avoid revisits, contributing to linear time complexity O(V + E) for both directed and undirected graphs . In undirected graphs, edges are examined twice, once per vertex, while in directed graphs, edges are examined once due to directed flow from origin, optimizing traversals further depending on edge directions .