如何高效地找到特定宽度字符串的理想列数?

11

我有n个不同长度的字符串s1, s2, …, sn,我想在终端上以c列的形式显示。终端的宽度为m个字符。每列i都有一个特定的宽度wi,该宽度等于该列中最长条目的宽度。在每对列之间有一定量的空格s。所有列和它们之间的空格的总宽度不能大于终端的宽度(w1 + w2 + … + wc + (c - 1) · s ≤ m). 每列应包含⌈n / c⌉个字符串,除非n不能被c整除,在这种情况下,最后几列将少一个项目,或者只有最后一列将短一些,具体取决于字符串是按横向排列还是按纵向排列。

是否存在一种高效(例如O(n·w)其中w = max(w1, w2, …, wn))的算法,可以确定我最多可以将多少列合适地放入c列中,如果...

  • 字符串按横向排列

    string1 string2  string3 string4
    string5 string6  string7 string8
    string9 string10
    
  • 字符串排列向下

    string1 string4 string7 string10
    string2 string5 string8
    string3 string6 string9
    

?

后来的发现

我发现s并不重要。对于每个满足s > 0 的问题实例,都可以通过将每个字符串扩展s个字符,并将终端的宽度扩展s个字符以弥补屏幕末尾额外的s个字符而将其转换为一个s = 0的实例。


@wckd 字符串的顺序不能改变。它们必须按照它们原来的顺序放置,无论是横向还是纵向。 - fuz
我觉得你不能这样做。看起来你正在用自动调整列宽和间距的方式填充一个二维表,每行固定显示一定数量的字符,而你想要最大化列数。关键是你需要限制每行的大小以适应终端。这意味着实际上你只需要为每个列号存储一行(约N/c个字符串),总共有O(N^2)个位置需要填充。 - Nuclearman
话虽如此,您可能会发现使用两种方法都可以受益,这两种方法都利用了模数。1)将最大尺寸的剩余字符串添加到每行(使用模数找到应该放在哪一列)。一旦一行接近填满:2)单独填写一行的列,但您可以使用模数来完成。| 本质上,这是对wckd答案的轻微优化。| 显然的瓶颈与许多问题相同,其中最佳算法都是O(N ^ 2),因此认为它就是这样。 - Nuclearman
数据集是否足够小,可以预先计算直方图,显示一组不同大小字符串的频率?例如,有多少个字符串长度小于5个字符,有多少个字符串长度在5到10之间等等。让我们这样说:字符串集的大小和范围是什么,s的范围和m的上限是多少? - גלעד ברקן
假设任意大的nw < 1000,并且m < 10000。 - fuz
显示剩余4条评论
4个回答

4

很遗憾,我认为你能得到的最快算法是O(n^2)。这是因为你可以在单个列表的传递中确定是否可能在c列中进行配置,但你无法知道要更改c的数量,因此你只能尝试不同的值。最多,你的算法将执行n次。

以下是我编写的伪代码

for c = n.size, c > 0, c --
  remainingDist = m - c*s
  for i = 1, i <= c, i ++ 
    columnWidth = 0
    for j = i, j <= n.size; j += c
      columnWidth = max(n[j], columnWidth)
    end
    remainingDist -= columnWidth
  end
  if remainingDist >= 0
    success
    columns = c
    break
  end
end

您可以通过首先计算项目的平均大小并从中推算出“理想”列数来跳转到循环的中间位置。


1
第二个和第三个for循环实际上只是对列表进行单次遍历的分解,它们本身的时间复杂度为O(n)。 - wckd
计算平均大小并没有太大的帮助,因为一些边角情况(例如,可以是一个列的一部分或另一个列的一部分的大列仍然可能出现)。 - fuz
你的评论的真实性完全取决于你所拥有的数据。然而,它很可能非常有帮助,因为在我编写伪代码时,您将首先看到是否可以将所有内容放在一行上。如果您有大量数据,则会浪费很多循环来查看n-1、n-2、n-3等是否适合一行。最理想的情况是c*s + average_length * c = m,因为这样就不会浪费空间。因此,从那里开始非常有用。 - wckd
我对数据的形状没有影响。假设数据集是“好的”并不是一个好的假设。 - fuz
@FUZxxl非常感谢您授予我赏金,尽管我认为我的答案不值得。wckd似乎提供了一个坚实的伪代码来得出答案。由于您对mw有限制,这些可以为c提供一个O(1)的即时初始上限。我认为我的想法可以提供轻微的优化,但只有在更长字符串的频率显着更高时才能提供显着的减少。 - גלעד ברקן
@גלעדברקן 我不会向wckd颁发赏金,因为他只是发布了显而易见的算法(选择一个上限,然后将其减小直到无法再减)。在我提出这个问题之前,我就已经有了这个算法,并且以某种方式期望人们不会发布它,因为它很明显且缓慢。 - fuz

