第一节、寻找和为定值的两个数
题目:输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。要求时间复杂度是O(n)。如果有多对数字的和等于输入的数字,输出任意一对即可。
例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。
思路如下:
1:直接穷举,从数组中任意选取两个数,判定它们的和是否为输入的那个数字。此举复杂度为O(n^2) 。很显然,我们要寻找效率更高的解法。
2:题目相当于,对每个a[i],查找sum-a[i]是否也在原始序列中,每一次要查找的时间都要花费为O(n),这样下来,最终找到两个数还是需要O(n^2) 的复杂度。那如何提高查找判断的速度呢?
答案是二分查找。二分查找的时间复杂度为O(lg n) ,如果原数组有序,直接二分查找,n个数的总时间为O(n lg n) ;如果原数组无序,则需要先排序后二分,复杂度同样为O(nlgn + nlgn) =O(nlgn) ,空间复杂度为O(1) 。
3:有没有更好的办法呢?可以依据上述思路2的思想,进一步缩小查找时间。如果数组无序,则先在O(nlgn) 时间内排序。
举个例子,如下:
原始序列:1、 2、 4、 7、11、15
用输入数字15减一下各个数,得到对应的序列为:
对应序列:14、13、11、8、4、0
第一个数组以一指针i 从数组最左端开始向右扫描,第二个数组以一指针j 从数组最右端开始向左扫描,谁指的元素小,谁先移动,如果a[*i]=a[*j],就找出这俩个数来了。两端同时查找,时间复杂度瞬间缩短到了O(n),但却同时需要O(n)的空间存储第二个数组。
因此,如果原数组无序,则需要时间O(n +nlgn) =O(nlgn) ;如果原数组有序,则需要时间O(n) 。
4:针对上述思路进一步改进,使空间复杂度由O(n)变为O(1)。如果数组是无序的,则先在O(nlgn) 时间内排序,然后用两个指针 i,j,各自指向数组的首尾两端,令i=0,j=n-1,逐次判断a[i]+a[j]?=sum,如果a[i]+a[j]>sum,则要想办法让a[i]+a[j]的值减小,所以此刻i不动,j--,如果某一刻a[i]+a[j]<sum,则要想办法让a[i]+a[j]的值增大,所以此 刻i++,j不动。所以,数组无序的时候,时间复杂度最终为O(n +nlgn) =O(nlgn),若原数组是有序的,则不需要事先的排序,直接O(n)搞定,且空间复杂度还是O(1) ,此思路是相对于上述所有思路的一种改进。
5:还可以构造hash表,正如编程之美上的所述,给定一个数字,根据hash映射查找另一个数字是否也在数组中,只需用O(1) 的时间,这样的话,总体的算法通上述思路3 一样,也能降到O(n) ,但有个缺陷,就是构造hash额外增加了O(n) 的空间,不过,空间换时间,仍不失为在时间要求较严格的情况下的一种好办法。
所以,要想达到时间O(n) ,空间O(1) 的目标,除非原数组是有序的(指针扫描法),不然,当数组无序的话,就只能先排序,后指针扫描法或二分查找(时间O(nlgn) ,空间O(1) ),或映射或hash(时间O(n) ,空间O(n) )。时间或空间,必须牺牲一个。
二:二分查找
“二分查找可以解决已排序数组的查找问题:只要数组中包含T(即要查找的值),那么通过不断缩小包含T的范围,最终就可以找到它。一开始,范围覆盖整个数组。将数组的中间项与T进行比较,可以排除一半元素,范围缩小一半。就这样反复比较,反复缩小范围,最终就会在数组中找到T,或者确定原以为T所在的范围实际为空。对于包含N个元素的表,整个查找过程大约要经过logN次比较。
多数程序员都觉得只要理解了上面的描述,写出代码就不难了;但事实并非如此。我在贝尔实验室和IBM的时候都出过这道考题。那些专业的程序员有几个小时的时间,可以用他们选择的语言把上面的描述写出来。考试结束后,差不多所有程序员都认为自己写出了正确的程序。于是,我们花了半个钟头来看他们编写的代码经过测试用例验证的结果。几次课,一百多人的结果相差无几:90%的程序员写的程序中有bug(我并不认为没有bug的代码就正确)。
我很惊讶:在足够的时间内,只有大约10%的专业程序员可以把这个小程序写对。但写不对这个小程序的还不止这些人:高德纳在《计算机程序设计的艺术 第3卷 排序和查找》第6.2.1节的“历史与参考文献”部分指出,虽然早在1946年就有人将二分查找的方法公诸于世,但直到1962年才有人写出没有bug的二分查找程序。”——乔恩·本特利,《编程珠玑(第1版)》第35-36页。
二分查找算法的边界,一般来说分两种情况,一种是左闭右开区间,类似于[left, right),一种是左闭右闭区间,类似于[left, right].需要注意的是, 循环体外的初始化条件,与循环体内的迭代步骤, 都必须遵守一致的区间规则,也就是说,如果循环体外初始化时,是以左闭右开区间为边界的,那么循环体内部的迭代也应该如此.如果两者不一致,会造成程序的错误.比如下面就是错误的二分查找算法:
left = 0, right = n;
while (left < right)
{
middle = (left + right) / 2;
if (array[middle] > v)
{
right = middle - 1;
}
else if (array[middle] < v)
{
left = middle + 1;
}
else
{
return middle;
}
}
这个算法的错误在于, 在循环初始化的时候,初始化right=n,也就是采用的是左闭右开区间,而当满足array[middle]> v的条件是, v如果存在的话应该在[left,middle)区间中,但是这里却把right赋值为middle - 1了,这样,如果恰巧middle-1就是查找的元素,而且middle-1=left,那么就会找不到这个元素。
下面给出两个算法, 分别是正确的左闭右闭和左闭右开区间算法,可以与上面的进行比较:
int search2(int array[], int n, int v)
{
int left, right, middle;
left = 0, right = n - 1;
while (left <= right)
{
middle = (left + right) / 2;
if (array[middle] > v)
{
right = middle - 1;
}
else if (array[middle] < v)
{
left = middle + 1;
}
else
{
return middle;
}
}
return -1;
}
int search3(int array[], int n, int v)
{
int left, right, middle;
left = 0, right = n;
while (left < right)
{
middle = (left + right) / 2;
if (array[middle] > v)
{
right = middle;
}
else if (array[middle] < v)
{
left = middle + 1;
}
else
{
return middle;
}
}
return -1;
}
另外:在循环体内,计算中间位置的时候,使用的是这个表达式:
middle = (left + right) / 2;
假如,left与right之和超过了所在类型的表示范围的话,那么middle就不会得到正确的值.所以,更稳妥的做法应该是这样的:
middle = left + (right - left) / 2;
(http://www.cppblog.com/converse/archive/2009/10/05/97905.html)
三:编程求解
输入两个整数 n 和 m,从数列1,2,3.......n 中 随意取几个数,使其和等于 m ,要求将其中所有的可能组合列出来。
分析:用f(m, n)表示从1到n的序列中,和等于m的所有可能的组合,分几种情况:
1:如果m<n,则m+1, m+2,..n这些数不可能出现在任何组合中,所以:
f(m, n) = f(m, m)。
2:如果m=n,则单独的n是组合之一,所以f(m, n) = 。
3:如果m>n,则可以根据组合中是否包含n进行区分,所以:
f(m, n) = 。
所以,该题目的解法可以用递归实现,考虑到要保存中间结果以便打印出最终所有的组合,所以可以借用栈实现,下面的代码直接使用list来模拟栈的操作:
void findsum(int sum, intn)
{
static list<int> indexStack;
if(sum < 1)return;
if(n < 1)return;
if(sum < n)
{
return findsum(sum, sum);
}
else if(sum == n)
{
cout << sum <<"+";
for(list<int>::iteratoriter = indexStack.begin();iter!=indexStack.end();++iter)
{
cout<<*iter<<"+";
}
printf("\b \n");
/*
\b是退格符,显示的时候是将光标退回前一个字符,
但不会删除光标位置的字符,
如果后边有新的字符,将覆盖退回的那个字符,
这与我们在文本编器中按Backspace的效果不一样。 */
return findsum(sum, sum-1);
}
else
{
indexStack.push_front(n);
findsum(sum-n, n-1);
indexStack.pop_front();
findsum(sum, n-1);
}
}
四:最长递增子序列(LongestIncreasing Subsequence,LIS问题)
给定一个长度为n的数组,找出一个最长的单调递增子序列(不一定连续)。 更正式的定义是:
设L=<a1,a2,…,an>是n个不同的实数的序列,L的递增子序列是这样一个子序列Lin=<ak1,ak2,…,akm>,其中k1<k2<…<km且ak1<ak2<…<akm。求最大的m值。
比如数组A 为{10, 11, 12, 13, 1,2, 3, 15},那么最长递增子序列为{10,11,12,13,15}。
1:动态规划(http://blog.chinaunix.net/uid-26548237-id-3757779.html)
对于数组a[n], 假设LIS[i]表示:以a[i]为结尾的,最长递增子序列的长度。
初始情况下,LIS[i]=1,因为最长递增子序列只包含a[i];
如果对于j,0<=j<i,假设LIS[j]已经求解出来了,则可以根据LIS[j]来计算LIS[i]:对于所有j(0<=j<i),如果a[i]>a[j],则LIS[j] + 1的最大值。也就是:
LIS[i]= 。所以,代码如下:
for(i = 0; i<len; i++)
{
LIS[i] = 1;
}
for(i = 0; i < len; i++)
{
for(j = 0; j < i; j++)
{
if(a[i] > a[j] &&LIS[i]<LIS[j]+1)
{
LIS[i] = LIS[j]+1;
}
}
}
max = LIS[0];
for(i = 1; i < len; i++)
{
printf("the LIS[%d] is %d\n", i, LIS[i]);
if(max < LIS[i])
{
max = LIS[i];
}
}
2:二分查找法(http://www.felix021.com/blog/read.php?1587)
假设存在一个序列d[1..9]= 2 1 5 3 6 4 8 9 7,可以看出来它的LIS长度为5。
下面一步一步试着找出它。
我们定义一个序列B,然后令 i = 1 to 9 逐个考察这个序列。B[i]中存储的是:对于长度为i的递增子序列的中的最大元素,其中的最小值。也就是长度为i的递增子序列的最小末尾。
定义LEN记录数组B中,已经得到的LIS的值。
首先,把d[1]有序地放到B里,令B[1] = 2,就是说当只有1一个数字2的时候,长度为1的LIS的最小末尾是2。这时Len=1
然后,读取d[2],因d[2] < B[1],所以令B[1] = 1,就是说长度为1的LIS的最小末尾是1,d[1]=2已经没用了,很容易理解吧。这时Len=1
接着,d[3]= 5,d[3]>B[1],所以令B[1+1]=B[2]=d[3]=5,就是说长度为2的LIS的最小末尾是5,很容易理解吧。这时候B[1..2]= 1, 5,Len=2
再来,d[4]= 3,它正好加在1,5之间,放在1的位置显然不合适,因为1小于3,长度为1的LIS最小末尾应该是1,这样很容易推知,长度为2的LIS最小末尾是3,于是可以把5淘汰掉,这时候B[1..2]= 1, 3,Len =2
继续,d[5]= 6,它在3后面,因为B[2] = 3, 而6在3后面,于是很容易可以推知B[3]= 6, 这时B[1..3]= 1, 3, 6,还是很容易理解吧? Len= 3 了噢。
第6个, d[6] = 4,你看它在3和6之间,于是我们就可以把6替换掉,得到B[3]= 4。B[1..3] = 1, 3, 4, Len继续等于3
第7个, d[7] = 8,它很大,比4大,嗯。于是B[4]= 8。Len变成4了
第8个, d[8] = 9,得到B[5] = 9,嗯。Len继续增大,到5了。
最后一个,d[9] = 7,它在B[3]= 4和B[4] = 8之间,所以我们知道,最新的B[4]=7,B[1..5] = 1, 3,4, 7, 9,Len =5。
于是我们知道了LIS的长度为5。之所以B数组中记录递增子序列的最小末尾,是为了遍历后续数组的时候,尽可能的增加递增子序列的长度。比如某一时刻B[1]= 1, B[2]=5,说明目前的长度为2的递增子序列中,最小末尾是5,之后碰到6,7,8,...等数的时候才会增加递增子序列的长度。如果碰见了一个数3,因3<5,所以可以用3替换5:B[2]=3。这样比如后续碰到了4,则就能增加递增子序列的长度了。
然后应该发现一件事情了:在B中插入数据是有序的,而且是进行替换而不需要挪动——也就是说,我们可以使用二分查找,将每一个数字的插入时间优化到O(logN)~~~~~于是算法的时间复杂度就降低到了O(nlogn)~!代码如下:
int BIN_LIS(int *a, intlen)
{
int *b = malloc((len+2) * sizeof(int));
int i;
int max = 0;
int left, right, mid;
b[1] = a[0];
max = 1;
for(i = 1; i < len; i++)
{
if(a[i] > b[max])
{
max++;
b[max] = a[i];
continue;
}
left = 1;
right = max;
while(left <= right)
{ //注意下面的边界条件
mid = left + (right -left)/2;
if(b[mid] < a[i])
{
left = mid +1;
}
else
{
right = mid -1;
}
}
b[left]= a[i];
}
return max;
}
五:双端LIS问题
问题描述:从一列数中筛除尽可能少的数使得从左往右看,这些数是从小到大再从大到小的。
思路:这是一个典型的双端LIS问题。
对于数组a,用数组b记录从左向右看时的LIS,b[i]表示,以a[i]结尾的,从a[0]到a[i]之间的LIS。
用数组c记录,从右向左看是的LIS,c[i]表示,以a[i]结尾的,从a[n-1]到a[i]之间的LIS。
所以,b[i] + c[i] - 1就表示以i为“顶点”的,满足题目要求的最长序列。所以n-max{b[i] + c[i] -1}就是题目的解。
这个问题同样需要辅助数组LIS, LIS[i]中存储的是:对于长度为i的递增子序列的中的最大元素,其中的最小值。也就是递增子序列的最小末尾。每当扫描到数组元素a[i]时,同LIS中的二分查找一样,需要找到在LIS中合适的位置,这个位置,也就是b[i]或者c[i]的值。
代码如下(自己写的,JULY的程序有问题):
int Double_LIS(int *a,int len)
{
int *Lis = malloc((len+2) * sizeof(int));
int *b = malloc(len * sizeof(int));
int *c = malloc(len * sizeof(int));
int i;
int max = 0;
int left, right, mid;
int maxlen = 0;
int index;
Lis[1] = a[0];
max = 1;
b[0] = max;
for(i = 1; i < len; i++)
{
if(a[i] > Lis[max])
{
max++;
Lis[max] = a[i];
b[i] = max;
printf("b[%d] is %d\n", i, b[i]);
continue;
}
left = 1;
right = max;
while(left <= right)
{
mid = left + (right - left)/2;
if(Lis[mid] < a[i])
{
left = mid + 1;
}
else
{
right = mid - 1;
}
}
Lis[left] = a[i];
b[i] = left;
printf("b[%d] is %d\n", i, b[i]);
}
memset(Lis, 0, (len+2) * sizeof(int));
Lis[1] = a[len-1];
max = 1;
c[len-1] = max;
for(i = len - 2; i >= 0; i--)
{
if(a[i] > Lis[max])
{
max++;
Lis[max] = a[i];
c[i] = max;
printf("c[%d] is %d\n", i, c[i]);
continue;
}
left = 1;
right = max;
while(left <= right)
{
mid = left + (right - left)/2;
if(Lis[mid] < a[i])
{
left = mid + 1;
}
else
{
right = mid - 1;
}
}
Lis[left] = a[i];
c[i] = left;
printf("c[%d] is %d\n", i, c[i]);
}
maxlen = 0;
for(i = 0; i < len; i++)
{
if(b[i]+c[i] > maxlen)
{
maxlen = b[i]+c[i];
index = i;
}
}
printf("index is %d\n", index);
return len - maxlen + 1;
}
(http://blog.csdn.net/v_JULY_v/article/details/6419466)