子序列问题及其算法

子序列问题

前言

笔者曾经参加头条的面试,面试官在算法环节问的就是这个问题,首先问了我连续子序列的情形,之后改为一般子序列。一般面试算法都是这种循序渐进的方式,先给出一个简单情形,接着将问题提升,并不断优化。题目不一定很难,主要考核面试者的应变和分析能力。可惜当时对算法理解还是过于浅显,作为总结写下这篇文章。

连续子列问题

最大子序和

问题描述:

求给定序列的所有连续子序列的最大和

分析

如果一个连续的子序列它的头部和负(即以起点开始的连续子列),则将头部丢去后得到的序列和更大。因此,每当有头部和负时,置总和为0,表示从新开始累计求和,更新总和后也同时更新最大和的值。

代码:

int sum = 0;
int res = -INF;
for (int i = 0; i < N; i++) {
    sum += nums[i];
    res = Math.max(res, sum);
    if (sum < 0)
        sum = 0;
}

有长度限制的子序和

加入长度限制,这里有两种条件。一种是长度不大于m,另一种是长度不小于m。

无论哪种情况,都可以预处理出前缀和,这样可以在\(O(1)\)时间求出某一子段的和。

长度不大于m

相当于一个不大于m的滑动窗口,每次有元素sum[i]将要入队时,求子序列以其作为结尾,则子序列头需要尽可能的小。这个最小值是从大小为m的窗口中选取的,这个问题可以用单调队列维护一个滑动窗口的最小值来实现。

代码:

int head = 0, tail = -1;
int res = 0xcfcfcfcf;
for (int i = 1; i <= n; i++) {
    if (i - q[head] > m)
        head++;
    res = Math.max(res, s[i] - s[q[head]]);
    while (head <= tail && s[i] <= s[q[tail]]) {
        tail--;
    }
    q[++tail] = i;
}

长度不小于m

方法一

扫描二维码关注公众号,回复: 7874942 查看本文章

枚举结束点,则起始点必须满足\(j + m - 1 <= i\),则等价于求下式的值:
\[ \max_{m<=i <n}\{sum_i - \min_{j + m - 1 <= i}(sum_j)\} \]
代码:

for (int i = m; i <= n; i++) {
    int min = INF;
    for (int j = 0; j + m - 1 <= i; j++) 
        min = Math.min(min, s[j]);
    res = Math.max(res, s[i] - min);
}

算法复杂度: \(O(n ^ 2)\)

方法二

方法一中,为了求满足条件的起始点,需要从头开始的扫描,但每当i的指针加1时,\(sum_j\)只多了一个,因此可以动态维护满足起始点的最小值。

代码:

int min = INF;
for (int i = m; i <= n; i++) {
    min = Math.min(min, s[i - m]);
    res = Math.max(res, s[i] - min);
}

不重复子序列

问题描述:

求给定序列不重复子序列的最大长度,即子序列中每个元素只出现一次

分析

当序列中新增添一个元素a时,如果不满足不重复性,则存在和a相同的元素,如果想要子序列以a结尾,则至少起始指针从与a相同元素的后一个开始。

方法

双指针算法,记录两个指针位置\(i,j\)分别表示结束和开始下标。当遇到重复元素时,移动\(j\)直到\(i\)\(j\)的窗口内不含有重复元素。

算法复杂度:\(O(n)\)

代码:

int res = 1;
for (int i = 0, j = 0; i < n; i++) {
    count[nums[i]]++;
    while (count[nums[i]] > 1) {
        count[nums[j]]--;
        j++;
    }
    res = Math.max(res, i - j + 1);
}

这个算法的起始指针是步进的方式,还可以用一个map来存储元素和下标,这样在遇到重复元素时,可以直接跳到合法的最小位置。

上升子序列问题

原问题

问题描述:

求一个给定序列的严格上升子序列的长度

方法:

动态规划

\(dp[i]\)表示以下标\(i\)结尾的子序列的最大长度,显然状态转移需要从下标小于\(i\)且序列结尾小于\(a[i]\)的那些序列中选择。

