一、赋值运算符函数
题目:如下为类型CMyString的声明,请为该类型添加赋值运算符函数。
class CMyString { public: CMyString(char *pData = nullptr); CMyString(const CMyString &str); ~CMyString(); private: char *m_pData; };
测试用例:
- 把一个CMyString的实例赋值给另外一个实例。
- 把一个CMyString的实例赋值给它自己。
- 连续赋值。
没有考虑异常安全性的解法:
CMyString& CMyString::operator=(const CMyString &rhs) { if(this != &rhs) { delete []m_pData; m_pData = nullptr; m_pData = new char[strlen(rhs.m_pData) + 1]; strcpy(m_pData, rhs.m_pData); } return *this; }
考虑异常安全性的解法:
CMyString& CMyString::operator=(const CMyString &rhs) { if(this != &rhs) { CMyString strTemp(rhs); // 创建一个临时实例strTemp /* 交换strTemp.m_pData和实例自身的m_pData */ char *pTemp = m_pData; m_pData = strTemp.m_pData; strTemp.m_pData = pTemp; } /* 自动调用strTemp的析构函数,释放strTemp.m_pData指向的新内存 */ return *this; }
补:在新的代码中,我们在CMyString的构造函数里用new分配内存,故如果内存不足将抛出诸如bad_alloc等异常,但由于我们还没有修改实例自身的状态,故实例的状态还是有效的,这也就保证了异常安全性。
考点:
- C++基础语法,如运算符函数、常量引用等。
- 内存泄露。
- 代码异常安全性。
二、实现Singleton(单例)模式
题目:设计一个类,我们只能生成该类的一个实例。
解读题意:只能生成一个实例的类是实现了Singleton模式的类型。
考点:
- 单例模式
- C#基础语法,如静态构造函数等。
- 多线程编程。
三、数组中重复的数字
题目一:找出数组中重复的数字。在一个长度为n的数组里的所有数字都在0~n-1的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。例如,如果输入长度为7的数组{2,3,1,0,2,5,3},那么对应的输出是重复的数字2或者3。
测试用例:
- 长度为n的数组里包含一个或多个重复的数字。
- 数组中不包含重复的数字。
- 无效输入测试用例,如输入空指针、长度为n的数组中包含0~n-1之外的数字。
时间复杂度和空间复杂度均为O(n)的解法:
int solve(int arr[], int len) { int ans = -1; // 没有重复的数字则返回-1 int *pArr = new int[len](); // 对动态数组进行值初始化 for(int i = 0; i != len; ++i) { ++pArr[arr[i]]; } for(int i = 0; i != len; ++i) { if(pArr[i] > 1) { ans = i; break; } } delete []pArr; return ans; }
补:书中的解法是时间复杂度为O(n),而空间复杂度为O(1)。
题目二:不修改数组找出重复的数字。在一个长度为n+1的数组里的所有数字都在1~n的范围内,所以数组中至少有一个数字是重复的。请找出数组中任意一个重复的数字,但不能修改输入的数组。例如,如果输入长度为8的数组{2,3,5,4,3,2,6,7},那么对应的输出是重复的数字2或者3。
分析:这一题和上面的面试题类似,但题目明确要求不能修改输入的数组。我们可以像上题一样,创建一个长度为n+1的辅助数组,但该方案需要O(n)的辅助空间。我们要设法尝试避免使用O(n)的辅助空间。但正如下面这个算法所示,即使它的空间复杂度为O(1),但其时间复杂度却为O(nlogn),而且它不能保证找出所有重复的数字。故我们选取什么算法应取决于面试官提出的功能要求或性能要求。
思路:由题意可以确定数组中一定有重复的数字,故可以把从1~n的数字从中间的数字m分为两部分,前面一半为1~m,后面一半为m+1~n。如果1~m的数字的数目超过m,那么这一半的区间里一定包含重复的数字;否则,另一半m+1~n的区间里一定包含重复的数字。然后,继续把包含重复数字的区间一分为二,直到找到一个重复的数字。
四、在二维数组中的查找
题目:在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
测试用例:
- 二维数组中包含查找的数字。
- 二维数组中没有查找的数字。
- 特殊输入测试。
解法:
bool solve(int *matrix, int rows, int cols, int x) { bool found = false; if(rows > 0 && cols > 0) { int row = 0, col = cols - 1; while(row < rows && col >= 0) { if(matrix[row * cols + col] == x) { found = true; break; } else if(matrix[row * cols + col] > x) --col; else ++row; } } return found; }
分析:若从二维数组(矩形)的中间选取一个数字来比较,那么如果该数字与目标值不等的话,接下来的查找将没有固定的范围。而如果从右上角或左下角开始进行比较,则会逐渐缩小查找范围,最终得到结果。所以,该题考察的是应聘者能否通过具体的例子来找出其中的规律。
五、替换空格
题目:请实现一个函数,把字符串中的每个空格替换成“%20”。例如,输入“We are happy!”,则输出“We%20are%20happy!”。
题意:假设面试官是让我们在原来的字符串上进行替换,并且保证输入的字符串后面有足够多的空余内存。
测试用例:
- 输入的字符串中包含空格,空格可位于字符串的最前面、最后面或中间,甚至可以有连续多个空格。
- 输入的字符串中没有空格。
- 特殊输入测试,如字符串是一个空字符串,或字符串只有一个空格字符。
时间复杂度为O(n2)的解法:
从头到尾扫描字符串,每次碰到空格字符的时候进行替换。而由于是把1个字符替换成3个字符,所以我们必须要把空格后面所有的字符都后移2字节,否则就有两个字符被覆盖了。
假设字符串的长度为n,对每个空格字符,需要移动后面O(n)个字符,因此对于含有O(n)个空格字符的字符串而言,总的时间效率是O(n2)。
时间复杂度为O(n)的解法:
先遍历一次字符串,这样就能统计出字符串中空格的总数,并可以由此计算出替换之后的字符串的总长度。我们从字符串的后面开始复制和替换。
六、从尾到头打印链表
题目:输入一个链表的头节点,从尾到头反过来打印出每个节点的值。
测试用例:
- 功能测试,如输入的链表有多个节点,或输入的链表只有一个节点。
- 特殊输入测试,如输入的链表头节点指针为nullptr。
栈+循环的解法:
struct ListNode { int value; ListNode *next; }; void print_list(ListNode *pHead) { stack<int> sck; ListNode *pNode = pHead; while(pNode) { sck.push(pNode->value); pNode = pNode->next; } while(!sck.empty()) { cout << sck.top() << endl; sck.pop(); } }
栈+递归的解法:
struct ListNode { int value; ListNode *next; }; void print_list_recursively(ListNode *pHead) { if(pHead != nullptr) { if(pHead->next != nullptr) { print_list_recursively(pHead->next); } cout << pHead->value << cout; } }
补充:既然想到了用栈来实现该函数,并且递归在本质上就是一个栈结构,于是很自然地又想到了用递归来实现。
注意:当链表非常长的时候,就会导致函数调用的层级很深,从而有可能导致函数调用栈溢出。
七、重建二叉树
题目:输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树并输出它的头节点。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。例如,输入前序遍历序列{1, 2, 4, 7, 3, 5, 6, 8}和中序遍历序列{4, 7, 2, 1, 5, 3, 8, 6},则重建如下图所示的二叉树并输出它的头节点。
测试用例:
- 普通二叉树。
- 特殊二叉树,如只有一个节点的二叉树、所有节点都没有右子节点的二叉树、所有节点都没有左子节点的二叉树。
- 特殊输入测试,如二叉树的根节点指针为nullptr、输入的前序遍历序列和中序遍历序列不匹配。
考点:
- 对二叉树的前序遍历和中序遍历的理解程度。
- 分析复杂问题的能力,把构建二叉树的大问题分解成构建左、右子树的两个小问题,而小问题和大问题在本质上是一致的,故可以用递归的方式解决。
struct BinaryTreeNode { int m_nValue; BinaryTreeNode *m_pLeft; BinaryTreeNode *m_pRight; }; BinaryTreeNode* Construct(int* preorder, int* inorder, int length) { if(preorder == nullptr || inorder == nullptr || length <= 0) return nullptr; return ConstructCore(preorder, preorder + length - 1, inorder, inorder + length - 1); } BinaryTreeNode* ConstructCore(int* startPreorder, int* endPreorder, int* startInorder, int* endInorder) { // 前序遍历序列的第一个数字是根结点的值 int rootValue = startPreorder[0]; BinaryTreeNode* root = new BinaryTreeNode(); root->m_nValue = rootValue; root->m_pLeft = root->m_pRight = nullptr; if(startPreorder == endPreorder) { if(startInorder == endInorder && *startPreorder == *startInorder) return root; else throw std::exception("Invalid input."); } // 在中序遍历中找到根结点的值 int* rootInorder = startInorder; while(rootInorder <= endInorder && *rootInorder != rootValue) ++ rootInorder; if(rootInorder == endInorder && *rootInorder != rootValue) throw std::exception("Invalid input."); int leftLength = rootInorder - startInorder; int* leftPreorderEnd = startPreorder + leftLength; if(leftLength > 0) { // 构建左子树 root->m_pLeft = ConstructCore(startPreorder + 1, leftPreorderEnd, startInorder, rootInorder - 1); } if(leftLength < endPreorder - startPreorder) { // 构建右子树 root->m_pRight = ConstructCore(leftPreorderEnd + 1, endPreorder, rootInorder + 1, endInorder); } return root; }
小结:其实面试不会让写这么一道题,但容易考到的是先序遍历的递归/循环实现方法,对于中序遍历、后序遍历也是如此。
八、二叉树的下一个节点
题目:给定一棵二叉树和其中的一个节点,如何找出中序遍历序列的下一个节点?树中的节点除了有两个分别指向左、右子节点的指针,还有一个指向父节点的指针。
分析:此题需要应聘者在掌握中序遍历的基础上,通过具体的例子来找出其中的规律,并由此设计出可行的算法。
解法:
BinaryTreeNode* find_next(BinaryTreeNode *pNode) { if(pNode == nullptr) return nullptr; BinaryTreeNode *pNext = nullptr; if(pNode->m_pRight != nullptr) { BinaryTreeNode *pTemp = pNode->m_pRight; while(pTemp->m_pLeft != nullptr) pTemp = pTemp->m_pLeft; pNext = pTemp; } else if(pNode->m_pParent != nullptr) { BinaryTreeNode *pCurrent = pNode; BinaryTreeNode *pParent = pNode->m_pParent; while(pParent != nullptr && pCurrent == pParent->m_pRight) { pCurrent = pParent; pParent = pParent->m_pParent; } pNext = pParent; } return pNext; }
九、用两个栈实现队列
题目:用两个栈实现一个队列。队列的声明如下,请实现它的两个函数appendTail和deleteHead,分别完成在队列尾部插入节点和在队列头部删除节点的功能。
测试用例:
- 往空的队列里添加、删除元素。
- 往非空的队列里添加、删除元素。
- 连续删除元素直至队列为空。
解法:
stack<int> sck1; stack<int> sck2; void appendTail(int val) { sck1.push(val); } int deleteHead() { if(sck2.empty()) { while(!sck1.empty()) { int temp = sck1.top(); sck1.pop(); sck2.push(temp); } } if(sck2.empty()) throw new exception("queue is empty"); int ret = sck2.top(); sck2.pop(); return ret; }
分析:这道题的意图是要求我们操作两个“后进先出”的栈sck1和sck2以实现一个“先进先出”的队列。当添加元素时,我们总是向栈sck1内插入;而删除元素时,则总是从栈sck2内删除。
拓展:用两个队列实现一个栈。分析思路见书p71。
十、斐波拉契数列
题目一:求斐波那契数列的第n项。写一个函数,输入n,求斐波那契(Fibonacci)数列的第n项。
递归解法:
int fibonacci(int n) { if(n == 0) return 0; if(n == 1) return 1; return fibonacci(n-1) + fibonacci(n-2); }
分析:许多教科书在讲解递归算法时,总是以此段代码作为示例,但该解法有很严重的效率问题。一句话阐明,该递归代码分解的两个子问题中存在大量重复的计算。
循环解法:
int fibonacci(int n) { if(n == 0) return 0; if(n == 1) return 1; int x1 = 0; int x2 = 1; int ans = 0; for(int i = 2; i <= n; ++i) { ans = x1 + x2; x1 = x2; x2 = ans; } return ans; }
分析:上述递归代码之所以慢,是因为存在大量重复的计算,因此,我们只要想办法避免重复计算就行了。该解法是从下往上计算,首先根据f(0)和f(1)算出f(2),再根据f(1)和f(2)算出f(3)……以此类推就可以算出第n项了。
题目二:青蛙跳台阶问题。一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级的台阶总共有多少种跳法。
分析:把n级台阶时的跳法看成n的函数,记为f(n),这就不难看出该问题就是斐波那契数列了。