最长递增子序列问题
转自http://blog.youkuaiyun.com/joylnwang/article/details/6766317
一个各公司都喜欢拿来做面试笔试题的经典动态规划问题,互联网上也有很多文章对该问题进行讨论,但是我觉得对该问题的最关键的地方,这些讨论似乎都解释的不很清楚,让人心中不快,所以自己想彻底的搞一搞这个问题,希望能够将这个问题的细节之处都能够说清楚。
对于动态规划问题,往往存在递推解决方法,这个问题也不例外。要求长度为i的序列的Ai{a1,a2,……,ai}最长递增子序列,需要先求出序列Ai-1{a1,a2,……,ai-1}中以各元素(a1,a2,……,ai-1)作为最大元素的最长递增序列,然后把所有这些递增序列与ai比较,如果某个长度为m序列的末尾元素aj(j
unsigned int LISS(const int array[], size_t length, int result[])
{
unsigned int i, j, k, max;
//变长数组参数,C99新特性,用于记录当前各元素作为最大元素的最长递增序列长度
unsigned int liss[length];
//前驱元素数组,记录当前以该元素作为最大元素的递增序列中该元素的前驱节点,用于打印序列用
unsigned int pre[length];
for(i = 0; i < length; ++i)
{
liss[i] = 1;
pre[i] = i;
}
for(i = 1, max = 1, k = 0; i < length; ++i)
{
//找到以array[i]为最末元素的最长递增子序列
for(j = 0; j < i; ++j)
{
//如果要求非递减子序列只需将array[j] < array[i]改成<=,
//如果要求递减子序列只需改为>
if(array[j] < array[i] && liss[j] + 1> liss[i])
{
liss[i] = liss[j] + 1;
pre[i] = j;
//得到当前最长递增子序列的长度,以及该子序列的最末元素的位置
if(max < liss[i])
{
max = liss[i];
k = i;
}
}
}
}
//输出序列
i = max - 1;
while(pre[k] != k)
{
result[i--] = array[k];
k = pre[k];
}
result[i] = array[k];
return max;
}
该函数计算出长度为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 < ……
unsigned int LISSEx(const int array[], size_t length, int result[])
{
unsigned int i, j, k, l, max;
//栈数组参数,C99新特性,这里的liss数组与上一个函数意义不同,liss[i]记录长度为i + 1
//递增子序列中最大值最小的子序列的最后一个元素(最大元素)在array中的位置
unsigned int liss[length];
//前驱元素数组,用于打印序列
unsigned int pre[length];
liss[0] = 0;
for(i = 0; i < length; ++i)
{
pre[i] = i;
}
for(i = 1, max = 1; i < length; ++i)
{
//找到这样的j使得在满足array[liss[j]] > array[i]条件的所有j中,j最小
j = 0, k = max - 1;
while(k - j > 1)
{
l = (j + k) / 2;
if(array[liss[l]] < array[i])
{
j = l;
}
else
{
k = l;
}
}
if(array[liss[j]] < array[i])
{
j = k;
}
//array[liss[0]]的值也比array[i]大的情况
if(j == 0)
{
//此处必须加等号,防止array中存在多个相等的最小值时,将最小值填充到liss[1]位置
if(array[liss[0]] >= array[i])
{
liss[0] = i;
continue;
}
}
//array[liss[max -1]]的值比array[i]小的情况
if(j == max - 1)
{
if(array[liss[j]] < array[i])
{
pre[i] = liss[j];
liss[max++] = i;
continue;
}
}
pre[i] = liss[j - 1];
liss[j] = i;
}
//输出递增子序列
i = max - 1;
k = liss[max - 1];
while(pre[k] != k)
{
result[i--] = array[k];
k = pre[k];
}
result[i] = array[k];
return max;
}
这个算法的思想可以算得上巧妙,在时间复杂度上提升明显,但是同时在实现时也比通俗算法多了好些坑,这里说明一下:
算法中为了获得实际的序列,数组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,他的最长递增子序列的数学期望是多少呢?