《程序员面试金典》题解

时间:2021-06-13 00:37:44

程序员面试金典-渐进式题解

本题解意在提供每道题目从小白能想到的方法,逐步到(近乎)最优方法,并且一一给出代码。欢迎讨论和指出错误。

1.1字符串 确定互异字符串

题目描述

请实现一个算法,确定一个字符串的所有字符是否全都不同。这里我们要求不允许使用额外的存储结构

给定一个string iniString,请返回一个bool值,True代表所有字符全都不同,False代表存在相同的字符。保证字符串中的字符为ASCII字符。字符串的长度小于等于3000。

测试样例

"aeiou"
返回:True
"BarackObama"
返回:False

思路一:哈希思想,空间复杂度O(N)

第一感觉是用哈希的思想(桶排序),通过统计各个字符串出现次数,执行判别。简单实现如下:

    bool checkDifferent_by_hash(string str) {
        int size=str.size();
        if(size>256) return false;
        bool *isDifferent=new bool[256]();
        bool res = true;
        for(unsigned char i=0;i<size;i++) {
            if(isDifferent[str[i]]) {
                res = false;
                break;
            }
            isDifferent[str[i]]=true;
        }
        delete isDifferent;
        return res;
    }

题目要求不能使用额外存储结构,所以这种写法在本题中不符合要求。遗憾的是,牛客网上这样的写法可以AC。

思路二:二重循环做判断,O(N^2)

为了避免使用额外存储空间,使用二重循环,类似于冒泡排序、选择排序的那种思想,扫描二维表(扫描对角线下的一半即可)。虽然可以AC,缺点是时间复杂度过高。

    bool checkDifferent_by_2_for_loop(string str) {
        for (int i=0; i<str.length(); i++) {
            for (int j=i+1; j<str.length(); j++) {
                if (str[i] == str[j]) {
                    return false;
                }
            }
        }
        return true;
    }

思路三:排序后再判断,O(NlogN)

在思路二的基础上,尝试将复杂度降低,容易想到排序。不过要注意,不使用额外存储空间而执行排序,能够做到O(nlogn)的复杂度的,也就是堆排序了。C++ STL里的sort()函数,在数据量大时采用快排,分段递归排序,一旦分段后的数据小于某个值,就改用插入排序。如果递归层次过深,还会改用堆排序。这样,结合了各类算法的所有优点,但它不是纯粹的某一种排序算法,则可能存在额外空间分配的问题。所以,应当使用堆排序,而不是简单的使用sort(),才能满足题目要求。(常见算法的时间和空间复杂度,可以看这篇博客).

     bool checkDifferent_heap_sort_and_check(string str) {
         make_heap(str.begin(), str.end());
         sort_heap(str.begin(), str.end());
         for (int i=1; i<str.length(); i++) {
             if (str[i] == str[i-1]) {
                 return false;
             }   
         }   
         return true;
     }

最优答案

结合思路一当中对字符串长度的预判,以及思路三的堆排序后判断,进一步优化AC代码:

     bool checkDifferent_heap_sort_and_check(string str) {
         if (str.length()>256) return false;                                                    
         make_heap(str.begin(), str.end());
         sort_heap(str.begin(), str.end());
         for (int i=1; i<str.length(); i++) {
             if (str[i] == str[i-1]) {
                 return false;
             }   
         }   
         return true;
     }

1.2 原串翻转

题目描述

请实现一个算法,在不使用额外数据结构和储存空间的情况下,翻转一个给定的字符串(可以使用单个过程变量)。

给定一个string iniString,请返回一个string,为翻转后的字符串。保证字符串的长度小于等于5000。

测试样例:

"This is nowcoder"
返回:"redocwon si sihT"

思路和代码

没什么好说的,对称位置的字符做swap就好了。交换两个字符怎么做?t=a;a=b;b=t;即可。

     string reverseString(string str) {
         int len = str.length();
         char t;
         for (int i=0; i<len/2; i++) {
             t = str[i];
             str[i] = str[len-1-i];
             str[len-1-i] = t;
         }   
         return str;
     }

1.3 确定两串乱序同构

题目描述

给定两个字符串,请编写程序,确定其中一个字符串的字符重新排列后,能否变成另一个字符串。这里规定大小写为不同字符,且考虑字符串重点空格。

