Session-2-3-Examples

This page shows the source code for Session-2-3-Examples.py with teaching notes, PURPOSE comments, and VS Code instructions.

Source File Session-2-3-Examples.py
Folder Chapter-2-Basic-Sessions

import sys
sys.stdout.reconfigure(encoding='utf-8')

"""
Session 3: Data Structures - Code Examples
Introduction to Python Course
"""

# ============================================
# PART 1: Lists
# ============================================
print("=" * 50)
print("PART 1: Lists")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: A list is Python's most used data structure.
#    It stores multiple values in a single variable, in order,
#    and you can change it at any time (add, remove, modify).
#    Think of it as a shopping list or a numbered to-do list.
#
# 2) VS CODE: Run each numbered Example block separately -
#    select its lines and press Shift+Enter. After each one,
#    try the TRY suggestion before moving to the next example.
# ======================================================

# ------------------------------------------------------
# HOW LISTS WORK
# ------------------------------------------------------
#
#   CREATE a list with square brackets, items separated by commas:
#       fruits = ["apple", "banana", "cherry"]
#
#   INDEXING - access one item by its position number:
#       fruits[0]   -> "apple"    (first item, index starts at 0!)
#       fruits[1]   -> "banana"
#       fruits[-1]  -> "cherry"   (last item, -1 counts from end)
#       fruits[-2]  -> "banana"   (-2 is second from end)
#
#   SLICING - get a sub-list using [start:stop]:
#       fruits[0:2] -> ["apple", "banana"]  (stop is NOT included)
#       fruits[:2]  -> ["apple", "banana"]  (start defaults to 0)
#       fruits[1:]  -> ["banana", "cherry"] (stop defaults to end)
#       fruits[::2] -> every 2nd item
#       fruits[::-1]-> reversed list
#
#   MODIFY - lists are MUTABLE (can be changed after creation):
#       fruits[0] = "mango"          replace an item
#       fruits.append("kiwi")        add to the end
#       fruits.insert(1, "pear")     add at a specific position
#       fruits.remove("banana")      remove by value
#       fruits.pop()                 remove and return the last item
#
#   IMPORTANT: index 0 is the FIRST item. This catches beginners
#   out constantly - the first item is fruits[0], NOT fruits[1].
# ------------------------------------------------------

# Example 1: Creating lists
print("\n--- Example 1: Creating Lists ---")
# ======================================================
# WHAT: Three different list types showing that a list can
#       hold strings, numbers, booleans, or even other lists.
#       A list containing another list is called a nested list.
# TRY:  Add your own items to the fruits list and print it.
# ======================================================
fruits = ["apple", "banana", "cherry", "date"]
numbers = [10, 20, 30, 40, 50]
mixed = [1, "hello", 3.14, True, [1, 2, 3]]   # any types allowed

print(f"Fruits: {fruits}")
print(f"Numbers: {numbers}")
print(f"Mixed list: {mixed}")

# Example 2: Accessing items
print("\n--- Example 2: Accessing List Items ---")
# ======================================================
# WHAT: Shows positive indexing (0 = first) and negative
#       indexing (-1 = last). Negative indexes count backwards
#       from the end - very handy when you don't know the length.
# TRY:  Print fruits[2] and fruits[-2] - what do you get?
# ======================================================
print(f"First fruit:  fruits[0]  = {fruits[0]}")   # index 0 = first
print(f"Last fruit:   fruits[-1] = {fruits[-1]}")   # -1 = last
print(f"Second fruit: fruits[1]  = {fruits[1]}")    # index 1 = second

# Example 3: Slicing
print("\n--- Example 3: List Slicing ---")
# ======================================================
# WHAT: Slicing extracts a portion of a list.
#       [start:stop]   -> from start UP TO (not including) stop
#       [:stop]        -> from beginning up to stop
#       [start:]       -> from start to the end
#       [::step]       -> every step-th item
#       [::-1]         -> all items in reverse order
# TRY:  Try numbers[2:8:2] - what pattern does it give?
# ======================================================
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(f"Original:       {numbers}")
print(f"First 5  [:5]:  {numbers[:5]}")       # stop before index 5
print(f"Last 5  [-5:]:  {numbers[-5:]}")      # start 5 from end
print(f"Middle [3:7]:   {numbers[3:7]}")      # index 3,4,5,6
print(f"Every 2nd [::2]:{numbers[::2]}")      # step=2
print(f"Reversed [::-1]:{numbers[::-1]}")     # step=-1 reverses

