在 O(lg n) 中查找 Python 列表的唯一数字对中的单个数字

Posted

技术标签:

【中文标题】在 O(lg n) 中查找 Python 列表的唯一数字对中的单个数字【英文标题】:Find single number in pairs of unique numbers of a Python list in O(lg n) 【发布时间】:2021-05-31 13:58:40 【问题描述】:

我有一个关于编程算法中的分而治之的问题。假设你在 Python 中得到一个随机整数列表,其中包括:

    唯一的连续整数对 列表中某处的单个整数

并且条件是排他性的,这意味着[2,2,1,1,3,3,4,5,5,6,6] 有效,但这些条件无效:

    [2,2,2,2,3,3,4](违反条件1:因为2有两对,而任意数最多只能有1对) [1,4,4,5,5,6,6,1](违反条件1:因为有一对1但它们不连续)。 [1,4,4,5,5,6,6,3](违反条件2:有2个单数,1和3)

现在的问题是你能在 O(lgn) 算法中找到“单个”数字索引吗?

我原来的刺拳是这样的:

def single_num(array, arr_max_len):

  i = 0

  while (i < arr_max_len):
    if (arr_max_len - i == 1):
      return i
    elif (array[i] == array[i + 1]):
      i = i + 2
    else:
      return i # don't have to worry about odd index because it will never happen
  
  return None 

但是,该算法似乎在 O(n/2) 时间运行,这似乎是它可以做的最好的。

即使我使用分而治之,我认为它不会比 O(n/2) 时间更好,除非有某种方法超出了我目前的理解范围。

谁有更好的主意,或者我可以说,这已经是 O(log n) 时间了?

编辑:Manuel 似乎有最好的解决方案,如果允许,我将有时间自己实施解决方案以供理解,然后接受 Manuel 的回答。

【问题讨论】:

对不起,我输入了 else: return None when else: return i 应该是正确的。已在 OP 中更正。 “我可以说,这已经是 O(log n) 时间了吗?” - 当然不是。 我不明白您的列表条件的含义,即 1 和 2 以及“条件是排他性的,意思是 22113345566 有效,2222334 无效,14455661 也不是。” @darrylG 我添加了更多解释...希望现在更有意义。 @RosaryLightningX 没有人这样做。 【参考方案1】:

解决方案

只需对偶数索引进行二进制搜索,即可找到值与下一个值不同的第一个。

from bisect import bisect