给定一个string stringA和一个string stringB,请返回一个bool,代表两串是否重新排列后可相同。保证两串的长度都小于等于5000。

测试样例:

"This is nowcoder","is This nowcoder"
返回:true
"Here you are","Are you here"
返回:false

思路1:分别排序再逐位比较

两个字符串分别排序,然后相同位置的字符进行比较。排序如果用堆排序实现,时间复杂度是O(NlogN),空间复杂度O(1),也就是不需要额外空间。另外,如果两个字符串长度不同,则直接过滤掉。

     bool checkSam_by_heap_sort(string strA, string strB) {
         if (strA.length() != strB.length()) return false;

         make_heap(strA.begin(), strA.end());
         sort_heap(strA.begin(), strA.end());

         make_heap(strB.begin(), strB.end());
         sort_heap(strB.begin(), strB.end());

         for(int i=0; i<strA.length(); i++) {
             if (strA[i]!=strB[i]) {
                 return false;
             }   
         }   
         return true;
     }

思路2:hash思想,空间换时间

可以用STL的unordered_map,也可以手动开一个数组,对于每个字符串,统计每个字符出现的次数。比较两个字符串各自的统计数组,如果完全相同,则返回true,否则为false。时间复杂度为O(N),空间复杂度也为O(N)。同样地,首先也先排除长度不等的两个字符串。

     bool checkSam_by_hash(string strA, string strB) {
         if (strA.length() != strB.length()) return false; 
         int n = 256; //考虑到还有空格等非字母字符,应当使用256而不是52
         int cnt[n];
         for(int i=0; i<n; i++){
             cnt[i] = 0;
         }
         // strA
         for(int i=0; i<strA.length(); i++){
             cnt[strA[i]] ++;
         }
         // strB
         for(int i=0; i<strB.length(); i++) {
             cnt[strB[i]] --;
         }
         // summary check
         for(int i=0; i<n; i++) {
             if(cnt[i]!=0) {
                 return false;
             }
         }       
         return true;
     }

1.4 空格替换

题目描述

请编写一个方法,将字符串中的空格全部替换为“%20”。假定该字符串有足够的空间存放新增的字符,并且知道字符串的真实长度(小于等于1000),同时保证字符串由大小写的英文字母组成。

给定一个string iniString 为原始的串,以及串的长度 int len, 返回替换后的string。

测试样例:

"Mr John Smith”,13
返回:"Mr%20John%20Smith"
”Hello World”,12
返回:”Hello%20%20World”

思路0:避免使用replace()函数

本题相当于手动实现一个字符串字串的replace()函数。平时编码的确可以用replace()函数,作为面试题则需要底层实现。用python或java等的replace()函数的调用的,都犯规。

思路1:创建新的变量,逐个字符拷贝

可能是最容易想到的写法了,缺点是不断的重新分配空间:

     string replaceSpace_method1(string str, int length) {
         string res = ""; 
         for (int i=0; i<str.length(); i++) {
             if (str[i] == ' ') {
                 res = res + "%20";
             } else {
                 res = res + str[i];
             }
         }
         return res;
     }

思路2:在原字符串上一次性分配好空间

首先扫描整个字符串,统计出空格数量,得到结果字符串的长度;然后重新分配当前字符串的空间为新的字符串长度,并且从原串末尾到头部扫描,复制到对应位置上:如果是空白字符则赋值为'%20'三个字符,否则直接原样复制。

     string replaceSpace_method2(string str, int length) {
         for (int i=0; i<str.length(); i++) {
             if (str[i]==' ') {
                 length += 2; // ' ' ==> '%20', 增加了2个字符
             }
         }
         int old_len = str.length();
         str.resize(length);
         int j = length - 1;
         // 对于原有str,从后往前扫描
         for (int i=old_len-1; i>=0; i--){
             if(str[i]==' ') {
                 str[j] = '0';
                 str[j-1] = '2';
                 str[j-2] = '%';
                 j-=3;
             } else {
                 str[j] = str[i];
                 j-=1;
             }
         }
         return str;
     }

1.5 基本字符串压缩

题目描述

利用字符重复出现的次数,编写一个方法,实现基本的字符串压缩功能。比如,字符串“aabcccccaaa”经压缩会变成“a2b1c5a3”。若压缩后的字符串没有变短,则返回原先的字符串。

