ESC190 Lecture Notes: Part 2
Data Structures and Basic Algorithms

See all lecture notes here.

L11: Sorting

qsort

C has a built-in function called qsort that can be used to sort an array of elements. The function takes in 4 arguments:
  1. The array to be sorted
  2. The number of elements in the array
  3. The size of each element in the array
  4. A function that compares two elements in the array. It should return
    • A negative number if the first element is smaller than the second element
    • 0 if the two elements are equal
    • A positive number if the first element is larger than the second element
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
    
int compare_ints(const void *p_a, const void *p_b)
{
  int *p_a_i = (int *)p_a;
  int *p_b_i = (int *)p_b;
  return *p_a_i - *p_b_i;
}
    
int main()
{
  int arr[] = {6, 5, 10, 2};
  qsort(arr, 4, sizeof(int), compare_ints);
}
The reason the compare function takes in void pointers is because qsort is a generic function that can be used to sort any type of data. The function then casts the void pointers to the correct type and compares the two elements. This allows us to be more creative. Suppose we have our own structure to represent students, and we wish to sort them by their age. If two students have the same age then we wish to sort them by their name. We can do this by writing a compare function that compares the ages first and then compares the names if the ages are equal. We have,
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct student1{
  char name[20];
  int age;
} student1;

int compare_student1s(const void *p_a, const void *p_b)
{
  // Want to sort student1's by name
  // If students have the same age, sort by name

  student1 *p_a_s = (student1 *)p_a;
  student1 *p_b_s = (student1 *)p_b;
  int age_diff = p_a_s->age - p_b_s->age;
  if(age_diff != 0)
  {
    return age_diff;
  }else{
    return strcmp(p_a_s->name, p_b_s->name);
  }
}

int main()
{
  student1 s1_arr[] = {{"John", 20}, {"Jill", 21}, {"Jack", 21}};

  qsort(s1_arr, 3, sizeof(student1), compare_student1s);

  for (int i = 0; i < 3; i++)
    printf("%s %d\n", s1_arr[i].name, s1_arr[i].age);
}
Note that:
  • We can use curly braces to initialize a struct
  • The strcmp function compares two strings and returns a negative number if the first string is smaller than the second string, 0 if the two strings are equal, and a positive number if the first string is larger than the second string

Reading Files

Quick tip: When copying a file, download it instead of copying and pasting. When copying, you may accidentally ruin the formatting of the file.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main()
{
  FILE *fp = fopen("cities.txt", "r");
  char line[200];
  fgets(line, 200, fp);
  line[strlen(line) - 1] = '\0';
  int num_items = atoi(line);

  return 0;
}
The code works via the following:
  1. Within main, a file pointer fp is created and is assigned the result of opening the file cities.txt in read mode. If the file doesn't exist, fopen returns NULL.
  2. A character array line with a maximum size of 200 characters is declared.
  3. The function fgets is called, which reads a line of text from the file pointed to by fp and stores it in the line array. The maximum number of characters to be read is specified as 200, the second argument to fgets.
  4. The last character of line, which is the newline character, is replaced with a null terminator ('\0') to end the string.
  5. The function atoi is called with line as its argument, which converts the string representation of a number to its integer value. This is 93827 for the project, the total number of cities.
Alternatively, we can use fgets to read the entire file line by line. This is useful if we want to read the file line by line and do something with each line.

L12: Bubble Sort

Bubble sort was gone over very quickly last lecture, so this lecture will go over it in more detail.

Bubble Sort

Bubble sort is a simple sorting technique that repeatedly steps through the list to be sorted, compares each pair of adjacent items and swaps them if they are in the wrong order. The pass through the list is repeated until no swaps are needed, which indicates that the list is sorted. The algorithm gets its name from the way smaller elements "bubble" to the top of the list. The pseudocode is given by (from Wikipedia),

function bubbleSort(L)
    n = length(L)
    repeat
        swapped = false
        for i=1 to n-1 inclusive do
            /* if this pair is out of order */
            if A[i-1] > A[i] then
                /* swap them and remember something changed */
                swap(A[i-1], A[i])
                swapped = true
            end if
        end for
    until not swapped
end function

Exercise

Consider the below sequence. Manually apply bubble sort:
5, 2, 10, 56, 15
    

Each line represents a different step:
5, 2, 10, 56, 8
2, 5, 10, 56, 8 // 2 and 5 swapped
2, 5, 10, 56, 8 // 5 and 10 in order
2, 5, 10, 56, 8 // 10 and 56 in order
2, 5, 10, 8, 56 // 56 and 8 swapped
// Go back to beginning
2, 5, 10, 8, 56 // 2 and 8 in order
2, 5, 10, 8, 56 // 5 and 10 in order
2, 5, 8, 10, 56 // 8 and 10 swapped
2, 5, 8, 10, 56 // 10 and 56 in order
// Go back to beginning
2, 5, 8, 10, 56 // 2 and 5 in order
2, 5, 8, 10, 56 // 5 and 8 in order
2, 5, 8, 10, 56 // 8 and 10 in order
2, 5, 8, 10, 56 // 10 and 56 in order
In C, it can be implemented as,

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void bubble_sort(void *arr, int num_items, int item_size,
                 int (*compare)(const void *, const void *))
{
  int i, j;
  void *temp = malloc(item_size);

  for (i = 0; i < num_items - 1; i++){
    for (j = 0; j < num_items - i - 1; j++){
      // compare arr[j] and arr[j + 1], swap if necessary
      // arr[j] only makes sense if array is of type int*, float*, etc.
      void *p_j = arr + j * item_size;
      void *p_j1 = arr + (j + 1) * item_size;

      // Now can compute compare(p_j, p_j1)
      if (compare(p_j, p_j1) > 0){
        // kind of want to swap *p_j and *p_j1
        memcpy(temp, p_j, item_size); // temp = *p_j
        memcpy(p_j, p_j1, item_size);
        memcpy(p_j1, temp, item_size);
      }
    }
  }
  free(temp);
}

int compare_int(const void *p_a, const void *p_b)
{
  const int *x = (const int *)p_a;
  const int *y = (const int *)p_b;
  return *x - *y;
}

