【演算法學習筆記】淺談懸線法

RioTian發表於2021-07-21

懸線法

什麼是懸線法?

懸線法是用來解決最大子矩形問題的有力武器,它的思想很簡單,程式碼也很好寫。

懸線法的適用範圍是單調棧的子集。具體來說,懸線法可以應用於滿足以下條件的題目:

  • 需要在掃描序列時維護單調的資訊;
  • 可以使用單調棧解決;
  • 不需要在單調棧上二分。

看起來懸線法可以被替代,用處不大,但是懸線法概念比單調棧簡單,更適合初學 OI/ACM 的選手理解並解決最大子矩陣等問題。

原理

一般地,我們有一張 \(n∗m\) 的圖,裡面有一些障礙,我們想要求出一個最大的子矩形,使得它裡面沒有任何障礙(或者說,使它滿足某個條件)。

我們考慮從每個點向上作一條射線,這條線如果遇到一個障礙或者是矩形的上邊界就停下。這條線,就叫做懸線。我們把這條線儘可能地往左右移動(儘可能指的是不遇到障礙),就可以圍成一個極大子矩形,這個子矩形是這條懸線所能構成的最大的子矩形。

顯然,最大子矩形是屬於所有懸線能構成的極大子矩形的集合裡的。

於是,我們只要列舉每個懸線,\(\mathcal{O}(n)\) 地算出每個極大子矩形的面積,然後取一個 \(max\) 就行了。

考慮每一個點和每一條懸線一一對應,且一共有 \(n∗m\) 個點,所以複雜度就是 \(O(nm)\)

那麼,問題就轉化為如何 \(O(1)\) 地算出每個極大子矩形的面積。

舉個例子,我們假設我們要求矩形裡都是相同的數。

我們考慮遞推,設$ H[i][j]$ 表示 \((i,j)\) 的懸線長度。

則存在

if (a[i][j] == a[i - 1][j]) H[i][j] = H[i - 1][j] + 1;
else H[i][j] = 1;

然後我們再考慮左右邊界,記為 \(L[i][j],R[i][j]\) ,這也是可以遞推的。

//Lx, Rx表示在這一行中(i,j)能達到最左邊和最右邊的位置
if (a[i][j] == a[i - 1][j])
    L[i][j] = max(L[i - 1][j], Lx[i][j]), R[i][j] = min(R[i - 1][j], Rx[i][j]);

當然,意思是這個意思,實現起來會有一些不一樣的地方。

最後,我們列舉每個點,統計一下答案就好了。

例題:ZJOI2007 棋盤製作

【AC Code】

const int N = 2e3 + 10;
int a[N][N];
int H[N][N], L[N][N], R[N][N];

int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            cin >> a[i][j], L[i][j] = R[i][j] = j, H[i][j] = 1;

    for (int i = 1; i <= n; ++i) {
        for (int j = 2; j <= m; ++j)
            L[i][j] = a[i][j] == a[i][j - 1] ? j : L[i][j - 1];
        for (int j = m - 1; j >= 1; --j)
            R[i][j] = a[i][j] == a[i][j + 1] ? j : R[i][j + 1];
    }
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) {
            if (i > 1 and a[i][j] != a[i - 1][j]) {
                H[i][j] = H[i - 1][j] + 1;
                L[i][j] = max(L[i][j], L[i - 1][j]), R[i][j] = min(R[i][j], R[i - 1][j]);
            }
        }

    int ans1 = 0, ans2 = 0;
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j) {
            int a = H[i][j], b = R[i][j] - L[i][j] + 1;
            if (a > b) swap(a, b);
            ans1 = max(ans1, a * a);
            ans2 = max(ans2, a * b);
        }

    cout << ans1 << "\n" << ans2;
}

OI wiki 社群

以下引用部分 OI Wiki 的題目講解,感謝社群的引用許可!

"SP1805 HISTOGRA - Largest Rectangle in a Histogram"

題目大意:在一條水平線上有 \(n\) 個寬為 \(1\) 的矩形,求包含於這些矩形的最大子矩形面積。

懸線,就是一條豎線,這條豎線有初始位置和高度兩個性質,可以在其上端點不超過當前位置的矩形高度的情況下左右移動。

對於一條懸線,我們在這條上端點不超過當前位置的矩形高度且不移出邊界的前提下,將這條懸線左右移動,求出其最多能向左和向右擴充套件到何處,此時這條懸線掃過的面積就是包含這條懸線的儘可能大的矩形。容易發現,最大子矩形必定是包含一條初始位置為 \(i\),高度為 \(h_i\) 的懸線。列舉實現這個過程的時間複雜度為 \(O(n ^ 2)\),但是我們可以用懸線法將其優化到 \(O(n)\)

我們考慮如何快速找到懸線可以到達的最左邊的位置。

