Python Core Concepts: File I/O, Data Structures & Algorithms

Python File Handling Basics

Common modes for opening files:

  • open(filename, 'w'): Open for writing (truncates file if exists, creates if not).
  • open(filename, 'r'): Open for reading (default mode).
  • open(filename, 'a'): Open for appending (creates file if not exists).

Common file handle (fh) methods:

  • fh.read(): Reads the entire file content.
  • fh.readline(): Reads a single line from the file.
  • fh.readlines(): Reads all lines into a list of strings.
  • fh.write(string): Writes a string to the file.
  • fh.writelines(list_of_strings): Writes a list of strings to the file.
  • fh.close(): Closes the file handle, releasing resources.

File Handling Examples

1. Without Using the with Statement (Manual Close)

# Open file for writing
file = open('file_path', 'w')
# Write content
file.write('Hello world!')
# IMPORTANT: Close the file
file.close()

2. Without Using with Statement (Using Try-Finally)

This ensures the file is closed even if errors occur during writing.

# Open file for writing
file = open('file_path', 'w')
try:
    # Attempt to write content
    file.write('Hello world')
finally:
    # Ensure file is closed regardless of success or failure
    file.close()

3. Using the with Statement (Recommended)

The with statement automatically handles closing the file, even if errors occur.

# Open file for writing using 'with'
with open('file_path', 'w') as file:
    # Write content
    file.write('Hello world!')
# File is automatically closed here

Python Data Structures & Operations

Tuples

  • Variable Swapping: Tuples allow for easy variable swapping.
(x, y) = (y, x) # Swaps the values of x and y
Concatenation: Tuples can be concatenated using the + operator.
(2, 'two', 3) + (5, 6)  # Results in (2, 'two', 3, 5, 6)

Lists

  • Append: Adds an element to the end of the list.
L = [1, 2]
L.append(5)  # L becomes [1, 2, 5]
Extend: Appends elements from another iterable to the end.
L1 = [2, 1, 3]
L1.extend([0, 6])  # L1 becomes [2, 1, 3, 0, 6]
Delete by Index: Removes the element at a specific index.
L = [10, 20, 30]
del(L[1])  # L becomes [10, 30]
Pop: Removes and returns the last element (or element at a specified index).
L1 = [2, 1, 3, 0, 6]
last_element = L1.pop()  # last_element is 6, L1 becomes [2, 1, 3, 0]
element_at_index_1 = L1.pop(1) # element_at_index_1 is 1, L1 becomes [2, 3, 0]
Remove by Value: Removes the first occurrence of a specific element.
L1 = [2, 1, 3, 1]
L1.remove(1)  # L1 becomes [2, 3, 1]

String Operations

  • Convert String to List: Creates a list of characters from a string.
s = "hello"
char_list = list(s)  # char_list becomes ['h', 'e', 'l', 'l', 'o']
Split String: Splits a string into a list based on a delimiter.
s = "a-b-c"
parts = s.split('-')  # parts becomes ['a', 'b', 'c']
Join List into String: Concatenates elements of a list into a single string.
L = ['a', 'b', 'c']
joined_string = ''.join(L)  # joined_string becomes "abc"
joined_with_underscore = '_'.join(L) # joined_with_underscore becomes "a_b_c"
Finding Common Letters (Example):
s1 = 'itb u rock'
s2 = 'i rule itb'

# Find the first common letter
if len(s1) == len(s2):
    for char1 in s1:
        for char2 in s2:
            if char1 == char2:
                print('A common letter is:', char1)
                break # Exits inner loop after finding the first match for char1
        else:
            continue # Only executed if the inner loop did NOT break
        break # Exits outer loop after finding the first common letter overall
String Formatting (Legacy %-formatting):
x = 'looked'
print("Misha %s and %s around" % ('walked', x))
# Output: Misha walked and looked around

Note: F-strings (f"Misha {verb1} and {verb2} around") are the modern and preferred way for string formatting in Python 3.6+.

Dictionaries

Dictionaries store key-value pairs.

  • Creation:
