1 of 35

Lecture 4-5:�Arrays & Linked Lists

2 of 35

Announcement

Goal:�Searching

Recursion

Array & Linked List

Stack & Queue

​

​

2024/10/13(MON) – Quiz 1

​

2

3 of 35

Arrays

3

4 of 35

Internal Implementation: Memory

  • Internally, all variables and constants we use in our program should be stored somewhere in memory.
    • For a single variable of a primitive type (int, float, …), we know its size (how many bits are needed).

3

10

a = 3

b = 10

b += 7

17

c = 1234567890L

1234567890

4

5 of 35

Array Resizing

  • Two problems of an array due to its fixed length
    • Memory wastage: if it contains only n << N valid elements
    • Memory shortage: if it wants to contain more than N elements
  • Array resizing: create another larger array and copy all the elements
    • L.append(3) when the current array is full.

Index

0

1

2

3

4

5

6

7

8

9

Value

0

0

0

0

0

0

0

0

-0

0

Index

0

1

2

3

4

5

6

7

8

9

Value

-7

15

2

6

-1

5

4

10

-4

21

L

Index

0

1

2

3

4

5

6

7

8

9

Value

-7

15

2

6

-1

5

4

10

-4

21

10

0

10

3

Create a new long array O(1)

Create(copy) a new long array O(N)

Add a new element O(1)

5

6 of 35

Internal Implementation: Memory

  • Now, let’s think about the time complexity!
    • Theoretical time complexity for a.append(6)?
    • Actual time complexity for a.append(6), if there’s no enough space after it?

3

10

17

1234567890

1

2

3

4

5

a.append(6)

This is not ideal, since we do not have to know how memory space is being used at every moment!

O(1)

O(N)

6

7 of 35

Arrays

  • An array is an object comprising a numbered sequence of memory boxes
    • More fundamental data structure that Python lists are built on.
    • This is why we can easily access the i-th element of list A by using A[i].�
  • An array comprises
    • Fixed integer length (N) – should be set when initializing it
    • A sequence of N memory boxes (numbered 0 through N - 1)

Array�(Fixed length)

List�(Dynamic length)

Resizing

Shifting

Copying

7

8 of 35

Array Resizing

  • Array resizing is expensive: new memory boxes and copy operation
    • Increasing size by one every time is not efficient (too many resizing)
    • Increasing size too much at once is not efficient either (memory wastage)�
  • To resize fewer, Python list size grows as 0, 4, 8, 16, 25, 35, 46, 58, …
    • Mild over-allocation proportional to the current size�
  • Anyway, is there any better way of organizing a collection of data to support append and pop easily?

8

9 of 35

Linked Lists

9

10 of 35

Linked Lists

  • Main idea:
    • Allow each element in the list to be scattered in the memory.
    • Instead, each element points to the next one.

3

10

17

1234567890

1

2

3

4

5

l.append(5)

1

2

7

3

4

5

4

5

5

7

2

13579246810

l.append(7)

l.append(2)

10

11 of 35

Linked Lists

  • Class Node
    • Because we always need to store the item and the pointer to the next node, let’s make this as a class!

class Node():

def __init__(self, x):

self.item = x

self.next = None

a = Node(5)

b = Node(6)

a.next = b

5

a

6

b

print(a.item)

print(a.next.item)

5

6

item

next

11

12 of 35

Review: Python Object Reference

p = Node(5)

p = Node(6)

p.item

q.item

5

6

q = p

6

6

q = Node(9)

6

9

p = None

Error!

9

q = p

Error!

Error!

12

13 of 35

Singly Linked List

  • Let’s design the singly linked list data structure. What functionalities do we need?
    • Creating an empty list
    • Adding / inserting a new item
    • Retrieving an item
    • Deleting / removing an existing item

5

a

6

item

next

3

7

at position i

13

14 of 35

Singly Linked List

  • Class LinkedList
    • We keep only the reference to the first node.
    • At creation, first node is None, having no element in the list.

class Node():

def __init__(self, x):

self.item = x

