【问题标题】:Finding minimal distance between unsorted and sorted lists查找未排序列表和排序列表之间的最小距离
【发布时间】:2014-02-22 12:41:37
【问题描述】:

令 A 是一个列表,而 S 是相同元素的排序列表。假设所有元素都不同。如何找到将 A 变为 S 的最小“移动”(move X before Y (or end)) 集?

例子:

A = [8,1,2,3]
S = [1,2,3,8]

A => S requires one move: 
   move 8 before end

A = [9,1,2,3,0]
S = [0,1,2,3,9]

A => S requires two moves:
   move 9 before 0
   move 0 before 1

我更喜欢 javascript 或 python,但任何语言都可以。

【问题讨论】:

  • A = S 怎么样?我猜想在每一种现代编程语言中都可以使用,其中未排序列表和排序列表具有共同的基类。
  • 所以您可以随意移动元素而不会受到惩罚? (通常,您的第一个示例需要 4 次移动:将所有元素向右移动(3 次移动)并将 8 放在最后(1 次移动))。如果您确实可以移动所有东西,那么您正在寻找Longest increasing subsequence
  • @MartinMeeser 目的不是让A 排序,而是计算使其排序的最小移动数。
  • @Heuster:出于这个问题的目的,我们假设所有的移动都具有相同的成本。
  • 不确定这是否是最佳解决方案,但可以将问题简化为最短路径问题,其中source=Atarget=S,未加权图包含所有排列,边是 -可能的举动。解决方案的复杂性将是使用 BFS 的 O(n^d),或使用双向 BFS 的 O(n^(d/2)) - 其中 d 是所需的最少“移动”数量。

标签: javascript python algorithm language-agnostic


【解决方案1】:

这个问题相当于longest increasing subsequence问题。

您必须定义一个比较运算符lessless(a, b) 将返回 true 当且仅当 a 在目标序列中位于 b 之前。现在使用这个比较运算符,计算源序列的最大递增子序列。您将不得不移动不属于该子序列的每个元素(否则子序列将不是最大的),并且您可以将其移动一次(将其移动到其目标位置)。

编辑:根据 amit 的要求,这是我对上述陈述的证明: 让我们表示目标序列B,让我们表示源序列A。让n = |A|k 为上述最长递增序列的长度。

  • 让我们假设可以从A 到达B,移动次数少于n - k。这意味着至少来自An - k + 1 元素不会被移动。令 s1,s2,...sm 为未移动的元素集合。从假设我们知道m > k。现在由于这些元素没有移动,因此它们相对于彼此的相对位置不能改变。因此,目标序列B 中所有这些元素的相对位置与A 中的位置相同。因此,上面定义的运算符 less(si, sj) 对于任何ij 都应该为真。但如果这是真的,那么 s1,s2,...sm 正在增加序列,并且m > k 这会导致与 k 是最长递增序列长度的假设相矛盾。
  • 现在让我们展示一个算法,通过移动所有元素(除了属于最长递增序列一部分的元素)从A 到达B。我们将按照它们在 B 中出现的顺序移动元素。我们不会移动属于最长递增序列的元素。如果当前元素是 B 中的第一个元素,我们只需将其移动到序列的开头。否则,我们将当前元素移动到 B 中前一个元素的位置之后。请注意,此元素可能是我们已移动的前一个元素,也可能是最长递增序列中的一个元素。请注意,在我们将要移动索引为i 的元素的每一步中,所有索引为1, 2, ...i-1 的元素都将具有正确的相对位置。

编辑:添加一些代码以使答案更清晰。我觉得自己不是 javascript 专家,因此请随时纠正或批评我的解决方案。

让我们定义一个函数transform(a, s),它接受两个参数——如语句中所述列出a 和b。首先,我将创建一个映射positions,将a 中的每个元素映射到其在s 中的位置:

var positions = {};
for (var i = 0; i < a.length; ++i) {
  positions[a[i]] = i;
}

现在我有了这个数组,我可以在上面的答案中定义一个辅助函数。 Less 将采用两个值 ab(以及我刚刚创建的辅助映射),当且仅当 as(目标列表)中的 b 之前返回 true:

function less(a, b, positions) {
  return positions[a] < positions[b];
}

现在我不会描述如何在a 中找到关于该比较运算符的最大递增子序列。您可以查看this question 以了解如何执行此操作的详细说明。我将简单地假设我定义了一个函数:

function max_increasing_subsequence(a, positions)

这将返回a 中相对于上面定义的比较运算符less(使用positions)作为列表的最大递增子序列。我将使用您的第二个示例来说明我们目前所拥有的:

A = [9,1,2,3,0]
S = [0,1,2,3,9]

位置的值如下:

positions = { 0 : 0,
              1 : 1,
              2 : 2,
              3 : 3,
              9 : 4}

