Wednesday, February 18th, 2026¶

On Monday, we discussed documenting code using both Markdown and code comments.

Documenting code in your report¶

When writing your report, every code cell must be accompanied by a preceding Markdown cell above which explains:

  1. What the code cell does,
  2. How the code works,
  3. How it fits into the narrative of the report (i.e. why are we doing this?).

The code cell must then include code comments that tie into the Markdown explanation of how the code works for further clarification.

Exercise: Write a function is_prime_like that takes in an integer n and returns True if n is prime-like and False otherwise. Include a preceding Markdown cell that explains what the function does and how it works, and include code comments that connect to the Markdown explanation for clarification.

In [ ]:
 

Exercise: Write a function get_prime_decomp that takes in an integer n and returns a list containing the prime decomposition of n. Include a preceding Markdown cell that explains what the function does and how it works, and include code comments that connect to the Markdown explanation for clarification.

In [ ]:
 

Modules in Python¶

When starting a Jupyter notebook, Python loads in a base set of commands that are made available (e.g. print, list, int, etc.). However, there are many other commands that are not loaded by default that we sometimes want to make use of. We can import various modules that contain all kinds of additional functionality.

For example, suppose we want to consider the efficiency of our get_primes function. The time module contains tools relating to time, which we can use to time the get_primes function.

Importing a module¶

We can use the syntax import <some module> to make that module available to us. After doing so, we can use the syntax <some module>.<some function or object> to call upon variables functions or objects contained in that module.

In [4]:
import time

The time module contains many functions. We can use the help function to look at the documentation and see some of the available functions.

In [6]:
#help(time)

For example, the asctime function from the time module gives a string specifying the current time.

In [7]:
time.asctime()
Out[7]:
'Wed Feb 18 15:38:39 2026'

Another is the time function from the time module. This function returns the number of seconds (as a float) since the Epoch. We can use this function to time how long it takes for some code to run.

In [8]:
time.time()
Out[8]:
1771447158.406582
In [9]:
time.time()
Out[9]:
1771447201.757252
In [10]:
t0 = time.time()
In [11]:
t1 = time.time()
In [12]:
print(t1-t0, 'seconds have elapsed.')
14.405227184295654 seconds have elapsed.

Importing from a module¶

Sometimes we just want to make use of a single function or object from within a module. We can use the syntax from <some module> import <some function or object> to gain direct access to the function or object. After doing so, we can simply use <some function or object> directly rather than looking back inside the module (that is, we do not need to include <some module>. before calling upon the function/object).

For example, if we try to directly call on the asctime function, we will get an error.

In [13]:
time.asctime()
Out[13]:
'Wed Feb 18 15:42:54 2026'
In [14]:
asctime()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[14], line 1
----> 1 asctime()

NameError: name 'asctime' is not defined

If we import the asctime function from the time module, then we can directly call upon asctime.

In [15]:
from time import asctime
In [17]:
asctime()
Out[17]:
'Wed Feb 18 15:43:50 2026'

Exercise: Time how long it takes the is_prime function to test whether $n = 100,000,007$ is prime.

In [18]:
def is_prime(n):
    for d in range(2,n):
        if n % d == 0:
            return False
    return True
In [19]:
n = 100_000_007

t0 = time.time()
print(is_prime(n))
t1 = time.time()

print(t1 - t0)
True
18.63295340538025
In [21]:
is_prime(131)
Out[21]:
True

Optimizing the is_prime function¶

For the first project, we will need to call upon the is_prime function many times. With that in mind, it will be good to try to make the is_prime function more efficient so that our code can run in a reasonable amount of time.

In [30]:
def old_is_prime(n):
    for d in range(2,n):
        if n % d == 0:
            return False
    return True

Exercise: Rewrite the is_prime function to take advantage of the $\sqrt{n}$ optimization discussed in class. We can use the sqrt function from the math module to compute square roots.

In [27]:
from math import sqrt

def is_prime(n):
    for d in range(2,int(sqrt(n))+1):
        if n % d == 0:
            return False
    return True
In [28]:
is_prime(131)
Out[28]:
True

Exercise: Compare the runtime for the old is_prime function (without optimization) to the newly optimized is_prime function when checking whether $n = 100,000,007$ is prime.

In [31]:
n = 100_000_007

t0 = time.time()
print(old_is_prime(n))
t1 = time.time()

print(t1 - t0)
True
20.87965989112854
In [32]:
t0 = time.time()
print(is_prime(n))
t1 = time.time()

print(t1 - t0)
True
0.003634214401245117

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 [33]:
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 use a single for loop to iterate through the two lists zipped together.

In [34]:
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 [37]:
false_primes = [561, 701, 1001, 2003]
prime_decomps = [[5, 7, 11], [13, 17], [2, 5, 131]]
In [38]:
for false_prime, prime_decomp in zip(false_primes, prime_decomps):
    print(false_prime, prime_decomp)
561 [5, 7, 11]
701 [13, 17]
1001 [2, 5, 131]

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 [39]:
my_list = ['a','b','c','d','e','f','g','h']

print(my_list[0])
print(my_list[3])
print(my_list[-1])
a
d
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 [41]:
every_other = []

for i in range(len(my_list)):
    if i % 2 == 0:
        every_other.append(my_list[i])

print(every_other)
['a', 'c', 'e', 'g']
In [42]:
every_other = []

for i in range(0,len(my_list),2):
    every_other.append(my_list[i])

print(every_other)
['a', 'c', 'e', 'g']

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 list at the beginning (i.e. 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 [47]:
print(my_list)
print(my_list[:4])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['a', 'b', 'c', 'd']
In [46]:
print(my_list)
print(my_list[3:])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
['d', 'e', 'f', 'g', 'h']

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 [48]:
my_list[::2]
Out[48]:
['a', 'c', 'e', 'g']

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 [ ]:
 

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

In [ ]:
 

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

In [ ]: