数据结构与算法
1. 为什么要学习数据结构和算法?
- 直接好处就是写出性能更优的代码;
- 算法,是一种解决问题的思路和方法,有机会应用到生活和事业的其他方面;
- 长期来看,大脑的思考能力是一个人的核心竞争能力,而算法是为数不多的能够有效训练大脑思考能力的途径之一;
2. 怎么学
- 多思考,死记硬背不如不学
- 一定要敲代码,适当刷题
- 学习笔记,留言区互动
- 遇到难点反复迭代
3. 结构图谱
image.png10个主要数据结构
数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树
10个主要算法
递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法
4. 效率和资源消耗的度量衡--复杂度分析
4.1 时间复杂度
渐进时间复杂度,标识算法的执行时间与数据规模之间的增长关系.
- 只关注循环执行次数最多的那段代码
- 加法法则:总复杂度等于量级最大的那段代码的复杂度
- 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
-
logn表示以任意数字为底数的时间复杂度,O(log3n) = O(C * log2n) = O(log2n)
image.png
4.2 空间复杂度
渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系.
注意是因为算法而额外增加的存储空间,不包括数据本身的存储空间;
5. 数组 & 链表
image.png5.1 对比
- 数组是内存中连续的一块内存空间,初始化时,必须要有这一块能够满足的内存用以开辟;而链表支持动态扩容
- 数组访问比链表速度快,数组根据首地址 + 偏移量 * 元素大小 寻址,而链表需要遍历;
- 链表比数组插入快;
基础的数据结构就是数组和链表, 而后面更加复杂的 树 队列 图 等等 都可以通过数组和链表等方式存储, 出现树 队列 图 等数据结构的原因 就是为了解决 部分问题处理过程中时间复杂度过高的问题, 所以数据结构就是为了算法而生的
5.2 如何用链表实现LRU缓存淘汰算法
- 指导思想
单向链表,越靠后的越是最早访问 - 流程
每次获取遍历链表找到节点,找到后,删除原位置数据,在头节点重新插入;没找到时,直接在头节点插入;
如果链表超过容量,则删除最末尾节点 - 优化方案
此方案每次需要遍历链表,时间复杂度为O(n),可考虑散列表来记录每个数据的位置,将时间复杂度降到O(1);
6. 栈
7. 队列
8. 递归算法
8.1 使用条件
- 问题可以分解为多个子问题
- 问题和子问题除了数据规模不同,解题思路一样
- 存在递归终止条件
8.2 递归常见问题及解决方案
- 警惕堆栈溢出:可以声明一个全局变量来控制递归的深度,从而避免堆栈溢出(或者改为迭代循环的非递归写法)。
- 警惕重复计算:通过某种数据结构来保存已经求解过的值,从而避免重复计算。
8.3 如何将递归改写为非递归代码?
笼统的讲,所有的递归代码都可以改写为迭代循环的非递归写法。如何做?抽象出递推公式、初始值和边界条件,然后用迭代循环实现。
9. 排序算法
image.png在小规模数据面前,O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长
10. 二分查找
O(logn) 惊人的查找速度
10.1 局限性
- 首先,二分查找依赖的是顺序表结构,简单点说就是数组。
- 其次,二分查找针对的是有序数据。
- 再次,数据量太小不适合二分查找。(顺序扫即可)
- 最后,数据量太大也不适合二分查找。(数组-连续内存空间)
11. 散列表
11.1 散列表由来
- 散列表来源于数组,利用数组按照下标随机访问的特性;
- 需要存储在散列表中的数据称为键,将键转化为数组下标的方法叫做散列函数,散列函数计算的结果叫做散列值,数据内容存储在散列值对应的数组下标位置;
11.2 散列冲突的解决方案
- 开放寻址法
- 链表法(常用)
为什么散列表和链表经常放在一起使用?
虽然散列表支持高效的插入、删除、查找数据,但是数据都是通过散列函数打乱存储的,不支持排序,结合链表,可以做到既能快速增删查,也能排序查询;
redis如何用O(1)实现LRU算法
12. 哈希算法
将任意长度的二进制值串映射成固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。
12.1 如何设计一个优秀的哈希算法
- 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);
- 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同;
- 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;4. 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。
12.2 一致性哈希算法
在分布式存储应用中,利用一致性哈希算法,可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。
image.png
13. 二叉树
几个概念13.1 二叉树有哪几种存储方式
- 链式存储法
每个节点由3个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式比较常用,大部分二叉树代码都是通过这种方式实现的。 - 顺序存储
用数组来存储,对于完全二叉树,如果节点X存储在数组中的下标为i,那么它的左子节点的存储下标为2i,右子节点的下标为2i+1,反过来,下标i/2位置存储的就是该节点的父节点。注意,根节点存储在下标为1的位置。完全二叉树用数组来存储时最省内存的方式。
完全二叉树定义的由来
13.2 二叉树的遍历
二叉树的遍历13.3 二叉查找树
在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
13.4 有了如此高效的散列表,为什么还需要二叉树?
- 散列表无序存储,如果要有序输出,则需要排序,而二叉树用时间复杂度为O(n)的中序遍历即可
- 散列表插入时有扩容、缩容的话性能低下
- 散列表基于数组,需要连续的内存空间,如果数据量大,不一定满足
- 散列表如果hash冲突,不一定比二叉树高效;
13.5 平衡二叉查找树
二叉树中任意一个节点的左右子树的高度相差不能大于 1;
平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些;
13.6 红黑树
reference
红黑树是最常用的一种平衡二叉查找树;它是「近似平衡」的。它是为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题而产生的。红黑树的高度近似 log2n,所以它是近似平衡,插入、删除、查找操作的时间复杂度都是 O(logn)。
- 根节点是黑色的;
- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
13.7 红黑树自平衡的三种操作:左旋、右旋和变色。
左旋、右旋- 插入的节点必须是红色
- 红黑树的平衡过程是自底向上的递归操作
14. 四个基本的算法思想
前文介绍了基础的数据结构和算法,接下来,介绍几个更加基本的算法;确切的说,它们是算法思想,并不是具体的算法,常用来指导我们设计具体的算法和编码;
14.1 贪心算法
贪心算法使用的场景有限;这种算法思想更多的是指导设计基础算法,比如最小生成树算法、单源最短路径算法、霍夫曼编码;
贪心算法的最难的一块是如何将要解决的问题抽象成贪心算法模型,只要这一步搞定之后,贪心算法的编码一般很简单;
贪心算法解决问题的正确性虽然很多时候都看起来是显而易见的,但是要严谨地证明算法能够得到最优解,并不是件容易的事。所以,很多时候,我们只需要多举几个例子,看一下贪心算法的解决方案是否真的能得到最优解就可以了。
因此不要刻意去记忆贪心算法的原理,多练习才是最有效的学习手段;
14.2 分治算法
核心思想就是分而治之,一般用递归来实现(分治算法是一种处理问题的思想,而递归是一种编程技巧);
分治算法解决的问题需要满足几个条件:
- 原问题与分解成的小问题具有相同的模式;
- 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别;
- 具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
- 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
14.3 回溯算法
一句话描述就是:枚举所有情况,剪枝掉不满足条件的,找最优的
回溯算法一般采用递归的方式来实现
回溯的处理思想,有点类似枚举搜索;枚举所有的解,找到满足期望的解;
为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
回溯算法可以用来指导像深度优先搜索这种算法,除此之外,很多经典的数学问题都可以用回溯来解决:数独、八皇后、0-1背包、图的着色、旅行商问题、正则表达式;
14.3.1 0-1背包问题的回溯解法
0-1背包问题的经典解法是动态规划,不过还有一种简单但是没那么高效的算法:回溯算法;
public int maxW = Integer.MIN_VALUE; //存储背包中物品总重量的最大值
// cw表示当前已经装进去的物品的重量和;i表示考察到哪个物品了;
// w背包重量;items表示每个物品的重量;n表示物品个数
// 假设背包可承受重量100,物品个数10,物品重量存储在数组a中,那可以这样调用函数:
// f(0, 0, a, 10, 100)
public void f(int i, int cw, int[] items, int n, int w) {
if (i == n) { // i==n表示已经考察完所有的物品
if (cw > maxW && cw <= w) // 不能超过背包重量
maxW = cw;
return;
}
f(i + 1, cw, items, n, w); // 当前物品先选择不装进背包
f(i + 1, cw + items[i], items, n, w); // 又回溯回来,当前物品装进背包
}
14.4 动态规划
大部分动态规划能解决的问题,都可以通过回溯算法来解决;
上面的0-1背包问题用回溯法时间复杂度是:O(2^n),而通过动态规划可以将时间复杂度缩短至:O(n*w)。n 表示物品个数,w 表示背包可以承载的总重量。当n很大时,指数级的时间复杂度要比O(n*w)高很多!
动态规划比较适合用来求解最优问题,比如求最大值、最小值等等,它可以非常显著地降低时间复杂度;
适合用动态规划解决的问题
- 一个模型
- 三个特征
最优子结构、无后效性、重复子问题
动态规划解决问题的思路:
我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。这也是动态规划这个名字的由来。
01背包问题的动态规划解题过程
- 状态转移表法
回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码 - 状态转移方程法
找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码
动态规划的弊端
尽管动态规划的执行效率比较高,但是额外申请一个n乘w+1的二维数组,对空间的消耗比较多;所以,动态规划是一种空间换时间的解决思路;
实际上,我们只需要一个w+1的一维数组就可解决问题;动态规划状态转移的过程,都可以基于这个一维数组来操作;
0-1背包问题的动态规划解法:
public static int knapsack2(int[] items, int n, int w) {
boolean[] states = new boolean[w+1]; // 默认值false
states[0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (items[0] <= w) {
states[items[0]] = true;
}
for (int i = 1; i < n; ++i) { // 动态规划
for (int j = w-items[i]; j >= 0; --j) {//把第i个物品放入背包
if (states[j]==true) states[j+items[i]] = true;
}
}
for (int i = w; i >= 0; --i) { // 输出结果
if (states[i] == true) return i;
}
return 0;
}
14.5 贪心 vs 回溯 vs 动态规划
- 贪心
一条路走到黑,就一次机会,只能哪边看着顺眼走哪边 - 回溯
一条路走到黑,无数次重来的机会,还怕我走不出来(Snapshot View) - 动态规划
拥有上帝视角,手握无数平行宇宙的历史存档,同时发展出无数个未来(Versioned Archive View)