数据结构与算法之美读后感

环境

MacBook Pro
极客时间

前言

均摊时间复杂度个人理解:

应用场景(自己的话):

在对一个数据结构进行连续操作时,如果大部分情况都是O(1),只有少部分情况是O(n)时,就可以使用均摊分析法。即将O(n)的情况,均摊到O(1)上。

课程原话:

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

举例来说:


// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10]; 
int len = 10;
int i = 0;

// 往数组中添加一个元素
void add(int element) {
   if (i >= len) { // 数组空间不够了
     // 重新申请一个2倍大小的数组空间
     int new_array[] = new int[len*2];
     // 把原来array数组中的数据依次copy到new_array
     for (int j = 0; j < len; ++j) {
       new_array[j] = array[j];
     }
     // new_array复制给array,array现在大小就是2倍len了
     array = new_array;
     len = 2 * len;
   }
   // 将element放到下标为i的位置,下标i加一
   array[i] = element;
   ++i;
}

分析:

主要看循环次数最多的那个for循环;
上面的代码就是往数组里面插入元素

最好情况时间复杂度

O(1), 即每次都不用走循环,都有位置插。

最坏情况时间复杂度

每次调这个方法时,都要走循环,即发生扩容:

第一次:n,
第二次:2n,
第三次:2^2 * n
第四次:2^3 * n
第k次:2^k * n

常量、系数和低阶可以省略。
所以复杂度为:O(n)

平均时间复杂度

因为插入数组的位置情况有n个,额外再加上扩容,共有n+1种情况,
所以每个位置的概率是:1/n+1,并且每种情况时间复杂度为O(1),额外扩容的那种情况是O(n),所以加权平均数是:

p = 1/n+1
1* p + 1*p + 1*p ... ... + 1*p + n * p = O(1) 
// 这里没搞懂为啥是O(1)

为啥为O(1)呢???

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。

上面表达式等于:

2n/(n+1) 
// 省略 常量
n/(n+1)

根据定义, 这个表达式,并不会因为n的增大,所以为O(1)复杂度。

均摊复杂度

个人理解:
当每次发生扩容后,后面又有经历多个O(1)的复杂度,也就是每次扩容后,紧接就是常量级的复杂度,那么可以把扩容那次O(n)的复杂度,均摊到每次的O(1)中去,这样最后均摊的复杂度就是O(1)。

课程的解释:
每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)

参考地址:https://time.geekbang.org/column/article/40447
注意:该课程需要💰

递归

1、一个问题可以分解成一个个子问题来解决。
2、这个问题和子问题,除了数据规模之外,其他处理方式都是一模一样的。
3、有终止条件

满足上面三个情况,就可以使用递归。

递归的弊端

1、堆栈溢出
2、会出现重复计算 使用散列表来解决
3、空间复杂度高
4、过多的函数调用,耗时可能会很多

递归,就是采用栈这种数据结构,再配合简单的逻辑来实现

哈希算法应用

均衡负载

Q:请求路由到服务器,如果每次都路由到同一台?

A:将客户端IP或者会话ID通过哈希算法得到哈希值,再将哈希值和服务器列表值取模,得到的就是要路由的服务器。

分析算法复杂度

遇到复杂点的算法复杂度时,可以采用递归树的方法来进行分析。比如递归代码的时间复杂度。
有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历。

二叉树

完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列

① 堆是一个完全二叉树;
② 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。

对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。

堆插入数据时,其放到堆的最后面,然后再自下而上进行堆化。
堆删除数据,假设删除堆顶数据,那么就把堆的最后一个数据,放到堆顶,
再自上而下进行堆化,使其满足堆的要求。

思路理解:当有大小顺序要求时,比如要求右边的都比左边的大,那就是二叉搜索树。

  • 求topK用小顶堆;求bottomK用大顶堆

官方:
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

缺点:
二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于 log2n 的情况,从而导致各个操作的效率下降。极端情况下,二叉树会退化为链表,时间复杂度会退化到 O(n)。
解决办法:要解决这个复杂度退化的问题,我们需要设计一种平衡二叉查找树。

如何存储二叉树

想要存储一棵二叉树,有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。

有了高效的散列表为什么还需要二叉树

