【牛客网】dd爱科学 最长非递减子序列 二分查找
题目链接: dd爱科学1.0 牛客竞赛:NC221822
题目描述:
链接:https://ac.nowcoder.com/acm/problem/221822
来源:牛客网题目描述
大科学家dd最近在研究转基因白菜,白菜的基因序列由一串大写英文字母构成,dd经过严谨的推理证明发现,只有当白菜的基因序列呈按位非递减形式时,这株白菜的高附加值将达到最高,于是优秀的dd开始着手修改白菜的基因序列,dd每次修改基因序列的任意位需要的代价是1
dd想知道,修改白菜的基因序列使其高附加值达到最高,所需要的最小代价的是多少。
输入描述:
第一行一个正整数n(1≤n≤1000000)
第二行一个长度为n的字符串,表示所给白菜的基因序列
保证给出字符串中有且仅有大写英文字母
输出描述:
输出一行,表示最小代价
示例1
输入
复制
5
ACEBF
输出
复制
1
说明
改成ACEEF或者ACEFF,都只用改动一个字符,所需代价最小为1
最长非递子序列LIS
- 非递减:序列中每个元素大于等于前一个元素(如
A≤C≤E≤F
、2≤2≤3
)。 - 子序列:从原序列中按原有顺序选取部分元素组成的新序列(元素可间隔,无需连续)。例如原序列
ACEBF
,A、C、F
是子序列,A、B、F
也是子序列,但C、A
不是(顺序颠倒)。
LIS 定义:在原序列的所有非递减子序列中,长度最长的那一个(或多个,LIS 可能不唯一,但长度固定)。
二、实例解析:以基因序列 ACEBF
为例
原序列:A C E B F
(对应字母序数字:0 2 4 1 5
),我们分两步理解 LIS:
步骤 1:暴力列举,直观感受 LIS
先列出原序列的部分非递减子序列,再找出最长的:
- 长度 1:
A
、C
、E
、B
、F
(单个元素都是非递减子序列); - 长度 2:
A,C
、A,E
、A,B
、A,F
、C,E
、C,F
、E,F
、B,F
; - 长度 3:
A,C,E
、A,C,F
、A,E,F
、C,E,F
、A,B,F
; - 长度 4:
A,C,E,F
(满足非递减:A≤C≤E≤F
)、A,C,B,F
(不满足,C>B
)、A,E,B,F
(不满足,E>B
); - 长度 5:无(原序列
A,C,E,B,F
中E>B
,不满足非递减)。
结论:该序列的 LIS 长度为 4,对应的 LIS 可以是 A,C,E,F
(还有其他可能吗?比如 A,C,E,F
是唯一长度 4 的,因为其他组合都会出现 “前大后小” 的断层)。
步骤 2:高效计算 LIS(贪心 + 二分查找)
暴力列举仅适用于短序列,对于 n=1e6
的大规模数据,需用 “贪心 + 二分查找” 算法(O (n log n) 复杂度)。我们用 ACEBF
演示该算法的核心 —— 维护一个「最小结尾元素数组 tails
」:
tails
数组的含义:tails[k]
表示「长度为 k+1
的非递减子序列的最小可能结尾元素」。目的是让子序列的结尾尽可能小,为后续元素留出更多 “延长” 的空间。
算法执行过程(原序列:A(0) → C(2) → E(4) → B(1) → F(5)
)
-
处理第一个元素
A(0)
tails
为空,直接加入A
。tails = [A]
(此时长度 1 的子序列最小结尾是A
)。
-
处理第二个元素
C(2)
C ≥ tails[-1]
(C≥A
),直接追加。tails = [A, C]
(长度 2 的子序列最小结尾是C
)。
-
处理第三个元素
E(4)
E ≥ tails[-1]
(E≥C
),直接追加。tails = [A, C, E]
(长度 3 的子序列最小结尾是E
)。
-
处理第四个元素
B(1)
B < tails[-1]
(B<E
),需在tails
中找第一个大于B
的元素,用B
替换它。- 二分查找
tails [A,C,E]
:第一个大于B
的是C
,替换为B
。 tails = [A, B, E]
(此时长度 2 的子序列最小结尾从C
变为B
,后续元素更易延长)。
-
处理第五个元素
F(5)
F ≥ tails[-1]
(F≥E
),直接追加。tails = [A, B, E, F]
。
最终结果
tails
数组的长度为 4,即 LIS 长度 = 4(与暴力列举一致)。
三、LIS 与 “最小修改代价” 的关联
回到原问题:修改序列为非递减的最小代价 = 序列总长度 - LIS 长度。以 ACEBF
为例:
- 序列总长度
n=5
; - LIS 长度 = 4(无需修改的最长部分);
- 最小代价 = 5 - 4 = 1(只需修改 1 个元素,如将
B
改为E
,得到ACEEF
)。
本质:LIS 是原序列中 “天然符合非递减要求” 的最长片段,保留这部分,修改其余元素即可,修改次数自然最少。
四、拓展例子:LIS 不唯一的情况
原序列:A B C B A F
(数字:0 1 2 1 0 5
)
- 可能的 LIS:
A,B,C,F
(长度 4)、A,B,B,F
(长度 4)、A,B,A,F
(不满足,B>A
)。 - LIS 长度固定为 4,最小代价 = 6 - 4 = 2。
总结
- LIS 是 “非递减 + 子序列 + 最长” 的结合体,长度是核心指标;
- 短序列可暴力列举,长序列需用 “贪心 + 二分查找” 维护
tails
数组高效计算; - 与原问题直接关联:LIS 长度决定了 “无需修改的最大部分”,从而推导出最小修改代价。
之所以是 “ACEEF”,是因为:
- 先找到了最长的 “无需修改” 的子序列
A→C→E→F
; - 定位到唯一需要修改的元素是 “B”;
- 把 “B” 改成符合前后非递减关系的 “E”,就得到了合法序列,且代价最小(仅 1 次修改)。
python 代码实现
为什么用 bisect_right 而非手动二分?
bisect_right 是用 C 语言实现的内置函数,执行效率远高于 Python 手动编写的二分查找循环(尤其是在 n=1e6 这样的大规模场景中),这也是解决超时问题的关键优化之一
def solution():n = int(input())s = input()tails = [] # 存储LISfor ch in s:left = 0right = len(tails) # tail 的当前长度while left < right:mid = left + (right - left) // 2if tails[mid] <= ch:left = mid + 1else:right = midif len(tails) == left:tails.append(ch)else:tails[left] = ch# print(tails)# print(f"最小代价{n-len(tails)}")print(n - len(tails))solution() 运行超时
import sysdef solution():# 用sys.stdin读取输入,处理大规模数据更高效data = sys.stdin.read().split()n = int(data[0])s = data[1]tails = []for ch in s:left = 0right = len(tails)# 二分查找保持不变(核心逻辑正确)while left < right:mid = left + (right - left) // 2if tails[mid] <= ch:left = mid + 1else:right = midif left == len(tails):tails.append(ch)else:tails[left] = chprint(n - len(tails))solution()
‘使用内置函数避免超时
import bisectn = int(input())
s = input().strip()tails = [] # 存储LIS的最小结尾元素for c in s:# 找到tails中第一个大于c的位置(bisect_right返回插入点)idx = bisect.bisect_right(tails, c)if idx == len(tails):# 当前字符可延长LIS,直接追加tails.append(c)else:# 替换为更小的结尾元素,为后续字符留空间tails[idx] = c# 最小代价 = 总长度 - LIS长度
print(n - len(tails))
C++
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
using namespace std;#define N 1000000
char arr[N + 5] = {0};int main()
{int n;cin >> n;string str;cin >> str;arr[0] = '1';for(int i = 0; i < n; i++){char cur = str[i];int len = strlen(arr) - 1;//cout << "i : " << i << " len : " << len << endl;
// if(len == 0)
// {
// arr[1] = cur;
// continue;
// }//std::cout << "arr: " << arr;int left = 0, right = len;while(left < right){int mid = (right - left) / 2 + left + 1;if(arr[mid] <= cur){left = mid;}else{right = mid - 1;}}//std::cout << "left: " << left << "right: " << right << endl;arr[right + 1] = cur;len = strlen(arr);// for(int i = 0; i < len; i++) cout << arr[i] << " ";// cout << endl;}int len = strlen(arr) - 1;cout << n - len;return 0;
}
代码解析
-
核心逻辑:利用
bisect.bisect_right
实现二分查找,高效维护tails
数组(存储 LIS 的最小结尾元素),最终通过n - len(tails)
得到最小修改代价。 -
二分查找的作用:
bisect.bisect_right(tails, c)
返回c
在tails
中第一个大于c
的位置,确保找到替换或追加的正确位置。- 例如处理
ACEBF
中的B
时,tails
此时为['A','C','E']
,bisect_right
找到C
(索引 1)是第一个大于B
的元素,将其替换为B
,tails
变为['A','B','E']
。
-
时间复杂度:遍历字符串的时间为
O(n)
,每次二分查找的时间为O(log k)
(k
为当前tails
长度,最大为n
),整体复杂度为O(n log n)
,可处理n=1e6
的大规模输入。 -
示例验证:对于输入
ACEBF
,tails
最终会变为['A','B','E','F']
(长度 4),因此最小代价为5-4=1
,与预期结果一致。