如何在Perl中高效地统计覆盖给定范围的区间数?

3

我有一个包含3万个范围的数据库,每个范围都由一对起始点和结束点表示:

[12,80],[34,60],[34,9000],[76,743],...

我想编写一个Perl子例程,它接受一个范围(不是来自数据库),并返回数据库中完全“包含”给定范围的范围数量。
例如,如果我们只有这4个范围在数据库中,而查询范围是[38,70],则子例程应该返回2,因为第一个和第三个范围都完全包含查询范围。
问题:我希望尽可能地使查询“便宜”,如果有助于解决问题,我不介意进行大量的预处理。
一些注意事项:
1. 我自由使用了“数据库”一词,我并不是指实际的数据库(例如SQL);它只是一长串范围。 2. 我的世界是循环的...有一个给定的max_length(例如9999),像[8541,6]这样的范围是合法的(你可以把它看作是[8541,9999][1,6]的并集形成的单个范围)。
谢谢, Dave 更新: 以下是我的原始代码:
use strict;
use warnings;

my $max_length = 200;
my @ranges     = (
    { START => 10,   END => 100 },
    { START => 30,   END => 90 },
    { START => 50, END => 80 },
    { START => 180,  END => 30 }
);

sub n_covering_ranges($) {
    my ($query_h) = shift;
    my $start     = $query_h->{START};
    my $end       = $query_h->{END};
    my $count     = 0;
    if ( $end >= $start ) {

        # query range is normal
        foreach my $range_h (@ranges) {
            if (( $start >= $range_h->{START} and $end <= $range_h->{END} )
                or (    $range_h->{END} <= $range_h->{START} and  $range_h->{START} <= $end )
                or ( $range_h->{END} <= $range_h->{START} and  $range_h->{END} >= $end)
                )
            {
                $count++;
            }
        }

    }

    else {

        # query range is hanging over edge
        # only other hanging over edges can contain it
        foreach my $range_h (@ranges) {
            if ( $start >= $range_h->{START} and $end <= $range_h->{END} ) {
                $count++;
            }
        }

    }

    return $count;
}

print n_covering_ranges( { START => 1, END => 10 } ), "\n";
print n_covering_ranges( { START => 30, END => 70 } ), "\n";

是的,我知道这些if语句很丑,可以做得更好、更高效。

更新2 - 建议的解决方案基准测试

到目前为止,我已经对两个提出的解决方案进行了一些基准测试:naive one,由cjm提出,类似于我的原始解决方案,以及memory-demanding one,由Aristotle Pagaltzis提出。再次感谢你们两个!

为了比较这两种方法,我创建了以下使用相同接口的程序包:

use strict;
use warnings;

package RangeMap;

sub new {
    my $class      = shift;
    my $max_length = shift;
    my @lookup;
    for (@_) {
        my ( $start, $end ) = @$_;
        my @idx
            = $end >= $start
            ? $start .. $end
            : ( $start .. $max_length, 0 .. $end );
        for my $i (@idx) { $lookup[$i] .= pack 'L', $end }
    }
    bless \@lookup, $class;
}

sub num_ranges_containing {
    my $self = shift;
    my ( $start, $end ) = @_;
    return 0 unless defined $self->[$start];
    return 0 + grep { $end <= $_ } unpack 'L*', $self->[$start];
}

1;

并且:

use strict;
use warnings;

package cjm;

sub new {
    my $class      = shift;
    my $max_length = shift;

    my $self = {};
    bless $self, $class;

    $self->{MAX_LENGTH} = $max_length;

    my @normal  = ();
    my @wrapped = ();

    foreach my $r (@_) {
        if ( $r->[0] <= $r->[1] ) {
            push @normal, $r;
        }
        else {
            push @wrapped, $r;
        }
    }

    $self->{NORMAL}  = \@normal;
    $self->{WRAPPED} = \@wrapped;
    return $self;
}