max_increasing_subsequence(a, positions) 的结果将是[1, 2, 3]。顺便说一句,如果a 中可能有重复元素,最好返回索引而不是max_increasing_subsequence 中的元素(在此特定示例中,差异将不可见)。

现在我将创建另一个辅助映射来指示最大递增子序列中包含哪些元素:

var included = {};
l = max_increasing_subsequence(a, positions);
for (var i = 0; i < l.length; ++i) {
  included[l[i]] = true;
}

现在您可以在s 上进行一次迭代来完成解决方案。我将为最后一个元素添加一个特殊情况,以使代码更易于理解:

if (!(s[s.length - 1] in included)) {
  console.log("Move" + s[s.length - 1] + " at the end");
}
for (var i = s.length - 2; i >= 0; --i) {
  if (!(s[i] in included)) {
    console.log("Move" + s[i] + " before " + s[i + 1]);
  }
}

请注意,在上面的解决方案中,我假设每次您记录一个新命令时,您都会在所有先前的命令都已执行之后,按照数组 a 的顺序记录它。

所以总的来说,我相信变换应该是这样的:

function transform(a, s) {
  var positions = {};
  for (var i = 0; i < a.length; ++i) {
    positions[a[i]] = i;
  }
  var included = {};
  l = max_increasing_subsequence(a, positions);
  var included = {};
  for (var i = 0; i < l.length; ++i) {
    included[l[i]] = true;
  }
  if (!(s[s.length - 1] in included)) {
    console.log("Move" + s[s.length - 1] + " at the end");
  }
  for (var i = s.length - 2; i >= 0; --i) { // note s.length - 2 - don't process last element
    if (!(s[i] in included)) {
      console.log("Move" + s[i] + " before " + s[i + 1]);
    }
  }
}

我希望这段代码能让我的答案更清楚。

【讨论】:

  • 你能证明这种方法的正确性吗?虽然我认为它是正确的,但我仍然想确定它。
  • @amit 实际上我已经通过实验证明了这一点。我在各种计算机编程比赛中多次通过了同样的问题。相当于众所周知的问题“如何给你处理最少的手牌”
  • 其实我认为正确性证明很简单:考虑一个只移动 k 个项目的有效解决方案。它使其他 n-k 个项目保持不变,因此它们必须已经排序。但是所提出的算法会找到 最长 排序的子序列,因此它必须至少保留 n-k 项未触及。因此,它最多移动 k 个项目。
  • @amit , Eyal 我已经添加了详细的证明。请看一看。
  • 关于你的证明:你不是说“让我们假设有可能从 A 到达 B 的步数少于 n-k”吗?
【解决方案2】:

如果您将两个列表视为两个字符串 - 例如这些数字是 ASCII 编码中的值——那么问题等同于找到允许您将第一个字符串转换为第二个字符串的操作。操作数反过来就是字符串之间的 Levenshtein 或编辑距离。

Levenshtein distance 可以通过using dynamic programming 找到,将两个字符串的所有前缀之间的距离存储在一个矩阵中,然后追溯您的步骤以在矩阵的每一行找到最佳操作(第所需的操作最少)。

@IvayloStrandjev 建议的最长递增子序列算法与最长 common 子序列问题有关,该问题又与编辑距离有关,作为仅允许插入和替换的替代度量。可能它在空间中的性能更高,因为它利用了必须对序列之一进行排序的事实;我只是想提供一个我觉得更容易掌握的替代答案。

这是完整矩阵 Levenshtein 算法的 Python 实现,如上面链接的 Wikipedia 页面中所述(最初在 1974 paper by Wagner and Fischer 中找到),其中还提供了 proof of correctness。在这里,我们还将操作的名称存储在与操作 scores 大小相同的矩阵中,并在完成一行后打印最佳操作。

import argparse

import numpy as np


class Levenshtein(object):
    def __init__(self, string1, string2):
        self.string1 = string1
        self.string2 = string2
        self.scores_matrix = np.zeros(
            (len(self.string1) + 1, len(self.string2) + 1), dtype=np.int16)
        self.operations_matrix = np.empty_like(
            self.scores_matrix, dtype=(np.str_, 16))
        self.total_steps = 0

    def distance(self):
        m = len(self.string1) + 1
        n = len(self.string2) + 1
        for i in range(m):
            self.scores_matrix[i, 0] = i
        for j in range(n):
            self.scores_matrix[0, j] = j
        for j in range(1, n):
            for i in range(1, m):
                if self.string1[i - 1] == self.string2[j - 1]:
                    self.scores_matrix[i, j] = self.scores_matrix[i - 1, j - 1]
                    self.operations_matrix[i, j] = 'match'
                else:
                    self.scores_matrix[i, j] = self.select_operation(i, j)
                if j == n - 1:  # a row is complete
                    self.determine_best_op_and_print(i)
        return self.scores_matrix[m - 1, n - 1]

    def select_operation(self, i, j):
        possible_ops = ['delete', 'insert', 'substitute']
        ops_scores = [
            self.scores_matrix[i - 1, j] + 1,  # deletion
            self.scores_matrix[i, j - 1] + 1,  # insertion
            self.scores_matrix[i - 1, j - 1] + 1]  # substitution
        chosen_op = min(ops_scores)
        chosen_op_name = possible_ops[ops_scores.index(chosen_op)]
        self.operations_matrix[i, j] = chosen_op_name
        return chosen_op

    def determine_best_op_and_print(self, i):
        reversed_row = self.scores_matrix[i][::-1]
        reversed_pos_min = np.argmin(reversed_row)
        pos_min = len(self.scores_matrix[i]) - (reversed_pos_min + 1)
        best_op_name = self.operations_matrix[i, pos_min]
        if best_op_name != 'match':
            self.total_steps += 1
            print best_op_name, self.string1[i - 1], self.string2[pos_min - 1]


