算法分析与设计 - 作业7

问题一

有一系列数,假设 L-序列是其中长度为 L 的连续子序列,对于每个 L-序列有一个最小值,称之为 L-序列的波谷。设计算法策略求原序列中的最大波谷(max(min(L-序列))。

可能更好看的题面:给定一长度为 \(n\) 的整数列 \(a\),给定参数 \(L(1\le l\le n)\),求:

\[\max_{1\le i\le n - L + 1} \left(\min_{i\le j\le i+L-1} a_j \right) \]

解法一

考虑枚举答案的可能值 \(k\),检查最终答案是否不小于 \(k\)

等价于检查原数列中是否存在连续 \(L\) 个位置均不小于 \(k\),枚举所有可能的 \(n-L+1\) 个 L-序列检查其中不小于 \(k\) 的数的数量即可,若存在这样的 L-序列则说明该答案可能值合法。对于所有合法的答案的可能值取最大值即为最终答案。

显然答案一定为数列中的数,则需要枚举的答案的可能值为 \(O(n)\) 级别。每次检查需要枚举的区间数量为 \(O(n-L)\) 级别,则总时间复杂度 \(O(n(n-L))\) 级别。

//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e6 + 10;
const int kInf = 1e9 + 2077;
//=============================================================
int n, m, a[kN];
int ans;
std::vector <std::pair <int, int> > intervals;
//=============================================================
bool check(int lim_ ) {
  int cnt = 0, flag = 0;
  for (int i = 1; i < m; ++ i) {
    if (a[i] >= lim_) ++ cnt;
  } 
  for (int l = 1, r = m; r <= n; ++ l, ++ r) {
    if (a[r] >= lim_) ++ cnt;
    if (cnt == m) {
      if (!flag) intervals.clear();
      flag = 1;
      intervals.push_back(std::make_pair(l, r));
    }
    if (a[l] >= lim_) -- cnt;
  }
  return flag;
}
//=============================================================
int main() {
  //freopen("1.txt", "r", stdin);
  std::ios::sync_with_stdio(0), std::cin.tie(0);
  std::cin >> n >> m;
  for (int i = 1; i <= n; ++ i) std::cin >> a[i];
  for (int i = 1; i <= n; ++ i) {
    if (a[i] <= ans) continue;
    if (check(a[i])) ans = std::max(ans, a[i]);
  }
  std::cout << ans << "\n";
  for (auto i: intervals) {
    std::cout << i.first << " " << i.second << "\n";
  }
  return 0;
}

解法二

对于解法一,发现当最终答案为 \(k'\) 时,对于所有 \(k\le k'\),在上述检查过程中均可判定为合法的答案可能值;而对于所有 \(k>k'\) 在上述检查过程中均判断为非法。

即在所有的答案的可能值 \(v\) 中,存在阈值 \(k'\),使得所有 \(v\le k'\) 均可判定为合法,\(v>k'\) 均判定为非法,最终答案即为该阈值。发现存在数值上的单调性,考虑通过二分法求得阈值进而求得最终答案。具体地,考虑在二分法中维护当前二分区间 \([l, r]\)

  • 初始化 \(l = -\infin\)\(r = +\infin\)
  • 不断执行下列过程直至 \(l>r\)(待检查区间变为空集):
    • 求得当前区间的中点 \(m = \frac{l+r}{2}\),检查 \(m\) 是否为合法的答案可能值。
    • \(m\) 合法,说明所有 \(v\le m\) 均为合法的答案可能值,于是令 \(l=m+1\) 表示不需要再检查它们的合法性,令最终答案 \(\operatorname{answer} = m\)
    • \(m\) 非法,说明所有 \(v\ge m\) 均为非法的答案可能值,于是令 \(r = m - 1\) 表示不需要再检查它们的合法性。
  • 完成上述二分过程后,最终结果即为 \(\operatorname{answer}\)

发现上述过程至少有一次检查成功,且可保证一定检查到了阈值 \(k'\),且阈值一定为 \(l-1\)(当检查 \(m\) 合法时会令 \(l=m+1\)),正确性得到了保证。

每次检查时间复杂度同样为 \(O(n-L)\),但需要检查的答案可能值变为 \(O(\log (r - l))\) 级别。记答案可能值值域大小为 \(v\),则总时间复杂度为 \(O((n-L)\log v)\) 级别。

//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e6 + 10;
const int kInf = 1e9 + 2077;
//=============================================================
int n, m, a[kN];
int ans;
std::vector <std::pair <int, int> > intervals;
//=============================================================
bool check(int lim_ ) {
  int cnt = 0, flag = 0;
  for (int i = 1; i < m; ++ i) {
    if (a[i] >= lim_) ++ cnt;
  } 
  for (int l = 1, r = m; r <= n; ++ l, ++ r) {
    if (a[r] >= lim_) ++ cnt;
    if (cnt == m) {
      if (!flag) intervals.clear();
      flag = 1;
      intervals.push_back(std::make_pair(l, r));
    }
    if (a[l] >= lim_) -- cnt;
  }
  return flag;
}
//=============================================================
int main() {
  //freopen("1.txt", "r", stdin);
  std::ios::sync_with_stdio(0), std::cin.tie(0);
  std::cin >> n >> m;
  for (int i = 1; i <= n; ++ i) std::cin >> a[i];

  for (int l = -1e9, r = 1e9; l <= r; ) {
    int mid = (l + r) / 2;
    if (check(mid)) {
      ans = mid;
      l = mid + 1;
    } else {
      r = mid - 1;
    }
  }
  std::cout << ans << "\n";
  for (auto i: intervals) {
    std::cout << i.first << " " << i.second << "\n";
  }
  return 0;
}

这种基于答案的合法性存在阈值,从而通过二分法找到阈值不断缩小答案可能值范围的方法叫做二分答案。一般用来解决此类满足答案的合法性存在阈值的问题,比如令最小值最大或是令最大值最小的最优化问题(比如此题),可以通过二分答案转化为求令答案的可能值合法的最大/最小的阈值。

可以将最优化问题转化为判定某个值是否合法判定性问题,一般可以大大降低实现算法的思维难度。

一点例题:

解法三

解法一二太绕圈子了!考虑直接求得所有 L-序列中的波谷,再直接对它们取最大值即为答案。那么如何求得所有 L-序列中的波谷?

这是一个被称作“滑动窗口”的经典问题:该问题可以形象地看做有一个长度为 \(L\) 的窗口,初始时该窗口位于数列 \(a\) 的开头且覆盖了 \(a_1\sim a_L\)。接下来将不断地将该窗口向右移动一个位置,每次移动时都需要求得当前窗口中的最大值/最小值,直至移动到数列的尾部。

我们考虑基于一个实际情境解决该经典问题:

你是校队的教练,每一年校队都会来 1 位新选手,每位选手都有一个能力值。每年都会举办一场比赛,要求只能近四年内入学的选手参赛,且只有一个名额
假设每位选手进入校队后能力值不变,且每年会选择能力值最高的选手参赛。现在已知之后若干年的校队新成员名单,求每年应派出哪位选手参赛。

一个显然的想法是,如果在某年入学了一位很强的选手,那么在他之前入学的所有比他弱的选手这一年,甚至直至毕业都不会有机会参赛了,于是可以直接劝退他。一个人比你小还比你强还和你一样努力,那还学个集贸啊直接重开了算了哈哈

把可怜的老东西劝退之后,发现此时校队内的选手均是有潜在的参赛可能的,且按入学年份升序排序时,能力值一定是递减的(老东西凭实力优势还可以打到退役,老东西退役之后才能换新人顶上去)。则此时校队中入学年份最早的也是能力值最大的,也即这一年应当选出的参赛选手。在某年比赛结束后,把退役的老东西再从校队踢出去后,发现校队内的选手仍然是满足上述性质的。

基于上述想法,我们可以很方便地维护当前校队内有潜在参赛可能的选手:

  • 考虑按照入学年份递增枚举选手。
  • 每年入学时:按照入学年份递减(也即能力递减)枚举此时校队内的选手,把能力不如新选手的老东西劝退。
  • 选择选手参赛时:选择此时入学年份最早(也即能力值最高)的选手参赛。
  • 比赛结束时:检查入学年份最早的选手是否退役,若退役则踢走。

仅需一个支持在尾部添加元素,头部查询删除元素的数据结构——队列,即可实现上述过程。因为该队列中的元素时刻存在单调性(在本问题中选手按入学年份升序时能力值递减),因此又称单调队列,常用于解决此类元素是否会对问题产生贡献与单调性有关的问题。


回到此题,以下为单调队列的实现。删除元素采用了懒惰删除(在查询时才将不合法的元素删除),算法竞赛风格的代码如下:

添加元素:

int n, m, a[kN];
int head, tail, q[kN]; //队列头,队列尾,队列

void insert(int pos_) { //将位置为 pos_ 的元素插入队列
  while (head <= tail && a[pos_] <= a[q[tail]]) -- tail; //不断删除队列尾部的权值大于 a[pos_] 的元素
  q[++ tail] = pos_; //插入该元素
}

查询元素:

int query(int pos_) { //查询当滑动窗口尾部在 pos_ 位置时窗口内的最小值
  while (head <= tail && q[head] + m - 1 < pos_) ++ head; //删除队列头的非法元素(不在该窗口内)
  return q[head]; //返回队列头的元素
}

主函数内移动窗口的过程:

for (int i = 1; i <= n; ++ i) {
  insert(i); //插入位置 i
  int ret = a[query(i)]; //查询窗口内的最小值
}

通过上述过程即可求得所有 L-序列中的波谷,取其中最大值即为答案。

在上述过程中每个元素均只会入队一次,出队一次,则对队列的操作仅有 \(O(n)\) 级别,则总时间复杂度 \(O(n)\) 级别。

//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e6 + 10;
const int kInf = 1e9 + 2077;
//=============================================================
int n, m, a[kN];
int head, tail, q[kN];
int ans;
std::vector <std::pair<int,int> > intervals;
//=============================================================
void clear() {
  head = 1, tail = 0;
}
void insert(int pos_) {
  while (head <= tail && a[pos_] <= a[q[tail]]) -- tail;
  q[++ tail] = pos_;
}
int query(int pos_) {
  while (head <= tail && q[head] + m - 1 < pos_) ++ head;
  return q[head];
}
//=============================================================
int main() {
  //freopen("1.txt", "r", stdin);
  std::ios::sync_with_stdio(0), std::cin.tie(0);
  std::cin >> n >> m;
  for (int i = 1; i <= n; ++ i) std::cin >> a[i];

  clear();
  ans = -kInf;
  for (int i = 1; i <= n; ++ i) {
    insert(i);
    if (i >= m) {
      int ret = a[query(i)];
      if (ret >= ans) {
        if (ret > ans) ans = ret, intervals.clear();
        intervals.push_back(std::make_pair(i - m + 1, i));
      } 
    }
  }
  std::cout << ans << "\n";
  for (auto i: intervals) {
    std::cout << i.first << " " << i.second << "\n";
  }
  return 0;
}

解法四

与解法一类似地,考虑枚举答案的可能值,并检查该可能值是否合法。

但是考虑不枚举权值,而是枚举某个位置 \(i(1\le i\le n)\),检查它是否可以作为某个 L-序列的波谷,仅需检查是否存在一个区间 \([x, y]\),使得 \(x\le i\le y\),且对于区间中的元素 \(a_j(x\le j\le y)\),均有 \(a_j\ge a_i\)

考虑对于每个位置 \(i(1\le i\le n)\),预处理出 \(l_i\) 表示元素 \(a_i\) 向左第一个比 \(a_i\)的元素位置,若这样的位置不存在这令 \(l_i=0\)\(r_i\) 表示向右第一个比 \(a_i\)的元素的位置,若不存在则令 \(r_i=n + 1\)。即有:

\[\begin{aligned} S_i = \{ j | (1\le j<i) \land (a_j > a_i)\},\ l_i = \begin{cases} \max S_i &(S_i \not= \varnothing) \\ 0 &\text{otherwise} \end{cases}\\ T_i = \{ j | (i<j\le n) \land (a_j > a_i)\},\ r_i = \begin{cases} \min T_i &(T_i \not= \varnothing) \\ n+1 &\text{otherwise} \end{cases} \end{aligned}\]

预处理 \(l_i, r_i\) 后即可很方便地得到在检查过程中所需区间。对于所有区间 \([x, y] (l_i < x \le i \le y < r_i)\),显然位置 \(i\) 被包含在了该区间中,且在该区间中没有比 \(a_i\) 更小的值。于是仅需检查其中最长的区间的长度是否大于 \(L\),也即是否有 \((r_i - 1) - (l_i + 1) + 1 \ge L\) 即可。

那么如何预处理 \(l_i, r_i\) 呢?这同样是一个经典问题,建议参考:P1901 发射站 - 洛谷,可通过在正序倒序枚举元素时维护一个元素权值递增的栈求得。

每轮预处理时每个元素仅会入栈一次,出栈一次,则预处理时间复杂度为 \(O(n)\) 级别。检查时仅需枚举每个位置 \(O(1)\) 地检查上述式子是否满足即可,则总时间复杂度与解法三同样为 \(O(n)\) 级别。

另外如果要输出所有满足条件的区间的话,需要保证不会枚举到重复的区间。在代码实现时考虑维护了上一次枚举到的合法区间中的波谷的位置 \(\operatorname{last}\),在枚举之后的合法区间时保证区间中不包含 \(\operatorname{last}\),通过钦定当前位置一定是合法区间中最左侧的波谷从而保证不会枚举到重复区间。

//
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 1e6 + 10;
const int kInf = 1e9 + 2077;
//=============================================================
int n, m, a[kN], l[kN], r[kN];
int top, st[kN];
int ans, last_pos;
std::vector <std::pair <int, int> > intervals;
//=============================================================
void clear() {
  top = 0;
}
void Init() {
  clear();
  for (int i = 1; i <= n; ++ i) {
    while (top && a[st[top]] > a[i]) r[st[top]] = i, -- top;
    st[++ top] = i;
  }
  while (top --) r[st[top]] = n + 1;

  clear();
  for (int i = n, j = 1; i; -- i, ++ j) {
    while (top && a[st[top]] > a[i]) l[st[top]] = i, -- top;
    st[++ top] = i;
  }
  while (top --) l[st[top]] = 0;
}
void check(int pos_) {
  if ((r[pos_] - 1) - (l[pos_] + 1) + 1 < m) return ;
  if (a[pos_] < ans) return ;
  if (a[pos_] > ans) ans = a[pos_], intervals.clear();
  
  int x = std::max(l[pos_] + 1, pos_ - m + 1), y = x + m - 1;
  int lim = std::min(r[pos_] - 1, pos_ + m - 1);
  while (y <= lim) {
    if (x > last_pos) intervals.push_back(std::make_pair(x, y));
    ++ x, ++ y;
  }
  last_pos = pos_;
}
//=============================================================
int main() {
  //freopen("1.txt", "r", stdin);
  std::ios::sync_with_stdio(0), std::cin.tie(0);
  std::cin >> n >> m;
  for (int i = 1; i <= n; ++ i) std::cin >> a[i];
  Init();
  ans = last_pos = -kInf;
  for (int i = 1; i <= n; ++ i) check(i);

  std::cout << ans << "\n";
  for (auto i: intervals) {
    std::cout << i.first << " " << i.second << "\n";
  }
  return 0;
}

写在最后

参考:

posted @ 2024-03-31 23:13  Rainycolor  阅读(212)  评论(0)    收藏  举报