Сортировка списка двухсторонних элементов на основе сходства последовательных элементов
Я ищу какой-то алгоритм "Сортировка 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
чтобы избежать дегенеративных случаев).
Вы также можете взглянуть на это. Метод для оценки следующего возможного домино состоит в том, чтобы перетасовать множество оставшихся домино и суммировать потери для каждого тасования. Минимальная суммарная потеря, вероятно, даст вам хорошее следующее домино. Я не пробовал...