def parse_cli():
    parser = argparse.ArgumentParser()
    parser.add_argument('--list', nargs='*', required=True)
    return parser.parse_args()

if __name__ == '__main__':
    args = parse_cli()
    A = args.list
    S = sorted(A)
    lev = Levenshtein(A, S)
    dist = lev.distance()
    print "{} total steps were needed; edit distance is {}".format(
        lev.total_steps, dist)

以下是如何使用您提供的示例运行代码以及预期的输出:

$ python levenshtein.py --list 8 1 2 3
substitute 8 1
1 total steps were needed; edit distance is 2

$ python levenshtein.py --list 9 1 2 3 0
substitute 9 0
substitute 0 9
2 total steps were needed; edit distance is 2

【讨论】:

    【解决方案3】:

    这在很大程度上取决于未说明的问题的一些参数。首先,哪些动作是合法的?仅相邻元素交换?任意删除和插入?其次,您只需要移动的数量还是需要执行特定移动的列表?这些导致了不同的算法:

    1. 仅相邻交换 - 如果您只关心最小数量,这称为反转计数。
    2. 删除、非相邻交换等 - 前面提到的 Levenshtein 距离是更通用的编辑距离。关于这一点的一个技巧是你如何定义你的移动集。将元素移动 3 个位置超过一次移动还是两次移动(删除和插入)?

    反转计数非常简单,可以使用一些基本的递归算法来完成。您可以使用合并排序来查找两个列表之间的反转计数,方法是使用一个列表制作另一个列表的转换版本,其中新元素是索引。所以如果你有两个序列,你可以这样做:

    sequence = [seq2.index(element) for element in seq]
    

    计算反转的简单直接 Python 合并排序实现是:

    if len(sequence) <= 1:
        return 0, sequence
    else:
        firstHalf = sequence[:int(len(sequence)/2)]
        secondHalf = sequence[int(len(sequence)/2):]
        count1, firstHalf = mergeSortInversionCount(firstHalf)
        count2, secondHalf = mergeSortInversionCount(secondHalf)
        firstN = len(firstHalf)
        secondN = len(secondHalf)
        secondHalfEnd = secondN
        count3 = count1 + count2
        # Count the inversions in the merge
        # Uses a countdown through each sublist
        for i in xrange(firstN-1, -1, -1):
            x = firstHalf[i]
            inversionFound = False
            for j in xrange(secondHalfEnd-1,-1,-1):
                if x > secondHalf[j]:
                    inversionFound = True
                    break
            if inversionFound:
                secondHalfEnd = j+1
                count3 += j+1
        mergeList = firstHalf + secondHalf
        mergeList.sort()
        return count3, mergeList
    

    这只是将列表分成两半并计算倒置数,同时对列表进行排序。从算法上讲,合并排序非常有效(NlogN,尽管从实践上讲,您可以使用一些 numpy 矩阵或通过为底层 Python 排序算法开发对 C 代码的小修改来更快地计算它。从技术上讲,鉴于这种方法可以转换任何将变量类型转换为数字,它基本上简化为列表排序方法,因此您可以使用其他元素方式列表排序来做同样的事情,只要您跟踪计数。

    使用这些方法中的任何一种(反转计数、Levenstein 等),您都可以清楚地记录移动。反转计数记录交换,logc 指出了一种合理的方法来记录 Levenstein 的一些更一般的移动。就个人而言,我倾向于为此使用反转计数,因为它们相当简单。但这很大程度上取决于你想要什么。如果您需要比二元素邻居交换更多的操作,Levenstein 是一个明确的选择。

    【讨论】:

      【解决方案4】:

      执行Cycle Sort 并计算移动次数。这保证是最小数量。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2020-09-29
        • 1970-01-01
        • 2022-06-17
        • 1970-01-01
        • 2016-11-06
        • 1970-01-01
        • 1970-01-01
        • 2016-02-16
        相关资源
        最近更新 更多