最长上升子序列之 DP 转移优化 (树状数组、平衡树 & CDQ 分治)

最近又重新做了下经典的最长上升子序列 LIS 的两个问题,尝试了三种新的方法。

本文记录「经典 LIS 问题」和「有限上升 LIS 问题」的基于动态规划转移优化的三种解法,分别基于树状数组、CDQ 分治 和 平衡树。

关于经典 LIS 问题,本博客已经有三篇文章讲了三种方法
  1. 朴素的 O(n2) DP 解法
  2. 二分 + 贪心的解法、以及 DAG 模型推广
  3. (本文)基于朴素 DP 解法 - 进一步优化 DP 转移

经典 lis 问题

经典的 LIS 问题,描述如下:

在一个数组中找到最长的、元素严格递增的子序列。 并不要求这个序列在原数组中连续,但是其中元素的相对顺序要和在原数组中保持一致。

-- leetcode 300 最长递增子序列

朴素的 DP 思路 (其分析可以参考 LIS DP 解法),伪代码如下。 可以看到目前的动态规划转移时间是 $O(n)$,接下来的思路都是在优化这个 DP 转移。

int dp[n]; dp[0] = 1;

for (int i = 1; i < n; i++) {
    for (int j = 0; j < i; j++)
        if (a[j] < a[i]) dp[i] = max(dp[i], dp[j]+1);
    ans = max(ans, dp[i]);
}
树状数组优化转移

前置知识点:树状数组原理,熟悉的话,可以略过。

对原来的 DP 思路稍做改变,定义原数组 a值域上dp[x], 其含义是,以值 x 结尾的能形成的最长上升子序列的长度

自左向右扫描,可以归纳出递推逻辑:

dp[x] = max(dp[x], dp[v] + 1),其中 v 是已经扫描过的所有严格 < xa[j] 的值。

下面的图可以帮助理解这个递推关系,细节则不再展开讨论。

递推关系也可以写成:

// v 是已经扫描过的 且 < x 的 a[j] 的值
dp[x] = max(dp[v], for each v < x) + 1;

采用树状数组来维护此 dp 数组的区间最值即可:

  1. 查询区间 [1,x-1] 上维护的 dp 最大值
  2. 加一后得到 dp[x],进行单点维护

大概的伪代码如下,其中:

  1. update 为树状数组单点更新函数
  2. ask 为树状数组前缀区间最值查询函数
int mx = max(a); // 最大值
int dp[mx];

for (auto x : a) update(x, ask(x-1) + 1);
ans = ask(mx); // 答案

具体的实现代码见下方链接,其中考虑到输入的原数组的值域过大、有负数等情况,进行了离散化预处理(相关离散化的文章)。

时间复杂度是 $O(n\log({max(a)}))$,如果进行离散化处理的话,也可以说是 $O(n\log{n})$ 的。

经典 LIS 问题 - 树状数组 - 值域 DP 优化 C++ 实现 - github

平衡树解法

和树状数组的思想一样,平衡树的解法也是一种数据结构优化 dp 转移的手段,这里采用 fhq treap [1]

仍然是在优化下面的转移关系:

// v 是已经扫描过的 且 < x 的 a[j] 的值
dp[x] = max(dp[v], for each v < x) + 1;

fhq treap 的 split 操作可以根据一个给定的数值 v,把树分成两个子树, 使得左子树中所有节点的权值全部不大于 v,右子树中所有节点的权值全部大于 v

在每个节点上额外维护两个信息:

  • 以当前节点的权值 val 结尾的 LIS 长度 len,也就是上面说的 dp[val]
  • 当前节点代表的子树中最大的 LIS 长度 mxlen,也就是子树中最大的 dp 值。
fhq treap 中的节点 C++ 定义
struct {
    int l, r;   // 左右孩子节点
    int val;    // BST 的权值, 即 nums 数组中的元素值 x
    int rnd;    // 堆的随机值
    int size;   // 树的大小
    int len;    // 以 val 结尾的 lis 的长度, 也就是 dp[val]
    int mxlen;  // 当前子树中的最大的 lis 长度,也就是子树内最大的 dp[val]
} tr[N];

首先,要解决的问题是,如何维护这两个信息

fhq treap 的核心操作「分裂」和「合并」都是一种不断递归的过程,有一个 pushup 函数穿插其中,原本是用来维护各个子树的大小的。

pushup 是在递归过程中的后序函数,自底向上地通过左右子树维护父树的信息

