如何最有效地在矩阵中找到给定大小的相同值矩形区域?

时间:2011-01-11 10:39:54

标签: algorithm scala matrix scala-2.8

我的问题非常简单,但我还没有找到有效的实施方案。

假设有一个像这样的矩阵A:

0 0 0 0 0 0 0
4 4 2 2 2 0 0
4 4 2 2 2 0 0
0 0 2 2 2 1 1
0 0 0 0 0 1 1

现在我想找到这个矩阵中具有给定大小的矩形区域的所有起始位置。区域是A的子集,其中所有数字都相同。

假设width = 2且height = 3。有3个这样大小的区域:

2 2   2 2   0 0
2 2   2 2   0 0
2 2   2 2   0 0

函数调用的结果将是这些区域的起始位置(x,y从0开始)的列表。

List((2,1),(3,1),(5,0))

以下是我目前的实施情况。 “区域”在这里被称为“表面”。

case class Dimension2D(width: Int, height: Int)
case class Position2D(x: Int, y: Int)

def findFlatSurfaces(matrix: Array[Array[Int]], surfaceSize: Dimension2D): List[Position2D] = {

    val matrixWidth = matrix.length
    val matrixHeight = matrix(0).length
    var resultPositions: List[Position2D] = Nil

    for (y <- 0 to matrixHeight - surfaceSize.height) {
        var x = 0
        while (x <= matrixWidth - surfaceSize.width) {
            val topLeft = matrix(x)(y)
            val topRight = matrix(x + surfaceSize.width - 1)(y)
            val bottomLeft = matrix(x)(y + surfaceSize.height - 1)
            val bottomRight = matrix(x + surfaceSize.width - 1)(y + surfaceSize.height - 1)
            // investigate further if corners are equal
            if (topLeft == bottomLeft && topLeft == topRight && topLeft == bottomRight) {
                breakable {
                    for (sx <- x until x + surfaceSize.width;
                         sy <- y until y + surfaceSize.height) {
                        if (matrix(sx)(sy) != topLeft) {
                            x = if (x == sx) sx + 1 else sx 
                            break
                        }
                    }
                    // found one!       
                    resultPositions ::= Position2D(x, y)
                    x += 1
                }
            } else if (topRight != bottomRight) {
                // can skip x a bit as there won't be a valid match in current row in this area
                x += surfaceSize.width 
            } else {
                x += 1
            }
        }   
    }
    return resultPositions
}

我已经尝试在其中加入一些优化,但我确信有更好的解决方案。是否存在我可以移植的matlab函数?我也想知道这个问题是否有自己的名字,因为我并不确切知道谷歌的用途。

感谢你的思考!我很高兴看到你的建议或解决方案:)

编辑:我的应用程序中的矩阵尺寸大约为300x300到3000x3000。此外,对于相同的矩阵,该算法仅被称为一次。原因是矩阵总是会在之后发生变化(大约是它的1-20%)。

结果

我实现了Kevin,Nikita和Daniel的算法,并在我的应用程序环境中对它们进行了基准测试,即这里没有孤立的综合基准测试,但是特别注意以最高性能的方式集成所有算法,这对Kevin的方法尤其重要。它使用泛型(见下文)。

首先,使用Scala 2.8和jdk 1.6.0_23进行原始结果。作为解决特定于应用程序的问题的一部分,算法被执行了数百次。 “持续时间”表示应用程序算法完成之前所需的总时间(当然没有jvm启动等)。我的机器是2.8GHz Core 2 Duo,有2个内核和2gig内存,-Xmx800M给了JVM。

重要提示:我认为我的基准设置对于像Daniel这样的并行算法来说并不公平。这是因为应用程序已经在计算多线程。所以这里的结果可能只显示相当于单线程速度。

矩阵大小233x587:

                  duration | JVM memory | avg CPU utilization
original O(n^4) | 3000s      30M          100%  
original/-server| 840s       270M         100%
Nikita O(n^2)   | 5-6s       34M          70-80%
Nikita/-server  | 1-2s       300M         100%
Kevin/-server   | 7400s      800M         96-98%
Kevin/-server** | 4900s      800M         96-99%
Daniel/-server  | 240s       360M         96-99%

** @specialized,通过避免类型擦除来制作generics faster

矩阵大小2000x3000:

                  duration | JVM memory | avg CPU utilization
