最长上升子序列
给定一个包含 个数的数组:。任务是在 中找到最长的严格递增子序列。
形式化地说,我们要寻找最长的下标序列 ,使得
本文将讨论解决这一问题的多种算法。此外,我们还会讨论一些可以归约为该问题的其他问题。
使用动态规划的 解法
动态规划是一种非常通用的技术,可以解决很大一类问题。这里我们将这种技术应用到当前这个具体问题中。
首先,我们只寻找最长递增子序列的长度,之后再学习如何恢复子序列本身。
求长度
为了完成这个任务,我们定义一个数组 ,其中 表示以索引 处的元素结尾的最长递增子序列的长度。
示例
以索引 4 结尾的最长递增子序列是 ,长度为 3;以索引 8 结尾的最长递增子序列可以是 或 ,二者长度都为 5;以索引 9 结尾的最长递增子序列是 ,长度为 2。
我们将逐步计算这个数组:先计算 ,然后计算 ,依此类推。计算完整个数组之后,问题的答案就是数组 中的最大值。
现在假设当前索引为 。也就是说,我们要计算 ,而之前的所有值 都已经知道。那么有两种情况:
-
:所需的子序列只包含元素 。
-
:子序列将以 结尾,而在它之前会有某个数 ,其中 且 。
很容易看出,以 结尾的这个子序列本身必然是以 结尾的最长递增子序列之一。数 只是把这个最长递增子序列再扩展一个数。
因此,我们只需要遍历所有满足 且 的 ,将 添加到以 结尾的最长递增子序列后面,并从所得序列中取最长的那个。以 结尾的最长递增子序列长度为 ,将其扩展一个元素后,长度就是 。
如果把这两种情况结合起来,就得到 的最终表达式:
实现
下面是上述算法的一种实现,它计算最长递增子序列的长度。
int lis(vector<int> const& a) {
int n = a.size();
vector<int> d(n, 1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i])
d[i] = max(d[i], d[j] + 1);
}
}
int ans = d[0];
for (int i = 1; i < n; i++) {
ans = max(ans, d[i]);
}
return ans;
}
恢复子序列
到目前为止,我们只学会了如何求子序列的长度,还没有学习如何找到子序列本身。
为了能够恢复子序列,我们再生成一个辅助数组 ,并在计算数组 的同时计算它。 表示以 结尾的最长递增子序列中倒数第二个元素的索引 。换句话说,索引 就是使 取得最大值时对应的那个索引 。从某种意义上说,这个辅助数组 指向各个元素的“祖先”。
然后,为了得到子序列,我们只需要从具有最大 的索引 开始,沿着这些祖先不断向前,直到得到完整的子序列,也就是说,直到到达满足 的元素。
恢复过程的实现
我们对前面几节中的代码稍作修改。我们将在计算 的同时计算数组 ,然后再计算子序列。
为了方便,我们最初令祖先 。对于满足 的元素,它们的祖先值将保持为 ,这会使恢复子序列稍微方便一些。
vector<int> lis(vector<int> const& a) {
int n = a.size();
vector<int> d(n, 1), p(n, -1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i] && d[i] < d[j] + 1) {
d[i] = d[j] + 1;
p[i] = j;
}
}
}
int ans = d[0], pos = 0;
for (int i = 1; i < n; i++) {
if (d[i] > ans) {
ans = d[i];
pos = i;
}
}
vector<int> subseq;
while (pos != -1) {
subseq.push_back(a[pos]);
pos = p[pos];
}
reverse(subseq.begin(), subseq.end());
return subseq;
}
恢复子序列的另一种方法
也可以不使用辅助数组 来恢复子序列。我们可以简单地重新计算当前的 值,同时观察最大值是如何得到的。
这种方法会使代码稍微长一些,但作为回报,我们可以节省一些内存。
使用动态规划和二分查找的 解法
为了得到更快的解法,我们构造另一种运行时间为 的动态规划方法,然后再把它改进为 。
我们将使用动态规划数组 。这一次, 并不对应元素 ,也不对应数组的某个前缀。 表示长度为 的递增子序列能够以其结尾的最小元素。
最初我们假设 ,而对于其他所有长度,都有 。
我们仍然逐个处理这些数,先处理 ,然后是 ,依此类推,并在每一步维护数组 ,使它始终保持最新状态。
示例
给定数组 ,下面列出了它的所有前缀以及对应的动态规划数组。注意,数组中的值并不总是在末尾发生变化。
当我们处理 时,可以问自己:要把当前的数 写入数组 ,需要满足什么条件?
如果存在一个长度为 、以 结尾的最长递增序列,并且不存在另一个长度为 、以更小的数结尾的最长递增序列,那么我们令 。与前一种方法类似,如果从长度为 的最长递增序列中去掉 ,就会得到另一个长度为 的最长递增序列。
因此,我们希望用数 去扩展一个长度为 的最长递增序列。显然,在所有长度为 的最长递增序列中,以最小元素结尾的那个效果最好,换句话说,就是以元素 结尾的长度为 的序列。
当且仅当 时,存在一个长度为 的最长递增序列,可以用数 对它进行扩展。因此,我们可以遍历每一个长度 ,并通过检查这一条件来判断能否扩展一个长度为 的最长递增序列。
此外,我们还需要检查是否已经找到一个长度为 、且结尾元素更小的最长递增序列。因此,只有当 时,我们才进行更新。
处理完 中的所有元素之后,所求子序列的长度就是满足 的最大 。
使用 作为填充值只是为了方便表述递推关系;实际并不需要存储它们。在下面的实现中,我们只保留那些长度已经真正出现过的项。于是, 在每一步最多只会增加一个元素;访问超过它末尾的位置,就对应于原来的 ,而答案就是它的大小。
int lis(vector<int> const& a) {
vector<int> d;
for (int x : a) {
size_t l = 0;
while (l < d.size() && d[l] < x)
l++;
if (l == d.size())
d.push_back(x);
else
d[l] = x;
}
return d.size();
}
现在我们作出两个重要观察。
- 数组 始终是有序的:对于所有 ,都有 。
这是显然的,因为只要从长度为 的递增子序列中去掉最后一个元素,就会得到一个长度为 、且结尾元素更小的递增子序列。
- 元素 最多只会更新一个 的值。
这一点可以直接从上面的实现中看出。在数组中只可能存在一个位置满足
因此,我们可以使用二分查找,在 的时间内在数组 中找到这个元素。实际上,我们可以直接在数组 中寻找第一个大于等于 的数,然后按照上面的实现方式尝试更新这个元素。
实现
这样就得到了改进后的 实现,它与上面的代码相比,唯一的区别就是寻找位置的方法:
int lis(vector<int> const& a) {
vector<int> d;
for (int x : a) {
auto it = lower_bound(d.begin(), d.end(), x);
if (it == d.end())
d.push_back(x);
else
*it = x;
}
return d.size();
}
恢复子序列
使用这种方法同样可以恢复子序列。一种直接的方法是维护两个辅助数组:一个用于把 中的每个位置映射回它在 中的索引,另一个是“祖先”数组 ,其中保存以 结尾的最优子序列中前一个元素的索引。
不过,我们也可以采用一种更节省内存的方法,只使用一个辅助数组 ,并把它作为算法已经执行的二分查找的副产品记录下来。令 表示 最终位于 中的位置,因此,以 结尾的最长递增子序列长度为 。
现在假设 LIS 的长度为 ,我们从后向前遍历 ,先选择最后一个满足 的元素,再选择它之前最后一个满足 的元素,依此类推,直到 。按照这种方式选出的每个元素,都是前一个已选元素的合法前驱。事实上,假设我们已经选择了满足 的 ,并令 为它之前最后一个满足 的元素。
由于 始终保存最近一次被放到位置 的元素,因此在处理 的那一刻,它等于 。而二分查找之所以把 放到位置 ,正是因为 ,因此 。
按照这种方式收集元素,并在最后将它们反转,就可以得到一个最长递增子序列。
vector<int> lis(vector<int> const& a) {
int n = a.size();
vector<int> d, p(n);
for (int i = 0; i < n; i++) {
auto it = lower_bound(d.begin(), d.end(), a[i]);
p[i] = it - d.begin();
if (it == d.end())
d.push_back(a[i]);
else
*it = a[i];
}
int l = d.size() - 1;
vector<int> subseq;
for (int i = n - 1; i >= 0 && l >= 0; i--) {
if (p[i] == l) {
subseq.push_back(a[i]);
l--;
}
}
reverse(subseq.begin(), subseq.end());
return subseq;
}
使用数据结构的 解法
除了上面这种在 时间内计算最长递增子序列的方法之外,我们也可以采用另一种方法解决这个问题:使用一些简单的数据结构。
回到第一种方法。回忆一下, 是满足 且 的 。
因此,如果我们再定义一个数组 ,使得
那么计算 的问题,就等价于求数组 某个前缀中的最大值:
求一个会发生变化的数组的前缀最大值,是一个标准问题,可以使用多种不同的数据结构来解决。例如,我们可以使用线段树或树状数组。
这种方法显然存在一些缺点:从实现的长度和复杂度来看,这种方法会比使用二分查找的方法更差。此外,如果输入的数 特别大,那么我们就需要使用一些技巧,例如对这些数进行压缩(也就是将它们重新编号为 到 ),或者使用动态线段树(只生成树中重要的分支)。否则,内存消耗会过高。
另一方面,这种方法也有一些优点:使用这种方法时,你不需要考虑动态规划解法中的那些微妙性质。而且,这种方法使我们能够非常容易地将问题推广到更一般的情况(见下文)。
相关问题
下面列出几个与求最长递增子序列密切相关的问题。
最长不下降子序列
实际上,这几乎是同一个问题。只是现在允许在子序列中使用相同的数。
解法本质上也几乎相同。我们只需要改变不等号,并对二分查找稍作修改。
最长递增子序列的数量
我们可以使用前面讨论的第一种方法,无论是 版本,还是使用数据结构的版本。我们只需要额外保存:以各个 值结尾的最长递增子序列可以通过多少种方式得到。
以 结尾的最长递增子序列的构造方法数量,等于所有满足 取得最大值的、以 结尾的最长递增子序列的构造方法数量之和。这样的 可能有多个,因此需要把它们全部相加。
使用线段树时,这种方法同样可以在 的时间内实现。
这个问题不能使用二分查找的方法来解决。
覆盖一个序列所需的最少非递增子序列数量
给定一个包含 个数的数组 ,我们需要用尽可能少的颜色给这些数染色,使得每一种颜色对应的元素构成一个非递增子序列。
为了解决这个问题,我们注意到,所需颜色的最小数量等于最长递增子序列的长度。
证明:我们需要证明这两个问题之间的对偶性。
设 为最长递增子序列的长度, 为构成一个覆盖所需的最少非递增子序列数量。我们需要证明 。
显然,不可能有 ,因为如果我们有 个严格递增的元素,那么其中任意两个元素都不能属于同一个非递增子序列。因此有 。
现在我们用反证法说明 也是不可能的。假设 。那么考虑任意一个由 个非递增子序列组成的最优集合。我们按照下面的方法变换这个集合:只要存在两个这样的子序列,使得第一个子序列的起始位置早于第二个子序列,并且第一个子序列的首元素大于等于第二个子序列的首元素,那么就把第一个子序列的首元素取下来,并把它接到第二个子序列的开头。
经过有限次操作之后,我们仍然有 个子序列,而它们的首元素将构成一个长度为 的递增子序列。由于我们假设 ,于是得到了矛盾。
因此可以得到 。
恢复这些序列:可以使用贪心方法完成将原序列划分成这些子序列的过程。也就是说,从左到右遍历,把当前这个数分配给这样一个子序列:这个子序列的末尾元素是在所有大于等于当前数的末尾元素中最小的那个。
练习题
- ACMSGURU - “North-East”
- Codeforces - LCIS
- Codeforces - Tourist
- SPOJ - DOSA
- SPOJ - HMLIS
- SPOJ - ONEXLIS
- SPOJ - SUPPER
- Topcoder - AutoMarket
- Topcoder - BridgeArrangement
- Topcoder - IntegerSequence
- UVA - Back To Edit Distance
- UVA - Happy Birthday
- UVA - Tiling Up Blocks