fhq treap 中的 分裂、合并 和 pushup 函数 C++
void pushup(int p) {
    tr[p].size = tr[tr[p].l].size + tr[tr[p].r].size + 1;
}

// 按给定的 v 值来分裂成两个子树 x 和 y
// 分裂后 x 的所有权值都 <= v,y 的所有权值都 > v
void split(int p, int v, int &x, int &y) {
    if (!p) {
        x = y = 0;
        return;
    }
    if (tr[p].val <= v) {
        x = p;
        split(tr[p].r, v, tr[p].r, y);
    } else {
        y = p;
        split(tr[p].l, v, x, tr[p].l);
    }

    // pushup 是自底向上的穿插
    pushup(p);
}

// 合并两个子树 x 和 y
// 注意要求:x 中的所有权值要不大于 y 中的
int merge(int x, int y) {
    if (!x || !y) return x + y;
    if (tr[x].rnd < tr[y].rnd) {
        tr[x].r = merge(tr[x].r, y);
        pushup(x);
        return x;
    } else {
        tr[y].l = merge(x, tr[y].l);
        pushup(y);
        return y;
    }
}

我们可以借路 pushup 函数来维护 mxlen 字段, 维护过程如下图所示,其中节点上的数字表示 maxlen

pushup 维护 maxlen 字段 - C++ 代码
void pushup(int p) {
    tr[p].size = tr[tr[p].l].size + tr[tr[p].r].size + 1;
    tr[p].mxlen = tr[p].len; // 至少为当前节点的 lis len
    // 再和左右子树比较,取最值
    if (tr[p].l) tr[p].mxlen = max(tr[p].mxlen, tr[tr[p].l].mxlen);
    if (tr[p].r) tr[p].mxlen = max(tr[p].mxlen, tr[tr[p].r].mxlen);
}

下面考虑主过程,可以扫描原数组 nums 的每一项元素值 x

  • x-1 执行分裂后,左子树中所有的权值都严格小于 x

    构造一个询问函数 ask(v),它返回权值不大于 v 的子树上最大的 LIS 长度 mxlen

    ask 函数的 C++ 实现
    int ask(int v) {
        // 先分裂为左子树 x 和右子树 y
        int x, y;
        split(root, v, x, y);
        // 此时 x 的所有权值 val 都不大于 v
        int ans = tr[x].mxlen;
        // 搞完以后,要记得把两个子树再合并回去
        root = merge(x, y);
        return ans;
    }
    
  • 取左子树的 mxlen,再加一,就是以 x 结尾能形成的最长的 LIS 的长度 len。 新建一个节点,插入到 treap 中。

    插入新节点的实现代码如下:

    insert 函数的 C++ 实现
    int newnode(int v, int len) {
        tr[++n].val = v;
        tr[n].rnd = rand();
        tr[n].size = 1;
        tr[n].len = len;
        tr[n].mxlen = len;
        return n;
    }
    
    void insert(int v, int len) {
        int x, y;
        split(root, v, x, y);
        int z = newnode(v, len);
        root = merge(merge(x, z), y);
    }
    
  • 最终的答案就是根节点的 mxlen

所以,最终的求解主流程代码如下,时间复杂度是 $O(n\log{n})$。

FHQ treap;
for (auto x : nums) {
    int mxlen = treap.ask(x - 1);
    treap.insert(x, mxlen + 1);
}
return treap.tr[treap.root].mxlen; // 根上的 mxlen 就是答案

最终的完整实现代码: 经典 LIS 问题 - 平衡树 - 值域 DP 优化 C++ 实现 - github

可以看到,treap 的解法比树状数组的要稍微复杂一点,不过免去了离散化的烦恼。

CDQ 分治优化转移

CDQ 分治的是一种分治思想:

  1. 二分要处理的区间,分成左右两边。
  2. 先递归处理左边。
  3. 然后计算左边对右边的贡献。
  4. 再递归处理右边。

用伪代码来描述这个过程:

void cdq(int a[], int start, int end) {
    int mid = (start + end) >> 1;
    // 递归处理左边
    cdq(a, start, mid);
    // TODO: 计算左边对右边的贡献
    // 再递归处理右边
    cdq(a, mid+1, end);
}

CDQ 分治和归并排序很像,归并求逆序对数量 可以看做一种 CDQ 分治。

现在将考虑如何用 CDQ 分治的思路解决 LIS 问题。