int main()
{
  int arr[] = {64, 34, 25, 12, 22, 11, 90};
  int num_items = sizeof(arr) / sizeof(arr[0]);
  bubble_sort(arr, num_items, sizeof(int), compare_int);

  for (int i = 0; i < num_items; i++)
    printf("%d ", arr[i]);

  return 0;
}
Here are some comments about the signature of the bubble_sort function:
  • void *arr: This is a pointer to the array to be sorted. The data type of the array is not specified, so the function can be used with arrays of any data type.
  • int (*compare)(const void *, const void *) is a pointer to a comparison function. The comparison function takes two pointers to void as arguments and returns an integer less than, equal to, or greater than zero, depending on the result of the comparison. This allows the function to be used with arrays of any data type and to sort the array in any order.
Note that when we swap two elements in the array, we need to use the memcpy function to copy the data from one element to another. This is because we don't know the data type of the elements in the array.

The memcpy function takes three arguments: the destination, the source, and the number of bytes to copy. The destination and source are both pointers to void, so we can use the swap function with arrays of any data type.

Exercise

Consider the above code. It is actually inefficient and not the most ideal implementation of bubble sort. Why? (Hint: Compare this to the pseudocode)

There is no stop condition. If the array is somehow fully sorted halfway into the program, it will still run to the very end (even if you pass in an already sorted array!)

Optimizing Bubble Sort

We can modify the program such that if a full pass through the array is made without any swaps, then the array is already sorted and we can stop the program. This can be done by modifying the bubble_sort function as follows:
void bubble_sort(void *arr, int num_items, int item_size,
                 int (*compare)(const void *, const void *))
{
  int i, j;
  void *temp = malloc(item_size);

  for (i = 0; i < num_items - 1; i++)
  {
    int swapped = 0;
    for (j = 0; j < num_items - i - 1; j++)
    {
      // compare arr[j] and arr[j + 1], swap if necessary
      // arr[j] only makes sense if array is of type int*, float*, etc.
      void *p_j = arr + j * item_size;
      void *p_j1 = arr + (j + 1) * item_size;

      // Now can compute compare(p_j, p_j1)
      if (compare(p_j, p_j1) > 0)
      {
        // kind of want to swap *p_j and *p_j1
        memcpy(temp, p_j, item_size); // temp = *p_j
        memcpy(p_j, p_j1, item_size);
        memcpy(p_j1, temp, item_size);
        swapped = 1;
      }
    }
    if (!swapped){
      break; // return could cause a memory leak
             // could go free(temp) and return
    }
  }
  free(temp);
}

L13: Patch

Refer to the pre-lecture code patch.c and patch.py

Using GDB

First, compile patch.c and you should get an output file. See Lecture 10 for more information on compiling C code. After it is compiled, we can run
gdb patch
Then typing
layout asm
we are able to get the machine instructions that comprise the code. You don't need to worry about this right now, you'll learn about it in 2nd year ECE253 :) The only thing we need to understand is that the compiler is able to translate the C code into machine code, which gives very simple instructions.

Changing a Function

The goal of this exercise is to change the function g such that it behaves like the function f. They are defined as follows:
int f(int a, int b, int c)
{
    return a + b;
}

int g(int a, int b, int c)
{
    return a + c;
}
When looking at the machine instructions, I obtain the following snippet:

│   0x1189 <f>              endbr64
│   0x118d <f+4>            push   %rbp
│   0x118e <f+5>            mov    %rsp,%rbp
│   0x1191 <f+8>            mov    %edi,-0x4(%rbp)
│   0x1194 <f+11>           mov    %esi,-0x8(%rbp)
│   0x1197 <f+14>           mov    %edx,-0xc(%rbp)
│   0x119a <f+17>           mov    -0x4(%rbp),%edx
│   0x119d <f+20>           mov    -0x8(%rbp),%eax
│   0x11a0 <f+23>           add    %edx,%eax
│   0x11a2 <f+25>           pop    %rbp
│   0x11a3 <f+26>           retq
│   0x11a4 <g>              endbr64
│   0x11a8 <g+4>            push   %rbp
│   0x11a9 <g+5>            mov    %rsp,%rbp
│   0x11ac <g+8>            mov    %edi,-0x4(%rbp)
│   0x11af <g+11>           mov    %esi,-0x8(%rbp)
│   0x11b2 <g+14>           mov    %edx,-0xc(%rbp)
│   0x11b5 <g+17>           mov    -0x4(%rbp),%edx
│   0x11b8 <g+20>           mov    -0xc(%rbp),%eax
│   0x11bb <g+23>           add    %edx,%eax
│   0x11bd <g+25>           pop    %rbp
│   0x11be <g+26>           retq 
Some brief comments about this structure:
  • Each line is an instruction. Each byte of instruction is stored in a different location in memory. The first set of characters represent the memory in hexadecimal.
  • The mov command moves data from one location to another. The first argument is the destination, the second argument is the source.
  • In GDB, we can display the value at an address with the command x 0x119d, which in this particular example gives 0x119d <f+20>: 0x01f8458b. The hexadecimal number 0x01f8458b encodes the instructions for that line. We can display it as a decimal if we do x/d 0x119d which outputs 0x119d <f+20>: 33047947
If we compare the machine code for both functions, they differ by only one line! Compare <f+20> with <g+20>. If we wish to modify g such that it acts like f, we just need to change what this one line does in memory. This is done using the following code. Consider,
void patch(int (*h)(int, int, int))
{
    *(int *)((void*)h + 20 )  = 33047947; //0x01f8458b;
}
Here is what this function does:
  1. It takes in the pointer to a function. Recall that if we want to modify int a = 5; inside a function, we need to pass in the address. The same goes here if we want to modify a function.
  2. We cast the pointer to a void pointer (i.e. not associated with any data type) using (void*)h.
  3. Note that (void*)h is the address of the start of the function. To get to the part of the function or instructions we actually want to change, we need to increment the address by 20 bytes (which we said earlier was the only place that it differed from f).
  4. (void*)h + 20 is a void pointer that points to the line of instruction we want to change. But we've seen that instructions are encoded by a number, so we need to cast this to an int pointer via (int *)((void*)h + 20).
  5. We've seen that the instruction we want to change this to can be represented as an integer as 33047947, so we write this into memory at this address.
    • Clarification: Note that an integer is 4 bytes but the instruction at 0x11b8 <g+20> is 3 bytes, since the next instruction is at 0x11b8 <g+20>. The reason this still works is because the instruction at 0x11bb <g+23> and 0x11a0 <f+23> is the same, so the 4th byte will be overwritten with a byte that is equal to it.