代码:

for (int i = 1; i < n; i++)
    for (int j = 0; j < i; j++)
        if (a[i] > a[j])
            dp[i] = Math.max(dp[i], dp[j] + 1);

想要求出最大长度只需枚举结尾下标即可,当然也可以在更新dp的同时,也同时维护一个最大长度。

算法复杂度: \(O(n^2)\)

单调队列优化

在前一个问题中发现能否状态转移之和前序列的尾有关,因此可以用一个数组\(last [\ ]\)来存储以每个长度下,最小需要多大才能对接到前子序列。

长度更长的序列是由较短序列构成的因此有: \(last[i] < last[j] \quad when \ i < j\)

\(last\)数组现在成为了单调队列,在新加一个数\(a[ i]\)时,该数可以在\(last\)数组选择一个比它大的第一个数,在长度不变下,如果以\(a[i ]\)更新,结尾变小,更容易被加长。在选择合适的放置位置时,可以用二分。

代码:

queue[0] = 0xcfcfcfcf;
int len = 0;
for (int i = 0; i < n; i++) {
    int l = 0;
    int r = len;
    while (l < r) {
        int mid = (l + r + 1) >> 1;
        if (queue[mid] < a[i])
            l = mid;
        else
            r = mid - 1;
    }
    len = Math.max(len, r + 1);
    queue[r + 1] = a[i];
}

注意这里下标的具体含义,选择合适的二分方式,\(queue[0 ]\)是设置的一个虚拟头节点。

算法复杂度: \(O(n\log n)\)

最大上升子序和

\(dp[i]\)表示以\(i\)结尾上升子序列的最大和,状态转移也是显然的。

代码:

for (int i = 0; i < N; i++) {
    dp[i] = nums[i];
    for (int j = 0; j < i; j++) {
        if (nums[i] > nums[j]) {
            dp[i] = Math.max(dp[i], dp[j] + nums[i]);
        }
    }
}

长度越长不代表子序和越大,所以无法用单调队列优化。

公共子序列问题

最长公共子序列

问题描述:

求字符串a,b的最长公共子序列的长度

方法:

动态规划,\(dp[i][j]\)表示\(a\)的前\(i\)个字符,\(b\)的前\(j\)个字符中的最大公共子序列长度。

状态转移方程
\[ dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) \]
当a[i] 和b[j]相同时,多了一条转移路线。
\[ dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1)\quad if \ a[i] = b[j] \]

代码:

for (int i = 0; i < N; i++)  {
    for (int j = 0; j < M; j++) {
        dp[i + 1][j + 1] = Math.max(dp[i][j + 1], dp[i + 1][j]);
        if (a.charAt(i) == b.charAt(j))
            dp[i + 1][j + 1] = Math.max(dp[i + 1][j + 1], dp[i][j] + 1);
    }
}

最长公共上升子序列

与上一问题相比增加了上升的约束。

解题方式依旧是动态规划,这次\(dp[i][j]\)表示的是\(A\)的前\(i\)个和\(B\)的以\(j\)结尾的公共上升子序列长度最大值。

这里引入了不对称的表示,第一维是前i个,第二维是以j结尾,后者约束更强。

代码:

for (int i = 1; i <= N; i++) {
    for (int j = 1; j <=N; j++) {
        dp[i][j] = dp[i - 1][j];
        if (A[i] == B[j]) {
            dp[i][j] = Math.max(1, dp[i][j]);
            for (int k = 1; k < j; k++) {
                if (B[k] < B[j])
                    dp[i][j] = Math.max(dp[i][j], dp[i - 1][k] + 1);
            }
        }
    }
}

总结

通过上面几个子序列模型,学习了相应的解决办法。往往只是增加了一个约束条件后,问题的求解方式完全不同。

我们用了动态规划,双指针,单调队列优化,希望仔细体会不同问题细微差别,在遇到新的问题学会知识迁移。

猜你喜欢

转载自www.cnblogs.com/yuanwen859/p/11871717.html