COMP108 (Algorithms)
Consider the following assembly line:
- 2 assembly lines, each with $n$ stations ($S_{i,j}$: line $i$ station $j$).
- $S_{i,j}$ and $S_{2,j}$ perform the same task by time taken is different.
- $a_{i,j}$ - Assembly time at $S_{i,j}$.
- $t_{i,j}$ - Transfer time from $S_{i,j}$.
graph LR
subgraph Line1
11[5]
12[5]
13[9]
14[4]
end
subgraph Line2
21[15]
22[4]
23[3]
24[7]
end
Start --> 11
11 -->|0| 12
12 -->|0| 13
13 -->|0| 14
14 --> End
13 -->|1| 24
12 -->|4| 23
11 -->|2| 22
Start --> 21
21 -->|0| 22
22 -->|0| 23
23 -->|0| 24
24 --> End
23 -->|1| 14
22 -->|11| 13
21 -->|2| 12
Determine which stations to go in order to while minimising the total time through the $n$ stations.
Trees
You can represent all the possible paths in a tree structure:
- The nodes are labelled with the assembly time.
- The edges are labelled with the transfer time.
You can then minimise for time while removing branches from the tree.
Dynamic Programming
To avoid populating the entire tree you can use a dynamic programming approach. This is because we don’t need to try all the possible choices.
- If we know that fastest way to get to $S_{1,n}$ and $S_{2,n}$.
- The faster of these two is overall the fastest.
- To find the fastest way to $S_{1,n}$ we need to know the fastest way to $S_{1,n-1}$ and $S_{2,n-1}$.
- Similarly for $S_{2,n}$.
- Generalising, we want the fastest way to get though $S_{1,j}$ and $S_{2,j}$ for all $j$.
Sub-Problems
- Given $j$, what is the fastest way to get to $S_{1,j}$?
- Given $j$, what is the fastest way to get to $S_{2,j}$?
Definitions:
- $f_1[j]$ - The fastest time to get to $S_{1,j}$.
- $f_2[j]$ - The fastest time to get to $S_{2,j}$.
The final solution is:
\[\min\{f_1[n],f_2[n]\}\]
Sub-Problem 1
Given $j$, what is the fastest way to get to $S_{1,j}$:
-
The fastest way to $S_{1,j-1}$, then directly to $S_{1,j}$:
\[f_1[j-1]+a_{1,j}\]
-
The fastest way to $S_{2,j-1}$, a transfer from line 2 to line 1 then to $S_{1,j}$:
\[f_2[j-1]+t_{2,j-1}+a_{1,j}\]
Therefore we want:
\[f_1[j]=\min\{f_1[j-1]+a_{1,j}, f_2[j-1]+t_{2,j-1}+a_{1,j}\}\]
Boundary case:
\[f_1[1]=a_{1,1}\]
Sub-Problem 2
Given $j$, what is the fastest way to get to $S_{2,j}$:
-
The fastest way to $S_{1,j-1}$, a transfer from line 2 to line 1 then to $S_{2,j}$:
\[f_1[j-1]+t_{1,j-1}+a_{2,j}\]
-
The fastest way to $S_{2,j-1}$, then directly to $S_{2,j}$:
\[f_2[j-1]+a_{2,j}\]
Therefore we want:
\[f_2[j]=\min\{f_2[j-1]+a_{2,j}, f_1[j-1]+t_{1,j-1}+a_{2,j}\}\]
Boundary case:
\[f_2[1]=a_{2,1}\]
Example
For an example on two assembly lines view slide 15 onwards.
The summary is that you go from the beginning of the line to the end, keeping track of both sides, and choosing the least cost as you progress.
The expected table form would be like this:
$j$ |
$f_1[j]$ |
$f_2[j]$ |
1 |
5 |
15 |
2 |
10 |
11 |
3 |
19 |
14 |
4 |
20 |
21 |
$f_1[j]$ and $f_2[j]$ are the shortest paths to each respective node.
Pseudo Code
f1[1] = a11
f2[1] = a21
for j = 2 to n do
begin
f1[j] = min{f1[j-1] + a1j, f2[j-1] + t2(j-1) + a1j}
f2[j] = min{f2[j-1] + a2j, f1[j-1] + t1(j-1) + a2j}
end
f* = min{f1[n], f2[n]}
Time Complexity
The time complexity is:
\[O(n)\]
as it loops through each node once and completes some $O(1)$ tasks.
For more assembly lines this time complexity is the same.
Dynamic programming allows for more efficient implantation of divide and conquer algorithms.
Fibonacci Numbers
This is the definition for the Fibonacci numbers:
\[F(n)=\begin{cases}
1 & \text{if } n=0\text{ or } 1\\
F(n-1)+F(n-2) & \text{if } n> 1
\end{cases}\]
and the code that we had before:
Algorithm F(n)
if n == 0 OR n == 1 then
return 1
else
return F(n - 1) + F(n - 2)
This code calculates particular Fibonacci numbers many times which is inefficient.
Memorisation
To improve on this we can memorise new Fibonacci numbers so that we don’t have to calculate them again:
- Store $F(n)$ somewhere after we have computed its value.
- Afterwards, we don’t need to re-compute $F(n)$ as we can retrieve its value from the memory.
Main Algorithm
set v[0] = 1
set v[1] = 1
for i = 2 to n do
v[i] = -1 // initialise all other values to -1
output F(n)
Algorithm F(n)
if v[n] < 0 then
v[n] = F(n - 1) + F(n - 2)
return v[n]
This significantly reduces the amount of calculation.
Compute $F(n)$ in Advance
The 2$^{nd}$ version still makes many function calls. Making many function calls negatively effects performance. To improve this we can compute the values from the bottom-up.
Algorithm F(n)
set v[0] = 1
set v[1] = 1
for i = 2 to n do
v[i] = v[i - 1] + v[v - 2]
return v[n]
Summary
- Write down a formula that relates a solution of a problem with those of sub-problems:
- Index the sub-problems so that they can be stored and retrieved easily in a table.
- Fill the table in some bottom-up manner:
- Start filling the solution of the smallest problem.
- This ensures that when we solve a particular sub-problem, the solutions of all smaller sub-problems on which it depends are available.
Solving Recurrence for Fibonacci Numbers
Suppose $n$ is even:
\[\begin{aligned}
T(n) &= \mathbf{T(n-1)} + T(n-2) + 1\\
&= \mathbf{(T(n-2)+T(n-3)+1)} + T(n-2) + 1\\
&> 2\times T(n-2)\\
&> 2\times (\mathbf{2\times T(n-4)}) = 2^2 \times T(n-4)\\
&> 2^2\times (2\times T(n-6)) = 2^3 \times T(n-6)\\
&> 2^3\times (2\times T(n-8)) = 2^4 \times T(n-2\times 4)\\
&\vdots\\
&>2^k \times \mathbf{T(n-2k)}\\
&\vdots\\
&> 2^{\mathbf{\frac n 2}}\times \mathbf{T(0)}\\
&= 2^{\frac n 2}
\end{aligned}\]
Suppose $n$ is odd:
\[\begin{aligned}
T(n) &> 2^{\mathbf{\frac{n-1} 2}} \times \mathbf{T(1)}\\
&=2^{\frac{n-1} 2}
\end{aligned}\]
The expressions being operated on are in bold.
Solving Recurrence for Merge Sort
\(T(n)=\begin{cases}
1 & \text{if } n= 1\\
2\times T(\frac n 2)+n & \text{if } n> 1
\end{cases}\)
Solving the Recurrence
\[\begin{aligned}
T(n) &= 2\times \mathbf{T\left(\frac n 2\right)} +n\\
&=2\times(\mathbf{2\times T\left(\frac n{2^2}+\frac n 2\right)}+n=2^2\times T\left(\frac n{2^2}\right)+2n\\
&=2^2\times(2\times T\left(\frac n{2^3}+\frac n {2^2}\right)+2n=2^3\times T\left(\frac n{2^3}\right)+3n\\
&\vdots\\
&= 2^{\log n}\times T\left(\frac n{2^{\log n}}\right) + (\log n) \times n\\
&= 2^{\log n}\times T(1) +(\log n)\times n\\
&= n+n\log n\\
&= O(n\log n)
\end{aligned}\]
Summary
For the following recurrences, the following time complexities are true:
- $T(n)=2\times T\left(\frac n 2\right) +1$
This is for finding sum/min/max.
- $T(n)=2\times T\left(\frac n 2\right) +n$
This is merge sort.
- $T(n)=2\times T(n-1) +1$
This is Fibonacci numbers.
- $T(n)=T\left(\frac n 2\right) +1$
This is for recursive binary search.
The formula for Fibonacci numbers is as follows:
\[F(n)=\begin{cases}
1 & \text{if } n=0 \text{ or } 1\\
F(n-1) + F(n-2) & \text{if } n> 1
\end{cases}\]
Pseudo Code
This is a recursive implementation:
Algorithm F(n)
if n == 0 OR n == 1 then
return 1
else
return F(n-1) + F(n-2)
Time Complexity
To analyse the time complexity of calculating Fibonacci numbers, we use a mathematical tool called recurrence.
Let $T(n)$ denote the time to calculate Fibonacci number $F(n)$:
\[T(n)=\begin{cases}
1 & \text{if } n=0 \text{ or } 1\\
T(n-1) + T(n-2) + 1 & \text{if } n> 1
\end{cases}\]
- $T(n-1)$: Time to calculate $F(n-1)$.
- $T(n-2)$: Time to calculate $F(n-2)$.
- $+1$: Time to add $F(n-1)$ and $F(n-2)$.
- When $n$ is 0 or 1 there is no needed to divide and the only time needed is to return the number 1.
A recurrence is an equation or inequality that describes a function in terms of its value on smaller inputs.
To solve a recurrence is to derive asymptotic bounds on the solution.
Method
- Divide the sequence of $n$ numbers into two halves.
- Recursively sort the two halves.
- Merge the two sorted halves into a single sorted sequence.
It is easier to merge two sequences that are already sorted, compared to two non-sorted sequences.
How to Merge
To merge two sorted sequences we keep two pointers, one to each sequence.
- Compare the two numbers pointed.
- Copy the smaller one to the result and advance the smaller pointer.
- Repeat steps 1. and 2.
- When we reach the end of one sequence, simply copy the remaining number in the other sequence to the result.
Pseudo Code
For simplicity, assume $n$ is a power of 2.
- $A$ is the full array.
- $B$ is sorting array 1.
- $C$ is sorting array 2.
Algorithm Merge(B[1..p], C[1..q], A[1..(p+q)])
i = 1, j = 1, k = 1
while i <= p AND j <= q do
begin
if B[i] <= C[j] then // B is smaller
A[k] = B[i]
i++
else // C is smaller
A[k] = C[j]
j++
k++
end
if i == (p + 1) then // B is empty
copy C[j..q] to A[k..(p+q)]
else // C is empty
copy B[i..p] to A[k..(p+q)]
Example
Consider the following variables:
- $B=\{10,13,51,64\}$
- $C=\{5,21,32,34\}$
The following iterations occur:
|
i |
j |
k |
A[] |
Before loop. |
1 |
1 |
1 |
Empty |
End of 1st iteration. |
1 |
2 |
2 |
5 |
End of 2nd iteration. |
2 |
2 |
3 |
5, 10 |
End of 3rd. |
3 |
2 |
4 |
5, 10, 13 |
End of 4th. |
3 |
3 |
5 |
5, 10, 13, 21 |
End of 5th. |
3 |
4 |
6 |
5, 10, 13, 21, 32 |
End of 6th. |
3 |
5 |
7 |
5, 10, 13, 21, 32, 34 |
After if else . |
|
|
|
5, 10, 13, 21, 32, 34, 51, 64 |
Time Complexity
- Each node takes $O(r)$ time when there are $r$ integers.
- Each level takes $O(n)$ time because total number of integer is $n$.
- There are $O(\log n)$ levels.
This gives an overall time complexity of:
\[O(n\log n)\]
This is faster than other algorithms that we have seen so far.
The idea of divide and conquer is to divide a problem into several smaller instances of ideally the same size.
- The smaller instances are solved, typically recursively.
- The solutions for the smaller instances are combined to get a solution to the large instance.
Sum Example
Find the sum of all the numbers in an array. Suppose we have 8 numbers:
\[\{4,6,3,2,8,7,5,1\}\]
We can apply the following:
graph BT
1[4 6 3 2 8 7 5 1] -->|36| 0[ ]
10[4 6 3 2] -->|15| 1
11[8 7 5 1] -->|21| 1
100[4 6] -->|10| 10
101[3 2] -->|5| 10
1000[4] -->|4| 100
1001[6] -->|6| 100
1010[3] -->|3| 101
1011[2] -->|2| 101
110[8 7] -->|15| 11
111[5 1] -->|6| 11
1100[8] -->|8| 110
1101[7] -->|7| 110
1110[5] -->|5| 111
1111[1] -->|1| 111
Pseudo Code
For simplicity, assume that $n$ is a power of 2. We can then call the following algorithm by RecSum(A, 1, n)
:
Algorithm RecSum(A[], p, q)
if p == q then
return A[p] // this is the base case, there is one value
else
begin // this is the iterative step
sum1 = RecSum(A, p, (p+q-1)/2) // this finds the two sides of the midpoint
sum2 = RecSum(A, (p+q+1)/2, q)
return sum1 + sum2
end
Minimum Example
This finds the minimum number in an array.
Pseudo Code
For simplicity, assume that $n$ is a power of 2. We can call the following algorithm by RecMin(A, 1, n)
:
Algorithm RecMin(A[], p, q)
if p == q then
return A[p] // pointing to the same value
else
begin
answer1 = RecMin(A, p, (p+q-1)/2) // divide the array
answer2 = RecMin(A, (p+q+1)/2, q)
if answer2 <= answer2 then // find minimum of returned values
return answer1
else
return answer2
end
Time Complexity of Divide and Conquer
The time complexity is:
\[O(n)\]
This is as there are $n$ order 1 comparisons completed.
Single-Source Shortest Paths
Consider a directed/undirected connected graph $G$:
- The edges are labelled by weight.
Given a particular vertex called the source:
- Find shortest paths from the source to all other vertices.
Example
Undirected connected graph $G$:
graph LR
a ---|2| b
b ---|3| c
a ---|3| c
b ---|2| d
c ---|1| d
Shortest Paths (Local Property)
-
From $a$:
graph LR
a((a)) ---|2| b
a ---|3| c
b ---|2| d
-
From $b$:
graph LR
a ---|2| b
b((b)) ---|3| c
b ---|2| d
-
From $c$:
graph LR
b ---|3| c
a ---|3| c
c((c)) ---|1| d
-
From $d$:
graph LR
a ---|3| c
b ---|2| d((d))
c ---|1| d
Dijkstra’s Algorithm
This algorithm assumes that the weight of edges are positive.
The idea is to choose the edge adjacent to any chosen vertices such that the cost of the part to the source is minimum.
Example
Suppose that $a$ is the source:
graph LR
a((a)) ---|5| b
a ---|7| c
b ---|10| h
c ---|2| h
c ---|7| e
e ---|5| h
e ---|5| f
f ---|1| h
c ---|2| d
a ---|10| d
d ---|4| e
f ---|3| g
d ---|3| g
h ---|2| g
- Every vertex keeps 2 labels, initially as $(\infty, -)$:
- The weight of the current shortest path from $a$.
- The vertex preceding $v$ on that path.
graph LR
a((a)) ---|5| b["b: (∞, -)"]
a ---|7| c["c: (∞, -)"]
b ---|10| h["h: (∞, -)"]
c ---|2| h
c ---|7| e["e: (∞, -)"]
e ---|5| h
e ---|5| f["f: (∞, -)"]
f ---|1| h
c ---|2| d["d: (∞, -)"]
a ---|10| d
d ---|4| e
f ---|3| g["g: (∞, -)"]
d ---|3| g
h ---|2| g
- Each round:
- A vertex is picked, neighbours’ labels updated.
- Pick from all un-chosen vertices the one with smallest weight for the next round.
graph LR
a((a)) ===|5| b["b: (5, a)"]
a -.-|7| c["c: (7, a)"]
b ---|10| h["h: (∞, -)"]
c ---|2| h
c ---|7| e["e: (∞, -)"]
e ---|5| h
e ---|5| f["f: (∞, -)"]
f ---|1| h
c ---|2| d["d: (10, a)"]
a -.-|10| d
d ---|4| e
f ---|3| g["g: (∞, -)"]
d ---|3| g
h ---|2| g
Followed paths are shown as dotted.
$a\rightarrow b$ has the minimum weight so is chosen in bold.
This continues until the following graph is reached:
graph LR
a((a)) ===|5| b["b: (5, a)"]
a ===|7| c["c: (7, a)"]
b -.-|10| h["h: (9, c)"]
c ===|2| h
c -.-|7| e["e: (13, d)"]
e -.-|5| h
e -.-|5| f["f: (10, h)"]
f ===|1| h
c ===|2| d["d: (9, c)"]
a -.-|10| d
d ===|4| e
f -.-|3| g["g: (11, h)"]
d -.-|3| g
h ===|2| g
You should record the history of label changes.
In the case of a tie there is no difference. You may want to go alphabetically.
Table Presentation
$b$ |
$c$ |
$d$ |
$e$ |
$f$ |
$g$ |
$h$ |
Chosen |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(5,a)$ |
$(7,a)$ |
$(10,a)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(a,b)$ |
|
$(7,a)$ |
$(10,a)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(15,b)$ |
$(a,c)$ |
|
|
$(9,c)$ |
$(14,c)$ |
$(\infty,-)$ |
$(\infty,-)$ |
$(9,c)$ |
$(c,d)$ |
|
|
|
$(13,d)$ |
$(\infty,-)$ |
$(12,d)$ |
$(9,c)$ |
$(c,h)$ |
|
|
|
$(13,d)$ |
$(10,h)$ |
$(11,h)$ |
|
$(h,f)$ |
|
|
|
$(13,d)$ |
|
$(11,h)$ |
|
$(h,g)$ |
|
|
|
$(13,d)$ |
|
|
|
$(d,e)$ |
Pseudo Code
Each vertex $v$ is labelled with two labels:
- A numeric label $d(v)$ indicates the length of the shortest path from the source to $v$ found so far.
- Another label $p(v)$ indicates the next-to-last vertex on such a path.
- This is the vertex immediately preceding $v$ on that shortest path.
Given a graph $G=(V,E)$ and a source vertex $s$.
for every vertex v in the graph do
set d(v) = ∞ and p(v) = null
set d(s) = 0 and V_t = Ø and E_t = Ø
while V \ V_t != Ø do // there is still some vertex left
begin
choose the vertex v in V \ V_t with minimum d(u)
set V_t = V_t ∪ {u} and E_t = E_t ∪ {(p(u), u)}
for every vertex v in V \ V_t that is a neighbour of u do
if d(u) + w(u, v) < d(v) then // a shorter path is found
set d(v) = d(u) + w(u, v) and p(v) = v
end
$V_T$ and $E_T$ are the immediate sub-trees of $V$ and $V$ respectively.
Time Complexity
The time complexity is:
\[v+n(n+n)\]
This gives an overall big-o notation of:
\[O(n^2)\]
Minimum Spanning Tree (MST)
Given an undirected connected graph $G$:
- The edges are labelled by weight
Spanning tree of $G$:
A tree containing all vertices in $G$.
Minimum spanning tree of $G$:
- A spanning tree of $G$ with minimum weight.
Example
Undirected connected graph $G$:
graph LR
a ---|2| b
b ---|3| c
a ---|3| c
b ---|2| d
c ---|1| d
Spanning trees of $G$:
graph LR
a ---|2| b
b ---|3| c
b ---|2| d
graph LR
a ---|2| b
b ---|2| d
c ---|1| d
graph LR
b ---|3| c
a ---|3| c
c ---|1| d
There are $m\choose{n-1}$ (choose) possible spanning trees, where $m$ is the number of edges in the original graph and $n$ is the number of nodes.
Minimum spanning tree of $G$:
graph LR
a ---|2| b
b ---|2| d
c ---|1| d
This tree has the smallest total weight.
Kruskal’s Algorithm
The idea is to select edges from smallest to largest weight until the MST is made.
Example
graph LR
a ---|4| b
b ---|11| h
a ---|8| h
h ---|7| i
b ---|8| c
i ---|2| c
h ---|1| g
c ---|7| d
d ---|14| f
c ---|4| f
g ---|2| f
d ---|9| e
f ---|10| e
- Arrange edges from smallest to largest weight.
- Select edges from smallest to largest, provided that they don’t make a cycle.
✓ |
(g, h) |
1 |
✓ |
(c, i) |
2 |
✓ |
(f, g) |
2 |
✓ |
(a, b) |
4 |
✓ |
(c, f) |
4 |
✓ |
(c, d) |
7 |
Makes cycle |
(h, i) |
7 |
✓ |
(a, h) |
8 |
Makes cycle |
(b, c) |
8 |
✓ |
(d, e) |
9 |
Makes cycle |
(e, f) |
10 |
Makes cycle |
(b, h) |
11 |
Makes cycle |
(d, f) |
14 |
This gives the following tree:
graph LR
a ---|4| b
a ---|8| h
i ---|2| c
h ---|1| g
c ---|7| d
c ---|4| f
g ---|2| f
d ---|9| e
Order of edges selected:
(g, h), (c, i), (f, g), (a, b), (c, f), (c, d), (a, h), (d, e)
This algorithm is greedy as it chooses the smallest wight edge to be included in the MST.
Pseudo Code
Given an undirected connected graph $G=(V,E)$:
T = Ø
E' = E // E' is the set of all edges
while E' != Ø do
begin
pick an edge e in E' with minimum weight
if adding e to T does not form cycle then
add e to T // This is the same as T = T ∪ {e}
remove e from E' // This is the same as E' = E' \ {e}
end
When we consider a new edge $e=(u, v)$, there may be three cases:
- At most one of $u$ and $v$ is end point of an already chosen edge.
- $u$ and $v$ both belong to the same partial tree that has been already chosen.
- $u$ and $v$ belong to two separate partial trees that have been already chosen.
Time Complexity
The time complexity is:
\[m(m+n)\]
This will give a big-o notation of:
\[O(m^2)\]
Optimising Pseudo Code
- You can stop earlier if you know that you have the correct number of edges.
- You can also presort the edges.
With these two the time complexity is:
\[O(m\log m)+O(mn)\]
The process of the greedy algorithm is explained in this lecture:
The main idea is to bin pack using the largest items first.
Greedy algorithm doesn’t always find an optimal solution but it is easy to implement.
Knapsack Problem
You want to steal the best items from a house optimising for:
Formally this can be written like so:
Given $n$ items with weights $w_1,w_2,\ldots,w_n$, values $v_1,v_2,\ldots,v_n$ and a knapsack with capacity $W$.
Find the most valuable subset of items that can fit into the knapsack.
Exhaustive Algorithm
Approach:
- Try every subset of $n$ given items.
- Compute total wight of each subset.
- Compute total value of those subsets that do not exceed knapsack’s capacity.
- Pick the subset that has the largest value.
Analysis:
- There are $2^n-1$ subsets to consider.
- This is exponential in $n$.
Example
Consider the following values:
- Item 1
- Item 2
- Item 3
- Knapsack
One method of optimising would be to iterate through each combinations of subsets that fit in the knapsack and select the set with the most value:
Subset |
Weight |
Value |
$\emptyset$ |
0 |
0 |
{1} |
10 |
60 |
{2} |
20 |
100 |
{3} |
30 |
120 |
{1, 2} |
30 |
160 |
{1, 3} |
40 |
180 |
{2, 3} |
50 |
220 |
{1, 2, 3} |
60 |
N/A |
An issue with this method is that the combinations grow exponentially with additional items.
Greedy Algorithm
Example
Consider the following values:
- Item 1
- Item 2
- Item 3
- Knapsack
Pick the item with the next largest value if total weight $\leq$ capacity.
Result:
- Item 3 is picked, total value = 120, total weight = 30.
- Item 2 is picked, total value = 220, total wight = 50.
- Item 1 cannot be picked.
This happens to be optimal.
Time Complexity
First the values need to be sorted. This takes:
\[O(n\log n)\]
Completeness
This algorithm doesn’t always give an optimal solution. Using other modifiers like $\frac v w$ also won’t give optimal solutions.
Approximation Ratio
No matter the input, if a greedy algorithm has an approximation ratio of $\frac1 2$, it will always select a subset with a total value of at least $\frac 1 2$ of the optimal solution.
There are two main traversal algorithms: BFS and DFS. These have been covered in the following COMP111 lectures:
BFS with a Queue/Linked List
Example
Suppose $a$ is the starting vertex:
graph LR
a((a)) --- b((b))
a --- e((e))
a --- d((d))
b --- f((f))
e --- f
d --- h((h))
f --- c((c))
e --- k((k))
e --- g((g))
f --- k
This would generate the following queues when searching with BFS:
- $\text{head}\rightarrow a\leftarrow\text{tail}$
- $\text{head}\rightarrow b\rightarrow d\rightarrow e\leftarrow\text{tail}$
- $\text{head}\rightarrow d\rightarrow e\rightarrow f\leftarrow\text{tail}$
- $\text{head}\rightarrow d\rightarrow e\rightarrow f\leftarrow\text{tail}$
- $\text{head}\rightarrow f\rightarrow h\rightarrow g\rightarrow k\leftarrow\text{tail}$
- $\text{head}\rightarrow h\rightarrow g\rightarrow k\rightarrow c\leftarrow\text{tail}$
- $\text{head}\rightarrow g\rightarrow k\rightarrow c\leftarrow\text{tail}$
- $\text{head}\rightarrow k\rightarrow c\leftarrow\text{tail}$
- $\text{head}\rightarrow c\leftarrow\text{tail}$
Every time an element is removed from the queue, it is added to the traversal:
BFS: $a,b,d,e,f,h,g,k,c$
Pseudo Code
unmark all vertices
choose some starting vertex s
mark s and insert s into tail of list L
while L is non-empty do
begin
remove a vertex v from head of L
visit v // print its data
for each unmarked neighbour w of v do
mark w and insert w into tail of list L
end
DFS with Stack
Example
Suppose $a$ is the starting vertex:
graph LR
a((a)) --- b((b))
a --- e((e))
a --- d((d))
b --- f((f))
e --- f
d --- h((h))
f --- c((c))
e --- k((k))
e --- g((g))
f --- k
This would generate the following stacks when searching with DFS:
- $\mathbf a\leftarrow \text{top}$
- $e,d,\mathbf b\leftarrow \text{top}$
- $e,d,\mathbf f\leftarrow \text{top}$
- $e,d,k,e,\mathbf c\leftarrow \text{top}$
- $e,d,k,\mathbf e\leftarrow \text{top}$
- $e,d,k,k,\mathbf g\leftarrow \text{top}$
- $e,d,k,\mathbf k\leftarrow \text{top}$
-
$e,d,k\leftarrow \text{top}$
Not added to path as it already exists.
- $e,\mathbf d\leftarrow \text{top}$
- $e,\mathbf h\leftarrow \text{top}$
-
$e\leftarrow\text{top}$
Not added to path as it already exists.
Every time a unique element is popped from the stack, it is added to the traversal:
DFS: $a,b,f,c,e,g,k$
Pseudo Code
unmark all vertices
push starting vertex u onto top of stack S
while S is non-empty do
begin
pop a vertex v from top of S
if v is unmarked then
begin
visit and mark v
for each unmarked neighbour w of v do
push w onto top of S
end
end
- Paths
- A number of nodes from one point to another.
- Circuits
- If the start and end of the path is the same then it is a circuit.
Euler Circuits
A circuit that visits every edge once.
Vertexes can be repeated.
Not all graphs have an Euler circuit.
Conditions
- The graph must be connected.
- This means that there must be no breaks in the graph.
- The degree of every vertex must be even.
Hamiltonian Circuit
Oppositely to an Euler circuit, this is a circuit that contains every vertex once.
This may not visit all edges.
This is a difficult problem to solve and will be reviewed later.
Representation of Undirected Graphs
An undirected graph ca be represented by an adjacency matrix, adjacency list, incidence matrix or incidence list.
- Adjacency Matrix/List
- The relationship between vertex adjacency (vertex vs vertex).
- Incidence Matrix/List
- The relationship between edge incidence (vertex vs edge).
Adjacency Matrix/List
Adjacency matrix $M$ for a simple undirected graph with $n$ vertices is an $n\times n$ matrix:
- $M(i,j)=1$ if vertex $i$ and vertex $j$ are adjacent.
- $M(i,j)=0$ otherwise.
Adjacency list - each vertex has a list of vertices to which it is adjacent.
graph LR
a((a)) --- d((d))
a --- c((c))
b((b)) --- c
b --- d
c --- e((e))
c --- d
d --- e
The prior graph is represented in the following matrix:
\[\begin{pmatrix}
0 & 0 & 1 & 1 & 0\\
0 & 0 & 1 & 1 & 0\\
1 & 1 & 0 & 1 & 1\\
1 & 1 & 1 & 0 & 1\\
0 & 0 & 1 & 1 & 0
\end{pmatrix}\]
$a$ to $b$ are listed left to right and top to bottom.
- The diagonals are always 0 on a simple graph.
- There is also diagonal symmetry as this is an undirected graph.
- The row/column will always add to the degree of the vertex.
This can also be represented in this list:
- $a\rightarrow c\rightarrow d$
- $b\rightarrow c\rightarrow d$
- $c\rightarrow a\rightarrow b\rightarrow d\rightarrow e$
- $d\rightarrow a\rightarrow b\rightarrow c\rightarrow e$
- $e\rightarrow c\rightarrow d$
The length of the list is the same as the number of edges.
If the graph has few edges and space is of a concern then the list is better.
Incidence Matrix/List
Incidence matrix $M$ for a simple undirected graph with $n$ vertices and $m$ edges is an $m\times n$ matrix:
- $M(i,j)=1$ if edge $i$ and vertex $j$ are incident.
- $M(i,j)=0$ otherwise.
Incidence list - each edge has a list of vertices to which it is incident with.
graph LR
a((a)) ---|2| d((d))
a ---|1| c((c))
b((b)) ---|3| c
b ---|4| d
c ---|7| e((e))
c ---|5| d
d ---|6| e
The prior graph is represented in the following matrix:
\[\begin{pmatrix}
1 & 0 & 1 & 0 & 0\\
1 & 0 & 0 & 1 & 0\\
0 & 1 & 1 & 0 & 0\\
0 & 1 & 0 & 1 & 0\\
0 & 0 & 1 & 1 & 0\\
0 & 0 & 0 & 1 & 1\\
0 & 0 & 1 & 0 & 1
\end{pmatrix}\]
Vertices $a$ to $b$ are left to right and edges 1 to 7 are top to bottom.
- The columns sum to the degree of the vertex.
- The rows should sum to two for the two ends of the edge.
This can also be represented in this list:
- $1\rightarrow a\rightarrow c$
- $2\rightarrow a\rightarrow d$
- $3\rightarrow b\rightarrow c$
- $4\rightarrow b\rightarrow d$
- $5\rightarrow c\rightarrow d$
- $6\rightarrow d\rightarrow e$
- $7\rightarrow c\rightarrow e$
Representation of Directed Graphs
Adjacency Matrix/List
Adjacency matrix $M$ for a simple directed graph with $n$ vertices is an $n\times n$ matrix:
- $M(i,j)=1$ if $(i,j)$ if $i$ points to $j$.
- $M(i,j)=0$ otherwise.
Adjacency list - each vertex $u$ has a list of vertices pointed to by an edge leading away from $u$.
graph LR
a((a)) --> b((b))
c((c)) --> b
b --> d((d))
b --> e((e))
d --> e
c --> e
The prior graph is represented in the following matrix:
\[\begin{pmatrix}
0 & 1 & 0 & 0 & 0\\
0 & 0 & 0 & 1 & 1\\
0 & 1 & 0 & 0 & 1\\
0 & 0 & 0 & 0 & 1\\
0 & 0 & 0 & 0 & 0
\end{pmatrix}\]
$a$ to $b$ are listed left to right and top to bottom.
- The diagonals are always 0 on a simple graph.
- There is no diagonal symmetry as this is an directed graph.
- The row/column will always add to the degree of the vertex.
- The sum of the matrix is the number of edges in the graph.
This can also be represented in this list:
- $a\rightarrow b$
- $b\rightarrow d\rightarrow e$
- $c\rightarrow b\rightarrow e$
- $d\rightarrow e$
- $e$
The length of the list is the same as the out-degree.
Incidence Matrix/List
Incidence matrix $M$ for a directed graph with $n$ vertices and $m$ edges is an $m\times n$ matrix:
- $M(i,j)=1$ if edge $i$ is leading away from vertex $j$.
- $M(i,j)=-1$ if edge $i$ is leading to vertex $j$.
- $M(i,j)=0$ otherwise.
Incidence list - each vertex $u$ has a list of vertices pointed to by an edge leading away from $u$.
graph LR
a((a)) -->|1| b((b))
c((c)) -->|2| b
b -->|3| d((d))
b -->|4| e((e))
d -->|5| e
c -->|6| e
The prior graph is represented in the following matrix:
\[\begin{pmatrix}
1 & -1 & 0 & 0 & 0\\
0 & -1 & 1 & 0 & 0\\
0 & 1 & 0 & -1 & 0\\
0 & 1 & 0 & 0 & -1\\
0 & 0 & 0 & 1 & -1\\
0 & 0 & 1 & 0 & -1
\end{pmatrix}\]
Vertices $a$ to $b$ are left to right and edges 1 to 7 are top to bottom.
- There should be a -1 and 1 in each row.
This can also be represented in this list:
- $1\rightarrow a\rightarrow b$
- $2\rightarrow c\rightarrow b$
- $3\rightarrow b\rightarrow d$
- $4\rightarrow b\rightarrow e$
- $5\rightarrow d\rightarrow e$
- $6\rightarrow c\rightarrow e$
The order is significant.
Undirected Graphs
An undirected graph $G=(V,E)$ consists of a set of vertices $V$ and a set of edges $E$. Each edge is an unordered pair of vertices.
Unordered means that $\{b,c\}$ and $\{c,b\}$ refer to the same edge.
graph LR
a((a)) --- b((b))
a --- e((e))
a --- c((c))
a --- d((d))
b --- d
b --- c
b --- f
d --- e
e --- f
Terminology
For the following graph:
graph LR
u((u)) --- w((w))
u --- x((x))
u ---|e| v((v))
w --- v
- $u$ and $v$ are adjacent and are neighbours of each-other.
- $u$ and $v$ are endpoints of $e$.
- $e$ is said to be incident with $u$ and $v$.
- $e$ is said to connect $u$ and $v$.
The degree of a vertex $v$, denoted by $\deg(v)$, is the number of edges incident with it.
A loop contributes twice to the degree.
The degree of a graph is the maximum degree over all vertices.
Simple Graphs
An undirected graph is simple if:
- There is at most one edge between two vertices.
Multigraphs
- Allows more than one edge between two verices.
Directed Graphs
A directed graph $G(V,E)$ consists of the same as a directed graph. Each edge is an ordered pair of vertices.
Ordered means that the order of each pair of vertices refers to their direction.
graph LR
a((a)) --> b((b))
a --> e((e))
a --> c((c))
a --> d((d))
b --> d
b --> c
b --> f
d --> e
e --> f
This can be useful in referring to one-way paths.
Terminology
Example
There is an example on an undirected graph which proves:
The number of vertices with odd degree must be even.
Binary Search Tree
For a vertex with value $X$:
- Left child has value $\leq X$.
- Right child has value $> X$.
graph TD
60 --- 40
60 --- 90
40 --- 20
40 --- 50
20 --- 10
20 --- 30
90 --- 80
90 --- 110
80 --- 70
110 --- 100
110 --- 120
To search for a number go left if you want to go smaller and right if you want to go bigger.
In-order traversal gives the tree in ascending order.
Expression Tree
This tree represents a mathematical expression.
For the following expression:
\[(2+5*4)\times3\]
In post-fix:
\[2\ 5\ 4 \times+\ 3\ \times\]
graph TD
*1[*] --- +
*1 --- 3((3))
+ --- 2((2))
+ --- *2[*]
*2 --- 5((5))
*2 --- 4((4))
Post-order traversal will give the post-fix representation.
- A binary tree is a tree where the degree is at most two.
- The two sub-trees are called left sub-tree and the right sub-tree (may be empty).
Traversing
There are three common ways to traverse a binary tree:
- Pre-order traversal (VLR)
- Vertex, left sub-tree, right sub-tree.
- In-order traversal (LVR)
- Left sub-tree, vertex, right sub-tree.
- Post-order traversal (LRV)
- Left sub-tree, right-subtree, vertex
The difference is where the V appears.
Example
For the following tree you would gain the following traversals:
graph TD
r --- a
r --- b
a --- c
a --- d
d --- g
b --- e
b --- f
f --- h
f --- k
Pre-order
r, a, c, d, g, b, e, f, h, k
You can imagine this as following the left of the node.
In-order
c, a, g, d, r, e, b, h, f, k
You can imagine this as following the middle of the node.
Post-order
c, g, d, a, e, h, k, f, b, r
You can imagine this as following the right of the node.
Trees are linked lists with branches.
Definition
A tree $T=(V,E)$ consists of a set of vertices $V$ and a set of edges $E$ such that for any pair of vertices $u,v\in V$ there is exactly one path between $u$ and $v$:
graph TD
u((u)) --> y((y))
u --> v((v))
u --> x((x))
v --> w((w))
Equivalent Statements
- There is exactly one path between two vertices in $T$.
- $T$ is connected (there is at least one path between any two vertices in $T$) and there is no cycle (acyclic) in $T$.
- $T$ is connected and removal of one edge disconnects $T$.
- $T$ is acyclic and adding one edge creates a cycle.
-
$T$ is connected and $m=n-1$ (where $n\equiv \vert V\vert,m\equiv \vert E\vert$).
This statement is proven next.
Statement 5 Proof
$P(n):$ If a tree $T$ has $n$ vertices and $m$ edges, then $m=n-1$.
By induction on the number of vertices.
Base Case
A tree with single vertex does not have an edge.
graph TD
a(( ))
$n=1$ and $m=0$ therefore $m=n-1$
Induction Step
$P(n-1)\Rightarrow P(n)$ for $n>1$?
-
Remove an edge from the tree $T$. By (3), $T$ becomes disconnected. Two connected components $T_1$ and $T_2$ are obtained, neither contains a cycle (otherwise the cycle is also present in $T$.
graph LR
u((u)) ---|remove| v((v))
$u$ and $v$ are parts of two sub-trees ($T_1$ and $T_2$)that we are splitting.
-
Therefore, both $T_1$ and $T_2$ are trees.
Let $n_1$ and $n_2$ be the number of vertices in $T_1$ and $T_2$.
$\Rightarrow n_1+n_2=n$
-
By the induction hypothesis, $T_1$ and $T_2$ contains $n_1-1$ and $n_2-1$ edges, respectively.
Therefore we can calculate that $m=(n_1-1)+(n_2-1)+1$. Simplified this is $m=n_1+n_2-1$. As $n=n_1+n_2$ we can simplify to $m=n-1$.
-
Hence $T$ contains $(n_1-1)(n_2-1)+1=n-1$ edges.
Rooted Trees
This is a tree with a hierarchical structure such as a file structure:
graph TD
/ --- etc
/ --- bin
/ --- usr
/ --- home
usr --- ub[bin]
home --- staff
home --- students
students --- folder1
students --- folder2
folder1 --- file1
folder1 --- file2
folder1 --- file3
The degree of this tree is 4 as /
has the largest degree.
- The topmost vertex is called the root.
- A vertex $u$ may have some children below it, $u$ is called the parent of its children
- Degree of a vertex is the number of children it has.
- Degree of a tree is the maximum degree of all vertices.
- Vertex with no child (degree 0) is called a leaf.
- Vertices other than leaves/root are called internal vertices.
- Each child is the root of its own sub-tree.
Method
- Look up the elements one by one.
- Build up sorted list by inserting the element in the correct location.
Example
34 |
10 |
64 |
51 |
32 |
21 |
Number shifted to right |
34 |
10 |
64 |
51 |
32 |
21 |
|
10 |
34 |
64 |
51 |
32 |
21 |
34 |
10 |
34 |
64 |
51 |
32 |
21 |
|
10 |
34 |
51 |
64 |
32 |
21 |
64 |
10 |
32 |
34 |
51 |
64 |
21 |
34, 51, 64 |
10 |
21 |
32 |
34 |
51 |
64 |
32, 34, 51, 64 |
Bold is the number being considered and italic is the sorted list.
Pseudo-Code
for i = 2 to n do
begin
key = A[i]
loc = 1
while loc < i AND A[loc] < key do
loc++
for j = i downto (loc + 1) do
A[j] = A[j - 1]
A[loc] = key
end
Time Complexity
The nested for loops give the following table:
$i$ |
N° of Comparisons |
N° of Shifts |
1 |
$\leq$ 1 |
$\leq$ 1 |
2 |
$\leq$ 2 |
$\leq$ 2 |
$\vdots$ |
$\vdots$ |
$\vdots$ |
$n$ |
$\leq n-$1 |
$\leq n-$1 |
From this we get the following big-O notation:
\[O(n^2)\]
Using Linked Lists
Pseudo-Code
head = NIL
while thereIsNewNumber do
begin
input number * create node
node.data = num
if head == NIL OR head.data > node.data then
begin
node.next = head
head = node
end
else begin
curr = head
while curr.next =/= NIL AND curr.next <= node.data do
curr = curr.next
node.next = curr.next
curr.next = node
end
end
Time Complexity
The time complexity is exactly the same as compared to using an array:
\[O(n^2)\]
Method
- Find minimum key from the input sequence.
- Delete it from input sequence.
- Append it to resulting sequence.
- Repeat until nothing is left in the original sequence.
Example
34 |
10 |
64 |
51 |
32 |
21 |
To Swap |
34 |
10 |
64 |
51 |
32 |
21 |
34, 10 |
10 |
34 |
64 |
51 |
32 |
21 |
34, 21 |
10 |
21 |
64 |
51 |
32 |
34 |
64, 32 |
10 |
21 |
32 |
51 |
64 |
34 |
51, 34 |
10 |
21 |
32 |
34 |
64 |
51 |
64, 51 |
10 |
21 |
32 |
34 |
51 |
64 |
|
10 |
21 |
32 |
34 |
51 |
64 |
|
Underlined is the current position, bold is the current smallest, and italic is sorted.
Pseudo-Code
This will give the following code when using while
loops:
i = 1
while i <= n do
begin
loc = i
j = i + 1
while j <= n do
begin
if A[j] < A[loc] then
loc = j
j++
end
swap A[i] and A[loc]
i++
end
and the following for for
loops:
for i = 1 to (n - 1) do
begin
loc = i
for j = (i + 1) to n do
if A[j] < A[loc] then
loc = j
swap A[i] and A[loc]
end
Time Complexity
The nested for loops give the following table:
$i$ |
N° of > Comparisons |
$1$ |
$n-1$ |
$2$ |
$n-2$ |
$\vdots$ |
$\vdots$ |
$n-1$ |
$1$ |
This gives the following simplified equation:
\[\frac{n^2-n}{2}\]
We keep the highest power which gives the following big-O notation:
\[O(n^2)\]
Using Linked Lists
Pseudo-Code
if head == NIL then
Empty list and STOP
curr = head
while curr.next =/= NIL do
begin
min = curr
node = curr.next
while node =/= NIL do
begin
if node.data < min dataq then
min = node
Node = node.next
end
swapnode(curr,min)
curr = curr.next
end
Time Complexity
The time complexity is exactly the same as compared to using an array:
\[O(n^2)\]
Cases
- In the best case selection sort takes 0 swaps.
- In the worst case, selection sort takes $O(n^2)$ times.
This time we will be looking at bubble sort in a linked list.
Linked List Pseudo Code
if head = NIL then
Empty list and STOP
last = NIL
curr = head
while curr.next =/= last do
begin
while curr.next =/= last do
begin
if curr.data > curr.next.data then
swapnode(curr, curr.next)
curr = curr.next
end
last = curr
curr = head
end
Time Complexity Compared to Array
The time complexity is exactly the same as compared to using an array:
\[O(n^2)\]
Method
- Starting from the first element, swap adjacent items if they are not in ascending order.
- When last item is reached, the last item is the largest.
- Repeat the above steps for the remaining items to find the second largest item.
- When there is no change the list is sorted.
Example
Each table is one iteration:
34 |
10 |
64 |
51 |
32 |
21 |
10 |
34 |
64 |
51 |
32 |
21 |
10 |
34 |
64 |
51 |
32 |
21 |
10 |
34 |
51 |
64 |
32 |
21 |
10 |
34 |
51 |
32 |
64 |
21 |
10 |
34 |
51 |
32 |
21 |
64 |
No change, list is sorted.
Bold are being considered, italic are sorted.
Pseudo-Code
for i = n downto 2 do
for j = 1 to (i - 1) do
if (A[j] > A[j + 1]) then
swap A[j] & A[j+1]
end
end
Swapping Variables
Generally you need a temporary variable, otherwise you will overwrite your original data:
You can also recover the data this way without using a temporary variable:
This method is less readable and may overflow if you have two very large numbers.
Time Complexity
The nested for loops give the following table:
$i$ |
N° of > Comparisons |
$n$ |
$n-1$ |
$n-1$ |
$n-2$ |
$\vdots$ |
$\vdots$ |
2 |
1 |
This gives the following simplified equation:
\[\frac{n^2-n}{2}\]
We keep the highest power which gives the following big-O notation:
\[O(n^2)\]
Cases
- In the best case bubble sort takes 0 swaps.
- In the worst case, bubble sort takes $O(n^2)$ times.
Time Complexity
For a list with $n$ elements the following operations have the following time complexity:
- Traversing: $O(n)$
- Searching:
- Unsorted: $O(n)$
- Sorted: $O(n)$
- Insertion/Deletion:
- Head/Tail: $O(1)$
- Middle: $O(n)$
- Finding the location may take $O(n)$ time.
Memory Management
- Memory needs to be allocated for a new node.
- We should free any object that is no longer used (after we delete a node).
- In some systems, a garbage collector is responsible for storage management.
Arrays v.s. Lists
- Arrays
- Easy to access, one can access any element in a single access by its index.
- Size needs to be predetermined, may waste space.
- If we want to insert an element in the middle, needs to shift the rest.
- Linked Lists
- Needs to traverse the list to access elements in the middle.
- Does not need to predefine size, it’s flexible to add elements.
- Once we find the location to insert, we can insert without moving the rest, only need to alter a few pointers.
Deletion from the Head of the List
Consider a list 15, 10, 20
. We want to remove 15
and return it to the user.
List-Delete-Head(L)
node = head
if node =/= NIL then
begin // list wasn't empty
head = head.next
if head =/= NIL then
head.prev = NIL
node.next = NIL
end
return node
Deletion from the Tail of the List
Consider a list 15, 10, 20
. We want to remove 20
and return it to the user.
List-Delete-Tail(L)
node = tail
if tail =/= NIL then
begin // list wasn't empty
tail = tail.prev
tail.next = NIL
node.prev = NIL
end
return node
Deletion from the Middle
Consider a list 15, 10, 20
. We want to delete a node pointed to by curr
and return the value.
// Assume that curr is pointing to a node
List-Delete(L, curr)
curr.next.prev = curr.prev
curr.prev.next = curr.next
curr.next = NIL
curr.prev = NIL
return curr
Insertion
Insertion to Head of List
List-Insert-Head(L,node)
node.next = head
node.prev = NIL
if head =/= NIL then
head.prev = node
else // list was empty
tail = node
head = node
Insertion to Tail of List
List-Insert-Tail(L,node)
node.next = NIL
node.prev = tail
if tail =/= NIL then
tail.next = node
else // list was empty
head = node
tail = node
Insertion in Middle
Suppose we want to insert a node after a node pointed to by curr
.
# assume that curr is pointing to a node
List-Insert(L,curr,node)
node.next = curr.next
node.prev = curr
curr.next.prev = node # points to new position from old next
curr.next = node # point to new position from prev
This will not work if curr
is at any end.
Time Complexity
The time complexity of:
- inserting to the head/tail of doubly linked list is $O(1)$.
- inserting to the head of singly linked list is $O(1)$.
- inserting to the tail of singly linked list is $O(n)$.
- You may have to traverse the list if you don’t have an extra pointer for the last element in the list. If you do then it is $O(1)$
- inserting in a sorted doubly linked list is $O(n)$.
- You have to search through the entire list in the worst case. The insertion is only $O(1)$.
Linked lists are lists in which elements are arranges in a linear order. The order is determined by a pointer (rather than array indices).
Structure
Each node has a data field and one/two pointers linking to the next/previous element in the list.
Nodes with one pointer are called singly linked and ones with two are called doubly linked.
graph TD
subgraph node
prev --> a[ ]
data --> b[15]
next --> c[ ]
end
We refer to the three objects as: node.data
, node.next
and node.prev
:
- If node is the last element, then
node.next
is NIL
.
- If node is the first element, then
node.prev
is NIL
.
head
points to the first node
tail
points to the last node.
The difference between linked lists and arrays is that you must follow the pointers to get to the data and can’t just go straight to a particular index.
graph LR
head --> prev1[ ]
subgraph 1
prev1[prev]
15
next1[next]
end
next1 --> prev2
subgraph 2
prev2[prev]
10
next2[next]
end
next2 --> prev3
subgraph 3
prev3[prev]
20
next3[next]
end
next3 --> tail
Traversing
Traversing and outputting each element of a linked list:
node = head
while node =/= NIL do
begin
output node.data # output the data from this node
node = node.next # go to next node
end
Searching
Searching if the value key
is in a linked list:
node = head
found = false
while node =/= NIL AND found == false do
begin
if node.data == "key" then
found = true
else # this keeps the node in the case that the element is found.
node = node.next
end
if found == true then
output "Found!"
else output "Not found!"
Alternative
node = head
while node =/= NIL AND node.data =/= "key" do
node = node.next
if node == NIL then
output "Not found!"
else output "Found!"
Time Complexity
The time complexity of these two algorithms are:
\[O(n)\]
Searching Sorted Lists
We are unable to use a binary search to find elements in a linked list as we have to walk the list. We are able to end the search if the data overshoots the requested data:
node = head
while node =/= NIL AND node.data < "key" do
node = node.next
if node == NIL then # must check for NIL to avoid reading empty node
output "Not found!"
else if node.data == "key" then
output "Found!"
else output "Not found!"
Time Complexity
The time complexity of this algorithm is:
\[O(n)\]
Stacks
The following are the operations on stacks:
- Push - Insert element tot he location 1 beyond top.
- Pop - Delete element from the top.
The stack has one pointer that points to the top-most data in the stack.
- Push
- Increment
top
by 1 and save the data to S[top]
.
- Pop
- Retrieve data from
S[top]
and decrement top
by 1.
You need to check if the stack is empty before popping and the opposite for pushing.
Pseudo Code - Infinite Size
Push(S,x)
top++
S[top] = x
Pop(S)
if top == 0 then
stack is EMPTY
else
begin
x = S[top]
top--
return x
end
Pseudo Code - Limited Size
Push(S,x)
if top == size then
stack is FULL
else
begin
top++
S[top] = x
end
Post-fix Expressions with Stacks
You can use the stack to compute mathematical operations. This is the case if you use reverse Polish notation. This style of notation makes the precedence implicit.
Method
- Rad the next token from the expressions which may be operator or operand.
- If token is operand, PUSH it to stack.
- If token is operator;
- POP two operand from stack.
- Operate on the two operands.
- PUSH result back to stack.
- If expression is not exhausted, go back to step 1.
- If expression is exhausted, evaluation finished and answer is on top of stack.
Example
Evaluate 10 3 2 * - 5 *
Expression |
Stack |
10 3 2 * - 5 * |
|
3 2 * - 5 * |
10 |
2 * - 5 * |
10, 3 |
* - 5 * |
10 , 3, 2 |
- 5 * |
10, 6 - Push top two, complete operand, push result. |
5 * |
4 - Push top two, complete operand, push result. |
* |
4, 5 |
|
20 - Push top two, complete operand, push result. |
Convert Infix to Postfix Expression Using Stack
Whenever an operator is popped, output it.
- Read the next token from the expression which may be operator, operand, ( or ).
- If token is operand, OUTPUT it.
- If token is (, PUSH it on the stack.
- If token is ), POP from the stack until matching (.
- If token is operator, POP from stack until you see one of the following:
- An operator with same/lower precedence.
- An opening (.
- The stack is empty.
- PUSH the operator on stack.
- If expression is not exhausted, go to step 1.
- If expression is exhausted, POP rest of stack.
- Arrays - Alow random access to data.
- Queues - FIFO
- Stack - LIFO
Queues
There are two operations on a queue:
- Enqueue - Insert element to the tail.
- Dequeue - Delete element from the head.
The head points to the first element and the tail points to the location where data should be enqueued.
The actions are completed as follows:
- Enqueue
- Save the data to
Q[tail]
and increment tail
by 1.
- Dequeue
- Retrieve data from
Q[head]
and increment head
by 1.
Need to check if queue is empty before dequeue or full before enqueue.
Pseudo Code - Infinite Size
Enqueue(Q,x)
Q[tail] = x
tail++
Dequeue(Q)
if head == tail then # both pointers at same place
Queue is EMPTY
else begin
x = Q[head]
head++
return x
end
Pseudo Code - Limited Size
Enqueue(Q,x)
if tail > size then
Queue is FULL
else begin
Q[tail] = x
tail++
end
Looping
In order to make use of free lower blocks the indexes must wrap around. This will give the following methods:
- Enqueue
- After incrementing
tail
, if it exceeds size
, then reset it to 1.
- Same for checking full or not.
- If indexes are from 0 to
size - 1
you can use %
.
- Dequeue
- After incrementing
head
, if it exceeds size
, then reset it to 1.
- If indices are from 0 to
size - 1
, you can use %
.
Suppose we have data of daily rainfall for a year. Find the maximum monthly average rainfall over a year.
- Lets assume that there are $d$ days in a month and $m$ months in a year.
We could store rainfall data in a 2D array of size $m\times d$:
rainfall[i][j]
stores rainfall of month $i$ and day $j$.
You can split the problem into the following questions:
- What is the average rainfall of month $i$?
- What is the maximum average?
Average of Month $i$
j = 1
sum = 0
while j <= d do
begin
sum += rainfall[i][j] # ask for i
j++
end
average = sum / d
This is the nested portion of the loop. $i$ should be incremented outside.
Maximum of the Averages
m = 0
i = i
while i <= m do
begin
sum = 0
for j = 1 to d do
sum += rainfall[i][j]
average = sum / d
if average > m then
m = average
i++
end
output m
Time Complexity
The nested portion has a time complexity of $O(d)$. This is repeated $m$ times giving an overall time complexity of:
\[O(m\times d)\]
If the dataset is sorted in descending order then the first number is the maximum number.
This is excessive if set is unsorted.
Pseudo-code Examples
Maximum
i = 1
m = 0 # only works for +ve numbers; see next example
while i <= n do
begin
if A[i] > m then
m = A[i]
i++
end
output m
Minimum
i = 1
m = A[1] # initialise to the first number in the array
while i <= n do
begin
if A[i] < m then # this comparison is changed
m = A[i]
i++
end
output m
Time Complexity
The time complexity of this algorithm is:
\[O(n)\]
This is as it iterates through every item in the list once.
Finding Location
i = 2 # location of second
loc = 1 # location of first
while i <= n do
begin
if A[i] > A[loc] then
loc = i
i++
end
output loc and A[loc]
This will find the location of the first instance of the largest number.
To find the location of the last location you would use the following:
i = 2 # location of second
loc = 1 # location of first
while i <= n do
begin
if A[i] >= A[loc] then
loc = i
i++
end
output loc and A[loc]
This will update if we come to a new instance of the maximum value.
Finding All Locations of Maximum
Find the first maximum:
m = A[1]
i = 2 # compare A[1] with A[2] in first iteration
while i <= n do
begin
if A[i] > m then
m = A[i]
i = i + 1
end
Then print other locations of m
:
i = 1
while i <= n do
begin
if A[i] == m then
output i
i = i + 1
end
output M
Finding First and Second Maximums
m1 = max(A[1],A[2]) # ensure correct order
m2 = min(A[1],A[2])
i = 3 # skip comparing already compared
while i <= n do
begin
if A[i] > m1 then
begin
m2 = m1
m1 = A[i]
end
else if A[i] > m2 then
m2 = A[i]
i = i + 1
end
output m1 and m2
Time Complexity
There is one loop with a constant number of comparisons. This gives a big-$O$ notation of:
\[O(n)\]
This assignment is based around memory management.
Paging/Caching
Two level virtual memory system
Slow memory contains more pages than fast memory called cache.
Requesting a page:
- If a page is already in cache.
- If page not in cache.
In case of a miss, need to evict a page from cache to make room:
eviction algorithms
Eviction Algorithms
No Eviction
Nothing is evicted with the cache containing exactly what it contains initially.
Example
- Cache: 20, 30, 10
- Sequence of Requests: 20, 30, 5, 30, 5, 20
- Output: hhmhmh (4h 2m)
Evict FIFO
In the case of a miss the first element in the cache will be evicted first. This is similar to a queue.
Example
- Cache: 20, 30, 10
- Sequence of Requests: 20, 30, 5, 30, 5, 20
- Output: hhmhhm (4h 2m)
Second row is after the request sequence.
Evict LFU (Least Frequently Used)
If there is more than one page with lowest frequency, evict the leftmost one.
Example
- Cache: 20, 30, 10
- Sequence of Requests: 20, 30, 5, 30, 5, 20
- Output: hhmhhh (5h 1m)
Cache |
Frequency |
Explanation |
20 30 10 |
1 1 1 |
Initial cache. |
20 30 10 |
2 1 1 |
Frequency of 20 increased. |
20 30 10 |
2 2 1 |
Frequency of 30 increased. |
20 30 5 |
2 2 1 |
5 is a miss, evict 10 (freq of 1). |
20 30 5 |
2 3 1 |
Frequency of 30 increased. |
20 30 5 |
2 3 2 |
Frequency of 5 increased. |
20 30 5 |
3 3 2 |
Frequency of 20 increased. |
Evict LFD (Longest Forward Distance)
We look forward in the request queue and replace the item in the cache that is furthest forward.
If there is more than one page with same position of next request $\infty$ evict, leftmost one.
Example
- Cache: 20, 30, 10
- Sequence of Requests: 20, 30, 5, 30, 5, 20
- Output: hhmhhh (5h 1m)
Cache Before |
Cache After |
Position of Next Request |
20 30 10 |
|
1 2 $\infty$ |
20 30 10 |
No change. |
6 2 $\infty$ |
20 30 10 |
No change. |
6 4 $\infty$ |
20 30 10 |
20 30 5 |
6 4 5 |
20 30 5 |
No change. |
6 $\infty$ 5 |
20 30 5 |
No change. |
6 $\infty$ $\infty$ |
20 30 5 |
No change. |
$\infty$ $\infty$ $\infty$ |
This method is a moire efficient way of searching but only when the sequence of numbers is sorted.
A sequence of $n$ sorted numbers in ascending order and a target number key
.
Method
- Compare
key
with number in the middle.
- Then focus on only the first half or the second half, dependant on whether
key
is smaller or greater than the middle number.
- Reduce the amount of number to be searched by half.
Example
3 |
7 |
11 |
12 |
15 |
19 |
24 |
33 |
41 |
55 |
|
|
|
|
24 |
|
|
|
|
|
Go for lower if there is no middle. Consistency is key.
Found
If the key
is not in the array you can conclude the number is not there when comparing to the last digit.
Pseudo Code
first = 1
last = n
found = false
while first <= last && found == false do
begin
mid = floor((first + last) / 2)
if A[mid] == key then
found == true
else if A[mid] > key then // these two cases will trip first <= last
last = mid - 1
else A[mid] < key
first = mid + 1
end
if found == true then
output "Found"
else
output "Not Found"
Time Complexity
This search is generally quicker than linear search.
- Best case:
key
is the middle number.
- $O(1)$
- Worst case:
For arrays, out of bounds means any index that is not in the range of the index.
When using arrays in pseudo code the following notation will be used:
Arrays in the notes will also be indexed from 1.
Sequential/Linear Search
$n$ numbers stored in an array A[1..n]
, and a traget number key
.
Output
Determine if key
is in the array or not.
Algorithm
- From
i = 1
, coparte key
with A[i]
one by one as long as i <= n
.
- Stop and report
"Found"
when key
is the same as A[i]
.
- Repeat and report
"Not Found"
when i > n
.
Example
Found
Pseudo Code
i = 1
found = false
while i <= n AND found == false
begin
if A[i] == key then
found = true
else
i = i + 1 // Don't increment if number is found to save i.
end
if found == true then
output "Found"
else
output "Not Found"
Time Complexity
How many comparisons does this algorithm take?
- Best case:
- The best case is where the
key
is the first element in the list.
- $O(1)$
- Worst case:
- The worst case is where the
key
is not in the array or the key
is the last number.
- $O(n)$
Powers
To find the time complexity of an algorithm look at the loops:
count = 0
for i = 1 to x do
for j = 1 to y do
for k = 1 to z do
count = count + 1
For this loop the time complexity is $O(xyz)$. You can see that the variable relates to the time complexity.
Here is another example:
i = 1
while i <= n do
begin
j = i
while j <= n do
j = j + 1
i = i + 1
end
The time complexity for this loop is $O(n^2)$. This is as a result of the time complexity of:
\[\frac{n\times(n+1)}{2}\]
The highest term in this equation is $n^2$.
Logs
Here is another algorithm:
i = 1
count = 0 while i < n do
begin
i = i * 2
count = count + 1
end
output count
This is the trace table for this algorithm:
-
Suppose $n=8$
Iteration |
i before |
i after |
count |
Before Loop |
|
1 |
0 |
1 |
1 |
2 |
1 |
2 |
2 |
4 |
2 |
3 |
4 |
8 |
3 |
-
Suppose $n=32$
Iteration |
i before |
i after |
count |
Before Loop |
|
1 |
0 |
1 |
1 |
2 |
1 |
2 |
2 |
4 |
2 |
3 |
4 |
8 |
3 |
4 |
8 |
16 |
4 |
5 |
16 |
32 |
5 |
We can see by comparing $n$ to the iterations that this algorithm is not linear.
To find the count that gives $n$ we would need to calculate:
\[2^\text{count}=n\]
Therefore this algorithm has logarithmic time complexity:
\[O(\log n)\]
Don’t presume, when you see a loop, that the time complexity is polynomial.
Efficiency matters as we want to make more ambitious code to leverage increases in computational power. If we didn’t optimise code then we’d be wasting resources.
Time Complexity Analysis
Timing execution time is not enough as it is not reproducible and wastes time.
We should identify some important operations and count how many times these operation need to be executed.
We can then change the input size and see how the time the algorithm takes changes.
Data vs Speed Increase
Assume this initial scenario:
- An algorithm takes $n^2$ comparisons to sort $n$ numbers.
- We need 1 second to sort 5 numbers (25 comparisons).
Now suppose that Computing speed increases by a factor of 100:
- This means in 1 second we can now do 2500 comparisons.
- This gives a result of 50 numbers vs the original 5.
We show here that an $n^2$ comparison algorithm gives a 10 times speed increase with a raw 100 times speed increase.
Important Operations
Here is an algorithm:
sum = 0
i = 1
while i <= n do
begin
sum = sum + i
i++
end
output sum
The important operation of summation is addition.
For this algorithm we need $2n$ additions (dependant on the input size of $n$).
Which Algorithm is Fastest?
This question may be dependant on the input size you may want to use several algorithms:
- $f_1(n) = n^2 -3n+6$
- $f_2(n) = 50n+20$
Generally you would want to choose the most efficient algorithm for when the input is maximal. In this example you would choose $f_2(n)$.
Growth Rate
Here are functions ordered by their growth rate:
- Constant
- Logarithm
- Polynomial
- Polynomial Logarithm (In-between each polynomial.)
- Polynomial
- Exponential
In computer science $\log$ refers to $\log_2$.
Other Hierarchies
Comparing $\log^3n$ and $n$.
Identity:
\(n\equiv 2^{\log_2n}\)
As $\log^3n$ is polynomial in terms of $\log n$ and $n$ (by the identity) is exponential in terms of $\log n$ the former is lower in the hierarchy.
Similarly, $\log^kn$ is lower than $n$ in the hierarchy, for any constant $k$.
Big-O Notation
\(f(n) =O(g(n))\)
Read as $f$ of $n$ is of order $g$ of $n$.
Roughly speaking, this means that $f(n)$ is at most a constant times $g(n)$ for all large $n$:
- $2n^3=O(n^3)$
- $3n^2=O(n^2)$
- $2n\log n=O(n\log n)$
- $n^3+n^2=O(n^3)$
When we have an algorithm, we can then measure its time complexity by:
- Counting number of operations in therms of the input size.
- Expressing it using big-O notation.
This lecture follows through some examples of solving particular problems in pseudo code. The slides for this lecture are available here.
Factors of $x$ but Not Factors of $y$
In this example we find the factors of $x$ and $y$ and subtract the elements in the $y$ list from the $x$ list.
Lowest Common Multiple
This example iterates through all numbers until it finds one that satisfies mod = 0
for both numbers.
Using breaks in a loop is not a good practice as it diminishes readability.
Algorithms
An algorithm is a precise and concise set of instructions that guide you to solve a specific problem in a finite amount of time.
graph LR
Input --> Algorithm
Algorithm --> Output
The differences between algorithms and programs are as follows:
- In algorithms the content is more important than the form.
- Algorithms are free from grammatical Rules.
- Programs must follow syntax rules.
Pseudo Code
This is a logical code that doesn’t strictly follow the syntax of any individual programming language. They are useful for drafting algorithms.
Pseudo code uses a combination of english and code to make it more human readable.
Control Flow
The following conventions for pseudo code are preferred:
if <condition> then
<statement>
else
<statement>
for <variable> <- <value1> to <value2> do
<statement>
while <condition> do
<statement>
Blocks
Block in control sequences should be laid out like so:
begin
<statement1>
<statement2>
.
.
.
end
or:
{
<statement1>
<statement2>
.
.
.
}
Operations
- The
%
operator can be used in place of mod
.
Other Notes
The use of a boolean variable to mark a significant event is called a flag variable. You can use this to mark a condition that has been met.
It is recommended that you draw a trace table of all the values at each iteration to find logical errors in your algorithms. You can also use them to find out what an algorithm does.
The lecturer for this subject is Prof. Prudence Wong. Email is pwong@liverpool.ac.uk.