《编程之法:面试和算法心得》
本章导读
面试编程题的常用思路:
- 暴力穷举(递归、回溯) 如求n个数的全排列或八皇后(N皇后问题)
- 分治法
- 空间换时间(如活用哈希表)
- 合适的数据结构,如寻找最小的k个数中,用堆代替数组
- 排序
- 不允许排序,考虑不改变数列顺序的贪心算法(如最小生成树Prim、kruskal和最短路径dijkstra,或动态规划(01背包)
- 细节处理,不要忽略边界
小结:
1.寻找最小K个数:(1).排序 (2) k最大堆 (3) partition
2.寻找和为给定值的两个数:(1). sum-a[i] 数组 (2). hash表 (3) 两指针两端扫描
3.寻找和为定值的多个数:(1) 0-1背包 (2) 回溯+剪枝
4.最大连续子数组和:(1).暴力 (2).(a[j], currSum+a[j])扫描
5.跳台阶:(1) fibonacci
6.奇偶排序:(1)partition (2) 第二种partition
7.荷兰国旗: (1) 三指针partition
8.矩阵相乘:(1). 暴力 (2) strassen算法
9.完美洗牌:(1). 暴力 (2). i --> (2*i)%(2*n+1)
(3)利用神级结论2*n=(3^k - 1)
文章目录
第二章 数组
2.1 寻找最小k个数
题目描述
输入n个整数,输出其中最小的k个。
分析与解法
解法一: 排序
思想:排序,遍历输出K
时间复杂度:O(n * logn) + O(k) = O(n*logn)
解法二: k数组+选择排序
思想:维护一个容量为k的数组,利用选择或交换排序得到最大值kmax(O(k)),n-k个每个与kmax比较然后更新(O(k))
时间复杂度:O(n*k)
解法三: k最大堆
思想:维护容量为k的最大堆
时间复杂度:O( k + (n-k)*logk) = O(n*logk)
解法四: 快速选择算法
快速选择算法:
- 选取S中一个元素作为枢纽元pivot,将集合S-{pivot}分割成S1和S2,就像快速排序那样
- 如果k <= |S1|, 第k个最小元素在S1中,返回QuickSelect(S1, k)。
- 如果k = 1+|S1|,pivot为第k个最小元素,直接返回
- 否则,第k个最小元素在S2中,即S2中第(k - |S1| - 1) 个最小元素,返回QuickSelect(S2, k - |S1| - 1)
平均时间复杂度O(n)
更进一步,《算法导论》第9章第9.3节介绍了一个最坏情况下亦为O(n)时间的SELECT算法,有兴趣的读者可以参看。
2.2 寻找和为定值的两个数
题目描述
输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。
要求时间复杂度是O(N)。如果有多对数字的和等于输入的数字,输出任意一对即可。
例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。
分析与解法
直接穷举,从数组中任选2个数判断和是否为给定数字,O(n^2)
题目相当于:对每个a[i],查找sum - a[i] 是否也在原始序列中,如何提高查找速度?
答案是二分查找,将O(N)查找变为O(log N),如果有序直接二分,无序,则排序后二分
如何优化到O(N)呢?
解法一:开创sum-a[i]的数组
思想:创建sum-a[i]的数组,由左到右扫a[i],由右到左扫b[i],相等即结果。
根据前面的分析,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(N),空间O(N)
解法三: 两指针两端扫描法
- 如果数组无序
- 先排序O(N logN)
- 两个指针i = 0, j = n-1
- 如果某时刻a[i] + a[j] > sum, j–
- 如果某时刻a[i] + a[j] < sum, i++
数组无序时,时间复杂度O(N logN + N) = O(N logN)
- 如果数组有序,直接扫,O(N)搞定,空间复杂度O(1)
解法总结
不论是有序还是无序,解决这类问题有三种解法:
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)的目标,除非原数组是有序的(指针扫描法),不然,当数组无序的话,就 (1)只能先排序,后指针扫描法或二分(时间 O(Nlog N),空间O(1)),或 (2)映射或hash(时间O(N),空间O(N))。时间或空间,必须牺牲一个,达到平衡。
综上,若是数组有序的情况下,优先考虑两个指针两端扫描法,以达到最佳的时O(N),空O(1)效应。否则,如果要排序的话,时间复杂度最快当然是只能达到O(N log N),空间O(1)则不在话下。
问题扩展
- 如果在返回找到的两个数的同时,还要求你返回这两个数的位置列?
- 如果需要输出所有满足条件的整数对呢?
- 如果把题目中的要你寻找的两个数改为“多个数”,或任意个数列?
2.3 寻找和为定值的多个数
题目描述
输入两个整数n和sum,从数列1,2,3…n 中随意取几个数,使其和等于sum,要求将其中所有的可能组合列出来。
分析与解法
解法一:0-1背包
注意到取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
}
学会用list.reverse()和list.push_front()和list.pop_front()
解法二:???回溯+剪枝
这个问题属于子集和问题(也是背包问题)。本程序采用回溯法+剪枝,其中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值。
2.4 最大连续子数组和
题目描述
输入一个整形数组,数组里有正数也有负数。数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。 求所有子数组的和的最大值,要求时间复杂度为O(n)。
例如输入的数组为1, -2, 3, 10, -4, 7, 2, -5,和最大的子数组为3, 10, -4, 7, 2, 因此输出为该子数组的和18。
分析与解法
解法一:暴力
思想:暴力,计算每个i到j元素的和
时间复杂度:O(n^3)
解法二:(a[j], currSum+a[j])扫描
事实上,当我们令currSum为当前最大子数组的和,maxSum为最后要返回的最大子数组的和,当我们往后扫描时,
- 对第j+1个元素有两种选择:要么放入前面找到的子数组,要么做为新子数组的第一个元素;
- 如果currSum加上当前元素a[j]后不小于a[j],则令currSum加上a[j],否则currSum重新赋值,置为下一个元素,即currSum = a[j]。
- 同时,当currSum > maxSum,则更新maxSum = currSum,否则保持原值,不更新。
举一反三
- 最大矩形,子矩阵和
- 允许交换两个数的位置,求最大子数组和。
https://app.codility.com/cert/view/certDUMWPM-8RF86G8P9QQ6JC8X/details/
2.6 跳台阶
题目描述
一个台阶总共有n 级,如果一次可以跳1 级,也可以跳2 级。
求总共有多少总跳法,并分析算法的时间复杂度。
分析与解法
解法一:Fibonacci
f(n) = 1 (n = 1)
= 2 (n = 2)
= f(n-1) + f(n-2) (n>2)
解法二:空间优化
dp[n] = dp[n-1] + dp[n-2], 用两个dp[0], dp[1], dp[2] 即可
举一反三
- 换硬币问题
2.6 奇偶排序
题目描述
输入一个整数数组,调整数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。要求时间复杂度为O(n)。
分析与解法
解法一: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);
}
}
}
解法二:partition-2
按快排partition的第二种实现,就是wb教的那种
借鉴partition的上述实现,我们也可以维护两个指针i和j,一个指针指向数组的第一个数的前一个位置,我们称之为后指针i,向右移动;一个指针指向数组第一个数,称之为前指针j,也向右移动,且前指针j先向右移动。如果前指针j指向的数字是奇数,则令i指针向右移动一位,然后交换i和j指针所各自指向的数字。
PS: 感觉这种可以保证数据的相对位置不变
/奇偶互换
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]);
}
2.7 荷兰国旗
题目描述
现有n个红白蓝三种不同颜色的小球,乱序排列在一起,请通过两两交换任意两个球,使得从左至右,依次是一些红球、一些白球、一些蓝球。
分析与解法
借鉴partition过程,设置3个指针完成重新排列
解法一: partition
类似partition,三指针:一个前指针begin,一个中指针current,一个后指针end
- current所指元素为0时,与begin指针所指元素交换,current++,begin++
- current所指元素为1时,不动,current++
- current所指元素为2时,与end交换,current不动,end–。因为第三步中current指针所指元素与end指针所指元素交换之前,如果end指针之前指的元素是0,那么与current指针所指元素交换之后,current指针此刻所指的元素是0,此时,current指针能动么?不能动,因为如上述第1点所述,如果current指针所指的元素是0,还得与begin指针所指的元素交换。
//引用自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--;
}
}
2.8 矩阵相乘
题目描述
请编程实现矩阵乘法,并考虑当矩阵规模较大时的优化方法。
分析与解法
解法一、暴力解法
O(n^3)
解法二:strassen算法
分治矩阵
https://www.cnblogs.com/hdk1993/p/4552534.html
当给定两个二维矩阵AB时,可以分为更小的矩阵,我们发现在相乘的过程中,有8次乘法,4次加法
表面上看,Strassen算法仅仅比通用矩阵相乘算法好一点,因为通用矩阵相乘算法时间复杂度是n3,而Strassen算法复杂度只是O(nlog7)=O(n2.807)。但随着n的变大,比如当n >> 100时,Strassen算法是比通用矩阵相乘算法变得更有效率。
2.9 完美洗牌
题目描述
有个长度为2n的数组{a1,a2,a3,…,an,b1,b2,b3,…,bn},希望排序后{a1,b1,a2,b2,…,an,bn},请考虑有无时间复杂度o(n),空间复杂度0(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)的时间复杂度。 -
中间交换
当然,除了如上面所述的让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,…an,b1,b2,b3…bn,最终把它置换成b1,a1,b2,a2,…bn,an。读者可以看到,这个完美洗牌问题本质上与本题完全一致,只要在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可。
故下文将从完美洗牌算法的最基本的原型开始说起,以让读者能对此算法一目了然。
2.1 位置置换pefect_shuffle1算法
起始序列: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 i) % (2 * n + 1) 个位置。
再综合到任意情况,任意的第i个元素,我们最终换到了 (2 i) % (2 n + 1)的位置。 为何呢?因为: > 当0 < i < n时, 原式= (2i) % (2 n + 1) = 2i; > 当i > n时,原式(2 i) % (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
当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)
{
swap(a[i],a[from]);
}
}
2.2.2 神级结论:若2*n=(3^k - 1),则可确定圈的个数及各自头部的起始位置
下面我要引用此论文“A Simple In-Place Algorithm for In-Shuffle”的一个结论了,即 对于2*n = (3k-1)这种长度的数组,恰好只有k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1)。
论文原文部分为:
也就是说,利用上述这个结论,我们可以解决这种特殊长度2*n = (3^k-1)的数组问题,那么若给定的长度n是任意的咋办呢?此时,我们可以采取分而治之算法的思想,把整个数组一分为二,即拆分成两个部分:
让一部分的长度满足神级结论:若2*m = (3k-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,…2n
且为了能让前部分的序列满足神级结论2m = (3k-1),我们可以把中间那两段长度为n-m和m的段交换位置,即相当于把m+1…n,n+1…n+m的段循环右移m次(为什么要这么做?因为如此操作后,数组的前部分的长度为2m,而根据神级结论:当2m=3^k-1时,可知这长度2m的部分恰好有k个圈)。即(1…m)(m+1…n+m)(n+m+1…2n) --> (1…m)(n+1…n+m)(m+1…n)(n+m+1…2n),中间(n+1…n+m)的长度为m
而如果读者看过本系列第一章、左旋转字符串的话,就应该意识到循环位移是有O(N)的算法的,其思想即是把前n-m个元素(m+1… n)和后m个元素(n+1 … n+m)先各自翻转一下,再将整个段(m+1… n, n+1 … n+m)翻转下。
翻转后,得到的目标数组的下标为: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
从上文的分析过程中也就得出了我们的完美洗牌算法,其
算法流程为:
1.输入数组 A[1…2 n]
2.step 1 找到 2*m = 3k - 1 使得 3k <= 2 n < 3^(k +1)
3.step 2 把a[m + 1…n + m]那部分循环移m位
4.step 3 对每个i = 0,1,2…k - 1,3i是个圈的头部,做cycle_leader算法,数组长度为m,所以对2 m + 1取模。
5. step 4 对数组的后面部分A[2 m + 1… 2 n]继续使用本算法, 这相当于n减小了m。
此完美洗牌算法实现的参考代码如下:
//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两两相邻元素即可
证明:…
本章数组和队列的习题
…