original O(n^4) | too long   100M         100%  
Nikita O(n^2)   | 150s       760M         70%
Nikita/-server  | 295s (!)   780M         100%
Kevin/-server   | too long, didn't try

首先,关于记忆的小记。 -server JVM选项使用更多内存,具有更多优化的优势,并且通常可以更快地执行。从第二个表中可以看出,使用-server选项时,Nikita的算法速度较慢,这显然是因为达到了内存限制。我认为,即使对于小矩阵,这也会减慢Kevin的算法,因为功能方法无论如何都要使用更多的内存。为了消除记忆因素,我还用50x50矩阵尝试过一次,然后凯文用了5秒和Nikita的0秒(好,接近0)。因此,无论如何它仍然较慢,而不仅仅是因为记忆。

从数字中可以看出,我显然会使用Nikita的算法,因为它很快,在我的情况下这绝对是必要的。 Daniel也指出,它也可以很容易地并行化。唯一的缺点是它不是真正的scala方式。

目前凯文的算法通常有点过于复杂,因此很慢,但我确信可以进行更多优化(参见他的回答中的最后评论)。

为了将Nikita的算法直接转换为功能风格,Daniel提出了一个已经非常快速的解决方案,他说如果他能使用scanRight会更快(参见他的回答中的最后评论)。

下一步是什么?

在技术方面:等待Scala 2.9,ScalaCL,并进行综合基准测试以获得原始速度。

我所有这一切的目标都是拥有功能代码,但前提是它不会牺牲太多的速度。

答案选择:

至于选择答案,我想将Nikita和Daniel的算法标记为答案,但我必须选择一个。我的问题标题包括“最有效”,其中一个是命令式最快,另一个是功能式。虽然这个问题被标记为Scala我选择了尼基塔的命令式算法,因为2s对240s仍然是我接受的太多差异。我确定差异仍然可以推迟一点,任何想法?

所以,非常感谢你们!虽然我不会使用函数算法,但我对Scala有了很多新的见解,我想我会慢慢了解所有功能性疯狂及其潜力。 (当然,即使没有做太多函数式编程,Scala也比Java更令人愉悦......这是学习它的另一个原因)

3 个答案:

答案 0 :(得分:8)

答案 1 :(得分:5)

你可以在O(n^2)相对容易地做到这一点 首先,一些预先计算。对于矩阵中的每个单元格,计算它下面有多少连续单元格具有相同的数字 对于您的示例,生成的矩阵a(想不出更好的名称:/)将如下所示

0 0 0 0 0 2 2
1 1 2 2 2 1 1
0 0 1 1 1 0 0
1 1 0 0 0 1 1
0 0 0 0 0 0 0

可以轻松地在O(n^2)制作。

现在,对于每一行i,让我们找到行i顶部的所有矩形(行i + height - 1的底部)。
以下是i = 1

的说明
0 0 0 0 0 0 0
-------------
4 4 2 2 2 0 0
4 4 2 2 2 0 0
0 0 2 2 2 1 1
-------------
0 0 0 0 0 1 1

现在,主要想法

int current_width = 0;
for (int j = 0; j < matrix.width; ++j) {
    if (a[i][j] < height - 1) {
        // this column has different numbers in it, no game
        current_width = 0;
        continue;
    }

    if (current_width > 0) {
        // this column should consist of the same numbers as the one before
        if (matrix[i][j] != matrix[i][j - 1]) {
            current_width = 1; // start streak anew, from the current column
            continue;
        }
    }

    ++current_width;
    if (current_width >= width) {
        // we've found a rectangle!
    }
}

在上面的示例中,i = 1current_width在每次迭代后将为0, 0, 1, 2, 3, 0, 0

现在,我们需要迭代所有可能的i,我们有一个解决方案。

答案 2 :(得分:5)

我会在这里扮演魔鬼的拥护者。我将展示以功能样式编写的Nikita's精确算法。我也将它并行化,只是为了表明它可以完成。

首先,计算单元格下方具有相同编号的连续单元格。我通过将所有值加上一个与Nikita的建议输出相比进行了微小的更改,以避免在代码的其他部分中使用- 1

def computeHeights(column: Array[Int]) = (
    column
    .reverse
    .sliding(2)
    .map(pair => pair(0) == pair(1))
    .foldLeft(List(1)) ( 
        (list, flag) => (if (flag) list.head + 1 else 1) :: list
    )
)

