1. Lists
    1. Implemented as an array: Lists of objects or strings are implemented as an array of pointers
    2. Useful operations
      1. You can index into a list just like an array. For example, if your list is named people, then people[0] returns the first person in people.
      2. You can use negative indices. For example, people[-1] returns the last person in people, people[-2] returns the next to last person, and so on.
      3. You can use slices. For example people[3:6] will return a list containing [people[3], people[4], people[5]]. You do not get the person at the ending subscript. Similarly, people[-5:-2] will give you the list [people[-5], people[-4], people[-3]].
        1. You can leave the final subscript off the slice and then it will go to the end of the list. For example, people[3:] will return the list starting at people[3] and containing all the remaining items on the list. Similarly, people[-3:] will return the last 3 people on the list, starting at people[-3].
        `
      4. list.append(val): appends value to the end of the list
      5. list.extend(list1): extends list by appending all elements of list1 to list
      6. list.insert(index, val): insert val at the indicated index and slide all elements at index or higher to the right
      7. list.remove(val): removes first instance of val from the list
      8. list.pop([index]): with no argument it removes and returns the last item in the list; otherwise it removes and returns the element at index.
      9. list.index(val): returns the index of the first element in the list whose value is val. It is an error if there is no such item.
      10. list.reverse(): reverses the elements of the list
      11. del(): deletes an element or a slice
        del(a[0])    # deletes element 0
        del(a[2:5])  # deletes elements 2-4
        
    3. Sorting lists (for more details see Python's Sorting HOW TO documentation)
      1. use either list.sort or sorted(list)
        1. list.sort(): sorts list in place and is slightly more efficient
        2. sorted()
          1. returns a sorted sequence and leaves the original sequence unchanged
          2. sorts any sequence that is iterable
        3. the sort algorithm is stable: It preserves the order of equal elements in the original list
        4. the sort algorithm sorts in ascending order
          >>> sorted([5, 2, 3, 1, 4])
          [1, 2, 3, 4, 5]
          
        5. Sorting in reverse order: Use the reverse flag
          >>> sorted([5, 2, 3, 1, 4], reverse=True)
          [5, 4, 3, 2, 1]
          
        6. Key functions: Both sort and sorted take a key parameter that specifies a function to be applied to each sort operand.
          1. Applied once per sort, not once per comparison
          2. Examples
            1. Sorting names irrespective of case
              names.sorted(key = str.lower)
              
            2. Sorting tuples
              >>> student_tuples = [
                  ('john', 'A', 15),
                  ('jane', 'B', 12),
                  ('dave', 'B', 10),
              ]
              >>> sorted(student_tuples, key=lambda student: student[2])   # sort by age
              [('dave', 'B', 10), ('jane', 'B', 12), ('john', 'A', 15)]
              
            3. Sorting objects
              >>> sorted(student_objects, key=lambda student: student.age)   # sort by age
              
              Note that in examples 2 and 3 we cannot simply write
              key = student[2]
              key = student.age
              
              because key is supposed to be a pointer to a function, not a value
        7. primary and secondary sorting
          1. Have the key function return a tuple, with the first value being the primary key, and all subsequent values being secondary keys. For example, the following statement sorts students by age and then by name:
            >>> sorted(student_objects, key=lambda student: (student.age, student.name))
            
          2. If the primary and secondary keys should be sorted in different directions (e.g., one is ascending and one is descending)

            1. If the secondary is a number, you can use a tuple and negate the secondary value using a key function
            2. Otherwise first sort the sequence by the secondary key, and then sort the sequence by the primary key

        8. attribute accessor functions: simplify accesses to attributes
          1. imported from operator module
          2. itemgetter(index): returns element at index position in a tuple
            >>> import operator
            >>> sorted(student_tuples, key=operator.itemgetter(2)) 
            # sorts first on item 2, then item 1 -- item 2 is primary, item 1 is secondary
            >>> sorted(student_tuples, key=operator.itemgetter(2, 1)) 
            
          3. attrgetter(attribute): returns value of attribute in an object
            >>> import operator
            >>> sorted(student_objects, key=attrgetter('age'))
            
            # sort by grade and then by age -- grade is primary and age is secondary
            >>> sorted(student_objects, key=attrgetter('grade', 'age'))
            
    4. Functional Operations on Lists
      1. filter(function, sequence): Returns a filter object which is essentially a generator function that generates an iterable sequence consisting of those items from the sequence for which function(item) is true. The following example returns all tuples whose person makes less than $40,000 a year.
        # \ is a line continuation character
        >>> people = [('brad', 50000), ('yifan', 80000), ('smiley', 15000), ('george', 20000), \
        ...     ('james', 10000)]
        >>> filter(lambda person: person[1] < 40000, people)
        
        >>> result = filter(lambda person: person[1] < 40000, people)
        >>> for tuple in result:
        ...   print (tuple)
        ... 
        ('smiley', 15000)
        ('george', 20000)
        ('james', 10000)
        
      2. map(function, sequence): Calls function(item) for each of the sequence's items and returns an iterable sequence of the return values (the iterable sequence is actually a map object) The following example returns the cubes of the first 10 positive integers.
        >>> map(lambda x: x*x*x, range(1, 11))
        # if you iterate over the returned map object you will obtain the
        # sequence [1, 8, 27, 64, 125, 216, 343, 512, 729, 1000]
        

        1. map may take multiple sequences, in which case the function must take as many arguments as there are sequences and the sequences must have the same length. The following example computes the pair-wise sum of two vectors:
          >>> vector1 = [10, 20, 30, 40, 50]
          >>> vector2 = [5, 10, 15, 20, 25]
          >>> map(lambda x,y: x+y, vector1, vector2)
          # returns an iterable sequence with [15, 30, 45, 60, 75]
          

      3. reduce(function, sequence): Returns a single value constructed by calling the function on the first two items of the sequence, then on the result, and the next item, and so on.

        1. The function must take two arguments and return a single value.
        2. If the sequence has only one value, then that value is returned.
        3. A starting value may be provided as a third argument:
          reduce(function, sequence, startingValue)
          
          If a starting value is not provided, then the first two values in the sequence are passed to the function on the first function call.
        4. reduce was a built-in Python function in 2.x but was moved to the functools library in 3.x because a for loop is considered more readable than reduce and because functions such as sum, max, min, and len have been provided for the most common use cases. If you want to use reduce in 3.x, then you must import it as follows:
          from functools import reduce
          
        5. The following function computes the sum of a vector:
          >>> vector1 = [10, 20, 30, 40, 50]
          >>> reduce(lambda x,y: x+y, vector1)
          150
          
        6. This example returns the minimum value in a vector:
          >>> vector1 = [10, 20, 30, 40, 50]
          >>> reduce(lambda x,y: x if x < y else y, vector1)  # only works since 2.6
          
          or
          
          >>> def min(x,y):
          ...   if (x < y):
          ...     return x
          ...   else: 
          ...     return y
          ... 
          >>> reduce(min, vector1)
          
  2. Stacks: Use a list
    1. list.append(x): add an element to the stack
    2. list.pop(x): remove an element from the top of the stack

  3. Queues: Use collections.deque
    1. To use a deque
      from collections import deque
      
    2. Operations
      1. queue.append(x): append a value to the end of the queue
      2. queue.popleft(): remove a value from the front of the queue (i.e., left side of the queue)
    3. Don't use a list to implement a deque: list is implemented as an array so inserts at the front of the list will be O(n)

  4. Tuples: An immutable sequence
    1. Syntax
      t = (3, 6, 9)   # comma separated list in parentheses
      t = 3, 6, 9     # comma separated list
      t = 4,          # singleton--must have comma at the end
      
      t = (4)         # wrong--t is the integer 4 because Python
                      # thinks 4 is an arithmetic expression in ()s
      
      
    2. Access elements of tuples like arrays
      >>> t[1]  
      6
      
    3. Unpacking a tuple: useful for returning multiple values from a function
      x, y, z = t     # x = 3, y = 6, z = 9
      
      def minmax(x, y):
        if x < y:
          return (x,y)
        else:
          return (y,x)
      
      min, max = minmax(8, 6)
      
    4. Tuples are more space efficient than lists because they are immutable
      1. Lists over-allocate memory to handle appends
      2. Accesses should be roughly the same for both tuples and lists
      3. Instantiation (i.e., creation) of tuples is an order of magnitude faster for tuples because tuples can be built statically and lists are built dynamically (since lists are mutable and tuples are not)

  5. Dictionaries: Hash tables (also called associative arrays) that store key value pairs
    1. created with {}'s
      >>> tel = {'jack': 4098, 'sape': 4139}
      
    2. accesses look like arrays, but use keys rather than integers
      >>> tel['jack']
      4098
      >>> tel['guido'] = 4127
      
    3. membership testing: use in
      >>> 'guido' in tel
      True
      
    4. deletion: use del
      del tel['sape']
      
    5. flattening a dictionary into a list
      1. keys(): returns a list of keys
        >>> tel.keys()
        ['guido', 'jack']
        
      2. values(): returns a list of values
        >>> tel.values()
        [4127, 4098]
        
      3. items(): returns a list of the key/value pairs as a list of tuples
        >>> tel.items()
        [('jack', 4098), ('guido', 4127)]
        

  6. Sets: A unique collection with no duplicates
    1. Creation
      1. With curly braces {}
        x = { 3, 6, 9 }
        
      2. Using the set function and passing a sequence as an argument
        x = set([3, 6, 9])
        
      3. Must create an empty set using set(): Empty braces, {}, creates an empty dictionary
        x = set()
        

      4. Duplicates are automatically deleted when the set is created
        >>> x = { 3, 6, 9, 6, 9 }
        >>> x
        set([9, 3, 6])
        
    2. Operations
      1. Membership testing: Use in
        >>> 3 in x
        True
        >>> 4 in x
        False
        
      2. Set operations
        1. .add(elem): adds an element to the set if it's not already there. There is no return value.
        2. .remove(elem): removes an element from the set if it's there. There is no return value.
        3. Union: a | b
        4. Intersection: a & b
        5. Set Difference: a - b

  7. Trees: Python does not support trees. You need to create your own tree by creating your own classes (such as a node class) and writing your own algorithm