# Create an empty list
my_list = []
print(my_list)
my_list = list()
print(my_list)[]
[]
Programming for Engineers
List is a build-in array-like data structure in Python. Unlike “traditional” arrays, Python lists are untyped. This makes their usage simpler but less efficient and sometimes “dangerous”. More efficient ‘standard’ array implementation can be found in the NumPy package, which we will discuss in a later lecture.
Creating empty lists can be done in two ways:
Using the square brackets [] is the preferred way in
Python. The function list() can also be used to to case any
iterable to a list.
Indexing is done using square brackets []. The index is
the position (zero-based) of the element in the list. Negative indices
count from the end of the list.
Slicing allows to take a ‘subset’ of the list. The syntax is
my_list[start:end] or my_list[start:end:step].
The result will include elements at indices starting from the
start up to but not including the end. If the
step is specified, this will be the increment (or decrement if negative)
between indices (by default, the step is 1). If the slice should start
from the beginning, the start value can be omitted.
Likewise, if the slice should end at the end, the end value
can be omitted.
[4, 5]
[4, 5]
[6, 7, 8]
[6, 7, 8]
All of these are the same:
[4, 6, 8]
[4, 6, 8]
[4, 6, 8]
Negative step:
…and any other combination you can imagine.
There are several ways of how to remove elements from a list: splicing, ‘popping’ and deleting.
Splice (combine two sub-lists, excluding the removed element):
[4, 5, 7, 8]
Using the pop method:
8
[4, 5, 6, 7]
6
[4, 5, 7]
Using the del keyword:
[4, 5, 7, 8]
There is also the remove method, which removes the first
occurrence of the specified value.
[4, 6, 7, 8]
Deleting from the end of the array is typically faster than deleting from ‘within’ the array, which is still faster than deleting from the beginning of the array.
Using the append method:
Using the insert method:
[4, 5, 6, 7, 8]
Extending an array with extend:
Or adding two lists with +:
Length of a list:
Remember the zero-based indexing:
Comparison of lists (not very useful, usually you compare elements in a loop):
list_a = [1, 2, 3]
list_b = [4, 5, 6]
# comparison
print("\nComparison:")
print(list_a == list_b) # equality
print(list_a != list_b) # inequality
print("Equality is not strict but 'ordered'")
print("[1, 2, 3.0] == [1, 2, 3]: ", [1, 2, 3.0] == [1, 2, 3]) # True
print("[1, 2, 3] == [3, 2, 1]: ", [1, 2, 3] == [3, 2, 1]) # False
# Be (ALWAYS) aware of precision/numerical issues
print(
"[1, 2, 3.000000000000000001] == [1, 2, 3]: ",
[1, 2, 3.000000000000000001] == [1, 2, 3]) # True, depends on the precision
Comparison:
False
True
Equality is not strict but 'ordered'
[1, 2, 3.0] == [1, 2, 3]: True
[1, 2, 3] == [3, 2, 1]: False
[1, 2, 3.000000000000000001] == [1, 2, 3]: True
print("3 == 3.00000001", 3 == 3.00000001) # False
print("3 == 2.99999999", 3 == 2.99999999) # False
print("1/3 == 0.33333333", 1/3 == 0.33333333) # False
print("3.00000000000000001 == 3: ", 3.00000000000000001 == 3) # True
print("2.99999999999999999 == 3: ", 2.99999999999999999 == 3) # True
print("1/3 == 0.3333333333333333333: ", 1/3 == 0.3333333333333333333) # True3 == 3.00000001 False
3 == 2.99999999 False
1/3 == 0.33333333 False
3.00000000000000001 == 3: True
2.99999999999999999 == 3: True
1/3 == 0.3333333333333333333: True
End of side-quest.
Inequality of lists:
list_a: [1, 2, 3]
list_b: [4, 5, 6]
Ordering:
list_a < list_b: True
list_a > list_b: False
list_a <= list_b: True
list_a >= list_b: False
Membership checking:
index - find the index of the first occurrence of the
specified value
count - count the number of occurrences of the specified
value
sort - sort the list
[3, 4, 5, 6, 7]
[7, 6, 5, 4, 3]
reverse - reverse the list
copy - create a copy of the list
my_list = [4, 5, 6, 7, 8]
not_a_copy = my_list # not a copy, just a reference
not_a_copy.append(9)
print(my_list) # 9 was added to the original list
my_list_copy = my_list.copy() # make a copy
my_list_copy.append(10)
print(my_list_copy) # 10 was added to the copy
print(my_list) # the original list is not affected
memfig("my_list", "not_a_copy", "my_list_copy")[4, 5, 6, 7, 8, 9]
[4, 5, 6, 7, 8, 9, 10]
[4, 5, 6, 7, 8, 9]
Buuuut, copy is not a deep copy:
[[4, 5, 6], {'a': 3, 'b': 2}]
Although, you shouldn’t be using multi-typed lists anyway.
Since we are talking about copies, let’s get back to slices - what you actually get? It depends on the contents of the list. “Basic” or primitive types are copied (they are contained directly in the list). Complex data types (lists, dicts, tuples, etc.) are not copied directly, only their references are copied.
my_list = [1, 2, 3, 4, 5]
sub_list1 = my_list[1:2] # sub-list containing only the first item
sum1 = sub_list1[0] + 5 # we add 5 to the first item and store the result in 's1'
sub_list1.append(9) # append '9' to the sub list
print(my_list, sub_list1, sum1, sep='\n')
my_second_list = [[1], [2], [3], [4], [5]]
sub_list2 = my_second_list[1] # second sub list
sub_list2.append(9) # append '9' to the sub list
sum2 = sub_list2[0] + 5
print(my_second_list, sub_list2, sum2, sep='\n')[1, 2, 3, 4, 5]
[2, 9]
7
[[1], [2, 9], [3], [4], [5]]
[2, 9]
7
It is best to avoid using heterogeneous lists (though sometimes, they are handy).
The range function creates a “range” object containing a
sequence of integers from 0 to some number, excluding the last number.
This can be converted to a list using the list()
function.
The range function allows you to specify the starting
number and the step size. The general syntax is
range(start, stop, step).
List comprehensions are a concise way to create lists. However, they are technically equivalent to a for loop.
Which is equivalent to:
The syntax for list comprehensions is:
Comprehension, in general, are ‘one-liner’ creators of collections of objects. They can in some cases make the code more readable and in some cases much less readable. Basic Python datatypes support comprehensions.
Comprehension with conditions:
<expression> will be added to the list only if
<condition> is True.
Comprehension with conditions and if-else:
<expression_one> will be added to the list if
<condition> is True. Otherwise,
<expression_two> will be added to the list
(<condition> is False).
[0, 1, -2, 3, -4, 5, -6, 7, -8, 9]
The “inner” and “outer” (selection) conditions can be combined:
The whole expression (including the inner condition; everything
before for) is only evaluated if the outer/selection
condition is True.
In the following, even numbers are squared and odd numbers are negated. However, only numbers above 3 are considered.
Dictionaries are like lookup tables. They are implemented using a hash table, which means accessing an element is very fast.
Empty dictionarys can be created using the dict()
function or with {} (preferred way).
Initialization:
Insert
Delete
1
1
None
1
-1
Dictionary comprehension:
The hash() function returns the hash value of an object
- seemingly arbitrary but for the same input consistent value (headache
warning: the consistency holds only for the current “session”!).
Array access, given an index is fast (constant time), however, finding a specific value is slow. Hashmaps solve this by encoding the value into an index.
7
class MyHashmap:
def __init__(self, total_items):
self.total_items = total_items
self.keys = [None] * total_items
self.values = [None] * total_items
def __setitem__(self, key, value): # magic to allow indexed assignment
index = hash_into_index(key, self.total_items)
self.keys[index] = key
self.values[index] = value
def __getitem__(self, key): # magic to allow indexing
index = hash_into_index(key, self.total_items)
return self.values[index]
def __contains__(self, key): # magic to allow the "in" keyword
index = hash_into_index(key, self.total_items)
return self.keys[index] == key
def __iter__(self): # magic to allow the for-each loop
for key, value in zip(self.keys, self.values):
if key is not None:
yield key, value
def print(self):
for key, value in self:
print(f"{key}: {value}")
total_items = 10
hashmap = MyHashmap(total_items)
hashmap["hello"] = "world"
print(hashmap["hello"])
print("hello" in hashmap)
print("Contents of our hashmap:")
hashmap["water"] = "world"
hashmap["hellno"] = "word"
hashmap["pi"] = 3.14
hashmap["e"] = 2.718
hashmap[9] = "number hashes are the same as the number itself"
hashmap.print()
memfig("hashmap")world
True
Contents of our hashmap:
hellno: word
e: 2.718
pi: 3.14
hello: world
9: number hashes are the same as the number itself
Obviously, this is not a very good implementation of a hashmap: 1) Collisions (multiple keys hash to the same index) 2) Memory usage (None values for empty slots)
Besides lists, Python also has tuples and sets. These can also store multiple values but offer different functionalities.
A tuple is an immutable list. Tuples are created using the
parentheses (). Similarly, the tuple()
function can be used to convert an iterable to a tuple.
Empty tuple:
Single element tuple:
20
(2, 2, 2, 2, 2, 2, 2, 2, 2, 2)
Creating tuples:
(1, 2, 3)
(4, 5, 6)
Tuple comprehension:
Oops, that’s not what we wanted! Actual tuple comprehension (we might talk about generators later):
A set is an unordered collection of unique elements. Sets are created
using the {<iterable>}. Similarly, the
set() function can be used to convert an iterable to a
set.
Empty set:
Careful, {} will not create an empty set but an empty
dictionary! (see below)
Creating sets:
{1, 2, 3}
{4, 5, 6}
{4, 5, 6, 7}
Adding elements to a set is an idempotent operation - adding the same element twice does not change the set.
my_set: {1, 2, 3}
my_set after adding 4: {1, 2, 3, 4}
my_set after adding 4 one hundred times: {1, 2, 3, 4}
Set comprehension:
Sets are not immutable but they do not support assignment. Rather, you can add or remove elements.
Main difference is that tuples are immutable. They cannot be changed once created. Some operations are faster than on lists.
Tuples are used as the return type in case of functions returning multiple values.
([0, 1, 2, 3, 4], 5)
Tuples are “safe” to pass around due to their immutability.
def my_function(iterable_input):
result = []
for i in iterable_input:
result.append(i * 2)
# sneakily change the input:
iterable_input[0] = 10
return result
my_list = [1, 2, 3] # precious data we don't want to change
print(f"My list before running my_function: {my_list}")
print(my_function(my_list))
print(f"My list after running my_function: {my_list}")
my_tuple = (1, 2, 3) # precious data we don't want to change
try:
print(my_function(my_tuple))
except TypeError: # this will catch the error raised
# when the function tries to change the tuple
print("Aha! The function tried to change my tuple!")My list before running my_function: [1, 2, 3]
[2, 4, 6]
My list after running my_function: [10, 2, 3]
Aha! The function tried to change my tuple!
Sets are unordered and do not allow duplicates. They are implemented as ‘hash set’ and thus certain operations are very efficient (e.g. membership checking, union, intersection, etc.).
{1, 2, 3, 4, 5}
True
False
{1, 2, 3, 4, 5}
Set specific operations:
{1, 2, 3, 4, 5, 6}
{3}
{2, 3}
{5}
{2, 3, 5}
Since tuple are immutable, unlike lists or sets, they can be used as keys in a dictionary.
This would throw an error:
The enumerate() function returns a tuple of the index
and the value at that index. This is equivalent to the indexed loop but
‘nicer’ (no need to explicitly extract the value and index).
The while loop is useful in some special cases (e.g.,
growing lists - although, this can be dangerous).
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 3, 5, 7, 8, 9, 3]
More typical is to use infinite loops with while True
and break statements.
The same looping “techniques” work for sets and tuples.
Value: 1
Value: 2
Value: 3
Most common way to iterate over a dictionary is to use the
items() method, which will return (key, value) pairs.
The letter a is at position 1 in the alphabet.
The letter c is at position 3 in the alphabet.
The letter e is at position 5 in the alphabet.
The letter g is at position 7 in the alphabet.
The letter i is at position 9 in the alphabet.
The letter k is at position 11 in the alphabet.
There are also keys() and values() methods
that return the keys and values respectively.
The letter a is at position 1 in the alphabet.
The letter c is at position 3 in the alphabet.
The letter e is at position 5 in the alphabet.
The letter g is at position 7 in the alphabet.
The letter i is at position 9 in the alphabet.
The letter k is at position 11 in the alphabet.
Simply “stack” lists inside of a list (nested list).
[[1, 2, 3], [4, 5, 6], [7, 8, 9]]
[[0, 1, 2], [3, 4, 5], [6, 7, 8]]
matrix[row][column]:
For each row in the matrix, loop through each element in the row.
Enumerate the rows and columns.
Value at row 0 and column 0: 1
Value at row 0 and column 1: 2
Value at row 0 and column 2: 3
Value at row 1 and column 0: 4
Value at row 1 and column 1: 5
Value at row 1 and column 2: 6
Value at row 2 and column 0: 7
Value at row 2 and column 1: 8
Value at row 2 and column 2: 9
Assignment to a matrix:
Checking types of variables is very useful, especially in Python that
allows dynamic typing. Many operations are defined for different data
types. For example, 1 * 2 is fine, and so is
[1] * 2 and "1" * 2 but they result in
different outcomes. It is often important to assert that the variables
are of the expected type (use if or
assert).
The is operator can be used to check if two objects are
the same object.
a = [1, 2, 3]
b = [1, 2, 3]
c = a
d = a[:]
e = a.copy()
# the elements have the same value but `a` is not the same object as `b`
print(a is b)
# `a` and `c` reference the same object
print(a is c)
# `d` got the values from `a` but it is still a different object
# (different memory location)
print(a is d)
# copy makes a new object with the same values
# (essentially, similar process to defining `d`)
print(a is e)
memfig("a", "b", "c", "d", "e")False
True
False
False
It is not?
True
False
True
True
Some ‘objects’ are the same but it makes no sense to compare them
using is. Although, is will get you a strict
equality.
False
True
True
<>:2: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
<>:3: SyntaxWarning: "is" with 'float' literal. Did you mean "=="?
<>:4: SyntaxWarning: "is" with 'tuple' literal. Did you mean "=="?
<>:2: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
<>:3: SyntaxWarning: "is" with 'float' literal. Did you mean "=="?
<>:4: SyntaxWarning: "is" with 'tuple' literal. Did you mean "=="?
/tmp/ipykernel_102457/4199038124.py:2: SyntaxWarning: "is" with 'int' literal. Did you mean "=="?
print(1 is 1.0) # False, because float != int
/tmp/ipykernel_102457/4199038124.py:3: SyntaxWarning: "is" with 'float' literal. Did you mean "=="?
print(1.0 is 1.0) # True
/tmp/ipykernel_102457/4199038124.py:4: SyntaxWarning: "is" with 'tuple' literal. Did you mean "=="?
print((1, 2, 3) is (1, 2, 3)) # Unexpectedly, True but also not advised;
For number type check, see below.
Use the type(<object>) build-in function to get
the type of an object.
The isinstance(<object>, <type>) build-in
function checks whether the object is an instance of the type.
This will become handy much later, when you will be working with classes. However, there is some use with basic datatypes.
Potentially unsafe code:
6
6.0
33
Code with run-time type checking and assertions:
def multiply_two_numbers(a, b):
assert isinstance(a, (int, float)), "'a' must be a number! "
f"But it was: {type(a)}"
assert isinstance(b, (int, float)), "'b' must be a number! " f"But it was: {type(b)}"
# Alternatively, this will also work:
assert issubclass(type(my_integer), (int, float)), "'a' must be a number! "
f"But it was: {type(a)}"
return a * b
print(multiply_two_numbers(3, 2))
print(multiply_two_numbers(3.0, 2))
try:
print(multiply_two_numbers("3", 2)) # this will cause an error
except AssertionError as e:
print(e)6
6.0
'a' must be a number!
More info about basic types and comparison: https://docs.python.org/3/library/stdtypes.html
assert is fine for testing and debugging, but not for
production code! There are better, safer ways of handling incorrect
inputs (try...except +
warn/log.error and handle the erroneous state
“gracefully”). Essentially, production code should never halt!!!
from typing import Union, Optional
def nice_multiply_two_numbers(
a: Union[int, float],
b: Union[int, float],
raise_error: bool = True) -> Optional[Union[int, float]]:
if not isinstance(a, (int, float)):
if raise_error:
raise TypeError(f"'a' must be a number! But it was: {type(a)}")
else:
return None # does not have to be explicit
if not isinstance(b, (int, float)):
if raise_error:
raise TypeError(f"'b' must be a number! But it was: {type(b)}")
else:
return None # does not have to be explicit
return a * b
print("Using type error & try...except:")
try:
print(nice_multiply_two_numbers(3, 2))
print(nice_multiply_two_numbers(3.0, 2))
print(nice_multiply_two_numbers("3", 2)) # this will cause an error
except TypeError as e:
print(f"One of the inputs had an incorrect type. See the error:\n{e}")
print("\nUsing None return type:")
result = nice_multiply_two_numbers("3", 2, raise_error=False)
if result is None:
print("One of the inputs had an incorrect type.")
else:
print(result)Using type error & try...except:
6
6.0
One of the inputs had an incorrect type. See the error:
'a' must be a number! But it was: <class 'str'>
Using None return type:
One of the inputs had an incorrect type.
Efficiency of algorithms matter.
Runtime of an algorithm typically depends on the size of the input. For example, time to loop once through a list will linearly increase with the size of the list. Thus, if accessing an element of a list takes 1ns, looping through a list of N elements will take N * 1ns. However, on a different computer, the element access time might be 10ns. Still, the loop will take N * 10ns. Therefore, we simply say the runtime ‘scales’ linearly with N.
Searching for a specific item in a list requires looping through the list. However, the item might be at the beginning of the list, thus the search time will be 1 (x access time). If the idem is at the end of the list, the search time will be N. On average, we can expect the search time to be N/2. We are, however, typically interested in the worst-case scenario, and for simplicity, ignore any constants. Therefore, we again simply say that the lookup time ‘scales’ linearly with N, just as with the ‘full’ loop. (There is typically some overhead and randomness, so we are not interested in precise numbers.)
Asymptotic complexity is a way to describe the runtime of an algorithm as a function of the input size. We can use this to compare the performance of different algorithms - the efficiency of their implementation. For example, we can compare different sorting algorithms.
We can use the timeit module to profile the runtime of
an algorithm. Usage:
Average run time: 0.000329630200030806
In Jupyter notebooks or IPython, we can use the %timeit
magic command instead:
This will run the function and time it.
There are also better, more advanced tools for profiling
(e.g. cProfile) but we will manage with timeit for now.