self.next = None

class LinkedList():

def __init__(self):

self.first = None

​

def insert(self, x, i):

# insert x at [i]

​

def get(self, i):

# get item at [i]

​

def delete(self, i):

# delete item at [i]

5

first

6

item

next

14

15 of 35

Inserting an Item at position i

  • Step 1: Creating a new node with the given item.
  • Step 2: Traverse to the (i - 1)-th position.
  • Step 3: Set the new node’s next as the original i-th node.
  • Step 4: Update the (i - 1)-th node’s next as the new node.

Example:�insert “4” at position 2.

5

first

6

item

next

3

7

4

curr

curr

15

16 of 35

Inserting an Item at position i

  • Step 1: Creating a new node with the given item.
  • Step 2: Traverse to the (i - 1)-th position.
  • Step 3: Set the new node’s next as the original i-th node.
  • Step 4: Update the (i - 1)-th node’s next as the new node.

def insert(self, x, i):

# insert x at [i]

new_node = Node(x)

​

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1

​

new_node.next = curr.next

​

curr.next = new_node

class Node():

def __init__(self, x):

self.item = x

self.next = None

5

first

6

item

next

3

7

4

curr

curr

new_node

Step 1

Step 2

Step 3

Step 4

16

17 of 35

Does this work at the end?

  • Step 1: Creating a new node with the given item.
  • Step 2: Traverse to the (i - 1)-th position.
  • Step 3: Set the new node’s next as the original i-th node.
  • Step 4: Update the (i - 1)-th node’s next as the new node.

Example:�insert “4” at position 4.

5

first

6

item

next

3

7

4

curr

curr

curr

curr

17

18 of 35

Does this work at the beginning?

  • Step 1: Creating a new node with the given item.
  • Step 2: Traverse to the (i - 1)-th position.
  • We need special treatment when we insert at position 0!

Example:�insert “4” at position 0.

5

first

6

item

next

3

7

4

curr

curr

curr

curr

i - 1 = -1 😨

18

19 of 35

Inserting an Item at position i

  • If inserting at the first position:
    • Step 1: Creating a new node with the given item.
    • Step 2: Set the new node’s next as the original first node.
    • Step 3: Update the first node reference to the new node.
  • else:
    • Step 1: Creating a new node with the given item.
    • Step 2: Traverse to the (i - 1)-th position.
    • Step 3: Set the new node’s next as the original i-th node.
    • Step 4: Update the (i - 1)-th node’s next as the new node.

Check: does this work when we insert the very first item (that is, does it work when self.first = None)?

def insert(self, x, i):

# insert x at [i]

if i == 0:

new_node = Node(x)

new_node.next = self.first

self.first = new_node

else:

new_node = Node(x)

​

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1

​

new_node.next = curr.next

​

curr.next = new_node

19

20 of 35

Inserting an Item at position i

Check: what happens with our code if i > last position?

It will crash here, when it tries to access None.next

We should prevent this, instead of letting the users to be responsible!

def insert(self, x, i):

# insert x at [i]

if i == 0:

new_node = Node(x)

new_node.next = self.first

self.first = new_node

else:

new_node = Node(x)

​

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1

​

new_node.next = curr.next

​

curr.next = new_node

7

curr

…

None

20

21 of 35

Size Variable

  • First try:
    • Let’s add a check at the beginning, if i is within the valid range.
    • Valid range?
  • But, how do we know the size?
    • A naive way: traverse from the first until we meet None.
    • This is not efficient, since we need to traverse N items whenever we insert a new item, regardless of the target position. 😨
    • Any better way?

def insert(self, x, i):

# insert x at [i]

� if i == 0:

new_node = Node(x)

new_node.next = self.first

self.first = new_node

else:

new_node = Node(x)

​

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1

​

new_node.next = curr.next

curr.next = new_node

From 0 to current length (item count)

if i > size: return

elif i <= size:

21

22 of 35

Size Variable

  • Solution: Let’s keep the size variable in the class, and maintain it whenever we insert or delete an element.
  • Time complexity?

