砍樹 題解|APCS 2020 年 1 月中高級 第 3 題


前置知識

這題的核心推導(一棵樹能不能倒只看還站著的左右鄰居)下面會從頭講;要先會的是兩個工具,同一個主題兩邊都有的,對照著讀會更快上手:

  • 鏈結串列:NTUCPC Guide〈Linked List〉——用兩個陣列 prv[]、nxt[] 記每個元素的前後是誰,拿掉一個元素只要改兩個值;吳邦一《AP325》第 3 章的「串列鏈結」一節就是拿本題(例題 P-3-6)當例子,\(O(n^2)\) 版、串列加佇列版、堆疊版三種寫法都有(教材 PDF 第 99~105 頁;Python 版第 3 章)。
  • 佇列:NTUCPC Guide〈Stack、Queue 與 Deque〉——「要重新檢查的東西排隊,輪到了再看」。

簡潔題意

\(N\) 棵樹長在 \([0, L]\) 上,位置 \(c_i\) 由小到大給、高度 \(h_i\)。砍一棵樹要讓它向左倒(蓋住 \([c_i - h_i, c_i]\))或向右倒(蓋住 \([c_i, c_i + h_i]\)),倒下的範圍不能超出 \([0, L]\)、裡面不能有還站著的樹,別的樹剛好在端點不算壓到。能砍就砍、砍到沒有樹能砍為止(最後砍掉的集合跟順序無關),輸出砍掉幾棵、以及砍掉的樹裡最高的高度(沒砍到任何樹就印 0)(\(1 \le N \le 10^5\),\(1 \le L \le 10^9\),\(1 \le h_i \le 10^9\))。

依正確通過的測試資料筆數給分,其中:

  • 子題組 1(\(30\) 分):\(N \le 1000\)。
  • 子題組 2(\(70\) 分):無額外限制。

一棵樹能不能倒:只看還站著的左右鄰居

第 \(i\) 棵樹向左倒蓋住 \([c_i - h_i, c_i]\),這段裡會不會有別的樹,只需要看目前還站著的左鄰居:它的位置 \(c_l\) 不在 \((c_i - h_i, c_i)\) 裡就行(更左邊的樹更不可能在裡面)。「剛好在端點不算壓到」,所以 \(c_l = c_i - h_i\) 是允許的——條件寫成 \(c_l \le c_i - h_i\),等號要留著。向右倒同理:\(c_i + h_i \le L\),而且右鄰居 \(c_r \ge c_i + h_i\)。兩個方向有一個行就能砍(圖 1)。

圖 1:範例六棵樹的倒下區間,一開始 2、4、6 號能砍;砍掉之後 1 號向右的區間空出來也能砍,3、5 號砍不掉

砍掉一棵樹,只有它的左右鄰居會受影響:鄰居的「最近的還站著的樹」換人了,原本被擋住的方向可能空出來——範例裡 \(1\) 號一開始向右被 \(2\) 號擋住,\(2\) 號砍掉後就倒得下。其他樹的鄰居沒變,不用重看。

座標和高度都到 \(10^9\),\(c_i + h_i\) 最大 \(2 \times 10^9\),還在 int 的 \(2147483647\) 以內,不過很接近;不想算就用 long long,兩種都對(估算方法見語法書 2.9)。

先拿下 30 分:有砍到就再掃一輪

最直接的寫法:從左到右掃一遍,能砍的就砍;只要這一輪有砍到樹,就再掃一輪,直到一整輪都沒砍到為止。找「最近的還站著的鄰居」就往左、往右跳過已經砍掉的樹。

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

const int MAX_N = 100000;
int n;
long long L, c[MAX_N + 2], h[MAX_N + 2];
bool cut[MAX_N + 2];             // 這棵樹砍掉了沒

int main() {
    cin >> n >> L;
    for (int i = 1; i <= n; i++) cin >> c[i];
    for (int i = 1; i <= n; i++) cin >> h[i];
    int count = 0;
    long long tallest = 0;
    bool changed = true;
    while (changed) {                            // 這一輪有砍到樹,就再掃一輪
        changed = false;
        for (int i = 1; i <= n; i++) {
            if (cut[i]) continue;
            int l = i - 1, r = i + 1;            // 往左右找最近「還站著」的樹
            while (l >= 1 && cut[l]) l--;
            while (r <= n && cut[r]) r++;
            bool left = c[i] - h[i] >= 0 && (l == 0 || c[l] <= c[i] - h[i]);
            bool right = c[i] + h[i] <= L && (r == n + 1 || c[r] >= c[i] + h[i]);
            if (left || right) {
                cut[i] = true;
                count++;
                tallest = max(tallest, h[i]);
                changed = true;
            }
        }
    }
    cout << count << endl << tallest << endl;
    return 0;
}