3

我不确定如何在代码中准确表达这个想法,但是通过将字符串的直方图按大小排序,我们可能能够设定一个理论上限来进一步精细化地确定列数,就像wckd的方法一样。由于列的大小受其最长元素的限制,并且我们必须尽可能平均地划分列,只要迄今为止最大字符串的数量占总数的比例足够小,我们就可以继续使用较短字符串分割列。例如,

size frequency
10   5
 6   10
 3   11

m = 30, s = 1

start: 30 - (10 + 1) = 19
implies 13 + 13:

10 (x5)  6 (x2) 
 6 (x8)  3 (x11)

but the portion of 6 and 3 is large enough to split the second column:

19 - (6 + 1) = 12
implies 9 + 9 + 8:

10 (x5) 6 (x6) 3 (x8)
6  (x4) 3 (x3)

but splitting again will still fit:
12 - (6 + 1) = 5
implies 7 + 7 + 6 + 6:

10 (x5) 6 (x7) 6 (x1) 3 (x6)
6  (x2)        3 (x5)

理论上,我们最多会得到4列(在实际结果中明显不允许排序字符串),这可能会因wckd的方法而减少。
根据数据,我想知道这种优化有时是否有用。构建直方图应该需要 O(n + k * log k) 的时间和O(k) 的空间,其中k是字符串大小的数量(你已经限制了w < 1000, m < 10000)。而我建议的操作实际上与n无关,只取决于msk的分布;由于k已经排序,我们只需要一次遍历来分割/计算列数。

我不确定你想要实现什么:不允许重新排序单元格。 - fuz
@FUZxxl 谢谢您的评论。正如我在答案中解释的那样,我正在尝试设置一个“理论上的列数上限,以便通过像wckd的精确方法进一步细化;”而且,“显然,在实际结果中不允许对字符串进行排序;”相反,按大小排序的直方图只是一个潜在有用的工具,可以将起始点从c = n.size降低。在我的例子中,O(n^2)循环的起始点从26降低到4。 - גלעד ברקן
@FUZxxl 证明这种方法是否可以设置渐近更好的上限可能对我来说数学上太高级了,但思考起来很有趣。我会认真考虑的。 - גלעד ברקן
你的证明没有提及上界。你计算的上界比朴素的 O(n) 或 O(n/w) 更好吗? - fuz
我所说的是你的方法生成的估计值,而不是生成估计值所需的时间。天真的估计是n = O(n),显然可以改进为O(n/w)。为了使你的方法具有渐近更好的运行时间,估计值必须是次线性的。它是吗?否则,你的算法在渐近运行时间上没有改进。 - fuz
显示剩余5条评论

2

我看了第一个问题(水平填充),并假设间隙大小(s)为零,就像您在编辑中建议的那样。

首先:我知道赏金已经结束了,并且我没有证据证明有比O(n²)更好的算法。

但是,我有一些想法可以提出,可能仍然很有趣。

我的建议算法如下:

O(n)的时间获取c的某个上限(稍后会详细介绍)

如果c为0或1,或者所有字符串都适合一行,则该c是答案。停止。

使用鸽巢排序O(w+n)(其中w = max(s[]),w <= m)上对s[]创建一个按宽度降序的索引ss[]ss[]的一个元素具有两个属性:widthseqNo(原始序列号,因为它在s[]中出现)。

然后,按宽度递减的顺序循环遍历,直到每个列都有一个宽度的c列配置。如果这些宽度的总和仍然不大于m,则c是一个解决方案。更正式地说:

    knownColumnWidths = new Set() of column numbers
    sumWidth = 0
    for i from 0 to n-1:
        colNo = ss[i].seqNo modulo c
        if not colNo in knownColumnWidths:
            sumWidth += ss[i].width
            if sumWidth > m:
                // c is not a solution, exit for-loop
                break
            add colNo to knownColumnWidths
            if knownColumnWidths has c values:
                // solution found
                return c as solution. Stop

