数据结构与算法

194 阅读11分钟

参考

数据结构与算法之美 剑指Offer visualgo 动图学习

为什么学习数据结构与算法

  1. 大厂面试
  2. 有益于月度框架源码,理解背后的设计思想
  3. 写出高性能的代码

系统高效学习的学习数据结构和算法

  1. 掌握复杂度分析
  2. 知识图谱
  3. 边学边练,适度刷题
  4. 知识需要沉淀,反复迭代,螺旋上升

复杂度分析

  1. 时间复杂度:表示代码执行时间随数据规模增长的变化趋势
  2. 时间复杂度分析:
    1. 只关注循环执行次数最多的一段代码
    2. 加法法则:总复杂度等于量级最大的那段代码的复杂度
    3. 乘法法则:签到代码的复杂度等于嵌套内外代码的复杂度的乘积
  3. 空间复杂度:表示算法的存储空间与数据规模之间的增长关系
  4. 最好、最坏、平均、均摊时间复杂度: 1.以遍历查找为例:最好即查找元素在第一个位置,最坏即查找元素在最后一个位置 2.平均时间复杂度:引入概率机制可以推算出平均的复杂度(也即期望复杂度),大O复杂度是一种变化趋势,去掉具体系数即为常见的O(n)等复杂度公式 3.均摊时间复杂度:场景较少,一种list或者hash扩容时的复杂度分析,即正常插入是O(1),但是需要扩容时,复杂度是O(n)

数组

  1. 如何实现随机访问:
    1. 数组的两个限制:是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据
    2. 通过首地址 + 下标 * 数据结构大小方式访问数据地址:a[i]_address = base_address + i * data_type_size
  2. 低效的“插入”和“删除”
    1. 随机插入,需要将插入点之后的数据进行搬移,时间复杂度为O(n): (1+2+...n)/n=O(n) ; 优化思路:将某个数据插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
    2. 为保证内存的连续性,随机删除也要搬移数据,复杂度为O(n),优化思路:我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
  3. 警惕数组的访问越界问题

链表

  1. 链表:是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针。
  2. 写出正确的链表代码:
    1. 理解指针或引用的含义:都是一样的,都是存储所指对象的内存地址
    2. 警惕指针丢失和内存泄漏
    丢失:
    p->next = x;  // 将p的next指针指向x结点;
    x->next = p->next;  // 将x的结点的next指针指向b结点;
    未丢失:
    new_node->next = p->next;
    p->next = new_node;
    
    3.利用哨兵简化实现难度 4.重点留意边界条件处理 5.举例画图,辅助思考 6.多写多练,没有捷径
  3. 实现LRU缓存淘汰算法
    1. 维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的
    2. 当有一个新的数据被访问时,我们从链表头开始顺序遍历链表
      1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部
      2. 如果此数据没有在缓存链表中,又可以分为两种情况:
        1. 如果此时缓存未满,则将此结点直接插入到链表的头部
        2. 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部

  1. 栈:后进者先出,先进者后出
  2. 栈是一种“操作受限”的线性表,因为受限所以功能简单不易用错,用在数据集合只涉及在一端插入和删除数据,并且满足后进先出,先进后出的特性,这是应首选“栈“这种数据结构。
  3. 如何实现“栈”:基于数组实现,基于链表实现
  4. 栈在函数调用中的应用
  5. 栈在表达式求值的中的应用
  6. 栈在括号匹配中的应用

队列

  1. 队列:先进者先出
  2. 用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列
  3. 顺序队列实现逻辑:
    1. 初始化 head = 0 和 tail = 0
    2. 入队 tail + 1
      1. tail = n 但 head 不等于0时,数据搬移
      2. tail = n 且 head 等于0时,队列满
    3. 出队 head + 1
      1. head == tail 时队列为空
  4. 链式队列实现逻辑:
    1. 初始化head 和 tail 指针 分别指向链表第一个和最后一个结点,队列大小count = 0
    2. 入队 tail -> next = new_node, tail = tail -> next, count += 1
      1. count == n 时,队列满
    3. 出队 head = head -> next ,count -= 1
      1. count = 0 时,队列空
  5. 循环队列-基于数组实现
    1. 入队时 tail += 1,当 tail = n 时,新元素放到数组下标0位置 将tail 置为1
      1. 当tail == head时,队列空
      2. 当 (tail + 1) % n == head 时,队列满
  6. 阻塞队列和并发队列
    1. 阻塞队列:阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞
    2. 并发队列:线程安全的队列我们叫作并发队列。
      1. 最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。
      2. 实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。入队前,获取tail位置,入队时比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败。出队则是获取head位置,进行cas。说明:cas无锁算法利用在并发冲突小的场景,且发生冲突时回滚成本比较高,使用cas请注意冲突场景

