第 1 章 面试的流程
1. 简历中描述项目的 STAR 模型
a. Situation 简短的项目背景。比如项目的规模,开发的软件的功能、目标用户等。
b. Task 自己完成的任务。在用词上要区分「参与」和「负责」,面试官看到简历上应聘者「负责」了某个项目,他可能就会问项目的总体框架设计、核心算法、团队合作等问题。
c. Action 为完成任务做了哪些工作,是怎么做的。做系统设计的,可以介绍系统架构的特点;做软件开发的,可以写基于什么工具在哪个平台下应用了哪些技术。
d. Result 自己的贡献。这方面的信息最好能用数字加以说明,如果参与功能开发,可以说按时完成了多少功能;如果做优化,可以说性能提高的百分比是多少;如果是维护,可以说修改了多少个 Bug。
举个例子:Winforms 是微软 .NET 中的一个成熟的 UI 平台( Situation )。本人的工作是在添加少量新功能之外主要负责维护已有的功能( Task )。新的功能主要是让 Winforms 的控件风格和 Vista、Windows 7 的风格保持一致。在维护方面,对于较难的问题,我用 WinDbg 等工具进行调试( Action )。在过去两年中,我共修改了超过 200 个 Bug( Result )。
除此之外,面试官针对项目经验最常问的问题包括如下几个类型:
a. 你在该项目中碰到最大的问题是什么,你是怎么解决的?
b. 从这个项目中你学到了什么?
c. 什么时候会和其他团队成员(包括开发人员、测试人员、设计人员、项目经理等)有什么样的冲突,你们是怎么解决冲突的?
2. 简历中技能的描述
除应聘者参与过的项目之外,面试官可能针对简历上提到的技能提出问题,和描述项目时要注意「参与」和「负责」一样,描述技能掌握程度时也要注意「了解」、「熟悉」和「精通」的区别。
了解 指对某项技术只是上过课或者看过书,但没有做过实际的项目。通常不建议在简历中列出只是肤浅地了解一点的技能,除非这项技术应聘的职位需要。
熟悉 指在项目中使用某项技术已经有较长的时间,通过查阅相关的文档可以独立解决大部分问题。简历中我们描述技能的掌握程度大部分应该是「熟悉」。
精通 指我们对一项技术使用的得心应手,在项目开发过程中,同事向我们请教这个领域的问题时,我们都有信心也有能力解决。
3. 回答「为什么跳槽」
笔者自己跳过几次槽,从 Autodesk 跳槽到微软,再从微软跳槽到思科,后来又从思科回到了微软。
在微软面试被问到为什么要跳槽时,笔者的回答是:我在 Autodesk 开发的软件 Civil 3D 是一款面向土木行业的设计软件。如果我想在现在的职位上得到提升,就必须加强土木行业的学习,可我对诸如计算土方量、道路设计等没有太多兴趣,因此出来寻找机会。
在微软工作两年半之后去思科面试的时候,笔者的回答是:我在微软的主要工作是开发和维护 .NET 的 UI 平台 Winforms。由于 Winforms 已经非常成熟,不需要添加多少新功能,因此我的大部分工作是维护和修改 Bug。两年下来,调试的能力得到了很大的提高,但长期如此,自己的软件开发和设计能力将不能得到提高,因此想出来寻找可以设计和开发系统的职位。同时,我在过去几年里的工作都是开发桌面软件,对网络了解甚少,因此希望下一个工作能与网络相关。众所周知,思科是一家网络公司,这里的软件和系统或多或少都离不开网络,因此我对思科的职位很感兴趣。
4. 技术面试环节
面试官在通过简历及行为面试大致了解应聘者的背景之后,接下来就要开始技术面试了。总体来说他们都会关注应聘者的 5 种素质:
a. 基础知识扎实全面,包括编程语言、数据结构、算法。其中数据结构是考察的重点,应聘者需要熟练掌握链表、树、栈、队列和哈希表等数据结构,以及它们的操作。对于算法,大部分公司都会注重考查查找和排序,其中重点需要掌握二分查找、归并排序和快速排序,因为很多面试题都只是这些算法的变体而已。
b. 能写出正确的、完整的、鲁棒的高质量代码。技术面试的面试官一般都是程序员,他们通常没有那么多想法,题目做对、做完整了,就让你通过面试。所以,面试官除了希望应聘者的代码能够完成基本的功能,还会关注应聘者是否考虑了边界条件、特殊输入及错误处理。
c. 能思路清晰地分析、解决复杂问题。有时候面试官会有意出一些比较复杂的问题,甚至不期待应聘者能在面试不到一个小时的时间里给出完整的答案,他更看重的是应聘者是否有清晰的思路。所以我们可以:画图使抽象问题形象化,举例使抽象问题具体化,分解使复杂问题简单化。
d. 能从时间、空间复杂度两方面优化算法效率。想要优化代码的效率,我们需要熟知各种数据结构的优缺点,并能选择合适的数据结构解决问题。
e. 具备优秀的沟通能力、学习能力、发散思维能力等。随着软件系统的规模越来越大,软件开发已经告别了单打独斗的年代,程序员与他人的沟通变得越来越重要。在面试过程中,面试官会观察应聘者在介绍项目经验或者算法思路时是否观点明确、逻辑清晰,并以此判断其沟通能力的强弱。另外,面试官也会从应聘者说话的神态和语气来判断他是否有团队合作的意识。对于学习能力,通常面试官有两种办法考查应聘者的学习能力。第一种方法是询问应聘者最近在看什么书、从中学到了哪些新技术。第二种方法是抛出一个新概念,接下来会观察应聘者能不能在较短的时间内理解并解决相关的问题。
5. 回答「你还有什么问题吗」
推荐问的问题是与应聘的职位或者项目相关的问题。
第 2 章 面试需要的基础知识
1. 数据结构 - 数组
数组可以说是最简单的一种数据结构,它占据一块连续的内存并按照顺序存储数据。创建数组时,我们首先指定数组的容量大小,然后根据大小分配内存。因此数组的空间效率不是很好,经常会有空闲的区域没有得到充分利用。
由于数组中的内存是连续的,于是可以根据下标在 O(1) 时间读 / 写任何元素,我们可以根据数组时间效率高的优点,用数组来实现简单的哈希表:把数组的下标设为哈希表的 key,数组中每一个数字设为哈希表的 value。
面试题 3 - 1
题目:找出数组中重复的数字。在一个长度为 n 的数组里的所有数字都在 0 ~ n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。例如,如果输入长度为 7 的数组 { 2, 3, 1, 0, 2, 5, 3 },那么对应的输出是重复的数字 2 或者 3。
方法一,将数组排序,然后从头到尾扫描排序后的数组就可以了。排序一个长度为 n 的数组的时间复杂度为 O(nlogn)。
方法二,从头到尾扫描数组的每个数字,每扫描到一个数字的时候,都可以用 O(1) 的时间来判断哈希表里是否已经包含了该数字,如果哈希表里还没有这个数字,就把它加入哈希表;如果哈希表里已经存在该数字,就找到一个重复的数字。这个算法的时间复杂度是 O(n),但它提高时间效率是以一个大小为 O(n) 的哈希表为代价的。
方法三,如果这个数组中没有重复的数字,那么当数组排序之后数字 i 将出现在下标为 i 位置。现在让我们重排这个数组,从头到尾扫描数组的每个数字,首先比较这个数字 m 是不是等于 i,如果是,接着扫描下一个数字;如果不是,再拿它和第 m 个数字进行比较,如果它和第 m 个数字相等,就找到了一个重复的数字(该数字在下标为 i 和 m 的位置都出现了),如果它和第 m 个数字不相等,就把第 i 个数字和第 m 个数字交换,把 m 放到属于它的位置。接下来再重复这个比较、交换的过程,直到我们发现一个重复的数字。这个算法的时间复杂度是 O(n),控件复杂度为 O(1)。
1 | int duplicate(int numbers[], int length) { |
面试题 3 - 2
题目:不修改数组找出重复的数字。在一个长度为 n 的数组里的所有数字都在 1 ~ n-1 的范围内,所以数组中至少有一个数字是重复的。请找出数组中任意一个重复的数字,但不能修改输入的数组。例如,如果输入长度为 7 的数组 { 2, 3, 5, 4, 3, 2, 6 },那么对应的输出是重复的数字 2 或者 3。
我们把从 1 ~ n-1 的数字从中间的数字 m 分为两部分,前面一半为 1 ~ m,后面一半为 m+1 ~ n-1。如果 1 ~ m 的数字的数目超过 m,那么这一半的区间里一定包含重复的数字;否则,另一半区间里一定包含重复的数字。我们继续把包含重复数字的区间一分为二,直到找到一个重复的数字。这个过程和二分查找算法类似,只是多了一步统计区间里数字的数目。这个算法的时间复杂度是 O(nlogn),空间复杂度为 O(1)。
1 | int getDuplication(const int* numbers, int length) { |
面试题 4
题目:二维数组中的查找。在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。例如,从下面的矩阵中查找数字 7
1 | 1 2 8 9 |
首先选取数组中右上角的数字,如果该数字等于要查找的数字,则查找过程结束。如果该数字大于要查找的数字,则剔除这个数字所在的列。如果该数字小于要查找的数字,则剔除这个数字所在的行。这样每一步都可以缩小查找的范围,直到找到要查找的数字,或者查找范围为空。
1 | int main(int argc, const char * argv[]) { |
2. 数据结构 - 字符串
字符串是由若干字符组成的序列,C/C++ 中每个字符串都以字符 \0 作为结尾,这样我们就能很方便地找到字符串的最后尾部。但由于这个特点,每个字符串中都有一个额外字符的开销,稍不留神就会造成字符串的越界。
1 | char str[] = "012345"; |
为了节省内存,C/C++ 把常量字符串放到单独的一个内存区域,当几个指针赋值给相同的常量字符串时,它们实际上会指向相同的内存地址。但用常量内存初始化数组,情况却有所不同。运行下面的代码,得到的结果是什么?
1 | char str1[] = "hello world"; |
str1 和 str2 是两个字符串数组,我们会为它们分配两个长度为 12 字节的空间,并把 hello world 的内容分别复制到数组中去。这是两个初始地址不同的数组,因此 str1 和 str2 的值也不相同。输出 str1 和 str2 不相同。
str3 和 str4 是两个指针,我们无须为它们分配内存以存储字符串的内容,而只需要把它们指向 hello world 在内存中的地址就可以了。由于 hello world 是常量字符串,它在内存中只有一个拷贝,因此 str3 和 str4 指向的是同一个地址。输出 str3 和 str4 相同。
面试题 5
题目:替换空格。请实现一个函数,把字符串中的每个空格替换成 %20。例如,输入 We are happy. 则输出 We%20are%20happy.。
在网络编程中,如果 URL 参数中含有特殊字符,则可能导致服务器无法获得正确的参数值,我们需要将这些特殊符号转换成服务器可以识别的字符。转换的规则是在 % 后面跟上 ASCII 码的两位十六进制的表示。
方法一,从头到尾扫描字符串,每次碰到空格字符的时候进行替换。由于是把 1 个字符替换成 3 个字符,我们必须要把空格后面所有的字符都后移 2 字节,否则就有两个字符被覆盖了。假设字符串的长度是 n,对每个空格字符,需要移动后面 O(n) 个字符,因此对于含有 O(n) 个空格字符的字符串而言,总的时间效率是 O(n²)。
方法二,首先遍历一次字符串,统计出字符串中空格的总数,计算替换后的字符串的总长度。以前面的字符串 We are happy 为例,这个字符串的长度是 14(包括结尾符号 \0),里面有两个空格,因此替换之后字符串的长度是 18。
我们从字符串的后面开始复制和替换。首先准备两个指针 P1 和 P2,P1 指向原始字符串的末尾,P2 指向替换后的字符串的末尾。接下来我们向前移动指针 P1,逐个把它指向的字符复制到 P2 指向的位置。碰到空格之后,把 P1 向前移动 1 格,在 P2 之前插入字符串 %20,同时也要把 P2 向前移动 3 格。从上面的分析中可以看出,所有的字符都只复制(移动)一次,因此这个算法的时间效率是 O(n),比第一个方法要快。
1 | int main(int argc, const char * argv[]) { |
3. 数据结构 - 链表
我们说链表是一种动态数据结构,是因为在创建链表时,无须知道链表的长度。当插入一个节点时,我们只需要为新节点分配内存,然后调整指针的指向来确保新节点被链接到链表当中。内存分配不是在创建链表时一次性完成的,而是每添加一个节点分配一次内存。由于没有闲置的内存,链表的空间效率比数组高。
面试题 6
题目:从尾到头打印链表。输入一个链表的头节点,从尾到头反过来打印出每个节点的值。链表节点定义如下:
1 | struct ListNode { |
方法一,很多人第一反应是从头到尾输出将会比较简单,于是我们很自然地想到把链表中的链接节点的指针反转过来,改变链表的方向,然后就可以从头到尾输出了。但是是否允许在打印链表的时候修改链表的结构,这取决于面试官的要求,因此在面试的时候我们要询问清楚面试官的要求。
方法二,我们可以用栈实现「后进先出」这种顺序,每经过一个节点的时候,把该节点放到一个栈中。当遍历完整个链表后,再从栈顶开始逐个输出节点的值。
1 | typedef struct listNode { |
方法三,既然想到了用栈来实现这个函数,而递归在本质上就是一个栈结构。要实现反过来输出链表,我们每访问到一个节点的时候,先递归输出它后面的节点,再输出该节点自身,这样链表的输出结果就反过来了。虽然基于递归的代码看起来很简洁,但有一个问题:当链表非常长的时候,就会导致函数调用的层级很深,从而有可能导致函数调用栈溢出。显然用栈基于循环实现的代码的鲁棒性要好一些。
1 | void PrintListReversingly_Recursively(ListNode* pHead) { |
4. 数据结构 - 树
面试的时候提到的树,大部分是二叉树,其中最重要的操作莫过于遍历,通常树有如下几种遍历方式:
- 前序遍历:先访问根节点,再访问左子节点,最后访问右子节点。图中前序遍历的顺序是 10、6、4、8、14、12、16;
- 中序遍历:先访问左子节点,再访问根节点,最后访问右子节点。图中中序遍历的顺序是 4、6、8、10、12、14、16;
- 后序遍历:先访问左子节点,再访问右子节点,最后访问根节点。图中后序遍历的顺序是 4、8、6、12、16、14、10;
- 宽度优先遍历:先访问树的第一层节点,再访问树的第二层节点,直到访问到最下面一层节点。图中宽度优先遍历的顺序是 10、6、14、4、8、12、16。

二叉树有很多特例,二叉搜索树就是其中之一。在二叉搜索树中,左子节点总是小于或等于根节点,右子节点总是大于或等于根节点,上图中的二叉树就是一颗二叉搜索树。我们可以平均在 O(logn) 的时间内根据数值在二叉搜索树中找到一个节点。
二叉树的另外两个特例是堆和红黑树。堆分为最大堆和最小堆,在最大堆中根节点的值最大,在最小堆中根节点的值最小,有很多需要快速找到最大值或者最小值的问题都可以用堆来解决。红黑树是把树中的节点定义为红、黑两种颜色,并通过规则确保从根节点到叶节点的最长路径的长度不超过最短路径的二倍。在 C++ 的 STL 中,set、multiset、map、multimap 等数据结构都是基于红黑树实现的。
面试题 7
题目:重建二叉树。输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。例如,输入前序遍历序列 { 1, 2, 4, 7, 3, 5, 6, 8 } 和中序遍历序列 { 4, 7, 2, 1, 5, 3, 8, 6 },则重建如下图所示的二叉树并输出它的头节点。二叉树节点的定义如下:
1 | typedef struct binaryTreeNode { |
前序遍历序列的第一个数字 1 就是根节点的值,根据中序遍历的特点,在根节点的值 1 前面的 3 个数字都是左子树节点的值,位于 1 后面的 4 个数字都是右子树节点的值。然后我们可以用同样的方法分别构建左、右子树。也就是说,接下来的事情可以用递归的方法去完成。
1 | typedef struct binaryTreeNode { |
面试题 8
题目:二叉树的下一个节点。给定一棵二叉树和其中的一个节点,如何找出中序遍历序列的下一个节点?树中的节点除了有两个分别指向左、右子节点的指针,还有一个指向父节点的指针。
如下图的二叉树的中序遍历序列是 { d, b, h, e, i, a, f, c, g },我们将以这棵树为例来分析如何找出二叉树的下一个节点。

- 如果这个节点有右子树,那么它下一个节点就是它的右子树中的最左子节点;
- 如果这个节点没有右子树,但是这个节点是它父节点的左子节点,那么它的下一个节点就是它的父节点;
- 如果这个节点没有右子树,并且这个节点是它父节点的右子节点,我们可以沿着指向父节点的指针一直向上遍历,直到找到一个是它父节点的左子节点的节点,那么这个节点的父节点就是我们要找的下一个节点。
1 | typedef struct binaryTreeNode { |
5. 数据结构 - 栈和队列
栈是一个非常常见的数据结构,比如操作系统会给每个线程创建一个栈用来存储函数调用时各个函数的参数、返回地址和临时变量等。栈的特点是后进先出,即最后被 Push 栈的元素会第一个被 Pop。通常栈是一个不考虑排序的数据结构,我们需要 O(n) 时间才能找到栈中最大或者最小的元素,如果想要在 O(1) 时间内得到栈的最大值或者最小值,则需要对栈做特殊的设计。
队列是另外一种很重要的数据结构,和栈不同的是,队列的特点是先进先出。
面试题 9
题目:用两个栈实现队列。请实现队列中的两个函数 appendTail 和 deleteHead,分别完成在队列尾部插入节点和在队列头部删除节点的功能。
这道题的意图是要求我们操作这两个「先进后出」的栈实现一个「先进先出」的队列。我们通过一个具体的例子来分析往该队列插入和删除元素的过程。首先插入一个元素 a,先把它插入 stack1,此时 stack1 中有元素 { a },stack2 为空。再压入两个元素 b 和 c,还是插入 stack1,此时 stack1 中的元素有 { a, b, c },其中 c 位于栈顶,而 stack2 仍然为空。
这时候我们从队列中删除一个元素,按照队列先入先出的规则,由于 a 比 b、c 先插入队列中,最先被删除的元素应该是 a。元素 a 存储在 stack1 中,但并不在栈顶上,因此不能直接删除。所以我们把 stack1 中的元素逐个弹出并压入 stack2,stack1 为空,而 stack2 中的元素是 { c, b, a },这时候就可以弹出 stack2 的栈顶 a 了。
从上面的分析中我们可以总结出:当 stack2 不为空时,在 stack2 中的栈顶元素是最先进入队列的元素,可以弹出。当 stack2 为空时,我们把 stack1 中的元素逐个弹出并压入 stack2。由于先进入队列的元素被压到 stack1 的底端,经过弹出和压入操作之后就处于 stack2 的顶端,又可以直接弹出。
1 | char stack1[100] = {}; |
面试题 9 - 附
题目:用两个队列实现一个栈。
我们通过一系列栈的压入和弹出操作来分析用两个队列模拟一个栈的过程。我们先往栈内压入一个元素 a,由于两个队列现在都是空的,我们可以选择把 a 插入两个队列的任意一个,不妨把 a 插入 queue1。接下来继续往 queue1 压入 b、c 两个元素,这个时候 queue1 包含了 3 个元素 { a, b, c }。
现在我们从栈内弹出一个元素,根据栈的后入先出原则,最后被压入栈的 c 应该最先被弹出。由于 c 位于 queue1 的尾部,而我们每次只能从队列的头部删除元素,因此我们可以先从 queue1 中依次删除元素 a、b 并插入 queue2,再从 queue1 中删除元素 c,这就相当于从栈中弹出元素 c 了。我们可以用同样的方法从栈内弹出元素 b。
接下来我们考虑往栈内压入一个元素 d,此时 queue1 已经有一个元素,我们就把 d 插入 queue1 的尾部。如果我们再从栈内弹出一个元素,那么此时被弹出的应该是最后被压入的 d。由于 d 位于 queue1 的尾部,我们只能先从头删除 queue1 的元素并插入 queue2,直到在 queue1 中遇到 d 再直接把它删除。
6. 算法和数据操作 - 递归和循环
递归是在一个函数的内部调用这个函数自身,而循环则是通过设置计算的初始值和终止条件,在一个范围内重复运算。通常递归的代码会比较简洁,在树的前序、中序、后序遍历算法的代码中,递归的实现明显要比循环简单得多。在面试的时候,如果面试官没有特别的要求,可以尽量多采用递归的方法编程。递归虽然有简洁的优点,但它同时也有显著的缺点:
- 递归由于是函数调用自身,而函数调用是有时间和空间的消耗的:每一次函数调用,都需要在内存栈中分配空间以保存参数、返回地址和临时变量,而且往栈里压入数据和弹出数据都需要时间,所以递归实现的效率不如循环;
- 递归中有可能很多计算都是重复的,从而对性能带来很大的负面影响。递归的本质是把一个问题分解成两个或者多个小问题。如果多个小问题存在相互重叠的部分,就存在重复的计算;
- 除效率之外,递归还有可能引起更严重的问题:调用栈溢出。在前面的分析中提到需要为每一次函数调用在内存栈中分配空间,而每个进程的栈的容量是有限的,当递归调用的层级太多时,就会超出栈的容量,从而导致调用栈溢出。
面试题 10 - 1
题目:斐波那契数列。写一个函数,输入 n,求斐波那契数列的第 n 项。斐波那契数列的定义如下:

方法一,很多 C 语言教科书在讲述斐波那契数列的时候,常用递归的解法,效率很低,挑剔的面试官不会喜欢。我们以求解 f(10) 为例来分析递归的求解过程,用树形结构来表示这种依赖关系,如下图所示:

我们不难发现,在这棵树中有很多节点是重复的,而且重复的节点数会随着 n 的增大而急剧增加,用递归方法计算的时间复杂度是以 n 的指数的方式递增的。读者不妨求斐波那契数列的第 100 项试试,感受一下这样递归会慢到什么程度。
1 | long long Fibonacci(unsigned int n) { |
方法二,上述递归代码之所以慢,是因为重复的计算太多。我们可以从下往上计算,首先根据 f(0) 和 f(1) 算出 f(2),在根据 f(1) 和 f(2) 算出 f(3),以此类推就可以算出第 n 项了,这种思路的时间复杂度是 O(n) 。
1 | long long Fibonacci(unsigned int n) { |
方法三

代码地址:https://github.com/zhedahht/CodingInterviewChinese2/blob/master/10_Fibonacci/Fibonacci.cpp
面试题 10 - 2
题目:青蛙跳台阶问题。一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级台阶。求该青蛙跳上一个 n 级台阶总共有多少种跳法。
首先我们考虑最简单的情况:只有 1 级台阶,那显然只有一种跳法;如果有 2 级台阶,可以跳两次,一次跳 1 级,或者跳一次,跳 2 级。
接着我们再来讨论一般情况:我们把 n 级台阶时的跳法看成 n 的函数 f(n),当 n > 2 时,第一次跳的时候就有两种不同的选择:一是第一次只跳 1 级,此时跳法数目等于后面剩下的 n - 1 级台阶的跳法数目,即为 f(n-1);二是第一次跳 2 级,此时跳法数目等于后面剩下的 n - 2 级台阶的跳法数目,即为 f(n-2)。因此,n 级台阶的不同跳法的总数 f(n)=f(n-1)+f(n-2)。分析到这里,我们不难看出这实际上就是斐波那契数列了。
面试题 10 - 附 1
题目:在青蛙跳台阶的问题中,如果把条件改成:一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级台阶,也可以跳上 n 级台阶,此时该青蛙跳上一个 n 级的台阶总共有多少种跳法?
我们用数学归纳法可以证明 f(n)=2^(n-1)。
面试题 10 - 附 2
题目:我们可以用 2×1 的小矩形横着或者竖着去覆盖更大的矩形。请问用 8 个 2×1 的小矩形无重叠地覆盖一个 2×8 的大矩形,总共有多少种方法?

同样是斐波那契数列。
7. 算法和数据操作 - 查找和排序
查找和排序都是在程序设计中经常用到的算法。查找相对而言较为简单,不外乎顺序查找、二分查找、哈希表查找和二叉排序树查找。在面试的时候,不管是用循环还是用递归,面试官都期待应聘者能够信手拈来写出完整正确的二分查找代码。哈希表和二叉排序树查找的重点在于考察对应的数据结构而不是算法。哈希表最主要的优点是我们利用它能够在 O(1) 时间内查找某一元素,但其缺点是需要额外的空间来实现哈希表。
排序比查找要复杂一些,面试官会经常要求应聘者比较插入排序、冒泡排序、归并排序、快速排序等不同算法的优劣。强烈建议应聘者对各种排序算法的特点烂熟于胸,能够从额外空间消耗、平均时间复杂度和最差时间复杂度等方面去比较它们的优缺点。很多公司的面试官喜欢在面试环节要求应聘者写出快速排序的代码。
实现快速排序算法的关键在于先在数组中选择一个数字,接下来把数组中的数字分为两部分,比选择的数字小的数字移到数组的左边,比选择的数字大的数字移到数组的右边。
1 | void Swap(int* x, int* y) { |
不同的排序算法适用的场合也不尽相同,快速排序虽然总体的平均效率是最好的,但也不是任何时候都是最优的算法。在面试的时候,如果面试官要求实现一个排序算法,那么应聘者一定要问清楚这个排序应用的环境是什么、有哪些约束条件、是否可以用辅助内存。比如对公司员工年龄进行排序,下面的方法用长度 100 的整数数组作为辅助控件换来了 O(n) 的时间效率:
1 | void SortAges(int ages[], int length) { |
面试题 11
题目:旋转数组的最小数字。把一个数组最开始的若干个元素搬到数组的末尾,我们称之为数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。例如,数组 { 3, 4, 5, 1, 2 } 为 { 1, 2, 3, 4, 5 } 的一个旋转,该数组的最小值为 1。
本题给出的数组在一定程度上是排序的,因此我们可以用二分查找法的思路来寻找这个最小的元素。
1 | int MinInOrder(int* numbers, int index1, int index2) { |
8. 算法和数据操作 - 回溯法
如果面试题要求在二维数组(可能具体表现为迷宫或者棋盘等)上搜索路径,那么我们可以尝试用回溯法。通常回溯法很适合用递归的代码实现。只有当面试官限定不可以用递归实现的时候,我们再考虑用栈来模拟递归的过程。
回溯法可以看成蛮力法的升级版,它从解决问题每一步的所有可能选项里系统地选择出一个可行的解决方案。回溯法非常适合由多个步骤组成的问题,并且每个步骤都有多个选项。当我们在某一步选择了其中一个选项时,就进入下一步,然后又面临新的选项。我们就这样重复选择,直至到达最终的状态。
用回溯法解决的问题的所有选项可以形象地用树状结构表示,树的叶节点对应着终结状态,如果在叶节点的状态满足题目的约束条件,那么我们找到了一个可行的解决方案。如果在叶节点的状态不满足约束条件,那么只好回溯到它的上一个节点再尝试其他的选项。
面试题 12
题目:矩阵中的路径。请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一格开始,每一步可以在矩阵中向左、右、上、下移动一格。如果一条路径经过了矩阵的某一格,那么该路径不能再次进入该格子。例如,在下面的 3×4 的矩阵中包含一条字符串 bfce 的路径,但矩阵中不包含字符串 abfb 的路径,因为字符串的第一个字符 b 占据了矩阵中的第一行第二个格子之后,路径不能再次进入这个格子。

这是一个可以用回溯法解决的典型题,由于回溯法的递归特性,路径可以被看成一个栈。当在矩阵中定位了路径中前 n 个字符的位置之后,在与第 n 个字符对应的格子的周围都没有找到第 n+1 个字符,这时候只好在路径上回到第 n-1个字符,重新定位第 n 个字符。
1 | bool hasPathCore(const char* matrix, int rows, int cols, int row, int col, const char* str, int pathLength, bool* visited) { |
面试题 13
题目:机器人的运动范围。地上有一个 m 行 n 列的方格,一个机器人从坐标 (0, 0) 的格子开始移动,它每次可以向左、右、上、下移动一格,但不能进入行坐标和列坐标的数位之和大于 k 的格子。例如,当 k 为 18 时,机器人能够进入方格 (35, 37),因为 3+5+3+7=18。但它不能进入方格 (35, 38),因为 3+5+3+8=19。请问该机器人能够到达多少个格子?
1 | int getDigitSum(int number) { |
9. 算法和数据操作 - 动态规划与贪婪算法
如果面试题是求某个问题的最优解,并且该问题可以分为多个子问题,那么我们可以尝试用动态规划。在用自上而下的递归思路去分析动态规划问题的时候,我们会发现子问题之间存在重叠的更小的子问题。为了避免不必要的重复计算,我们用自下而上的循环代码来实现,也就是把子问题的最优解先算出来并用数组(一维或者二维数组)保存下来,接下来基于子问题的解计算大问题的解。
如果我们告诉面试官动态规划的思路之后,面试官还在提醒说再分解子问题的时候是不是存在某个特殊的选择,如果采用这个特殊的选择将一定能得到最优解,那么,通常面试官这样的提示意味着该面试题可能适用于贪婪算法。当然面试官也会要求应聘者证明贪婪选择的确最终能够得到最优解。
面试题 14
题目:剪绳子。给你一根长度为 n 的绳子,请把绳子剪成 m 段(m、n 都是整数,n>1,m>1),每段绳子的长度记为 k[0], k[1], ···, k[m]。请问 k[0] × k[1] × ··· × k[m] 可能的最大乘积是多少?例如,当绳子的长度是 8 时,我们把它剪成长度分别为 2、3、3 的三段,此时得到的最大乘积是 18。
方法一,动态规划。
10. 算法和数据操作 - 位运算
位运算可以看成一类特殊的算法,它是把数字表示成二进制之后对 0 和 1 的操作。一般总共只有与、或、异或、左移和右移 5 种位运算。