仍然采用经典的 LIS 的 dp 定义方式,即 dp[i] 的含义是以位置 i 结尾的能形成的最长上升子序列的长度。 容易知道,递推关系是:

// j 为 < i 的且满足 a[j] < a[i] 的下标
dp[i] = max(dp[j], for each a[j] < a[i]) + 1;

关键在于考虑,CDQ 分治过程中,如何计算左边对右边的贡献。

二分当前区间后,把左右两边分别排序

此时左边已经递归处理完毕了,也就是左边的任一个 dp[j] 的含义已经是,左边区间内能形成的以 a[j] 结尾的 LIS 的长度

考虑左边对右边每一个 i 的影响:

  1. 找到左边所有小于 a[i] 的元素的下标 j
  2. 取其中 dp 值最大的那个,dp[i] 至少为 max(dp[j]) + 1

大概伪代码是:

for (int i = mid+1; i <= end; i++) {
    int ma = 0;
    for (int j = start; j <= mid; j++)
        if (a[j] < a[i]) ma = max(ma, dp[j]);
    dp[i] = max(dp[i], ma) + 1;
}

但是这段代码的时间复杂度是平方级别的,其实可以优化。

因为左右两段都已经排序好了,所以 没有必要回溯 j

原因在于,下一次计算 i+1 的时候,当前的 j 和其左边都一定小于 a[i+1],因为 i 右移了,值增大了, 所以只需要进一步右移 j 即可,直到不满足 a[j] < a[i] 停止。

代码进一步优化成下面的样子,虽然是两层循环,但是内层并无回溯,所以总体时间复杂度是线性的。

int ma = 0, j = start, i = mid+1;
while (i <= end) {
    while (j <= mid && a[j] < a[i]) ma = max(ma, dp[j++]);
    dp[i] = max(dp[i], ma) + 1;
    i++;
}

由于还没有处理右半段区间,dp[i] 的值只可以说是「至少是」这些,还无法盖棺定论。

再处理右边之前,要提前恢复右边的顺序,因为 LIS 问题是按原顺序而言的, 我们事先排序只是为了计算左边对右边的贡献。恢复顺序可以考虑按下标重新排序。

整个过程和归并排序相似,整体的伪代码如下:

void cdq(int a[], int start, int end) {
    int mid = (start + end) >> 1;
    // 递归处理左边
    cdq(a, start, mid);
    // 左边右边分别排序, 按值排序
    sort(a, start, mid), sort(a, mid+1, end);
    // 计算左边对右边的贡献
    int ma = 0, j = start, i = mid+1;
    while (i <= end) {
        while (j <= mid && a[j] < a[i]) ma = max(ma, dp[j++]);
        dp[i] = max(dp[i], ma) + 1;
        i++;
    }
    // 恢复右边的顺序, 按下标排序
    sort(a, mid+1, end, cmp_i);
    // 再递归处理右边
    cdq(a, mid+1, end);
}

最终 dp 数组中的最大值就是答案。

复杂度分析上,类似归并排序,递归深度 $\log{n}$,每次处理中存在 $n\log{n}$ 排序,所以整体时间复杂度是 $O(n(\log{n})^2)$。

经典 LIS 问题 - CDQ 分治 - C++ 实现 - github

不过,CDQ 分治远没有 LIS 的其他解法简洁,时间复杂度也不是最优,这里权当一种新的解法来给出。

有限上升 LIS 问题

有限上升的意思是说,在经典的 LIS 问题基础上,限制序列的相邻元素之差不超过一个给定的数字 k。 问题来自 leetcode 2407. 最长递增子序列 II

给你一个整数数组 a 和一个整数 k 。找到 a 中满足以下要求的最长子序列:

  1. 子序列 严格递增
  2. 子序列中相邻元素的差值 不超过 k 。

请你返回满足上述要求的 最长子序列 的长度。 子序列 是从一个数组中删除部分元素后,剩余元素不改变顺序得到的数组。

基于前面的讨论,这个问题也可以有「DP + 树状数组优化」和「DP + CDQ分治」两种思路。

DP + 树状数组优化

基于 前面经典 LIS 的树状数组解法的讨论, 有限上升 LIS 问题无非是限制了 DP 转移时的来源值域范围要在一个区间内。

现在的 DP 转移逻辑变成了:

// v 是已经扫描过的 且满足 x-k <= v < x 的 a[j] 的值
dp[x] = max(dp[v], for each x-k <= v < x) + 1;