递归

  1. 递归三要素
    1. 一个问题的解可以分解为几个子问题的解
    2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
    3. 存在递归终止条件
  2. 如何编写递归代码:写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码
  3. 递归代码要警惕堆栈溢出
  4. 递归代码要警惕重复计算:例如斐波那契数列的递归实现

排序

  1. 常见排序算法 image
  2. 排序算法的执行效率:
    1. 最好情况、最坏情况、平均情况时间复杂度
    2. 时间复杂度的系数、常数 、低阶
    3. 比较次数和交换(或移动)次数
  3. 排序算法的内存消耗
    1. 空间复杂度来衡量
    2. 原地排序:原地排序算法,就是特指空间复杂度是 O(1) 的排序算法
  4. 排序算法的稳定性
    1. 思想:待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变
    2. 稳定的排序算法可以基于对象的属性做多次排序
  5. 冒泡排序:
    1. 思想:冒泡排序只会操作相邻的两个数据;每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
    2. 冒泡排序特性:
      1. 原地:空间复杂度O(1)说明:空间复杂度是指算法执行过程中额外的空间, 数据原本就需要消耗的空间n,不算是算法空间的复杂度
      2. 稳定
      3. 时间复杂度
        1. 最好:O(n)
        2. 最坏:O(n^2)
  6. 插入排序:
    1. 思想::取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
    2. 插入排序特性:
      1. 原地
      2. 稳定
      3. 时间复杂度:
        1. 最好:O(n)
        2. 最坏:O(n^2)
  7. 选择排序:
    1. 思想:选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
    2. 选择排序特性:
      1. 原地
      2. 不稳定:比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。
      3. 时间复杂度同插入排序和冒泡排序
  8. 归并排序
    1. 定义:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了
    2. 归并排序使用了分治思想,分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。说明:分治是一种解决问题的处理思想,递归是一种编程技巧
    3. 伪代码如下:
    // 归并排序算法, A是数组,n表示数组大小
    merge_sort(A, n) {
      merge_sort_c(A, 0, n-1)
    }
    
    // 递归调用函数
    merge_sort_c(A, p, r) {
      // 递归终止条件
      if p >= r  then return
    
      // 取p到r之间的中间位置q
      q = (p+r) / 2
      // 分治递归
      merge_sort_c(A, p, q)
      merge_sort_c(A, q+1, r)
      // 将A[p...q]A[q+1...r]合并为A[p...r]
      merge(A[p...r], A[p...q], A[q+1...r])
    }
    
    1. 归并排序的性能分析:
      1. 稳定
      2. 时间复杂度: O(nlogn)
      3. 空间复杂度:非原地排序,最大是O(n)
  9. 快速排序
    1. 思想:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点);我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
    2. 伪代码:
    
    // 快速排序,A是数组,n表示数组的大小
    quick_sort(A, n) {
      quick_sort_c(A, 0, n-1)
    }
    // 快速排序递归函数,p,r为下标
    quick_sort_c(A, p, r) {
      if p >= r then return
    
      q = partition(A, p, r) // 获取分区点
      quick_sort_c(A, p, q-1)
      quick_sort_c(A, q+1, r)
    }
    // 原地分区函数
    partition(A, p, r) {
      pivot := A[r]
      i := p
      for j := p to r-1 do {
        if A[j] < pivot {
          swap A[i] with A[j]
          i := i+1
        }
      }
      swap A[i] with A[r]
      return i
    
    
    1. 快速排序的性能分析
      1. 原地不稳定
      2. 时间复杂度:O(nlogn),分区极不稳定的场景退化为O(n^2)
    2. 快排的一道算法题
      1. 题目:现在你有 10 个接口访问日志文件,每个日志文件大小约 300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这 10 个较小的日志文件,合并为 1 个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有 1GB,你有什么好的解决思路,能“快速”地将这 10 个日志文件合并吗?
      2. 解答:
        1. 先取得十个文件时间戳的最小值数组的最小值a,和最大值数组的最大值b。然后取mid=(a+b)/2,然后把每个文件按照mid分割,取所有前面部分之和,如果小于1g就可以读入内存快排生成中间文件,否则继续取时间戳的中间值分割文件,直到区间内文件之和小于1g。同理对所有区间都做同样处理。最终把生成的中间文件按照分割的时间区间的次序直接连起来即可。
        2. 先构建十条io流,分别指向十个文件,每条io流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的io流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io流读取下一行数据,以此类推,完成文件的合并, 这种处理方式,日志文件有n个数据就要比较n次,每次比较选出一条数据来写入,时间复杂度是O(n),空间复杂度是O(1),几乎不占用内存。
  10. 桶排序
    1. 思想:将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
    2. 桶排序时间复杂度:如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)
    3. 桶排序的问题:
      1. 要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序
      2. 数据在各个桶之间的分布是比较均匀的
    4. 桶排序使用外部排序的场景:所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中
  11. 计数排序
    1. 计数排序其实是桶排序的一种特殊情况,当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间
    2. 计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数
  12. 基数排序
    1. 基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了
    2. 示例:手机号排序,按照位数进行排序,排11次
  13. 排序优化
    1. 选择合适的排序算法 image
    2. 数据量少的场景,可以选择O(n^2)的排序方法,数据量较多选择O(nlogn)的排序方法
    3. 归并和快排的对比
      1. 归并时间复杂度非常稳定,O(nlogn),快排最差是O(n^2)
      2. 归并空间复杂度为O(n),快排可以做到原地排序
      3. 当n非常大是,O(n)的空间复杂度,无法使用内存排序,因此归并虽然时间复杂度可以接受,使用场景仍然少于快排
    4. 快排的优化
      1. 优化的思路:O(n^2)的事件复杂度主要因为分区点选择不够合理,理想的分区点是,被分区点分开的两个分区,数据数量差不多
      2. 三数取中法:我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点
      3. 随机法:随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选得很差的情况,所以平均情况下,这样选的分区点是比较好的。

二分查找

  1. 定义:二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
  2. 时间复杂度:O(logn) 有时候 会比 O(1) 的算法更快
  3. 应用场景:
    1. 二分查找依赖的是顺序表结构,简单点说就是数组
    2. 二分查找针对的是有序数据
    3. 数据量太小不适合二分查找
    4. 数据量太大也不适合二分查找。说明:二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。注意这里的“连续”二字,也就是说,即便有 2GB 的内存空间剩余,但是如果这剩余的 2GB 内存空间都是零散的,没有连续的 1GB 大小的内存空间,那照样无法申请一个 1GB 大小的数组。而我们的二分查找是作用在数组这种数据结构之上的,所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。
  4. 二分查找的常见变体:
    1. 查找第一个值等于给定值的元素,当mid元素等于value时,往前找到第一个即可
    public int bsearch(int[] a, int n, int value) {
      int low = 0;
      int high = n - 1;
      while (low <= high) {
        int mid =  low + ((high - low) >> 1);
        if (a[mid] > value) {
          high = mid - 1;
        } else if (a[mid] < value) {
          low = mid + 1;
        } else {
          if ((mid == 0) || (a[mid - 1] != value)) return mid;
          else high = mid - 1;
        }
      }
      return -1;
    }
    
    1. 查找最后一个值等于给定值的元素,类似于变体1
    2. 查找第一个大于等于给定值的元素,实现思路类似,将大于和等于操作合并即可
    public int bsearch(int[] a, int n, int value) {
      int low = 0;
      int high = n - 1;
      while (low <= high) {
        int mid =  low + ((high - low) >> 1);
        if (a[mid] >= value) {
          if ((mid == 0) || (a[mid - 1] < value)) return mid;
          else high = mid - 1;
        } else {
          low = mid + 1;
        }
      }
      return -1;
    }
    
    1. 查找最后一个小于等于给定值的元素,类似于变体3

跳表

  1. 链表加多级索引的结构,就是跳表。 image
  2. 跳表的时间复杂度O(logn)说明:与二分查找相似,单二分查找空间复杂度是O(1),跳表的空间复杂度为O(n),空间主要是来存储多级索引
  3. 跳表的动态插入和删除
    1. 插入:链表的在指定位置要查到指定位置,需要遍历链表查询,时间复杂度是O(n),之后的插入操作是O(1);而跳表是基于链表的多级索引实现的,查找的时间复杂度是O(logn),插入操作也是O(1)
    2. 删除:删除操作需要获得节点的前驱节点,通过指针操作完成删除。如果是单项链表,需要查找前驱节点,如果是双向链表就无需考虑这个问题。
  4. 跳表索引的动态更新
    1. 如果插入数据不更新索引,会导致退化为单链表
    2. 通过一个随机函数,来决定将这个节点插入到哪几级索引中 image
  5. Redis为什么用跳表而不是红黑树来实现有序集合
    1. Redis有序集合的核心操作:
      1. 插入一个数据
      2. 删除一个数据
      3. 查找一个数据
      4. 按照区间查找数据
      5. 迭代输出有序集合
    2. 其中插入、删除、查找以及迭代输出有序集合的操作,红黑树也可以完成,时间复杂度与跳表一致。但是按照区间查找数据,红黑树的效率。
    3. 跳表更容易代码实现

散列表

  1. 思想:散列表用的是数组支持按照下标随机访问数据的特性,通过散列函数把元素的key映射为下标,然后将数据存储在数组中对应下标位置。当我们按照key查找元素时,可以用同样的散列函数,将键值转化为数组下标,从对应的数组下标位置取出数据。
  2. 散列函数的基本要求:
    1. 散列函数计算得到的散列值是一个非负整数
    2. 如果 key1 = key2,那 hash(key1) == hash(key2)
    3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)
  3. 散列冲突
    1. 开放寻址法:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入
      1. 线性探测:说明:我们在查找的时候要比较key与数组存储元素的key,来判定查找的元素是不是我们想要的,这就要求数组存储的元素要包含key;java中的hashMap数据结构,就会用Entry数据结构作为value值,Entry中包含hash,key,value三个元素,其中hash和key用于判断查找元素是否是正确的
        1. 插入:从散列的下标往后顺序探测,直到一个空的位置,如果到数组末尾依然没有空位置,则从数组头开始往后探测
        2. 查找:从散列的下标往后顺序探测,直到一个空的位置依然不等于value,说明找的元素并没有在散列表中
        3. 删除:删除操作通常要不能将数组元素删除,而是要标记为deleted,以为查找时如果需要顺序探测,碰到的空闲位置是我们后来删除的,会导致原来的查找算法失效,本来存在的数据会被认定为不存在。
      2. 二次探测:二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……
      3. 双重散列:不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置说明:双重散列的思想与布隆过滤器多重散列函数思想一致
    2. 链表法: 散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表
      1. 插入:当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)
      2. 查找和删除:我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除
      3. 时间复杂度:两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数
  4. 如何设计散列函数
    1. 散列函数的设计不能太复杂
    2. 散列函数生成的值要尽可能随机并且均匀分布
  5. 装载因子的大小
    1. 过大容易冲突,过小浪费空间
    2. 动态扩容算法,均摊时间复杂度
    3. 动态扩容时时间优化
      1. 插入:我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中
      2. 查找:为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找
  6. 如何选择冲突的解决办法?
    1. 开放寻址法:
      1. 优点:
        1. 数据存储在数组,有效利用CPU缓存加速查询
        2. 序列化容易
      2. 缺点:
        1. 冲突代价更高,因此装载因子不能太大,导致更浪费空间
    2. 链表法:
      1. 空间利用率更高
      2. CPU缓存不友好
      3. 存储小对象,指针空间消耗大,因此适用于存储大对象
  7. 工业级散列表举例分析:以Java的HashMap为例
    1. 初始大小:HashMap 默认的初始大小是 16,当然这个默认值是可以设置的
    2. 装载因子和动态扩容:默认0.75,当HashMap中元素个数超过0.75*装载因子时,自动扩容,每次扩容为原来的两倍大小
    3. 散列冲突的解决方法:链表法,JDK1.8中,如果链表长度超过8,转化为红黑树
    4. 散列函数
    int hash(Object key) {
        int h = key.hashCode();
        return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
    }
    
    // hashCode是Java对象的hash code, String类型的对象如下:
    public int hashCode() {
      int var1 = this.hash;
      if(var1 == 0 && this.value.length > 0) {
        char[] var2 = this.value;
        for(int var3 = 0; var3 < this.value.length; ++var3) {
          var1 = 31 * var1 + var2[var3];
        }
        this.hash = var1;
      }
      return var1;
    }
    
  8. 散列表和链表组合使用的案例
    1. LRU缓存淘汰算法,在链表章节,我们使用链表实现LRU缓存,查询缓存内容要遍历链表,时间复杂度O(n),本节结合hash算法,可以更高效的实现LRU缓存淘汰算法说明:整个结构简单理解是通过散列表实现缓存的存储,存储的结点数据包含双向链表的prev和next以及hnext三个地址,prev和next方便通过散列表更新双向链表,hnext方便通过双向链表找到散列表
      1. 数据结构,如下图:
        1. prev为双向链表的前驱节点
        2. next为双向链表的后继节点
        3. hnext为散列表冲突时的拉链 image
      2. 查找:散列表查找数据时间复杂度O(1), 通过散列表很快在缓存中查找到一个数据,之后将它移动到双向链表的尾部
      3. 删除:散列表可以在O(1)时间找到删除结点,通过结点prev找到双向链表的前驱节点,所以在双向链表中删除也只需要O(1)的复杂度
      4. 添加:先查看是否在缓存中,如果在,需要将其移动到双向链表尾部;如其不在,看缓存是否已满。如果满了,则将双向链表头部结点删除,同时根据hnext删除散列表上的元素;之后将新增数据放到链表的尾部;如其未满,就直接将数据放到双向链表尾部。
    2. Redis 有序集合说明:Redis有序集合使用场景为根据value查找key值,由于value有序因此查找value在指定区间内的key亦可实现排行榜
      1. 常用操作
        1. 添加成员对象
        2. 按照键值来删除一个成员对象
        3. 按照键值来查找一个成员对象
        4. 按照分值区间查找数据
        5. 按照分值大小排序成员变量
      2. 如果仅仅按照分值来讲对象组织成跳表结构,那按照键值来删除、查询成员对象就会很慢,解决方法与LRU缓存淘汰算法类似。我们可再按照键值构建一个散列表,这样按照key来删除、查找一个成员对象的时间复杂度就变成了O(1)。同时借助跳表结构,其他操作就非常高效。
    3. Java LinkedHashMap说明:LinkedHashMap维护了对象的使用顺序,不只是存储顺序,本质上就是一个LRU缓存淘汰算法

哈希算法

  1. 定义:将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值
  2. 要求:
    1. 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
    2. 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
    3. 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
    4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值
  3. 应用场景:
    1. 安全加密说明:这里作者认为MD5是一种加密算法,其实只是一种摘要算法,完全是不可逆的,只能说hash在密码学中存在重要的应用,如签名认证,唯一标识,数据校验等,但不能认为hash是一种加密算法
      1. MD5、SHA、DES、AES
    2. 唯一标识
    3. 数据校验
    4. 散列函数
    5. 负载均衡:
      1. 实现一种sessioni sticky的负载均衡算法
      2. 方案:客户端ip和回话id等进行hash后与服务器列表进行取模运算,可以将会话信息粘滞到统一服务节点
    6. 数据分片:
      1. 相同id的hash也相同,因次在读取数据时根据hash可以得到分片位置
    7. 分布式存储 & 一致性hash
      1. 一致性hash可以在存储节点扩缩容场景,只需要迁移少部分数据到其他节点,通过引入虚拟节点,可以将数据平缓的迁移到其他节点,而不是相邻节点。

二叉树

  1. 父节点、子节点、兄弟节点、根节点、叶节点(叶子结点)
  2. 高度、深度、层(层是深度+1) image
  3. 二叉树:每个节点做多有两个子节点,分别是左子节点和右子节点
  4. 满二叉树:叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点
  5. 完全二叉树:叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大
  6. 如何标识一棵二叉树:
    1. 链式存储法:每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来 image
    2. 顺序存储法:把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置 image image 非完全二叉树,空节点也需要占用数组下标
  7. 二叉树的遍历
    1. 前序遍历:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树
    2. 中序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树
    3. 后序遍历:对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身
    4. 二叉树遍历的时间复杂度O(n)
  8. 二叉查找树
    1. 定义: 是二叉树的一种类型,也叫二叉搜索树;在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
    2. 查找操作:先取根节点,如果根节点等于查找值,就返回,如果根节点小于查找值,就在左子树中递归查找,否则在右子树中递归查找
    3. 插入操作:先取根节点比较插入数据和节点的大小关系,如果插入数据比节点大,并且右子树为空,就直接插入右子节点位置,否则在右子树中递归操作,如果插入数据比节点小,并且左子树为空,就直接插入左子节点位置,否则在左子树中递归操作。
    4. 删除操作:分为3中场景(1)删除节点没有子节点,将父节点指向该节点的指针置位空;(2)删除节点只有一个子节点,让父节点指向该节点的指针指向子节点;(3)删除节点有两个子节点,需要找到节点的右子树中的最小值替代删除节点的位置 image
    5. 支持重复数据的二叉查找树:
      1. 采用链表法,将相同key值的节点放到链表上
      2. 将相同key值的后插入节点当做右子树中的最小值(相当于后插入节点大于先插入节点);查找时,并不停止查找,而是继续在右子树中查找,知道遇到叶子结点;删除操作,查找到所有要删除的节点,依次删除 image image
    6. 二叉查找树的时间复杂度:树的形态各异,但是跟树的高度成正比,也就是O(height),最理想的情况,二叉查找树是一棵完全二叉树。此时插入和删除的复杂度为O(logn) image

红黑树

  1. 红黑树是一种简化的平衡二叉查找树,引入红黑节点的概念,将二叉查找树的高度控制在对数级上。
  2. 如无必要可以不必完全手写红黑树,但需了解红黑树这种结构产生是为了处理平衡二叉查找树动态更新过程中复杂度提升的问题,其本质还是利用树的自平衡(各种旋转)的能力来维持平衡二叉查找树的查找性能。
  3. 红黑树定义:
    1. 根节点是黑色的;
    2. 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
    3. 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的
    4. 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点
  4. 红黑树的基本思想
    1. 把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性
    2. 找准关注节点,不要搞丢、搞错关注节点
    3. 插入操作的平衡调整比较简单,但是删除操作就比较复杂

递归树

  1. 归并排序,快速排序,斐波那契数列等都可以通过递归的方法去实现,可以通过递归树去计算时间复杂度

  1. 堆的定义
    1. 堆是一个完全二叉树
    2. 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
  2. 堆有哪些操作
    1. 完全二叉树比较适合用数组来存储
    2. 堆化
      1. 从下往上:每插入一个节点,就递归的跟父亲节点比较大小,交换位置,直到根节点
      2. 从上往下:从根节点开始与两个子节点比较,交换位置,直到比较完所有叶子结点
    3. 插入元素往往自下往上堆化,删除元素往往自上向下堆化
  3. 堆排序与快速排序对比
    1. 对排序数据访问的方式没有快速排序友好(堆排访问的下标是跳动的,快排局部顺序)
    2. 堆排数据交换次数多余快排
    3. 排序的复杂度:都是O(logn)
  4. 堆的应用
    1. 优先级队列:利用堆顶元素最大或者最小的特性实现
    2. 求Top K:动态维护Top K元素
    3. 求中位数:一个小顶堆维持前n/2数据,一个大顶堆维持后n/2数据,新增数据时与大顶堆堆顶比较,决定新增元素加入大顶堆还是小顶堆,然后调整两个堆的数量,动态的将大顶堆或者小顶堆的堆顶元素移动对方堆中。

  1. 图是一种非线性表数据结构,图中的元素叫做顶点,顶点之间的连接关系叫做边,顶点之间连接的数量叫做顶点的度,有向图中把度分为入度和出度。
  2. 邻接矩阵存储:依赖一个二维数组,顶点为数组下标,value标识顶点之间的连接关系
  3. 邻接表存储:每个顶点连接的所有顶点以链表方式存储,链表如果较长可以优化数据结构为跳表,平衡二叉树,红黑树,散列表等
  4. 逆邻接表 5.广度优先搜索 BFS 1.利用一个队列存储临近的顶点 2.利用一个set存储访问过的顶点 6.深度优先搜索 DFS 1.利用一个栈存储临近的顶点 2.利用一个set存储访问过的顶点

