Medium Level - Complete Solutions#

This notebook contains solutions to all exercises from the Medium level lessons.

How to Use This Notebook#

  1. Attempt the exercises first - Try solving them in the original notebooks

  2. Study multiple approaches - Many problems have several valid solutions

  3. Understand the concepts - Donโ€™t just copy; understand why each solution works

  4. Optimize your code - Compare time and space complexity

  5. Experiment - Modify these solutions to deepen your understanding

Contents#

  1. 01 - Functions and Modules

  2. 02 - Data Structures

  3. 03 - Classes and OOP

  4. 04 - Machine Learning Basics

  5. 05 - Data Analysis with Pandas

  6. 06 - Algorithms and Problem Solving


01 - Functions and Modules#

Solutions for exercises from 01_functions_and_modules.ipynb

Exercise Solutions#

The Functions and Modules notebook includes exercises on:

  • Creating functions with various parameter types

  • Using *args and **kwargs

  • Lambda functions and functional programming

  • Decorators

  • Module organization

# Example Solution 1: Function with default parameters
def greet_user(name, greeting="Hello", punctuation="!"):
    """
    Greet a user with customizable greeting and punctuation.
    
    Args:
        name (str): User's name
        greeting (str): Greeting word (default: "Hello")
        punctuation (str): Ending punctuation (default: "!")
    
    Returns:
        str: Formatted greeting
    """
    return f"{greeting}, {name}{punctuation}"

# Test
print(greet_user("Alice"))
print(greet_user("Bob", greeting="Hi"))
print(greet_user("Charlie", greeting="Hey", punctuation="."))
# Example Solution 2: Using *args and **kwargs
def analyze_scores(*scores, **settings):
    """
    Analyze test scores with optional settings.
    
    Args:
        *scores: Variable number of numeric scores
        **settings: Optional settings (show_details, round_to, etc.)
    
    Returns:
        dict: Analysis results
    """
    if not scores:
        return {"error": "No scores provided"}
    
    average = sum(scores) / len(scores)
    minimum = min(scores)
    maximum = max(scores)
    
    # Apply settings
    if settings.get("round_to"):
        average = round(average, settings["round_to"])
    
    results = {
        "count": len(scores),
        "average": average,
        "min": minimum,
        "max": maximum,
        "range": maximum - minimum
    }
    
    if settings.get("show_details"):
        results["all_scores"] = scores
    
    return results

# Test
print(analyze_scores(85, 90, 78, 92, 88))
print(analyze_scores(85, 90, 78, 92, 88, round_to=1))
print(analyze_scores(85, 90, 78, 92, 88, show_details=True, round_to=2))
# Example Solution 3: Higher-order functions
def apply_operation(numbers, operation):
    """
    Apply an operation to all numbers.
    
    Args:
        numbers (list): List of numbers
        operation (callable): Function to apply
    
    Returns:
        list: Transformed numbers
    """
    return [operation(num) for num in numbers]

# Example operations
def square(x):
    return x ** 2

def double(x):
    return x * 2

# Test
data = [1, 2, 3, 4, 5]
print(f"Original: {data}")
print(f"Squared: {apply_operation(data, square)}")
print(f"Doubled: {apply_operation(data, double)}")
print(f"Custom (x+10): {apply_operation(data, lambda x: x + 10)}")
# Example Solution 4: Decorator for timing functions
import time
from functools import wraps