如果 c 被拒绝作为解决方案,则重复先前的代码,使用 c = c-1 。
该算法的最后部分似乎是O(n²)。但是,如果for循环具有最坏情况性能(即 n-c + 1 次迭代),则接下来的几次( c / 2 )运行将具有接近最佳性能(即接近 c 次迭代)。但最终它仍然看起来像O(n²)
为了获得 c 的良好上限(参见上面的第一步),我建议这样做:
首先,在不超过限制的情况下填充尽可能多的字符串在终端的第一行,并将其作为 c 的初始上限。更正式地说:
sumWidth = 0
c = 0
while c < n and sumWidth + s[c] <= m:
    sumWidth += s[c]
    c++

这显然是 O(n)

可以进一步改进如下:

c 个宽度的总和,但从一个字符串开始向右移动,检查是否仍然不大于 m。继续这样移动。当超过 m 时,减小 c 直到 c 宽度的总和再次合适,并使用连续 c 个宽度继续移动。

更正式地说,c 从上述找到的上限开始:

for i from c to n - 1:
    if s[i] > m:
        c = 0. Stop // should not happen: all widths should be <= m
    sumWidth += s[i] - s[i - c]
    while sumWidth > m:
        // c is not a solution. Improve upper limit: 
        c--
        sumWidth -= s[i - c]

这意味着一次扫描中可能会有几个 c 的改进。当然,在最坏的情况下,它根本没有任何改进。
这就结束了我的算法。我估计它在随机输入上表现良好,但仍然看起来像。
但是我有一些观察结果,我没有在上面的算法中使用:
  1. 当您找到某个 c 的列宽度,但总宽度大于时,则仍然可以将此结果用于的情况。然后不需要遍历所有字符串宽度。对于i in 0..c'-1,只需采用 sum(max(s [i],s [i + c']) suff即可。同样的原则适用于 c 的其他除数。

我没有使用这个,因为如果您必须从 c 降至而没有找到解决方案,则已经在算法上花费了。但也许它可以在另一个算法中发挥作用...

  1. 当不允许零长度的字符串时,算法可以变为,因为可能的解决方案数量( c 的值)仅限于,并且确定其中一个是否为解决方案,只需要通过所有宽度进行一次扫描。

我允许零长度字符串。

  1. 最初,我正在研究对 c 进行二进制搜索,将其除以2并针对其余的一半进行操作。但是,这种方法无法使用,因为即使找到某个 c 不是解决方案,这也不排除存在是解决方案的可能性。
希望这个答案中有您觉得有用的东西。

2
解决这个问题的一种明显方法是按照预定顺序迭代所有字符串长度,更新每个字符串所在列的宽度,并在列宽总和超过终端宽度时停止。然后,对于减少的列数(或增加的行数,用于“向下排列”情况),重复此过程直到成功。这个预定顺序有三种可能的选择:
  1. 按行(先考虑第一行中的所有字符串,然后是第2、3行等)
  2. 按列(先考虑第一列中的所有字符串,然后是第2、3列等)
  3. 按值(按长度对字符串进行排序,然后按排序顺序迭代它们,从最长的开始)。
这三种方法都适用于“横向排列”子问题。但是对于“向下排列”子问题,它们的最坏时间复杂度都为O(n^2)。即使对于随机数据,前两种方法也显示出二次复杂度。按值的方法对于随机数据非常好,但很容易找到最坏情况:只需将短字符串分配给列表的前半部分,将长字符串分配给后半部分。
在这里,我描述了一种“向下排列”情况下没有这些缺点的算法。

不需要分别检查每个字符串长度,它使用范围最大查询(RMQ)在O(1)时间内确定每个列的宽度。第一列的宽度只是范围(0..num_of_rows)中的最大值,下一列的宽度是范围(num_of_rows..2*num_of_rows)中的最大值,以此类推。

为了在O(1)时间内回答每个查询,我们需要准备一个包含范围(0..2 k),(1..1 + 2 k)等最大值的数组,其中k是最大的整数,使得2 k 不大于当前行数。每个范围最大查询被计算为来自该数组的两个条目的最大值。当行数开始变得太大时,我们应该将这个查询数组从k更新到k + 1(每个这样的更新需要O(n)范围查询)。

以下是C ++14实现:

