最长递增和递减子序列详解(longest increasing subsequence)

本文详细解析了求解最长递增子序列的传统算法及改进算法,并通过实例演示了算法的具体应用过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

对于动态规划问题,往往存在递推解决方法,这个问题也不例外。要求长度为i的序列的Ai{a1,a2,……,ai}最长递增子序列,需要先求出序列Ai-1{a1,a2,……,ai-1}中以各元素(a1,a2,……,ai-1)作为最大元素的最长递增序列,然后把所有这些递增序列与ai比较,如果某个长度为m序列的末尾元素aj(j<i)比ai要小,则将元素ai加入这个递增子序列,得到一个新的长度为m+1的新序列,否则其长度不变,将处理后的所有i个序列的长度进行比较,其中最长的序列就是所求的最长递增子序列。举例说明,对于序列A{35, 36, 39, 3, 15, 27, 6, 42}当处理到第九个元素(27)时,以35, 36, 39, 3, 15, 27, 6为最末元素的最长递增序列分别为
    35
    35,36
    35,36,39
    3
    3,15
    3,15,27
    3,6
当新加入第10个元素42时,这些序列变为
    35,42
    35,36,42
    35,36,39,42,
    3,42
    3,15,42
    3,15,27,42
    3,6,42

这其中最长的递增序列为(35,36,39,42)和(3,15,27,42),所以序列A的最长递增子序列的长度为4,同时在A中长度为4的递增子序列不止一个。

该算法的思想十分简单,如果要得出Ai序列的最长递增子序列,就需要计算出Ai-1的所有元素作为最大元素的最长递增序列,依次递推Ai-2,Ai-3,……,将此过程倒过来,即可得到递推算法,依次推出A1,A2,……,直到推出Ai为止,

代码如下
[cpp]  view plain copy
  1. unsigned int LISS(const int array[], size_t length, int result[])  
  2. {  
  3.     unsigned int i, j, k, max;  
  4.   
  5.     //变长数组参数,C99新特性,用于记录当前各元素作为最大元素的最长递增序列长度  
  6.     unsigned int liss[length];  
  7.   
  8.     //前驱元素数组,记录当前以该元素作为最大元素的递增序列中该元素的前驱节点,用于打印序列用  
  9.     unsigned int pre[length];  
  10.   
  11.     for(i = 0; i < length; ++i)  
  12.     {  
  13.         liss[i] = 1;  
  14.         pre[i] = i;  
  15.     }  
  16.   
  17.     for(i = 1, max = 1, k = 0; i < length; ++i)  
  18.     {  
  19.         //找到以array[i]为最末元素的最长递增子序列  
  20.         for(j = 0; j < i; ++j)  
  21.         {  
  22.             //如果要求非递减子序列只需将array[j] < array[i]改成<=,  
  23.             //如果要求递减子序列只需改为>  
  24.             if(array[j] < array[i] && liss[j] + 1> liss[i])  
  25.             {  
  26.                 liss[i] = liss[j] + 1;  
  27.                 pre[i] = j;  
  28.   
  29.                 //得到当前最长递增子序列的长度,以及该子序列的最末元素的位置  
  30.                 if(max < liss[i])  
  31.                 {  
  32.                     max = liss[i];  
  33.                     k = i;  
  34.                 }  
  35.             }  
  36.         }  
  37.     }  
  38.   
  39.     //输出序列  
  40.     i = max - 1;  
  41.   
  42.     while(pre[k] != k)  
  43.     {  
  44.         result[i--] = array[k];  
  45.         k = pre[k];  
  46.     }  
  47.   
  48.     result[i] = array[k];  
  49.   
  50.     return max;  
  51. }  
该函数计算出长度为length的array的最长递增子序列的长度,作为返回值返回,实际序列保存在result数组中,该函数中使用到了C99变长数组参数特性(这个特性比较赞),不支持C99的同学们可以用malloc来申请函数里面的两个数组变量。函数的时间复杂度为O(nn),下面我们来介绍可以将时间复杂度降为O(nlogn)改进算法。