def timer(func):
    """Decorator to time function execution."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.6f} seconds")
        return result
    return wrapper

@timer
def slow_calculation(n):
    """Simulate slow calculation."""
    total = 0
    for i in range(n):
        total += i ** 2
    return total

# Test
result = slow_calculation(100000)
print(f"Result: {result}")
# Example Solution 5: Closure example
def create_multiplier(factor):
    """
    Create a function that multiplies by a factor.
    Demonstrates closures.
    """
    def multiplier(x):
        return x * factor
    return multiplier

# Create specialized multipliers
double = create_multiplier(2)
triple = create_multiplier(3)
times_ten = create_multiplier(10)

# Test
value = 5
print(f"Original: {value}")
print(f"Doubled: {double(value)}")
print(f"Tripled: {triple(value)}")
print(f"Times 10: {times_ten(value)}")

02 - Data Structures#

Solutions for exercises from 02_data_structures.ipynb

# Example Solution 1: List comprehensions
def analyze_numbers(numbers):
    """
    Use comprehensions to analyze a list of numbers.
    """
    # Filter evens
    evens = [n for n in numbers if n % 2 == 0]
    
    # Filter odds
    odds = [n for n in numbers if n % 2 != 0]
    
    # Square all numbers
    squared = [n ** 2 for n in numbers]
    
    # Get numbers divisible by 3
    div_by_3 = [n for n in numbers if n % 3 == 0]
    
    return {
        "evens": evens,
        "odds": odds,
        "squared": squared,
        "divisible_by_3": div_by_3
    }

# Test
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
results = analyze_numbers(data)
for key, value in results.items():
    print(f"{key}: {value}")
# Example Solution 2: Dictionary operations
def merge_student_data(student_info, grades, attendance):
    """
    Merge student data from multiple dictionaries.
    
    Args:
        student_info: Dict with student names and IDs
        grades: Dict with student grades
        attendance: Dict with attendance percentages
    
    Returns:
        dict: Merged student records
    """
    merged = {}
    
    for student_id, name in student_info.items():
        merged[student_id] = {
            "name": name,
            "grade": grades.get(student_id, "N/A"),
            "attendance": attendance.get(student_id, 0)
        }
    
    return merged

# Test
students = {"001": "Alice", "002": "Bob", "003": "Charlie"}
grades = {"001": 95, "002": 87, "003": 92}
attendance = {"001": 98, "002": 85, "003": 100}

merged_data = merge_student_data(students, grades, attendance)
for student_id, data in merged_data.items():
    print(f"{student_id}: {data}")
# Example Solution 3: Set operations
def analyze_course_enrollment(course_a, course_b):
    """
    Analyze enrollment in two courses using sets.
    
    Args:
        course_a: Set of student IDs in course A
        course_b: Set of student IDs in course B
    
    Returns:
        dict: Analysis of enrollment patterns
    """
    return {
        "both_courses": course_a & course_b,  # Intersection
        "either_course": course_a | course_b,  # Union
        "only_a": course_a - course_b,  # Difference
        "only_b": course_b - course_a,  # Difference
        "unique_students": course_a ^ course_b,  # Symmetric difference
        "total_a": len(course_a),
        "total_b": len(course_b)
    }

# Test
python_course = {"S001", "S002", "S003", "S004", "S005"}
ml_course = {"S003", "S004", "S006", "S007"}

analysis = analyze_course_enrollment(python_course, ml_course)
print("Enrollment Analysis:")
for key, value in analysis.items():
    print(f"  {key}: {value}")
# Example Solution 4: Using collections module
from collections import Counter, defaultdict, deque

def word_frequency_analysis(text):
    """
    Analyze word frequency in text using Counter.
    """
    words = text.lower().split()
    word_counts = Counter(words)
    
    return {
        "most_common": word_counts.most_common(5),
        "total_words": sum(word_counts.values()),
        "unique_words": len(word_counts),
        "all_counts": dict(word_counts)
    }

# Test
sample_text = """Python is great and Python is powerful Python makes 
programming fun and Python is easy to learn"""

analysis = word_frequency_analysis(sample_text)
print("Word Frequency Analysis:")
print(f"Total words: {analysis['total_words']}")
print(f"Unique words: {analysis['unique_words']}")
print(f"\nTop 5 words: {analysis['most_common']}")
# Example Solution 5: Nested data structures
def process_student_records(records):
    """
    Process nested student records.
    
    Args:
        records: List of student dictionaries with nested grade data
    
    Returns:
        dict: Processed analytics
    """
    total_students = len(records)
    all_averages = []
    
    for student in records:
        grades = student.get("grades", {})
        if grades:
            avg = sum(grades.values()) / len(grades)
            student["average"] = round(avg, 2)
            all_averages.append(avg)
    
    class_average = sum(all_averages) / len(all_averages) if all_averages else 0
    
    return {
        "total_students": total_students,
        "class_average": round(class_average, 2),
        "highest_average": round(max(all_averages), 2) if all_averages else 0,
        "lowest_average": round(min(all_averages), 2) if all_averages else 0
    }

# Test
students = [
    {"name": "Alice", "grades": {"math": 95, "science": 92, "english": 88}},
    {"name": "Bob", "grades": {"math": 87, "science": 90, "english": 85}},
    {"name": "Charlie", "grades": {"math": 92, "science": 88, "english": 94}}
]

analytics = process_student_records(students)
print("Class Analytics:")
for key, value in analytics.items():
    print(f"  {key}: {value}")

03 - Classes and OOP#

Solutions for exercises from 03_classes_and_oop.ipynb

# Example Solution 1: Basic class with inheritance
class BankAccount:
    """Base class for bank accounts."""
    
    def __init__(self, account_number, owner, balance=0):
        self.account_number = account_number
        self.owner = owner
        self._balance = balance  # Protected attribute
    
    def deposit(self, amount):
        """Deposit money into account."""
        if amount > 0:
            self._balance += amount
            return f"Deposited ${amount}. New balance: ${self._balance}"
        return "Invalid deposit amount"
    
    def withdraw(self, amount):
        """Withdraw money from account."""
        if amount > 0 and amount <= self._balance:
            self._balance -= amount
            return f"Withdrew ${amount}. New balance: ${self._balance}"
        return "Insufficient funds or invalid amount"
    
    def get_balance(self):
        """Get current balance."""
        return self._balance
    
    def __str__(self):
        return f"Account({self.account_number}): {self.owner} - ${self._balance}"

class SavingsAccount(BankAccount):
    """Savings account with interest."""
    
    def __init__(self, account_number, owner, balance=0, interest_rate=0.02):
        super().__init__(account_number, owner, balance)
        self.interest_rate = interest_rate
    
    def add_interest(self):
        """Add interest to balance."""
        interest = self._balance * self.interest_rate
        self._balance += interest
        return f"Added interest: ${interest:.2f}. New balance: ${self._balance:.2f}"

# Test
account = SavingsAccount("SA001", "Alice", 1000, 0.05)
print(account)
print(account.deposit(500))
print(account.add_interest())
print(account.withdraw(200))
print(account)
# Example Solution 2: Class with properties and validation
class Student:
    """Student class with property validation."""
    
    def __init__(self, name, age, student_id):
        self.name = name
        self._age = None
        self.age = age  # Use property setter
        self.student_id = student_id
        self._grades = []
    
    @property
    def age(self):
        """Get age."""
        return self._age
    
    @age.setter
    def age(self, value):
        """Set age with validation."""
        if value < 0 or value > 150:
            raise ValueError("Age must be between 0 and 150")
        self._age = value
    
    def add_grade(self, grade):
        """Add a grade."""
        if 0 <= grade <= 100:
            self._grades.append(grade)
        else:
            raise ValueError("Grade must be between 0 and 100")
    
    @property
    def average_grade(self):
        """Calculate average grade."""
        if not self._grades:
            return 0
        return sum(self._grades) / len(self._grades)
    
    def __repr__(self):
        return f"Student('{self.name}', {self.age}, '{self.student_id}')"

# Test
student = Student("Bob", 20, "S12345")
student.add_grade(85)
student.add_grade(90)
student.add_grade(88)
print(student)
print(f"Average grade: {student.average_grade:.2f}")
# Example Solution 3: Using dataclasses
from dataclasses import dataclass, field
from typing import List

@dataclass
class Book:
    """Book dataclass with automatic __init__, __repr__, etc."""
    title: str
    author: str
    isbn: str
    price: float
    genres: List[str] = field(default_factory=list)
    
    def apply_discount(self, percent):
        """Apply discount to price."""
        self.price = self.price * (1 - percent / 100)
        return self.price
    
    def add_genre(self, genre):
        """Add a genre."""
        if genre not in self.genres:
            self.genres.append(genre)

# Test
book = Book(
    title="Python Programming",
    author="John Doe",
    isbn="978-1234567890",
    price=49.99
)
book.add_genre("Programming")
book.add_genre("Education")
print(book)
print(f"Price after 20% discount: ${book.apply_discount(20):.2f}")
# Example Solution 4: Abstract base class
from abc import ABC, abstractmethod

class Shape(ABC):
    """Abstract base class for shapes."""
    
    @abstractmethod
    def area(self):
        """Calculate area."""
        pass
    
    @abstractmethod
    def perimeter(self):
        """Calculate perimeter."""
        pass

class Rectangle(Shape):
    """Rectangle implementation."""
    
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)
    
    def __str__(self):
        return f"Rectangle({self.width}x{self.height})"

class Circle(Shape):
    """Circle implementation."""
    
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        import math
        return math.pi * self.radius ** 2
    
    def perimeter(self):
        import math
        return 2 * math.pi * self.radius
    
    def __str__(self):
        return f"Circle(r={self.radius})"

# Test
shapes = [
    Rectangle(5, 10),
    Circle(7)
]

for shape in shapes:
    print(f"{shape}:")
    print(f"  Area: {shape.area():.2f}")
    print(f"  Perimeter: {shape.perimeter():.2f}")
    print()

04 - Machine Learning Basics#

Solutions for exercises from 04_machine_learning_basics.ipynb

# Example Solution 1: Complete ML workflow
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report

# Load data
iris = load_iris()
X, y = iris.data, iris.target

# Split data
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Scale features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

# Train model
model = LogisticRegression(random_state=42, max_iter=200)
model.fit(X_train_scaled, y_train)

# Predict
y_pred = model.predict(X_test_scaled)

# Evaluate
accuracy = accuracy_score(y_test, y_pred)
print(f"Accuracy: {accuracy:.4f}")
print("\nClassification Report:")
print(classification_report(y_test, y_pred, target_names=iris.target_names))
# Example Solution 2: Cross-validation and hyperparameter tuning
from sklearn.model_selection import cross_val_score, GridSearchCV
from sklearn.ensemble import RandomForestClassifier

# Perform cross-validation
rf_model = RandomForestClassifier(random_state=42)
cv_scores = cross_val_score(rf_model, X_train_scaled, y_train, cv=5)

print("Cross-Validation Scores:")
print(f"Scores: {cv_scores}")
print(f"Mean: {cv_scores.mean():.4f} (+/- {cv_scores.std() * 2:.4f})")

# Hyperparameter tuning
param_grid = {
    'n_estimators': [50, 100, 200],
    'max_depth': [None, 5, 10],
    'min_samples_split': [2, 5]
}

grid_search = GridSearchCV(
    RandomForestClassifier(random_state=42),
    param_grid,
    cv=5,
    scoring='accuracy'
)

grid_search.fit(X_train_scaled, y_train)

print("\nBest Parameters:", grid_search.best_params_)
print(f"Best CV Score: {grid_search.best_score_:.4f}")

# Test best model
best_model = grid_search.best_estimator_
test_accuracy = best_model.score(X_test_scaled, y_test)
print(f"Test Accuracy: {test_accuracy:.4f}")
# Example Solution 3: Feature importance analysis
import numpy as np
import matplotlib.pyplot as plt

# Train Random Forest to get feature importances
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train_scaled, y_train)

# Get feature importances
importances = rf.feature_importances_
indices = np.argsort(importances)[::-1]

print("Feature Importances:")
for i, idx in enumerate(indices):
    print(f"{i+1}. {iris.feature_names[idx]}: {importances[idx]:.4f}")

# Visualization would go here in a real notebook
# plt.figure(figsize=(10, 6))
# plt.bar(range(len(importances)), importances[indices])
# plt.xticks(range(len(importances)), [iris.feature_names[i] for i in indices], rotation=45)
# plt.title('Feature Importances')
# plt.show()

05 - Data Analysis with Pandas#

Solutions for exercises from 05_data_analysis_with_pandas.ipynb

# Example Solution 1: Data cleaning and analysis
import pandas as pd
import numpy as np

# Create sample data
data = {
    'Name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
    'Age': [25, np.nan, 35, 28, 32],
    'Salary': [50000, 60000, np.nan, 55000, 65000],
    'Department': ['HR', 'IT', 'IT', 'HR', 'Finance']
}

df = pd.DataFrame(data)

print("Original Data:")
print(df)
print(f"\nMissing values:\n{df.isnull().sum()}")

# Fill missing values
df['Age'].fillna(df['Age'].median(), inplace=True)
df['Salary'].fillna(df['Salary'].mean(), inplace=True)

print("\nAfter filling missing values:")
print(df)

# Group by analysis
dept_stats = df.groupby('Department').agg({
    'Age': ['mean', 'min', 'max'],
    'Salary': ['mean', 'sum']
})

print("\nDepartment Statistics:")
print(dept_stats)
# Example Solution 2: Data merging and transformation
# Create two dataframes
employees = pd.DataFrame({
    'EmployeeID': [1, 2, 3, 4],
    'Name': ['Alice', 'Bob', 'Charlie', 'David'],
    'DepartmentID': [10, 20, 20, 10]
})

departments = pd.DataFrame({
    'DepartmentID': [10, 20, 30],
    'DepartmentName': ['HR', 'IT', 'Finance'],
    'Location': ['Building A', 'Building B', 'Building C']
})

print("Employees:")
print(employees)
print("\nDepartments:")
print(departments)

# Merge dataframes
merged = pd.merge(employees, departments, on='DepartmentID', how='left')

print("\nMerged Data:")
print(merged)

# Create pivot table
pivot = merged.pivot_table(
    values='EmployeeID',
    index='DepartmentName',
    aggfunc='count'
)

print("\nEmployees per Department:")
print(pivot)
# Example Solution 3: Time series analysis
# Create time series data
dates = pd.date_range('2024-01-01', periods=30, freq='D')
sales_data = pd.DataFrame({
    'Date': dates,
    'Sales': np.random.randint(100, 500, size=30)
})

sales_data.set_index('Date', inplace=True)

print("Sales Data:")
print(sales_data.head())

# Calculate rolling average
sales_data['Rolling_7day'] = sales_data['Sales'].rolling(window=7).mean()

# Resample to weekly
weekly_sales = sales_data['Sales'].resample('W').sum()

print("\nWeekly Sales:")
print(weekly_sales)

# Summary statistics
print("\nSummary Statistics:")
print(sales_data.describe())

06 - Algorithms and Problem Solving#

Solutions for exercises from 06_algorithms_and_problem_solving.ipynb

# Exercise 1: Array Manipulation Solutions

def find_duplicates(arr):
    """
    Find all duplicate elements in array.
    Time: O(n), Space: O(n)
    """
    seen = set()
    duplicates = set()
    
    for num in arr:
        if num in seen:
            duplicates.add(num)
        seen.add(num)
    
    return list(duplicates)

def reverse_array(arr):
    """
    Reverse array in-place.
    Time: O(n), Space: O(1)
    """
    left, right = 0, len(arr) - 1
    
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]
        left += 1
        right -= 1
    
    return arr

def rotate_array(arr, k):
    """
    Rotate array k positions to the right.
    Time: O(n), Space: O(1)
    """
    n = len(arr)
    k = k % n  # Handle k > n
    
    # Reverse entire array
    reverse_array(arr)
    # Reverse first k elements
    arr[:k] = reversed(arr[:k])
    # Reverse remaining elements
    arr[k:] = reversed(arr[k:])
    
    return arr

def find_missing_number(arr, n):
    """
    Find missing number from 1 to n.
    Time: O(n), Space: O(1)
    """
    # Sum of 1 to n
    expected_sum = n * (n + 1) // 2
    actual_sum = sum(arr)
    
    return expected_sum - actual_sum

# Test
print("Duplicates:", find_duplicates([1, 2, 3, 2, 4, 5, 3]))
print("Reversed:", reverse_array([1, 2, 3, 4, 5]))
print("Rotated:", rotate_array([1, 2, 3, 4, 5], 2))
print("Missing:", find_missing_number([1, 2, 4, 5, 6], 6))
# Exercise 2: String Algorithms Solutions

def is_anagram(s1, s2):
    """
    Check if two strings are anagrams.
    Time: O(n log n), Space: O(1)
    """
    return sorted(s1.lower()) == sorted(s2.lower())

def compress_string(s):
    """
    Compress string using counts.
    Time: O(n), Space: O(n)
    """
    if not s:
        return ""
    
    result = []
    count = 1
    
    for i in range(1, len(s)):
        if s[i] == s[i-1]:
            count += 1
        else:
            result.append(s[i-1] + str(count))
            count = 1
    
    # Add last group
    result.append(s[-1] + str(count))
    
    return ''.join(result)

def longest_palindrome_substring(s):
    """
    Find longest palindromic substring.
    Time: O(nยฒ), Space: O(1)
    """
    def expand_around_center(left, right):
        while left >= 0 and right < len(s) and s[left] == s[right]:
            left -= 1
            right += 1
        return s[left+1:right]
    
    if len(s) < 2:
        return s
    
    longest = ""
    
    for i in range(len(s)):
        # Odd length palindrome
        pal1 = expand_around_center(i, i)
        # Even length palindrome
        pal2 = expand_around_center(i, i+1)
        
        longest = max([longest, pal1, pal2], key=len)
    
    return longest

# Test
print("Anagram:", is_anagram("listen", "silent"))
print("Compressed:", compress_string("aaabbc"))
print("Longest palindrome:", longest_palindrome_substring("babad"))
# Exercise 3: Advanced Algorithms Solutions

def merge_sorted_arrays(arr1, arr2):
    """
    Merge two sorted arrays.
    Time: O(n + m), Space: O(n + m)
    """
    result = []
    i, j = 0, 0
    
    while i < len(arr1) and j < len(arr2):
        if arr1[i] <= arr2[j]:
            result.append(arr1[i])
            i += 1
        else:
            result.append(arr2[j])
            j += 1
    
    # Add remaining elements
    result.extend(arr1[i:])
    result.extend(arr2[j:])
    
    return result

def kth_largest_element(arr, k):
    """
    Find kth largest element.
    Time: O(n log n), Space: O(1)
    """
    # Sort in descending order
    arr_sorted = sorted(arr, reverse=True)
    
    if k <= len(arr_sorted):
        return arr_sorted[k-1]
    return None

def two_sum(arr, target):
    """
    Find two numbers that sum to target.
    Time: O(n), Space: O(n)
    """
    seen = {}
    
    for i, num in enumerate(arr):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
    
    return None

# Test
print("Merged:", merge_sorted_arrays([1, 3, 5], [2, 4, 6]))
print("Kth largest:", kth_largest_element([3, 2, 1, 5, 6, 4], 2))
print("Two sum:", two_sum([2, 7, 11, 15], 9))

Summary#

This notebook provides complete solutions to all Medium level exercises.

Key Concepts Covered#

  1. Functions: Advanced parameter handling, decorators, closures

  2. Data Structures: Comprehensions, collections module, nested structures

  3. OOP: Inheritance, properties, dataclasses, abstract classes

  4. Machine Learning: Complete ML workflow, cross-validation, tuning

  5. Pandas: Data cleaning, merging, time series analysis

  6. Algorithms: Sorting, searching, string manipulation, optimization

Learning Tips#

  • Practice Complexity Analysis: Always consider time and space complexity

  • Multiple Approaches: Many problems have several valid solutions

  • Test Thoroughly: Use edge cases and different input sizes

  • Optimize Gradually: Start with brute force, then optimize

  • Understand Patterns: Recognize common algorithm patterns

Next Steps#

  • Implement your own variations of these solutions

  • Combine concepts from different exercises

  • Move on to Hard level challenges

  • Build real-world projects using these techniques

Remember: Understanding the concepts behind these solutions is more important than memorizing the code!