定義 \(l_i\) 為當前找到的 \(i\) 位置的懸線能擴充套件到的最左邊的位置,容易得到 \(l_i\) 初始為 \(i\),我們需要進一步判斷還能不能進一步往左擴充套件。

  • 如果當前 \(l_i = 1\),則已經擴充套件到了邊界,不可以。
  • 如果當前 \(a_i > a_{l_i - 1}\),則從當前懸線擴充套件到的位置不能再往左擴充套件了。
  • 如果當前 \(a_i \le a_{l_i - 1}\),則從當前懸線還可以往左擴充套件,並且 \(l_i - 1\) 位置的懸線能向左擴充套件到的位置,\(i\) 位置的懸線一定也可以擴充套件到,於是我們將 \(l_i\) 更新為 \(l_{l_i - 1}\),並繼續執行判斷。

通過攤還分析,可以證明每個 \(l_i\) 最多會被其他的 \(l_j\) 遍歷到一次,因此時間複雜度為 \(O(n)\)

【參考程式碼】

const int N = 100010;
int n, a[N], l[N], r[N];
ll ans;
int main() {
    while (scanf("%d", &n) != EOF && n) {
        ans = 0;
        for (int i = 1; i <= n; i++) scanf("%d", &a[i]), l[i] = r[i] = i;
        for (int i = 1; i <= n; i++)
            while (l[i] > 1 && a[i] <= a[l[i] - 1]) l[i] = l[l[i] - 1];
        for (int i = n; i >= 1; i--)
            while (r[i] < n && a[i] <= a[r[i] + 1]) r[i] = r[r[i] + 1];
        for (int i = 1; i <= n; i++)
            ans = max(ans, (long long)(r[i] - l[i] + 1) * a[i]);
        printf("%lld\n", ans);
    }
    return 0;
}

"UVA1619 感覺不錯 Feel Good"

對於一個長度為 \(n\) 的數列,找出一個子區間,使子區間內的最小值與子區間長度的乘積最大,要求在滿足舒適值最大的情況下最小化長度,最小化長度的情況下最小化左端點序號。

本題中我們可以考慮列舉最小值,將每個位置的數 \(a_i\) 當作最小值,並考慮從 \(i\) 向左右擴充套件,找到滿足 \(\min\limits _ {j = l} ^ r a_j = a_i\) 的儘可能向左右擴充套件的區間 \([l, r]\)。這樣本題就被轉化成了懸線法模型。

【參考程式碼】

const int N = 100010;
int n, a[N], l[N], r[N];
long long sum[N];
long long ans;
int ansl, ansr;
bool fir = 1;
int main() {
    while (scanf("%d", &n) != EOF) {
        memset(a, -1, sizeof(a));
        if (!fir)
            printf("\n");
        else
            fir = 0;
        ans = 0;
        ansl = ansr = 1;
        for (int i = 1; i <= n; i++) {
            scanf("%d", &a[i]);
            sum[i] = sum[i - 1] + a[i];
            l[i] = r[i] = i;
        }
        for (int i = 1; i <= n; i++)
            while (a[l[i] - 1] >= a[i]) l[i] = l[l[i] - 1];
        for (int i = n; i >= 1; i--)
            while (a[r[i] + 1] >= a[i]) r[i] = r[r[i] + 1];
        for (int i = 1; i <= n; i++) {
            long long x = a[i] * (sum[r[i]] - sum[l[i] - 1]);
            if (ans < x || (ans == x && ansr - ansl > r[i] - l[i]))
                ans = x, ansl = l[i], ansr = r[i];
        }
        printf("%lld\n%d %d\n", ans, ansl, ansr);
    }
    return 0;
}

最大子矩形

"P4147 玉蟾宮"

給定一個 \(n \times m\) 的包含 'F''R' 的矩陣,求其面積最大的子矩陣的面積 \(\times 3\),使得這個子矩陣中的每一位的值都為 'F'

我們會發現本題的模型和第一題的模型很像。仔細分析,發現如果我們每次只考慮某一行的所有元素,將位置 \((x, y)\) 的元素儘可能向上擴充套件的距離作為該位置的懸線長度,那最大子矩陣一定是這些懸線向左右擴充套件得到的儘可能大的矩形中的一個。

【AC Code】

int m, n, a[1010], l[1010], r[1010], ans;
int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        char s[3];
        for (int j = 1; j <= m; j++) {
            scanf("%s", s);
            if (s[0] == 'F')
                a[j]++;
            else if (s[0] == 'R')
                a[j] = 0;
        }
        for (int j = 1; j <= m; j++)
            while (a[l[j] - 1] >= a[j]) l[j] = l[l[j] - 1];
        for (int j = m; j >= 1; j--)
            while (a[r[j] + 1] >= a[j]) r[j] = r[r[j] + 1];
        for (int j = 1; j <= m; j++) ans = std::max(ans, (r[j] + l[j] - 1) * a[j]);
    }
    printf("%d", ans * 3);
    return 0;
}

參考

相關文章