CCC 2023 S2 Symmetric Mountains Solution: Center Expansion for Minimum Asymmetry at Every Length



☰

Start NorthStar

CP Weekly Challenge

CCC 2023 S2 Symmetric Mountains Solution: Center Expansion for Minimum Asymmetry at Every Length

Fix each odd or even center, expand outward, and find the minimum asymmetry for every crop length in O(N²).

CP Weekly Challenge

CCC 2023 S2 Symmetric Mountains 题解:固定中点向外扩展,O(N²) 求每个长度的最小不对称值

枚举奇偶两类中心并向外扩展,用 O(N²) 求出每种裁剪长度的最小不对称值。

Share this article

Copy the link, open your phone share sheet, scan the QR code for WeChat, or send by email.




Email

A row of mountains is photographed, and any contiguous crop [l, r] has an "asymmetric value": pair up mountains that are equidistant from the center, sum the absolute height differences. The task asks for more than one crop's answer — for every length k from 1 to N, the minimum asymmetric value among all crops of that length. CCC 2023 Senior Problem 2, Symmetric Mountains, looks like it requires enumerating a massive number of intervals. What it really tests is whether you can see that every interval has exactly one center, and eliminate the repeated work. The whole algorithm is one idea: fix a center, expand outward.

Companion video: Watch Berny's 2023 S2 Symmetric Mountains walkthrough on Xiaohongshu

Where This Fits in the CCC DSA Map

  • Level: Senior S2
  • Pattern: Center Expansion (two pointers moving outward)
  • Prerequisite: arrays and indexing, loops, abs(), Big-O basics (Python basics: input, operators, and conditions, Python list fundamentals)
  • What this teaches: turn "every interval has exactly one center" into an incremental recurrence (each step adds exactly one pair), then aggregate minima by length — recognize and remove repeated work
  • Where it appears again: palindrome checking and longest palindromic substring, the two-pointer / sliding-window topic (planned), CPW-012 Searching for Strings (planned)

What to Study Next

What the Problem Is Asking

The input gives N mountain heights h₁…hₙ. A crop is a contiguous interval [l, r]; its asymmetric value is the sum of absolute differences of mirrored positions: |h_l − h_r| + |h_{l+1} − h_{r−1}| + …

Output N integers: the k-th one is the minimum asymmetric value among all crops of length k.

Official sample: N=7, heights [3 1 4 1 5 9 2], answer 0 2 0 5 2 10 10.

Read the original problem in the CEMC 2023 CCC Senior problem set.

Bounds: 1 ≤ N ≤ 5000, 0 ≤ h_i ≤ 10^5, with layered subtasks: N≤300 yields to brute force; the middle subtask has non-decreasing heights (where prefix sums can split the absolute values); full marks have no extra constraints — and center expansion handles all of them.

Where Beginners Often Get Stuck

  • Sorting first: sorting makes absolute values easy to split, but it reorders the mountains — the photograph changes and so does every interval. Sorting is forbidden here.
  • Brute force by length: enumerating every start for every length and recomputing each asymmetry from scratch is O(N³) — about 10¹¹ operations at N=5000, hopeless.
  • Forgetting even centers: odd-length crops are centered on a mountain; even-length crops are centered on the gap between two mountains. Writing only one kind misses half the intervals.
  • Initializing the answer array to 0: each length asks for a minimum, so the initial value must be infinity before taking min.
  • Prefix-sum splitting for general data: splitting |a−b| requires knowing the order; full-mark data has no monotonicity, so that trick dies on subtask 3.

Core Algorithm Idea

Expand the definition of a crop [l, r]:

asym(l, r) = |h_l − h_r| + |h_{l+1} − h_{r−1}| + |h_{l+2} − h_{r−2}| + …

Except for the outermost pair, everything inside the brackets is exactly asym(l+1, r−1). That gives an incremental recurrence:

asym(l, r) = |h_l − h_r| + asym(l+1, r−1)

Empty intervals and single mountains cost 0. So if intervals are generated by "fix a center, expand outward", each expansion step only adds the newly exposed pair — the inner value is already in the accumulator, and no pair is ever computed twice.

Every interval [l, r] has exactly one center: an index c for odd lengths, a gap (c, c+1) for even lengths. Enumerating all centers (N position centers plus N−1 gap centers) and expanding outward covers all N(N+1)/2 intervals.