在基本算法中,我们发现,当需要计算前i个元素的最长递增子序列时,前i-1个元素作为最大元素的各递增序列,无论是长度,还是最大元素值,都毫无规律可循,所以开始计算前i个元素的时候只能遍历前i-1个元素,来找到满足条件的j值,使得aj < ai,且在所有满足条件的j中,以aj作为最大元素的递增子序列最长。有没有更高效的方法,找到这样的元素aj呢,实际是有的,但是需要用到一个新概念。在之前我举的序列例子中,我们会发现,当计算到第10个元素时,前9个元素所形成最长子序列分别为

    35
    35,36
    35,36,39
    3
    3,15
    3,15,27

    3,6

这其中长度为3的子序列有两个,长度为2的子序列有3个,长度为1的子序列2个,所以一个序列,长度为n的递增子序列可能不止一个,但是所有长度为n的子序列中,有一个子序列是比较特殊的,那就是最大元素最小的递增子序列(挺拗口的概念),在上述例子中,序列(3),(3,6),(3,5,27)就满足这样的性质,他们分别是长度为1,2,3的递增子序列中最大元素最小的(截止至处理第10个元素之前),随着元素的不断加入,满足条件的子序列会不断变化。如果将这些子序列按照长度由短到长排列,将他们的最大元素放在一起,形成新序列B{b1,b2,……bj},则序列B满足b1 < b2 < …… <bj。这个关系比较容易说明,假设bxy表示序列A中长度为x的递增序列中的第y个元素,显然,如果在序列B中存在元素bmm > bnn,且m < n则说明子序列Bn的最大元素小于Bm的最大元素,因为序列是严格递增的,所以在递增序列Bn中存在元素bnm < bnn,且从bn0到bnm形成了一个新的长度为m的递增序列,因为bmm > bnn,所以bmm > bnm,这就说明在序列B中还存在一个长度为m,最大元素为bnm < bmm的递增子序列,这与序列的定义,bmm是所有长度为m的递增序列中第m个元素最小的序列不符,所以序列B中的各元素严格递增。发现了如此的一个严格递增的序列,这让我们柳暗花明,可以利用此序列的严格递增性,利用二分查找,找到最大元素刚好小于aj的元素bk,将aj加入这个序列尾部,形成长度为k+1但是最大元素又小于bk+1的新序列,取代之前的bk+1,如果aj比Bn中的所有元素都要大,说明发现了以aj为最大元素,长度为n+1的递增序列,将aj做Bn+1的第n+1个元素。从b1依次递推,就可以在O(nlogn)的时间内找出序列A的最长递增子序列。

理论说明比较枯燥,来看一个例子,以序列{6,7,8,9,10,1,2,3,4,5,6}来说明改进算法的步骤:

程序开始时,最长递增序列长度为1(每个元素都是一个长度为1的递增序列),当处理第2个元素时发现7比最长递增序列6的最大元素还要大,所以将6,7结合生成长度为2的递增序列,说明已经发现了长度为2的递增序列,依次处理,到第5个元素(10),这一过程中B数组的变化过程是

    6
    6,7
    6,7,8
    6,7,8,9
    6,7,8,9,10

开始处理第6个元素是1,查找比1大的最小元素,发现是长度为1的子序列的最大元素6,说明1是最大元素更小的长度为1的递增序列,用1替换6,形成新数组1,7,8,9,10。然后查找比第7个元素(2)大的最小元素,发现7,说明存在长度为2的序列,其末元素2,比7更小,用2替换7,依次执行,直到所有元素处理完毕,生成新的数组1,2,3,4,5,最后将6加入B数组,形成长度为6的最长递增子序列.

这一过程中,B数组的变化过程是

    1,7,8,9,10
    1,2,8,9,10
    1,2,3,9,10
    1,2,3,4,10
    1,2,3,4,5
    1,2,3,4,5,6

当处理第10个元素(5)时,传统算法需要查看9个元素(6,7,8,9,10,1,2,3,4),而改进算法只需要用二分查找数组B中的两个元素(3, 4),可见改进算法还是很阴霸的。