sub num_ranges_containing {
    my $self = shift;
    my ( $start, $end ) = @_;

    if ( $start <= $end ) {

        # This is a normal range
        return ( grep { $_->[0] <= $start and $_->[1] >= $end }
                @{ $self->{NORMAL} } )
            + ( grep { $end <= $_->[1] or $_->[0] <= $start }
                @{ $self->{WRAPPED} } );
    }
    else {

        # This is a wrapped range
        return ( grep { $_->[0] <= $start and $_->[1] >= $end }
                @{ $self->{WRAPPED} } )

            # This part should probably be calculated only once:
            + ( grep { $_->[0] == 1 and $_->[1] == $self->{MAX_LENGTH} }
                @{ $self->{NORMAL} } );
    }
}

1;

我接着使用了一些真实数据:$max_length=3150000,大约有17000个范围,平均大小为几千个,最后进行了大约10000个查询。我计时了对象的创建(添加所有范围)和查询。结果如下:
cjm creation done in 0.0082 seconds
cjm querying done in 21.209857 seconds
RangeMap creation done in 45.840982 seconds
RangeMap querying done in 0.04941 seconds

恭喜Aristotle Pagaltzis!您的实现速度非常快! 然而,为了使用这个解决方案,我显然希望在创建对象时进行预处理(创建)一次。我可以在创建后存储(nstore)这个对象吗?我以前从未做过这样的事情。我该如何检索它?有什么特别的地方吗?希望检索速度很快,这样就不会影响这个伟大数据结构的整体性能。 更新3: 我尝试了一个简单的nstore和检索RangeMap对象。这似乎运行良好。唯一的问题是生成的文件大小约为1GB,并且我将有大约1000个这样的文件。我可以接受这个存储量,但我想知道是否有更有效的方法来存储它,而不会对检索性能产生太大影响。还请参见此处:http://www.perlmonks.org/?node_id=861961。 更新4 - RangeMap bug

很遗憾,RangeMap 存在一个错误。 感谢 PerlMonks 的 BrowserUK 指出这一点。 例如,创建一个对象,其 $max_length = 10,并且具有单个范围 [6,2]。 然后查询 [7,8]。 答案应该是 1,而不是 0

我认为这个更新的包应该可以解决问题:

use strict;
use warnings;

package FastRanges;

sub new($$$) {
    my $class      = shift;
    my $max_length = shift;
    my $ranges_a   = shift;
    my @lookup;
    for ( @{$ranges_a} ) {
        my ( $start, $end ) = @$_;
        my @idx
            = $end >= $start
            ? $start .. $end
            : ( $start .. $max_length, 1 .. $end );
        for my $i (@idx) { $lookup[$i] .= pack 'L', $end }
    }
    bless \@lookup, $class;
}

sub num_ranges_containing($$$) {
    my $self = shift;
    my ( $start, $end ) = @_;    # query range coordinates

    return 0
        unless ( defined $self->[$start] )
        ;    # no ranges overlap the start position of the query

    if ( $end >= $start ) {

        # query range is simple
        # any inverted range in {LOOKUP}[$start] must contain it,
        # and so does any simple range which ends at or after $end
        return 0 + grep { $_ < $start or $end <= $_ } unpack 'L*',
            $self->[$start];
    }
    else {

        # query range is inverted
        # only inverted ranges in {LOOKUP}[$start] which also end
        # at of after $end contain it. simple ranges can't contain
        # the query range
        return 0 + grep { $_ < $start and $end <= $_ } unpack 'L*',
            $self->[$start];
    }
}

1;

欢迎您的评论。


1
我们需要知道这个“数据库”是如何存储的。它是一个文本文件,一个数组的数组引用,还是其他什么东西? - cjm
@cjm 我正在构建它,所以一切都顺利进行。目前它是一个哈希的哈希(每个范围都是一个哈希,其中包含“开始”,“结束”和许多其他我不需要的东西)。预处理的一部分可能是将其转换为更简单的哈希数组,或者二维数组等。 - David B
6个回答

2
这里是一种暴力解决方案的方法:
use strict;
use warnings;

