第5章:优化时间和空间效率
面试题29:数组中出现次数超过一半的数字
- 如果是已经排好序的数组,则该数字一定是中位数。但排序需要O(NlogN)的时间。
- 在遍历数组的时候,可以用哈希表记录下每个数字出现的次数,如果发现当前遍历的数字出现次数高于数组长度一半,输出即可。
哈希表有常数级的访问操作,实际上等同于一种“开挂”。 - 可以利用快排的思想,当发现分割点小于数组中心点时,只排右边的部分,而不是两个部分全排,反之亦然。
int part(int* a,int n,int left,int right){
int k=-1;
for(int i=0;i<right;++i){
if(a[i]<=a[right]){
swap(a[i],a[k+1]);
k++;
}
}
swap(a[right],a[k+1]);
if(k+1==n/2)
return a[n/2];
if(k+1>n/2)
return part(a,n,left,k);
else
return part(a,n,k+2,right);
}
注意,如果后续做验证,需要遍历统计该数字的次数,看是否真的高于总长度一半。
函数设计的时候,总长度,左右范围都要设计参数。因为每次判断时,必须知道分割点在整个数组的位置。
关于时间复杂度:
快排的时间是O(NlogN),简单来说,每次划分需要O(N),共需要划分logN次。
-------------------------
----------- ------------
----- ----- ----- -----
-- -- -- -- -- -- -- --
例如,第一层,划分N的数组,假设需要F(N)的时间,F是N的线性函数。
第二层,划分N/2的数组,但是要两段,加起来是F(N)
后续同理;每层需要F(N)的时间,共LogN层,故最后的时间是NlogN,和归并排序同理。但归并排序,是强行均分;快排是理想情况下均分。
极端的情况,会使得层数很高,为N层,但是每层依然需要遍历N次,就成了N^2.
但是在这里,如果每次只需要处理一半,就会是下面的情况:
-------------------------
-----------
-----
--
这样共需要F(N)+F(N)/2+F(N)/4+….由数学等比数列可知,是O(N)。
4. 利用数组特性
因为目标数值出现的次数高于其他数值次数的总和,因此可以用以下奇特的方法:
两个变量,Num记录数字,cnt记录次数。
遍历数组,如果当前数字==Num,则cnt++,否则cnt–;
如果cnt为零,则Num=当前数字,cnt=1;
最后的num即为目标值。
相当于,cnt记录了某个值登记以后,与其后所有其他的值次数之差。某个值被登记之后,一直保持擂主的位置,直至被新擂主打败。
实现较简单,所以代码略。
面试题30:最小的K个数
- 方法同上,利用快排的划分思想,可以在O(N)内完成。当然这个数值和快排一样,都是理想状态下的情况。
- 使用一个容器装载K个数,然后遍历整个数组,如果发现当前遍历的数字小于这K个数的最大值,则替换掉它。
这样,就会面临两个问题:1. 查找最大的数;2.替换最大的数
容易想到利用一个排好序的数组来做,最大的数就是数组尾;但是替换掉它之后,必须重新排序,至少需要KLogK的时间。
对于这种只需要找到最大值,但其他部分可以无序的情况,用大根堆就很适合。因为堆每次调整,仅需要LogK的时间。
STL中没有专门的堆结构,但是有set和map,两者都是利用红黑树实现的。查找和插入删除都需要logK的时间。
有趣的是,本人验证,set内部从begin遍历打印,序列是升序的。
可能是因为,红黑树内部是无序的,但是迭代器迭代过程中,会依次搜寻下一个节点,使得最终输出有序。
待定:一般情况下我们认为set和map都是在常数时间完成操作的?
简单理解:红黑树是经过极度优化下的二叉树,极其稳定快速。即使10亿数,也能在30次内完成。
简单结论:set和map是小根堆,但可以指定额外参数使其为大根堆。
set和map的元素是const的,不可直接修改。
set<int,greater<int>>是大根堆(需要#include<functional>)否则为小根堆。
第二个参数指定额外种类。
回到题目本身:
set和map分有序无序,单值和多值的情况。这种情况当然用有序多值的multiset。
vector<int> GetLeastNumbers_Solution(vector<int> input, int k) {
set<int,greater<int> > bigSet;
for(auto x:input){
if(bigSet.size()<k)
bigSet.insert(x);
else{
if(x<*bigSet.begin()){
bigSet.erase(bigSet.begin());
bigSet.insert(x);
}
}
}
vector<int> v(bigSet.begin(),bigSet.end());
reverse(v.begin(),v.end());
return v;
}
面试题31:连续子数组的最大和
非常简单的动态规划。对于数组来说,通常会有一个维度i,表征以i结尾的子串的状态。
这里显然定义一个数组,其i项表示原数组中,以i作结尾的子串中最大的和是多少。
对于遍历到的数array[i],要么它纳入到前面的串尾,要么自立门户。前者需要它的前串的和大于0;否则就选择后者。
int FindGreatestSumOfSubArray(vector<int> array) {
int n=array.size();
vector<int> b(n);
b[0]=array[0];
for(int i=1;i<n;++i){
b[i]=array[i]+max(b[i-1],0);
}
return *max_element(b.begin(),b.end());
}
面试题32:从1到n整数中1出现的次数
通常这种情况要用到排列组合。例如:n为2134的时候,求1出现的次数。
其实这里容易想到,把2134划分为1~2000和2001~2134两个区间。因为第一个区间内,除了最高位之外,所有的位可以从0到9任意枚举。而后者等同于1~134内1出现的次数,就可以用递归。
首先厘清一个概念:
1. 从1到n中,1出现的次数
2. 从1到n中,含有1的数字共有几个
例如:1..20中,含1的数字有1 10 11 12…19共11个,但数字11有两个1,所以共出现了12个1.
所以1的计量方法是:
1出现的次数=千位上出现的1的次数+百位上出现1的次数+十位上出现1的次数+个位上出现1的次数
例如:从1到3999中,1出现的次数为:
千位上出现了多少次1?即,千位上是1的数字,共多少个?当然是千位 置1,其他位随意枚举,看可以产生多少数字。易得共10*10*10
种。(当然,前提是千位可以是1。如果改题目为求8出现的次数,那么千位必须不小于8才行。)
百位上出现了多少次1?即,百位上是1的数字,共有几个?当然是百位 置1,千位可以是[0..3],其他位随意枚举,共4*10*10
种。
十位个位的情形同百位。
结论:求[1…H9999]中x出现的次数。H表示最高位数字,H>=1.数字共有n位。
答案是:(H>=x?)*10^(n-1)+(H+1)*10^(n-2)
关于递归:上述例子可以看出,除了最高位以外,其他位置都是9的数字,容易用枚举方式求得答案。所以每个数进行区间分解:
F(1..2134)=F(1..1999)+F(2000)+F(2001..2134)
=F(1..1999)+F(1..134)
F(1..1134)=F(1..999)+F(1000)+F(1001..1134)
=F(1..999)+1+length(1001..1134)+F(1..134)
=F(1..999)+1+F(1..134)+134
所以可以看出,对于四位整数abcd来说,x出现的次数:
1. 如果a!=x ,F(abcd)=F([a-1]999)+F(bcd)
2. 如果a==x ,F(abcd)=F([a-1]999)+1+bcd+F(bcd)
//具体实现时,我们用字符串表示数字,比较方便
int num1(const char* str){
if(strlen(str)==1){
return str[0]>='1';
}
int len=strlen(str);
int first=str[0]-'0';
if(first==1){
return (len-1)*pow(10,len-2)+1+stoi(str+1)+num1(str+1);
}else{
return (first-1>=1)*pow(10,len-1)+(len-1)*pow(10,len-2)*first +num1(str+1);
}
}
面试题33:把数组排列成最小的数
很类似于“把字符串拼接成字典顺序最小的字符串”,这是一种排序问题,不过是按照“拼接结果”来构造排列条件。
例如:b ba ,如果单纯比较,b
class Solution {
public:
//类里面这种被STL函数调用的方法,用static来修饰
static bool rightF(const string& s1,const string& s2){
return s1+s2<s2+s1;
}
string PrintMinNumber(vector<int> numbers) {
if(numbers.size()==0)
return string();
vector<string> vs;
for(auto x:numbers){
vs.push_back(to_string(x));
}
//传入的函数参数,形参必须是同类的const引用;返回排序后前后两个对象,应该满足的条件
sort(vs.begin(),vs.end(),rightF);
string res;
for(auto x:vs)
res.append(x);
return res;
}
面试题34:丑数
把只包含因子2、3和5的数称作丑数(Ugly Number)。例如6、8都是丑数,但14不是,因为它包含因子7。 习惯上我们把1当做是第一个丑数。求按从小到大的顺序的第N个丑数。
抽象:已知一个数列的前n-1项是丑数数列,求它的第n项。
每个数都是由因子相乘得到,丑数的因子仅限于2,3,5.那么第n个丑数,一定是由前n-1个丑数中的某个基数,乘以2,3,5中的某个因子得到的。
例如:1 2 3 4 5 6 ?
因为因子是有限的3个,所以整个过程是3选一的过程。
假设因子是2,那么从数列中,找到第一个与2相乘之后,大于6的基数。该基数是4,乘以2后是8.
同理,假设因子是3,则该基数是3,乘以3后是9.
假设因子是5,该基数是2,乘以5后是10.
三者选最小,即为8.故目标值为8.
优化:3种因子各自记录上一次被选中时,其基数的位置。下次被调用时就可以从该位置向后寻找,大大提高效率。
int GetUglyNumber_Solution(int index) {
vector<int> v(index);
v[0]=1;
int *p2=&v[0],*p3=&v[0],*p5=&v[0];
for(int i=1;i<=index;++i){
while(2**p2<=v[i-1])
p2++;
while(3**p3<=v[i-1])
p3++;
while(5**p5<=v[i-1])
p5++;
int n2=2**p2,n3=3**p3,n5=5**p5;
v[i]=min(min(n2,n3),n5);
}
return v[index-1];
}
注意:在vector中使用指针做迭代是很危险的!因为vector更改size的操作(如pushback),会重分配内存使得指针失效!string也是如此,这种错误很难发现!所以建议用下标。
面试题35:第一个只出现一次的字符
其实很容易,用哈希表对每个字符出现次数进行统计,然后找到第一个次数为1的即可。注意“第一个”是原始字符串中出现的第一个,不是字典序中的第一个。
哈希表用数组即可。
int FirstNotRepeatingChar(string str) {
int cnt[256]={};
for(auto x:str){
cnt[x]++;
}
for(int i=0;i<str.length();++i){
if(cnt[str[i]]==1){
return i;
}
}
return -1;
}
面试题36:数组中的逆序对
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数P。
例如:[7 5 6 4]中,[7 6] [7 5] [7 4] [6 4][5 4]是逆序对。
很容易想到,对于数组中的A[i],用它和前面的数对比,比它大的数,就可以与它构成一个逆序对。这样需要O(N^2).
又想到,寻找比它大的数,如果在一个排好序的序列中,可以用二分法查找。但是比较完,又需要排序,插入时还是需要O(N)的时间。
这里使用了归并排序的方法,在merge过程中统计数据,边排序边统计。简直丧心病狂。
比如有一个数组a 1 3 5 2 4 6
可以划分为两段a1+a2:1 3 5
+ 2 4 6
逆序对是有两个数构成的,所以有三种情况:
1. 两个数都在a1:即,求数组a1中的逆序对数
2. 两个数都在a2:即,求数组a2中的逆序对数
3. 前者在a1后者在a2
情况1和情况2,分别是对子数组a1和a2递归调用。问题在于情况3如何求解。
在这里,假设a1和a2是已经排好序的,那么事情就容易许多。对于情况3,a1和a2内部调整是不影响逆序对的数目的,所以我们可以先对a1和a2分别排序。
假设排序后是1 3 5
+2 4 6
这两个数组,我们使用归并排序,并在排序过程中统计逆序对。排序后的数组是没有逆序对的,说明在排序的过程中“消灭”了逆序对。
i i i
1 3 5 — 3 5 - 3 5
2 4 6 2 4 6 - 4 6
j j j
------ 1----- 12----
我们用指针i和j分别指向a1和a2首位。排序时,比较i和j的数据,较小者移入缓存区。
1. 把1移入缓存区,i++。因为a1本来就在a2前面,a1的首元素移动到缓存区最前面,不会涉及逆序对。
2. 把2移入缓存区,j++。a1本来在a2前面,但是把2放在了前面,这意味着2是当前数组3 5
+2 4 6
中的最小值。但是,2的前面有a1中残存的3 5
,这会构成逆序对。
也就是说,从a2中选取头元素进入缓存区时,会“揭示”a1当前数目的逆序对。
注意:当i和j某一个到头的时候,就把未到头的数组“无条件”填充到缓存区。在这个过程中,不会涉及到逆序对。因为残存的数组中,一定是最大的那些数,前面不会有数比它们更大了。
所以,整个过程其实是,从两个有序数列中依次提取最小值,并查看原始数列中,该数字前有多少个数字,是大于它的。
以下就是一边归并排序,一边做统计的函数。因为数据可能极大,所以每次cnt取模。
void msort(int* a,int n,int* t,int& cnt){
if(n<=1)
return;
int n1=n/2,n2=n-n/2;
int *a1=a,*a2=a+n1;
msort(a1,n1,t,cnt);cnt%=1000000007; //--------
msort(a2,n2,t,cnt);cnt%=1000000007; //--------
//merge itself
int i=0,j=0,id=0;
while(i<n1 && j<n2){
if(a1[i]<=a2[j]){
t[id++]=a1[i++];
}else{
t[id++]=a2[j++];
cnt+=n1-i; //---------
cnt%=1000000007; //---------
}
}
//the rest
while(i<n1){
t[id++]=a1[i++];
}
while(j<n2){
t[id++]=a2[j++];
}
//fill back
for(int i=0;i<n;++i)
a[i]=t[i];
}
int InversePairs(vector<int> data) {
int n=data.size();
int* d=new int[data.size()]{};
for(int i=0;i<data.size();++i)
d[i]=data[i];
int* t=new int[data.size()+10]{};
int cnt=0;
msort(d,n,t,cnt);
return cnt;
}
面试题37:两个链表的第一个公共节点
说来也巧妙,对于无环链表,如果两链表有交点,则一定是Y字型的。预先计算两个链表的长度,以及长度差D;较长者的头指针预先走D步,然后两指针同时走,一定会在交点处交汇。
int dep(ListNode* h){
int cnt=0;
while(h){
cnt++;
h=h->next;
}
return cnt;
}
ListNode* FindFirstCommonNode( ListNode* pHead1, ListNode* pHead2) {
int dep1=dep(pHead1);
int dep2=dep(pHead2);
if(!dep1 || !dep2)
return nullptr;
ListNode* pLong=dep1>dep2?pHead1:pHead2;
ListNode* pShort=dep1>dep2?pHead2:pHead1;
int dis=abs(dep1-dep2);
for(int i=0;i<dis;++i)
pLong=pLong->next;
while(pLong){
if(pLong==pShort){
return pLong;
}else{
pLong=pLong->next;
pShort=pShort->next;
}
}
return nullptr;
}