一輪最少砍一棵,所以最多 \(N\) 輪、每輪掃 \(N\) 棵,\(O(N^2)\):\(N = 1000\) 時 \(10^6\),一瞬間,子題組 1 的 \(30\) 分穩穩到手。\(N = 10^5\) 時最壞的情況是「每一輪只有最右邊那棵能倒」——每棵樹都被右鄰居擋住、只能等右邊的倒了自己才能倒,這樣要掃 \(N\) 輪,本站實測 \(17\) 秒。

從 30 分到 100 分:只重看鄰居

重掃一整輪很浪費:砍掉一棵樹只會讓它的左右鄰居變得可能能砍,其他樹看了也是白看。所以改成排隊:一開始每棵樹都排隊檢查一次;輪到的樹若能砍就砍掉,並且把它的左右鄰居重新排隊。鄰居是誰,用鏈結串列 prv[]、nxt[] 維護——砍掉 \(i\) 就把 prv[i] 和 nxt[i] 直接接起來,之後查鄰居都是 \(O(1)\),不用再跳過砍掉的樹。

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

const int MAX_N = 100000;
int n;
long long L, c[MAX_N + 2], h[MAX_N + 2];
bool cut[MAX_N + 2];
int prv[MAX_N + 2], nxt[MAX_N + 2];   // 目前還站著的左右鄰居(0 和 n + 1 代表沒有鄰居)

bool canCut(int i) {
    bool left = c[i] - h[i] >= 0 && (prv[i] == 0 || c[prv[i]] <= c[i] - h[i]);
    bool right = c[i] + h[i] <= L && (nxt[i] == n + 1 || c[nxt[i]] >= c[i] + h[i]);
    return left || right;
}

int main() {
    cin >> n >> L;
    for (int i = 1; i <= n; i++) cin >> c[i];
    for (int i = 1; i <= n; i++) cin >> h[i];
    for (int i = 1; i <= n; i++) {
        prv[i] = i - 1;
        nxt[i] = i + 1;
    }
    queue<int> q;                           // 等著被檢查的樹
    for (int i = 1; i <= n; i++) q.push(i); // 每棵樹先排隊檢查一次
    int count = 0;
    long long tallest = 0;
    while (!q.empty()) {
        int i = q.front();
        q.pop();
        if (cut[i] || !canCut(i)) continue;
        cut[i] = true;
        count++;
        tallest = max(tallest, h[i]);
        int l = prv[i], r = nxt[i];
        nxt[l] = r;                         // 把 i 從串列中拿掉:左右鄰居直接相連
        prv[r] = l;
        if (l >= 1) q.push(l);              // 鄰居的情況變了,重新排隊檢查
        if (r <= n) q.push(r);
    }
    cout << count << endl << tallest << endl;
    return 0;
}

每棵樹最多被砍一次,每砍一次只多排兩棵樹進佇列,所以總共檢查不超過 \(3N\) 次,\(O(N)\);\(N = 10^5\) 本站實測不到 \(0.1\) 秒。同一棵樹可能在佇列裡出現好幾次,砍掉後再輪到它就靠 cut[i] 直接跳過。

測過再交:範例一棵都沒卡在邊界上

範例六棵樹有連鎖(\(2\) 號砍掉 \(1\) 號才能倒)、有砍不掉的(\(3\)、\(5\) 號),但沒蓋到:一棵都砍不掉要印兩個 0、剛好倒在 \(0\) 或 \(L\) 上、剛好碰到鄰居的端點、以及「要從最右邊一路往左連鎖」的大測資(驗速度)。

