W7 - Advanced Program Control - Course Notes
W7 - Advanced Program Control - Course Notes
Overview
The greater the complexity of the tasks that we wish to perform with the programs we write, the
more important it becomes to understand the instructions that we employ within those
programs and their interactions with one another. These relationships and the ordering of
different program instructions is referred to as an algorithm. Flowcharts can be very useful in
expressing algorithms visually, which is easier to digest than a sequence of descriptive text.
This week, we will cover the complexity of programs, exploring nested loops and the
consequence of program complexity on the time it takes to execute a program. The usefulness
of nesting loops is illustrated through the example of solving simultaneous equations using
matrix algebra, which is relatively easy to program and scalable.
1
ENG1014 Engineering Numerical Analysis Course Notes
Figure 7.1 – Example flowchart for cooking rice on a stove.1 Note that the “decision” points are
expressed as yes/no questions; this is required if a computer will control the process.
1
AB Chaudhuri “Flowchart and Algorithms Basics: The Art of Programming (2020)
2
ENG1014 Engineering Numerical Analysis Course Notes
Another example might be the process we used to fit the various non-linear models we
encountered in W5 – Models and Curve Fitting.
3
ENG1014 Engineering Numerical Analysis Course Notes
A good way to ensure that your algorithm is complete is to break the problem down into multiple
different levels, while still ensuring that the algorithm is complete at each level. At the final level,
each part of the algorithm should be simple enough to attack successfully. The process of
breaking down a complex program is shown graphically in Figure 7.3.
Figure 7.3 – Process of breaking down a complex program into small, manageable parts.
Breaking down complex problems into manageable pieces, as described in Figure 7.3, is an
important abstract skill for all engineers. It seems like a simple thing to do, but it is very easy to
miss details that end up being crucial. Always check that your breakdown is complete.
4
ENG1014 Engineering Numerical Analysis Course Notes
for i in range(rows):
for j in range(cols):
A[i,j] = i*j
print(A)
Output [[ 0. 0. 0. 0. 0. 0.]
[ 0. 1. 2. 3. 4. 5.]
[ 0. 2. 4. 6. 8. 10.]
[ 0. 3. 6. 9. 12. 15.]
[ 0. 4. 8. 12. 16. 20.]]
Figure 7.5 shows the corresponding flowchart for this algorithm. You can see that the presence
of nested loops can start to make flowcharts quite complicated since you must manage the exit
conditions of all the loops present, as well as anything that happens inside those loops.
5
ENG1014 Engineering Numerical Analysis Course Notes
Refer to Example 1 from the W7 – Advanced Program Control – Course Notes Examples
6
ENG1014 Engineering Numerical Analysis Course Notes
7.2.2. Pre-allocation
When writing for loops in ENG1014 to build new arrays, we usually already know the shape of
the new array we want to create. In this case, we should pre-allocate the array. Pre-allocation
means that we create an “empty” array and then fill it up later with the values we want. In
ENG1014, the functions np.zeros() and np.ones() can be used to pre-allocate NumPy arrays.
Consider the example where we want to calculate 𝑟𝑐𝑜𝑠(𝜃) for various values of 𝑟 and 𝜃:
r = np.arange(0, 10)
theta = np.arange(0, 2*np.pi, 0.1)
for i in range(rows):
for j in range(cols):
A[i][j] = r[i]*np.cos(theta[j])
Here, we start with some data stored in r and theta. Using the np.zeros() function, we pre-
allocate an array A that has one row for each item in r and one column for each item in theta.
Following that, we loop over the rows and the columns, and calculate the value of A[i][j] for
each combination of i and j.
It is possible to append (add new values at the end) values to the end of an array, changing its
size each time you calculate a new value, but it should be avoided where possible. This is
because it will make your code run slowly, because the way Python appends to an array is quite
inefficient. In some limited circumstances this is necessary, but these are relatively rare.
Refer to Example 2 from the W7 – Advanced Program Control – Course Notes Examples
Complete Practice Questions 1-7 from the W7 – Advanced Program Control Course Notes
Practice Questions
7
ENG1014 Engineering Numerical Analysis Course Notes
7.3.1. Timing
The simplest way that we can think about how efficient our code is by timing it. An efficient
program will run faster than an inefficient program when run on the same hardware. The built-in
Python module timeit contains a function default_timer() that returns the system time in
seconds. We can use this twice to get a start and an end time for anything that we want to time.
Thus, if we do this for each program we want to compare, we can compare the efficiency of our
code.
You should note that the time taken will change a little bit every time you run the program.
Sometimes one approach might be faster than the other, but at other times that might change
just because your computer was allocating resources to something else. If this happens, how
can we know which approach we should be taking?
Refer to Example 3 from the W7 – Advanced Program Control – Course Notes Examples
Big-O notation: If our input is “n” times larger, roughly how much longer will the program take
to run?
Big-O notation won’t tell you exactly how long a program will take; it is a rule-of-thumb, that
becomes more accurate when inputs become larger.
8
ENG1014 Engineering Numerical Analysis Course Notes
Example 1: 𝑶(𝒏)
Let’s consider an example of finding the maximum value in an array.
We can find the maximum value by assuming that the first thing we see is the maximum, then
comparing it to the next value we see. If the next value is higher, we replace that as the maximum
and then keep comparing that new maximum to each new value.
x = np.array([5, 10, 1, 3, 2, 20, 9, 17])
max_val = x[0]
for i in range(len(x)):
new_val = x[i]
if new_val > max_val:
max_val = new_val
How many iterations of this loop do we go through? There are 8 items in x, so we go through the
loop 8 times. However, if we had an array with 50 items, we would go through it 50 times. So, if
we have an array with 𝑛 items, we will go through the loop 𝑛 times.
We describe this computational complexity as being 𝑂(𝑛) , meaning that the number of
computations is some constant multiplied by n, otherwise described as on the order of 𝑛.
Example 3: 𝑶(𝒏𝟐 )
Let’s look at a more complicated example. Let’s say we had a list of numbers, and we wanted
to pair up every number with every other number. We can do this by picking the first element in
the list, then going through every element in the list and pairing them, then doing the same for
the second, third etc elements. This looks like a nested loop.
vals = np.array([9, 6, 2, 7, 3])
for i in range(len(vals)):
for j in range(len(vals)):
print((vals[i], vals[j]))
9
ENG1014 Engineering Numerical Analysis Course Notes
How many operations did we do? The outer loop ran 5 times, and the inner loop ran 5 times for
each time the outer loop ran. So, we did 52 = 25 calculations. If we had a list of length 𝑛, we
would do 𝑛 2 calculations, so we would have a 𝑂(𝑛 2 ) algorithm. In general, the complexity of a
nested piece of code is the complexity of the inside multiplied by the complexity of the outside,
in this case 𝑂(𝑛 ∗ 𝑛) = 𝑂(𝑛 2 ).
Example 4: 𝑶(𝒏)
It is important to note that sometimes you can end up with something that behaves like a loop,
even though you haven’t written one. This often happens when you are doing operations on
entire arrays.
x = np.arange(5,10)
print(x)
In this case, print(x) is an 𝑂(𝑛) operation, because it must go through and print each element
of the entire array x.
Example 5: 𝑶(𝒏𝟐 )
Likewise, if we nested an operation on array within a loop, we would have an 𝑂(𝑛 2 ) algorithm:
x = np.arange(6)
y = np.zeros(np.shape(x))
for i in range(len(x)):
y += x
In this example, we went through the loop 𝑂(𝑛) times. Each time, we added the entirety of x to
y. In doing so, we had to add each element of x to y, which also takes 𝑂(𝑛) time. Therefore, we
have an 𝑂(𝑛 2 ) algorithm. You should be careful when thinking about the efficiency of vector
operations, because they can implicitly use loops in this way. Sometimes it’s necessary, but at
other times you may unknowingly give your algorithms a poor complexity.
10
ENG1014 Engineering Numerical Analysis Course Notes
higher order complexity means more computation as we scale up, we generally want to make
our algorithms as low a complexity as possible. Ideally, all our algorithms would be 𝑂(1) –
constant! But this is obviously not possible, so we must deal with linear, quadratic or other
higher order complexities.
Because we only care about scaling as our inputs become very large, in Big-O we only write the
factor that contributes most to the scaling. If you have constant factors and/or lower order
terms, we simply ignore them. Let’s make our previous example a little bit more complicated to
illustrate this point.
Example 6: 𝑶(𝒏𝟐 )
for i in range(len(vals)):
print(vals[i])
for i in range(len(vals)):
for j in range(len(vals)):
print(f"\n{vals[i]*vals[j]}")
print((vals[i], vals[j]))
We have added two new steps here: we print out every number, and we also calculate the
product of each pair. So, the steps we are doing are:
• Printing out every number. This is an 𝑂(𝑛) operation, because we go through every
element in the list once.
• Printing out the product of each pair. This happens once every time we go through the
inner loop, which happens 𝑛 2 times, just like it did for printing out the pair, so it is an
𝑂(𝑛 2 ) operation.
• Printing out the pair of numbers. As we discussed earlier, this is an 𝑂(𝑛 2 ) operation.
11
ENG1014 Engineering Numerical Analysis Course Notes
As we said earlier, we only care about how the amount of computation scales as the inputs get
very large. When 𝑛 is very large, 𝑛 2 will dwarf 𝑛 to the point where it is negligible, which is why
we ignore it. Similarly, since we only care about how much the amount of computation changes
as inputs grow, we simply ignore any constant factors that appear. The reason that we only care
about how our algorithms behave on large inputs is because they are the ones that can be
affected the most by a small change. Small inputs are always going to run fast, and large inputs
are always going to run slowly.
At a basic level, we can judge the complexity of an algorithm by how deep the nested loops go.
Example 1 had 1 loop, and it was 𝑂(𝑛). Both examples 3 and 6 had 2 loops nested and were
𝑂(𝑛 2 ) . Similarly, if you nested 3, or 4 or 5 loops, you would get 𝑂(𝑛 3) , 𝑂(𝑛 4 ) and 𝑂(𝑛 5 )
complexities. However, there are many cases where this isn’t true, and you need to be more
careful. It is common to see 𝑂(log(𝑛)) and 𝑂(2𝑛 ) complexities in some contexts, but we will
not go into detail about them in this unit. Figure 7.6 shows a hierarchy of some common
complexities, with 𝑂(1) being the best and 𝑂(𝑛!) being the worst.
2
https://adrianmejia.com/how-to-find-time-complexity-of-an-algorithm-code-big-o-notation/
12
ENG1014 Engineering Numerical Analysis Course Notes
for i in range(rows):
for j in range(cols):
A[i,j] = i*j
We note that A is a 5 × 6 matrix, so the outer loop runs 5 times and the inner loop runs 6 times,
and we get 5*6=30 operations. You could run the same algorithm on any 𝑚 × 𝑛 matrix, and it
would take 𝑂(𝑚𝑛) time to run.
The principles we discussed earlier about simplifying complexities still apply, but you should be
careful to note that you can’t always cancel out the other variables down to one term. For
example, if we had a much more complicated algorithm we might get:
𝑂(𝑚2 𝑛) + 𝑂(𝑚𝑛 2 ) + 𝑂(𝑚𝑛) + 𝑂(2𝑚2 )
= 𝑂(𝑚2 𝑛 + 𝑚𝑛 2 + 𝑚𝑛 + 2𝑚2 )
= 𝑂(𝑚2 𝑛 + 𝑚𝑛(𝑛 + 1) + 2𝑚2 )
= 𝑂(𝑚2 𝑛 + 𝑚𝑛 2 + 2𝑚2 )
= 𝑂(𝑚2 (𝑛 + 2) + 𝑚𝑛 2 )
= 𝑂(𝑚2 𝑛 + 𝑚𝑛 2 )
You could try to simplify this differently with some different working out steps, but you will
always end up with two terms that you can’t simplify out. In this unit, we won’t see many
algorithms with complexities that are this annoying to deal with, but you will certainly run into
many algorithms that are 𝑂(𝑚𝑛).
Complete Practice Questions 8-13 from the W7 – Advanced Program Control Course Notes
Practice Questions
13
ENG1014 Engineering Numerical Analysis Course Notes
14
ENG1014 Engineering Numerical Analysis Course Notes
15
ENG1014 Engineering Numerical Analysis Course Notes
9 Substitute 𝑥, 𝑦, and 𝑧 into 𝐸𝑞. 1 and solve for 𝑤 2𝑤 + 2(2) + 0(3) + 1(4) = 10
10 − (2(2) + 0(3) + 1(4))
𝑤=
2
𝑤=1
We can also express systems of linear equations in the form of a matrix equation, 𝐴𝑥 = 𝑏 .
Finding 𝑥 will give us the solution of the linear system.
16
ENG1014 Engineering Numerical Analysis Course Notes
Here is the same process for solving the four simultaneous equations, but using matrices:
17
ENG1014 Engineering Numerical Analysis Course Notes
We mentioned in 7.1.1. What is an algorithm? that breaking down complex problems into
manageable pieces is important to ensuring that your algorithm is complete. On examination of
both processes above, you may note that it can be broken down into two smaller pieces: steps
1-5 eliminate terms, starting from the top-left element working towards the bottom-right
element, and steps 6-9 substitute known values to solve for the unknown values, starting from
the bottom-right element and working backwards towards the top-left element. Steps 1-5 are
collectively known as “forward elimination” and steps 6-9 are known as “back substitution”. If
we follow these exact processes every single time, then we will always get to a final answer, and
there is no need for any human judgement during the process. As discussed earlier, these are
two of the important requirements for an algorithm.
Forward elimination
We can break steps 1-5 down even further. Steps 1-3 eliminated the terms in the first column
below the main diagonal, step 4 eliminated the terms in the second column below the main
diagonal, and step 5 eliminated the term in the third column below the main diagonal.
To eliminate the term 𝐴𝑟𝑐 , the row 𝑐 was multiplied by a factor and then subtracted from row 𝑟.
That factor was specifically chosen by dividing the element 𝐴𝑟𝑐 by 𝐴𝑐𝑐 . As 𝐴𝑐𝑐 is used to
eliminate all terms in column 𝑐 below the main diagonal, 𝐴𝑐𝑐 is known as the “pivot”.
This process is then repeated until we have an upper triangular matrix.
18
ENG1014 Engineering Numerical Analysis Course Notes
Back substitution
Once we have an upper triangular matrix, we then need to solve for the unknown values.
Step 6 solved for the last unknown, dividing the element in the last row of 𝐵 by the element in
the last row and column of 𝐴.
In steps 7-8, to solve for the element 𝑥𝑟 , the traditional multiplication of the already-solved
variables (𝐴𝑟,𝑟+1 to 𝐴𝑟,𝑛 multiplied by 𝐵𝑟+1 to 𝐵𝑛 , where 𝑛 is the number of rows/column in 𝐴)
was subtracted from 𝐵𝑟 , and the result divided by 𝐴𝑟𝑟 .
Arguments: Returns:
• 𝐴 – 2D array containing the coefficients 𝑥 – a 1D array containing the unknowns
• 𝑏 – 1D array or 2D row/column vector
containing the solutions
Algorithm:
1. Find the shape of 𝐴, let this be known as 𝑚 × 𝑛.
2. Create the augmented matrix [𝐴 𝑏] as 𝐴𝑢𝑔 (this simplifies step 7).
3. Pre-allocate an array of 0s for 𝑥.
Forward elimination:
4. Start at the first column, let this be known as 𝑐.
5. Start at the first row below the main diagonal, let this be known as 𝑟.
6. Determine the normalisation factor (𝐴𝑟𝑐 /𝐴𝑐𝑐 ).
7. Replace row 𝑟 of 𝐴𝑢𝑔 with row 𝑟 of 𝐴𝑢𝑔 – factor × row 𝑐 of 𝐴𝑢𝑔.
8. Is 𝑟 the last row? If yes, continue. If no, move to the next row and repeat steps 6-8.
9. Is 𝑐 the second last column? If yes, continue. If no, move to the next column and
repeat steps 5-9.
Back substitution:
10. Solve the last row as 𝑏𝑛 /𝐴𝑛 .
11. Start at the second last row, let this be known as 𝑟.
12. Determine the value of 𝑥𝑟 according to the following equation:
𝑏𝑟 −𝐴𝑟𝑞 𝑥𝑞
𝑥𝑟 = , where 𝑞 = 𝑟 + 1: 𝑛
𝐴𝑟𝑟
13. Is 𝑟 the last row? If yes, continue. If no, move to the next row and repeat steps 12-13.
14. Return 𝑥. Algorithm is complete.
19
ENG1014 Engineering Numerical Analysis Course Notes
Complete Steps 1-4 from the W7 – Advanced Program Control – ENG1014 Module
Instructions
Refer to Examples 4-5 from the W7 – Advanced Program Control – Course Notes Examples
Requirement 1
For the Naïve Gaussian elimination algorithm above, it is obvious that one requirement of this
algorithm is that 𝐴 is a square matrix. Thus, 𝐴 must have 2 dimensions, and must have the same
number of rows and columns. The vector 𝑏 must have 1 dimension or have 2 dimensions but
just 1 row or column. Additionally, 𝑏 must have the same number of elements as there are
rows/columns in 𝐴. These are all simple requirements that we can check and raise an error if
they are not met.
Requirement 2
From your previous studies, you should be familiar with the three types of solutions to systems
of linear equations described in Figure 7.8: one solution, no solution, and infinite solutions.
20
ENG1014 Engineering Numerical Analysis Course Notes
Matrix representation:
2 4 𝑥 8
[ ] [𝑦] = [ ]
1 2 4
After applying forward elimination:
2 4 𝑥 8
[
] [𝑦] = [ ]
0 0 0
The second row indicates redundancy, 0 = 0, which means that there are infinite solutions.
Example 2
𝑥 + 2𝑦 = 3
𝑥 + 2𝑦 = 5
Matrix representation:
1 2 𝑥 3
[ ] [𝑦] = [ ]
1 2 5
After applying forward elimination:
1 2 𝑥 3
[][ ] = [ ]
0 0 𝑦 2
The second row indicates inconsistency, 0 ≠ 2, which means there are no solutions.
These two examples make it clear that the Naïve Gaussian elimination algorithm above does
not work when the system of linear equations either has infinite or no solutions.
In 3.6.1. Input testing, we also mentioned that if it is not possible to check whether the input
requirements are fulfilled, the limitations should be clearly noted in the function’s
documentation. Checking this requirement is more complicated than just checking the shape
of the inputs, so we will just note the limitations of this algorithm in the function documentation.
21
ENG1014 Engineering Numerical Analysis Course Notes
Requirement 3
The Naïve Gaussian elimination algorithm involves two division operators – one at step 6 and
one in step 11, where both divide a value by the pivot. As you cannot divide a value by 0, the
algorithm will fail when there is a zero in the pivot. In this case, hopefully you know that the
solution is to perform a row-swap, also known as “partial pivoting”.
Complete Steps 5-6 from the W7 – Advanced Program Control – ENG1014 Module
Instructions
Complete Steps 7-9 from the W7 – Advanced Program Control – ENG1014 Module
Instructions
Refer to Example 6 from the W7 – Advanced Program Control – Course Notes Examples
Complete Practice Questions 14-15 from the W7 – Advanced Program Control Course Notes
Practice Questions
22