def _merge_clusters(scheme, O, S, add_random=True):
    """Merge bad clusters into 1"""
    clusters = construct_clusters(scheme, S)
    new_clusters = []
    while clusters:
        p = random.randint(0, 1)
        updated_cluster = clusters.pop(0)
        if not add_random or p:
            # randomly decide if want to merge curr cluster
            # always expect split if len(clusters) == 1
            cluster_length = len(clusters)
            for _ in range(cluster_length):
                if not clusters:
                    break
                next_cluster = clusters.pop(0)
                merged = deepcopy(updated_cluster)
                merged.merge(next_cluster)
                # if merged cluster is worse than split clusters, do not merge
                # else, merge
                if merged.value <= updated_cluster.value + next_cluster.value:
                    clusters.append(next_cluster)
                else:
                    updated_cluster = merged
        new_clusters.append(updated_cluster)
    # fix ids
    for i in range(len(new_clusters)):
        new_clusters[i].id = i
    new_S = Solution.from_clusters(scheme, new_clusters)
    if satisfies_constraints(scheme, new_S):
        S = new_S
    else:  # shouldn't happen
        raise ValueError('infeasible solution in split')
    return S
def _move_machines(scheme, O, S):
    """Perform movement of machines between clusters within a solution"""
    clusters = construct_clusters(scheme, S)
    rated_machines = _find_best_fit_for_machines(scheme, clusters)
    while rated_machines:
        stat = rated_machines.pop(0)
        curr_id, new_id = stat.curr_cluster, stat.new_cluster
        if clusters[curr_id].near_empty:
            # can't move anything out of near empty cluster
            continue
        curr_cluster = deepcopy(clusters[curr_id])
        new_cluster = deepcopy(clusters[new_id])
        curr_cluster.machines, new_cluster.machines = _move(
            stat.machine, curr_cluster.machines, new_cluster.machines)
        old_value = clusters[curr_id].value + clusters[new_id].value
        new_value = curr_cluster.value + new_cluster.value
        # if new clusters have better objective than old ones, approve move
        if new_value > old_value:
            clusters[curr_id] = curr_cluster
            clusters[new_id] = new_cluster
            rated_machines = _find_best_fit_for_machines(scheme, clusters)

    # construct new S
    # if new S is better and satisfies constraints, approve changes
    new_S = Solution.from_clusters(scheme, clusters)
    if O(scheme, new_S) > O(scheme, S) and satisfies_constraints(
            scheme, new_S):
        S = new_S
    return S
def _split_clusters(scheme, O, S, add_random=True):
    """Split bad clusters in solution"""
    clusters = construct_clusters(scheme, S)
    new_clusters = []
    for cluster in clusters:
        p = random.randint(0, 1)
        if not cluster.can_split:
            # copy "as is" if can't split the cluster
            new_clusters.append(cluster)
            continue
        if add_random and len(clusters) > 1 and p:
            # randomly decide if want to split curr cluster
            # always expect split if len(clusters) == 1
            new_clusters.append(cluster)
            continue
        new_clusters += _split(scheme, cluster)
    # fix ids:
    for i in range(len(new_clusters)):
        new_clusters[i].id = i
    new_S = Solution.from_clusters(scheme, new_clusters)
    if satisfies_constraints(scheme, new_S):
        S = new_S
    else:  # shouldn't happen
        raise ValueError('infeasible solution in split')
    return S