字符串匹配

  1. BF算法:暴力匹配算法
    1. 从主串的首位置开始遍历模式串的长度个字符串,匹配则返回,不匹配则主串位置后移
    2. 最坏的时间复杂度:O(n*m)
    3. 简单首选
  2. RK算法:Rabin-Karp 算法
    1. 也是从主串首位置开始遍历,但是引用hash算法,计算n - m + 1个子串的hash值与模式串的hash值比较,不同则跳过,相同则比较子串与模式串是否相等
  3. BM算法:Boyer-Moore算法
    1. 如果子串与模式串不匹配,则通过两种方案来跳过主串部分字符串(坏字符以及好后缀)
    2. bm算法实现比较复杂,实际应用中也很少有手写bm的场景,学习bm算法需要了解算法的思想即:在功能实现的基础上,是否可以做一些数据结构和算法层面的优化,以及优化的常用思想:散列表,缓存,预处理等等
  4. KMP算法:Knuth Morris Pratt 算法
    1. 这里我已经看不下去了

贪心算法

  1. 贪心算法的解题步骤:
    1. 当问题具有“针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大”的性质时,首先要想到使用贪心算法
    2. 举几个例子看下贪心算法产生的结果是否是最优的(大部分能用贪心算法的问题,正确性都是显而易见,不需要严格数学推导证明)
  2. 贪心算法的常见问题:
    1. 分糖果问题
    2. 钱币找零
    3. 区间覆盖
    4. 霍夫曼编码
    5. 等待时长

分治算法

  1. 分支算法能解决的问题,一般需要满足下面这几个条件:
    1. 原问题与分解成的小问题具有相同的模式
    2. 原问题分解成的子问题可以独立求解,子问题之间没有相关性
    3. 具有分解终止条件,也就是说,当问题足够小时,可以直接求解
    4. 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了
  2. 分支算法的应用举例:
    1. 归并排序
    2. MapReduce

回溯算法

  1. 定义:回溯算法是一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
  2. 经典案例:
    1. 八皇后问题
    result_list = list([-1, -1, -1, -1, -1, -1, -1, -1, -1])
    
    def print_queens():
        for row in range(1, 9):
            for column in range(1, 9):
                if result_list[row] == column:
                    print "Q ",
                else:
                    print "* ",
    
                if column == 8:
                    print "\n"
    
    
    def cal_8_queens():
        row = 1
        column = 1
        while True:
            if is_ok(row, column):
                result_list[row] = column
                if row == 8:
                    print_queens()
                    break
                else:
                    row += 1
                    column = 1
            else:
                if column != 8:
                    column += 1
                else:
                    last_row = row - 1
                    last_column = result_list[last_row]
                    result_list[last_row] = -1
                    if last_column == 8:
                        last_row -= 1
                        last_column = result_list[last_row]
                        result_list[last_row] = -1
                    row = last_row
                    column = last_column + 1
    
    
    
    
    def is_ok(row, column):
        if is_leftup_ok(row, column) and is_up_ok(row, column) and is_rightup_ok(row, column):
            return True
        return False
    
    
    def is_leftup_ok(row, column):
        leftup_row = row - 1
        leftup_column = column - 1
        while leftup_column > 0 and leftup_row > 0:
            if result_list[leftup_row] == leftup_column:
                return False
            leftup_row -= 1
            leftup_column -= 1
        return True
    
    
    def is_up_ok(row, column):
        up_row = row - 1
        while up_row > 0:
            if result_list[up_row] == column:
                return False
            up_row -= 1
        return True
    
    
    def is_rightup_ok(row, column):
        rightup_row = row - 1
        rightup_column = column + 1
        while rightup_column < 9 and rightup_row > 0:
            if result_list[rightup_row] == rightup_column:
                return False
            rightup_row -= 1
            rightup_column += 1
        return True
    
    
    if __name__ == '__main__':
        cal_8_queens()
    
    
    1. 正则表达式

动态规划

拓扑排序

最短路径

位图

概率统计

向量空间

B+树

搜索

索引

并行算法