State / Data Structure Design

  • ans[k]: the minimum asymmetric value among crops of length k (k = 1..N), initialized to infinity.
  • Transient expansion state: left, right, cost — the current interval's endpoints and its accumulated asymmetry.

Each expansion produces one interval and updates ans[r − l + 1] with cost. The intervals themselves never need to be stored.

Algorithm Steps

  1. Read N and the height array h.
  2. Initialize ans[1..N] to infinity.
  3. For each position center c (0..N−1): start from l=r=c with cost=0; each step adds |h[l] − h[r]|, updates ans[r−l+1] with cost, then moves l left and r right until out of bounds.
  4. For each gap center c (0..N−2): start from l=c, r=c+1, and expand the same way.
  5. Print ans[1..N], space-separated.

Sample Check with the Official Data

Verifying each length against the sample [3 1 4 1 5 9 2] (0-based indices):

Length Best crop Asymmetry
1 any single mountain 0
2 [3 1] 2
3 [1 4 1] 0
4 [3 1 4 1] or [1 5 9 2] 5
5 [3 1 4 1 5] 2
6 [1 4 1 5 9 2] 10
7 all seven 10

Watch center 2 (the mountain of height 4) grow the crop [3 1 4 1 5]: (4,4) adds 0; expanding to (1,3) adds |1−1|=0; expanding to (0,4) adds |3−5|=2 — three steps, one new pair each. The official explanation lists the three length-5 crops as costing 2, 9, and 10; the minimum 2 matches the output.

Python Solution

n = int(input())
mountains = list(map(int, input().split()))
answer = [10**18] * (n + 1)

for center in range(n):
    cost = 0
    left = center
    right = center
    while left >= 0 and right < n:
        cost += abs(mountains[left] - mountains[right])
        length = right - left + 1
        answer[length] = min(answer[length], cost)
        left -= 1
        right += 1

for center in range(n - 1):
    cost = 0
    left = center
    right = center + 1
    while left >= 0 and right < n:
        cost += abs(mountains[left] - mountains[right])
        length = right - left + 1
        answer[length] = min(answer[length], cost)
        left -= 1
        right += 1

print(*answer[1:])

This matches the curriculum notebook (p2003, Session 3) line for line — same variable names students see in class: mountains, center, cost, answer. Note that cost grows monotonically during one expansion, but lengths do not share answers — you cannot prune an expansion early just because the cost already exceeds some length's record.

C++ Solution

#include <algorithm>
#include <iostream>
#include <vector>
#include <cstdlib>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> mountains(n);
    for (int& height : mountains) {
        cin >> height;
    }

    const long long INF = (1LL << 60);
    vector<long long> answer(n + 1, INF);

    for (int center = 0; center < n; center++) {
        long long cost = 0;
        int left = center;
        int right = center;
        while (left >= 0 && right < n) {
            cost += abs(mountains[left] - mountains[right]);
            int length = right - left + 1;
            answer[length] = min(answer[length], cost);
            left--;
            right++;
        }
    }

    for (int center = 0; center + 1 < n; center++) {
        long long cost = 0;
        int left = center;
        int right = center + 1;
        while (left >= 0 && right < n) {
            cost += abs(mountains[left] - mountains[right]);
            int length = right - left + 1;
            answer[length] = min(answer[length], cost);
            left--;
            right++;
        }
    }

    for (int length = 1; length <= n; length++) {
        if (length > 1) {
            cout << ' ';
        }
        cout << answer[length];
    }
    cout << '\n';
    return 0;
}

Same as the curriculum reference; long long guards against overflow (worst case about ⌊N/2⌋×10⁵); answer starts from a (1LL << 60)-scale infinity; the two loops are perfectly symmetric except for the initial (left, right).

Why the Algorithm Is Correct

  • Every interval is visited exactly once: any interval [l, r] has a unique center (a position or a gap), its expansion from that center passes through [l, r], and intervals generated from different centers never overlap.
  • Each updated cost equals the true asym(l, r): induction on the expansion radius. Radius 0 (or the gap initial state) gives length 1 (or 2), where cost = 0 plus the first pair — correct. Assuming the inner [l+1, r−1] value is correct, expanding to [l, r] adds exactly the outermost pair |h_l − h_r|, and the recurrence asym(l, r) = |h_l − h_r| + asym(l+1, r−1) makes it correct.
  • ans[k] ends as the minimum over all crops of length k: every crop of length k is computed during its center's expansion and participates in the min — no omissions, no duplicates.