my_dict = {}
grades = {"Anna": "B", "John": "A", "Ichi": "A"}
Accessing Values: Use the key in square brackets. Accessing a non-existent key raises a KeyError.
print(grades['John'])  # Output: A
Adding/Updating Entries: Assign a value to a key.
grades['Sylvan'] = "A"  # Adds Sylvan: A
grades['Anna'] = "A+" # Updates Anna's grade
Testing Key Existence: Use the in keyword.
print('John' in grades)  # Output: True
print('Peter' in grades) # Output: False
Deleting Entries: Use the del keyword.
del(grades["Anna"])
Getting Keys: Returns a view object containing the dictionary’s keys.
keys_list = list(grades.keys()) # Convert view to list if needed
Getting Values: Returns a view object containing the dictionary’s values.
values_list = list(grades.values()) # Convert view to list if needed

Matrix Operations in Python

Functions for basic matrix operations.


def create_matrix(rows, columns):
    """Creates a matrix filled with zeros."""
    matrix = []
    for i in range(rows):
        row = []
        for j in range(columns):
            row.append(0)
        matrix.append(row)
    return matrix

def add_matrices(matrix1, matrix2):
    """Adds two matrices element-wise."""
    rows1, columns1 = len(matrix1), len(matrix1[0])
    rows2, columns2 = len(matrix2), len(matrix2[0])

    if rows1 != rows2 or columns1 != columns2:
        raise ValueError("Matrix dimensions must match for addition")

    result_matrix = []
    for i in range(rows1):
        row = []
        for j in range(columns1):
            sum_element = matrix1[i][j] + matrix2[i][j]
            row.append(sum_element)
        result_matrix.append(row)
    return result_matrix

def multiply_matrices(matrix_a, matrix_b):
    """Multiplies two matrices."""
    rows_a, columns_a = len(matrix_a), len(matrix_a[0])
    rows_b, columns_b = len(matrix_b), len(matrix_b[0])

    if columns_a != rows_b:
        raise ValueError("Matrix dimensions are incompatible for multiplication (columns_a != rows_b)")

    result_matrix = []
    for i in range(rows_a):
        row = []
        for j in range(columns_b):
            # Initialize the element at (i, j) to 0
            element = 0
            for k in range(columns_a):  # or range(rows_b)
                element += matrix_a[i][k] * matrix_b[k][j]
            row.append(element)
        result_matrix.append(row)
    return result_matrix

def create_submatrix(matrix, row_number_to_remove, column_number_to_remove):
    """Creates a submatrix by removing a specified row and column (1-based index)."""
    # Adjust input row and column numbers to zero-based indexing
    row_idx_to_remove = row_number_to_remove - 1
    col_idx_to_remove = column_number_to_remove - 1

    # Check if the input row and column numbers are within valid bounds
    if not (0 <= row_idx_to_remove < len(matrix)) or not (0 <= col_idx_to_remove < len(matrix[0])):
        raise ValueError("Invalid row or column number for submatrix creation")

    # Create the submatrix using list comprehension (alternative 1)
    # submatrix = [
    #     row[:col_idx_to_remove] + row[col_idx_to_remove + 1:]
    #     for row_index, row in enumerate(matrix)
    #     if row_index != row_idx_to_remove
    # ]

    # Create the submatrix using nested list comprehension (alternative 2)
    submatrix = [
        [matrix[i][j] for j in range(len(matrix[0])) if j != col_idx_to_remove]
        for i in range(len(matrix)) if i != row_idx_to_remove
    ]

    return submatrix

Python Exception Handling Example

This program demonstrates defining custom exceptions and handling them using try...except...finally blocks.


# Define custom exception classes
class Exc0(Exception):
    def __init__(self, msg):
        self.message = msg
    def __str__(self):
        return self.message

class Exc1(Exc0): # Exc1 inherits from Exc0
    def __init__(self, msg):
        super().__init__(msg)

# --- First Try-Except-Finally Block ---
try:
    print("Raising Exc1...")
    raise Exc1("Exc1 occurred") # Raise an instance of Exc1
except Exc0 as e: # Catches Exc1 because Exc1 is a subclass of Exc0
    print("Exc0 caught")
    print(e) # Prints the message from the caught exception
finally:
    # This block always executes
    print("Finally done")

print("\n---\n") # Separator

# --- Second Try-Except-Finally Block ---
try:
    print("Raising Exc0...")
    raise Exc0("Exc0 occurred") # Raise an instance of Exc0
except Exc1 as e: # This block will NOT catch Exc0
    print("Exc1 caught")
    print(e)
except Exc0 as e: # This block WILL catch Exc0
    print("Exc0 caught again")
    print(e)
finally:
    # This block always executes
    print("Finally again")

print("\nEnd of program")

Explanation (Indonesian & English)