也就是说,要求 DP 转移来源的值要在区间 [x-k, x-1]

树状数组也可以维护「单点修改、查询区间最值」的,详情可以参考 这篇文章

当然,线段树也是可以的。

伪代码如下,其中 query 是查询区间最值的函数。

需要注意的细节是,要防止 x-k 是负数的情况:

int mx = max(a); // 最大值
int dp[mx];

for (auto x : a) update(x, query(max(x-k, 1), x-1) + 1);
ans = ask(mx); // 答案

树状数组查询区间最值的时间复杂度是 $(\log{n})^2$, 所以此解法的时间复杂度是 $O(n(\log{n})^2)$。

有限上升 LIS 问题 - 树状数组 - 值域 DP 优化 C++ 实现 - github

DP + CDQ 分治

基于 前面讨论的经典 LIS 问题的 CDQ 分治方法,需要变化的地方在于「计算左边对右边的贡献」这一部分。

原本只需要对右边的每一个 i,计算左边满足 a[j] < a[i] 的所有 jmax(dp[j]) 即可, 现在要加一个限定,就是要同时满足 a[i] - a[j] <= k

对于每一个右边的 i,左边参与决策的 j 不能再只维护一个变量了, 而是需要在一个符合条件的窗口内的多个 j 来决策这个最大值。

这是经典的「滑动窗口求最值问题」,经典的解法是采用单调队列,均摊时间 $O(n)$ 。

要维护窗口内最大值,因此采用单调递减队列,也就是 要求队头到队尾始终满足 DP 值严格递减性质

  1. 队尾维护:每加入一个 j 到队尾时,都要提前从队尾弹出比 jDP 值 更小的元素。
  2. 队头维护:因为已排序,队头的元素值更小,一旦不满足 k 的约束,则弹出。
  3. 单调队列的队头是符合条件的最大值。

用伪代码来描述大概是:

deque q; // 填入下标

while (j <= mid && a[j] < a[i]) {
    // 队尾维护:保证单调递减
    while (!q.empty() && dp[q.back()] < dp[j])
        q.pop_back();
    // 加入新的 a[j]
    q.push_back(j++);
    // 队头维护:保证满足 a[i] - a[j] <= k
    while (!q.empty() && a[q.front()] + k < a[i])
        q.pop_front();
}

// 队头的 DP 值最大
dp[i] = max(dp[i], dp[q.front()] + 1);

回头看一下之前 经典 LIS 问题 CDQ 分治解法的代码。 由于我们只是改变了最值的获取方式,在两边都有序的情况下,每次迭代右边的 i 时,仍然无需回溯左边的 j, 而且队列 q 也无需清空。

总体的伪代码如下,虽然有三层循环,但是每个 j 只会入队一次、最多出队一次,而且 j 不会回溯,所以其时间复杂度仍然是线性的

deque q; // 填入下标
int j = start, i = mid+1;

while (i <= end) {
    while (j <= mid && a[j] < a[i]) {
        // 队尾维护:保证单调递减
        while (!q.empty() && a[q.back()] < a[j])
            q.pop_back();
        // 加入新的 a[j]
        q.push_back(j++);
        // 队头维护:保证满足 a[i] - a[j] <= k
        while (!q.empty() && a[q.front()] + k < a[i])
            q.pop_front();
    }
    // 注意再排除下队头不符合条件的情况
    // 因为上面的子循环可能根本不会进入, 但是 a[i] 已经更换到一个新的
    while (!q.empty() && a[q.front()] + k < a[i])
        q.pop_front();

    // 细节没有写出: q empty 的情况
    dp[i] = max(dp[i], dp[q.front()] + 1);
    i++;
}

总的代码框架和经典 LIS 问题的 CDQ 解法代码一致。 整体时间复杂度仍然是 $O(n(\log{n})^2)$

有限上升 LIS 问题 - CDQ 分治 C++ 实现 - github

可以看到 CDQ 分治虽然可以做,但是已经变的很复杂了。在 leetcode 上 虽然可以 AC,但是跑的比树状数组和线段树的解法要慢许多。

(完)


更新记录:

  • 2024/01/08 - 添加经典 LIS 问题的平衡树解法。

相关阅读:

本文原始链接地址: https://writings.sh/post/lis-dp-optimization

王超 ·
微信扫码赞赏
评论 首页 | 归档 | 算法 | 订阅