Therefore the output is exactly the requested minimum asymmetric value for every length.

Complexity Analysis

  • Time O(N²): N(N+1)/2 intervals with O(1) amortized work each — about 12,502,500 constant-time updates at N=5000.
  • Space O(N): the answer array, plus the input.
  • Measured on this machine (Apple Silicon, CPython 3): the curriculum Python version takes about 17s on the N=5000 random case — it will not fit a typical 5-second limit; C++ runs it in about 0.03s with no pressure. Bottom line: use the Python version to learn the algorithm (some judges relax limits for Python or allow PyPy), and submit C++ in a real contest. Python tuning is constant-factor only (inline compare instead of min(), buffered input — together roughly the edge of 5s); the algorithm itself is already optimal in its complexity class.

Common Mistakes

  1. Sorting first — reordering changes every interval and guarantees a wrong answer.
  2. Forgetting gap centers — odd lengths come out right while even lengths stay at their initial value.
  3. Initializing ans to 0 — the min updates never take effect and the output is all zeros.
  4. Recomputing the full asymmetry inside each expansion — degrades to O(N³) and times out at full marks.
  5. Prefix-sum splitting on non-monotonic data — splitting |a−b| needs a known order, which full-mark data does not have.
  6. Output format — N integers on one line, space-separated; trailing spaces or missing lengths are format errors.

Review Questions

  1. Why does every interval have exactly one center? Which lengths' optima are missed if gap centers are skipped?
  2. Why is sorting illegal in this problem? Explain it in terms of how an "interval" is defined.
  3. The recurrence asym(l, r) = |h_l − h_r| + asym(l+1, r−1) maps to a length-ordered DP — how is it written, and what do the three rolling rows store?
  4. Why does the non-decreasing subtask allow O(1) asymmetry queries via prefix sums, and what exactly breaks on full-mark data?
  5. If you must submit in Python under a tight limit, what constant-factor optimizations remain, and roughly how much do they save?

一排山的高度给定后,任取一个连续区间(裁剪),把区间两端等高对称位置的山两两配对,高度差之和就是这个裁剪的"不对称值"。题目要的不是某一个裁剪的答案,而是:对每个可能的长度 1 到 N,所有该长度裁剪中最小的不对称值。CCC 2023 Senior 第 2 题 Symmetric Mountains 看起来要枚举海量区间,真正考的却是:你能不能看出"每个区间恰有一个中心",把重复计算消掉。核心算法只有一个——固定中点,向外扩展。

配套视频: 在小红书观看 Berny 老师讲解《2023 S2 Symmetric Mountains》

本题在 CCC DSA 地图里的位置

  • Level: Senior S2
  • Pattern: Center Expansion(中心扩展,向外双指针)
  • Prerequisite: 数组与下标、循环、abs()、Big-O 基础(Python 基础:输入、运算符与条件、Python 列表基础)
  • What this teaches: 把"每个区间恰有一个中心"翻译成增量递推(每扩一步只加一对),再按长度聚合最小值——识别并消去重复计算
  • Where it appears again: 回文判定与最长回文子串、双指针/滑窗专题 (planned)、CPW-012 Searching for Strings(planned)

下一步推荐

如果你已经理解这道题,可以继续看:

题目要我们做什么

输入 N 座山的高度 h₁…hₙ。一个裁剪是连续区间 [l, r],它的不对称值定义为:从两端向中间逐对比较,所有对称位置高度差的绝对值之和,即 |h_l − h_r| + |h_{l+1} − h_{r−1}| + …。

输出 N 个整数:第 k 个是所有长度为 k 的裁剪里,不对称值的最小值。

官方样例:N=7,高度 [3 1 4 1 5 9 2],答案是 0 2 0 5 2 10 10。

题目原文见 CEMC 2023 CCC Senior 官方题集。

数据范围:1 ≤ N ≤ 5000,0 ≤ h_i ≤ 10^5。子任务分层:N≤300 可直接暴力;中间子任务高度非递减(可用前缀和拆绝对值);满分无任何附加约束——中心扩展对所有子任务通用。