# Example 4: Modifying lists
print("\n--- Example 4: Modifying Lists ---")
# ======================================================
# WHAT: Demonstrates the key methods for changing a list.
#       append()  -> adds one item to the END
#       insert()  -> adds one item at a chosen position
#       remove()  -> deletes the first item matching the value
#       pop()     -> removes AND returns the last item (useful
#                    when you need to know what was removed)
# TRY:  After all modifications, print len(shopping) to see
#       the final count.
# ======================================================
shopping = ["milk", "bread", "eggs"]
print(f"Original:      {shopping}")

shopping.append("butter")              # adds to end
print(f"After append:  {shopping}")

shopping.insert(1, "cheese")           # inserts at index 1
print(f"After insert:  {shopping}")

shopping[0] = "almond milk"            # replaces item at index 0
print(f"After change:  {shopping}")

shopping.remove("bread")               # removes first "bread" found
print(f"After remove:  {shopping}")

last_item = shopping.pop()             # removes & returns last item
print(f"Popped item:   {last_item}")
print(f"Final list:    {shopping}")

# Example 5: List methods
print("\n--- Example 5: List Methods ---")
# ======================================================
# WHAT: Built-in functions and methods that work on lists:
#       len()     -> number of items
#       sum()     -> total of all numbers
#       max()     -> largest value
#       min()     -> smallest value
#       count()   -> how many times a value appears
#       index()   -> position of the first match
#       sorted()  -> returns a NEW sorted list (original unchanged)
#       .sort()   -> sorts the list IN PLACE (original changed!)
#       .reverse()-> reverses the list IN PLACE
# TRY:  What is the difference between sorted(numbers) and
#       numbers.sort()? Run both and check if the original changes.
# ======================================================
numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5]
print(f"Original:              {numbers}")
print(f"len():                 {len(numbers)}")
print(f"sum():                 {sum(numbers)}")
print(f"max():                 {max(numbers)}")
print(f"min():                 {min(numbers)}")
print(f"count(5):              {numbers.count(5)}")    # how many 5s?
print(f"index(9):              {numbers.index(9)}")    # where is 9?

sorted_nums = sorted(numbers)                          # NEW list returned
print(f"sorted() new list:     {sorted_nums}")
print(f"Original unchanged:    {numbers}")             # still original order

numbers.sort()                                         # modifies IN PLACE
print(f"After .sort() in place:{numbers}")

numbers.reverse()                                      # modifies IN PLACE
print(f"After .reverse():      {numbers}")

# Example 6: List comprehensions
print("\n--- Example 6: List Comprehensions ---")
# ======================================================
# WHAT: A list comprehension is a compact way to build a
#       new list from an existing sequence - all in one line.
#
#   STRUCTURE:
#       [expression  for item in sequence]
#       [expression  for item in sequence  if condition]
#
#   The long way (for loop):
#       squares = []
#       for x in range(10):
#           squares.append(x**2)
#
#   The short way (list comprehension):
#       squares = [x**2 for x in range(10)]
#
#   With a filter (only include even numbers):
#       evens = [x for x in range(20) if x % 2 == 0]
#
# TRY:  Write a comprehension that gives the squares of
#       only the even numbers from 0 to 20:
#       [x**2 for x in range(20) if x % 2 == 0]
# ======================================================
squares = [x**2 for x in range(10)]                   # x squared for each x
print(f"Squares:    {squares}")

evens = [x for x in range(20) if x % 2 == 0]          # filter: only even
print(f"Evens:      {evens}")

fruits = ["apple", "banana", "cherry"]
upper_fruits = [fruit.upper() for fruit in fruits]     # .upper() on each
print(f"Uppercase:  {upper_fruits}")

