Сортировка списка двухсторонних элементов на основе сходства последовательных элементов

Я ищу какой-то алгоритм "Сортировка Domino", который сортирует список двухсторонних элементов на основе сходства "касательных" сторон последующих элементов.

Предположим, что следующий список, где элементы представлены 2-мя кортежами:

>>> items
[(0.72, 0.12),
 (0.11, 0.67),
 (0.74, 0.65),
 (0.32, 0.52),
 (0.82, 0.43),
 (0.94, 0.64),
 (0.39, 0.95),
 (0.01, 0.72),
 (0.49, 0.41),
 (0.27, 0.60)]

Цель состоит в том, чтобы отсортировать список таким образом, чтобы сумма квадратов разностей касательных концов каждого из двух последующих элементов (потеря) была минимальной:

>>> loss = sum(
...     (items[i][1] - items[i+1][0])**2
...     for i in range(len(items)-1)
... )

В приведенном выше примере это можно вычислить, просто используя все возможные перестановки, но для списков с большим количеством элементов это становится быстро неосуществимым (O(n!)).

Подход к выбору лучшего совпадения шаг за шагом, как описано здесь

def compute_loss(items):
    return sum((items[i][1] - items[i+1][0])**2 for i in range(len(items)-1))


def domino_sort(items):
    best_attempt = items
    best_score = compute_loss(best_attempt)
    for i in range(len(items)):
        copy = [x for x in items]
        attempt = [copy.pop(i)]
        for j in range(len(copy)):
            copy = sorted(copy, key=lambda x: abs(x[0] - attempt[-1][1]))
            attempt.append(copy.pop(0))
        score = compute_loss(attempt)
        if score < best_score:
            best_attempt = attempt
            best_score = score
    return best_attempt, best_score

дает следующий результат с потерей 0.1381:

[(0.01, 0.72),
 (0.72, 0.12),
 (0.11, 0.67),
 (0.74, 0.65),
 (0.49, 0.41),
 (0.39, 0.95),
 (0.94, 0.64),
 (0.82, 0.43),
 (0.32, 0.52),
 (0.27, 0.6)]

Это, однако, не лучшее решение, которое

[(0.01, 0.72),
 (0.82, 0.43),
 (0.27, 0.6),
 (0.49, 0.41),
 (0.32, 0.52),
 (0.39, 0.95),
 (0.94, 0.64),
 (0.72, 0.12),
 (0.11, 0.67),
 (0.74, 0.65)]

с потерей 0.0842. Очевидно, что вышеупомянутый алгоритм хорошо работает для первых нескольких элементов, однако различия для последних растут настолько большими, что они доминируют над потерей.

Есть ли какой-либо алгоритм, который может выполнять такой вид в приемлемой временной зависимости (возможно для списков сотен элементов)?

Если это невозможно сделать именно в виде менее O(n!), Есть ли приблизительные подходы, которые, вероятно, вернут хороший результат (небольшая потеря)?

Ответы

Ответ 1

В общем, эта проблема заключается в нахождении гамильтонова пути с минимальной длиной, которая тесно связана с известной проблемой продавца путешествий (TSP). И это не похоже на частный случай этой проблемы, который можно решить в полиномиальное время.

Существует огромное количество эвристик и приближенных алгоритмов для решения TSP. Эта статья в Википедии может стать хорошим местом для начала.

Ответ 2

Немного более эффективная версия наивного подхода с использованием bisect.
(реализация kudos: fooobar.com/questions/63446/...)

# Domino Packing
from bisect import bisect_left
from pprint import pprint


def compute_loss(items):
    return sum((items[i][1] - items[i+1][0])**2 for i in range(len(items)-1))


def find_nearest(values, target):
    """
    Assumes values is sorted. Returns closest value to target.
    If two numbers are equally close, return the smallest number.
    """
    idx = bisect_left(values, target)
    if idx == 0:
        return 0
    if idx == len(values):
        return -1
    before = values[idx - 1]
    after = values[idx]
    if after - target < target - before:
        return idx      # after
    else:
        return idx - 1  # before


if __name__ == '__main__':

    dominos = [(0.72, 0.12),
               (0.11, 0.67),
               (0.74, 0.65),
               (0.32, 0.52),
               (0.82, 0.43),
               (0.94, 0.64),
               (0.39, 0.95),
               (0.01, 0.72),
               (0.49, 0.41),
               (0.27, 0.60)]

    dominos = sorted(dominos, key=lambda x: x[0])
    x_values, y_values = [list(l) for l in zip(*dominos)]
    packed = list()
    idx = 0

    for _ in range(len(dominos)):
        x = x_values[idx]
        y = y_values[idx]
        del x_values[idx]
        del y_values[idx]

        idx = find_nearest(x_values, y)
        packed.append((x, y))

    pprint(packed)
    print("loss :%f" % compute_loss(packed))

выход:

[(0.01, 0.72),
 (0.72, 0.12),
 (0.11, 0.67),
 (0.74, 0.65),
 (0.49, 0.41),
 (0.39, 0.95),
 (0.94, 0.64),
 (0.82, 0.43),
 (0.32, 0.52),
 (0.27, 0.6)]
loss :0.138100

Ответ 3

Теоретический вопрос уже обсуждался в других опросах. Я попытался улучшить алгоритм "ближайшего невидимого соседа".

Прежде чем я получу в alogrithms, обратите внимание, что вы, очевидно, можете заменить sorted + pop(0) pop(min_index):

min_index, _ = min(enumerate(copy), key=lambda i_x: abs(i_x[1][0] - attempt[-1][1]))
attempt.append(copy.pop(min_index))

Метод 1: Улучшение базового подхода

Я руководствовался очень простой идеей: вместо того, чтобы рассматривать только левую часть следующего домино, чтобы увидеть, совпадает ли он с правой частью текущей последовательности, почему бы не добавить ограничение на его правую сторону?

Я попробовал это: проверьте, находится ли правая сторона кандидата близко к левой стороне оставшихся домино. Я подумал, что легче найти "следующее следующее" домино с правой стороной, близкой к остальным левым сторонам. Таким образом, я внесла следующие изменения в ваш код:

mean = sum(x[0] for x in copy)/len(copy)
copy = sorted(copy, key=lambda x: abs(x[0] - attempt[-1][1]) + abs(x[1]-mean)) # give a bonus for being close to the mean.

Но это не было улучшением. Кумулятивный убыток за 100 случайных рядов по 100 пунктов (все значения от 0 до 1):

  • ближайший невидимый сосед: 132.73
  • ближайший невидимый сосед и правая сторона близки к среднему: 259.13

После некоторой настройки я попытался превратить бонус в штрафную:

mean = sum(x[0] for x in copy)/len(copy)
copy = sorted(copy, key=lambda x: 2*abs(x[0] - attempt[-1][1]) - abs(x[1]-mean)) # note the 2 times and the minus

На этот раз произошло явное улучшение:

  • ближайший невидимый сосед: 145.00
  • ближайший невидимый сосед и правая сторона далеко от среднего: 93.65

Но почему? Я сделал небольшое исследование. Очевидно, исходный алгоритм работает лучше в начале, но новый алгоритм "поглощает" большие домино (с большим промежутком между левым и правым) и, таким образом, работает лучше в конце.

Поэтому я сосредоточился на пробеле:

copy = sorted(copy, key=lambda x: 2*abs(x[0] - attempt[-1][1]) - abs(x[1]-x[0]))

Идея понятна: потребляйте большие домино перед остальными. Это сработало хорошо:

  • ближайший невидимый сосед: 132.85
  • ближайший невидимый сосед и правая сторона далеко от среднего: 90,71
  • ближайший невидимый сосед и большие домино: 79.51

Способ 2: Улучшить заданную последовательность

Хорошо, теперь более сложная эвристика. Я вдохнул вдохновение из эйнштейна Лин-Кернигана. Я попытался построить последовательности свопов, удовлетворяющих следующему условию: остановить последовательность, как только последняя свопа приведет к уменьшению локальных потерь для одного из измененных домино. Кажется, что каждая последовательность свопов находит лучшее.

Код будет более четким, чем длинное объяснение:

def improve_sort(items, N=4):
    """Take every pair of dominos and try to build a sequence that will maybe reduce the loss.
    N is the threshold for the size of the subsequence"""
    ret = items
    ret = (items, compute_loss(items))
    for i in range(len(items)):
        for j in range(i+1, len(items)):
            # for every couple of indices
            r = try_to_find_better_swap_sequence(ret, [i, j], N)
            if r[1] < ret[1]:
                ret = r

    return ret

def try_to_find_better_swap_sequence(ret, indices, N):
    """Try to swap dominos until the local loss is greater than in the previous sequence"""
    stack = [(indices, ret[0])] # for an iterative DFS
    while stack:
        indices, items = stack.pop()

        # pop the last indices
        j = indices.pop()
        i = indices.pop()
        # create a copy and swap the i-th and the j-th element
        items2 = list(items)
        items2[i] = items[j]
        items2[j] = items[i]
        loss = compute_loss(items2)
        if loss < ret[1]:
            ret = (items2, loss)
        if len(indices) <= N-3: # at most N indices in the sequence
            # continue if there is at least one local improvement
            if local_loss(items2, i) < local_loss(items, i): # i was improved
                stack.extend((indices+[i,j,k], items2) for k in range(j+1, len(items2)))
            if local_loss(items2, j) < local_loss(items, j): # j was improved
                stack.extend((indices+[j,i,k], items2) for k in range(i+1, len(items2)))

    return ret

def local_loss(items, i):
    """This is the loss on the left and the right of the domino"""
    if i==0:
        return (items[i][1] - items[i+1][0])**2
    elif i==len(items)-1:
        return (items[i-1][1] - items[i][0])**2
    else:
        return (items[i-1][1] - items[i][0])**2+(items[i][1] - items[i+1][0])**2
  • no sort + улучшить вид: 46.72
  • ближайший невидимый сосед и большие домино: 78.37
  • ближайший невидимый сосед и большие домино + better_sort: 46.68

Заключение

Второй метод все еще субоптимален (попробуйте его на оригинальные items). Это, очевидно, медленнее первого, но дает гораздо лучшие результаты и даже не требует предварительной сортировки. (Рассмотрите возможность использования shuffle чтобы избежать дегенеративных случаев).

Вы также можете взглянуть на это. Метод для оценки следующего возможного домино состоит в том, чтобы перетасовать множество оставшихся домино и суммировать потери для каждого тасования. Минимальная суммарная потеря, вероятно, даст вам хорошее следующее домино. Я не пробовал...