my @ranges = ([12,80],[34,60],[34,9000],[76,743]);

# Split ranges between normal & wrapped:
my (@normal, @wrapped);

foreach my $r (@ranges) {
  if ($r->[0] <= $r->[1]) {
    push @normal, $r;
  } else {
    push @wrapped, $r;
  }
}

sub count_matches
{
  my ($start, $end, $max_length, $normal, $wrapped) = @_;

  if ($start <= $end) {
    # This is a normal range
    return (grep { $_->[0] <= $start and $_->[1] >= $end } @$normal)
        +  (grep { $end <= $_->[1] or $_->[0] <= $start } @$wrapped);
  } else {
    # This is a wrapped range
    return (grep { $_->[0] <= $start and $_->[1] >= $end } @$wrapped)
        # This part should probably be calculated only once:
        +  (grep { $_->[0] == 1 and $_->[1] == $max_length } @$normal);
  }
} # end count_matches

print count_matches(38,70, 9999, \@normal, \@wrapped)."\n";

+1 感谢cjm,这与我所做的非常相似,而且确实有效,但我想知道是否可以通过一些预处理使事情更快,并避免在每个查询中迭代所有范围(我使用了循环,你使用了grep,但我猜它是一样的...还是吗?)。再想一想,也许我把车马放错了位置 - 也许这已经足够好了。我应该在真正的大规模数据上测试性能。 - David B
问题是如何比最简单的算法更好,而不是如何编写它(这将等同于“为我编码”的请求,因为它是从散文直接转换为代码)。 - Aristotle Pagaltzis

2

你有很多可用内存吗?

my $max_length = 9999;
my @range = ( [12,80],[34,60],[34,9000] );

my @lookup;

for ( @range ) {
    my ( $start, $end ) = @$_;
    my @idx = $end >= $start ? $start .. $end : ( $start .. $max_length, 0 .. $end );
    for my $i ( @idx ) { $lookup[$i] .= pack "L", $end }
}

现在你有一个包含打包数字列表的数组@lookup,每个索引处的打包列表都包含包括该点的所有范围的结尾。因此,要检查有多少个范围包含另一个范围,您需要在数组中查找其起始索引,然后计算从该索引处的打包列表中小于或等于结束索引的条目数。这个算法是O(n)与覆盖任何一个点的最大范围数量有关(限制是总范围数量),每次迭代的开销非常小。
sub num_ranges_containing {
    my ( $start, $end ) = @_;

    return 0 unless defined $lookup[$start];

    # simple ranges can be contained in inverted ranges,
    # but inverted ranges can only be contained in inverted ranges
    my $counter = ( $start <= $end )
        ? sub { 0 + grep { $_ < $start or  $end <= $_ } }
        : sub { 0 + grep { $_ < $start and $end <= $_ } };

    return $counter->( unpack 'L*', $lookup[$start] );
}

未经测试。

为了更加整洁,

package RangeMap;

sub new {
    my $class = shift;
    my $max_length = shift;
    my @lookup;
    for ( @_ ) {
        my ( $start, $end ) = @$_;
        my @idx = $end >= $start ? $start .. $end : ( $start .. $max_length, 0 .. $end );
        for my $i ( @idx ) { $lookup[$i] .= pack 'L', $end }
    }
    bless \@lookup, $class;
}

sub num_ranges_containing {
    my $self = shift;
    my ( $start, $end ) = @_;

    return 0 unless defined $self->[$start];

    # simple ranges can be contained in inverted ranges,
    # but inverted ranges can only be contained in inverted ranges
    my $counter = ( $start <= $end )
        ? sub { 0 + grep { $_ < $start or  $end <= $_ } }
        : sub { 0 + grep { $_ < $start and $end <= $_ } };

    return $counter->( unpack 'L*', $self->[$start] );
}

package main;
my $rm = RangeMap->new( 9999, [12,80],[34,60],[34,9000] );

那样你就可以拥有任意数量的范围。 同时也没有经过测试。

