现如今越来越多的公司在面试过程中会考察数据结构和算法。在最近几年,难度颇有上升趋势。因此作为求职者,在面试前刷刷题似乎已经成为准备过程中必不可少的环节了。
在 5 年前,Leetcode 只有 200 道左右的题目,不仅数量少,而且题目种类也不全面。求职者为了刷算法,除了“泡” Leetcode 以外,还需要去看《剑指 offer》、《编程之美》、《编程珠玑》等书籍作为补充。时至今日 Leetcode 题目数量已经过千,且基本上都收录了其它同类型网站、书籍中的题目,因此我们只要专心把 Leetcode 上的题目弄透,算法题目基本上就能了解的差不多了。
对于刚开始接触刷题的新手来说,最重要的是克服内心恐惧。俗话说“万事开头难”,任何事要想做好,都需要不断练习、积累,刷题也不例外:只有经过一段时间训练,才能做到在算法题面前「才思泉涌」。个人认为 LeetCode 中的 easy 和 medium 难度的题只要有信心和耐心,是绝对可以完全掌握的。同时,国内一线互联网公司的算法题难度普遍不超过 medium。这么看来,攻克面试算法对大多数人而言,只是需要一个决心而已。
接下来,我就谈一谈自己总结的刷题方法。由于每个人的背景不同,在这里仅供大家参考。读者不必完全照搬经验,如能获得启发,找到适合自己的刷题方法,才是最大的收获。
在开始刷题之前,系统化的学习常用数据结构(数组与链表、字符串、栈与队列、树与图、哈希表等)和基础算法思想(排序、搜索、图论、动态规划、分治、贪心、回溯等)是重中之重。通过补充回顾这些基础知识,建立较为完整的刷题思维体系,才能在遇见新题时,寻找到最优解的方向。
刷题初期,如果看到题目超过 5 分钟还没思路,不妨马上看答案。LeetCode Discuss 版块有很多对于题目解法的讨论,可以对 discuss 按 Most Votes 排序,查看高票数讨论里的解法。多看几种解法,学习人家的最优解,补数据结构和算法知识,进而理解后尝试做到独自实现一遍。如果写起来还有些磕磕绊绊,就反复的看,直到能完全独自实现。按照这个方法刷完一遍后,各种算法题的套路基本上都遇到了。第二遍刷的时候就要自己思考,因为有了大体的方向感和目标,多多少少能写出来一些,medium 难度的题尽量做到能在 30 分钟内完成。在真实的面试场景中,一道题的解题时间也差不多在 30 分钟之内。第三遍就是再过一下,巩固各种套路下的思维方式和解法。经过这三遍刷题,再去应对面试就不再困难。当然这个说起来容易,但坚持下来并不简单,这需要一个循序渐进的过程。
对于“刷题用什么语言”这个问题,我的看法是用自己最擅长的语言。在面试时只有用自己熟悉的语言,才能更得心应手。不过还是建议大家在熟练掌握一门语言之上,尝试去接触一些不同类型的语言,比如选取 JavaScript/Python 配个 C++/Java 的组合,就很有互补性。我目前主要写 Python,业余时间会学一下 C++,早年也写 JavaScript,我在下文的实战例题里也会采用这三种语言之一来实现。
关于刷题的顺序,我的建议是按照 LeetCode 的分类来刷。同一类型的题一起刷,可以加深记忆,比如一周就固定刷一个类型。可以按照 LeetCode 题目列表页面的右侧的分类去筛选题目。
同时在下文中我也总结了面试中各个分类下高频面试题,大家也可以按照频次,有针对性的来刷。关于刷题数量,这取决于你能预留的准备时间:在时间充足的情况下,当然越多越好。不过以面试为目的的刷题,准备时间也不宜拖得过长,需要短期高效,因此我建议在 2~6 个月之内。
如果是对刷题感兴趣的读者,强烈建议大家可以参加一下 LeetCode 每周日的 Weekly Context。每周日北京时间的早上 10:30-12:00,90 分钟内解 4 道题,一般情况是 1 道 easy、2 道 medium、1 道 hard 的题目。通过这个比赛可以帮助大家时刻持续保持手感,也能在解题过程中获得成就感。
在刷题之前,需要系统化的学习一些算法基础知识,其中非常重要的一个内容就是复杂度分析。有的时候分析比会写更重要,有了分析和比较算法复杂度的能力,才能找到问题的最优解。
复杂度一般采用大 O 记号来表示,分为时间复杂度和空间复杂度。
循环次数:
对于一个循环,假设循环体的时间复杂度为 O(n),循环次数为 m,则这个循环的时间复杂度为 O(n*m)。
均摊分析:
对于每一次操作时间复杂度不相同的场景,可以将复杂操作的时间复杂度均摊到之前每一次操作中来分析。例如 C++ 里的 vector 动态数组的自动扩容机制,每次往 vector 里 push 值的时候会判断当前 size 是否等于 capacity,一旦元素超过容器限制,则再申请扩大一倍的内存空间,把原来 vector 里的值复制到新的空间里,触发扩容的这次 push 操作的时间复杂度是 O(n),但均摊到前面 n 个元素后,可以认为时间复杂度是 O(1) 常数。
递归式:
比如快排、归并排序。这类时间复杂度的分析往往比较困难,可以使用主定理,这是算法导论第一章里介绍的一种方法,但在实际面试中很少会碰到需要用主定理分析的情况。
常见时间复杂度:
常见时间复杂度的比较:
O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
在实际面试中,如果你给到的算法时间复杂度是 O(1)/O(logn)/O(n) 中的一种时,基本上就已经是最优解,没必要再进一步优化了。如果是 O(n²) 面试官还让你优化的话,有极大可能是可以优化到 O(nlogn) 或者 O(n),O(n³) 等可以依次类推。
常见的空间复杂度:O(1)、O(n)、O(n²)。有的题目在空间上要求 in-place(原地),是指使用O(1)空间,在输入的空间上进行原地操作。比如字符串反转:
// c++
void reverse(string& str) {
int i = 0, j = str.length();
while (i < j)
swap(str[i++], str[j--]);
}
但 in-place 又不完全等同于常数的空间复杂度,比如数组的快排认为是 in-place 交换,但其递归产生的堆栈的空间是可以不考虑的,所以 in-place 相对 O(1) 空间的要求会更宽松一点。
在解决一道算法题时,可以分为两个步骤:
相信绝大多数的读者都难在了第一步,建立起这种直觉,需要有足够多的知识储备,只有胸中有丘壑,才能游刃有余的选择合适的方法。
接下来我为读者们总结一下需要熟知的常用数据结构和算法,每个部分也有对应的经典题,请大家务必手写实现一下。出于篇幅的原因,我会选择其中几道详细讲解,对于其它大家感兴趣或者有疑问的部分欢迎大家在读者圈交流。
数组和字符串相关性极高,涉及到字符串的题目都可以转换成字符数组来处理。对于 Web 开发工程师,日常工作中会频繁的操作数组和字符串,自然在面试中这部分也是重头戏。
经典题
简单操作:插入、删除字符,旋转
规则判断:罗马数字转换,atoi,浮点数
此类题目需要细致的编程能力,对算法本身要求不是很高。
数字运算:大数相加、二进制加法
这类题目的核心思想是将数字转换为字符串后再处理。
排序、交换、搜索:
字典序:字典中的排序方式,依次比较每一个字符,字符小的排前面,相同则比较下一个,如果前面的字符都相同但长度不一样长,那么把短的排前面。
二分:
字符计数(hash): 变位词
所谓变位词,是一种把某个词或句子的字母的位置(顺序)加以改换所形成的新词。判断两个词是否是变位词有两种思路:
匹配:正则表达式、全串匹配、KMP、周期判断
简易的正则匹配题目不难但实现细节处理会比较多。KMP 在实际面试中比较少见,尽管它很有用,但很少要求直接实现。
搜索:单词变换、排列组合
实战例题一:交换星号
题意:一个字符串中只包含 *
和数字,请把 *
号都放开头。 思路:
*
, [i…j-1]是数字,[j…n-1]未探测 for (int i = 0, j = 0; j < n; ++j)
if (s[j] == '\*') swap(s[i++], s[j]);
// 样例 \*02\*4\*8
// i=0, j=0, \*02\*4\*8 交换s[0],不变,i=1
// i=1, j=1, \*02\*4\*8 不变
// i=1, j=2, \*02\*4\*8 不变
// i=1, j=3, 交换s[1],s[3]变为 \*\*204\*8,并且 i=2
// i=2, j=4, \*\*204\*8 不变
// i=2, j=5, 交换s[2],s[5]变为 \*\*\*2048,并且 i=3
// 再往后没变化。
// 可以看到处理完后数字的顺序是 2048,而输入数字的相对顺序是 0248
[j, n)
区间始终是数字,当 i 扫完整个字符串时,此时将[0, j)
用 *
填充// javascript
/\*\* \* 辅助函数,判断参数是否是数字 \* @param {char} n \* @return {bool} \*/
const isNumeric = n =\> !isNaN(parseFloat(n)) && isFinite(n);
/\*\* \* @param {string} s \* @return {string} \*/
const solution = s =\> {
const n = s.length
let a = s.split('')
let j = n - 1
for (let i = n - 1; i >= 0; --i)
if (isNumeric(a[i])) a[j--] = a[i]
for (; j >= 0; --j) a[j] = '\*'
return a.join('')
};
举这个例子的目的是介绍快排 partition 思想的具体应用,同时展示常用的逆序操作数组的技巧。
实战例题二:Longest Substring Without Repeating Characters
题意:给定一个字符串,返回它最长的不包含重复的子串的长度。例如输入 abcabcbb 输出 3(对应 abc)。
思路:
[i..j)
,使窗口内字符不重复。如何维护?保证窗口[i, j)
之间没有重复字符:
[i, j)
中没有重复字符[i, j)
区间的最大长度,当 j 扫完整个字符串时,此时记录的最大长度即为所求的最长的不重复子串的长度实现:
// javascript
/\*\* \* @param {string} s \* @return {number} \*/
const lengthOfLongestSubstring = s =\> {
let answer = 0, len = s.length
// 记录当前区间内出现的字符
let mapping = {}
// [i, j)
for (let i = 0, j = 0; ; ++i) {
// j 右移的过程
while (j < len && !mapping[s[j]])
mapping[s[j++]] = true
answer = Math.max(answer, j - i)
if (j >= len)
break;
// i 右移的过程,同时将移出的字符在 mapping 中重置
while (s[i] != s[j])
mapping[s[i++]] = false
mapping[s[i]] = false
}
return answer
};
举这个例子的目的是为了展示滑动窗口的思想,通过滑动窗口一般能实现 O(n) 的时间复杂度和 O(1) 的空间复杂度。
实战例题三:最大子数组和
题意:给定数组a[1…n],求最大子数组和,即找出 1 <= i <= j <= n,使 a[i] + a[i + 1] + … + a[j] 和最大。
思路:
实现:
// javascript
/\*\* \* @param {array} A \* @return {number} \*/
const maxSubseqSum = A =\> {
let thisSum = 0, maxSum = 0, n = A.length
for (let i = 0; i < n; i++ ) {
// 向右累加
thisSum += A[i]
// 发现更大和则更新当前结果
maxSum = Math.max(maxSum, thisSum)
// 如果当前子列和为负
if (thisSum < 0)
// 则不可能使后面的部分和增大,抛弃之
thisSum = 0
}
return maxSum
}
这是一道非常经典的教科书级的题目。因为解法多种多样,所以始终有很多公司乐于将这样的题目作为笔试或面试题,最优解体现贪心的算法思想。
数组需要一块连续的内存用来存储数据,而链表恰恰相反,它并不需要一块连续的内存,它通过指针将不连续的内存块串起来。链表常见的有单向链表,双向链表和循环链表。
链表的题目一般都不是很难,主要在处理指针指向的时候要特别注意。
经典题
基本操作 (分组)翻转
排序
归并
复制(复杂链表的复制)
链表是否有环、求环起点、求环长度
和其他数据结构(二叉树)的相互转换
数组和链表对比:
实战例题一:Merge k Sorted Lists
题意:合并 k 个有序链表
思路:
实现:
// c++
/\*\* \* Definition for singly-linked list. \* struct ListNode { \* int val; \* ListNode \*next; \* ListNode(int x) : val(x), next(NULL) {} \* }; \*/
class Solution {
public:
// 定义最小优先队列的 compare functor
struct compare {
bool operator()(const ListNode\* l, const ListNode\* r) {
return l->val > r->val;
}
};
ListNode\* mergeKLists(vector\& lists) {
ListNode* dummy = new ListNode(-1);
ListNode* cur = dummy;
priority_queue, compare> q;
// 初始将各个链表的头结点 push 进优先队列
for (auto node:lists)
if (node)
q.push(node);
// 每 pop 出一个最小的节点之后,再将该节点的下一个节点 push 进优先队列
while (!q.empty()) {
ListNode* t = q.top();
cur->next = t;
cur = cur->next;
q.pop();
if (t->next)
q.push(t->next);
}
return dummy->next;
}
};
实战例题二:Linked List Cycle
题意:判断一个链表是否有环,要求空间复杂度O(1)
思路:如果不考虑空间复杂度,可以使用一个 map 记录遍历的节点。当遇到第一个在 map 中存在的节点时,说明回到了出发点,即链表有环,同时也找到了环的入口。不使用额外内存空间的技巧是使用快慢指针,即采用两个指针,慢指针每verflow:auto;box-sizing:border-box;white-space:pre-wrap;line-height:1.45;color:rgb(51, 51, 51);display:block;word-break:break-word;overflow-wrap:break-word;background-color:rgba(128, 128, 128, 0.05);margin:0px 0px 1.1em;font-family:“Source Code Pro”, monospace;font-size:0.9em;text-align:start;padding:10px 20px;border:0px;border-radius:5px;outline:0px;">
// c++
/\*\* \* Definition for singly-linked list. \* struct ListNode { \* int val; \* ListNode \*next; \* ListNode(int x) : val(x), next(NULL) {} \* }; \*/
class Solution {
public:
bool hasCycle(ListNode \*head) {
auto walker = head;
auto runner = head;
while(runner && runner->next) {
// 慢指针每次走一步
walker = walker->next;
// 快指针每次走两步
runner = runner->next->next;
// 相遇
if(walker == runner)
return true;
}
return false;
}
};
链表经典题目,巧妙运用快慢指针。如何找到环的起点作为一个 Follow-up 留给读者们思考。
堆栈,它是一种运算受限的线性表。其限制是仅允许在表的一端进行插入和删除运算。这一端被称为栈顶,相对地,把另一端称为栈底。由于只允许入栈和出栈都是在栈顶操作,所有最早入栈的元素最后出栈(LIFO—last in first out)。
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。最早进入队列的元素最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)线性表。
经典题
多项式求值 - 逆波兰表达式
用队列实现栈/用栈实现队列
最小栈
括号/标签匹配
实战例题一:Implement Stack using Queues
题意:用队列来实现栈,需要实现 push、pop、top、empty 4 个方法。
思路:需要两个队列,其中一个队列用来放最后加进来的数,模拟栈顶元素。剩下所有的数都按顺序放入另一个队列中。当 push() 操作时,将新数字先加入模拟栈顶元素的队列中,如果此时队列中有数字,则将原本有的数字放入另一个队中,让新数字在这队中,用来模拟栈顶元素。当 top() 操作时,如果模拟栈顶的队中有数字则直接返回,如果没有则到另一个队列中通过平移数字取出最后一个数字加入模拟栈顶的队列中。当 pop() 操作时,先执行下 top() 操作,保证模拟栈顶的队列中有数字,然后再将该数字移除即可。当 empty() 操作时,当两个队列都为空时,栈为空。
// c++
class MyStack {
public:
/\*\* Initialize your data structure here. \*/
MyStack() {}
/\*\* Push element x onto stack. \*/
void push(int x) {
q2.push(x);
while (q2.size() > 1) {
q1.push(q2.front()); q2.pop();
}
}
/\*\* Removes the element on top of the stack and returns that element. \*/
int pop() {
int x = top(); q2.pop();
return x;
}
/\*\* Get the top element. \*/
int top() {
if (q2.empty()) {
for (int i = 0; i < (int)q1.size() - 1; ++i) {
q1.push(q1.front()); q1.pop();
}
q2.push(q1.front()); q1.pop();
}
return q2.front();
}
/\*\* Returns whether the stack is empty. \*/
bool empty() {
return q1.empty() && q2.empty();
}
private:
queue q1, q2;
};
题目不难,属于比较经典的题目,主要考察面试者对栈和队列性质的理解。
实战例题二:Remove All Adjacent Duplicates In String
题意:删除字符串中的所有相邻重复项,比入输入为 “abbaca”,首先可以删除 “bb” 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串 “aaca”,其中又只有 “aa” 可以执行重复项删除操作,所以最后的字符串为 “ca”。
思路:我们可以通过将所有字符串中所有字符入栈,在每一次入栈前判断当前栈顶元素跟即将入栈的元素是否相同,相同我们则直接将栈顶元素 pop 出来,否则将当前元素入栈,所有字符都处理一遍后,栈内剩余的字符即为我们最终的结果。
实现:
// javascript
/\*\* \* @param {string} S \* @return {string} \*/
const removeDuplicates = S =\> {
let stack = []
const length = S.length
for (let i = 0; i < length; i++) {
if (stack.length > 0 && stack[stack.length - 1] === S[i])
stack.pop()
else
stack.push(S[i])
}
return stack.join("")
};
对于遇到这种成对匹配的问题,建立起直觉,重点考虑是否用栈可以实现。
图是由顶点和边组成,可以无边,但至少包含一个顶点,图的分类有多种维度,可以分为有向图和无向图,也可以分为有权图和无权图,还有连通图和非连通图。图的描述方式有两种邻接矩阵和邻接表,空间复杂度分别为O(n²) 和 O(m + n)。
图的顶点有 3 个概念:
树其实是一种特殊的边数比结点数少一的连通图。
二叉树:
包含一个根节点,每个节点至多有两个子节点。
二叉搜索树(BST)
最大/小堆
堆(heap)又称作优先队列(priority queue),跟普通队列的规则不同,是按照元素的优先级取出元素而不是按照元素进入队列的先后顺序取出元素的。一般所指的堆默认是指二叉堆。
以最大堆为例,其性质:
AVL
AVL 树是最早被发明的自平衡二叉搜索树。 在 AVL 树中,任一节点对应的两棵子树的最大高度差为 1,因此也被称为高度平衡树。可视化的展示 AVL 各种操作。
判断一棵树是否是平衡二叉树的递归描述:
红黑树(R/B Tree)
红黑树是每个节点都带有颜色属性的二叉搜索树,它可以在 O(logn) 时间内完成查找,插入和删除。
在二叉搜索树强制一般要求以外,对于任何有效的红黑树增加了如下的额外要求:
AVL 树和红黑树对比:
AVL 平衡度最好,但每个节点要额外保存一个高度差,现在实际应用主要是出现在教科书中以及学生的大作业里,AVL 的平衡算法比较麻烦,需要左右两种 rotate 交替使用,分四种情况。红黑树一样也是平衡二叉搜索树,是工业界最主要使用的二叉搜索平衡树,通过染色规则降低了对平衡度的要求,数学证明红黑树的最大深度是 2log(n + 1) , 最差情况从根到叶子的最长路可以是最短路的两倍,所以它的查询时间复杂度也是O(log n),所以红黑树的时间复杂度是略逊于 AVL,C++ 的 std::set/map/multiset/multimap
等均由红黑树实现。
红黑树和 hashtable 对比:
Tire 树
主要用于前缀匹配,比如字符串、ip地址的搜索,在字符串长度是固定或有限的场景下,Trie 的深度可控,可以获得很好的搜索效果。不过 Trie 的通用性不高,需要针对实际应用来设计自己的Trie,比如做个字典应用,是用 26 个字母,还是用 unicode 来前缀匹配?如果是 ip 地址搜索,是用二进制来前缀拼配,还是八进制来匹配?不同的场景下 childrens 的大小是不一样的。
并查集
并查集是一种树型的数据结构,用于处理一些不交集的合并及查询问题,它可以被用来判断两个元素是否属于同一子集。 定义了两个用于此数据结构的操作:Find,确定元素属于哪一个子集;Union:将两个子集合并成同一个集合。
经典题
树的遍历:前序、中序、后序,递归/非递归实现
树的基本操作:获取树的高度,判断两颗树是否相同,判断是否是二叉搜索树,判断一个树是否左右对称
连通性(割点、割边)
最小生成树
最小生成树是一副连通加权无向图中一棵权值最小的生成树。最小生成树在实际中具有重要用途,比如路网设计、通信网的设计、旅游路线的规划等等。
构造最小生成树的原则
两种标准解法,Kruskal 算法和 Prim 算法,这两种算法的详细描述会比较复杂,这里就不具体阐述了,感兴趣的读者可以找一找这方面的资料。
最短路(Dijkstra, Floyed)
图论中的经典问题之一,假设网络中的每条边都有一个权重(常用长度、成本、时间等表示),最短路问题的目标是找出给定两点(通常是源节点和汇节点)之间总权重之和最小的路径。 Dijkstra 是针对单源最短路的算法,即该算法将会找出从某点出发到其他点的最短距离。 Floyd 是多源最短路算法,复杂度最高(n³),通常用在点比较少的起点不固定的问题中。能解决负边(负权)但不能解决负环。
图的搜索(BFS, DFS)
欧拉回路
给定一张无向/有向图,求一条经过所有边恰好一次的回路。有解的条件:
欧拉回路问题的常见的解法有通过递归每个点,剩下的路径仍然构成欧拉回路;另一种是利用一个辅助队列,优势在于占用的空间更少;还有一种是利用深度优先搜索,前向边和后向边来判断
哈密尔顿回路
在任意给定的图中,能不能找到这样的路径,即从一点出发不重复地走过所有的结点(不必通过图中每一条边),最后又回到原出发点。概念上理解一下就够了。
拓扑排序
拓扑排序是有向无环图的所有顶点的线性序列,该序列须满足下面两个条件:
拓扑排序常用实现方法:
实战例题一:Validate Binary Search Tree
题意:判断一棵树是否为二叉搜索树
思路:二叉搜索树的性质是左子树的值小于根节点的值,根节点的值小于右子树的值,因此二叉搜索树的中序遍历应该是升序的,可以利用中序遍历的这个特点来判断是否是二叉搜索树。 实现:
// c++
/\*\* \* Definition for a binary tree node. \* struct TreeNode { \* int val; \* TreeNode \*left; \* TreeNode \*right; \* TreeNode(int x) : val(x), left(NULL), right(NULL) {} \* }; \*/
class Solution {
public:
// 中序遍历的递归实现
void travel(TreeNode\* root, stack\ &s) {
if (root == nullptr)
return;
travel(root->left, s);
s.push(root->val);
travel(root->right, s);
}
bool isValidBST(TreeNode\* root) {
if (root == nullptr)
return true;
// 这里用了个栈,用其它线性结构也是可以的
stack s;
travel(root, s);
int last = s.top();
s.pop();
// 判断是否是升序
while(!s.empty()) {
if (s.top() >= last)
return false;
last = s.top();
s.pop();
}
return true;
}
};
主要考察二叉搜索树的性质,以及二叉树的中序遍历。
实战例题二:Rotting Oranges
题意:在一个二维矩阵中,每个格子可能有三种状态:空格、放着一个好的橘子或者放着一个已经坏的橘子。每过 1 分钟,坏橘子都会把与它相邻的好橘子传染,导致好橘子变坏。问最少需要多少分钟,矩阵中的所有橘子都坏了。如果不可能实现,则返回 -1。
思路:每分钟坏橘子会向外扩散一层,因此我们需要从所有一开始是坏的橘子开始,用 BFS 搜索一遍,即可得知每个橘子被传染的具体时间。如果 BFS 结束后,发现矩阵中还存在好橘子,那说明存在至少一个橘子的连通区域是被”隔离“起来了,不会被传染,这个时候返回 -1。
实现:
// c++
class Solution {
public:
int orangesRotting(vector\\>& grid) {
// bfs 需要一个 queue 辅助
queue> q;
// 好橘子的数量
int fresh = 0;
int lenX = grid.size();
int lenY = grid[0].size();
int answer = 0;
for (int i = 0; i < lenX; i++) {
for (int j = 0; j < lenY; j++) {
// 统计初始的好橘子的数量
if (grid[i][j] == 1)
fresh ++;
// 坏橘子入队
if (grid[i][j] == 2)
q.push({i, j});
}
}
// 如果初始值就没有好橘子,直接返回
if (fresh == 0)
return 0;
// 外层 while 每循环一次为 1 分钟
while (q.size() > 0) {
int s = q.size();
// 记录该分钟内被新传染坏的橘子数量
int r = 0;
while (s --) {
auto f = q.front();
q.pop();
int a[5] = {0, 1, 0, -1, 0};
// 遍历上下左右四个方向
for (int i = 0; i < 4; i++) {
int x = f.first + a[i];
int y = f.second + a[i + 1];
// x,y 越界,或者已经是坏橘子了,则不处理
if (x < 0 || y < 0 || x >= lenX || y >= lenY || grid[x][y] != 1)
continue;
r ++;
fresh --;
grid[x][y] = 2;
q.push({x, y});
}
}
if (r > 0)
answer ++;
}
return fresh == 0 ? answer: -1;
}
};
Bitmap(位图)是一种常用的结构,通过使用 bit 位来记录一些逻辑状态。主要用于海量数据的场景,通过数据的分布特征,压缩空间占用
递归与动态规划有紧密的关系,动态规划问题的特点是有最优子结构性质,通过记忆化等方法弄掉重复计算。常见的递归是自上往下求解,而动态规划思路常常是自底向上,这两种形式是相反的,但是解决问题的形式是一样的,都是不断迭代到底层,递归会通过堆栈存储临时数据。 对于面试中遇到动态规划的问题,多数是一些经典题,而积累解这种题能力的方式只有多做,动态规划的题目稍微变个形,就很难想得到思路了。
经典题
不同路径
最长公共子序列/最长公共子串(LCS)
最长递增子序列
最长回文子串(朴素的动态规划算法/Manacher 线性的最长回文子串算法)
编辑距离
实战例题一:Unique Paths
题意:有一个 m * n
的网格,机器人从左上角走到右下角,只能向右或者向下走,问有多少种路线的走法。
思路:拆解成一步步的子问题,每一步都依赖上一步的结果。比较明显的可以 DP 求解的问题,dp[i][j]
表示机器人从左上角走到第 i 行 第 j 列时路径的数量,初值 dp[0][0] = 1
,机器人只能向右或者向下走,那么到达某个位置的不同路径数就是到达该位置上面一个位置和该位置左面一个位置的路径和。状态转移方程为 dp[i][j] = dp[i-1][j] + dp[i][j-1]
。
实现:
// c++
class Solution {
public:
int uniquePaths(int m, int n) {
// 赋初值
vector> dp(m, vector(n, 1));
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
return dp[m - 1][n - 1];
}
};
动态规划经典题目,还可以通过滚动数组的方式降维,将空间复杂度降为 O(n)。
实战例题二:Edit Distance
题意:由一个字符串变为另一个字符串的最少操作次数,可以删除一个字符,替换一个字符,插入一个字符。
思路:
dp[i][j]
表示 word1 的[0…i)的子串变为 word2 的[0…j)的子串最少操作的次数,初值 dp[0][j] = j
, dp[i][0] = i
。状态转移方程
word1[i - 1] == word2[j - 1]
时: dp[i][j] = dp[i - 1][j - 1]
word1[i - 1] != word2[j - 1]
时: dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1)
实现:
// c++
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.length(), n = word2.length();
vector> dp(m + 1, vector(n + 1));
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i == 0) {
dp[i][j] = j;
} else if (j == 0) {
dp[i][j] = i;
} else {
dp[i][j] = min(dp[i - 1][j - 1] + (word1[i - 1] == word2[j - 1] ? 0 : 1),
min(dp[i - 1][j] + 1, dp[i][j - 1] + 1)
);
}
}
}
return dp[m][n];
}
};
这一部分介绍一些面试过程中解算法题的建议:一定要和面试官充分沟通,无论是最开始对于题意的理解,还是思考过程中的思路想法,都可以讲出来。尤其是在解题过程中遇到困难,自己没有思路的时候,可以尝试向面试官求助,适当的提示并不会影响面试的最终结果。同时,沟通能力也是面试过程中一个潜在的考察点,它的意义在于能够模拟真实工作中讨论解决一个问题的场景。如果沟通不是潜在考察点,那为什么不用笔试而用面试呢?
我总结了一下当拿到一个具体问题时的解决流程:
从今天起,下一个决心,制定一个计划,通过不断练习,提升自己解算法题的能力,最好的结果是拿到满意的 Offer,不再需要这份资料。
学习数据结构和算法不仅仅对面试有帮助,对于程序的强健性、稳定性、性能来说,算法虽然只是细节,但却是最重要的一部分之一。比如 AVL 或者 B+ 树,可能除了在学校的大作业,一辈子也不会有机会实现一个出来,但你学会了分析和比较类似算法的能力, 有了搜索树的知识,你才能真正理解为什么 InnoDB 索引要用 B+ 树,你才能明白 like "abc%"
会不会使用索引,而不是人云亦云、知其然不知其所以然。
欢迎关注我的公众号,回复关键字“大礼包” ,将会有大礼相送!!! 祝各位面试成功!!!