# Example 7: Nested lists
print("\n--- Example 7: Nested Lists (Matrix) ---")
# ======================================================
# WHAT: A list of lists is called a nested list (or matrix).
#       Access elements with TWO indexes: [row][column].
#       matrix[1][2] means: row 1 (second row), column 2 (third column).
#       Row and column indexes both start at 0.
# TRY:  Print matrix[2][0] - which number does that give?
#       (row 2, column 0 -> should be 7)
# ======================================================
matrix = [
    [1, 2, 3],   # row 0
    [4, 5, 6],   # row 1
    [7, 8, 9]    # row 2
]
print(f"Matrix:         {matrix}")
print(f"Row 0:          {matrix[0]}")         # entire first row
print(f"matrix[1][2]:   {matrix[1][2]}")      # row 1, column 2 -> 6

# Example 8: Practical - Grade tracker
print("\n--- Example 8: Grade Tracker ---")
# ======================================================
# WHAT: Real-world use of list math functions. :.2f inside
#       the f-string formats the average to 2 decimal places.
# TRY:  Add a grade of 100 to the list and recalculate.
#       Does the average change as expected?
# ======================================================
grades = [85, 92, 78, 90, 88]
print(f"Grades:  {grades}")
print(f"Average: {sum(grades) / len(grades):.2f}")   # :.2f = 2 decimal places
print(f"Highest: {max(grades)}")
print(f"Lowest:  {min(grades)}")


# ============================================
# PART 2: Tuples
# ============================================
print("\n" + "=" * 50)
print("PART 2: Tuples")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: A tuple is like a list BUT it cannot be
#    changed after creation (immutable). Use a tuple when
#    your data should NEVER change: GPS coordinates, RGB
#    colours, a person's birth date, database records.
#    Tuples use () instead of [].
#    Bonus: functions can return multiple values via tuples.
#
# 2) VS CODE: Run each Example block with Shift+Enter.
#    The key insight to test: try modifying a tuple element
#    and watch Python raise a TypeError.
# ======================================================

# ------------------------------------------------------
# HOW TUPLES WORK - LIST vs TUPLE COMPARISON
# ------------------------------------------------------
#
#   LIST  (mutable - can change)      TUPLE (immutable - cannot change)
#   fruits = ["apple", "banana"]      coords = (10, 20)
#   fruits[0] = "mango"    <- OK      coords[0] = 99   <- ERROR!
#   fruits.append("kiwi")  <- OK      No append method on tuples
#
#   SYNTAX:
#       my_tuple = (1, 2, 3)          standard tuple
#       single   = (42,)              ONE-item tuple NEEDS a comma!
#       single   = (42)               this is just the number 42, NOT a tuple
#
#   UNPACKING - assign each item to its own variable in one line:
#       x, y = (10, 20)              x=10, y=20
#       name, age, job = ("Alice", 25, "Engineer")
#
#   WHY USE TUPLES?
#   - Faster than lists for read-only data
#   - Signals to other programmers "this data must not change"
#   - Can be used as dictionary keys (lists cannot)
#   - Functions use them to return multiple values at once
# ------------------------------------------------------

# Example 1: Creating tuples
print("\n--- Example 1: Creating Tuples ---")
# ======================================================
# WHAT: Shows four tuple types. The single-item tuple (42,)
#       is a common gotcha - the comma is REQUIRED. Without
#       the comma, Python treats (42) as just the number 42.
# TRY:  Print type((42,)) and type((42)) to see the difference.
# ======================================================
coordinates = (10, 20)
person = ("Alice", 25, "Engineer")
single = (42,)          # comma required for single-item tuple!
colors = ("red", "green", "blue")

print(f"Coordinates: {coordinates}")
print(f"Person:      {person}")
print(f"Single item: {single}   <- note the comma inside")
print(f"Colors:      {colors}")

# Example 2: Accessing tuples
print("\n--- Example 2: Accessing Tuple Items ---")
# ======================================================
# WHAT: Same indexing rules as lists - index 0 is first,
#       -1 is last. Reading works; writing raises a TypeError.
# ======================================================
print(f"X coordinate: coordinates[0] = {coordinates[0]}")
print(f"Y coordinate: coordinates[1] = {coordinates[1]}")
print(f"Person name:  person[0]      = {person[0]}")