+1 感谢Aristotle Pagaltzis。我喜欢这个想法,但它可能有些问题,因为'max_length'可能高达1000万,但范围的数量相对较低(约30k),这意味着我们实际上有稀疏数据。此外,我不确定这是否适用于环绕范围的情况下能否正确工作。 - David B
最坏情况下将有1000万个子数组,每个子数组大约110字节,总共大约1GB。一个空的1000万元素数组只有40MB。加上子数组内容的开销...你可能会超过32位内存限制,但在64位机器上至少很容易解决。:-)特别是如果大多数点只被几个范围覆盖。如果您的空间的大部分未被任何范围覆盖,则需要相应较少的子数组。 - Aristotle Pagaltzis
啊,是的。当然需要将 wrap-around 的情况改为 ($start .. $max_length, 0 .. $end)。已经修正了答案。 - Aristotle Pagaltzis
另外一个更正:在num_ranges_containing()函数的return语句之前应该添加return 0 unless defined $self->[$start]; - David B
我已经再次更新了代码,希望这是最后一次,以解决你发现的问题。 - Aristotle Pagaltzis
显示剩余5条评论

2

有一个比自己编写范围更容易的方法:使用Number::Interval

my @ranges     = (
    { START => 10,   END => 100 },
    { START => 30,   END => 90 },
    { START => 50, END => 80 },
    { START => 180,  END => 30 }
);
my @intervals;
for my $range ( @ranges ) {
  my $int = new Number::Interval( Min => $range->{START},
                                  Max => $range->{END} );
  push @intervals, $int;
}

然后您可以使用 intersection() 方法来判断两个范围是否重叠:

my $num_overlap = 0;
my $checkinterval = new Number::Interval( Min => $min, Max => $max );
for my $int ( @intervals ) {
  $num_overlap++ if $checkinterval->intersection( $int );
}

我不确定它在处理您的“循环”范围时会做什么(使用Number::Interval将被归类为“反转”间隔),因此您需要进行一些实验。

但是使用模块确实比编写自己的范围比较方法更好。

编辑:实际上,仔细查看文档后,intersection() 不会做你想要的事情(实际上,它修改了一个间隔对象)。 您可能希望在开始和结束值上使用 contains() 方法,如果这两个值都包含在另一个间隔内,则第一个间隔包含在第二个间隔内。

当然,您可以更新 Number::Interval 以添加此功能... :-)


+1 谢谢,这很好知道。几乎任何东西都有一个 Perl 模块,不是吗? - David B
有的,只是要找到它而已。 :-) - CanSpice

1
你遇到了哪个部分的问题?你已经尝试过什么了吗?这是一个相当简单的任务:
  * Iterate through the ranges
  * Foreach range, check if the test range is in it.
  * Profile and benchmark

这是相当简单的 Perl 代码:

 my $test = [ $n, $m ];
 my @contains = map { 
      $test->[0] >= $_->[0] 
         and 
      $test->[1] <= $_->[1]
      } @ranges

对于环绕范围,诀窍是在查看它们之前将其分解为单独的范围。这是蛮力工作。
另外,社交方面的注意事项,你的问题提问率相当高:比我预期的要高得多,因为我认为真正尝试解决自己问题的人不会这么频繁地访问Stackoverflow。我认为你过于依赖它,而不是真正得到帮助,实际上是把你的工作外包了出去。这并不好。我们没有任何报酬,特别是没有被支付来做你分配给我们的工作。如果你至少尝试了问题的实现,情况可能会截然不同,但是你的很多问题似乎表明你甚至都没有尝试过。

谢谢你的回复,Brian。关于你的留言,我很抱歉你有这样的感觉。你可以放心,我没有外包任何东西。我所处的环境中没有可用的程序员同事,因此也许我会比人们预期的更经常地使用这个论坛,来寻求那些你可能只需要问办公室旁边的人的问题。无论如何,我非常喜欢 Stack Overflow,从你们所有人身上学到了很多,并且非常感激你们的帮助。 - David B
就像我之前说的,展示一下你已经尝试过的东西,我会有不同的感受。 - brian d foy
出于对我们所有人的尊重。 - brian d foy