初学者容易卡在哪里

  • 先排序再算:排序让绝对值好拆,但会改变山的顺序——照片被重排,区间全变了。本题严禁排序。
  • 按长度直接暴力:对每个长度枚举所有起点,再整体重算不对称值,是 O(N³),N=5000 约 10¹¹ 次运算,必然超时。
  • 漏掉偶数中心:长度为奇数的裁剪,中心是"一座山";长度为偶数时,中心在"两座山之间的空隙"。只写一种中心会漏掉一半区间。
  • 答案数组初值写成 0:每个长度要求的是"最小值",初值必须是无穷大,再不断取 min。
  • 对非递减数据写前缀和拆号:拆绝对值依赖单调性,满分数据没有单调保证,此法过不了第三个子任务。

核心算法概念

观察一个裁剪 [l, r] 的不对称值展开式:

asym(l, r) = |h_l − h_r| + |h_{l+1} − h_{r−1}| + |h_{l+2} − h_{r−2}| + …

除了最外面一对,括号里剩下的所有项恰好就是 asym(l+1, r−1)。于是得到一条增量递推:

asym(l, r) = |h_l − h_r| + asym(l+1, r−1)

空区间和单座山的不对称值都是 0。这意味着:如果按"固定中心、向外扩张"的顺序生成区间,每扩一步只需要加上新露出来的一对高度差,内层的值已经在累加器里——没有任何一对被重复计算。

而每个区间 [l, r] 都有且仅有一个中心:奇数长度的中心是某个下标 c,偶数长度的中心是某个空隙 (c, c+1)。所以枚举全部中心(N 个位置中心 + N−1 个空隙中心),从中心向外扩张并维护累加器,就覆盖了全部 N(N+1)/2 个区间。

状态定义

  • ans[k]:长度为 k 的裁剪的最小不对称值(k = 1..N),初值为 ∞。
  • 扩张过程中的瞬时状态:left、right、cost——当前区间两端下标与已累加的不对称值。

每次扩张得到一个新区间,就用 cost 更新 ans[r − l + 1]。不需要保存所有区间本身。

算法步骤

  1. 读入 N 和高度数组 h。
  2. ans[1..N] 全部初始化为 ∞。
  3. 对每个位置中心 c(0..N−1):从 l=r=c 出发,cost=0;每次先加上 |h[l] − h[r]|,用 cost 更新 ans[r−l+1],然后 l 左移、r 右移,直到越界。
  4. 对每个空隙中心 c(0..N−2):从 l=c, r=c+1 出发,同样扩张并更新。
  5. 输出 ans[1..N],空格分隔。

官方样例核对

用样例 [3 1 4 1 5 9 2] 逐长度核对(组号从 0 开始):

长度 最优裁剪 非对称值
1 任意一座山 0
2 [3 1] 2
3 [1 4 1] 0
4 [3 1 4 1] 或 [1 5 9 2] 5
5 [3 1 4 1 5] 2
6 [1 4 1 5 9 2] 10
7 全部七座 10

再以中心 2(高度 4 那座山)为例看增量过程,裁剪 [3 1 4 1 5] 的代价是这样长出来的:先 (4,4) 加 0;扩到 (1,3) 加 |1−1|=0;再扩到 (0,4) 加 |3−5|=2——总长 3 步,每步只加一对。官方对长度 5 的三个裁剪代价是 2、9、10,取最小得 2,与输出一致。

Python 解法

n = int(input())
mountains = list(map(int, input().split()))
answer = [10**18] * (n + 1)

for center in range(n):
    cost = 0
    left = center
    right = center
    while left >= 0 and right < n:
        cost += abs(mountains[left] - mountains[right])
        length = right - left + 1
        answer[length] = min(answer[length], cost)
        left -= 1
        right += 1

for center in range(n - 1):
    cost = 0
    left = center
    right = center + 1
    while left >= 0 and right < n:
        cost += abs(mountains[left] - mountains[right])
        length = right - left + 1
        answer[length] = min(answer[length], cost)
        left -= 1
        right += 1

print(*answer[1:])

要点:代码与课程教案(p2003 Session 3)的参考实现一致——奇偶两类中心各写一个扩张循环,mountains / center / cost / answer 就是课堂上的变量名,学生可以直接对照。注意 cost 单调不减,但不同长度的答案互不影响,不能用"已经变大就剪枝"提前退出。

C++ 解法