# Example 3: Tuple unpacking
print("\n--- Example 3: Tuple Unpacking ---")
# ======================================================
# WHAT: Unpacking assigns each tuple item to a separate
#       variable in one clean line. The number of variables
#       on the left MUST match the number of items in the tuple.
#       This is also how functions can "return" multiple values.
# TRY:  Swap x and y using tuple unpacking: x, y = y, x
#       Print x and y before and after to see the swap.
# ======================================================
x, y = coordinates                  # unpacks (10, 20) into x and y
name, age, job = person             # unpacks 3-item tuple into 3 variables
print(f"x={x}, y={y}")
print(f"Name: {name}, Age: {age}, Job: {job}")

# Example 4: Tuple methods
print("\n--- Example 4: Tuple Methods ---")
# ======================================================
# WHAT: Tuples only have 2 methods (vs many for lists)
#       because they cannot be modified. count() and index()
#       work exactly like their list equivalents.
# ======================================================
numbers = (1, 2, 3, 2, 4, 2, 5)
print(f"Numbers:     {numbers}")
print(f"len():       {len(numbers)}")
print(f"count(2):    {numbers.count(2)}")    # how many 2s?
print(f"index(4):    {numbers.index(4)}")    # where is 4?

# Example 5: Why tuples? (Immutability)
print("\n--- Example 5: Tuple Immutability ---")
# ======================================================
# WHAT: Demonstrates that tuples protect their data.
#       Uncommenting point[0] = 15 will raise:
#           TypeError: 'tuple' object does not support item assignment
#       This is intentional - it prevents accidental changes
#       to data that should stay fixed.
# TRY:  Uncomment point[0] = 15, run it, read the error,
#       then re-comment it.
# ======================================================
point = (10, 20)
print(f"Original point: {point}")
# point[0] = 15  # <- Uncomment to see TypeError in action!
print("Tuples cannot be modified after creation - this protects your data")

# Example 6: Returning multiple values
print("\n--- Example 6: Function Returning Tuple ---")
# ======================================================
# WHAT: A function normally returns ONE value. By returning
#       a tuple, it can return TWO (or more) values at once.
#       The caller unpacks the tuple into separate variables.
#       This is a very common Python pattern.
# TRY:  Change nums to [100, 5, 42, 73, 8] and run again.
# ======================================================
def get_min_max(numbers):
    return (min(numbers), max(numbers))   # returns a 2-item tuple

nums = [3, 7, 1, 9, 4]
minimum, maximum = get_min_max(nums)      # unpack returned tuple
print(f"Numbers: {nums}")
print(f"Min: {minimum}, Max: {maximum}")


# ============================================
# PART 3: Dictionaries
# ============================================
print("\n" + "=" * 50)
print("PART 3: Dictionaries")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: A dictionary stores data as KEY:VALUE pairs.
#    Instead of accessing items by position number (like lists),
#    you access them by a meaningful name (the key).
#    Think of it like a real dictionary: look up a word (key)
#    to get its definition (value). Perfect for storing
#    properties of one thing: a student record, a product,
#    a settings file.
#
# 2) VS CODE: Run each Example with Shift+Enter. The most
#    important Examples to study carefully are 2, 3, 4, and 5
#    - they cover the everyday operations you will use constantly.
# ======================================================

# ------------------------------------------------------
# HOW DICTIONARIES WORK
# ------------------------------------------------------
#
#   CREATE with curly braces, key:value pairs:
#       student = {"name": "Alice", "age": 20, "grade": "A"}
#
#   READ a value using its key in square brackets:
#       student["name"]        -> "Alice"
#       student["age"]         -> 20
#
#   SAFER READ with .get() - returns None (or a default)
#   instead of crashing if the key does not exist:
#       student.get("email")              -> None  (no crash)
#       student.get("email", "Unknown")   -> "Unknown"
#
#   WRITE / UPDATE - same syntax, just assign:
#       student["email"] = "alice@school.com"   add new key
#       student["age"]   = 21                   update existing key
#
#   DELETE a key:
#       del student["grade"]
#
#   USEFUL METHODS:
#       student.keys()    -> all the key names
#       student.values()  -> all the values
#       student.items()   -> all (key, value) pairs  <- used in for loops
#
#   CHECK if a key exists:
#       "name" in student  -> True
#       "email" in student -> False (if not added yet)
#
#   KEYS must be unique and immutable (strings or numbers).
#   VALUES can be anything: strings, numbers, lists, even other dicts.
# ------------------------------------------------------

