剑指OFFER纪念版(5)

第5章:优化时间和空间效率

面试题29:数组中出现次数超过一半的数字
  1. 如果是已经排好序的数组,则该数字一定是中位数。但排序需要O(NlogN)的时间。
  2. 在遍历数组的时候,可以用哈希表记录下每个数字出现的次数,如果发现当前遍历的数字出现次数高于数组长度一半,输出即可。
    哈希表有常数级的访问操作,实际上等同于一种“开挂”。
  3. 可以利用快排的思想,当发现分割点小于数组中心点时,只排右边的部分,而不是两个部分全排,反之亦然。
    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个数
  1. 方法同上,利用快排的划分思想,可以在O(N)内完成。当然这个数值和快排一样,都是理想状态下的情况。
  2. 使用一个容器装载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 53 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;

    }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值