Note that this particular example is not very useful, as it's not a good practice and very difficult to debug, hopefully for obvious reasons. Sometimes it's done for optimizations, but we don't need to worry about that.

Python Memory Model Weirdness

Consider the following code,
import ctypes

def change_float(x):
    # Go to the memory address of x and change the value there
    pointer = ctypes.c_void_p(id(x))
    float_pointer = ctypes.cast(pointer, ctypes.POINTER(ctypes.c_double))
    float_pointer[2] = 12.0



if __name__ == '__main__':
    x = 11.0
    change_float(x)
    print(x) # 12.0
    print(x == 11.0) # True
    print(11.0 == 12.0) # True
    print(10+1.0 < 10+2.0) # False
Surprisingly, this prints
12.0
True
True
As a quick reminder, in Python, variables are essentially just names that are bound to objects. When you pass a variable to a function in Python, what is actually passed is a reference to the object, rather than a copy of the object itself. The code works via,
  1. Inside the function, the id function is used to get the memory address of the object, and uses the ctypes module to cast that memory address to a pointer to a ctypes.c_double type. There is some nomenclature confusions here,
    • In C, a double is a double-precision floating point number (usually 64 bits) while a float is a single-precision floating point number (usually 32 bits)
    • In Python, a float is the same as a double in C.
  2. The line float_pointer[2] = 12.0 is setting the value stored at the memory location pointed to by float_pointer to be 12.0.
  3. This line of code modifies the memory of the float object that was passed to the change_float function, so that the address at which the values x and 12.0 are stored, is the same, so x == 12.0 and their values are equal. However, the object x and 11.0 are identical (i.e. they have the same id so Python doesn't both checking their actual values), so we also have x == 11.0 and 11.0 == 12.0 (since 11.0 is the same object as x).

    Note that the memory representation of an object is the way that the value of the object is stored in computer memory, while the id is a unique identifer that is assigned to each object. Two objects could have different ids but the same memory representation. The reason this is allowed is because the ctypes module is allowing the user to bypass Python's memory management and directly access memory, which is not typically recommended or necessary.
  4. The reason the last three lines work is that the 11.0 literal is mapped to refer to a single id address since it appears in the code several times. So print(10+1.0 < 10+2.0) will still print False.
It's important to recognize that this is not recommended for general use, as it relies on implementation-specific details and can lead to unexpected behavior and make the code difficult to understand and maintain. It's best to stick to the standard Python operations and data structures, which provide a more predictable and stable behavior.

What do I need to know and don't know?
  • Know: Python and C memory model
  • Don't Know: Machine Code and how to use ctypes in Python

L14: Python Memory Model and Valgrind

Python Memory Model

Let us review the previous section on the Python memory model and refer to the previous code. When we assign values to variables, we create a variable table and a memory table. So x = 11.0 will give us
-----------
Variable Table
x | @1032
-----------
Memory Table
%1032 | 11.0
-----------
and after change_float(x) is ran, we get the following tables:
-----------
Variable Table
x | @1032
-----------
Memory Table
%1032 | 12.0
-----------
Here, the address of x has not changed, but the value at the address that x refers to has changed to 12. When variables are passed to functions in Python, we always pass through the address of the variable, so this is analogous to C, where we pass in a pointer and change the value at that address. Usually, this doesn't matter that much because most objects are immutable, which means that they cannot be changed once they are created and re-assigning the variable to refer to a new address in memory. But with ctypes, we can make Python variables behave more like how they would in C, but there really isn't a lot of applications besides for pedagogical purposes.

Another important feature of Python is that when the code runs, it first scans the entire code for literals. Anytime it sees a literal and the literal is repeated, it maps it to the same memory location. This is done for optimization purposes, as literals are immutable. This is why using ctypes can lead to some counterintuitive behaviour.

This is why when we run print(11.0) it prints out 12.0, as it is getting the ID of the literal 11.0 going to the corresponding address, but we overwrote the value at that address as 12.0, which is what it will print out. So even something like 10.0 + 2.0 == 11.0 will still return TRUE since the address that the literal 11.0 refers to stores the value 12.0.

However,
x = 5
y = 7
x1 = 6
print(x + y == x1 + y)
will give us False because these are no longer connected to the literal 11.0 which is what started this whole mess.

Valgrind

We can use valgrind to debug problems with memory issues. There are four ways to run valgrind:
  • Linux: Can be installed
  • Windows: Install Windows Subsystem for Linux (WSL): See here.
  • Any computer: Connect to the ECF Linux machines
  • Gradescope: There is a "test" assignment on gradescope which will run Valgrind for you.
Consider the following two programs,
#include <stdio.h>
#include <stdlib.h>

int main(){
    char a[6] = "EngSci";
    printf("%c", a[1000000]);
}
#include <stdio.h>
#include <stdlib.h>
  
int main(){
    char *p = malloc(1000000);
    return 0;
}
There are two issues with it:
  • Undefined behaviour: The program tries to access a memory location that is out of bounds. The array a has a size of 6, and accessing an index that is out of bounds leads to undefined behavior, and in this case, it results in a segmentation fault.
  • Memory leak: The program allocates memory using the malloc function but does not free it, leading to a memory leak.
We can run valgrind by first compiling the first program, then running:
valgrind ./file_name
On my system, I get the following message:
==3274== Memcheck, a memory error detector
==3274== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==3274== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==3274== Command: ./test_valgrind
==3274== 
==3274== Invalid read of size 1
==3274==    at 0x109191: main (in /home/xueqilin/esc190_notes/test_valgrind)
==3274==  Address 0x1fff0f3862 is not stack'd, malloc'd or (recently) free'd
==3274== 
==3274== 
==3274== Process terminating with default action of signal 11 (SIGSEGV)
==3274==  Access not within mapped region at address 0x1FFF0F3862
==3274==    at 0x109191: main (in /home/xueqilin/esc190_notes/test_valgrind)
==3274==  If you believe this happened as a result of a stack
==3274==  overflow in your program's main thread (unlikely but
==3274==  possible), you can try to increase the size of the
==3274==  main thread stack using the --main-stacksize= flag.
==3274==  The main thread stack size used in this run was 8388608.
==3274== 
==3274== HEAP SUMMARY:
==3274==     in use at exit: 0 bytes in 0 blocks
==3274==   total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==3274== 
==3274== All heap blocks were freed -- no leaks are possible
==3274== 
==3274== For lists of detected and suppressed errors, rerun with: -s
==3274== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
Segmentation fault
This error message tells us that the error occurred in the main function, and it was an invalid read of size 1. The program tried to read from the memory location at address 0x1fff0f3862, which was not a valid location. If we run the VS Code Debugger, it will also automatically perform post-mortem debugging and tell us the line where the segmentation fault occurs. Running valgrind on the second program gives
==3471== Memcheck, a memory error detector
==3471== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==3471== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==3471== Command: ./test_valgrind
==3471== 
==3471== 
==3471== HEAP SUMMARY:
==3471==     in use at exit: 1,000,000 bytes in 1 blocks
==3471==   total heap usage: 1 allocs, 0 frees, 1,000,000 bytes allocated
==3471== 
==3471== LEAK SUMMARY:
==3471==    definitely lost: 1,000,000 bytes in 1 blocks
==3471==    indirectly lost: 0 bytes in 0 blocks
==3471==      possibly lost: 0 bytes in 0 blocks
==3471==    still reachable: 0 bytes in 0 blocks
==3471==         suppressed: 0 bytes in 0 blocks
==3471== Rerun with --leak-check=full to see details of leaked memory
==3471== 
==3471== For lists of detected and suppressed errors, rerun with: -s
==3471== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
which tells us how many bytes are lost. In real applications, this example is not particularly helpful to us as it only tells us thee memory that is lost at the end of the program, and most systems automatically clean up the memory once the program is finished running. However, for continuously running programs, it is important to make sure there are no memory leaks.

