【ACWing】273. 分级(配数学证明)

题目地址:

https://www.acwing.com/problem/content/275/

给定长度为 n n n的序列 a a a,构造一个长度为 n n n的序列 b b b,满足: b b b非严格单调,即 b 1 ≤ b 2 ≤ … ≤ b n b_1≤b_2≤…≤b_n b1b2bn b 1 ≥ b 2 ≥ … ≥ b n b_1≥b_2≥…≥b_n b1b2bn。最小化 s = ∑ i = 1 n ∣ a i − b i ∣ s=∑^n_{i=1}|a_i−b_i| s=i=1naibi。只需要求出这个最小值 s s s

输入格式:
第一行包含一个整数 N N N
接下来 N N N行,每行包含一个整数 A i A_i Ai

输出格式:
输出一个整数,表示最小 S S S值。

数据范围:
1 ≤ N ≤ 2000 1≤N≤2000 1N2000
0 ≤ A i ≤ 1 0 6 0≤A_i≤10^6 0Ai106

只考虑非降情形,可以证明最优解是存在的,这个较为显然,因为 S S S必然是非负整数,而任意自然数集合一定是有最小值的。

这个问题需要用到Slope Trick的技巧。先给几个定义:
考虑一元实值函数 f ( x ) f(x) f(x),如果 f f f满足以下三条,我们就称其Slope Trickable:
1、 f ( x ) f(x) f(x)连续;
2、 f ( x ) f(x) f(x)可以分解为有限的若干子段,使得每一段里导数 f ′ ( x ) f'(x) f(x)恒等于一个整数;
3、 f ( x ) f(x) f(x)下凸,即 f ′ ( x ) f'(x) f(x)单调增(严格的下凸函数定义并不等价导函数单调增,甚至可以导数不存在。这里不需要细究)。

不妨称这种函数为“好函数”。最经典的好函数是 ∣ x − a ∣ |x-a| xa,其满足上面的三个条件。容易证明,若干个好函数之和依然是好函数。

回到题目。设 f i ( x ) f_i(x) fi(x)是只考虑 a 1 ∼ i a_{1\sim i} a1i的情况下,并且 a i ≤ x a_i\le x aix,求得的 s s s最小值。令 f 0 ( x ) = 0 f_0(x)=0 f0(x)=0。设 g i ( x ) g_i(x) gi(x)也是只考虑 a 1 ∼ i a_{1\sim i} a1i的情况下,并且 a i = x a_i=x ai=x,求得的 s s s最小值。显然有: { f i ( x ) = min ⁡ { g i ( t ) : t ≤ x } g i ( x ) = f i − 1 ( x ) + ∣ x − a i ∣ \begin{cases}f_i(x)=\min\{g_i(t): t\le x\} \\ g_i(x)=f_{i-1}(x)+|x-a_i|\end{cases} {fi(x)=min{gi(t):tx}gi(x)=fi1(x)+xai我们证明 f i ( x ) f_i(x) fi(x)也是好函数。可以用数学归纳法。 f 0 f_0 f0是好函数。假设对于 i ≤ k − 1 i\le k-1 ik1 f i f_i fi是好函数,那么可以知道 g i g_i gi也是好函数(好函数之和依然是好函数)。只需要证明如果 g g g是好函数,则 h ( x ) = min ⁡ { g ( t ) : t ≤ x } h(x)=\min\{g(t):t\le x\} h(x)=min{g(t):tx}也是好函数。直观理解是很显然的,其实 h h h满足,当 g g g单调下降的时候, h = g h=g h=g,当 g g g开始不下降的时候, h h h把那一段开始“拉平”,即斜率拉平为 0 0 0。综上 f i f_i fi是好函数。我们定义 f i f_i fi的拐点多重集(下面简称“拐点集”),为其所有导数从左向右增加 1 1 1的点,如果某个点处导数增加 k k k,则将该点计入 k k k次。例如 ∣ x − 1 ∣ |x-1| x1∣的拐点集为 { 1 , 1 } \{1,1\} {1,1},因为在 1 1 1的位置斜率由 − 1 → 1 -1\to 1 11,变了 2 2 2,所以 1 1 1出现两次。

s i = ∣ a 1 − b 1 ∣ + ∣ a 2 − b 2 ∣ + ⋅ ⋅ ⋅ + ∣ a i − b i ∣ s_i=|a_1−b_1|+|a_2−b_2|+···+|a_i−b_i| si=a1b1+a2b2+⋅⋅⋅+aibi,那么 s i s_i si能取到的最小值即为 t i = lim ⁡ x → + ∞ f i ( x ) t_i=\lim_{x\to +\infty}f_i(x) ti=limx+fi(x),所以我们本质上只需要每次求出 t i t_i ti即可,最后返回 s = t n s=t_n s=tn。注意到 f i − 1 f_{i-1} fi1右边充分远处导数一定为 0 0 0 g i g_i gi右边充分远处导数一定为 1 1 1。考虑 f i − 1 f_{i-1} fi1的最右边的不可导点(即 t i − 1 t_{i-1} ti1),分两种情况:
1、如果 a i ≥ t i − 1 a_i\ge t_{i-1} aiti1,则 t i = t i − 1 t_i=t_{i-1} ti=ti1,比较显然。那么 f i f_i fi的拐点集即在 f i − 1 f_{i-1} fi1的拐点集中加上 a i a_i ai即可;
2、如果 a i < t i − 1 a_iai<ti1,由于是加上了 ∣ x − a i ∣ |x-a_i| xai这个函数,这个时候 t i t_i ti的增加值其实就是 t i − 1 − a i t_{i-1}-a_i ti1ai。那么 f i f_i fi的拐点集中, f i − 1 f_{i-1} fi1的最大拐点实际上拐的次数会少 1 1 1(因为 ∣ x − a i ∣ |x-a_i| xai在那个点的斜率为 1 1 1),所以要将这个拐点从 f i − 1 f_{i-1} fi1拐点集中删除一次;而在 ( a i , t i − 1 ) (a_i,t_{i-1}) (ai,ti1)这个区间内的拐点,次数是不变的,因为此区间内斜率都是增加 1 1 1;然后 a i a_i ai的拐点次数增加 2 2 2 ( − ∞ , a i ) (-\infty, a_i) (,ai)里的拐点次数也是不变,因为斜率都是减 1 1 1

所以具体算法就是用一个最大堆维护拐点集,以求 s i s_i si。代码如下:

#include 
#include 
#include 
using namespace std;

const int N = 2010;
int n, a[N];

int solve() {
  int res = 0;
  priority_queue<int> heap;
  for (int i = 1; i <= n; i++) {
    heap.push(a[i]);
    if (a[i] < heap.top()) {
      res += heap.top() - a[i];
      heap.pop();
      // a[i]拐点次数需要增加2,所以这里要额外增加1次
      heap.push(a[i]);
    }
  }
  return res;
}

int main() {
  scanf("%d", &n);
  for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
  int res = solve();
  reverse(a + 1, a + 1 + n);
  printf("%d\n", min(res, solve()));
}

时间复杂度 O ( n log ⁡ n ) O(n\log n) O(nlogn),空间 O ( n ) O(n) O(n)

你可能感兴趣的:(AC,数学,算法,c++,数据结构)