輸入 正確輸出 這一筆在測什麼
1 10/5/6 0/0 兩個方向都超出範圍:一棵都砍不掉,第二行要印 0
1 10/5/5 1/5 剛好倒在 \(L\) 上(\(5 + 5 = 10\)):允許
2 10/1 3/2 9 1/2 \(1\) 號向右倒剛好碰到 \(3\) 號的位置 \(3\):端點不算壓到,能砍;\(2\) 號兩邊都不行
3 20/5 10 15/6 3 6 3/6 連鎖:只有中間的 \(2\) 號一開始能倒,砍掉它之後左右兩棵才倒得下
4 100/10 20 30 40/15 15 15 15 4/15 從右往左連鎖:每棵都被右鄰居擋住,最右邊那棵倒了才一棵接一棵倒
用程式造:\(N = 100000\),\(c_i = 10i\)、\(h_i = 15\)、\(L = 10^6 + 20\) 100000/15 上一列放大到 \(10^5\) 棵:重掃一整輪的寫法要跑 \(N\) 輪,超時

六筆都親眼看過正確,這題就穩了。

另一種做法:從左到右一路掃、用堆疊

這是《AP325》裡「更簡單的作法」:從左到右處理,用一個堆疊 standing 放「掃過了、暫時砍不掉」的樹。輪到第 \(i\) 棵時,它的左鄰居就是堆疊頂端那棵,右鄰居一定是還沒處理的第 \(i + 1\) 棵。能砍就砍,砍掉之後堆疊頂端那棵的右鄰居變成了 \(i + 1\),它可能因此能向右倒——倒了再看下一個頂端,用 while 一路往回檢查。砍不掉就把 \(i\) 推進堆疊。

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

const int MAX_N = 100000;
int n;
long long L, c[MAX_N + 2], h[MAX_N + 2];

bool fallLeft(int i, int l) {           // l = 左鄰居(0 代表沒有)
    return c[i] - h[i] >= 0 && (l == 0 || c[l] <= c[i] - h[i]);
}

bool fallRight(int i, int r) {          // r = 右鄰居(n + 1 代表沒有)
    return c[i] + h[i] <= L && (r == n + 1 || c[r] >= c[i] + h[i]);
}

int main() {
    cin >> n >> L;
    for (int i = 1; i <= n; i++) cin >> c[i];
    for (int i = 1; i <= n; i++) cin >> h[i];
    vector<int> standing;               // 掃過、暫時砍不掉的樹(由左到右)
    int count = 0;
    long long tallest = 0;
    for (int i = 1; i <= n; i++) {
        int l = standing.empty() ? 0 : standing.back();   // 左鄰居就是最後一棵還站著的
        if (fallLeft(i, l) || fallRight(i, i + 1)) {      // 右鄰居此時一定是 i + 1
            count++;
            tallest = max(tallest, h[i]);
            // i 倒了,standing 最後那棵的右鄰居變成 i + 1:可能換它倒,一路往回檢查
            while (!standing.empty() && fallRight(standing.back(), i + 1)) {
                count++;
                tallest = max(tallest, h[standing.back()]);
                standing.pop_back();
            }
        } else {
            standing.push_back(i);
        }
    }
    cout << count << endl << tallest << endl;
    return 0;
}

為什麼只看向右倒就夠:堆疊裡一棵樹的左鄰居(它下面那棵)在它還沒被砍掉之前永遠不會變,所以向左倒的機會在它被推進堆疊時就已經判過了。每棵樹進出堆疊各一次,\(O(N)\)。兩種做法都是滿分;佇列版比較不容易漏掉情況,堆疊版比較短。

常犯錯誤
  • 端點算成壓到(條件寫成 \(c_l < c_i - h_i\)):範例印 3/30,正確是 4/30;自測表第 3 列印 0/0。
  • 沒檢查倒下的範圍會不會超出 \([0, L]\):範例印 6/55;自測表第 1 列印 1/6。
  • 第二行印全部樹裡最高的,不是砍掉的樹裡最高的:範例印 4/55。
  • 砍掉一棵樹後沒有重新檢查它的鄰居(佇列版只排隊一次、或者只從左到右掃一遍):範例印 3/25——\(1\) 號沒機會在 \(2\) 號倒了之後再看一次;自測表第 5 列印 1/15。
  • 砍掉後沒更新串列,鄰居還是砍之前那棵:範例印 3/25;自測表第 4 列印 1/3。
  • 堆疊版把 while 寫成 if(砍掉一棵後只回頭看一棵):範例過,計分測資掛 \(2\) 筆;自測表第 5 列印 2/15。
  • 重掃一整輪的 \(30\) 分版硬交:\(N = 10^5\) 從右往左連鎖的測資本站實測 \(17\) 秒,超時。