Program ini mendefinisikan dua kelas pengecualian (exception), Exc0 dan Exc1, di mana Exc1 merupakan subkelas dari Exc0. Kedua kelas ini memiliki metode __init__ untuk menginisialisasi pesan error dan metode __str__ untuk mengembalikan pesan error tersebut ketika objek pengecualian dicetak.

(This program defines two exception classes, Exc0 and Exc1, where Exc1 is a subclass of Exc0. Both classes have an __init__ method to initialize an error message and a __str__ method to return that error message when the exception object is printed.)

Blok Pertama Try-Except-Finally

Dalam blok try pertama, sebuah objek pengecualian Exc1 dibangkitkan menggunakan pernyataan raise.
Karena Exc1 adalah subkelas dari Exc0, maka pengecualian ini ditangkap oleh blok except Exc0 as e.
Program mencetak “Exc0 caught” dan kemudian mencetak pesan dari objek pengecualian Exc1, yaitu “Exc1 occurred”.
Setelah itu, blok finally dieksekusi, mencetak “Finally done”.

(In the first try block, an Exc1 exception object is raised using the raise statement.
Because Exc1 is a subclass of Exc0, this exception is caught by the except Exc0 as e: block.
The program prints “Exc0 caught” and then prints the message from the Exc1 exception object, which is “Exc1 occurred”.
After that, the finally block is executed, printing “Finally done”.)

Blok Kedua Try-Except-Finally

Dalam blok try kedua, sebuah objek pengecualian Exc0 dibangkitkan.
Pengecualian ini tidak dapat ditangkap oleh blok except Exc1 as e karena Exc0 bukanlah subkelas dari Exc1.
Blok except Exc0 as e ditambahkan untuk menangkap pengecualian ini.
Program mencetak “Exc0 caught again” dan kemudian mencetak pesan dari objek pengecualian Exc0, yaitu “Exc0 occurred”.
Setelah itu, blok finally dieksekusi, mencetak “Finally again”.

(In the second try block, an Exc0 exception object is raised.
This exception cannot be caught by the except Exc1 as e: block because Exc0 is not a subclass of Exc1.
The except Exc0 as e: block is added to catch this exception.
The program prints “Exc0 caught again” and then prints the message from the Exc0 exception object, which is “Exc0 occurred”.
After that, the finally block is executed, printing “Finally again”.)

Akhir Program

Program kemudian mencetak “End of program”, menandakan akhir dari eksekusi program.

(The program then prints “End of program”, indicating the end of the program’s execution.)

Expected Output:

Raising Exc1...
Exc0 caught
Exc1 occurred
Finally done

---

Raising Exc0...
Exc0 caught again
Exc0 occurred
Finally again

End of program

Object-Oriented Programming in Python

Example demonstrating classes, inheritance, method overriding, and operator overloading.


import math # Needed if using more complex shapes, not strictly for Rectangle perimeter

class Shape(object):
    """Base class for geometric shapes."""
    def __init__(self, type_of_shape: str):
        self.type_of_shape = type_of_shape

    def __str__(self):
        return "I am a " + self.type_of_shape

class Polygon(Shape):
    """Represents shapes with straight sides."""
    def __init__(self, type_of_shape, side_lengths: list):
        super().__init__(type_of_shape)
        # It's generally better to initialize instance variables in __init__
        self.side_lengths = side_lengths if side_lengths is not None else []

    def compute_perimeter(self):
        """Calculates the perimeter of the polygon."""
        perimeter = 0
        for length in self.side_lengths:
            perimeter += length
        return perimeter

    def get_number_of_sides(self):
        """Returns the number of sides of the polygon."""
        return len(self.side_lengths)

    def __gt__(self, other):
        """Overloads the greater than operator to compare perimeters."""
        if not isinstance(other, Polygon):
            return NotImplemented # Indicate comparison is not supported

        self_perimeter = self.compute_perimeter()
        other_perimeter = other.compute_perimeter()

        if self_perimeter > other_perimeter:
            return True
        # Optional: Handle equality specifically if needed, 
        # but standard > doesn't return 'Same'.
        # elif self_perimeter == other_perimeter:
        #     return 'Same' # Custom return, not standard for >
        else:
            return False