def single_num(a):
    class E:
        def __getitem__(_, i):
            return a[2*i] != a[2*i+1]
    return 2 * bisect(E(), False, 0, len(a)//2)

说明

我正在搜索的虚拟“列表”E() 的可视化:

       0  1   2  3   4  5   6  7   8  9   10 (indices)
  a = [2, 2,  1, 1,  3, 3,  4, 5,  5, 6,  6]
E() = [False, False, False, True,  True]
       0      1      2      3      4     (indices)

在开始时,配对匹配(所以!= 产生False-values)。从单个数字开始,对匹配(所以!= 返回True)。从False &lt; True 开始,这是一个排序列表,bisect 很乐意在其中搜索。

替代实现

没有bisect,如果你还没有厌倦写二分搜索:

def single_num(a):
    i, j = 0, len(a) // 2
    while i < j:
        m = (i + j) // 2
        if a[2*m] == a[2*m+1]:
            i = m + 1
        else:
            j = m
    return 2*i

叹息...

我希望bisect 支持给它一个可调用对象,这样我就可以做return 2 * bisect(lambda i: a[2*i] != a[2*i+1], False, 0, len(a)//2)。 Ruby does,这可能是我有时使用 Ruby 而不是 Python 解决编码问题的最常见原因。

测试

顺便说一句,我在所有可能的情况下都测试了最多 1000 对:

from random import random

for pairs in range(1001):
    a = [x for _ in range(pairs) for x in [random()] * 2]
    single = random()
    assert len(set(a)) == pairs and single not in a
    for i in range(0, 2*pairs+1, 2):
        a.insert(i, single)
        assert single_num(a) == i
        a.pop(i)

【讨论】:

这在某些情况下给出了不正确的答案,例如single_num([2, 1, 1]) 它报告为 0(应该是 2)。 @DarrylG 不,索引 0 是正确答案。 @Manuel - 认为它会报告数字而不是问题所要求的数字索引。我建议在您的代码中添加注释以阐明结果的类型。 @DarrylG 您可以从 OP 的代码中看到他们想要索引,所以我不确定您为什么认为我会做一些不同的事情。此外,索引更有用,因为您可以在 O(1) 中轻松地从索引中获取值,但反之则不行。 @RosaryLightningX 剩下的一半不包含我们搜索的内容。这只是一个普通的二分查找。【参考方案2】:

lg n 算法是将输入分成更小的部分,并丢弃一些更小的部分,这样您就可以使用更小的输入。由于这是一个搜索问题,因此 lg n 时间复杂度的可能解决方案是二分搜索,每次将输入分成两半。


我的方法是从几个简单的案例开始,找出我可以利用的任何模式。

在以下示例中,最大整数是目标数。

# input size: 3  
[1,1,2]
[2,1,1]

# input size: 5  
[1,1,2,2,3]
[1,1,3,2,2]
[3,1,1,2,2]

# input size: 7  
[1,1,2,2,3,3,4]
[1,1,2,2,4,3,3]
[1,1,4,2,2,3,3]
[4,1,1,2,2,3,3]

# input size: 9  
[1,1,2,2,3,3,4,4,5]
[1,1,2,2,3,3,5,4,4]
[1,1,2,2,5,3,3,4,4]
[1,1,5,2,2,3,3,4,4]
[5,1,1,2,2,3,3,4,4]

您可能注意到输入大小始终是奇数,即2*x + 1

由于这是一个二分搜索,您可以检查中间的数字是否是您的目标数字。如果中间的数字是单个数字(if middle_number != left_number and middle_number != right_number),那么你已经找到了。否则,您必须搜索输入的左侧或右侧。

请注意,在上面的示例测试用例中,中间数字不是目标数字,中间数字与其对之间存在模式。

对于输入大小3(2*1 + 1),if middle_number == left_number,目标数在右边,反之亦然。

对于输入大小 5 (2*2 + 1),if middle_number == left_number,目标数字在左边,反之亦然。

对于输入大小 7 (2*3 + 1),if middle_number == left_number,目标数在右边,反之亦然。

对于输入大小 9 (2*4 + 1),if middle_number == left_number,目标数在左边,反之亦然。

这意味着2*x + 1中x的奇偶性(数组长度)影响是搜索输入的左边还是右边:如果x是奇数则搜索右边,如果x是偶数则搜索左边,如果middle_number = = left_number(反之亦然)。


基于所有这些信息,您可以提出递归解决方案。请注意,您必须确保每个递归调用中的输入大小都是奇数。 (编辑:确保输入大小是奇数会使代码更加混乱。您可能想提出一个解决方案,其中输入大小的奇偶性无关紧要。)

def find_single_number(array: list, start_index: int, end_index: int):
    # base case: array length == 1
    if start_index == end_index:
        return start_index
    
    middle_index = (start_index + end_index) // 2
        
    # base case: found target
    if array[middle_index] != array[middle_index - 1] and array[middle_index] != array[middle_index + 1]:
        return middle_index
        
    # make use of parity of array length to search left or right side
    # end_index == array length - 1
    x = (end_index - start_index) // 2

    # ensure array length is odd
    include_middle = (middle_index % 2 == 0)
        
    if array[middle_index] == array[middle_index - 1]:  # middle == number on its left
        if x % 2 == 0:  # x is even
            # search left side
            return find_single_number(
                array,
                start_index,
                middle_index if include_middle else middle_index - 1
            )

        else:  # x is odd
            # search right side side
            return find_single_number(
                array,
                middle_index if include_middle else middle_index + 1,
                end_index,
            )

    else:  # middle == number on its right
        if x % 2 == 0:  # x is even
            # search right side side
            return find_single_number(
                array,
                middle_index if include_middle else middle_index + 1,
                end_index,
            )

        else:  # x is odd
            # search left side
            return find_single_number(
                array,
                start_index,
                middle_index if include_middle else middle_index - 1
            )


# test out the code
if __name__ == '__main__':
    array = [2,2,1,1,3,3,4,5,5,6,6]  # target: 4 (index: 6)
    print(find_single_number(array, 0, len(array) - 1))

    array = [1,1,2]  # target: 2 (index: 2)
    print(find_single_number(array, 0, len(array) - 1))

    array = [1,1,3,2,2]  # target: 3 (index: 2)
    print(find_single_number(array, 0, len(array) - 1))

    array = [1,1,4,2,2,3,3]  # target: 4 (index: 2)
    print(find_single_number(array, 0, len(array) - 1))

    array = [5,1,1,2,2,3,3,4,4]  # target: 5 (index:0)
    print(find_single_number(array, 0, len(array) - 1))

我的解决方案可能不是最有效或最优雅的,但我希望我的解释能帮助您理解解决这类算法问题的方法。


证明它的时间复杂度为 O(lg n):

假设最重要的操作是中间数与左右数(if array[middle_index] != array[middle_index - 1] and array[middle_index] != array[middle_index + 1])的比较,时间成本为 1 个单位。让我们将此比较称为主要比较。

令 T 为算法的时间成本。 设 n 为数组的长度。

由于此解决方案涉及递归,因此存在基本情况和递归情况。

对于基本情况(n = 1),它只是主要的比较,所以: T(1) = 1。

对于递归情况,每次将输入分成两半(左半部分或右半部分);同时,还有一个主要的比较。所以: T(n) = T(n/2) + 1

现在,我知道输入大小必须总是奇数,但为了简单起见,我们假设 n = 2k;时间复杂度还是一样的。

我们可以将 T(n) = T(n/2) + 1 重写为: T(2k) = T(2k-1) + 1

另外,T(1) = 1 是: T(20) = 1

当我们展开 T(2k) = T(2k-1) + 1 时,我们得到:

T(2k) = T(2k-1) + 1 = [T(2k-2) + 1] + 1 = T(2k-2) + 2 = [T(2k-3) + 1] + 2 = T(2k-3) + 3 = [T(2k-4) + 1] + 3 = T(2k-4) + 4 = ...(重复直到 k) = T(2k-k) + k = T(20) + k = k + 1

由于n = 2k,这意味着k = log2 n.

将 n 代入,我们得到: T(n) = log2 n + 1

1 是一个常数,所以它可以被删除;日志操作的基础也是如此。

因此,算法时间复杂度的上界为: T(n) = lg n

【讨论】:

谢谢,打瞌睡后我还需要详细阅读这篇文章,但我的困惑是,由于列表没有以任何方式排序,“二分搜索”不会以最差的结果结束case O(n/2) 因为我们不能保证丢弃左列表或右列表?希望我有更多时间更详细地调查这个问题...... 这篇文章写得真好。 @RosaryLightningX 不仅仅是原始输入每次都被分成两半(这样做会产生 O(n/2) 的时间复杂度)。正是每个输入,原始输入以及源自原始输入的输入,每次都被分成两半 (O(log_2 n))。也许我应该编辑我的答案以包括证明。 我想我现在明白了——我们要丢弃偶数的列表?我喜欢你的解决方案!如果你想改进一下,我会接受。 @RosaryLightningX 我已经添加了证明。我希望它有所帮助。

以上是关于在 O(lg n) 中查找 Python 列表的唯一数字对中的单个数字的主要内容,如果未能解决你的问题,请参考以下文章

LIS (nlogn)

为啥我的合并排序不像 O (n * lg n))?

python 性能优化技巧

红黑树

Python 二分查找与 bisect 模块

Strassen algorithm(O(n^lg7))