在O(logn)时间内找到第k小的数字

11
这里有一个问题,一个未排序的数组 a[n],我需要在范围 [i, j] 中找到第 k 小的数字,绝对满足 1<=i<=j<=n, k<=j-i+1
通常我会使用 quick-find 来完成这项工作,但是如果有许多不同范围 [i, j] 的查询请求,它就不够快了,我很难想出一种算法以 O(logn) 的时间进行查询(预处理是允许的)。
欢迎任何想法。
附注:
让我更容易理解这个问题。任何类型的预处理都是允许的,但是查询需要在 O(logn) 的时间内完成。并且将有许多(超过 1 个)查询,例如在范围 [3,7] 中查找第一个,或者在范围 [10,17] 中查找第三个,或者在范围 [33,52] 中查找第11个。
通过 范围 [i, j],我指的是原始数组,而不是已排序或其他什么东西。
例如,a[5] = {3,1,7,5,9},查询[3,4]范围内的第1个数是5,查询[1,3]范围内的第2个数是5,查询[0,2]范围内的第3个数是7

如果允许预处理,就对其进行排序。 - Peter Wooster
@PeterWooster,是的,请对它进行排序,那么在区间[1,n]内查找第k个元素就很容易了。但是如果要查找区间[i,j]内的第k个元素呢?再次对区间[i,j]内的元素排序吗?NO! - Alcott
这似乎是区间最小值查询最近公共祖先问题的变体。 - Terry Li
@terry-li 这与区间最小值查询非常相似,唯一的区别在于我们无法对预处理阶段进行相同的空间优化,因为如果 k 可以从查询到查询改变,我们需要整个子列表的信息。 - Roasted Yam
6个回答

7
如果允许预处理且不计入时间复杂度,那么可以使用它来构建子列表,以便您可以高效地找到要查找的元素。与大多数优化一样,这会用空间换时间。
预处理步骤是将原始的n个数字列表创建为许多新的子列表。
每个子列表都是原始列表的一部分,从第n个元素开始,延伸m个元素,然后排序。因此,您的原始列表如下:
 {3, 1, 7, 5, 9}

给你:

 list[0][0] = {3}
 list[0][1] = {1, 3}
 list[0][2] = {1, 3, 7}
 list[0][3] = {1, 3, 5, 7}
 list[0][4] = {1, 3, 5, 7, 9}

 list[1][0] = {1}
 list[1][1] = {1, 7}
 list[1][2] = {1, 5, 7}
 list[1][3] = {1, 5, 7, 9}

 list[2][0] = {7}
 list[2][1] = {5, 7}
 list[2][2] = {5, 7, 9}

 list[3][0] = {5}
 list[3][1] = {5,9}

 list[4][0] = {9}

这不是一项廉价的操作(无论是时间还是空间),因此您可能希望在列表上维护一个“脏”标志,以便在修改操作(插入、删除、更改)后仅第一次执行该操作。
实际上,您可以使用惰性求值来提高效率。基本上,在开始时以及每次执行修改操作时将所有子列表设置为空列表。然后,每当您尝试访问一个子列表并且它为空时,在尝试获取其第k个值之前计算该子列表(仅计算该子列表)。
这确保子列表仅在需要时进行评估并进行缓存以防止不必要的重新计算。例如,如果您从第3到第6个子列表中从未请求值,则永远不会计算。
创建所有子列表的伪代码基本上是(包括两端的for循环):
for n = 0 to a.lastindex:
    create array list[n]
    for m = 0 to a.lastindex - n
        create array list[n][m]
        for i = 0 to m:
            list[n][m][i] = a[n+i]
        sort list[n][m]

惰性求值的代码略微复杂(但只是略微),因此我不会为其提供伪代码。

然后,要查找范围ij(其中ij是原始索引)中第k个最小数,只需查找lists [i] [j-i] [k-1],这是非常快速的O(1)操作:

                +--------------------------+
                |                          |
                |                          v
1st in range [3,4] (values 5,9),   list[3][4-3=1][1-1-0] = 5
2nd in range [1,3] (values 1,7,5), list[1][3-1=2][2-1=1] = 5
3rd in range [0,2] (values 3,1,7), list[0][2-0=2][3-1=2] = 7
|             |                         ^    ^    ^
|             |                         |    |    |
|             +-------------------------+----+    |
|                                                 |
+-------------------------------------------------+