# Example 1: Creating dictionaries
print("\n--- Example 1: Creating Dictionaries ---")
# ======================================================
# WHAT: A student record stored as a dictionary. Notice the
#       "courses" value is itself a list - values can be any type.
# TRY:  Add a "gpa": 3.8 key:value pair and print the dict.
# ======================================================
student = {
    "name": "Alice",
    "age": 20,
    "grade": "A",
    "courses": ["Math", "Python", "Physics"]   # value can be a list!
}
print(f"Student: {student}")

# Example 2: Accessing values
print("\n--- Example 2: Accessing Dictionary Values ---")
# ======================================================
# WHAT: Two ways to read a value:
#   dict["key"]            -> works, but CRASHES with KeyError
#                             if the key does not exist
#   dict.get("key")        -> returns None if key not found (safe)
#   dict.get("key", default) -> returns default if key not found
# TRY:  Try student["email"] (no .get) - you'll get a KeyError.
#       Then try student.get("email") - no crash, returns None.
# ======================================================
print(f"Name:    student['name']                   = {student['name']}")
print(f"Age:     student['age']                    = {student['age']}")
print(f"Courses: student['courses']                = {student['courses']}")
print(f"Grade:   student.get('grade')              = {student.get('grade')}")
print(f"Email:   student.get('email','Not provided')= {student.get('email', 'Not provided')}")

# Example 3: Modifying dictionaries
print("\n--- Example 3: Modifying Dictionaries ---")
# ======================================================
# WHAT: Assigning to a key adds it if new, updates it if existing.
#       There is no separate "add" vs "update" method - same syntax.
# TRY:  Print student before and after each change to see
#       exactly which key changed.
# ======================================================
student["email"] = "alice@school.com"   # new key -> adds it
student["age"]   = 21                   # existing key -> updates it
student["gpa"]   = 3.8                  # new key -> adds it
print(f"Updated student: {student}")

# Example 4: Dictionary methods
print("\n--- Example 4: Dictionary Methods ---")
# ======================================================
# WHAT: .keys(), .values(), .items() each return a view of
#       the dictionary. Wrapping in list() converts the view
#       to a regular list so it prints nicely.
#       .items() returns (key, value) PAIRS - used heavily
#       when looping (see Example 5).
# ======================================================
print(f"Keys:   {list(student.keys())}")
print(f"Values: {list(student.values())}")
print(f"Items:  {list(student.items())}")

if "name" in student:                   # check before accessing
    print("'name' key exists in student")

# Example 5: Looping through dictionaries
print("\n--- Example 5: Looping Through Dictionary ---")
# ======================================================
# WHAT: Two ways to loop.
#   for key in student          -> gives keys only; use student[key] to get value
#   for key, value in .items()  -> gives BOTH at once (more readable)
#   .items() unpacks each (key, value) pair directly into two variables.
# TRY:  Change the loop to only print keys where the value
#       is a string: if isinstance(value, str): print(...)
# ======================================================
print("Loop using keys only:")
for key in student:
    print(f"  {key}: {student[key]}")

print("\nLoop using .items() (key AND value together):")
for key, value in student.items():     # unpacks each pair
    print(f"  {key} = {value}")

# Example 6: Nested dictionaries
print("\n--- Example 6: Nested Dictionaries ---")
# ======================================================
# WHAT: Each value in class_roster is itself a dictionary.
#       Access nested values with two sets of []:
#           class_roster["student1"]["name"]  -> "Alice"
#       .items() on the outer dict gives (student_id, info) pairs;
#       then info["name"] drills into the inner dict.
# TRY:  Add "student4": {"name": "Diana", "grade": 95}
#       and run the loop again.
# ======================================================
class_roster = {
    "student1": {"name": "Alice",   "grade": 85},
    "student2": {"name": "Bob",     "grade": 92},
    "student3": {"name": "Charlie", "grade": 78}
}
print("Class Roster:")
for student_id, info in class_roster.items():
    print(f"  {student_id}: {info['name']} - Grade: {info['grade']}")

# Example 7: Practical - Phonebook
print("\n--- Example 7: Phonebook ---")
# ======================================================
# WHAT: A real use case - dictionary as a lookup table.
#       Names are keys, phone numbers are values.
#       Adding a new entry is just one assignment line.
# TRY:  Use .get() to look up a name that is NOT in the
#       phonebook and provide "Number not found" as the default.
# ======================================================
phonebook = {
    "Alice":   "555-1234",
    "Bob":     "555-5678",
    "Charlie": "555-9012"
}
print("Phonebook:")
for name, number in phonebook.items():
    print(f"  {name}: {number}")

