文字列の最適削除と辞書順最小化アルゴリズム

各位置 i の文字を c[i] とし、canErase[x][y] を「文字 x が直後の文字 y を削除可能か」を表すブール配列とする。maxReach[i] は、位置 i から連続して削除可能な最大範囲の終端インデックスを示す(つまり、i+1 から maxReach[i] までの文字はすべて削除可能で、maxReach[i]+1 は削除不可能)。

貪欲戦略として、ある文字が自身の後続文字を削除でき、かつ自身も他の文字によって削除可能な場合、その文字に後続を削除させるのが最適である。これは、前の文字が必ずしも同じ削除能力を持たないため、早期に削除した方が選択肢を狭めないからである。

この戦略に基づき、maxReach 配列を末尾から構築する:

maxReach[n] = n;
for (int pos = n - 1; pos >= 1; --pos) {
    maxReach[pos] = pos;
    while (canErase[c[pos]][c[maxReach[pos] + 1]]) {
        maxReach[pos] = maxReach[maxReach[pos] + 1];
    }
}

このループの時間計算量は O(n) である。各位置は while ループ内で高々1回しか参照されない。なぜなら、一度 maxReach[j] が更新されると、以降の探索ではその値を飛び越えるか、条件不成立で停止するため、重複処理が発生しないからである。

次に、辞書順最小の結果を得るための動的計画法を導入する。opt[i] を「位置 i 以降の部分列から得られる辞書順最小の文字列」と定義する。遷移式は以下の通り:

opt[i] = c[i] + min{ opt[j+1] } (i ≤ j ≤ maxReach[i])

この最小値検索を高速化するため、単調スタックを活用する。新しい opt[i] を追加する際、スタック先頭の要素が opt[i] 以上であればポップし、その後 opt[i] をプッシュする。これにより、区間最小値の候補を常に保持できる。ただし、maxReach が単調でないため、二分探索で有効な範囲内の最大インデックスを特定する必要がある。

メモリ使用量を O(n²) から O(n) に抑えるため、実際の文字列ではなく、次の遷移元のインデックスのみを記録する。

文字列比較のコストを削減するため、ダブルハッシュとバイナリジャンプテーブルを導入する。hashTbl[i][k]opt[i] の先頭 2^k 文字のハッシュ値、nextPos[i][k] はその次の文字の元文字列における位置を格納する。これにより、比較は O(log n) で可能となる:

bool compareFrom(int x, int y) {
    for (int step = 29; step >= 0; --step) {
        if (hashTbl[x][step] == hashTbl[y][step]) {
            x = nextPos[x][step];
            y = nextPos[y][step];
        }
    }
    return c[x] <= c[y];
}

最終的な実装では、ハッシュ定数には大きな素数ベースを使用し、衝突を回避する。以下は全体のコード例である:

#include <bits/stdc++.h>
using namespace std;

constexpr int MAX_CHAR = 27;
constexpr int MAX_LEN = 500005;
constexpr long long HASH_MOD = 8246138383318919979LL;

long long basePow[30], hashTbl[MAX_LEN][30];
int charAt[MAX_LEN], canErase[MAX_CHAR][MAX_CHAR], maxReach[MAX_LEN];
int stackBuf[MAX_LEN], nextPos[MAX_LEN][30], stackTop;

bool compareFrom(int x, int y) {
    for (int k = 29; k >= 0; --k) {
        if (hashTbl[x][k] == hashTbl[y][k]) {
            x = nextPos[x][k];
            y = nextPos[y][k];
        }
    }
    return charAt[x] <= charAt[y];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int charCount, strLen;
    cin >> charCount >> strLen;

    for (int i = 1; i <= charCount; ++i) {
        for (int j = 1; j <= charCount; ++j) {
            char flag;
            cin >> flag;
            canErase[i][j] = (flag == '1');
        }
    }

    for (int i = 1; i <= strLen; ++i) {
        char ch;
        cin >> ch;
        charAt[i] = ch - 'a' + 1;
    }

    basePow[0] = 7017559758196662635LL;
    for (int i = 1; i < 30; ++i) {
        basePow[i] = (basePow[i-1] * (__int128)basePow[i-1]) % HASH_MOD;
    }

    maxReach[strLen] = strLen;
    for (int i = strLen - 1; i >= 1; --i) {
        maxReach[i] = i;
        while (canErase[charAt[i]][charAt[maxReach[i] + 1]]) {
            maxReach[i] = maxReach[maxReach[i] + 1];
        }
    }

    stackBuf[++stackTop] = strLen;

    for (int i = strLen - 1; i >= 1; --i) {
        if (maxReach[i] == strLen) {
            nextPos[i][0] = 0;
        } else {
            auto it = lower_bound(stackBuf + 1, stackBuf + stackTop + 1, maxReach[i] + 1, 
                [](int a, int b) { return a > b; });
            nextPos[i][0] = *it;
        }

        hashTbl[i][0] = charAt[i];
        for (int j = 1; j < 30; ++j) {
            nextPos[i][j] = nextPos[nextPos[i][j-1]][j-1];
            hashTbl[i][j] = (hashTbl[i][j-1] * (__int128)basePow[j-1] % HASH_MOD + hashTbl[nextPos[i][j-1]][j-1]) % HASH_MOD;
        }

        while (stackTop && compareFrom(i, stackBuf[stackTop])) {
            --stackTop;
        }
        stackBuf[++stackTop] = i;
    }

    for (int cur = 1; cur != 0; cur = nextPos[cur][0]) {
        cout << char(charAt[cur] + 'a' - 1);
    }
    cout << '\n';

    return 0;
}

タグ: 文字列処理 動的計画法 貪欲法 ハッシュ 二分探索

6月11日 23:52 投稿