根据连续项目的相似性对双边项目列表进行排序

Posted

技术标签:

【中文标题】根据连续项目的相似性对双边项目列表进行排序【英文标题】:Sort a list of two-sided items based on the similarity of consecutive items 【发布时间】:2018-08-08 19:05:02 【问题描述】:

我正在寻找某种“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!) 的时间内准确进行这种排序,是否有任何近似方法可能返回好分数(小损失)?

【问题讨论】:

这个问题有一种明确的 NP-complete 感觉。因此,我建议尝试使用en.wikipedia.org/wiki/Branch_and_bound 之类的方法来解决它。 可能的最大项目数是多少? 这相当于旅行商问题,边是您上面描述的差异的平方。所以它是NP完全的 @btilly 谢谢我查看了分支和绑定,它看起来很有希望。然而据我所知,这个问题是 NP 难的,但不是 NP 完全的(搜索 best 顺序,而不仅仅是一个得分至少为 X 的顺序) . @DAle 虽然没有严格的限制,但该算法必须处理数百个项目,因此最多 1,000 个似乎是合理的。据我所知,与 TSP 相比,这个数字对于精确算法来说已经具有挑战性,所以我也会看看启发式算法。 【参考方案1】:

一般来说,这个问题是关于找到一个与著名的Travelling salesman problem (TSP) 密切相关的最小长度的Hamiltonian path。而且它看起来不像是这个问题的一个特例,可以在多项式时间内解决。

有大量用于求解 TSP 的启发式算法和近似算法。 This wikipedia article 可能是一个不错的起点。

【讨论】:

你可以说它与 TSP 有关,但实际上与哈密顿路径问题无关,因为我们知道存在一条路径。 @maraca,最小长度的哈密顿路径问题 我从来没有听说过这个表达式,这让我很困惑,因为哈密顿路径问题只问是否可能而不是路径的长度,这是 TSP 的目标最小哈密顿循环(或路径,通过添加一个与所有其他点距离为 0 的虚拟点,然后使用标准 TSP 求解技术来求解)。 @DAle 实际上,如果列表是循环的,那么 TSP 将是问题的一个特例(项目的两侧将对应于坐标),因此在第一个和最后一项。无论如何,据我所知,这个问题是 NP-hard 并且与 TSP 进行比较有望导致一堆有用的启发式方法。我去看看,谢谢!【参考方案2】:

使用 bisect 的简单方法的稍微更有效的版本。 (实施荣誉:https://***.com/a/12141511/6163736)

# 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

【讨论】:

你在分割什么?我回答这个问题的第一个想法是对元组之间的目标距离进行二分搜索,但我意识到在最优解中我们仍然可以在个体距离上有很大的变化。 domino 列表按 x 值排序,我们使用 bisect 在排序列表中搜索最接近的值以用作下一个元组/“domino”。显然,这不会产生最佳解决方案,因为 OP 已经展示了 啊,德克萨斯州。是的,我发表评论后才意识到你在做什么。【参考方案3】:

理论问题已经在其他答案中讨论过了。我试图改进你的“最近的未访问邻居”算法。

在我进入算法之前,请注意,您显然可以将 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))

方法一:改进基本方法

我被一个非常简单的想法所引导:与其考虑只考虑下一个多米诺骨牌的左侧来查看它是否与当前序列的右侧匹配,不如在其上添加一个约束右边也是?

我试过这个:检查候选的右侧是否靠近剩余的多米诺骨牌的左侧。我认为更容易找到右侧接近剩余左侧平均值的“下一个”多米诺骨牌。因此,我对您的代码进行了以下更改:

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:改进给定序列

好的,现在是更复杂的启发式方法。我的灵感来自the Lin–Kernighan heuristic。我尝试构建满足以下条件的交换序列:一旦最后一次交换确实减少了其中一个交换的多米诺骨牌的局部损失,就停止序列。每个交换序列都被估计找到最好的。

代码比冗长的解释更清晰:

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
无排序 + 改进排序:46.72 最近的未访问邻居和大多米诺骨牌:78.37 最近的未访问邻居和大骨牌 + 改进排序:46.68

结论

第二种方法仍然不是最理想的(在原来的items 上试试)。它显然比第一个慢,但结果要好得多,甚至不需要预排序。 (考虑使用shuffle 以避免退化的情况)。

你也可以看看this。对下一个可能的多米诺骨牌进行评分的方法是将剩余的多米诺骨牌洗牌很多次,并对每次洗牌的损失求和。最小的累积损失可能会给你一个好的下一个多米诺骨牌。我没试过……

【讨论】:

以上是关于根据连续项目的相似性对双边项目列表进行排序的主要内容,如果未能解决你的问题,请参考以下文章

根据音色(音调)按相似度对声音进行排序

如何允许用户重新排序列表中的项目?

如何通过与输入单词相关的相似性对数组进行排序。

Thinkphp微信项目总结1——整体策略

模糊分组依据,对相似词进行分组

按字母数字字符串 MS SQL Server 2012 中的相似性排序