给定一个string iniString为待压缩的串(长度小于等于10000),保证串内字符均由大小写英文字母组成,返回一个string,为所求的压缩后或未变化的串。

测试样例

"aabcccccaaa"
返回:"a2b1c5a3"
"welcometonowcoderrrrr"
返回:"welcometonowcoderrrrr"

思路和代码

个人认为本题主要考察的是int2str的实现。

     string zipString(string str) {
         if (str.length() == 1) {
             return str;                                                                        
         }   
         char t = str[0];
         int cnt = 1;
         string res = ""; 
         for (int i=1; i<str.length(); i++) {
             if (str[i] == t) {
                 cnt ++; 
             } else {
                 res = res + t + int2str(cnt);
                 t = str[i];
                 cnt = 1;
             }   
         }
         res = res + t + int2str(cnt);
         if (res.length() >= str.length()) {
             return str;
         }   
         return res;
     }
     string int2str(int n){ 
         string res = ""; 
         while(n>0) {
             char t = char(n % 10 + '0');
             res = res + t;
             n = n / 10; 
         }
         reverse(res.begin(), res.end());
         return res;
     }

1.6 像素翻转

题目描述

有一副由NxN矩阵表示的图像,这里每个像素用一个int表示,请编写一个算法,在不占用额外内存空间的情况下(即不使用缓存矩阵),将图像顺时针旋转90度。

给定一个NxN的矩阵,和矩阵的阶数N,请返回旋转后的NxN矩阵,保证N小于等于500,图像元素小于等于256。

测试样例:

[[1,2,3],[4,5,6],[7,8,9]],3
返回:[[7,4,1],[8,5,2],[9,6,3]]

思路和代码

如果考虑某一“圈”,则相当于循环的执行四个数字的“旋转式赋值”;如果仅仅考虑这四个对应的数字,其实就是交换a,b两个数字的扩展。
如果考虑有多少“圈”,数一下发现因为有对称性,只需要n/2圈。
把需要交换的四个点写出来,然后再考虑用它们“转圈”,能减少出错的可能。
考虑每一“圈”的一条边时,比如矩阵的第一行,最后一个元素其实应当归属于最后一列,而不应当被第一行考虑;因此,每行需要处理[0,n-2]这些元素。

class Transform {
public:
    vector<vector<int> > transformImage(vector<vector<int> > mat, int n) {
        // write code here
        for (int layer=0; layer<n/2; layer++) {
            int first = layer;
            int last = n - 1 - first;
            int t;
            for(int i=first; i<last; i++) {
                //mat[layer][i] 上
                //mat[i][n-1-layer] 右
                //mat[n-1-layer][n-1-i] 下
                //mat[n-1-i][layer] 左
                t = mat[layer][i];   //保存上
                mat[layer][i] = mat[n-1-i][layer];  //左->上
                mat[n-1-i][layer] = mat[n-1-layer][n-1-i];  //下->左
                mat[n-1-layer][n-1-i] = mat[i][n-1-layer];  //右->下
                mat[i][n-1-layer] = t;     // 保存的上->右
            }
        }
        return mat;
    }
};

1.7 清除行列

题目描述

请编写一个算法,若N阶方阵中某个元素为0,则将其所在的行与列清零。

给定一个N阶方阵int[][](C++中为vector >)mat和矩阵的阶数n,请返回完成操作后的int[][]方阵(C++中为vector >),保证n小于等于300,矩阵中的元素为int范围内。

测试样例:

[[1,2,3],[0,1,2],[0,0,1]]
返回:[[0,0,3],[0,0,0],[0,0,0]]

思路和代码

开两个数组(向量),分别记录某行或某列中是否存在0值元素,存在则标记为true(创建的数组、向量,默认值为0,也就是false)。然后再次遍历整个数组,如果当前元素所在行、所在列当中,在作为标记的向量中的值都为true,说明该元素应当置为0.

    vector<vector<int> > clearZero(vector<vector<int> >mat, int m) {
        int num_rows = mat.size();
        int num_cols = mat[0].size();
        vector<bool> rows(num_rows, 0);
        vector<bool> cols(num_cols, 0);
        for(int i=0; i<mat.size(); i++) {
            for(int j=0; j<mat[i].size(); j++) {
                if (mat[i][j]==0) {
                    rows[i] = true;
                    cols[j] = true;
                }
            }
        }
        for(int i=0; i<mat.size(); i++){
            for(int j=0; j<mat[i].size(); j++){
                if(rows[i] || cols[j]) {
                    mat[i][j] = 0;
                }
            }
        }
        return mat;
    }

