# Sets: unorder collection with unique values

def count_unique(s):
    """
    Count the number of unique characters in s
    >>> count_unique('aabb')
    2
    >>> count_unique('abcdef')
    6
    """
    seen_c = [] # O(1)
    for c in s:  # O(n)
        if c not in seen_c: # AS list gets longer, lenth of seen_c gets longer, O(n)
            # In most cases seen_c will be proportional to the length of s
            # 0+ 1+ 2+ 3+... +(n-1) = n^2, considering only the largest polynomial
            # Best case will be all repeated characters are same, then seen_c will contain only one character and loop through only one character
            seen_c.append(c)
    return len(seen_c) # O(n*n), O(n^2)
print(count_unique('hdgfyjkshsa'))

# Getting unique characters using set()
seen_c = set()                  # O(1), intialization is constant
def count_unique2(s):
    for c in s:                 # O(n), loop thorugh n elements
        if c not in seen_c:     # O(1), sets() like dictionaries can hash the values and hence lookup values in constant time
            seen_c.add(c)       # O(1), add also takes constant time
    return len(seen_c)          # Therefore, runtime will be O(n)
print(count_unique2('hdgfyjkshsa'))

# Getting unique characters using set comprehension
def count_unique3(s):
    return len({c for c in s}) # O(n), loop through n elements
print(count_unique3('hdgfyjkshsa'))

def count_unique4(s):
    return len(set(s)) # O(n), loop through n elements
print(count_unique4('hdgfyjkshsa')) # set(s) is same as {c for c in s}



# Generators
# Generators are special type of iterators that iterate over a sequence of values
# They are special because they are lazzy iterators which means they iterate over a values only when it is needed.
# It is like a list comprehension except we use paranthesis

l = [i for i in range(5)] # list
print(l) # [0, 1, 2, 3, 4]
g = (i for i in range(5)) # generator
print(g) # <generator object <genexpr> at 0x000002188E438740>

# To accees the values in the iterator object, the next() function has to be used on generator
print(next(g)) # 0
print(next(g), next(g), next(g), next(g)) # 1 2 3 4
#print(next(g), next(g), next(g), next(g), next(g), next(g)) # gives StopIteration error when all the elements in the iterator have been accessed
# once all the values in the generator have been accessed and exhausted then the values cannot be called again
# to get the values agian you will have ti instantiate it again and call next() function again

# Comparing generators with a list
print(sum([i for i in range(1, 1001)])) # sum() on list
print(sum((i for i in range(1,1001))))  # sum() on generator
# sum under the hood calls an iterator on the generator and calls next() over and over on generator
iterator = iter([i for i in range(1,1001)])
print(next(iterator))

iterator = iter((i for i in range(1,1001)))
print(next(iterator))

# Why use generator?
lst = [i for i in range(1, 1001)]
import sys
print(sys.getsizeof(lst)) # 8856 bytes

g = (i for i in range(1,1001))
print(sys.getsizeof(g)) # 104 bytes

g = (i for i in range(1,2001))
print(sys.getsizeof(g)) # 104 bytes

# Generator will always use a constant size even though the sequence grow

#lst = [slow_method(i) for i in range(1, 1001)] # This will evaludate slow_method on all the values in the list at once
#g = (slow_method(i for i in range(1, 1001))) # This will evaluate the slow_method on i only when it needs to

# Generator function
# Any function is generator function if it contains the word yield instead of return
def y():
    yield 1
    yield 2
    yield 3
# It behaves like function with return , except when calling next() that it will start off from the point after value return from last call
# To go inside the function you have to call next() on generator function instead of passing any value as argument or calling directly

print(y())
print(next(y()))


# Dictionaries and default dictionaries
def get_grades_naive(name):
    student_grades = {"Jack": [85, 90],
                     "Jill": [80, 95]
                     }
    if name in student_grades:
        return student_grades[name]
    return []

print(get_grades_naive("Jack"))
print(get_grades_naive("Joe"))

def get_grades_better(name):
    student_grades = {"Jack": [85, 90],
                     "Jill": [80, 95]
                     }
    return student_grades.get(name, []) # gets the valus of key from dictionary and gives a default value if the key is not found

print(get_grades_better("Jack"))
print(get_grades_better("Joe"))
# The setdefault() method will help add the default value for a key that does not exist in the list
def get_grades_with_assignment_better(name):
    student_grades = {"Jack": [85, 90],
                     "Jill": [80, 95]
                     }
    return student_grades.setdefault(name, [])
print(get_grades_with_assignment_better("Jack"))
print(get_grades_with_assignment_better("Joe"))

# Add new scores for students or new students with their score
def update_grades_with_assignment_better(name, score):
    student_grades = {"Jack": [85, 90],
                     "Jill": [80, 95]
                     }
    if name in student_grades:
        grades = student_grades[name]
    else:
        student_grades[name] = []
        grades = student_grades[name]
    grades.append(score)
    print(student_grades)

update_grades_with_assignment_better("Jack", 100)
update_grades_with_assignment_better("Joe", 50)

def set_grade_better(name, score):
    student_grades = {"Jack": [85, 90],
                     "Jill": [80, 95]
                     }
    grades = student_grades.setdefault(name, [])
    grades.append(score)
    print(student_grades)
set_grade_better("Jack", 100)
set_grade_better("Joe", 120)

# Using default dict from collections module to achive this
from collections import defaultdict
student_grades = defaultdict(list) #it will return default value [] when a key is not found in the dictionary
def set_grade_best(name, score):
    student_grades[name].append(score) # the syntax for dictionary gets access to grades value for a studnet name and then appends new score to it
    # if name is not fould then it binds the score to the default value of dict which is [] and then returns the grade
print(student_grades) # defaultdict(<class 'list'>, {})

set_grade_best("Jack", 100)
set_grade_best("Joe", 120)
print(student_grades) # defaultdict(<class 'list'>, {'Jack': [100], 'Joe': [120]})

# This is adding new items to default dict instead of checking if they already exist
# so to do that, we can pass a secong argument to the default dict which will be the actual dictionary along with a default value if any key does not exist in that dictionary
student_grades = {"Jack": [85, 90],
                  "Jill": [80, 95]
                  }
student_grades = defaultdict(list, student_grades)
def set_grade_best(name, score):
    student_grades[name].append(score)
print(student_grades) # defaultdict(<class 'list'>, {'Jack': [85, 90], 'Jill': [80, 95]})
set_grade_best("Jack", 100)
set_grade_best("Joe", 120)
print(student_grades) # defaultdict(<class 'list'>, {'Jack': [85, 90, 100], 'Jill': [80, 95], 'Joe': [120]})

student_score = defaultdict(lambda: 70)
print(student_score) # defaultdict(<function <lambda> at 0x0000026DBB312CB0>, {})
student_score["Jack"]
print(student_score) # defaultdict(<function <lambda> at 0x0000026DBB312CB0>, {'Jack': 70})
print(student_score["Joe"] + 10) # 80
print(student_score) # defaultdict(<function <lambda> at 0x0000026C62692CB0>, {'Jack': 70, 'Joe': 70})