Subtopic Notes

19.1 Algorithms

19. Computational thinking and Problem-solving

Chapter 19 - Computational thinking and Problem-solving

From this part of the book onward, only python will be used as program code. In the syllabus python, java and visual basic are all allowed but I am using python as it is the easiest.

Algorithm Performance

  • Different algorithms can perform the same task, but may vary in efficiency
  • Criteria for comparison
    • Time complexity: How long an algorithm takes to complete a task.
    • Space complexity: How much memory an algorithm uses while running.

Big O Notation

  • A mathematical notation used to describe the upper bound of an algorithm’s running time or memory usage as input size grows.
  • Focuses on the worst-case scenario.
  • Common Big O examples:
    • O(1): Constant time - execution time does not depend on input size.
    • O(n): Linear time - execution time grows linearly with input size.
    • O(n²): Quadratic time - execution time grows with the square of input size.
    • O(log n): Logarithmic time - execution time grows slowly as input increases.

Searching Algorithms

Linear Search

Simple searching algorithm that checks each element in a list or array sequentially until it finds the desired value.
Time Complexity: O(n)
Space Complexity: O(1)

numbers = [0, 6, 7, 96, 6, 5, 6, 0, 6, 4, 95, 2, 3] target = 95 #Number to search foundIndex = -1 for index in range(len(numbers)): if numbers[index] == target: foundIndex = index break if foundIndex == -1: print("Not Found") else: print("Found at index:" , foundIndex)

Binary Search

  • It works by repeatedly dividing the search range in half until the item is found or the range becomes empty.
  • Instead of checking every item, binary search quickly narrows down where the item could be.
  • Necessary Condition: The data must be sorted
  • As more items are added, the time increases, but since the data is halved each time, it grows more slowly and eventually becomes almost constant
  • Time Complexity:
    • Best Case: O(1) - The target is found at the middle element
    • Worst/Average Case: O(log₂ n)
  • Space Complexity: O(1)
def binary_search(arr, target): # Set the starting and ending indexes low = 0 high = len(arr) - 1 # Continue searching while there is a range to check while low <= high: # Find the middle index mid = (low + high) // 2 # Check if the middle element is the target if arr[mid] == target: return mid # Target found, return its position # If target is smaller, ignore the right half elif arr[mid] > target: high = mid - 1 # If target is larger, ignore the left half else: low = mid + 1 return -1 # Target not found

Sorting Algorithms

Insertion Sort

Insertion sort builds a sorted list one element at a time by inserting each item into its correct position.

  • Time Complexity:
    • Best case: O(n) (already sorted)
    • Worst case: O(n²) (reverse order)
  • Space Complexity: O(1)
numbers = [0, 6, 7, 96, 6, 5, 6, 0, 6, 4, 95, 2, 3] print("Before sorting:", numbers) # Start from the second element for i in range(1, len(numbers)): key = numbers[i] # Current element to insert j = i - 1 # Move elements greater than key one position to the right while j >= 0 and numbers[j] > key: numbers[j + 1] = numbers[j] j -= 1 # Insert key into the correct position numbers[j + 1] = key print("After sorting:", numbers)

Bubble Sort

Sorting algorithm that repeatedly swaps adjacent elements if they are in the wrong order and iterates until the list is sorted.
Time Complexity:

  • Best case: O(n) (optimized version, already sorted)
  • Worst case: O(n²) (reverse order)

Space Complexity: O(1)

numbers = [0, 6, 7, 96, 6, 5, 6, 0, 6, 4, 95, 2, 3] # Outer loop to iterate through the list n times for i in range(len(numbers) - 1, 0, -1): swapped = False # Inner loop to compare adjacent elements for j in range(i): if numbers[j] > numbers[j + 1]: #Swapping elements temp = numbers[j] numbers [j] = numbers[j + 1] numbers[j + 1] = temp swapped = True # If no swaps occurred, the list is already sorted if not swapped: break print(numbers) #Shows the sorted array

Abstract Data Types

Defines a data structure by its behavior from the user's perspective, rather than its concrete implementation

Stack

  • A list operating on the Last In First Out principle (LIFO).
  • Push: Adding item to stack
  • Pop: Removing item from the stack
  • isEmpty: Checks if stack is empty
  • The first item added to the stack is the last item that can be removed
  • Implementing stack using array
    • Declare an array to store the contents of stack
    • Declare a stack pointer pointing to the index of last element added (Value -1 if stack is empty)
  • Uses: Managing function calls, Syntax parsing in compilers, Recursion, 'Undo' functions, System memory architecture, Browsing history, Expression parsing (Reverse Polish Notation)
  • Advantage: Simple, Efficient, Limited Memory, LIFO
  • Disadvantage: Limited access, Random access not possible, Limited capacity leading to overflow