我宁愿在转换之前使用.view,但这对现有的Scala版本不起作用。如果确实如此,它将节省重复的数组创建,这应该加速代码,因为内存位置和带宽原因,如果没有其他。

现在,所有列同时出现:

import scala.actors.Futures.future

def getGridHeights(grid: Array[Array[Int]]) = (
    grid
    .transpose
    .map(column => future(computeHeights(column)))
    .map(_())
    .toList
    .transpose
)

我不确定并行化开销是否会在这里得到回报,但这是Stack Overflow上第一个实际上有机会的算法,考虑到计算列的非常重要的工作。这是使用即将推出的2.9功能编写它的另一种方式(它可能适用于Scala 2.8.1 - 不确定是什么:

def getGridHeights(grid: Array[Array[Int]]) = (
    grid
    .transpose
    .toParSeq
    .map(computeHeights)
    .toList
    .transpose
)

现在,尼基塔算法的核心:

def computeWidths(height: Int, row: Array[Int], heightRow: List[Int]) = (
    row
    .sliding(2)
    .zip(heightRow.iterator)
    .toSeq
    .reverse
    .foldLeft(List(1)) { case (widths @ (currWidth :: _), (Array(prev, curr), currHeight)) =>
        (
            if (currHeight >= height && currWidth > 0 && prev == curr) currWidth + 1
            else 1
        ) :: widths
    }
    .toArray
)

我在这段代码中使用了模式匹配,虽然我关心它的速度,因为所有的滑动,拉链和折叠都有两个很多东西在这里玩杂耍。而且,就性能而言,我使用的是Array而不是IndexedSeq,因为Array是JVM中唯一未被删除的类型,因此{{1}会带来更好的性能}。然后,由于内存的局部性和带宽,Int我也不是特别高兴。

另外,我是从右到左而不是Nikita从左到右做的,所以我可以找到左上角。

然而,与Nikita的答案中的代码相同,除了我仍然在他的代码中添加一个当前宽度的事实,而不是在这里打印结果。这里有一堆没有明确起源的事情 - .toSeqrowheightRow ...让我们在上下文中看到这个代码 - 并行化! - 了解整体情况。

height

2.9版本:

def getGridWidths(height: Int, grid: Array[Array[Int]]) = (
    grid
    .zip(getGridHeights(grid))
    .map { case (row, heightsRow) => future(computeWidths(height, row, heightsRow)) }
    .map(_())
)

而且,对于最后的结局,

def getGridWidths(height: Int, grid: Array[Array[Int]]) = (
    grid
    .toParSeq
    .zip(getGridHeights(grid))
    .map { case (row, heightsRow) => computeWidths(height, row, heightsRow) }
)

所以......毫无疑问,尼基塔算法的命令式版本更快 - 它只使用def findRectangles(height: Int, width: Int, grid: Array[Array[Int]]) = { val gridWidths = getGridWidths(height, grid) for { y <- gridWidths.indices x <- gridWidths(y).indices if gridWidths(y)(x) >= width } yield (x, y) } ,这比原始版本要快得多,它避免了大量创建临时集合 - 虽然Scala 可以在这里做得更好。此外,没有闭包 - 尽管它们有所帮助,但它们 比没有闭包的代码慢。至少在JVM成长起来帮助他们之前。

此外,尼基塔的代码可以很容易地与线程并行化 - 所有东西! - 没什么问题。

但我的观点是,尼基塔的代码并不是特别糟糕,因为它在这里和那里使用数组和一个可变变量。该算法干净利落地转换为更具功能性的风格。

修改

所以,我决定尝试制作一个高效的功能版本。它并没有真正完全正常运行,因为我使用Array,这是可变的,但它足够接近。不幸的是,它不适用于Scala 2.8.1,因为它在Iterator上缺少scanLeft

这里还有另外两件不幸的事情。首先,我放弃了网格高度的并行化,因为我无法绕过至少有一个Iterator,所有的集合复制都需要。但是仍然至少有一次数据复制(请参阅transpose调用以了解其中的位置)。

此外,由于我正在使用toArray,因此我无法使用并行集合。我想知道从一开始就让Iterable成为并行集合的并行集合,是否会使代码变得更好。

我不知道这是否比以前的版本更有效。这是一个有趣的问题......

grid