Monday, September 21st, 2026¶

Last week, we worked on documenting our code, learned how to use modules, and discussed the first project.

Writing your reports¶

As we have discussed, the reports should be written for a hypothetical reader that is not part of our class. We should make sure to:

  • Define and explain any necessary terms (e.g. primes, Fermat's little theorem, prime-like numbers, false primes, etc.).
  • Include a Markdown cell before each code cell that explains
    • What the code does,
    • How the code works,
    • How it fits into the report (e.g. why are we writing this code?).
  • Present your exploration in a self-motivated narrative way.
    • This includes an Introduction section and a Conclusion section.
      • The introduction should get the reader up to speed on the necessary terms and ideas to describe the main topic of exploration, and let the reader know what we will be exploration.
      • Observations and conclusions should be made throughout the report as we carry out the exploration and generate data.
      • The Conclusion section generally should not include any new information, but should summarize the high-points of the report.
    • The report should not refer to "Exercise 1" or "Part 3" or similar.
      • Do not write the report as though you're just ticking the boxes of what's been asked (however true that may or may not be).
      • Instead, the various tasks discussed in the project page should come up naturally through the narrative that describes our exploration.
  • Make sure to restart the kernel and run all of your cells top-to-bottom before submitting your report to ensure that everything runs as expected.

If you have not already done so, please take some time to read through the Project Report Guide on the course webpage. There is also an Example Report that shows the intended style of these reports.

Project 1: A prime or not a prime¶

Reminder, Project 1: A prime or not a prime is due this Friday at 11:59PM. Your report should be submitted through UBlearns.

We have discussed optimizing the is_prime function in-class, and the project page discusses using the pow function to speed up the is_prime_like function. There is one other subtle optimization that is necessary to find $20$ false primes in a reasonable amount of time.

Let's consider the following experiment. We'll define some functions that just return True or False. We'll create another set that wait $5$ seconds before returning True or False. We will use the sleep function from the time module, which takes in a float x and will cause Python to pause for that x seconds. To help us keep track of what has been run, these functions will print out some statements indicating what they are doing.

In [1]:
import time
In [2]:
def return_True():
    print('I am returning True.')
    return True
In [3]:
def return_False():
    print('I am returning False.')
    return False
In [4]:
def wait_and_return_True():
    print('I am waiting...',end=' ')
    time.sleep(5)
    print('Now I am returning True.')
    return True
In [5]:
def wait_and_return_False():
    print('I am waiting...', end=' ')
    time.sleep(5)
    print('Now I am returning False.')
    return False
In [6]:
wait_and_return_True()
I am waiting... Now I am returning True.
Out[6]:
True

Let's see how Python evaluates Boolean expressions involving these functions. We'll start with an or statement involving both the wait_and_return_False and return_True functions.

In [7]:
t0 = time.time()
if wait_and_return_False() or return_True():
    print('Hello!')
t1 = time.time()
print(t1-t0)
I am waiting... Now I am returning False.
I am returning True.
Hello!
5.000872611999512

In the above experiment, we see that Python first evaluates the wait_and_return_False function, and after that has finished moves on to evaluate the return_True function. The return_True function must be evaluated in order to determine whether the complete or statement will be True or False. What happens if we evaluate this or in the reverse order?

In [8]:
t0 = time.time()
if return_True() or wait_and_return_False():
    print('Hello!')
t1 = time.time()
print(t1-t0)
I am returning True.
Hello!
0.00048089027404785156

We see from the above that Python first evaluates the return_True function, but then never bothers to evaluate the wait_and_return_False function at all! This is because the complete or expression is guaranteed to be True as soon as the first part is True.

Now let's consider a similar example with an and statement.

In [9]:
t0 = time.time()
if wait_and_return_True() and return_False():
    print('Hello!')
t1 = time.time()
print(t1-t0)
I am waiting... Now I am returning True.
I am returning False.
5.000978708267212

In the above experiment, we see that Python first evaluates the wait_and_return_True function, and after that has finished moves on to evaluate the return_False function. Because the first part of the 'and' was True, the return_False function must be evaluated in order to determine whether the complete and statement is True or False. What happens if we evalute this and in the reverse order?

In [10]:
t0 = time.time()
if return_False() and wait_and_return_True():
    print('Done')
t1 = time.time()
print(t1-t0)
I am returning False.
0.00022172927856445312

We see from the above that Python first evaluates the return_False function, but then never bothers to evaluate the wait_and_return_True function at all! This is because the complete and expression is guaranteed to be False as soon as the first part is False.

How does this connect to Project 1? When identifying false primes, we are looking for numbers that are prime-like and are not prime. We can use the is_prime and is_prime_like functions to form an and statement for identifying false primes. From the above discussion, the order in which this and statement is formed can make a big difference in execution time.

The zip function¶

It often happens that we want to simultaneously iterate through two or more lists. For example, suppose we have two lists first_names and last_names that separately hold the first and last names of some individuals (in corresponding order).

The zip function allows us to "zip" multiple lists together so that we can iterate through each simultaneously. The syntax works as follows:

for <item 1>, <item 2>, ... in zip(<list 1>, <list 2>, ...): (do something...)

With this setup, the for loop will simultaneously grab all of the first elements from each list and name them <item 1>, <item 2>, ..., then perform the operations inside the loop, then grab the second elements from each list, etc.

Consider the following examples that illustrate this point. We compare iterating through both lists using nested for loops versus using the zip function to simultaneously iterate through both lists.

In [11]:
first_names = ['Seamus', 'Jordan', 'James']
last_names = ['Coleman', 'Pickford', 'Garner']

for first_name in first_names:
    for last_name in last_names:
        print(first_name, last_name)
Seamus Coleman
Seamus Pickford
Seamus Garner
Jordan Coleman
Jordan Pickford
Jordan Garner
James Coleman
James Pickford
James Garner

With nested for loops, we get every possible choice of first_name paired with every possible choice of last_name. Instead, we can zip the two lists together, then use a single for loop to iterate through the two lists simultaneously.

In [12]:
for first_name, last_name in zip(first_names, last_names):
    print(first_name, last_name)
Seamus Coleman
Jordan Pickford
James Garner

With the zip function, we pair off each first_name with the corresponding last_name, then iterate through each pair.

For another example, suppose we have a list false_primes of false primes and a list prime_decomps that contains the prime decompositions for each of the false primes. We may then with to iterate through each false prime and its corresponding prime decomposition in order to display this data to the reader.

In [13]:
# The data below is nonsense, just an example

false_primes = [5, 12, 39]
prime_decomps = [[5], [2,2,3], [3,13]]
In [14]:
for false_prime, prime_decomp in zip(false_primes, prime_decomps):
    print(false_prime, prime_decomp)
5 [5]
12 [2, 2, 3]
39 [3, 13]
In [15]:
prime_decomps_strs = []
for prime_decomp in prime_decomps:
    prime_decomp_strs = []
    for prime in prime_decomp:
        prime_decomp_strs.append(str(prime))
    prime_decomps_strs.append(prime_decomp_strs)
In [17]:
prime_decomps_strs
Out[17]:
[['5'], ['2', '2', '3'], ['3', '13']]

List slicing¶

So far, when working with lists we've discussed how to access specific elements by index using square brackets. That is my_list[i] will get the element in the ith index of my_list.

In [18]:
my_list = ['a','b','c','d','e','f','g','h']

print(my_list[0])
print(my_list[1])
print(my_list[-1])
a
b
h

Sometimes, we might want to get several elements from a list. For example, suppose we want a list containing every other element from my_list.

In [19]:
even_elements = []
for i in range(len(my_list)):
    if i % 2 == 0: # If the index is even
        even_elements.append(my_list[i])
In [20]:
print(my_list)
print(even_elements)
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['a', 'c', 'e', 'g']
In [ ]:
 

A more elegant approach is to use list slicing. There are several ways that we can take slices of a list.

  • my_list[start_index:] will start the slice at start_index and go the end of the list.
  • my_list[:stop_index] will start the slice at the beginning of the list (i.e. index 0) and proceed until one less than stop_index.
  • my_list[start_index:stop_index] will start the slice at start_index and proceed until one less than stop_index.

We can also use negative indices as stop_index to count backward from the end of the list. For example, stop_index=-1 will skip the last element of the list.

In [21]:
print(my_list)
print(my_list[4:])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['e', 'f', 'g', 'h']
In [22]:
print(my_list)
print(my_list[:4])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['a', 'b', 'c', 'd']
In [23]:
print(my_list)
print(my_list[2:6])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['c', 'd', 'e', 'f']

If we compare the use of list slicing with the use of the range function, we can see that they share a lot in common. Instead of using commas to separate the start and stop indices, we use colons. Just like the range function, we can optionally include a step size to our slice. For example:

  • my_list[::skip] will start at the beginning of the list and go to the end, but will go in steps of skip.
  • my_list[start_index:stop_index:skip] will start at start_index, proceed in steps of skip, and will stop when the index is greater than or equal to stop_index.
In [24]:
print(my_list)
print(my_list[::2])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['a', 'c', 'e', 'g']
In [26]:
print(my_list)
print(my_list[::3])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['a', 'd', 'g']
In [28]:
print(my_list)
print(my_list[1::3])
print(my_list[1:-1:3])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['b', 'e', 'h']
['b', 'e']

Exercise: Generate a list cubes that stores the cubes of the first $40$ positive integers. Then print out the third through tenth of these cubes (i.e. index-2 through index-9).

In [ ]:
 
In [ ]:
 

Exercise: Print out the last ten cubes from the cubes list.

In [ ]:
 
In [ ]:
 

Exercise: Print out every third cube from the cubes list, starting with the second cube (i.e. the index-1 entry).

In [ ]:
 
In [ ]:
 

List comprehension¶

So far, we've primarily constructed lists of data by using for loops and appending to an empty list. Another option is to use list comprehension. The syntax is as follows:

[<some expression> for <some item> in <some iterable>]

This will produce a list containing the results of <some expression after iterating through every <some item> in <some iterable>.

For example, suppose we want to construct a list containing the squares of the first $10$ positive integers. With our old approach, this would look something like the following.

In [29]:
squares = []
for i in range(1,11):
    squares.append(i**2)
In [30]:
print(squares)
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

Instead, we can use list comprehension.

In [31]:
squares = [i**2 for i in range(1,11)]
In [32]:
print(squares)
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

We can optionally include a condition when using list comprehension. The syntax is as follows.

[<some expression> for <some item> in <some iterable> if <some condition>]

In this case, the list will only include the result of <some expression> if <some condition> is True.

For example, suppose we want to construct a list containing the squares of the first $10$ positive integers that have remainder 1 after division by 4. With our old approach...

In [33]:
squares_with_rem = []
for square in squares:
    if square % 4 == 1:
        squares_with_rem.append(square)
In [34]:
print(squares_with_rem)
[1, 9, 25, 49, 81]

Using list comprehension...

In [35]:
squares_with_rem = [square for square in squares if (square % 4 == 1)]
In [36]:
print(squares_with_rem)
[1, 9, 25, 49, 81]
In [41]:
# The data below is nonsense, just an example

false_primes = [5, 12, 39]
prime_decomps = [[5], [2,2,3], [3,13]]
In [43]:
for prime_decomp in prime_decomps:
    prime_decomp_str = [str(prime) for prime in prime_decomp]
    #print(prime_decomp_str)
    print(' * '.join(prime_decomp_str))
5
2 * 2 * 3
3 * 13

Exercise: Use list comprehension to generate a list of all prime numbers less than $100$. You should make use of your is_prime function where needed.

In [38]:
def is_prime(n):
    n_is_prime = True
    for d in range(2,n):
        if n % d == 0:
            n_is_prime = False
            break
    return n_is_prime
In [39]:
primes = [n for n in range(2,101) if is_prime(n)]
In [40]:
print(primes)
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
In [ ]: