如何快速准备面试中的算法,获得 Offer?

如何快速准备面试中的算法,获得 Offer?

现如今越来越多的公司在面试过程中会考察数据结构和算法。在最近几年,难度颇有上升趋势。因此作为求职者,在面试前刷刷题似乎已经成为准备过程中必不可少的环节了。

在 5 年前,Leetcode 只有 200 道左右的题目,不仅数量少,而且题目种类也不全面。求职者为了刷算法,除了“泡” Leetcode 以外,还需要去看《剑指 offer》、《编程之美》、《编程珠玑》等书籍作为补充。时至今日 Leetcode 题目数量已经过千,且基本上都收录了其它同类型网站、书籍中的题目,因此我们只要专心把 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):二分查找,跟分治(Divide & Conquer)相关的基本上都是 logn
  • O(n):线性查找
  • O(nlogn):归并排序,快速排序的期望复杂度,基于比较排序的算法下界
  • O(n²):冒泡排序,插入排序,朴素最近点对
  • O(n³):Floyd 最短路,普通矩阵乘法
  • O(2ⁿ):枚举全部子集
  • O(n!):枚举全排列

常见时间复杂度的比较:

O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(n³) < O(2ⁿ) < O(n!)

  • O(logn) 近似于是常数的时间复杂度,当 n 为 2^32 的规模时 logn 也只是 32 而已;
  • 对于顺序执行的语句或者算法,总的时间复杂度等于其中最大的时间复杂度。例如 O(n²) + O(n) 可直接记做 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) 空间的要求会更宽松一点。

常用数据结构和算法

在解决一道算法题时,可以分为两个步骤:

  1. 看到题目有思路
  2. 能将思路通过代码实现

相信绝大多数的读者都难在了第一步,建立起这种直觉,需要有足够多的知识储备,只有胸中有丘壑,才能游刃有余的选择合适的方法。

接下来我为读者们总结一下需要熟知的常用数据结构和算法,每个部分也有对应的经典题,请大家务必手写实现一下。出于篇幅的原因,我会选择其中几道详细讲解,对于其它大家感兴趣或者有疑问的部分欢迎大家在读者圈交流。

数组&字符串

数组和字符串相关性极高,涉及到字符串的题目都可以转换成字符数组来处理。对于 Web 开发工程师,日常工作中会频繁的操作数组和字符串,自然在面试中这部分也是重头戏。

经典题

简单操作:插入、删除字符,旋转

规则判断:罗马数字转换,atoi,浮点数

此类题目需要细致的编程能力,对算法本身要求不是很高。

数字运算:大数相加、二进制加法

这类题目的核心思想是将数字转换为字符串后再处理。

排序、交换、搜索

字典序:字典中的排序方式,依次比较每一个字符,字符小的排前面,相同则比较下一个,如果前面的字符都相同但长度不一样长,那么把短的排前面。

二分

字符计数(hash): 变位词

所谓变位词,是一种把某个词或句子的字母的位置(顺序)加以改换所形成的新词。判断两个词是否是变位词有两种思路:

  1. 分别对两个字符串排个序再做比较
  2. 分别统计两个字符串中各个字符出现的次数,再比较

匹配:正则表达式、全串匹配、KMP、周期判断

简易的正则匹配题目不难但实现细节处理会比较多。KMP 在实际面试中比较少见,尽管它很有用,但很少要求直接实现。

搜索:单词变换、排列组合

实战例题一:交换星号

题意:一个字符串中只包含 * 和数字,请把 * 号都放开头。 思路:

  • 快排 partition 的思想
    • 快排是不稳定的排序,数字相对顺序会变
    • 循环不变式:[0…i-1]都是*, [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

  • 倒着复制 - 使用 i, j两个指针,初始时均指向数组最后一个元素,指针 i 不断的向左扫,当 i 遇到数字时,将 i 指向的值赋给 j 指向的元素,使得 [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)。

思路:

  1. 暴力枚举起点和终点,并判断重复,时间复杂度是O(n²) 。
  2. 通过双指针、滑动窗口,动态维护窗口[i..j),使窗口内字符不重复。

如何维护?保证窗口[i, j)之间没有重复字符:

  • 首先 i, j 两个指针均指向字符串头部,如果没有重复字符,则 j 不断向右滑动,直到出现重复字符;
  • 此时向右滑动指针 i ,使 j “解围”,即通过向右滑动指针 i 使得 [i, j)中没有重复字符
  • 在不断滑动和"解围"的过程中,不断更新遇到的[i, j)区间的最大长度,当 j 扫完整个字符串时,此时记录的最大长度即为所求的最长的不重复子串的长度
  • 时间复杂度 O(n)。

实现:

// 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] 和最大。

思路:

  1. 暴力枚举 i 和 j,时间复杂度 O(n³);
  2. 优化枚举,增加一个变量保存[i…j)区间的和,之后 j 再右移时,直接在这个和之上加 a[j],减少重复计算部分,可将时间复杂度优化到O(n²);
  3. 贪心法,从左开始扫数组,增加一个变量保存当前子列的和,当和 < 0 时,则说明当前子列不可能使后面的部分和增大了,抛弃之。同时在这个过程中的最大子列和存起来,扫完一遍后,即能得到所需的结果。

实现:

// 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
}

这是一道非常经典的教科书级的题目。因为解法多种多样,所以始终有很多公司乐于将这样的题目作为笔试或面试题,最优解体现贪心的算法思想。

链表

数组需要一块连续的内存用来存储数据,而链表恰恰相反,它并不需要一块连续的内存,它通过指针将不连续的内存块串起来。链表常见的有单向链表,双向链表和循环链表。

链表的题目一般都不是很难,主要在处理指针指向的时候要特别注意。

经典题

基本操作 (分组)翻转

排序

归并

复制(复杂链表的复制)

链表是否有环、求环起点、求环长度

和其他数据结构(二叉树)的相互转换

数组和链表对比:

  • 数组插入删除时间复杂度是 O(n),随机访问是 O(1),链表插入删除时间复杂度是 O(1),随机访问是 O(n)。
  • 数组简单易用,在实现上用连续的内存,随机访问时通过数组下标查询效率很高;缺点是当空间不够时,需要再申请一块更大的内存空间,然后搬移数据,成本很高。相反,链表大小没有限制,天然的支持动态扩容,但链表的随机访问效率很低,就单向链表而言,每次访问都得从头结点开始,依次访问,直到找到要访问的节点。

实战例题一:Merge k Sorted Lists

题意:合并 k 个有序链表

思路:

  1. 两两合并,转换成 k - 1 次 Merge Two Sorted Lists 的问题。
  2. 使用最小堆(最小优先队列)实现,首先将 k 个链表的头结点 push 进优先队列, pop 出最小的节点,同时 push 进该节点的 next 节点,所有节点执行一遍全部 pop 出来后,就能得到最终所需的结果。所有节点 push 1 次 pop 1 次,时间复杂度 O(nlogn) 。

实现:

// 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\<ListNode\*\>& lists) {
        ListNode* dummy = new ListNode(-1);
        ListNode* cur = dummy;
        priority_queue<ListNode*, vector<ListNode*>, 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<int> 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 个概念:

  1. 度(Degree):所有与它连接点的个数之和
  2. 入度(Indegree):存在于有向图中,所有接入该点的边数之和
  3. 出度(Outdegree):存在于有向图中,所有接出该点的边数之和

树其实是一种特殊的边数比结点数少一的连通图。

二叉树

包含一个根节点,每个节点至多有两个子节点。

二叉搜索树(BST)

  1. 左子树上所有结点的值均小于或等于它的根结点的值
  2. 右子树上所有结点的值均大于或等于它的根结点的值
  3. 左、右子树也分别为二叉搜索树

最大/小堆

堆(heap)又称作优先队列(priority queue),跟普通队列的规则不同,是按照元素的优先级取出元素而不是按照元素进入队列的先后顺序取出元素的。一般所指的堆默认是指二叉堆。

以最大堆为例,其性质:

  1. 任意节点大于它的所有子节点,最大节点在堆的根上(堆序性);
  2. 是一棵完全二叉树。即除了最底层,其他层的节点都被元素填满,且最底层尽可能地从左到右填入。

AVL

AVL 树是最早被发明的自平衡二叉搜索树。 在 AVL 树中,任一节点对应的两棵子树的最大高度差为 1,因此也被称为高度平衡树。可视化的展示 AVL 各种操作

判断一棵树是否是平衡二叉树的递归描述:

  1. 左右子树的高度差小于等于 1
  2. 其左右子树均为平衡二叉树

红黑树(R/B Tree)

红黑树是每个节点都带有颜色属性的二叉搜索树,它可以在 O(logn) 时间内完成查找,插入和删除。

在二叉搜索树强制一般要求以外,对于任何有效的红黑树增加了如下的额外要求:

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 所有叶子节点都是黑色(叶子是 NIL 节点)
  4. 每个红色节点必须有两个黑色的子节点(从每个叶子到根的所有路径上不能有两个连续的红色节点)
  5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点

AVL 树和红黑树对比:

AVL 平衡度最好,但每个节点要额外保存一个高度差,现在实际应用主要是出现在教科书中以及学生的大作业里,AVL 的平衡算法比较麻烦,需要左右两种 rotate 交替使用,分四种情况。红黑树一样也是平衡二叉搜索树,是工业界最主要使用的二叉搜索平衡树,通过染色规则降低了对平衡度的要求,数学证明红黑树的最大深度是 2log(n + 1) , 最差情况从根到叶子的最长路可以是最短路的两倍,所以它的查询时间复杂度也是O(log n),所以红黑树的时间复杂度是略逊于 AVL,C++ 的 std::set/map/multiset/multimap 等均由红黑树实现。

红黑树和 hashtable 对比:

  1. 红黑树是有序的,hashtable 是无序的,根据需求来选择。如果只需判断某个值是否存在之类的操作,hashtable 实现的要更加高效,如果是需要将两个 map 求并集交集差集等大量比较操作,红黑树实现的 map 更加高效;
  2. 时间复杂度上的区别,红黑树查找删除都是O(logn),hashtable 都是 O(1)。

Tire 树

主要用于前缀匹配,比如字符串、ip地址的搜索,在字符串长度是固定或有限的场景下,Trie 的深度可控,可以获得很好的搜索效果。不过 Trie 的通用性不高,需要针对实际应用来设计自己的Trie,比如做个字典应用,是用 26 个字母,还是用 unicode 来前缀匹配?如果是 ip 地址搜索,是用二进制来前缀拼配,还是八进制来匹配?不同的场景下 childrens 的大小是不一样的。

并查集

并查集是一种树型的数据结构,用于处理一些不交集的合并及查询问题,它可以被用来判断两个元素是否属于同一子集。 定义了两个用于此数据结构的操作:Find,确定元素属于哪一个子集;Union:将两个子集合并成同一个集合。

经典题

树的遍历:前序、中序、后序,递归/非递归实现

树的基本操作:获取树的高度,判断两颗树是否相同,判断是否是二叉搜索树,判断一个树是否左右对称

连通性(割点、割边)

  • 割点:如果去掉一个点以及与它连接的边,该点原来所在的图被分成两部分(不连通),则称该点为割点。
  • 割边:如果去掉一条边,该边原来所在的图被分成两部分(不连通),则称该点为割边。

最小生成树

最小生成树是一副连通加权无向图中一棵权值最小的生成树。最小生成树在实际中具有重要用途,比如路网设计、通信网的设计、旅游路线的规划等等。

构造最小生成树的原则

  • 尽可能选取权值最小的边,但不能构成回路
  • 选择 n-1 条边构成最小生成树

两种标准解法,Kruskal 算法和 Prim 算法,这两种算法的详细描述会比较复杂,这里就不具体阐述了,感兴趣的读者可以找一找这方面的资料。

最短路(Dijkstra, Floyed)

图论中的经典问题之一,假设网络中的每条边都有一个权重(常用长度、成本、时间等表示),最短路问题的目标是找出给定两点(通常是源节点和汇节点)之间总权重之和最小的路径。 Dijkstra 是针对单源最短路的算法,即该算法将会找出从某点出发到其他点的最短距离。 Floyd 是多源最短路算法,复杂度最高(n³),通常用在点比较少的起点不固定的问题中。能解决负边(负权)但不能解决负环。

图的搜索(BFS, DFS)

欧拉回路

给定一张无向/有向图,求一条经过所有边恰好一次的回路。有解的条件:

  • 有向图:所有点的出度入度都相等,从任意一点都可实现
  • 无向图:所有点度数都为偶数

欧拉回路问题的常见的解法有通过递归每个点,剩下的路径仍然构成欧拉回路;另一种是利用一个辅助队列,优势在于占用的空间更少;还有一种是利用深度优先搜索,前向边和后向边来判断

哈密尔顿回路

在任意给定的图中,能不能找到这样的路径,即从一点出发不重复地走过所有的结点(不必通过图中每一条边),最后又回到原出发点。概念上理解一下就够了。

拓扑排序

拓扑排序是有向无环图的所有顶点的线性序列,该序列须满足下面两个条件:

  1. 每个顶点出现且只出现一次;
  2. 若存在一条从顶点 A 到顶点 B 的路径,那么在序列中顶点 A 出现在顶点 B 的前面。

拓扑排序常用实现方法:

  1. 从图中选择一个没有前驱(即入度为0)的顶点并输出;
  2. 从图中删除该顶点和所有以它为起点的有向边;
  3. 重复 1 和 2 直到当前图为空或当前图中不存在无前驱的顶点为止,后一种情况说明有向图中必然存在环。

实战例题一: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\<int\> &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<int> 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\<vector\<int\>\>& grid) {
        // bfs 需要一个 queue 辅助
        queue<pair<int, int>> 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

Bitmap(位图)是一种常用的结构,通过使用 bit 位来记录一些逻辑状态。主要用于海量数据的场景,通过数据的分布特征,压缩空间占用

  • 在 40 亿个不重复无序的 unsigned int 的整数集合中,如何快速判断一个数是否在集合内;
  • 在 40 亿个无序的unsigned int 的整数集合中,如何快速找出重复的整数;
  • 如何对 40 亿个无序的unsigned int 的整数集合排序。
递归与动态规划(Recursive & DP)

递归与动态规划有紧密的关系,动态规划问题的特点是有最优子结构性质,通过记忆化等方法弄掉重复计算。常见的递归是自上往下求解,而动态规划思路常常是自底向上,这两种形式是相反的,但是解决问题的形式是一样的,都是不断迭代到底层,递归会通过堆栈存储临时数据。 对于面试中遇到动态规划的问题,多数是一些经典题,而积累解这种题能力的方式只有多做,动态规划的题目稍微变个形,就很难想得到思路了。

经典题

不同路径

最长公共子序列/最长公共子串(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<vector<int>> dp(m, vector<int>(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 解法,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<vector<int>> dp(m + 1, vector<int>(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%" 会不会使用索引,而不是人云亦云、知其然不知其所以然。

最后,非常感谢读者朋友们的支持,希望大家提出批评和指正,也希望长线保持交流。

欢迎关注我的公众号,回复关键字“大礼包” ,将会有大礼相送!!! 祝各位面试成功!!!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

洲洋的编程课堂

祝你找到满意的工作

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值