class Stack: def __init__(self, maxSize): self.items = [""] * maxSize self.pointer = -1 def push(self, data): if self.pointer >= len(self.items) - 1: print("Stack Overflow") else: self.pointer += 1 self.items[self.pointer] = data def pop(self): if self.pointer == - 1: print("Stack Underflow") else: item = self.items[self.pointer] self.items[self.pointer] = "" self.pointer -= 1 return item #Printing items of the stack def Display(self): if self.Ptr == -1: print("Stack is empty") else: print("Stack elements:") for i in range(self.Ptr, -1, -1): print(self.items[i])

Queue

  • A list operating on the First In First Out principle (FIFO)
  • The first item added is the first item to be removed
  • Data is added from the rear end by using the EndPointer/TailPointer and removed from the front by using the StartPointer/HeadPointer
  • Enqueue: Add an element to the back of the queue.
  • Dequeue: Remove the element at the front of the queue.
  • isEmpty: Check if the queue is empty.
  • Applications: Task Scheduling in OS and printer, Handling request in web server, streaming, call centers, handling packets, messaging or ordering services, video or mp3 players, traffic management, physical queues like supermarket

  • Linear Queue: Normal queue following simple FIFO queue; cannot reuse freed spaces
  • Circular Queue: Last position is connected back to the first position to form a circle, it reuses empty spaces in the list
  • Advantages: Large data managed efficiency, multi user services
  • Disadvantages: Operations on middle elements are hard, maximum size defined before implementation

Code for linear queue

class Queue: def __init__(self, max_size=8): # Maximum no. of item queue can hold self.max_size = max_size # The queue is stored in a list (array) self.queue = [""] * max_size # Front (or head) points to the position of the first item self.front = 0 # Rear/Tail points to the position where the last item was added self.rear = -1 # Insert item into the queue (Enqueue) def enqueue(self, data): # Check if queue is full (rear is at last position) if self.rear >= self.max_size - 1: print("Queue overflow") else: # Move rear (tail) forward and add new item self.rear += 1 self.queue[self.rear] = data print(data, "added to the queue") # Delete item from the queue (Dequeue) def dequeue(self): # Check if queue is empty (no items left) if self.front > self.rear: print("Queue underflow") # Nothing to remove return None else: # Get the item from the front (head) item = self.queue[self.front] # Move front (head) one step forward self.front += 1 print(item, "removed from the queue") return item # Example usage q = Queue() # Create a new queue # Add items q.enqueue("Apple") q.enqueue("Banana") q.enqueue("Cherry") # Remove items q.dequeue() q.dequeue() q.dequeue() q.dequeue() # Will show underflow

Code for Circular Queue

class CircularQueue: def __init__(self, max_size=8): # Maximum number of items the queue can hold self.max_size = max_size # The queue is stored in a list (array) self.queue = [""] * max_size # Front (or head) points to the first item to be removed # Rear (or tail) points to the last item added self.front = -1 self.rear = -1 # Insert an item into the queue (Enqueue) def enqueue(self, data): # Check if queue is full: # If moving rear forward would make it equal to front, # that means the queue has wrapped around and is now full if (self.rear + 1) % self.max_size == self.front: print("Queue overflow") else: # If queue is currently empty (no items), # set both front and rear to 0 (starting position) if self.front == -1: self.front = 0 # Move rear forward in a circular manner # (wraps around to 0 when it reaches the end) self.rear = (self.rear + 1) % self.max_size # Store the new item at the rear position self.queue[self.rear] = data print(data, "added to the queue") # Remove an item from the queue (Dequeue) def dequeue(self): # Check if queue is empty (no items left) if self.front == -1: print("Queue underflow") return None else: # Get the item from the front (head) item = self.queue[self.front] # If there was only one element, # reset both front and rear to -1 (queue becomes empty) if self.front == self.rear: self.front = -1 self.rear = -1 else: # Move front forward in a circular manner self.front = (self.front + 1) % self.max_size print(item, "removed from the queue") return item # Example usage q = CircularQueue() # Create a circular queue # Add items q.enqueue("Apple") q.enqueue("Banana") q.enqueue("Cherry") # Remove one item q.dequeue() # Add another item (will reuse the freed space) q.enqueue("Mango")

