【问题标题】:How to find the lexicographically smallest string by reversing a substring?如何通过反转子字符串来找到字典上最小的字符串?
【发布时间】:2023-03-28 20:58:01
【问题描述】:

我有一个字符串S,它由ab 组成。执行以下操作一次。目标是获取字典最小的字符串。

操作:正好反转S的一个子串

例如

  1. 如果S = abab 然后Output = aabb(反向ba 字符串S
  2. 如果S = abba 然后Output = aabb(反向bba 字符串S

我的方法

案例1:如果输入字符串的所有字符都相同,则输出将是字符串本身。

案例 2:如果 S 的形式为 aaaaaaa....bbbbbb....,那么答案本身就是 S

否则: 找到bS 中第一次出现的位置是i。字符串S 看起来像

aa...bbb...aaaa...bbbb....aaaa....bbbb....aaaaa...
     |
     i   

为了获得字典上最小的字符串,将反转的子字符串从索引 i 开始。请参阅下文了解可能的结尾 j。

aa...bbb...aaaa...bbbb....aaaa....bbbb....aaaaa...
     |           |               |               |
     i           j               j               j

为每个 j 反转子字符串 S[i:j] 并找到最小的字符串。 算法的复杂度为O(|S|*|S|),其中|S| 是字符串的长度。

有没有更好的方法来解决这个问题?可能是O(|S|) 解决方案。

我在想,如果我们能在线性时间内选择正确的j,那么我们就完成了。我们将选择 a 的数量最大的那个 j。如果有一个最大值,那么我们解决了问题,但如果不是这样呢?我已经尝试了很多。请帮忙。

【问题讨论】:

  • 答案不是总是先写所有'a'然后所有'b'吗?
  • @AbdenaceurLichiheb 您可以只执行一次操作。不必总是把所有的 a 放在开头,把所有的 b 放在最后。例如S = ababbaoutput=aabbab
  • 是的,我知道,假设字符串 abababa 的答案是 aaaabbb,你只需要计算 'a' 和 'b' 的数量,然后写所有 'a' 然后所有 'b'。
  • 我认为没有线性算法,但应该有线性对数算法。
  • @AbdenaceurLichiheb:您将哪一个子字符串从abababa 转换为aaaabbb

标签: string algorithm lexicographic


【解决方案1】:

TL;DR:这是一种仅对字符串进行一次迭代的算法(对于有限的字符串长度,具有 O(|S|) 的复杂性)。我在下面解释它的例子有点啰嗦,但算法真的很简单:

  • 遍历字符串,并更新其解释为反向(lsb-to-msb)二进制数的值。
  • 如果您找到比当前最大值更长的零序列中的最后一个零,则存储当前位置和当前反向值。从那时起,还要更新此值,将字符串的其余部分解释为正向 (msb-to-lsb) 二进制数。
  • 如果找到与当前最大值一样长的零序列中的最后一个零,则将当前反向值与存储端点的当前值进行比较;如果它更小,则将端点替换为当前位置。

因此,您基本上是在比较字符串的值(如果它被反转到当前点)与字符串的值(如果它只反转到(到目前为止)最佳点),并更新这个最佳值即时点。

这是一个快速的代码示例;毫无疑问,它可以更优雅地编码:

function reverseSubsequence(str) {
    var reverse = 0, max = 0, first, last, value, len = 0, unit = 1;
    
    for (var pos = 0; pos < str.length; pos++) {
        var digit = str.charCodeAt(pos) - 97;                   // read next digit
        if (digit == 0) {
            if (first == undefined) continue;                   // skip leading zeros
            if (++len > max || len == max && reverse < value) { // better endpoint found
                max = len;
                last = pos;
                value = reverse;
            }
        } else {
            if (first == undefined) first = pos;                // end of leading zeros
            len = 0;
        }
        reverse += unit * digit;                                // update reverse value
        unit <<= 1;
        value = value * 2 + digit;                              // update endpoint value
    }
    return {from: first || 0, to: last || 0};
}
var result = reverseSubsequence("aaabbaabaaabbabaaabaaab");
document.write(result.from + "&rarr;" + result.to);

(代码可以通过比较 reversevalue 来简化,只要找到一个零,而不仅仅是遇到一个最大长度的零序列的结尾。)


您可以创建一个只对输入进行一次迭代的算法,并且可以通过跟踪两个值来处理未知长度的传入流:整个字符串的值被解释为反向(lsb-to-msb)二进制数,并将字符串的一部分取反。每当反向值低于存储的最佳端点的值时,就找到了更好的端点。

以这个字符串为例:

aaabbaabaaabbabaaabaaab

或者,为了简单起见,用零和一写成:

00011001000110100010001   

我们遍历前导零,直到找到第一个:

0001
   ^

这是我们想要反转的序列的开始。我们将开始将 0 和 1 的流解释为反转 (lsb-to-msb) 二进制数,并在每一步之后更新这个数字:

reverse = 1, unit = 1  

然后在每一步,我们将单位加倍并更新倒数:

0001        reverse = 1
00011       unit = 2; reverse = 1 + 1 * 2 = 3
000110      unit = 4; reverse = 3 + 0 * 4 = 3
0001100     unit = 8; reverse = 3 + 0 * 8 = 3

此时我们找到了一个 1,0 的序列就结束了。它包含 2 个零,这是当前的最大值,因此我们将当前位置存储为可能的端点,同时存储当前的反向值:

endpoint = {position = 6, value = 3} 

然后我们继续迭代字符串,但在每一步,我们都会更新可能端点的值,但现在是一个普通的(msb-to-lsb)二进制数:

00011001      unit = 16; reverse = 3 + 1 * 16 = 19
              endpoint.value *= 2 + 1 = 7
000110010     unit = 32; reverse = 19 + 0 * 32 = 19
              endpoint.value *= 2 + 0 = 14
0001100100    unit = 64; reverse = 19 + 0 * 64 = 19
              endpoint.value *= 2 + 0 = 28
00011001000   unit = 128; reverse = 19 + 0 * 128 = 19
              endpoint.value *= 2 + 0 = 56

此时我们发现我们有一个 3 个零的序列,比当前最大的 2 长,所以我们把目前为止的端点扔掉,用当前位置和反向值替换它:

endpoint = {position = 10, value = 19}  

然后我们继续遍历字符串:

000110010001         unit = 256; reverse = 19 + 1 * 256 = 275
                     endpoint.value *= 2 + 1 = 39
0001100100011        unit = 512; reverse = 275 + 1 * 512 = 778
                     endpoint.value *= 2 + 1 = 79
00011001000110       unit = 1024; reverse = 778 + 0 * 1024 = 778
                     endpoint.value *= 2 + 0 = 158
000110010001101      unit = 2048; reverse = 778 + 1 * 2048 = 2826
                     endpoint.value *= 2 + 1 = 317
0001100100011010     unit = 4096; reverse = 2826 + 0 * 4096 = 2826
                     endpoint.value *= 2 + 0 = 634
00011001000110100    unit = 8192; reverse = 2826 + 0 * 8192 = 2826
                     endpoint.value *= 2 + 0 = 1268
000110010001101000   unit = 16384; reverse = 2826 + 0 * 16384 = 2826
                     endpoint.value *= 2 + 0 = 2536

这里我们发现我们还有另一个3个0的序列,所以我们将当前的反向值与端点的值进行比较,发现存储的端点有一个较低的值:

endpoint.value = 2536  < reverse = 2826  

所以我们将端点设置为位置 10,然后继续迭代字符串:

0001100100011010001      unit = 32768; reverse = 2826 + 1 * 32768 = 35594
                         endpoint.value *= 2 + 1 = 5073  
00011001000110100010     unit = 65536; reverse = 35594 + 0 * 65536 = 35594
                         endpoint.value *= 2 + 0 = 10146
000110010001101000100    unit = 131072; reverse = 35594 + 0 * 131072 = 35594
                         endpoint.value *= 2 + 0 = 20292
0001100100011010001000   unit = 262144; reverse = 35594 + 0 * 262144 = 35594
                         endpoint.value *= 2 + 0 = 40584

我们找到另一个 3 个零的序列,所以我们将这个位置与存储的端点进行比较:

endpoint.value = 40584 > reverse = 35594  

我们发现它有一个较小的值,所以我们用当前位置替换可能的端点:

endpoint = {position = 21, value = 35594}  

然后我们遍历最后一个数字:

00011001000110100010001   unit = 524288; reverse = 35594 + 1 * 524288 = 559882  
                          endpoint.value *= 2 + 1 = 71189

所以最后我们发现位置 21 给了我们最低的值,所以它是最优解:

00011001000110100010001  ->  00000010001011000100111
   ^                 ^
start = 3         end = 21

这是一个使用 bool 向量而不是整数的 C++ 版本。它可以解析超过 64 个字符的字符串,但复杂度可能是二次方的。

#include <vector>

struct range {unsigned int first; unsigned int last;};

range lexiLeastRev(std::string const &str) {
    unsigned int len = str.length(), first = 0, last = 0, run = 0, max_run = 0;
    std::vector<bool> forward(0), reverse(0);
    bool leading_zeros = true;

    for (unsigned int pos = 0; pos < len; pos++) {
        bool digit = str[pos] - 'a';
        if (!digit) {
            if (leading_zeros) continue;
            if (++run > max_run || run == max_run && reverse < forward) {
                max_run = run;
                last = pos;
                forward = reverse;
            }
        }
        else {
            if (leading_zeros) {
                leading_zeros = false;
                first = pos;
            }
            run = 0;
        }
        forward.push_back(digit);
        reverse.insert(reverse.begin(), digit);
    }
    return range {first, last};
}

【讨论】:

  • 当您操作的数字的长度通常取决于 n 时,这怎么可能是 O(n)?
  • 我不明白第 3 点的逻辑,即比较 endpoint.value 和 reverse。为什么它会起作用?除此之外,一切都一清二楚。
  • @algrid 你可能是对的,在最严格的数学意义上它不是 O(n);假设它在特定实现的整数范围内是线性的。
  • @cryptomanic 在任何时候,reverse 保存字符串的值,就好像它被反转到当前位置一样,而临时最佳端点的 value 保存字符串的值如果它只反转到那个端点。如果reversed 更小,则表示反转到当前点的字符串在字典上小于仅反转到端点的部分的字符串,因此当前位置成为新的最佳端点。
  • 如果每个比较中的位数取决于n,如果比较数是一个常数,它将是线性的。但是,在这种情况下,比较次数取决于n。仍然是一个很好的答案。
【解决方案2】:

所以,我想出了一个算法,它似乎比 O(|S|^2) 更有效,但我不太确定它的复杂性。这是一个粗略的大纲:

  1. 前导a's的条带,存储在变量start中。
  2. 将字符串的其余部分分组为字母块。
  3. 查找具有最长序列a's 的组的索引。
  4. 如果只剩下一个index,请继续执行 10。
  5. 过滤这些索引,使反转后b's 的 [first] 组的长度最小。
  6. 如果只剩下一个index,请继续执行 10。
  7. 过滤这些索引,以使反转后a's(不包括前导a's)的[第一]组的长度最小。
  8. 如果只剩下一个index,请继续执行 10。
  9. 回到 5,除了这次检查 a'sb's 的 [第二/第三/...] 组。
  10. 返回start,加上反向组直到index,加上剩余的组。

由于任何被反转的子字符串都以b 开头并以a 结尾,因此没有两个假设的反转是回文,因此两个反转不会导致相同的输出,保证存在唯一的最优解,算法将终止。

我的直觉说这种方法可能是 O(log(|S|)*|S|),但我不太确定。下面提供了 Python 中的示例实现(尽管不是很好)。

from itertools import groupby

def get_next_bs(i, groups, off):
    d = 1 + 2*off
    before_bs = len(groups[i-d]) if i >= d else 0
    after_bs = len(groups[i+d]) if i <= d and len(groups) > i + d else 0
    return before_bs + after_bs

def get_next_as(i, groups, off):
    d = 2*(off + 1)
    return len(groups[d+1]) if i < d else len(groups[i-d])

def maximal_reversal(s):
    # example input: 'aabaababbaababbaabbbaa'

    first_b = s.find('b')
    start, rest = s[:first_b], s[first_b:] 
    # 'aa', 'baababbaababbaabbbaa'

    groups = [''.join(g) for _, g in groupby(rest)]
    # ['b', 'aa', 'b', 'a', 'bb', 'aa', 'b', 'a', 'bb', 'aa', 'bbb', 'aa']

    try:
        max_length = max(len(g) for g in groups if g[0] == 'a')
    except ValueError:
        return s # no a's after the start, no reversal needed

    indices = [i for i, g in enumerate(groups) if g[0] == 'a' and len(g) == max_length]
    # [1, 5, 9, 11]

    off = 0
    while len(indices) > 1:
        min_bs = min(get_next_bs(i, groups, off) for i in indices)
        indices = [i for i in indices if get_next_bs(i, groups, off) == min_bs]
        # off 0: [1, 5, 9], off 1: [5, 9], off 2: [9]

        if len(indices) == 1:
            break

        max_as = max(get_next_as(i, groups, off) for i in indices)
        indices = [i for i in indices if get_next_as(i, groups, off) == max_as]
        # off 0: [1, 5, 9], off 1: [5, 9]

        off += 1

    i = indices[0]
    groups[:i+1] = groups[:i+1][::-1]

    return start + ''.join(groups)
    # 'aaaabbabaabbabaabbbbaa'

【讨论】:

  • 如果这样的子串多次出现怎么办?选择哪一个?
  • @cryptomanic 错过了这一点,我编写了一个新算法来解决这个问题。
猜你喜欢
  • 2021-06-11
  • 1970-01-01
  • 2021-03-09
  • 2011-01-28
  • 2023-02-01
  • 1970-01-01
  • 2014-01-30
  • 1970-01-01
相关资源
最近更新 更多