1

我认为像这样的问题展示了将任务分解成易于掌握的小块所带来的可维护性优势(尽管代码行数可能会增加)。

最简单的想法是普通非包装范围。

package SimpleRange;

sub new {
    my $class = shift;
    my ($m, $n) = @_;
    bless { start => $m, end => $n }, $class;
}

sub start { shift->{start} }
sub end   { shift->{end}   }

sub covers {
    # Returns true if the range covers some other range.
    my ($self, $other) = @_;
    return 1 if $self->start <= $other->start
            and $self->end   >= $other->end;
    return;
}

使用该构件块,我们可以创建一个包装范围类,它由一个或两个简单的范围组成(如果范围绕过宇宙的边缘,则为2)。与简单范围的类一样,该类定义了一个covers方法。该方法中的逻辑相当直观,因为我们可以使用我们的SimpleRange对象提供的covers方法。

package WrappingRange;

sub new {
    my $class = shift;
    my ($raw_range, $MIN, $MAX) = @_;
    my ($m, $n) = @$raw_range;

    # Handle special case: a range that wraps all the way around.
    ($m, $n) = ($MIN, $MAX) if $m == $n + 1;

    my $self = {min => $MIN, max => $MAX};
    if ($m <= $n){
        $self->{top}  = SimpleRange->new($m, $n);
        $self->{wrap} = undef;
    }
    else {
        $self->{top}  = SimpleRange->new($m, $MAX);
        $self->{wrap} = SimpleRange->new($MIN, $n);    
    }
    bless $self, $class;
}

sub top  { shift->{top}  }
sub wrap { shift->{wrap} }
sub is_simple { ! shift->{wrap} }

sub simple_ranges {
    my $self = shift;
    return $self->is_simple ? $self->top : ($self->top, $self->wrap);
}

sub covers {
    my @selfR  = shift->simple_ranges;
    my @otherR = shift->simple_ranges;
    while (@selfR and @otherR){
        if ( $selfR[0]->covers($otherR[0]) ){
            shift @otherR;
        }
        else {
            shift @selfR;
        }
    }
    return if @otherR;
    return 1;
}

运行一些测试:

package main;
main();

sub main {
    my ($MIN, $MAX) = (0, 200);

    my @raw_ranges = (
        [10, 100], [30, 90], [50, 80], [$MIN, $MAX],
        [180, 30], 
        [$MAX, $MAX - 1], [$MAX, $MAX - 2],
        [50, 49], [50, 48],
    );
    my @wrapping_ranges = map WrappingRange->new($_, $MIN, $MAX), @raw_ranges;

    my @tests = ( [1, 10], [30, 70], [160, 10], [190, 5] );
    for my $t (@tests){
        $t = WrappingRange->new($t, $MIN, $MAX);

        my @covers = map $_->covers($t) ? 1 : 0, @wrapping_ranges;

        my $n;
        $n += $_ for @covers;
        print "@covers  N=$n\n";
    }
}

输出:

0 0 0 1 1 1 1 1 1  N=6
1 1 0 1 0 1 1 1 0  N=6
0 0 0 1 0 1 0 1 1  N=4
0 0 0 1 1 1 0 1 1  N=5

+1 感谢FM,这是一个很好的分解,使事情更清晰。我曾经在Java中写过类似的东西(class SimpleRangeclass Range实现相同的接口,其中Range由最多两个SimpleRange组成)。但是,我不确定它如何解决高效查询问题。 - David B

1

我相信有更好的方法来完成这个任务,但这里是一个起点:

预处理:

  • 创建两个列表,一个按范围开始值排序,另一个按结束值排序。

一旦你得到了你的范围:

  • 使用二分查找在开始排序列表中匹配它的开始
  • 使用另一个二分查找在结束排序列表中匹配它的结束
  • 找到出现在两个列表中的范围(@start[0..$start_index]和@end[$end_index..$#end])。

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