Linked List

  • Nodes: Basic data structure which contains data and one or more links/pointers to other nodes. Nodes can be used to represent a tree structure or a linked list.
    • Node’s successor is the next node
    • Node's predecessor is the previous node
  • Pointers: Variable that stores the memory address of another variable as its value
  • A linked list is a data structure used to store a collection of items where each item is linked to the next one using pointers.
  • Traverse a linked list: Start at the first node and then go from node to node, following each node’s pointer to find the next node.
  • Two types depending on how data is sorted: ordered and unordered.
  • Linked List can be implemented using 2D Arrays (Singly)
  • The arrays are for data and the other for pointers
  • Uses:
    • Image viewer, Pages in Web browser, Music/Video Player, GPS, Task Scheduling, Symbol Table in Compilers, Undo and Redo
  • Advantage: Fast insertion and deletion
  • Disadvantage: Slow Search

Code for Linked List

class Node: #Repesents each element def __init__(self, data): self.data = data # Store the actual value of the node self.next = None # Pointer to the next node in the list (None if last node) class LinkedList: def __init__(self): self.head = None # Head points to the first node in the list, None if list is empty # Insert item at the end of the linked list def insert(self, data): new_node = Node(data) # Create a new node with given data if self.head == None: # If list is empty self.head = new_node # Make new node the head print(data, "inserted as head") return # Otherwise, traverse to the last node current = self.head while current.next != None: # While there is a next node current = current.next # Move to the next node current.next = new_node # Link the last node to the new node print(data, "inserted at the end") # Find an item in the linked list def find(self, key): current = self.head # Start from the head while current != None: # Traverse until the end if current.data == key: # If the data matches the key print(key, "found in the list") return True # Return True if found current = current.next # Move to next node print(key, "not found in the list") # Key was not found return False # Delete an item from the linked list def delete(self, key): current = self.head prev = None # To keep track of the previous node # Case 1: If head itself holds the key if current != None and current.data == key: self.head = current.next # Update head to next node print(key, "deleted from the list") return # Case 2: Search for the key in the list while current != None and current.data != key: prev = current # Keep track of previous node current = current.next # Move to next node # Key not found in the list if current == None: print(key, "not found in the list") return # Key found somewhere other than head prev.next = current.next # Bypass the node to delete it print(key, "deleted from the list") # Display all items in the linked list def display(self): current = self.head if current == None: # List is empty print("List is empty") return # Traverse the list and print each node's data while current != None: print(current.data, end=" -> ") current = current.next print("None") # End of list # Example usage ll = LinkedList() # Create a linked list # Insert nodes ll.insert(10) # Insert first node ll.insert(20) # Insert at end ll.insert(30) # Insert at end ll.display() # Display list: 10 -> 20 -> 30 -> None # Find nodes ll.find(20) # Should print found ll.find(40) # Should print not found # Delete nodes ll.delete(20) # Delete middle node ll.display() # Display list: 10 -> 30 -> None ll.delete(10) # Delete head node ll.display() # Display list: 30 -> None ll.delete(50) # Try to delete non-existent node

Binary Tree

  • Data structure in which each node has at most two children, referred to as the left child and the right child.
  • The nodes are arranged in a hierarchical structure, with a root node at the top and the leaves at the bottom
  • Traversing a tree
    • Preorder: Root → Left → Right (1 → 2 → 4 →8 → 9 → 5 → 10 → 3 → 6 → 7)
    • Inorder: Left → Root → Right (8 → 4 → 9 → 2 → 10 → 5 → 1 → 6 → 7 → 3)
    • Postorder: Left → Right → Root (8 → 9 → 4 → 10 → 5 → 2 → 6 → 7 → 3 → 1)

Binary Search Tree

A binary search tree (BST) is a binary tree in which each node has a value greater than all the values in its left subtree and less than all the values in its right subtree.
Advantage: Fast Search, insertion and deletion

Traversing in Binary Search Tree

  • Inorder
    • Left → Root → Right
    • 1 → 3 → 2 → 4 → 5 → 6 → 8 → 9 → 10 → 11
  • Preorder
    • Root → Left → Right
    • 8 → 4 → 3 → 1 → 2 → 6 → 5 → 10 → 9 → 11
  • Post Order
    • Left → Right → Root
    • 1 → 2 → 3 → 5 → 6 → 4 → 9 → 11 → 10 → 8
  • Searching for a particular data
    • Start at the root node and keep comparing
    • If search value smaller than current node, go left
    • If search value bigger than current node, go right
  • Finding Minimum Value
    • Go to the leftmost child (One with no left-child)
  • Finding Maximum Value
    • Go to the rightmost child (One with no right-child)
  • Adding/Inserting a Value
    • Start at the root and compare the value you want to insert.
    • Move left or right:
      • If the value is smaller than the current node, go to the left child.
      • If the value is larger than the current node, go to the right child.
    • Find the correct position:
      • If the tree is empty, insert the value at the root.
      • Otherwise, traverse until you find a leaf node (a node with no child in the required direction).
    • Insert the new node:
      • If the new value is smaller than the parent, make it the left child.
      • If the new value is larger than the parent, make it the right child.
      • For duplicate values, it is safest to avoid insertion, or optionally, insert as the right child of the duplicate