L16: Best Practices

Follow these three principles:
  • Break down each task into smaller sub-tasks
  • Test frequently WHILE you are writing the program
  • When debugging, debug line by line.
The examples in this lecture will attempt to demonstrate these practices. Just note that because of how the lecture is structured (i.e. it walks through the developement process), it is difficult to faithfully reflect the lecture.

Walkthrough of Lab Question

This is question 2 of Lab 4. Suppose we have a text file that lists several (not necessarily the below) constants, i.e. \(g=9.8\), \(e=2.72\), \(G=0.0000000000667\), and \(pi=3.14\) and we wish to compute the sum. How can we do this?

We should always be thinking of how we can test our code throughout the process, so we should first create the text file that will contain a sample test case, saved as data.txt

We will have it consist of a simpler file first, to make it test easier.
1. g = 9.8
2. e = 2.728
3.
where I used numbers to represent line numbers. Here, I am deciding ahead of time I will always leave an empty line at the end. This choice is arbitrary, as long as we remain consistent. Now we should break it up into sub-tasks.
  1. Task 1: First, we need to be able to extract the appropriate number from each line.

    We can write a function with signature const char* get_double_str(const char* line) where the const is optional, but is a good practice because it tells us that we should not be changing the contents of the string.

    It is important to note that const char* is different from char* const. The first gives us a pointer to a const char (in this case), while the second gives us a constant pointer to a char (where the value being pointed to can change but the pointer can't).

    There are several ways to approach this:
    1. Using strtok(). This works, but is a bit complicated in figuring out how it works.
    2. Finding the equal sign and then skipping a space.
    3. Finding the first digit.
    We will choose this last approach because it is more robust (and avoids issues where there is no space after the equal sign). We do this by creating other subtasks:

    Subtask: Checking if a character is a digit. We can do this via,
    int is_digit(char c)
    {
        return c >= '0' && c <= '9';
    }
    
    which works because the ASCII values of the digits are in order. Before we move on, we can test this:

    int main()
    {
        for(int c = '0'; c <= '9'; c++)
        {
            printf("%c is digit: %d. Another way to
            compute the value: %d\n", c, (int)c, c-'0');
        }
        printf("Is 'a' a digit? %d\n", is_digit('a'));
        printf("Is '0' a digit? %d\n", is_digit('0'));
        printf("Is '9' a digit? %d\n", is_digit('9'));
        printf("Is ' ' a digit? %d\n", is_digit(' '));
        printf("Is '4' a digit? %d\n", is_digit('4'));
    }
    
    which gives us the output:

    0 is digit: 48. Another way to compute the value: 0
    1 is digit: 49. Another way to compute the value: 1
    2 is digit: 50. Another way to compute the value: 2
    3 is digit: 51. Another way to compute the value: 3
    4 is digit: 52. Another way to compute the value: 4
    5 is digit: 53. Another way to compute the value: 5
    6 is digit: 54. Another way to compute the value: 6
    7 is digit: 55. Another way to compute the value: 7
    8 is digit: 56. Another way to compute the value: 8
    9 is digit: 57. Another way to compute the value: 9
    Is 'a' a digit? 0
    Is '0' a digit? 1
    Is '9' a digit? 1
    Is ' ' a digit? 0
    Is '4' a digit? 1
    Now, we can finish the function:

    const char* get_double_str(const char* line)
    {
        while(!is_digit(*line) && *line != '\0')
        {
            line++;
        }
        return line;
    }
    
    which is motivated by moving the pointer until we find a digit, or until it reaches the null character. We can test this as well in our main function,

    printf("%s\n", get_double_str("pi = 3.14"));
    printf("%s\n", get_double_str("G = 0.0000000000667"));
    printf("%s\n", get_double_str("e = 2.728"));
  2. which gives us the desired output. At this point, we should also be thinking if we thought of all the test cases. It turns out one thing we didn't think of is dealing with negatives! We can fix this by modifying the is_digit() function to also check if the character is a negative sign, via the following:
    int is_digit_or_minus(char c)
    {
        return (c >= '0' && c <= '9') || (c == '-');
    }
    
    We would also need to test this new code to check if it actually detects the negative sign AND if it still works with the regular digits AND that it is reflected correctly in get_double_str. But since we already have code that tests these functions, we only need to make minor modifications! The full code and test cases will be included at the end of this section.
  3. Task 2: Next, we need to be able to convert the string to an actual float. There is a default function for this, atof(), but we will write our own function for practice. The idea behind our function is that it will first get the sign, then get the integer part of the float, then get the decimal portion. To get the integer part, we note that as an example, \[542 = 10 \cdot (10 \cdot 5 + 4) + 2\]
  4. so we can work insides out: we multiply \(5\) by ten, then add \(4\). Then multiply the combined result by \(10\), then add on the next digit. This allows us to work from left to right. We can write a function to do this,

    double my_atof(const char *str)
    {   
        // First figure out the sign
        int sign = 1;
        if(*str == '-'){
            sign = -1;
            str++;
        }
    
        // str is now a string that starts with a digit
        double integer_part = 0;
        while(is_digit_or_minus(*str)){
            integer_part = 10*integer_part + (*str - '0');
            str++;
        }
    
        return sign * integer_part;
    }
    
    and of course, test this part out before we move on to the float part. This allows us to very early on identify if there is a problem with the integer part. If we don't test right now, and test later, then we don't know if the problem is with the integer part or the float part.

    Always test code you are able to test, before writing more code.

    Now how can we deal with the fractional part? We can try breaking down the decimal \(0.123\) in a similar way, writing it as: \[0.123 = 1 \cdot 10^{-1} + 2 \cdot 10^{-2} + 3 \cdot 10^{-3}.\] If we can keep track of the current power of 10 (which decreases by a factor of 10 each time), then we can read from left to right and add on the appropriate terms to the fractional part. This gives us,
    double my_atof(const char *str)
    {   
        // First figure out the sign
        int sign = 1;
        if(*str == '-'){
            sign = -1;
            str++;
        }
    
        // str is now a string that starts with a digit
        double integer_part = 0;
        while(is_digit_or_minus(*str)){
            integer_part = 10*integer_part + (*str - '0');
            str++;
        }
    
        // Now we potentially have a fractional part
        // Assume there is always a decimal point
        str++; // Skip the decimal point
    
        double fractional_part = 0;
        double cur_pow10 = 0.1;
        while(*str != '\0'){
            fractional_part += (*str - '0') * cur_pow10;
            cur_pow10 *= 0.1;
            str++;
        }
    
        return sign * (integer_part + fractional_part);
    }
    which we should also test.
We can now combine everything together.
void print_sum_of_constants(const char *filename)
{
    FILE *fp = fopen(filename, "r");
    if(fp == NULL){
        printf("Error opening file %s\n", filename);
        return;
    }

    double sum = 0;
    char line[100]; // Assume no line is longer than 100 characters
    while(fgets(line, 100, fp) != NULL){
        const char *double_str = get_double_str(line);
        sum += my_atof(double_str);
    }
    
    printf("Sum of constants in %s is %f\n", filename, sum);
}
However, if we try to this this code, we get \(12.148\) instead of \(12.528\). We can debug this by tracing the code line by line, and we will realize that we are not exiting the while loop correctly when computing the fractional part. We are only exiting it when we reach the null character, but we should also exit it when we reach the end of the line. We can fix this by changing it to
while(*str != '\0' && *str != '\n'){
  fractional_part += (*str - '0') * cur_pow10;
  cur_pow10 *= 0.1;
  str++;
}
and now it will work. See data.txt and read_doubles.c for the full code and test cases.

L17: Memory Model Review for Structs

Consider the following code, which creates a student struct and attempts to change the name:
#include <stdio.h>
#include <string.h>

typedef struct student1{
    char name[3];
    int age;
} student1;

void change_name_wrong(student1 s)
{
    s.name[0] = 'a';
}

void change_name_right_a(student1 *p_s)
{
    p_s->name[0] = 'b';
}

void change_name_right_b(student1 *p_s)
{
    strcpy(p_s->name, "c");
}

int main(){
    student1 s = {"x", 20};
    printf("%s %d\n", s.name, s.age);

    change_name_wrong(s);
    printf("%s %d\n", s.name, s.age);

    change_name_right_a(&s);
    printf("%s %d\n", s.name, s.age);

    change_name_right_b(&s);
    printf("%s %d", s.name, s.age);
}
This will create the following memory table after calling change_name_wrong().
Address Value
40
44
48 'a','\0',[],[] // function: s (temporary, created in change_name_wrong and lost after function call)
52 20
56
60
64 'x','\0',[],[] // main: s
68 20
72
where the [] represents padding (see lecture 10). After running change_name_right_a() we get the following memory table:
Address Value
40
44
48 64 // function: p_s
52
56
60
64 'b','\0',[],[] // main: s
68 20
72
After running change_name_right_b() we get the following memory table:
Address Value
40
44
48 64 // function: p_s
52
56
60
64 'c','\0',[],[] // main: s
68 20
72
76 'c','\0'
80
Note that in this function, the function first creates 'c','\0' somewhere in memory and then uses the strcpy function to copy the string to the address of s.name. It is also important to remember that

C equivalent of Python Code

Suppose we have the following Python code:
def change_name(s):
  s[0] = "b"

if __name__ == '__name__':
  s = ["x", 20]
  change_name(s)
How could we do the same thing in C? It is important to remember that in Python we are always dealing with addresses, we need to reflect this in our C code,
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct student2{
    char *name;
    int *p_age;
} student2;

void change_name(student2 *p_s)
{
    p_s->name = "b";
}
int main(){
    student2 *p_s = (student2*)malloc(sizeof(student2));

    // Create a temporary student2
    int *p_age = (int*)malloc(sizeof(int));
    *p_age = 20;
    student2 temp = {"x", p_age};

    // Copy the temporary student2 into p_s
    memcpy(p_s, &temp, sizeof(student2));

    printf("%s %d\n", p_s->name, *(p_s->age));

    change_name(p_s);
    printf("%s %d\n", p_s->name, *(p_s->age));

}
which corresponds to the following memory table after properly setting up the structure.
Address Value
40
44
48
52
56 108 // temp.name
60
64 120 // temp.p_age
68
72
76 56 // main: p_s
80
84 120 // main: p_age
88
92 108 // temp.name
96
100 120 // temp.p_age
104
108 'x','\0'
112
116
120 20
Now we can change the name of the student in the change_name function. We pass in p_s which is 56, the address where the student is stored. We then access the name of the student via p_s->name and set it equal to "b". Note that if we wrote 'b' instead, there would be a segmentation fault when we try to later print it, since 'b' corresponds to a character and "b" corresponds to an address (i.e. it points to the first character of a string).

L18: Consts and Linked Lists

The const keyword

  • const indicates that a value is not to be changing. Changing a const value could result in a compilation warning or warning. The intended purpose is to help catch errors in the code earlier. Consider:
    const char c = 42;
    c = 43; // compilation error
    
    const char *p_c = (char*)malloc(sizeof(char));
    *p_c = 0; // compilation error
    p_c = "hello"; // fine
    
  • In the second part of the above example, we demonstrate that we are not allowed to modify the contents of the memory at the address p_c. However, we can assign the pointer p_c to point to a string literal "hello". This is allowed because the pointer itself is not modified, only the memory location it points to.
  • String literals like "hello" are of type const char*
  • Sometimes, we wish to not be able to change the address, but be able to go to the address and change the contents. We can achieve that via the following:
    int * const p_n = (int*)malloc(sizeof(int))
    p_n = 0; // error
    *p_n = 1; // fine
    
  • Similarly, we can disallow changing both the address and the contents (but this is usually not done)
    const int * const p_n = (int*)malloc(sizeof(int))
    p_n = 0; // error
    *p_n = 1; // error
    
We can apply the const keyword to write our student2 structure as follows:
typedef struct student2{
    const char *name;
    const int *p_age;
} student2;
When an instance of this struct is created, the const keyword tells us we are reserving a place in memory for the address of the age and the address of the name. Like before, we cannot change the name like
void change_name_wrong(student2 *p_s)
{
    p_s->name[0] = 'b';
}
since p_s->name[0],p_s->name[1], etc. are read-only (cannot change the value of the memory at the pointer). The above code attempts to go to the address and change the value at that address (which can't be done, since it's a const), while doing something like
void change_name_right(student2 *p_s)
{
    p_s->name = "b";
}
is fine since we are not changing the value at the address, but rather the address itself.

Linked Lists

Motivation: We cannot dynamically add an element to an existing array or block of memory easily, as there could be no space there! The only option we have is to re-allocate everything to a new location with enough space. This is inefficient, especially for larger arrays. Instead, we can use a special data structure called a linked list.
  • A linked list consists of a data structure called a node, which consists of the following information:
    • data: the actual data you need to store
    • next: the address of the next node
  • We can then make an array by creating a chain of these nodes, where we can find the next node by following the next pointer. While this uses up more space than a regular array, it has the benefit of preventing re-allocating everything. For example, if we want to add an element to the end of the list, we can simply create a new node and set the next pointer of the last node to point to the new node (+ a minor detail we'll touch on in a bit).
  • See the lecture slides for a visual of linked lists and their operations.

Linked List Operations

We want to implement the following operations: Inserting, Removing, and Searching.
  • Inserting: Suppose we already have a pointer to a node, and we wish to insert a node after that. What we can do is create this new node, and set its next pointer to point to the node that the original node pointed to. Then, we can set the original node's next pointer to point to the new node we just created.
    • Analogy: A popular children's activity is to form a human train, where each person puts their hand on the shoulder of the person in front of them, forming a chain (linked list). If a new participant Bob (the new node) wants to join the train in front of Alice (who is holding Carol's shoulder), Bob can do so by holding onto Carol's shoulder, and then Alice can hold onto Bob's shoulder.
  • Removing: Suppose we already have a pointer to a node, and we wish to delete that node. We can do so by setting the next pointer of the node before the node we want to delete to point to the node after the node we want to delete. Finally, we free up the memory.
    • Analogy: If Bob wants to leave the train, he can simply let go of Carol's shoulder, and Alice can hold onto Carol's shoulder.
  • Getting: Suppose we wish to find a node (i.e. its pointer) at a certain index (i.e. the ith node) In the previous 2 operations, we assumed we already had this pointer. We can do so by iterating through the linked list by following the next pointer until we find the node we want.
    • Analogy: If a teacher wants to find the 7th student, they can start from the very beginning of the train and follow it to the end until the 7th student is found.
In summary, we can delete and insert nodes in constant time, which is faster than array operations which are O(n). However, getting the ith node is linear time, while we can do that in constant time for arrays.

Linked List Implementation

We can implement the linked list as follows:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef struct node{
    int data;
    struct node *next;
} node;

typedef struct LL{
    node *head;
    int size;
} LL;

void create_node(node **p_n, int data)
{
    *p_n = (node*)malloc(sizeof(node));
    (*p_n)->data = data;
    (*p_n)->next = NULL;
}


void create_LL_from_data(LL **p_LL, int *data_arr, int size)
{
    (*p_LL) = (LL*)malloc(sizeof(LL));
    (*p_LL)->size = 0; // initialize size to 0
    
    // keep track of the last node of the linked list
    node *tail = 0;

    for(int i = 0; i < size; i++){

        // n is a pointer to a node with data = data[i], next = NULL
        node *n;
        create_node(&n, data_arr[i]);

        // If the last node is the 1st node
        if(tail == 0){
            (*p_LL)->head = n;
        }

        // If the last node is not the 1st node
        else{
            // append the new node to the end of the linked list
            tail->next = n;
        }

        // update the tail
        tail = n;

        // update the size
        (*p_LL)->size++;
    }
}

int main(){
    int data_arr[] = {1, 2, 3, 4, 5};
    LL *p_LL;
    create_LL_from_data(&p_LL, data_arr, 5);
}
Here are some observations:
  • The idea behind the create_LL_from_data function is to keep track of the last node of the linked list (known as the tail) and constantly update it such that every time we create a new node, the tail's next pointer points to the new node.
  • Initializing a new node like node *n will cause the next pointer to be 0 i.e. NULL. This is how we can tell we reached the end of a linked list.
  • we need to use pointers to pointers because we want to be able to modify the value of the LL pointer (i.e. the memory address it points to), rather than just the value of the data it points to.

L19: Abstract Data Type

An abstract data type (ADT) is any collection of values, together with operations on those values. For example:
  • ints with operations +,-*,/,%
  • lists with operations insert, remove, get
An ADT specifies what values are represented and what operations can be performed, but not how to store them or how to carry them out. They allow for modularity and reuse, as it is independent of implementation. A data structure is an implementation of an ADT as it is a way to represent the values, and algorithms for each operation.

Example: Consider a list which is an ordered collection of data items that supports the following operations:
  • Insert(list, i, x): add item x at index i of list. The item previously at index i, and all following items, are shifted up by one index to make room for x.
  • Remove(list, i): remove item at index i of list. The item previously at index i+1, and all following items, are shifted down by one index to fill the gap.
  • Get(list, i): return the item at index i of list.
We define this ADT very abstractly, such that we only describe the outcome of each operation, but not how to implement it. For example, lists can be implemented using arrays, but it can also be implemented using linked lists.

Insert for Linked List

In the previous lecture, we defined the linked list struct as follows:
typedef struct node{
  int data;
  struct node *next;
} node;

typedef struct LL{
  node *head;
  int size;
} LL;
We also talked about how to implement insertions conceptually. We can implement it as follows:

void LL_insert(LL *my_list, int new_elem, int index)
{
  // Create the new node 
  node *n;
  create_node(&n, new_elem);

  // If the list is empty
  if(my_list->head == NULL){
    my_list->head = n;
    my_list->size++;
    return;
  }

  // To insert at the very beginning
  if(index == 0){
    n->next = my_list->head;
    my_list->head = n;
    my_list->size++;
    return;
  }

  // Errors
  if (index < 0 || index > my_list->size-1){
    fprintf(stderr, "Invalid index %d", index);
    exit(1); // exits the program, returns 1 to the OS
  }

  // Traverse to the desired index
  node *cur = my_list->head;
  for(int i = 0; i < index; i++){
    cur = cur->next;
  }

  n->next = cur->next;
  cur->next = n;
  my_list->size++;
}
Note that we deal with a few edge cases:
  • If the list is empty, which we can check if the head is NULL, then we proceed to make the new node the first (and only) element of the linked list.
  • We want to implement it such that inserting it at index 0 corresponds to inserting it at the very beginning. To do this, not only do we need to update the next pointer of the new node, but the head pointer of our linked list. The order here is important. If we instead ran my_list->head = n; first, we lose information on where the original head is located.
  • If the index is invalid, there are a few possibilities of what we can do.
    1. Just Crash: Sometimes this is a good idea, if we are trying to optimize the code. It's on the user to give valid parameters.
    2. Don't crash and don't insert. Instead, set a global variable to indicate an error. The reason we need a global variable here is that there's no other thing we can return that will indicate this. For example, if we are trying to return the element at an invalid index, a naive idea would be to return something like -1 or "Error", but how do we know that this is actually an error and their list doesn't actually include this!
    3. Print an error message. In particular the best way to print an error is using fprintf(stderr, "message") because it prints to the standard error stream, which is different from the standard output stream. Unlike printf (which sometimes doesn't show in terminal), this will always show, and allows regular print statements and error messages to be separated.
In the other cases, we first need to traverse to the desired index. Because this is not an array, we need to follow the linked list via the next pointers. Once we reach the desired index, we can update the next pointers of the new node and the node before it. Similar to above, the order of updating the next pointers matter!

Remove and Get for Linked List

The code to remove and get for linked lists can be found here and is very similar to the code for insert. For deletion, we just need to remember to update the next pointer of the node before the one we are deleting, and freeing up the memory. Like most cases, the order here is important. Getting is actually partially implemented in insertion and deletion, as we need to find the ith element. Error handling for all three functions are also similar.

Good Coding Practices

Suppose you have a piece of code, that relies on an abstract data structure (i.e. a list). We can define this structure in a header file list.h. Recall that an ADT doesn't include how it is implemented, just the operations.

We can now implement this ADT in a separate file. For example, we can either implement it using linkedlist.c or arraylist.c. For example, if our list.h file looks like
#if !defined(LIST_H)
#define LIST_H
void create_list_from_data(void **p_list, int *data_arr, int size);
void list_append(void *list, int new_elem);
void list_insert(void *list, int new_elem, int index);
void list_delete(void *list, int index);
void list_free_all(void *list);
int list_get(void *list, int index);
#endif
Then our linkedlist.c file needs to contain these functions. If we already have code (i.e. the earlier ones we wrote), we don't need to modify them. Instead, we can just create a new function that calls them in order to have the right form. For example,
void list_insert(void *list, int new_elem, int index)
{
    LL_insert((LL*)list, new_elem, index);
}

int list_get(void *list, int index)
{
    return LL_get(list, index);
}
Notice that the functions in list.h take in void pointers, because we want them to be as general as possible (i.e. could be implemented using either an array or linked list). These additional functions we write in linkedlist.c are just wrappers. One final note is that although we can cast the void pointer to the correct type (LL*), it is not necessary.

We can do this for both linkedlist.c and arraylist.c, and when we want to compile our main program, we can choose to compile it with either linkedlist.c or arraylist.c. For example, we can run
gcc linkedlist.c -c -o linkedlist.o
to create the object file linkedlist.o. We can then compile main.c alongside linkedlist.o to create an executable. We can do this by running
gcc main.c linkedlist.o -o main
If we instead wanted to compile using the array list implementation, we can run
gcc main.c arraylist.o -o main
This has several advantages:
  • Modularity and Reusability: When writing our main code, we don't have to worry about how certain functions are implemented. Also, if we already have code that can implement abstract data structures, we can just re-use them for future projects without changing too much!
  • Faster compilation: Using a precompiled object like linkedlist.o can save compilation time. Although this is not a big problem now, compilation time can be an issue for larger and more complex projects.

Python Classes

Structures in C are similar to classes in Python (Classes in Python are actually much much more powerful, but that's not the point in this section). We can define a Student class in Python as follows,
class Student:
  def __init__(self, name, gpa):
      self.name = name
      self.gpa = gpa
  
  def __str__(self):
      return self.name + ": " + str(self.gpa)
if __name__ == "__main__":
  s = Student('Fred', 3.7)
  print(s.name, s.gpa)
Here, __str__ is a built-in function, and we can call it via either:
s.__str__()
Student.__str__(s)
Here, the first line is just shorthand notation for the second line. What makes this built-in function useful is that we can call print(s) and it will treat this as print(s.__str__()) instead.

We can also write a function to compare two classes, so that we can use operators such as <, >, and ==. For example,
class Student:
  def __init__(self, name, gpa):
      self.name = name
      self.gpa = gpa
  
  def __str__(self):
      return self.name + ": " + str(self.gpa)
  
  def __lt__(self, other):
      # Return True if self has lower gpa than other
      # If GPAs are tied, compare names

      if self.gpa < other.gpa:
        return True
      elif self.gpa == other.gpa:
        return self.name < other.name
      else:
        return False
if __name__ == "__main__":
  fred = Student('Fred', 3.7)
  bob = Student('Bob', 2.0)
  print(Student.__lt__(fred, bob))
  print(fred < bob)
Recall that this is very similar to the compare function we wrote for qsort. Because we can compare different students, we can also sort them using the default sorted function in Python. For example,
L = [Student("Fred", 3.7"), Student("Bob", 3.7), Student("Alice", 3.9)]
L.sort()
print(L) # [Bob: 3.7, Fred: 3.7, Alice: 3.9]
will allow us to sort and print out the list of students in a very convenient way (using both the custom print and compare functions we wrote)! Note that this is a more flexible method of sorting classes in Python. The "traditional" way (for simple comparison functions), we will write a function to get a numerical value from the class and sort using that key. For example,
def get_name(s):
  return s.name
L.sort(key=get_name)
will sort the list of students in L alphabetically. Alternatively, we can use anonymous functions (lambda functions) which we can write in one line to save space. For example,
L.sort(key=lambda s: s.name)
achieves the same purpose.

However, it is difficult (but not impossible) to modify the "traditional" way and come up with a way to map a more complicated comparison function to a numerical value. Note: comparison functions can be as complex as we want, but they need to be transitive in order to get meaningful results.

L20: Precedence

Recall the standard order of operations for arithmetic expressions. For example, \[1 + 2 * 8 = 17\] and C will treat this completely normally. However, there are some unintuitive cases when dealing with other operators in C, which we will discuss in this lecture.

++ Operator

Recall the ++ operator, which increments a variable by 1. Specifically, it is a post increment operator, meaning that it increments the variable after it is evaluated. For example, consider the following code,
int a = 6;
int b = 7 + a++; // make b = 7+a, and then increment a by 1
Note that adding parentheses here doesn't matter, since the ++ operator is already the highest precedence (see precedence list). Therefore, the code
int a = 6;
int b = 7 + (a++); // make b = 7+a, and then increment a by 1
will give the same behaviour, which is not intuitive at first! Now consider something similar,
char *s = "hello";
printf("%c", *(s++)); // incrementing the address 
                      // what happens with just *s++
printf("%c", (*s)++); // incrementing the value at address s
                      // 'h' becomes 'i'
The first case is what happens if we don't have any parentheses at all (since ++ has highest precedence). Note that the second case here may lead to a segmentation fault.

This notation can be very useful, and can be used to write certain functions in a very fast way (see L7). For example, if we want to copy a string, we can do the following:
char *s = "hello";
char dest[10];
char *dest_write = dest;
// want to copy string s into dest
while(*dest_write++ = *s++);
The while loop is the only thing we need. There are a few things to note here:
  • The value of an "assignment relation" is equal to the value assigned. Therefore, the while loop will continue to run as long as *s is not NULL. In general, any non-zero value is true and any zero value (including NULL) is false.
  • The notation *dest_write++ = *s++ tells us to assign the value of *s to *dest_write, then the ++ operator tells us to increment both pointers AFTER the assignment happens.
While this works, it is usually not a good practice as you have to be very careful about the order of operations and think about things. It's included here for you to understand it exists and see it once, but please don't use this in your code when you could write more readable code in just a few extra lines.

L21: Linked Lists II

In this lecture, we will discuss how to implement a linked list in Python. Most of the time, you won't need to use a linked list in Python (we'll see an example next Monday), but it is still important to understand how linked lists work, and the work done in this lecture will be useful for implementing other data structures in Python for the rest of this course. The advantage of Python over C is pedagogical here, we want to focus on the data structure and not the specific implementation details.

If you are not familiar with classes, please see the last part of L19. Consider the Node class,
class Node:
  def __init__(self, value):
      self.value = value
      self.next = None
  
  def __str__(self):
      return f"{self.value}"
By default, we have set self.next = None to indicate that the next node is not yet defined. We can then create a linked list by creating a series of nodes and linking them together. For example,
if __name__ == '__main__':
  n1 = Node(12)
  n2 = Node(15)
  n3 = Node(500)

  n1.next = n2
  n2.next = n3
Recall that in the Python memory model, values are not copied. Everything is passed by reference. Therefore, something like n1.next = n2 means we make n1.next refer to the address of n2 (so they are treated as the "same" thing). In C, the analogy would be
node *n1 = create_node(12)
node *n2 = create_node(15)
n1->next = n2;
where the function create_node() performs the memory allocation for you, which is what Python is doing behind the scenes. We can also make a linked list class and include some functions. For example,
class LinkedList:
  def __init__(self):
      self.head = None
  
  def get_i(self, i):
      # return the value at index i
      cur = self.head
      for j in range(i):
          cur = cur.next
      return cur.value

  def append(self, value):
      '''Add a new node with the value value to the end of the list'''
      # Create a new node
      new_node = Node(value)
      print(new_node)

      if self.head == None:
          self.head = new_node
      else:
          # Find the last node
          cur = self.head
          while cur.next != None:
              cur = cur.next
          cur.next = new_node
  
  def insert(self, value, i):
      '''Insert a node with the value value at index i'''
      new_node = Node(value)

      if i == 0:
          new_node.next = self.head
          self.head = new_node
      else:
          cur = self.head
          for j in range(i-1):
              cur = cur.next
          new_node.next = cur.next
          cur.next = new_node

  def __str__(self):
      cur = self.head
      s = ""
      if(cur == None):
          return "Empty list :("
      
      while cur != None:
          s += str(cur) + " -> "
          cur = cur.next
      return s[:-4] # remove last arrow
Note that we didn't do anything new here. We took the existing code we had in L18 and transformed it into Python equivalents. The only new thing is the __str__ function, which allows us to easily print out the linked list. For this function we have two cases: if the list is empty, we print something, and if the list is non-empty we print out the values of each node, connected with the -> symbol. We can then test our code,
if __name__ == '__main__':
  LL = LinkedList()
  LL.append(3)
  LL.append(50)
  LL.append(100)

  print(LL)

L22+ Onwards

See Part 3.