这里有一些Python代码,展示了它的用法:
orig = [3,1,7,5,9]
print orig

print "====="
list = []
for n in range (len(orig)):
    list.append([])
    for m in range (len(orig) - n):
        list[-1].append([])
        for i in range (m+1):
            list[-1][-1].append(orig[n+i])
        list[-1][-1] = sorted(list[-1][-1])
        print "(%d,%d)=%s"%(n,m,list[-1][-1])

print "====="
# Gives xth smallest in index range y through z inclusive.
x = 1; y = 3; z = 4; print "(%d,%d,%d)=%d"%(x,y,z,list[y][z-y][x-1])
x = 2; y = 1; z = 3; print "(%d,%d,%d)=%d"%(x,y,z,list[y][z-y][x-1])
x = 3; y = 0; z = 2; print "(%d,%d,%d)=%d"%(x,y,z,list[y][z-y][x-1])
print "====="

正如预期的那样,输出结果为:
[3, 1, 7, 5, 9]
=====
(0,0)=[3]
(0,1)=[1, 3]
(0,2)=[1, 3, 7]
(0,3)=[1, 3, 5, 7]
(0,4)=[1, 3, 5, 7, 9]
(1,0)=[1]
(1,1)=[1, 7]
(1,2)=[1, 5, 7]
(1,3)=[1, 5, 7, 9]
(2,0)=[7]
(2,1)=[5, 7]
(2,2)=[5, 7, 9]
(3,0)=[5]
(3,1)=[5, 9]
(4,0)=[9]
=====
(1,3,4)=5
(2,1,3)=5
(3,0,2)=7
=====

1
@Alcott,如果i是一个值,你可以用二分查找来找到它。假设我在原始答案中没有表述清楚,我已经更新了它,希望更加明确。 - paxdiablo
抱歉,但我认为你没有理解问题。我刚刚更新了我的帖子,并给出了一个例子。 - Alcott
@Alcott,实际上你现在让它更清晰了。我根据你的编辑更新了我的答案。 - paxdiablo
点赞,这是一个非常好的想法,让我想起了稀疏表,我会尝试一下。 - Alcott
@Alcott,n 到底有多大,以及每分钟需要执行多少次?这将会改变所有提出的解决方案的价值主张。 - paxdiablo
显示剩余4条评论

6
当前的解决方案是O((logn)^2)。我相信可以将其修改为O(logn)。这个算法相对于paxdiablo算法的主要优势在于空间效率。这个算法需要O(nlogn)的空间,而不是O(n^2)的空间。
首先,从长度为m和n的两个排序数组中找到第k个最小的元素的复杂度是O(logm+logn)。从长度为a、b、c、d...的数组中找到第k个最小的元素的复杂度是O(loga+logb+...)。
现在,对整个数组进行排序并存储。对数组的前半部分和后半部分进行排序并存储,以此类推。你将拥有一个长度为n的已排序数组,两个长度为n/2的已排序数组,四个长度为n/4的已排序数组,依此类推。所需的总内存=1*n+2*n/2+4*n/4+8*n/8...= nlogn。
一旦你有了i和j,就能找出连接起来得到范围[i,j]的子数组列表。将会有logn个数组。在它们之间找到第k个最小的数字需要O((logn)^2)时间。
最后一段的例子: 假设数组大小为8(从0到7索引)。你有以下已排序列表:
A:0-7, B:0-3, C:4-7, D:0-1, E:2-3, F:4-5, G:6-7。
现在构建一个树,其中包含指向这些数组的指针,使每个节点都包含其直接组成部分。A将成为根,B和C是它的子节点,以此类推。
现在实现一个递归函数,返回一个数组列表。
def getArrays(node, i, j):
    if i==node.min and j==node.max:
        return [node];

    if i<=node.left.max:
        if j<=node.left.max:
            return [getArrays(node.left, i, j)];  # (i,j) is located within left node
        else:
            return [ getArrays(node.left, i, node.left.max), getArrays(node.right, node.right.min, j) ]; # (i,j) is spread over left and right node 
    else:
        return [getArrays(node.right, i, j)]; # (i,j) is located within right node