1.8 反转子串

题目描述

假定我们都知道非常高效的算法来检查一个单词是否为其他字符串的子串。请将这个算法编写成一个函数,给定两个字符串s1和s2,请编写代码检查s2是否为s1旋转而成,要求只能调用一次检查子串的函数。

给定两个字符串s1,s2,请返回bool值代表s2是否由s1旋转而成。字符串中字符为英文字母和空格,区分大小写,字符串长度小于等于1000。

测试样例:

"Hello world","worldhello "
返回:false
"waterbottle","erbottlewat"
返回:true

思路和代码

将s1和另一个s1串接起来,在里面如果找到s2就说明s2可以从s1旋转而成。另外,对于题目中说的只能调用一次检查子串的函数,一开始不是很理解,然而如下的写法(不符合题目要求)也能在牛客上AC:

    bool checkReverseEqual(string s1, string s2) {
        if(s1.length() != s2.length()){
            return false;
        }
        int len = s1.length();
        s1 = s1 + s1;
        for(int i=0; i<len; i++){
            if (s1.substr(i, len) == s2){
                return true;
            }
        }
        return false;
    }

后来发现可以用std::string::npos的,那么就简单多了(不过原理应该是一样吧,上面的写法更底层一些):

    bool checkReverseEqual(string s1, string s2) {
        if (s1.length()!=s2.length()) return false;
        string s = s1+s1;
        if (s.find(s2)!=string::npos){
            return true;
        }
        return false;
    }

2.2 链表中倒数第k个节点

题目描述

输入一个链表,输出该链表中倒数第k个结点。

思路和代码

个人觉得本题出现频率很高。anyway,直觉上先想到的就是“先后指针”法了,第一个指针先走k步,然后第一个指针和第二个指针按相同步速走;前k步如果第一个指针撞墙了那么直接回报一下撞墙了(NULL)。

    ListNode* FindKthToTail(ListNode* pListHead, unsigned int k) {
        ListNode* p = pListHead;
        for(int i=0; i<k; i++){
            if (p==NULL) {
                return NULL;
            }
            p = p->next;
        }
        ListNode* q = pListHead;
        while(p!=NULL) {
            p = p->next;
            q = q->next;
        }
        return q;
    }

2.3 访问单个节点的删除

题目描述

实现一个算法,删除单向链表中间的某个结点,假定你只能访问该结点。

给定待删除的节点,请执行删除操作,若该节点为尾节点,返回false,否则返回true

思路和代码

需要删除的节点为p,如果p不是尾巴节点,那么把p的后面一个节点的值拿过来,替代p自己的值,然后删除这个后面的节点,就达到要求。
本题应该熟练掌握,一次写好。

    bool removeNode(ListNode* pNode) {
        // write code here
        if (pNode->next==NULL) return false;
        pNode->val = pNode->next->val;
        ListNode* q = pNode->next;
        pNode->next = pNode->next->next;
        delete q;
        q = NULL;
        return true;
    }

2.4 链表分割

题目描述

编写代码,以给定值x为基准将链表分割成两部分,所有小于x的结点排在大于或等于x的结点之前

给定一个链表的头指针 ListNode* pHead,请返回重新排列后的链表的头指针。注意:分割以后保持原来的数据顺序不变。

思路和代码

分别设定两个指针来存储小于阈值x和大于等于阈值x的节点,遍历整个输入的链表来赋值到这两个链表上。遍历结束,则将第一个链表的结尾接到第二个链表上。
注意,第二个链表结尾应当手动赋值为NULL,因为它处理了边界情况:当最后一个大于阈值x的值,如果它不是链表末尾,那么在返回结果后进行遍历时,链表就没有终止了,产生环。显然应该避免环。