class Rectangle(Polygon):
    """Represents a rectangle, inheriting from Polygon."""
    def __init__(self, side_lengths: list):
        # Basic validation: A rectangle needs 4 sides, opposite sides equal
        # For simplicity here, we assume valid input or could add checks.
        # If just given [length, width], duplicate them: [l, w, l, w]
        if len(side_lengths) == 2:
             side_lengths = [side_lengths[0], side_lengths[1], side_lengths[0], side_lengths[1]]
        elif len(side_lengths) != 4:
             raise ValueError("Rectangle requires 2 or 4 side lengths.")
        # Could add check: side_lengths[0] == side_lengths[2] and side_lengths[1] == side_lengths[3]

        super().__init__('rectangle', side_lengths)
        # self.side_lengths is already set by the Polygon's __init__

    def __str__(self):
        """Provides a string representation of the rectangle."""
        # More specific representation for a rectangle
        if len(self.side_lengths) == 4:
             return f"A rectangle with sides {self.side_lengths[0]}, {self.side_lengths[1]}, {self.side_lengths[2]}, {self.side_lengths[3]}"
        else:
             return super().__str__() # Fallback if sides aren't typical

    def compute_area(self):
         """Calculates the area of the rectangle."""
         if len(self.side_lengths) == 4:
              # Assuming standard rectangle where sides are [l, w, l, w]
              return self.side_lengths[0] * self.side_lengths[1]
         else:
              return 0 # Or raise error

# --- Example Usage ---
p1 = Rectangle([1, 2, 1, 2]) # Perimeter = 6
p2 = Rectangle([1, 2, 3, 5]) # Not a valid rectangle, but Polygon logic works. Perimeter = 11
p3 = Rectangle([2, 3])      # Creates [2, 3, 2, 3]. Perimeter = 10

print(f"p1: {p1}, Perimeter: {p1.compute_perimeter()}, Area: {p1.compute_area()}")
print(f"p2: {p2}, Perimeter: {p2.compute_perimeter()}") # Area method not applicable if not valid rect
print(f"p3: {p3}, Perimeter: {p3.compute_perimeter()}, Area: {p3.compute_area()}")

print("\nComparing p2 and p1 based on perimeter:")
if p2 > p1:
    print(f"p2's perimeter ({p2.compute_perimeter()}) is greater than p1's ({p1.compute_perimeter()})")
    print(p2)
elif p1 > p2: # Check the opposite explicitly
    print(f"p1's perimeter ({p1.compute_perimeter()}) is greater than p2's ({p2.compute_perimeter()})")
    print(p1)
else:
    # This covers equality or if comparison returned something unexpected
    print(f"The perimeters are equal (p1: {p1.compute_perimeter()}, p2: {p2.compute_perimeter()})")

Coin Change Problem: Dynamic Programming

Finding the minimum number of coins required to make a given amount using a set of denominations.


def coin_change(coins, amount):
    """Calculates the minimum number of coins to make the amount.

    Args:
        coins (list): A list of available coin denominations.
        amount (int): The target amount.

    Returns:
        int: The minimum number of coins, or -1 if the amount cannot be made.
    """
    # Initialize dp array with a value indicating impossibility (infinity)
    # dp[i] will store the minimum coins needed to make amount i
    dp = [float('inf')] * (amount + 1)

    # Base case: 0 coins are needed to make amount 0
    dp[0] = 0

    # Iterate through each coin denomination
    for coin in coins:
        # Update dp array for amounts from the coin value up to the target amount
        for i in range(coin, amount + 1):
            # If the amount (i - coin) can be made,
            # consider using the current 'coin'.
            if dp[i - coin] != float('inf'):
                # Update dp[i] if using this 'coin' results in fewer total coins
                dp[i] = min(dp[i], dp[i - coin] + 1)

    # If dp[amount] is still infinity, the amount cannot be made
    # Otherwise, return the minimum number of coins found
    return dp[amount] if dp[amount] != float('inf') else -1

# --- Example Usage ---
denominations = [2, 3, 5, 10, 20, 30, 50]
amount = 78

min_coins = coin_change(denominations, amount)

if min_coins != -1:
    print(f"Minimum number of coins needed for amount {amount}: {min_coins}")
else:
    print(f"Amount {amount} cannot be made with the given denominations.")

# Example Output for amount 78: Minimum number of coins needed: 4 (e.g., 50 + 20 + 5 + 3)

Complexity Analysis

  • Time Complexity: O(amount * n), where amount is the target amount and n is the number of coin denominations. This is because we have nested loops: the outer loop iterates through n coins, and the inner loop iterates up to amount times for each coin.
  • Space Complexity: O(amount). This is due to the space required for the dp array, which has a size of amount + 1.