请问您能否进一步解释一下您最后一段的内容? - user
1
@user 我添加了一段简单的Python代码。如果您需要更多解释,请告诉我。 - ElKamina
如果我必须先对数组进行排序,那么仅返回 array[i+k] 有什么问题吗? - user
@user 请考虑以下示例:5 6 7 1 2 3。在[0,2]中第二大的数字是6。如果您对所有数字进行排序,得到的答案将是2,这是不正确的。 - ElKamina

3
预处理:制作一个nxn的数组,其中[k][r]元素是前r个元素中第k小的元素(为方便起见,从1开始索引)。
然后,给定某个特定范围[i,j]和k的值,请执行以下操作:
1.找到矩阵的[k][j]槽中的元素;将其称为x。
2.沿着矩阵的第i-1列向下移动,并查找其中有多少个值小于或等于x(将列0视为具有零个较小条目)。由于构造方式,此列将被排序(所有列都将被排序),因此可以在对数时间内找到。将此值称为s。
3.找到矩阵中[k+s][j]槽中的元素。这就是你的答案。
例如,给定3 1 7 5 9,
现在,如果我们要求[2,4]范围内的第二小值(再次,从1开始索引),我首先找到[1,4]范围内的第二小值为3。然后,我查看第1列,并发现有1个元素小于或等于3。最后,我在[3][5]槽中找到[1,4]范围内的第三小值为5,如所需。
这需要n^2空间和log(n)的查找时间。

在第二步,您应该从列“起始索引-1”进行检查。您的算法在[2,4]范围内给出了错误的第三个最小元素结果。 - user
你的算法有问题,对于范围[1,5]找到第5小的数。你的算法会导致索引超出范围错误。 - user
请看我的最后一条评论,即使修正了拼写错误,你的算法仍然是错的。 - user
@user 不,是正确的。 在你的例子中,i=1,j=5,k=5。在步骤1中,我们找到[5][5]位置的元素,它是9。接下来,我们查看 i-1 列。在这种情况下,它是“零”列,我们将其视为有0个更小的条目,所以 s=0。最后,我们找到 [5+0][5] = [5][5] 位置的元素,它是9,就像应该的那样。 - Dave
1
@user 更一般地说,我们正在寻找1-j中第k小的数字,然后调整它以适应i-j之外的任何更小的数字,因为由于排序,它们必须在1-(i-1)中出现,因此将出现在列i-1中(或者如果我们从第1列开始,则可能不会出现在任何地方,在这种情况下,我们可以在常数时间内从[k] [j]中取出数字)。 - Dave
显示剩余4条评论

1
这种方法不需要预处理,但速度比O(logN)略慢。它比朴素的迭代和计数快得多,并且可以支持序列的动态修改。
具体做法如下:假设长度n等于2的x次方,构建一个段树,其根节点表示[0,n-1]。对于每个节点,如果它表示一个区间[a,b],其中b>a,则让它有两个子节点,分别表示区间[a,(a+b)/2]和[(a+b)/2+1,b](即递归地除以二)。
然后,在每个节点上,维护一个在该段内的数字的单独二叉搜索树。因此,序列上的每个修改都需要O(logN)[在段上]*O(logN)[在BST上]。可以像这样进行查询,令Q(a,b,x)是在段[a,b]中x的排名。显然,如果可以有效地计算Q(a,b,x),则在x上进行二分查找可以有效地计算所需的答案(带有额外的O(logE)因子)。 Q(a,b,x)可以计算如下:找到构成[a,b]的最小段数,这可以在段树上用O(logN)完成。对于每个段,在该段的二叉搜索树上查询小于x的元素数量。添加所有这些数字以获得Q(a,b,x)
这应该是O(logN*logE*logN)。虽然不完全是您所要求的。

0

在O(log n)的时间内,不可能读取数组中的所有元素。由于它没有排序,并且没有提供其他信息,这是不可能的。


我说预处理是允许的。 - Alcott
什么样的预处理?那是没有意义的。你的预处理至少得是O(n)。 - bchurchill
1
我认为这实际上在原始帖子中就有,他只是在编辑时加粗了它。好的,预处理一下。对数组进行排序,然后选择第k个元素。O(1)。 - bchurchill
抱歉,@Alcott,我刚看到加粗的字体就以为是新增的内容了。 - paxdiablo

-1

在最坏和平均情况下,你无法做得比O(n)更好。你必须查看每个元素。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接