下面是该算法的实现:
[cpp]  view plain copy
  1. unsigned int LISSEx(const int array[], size_t length, int result[])  
  2. {  
  3.     unsigned int i, j, k, l, max;  
  4.   
  5.     //栈数组参数,C99新特性,这里的liss数组与上一个函数意义不同,liss[i]记录长度为i + 1  
  6.     //递增子序列中最大值最小的子序列的最后一个元素(最大元素)在array中的位置  
  7.     unsigned int liss[length];  
  8.   
  9.     //前驱元素数组,用于打印序列  
  10.     unsigned int pre[length];  
  11.   
  12.     liss[0] = 0;  
  13.   
  14.     for(i = 0; i < length; ++i)  
  15.     {  
  16.         pre[i] = i;  
  17.     }  
  18.   
  19.     for(i = 1, max = 1; i < length; ++i)  
  20.     {  
  21.         //找到这样的j使得在满足array[liss[j]] > array[i]条件的所有j中,j最小  
  22.         j = 0, k = max - 1;  
  23.   
  24.         while(k - j > 1)  
  25.         {  
  26.             l = (j + k) / 2;  
  27.   
  28.             if(array[liss[l]] < array[i])  
  29.             {  
  30.                 j = l;  
  31.             }  
  32.             else  
  33.             {  
  34.                 k = l;  
  35.             }  
  36.         }  
  37.   
  38.         if(array[liss[j]] < array[i])  
  39.         {  
  40.             j = k;  
  41.         }  
  42.   
  43.         //array[liss[0]]的值也比array[i]大的情况  
  44.         if(j == 0)  
  45.         {  
  46.             //此处必须加等号,防止array中存在多个相等的最小值时,将最小值填充到liss[1]位置  
  47.             if(array[liss[0]] >= array[i])  
  48.             {  
  49.                 liss[0] = i;  
  50.                 continue;  
  51.             }  
  52.         }  
  53.   
  54.                 //array[liss[max -1]]的值比array[i]小的情况  
  55.                 if(j == max - 1)  
  56.         {  
  57.             if(array[liss[j]] < array[i])  
  58.             {  
  59.                 pre[i] = liss[j];  
  60.                 liss[max++] = i;  
  61.                 continue;  
  62.             }  
  63.         }  
  64.   
  65.         pre[i] = liss[j - 1];  
  66.         liss[j] = i;  
  67.     }  
  68.   
  69.     //输出递增子序列  
  70.     i = max - 1;  
  71.     k = liss[max - 1];  
  72.   
  73.     while(pre[k] != k)  
  74.     {  
  75.         result[i--] = array[k];  
  76.         k = pre[k];  
  77.     }  
  78.   
  79.     result[i] = array[k];  
  80.   
  81.     return max;  
  82. }  
这个算法的思想可以算得上巧妙,在时间复杂度上提升明显,但是同时在实现时也比通俗算法多了好些坑,这里说明一下:
  • 算法中为了获得实际的序列,数组B中保存的不是长度为j的递增序列的最大元素的最小值,而是该值在输入数组A中的位置,如果只想求出最长递增子序列的长度,则B数组可以直接保存满足条件元素的值
  • 二分查找的结果,我们的目的是找到这样的一个j,使满足A[B[j]] > A[i]的所有j中,j取得最小值,但是在二分查找的时候可能会发生两种特殊情况,B数组的所有元素都不小于A[i],B数组的所有元素都比A[i]小,对于这两中情况需要专门处理
  • 对于B中所有元素都不小于A[i]的情况,要将A[i]更新到B[0]的位置
  • 对于B中所有元素都小于A[i]的情况,要将更新到B[max]的位置,同时将max值增加1,说明找到了比当前最长的递增序列更长的结果
  • 对于其他情况,在更新新节点的前驱节点时,要注意,当前元素的前驱节点是B[j-1],而不是pre[B[j]],这点要格外留意,后者看似有道理,但实际上在之前的更新中可能已经被变更过。

性能比较:长度为5000的随机数组,在我的机器上,改进算法的速度提升将近200倍,可见算法改进在程序性能表现中的重要性。不过传统算法也并非毫无价值,

首先,传统算法可以用来验证改进算法的正确性。二分搜索中的不确定性还是相当让人头痛的。其次,如果要求最长非递减子序列,最长递减子序列等等,传统算法改起来非常的直观(已经注释说明),而改进算法,最起码我没有一眼看出来如何一下就能改好。

目前我搜到的网上的有关此改进算法,在二分搜索满足条件的节点时,聊聊几笔,就完成了功能,但是我按照那种写法无一例外都遇到了某种类型的序列无法处理的情况,不知是否是我在理解算法方面出现偏差。

