《编程之法:面试和算法心得》(第二章)
《编程之法:面试和算法心得》(第二章)
http://old.jscode.me/topic/195/%E7%BC%96%E7%A8%8B%E4%B9%8B%E6%B3%95-%E9%9D%A2%E8%AF%95%E5%92%8C%E7%AE%97%E6%B3%95%E5%BF%83%E5%BE%97-%E7%AC%AC%E4%BA%8C%E7%AB%A0
第二章 数组
本章导读
笔试和面试中,除了字符串,另一类出现频率极高的问题便是与数组相关的问题。在阅读完第1章和本第二章后,读者会慢慢了解到解决面试编程题的有几种常用思路。首先一般考虑“万能的”暴力穷举(递归、回溯),如求n个数的全排列或八皇后(N皇后问题)。但因为穷举时间复杂度通常过高,所以需要考虑更好的方法,如分治法(通过分而治之,然后归并),以及空间换时间(如活用哈希表)。
此外,选择合适的数据结构可以显著提升效率,如寻找最小的k个数中,用堆代替数组。
再有,如果题目允许排序,则可以考虑排序。比如,寻找和为定值的两个数中,先排序,然后用前后两个指针往中间扫。而如果如果已经排好序了(如杨氏矩阵查找中),则想想有无必要二分。但是,如果题目不允许排序呢?这个时候,我们可以考虑不改变数列顺序的贪心算法(如最小生成树Prim、Kruskal及最短路dijkstra),或动态规划(如 01背包问题,每一步都在决策)。
最后,注意细节处理,不要忽略边界条件,如字符串转换成整数。
2.1寻找最小的k个数
题目描述
输入n个整数,输出其中最小的k个。
分析与解法
解法一
要求一个序列中最小的k个数,按照惯有的思维方式,则是先对这个序列从小到大排序,然后输出前面的最小的k个数。
至于选取什么的排序方法,我想你可能会第一时间想到快速排序(我们知道,快速排序平均所费时间为n*logn
),然后再遍历序列中前k个元素输出即可。因此,总的时间复杂度:O(n * log n)+O(k)=O(n * log n)
。
解法二
咱们再进一步想想,题目没有要求最小的k个数有序,也没要求最后n-k个数有序。既然如此,就没有必要对所有元素进行排序。这时,咱们想到了用选择或交换排序,即:
1、遍历n个数,把最先遍历到的k个数存入到大小为k的数组中,假设它们即是最小的k个数;
2、对这k个数,利用选择或交换排序找到这k个元素中的最大值kmax(找最大值需要遍历这k个数,时间复杂度为O(k)
);
3、继续遍历剩余n-k个数。假设每一次遍历到的新的元素的值为x,把x与kmax比较:如果x < kmax
,用x替换kmax,并回到第二步重新找出k个元素的数组中最大元素kmax‘;如果x >= kmax
,则继续遍历不更新数组。
每次遍历,更新或不更新数组的所用的时间为O(k)
或O(0)
。故整趟下来,时间复杂度为n*O(k)=O(n*k)
。
解法三
更好的办法是维护容量为k的最大堆,原理跟解法二的方法相似:
- 1、用容量为k的最大堆存储最先遍历到的k个数,同样假设它们即是最小的k个数;
- 2、堆中元素是有序的,令k1<k2<…<kmax(kmax设为最大堆中的最大元素)
- 3、遍历剩余n-k个数。假设每一次遍历到的新的元素的值为x,把x与堆顶元素kmax比较:如果
x < kmax
,用x替换kmax,然后更新堆(用时logk);否则不更新堆。
这样下来,总的时间复杂度:O(k+(n-k)*logk)=O(n*logk)
。此方法得益于堆中进行查找和更新的时间复杂度均为:O(logk)
(若使用解法二:在数组中找出最大元素,时间复杂度:O(k))
。
解法四
在《数据结构与算法分析–c语言描述》一书,第7章第7.7.6节中,阐述了一种在平均情况下,时间复杂度为O(N)
的快速选择算法。如下述文字:
- 选取S中一个元素作为枢纽元v,将集合S-{v}分割成S1和S2,就像快速排序那样
- 如果k <= |S1|,那么第k个最小元素必然在S1中。在这种情况下,返回QuickSelect(S1, k)。
- 如果k = 1 + |S1|,那么枢纽元素就是第k个最小元素,即找到,直接返回它。
- 否则,这第k个最小元素就在S2中,即S2中的第(k - |S1| - 1)个最小元素,我们递归调用并返回QuickSelect(S2, k - |S1| - 1)。
此算法的平均运行时间为O(n)。
示例代码如下:
//QuickSelect 将第k小的元素放在 a[k-1]
void QuickSelect( int a[], int k, int left, int right )
{
int i, j;
int pivot;
if( left + cutoff <= right )
{
pivot = median3( a, left, right );
//取三数中值作为枢纽元,可以很大程度上避免最坏情况
i = left; j = right - 1;
for( ; ; )
{
while( a[ ++i ] < pivot ){ }
while( a[ --j ] > pivot ){ }
if( i < j )
swap( &a[ i ], &a[ j ] );
else
break;
}
//重置枢纽元
swap( &a[ i ], &a[ right - 1 ] );
if( k <= i )
QuickSelect( a, k, left, i - 1 );
else if( k > i + 1 )
QuickSelect( a, k, i + 1, right );
}
else
InsertSort( a + left, right - left + 1 );
}
这个快速选择SELECT算法,类似快速排序的划分方法。N个数存储在数组S中,再从数组中选取“中位数的中位数”作为枢纽元X,把数组划分为Sa和Sb俩部分,Sa<=X<=Sb,如果要查找的k个元素小于Sa的元素个数,则返回Sa中较小的k个元素,否则返回Sa中所有元素+Sb中小的k-|Sa|个元素,这种解法在平均情况下能做到O(n)
的复杂度。
更进一步,《算法导论》第9章第9.3节介绍了一个最坏情况下亦为O(n)时间的SELECT算法,有兴趣的读者可以参看。
举一反三
1、谷歌面试题:输入是两个整数数组,他们任意两个数的和又可以组成一个数组,求这个和中前k个数怎么做?
分析:
“假设两个整数数组为A和B,各有N个元素,任意两个数的和组成的数组C有N^2个元素。
那么可以把这些和看成N个有序数列:
A[1]+B[1] <= A[1]+B[2] <= A[1]+B[3] <=…
A[2]+B[1] <= A[2]+B[2] <= A[2]+B[3] <=…
…
A[N]+B[1] <= A[N]+B[2] <= A[N]+B[3] <=…
问题转变成,在这N^2个有序数列里,找到前k小的元素”
2、有两个序列A和B,A=(a1,a2,…,ak),B=(b1,b2,…,bk),A和B都按升序排列。对于1<=i,j<=k,求k个最小的(ai+bj)。要求算法尽量高效。
3、给定一个数列a1,a2,a3,…,an和m个三元组表示的查询,对于每个查询(i,j,k),输出ai,ai+1,…,aj的升序排列中第k个数。
2.2 寻找和为定值的两个数
题目描述
输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。
要求时间复杂度是O(N)。如果有多对数字的和等于输入的数字,输出任意一对即可。
例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。
分析与解法
咱们试着一步一步解决这个问题(注意阐述中数列有序无序的区别):
直接穷举,从数组中任意选取两个数,判定它们的和是否为输入的那个数字。此举复杂度为O(N^2)。很显然,我们要寻找效率更高的解法
题目相当于,对每个a[i],查找sum-a[i]是否也在原始序列中,每一次要查找的时间都要花费为O(N),这样下来,最终找到两个数还是需要O(N^2)的复杂度。那如何提高查找判断的速度呢?
答案是二分查找,可以将O(N)的查找时间提高到O(log N),这样对于N个a[i],都要花logN的时间去查找相对应的sum-a[i]是否在原始序列中,总的时间复杂度已降为O(N log N),且空间复杂度为O(1)。
(如果有序,直接二分O(N log N),如果无序,先排序后二分,复杂度同样为O(N log N + N log N)= O(N log N),空间复杂度总为O(1))。
可以继续优化做到时间O(N)么?
解法一
根据前面的分析,a[i]在序列中,如果a[i]+a[k]=sum的话,那么sum-a[i](a[k])也必然在序列中。
举个例子,如下:
原始序列:
- 1、 2、 4、 7、11、15
用输入数字15减一下各个数,得到对应的序列为:
- 14、13、11、8、4、 0
第一个数组以一指针i 从数组最左端开始向右扫描,第二个数组以一指针j 从数组最右端开始向左扫描,如果第一个数组出现了和第二个数组一样的数,即a[*i]=a[*j],就找出这俩个数来了。
如上,i,j最终在第一个,和第二个序列中找到了相同的数4和11,所以符合条件的两个数,即为4+11=15。
怎么样,两端同时查找,时间复杂度瞬间缩短到了O(N),但却同时需要O(N)的空间存储第二个数组。
解法二
当题目对时间复杂度要求比较严格时,我们可以考虑下用空间换时间,上述解法一即是此思想,此外,构造hash表也是典型的用空间换时间的处理办法。
即给定一个数字,根据hash映射查找另一个数字是否也在数组中,只需用O(1)的时间,前提是经过O(N)时间的预处理,和用O(N)的空间构造hash表。
但能否做到在时间复杂度为O(N)的情况下,空间复杂度能进一步降低达到O(1)呢?
解法三
如果数组是无序的,先排序(N log N),然后用两个指针i,j,各自指向数组的首尾两端,令i=0,j=n-1,然后i++,j–,逐次判断a[i]+a[j]?=sum,
- 如果某一刻a[i]+a[j] > sum,则要想办法让sum的值减小,所以此刻i不动,j–;
- 如果某一刻a[i]+a[j] < sum,则要想办法让sum的值增大,所以此刻i++,j不动。
所以,数组无序的时候,时间复杂度最终为O(N log N + N)=O(N log N)。
如果原数组是有序的,则不需要事先的排序,直接用两指针分别从头和尾向中间扫描,O(N)搞定,且空间复杂度还是O(1)。
下面,咱们先来实现此思路(这里假定数组已经是有序的),代码可以如下编写:
void TwoSum(int data[], unsigned int length, int sum)
{
//sort(s, s+n); 如果数组非有序的,那就事先排好序O(N log N)
int begin = 0;
int end = length - 1;
//俩头夹逼,或称两个指针两端扫描法,很经典的方法,O(N)
while (begin < end)
{
long currSum = data[begin] + data[end];
if (currSum == sum)
{
//题目只要求输出满足条件的任意一对即可
printf("%d %d\n", data[begin], data[end]);
//如果需要所有满足条件的数组对,则需要加上下面两条语句:
//begin++
//end--
break;
}
else{
if (currSum < sum)
begin++;
else
end--;
}
}
}
解法总结
不论原序列是有序还是无序,解决这类题有以下三种办法:
- 1、二分(若无序,先排序后二分),时间复杂度总为O(N log N),空间复杂度为O(1);
- 2、扫描一遍X-S[i] 映射到一个数组或构造hash表,时间复杂度为O(N),空间复杂度为O(N);
- 3、两个指针两端扫描(若无序,先排序后扫描),时间复杂度最后为:有序O(N),无序O(N log N + N)=O(N log N),空间复杂度都为O(1)。
所以,要想达到时间O(N),空间O(1)的目标,除非原数组是有序的(指针扫描法),不然,当数组无序的话,就只能先排序,后指针扫描法或二分(时间 O(Nlog N),空间O(1)),或映射或hash(时间O(N),空间O(N))。时间或空间,必须牺牲一个,达到平衡。
综上,若是数组有序的情况下,优先考虑两个指针两端扫描法,以达到最佳的时O(N),空O(1)效应。否则,如果要排序的话,时间复杂度最快当然是只能达到O(N log N),空间O(1)则不在话下。
问题扩展
- 如果在返回找到的两个数的同时,还要求你返回这两个数的位置列?
- 如果需要输出所有满足条件的整数对呢?
- 如果把题目中的要你寻找的两个数改为“多个数”,或任意个数列?
举一反三
1、在二元树中找出和为某一值的所有路径
输入一个整数和一棵二元树,从树的根结点开始往下访问一直到叶结点所经过的所有结点形成一条路径,然后打印出和与输入整数相等的所有路径。
例如输入整数22和如下二元树
10
/ \
5 12
/ \
4 7
则打印出两条路径:10, 12和10, 5, 7。
其中,二元树节点的数据结构定义为:
struct BinaryTreeNode // a node in the binary tree
{
int m_nValue; // value of node
BinaryTreeNode *m_pLeft; // left child of node
BinaryTreeNode *m_pRight; // right child of node
};
2、有一个数组a,设有一个值n。在数组中找到两个元素a[i]和a[j],使得a[i]+a[j]等于n,求出所有满足以上条件的i和j。
3、3-sum问题
给定一个整数数组,判断能否从中找出3个数a、b、c,使得他们的和为0,如果能,请找出所有满足和为0个3个数对。
4、4-sum问题
给定一个整数数组,判断能否从中找出4个数a、b、c、d,使得他们的和为0,如果能,请找出所有满足和为0个4个数对。
2.3 寻找和为定值的多个数
题目描述
输入两个整数n和sum,从数列1,2,3…n 中随意取几个数,使其和等于sum,要求将其中所有的可能组合列出来。
分析与解法
解法一
注意到取n,和不取n个区别即可,考虑是否取第n个数的策略,可以转化为一个只和前n-1个数相关的问题。
- 如果取第n个数,那么问题就转化为“取前n-1个数使得它们的和为sum-n”,对应的代码语句就是sumOfkNumber(sum - n, n - 1);
- 如果不取第n个数,那么问题就转化为“取前n-1个数使得他们的和为sum”,对应的代码语句为sumOfkNumber(sum, n - 1)。
参考代码如下:
list<int>list1;
void SumOfkNumber(int sum, int n)
{
// 递归出口
if (n <= 0 || sum <= 0)
return;
// 输出找到的结果
if (sum == n)
{
// 反转list
list1.reverse();
for (list<int>::iterator iter = list1.begin(); iter != list1.end(); iter++)
cout << *iter << " + ";
cout << n << endl;
list1.reverse()//此处还需反转回来
}
list1.push_front(n); //典型的01背包问题
SumOfkNumber(sum - n, n - 1); //“放”n,前n-1个数“填满”sum-n
list1.pop_front();
SumOfkNumber(sum, n - 1); //不“放”n,n-1个数“填满”sum
}
解法二
这个问题属于子集和问题(也是背包问题)。本程序采用回溯法+剪枝,其中X数组是解向量,t=∑(1,…,k-1)Wi*Xi, r=∑(k,…,n)Wi,且
- 若t+Wk+W(k+1)<=M,则Xk=true,递归左儿子(X1,X2,…,X(k-1),1);否则剪枝;
- 若t+r-Wk>=M && t+W(k+1)<=M,则置Xk=0,递归右儿子(X1,X2,…,X(k-1),0);否则剪枝;
本题中W数组就是(1,2,…,n),所以直接用k代替WK值。
代码编写如下:
//输入t, r, 尝试Wk
void SumOfkNumber(int t, int k, int r, int& M, bool& flag, bool* X)
{
X[k] = true; // 选第k个数
if (t + k == M) // 若找到一个和为M,则设置解向量的标志位,输出解
{
flag = true;
for (int i = 1; i <= k; ++i)
{
if (X[i] == 1)
{
printf("%d ", i);
}
}
printf("\n");
}
else
{ // 若第k+1个数满足条件,则递归左子树
if (t + k + (k + 1) <= M)
{
SumOfkNumber(t + k, k + 1, r - k, M, flag, X);
}
// 若不选第k个数,选第k+1个数满足条件,则递归右子树
if ((t + r - k >= M) && (t + (k + 1) <= M))
{
X[k] = false;
SumOfkNumber(t, k + 1, r - k, M, flag, X);
}
}
}
void search(int& N, int& M)
{
// 初始化解空间
bool* X = (bool*)malloc(sizeof(bool)* (N + 1));
memset(X, false, sizeof(bool)* (N + 1));
int sum = (N + 1) * N * 0.5f;
if (1 > M || sum < M) // 预先排除无解情况
{
printf("not found\n");
return;
}
bool f = false;
SumOfkNumber(0, 1, sum, M, f, X);
if (!f)
{
printf("not found\n");
}
free(X);
}
0-1背包问题
0-1背包问题是最基础的背包问题,其具体描述为:有N件物品和一个容量为V的背包。放入第i件物品耗费的费用是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。
简单分析下:这是最基础的背包问题,特点是每种物品仅有一件,可以选择放或不放。用子问题定义状态:即F[i, v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。则其状态转移方程便是:
- F[i, v] = max{F[i-1, v], F[i-1, v-Ci ] + Wi}
根据前面的分析,我们不难理解这个方程的意义:“将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只和前 i-1 件物品相关的问题。即:
- 如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”,价值为 F[i-1, v ];
- 如果放第i件物品,那么问题就转化为“前i-1件物品放入剩下的容量为v-Ci的背包中”,此时能获得的最大价值就是F[i-1, v-Ci]再加上通过放入第i件物品获得的价值Wi。
伪代码如下:
F[0,0...V] ← 0
for i ← 1 to N
for v ← Ci to V
F[i, v] ← max{F[i-1, v], F[i-1, v-Ci] + Wi }
这段代码的时间和空间复杂度均为 O(VN),其中时间复杂度应该已经不能再优化了,但空间复杂度却可以优化到O(V)。感兴趣的读者可以继续思考或者参考网上一个不错的文档《背包问题九讲》。
举一反三
1、《挑战程序设计竞赛》的开篇有个类似的抽签问题,挺有意思,题目如下:
将写有数字的n个纸片放入一个纸箱子中,然后你和你的朋友从纸箱子中抽取4张纸片,每次记下纸片上的数字后放回子箱子中,如果这4个数字的和是m,代表你赢,否则就是你的朋友赢。
请编写一个程序,当纸片上所写的数字是k1,k2,k3,k4,…,kn时,是否存在抽取4次和为m的方案,如果存在,输出YES;否则,输出NO。
限制条件:
- 1 <= n <= 50
- 1 <= m <= 10^8
- 1 <= ki <= 10^8
分析:最容易想到的方案是用4个for循环直接穷举所有方案,时间复杂度为O(N^4),主体代码如下:
//通过4重for循环枚举所有方案
for (int a = 0; a < n, a++)
{
for (int b = 0; b < n; b++)
{
for (int c = 0; c < n; c++)
{
for (int d = 0; d < n; d++)
{
if (k[a] + k[b] + k[c] + k[d] == m)
{
f = true;
}
}
}
}
}
但如果当n远大于50时,则程序会显得非常吃力,如此,我们需要找到更好的办法。
提供两个思路:
①最内侧关于d的循环所做的事情:检查是否有d满足ka+ kb +kc + kd = m,移动下式子,等价于:检查是否有d使得kd = m - ka - kb - kc,也就是说,只要检查k中所有元素,判断是否有等于m-ka-kb-ka 的元素即可。设m-ka-kb-ka = x,接下来,就是要看x是否存在于数组k中,此时,可以先把数组k排序,然后利用二分查找在数组k中找x;
②进一步,内侧的两个循环所做的事情:检查是否有c和d满足kc + kd = m - ka -kb。同样,可以预先枚举出kc+kd所得的n^2数字并排好序,便可以利用二分搜索继续求解。
2、给定整数a1、a2、a3、…、an,判断是否可以从中选出若干个数,使得它们的和等于k(k任意给定,且满足-10^8 <= k <= 10^8)。
分析:此题相对于本节“寻找满足条件的多个数”如出一辙,不同的是此题只要求判断,不要求把所有可能的组合给输出来。因为此题需要考虑到加上a[i]和不加上a[i]的情况,故可以采用深度优先搜索的办法,递归解决。
3、有n个数,输出期中所有和为s的k个数的组合。
分析:此题有两个坑,一是这里的n个数是任意给定的,不一定是:1,2,3…n,所以可能有重复的数(如果有重复的数怎么处理?);二是不要求你输出所有和为s的全部组合,而只要求输出和为s的k个数的组合。
举个例子,假定n=6,这6个数为:1 2 1 3 0 1,如果要求输出和为3的全部组合的话,
- 1 2
- 1 2 0
- 0 3
- 1 1 1
- 1 1 1 0
而题目加了个限制条件,若令k=2,则只要求输出:[{1,2}, {0,3}] 即可。
2.4 最大连续子数组和
题目描述
输入一个整形数组,数组里有正数也有负数。数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。
求所有子数组的和的最大值,要求时间复杂度为O(n)。
例如输入的数组为1, -2, 3, 10, -4, 7, 2, -5
,和最大的子数组为3, 10, -4, 7, 2
,
因此输出为该子数组的和18。
分析与解法
解法一
求一个数组的最大子数组和,我想最直观最野蛮的办法便是,三个for循环三层遍历,求出数组中每一个子数组的和,最终求出这些子数组的最大的一个值。
令currSum[i, …, j]为数组A中第i个元素到第j个元素的和(其中0 <= i <= j < n),maxSum为最终求到的最大连续子数组的和。
且当全是负数的情况时,我们可以让程序返回0,也可以让程序返回最大的那个负数,这里,我们让程序返回最大的那个负数。
参考代码如下:
int MaxSubArray(int* A, int n)
{
int maxSum = a[0]; //全负情况,返回最大负数
int currSum = 0;
for (int i = 0; i < n; i++)
{
for (int j = i; j < n; j++)
{
for (int k = i; k <= j; k++)
{
currSum += A[k];
}
if (currSum > maxSum)
maxSum = currSum;
currSum = 0; //这里要记得清零,否则的话sum最终存放的是所有子数组的和。
}
}
return maxSum;
}
此方法的时间复杂度为O(n^3)。
解法二
事实上,当我们令currSum为当前最大子数组的和,maxSum为最后要返回的最大子数组的和,当我们往后扫描时,
- 对第j+1个元素有两种选择:要么放入前面找到的子数组,要么做为新子数组的第一个元素;
- 如果currSum加上当前元素a[j]后不小于a[j],则令currSum加上a[j],否则currSum重新赋值,置为下一个元素,即currSum = a[j]。
- 同时,当currSum > maxSum,则更新maxSum = currSum,否则保持原值,不更新。
即
currSum = max(a[j], currSum + a[j])
maxSum = max(maxSum, currSum)
举个例子,当输入数组是1, -2, 3, 10, -4, 7, 2, -5
,那么,currSum和maxSum相应的变化为:
- currSum: 0 1 - 1 3 13 9 16 18 13
- maxSum : 0 1 1 3 13 13 16 18 18
参考代码如下:
int MaxSubArray(int* a, int n)
{
int currSum = 0;
int maxSum = a[0]; //全负情况,返回最大数
for (int j = 0; j < n; j++)
{
currSum = (a[j] > currSum + a[j]) ? a[j] : currSum + a[j];
maxSum = (maxSum > currSum) ? maxSum : currSum;
}
return maxSum;
}
问题扩展
- 如果数组是二维数组,同样要你求最大子数组的和列?
- 如果是要你求子数组的最大乘积列?
- 如果同时要求输出子段的开始和结束列?
举一反三
1 给定整型数组,其中每个元素表示木板的高度,木板的宽度都相同,求这些木板拼出的最大矩形的面积。并分析时间复杂度。
此题类似leetcode里面关于连通器的题,需要明确的是高度可能为0,长度最长的矩形并不一定是最大矩形,还需要考虑高度很高,但长度较短的矩形。如[5,4,3,2,4,5,0,7,8,4,6]中最大矩形的高度是[7,8,4,6]组成的矩形,面积为16。
2、环面上的最大子矩形
《算法竞赛入门经典》 P89 页。
3、最大子矩阵和
一个MN的矩阵,找到此矩阵的一个子矩阵,并且这个子矩阵的元素的和是最大的,输出这个最大的值。如果所有数都是负数,就输出0。
例如:35的矩阵:
1 2 0 3 4
2 3 4 5 1
1 1 5 3 0
和最大的子矩阵是:
4 5
5 3
最后输出和的最大值17。
4、允许交换两个数的位置 求最大子数组和。
来源:https://codility.com/cert/view/certDUMWPM-8RF86G8P9QQ6JC8X/details 。
2.5跳台阶问题
题目描述
一个台阶总共有n 级,如果一次可以跳1 级,也可以跳2 级。
求总共有多少总跳法,并分析算法的时间复杂度。
分析与解法
解法一
首先考虑最简单的情况。如果只有1级台阶,那显然只有一种跳法。如果有2级台阶,那就有两种跳的方法了:一种是分两次跳,每次跳1级;另外一种就是一次跳2级。
现在我们再来讨论一般情况。我们把n级台阶时的跳法看成是n的函数,记为f(n)。
- 当n>2时,第一次跳的时候就有两种不同的选择:
- 一是第一次只跳1级,此时跳法数目等于后面剩下的n-1级台阶的跳法数目,即为f(n-1);
- 另外一种选择是第一次跳2级,此时跳法数目等于后面剩下的n-2级台阶的跳法数目,即为f(n-2)。
因此n级台阶时的不同跳法的总数f(n)=f(n-1)+f(n-2)。
我们把上面的分析用一个公式总结如下:
/ 1 n = 1
f(n)= 2 n = 2
\ f(n-1) + f(n-2) n > 2
原来上述问题就是我们平常所熟知的Fibonacci数列问题。可编写代码,如下:
long long Fibonacci(unsigned int n)
{
int result[3] = {0, 1, 2};
if (n <= 2)
return result[n];
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
那么,如果一个人上台阶可以一次上1个,2个,或者3个呢?这个时候,公式是这样写的:
/ 1 n = 1
f(n)= 2 n = 2
4 n = 3 //111, 12, 21, 3
\ f(n-1)+f(n-2)+f(n-3) n > 3
解法二
解法一用的递归的方法有许多重复计算的工作,事实上,我们可以从后往前推,一步步利用之前计算的结果递推。
初始化时,dp[0]=dp[1]=1,然后递推计算即可:dp[n] = dp[n-1] + dp[n-2]。
参考代码如下:
//1, 1, 2, 3, 5, 8, 13, 21..
int ClimbStairs(int n)
{
int dp[3] = { 1, 1 };
if (n < 2)
{
return 1;
}
for (int i = 2; i <= n; i++)
{
dp[2] = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = dp[2];
}
return dp[2];
}
举一反三
1、兔子繁殖问题
13世纪意大利数学家斐波那契在他的《算盘书》中提出这样一个问题:有人想知道一年内一对兔子可繁殖成多少对,便筑了一道围墙把一对兔子关在里面。已知一对兔子每一个月可以生一对小兔子,而一对兔子出生后.第三个月开始生小兔子假如一年内没有发生死亡,则一对兔子一年内能繁殖成多少对?
分析:这就是斐波那契数列的由来,本节的跳台阶问题便是此问题的变形,只是换了种表述形式。
2、换硬币问题。
想兑换100元钱,有1,2,5,10四种钱,问总共有多少兑换方法。
const int N = 100;
int dimes[] = { 1, 2, 5, 10 };
int arr[N + 1] = { 1 };
for (int i = 0; i < sizeof(dimes) / sizeof(int); ++i)
{
for (int j = dimes[i]; j <= N; ++j)
{
arr[j] += arr[j - dimes[i]];
}
}
此问题还有一个变形,就是打印出路径目前只想到要使用递归来解决这个问题。对此,利用一个vector来保存路径,每进入一层,push_back一个路径,每退出一层,pop_back一个路径。
2.6奇偶调序
题目描述
输入一个整数数组,调整数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。要求时间复杂度为O(n)。
分析与解法
最容易想到的办法是从头扫描这个数组,每碰到一个偶数,拿出这个数字,并把位于这个数字后面的所有数字往前挪动一位。挪完之后在数组的末尾有一个空位,然后把该偶数放入这个空位。由于每碰到一个偶数,需要移动O(n)个数字,所以这种方法总的时间复杂度是O(n^2),不符合题目要求。
事实上,若把奇数看做是小的数,偶数看做是大的数,那么按照题目所要求的奇数放在前面偶数放在后面,就相当于小数放在前面大数放在后面,联想到快速排序中的partition过程,不就是通过一个主元把整个数组分成大小两个部分么,小于主元的小数放在前面,大于主元的大数放在后面。
而partition过程有以下两种实现:
- 一头一尾两个指针往中间扫描,如果头指针遇到的数比主元大且尾指针遇到的数比主元小,则交换头尾指针所分别指向的数字;
- 一前一后两个指针同时从左往右扫,如果前指针遇到的数比主元小,则后指针右移一位,然后交换各自所指向的数字。
类似这个partition过程,奇偶排序问题也可以分别借鉴partition的两种实现解决。
为何?比如partition的实现一中,如果最终是为了让整个序列元素从小到大排序,那么头指针理应指向的就是小数,而尾指针理应指向的就是大数,故当头指针指的是大数且尾指针指的是小数的时候就不正常,此时就当交换。
解法一
借鉴partition的实现一,我们可以考虑维护两个指针,一个指针指向数组的第一个数字,我们称之为头指针,向右移动;一个指针指向最后一个数字,称之为尾指针,向左移动。
这样,两个指针分别从数组的头部和尾部向数组的中间移动,如果第一个指针指向的数字是偶数而第二个指针指向的数字是奇数,我们就交换这两个数字。
因为按照题目要求,最终是为了让奇数排在数组的前面,偶数排在数组的后面,所以头指针理应指向的就是奇数,尾指针理应指向的就是偶数,故当头指针指向的是偶数且尾指针指向的是奇数时,我们就当立即交换它们所指向的数字。
思路有了,接下来,写代码实现:
//判断是否为奇数
bool IsOddNumber(int data)
{
return data & 1 == 1;
}
//奇偶互换
void OddEvenSort(int *pData, unsigned int length)
{
if (pData == NULL || length == 0)
return;
int *pBegin = pData;
int *pEnd = pData + length - 1;
while (pBegin < pEnd)
{
//如果pBegin指针指向的是奇数,正常,向右移
if (IsOddNumber(*pBegin))
{
pBegin++;
}
//如果pEnd指针指向的是偶数,正常,向左移
else if (!IsOddNumber(*pEnd))
{
pEnd--;
}
else
{
//否则都不正常,交换
//swap是STL库函数,声明为void swap(int& a, int& b);
swap(*pBegin, *pEnd);
}
}
}
本方法通过头尾两个指针往中间扫描,一次遍历完成所有奇数偶数的重新排列,时间复杂度为O(n)。
解法二
我们先来看看快速排序partition过程的第二种实现是具体怎样的一个原理。
partition分治过程,每一趟排序的过程中,选取的主元都会把整个数组排列成一大一小的序列,继而递归排序完整个数组。如下伪代码所示:
PARTITION(A, p, r)
1 x ← A[r]
2 i ← p - 1
3 for j ← p to r - 1
4 do if A[j] ≤ x
5 then i ← i + 1
6 exchange A[i] <-> A[j]
7 exchange A[i + 1] <-> A[r]
8 return i + 1
举个例子如下:现要对数组data = {2, 8,7, 1, 3, 5, 6, 4}进行快速排序,为了表述方便,令i
指向数组头部前一个位置,j
指向数组头部元素,j
在前,i
在后,双双从左向右移动。
① j 指向元素2时,i 也指向元素2,2与2互换不变
i p/j
2 8 7 1 3 5 6 4(主元)
② 于是j 继续后移,直到指向了1,1 <= 4,于是i++,i 指向8,故j 所指元素1 与 i 所指元素8 位置互换:
i j
2 1 7 8 3 5 6 4
③ j 继续后移,指到了元素3,3 <= 4,于是同样i++,i 指向7,故j 所指元素3 与 i 所指元素7 位置互换:
i j
2 1 3 8 7 5 6 4
④ j 一路后移,没有再碰到比主元4小的元素:
i j
2 1 3 8 7 5 6 4
⑤ 最后,A[i + 1] <-> A[r],即8与4交换,所以,数组最终变成了如下形式:
2 1 3 4 7 5 6 8
这样,快速排序第一趟完成。就这样,4把整个数组分成了俩部分,2 1 3,7 5 6 8,再递归对这两部分分别进行排序。
借鉴partition的上述实现,我们也可以维护两个指针i和j,一个指针指向数组的第一个数的前一个位置,我们称之为后指针i,向右移动;一个指针指向数组第一个数,称之为前指针j,也向右移动,且前指针j先向右移动。如果前指针j指向的数字是奇数,则令i指针向右移动一位,然后交换i和j指针所各自指向的数字。
因为按照题目要求,最终是为了让奇数排在数组的前面,偶数排在数组的后面,所以i指针理应指向的就是奇数,j指针理应指向的就是偶数,所以,当j指针指向的是奇数时,不正常,我们就当让i++,然后交换i和j指针所各自指向的数字。
参考代码如下:
//奇偶互换
void OddEvenSort2(int data[], int lo, int hi)
{
int i = lo - 1;
for (int j = lo; j < hi; j++)
{
//data[j]指向奇数,交换
if ( IsOddNumber(data[j]) )
{
i = i + 1;
swap(data[i], data[j]);
}
}
swap(data[i + 1], data[hi]);
}
此解法一前一后两个指针同时向右扫描的过程中,也是一次遍历完成奇数偶数的重新排列,故时间复杂度也为O(n)。
举一反三
一个未排序整数数组,有正负数,重新排列使负数排在正数前面,并且要求不改变原来的正负数之间相对顺序,比如: input: 1,7,-5,9,-12,15 ans: -5,-12,1,7,9,15 要求时间复杂度O(n),空间O(1)。
分析:如果本题没有这个要求“并且要求不改变原来的正负数之间相对顺序”,那么同奇偶数排序是一道题,但加上这个不能改变正负数之间的相对顺序后,便使得问题变得比较艰难了,若有兴趣,读者可以参考这篇论文《STABLE MINIMUM SPACE PARTITIONING IN LINEAR TIME》。
2.7 荷兰国旗
题目描述
拿破仑席卷欧洲大陆之后,代表自由,平等,博爱的竖色三色旗也风靡一时。荷兰国旗就是一面三色旗(只不过是横向的),自上而下为红白蓝三色。
该问题本身是关于三色球排序和分类的,由荷兰科学家Dijkstra提出。由于问题中的三色小球有序排列后正好分为三类,Dijkstra就想象成他母国的国旗,于是问题也就被命名为荷兰旗问题(Dutch National Flag Problem)。
下面是问题的正规描述:
现有n个红白蓝三种不同颜色的小球,乱序排列在一起,请通过两两交换任意两个球,使得从左至右,依次是一些红球、一些白球、一些蓝球。
分析与解法
初看此题,我们貌似除了暴力解决并无好的办法,但联想到我们所熟知的快速排序算法呢?
我们知道,快速排序依托于一个partition分治过程,在每一趟排序的过程中,选取的主元都会把整个数组排列成一大一小的部分,那我们是否可以借鉴partition过程设定三个指针完成重新排列,使得所有球排列成三个不同颜色的球呢?
解法一
通过前面的分析得知,这个问题类似快排中partition过程,只是需要用到三个指针:一个前指针begin,一个中指针current,一个后指针end,current指针遍历整个数组序列,当
- current指针所指元素为0时,与begin指针所指的元素交换,而后current++,begin++ ;
- current指针所指元素为1时,不做任何交换(即球不动),而后current++ ;
- current指针所指元素为2时,与end指针所指的元素交换,而后,current指针不动,end-- 。
为什么上述第3点中,current指针所指元素为2时,与end指针所指元素交换之后,current指针不能动呢?因为第三步中current指针所指元素与end指针所指元素交换之前,如果end指针之前指的元素是0,那么与current指针所指元素交换之后,current指针此刻所指的元素是0,此时,current指针能动么?不能动,因为如上述第1点所述,如果current指针所指的元素是0,还得与begin指针所指的元素交换。
ok,说这么多,你可能不甚明了,直接引用下gnuhpc的图,就一目了然了:
参考代码如下:
//引用自gnuhpc
while( current<=end )
{
if( array[current] ==0 )
{
swap(array[current],array[begin]);
current++;
begin++;
}
else if( array[current] == 1 )
{
current++;
}
else //When array[current] =2
{
swap(array[current],array[end]);
end--;
}
}
举一反三
给定一个字符串里面只有"R" “G” “B” 三个字符,请排序,最终结果的顺序是R在前 G中 B在后。
要求:空间复杂度是O(1),且只能遍历一次字符串。
2.8 矩阵相乘
题目描述
请编程实现矩阵乘法,并考虑当矩阵规模较大时的优化方法。
分析与解法
根据wikipedia上的介绍:两个矩阵的乘法仅当第一个矩阵A的行数和另一个矩阵B的列数相等时才能定义。如A是m×n矩阵,B是n×p矩阵,它们的乘积AB是一个m×p矩阵,它的一个元素其中 1 ≤ i ≤ m, 1 ≤ j ≤ p。
值得一提的是,矩阵乘法满足结合律和分配率,但并不满足交换律,如下图所示的这个例子,两个矩阵交换相乘后,结果变了:
下面咱们来具体解决这个矩阵相乘的问题。
解法一、暴力解法
其实,通过前面的分析,我们已经很明显的看出,两个具有相同维数的矩阵相乘,其复杂度为O(n^3),参考代码如下:
//矩阵乘法,3个for循环搞定
void MulMatrix(int** matrixA, int** matrixB, int** matrixC)
{
for(int i = 0; i < 2; ++i)
{
for(int j = 0; j < 2; ++j)
{
matrixC[i][j] = 0;
for(int k = 0; k < 2; ++k)
{
matrixC[i][j] += matrixA[i][k] * matrixB[k][j];
}
}
}
}
解法二、Strassen算法
在解法一中,我们用了3个for循环搞定矩阵乘法,但当两个矩阵的维度变得很大时,O(n^3)的时间复杂度将会变得很大,于是,我们需要找到一种更优的解法。
一般说来,当数据量一大时,我们往往会把大的数据分割成小的数据,各个分别处理。遵此思路,如果丢给我们一个很大的两个矩阵呢,是否可以考虑分治的方法循序渐进处理各个小矩阵的相乘,因为我们知道一个矩阵是可以分成更多小的矩阵的。
如下图,当给定一个两个二维矩阵A B时:
这两个矩阵A B相乘时,我们发现在相乘的过程中,有8次乘法运算,4次加法运算:
矩阵乘法的复杂度主要就是体现在相乘上,而多一两次的加法并不会让复杂度上升太多。故此,我们思考,是否可以让矩阵乘法的运算过程中乘法的运算次数减少,从而达到降低矩阵乘法的复杂度呢?答案是肯定的。
1969年,德国的一位数学家Strassen证明O(N^3)的解法并不是矩阵乘法的最优算法,他做了一系列工作使得最终的时间复杂度降低到了O(n^2.80)。
他是怎么做到的呢?还是用上文A B两个矩阵相乘的例子,他定义了7个变量:
如此,Strassen算法的流程如下:
- 两个矩阵A B相乘时,将A, B, C分成相等大小的方块矩阵:
- 可以看出C是这么得来的:
- 现在定义7个新矩阵(读者可以思考下,这7个新矩阵是如何想到的):
- 而最后的结果矩阵C 可以通过组合上述7个新矩阵得到:
表面上看,Strassen算法仅仅比通用矩阵相乘算法好一点,因为通用矩阵相乘算法时间复杂度是,而Strassen算法复杂度只是
。但随着n的变大,比如当n >> 100时,Strassen算法是比通用矩阵相乘算法变得更有效率。
如下图所示:
根据wikipedia上的介绍,后来,Coppersmith–Winograd 算法把 N* N大小的矩阵乘法的时间复杂度降低到了:,而2010年,Andrew Stothers再度把复杂度降低到了
,一年后的2011年,Virginia Williams把复杂度最终定格为:
。
2.9完美洗牌算法
题目详情
有个长度为2n的数组{a1,a2,a3,…,an,b1,b2,b3,…,bn},希望排序后{a1,b1,a2,b2,…,an,bn},请考虑有无时间复杂度o(n),空间复杂度0(1)的解法。
题目来源:此题是去年2013年UC的校招笔试题,看似简单,按照题目所要排序后的字符串蛮力变化即可,但若要完美的达到题目所要求的时空复杂度,则需要我们花费不小的精力。OK,请看下文详解,一步步优化。
分析与解法
解法一、蛮力变换
题目要我们怎么变换,咱们就怎么变换。此题@陈利人也分析过,在此,引用他的思路进行说明。为了便于分析,我们取n=4,那么题目要求我们把
a1,a2,a3,a4,b1,b2,b3,b4
变成
a1,b1,a2,b2,a3,b3,a4,b4
1.1、步步前移
仔细观察变换前后两个序列的特点,我们可做如下一系列操作:
第①步、确定b1的位置,即让b1跟它前面的a2,a3,a4交换:
a1,b1,a2,a3,a4,b2,b3,b4
第②步、接着确定b2的位置,即让b2跟它前面的a3,a4交换:
a1,b1,a2,b2,a3,a4,b3,b4
第③步、b3跟它前面的a4交换位置:
a1,b1,a2,b2,a3,b3,a4,b4
b4已在最后的位置,不需要再交换。如此,经过上述3个步骤后,得到我们最后想要的序列。但此方法的时间复杂度为O(N^2),我们得继续寻找其它方法,看看有无办法能达到题目所预期的O(N)的时间复杂度。
1.2、中间交换
当然,除了如上面所述的让b1,b2,b3,b4步步前移跟它们各自前面的元素进行交换外,我们还可以每次让序列中最中间的元素进行交换达到目的。还是用上面的例子,针对a1,a2,a3,a4,b1,b2,b3,b4
第①步:交换最中间的两个元素a4,b1,序列变成(待交换的元素用粗体表示):
a1,a2,a3,b1,a4,b2,b3,b4
第②步,让最中间的两对元素各自交换:
a1,a2,b1,a3,b2,a4,b3,b4
第③步,交换最中间的三对元素,序列变成:
a1,b1,a2,b2,a3,b3,a4,b4
同样,此法同解法1.1、步步前移一样,时间复杂度依然为O(N^2),我们得下点力气了。
解法二、完美洗牌算法
玩过扑克牌的朋友都知道,在一局完了之后洗牌,洗牌人会习惯性的把整副牌大致分为两半,两手各拿一半对着对着交叉洗牌,如下图所示:
如果这副牌用a1 a2 a3 a4 b1 b2 b3 b4表示(为简化问题,假设这副牌只有8张牌),然后一分为二之后,左手上的牌可能是a1 a2 a3 a4,右手上的牌是b1 b2 b3 b4,那么在如上图那样的洗牌之后,得到的牌就可能是b1 a1 b2 a2 b3 a3 b4 a4。
技术来源于生活,2004年,microsoft的Peiyush Jain在他发表一篇名为:“A Simple In-Place Algorithm for In-Shuffle”的论文中提出了完美洗牌算法。
这个算法解决一个什么问题呢?跟本题有什么联系呢?
Yeah,顾名思义,完美洗牌算法解决的就是一个完美洗牌问题。什么是完美洗牌问题呢?即给定一个数组a1,a2,a3,…an,b1,b2,b3…bn,最终把它置换成b1,a1,b2,a2,…bn,an。读者可以看到,这个完美洗牌问题本质上与本题完全一致,只要在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可。
即:
a1,a2,a3,...an,b1,b2,b3..bn
通过完美洗牌问题,得到:
b1,a1,b2,a2,b3,a3... bn,an
再让上面相邻的元素两两swap,即可达到本题的要求:
a1,b1,a2,b2,a3,b3....,an,bn
也就是说,如果我们能通过完美洗牌算法(时间复杂度O(N),空间复杂度O(1))解决了完美洗牌问题,也就间接解决了本题。
虽然网上已有不少文章对上篇论文或翻译或做解释说明,但对于初学者来说,理解难度实在太大,再者,若直接翻译原文,根本无法看出这个算法怎么一步步得来的,故下文将从完美洗牌算法的最基本的原型开始说起,以让读者能对此算法一目了然。
2.1、位置置换pefect_shuffle1算法
为方便讨论,我们设定数组的下标从1开始,下标范围是[1…2n]。 还是通过之前n=4的例子,来看下每个元素最终去了什么地方。
起始序列:a1 a2 a3 a4 b1 b2 b3 b4
数组下标:1 2 3 4 5 6 7 8
最终序列:b1 a1 b2 a2 b3 a3 b4 a4
从上面的例子我们能看到,前n个元素中,
第1个元素a1到了原第2个元素a2的位置,即1->2;
第2个元素a2到了原第4个元素a4的位置,即2->4;
第3个元素a3到了原第6个元素b2的位置,即3->6;
第4个元素a4到了原第8个元素b4的位置,即4->8;
那么推广到一般情况即是:前n个元素中,第i个元素去了 第(2 * i)的位置。
上面是针对前n个元素,那么针对后n个元素,可以看出:
第5个元素b1到了原第1个元素a1的位置,即5->1;
第6个元素b2到了原第3个元素a3的位置,即6->3;
第7个元素b3到了原第5个元素b1的位置,即7->5;
第8个元素b4到了原第7个元素b3的位置,即8->7;
推广到一般情况是,后n个元素,第i个元素去了第 (2 * (i - n) ) - 1 = 2 * i - (2 * n + 1) = (2 * % (2 * n + 1) 个位置。
再综合到任意情况,任意的第i个元素,我们最终换到了 (2 * % (2 * n + 1)的位置。为何呢?因为:
当0 < i < n时, 原式= (2i) % (2 * n + 1) = 2i;
当i > n时,原式(2 * % (2 * n + 1)保持不变。
因此,如果题目允许我们再用一个数组的话,我们直接把每个元素放到该放得位置就好了。也就产生了最简单的方法pefect_shuffle1,参考代码如下:
// 时间O(n),空间O(n) 数组下标从1开始
void PefectShuffle1(int *a, int n)
{
int n2 = n * 2, i, b[N];
for (i = 1; i <= n2; ++i)
{
b[(i * 2) % (n2 + 1)] = a[i];
}
for (i = 1; i <= n2; ++i)
{
a[i] = b[i];
}
}
但很明显,它的时间复杂度虽然是O(n),但其空间复杂度却是O(n),仍不符合本题所期待的时间O(n),空间O(1)。我们继续寻找更优的解法。
与此同时,我也提醒下读者,根据上面变换的节奏,我们可以看出有两个圈,
一个是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1;
一个是3 -> 6 -> 3。
下文2.2.1、走圈算法cycle_leader将再次提到这两个圈。
2.2、完美洗牌算法perfect_shuffle2
2.2.1、走圈算法cycle_leader
因为之前perfect_shuffle1算法未达到时间复杂度O(N)并且空间复杂度O(1)的要求,所以我们必须得再找一种新的方法,以期能完美的解决本节开头提出的完美洗牌问题。
让我们先来回顾一下2.1节位置置换perfect_shuffle1算法,还记得我之前提醒读者的关于当n=4时,通过位置置换让每一个元素到了最后的位置时,所形成的两个圈么?我引用下2.1节的相关内容:
当n=4的情况:
起始序列:a1 a2 a3 a4 b1 b2 b3 b4
数组下标:1 2 3 4 5 6 7 8
最终序列:b1 a1 b2 a2 b3 a3 b4 a4
即通过置换,我们得到如下结论:
“于此同时,我也提醒下读者,根据上面变换的节奏,我们可以看出有两个圈,
一个是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1;
一个是3 -> 6 -> 3。”
这两个圈可以表示为(1,2,4,8,7,5)和(3,6),且perfect_shuffle1算法也已经告诉了我们,不管你n是奇数还是偶数,每个位置的元素都将变为第(2*i) % (2n+1)个元素:
因此我们只要知道圈里最小位置编号的元素即圈的头部,顺着圈走一遍就可以达到目的,且因为圈与圈是不相交的,所以这样下来,我们刚好走了O(N)步。
还是举n=4的例子,且假定我们已经知道第一个圈和第二个圈的前提下,要让1 2 3 4 5 6 7 8变换成5 1 6 2 7 3 8 4:
第一个圈:1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1
第二个圈:3 -> 6 -> 3:
原始数组:1 2 3 4 5 6 7 8
数组下标:1 2 3 4 5 6 7 8
走第一圈:5 1 3 2 7 6 8 4
走第二圈:5 1 6 2 7 3 8 4
上面沿着圈走的算法我们给它取名为cycle_leader,这部分代码如下:
//数组下标从1开始,from是圈的头部,mod是要取模的数 mod 应该为 2 * n + 1,时间复杂度O(圈长)
void CycleLeader(int *a, int from, int mod)
{
int t,i;
for (i = from * 2 % mod; i != from; i = i * 2 % mod)
{
t = a[i];
a[i] = a[from];
a[from] = t;
}
}
2.2.2、神级结论:若2*n=(3^k - 1),则可确定圈的个数及各自头部的起始位置
下面我要引用此论文“A Simple In-Place Algorithm for In-Shuffle”的一个结论了,即
对于2*n = (3^k-1)这种长度的数组,恰好只有k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1)。
论文原文部分为:
也就是说,利用上述这个结论,我们可以解决这种特殊长度2*n = (3^k-1)的数组问题,那么若给定的长度n是任意的咋办呢?此时,我们可以采取分而治之算法的思想,把整个数组一分为二,即拆分成两个部分:
让一部分的长度满足神级结论:若2*m = (3^k-1),则恰好k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1)。其中m < n,m往神级结论所需的值上套;
剩下的n-m部分单独计算;
当把n分解成m和n-m两部分后,原始数组对应的下标如下(为了方便描述,我们依然只需要看数组下标就够了):
原始数组下标:1…m m+1… n, n+1 … n+m, n+m+1,…2*n
且为了能让前部分的序列满足神级结论2*m = (3^k-1),我们可以把中间那两段长度为n-m和m的段交换位置,即相当于把m+1…n,n+1…n+m的段循环右移m次(为什么要这么做?因为如此操作后,数组的前部分的长度为2m,而根据神级结论:当2m=3^k-1时,可知这长度2m的部分恰好有k个圈)。
而如果读者看过本系列第一章、左旋转字符串的话,就应该意识到循环位移是有O(N)的算法的,其思想即是把前n-m个元素(m+1… n)和后m个元素(n+1 … n+m)先各自翻转一下,再将整个段(m+1… n, n+1 … n+m)翻转下。
这个翻转的代码如下:
//翻转字符串时间复杂度O(to - from)
void reverse(int *a, int from, int to)
{
int t;
for (; from < to; ++from, --to)
{
t = a[from];
a[from] = a[to];
a[to] = t;
}
}
//循环右移num位 时间复杂度O(n)
void RightRotate(int *a, int num, int n)
{
reverse(a, 1, n - num);
reverse(a, n - num + 1, n);
reverse(a, 1, n);
}
翻转后,得到的目标数组的下标为:
目标数组下标:1..m n+1..n+m m+1 .. n n+m+1,..2*n
OK,理论讲清楚了,再举个例子便会更加一目了然。当给定n=7时,若要满足神级结论2*n=3^k-1,k只能取2,继而推得n‘=m=4。
原始数组:a1 a2 a3 a4 a5 a6 a7 b1 b2 b3 b4 b5 b6 b7
既然m=4,即让上述数组中有下划线的两个部分交换,得到:
目标数组:a1 a2 a3 a4 b1 b2 b3 b4 a5 a6 a7 b5 b6 b7
继而目标数组中的前半部分a1 a2 a3 a4 b1 b2 b3 b4部分可以用2.2.1、走圈算法cycle_leader搞定,于此我们最终求解的n长度变成了n’=3,即n的长度减小了4,单独再解决后半部分a5 a6 a7 b5 b6 b7即可。
2.2.3、完美洗牌算法perfect_shuffle3
从上文的分析过程中也就得出了我们的完美洗牌算法,其算法流程为:
输入数组 A[1…2 * n]
step 1 找到 2 * m = 3^k - 1 使得 3^k <= 2 * n < 3^(k +1)
step 2 把a[m + 1…n + m]那部分循环移m位
step 3 对每个i = 0,1,2…k - 1,3^i是个圈的头部,做cycle_leader算法,数组长度为m,所以对2 * m + 1取模。
step 4 对数组的后面部分A[2 * m + 1… 2 * n]继续使用本算法, 这相当于n减小了m。
上述算法流程对应的论文原文为:
以上各个步骤对应的时间复杂度分析如下:
因为循环不断乘3的,所以时间复杂度O(logn)
循环移位O(n)
每个圈,每个元素只走了一次,一共2*m个元素,所以复杂度omega(m), 而m < n,所以 也在O(n)内。
T(n - m)
因此总的时间复杂度为 T(n) = T(n - m) + O(n) ,m = omega(n) ,解得:T(n) = O(n)。
此完美洗牌算法实现的参考代码如下:
//copyright@caopengcs 8/24/2013
//时间O(n),空间O(1)
void PerfectShuffle2(int *a, int n)
{
int n2, m, i, k, t;
for (; n > 1;)
{
// step 1
n2 = n * 2;
for (k = 0, m = 1; n2 / m >= 3; ++k, m *= 3)
;
m /= 2;
// 2m = 3^k - 1 , 3^k <= 2n < 3^(k + 1)
// step 2
right_rotate(a + m, m, n);
// step 3
for (i = 0, t = 1; i < k; ++i, t *= 3)
{
cycle_leader(a , t, m * 2 + 1);
}
//step 4
a += m * 2;
n -= m;
}
// n = 1
t = a[1];
a[1] = a[2];
a[2] = t;
}
2.2.4、perfect_shuffle2算法解决其变形问题
啊哈!以上代码即解决了完美洗牌问题,那么针对本章要解决的其变形问题呢?是的,如本章开头所说,在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可,代码如下:
//copyright@caopengcs 8/24/2013
//时间复杂度O(n),空间复杂度O(1),数组下标从1开始,调用perfect_shuffle3
void shuffle(int *a, int n)
{
int i, t, n2 = n * 2;
PerfectShuffle2(a, n);
for (i = 2; i <= n2; i += 2)
{
t = a[i - 1];
a[i - 1] = a[i];
a[i] = t;
}
}
上述的这个“在完美洗牌问题的基础上对它最后的序列swap两两相邻元素”的操作(当然,你也可以让原数组第一个和最后一个不变,中间的2 * (n - 1)项用原始的标准完美洗牌算法做),只是在完美洗牌问题时间复杂度O(N)空间复杂度O(1)的基础上再增加O(N)的时间复杂度,故总的时间复杂度O(N)不变,且理所当然的保持了空间复杂度O(1)。至此,咱们的问题得到了圆满解决!
问题扩展
神级结论是如何来的?
我们的问题得到了解决,但本章尚未完,即决定完美洗牌算法的神级结论:若2*n=(3^k - 1),则恰好只有k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1),是如何来的呢?
要证明这个结论的关键就是:这所有的圈合并起来必须包含从1到M之间的所有正数,一个都不能少。这个证明有点麻烦,因为证明过程中会涉及到群论等数论知识,但再远的路一步步走也能到达。
首先,让咱们明确以下相关的概念,定理,及定义(搞清楚了这些东西,咱们便证明了一大半):
概念1 mod表示对一个数取余数,比如3 mod 5 =3,5 mod 3 =2;
定义1 欧拉函数ϕ(m) 表示为不超过m(即小于等于m)的数中,与m互素的正整数个数
定义2 若ϕ(m)=Ordm(a) 则称a为m的原根,其中Ordm(a)定义为:a ^d ( mod m),其中d=0,1,2,3…,但取让等式成立的最小的那个d。
结合上述定义1、定义2可知,2是3的原根,因为2^0 mod 3 = 1, 2^1 mod 3 = 2, 2^2 mod 3 = 1, 2^3 mod 3 = 2,{a^0 mod m,a^1 mod m,a^2}得到集合S={1,2},包含了所有和3互质的数,也即d=ϕ(2)=2,满足原根定义。
而2不是7的原根,这是因为2^0 mod 7 = 1, 2^1 mod 7 = 2, 2^2 mod 7 = 4, 2^3 mod 7 = 1,2^4 mod 7 = 2,2^5 mod 7 = 4,2^6 mod 7 = 1,从而集合S={1,2,4}中始终只有1、2、4三种结果,而没包含全部与7互质的数(3、6、5便不包括),,即d=3,但ϕ(7)=6,从而d != ϕ(7),不满足原根定义。
再者,如果说一个数a,是另外一个数m的原根,代表集合S = {a^0 mod m, a^1 mod m, a^2 mod m…… },得到的集合包含了所有小于m并且与m互质的数,否则a便不是m的原根。而且集合S = {a^0 mod m, a^1 mod m, a^2 mod m…… }中可能会存在重复的余数,但当a与m互质的时候,得到的{a^0 mod m, a^1 mod m, a^2 mod m}集合中,保证了第一个数是a^0 mod m,故第一次发现重复的数时,这个重复的数一定是1,也就是说,出现余数循环一定是从开头开始循环的。
定义3 对模指数,a对模m的原根定义为 ,st:中最小的正整数d
再比如,2是9的原根,因为,为了让除以9的余数恒等于1,可知最小的正整数d=6,而ϕ(m)=6,满足原根的定义。
定理1 同余定理:两个整数a,b,若它们除以正整数m所得的余数相等,则称a,b对于模m同余,记作,读做a与b关于模m同余。
定理2 当p为奇素数且a是的原根时⇒ a也是的原根
定理3 费马小定理:如果a和m互质,那么a^ϕ(m) mod m = 1
定理4 若(a,m)=1 且a为m的原根,那么a是(Z/mZ)*的生成元。
取a = 2, m = 3。
我们知道2是3的原根,2是9的原根,我们定义S(k)表示上述的集合S,并且取x = 3^k(x表示为集合S中的数)。
所以:
S(1) = {1, 2}
S(2) = {1, 2, 4, 8, 7, 5}
我们没改变圈元素的顺序,由前面的结论S(k)恰好是一个圈里的元素,且认为从1开始循环的,也就是说从1开始的圈包含了所有与3^k互质的数。
那与3^k不互质的数怎么办?如果0 < i < 3^k与 3^k不互质,那么i 与3^k的最大公约数一定是3^t的形式(只包含约数3),并且 t < k。即gcd(i , 3^k) = 3^t,等式两边除以个3 ^ t,即得gcd( i/(3^t),3^(k - t) ) = 1, i/(3^t) 都与3^(k - t) 互质了,并且i / (3^t) < 3^(k - t), 根据S(k)的定义,可见i/(3^t) 在集合S(k - t)中。
同理,任意S(k - t)中的数x,都满足gcd(x , 3^k) = 1,于是gcd(3^k , x* 3^t) = 3 ^ t, 并且x3^t < 3^k。可见S(k - t)中的数x3^t 与 i形成了一一对应的关系。
也就是说S(k - t)里每个数x* 3^t形成的新集合包含了所有与3^k的最大公约数为3^t的数,它也是一个圈,原先圈的头部是1,这个圈的头部是3^t。
于是,对所有的小于 3^k的数,根据它和3^k的最大公约数,我们都把它分配到了一个圈里去了,且k个圈包含了所有的小于3^k的数。
下面,举个例子,如caopengcs所说,当我们取“a = 2, m = 3时,
我们知道2是3的原根,2是9的原根,我们定义S(k)表示上述的集合S,并且x= 3^k。
所以S(1) = {1, 2}
S(2) = {1, 2, 4, 8, 7, 5}
比如k = 3。 我们有:
S(3) = {1, 2 ,4 , 8, 16, 5, 10, 20, 13, 26, 25, 23, 19, 11, 22, 17, 7, 14} 包含了小于27且与27互质的所有数,圈的首部为1,这是原根定义决定的。
那么与27最大公约数为3的数,我们用S(2)中的数乘以3得到。 S(2) * 3 = {3, 6, 12, 24, 21, 15}, 圈中元素的顺序没变化,圈的首部是3。
与27最大公约数为9的数,我们用S(1)中的数乘以9得到。 S(1) * 9 = {9, 18}, 圈中得元素的顺序没变化,圈的首部是9。
因为每个小于27的数和27的最大公约数只有1, 3, 9这3种情况,又由于前面所证的一一对应的关系,所以S(2) * 3包含了所有小于27且与27的最大公约数为3的数,S(1) * 9 包含了所有小于27且和27的最大公约数为9的数。”
换言之,若定义为整数,假设/N定义为整数Z除以N后全部余数的集合,包括{0…N-1}等N个数,而(/N)*则定义为这Z/N中{0…N-1}这N个余数内与N互质的数集合。
则当n=13时,2n+1=27,即得/N ={0,1,2,3,…,26},(/N)*相当于就是{0,1,2,3,…,26}中全部与27互素的数的集合;
而2^k(mod 27)可以把(/27)*取遍,故可得这些数分别在以下3个圈内:
取头为1,(/27)*={1,2,4,8,16,5,10,20,13,26,25,23,19,11,22,17,7,14},也就是说,与27互素且小于27的正整数集合为{1,2,4,8,16,5,10,20,13,26,25,23,19,11,22,17,7,14},因此ϕ(m) = ϕ(27)=18, 从而满足的最小d = 18,故得出2为27的原根;
取头为3,就可以得到{3,6,12,24,21,15},这就是以3为头的环,这个圈的特点是所有的数都是3的倍数,且都不是9的倍数。为什么呢?因为2^k和27互素。
具体点则是:如果3×2^k除27的余数能够被9整除,则有一个n使得32^k=9n(mod 27),即32^k-9n能够被27整除,从而3*2^k-9n=27m,其中n,m为整数,这样一来,式子约掉一个3,我们便能得到2^k=9m+3n,也就是说,2^k是3的倍数,这与2^k与27互素是矛盾的,所以,3×2^k除27的余数不可能被9整除。
此外,2^k除以27的余数可以是3的倍数以外的所有数,所以,2^k除以27的余数可以为1,2,4,5,7,8,当余数为1时,即存在一个k使得2^k-1=27m,m为整数。
式子两边同时乘以3得到:32^k-3=81m是27的倍数,从而32^k除以27的余数为3;
同理,当余数为2时,2^k - 2 = 27m,=> 32^k- 6 =81m,从而32^k除以27的余数为6;
当余数为4时,2^k - 4 = 37m,=> 32^k - 12 =81m,从而32^k除以27的余数为12;
同理,可以取到15,21,24。从而也就印证了上面的结论:取头为3,就可以得到{3,6,12,24,21,15}。
取9为头,这就很简单了,这个圈就是{9,18}
你会发现,小于27的所有自然数,要么在第一个圈里面,也就是那些和27互素的数;要么在第二个圈里面,也就是那些是3的倍数,但不是9的倍数的数;要么在第三个圈里面,也就是是9倍数的数,而之所以能够这么做,就是因为2是27的本原根。证明完毕。
最后,咱们也再验证下上述过程:
因为,故:
i = 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
由于n=13,2n+1 = 27,据此公式可知,上面第 i 位置的数将分别变成下述位置的:
i = 2 4 6 8 10 12 14 16 18 20 22 24 26 1 3 5 7 9 11 13 15 17 19 21 23 25 0
根据i 和 i‘ 前后位置的变动,我们将得到3个圈:
1->2->4->8->16->5->10->20->13->26->25->23->19->11->22->17->7->14->1;
3->6->12->24->21->15->3
9->18->9
没错,这3个圈的数字与咱们之前得到的3个圈一致吻合,验证完毕。
举一反三
至此,本章开头提出的问题解决了,完美洗牌算法的证明也证完了,是否可以止步了呢?OH,NO!读者有无思考过下述问题:
1、既然完美洗牌问题是给定输入:a1,a2,a3,……aN,b1,b2,b3,……bN,要求输出:b1,a1,b2,a2,……bN,aN;那么有无考虑过它的逆问题:即给定b1,a1,b2,a2,……bN,aN,,要求输出a1,a2,a3,……aN,b1,b2,b3,……bN ?
2、完美洗牌问题是两手洗牌,假设有三只手同时洗牌呢?那么问题将变成:输入是a1,a2,……aN, b1,b2,……bN, c1,c2,……cN,要求输出是c1,b1,a1,c2,b2,a2,……cN,bN,aN,这个时候,怎么处理?
2.15本章数组和队列的习题
1、不用除法运算
两个数组a[N],b[N],其中A[N]的各个元素值已知,现给b[i]赋值,b[i] = a[0]*a[1]*a[2]…*a[N-1]/a[i];
要求:
- 1.不准用除法运算
- 2.除了循环计数值,a[N],b[N]外,不准再用其他任何变量(包括局部变量,全局变量等)
- 3.满足时间复杂度O(n),空间复杂度O(1)。
提示:题目要求b[i] = a[0]*a[1]*a[2]…*a[N-1]/a[i] ,相当于求:a[0]*a[1]*a[2]*a[3]…a[i-1]*a[i+1]…*a[N-1],等价于除掉当前元素a[i],其他所有元素(a[i]左边部分,和a[i]右边部分)的积。
记left[i]=∏a[k], (k=1…i-1); right=∏a[k], (k=i+1…n),根据题目描述b[i]=left[i] * right[i], 对于每一个b[i]初始化为1,left[i]和right[i]两部分可以分开两次相乘,即对于循环变量i=1…n, b[i]=left[i];b[n-i]=right[n-i], 循环完成时即可完成计算。
参考代码如下所示:
void Multiplication(int a[], int output[], int length)
{
int left = 1;
int right = 1;
for (int i = 0; i < length; i++)
output[i] = 1;
for (int i = 0; i < length; i++)
{
output[i] *= left;
output[length - i - 1] *= right;
left *= a[i];
right *= a[length - i - 1];
}
}
3、找出数组中唯一的重复元素
1-1000放在含有1001个元素的数组中,只有唯一的一个元素值重复,其它均只出现一次。
每个数组元素只能访问一次,设计一个算法,将它找出来;不用辅助存储空间,能否设计一个算法实现?
4、找出唯一出现的数
一个数组里,数都是两两出现的,但是有三个数是唯一出现的,找出这三个数。
5、找出反序的个数
给定一整型数组,若数组中某个下标值大的元素值小于某个下标值比它小的元素值,称这是一个反序。
即:数组a[]; 对于i < j 且 a[i] > a[j],则称这是一个反序。
给定一个数组,要求写一个函数,计算出这个数组里所有反序的个数。
6、
有两个序列A和B,A=(a1,a2,…,ak),B=(b1,b2,…,bk),A和B都按升序排列,对于1<=i,j<=k,求k个最小的(ai+bj),要求算法尽量高效。
8
假设一个大小为100亿个数据的数组,该数组是从小到大排好序的,现在该数组分成若干段,每个段的数据长度小于20「也就是说:题目并没有说每段数据的size 相同,只是说每个段的 size < 20 而已」,然后将每段的数据进行乱序(即:段内数据乱序),形成一个新数组。请写一个算法,将所有数据从小到大进行排序,并说明时间复杂度。
9
20个排序好的数组,每个数组500个数,按照降序排序好的,让找出500个最大的数。
10
O(1)空间内实现矩阵转置。
11
有N个数,组成的字符串,如012345,求出字串和取MOD3==0的子串,如012 12 123 45。
12
从一列数中筛除尽可能少的数使得从左往右看,这些数是从小到大再从大到小的。
提示:双端 LIS 问题,用 DP 的思想可解。
13
有两个序列a,b,大小都为n,序列元素的值是任意整数,无序。要求:通过交换a,b中的元素,使[序列a元素的和]与[序列b元素的和]之间的差最小。
例如:
var a=[100,99,98,1,2, 3];
var b=[1, 2, 3, 4,5,40]。
14、螺旋矩阵
Given a matrix of m x n elements (m rows, n columns), return all elements of the matrix in spiral order。一句话,即为螺旋矩阵问题。
举个例子,给定如下的一个矩阵:
你应该返回:[1,2,3,6,9,8,7,4,5]。如下图所示,遍历顺序为螺旋状:
15
给你10分钟时间,根据上排给出十个数,在其下排填出对应的十个数 要求下排每个数都是先前上排那十个数在下排出现的次数。
上排的十个数如下:
0,1,2,3,4,5,6,7,8,9
举一个例子,
数值: 0,1,2,3,4,5,6,7,8,9
分配: 6,2,1,0,0,0,1,0,0,0
0在下排出现了6次,1在下排出现了2次,
2在下排出现了1次,3在下排出现了0次…
以此类推…
16
对于一个整数矩阵,存在一种运算,对矩阵中任意元素加一时,需要其相邻(上下左右),某一个元素也加一,现给出一正数矩阵,判断其是否能够由一个全零矩阵经过上述运算得到。
17
一个整数数组,长度为n,将其分为m份,使各份的和相等,求m的最大值。
比如{3,2,4,3,6} 可以分成
- {3,2,4,3,6} m=1;
- {3,6}{2,4,3} m=2
- {3,3}{2,4}{6} m=3
所以m的最大值为3。
18
求一个数组的最长递减子序列 比如{9,4,3,2,5,4,3,2}的最长递减子序列为{9,5,4,3,2}。
19
如何对n个大小都小于100的整数进行排序,要求时间复杂度O(n),空间复杂度O(1)。
20
输入一个正数n,输出所有和为n连续正数序列。例如输入15,由于1+2+3+4+5=4+5+6=7+8=15,所以输出3个连续序列1-5、4-6和7-8。
21、找出数组中两个只出现一次的数字
一个整型数组里除了两个数字之外,其他的数字都出现了两次。请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。
22、找出数组中两个只出现一次的数字
题目:一个整型数组里除了两个数字之外,其他的数字都出现了两次。
请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。
23、把数组排成最小的数
输入一个正整数数组,将它们连接起来排成一个数,输出能排出的所有数字中最小的一个。例如输入数组{32, 321},则输出这两个能排成的最小数字32132。
24、旋转数组中的最小元素
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个排好序的数组的一个旋转,输出旋转数组的最小元素。例如数组{3, 4, 5, 1, 2}为{1, 2, 3, 4, 5}的一个旋转,该数组的最小值为1。
提示:从头到尾遍历数组一次,就能找出最小的元素,时间复杂度显然是O(N)。但这个思路没有利用输入数组的特性,请读者继续思考更好的解法。
25
N个鸡蛋放到M个篮子中,篮子不能为空,要满足:对任意不大于N的数量,能用若干个篮子中鸡蛋的和表示。
写出函数,对输入整数N和M,输出所有可能的鸡蛋的放法。
比如对于9个鸡蛋5个篮子
解至少有三组:
1 2 4 1 1
1 2 2 2 2
1 2 3 2 1
26
请把一个整形数组中重复的数字去掉。例如:
1, 2, 0, 2, -1, 999, 3, 999, 88
答案应该是:
1, 2, 0, -1, 999, 3, 88
27
有一台机器,上面有m个储存空间。然后有n个请求,第i个请求计算时需要占 R[i]个空间,储存计算结果则需要占据O[i]个空间(据O[i]个空间(其中O[i]<R[i])。问怎么安排这n个请求的顺序,使得所有请求都能完成。你的算法也应该能够判断出无论如何都不能处理完的情况。
比方说,m=14,n=2,R[1]=10,O[1]=5,R[2]=8,O[2]=6。在这个例子中,我们可以先运行第一个任务,剩余9个单位的空间足够执行第二个任务;但如果先走第二个任务,第一个任务执行时空间就不够了,因为10>14-6。
28
在一维坐标轴上有n个区间段,求重合区间最长的两个区间段。
29
如果用一个循环数组q[0…m-1]表示队列时,该队列只有一个队列头指针front,不设队列尾指针rear,求这个队列中从队列投到队列尾的元素个数(包含队列头、队列尾)。
30
给定一个实数数组,按序排列(从小到大),从数组从找出若干个数,使得这若干个数的和与M最为接近,描述一个算法,并给出算法的复杂度。
有N个正实数(注意是实数,大小升序排列) x1 , x2 … xN,另有一个实数M。 需要选出若干个x,使这几个x的和与 M 最接近。 请描述实现算法,并指出算法复杂度。
31
有无序的实数列V[N],要求求里面大小相邻的实数的差的最大值,关键是要求线性空间和线性时间。
32
一个数组保存了N个结构,每个结构保存了一个坐标,结构间的坐标都不相同,请问如何找到指定坐标的结构(除了遍历整个数组,是否有更好的办法)?
提示:要么预先排序,二分查找。要么哈希。hash的话,坐标(x,y)你可以当做一个2位数,写一个哈希函数,把(x,y)直接转成“(x,y)”作为key,默认用string比较。或如Edward Lee所说,将坐标(x, y)作为 Hash 中的 key。例如(m, n),通过 (m,n) 和 (n, m) 两次查找看是否在 HashMap 中。也可以在保存时就规定 (x, y) , x < y ,在插入之前做个判断。
33
现在有1千万个随机数,随机数的范围在1到1亿之间。现在要求写出一种算法,将1到1亿之间没有在随机数中的数求出来。
提示:编程珠玑上有此类似的一题,如果有足够的内存的话可以用位图法,即开一个1亿位的bitset,内存为100m/8== 12.5m, 然后如果一个数有出现,对应的bitset上标记为1,最后统计bitset上为0的即可。
34
有N+2个数,N个数出现了偶数次,2个数出现了奇数次(这两个数不相等),问用O(1)的空间复杂度,找出这两个数,不需要知道具体位置,只需要知道这两个值。
提示:xor一次,得到2个奇数次的数之和x。第二步,以x(展开成二进制)中有1的某位(假设第i位为1)作为划分,第二次只xor第i位为1的那些数,得到y。然后x xor y以及y便是那两个数。
35
一个整数数组,有n个整数,如何找其中m个数的和等于另外n-m个数的和?
36
一个数组,里面的数据两两相同,只有两个数据不同,要求找出这两个数据。要求时间复杂度0(N)空间复杂度O(1)。
37
一个环形公路,上面有N个站点,A1, …, AN,其中Ai和Ai+1之间的距离为Di,AN和A1之间的距离为D0。
高效的求第i和第j个站点之间的距离,空间复杂度不超过O(N)。
38
将一个较大的钱,不超过1000000(10^6)的人民币,兑换成数量不限的100、50、10、5、2、1的组合,请问共有多少种组合呢?
39
对于一个数组{1,2,3}它的子数组有{1,2},{1,3}{2,3},{1,2,3},元素之间可以不是连续的,对于数组{5,9,1,7,2,6,3,8,10,4},升序子序列有多少个?
或者换一种表达为:数组int a[]={5,9,1,7,2,6,3,8,10,4} 。求其所有递增子数组(元素相对位置不变)的个数, 例如:{5,9},{5,7,8,10},{1,2,6,8}。
40
M*M的方格矩阵,其中有一部分为障碍,八个方向均可以走,现假设矩阵上有Q+1节点,从(X0,Y0)出发到其他Q个节点的最短路径。
其中,1<=M<=1000,1<=Q<=100。
41
设子数组A[0:k]和A[k+1:N-1]已排好序(0≤K≤N-1)。试设计一个合并这2个子数组为排好序的数组A[0:N-1]的算法。要求算法在最坏情况下所用的计算时间为O(N),只用到O(1)的辅助空间。
提示:此题来源于在高德纳的计算机程序设计艺术第三卷第五章排序。
42
一个数组[1,2,3,4,6,8,9,4,8,11,18,19,100]
前半部分是是一个递增数组,后面一个还是递增数组,但整个数组不是递增数组,那么怎么最快的找出其中一个数?
43
数组中的数分为两组,让给出一个算法,使得两个组的和的差的绝对值最小,数组中的数的取值范围是0<x<100,元素个数也是大于0, 小于100 。
比如a[]={2,4,5,6,7},得出的两组数{2,4,6}和{5,7},abs(sum(a1)-sum(a2))=0;
比如{2,5,6,10},abs(sum(2,10)-sum(5,6))=1,所以得出的两组数分别为{2,10}和{5,6}。
44
从1…n中随机输出m个不重复的数
45
数组al[0,mid-1] 和 al[mid,num-1],都分别有序。将其merge成有序数组al[0,num-1],要求空间复杂度O(1)。
46、求旋转数组的最小元素
把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个排好序的数组的一个旋转,输出旋转数组的最小元素。例如数组{3, 4, 5, 1, 2}为{1, 2, 3, 4, 5}的一个旋转,该数组的最小值为1。
47
在一个平面坐标系上,有两个矩形,它们的边分别平行于X和Y轴。
其中,矩形A已知, ax1(左边), ax2(右边), ay1(top的纵坐标), ay2(bottom纵坐标). 矩形B,类似,就是 bx1, bx2, by1, by2。这些值都是整数就OK了。
要求是,如果矩形没有交集,返回-1, 有交集,返回交集的面积。
int area(rect const& a, rect const&
{
…
}
48
一个数组里,数都是两两出现的,但是有三个数是唯一出现的,找出这三个数。
提示:3个数唯一出现,各不相同。由于x与a、b、c都各不相同,因此x^a、x^b、x^c都不等于0。所以无法简单的用异或解决此问题。
49、计算逆序数组对
给定一整型数组,若数组中某个下标值大的元素值小于某个下标值比它小的元素值,称这是一个反序。
即:数组a[]; 对于i < j 且 a[i] > a[j],则称这是一个反序。
给定一个数组,要求写一个函数,计算出这个数组里所有反序的个数。
50
有两个序列A和B,A=(a1,a2,…,ak),B=(b1,b2,…,bk),A和B都按升序排列,对于1<=i,j<=k,求k个最小的(ai+bj),要求算法尽量高效。
51
有20个数组,每个数组里面有500个数组,降序排列,每个数字是32位的unit,求出这10000个数字中最大的500个。
52
100个任务,100个工人每人可做一项任务,每个任务每个人做的的费用为t[100][100],求一个分配任务的方案使得总费用最少。
提示:匈牙利算法。
53
寻找3个数的中位数
提示:可以采用两两比较的思路。
54
给定一数组,输出满足2a=b(a,b代表数组中的数)的数对,要求时间复杂度尽量低。
55
1万个元素的数组,90%的元素都是1到100的数,10%的元素是101–10000的数,如何高效排序。
56
一个有序数组(从小到大排列),数组中的数据有正有负,求这个数组中的最小绝对值。
57
等价于n*n的矩阵,填写0,1,要求每行每列的都有偶数个1 (没有1也是偶数个),问有多少种方法。
58
数组里找到和最接近于0的两个值。
59
N个数组,每个数组中的元素都是递增的顺序,现在要找出这N个数组中的公共元素部分,如何做? 注:不能用额外辅助空间。
60
二重歌德巴赫猜想
所有大于等于6的偶数都可以表示成两个(奇)素数之和。
给定1-10000,找到可以用两个素数之和表示每一个偶数的两个素数,然后输出这两个素数,如果有多对,则只需要输出其中之一对即可。
61
N个整数(数的大小为0-255)的序列,把它们加密为K个整数(数的大小为0-255).再将K个整数顺序随机打乱,使得可以从这乱序的K个整数中解码出原序列。设计加密解密算法,且要求K<=15*N.
如果是:
- N<=16,要求K<=16*N.
- N<=16,要求K<=10*N.
- N<=64,要求K<=15*N.
62
两个无序数组分别叫A和B,长度分别是m和n,求中位数,要求时间复杂度O(m+n),空间复杂度O(1) 。
63
假设一个大小为100亿个数据的数组,该数组是从小到大排好序的,现在该数组分成若干段,每个段的数据长度小于20「也就是说:题目并没有说每段数据的size 相同,只是说每个段的 size < 20 而已」,然后将每段的数据进行乱序(即:段内数据乱序),形成一个新数组。
请写一个算法,将所有数据从小到大进行排序,并说明时间复杂度。
64
20个排序好的数组,每个数组500个数,按照降序排序好的,让找出500个最大的数。
65
请自己用双向链表实现一个队列,队列里节点内存的值为int,要求实现入队,出队和查找指定节点的三个功能。
66
n个数字(0,1,…,n-1)形成一个圆圈,从数字0开始,每次从这个圆圈中删除第m个数字(第一个为当前数字本身,第二个为当前数字的下一个数字)。
当一个数字删除后,从被删除数字的下一个继续删除第m个数字。求出在这个圆圈中剩下的最后一个数字。
67、在从1到n的正数中1出现的次数
输入一个整数n,求从1到n这n个整数的十进制表示中1出现的次数。
例如输入12,从1到12这些整数中包含1 的数字有1,10,11和12,1一共出现了5次。
68
对于给定的整数集合S,求出最大的d,使得a+b+c=d。a,b,c,d互不相同,且都属于S。集合的元素个数小于等于2000个,元素的取值范围在[-2^28,2^28 - 1],假定可用内存空间为100MB,硬盘使用空间无限大,试分析时间和空间复杂度,找出最快的解决方法。
提示:两两相加转为多项式乘法,比如(1 2 4 6) + (2 3 4 5) => (x + x^2 + x^4 + x^6)*(x^2 + x^3 + x^4 + x^5) 。
69
长度为N的数组乱序存放着0带N-1.现在只能进行0与其他数的swap操作,请设计并实现排序,必须通过交换实现排序。
70
输入是两个整数数组,他们任意两个数的和又可以组成一个数组,求这个和中前k个数怎么做?
分析:假设两个整数数组为A和B,各有N个元素,任意两个数的和组成的数组C有N^2个元素。那么可以把这些和看成N个有序数列:
A[1]+B[1] <= A[1]+B[2] <= A[1]+B[3] <=…
A[2]+B[1] <= A[2]+B[2] <= A[2]+B[3] <=…
…
A[N]+B[1] <= A[N]+B[2] <= A[N]+B[3] <=…
问题转变成,在这N个有序数列里,找到前k小的元素”。
71、求500万以内的所有亲和数
如果两个数a和b,a的所有真因数之和等于b,b的所有真因数之和等于a,则称a,b是一对亲和数。 例如220和284,1184和1210,2620和2924。
72、杨辉三角的变形
1
1 1 1
1 2 3 2 1
1 3 6 7 6 3 1
以上三角形的数阵,第一行只有一个数1,以下每行的每个数,是恰好是它上面的数,左上的数和右上数等3个数之和(如果不存在某个数,认为该数就是0)。
求第n行第一个偶数出现的位置。如果没有偶数,则输出-1。例如输入3,则输出2,输入4则输出3。
73、三元组的数量
{5 3 1}和{7 5 3}是2组不同的等差三元组,除了等差的性质之外,还有个奇妙的地方在于:5^2 – 3^2 – 1^2 = 7^2 – 5^2 – 3^2 = N = 15。
{19 15 11}同{7 5 3}这对三元组也存在同样的性质:19^2 – 15^2 – 11^2 = 7^2 – 5^2 – 3^2 = N = 15。
这种成对的三元组还有很多。当N = 15时,有3对,分别是{5 3 1}和{7 5 3},{5 3 1}和{19 15 11},{7 5 3}和{19 15 11}。
现给出一个区间 [a,b]求a <= N <= b 范围内,共有多少对这样的三元组。(1 <= a <= b <= 5*10^6)
例如:a = 1,b = 30,输出:4。(注:共有4对,{5 3 1}和{7 5 3},{5 3 1}和{19 15 11},{7 5 3}和{19 15 11},{34 27 20}和{12 9 6}。
74、格子涂色
有一行方格,共有n个,编号为1-n,现在要用两种颜色(例如蓝色和黄色)给每个方格涂色,每个方格只能涂两种颜色之一,不能不涂。要求最终至少有m个连续的格子被涂成蓝色,问一共有多少种着色方法。例如n = 4, m = 3,有3种涂色的方法,分别为
- 蓝蓝蓝黄
- 蓝蓝蓝蓝
- 黄蓝蓝蓝
75、寻找直方图中面积最大的矩形
给定直方图,每一小块的height由N个非负整数所确定,每一小块的width都为1,请找出直方图中面积最大的矩形。
如下图所示,直方图中每一块的宽度都是1,每一块给定的高度分别是[2,1,5,6,2,3]:
那么上述直方图中,面积最大的矩形便是下图所示的阴影部分的面积,面积= 10单位。
注:上述所有的文章已于2014年6月30日基本停止更新(当然,如果有bug,请随时指出,一经确认,立即修正)。所有进一步的修改、改动、优化请见2015年10月14日上市销售的纸质版《编程之法:面试和算法心得》。感谢大家。
July、二零一四年八月十四日。
本书版权属于原作者,转载于:https://github.com/julycoding/The-Art-Of-Programming-By-July/blob/master/ebook/zh/Readme.md