phonebook["Diana"] = "555-3456"        # add new entry
print(f"\nAdded Diana: {phonebook['Diana']}")

# Example 8: Word counter
print("\n--- Example 8: Word Counter ---")
# ======================================================
# WHAT: Classic dictionary pattern - count occurrences.
#       .split() breaks the string into a list of words.
#       The loop checks: if the word is already a key,
#       increment its count; otherwise start it at 1.
#       This "count things" pattern appears constantly in
#       real programs (log analysis, text processing, etc.)
# TRY:  Change the text to a sentence you write yourself
#       and see which words appear most often.
# ======================================================
text = "python is great python is fun python is popular"
words = text.split()       # "python is great..." -> ["python","is","great",...]
word_count = {}

for word in words:
    if word in word_count:
        word_count[word] += 1    # already seen -> increment
    else:
        word_count[word] = 1     # first time seen -> start at 1

print("Word frequencies:")
for word, count in word_count.items():
    print(f"  {word}: {count}")


# ============================================
# PART 4: Sets
# ============================================
print("\n" + "=" * 50)
print("PART 4: Sets")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: A set stores UNIQUE values only - duplicates
#    are automatically removed. It also has no fixed order
#    (items may print in a different order each time).
#    Sets are great for: removing duplicates from a list,
#    checking membership very fast, and doing math-like
#    operations on groups (union, intersection, difference).
#
# 2) VS CODE: Run each Example with Shift+Enter. The set
#    operations (Example 3) are the most powerful feature -
#    study those carefully with the diagram below.
# ======================================================

# ------------------------------------------------------
# HOW SETS WORK
# ------------------------------------------------------
#
#   CREATE with curly braces (no key:value, just values):
#       fruits = {"apple", "banana", "cherry"}
#
#   NOTE: {} alone creates an empty DICTIONARY, not a set!
#       empty_set  = set()          <- correct empty set
#       empty_dict = {}             <- this is a dictionary!
#
#   DUPLICATES are silently dropped:
#       s = {1, 2, 2, 3, 3, 3}     -> {1, 2, 3}
#
#   MODIFY:
#       s.add("yellow")            add one item
#       s.remove("green")          remove (crashes if not found)
#       s.discard("purple")        remove (NO crash if not found)
#
#   SET OPERATIONS (like Venn diagrams):
#       set1 | set2   -> UNION:        all items from BOTH sets
#       set1 & set2   -> INTERSECTION: only items in BOTH sets
#       set1 - set2   -> DIFFERENCE:   items in set1 but NOT in set2
#       set1 ^ set2   -> SYMMETRIC DIFFERENCE: items in ONE but not BOTH
#
#   MEMBERSHIP CHECK (very fast compared to lists):
#       "apple" in fruits   -> True
#       "grape" in fruits   -> False
# ------------------------------------------------------

# Example 1: Creating sets
print("\n--- Example 1: Creating Sets ---")
# ======================================================
# WHAT: Shows that a set automatically removes duplicates.
#       Converting a list with repeats to a set is the
#       quickest way to get unique values in Python.
# TRY:  Create a set directly with duplicates:
#       {1, 2, 2, 3, 3, 3} and print it - only unique remain.
# ======================================================
fruits  = {"apple", "banana", "cherry"}
numbers = {1, 2, 3, 4, 5}
print(f"Fruits:  {fruits}")
print(f"Numbers: {numbers}")

numbers_list = [1, 2, 2, 3, 3, 3, 4, 4, 5]
numbers_set  = set(numbers_list)         # duplicates automatically removed
print(f"List with duplicates:  {numbers_list}")
print(f"Set (unique only):     {numbers_set}")

# Example 2: Adding and removing
print("\n--- Example 2: Adding and Removing ---")
# ======================================================
# WHAT: .add() and .remove() work like list equivalents.
#       .discard() is safer than .remove() because it does
#       NOT raise an error when the item is not present.
#       Use discard when you are not sure if an item exists.
# TRY:  Call colors.remove("purple") - you get a KeyError.
#       Then replace it with colors.discard("purple") - no error.
# ======================================================
colors = {"red", "green", "blue"}
print(f"Original:      {colors}")