Pre-allocation of arrays is (slightly) faster than iterative appending. Although, in Python, both are relatively slow. Depending on the task, list comprehension may be more efficient. In general, if the appending overhead is insignificant, it will not have significant impact on runtime whether pre-allocation is used. However, with large loops it might cause memory issues and pre-allocation will be important with more efficient array implementations (e.g., NumPy arrays).
import numpy as np
def my_simple_function(x):
return x
def my_complex_function(x):
return np.sqrt(np.log(x + 1)**min(1e2, np.exp(np.log(x + 1e-10))**0.33))
def list_append(n, func):
a = []
for i in range(n):
a.append(func(i))
return a
def list_preallocate(n, func):
a = [0] * n
for i in range(n):
a[i] = func(i)
return a
def list_comprehension(n, func):
return [func(i) for i in range(n)]
N = 100
print("Evaluating my_simple_function")
%timeit list_append(N, my_simple_function)
%timeit list_preallocate(N, my_simple_function)
%timeit list_comprehension(N, my_simple_function)
print("Evaluating my_complex_function")
%timeit list_append(N, my_complex_function)
%timeit list_preallocate(N, my_complex_function)
%timeit list_comprehension(N, my_complex_function)Evaluating my_simple_function
5.23 μs ± 637 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
3.82 μs ± 181 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
3.76 μs ± 82 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Evaluating my_complex_function
197 μs ± 6.8 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
190 μs ± 1.93 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
190 μs ± 3.15 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Python lists are fairly universal. However, they come with some overhead and thus are not always the best structure. In some cases, it is even worth to cast lists to sets or dictionaries.
N = 1000
# Generate list of randomly shuffled squares of numbers
l = (np.random.permutation(N)**2).tolist()
# Create a dictionary from the list
d = {x: x**2 for x in l}
# Create a set from the list
s = set(l)
# We want to find the index of a square of a number in the list
number_of_interest = int(N / 2)**2
print("Lookup time for list")
%timeit l.index(number_of_interest)
print("Lookup time for dictionary")
%timeit d.get(number_of_interest)
print("Lookup time for set")
%timeit s.intersection({number_of_interest})
print("---")
# Now we want to simply see if the number is in the list
print("Membership check time for list")
%timeit number_of_interest in l
print("Membership check time for dictionary")
%timeit number_of_interest in d
print("Membership check time for set")
%timeit number_of_interest in s
print("---")
print("'Fair' lookup time for dictionary from list")
%timeit {x: i for i, x in enumerate(l)}.get(number_of_interest)
print(f"'Fair' membership check time for dictionary from list")
%timeit number_of_interest in {x: i for i, x in enumerate(l)}
print("'Fair' membership check time for set from list")
%timeit number_of_interest in set(l)Lookup time for list
7.01 μs ± 157 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Lookup time for dictionary
40.9 ns ± 1.96 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
Lookup time for set
151 ns ± 4.23 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
---
Membership check time for list
5.29 μs ± 70.2 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
Membership check time for dictionary
36 ns ± 0.773 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
Membership check time for set
35.7 ns ± 0.869 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
---
'Fair' lookup time for dictionary from list
69.6 μs ± 1.35 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
'Fair' membership check time for dictionary from list
69.2 μs ± 2.41 μs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
'Fair' membership check time for set from list
30.6 μs ± 504 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)