美文网首页Linux校招面试剑指BATLinux学习之路
面试中常见的数据结构算法汇总(持续更新)

面试中常见的数据结构算法汇总(持续更新)

作者: batbattle | 来源:发表于2018-04-24 18:29 被阅读110次

    这两天在看一个很不错的资源,通过将常见的数据结构和算法可视化为动画或图画的形式,让学习者直观了解这些数据结构的结构形式与常用操作,以及常用算法的代码执行过程,非常受教。

    这些知识大部分都有所涉猎或者在课堂上学过,但直观的观察执行过程有助于加深理解。

    Basics

    Stack: Array Implementation: 数组从前往后插入,从后往前删除,用 top 指针指示栈顶(待插入位置)。

    Stack: Linked List Implementation Top: 指针指向非空的链头元素,每次插入都从 top指向的链头位置插入,删除也是从链头。

    Queues: Array Implementation: 数组,head 指向队首元素(在左边),tail 指向队尾元素后面的待插入位置(在右边),插入元素在 tail 处,删除元素在 head 处,两个指针都是从左往右走。

    Queues: Linked List Implementation: head 指向队首元素(在左边),tail 指向队尾元素(在右边),从 tail 插入,从 head 删除。

    Recursion

    Factorial: 求 n 的阶乘。函数if 分支给出分解终止条件;else 分支给出分解函数(自调用)和回溯(整合)的递推式。用 Python 写的。简单、经典。

    Reversing a String: 翻转字符串。过程同上,关键是分解方案。

    N-Queens Problem: n 阶皇后问题:给出 n*n 的棋盘,放上 n 个皇后,使得任意两个横着竖着斜着都不相邻。这个问题之所以用到递归,是因为在试探性(确定性试探,有固定顺序)地放置过程中,如果走入死胡同就需要回溯,倒退到之前的某一步。本质上是图的深度优先搜索策略。Python 代码有点复杂,需要研究并练习。

    Indexing

    Binary Search Trees: 二叉查找树,简单好用。对于每一个节点,左子树任意节点都比他小,右大。中序遍历是一个升序序列。要会插入、删除和查找操作。复杂度为 O(h)。

    AVL Trees (Balanced binary search trees): 平衡二叉树,首先是二叉查找树,然后满足平衡条件。平衡就是说任意节点的左右子树深度不得超过1,否则就需要旋转变换。这里最重要的知识点就是在插入、删除时,不满足条件时需要进行旋转操作。有三种形式:左旋、右旋、双旋。插入和删除时的旋转是不同的。平衡,保证了树的查找深度可以更小。

    Red-Black Trees: 红黑树,首先也是二叉查找树。然后根据一些规则,保证从跟节点,走到每个叶子节点,经过的黑色节点数相通。这也是保证「平衡」和较小深度的一种方法。

    红黑树五个性质:1. 只有红黑。2. 根黑。 3. 叶(NULL)黑。 4. 红点黑儿子。5. 条条大路同黑数。

    插入过程中,新插入的节点N总是红色的。然后根据其父亲节点P和叔父节点U的情况,进行适当的旋转和变色:P 红 N 红,右右,单旋;P 红 N 红,右左,双旋;P 红 U 红 N 红,黑色下沉一级。

    删除时,先找到替换节点,直接替换,如果不满足结构,则调整。

    Splay Trees: 伸展树,除了二叉查找树的性质,最新访问的节点总是调整到根节点处。这是基于查找的时间和空间局部性原理,可用于 cache。那么,关键之处就在调整了。三种:

    zig-step: p为 root,只需简单旋转。

    zig-zig step:有 g p n,且 p n 都是左或都是右,那么,先旋 g p,再旋 n。

    zig-zag step: 有 g p n,且 p n 不同为左或右,那么先 n p,再 g。

    Open Hash Tables (Closed Addressing): 同一个位置上,用链表链接多个节点。对于 strings 的 hash,如’hello’,先32位0与’o’的 ascii 求和;结果左移四位,用0补齐;左数11-14位,与’0000’异或;结果与’l’的 ascii 求和…最后的结果,求出10进制,然后hash 到某位置。

    Closed Hash Tables (Open Addressing): 区别于上面那个,某位置被占后,不可再添加新的元素,应该用再探测法找到其他位置。再探测法包括:线性探测(依次往后找空位),二次探测(1,4,9…)和再 hash(用 hash2)。

    Closed Hash Tables, using buckets: 每连续三个位置为一组(bucket),编号。hash 时,先 hash 到对应的组,如果第一个被占,则用第二个,否则第三个;如果三个都已经被占,则用最后面的额外 overflow 区。

    B Trees: 就是有些书上说的 B-树,英文是 B-tree,是多路查找树,也是平衡树,用于磁盘之类的存储结构。B 树在数据结构一书中是着重学习过的,主要知识点是 m 阶B 树的性质、插入后的分裂操作和删除后的合并操作。

    B+ Trees: 跟 B 树挺像,但有些不同:每层包括所有元素,每层的节点之间有链表链接,有更好的索引性能。在数据结构学习中,这种结构不是重点。

    Sorting

    Comparison Sorting

    Bubble Sort: 从头到尾,两两比较,若>,则交换,再往后比较;实际上就是传递大个,从头往后,遇到大个就让大个往后走,直到最大的挪到最后面。然后从头开始第二遍,一直到倒数第二个…每一次遍历,确定队尾部分的一个元素。

    Selection Sort: 有一个 min 指针,先指向第一个,然后依次与后面的比较,如果>,则 min 挪到该出,再继续往后比较,直到最后,然后把 min 处和开始处交换,这样就得到了第一个最小的。第二次,从第二个开始,重复上述过程。每一次遍历,确定队首部分的一个元素。

    Insertion Sort: 从头到尾,依次把当前元素插入到前面合适的位置,插入时从后往前比较,前面的部分已经有序,但不是最终结果。这样,走到最后,就排序成功了。

    Shell Sort:希尔排序,第一次以 d1为步长对列表分段,每个片段里对应元组排序;第二次以 d2(维基百科的分组表示形式也很直观容易理解。

    Merge Sort:归并排序,排序的操作为:1-2,3-4,1-4;5-6,7-8,5-8;1-8;9-10,11-12。。。也就是说,从左到右,把2个排好,再来2个,拍前4个;后面再找4个,像前面一样递归排序;然后合并这8个,再找8个,像前面一样递归排序。。。

    Quck Sort:快速排序,稍微复杂,这篇文章给出一个直观的描述,叫「挖坑填数+分治法」非常好。首先让 i 指向第一个,j 指向最后一个,挖走第一个数存在 X;从 j 开始向左找到一个小于 X 的数,挖走放在刚才的坑,形成新坑;从 i 开始向右找到一个大于 X 的数,挖走放在上一个坑,形成新坑,再从 j 开始向左找。。。直到i 和 j 相遇,把 X 放在最后一个坑中。这时左边都比 X 小,右边都比 X 大。这样,我们就用 X 把原问题划分了一下,左边和右边。第二次就用同样的方法分治 X 左边的和 X 右边的,用递归调用方法。如图:

    Bucket Sort:桶排序。先创建一个同样大小的数组,把待排序数组中的数 Hash(Open Hash) 到这个数组, 形成的新数组中从左到右是有序的,每个位置的链表也是有序的。第二步是从左到右遍历,把元素都放回去。这种方法特别快,但耗空间。截图如下,非常直观:

    Counting Sort:计数排序,很有技巧很好玩。这篇文章讲的很好理解:整个过程分为拉选票和入桶两个过程。投票完毕是这样的:

    待排数组[ 6 2 4 1 5 9 ]

    票箱数组[ 4 1 2 0 3 5 ]

    带排数组中每个元素,比如6,要拉票,所有小于我的都给我投票,所以一共4票。其他元素依次拉票,票箱数组显示了最终拉到的票数。第二步就是入桶:

    入桶后 [ 1 2 4 5 6 9 ]

    6有4票,入的是4号桶;2有1票,入的是1号桶,以此类推。

    (有重复数字怎么处理,日后再研究)

    Radix Sort:基数排序,按照个位数用上述计数排序方法(或其他稳定排序方法)排好,在此基础上按照十位数排序,依次类推。

    Heap Sort:堆排序,利用小顶堆的特点进行排序。在堆调整的过程中同时对数组中的元素调整顺序。堆调整完毕,数组就排序完毕了。如图:

    Heap-like Data Structures

    Heaps:小顶堆(二叉树,完全树),每个节点都比它的左右子树小。按照层级从左到右插入节点,然后自下向上调整大小。删除最小值的时候,直接删除根节点(一直是最小的),然后把最后一个节点移到根节点,然后自顶向下调整大小。若给出一个已经建立好的完全树,想调整为堆,则需要自底向上、从右到左地逐层调整,调整时还需要考虑子树是否不再满足堆条件,if so,自顶向下调整。由于堆是一个按照层次编号的完全树,所以用一个数组作为数据结构。操作堆就是操作数组。

    Binomial Queues:二项队列,也叫Binomial heap,是一种形状很有特点的树。首先要知道什么是二项树:如图为四个不同的二项树,其规律为:度数为k的二项树有一个根结点,根结点下有k个子女,每个子女分别是度数分别为k-1,k-2,…,2,1,0的二项树的根。度数为k的二项树共有2^{k}个结点,高度为k。在深度d处有{\tbinom nd}(二项式系数)个结点。二项堆自然就是满足节点相对大小关系的二项树。二项堆的插入很简单,只需要新建一个节点,并合并。合并的对象只能是两个度相同的二项堆:比较二个根结点关键字的大小,其中含小关键字的结点成为结果树的根结点,另一棵树则变成结果树的子树。删除最小的节点就是删除根节点,根节点下的几个子树全部分开。二项堆在各类操作中时间复杂度都为O(logn),可以说性能很好。其存储结构一般为链表。

    Fibonacci Heaps:如图,斐波那契堆由这样一些小顶堆组成,其中每个节点ADT 如下:

    struct FibonacciHeapNode {

          int key;      //结点

          int degree;    //度

          FibonacciHeapNode * left;  //左兄弟

          FibonacciHeapNode * right; //右兄弟

          FibonacciHeapNode * parent; //父结点

          FibonacciHeapNode * child;  //第一个孩子结点

          bool marked;          //是否被删除第1个孩子

      };

    并且用min 指向最小的根节点。每个节点有一个指针指向其一个子女,它的所有子女由双向循环链表连接,不同的小顶堆的根节点也是通过这种链表连接,叫做根表。

    插入新元素时,只要将其作为单元素 F 堆,并跟原有堆合并,合并的方式是新 F 堆加入原 F 堆的根表中。

    删除元素时,先把 min 指向的最小节点删除,然后将剩下的小顶堆分开,并按照二项堆组合的方式重新组合。

    Leftist Heaps:左偏堆,每个节点除了有左右孩子和键值外,还有一个距离。什么是距离呢? 引用维基百科的话:「当且仅当节点 i 的左子树且右子树为空时,节点被称作外节点(实际上保存在二叉树中的节点都是内节点,外节点是逻辑上存在而无需保存。把一颗二叉树补上全部的外节点,则称为extended binary tree)。节点 i 的距离是节点 i 到它的后代中的最近的外节点所经过的边数。特别的,如果节点 i 本身是外节点,则它的距离为 0;而空节点的距离规定为-1 。」如图。除了堆的性质外,还有一条「节点的左子节点的距离不小于右子节点的距离。」其插入删除等基本操作都是基于合并。怎样合并呢?找到 root 键值最小的那个,用其右子树与其他树合并,若右子树为空,把另外的树直接弄过来,若此时右子树距离比左子树大了,那就交换左右子树;若右子树不空,把右子树摘下来与其他树合并,如此递归进行。删除堆中最小值时,先删除它,再把遗留的几个子树按照前述方法合并。左偏堆合并操作的平摊时间复杂度为O(log n)。

    Skew Heaps:斜堆,上述左偏堆就是一种特殊的斜堆。斜堆没有距离的概念,其合并过程与左偏堆几乎一样,只是在每次合并之后都要左右子树互换一下(启发规则)。这样往往能导致最后形成的树中左子树比右子树深,所以是斜的。

    Graph Algorithms

    Breadth-First Search:广度优先搜索。图可分为有向图和无向图(都可应用本算法),其表示形式有图形、邻接表、临界矩阵,注意图中 Parent 和 Visited 两个数组。

    Depth-First Search:深度优先搜索,基本同上,除了搜索的顺序不同。搜索过程中会涉及到回溯问题,而回溯可以用栈这种数据结构,也可以用递归的这种运行形式。广度优先搜索则不会有回溯的情况。

    Connected Components:连通分量,对于无向图,就是这样的一个子图:任意两个节点都可以路径可达,再加入该子图之外的节点后就不满足任意可达性了,所以也可以叫做最大连通子图。其实每个独立的无向图都是连通分量。还有一个叫强联通分量,是对应于有向图的,这时就不太容易寻找一个有向图的强连通分量了,因为要保证任意两个节点是互相可达的。Kosaraju算法、Tarjan算法、Gabow算法是目前比较有效的算法。DSV 中用的哪种,我还没看明白,等看完《算法概论》中介绍的那种再说。

    Dijkstra’s Shortest Path:用于求解带权有向图(也可求无向图)的单源最短路径。如图,我们用这样一个表格来演示并记录。Vertex 表示图中的节点;Known 表示运行到目前为止,是否确定了该节点的最终最短路径,初始为 F(否);Cost 表示目前为止从源节点到该节点的最短路径,初始为 INF(无穷大); Path 是源点经过哪个节点到达的这个节点,初始为-1(无)。算法运行的过程:假设源点为2,此时2为 T,寻找2的直接邻节点,并更新 Cost(如果新 cost 比当前的小就更新,否则不更新),同时更新 Path 为2;然后,在所有F 的节点中,选择当前 Cost 最大的一个,标记为 T,这个节点的全局最短路径就确定下来了,以此作为中间节点,寻找其直接邻节点,并更新 Cost(注意,已经为 T 的不再更新)和 Path;如此循环,直到所有节点都为 T。

    最终得到如下图所示的表格。接下来就是把 Path 表示出来。比如运行到7的时候,7的 path 是5,5的是6,6的是2,所以7的最终 path 是 2 6 5 7.

    Prim’s Minimum Cost Spanning Tree:Prim 算法,对于给定的无向图,找出最小生成树。最小生成树就是包含图中所有节点和部分边是一个树,并且其权值之和最小。该算法的运行过程就是从源节点出发,选择权值最小的邻近节点,再以此为当前节点,选择未访问的权值最小的邻近节点,如此循环,若到头了,则回溯。总体来说是大 DFS 中包含着小 BFS。具体的运行过程可以使用Dijkstra算法中使用的表格形式。

    Topological Sort (Using Indegree array):对于一个DAG,一定存在拓扑排序,使得对于 uv,在拓扑排序中,u 一定在 v 前面。这里使用直观的Kahn算法:有两个集合,L 表示已经排好的,S 表示入度为0的节点;每次从 S 中取出一个节点 n 放入 L 中,并查看从 n 出发的节点中是否有入度减为0的,若有,加入到 S 中,然后再从 S 中取节点,循环。。。如果最后剩下边,说明该图是有环的,不存在拓扑序列,否则最后得到的 L 即拓扑序列。从算法的执行过程来看,它是基于队列的。

    Topological Sort (Using DFS):基于 DFS 的拓扑排序,这里 S 是所有出度为0的节点的集合。对于每个节点,递归访问以它为尾的所有节点,并标记为 Visited,并按照递归的退出顺序把节点加入到 L 中。这种方法其实也很直观,出度为0的节点,回溯到头一般就是入度为0的节点了。深度优先遍历会用到递归或栈。

    Floyd-Warshall (all pairs shortest paths):留着,太复杂了。

    Kruskal Minimum Cost Spanning Tree Algorithm:Kruskal 算法,对于给定的无向图,找出最小生成树。其方法很简单,就是从所有边中,依次挑选最小的边形成树的一个边,加入到当前的树中,如果这个边使树成为图,就舍弃,寻找次最小的,直到所有的节点都进入这个树中。

    Dynamic Programming

    Calculating nth Fibonacci number,

    Making Change,

    Longest Common Subsequence:动态规划适用于有最优子结构和重叠子问题的问题。最优子结构是指局部最优解能决定全局最优解,这样我们才能把问题分解成子问题来解决。子问题重叠性质是指「在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。

    」最常用的例子是斐波那契数列。其求解最常用的算法是如下递归:

    function fib(n)

          if n = 0 or n = 1

              return 1

          return fib(n − 1) + fib(n − 2)

    虽然通过递归把问题分解为子问题了,但是,递归过程中很多子问题被重叠计算了,比如当n=5时,fib(5)的计算过程如下:

    fib(5)

    fib(4) + fib(3)

    (fib(3) + fib(2)) + (fib(2) + fib(1))

    ((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))

    (((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))

    改进的方法是,我们可以通过保存已经算出的子问题的解来避免重复计算:

    array map [0...n] = { 0 => 0, 1 => 1 }

    fib( n )

        if ( map m does not contain key n)

            m[n] := fib(n − 1) + fib(n − 2)

        return m[n]

    其他

    Disjoint Sets:并查集,也叫union-find algorithm,因为该数据结构上的主要操作是 find 和 union,还有一个基本的 makeset 操作。

    数据结构是一个树,每个节点除了有值外,还有一个指针指向父节点。一个树就是一个集合,树的根节点代表这个集合。由于只需要一个指针指向父节点,一般用一个数组表示这棵树。

    数据结构说清楚了,接下来谈谈其操作。makeset 的作用是建立一个只含X 的集合。find 的作用是找到 X 的根节点,即找到 X 属于哪个集合。union 的作用是把 x 和 y 代表的集合合并。这三个操作的代码非常简单,但是得到的树会很高很偏,在之后的操作中效率就低了。对此有两种优化方法:路径压缩和按秩合并。下面给出最终代码,在注释处注明其改进:

    intfather[MAX];//用数组表示树,记录每个节点的父节点索引

    intrank[MAX];//

    /* 初始化集合*/

    voidMake_Set(intx)

    {

    father[x] = x;//只有一个元素的集合,父节点是自身,也可指定其他,如-1.

    rank[x] =0;//只有一个节点,秩(深度)为0

    }

    /* 查找x元素所在的集合,回溯时压缩路径*/

    intFind_Set(intx)

    {

    if(x != father[x])

            {

    father[x] = Find_Set(father[x]);//回溯时把中间经过的节点的父节点都指向根节点,使树扁平化,压缩路径

            }

    returnfather[x];

    }

    /* 按秩合并x,y所在的集合 */

    voidUnion(intx,inty)

    {

        x = Find_Set(x);

        y = Find_Set(y);

    if(x == y)return;//在一个集合中,不用合并

    if(rank[x] > rank[y])

            {

    father[y] = x;//总是把秩小的树的根节点指向较大的树的根节点,这样秩不会增加

            }

    else

            {

    if(rank[x] == rank[y])

                    {

                        rank[y]++;

                    }

                father[x] = y;

            }

    }


    参考:并查集

    相关文章

      网友评论

        本文标题:面试中常见的数据结构算法汇总(持续更新)

        本文链接:https://www.haomeiwen.com/subject/fnvbcftx.html