AC代码:

    ListNode* partition(ListNode* pHead, int x) {
        // write code here
        ListNode* fkrt1 = new ListNode(0);
        ListNode* fkrt2 = new ListNode(0);
        ListNode* p1 = fkrt1;
        ListNode* p2 = fkrt2;
        ListNode* cur = pHead;
        while(cur!=NULL){
            if(cur->val<x) {
                p1->next = cur;
                p1=p1->next;
            }else{
                p2->next = cur;
                p2=p2->next;
            }
            cur = cur->next;
        }
        p1->next = fkrt2->next;
        p2->next = NULL;  //处理边界情况
        ListNode* res = fkrt1->next;
        delete fkrt1;
        delete fkrt2;
        fkrt1 = NULL;
        fkrt2 = NULL;
        return res;
    }

为了提高AC率,应该自己在本地写代码时添加测试用例。完整的代码如下,包括了边界情况的测试数据,并且不存在内存泄漏的情况(可以用valgrind检查下):

#include <iostream>
#include <string>
#include <vector>

using namespace std;

struct ListNode {
    int val;
    struct ListNode* next;
    ListNode(int x): val(x), next(NULL) {}
};

class Partition {
public:
    ListNode* partition(ListNode* pHead, int x){
        ListNode* fkrt1 = new ListNode(0);  //fake root1,它后面的元素才是真正的头节点
        ListNode* fkrt2 = new ListNode(0);  //fake root2

        ListNode* p1 = fkrt1;
        ListNode* p2 = fkrt2;

        while(pHead!=NULL){
            if(pHead->val < x){
                p1->next = pHead;
                p1 = p1->next;
            }else{
                p2->next = pHead;
                p2 = p2->next;
            }
            pHead = pHead->next;
        }

        p1->next = fkrt2->next;

        // 下面这行很容易漏掉。处理的是边界情况:最后一个大于阈值x的值,如果它不是链表末尾,
        // 那么在返回结果后进行遍历时,链表就没有终止了,产生环。显然应该避免环。
        p2->next = NULL;
        
        ListNode* res = fkrt1->next;
        delete fkrt1;
        delete fkrt2;
        fkrt1 = NULL;
        fkrt2 = NULL;
        return res;
    }
};

void destroy_list(ListNode* root){
    if (root==NULL) return;
    ListNode* p = root->next;
    delete root;
    root = NULL;
    destroy_list(p);
}

int main() {
    //vector<int> vals = {1, 3, 5, 7, 9, 11, 2, 4, 6, 8, 10};
    vector<int> vals = {6,7,8,9,10,11, 1,2,3,4,5};
    vector<ListNode*> vl;
    for(int i=0; i<vals.size(); i++){
        vl.push_back(new ListNode(vals[i]));
        if(i>0){
            vl[i-1]->next = vl[i];
        }
    }
    cout << "the original datums are:" << endl;
    for(int i=0; i<vals.size(); i++){
        cout << vals[i] << ",";
    }
    cout << endl;

    
    ListNode* head = vl[0];
    Partition p;
    int x = 7;
    ListNode* res = p.partition(head, x);
    cout << "do splitting with thresh value = " << x << ", gains: " << endl;
    ListNode* cur = res;
    while(cur!=NULL){
        cout << cur->val << ",";
        cur = cur->next;
    }
    cout << endl;

    destroy_list(res);
   

    return 0;
}

2.5 链式A+B

题目描述

有两个用链表表示的整数,每个结点包含一个数位。这些数位是反向存放的,也就是个位排在链表的首部。编写函数对这两个整数求和,并用链表形式返回结果。

给定两个链表ListNode* A,ListNode* B,请返回A+B的结果(ListNode*)。

测试样例:

{1,2,3},{3,2,1}
返回:{4,4,4}

思路和代码

题目是模拟算数加法,注意进位,注意分配和释放内存。

    ListNode* plusAB(ListNode* a, ListNode* b){
        ListNode* fkrt = new ListNode(0);
        ListNode* cur = fkrt;
        int c = 0;
        while(a!=NULL || b!=NULL || c!=0){
            if (a!=NULL){
                c += a->val;
                a = a->next;
            }
            if (b!=NULL) {
                c += b->val;
                b = b->next;
            }
            cur->next = new ListNode(c % 10);
            cur = cur->next;
            c = c / 10;
        }
        ListNode* res = fkrt->next;
        delete fkrt;
        fkrt = NULL;
        return res;
    }

(以下内容待整理)

2.11 判断是否为平衡树

题目描述:

实现一个函数,检查二叉树是否平衡,平衡的定义如下,对于树中的任意一个结点,其两颗子树的高度差不超过1。
给定指向树根结点的指针TreeNode* root,请返回一个bool,代表这棵树是否平衡。

AC代码和测试代码:

#include <iostream>
#include <string>
#include <vector>

using namespace std;

struct TreeNode {
    int val;
    struct TreeNode* left;
    struct TreeNode* right;
    TreeNode(int x): val(x), left(NULL), right(NULL) {}
};

class Balance {
public:
    bool isBalance(TreeNode* root){
        int ht = 0;
        return isBalanceHt(root, ht);
    }
    /*
 * 返回判断值,表示以root为跟节点的树是否为平衡树
 * 同时,ht用来存储本颗树的高度
 */
    bool isBalanceHt(TreeNode* root, int& ht){
        if(root==NULL){
            ht = 0;
            return true;
        }
        int lht, rht;
        // case1: 左子树或者右子树中存在不平衡的,那么肯定不平衡
        if (!isBalanceHt(root->left, lht) || !isBalanceHt(root->right, rht)) {
            return false;
        }
        // case2: 左右子树都平衡,但是它们的高度差大于1,则当前树不平衡
        if (abs(lht-rht)>1) {
            return false;
        }
        // case3:肯定是平衡的了。注意设定树木的高度,给上一层节点调用(如果存在)使用。
        ht = (lht>rht?lht:rht) + 1;
        return true;
    }
};

void destroy_tree(TreeNode* root){
    if (root==NULL) {
        return;
    }
    TreeNode* left = root->left;
    TreeNode* right = root->right;
    delete root;
    root = NULL;
    destroy_tree(left);
    destroy_tree(right);
}

int main() {
    TreeNode* root = new TreeNode(0);
    TreeNode* a1 = new TreeNode(1);
    TreeNode* a2 = new TreeNode(2);
    TreeNode* a3 = new TreeNode(3);
    TreeNode* b1 = new TreeNode(11);
    TreeNode* b2 = new TreeNode(12);
    root->left = a1;
    root->right = b1;
    a1->left = a2;
    a2->left = a3;
    b1->right = b2;

    Balance b;
    cout << b.isBalance(root) << endl;

    destroy_tree(root); //注意释放内存

    return 0;
}

2.12 判断两点之间是否存在一条路径

/*
struct UndirectedGraphNode {
    int label;
    vector<struct UndirectedGraphNode *> neighbors;
    UndirectedGraphNode(int x) : label(x) {}
};*/

#include <unordered_map>
typedef UndirectedGraphNode DirectedGraphNode;

class Path {
public:
    bool checkPath(DirectedGraphNode* a, DirectedGraphNode* b) {
        return check(a,b) || check(b,a);
    }
    bool check(DirectedGraphNode* a, DirectedGraphNode* b) {
        if (a==NULL || b==NULL) return false;
        if (a==b) return true;
        unordered_map<DirectedGraphNode*, bool> visit;
        queue<DirectedGraphNode*> q;
        q.push(a);
        while(!q.empty()) {
            DirectedGraphNode* node = q.front();
            q.pop();
            visit[node] = true;
            for(int i=0; i<node->neighbors.size(); ++i) {
                if (node->neighbors[i]==b) return true;
                if (node->neighbors[i]!=NULL && !visit[node->neighbors[i]]) {
                    q.push((node->neighbors)[i]);
                }
            }
        }
        return false;
    }
};

/*
int main() {
    // 第一种情况:图里面没有环
    DirectedGraphNode* a1 = new DirectedGraphNode(1);
    DirectedGraphNode* a2 = new DirectedGraphNode(2);
    DirectedGraphNode* a3 = new DirectedGraphNode(3);
    DirectedGraphNode* a4 = new DirectedGraphNode(4);
    a1->neighbors = {a2, a3};
    a3->neighbors = {a4};
    
    Path p;
    cout << p.checkPath(a1, a4) << endl;
    cout << p.checkPath(a4, a1) << endl;

    
    // 第二种情况:图例面有环
    a1->neighbors = {a2, a3};
    a2->neighbors = {a3};
    a3->neighbors = {a4, a2};
    cout << p.checkPath(a1, a4) << endl;
    cout << p.checkPath(a4, a1) << endl;
   

    return 0;
}
*/