class LinkedList():

def __init__(self):

self.first = None

self.size = 0

​

def insert(self, x, i):

# insert x at [i]

​

def get(self, i):

# get item at [i]

​

def delete(self, i):

# delete item at [i]

def insert(self, x, i):

# insert x at [i]� if i == 0:

new_node = Node(x)

new_node.next = self.first

self.first = new_node

self.size += 1

elif i <= self.size:

new_node = Node(x)

​

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1

​

new_node.next = curr.next

curr.next = new_node

self.size += 1

O(1)

22

23 of 35

Retrieving an Item at position i – Homework

  • Basic logic
    • Step 1: Traverse to the i-th position.
    • Step 2: Return the item in the node.�
  • Any special cases to consider?
    • Check if your implementation works when
      • i = 0
      • i > self.size
      • self.size = 0
      • …

def get(self, i):

# get item at [i]

​

# TODO(students): implement!

​

return ?

23

24 of 35

Deleting an Item at position i

  • Step 1: Traverse to the (i - 1)-th position.
  • Step 2: Set the (i - 1)-th node’s next as the target’s next.

Example:�delete item at position 2.

This node is no longer accessible. “3” is no longer in your list!

5

first

6

item

next

3

7

curr

curr

24

25 of 35

Deleting an Item at position i

  • Step 1: Traverse to the (i - 1)-th position.
  • Step 2: Set the (i - 1)-th node’s next as the target’s next.

def delete(self, i):

# delete item at [i]

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1�

curr.next = curr.next.next

​

self.size -= 1

class Node():

def __init__(self, x):

self.item = x

self.next = None

5

first

6

item

next

3

7

curr

curr

Any edge case here?

Step 2

Step 1

25

26 of 35

Does this work at the end?

  • Step 1: Traverse to the (i - 1)-th position.
  • Step 2: Set the (i - 1)-th node’s next as the target’s next.

Example:�delete item at position 3.

5

first

6

item

next

3

7

curr

26

27 of 35

Does this work at the beginning?

  • Step 1: Traverse to the (i - 1)-th position.
  • Step 2: Set the (i - 1)-th node’s next as the target’s next.

Example:�delete item at position 0.

Again, we need special treatment when we delete the first one!

This condition is never satisfied.

5

first

item

next

curr

i - 1 = -1 😨

def delete(self, i):

# delete item at [i]

pos = 0

curr = self.first

while pos < i - 1:

curr = curr.next

pos += 1�

curr.next = curr.next.next

self.size -= 1

if i == 0:

self.first = self.first.next

else:

...

27

28 of 35

Time Complexity

  • Time complexity of Linked List?

Happen when?

Task

Worst case

Average case

Best case

Insertion

​

​

​

Retrieval

​

​

​

Deletion

​

​

​

O(N)

O(N)

O(N)

O(N)

O(N)

O(N)

O(1)

O(1)

O(1)

28

29 of 35

Doubly Linked List

  • Sometimes it is useful to have ability to access previous items.
  • No asymptotic benefit on complexity.

29

30 of 35

Comparison

  • Array
    • Consecutive memory space is assigned.
    • Fixed length
    • Random access is supported in O(1).
    • Suffers from item shifting.�
  • Linked list
    • Scattered in the memory space.
    • Additional space is needed for storing the next reference.
    • No random access allowed. (Linear traversal is required, taking O(N).)
    • No shifting is needed even with size change.

30

31 of 35

31

32 of 35

Application Questions

  • Print the middle of a given linked list. (Assume list size is not maintained.)
  • Brute-force solution
    • Traverse the entire list to count the number of elements.
    • Traverse half of them again.
    • → O(N)

5

7

2

8

2

4

9

32

33 of 35

Application Questions

33

34 of 35

Application Questions

  • Reverse a given linked list.

5

7

2

8

2

4

9

9

4

2

8

2

7

5

34

35 of 35

Application Questions

  • Detect if there is a cycle in the given linked list.

5

7

2

8

2

4

9

35