docs: def optimize(self, problem, max_iterations=100, max_seconds=float('inf'),
                   cache_encoded=True, cache_solution=False, clear_cache=True,
                   logging_func=_print_fitnesses,
                   n_processes=0):
          """Find the optimal inputs for a given fitness function.
  
          Args:
              problem: An instance of Problem. The problem to solve.
              max_iterations: The number of iterations to optimize before stopping.
              max_seconds: Maximum number of seconds to optimize for, before stopping.
                  Note that condition is only checked one per iteration,
                  meaning optimization can take more than max_seconds,
                  especially if fitnesses take a long time to calculate.
              cache_encoded: bool; Whether or not to cache fitness of encoded strings.
                  Encoded strings are produced directly by the optimizer.
                  If an encoded string is found in cache, it will not be decoded.
              cache_solution: bool; Whether or not to cache fitness of decoded solutions.
                  Decoded solution is provided by problems decode function.
                  If problem does not provide a hash solution function,
                  Various naive hashing methods will be attempted, including:
                      tuple, tuple(sorted(dict.items)), str.
              clear_cache: bool; Whether or not to reset cache after optimization.
                  Disable if you want to run optimize multiple times on the same problem.
              logging_func: func/None; Function taking:
                  iteration, population, solutions, fitnesses, best_solution, best_fitness
                  Called after every iteration.
                  Use for custom logging, or set to None to disable logging.
                  Note that best_solution and best_fitness are the best of all iterations so far.
              n_processes: int; Number of processes to use for multiprocessing.
                  If <= 0, do not use multiprocessing.
  
          Returns:
              object; The best solution, after decoding.
          """
          if not isinstance(problem, Problem):
              raise TypeError('problem must be an instance of Problem class')
  
          # Prepare pool for multiprocessing
          if n_processes > 0:
              try:
                  pool = multiprocessing.Pool(processes=n_processes)
              except NameError:
                  raise ImportError(
                      'pickle, dill, or multiprocessing library is not available.'
                  )
          else:
              pool = None
  
          # Set first, incase optimizer uses _max_iterations in initialization
          self.__max_iterations = max_iterations
  
          # Initialize algorithm
          self._reset()
  
          best_solution = {'solution': None, 'fitness': None}
          population = self.initial_population()
          try:
              # Begin optimization loop
              start = time.clock()
              for self.iteration in itertools.count(1):  # Infinite sequence of iterations
                  # Evaluate potential solutions
                  solutions, fitnesses, finished = self._get_fitnesses(
                      problem,
                      population,
                      cache_encoded=cache_encoded,
                      cache_solution=cache_solution,
                      pool=pool)
  
                  # If the best fitness from this iteration is better than
                  # the global best
                  best_index, best_fitness = max(
                      enumerate(fitnesses), key=operator.itemgetter(1))
                  if best_fitness > best_solution['fitness']:
                      # Store the new best solution
                      best_solution['fitness'] = best_fitness
                      best_solution['solution'] = solutions[best_index]
  
                  if logging_func:
                      logging_func(self.iteration, population, solutions,
                                   fitnesses, best_solution['solution'],
                                   best_solution['fitness'])
  
                  # Break if solution found
                  if finished:
                      self.solution_found = True
                      break
  
                  # Break if out of time
                  if time.clock() - start >= max_seconds:
                      break
  
                  # Break if out of iterations
                  if self.iteration >= max_iterations:
                      break
  
                  # Continue optimizing
                  population = self.next_population(population, fitnesses)
  
              # Store best internally, before returning
              self.best_solution = best_solution['solution']
              self.best_fitness = best_solution['fitness']
  
          finally:
              # Clear caches
              if clear_cache:
                  # Clear caches from memory
                  self.__encoded_cache = {}
                  self.__solution_cache = {}
  
                  # Reset encoded, and decoded key functions
                  self._get_encoded_key = self._get_encoded_key_type
                  self._get_solution_key = self._get_solution_key_type
  
              # Clean up multiprocesses
              try:
                  pool.terminate()  # Kill outstanding work
                  pool.close()  # Close child processes
              except AttributeError:
                  # No pool
                  assert pool is None
  
          return self.best_solution