后继,研究完这个问题之后产生了两个遗留问题,暂时没有答案,和大家分享一下
  • 对于一个序列A,最长递增子序列可能不止一个,传统算法找到的是所有递增子序列中,最大值下标最小(最早出现)的递增子序列,而改进算法找到的是最大值最小的递增子序列,那么改进算法所找到的递增子序列,是不是所有最长递增子序列中各元素合最小的一个呢,我感觉很可能是,但是还没想出怎么证明。
  • 对于元素互不相同的随机数序列A,他的最长递增子序列的数学期望是多少呢?
<think>我们需要实现一个函数`findLIS`,它接受一个整数数组`a`,并返回最长递增子序列Longest Increasing Subsequence, LIS)的长度。 注意:子序列不要求连续,但必须保持原数组中的相对顺序。 常见的解法有动态规划贪心+二分查找。动态规划的时间复杂度是O(n^2),而贪心+二分查找可以将时间复杂度优化到O(n log n)。 这里我们使用贪心+二分查找的方法,因为它在处理较大数据时更高效。 思路: 1. 我们维护一个数组`tail`,其中`tail[i]`表示长度为i+1的递增子序列的最小末尾值(即最后一个元素的值)。 2. 遍历输入数组`a`的每个元素`x`: - 如果`x`大于`tail`数组中的所有元素(即大于最后一个元素),则将`x`添加到`tail`的末尾,并增加当前LIS的长度。 - 否则,在`tail`数组中找到第一个大于等于`x`的元素,并用`x`替换它。这样做的目的是为了让后续可能出现的更长的递增子序列有更大的机会。 3. 最后,`tail`的长度就是最长递增子序列长度。 注意:这个算法只能得到最长递增子序列长度,如果要得到具体的子序列,需要额外的记录。但题目只要求长度,所以我们只返回长度。 由于在`tail`数组中查找插入位置时,`tail`数组是有序的(递增),所以可以使用二分查找。 步骤: 初始化一个空数组`tail`。 遍历数组`a`的每个元素`x`: 使用二分查找在`tail`中找到第一个大于等于`x`的元素的位置。 如果找不到(即`x`比`tail`中所有元素都大),则`x`添加到`tail`末尾。 否则,用`x`替换找到的位置上的元素。 最后,`tail`的长度就是LIS的长度。 例如:a = [8, 24, 7, 35, 23, 52, 39, 64, 78] 初始化:tail = [] 8 -> tail = [8] 24 -> 大于8,添加到tail: [8,24] 7 -> 替换8: [7,24] 35 -> 大于24,添加: [7,24,35] 23 -> 替换24: [7,23,35] 52 -> 添加: [7,23,35,52] 39 -> 替换52: [7,23,35,39] 64 -> 添加: [7,23,35,39,64] 78 -> 添加: [7,23,35,39,64,78] 所以长度为6。 注意:这里`tail`数组中的序列并不一定是真正的LIS(比如最后tail数组是[7,23,35,39,64,78]),但长度是正确的。 代码实现:</think>以下是一个使用动态规划与二分查找优化实现的 MATLAB 函数,用于计算最长递增子序列(LIS)的长度: ```matlab function b = findLIS(a) % 寻找最长递增子序列长度 % 输入: a - 输入数组 % 输出: b - 最长递增子序列长度 n = length(a); if n == 0 b = 0; return; end % 初始化动态规划数组 dp = zeros(1, n); % dp[i] 表示以 a[i] 结尾的 LIS 长度 dp(1) = 1; % 初始化辅助数组(用于二分查找) tail = zeros(1, n); % tail[i] 存储长度为 i+1 的递增子序列的最小末尾值 tail(1) = a(1); % 初始长度为1的子序列末尾值 len = 1; % 当前已知的最大递增子序列长度 % 遍历数组元素 for i = 2:n % 如果当前元素大于tail中最后一个元素(最大末尾值) if a(i) > tail(len) len = len + 1; tail(len) = a(i); dp(i) = len; % 如果当前元素小于等于tail中第一个元素 elseif a(i) <= tail(1) tail(1) = a(i); dp(i) = 1; else % 二分查找:在tail[1..len]中找到第一个大于等于a[i]的位置 left = 1; right = len; pos = 0; while left <= right mid = floor((left + right) / 2); if tail(mid) >= a(i) pos = mid; right = mid - 1; else left = mid + 1; end end if pos > 0 tail(pos) = a(i); dp(i) = pos; end end end % 最长递增子序列长度就是最大dp值 b = len; end ``` ### 算法解释: #### 1. 核心思想 使用**贪心策略 + 二分查找**优化传统的动态规划解法: - **贪心策略**:维护一个`tail`数组,其中`tail[i]`表示长度为`i+1`的递增子序列的最小末尾值 - **二分查找**:在`tail`数组中快速定位当前元素的插入位置 #### 2. 关键变量 - `dp`:动态规划数组,`dp[i]`表示以`a[i]`结尾的LIS长度 - `tail`:辅助数组,`tail[len]`存储长度为`len`的LIS的最小末尾值 - `len`:当前找到的最长递增子序列长度 #### 3. 处理逻辑 对于每个元素`a(i)`: ```matlab if a(i) > tail(len) % 情况1:扩展最长子序列 len = len + 1; tail(len) = a(i); dp(i) = len; elseif a(i) <= tail(1) % 情况2:更新最小元素 tail(1) = a(i); dp(i) = 1; else % 情况3:二分查找插入位置 % 二分查找代码... tail(pos) = a(i); % 更新对应位置的末尾值 dp(i) = pos; end ``` #### 4. 二分查找详解 ```matlab left = 1; right = len; pos = 0; while left <= right mid = floor((left + right) / 2); if tail(mid) >= a(i) pos = mid; % 记录可能的位置 right = mid - 1; % 继续向左搜索 else left = mid + 1; % 向右搜索 end end ``` - 查找第一个大于等于`a(i)`的位置 - 时间复杂度:O(log n) ### 时间复杂度分析: - **整体时间复杂度**:O(n log n) - 遍历数组:O(n) - 每个元素的二分查找:O(log n) - **空间复杂度**:O(n)(用于存储dptail数组) ### 测试用例: ```matlab % 测试用例1: 示例输入 x = [8, 24, 7, 35, 23, 52, 39, 64, 78]; fprintf('最长递增子序列长度: %d\n', findLIS(x)); % 输出: 6 % 测试用例2: 空数组 fprintf('空数组长度: %d\n', findLIS([])); % 输出: 0 % 测试用例3: 单元素数组 fprintf('单元素数组长度: %d\n', findLIS([5])); % 输出: 1 % 测试用例4: 全递减序列 fprintf('全递减序列长度: %d\n', findLIS([9,8,7,6,5])); % 输出: 1 % 测试用例5: 全递增序列 fprintf('全递增序列长度: %d\n', findLIS([1,2,3,4,5])); % 输出: 5 % 测试用例6: 随机序列 fprintf('随机序列长度: %d\n', findLIS([3,1,4,1,5,9,2,6,5])); % 输出: 4 ``` ### 算法优势: 1. **高效性**:O(n log n)时间复杂度优于传统动态规划的O() 2. **空间优化**:仅需O(n)额外空间 3. **稳定性**:正确处理各种边界情况(空数组、单元素等) 4. **扩展性**:稍加修改即可输出具体的最长递增子序列 ### 扩展:输出具体LIS序列 ```matlab function [len, lis] = findLISWithSequence(a) % 扩展函数:同时返回LIS长度具体序列 n = length(a); if n == 0 len = 0; lis = []; return; end % 初始化数组 dp = zeros(1, n); tail = zeros(1, n); prev = zeros(1, n); % 记录前驱元素索引 tail(1) = a(1); len = 1; dp(1) = 1; prev(1) = -1; % 起始元素无前驱 for i = 2:n if a(i) > tail(len) len = len + 1; tail(len) = a(i); dp(i) = len; prev(i) = find(tail(len-1) == a, 1, 'last'); % 记录前驱 elseif a(i) <= tail(1) tail(1) = a(i); dp(i) = 1; prev(i) = -1; else % 二分查找(同上)... tail(pos) = a(i); dp(i) = pos; prev(i) = find(tail(pos-1) == a, 1, 'last'); % 记录前驱 end end % 回溯构建LIS lis = zeros(1, len); idx = find(dp == len, 1, 'last'); % 从最后一个元素开始 for j = len:-1:1 lis(j) = a(idx); idx = prev(idx); if idx == -1, break; end end end ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值