template<class PP>
uint32_t arrangeDownRMQ(Vec& input)
{
    auto rows = getMinRows(input);
    auto ranges = PP{}(input, rows);

    while (rows <= input.size())
    {
        if (rows >= ranges * 2)
        { // lazily update RMQ data
            transform(begin(input) + ranges, end(input), begin(input),
                      begin(input), maximum
            );

            ranges *= 2;
        }
        else
        { // use RMQ to get widths of columns and check if all columns fit
            uint32_t sum = 0;

            for (Index i = 0; sum <= kPageW && i < input.size(); i += rows)
            {
                ++g_complexity;
                auto j = i + rows - ranges;

                if (j < input.size())
                    sum += max(input[i], input[j]);
                else
                    sum += input[i];
            }

            if (sum <= kPageW)
            {
                return uint32_t(ceilDiv(input.size(), rows));
            }

            ++rows;
        }
    }

    return 0;
}

这里的PP是可选的,对于简单情况,此函数对象不执行任何操作并返回1。

为了确定该算法的最坏时间复杂度,注意外部循环从rows = n * v / m(其中v是平均字符串长度,m是页面宽度)开始,并在最多rows = n * w / m(其中w是最大字符串长度)停止。"查询"循环中的迭代次数不超过列数或n / rows。将这些迭代次数相加得到O(n * (ln(n*w/m) - ln(n*v/m)))O(n * log(w/v))。这意味着具有小常数因子的线性时间。

我们应该在此添加执行所有更新操作的时间,其复杂度为O(n log n),以获得整个算法的复杂度:O(n * log n)。

如果在进行查询操作之前不执行任何更新操作,则更新操作所需的时间以及算法复杂度将降至O(n * log(w/v))。为了实现这一点,我们需要一些算法来用给定长度的子数组的最大值填充RMQ数组。我尝试了两种可能的方法,似乎使用一对堆栈的算法稍微快一些。这是C++14的实现(输入数组的片段被用于实现两个堆栈,以降低内存要求并简化代码):
template<typename I, typename Op>
auto transform_partial_sum(I lbegin, I lend, I rbegin, I out, Op op)
{ // maximum of the element in first enterval and prefix of second interval
    auto sum = typename I::value_type{};

    for (; lbegin != lend; ++lbegin, ++rbegin, ++out)
    {
        sum = op(sum, *rbegin);
        *lbegin = op(*lbegin, sum);
    }

    return sum;
}

template<typename I>
void reverse_max(I b, I e)
{ // for each element: maximum of the suffix starting from this element
    partial_sum(make_reverse_iterator(e),
                make_reverse_iterator(b),
                make_reverse_iterator(e),
                maximum);
}

struct PreprocRMQ
{
    Index operator () (Vec& input, Index rows)
    {
        if (rows < 4)
        { // no preprocessing needed
            return 1;
        }

        Index ranges = 1;
        auto b = begin(input);

        while (rows >>= 1)
        {
            ranges <<= 1;
        }

        for (; b + 2 * ranges <= end(input); b += ranges)
        {
            reverse_max(b, b + ranges);
            transform_partial_sum(b, b + ranges, b + ranges, b, maximum);
        }

        // preprocess inconvenient tail part of the array
        reverse_max(b, b + ranges);
        const auto d = end(input) - b - ranges;
        const auto z = transform_partial_sum(b, b + d, b + ranges, b, maximum);
        transform(b + d, b + ranges, b + d, [&](Data x){return max(x, z);});
        reverse_max(b + ranges, end(input));
        return ranges;
    }
};

实际上,我们更有可能看到短单词而不是长单词。在英语文本中,较短的单词数量超过较长的单词,自然数的较短文本表示也更为普遍。因此,我选择(稍作修改的)字符串长度的几何分布来评估各种算法。这里是整个基准测试程序(使用gcc的C++14)。旧版本的同一程序包含一些过时的测试和某些算法的不同实现:TL;DR。以下是结果:

time

针对“横向排列”情况:
  • “按列排列”的方法最慢
  • “按值排列”的方法适用于n = 1000..500'000'000
  • “按行排列”的方法更适用于小型和非常大型的数据
针对“纵向排列”情况:
  • “按值排列”的方法可以,但比其他方法慢(使其更快的一种可能是通过乘法实现除法),并且它使用更多内存并具有二次最坏时间复杂度
  • 简单的RMQ方法更好
  • 预处理后的RMQ最佳:它具有线性时间复杂度,并且其内存带宽要求相当低。
还可以查看每个算法所需的迭代次数,这也很有趣。在此,所有可预测的部分都被排除在外(排序,求和,预处理和RMQ更新)。

iter


这真的很棒。给我一些时间来理解核心思想。 - fuz

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