圆形牛棚
作为当代建筑的爱好者,农夫约翰建造了一个完美圆环形状的新牛棚。
牛棚内部有$n$个房间,围成一个环形,按顺时针编号为$1 \sim n$,所有相邻房间之间的距离均为$1$。
每个房间都既有通向相邻两个房间的门,也有通向牛棚外部的门。
约翰想让第$i$个房间内恰好有$r_{i}$头牛。
为了让奶牛们有序的进入牛棚,他计划打开一个外门,让牛从该门进入。
然后,每头牛顺时针(即当$i<n$时,第$i$个房间只能走到第$i+1$个房间;当$i=n$时,第$i$个房间只能走到第$1$个房间)穿过房间,直到到达合适的房间为止。
约翰希望通过合理选择打开的门,使得所有奶牛的行走距离之和尽可能小(这里只考虑每头牛进入牛棚以后的行走距离)。
请确定他的奶牛需要行走的最小总距离。
输入格式
第一行包含整数$n$。
接下来$n$行,包含$r_{1}$,...,$r_{n}$。
输出格式
输出所有奶牛需要行走的最小总距离。
数据范围
$3 \leq n \leq 1000$,
$1 \leq r_{i} \leq 100$
输入样例:
5 4 7 8 6 4
输出样例:
48
样例解释
最佳方案是让奶牛们从第二个房间进入。
解题思路
题意大概就是,选择一个房间作为入口,则代价就是每个房间牛的数量乘上该房间到入口房间的距离。
例如,如果我们选择$r_{0}$作为入口,则代价大小为$r_{0} \cdot 0 + r_{1} \cdot 1 + r_{2} \cdot 2 + ... + r_{n - 1} \cdot \left( {n - 1} \right)$。
如果我们选择$r_{1}$作为入口,则代价大小为$r_{1} \cdot 0 + r_{2} \cdot 1 + ... + r_{n - 1} \cdot \left( {n - 2} \right) + r_{0} \cdot \left( {n - 1} \right)$。以此类推。
所以我们有个最朴素的做法,就是枚举所有牛棚将其选择作为入口,然后总代价又可以通过$O\left( n \right)$的复杂度算出来,所以整个算法的时间复杂度为$O\left( n^{2} \right)$。
需要注意的是需要对每个牛棚的下标编号进行取模$n$。当枚举到下标$n - 1$时,因为顺时针走动,因此下一个牛棚的下标编号应该为$0$。
AC代码如下:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int N = 1010; 6 7 int a[N]; 8 9 int main() { 10 int n; 11 scanf("%d", &n); 12 for (int i = 0; i < n; i++) { 13 scanf("%d", a + i); 14 } 15 16 int ans = 5e7; 17 for (int i = 0; i < n; i++) { 18 int ret = 0; 19 for (int j = 1; j < n; j++) { 20 ret += a[(i + j) % n] * j; 21 } 22 ans = min(ans, ret); 23 } 24 25 printf("%d", ans); 26 27 return 0; 28 }
下面进行优化,讲讲时间复杂度为$O\left( n \right)$的算法思路和实现。
我们先把$n$个牛棚列出来,同时把每个牛棚到入口的距离列出来。
下面我们定义$P_{0}$为选择$r_{0}$为入口的总代价,即$P_{0} = r_{0} \cdot 0 + r_{1} \cdot 1 + ... + r_{n - 2} \cdot \left( {n - 2} \right) + r_{n - 1} \cdot \left( {n - 1} \right)$。
接下来我们考虑一下$P_{1}$怎么算,也就是怎么用$P_{0}$来表示。可以发现,在上图中,用第$2$行减去第$1$行就可以得到$P_{1} - P_{0}$的表达式,即$$\begin{align*} P_{1} &= P_{0} + \left( {n - 1} \right) \cdot r_{0} - \left( {r_{1} + r_{2} + ... + r_{n - 1}} \right) \\ &= P_{0} + n \cdot r_{0} - \left( {r_{0} + r_{1} + r_{2} + ... + r_{n - 1}} \right) \\ &= P_{0} + n \cdot r_{0} - s \end{align*}$$这里定义$s = r_{0} + r_{1} + ... + r_{n - 1}$。
同理可得$$\begin{align*} P_{2} &= P_{1} + n \cdot r_{1} - s \\ &= P_{0} + n \cdot \left( {r_{0} + r_{1}} \right) - 2s \end{align*}$$
以此类推,我们可以发现$P_{i}$有两种表达式,$$P_{i} = P_{i - 1} + n \cdot r_{i - 1} - s$$$$P_{i} = P_{0} + n \cdot \left( {r_{0}{+ r}_{1}{+ ... + r}_{i - 1}} \right) - i \cdot s$$
其中对于第一种表达式,我们可以发现每一种状态都可以用上一种状态来表示,一共有$n$种状态(选择$n$个不同的牛棚作为入口),然后又因为一定存在其中一种代价最小的状态,因此我们把$n$种状态通过迭代的方式枚举一遍,就可以找到最小的那种状态,这个状态对应的值就是最小代价。
AC代码如下:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int N = 1010; 6 7 int r[N]; 8 9 int main() { 10 int n; 11 scanf("%d", &n); 12 13 int sum = 0, p = 0; 14 for (int i = 0; i < n; i++) { 15 scanf("%d", r + i); 16 sum += r[i]; 17 p += r[i] * i; 18 } 19 20 int ret = 2e9; 21 for (int i = 0; i < n; i++) { 22 p += n * r[i] - sum; 23 ret = min(ret, p); 24 } 25 26 printf("%d", ret); 27 28 return 0; 29 }
接下来我们对第二种表达式进行更深一步的挖掘。
首先在这个表达式中,我们是选择$r_{0}$作为入口的,也就是选择$P_{0}$作为初始状态,事实上我们可以选择任意一个$P_{i}$作为初始状态,因为这是一个环,每一个点的地位都是等同的。也就是说,无论我们选择的是$P_{0}$还是$P_{1}$还是其他作为初始状态,都是可以推出$P_{0} \sim P_{n - 1}$的。
现在我们考虑一下,我们应该怎么选择,使得初始状态就是对应最小代价的那个状态。
现在我们就假设$P_{0}$就是那个最小代价的状态。如果P_{0}是最小代价,那么对于任意一个的$P_{i}$,都应该满足$P_{i} - P_{0} \geq 0$(所有的状态都满足大于等于$0$)。
因为有$P_{i} = P_{0} + n \cdot \left( {r_{0}{+ r}_{1}{+ ... + r}_{i - 1}} \right) - i \cdot s$,这就等价于$n \cdot \left( {r_{0}{+ r}_{1}{+ ... + r}_{i - 1}} \right) - i \cdot s \geq 0$。
进行移项和化简$$\begin{align*} n \cdot \left( {r_{0}{+ r}_{1}{+ ... + r}_{i - 1}} \right) &\geq i \cdot s \\ \frac{r_{0} + r_{1} + ... + r_{i - 1}}{i} &\geq \frac{s}{n} \\ \frac{r_{0} + r_{1} + ... + r_{i - 1}}{i} &\geq \overset{-}{r} \\ \frac{r_{0} + r_{1} + ... + r_{i - 1}}{i} - \overset{-}{r} &\geq 0 \\ \frac{r_{0} + r_{1} + ... + r_{i - 1} - i \cdot \overset{-}{r}}{i} &\geq 0 \\ \frac{\left( {r_{0} - \overset{-}{r}} \right) + \left( {r_{1} - \overset{-}{r}} \right) + ... + \left( {r_{i - 1} - \overset{-}{r}} \right)}{i} &\geq 0 \\ \left( {r_{0} - \overset{-}{r}} \right) + \left( {r_{1} - \overset{-}{r}} \right) + ... + \left( {r_{i - 1} - \overset{-}{r}} \right) &\geq 0 \end{align*}$$上式中,由于$i$大于$0$,所以可以消去。同时定义$\overset{-}{r} = \frac{s}{n}$,即平均值。
这里的初始状态为$P_{0}$,那么对于任意的$i$,上面的表达式中的每一项的和要满足大于等于$0$。更一般的情况,等价于我们要找的初始状态为$k$,满足$P_{k}$就是最小的代价,对于任意的$i$都要满足表达式$\left( {r_{k\% n} - \overset{-}{r}} \right) + \left( {r_{{({k + 1})}\% n} - \overset{-}{r}} \right) + ... + \left( {r_{{({k + i - 1})}\% n} - \overset{-}{r}} \right) \geq 0$。
我们把环变成一条链,线段的长度为环的两倍,也就是$2n$。环上从任意一点走长度为$n$的一段,等价于从线段中前一半长度的区间中找一个点,划出长度为$n$的一段。
对于表达式式$\left( {r_{k\% n} - \overset{-}{r}} \right) + \left( {r_{{({k + 1})}\% n} - \overset{-}{r}} \right) + ... + \left( {r_{{({k + i - 1})}\% n} - \overset{-}{r}} \right) \geq 0$要成立,相当于在这个长度为$n$的区间上,任意一个$r_{i} - \overset{-}{r}$的前缀和都要大于等于$0$。这里记$r_{i} - \overset{-}{r} = {r'}_{i}$,所以这就等价于在这个长度为$n$的区间上,任意一个${r'}_{i}$的前缀和都要大于等于$0$。需要注意的是,现在线段的长度为环的两倍,不需要再取模了。
所以可以在$2n$的区间定义前缀和${s'}_{i} = {r'}_{1} + {r'}_{2} + ... + {r'}_{i}$(前缀和的下标从$1$开始)。等价于在这个长度为$n$区间上任意一个点的前缀和满足${s'}_{i} - {s'}_{k - 1} \geq 0$。
其中,对于点$n$,${s'}_{n} = {r'}_{1} + {r'}_{2} + ... + {r'}_{n} = s - n \cdot \overset{-}{r} = s - s = 0$。
所以只要满足${s'}_{k - 1}$是最小值,那么就可以保证在长度为$n$的区间,${s'}_{i} - {s'}_{k - 1} \geq 0$恒成立。因为在一个集合里面,集合中的任意一个元素减去集合中最小的那个元素,得到的值一定是大于等于$0$的。
现在我们整理一下思路。
首先,我们先把这个环延长为长度为$2n$的线段,然后算一下${r'}_{i}$,接着算前缀和数组,在前缀和数组中找到一个$k-1$满足${s'}_{k-1}$是最小的那个值($k-1$在区间$0 \sim n-1$中),那么我们从$k$开始的,长度为$n$的线段,即以$k$为入口的代价最小。代码思路同上。
AC代码如下:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int N = 2010; 6 7 int r[N]; 8 double s[N]; 9 10 int main() { 11 int n; 12 scanf("%d", &n); 13 14 double sum; 15 for (int i = 1; i <= n; i++) { 16 scanf("%d", r + i); 17 r[n + i] = r[i]; 18 sum += r[i]; 19 } 20 21 double avg = sum / n; 22 for (int i = 1; i <= 2 * n; i++) { 23 s[i] = s[i - 1] + r[i] - avg; 24 } 25 26 int k = 0; 27 for (int i = 0; i < n; i++) { 28 if (s[i] < s[k]) k = i; 29 } 30 k++; 31 32 int ret = 0; 33 for (int i = 0; i < n; i++) { 34 ret += i * r[k + i]; 35 } 36 printf("%d", ret); 37 38 return 0; 39 }
参考资料
AcWing 1843. 圆形牛棚(寒假每日一题2022):https://www.acwing.com/video/3687/