Code for Binary Tree

class BinaryTreeArray: def __init__(self, max_size=20): # Each node: [value, left_index, right_index] self.tree = [[None, -1, -1] for _ in range(max_size)] self.root_index = -1 # Index of root node self.next_free = 0 # Next free position in the array self.max_size = max_size # Insert a new value into the tree def insert(self, value): if self.next_free >= self.max_size: print("Tree full, cannot insert", value) return # Create new node at next free index node_index = self.next_free self.tree[node_index][0] = value self.tree[node_index][1] = -1 self.tree[node_index][2] = -1 self.next_free += 1 # If tree is empty, make new node root if self.root_index == -1: self.root_index = node_index print(value, "inserted as root") return # Otherwise, find correct parent current = self.root_index while True: if value < self.tree[current][0]: if self.tree[current][1] == -1: self.tree[current][1] = node_index print(value, "inserted as left child of", self.tree[current][0]) break else: current = self.tree[current][1] elif value > self.tree[current][0]: if self.tree[current][2] == -1: self.tree[current][2] = node_index print(value, "inserted as right child of", self.tree[current][0]) break else: current = self.tree[current][2] else: print("Duplicate key", value, "ignored") break # Find a value in the tree def find(self, value): current = self.root_index while current != -1: if self.tree[current][0] == value: print(value, "found in the tree") return True elif value < self.tree[current][0]: current = self.tree[current][1] else: current = self.tree[current][2] print(value, "not found in the tree") return False # Inorder traversal: Left, Root, Right def inorder(self, index=None): if index is None: index = self.root_index if index == -1: return self.inorder(self.tree[index][1]) # Left child print(self.tree[index][0], end=" ") # Root self.inorder(self.tree[index][2]) # Right child # Preorder traversal: Root, Left, Right def preorder(self, index=None): if index is None: index = self.root_index if index == -1: return print(self.tree[index][0], end=" ") # Root self.preorder(self.tree[index][1]) # Left child self.preorder(self.tree[index][2]) # Right child # Postorder traversal: Left, Right, Root def postorder(self, index=None): if index is None: index = self.root_index if index == -1: return self.postorder(self.tree[index][1]) # Left child self.postorder(self.tree[index][2]) # Right child print(self.tree[index][0], end=" ") # Root # Example usage bt = BinaryTreeArray() bt.insert(50) bt.insert(30) bt.insert(70) bt.insert(20) bt.insert(40) bt.insert(60) bt.insert(80) print("Inorder traversal: ", end="") bt.inorder() print() print("Preorder traversal: ", end="") bt.preorder() print() print("Postorder traversal: ", end="") bt.postorder() print() # Find values bt.find(60) # Found bt.find(25) # Not found

Graph

  • A graph is an Abstract Data Type (ADT) because it models relationships between objects without specifying implementation.
  • It defines operations like adding vertices and edges, and traversing connections.
  • Vertices (nodes): Represent objects.
  • Edges (links): Represent relationships
  • Traversal: Explore connections between vertices.
  • Use Cases:
    • Graphs are useful when relationships matter, e.g., social networks, transport routes, computer networks, or task dependencies
  • Weighted Graph: Edge has an associated value (weight), representing cost, distance, time, or capacity.
  • Directed Graph: Edges have a direction, going from one vertex to another

Examples of ADTs and Possible Implementations

  • Stack
    • Using lists/arrays: Append to the end and remove from the end.
    • Using linked lists: Insert and remove at the head.
  • Queue
    • Using lists/arrays: Append at the end and remove from the front (or use circular arrays).
    • Using linked lists: Insert at tail, remove from head
  • Linked List
    • Using nodes and pointers explicitly.
      Can also be emulated using arrays/lists where each element stores the value and index of the next element.
  • Dictionary: Stores key-value pairs, allowing retrieval of values by keys
    • Using hash tables (built-in dict in Python).
    • Using linked lists or binary trees for ordered or chained implementations
  • Binary Tree
    • Using nodes with pointers to left and right children.
    • Using arrays/2D arrays for complete binary trees where children indices are calculated.