第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。

第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。

第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。

第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

为什么使用红黑树而不是用AVL树

AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。
每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。

红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

树中的元素叫节点,图中元素叫顶点,两个顶点的连线我们叫边。

广度优先搜索

有名叫地毯式搜索,层层推进,从起始顶点开始,依次往外遍历,广度优先搜索需要借助队列来实现,
遍历得到的路径就是,起始顶点到终止顶点的最短路径。

深度优先搜索

深度优先搜索利用的是回溯的思想,非常适合用递归实现,换种说法,深度优先搜索是借助栈来实现的。在执行效率方面,深度优先和广度优先搜索的时间复杂度都是 O(E),空间复杂度是 O(V)。

字符串匹配算法

我们在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。我们把主串的长度记作 n,模式串的长度记作 m。因为我们是在主串中查找模式串,所以 n>m。

BF(Brute Force) 算法

暴力匹配算法,就是拿模式串到主串中一一比较。需要对比 n-m+1次

RK 算法

在BF算法的基础上引入了哈希算法,主要是减少了比较时间。

RK 算法的思路是这样的:我们通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(这里先不考虑哈希冲突的问题,后面我们会讲到)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。

小结:
BF 算法是最简单、粗暴的字符串匹配算法,它的实现思路是,拿模式串与主串中是所有子串匹配,看是否有能匹配的子串。所以,时间复杂度也比较高,是 O(n*m),n、m 表示主串和模式串的长度。不过,在实际的软件开发中,因为这种算法实现简单,对于处理小规模的字符串匹配很好用。

RK 算法是借助哈希算法对 BF 算法进行改造,即对每个子串分别求哈希值,然后拿子串的哈希值与模式串的哈希值比较,减少了比较的时间。所以,理想情况下,RK 算法的时间复杂度是 O(n),跟 BF 算法相比,效率提高了很多。不过这样的效率取决于哈希算法的设计方法,如果存在冲突的情况下,时间复杂度可能会退化。极端情况下,哈希算法大量冲突,时间复杂度就退化为 O(n*m)。

BM算法

匹配顺序是由数组下表从大到小匹配的,也就是倒着匹配;
在这里插入图片描述
引入两个概念:坏字符和好后缀

坏字符规则

坏字符是指主串中的,xi和si的值是模式串中的下标。

滑动的长度为:si - xi

好后缀规则

因为只按照坏字符规则,或出现匹配倒退的现象:

比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa。不但不会向后滑动模式串,还有可能倒退。所以,BM 算法还需要用到“好后缀规则”。

坏字符是a,其对应的si是模式串b的位置,即为si=0, a 在模式串中存在,xi=1,假设不存在那么xi=-1;
其需要移动的位置为si-xi = 0 - 1= -1;

在这里插入图片描述

当好后缀在模式串中不存在时,还需要用好后缀子串和模式串前缀子串进行匹配
在这里插入图片描述

所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab。

坏字符和好后缀规则,取移动长度最大的那个,就是将要移动的长度。

KMP 算法

在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀。

在这里插入图片描述

KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位?

因为好前缀和主串没有关系,所有好前缀可以提前进行预处理好来后,直接使用。

KMP 算法可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为 next 数组,很多书中还给这个数组起了一个名字,叫失效函数(failure function)。数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。这句话有点拗口,我举了一个例子,你一看应该就懂了。
在这里插入图片描述

比如:ababa,前缀结尾字符下标,从左往右数,最后一个a下标为4,其最长可匹配前缀: aba,在ababa中有两组符合,
取最左边的那组,那么就是下标为:2。

Trie树

利用前缀相同的规格来构建多叉数。

支持多模式串的匹配

AC自动机

为了进一步提供Trie树的效率而发明了:AC自动机。

AC 自动机实际上就是在 Trie 树之上,加了类似 KMP 的 next 数组,只不过此处的 next 数组是构建在树上罢了。

所以,AC 自动机的构建,包含两个操作:

  • 将多个模式串构建成 Trie 树;
  • 在 Trie 树上构建失败指针(相当于 KMP 中的失效函数 next 数组)。

本质上也是Trie的升级版,引入了失败数组的概念,按层来计算每个节点的子节点的失效指针。

小节总结:简单暴力的方法,一步一步的往后移动,进行比较,高级点的玩法,就是预处理好next数组,把最长能移动的长度构建好,如KMP就是先构建好最长可匹配好前缀的位置,AC自动机也是类似,其叫:失败指针。

https://time.geekbang.org/column/article/72810

动态规划

https://time.geekbang.org/column/article/74788

在看动态规划的第一篇时,就倍感吃力,特别是在看到下面这段代码时,疑惑多多:


// weight:物品重量,n:物品个数,w:背包可承载重量

public int knapsack(int[] weight, int n, int w) {
  boolean[][] states = new boolean[n][w+1]; // 默认值false
  states[0][0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (weight[0] <= w) {
    states[0][weight[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划状态转移
    for (int j = 0; j <= w; ++j) {// 不把第i个物品放入背包
      if (states[i-1][j] == true) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= w-weight[i]; ++j) {//把第i个物品放入背包
      if (states[i-1][j]==true) states[i][j+weight[i]] = true;
    }
  }
  for (int i = w; i >= 0; --i) { // 输出结果
    if (states[n-1][i] == true) return i;
  }
  return 0;
}

我自身的疑惑:

“不把第i个物品放入背包” 这段话我一直在纠结为什么不把第i个物品放入背包中。
思考了良久:
今天2020年07月3日,又在纸上人肉代码一步一步的写出代码执行的含义;

1、不把第i个物品放入背包把第i个物品放入背包 这两句话指的是 两种可能性,亦或是两个决策的结果。(要么放入背包,要么不放入)

2、
下面这段代码其实是对 第一个物品进行初始化,因为第一个物品的状态集合就两个;<0, 0>, <0, 2>;

  states[0][0] = true;  // 第一行的数据要特殊处理,可以利用哨兵优化
  if (weight[0] <= w) {
    states[0][weight[0]] = true;
  }

3、通过人肉代码发现:不把第i个物品放入背包的情况,就把上一层的状态完整的复制到本层。
4、无论是不把第i个物品放入背包还是把第i个物品放入背包,条件都是if (states[i-1][j]==true),这是因为我们是基于上一层的状态来推导下一层的状态。而states[i-1][j]==true就表示上一层的有效状态;

所谓有效状态就是,下面表格中为1的:

在这里插入图片描述

我自己人肉的:

在这里插入图片描述

什么的问题适合用动态规划来解决?

一个模型三个特征

多阶段最优解模型:一个问题可以分解成多个阶段来实施,每个阶段都有多个决策组,最后在这些决策组的序列中找到最优解。

  • 无后效性 两层含义:一层:当前阶段的状态确定后,不会被后面阶段的状态影响。二层:只关心前一个阶段的状态值,而不关心是怎么走到这个状态的。
  • 最优子结构 : 最优解都可以通过子结构的最优解推导出来。问题的最优解包含子问题的最优解。
  • 重复子问题,会出现重复的状态。

歌曲相似度推荐

1、基于用户相似推荐
2、基于歌曲相似推荐

第一种基于用户相似推荐,对于每个用户,我们以对歌曲喜欢的程度进行打分,得到向量;
然后再计算欧几里得距离,距离近的则为相似用户,然后从该用户最喜欢的歌曲中进行推荐;

第二种基于歌曲相似推荐,对于每首歌,以用户喜欢的程度得分为向量,然后计算欧几里得距离,
距离近的则说明这两首歌相似,然后进行推荐;

课程里的话

针对每个用户,我们将对各个歌曲的喜爱程度作为向量。基于相似歌曲的推荐思路中,针对每个歌曲,我们将每个用户的打分作为向量.

压缩列表

正常的数组,里面每个元素的大小都是一样的;
比如 ["yu", "tao"],那么里面的元素就是取最大的值来进行保存;
而压缩列表,允许里面的元素大小不一样;

压缩列表不支持随机访问,有点类似链表,需要访问列表里的数据时,需要进行从头遍历访问;

好处:节省内存空间,并且可以使用CPU的二级缓存;

来自:52 | 算法实战(一):剖析Redis常用数据类型对应的数据结构

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

山鬼谣me

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值