#include <algorithm>
#include <iostream>
#include <vector>
#include <cstdlib>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> mountains(n);
    for (int& height : mountains) {
        cin >> height;
    }

    const long long INF = (1LL << 60);
    vector<long long> answer(n + 1, INF);

    for (int center = 0; center < n; center++) {
        long long cost = 0;
        int left = center;
        int right = center;
        while (left >= 0 && right < n) {
            cost += abs(mountains[left] - mountains[right]);
            int length = right - left + 1;
            answer[length] = min(answer[length], cost);
            left--;
            right++;
        }
    }

    for (int center = 0; center + 1 < n; center++) {
        long long cost = 0;
        int left = center;
        int right = center + 1;
        while (left >= 0 && right < n) {
            cost += abs(mountains[left] - mountains[right]);
            int length = right - left + 1;
            answer[length] = min(answer[length], cost);
            left--;
            right++;
        }
    }

    for (int length = 1; length <= n; length++) {
        if (length > 1) {
            cout << ' ';
        }
        cout << answer[length];
    }
    cout << '\n';
    return 0;
}

要点:与教案参考实现一致;long long 防止累加溢出(最坏约 ⌊N/2⌋×10⁵);answer 初值为 (1LL << 60) 级别的无穷大;两个循环结构完全对称,只是初始 (left, right) 不同。

正确性说明

  • 每个区间被恰好访问一次:任意区间 [l, r] 的中点唯一(位置或空隙),扩张从该中心出发必经 [l, r],且不同中心生成的区间集合不相交。
  • 每次更新的 cost 是正确的 asym(l, r):对扩张半径归纳。半径 0(或空隙初态)时区间长度为 1(或 2),cost 初值 0 加上首对高度差,正确;设内层 [l+1, r−1] 的累加值正确,则扩到 [l, r] 时新增的恰好是最外一对 |h_l − h_r|,由递推式 asym(l, r) = |h_l − h_r| + asym(l+1, r−1) 即得正确。
  • ans[k] 最终是所有长度为 k 的区间的最小值:长度为 k 的每个区间都在其中心扩张时被计算并参与取 min,无遗漏、无重复。

因此输出就是题目所求的每个长度的最小不对称值。

复杂度分析

  • 时间 O(N²):N(N+1)/2 个区间,每个区间摊还 O(1) 次更新。N=5000 时约 12,502,500 次常数更新。
  • 空间 O(N):答案数组(外加输入数组)。
  • 实测参考(本机 M 系列、CPython 3):教案原版 Python 在 N=5000 随机数据上约 17s,无法通过常见 5s 时限;C++ 约 0.03s 毫无压力。结论:Python 版用于学习算法本身(部分评测机对 Python 放宽时限或可用 PyPy),正式竞赛请提交 C++。想优化 Python 只能在常数层面下功夫(如内联比较代替 min()、buffer 读入约可压到 5s 边缘),算法本身已到最优渐进阶。

常见错误

  1. 先排序再做:改变山的相对顺序,区间集合全变,答案必错。
  2. 漏掉空隙中心:只有奇数长度的答案是对的,偶数长度仍是无穷大或初始值。
  3. ans 初值写成 0:min 更新永远失效,输出全 0。
  4. 扩张时整体重算不对称值:退化成 O(N³),满分必超时。
  5. 用非递减专属的前缀和拆号:拆 |a−b| 需要知道大小关系,满分数据不满足单调性。
  6. 输出格式:N 个整数一行、空格分隔;行末多空格、漏长度都是格式错误。

复习问题

  1. 为什么每个区间恰有一个中心?漏掉空隙中心会 miss 哪些长度的最优解?
  2. 为什么排序在这道题里是非法操作?从"区间"的定义角度解释。
  3. 递推式 asym(l, r) = |h_l − h_r| + asym(l+1, r−1) 对应的"按长度递推"DP 怎么写?它同样需要 O(N) 额外空间,滚动数组的三行分别保存什么?
  4. 非递减子任务为什么能用前缀和做到 O(1) 查询一个区间的不对称值?这个技巧在满分数据上失效的确切原因是什么?
  5. 如果你只能用 Python 提交且评测时限很紧,除了换语言还有哪些常数层面的优化?各自能省多少?

Questions or feedback?

Have a question about this article or want to suggest an improvement? Send us a private message.

Send Feedback

有问题或建议?

如果你对本文有疑问,或希望提出改进建议,请给我们发送私密留言。

发送反馈

Back to CP Weekly Challenge
返回 CP Weekly Challenge

CP ACADEMY

Not sure which challenges fit your level?

Use the CP assessment to explore your current starting point and choose topics to practise next. You can keep reading and practising without taking an assessment.

60 minutes continuously · Python or C++ · Choose your competition goal

Discover Your CP Level

Scroll to Top