colors.add("yellow")
print(f"After add:     {colors}")

colors.remove("green")                   # crashes if "green" not in set
print(f"After remove:  {colors}")

colors.discard("purple")                 # safe - no crash if not found
print(f"After discard: {colors}")

# Example 3: Set operations
print("\n--- Example 3: Set Operations ---")
# ======================================================
# WHAT: These four operations mirror Venn diagram logic:
#
#   set1 = {1,2,3,4,5}     set2 = {4,5,6,7,8}
#
#   UNION |          -> everything: {1,2,3,4,5,6,7,8}
#   INTERSECTION &   -> shared only: {4,5}
#   DIFFERENCE -     -> in set1 only: {1,2,3}
#   SYMMETRIC ^      -> in one but NOT both: {1,2,3,6,7,8}
#
# TRY:  Change set2 to {1, 2, 3} (identical to set1) and
#       check: what does intersection return? difference?
# ======================================================
set1 = {1, 2, 3, 4, 5}
set2 = {4, 5, 6, 7, 8}

print(f"Set 1:                   {set1}")
print(f"Set 2:                   {set2}")
print(f"Union        (|):        {set1 | set2}")   # all items from both
print(f"Intersection (&):        {set1 & set2}")   # only shared items
print(f"Difference   (-):        {set1 - set2}")   # in set1 but not set2
print(f"Symmetric    (^):        {set1 ^ set2}")   # in one but not both

# Example 4: Membership testing
print("\n--- Example 4: Membership Testing ---")
# ======================================================
# WHAT: 'in' checks whether a value exists in a set.
#       Sets are much faster than lists for this check
#       on large data - sets use hashing internally.
#       This is one of the main reasons to choose a set.
# TRY:  Compare: "apple" in fruits (set) vs
#                "apple" in ["apple","banana","cherry"] (list)
#       Both work - but for 1 million items, set wins easily.
# ======================================================
fruits = {"apple", "banana", "cherry"}
print(f"Fruits:              {fruits}")
print(f"'apple' in fruits:   {'apple' in fruits}")    # True
print(f"'grape' in fruits:   {'grape' in fruits}")    # False

# Example 5: Practical - Remove duplicates
print("\n--- Example 5: Remove Duplicates ---")
# ======================================================
# WHAT: The quickest way to deduplicate a list in Python:
#       convert to set (removes duplicates), then back to list.
#       NOTE: the output order may differ from the input order
#       because sets are unordered.
# TRY:  Check: is the order of unique_emails guaranteed?
#       Run this block twice and see if the order changes.
# ======================================================
emails = [
    "alice@example.com",
    "bob@example.com",
    "alice@example.com",    # duplicate
    "charlie@example.com",
    "bob@example.com"       # duplicate
]
print(f"Emails with duplicates: {emails}")
unique_emails = list(set(emails))        # set removes dupes, list converts back
print(f"Unique emails:          {unique_emails}")

# Example 6: Practical - Common interests
print("\n--- Example 6: Common Interests ---")
# ======================================================
# WHAT: Real-world use of set operations. Finding what two
#       people have in common is just an intersection (&).
#       Finding what only Alice likes is a difference (-).
#       This same pattern works for: common friends,
#       shared tags, overlapping skill sets, etc.
# TRY:  Add "hiking" to bob_interests and re-run.
#       Does "hiking" move from "only_alice" to "common"?
# ======================================================
alice_interests = {"reading", "hiking", "cooking", "music"}
bob_interests   = {"music", "gaming", "hiking", "sports"}

common     = alice_interests & bob_interests      # shared by both
only_alice = alice_interests - bob_interests      # Alice has, Bob doesn't
only_bob   = bob_interests   - alice_interests    # Bob has, Alice doesn't

print(f"Alice's interests: {alice_interests}")
print(f"Bob's interests:   {bob_interests}")
print(f"Common interests:  {common}")
print(f"Only Alice:        {only_alice}")
print(f"Only Bob:          {only_bob}")

print("\nSession 3 completed successfully!")
print("Master these data structures for efficient programming!")