本文是 极客时间- 数据结构与算法之美 - 王争 前 Google 工程师。专栏学习笔记整理,课程链接:https://time.geekbang.org/column/intro/100017301?tab=catalog
- 01 | 为什么要学习数据结构和算法?
- 面试
- 业务开发工程师
- 写出达到开源水平的框架才是目标
- 不要只会写凑合能用的代码
- 小结
- 精选留言
- 02 | 如何抓住重点,系统高效地学习数据结构与算法?
- 什么是数据结构和算法
- 学习这个专栏需要什么基础?
- 学习的重点在什么地方?
- 一些事半功倍的技巧
- 03 | 复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?
- 为什么需要复杂度分析
- 大 O 复杂度表示法
- 时间复杂度分析
- 几种常见时间复杂度实例分析
- O(1)
- O(logn)、O(nlogn)
- O(m+n)、O(m*n)
- 空间复杂度分析
- 内容小结
- 04 | 复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度
- 最好、最坏情况时间复杂度
- 平均情况时间复杂度
- 均摊时间复杂度
- 06 | 链表(上):如何实现LRU缓存淘汰算法?
- 五花八门的链表结构
- 链表 VS 数组 性能大比拼
- 解答开篇
- 内容小结
- 07 | 链表(下):如何轻松写出正确的链表代码?
- 技巧一:理解指针或引用的含义
- 技巧二:警惕指针丢失和内存泄漏
- 技巧三:利用哨兵简化实现难度
- 技巧四:重点留意边界条件处理
- 技巧五:举例画图、辅助思考
- 技巧六:多写多练,没有捷径
- 内容小结
- 17 | 跳表:为什么Redis一定要用跳表来实现有序集合?
- 如何理解 “跳表”
- 用跳表查询到底有多快
- 跳表是不是很浪费内存?
- 高效的动态插入和删除
- 跳表索引动态更新
- 内容小结
- 18 | 散列表(上):Word文档中的单词拼写检查功能是如何实现的?
- 散列思想
- 散列函数
- 散列冲突
- 1.开放寻址法
- 线性探测
- 2.链表法
- 内容小结
- 19 | 散列表(中):如何打造一个工业级水平的散列表?
- 如何设计散列函数
- 装在因子过大了怎么办?
- 如何避免低效扩容?
- 如何选择冲突解决方法
- 开放寻址法
- 链表法
- 工业级散列表举例分析
- 初始大小
- 装载因子和动态扩容
- 散列冲突的解决方法
- 散列函数
- 解答开篇
- 20 | 散列表(下):为什么散列表和链表经常会一起使用?
- LRU缓存淘汰算法
- Redis 有序集合
- Java LinkedHashMap
- 解答开篇 & 内容小结
- 21 | 哈希算法(上):如何防止数据库中的用户信息被脱库?
- 什么是哈希算法?
- 应用一 : 安全加密
- 应用二 : 唯一标识
- 应用三 : 数据校验
- 应用四 : 散列函数
- 解答开篇
- 内容小结
- 22 | 哈希算法(下):哈希算法在分布式系统中有哪些应用?
- 应用五 : 负载均衡
- 应用六 : 数据分片
- 应用七 : 分布式存储
- 23 | 二叉树基础(上):什么样的二叉树适合用数组来存储?
- 树
- 二叉树
- 二叉树的遍历
- 解答开篇 & 内容小结
- 24 | 二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?
- 二叉查找树(BST)
- 二叉查找树的查找操作
- 二叉查找树的插入操作
- 二叉查找树的删除操作
- 二叉树的其他操作
- 支持重复数据的二叉查找树
- 二叉查找树的时间复杂度分析
- 解答开篇
- 25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?
- 什么是"平衡二叉查找树"
01 | 为什么要学习数据结构和算法?
面试
越是厉害的公司,越是注重考察数据结构与算法这类基础知识。
学任何知识都是为了“用”的,是为了解决实际工作问题的,学习数据结构和算法自然也不例外。
业务开发工程师
如果不了解类库背后的原理,不懂时间、空间复杂度分析,如何用好、用对?如何评估代码的性能和资源的消耗?
各种框架、中间件、和底层系统等基础架构中,一般都揉合了很多基础数据结构与算法的设计思想。
弄明白底层原理就能更好的使用它们,即使出现问题也很容易就能定位。
掌握数据结构和算法,不管对于阅读框架源码,还是理解其背后的设计思想,都是非常有用的。
写出达到开源水平的框架才是目标
高手之间的竞争其实是细节:算法是不是够优化、数据存取效率是不是高、内存是不是足够节省。
不要只会写凑合能用的代码
性能好坏是编程能力的一个重要评判标准。
小结
学习数据结构和算法,不是为了死记硬背知识点。建立时间复杂度和空间复杂度意识、写出高质量的代码、能够设计基础架构、提升编程技能、训练逻辑思维、积攒人生经验、获得工作回报、实现价值、完善人生。
掌握了数据结构和算法,看待问题的深度,解决问题的角度就会完全不一样。
精选留言
为什么学习数据结构和算法:
- 写出性能更优的代码。
- 算法是解决问题的思路和方法,有机会应用到生活和事业的其他方面。
- 大脑思考能力是个人最重要的核心竞争力,算法是为数不多的能有效训练大脑思考能力的途径之一。
看10遍也没有自己实现一遍学的牢。
做技术就是不要浮躁,要耐得住寂寞,沉得下心。
02 | 如何抓住重点,系统高效地学习数据结构与算法?
什么是数据结构和算法
广义上讲:一组数据的存储结构和操作数据的一组方法。
狭义上讲:某些著名的数据结构和算法:队列、栈、堆、二分查找、动态规划等。这些都是前人的只会结晶,可以直接拿来用。经过非常多的求证和检验,可以高效的帮助我们解决很多实际的开发问题。
数据结构为算法服务,算法要作用在特定数据结构之上。
数据结构是静态的,只是组织数据的一种方式。如果不在它的基础上操作、构建算法,孤立存在的数据结构是没有用的。
学习这个专栏需要什么基础?
从实际场景出发、是什么、为什么、怎么做。
学习的重点在什么地方?
复杂度分析:占据数据结构和算法半壁江山,是数据结构和算法的精髓。
数据结构和算法解决的是如何更省、更快的存储和处理数据的问题,复杂度分析方法考量效率和资源的消耗。
20个最常用的、最基础的数据结构和算法:
- 10个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树。
- 10个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。
不要死记硬背,不要为了学习而学习,而是要学习:来历、特点、适合解决的问题、实际的应用场景。
学习数据结构和算法是非常好的思维训练的过程,不要被动的记忆,多辩证思考,多问为什么,坚持下去,写代码就会不由自主的考虑很多性能方面的事情,时间复杂度、空间复杂度非常高的代码出现的次数会越来越少,编程内功就真正得到了修炼。
一些事半功倍的技巧
- 边学边练,适度刷题。
- 多问、多思考、多互动。
- 不懂不丢人、勇敢的提出来一起解决。
- 避免一知半解,搞懂所有内容。
- 写学习笔记和学习心得。
- 只是需要沉淀,不要想试图一下子掌握所有。想听一遍或者看一遍就把所有知识掌握是不可能的,学习知识的过程是反复迭代,不断沉淀的过程。
- 书读百遍其义自见。
将指定算法可视化:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
03 | 复杂度分析(上):如何分析、统计算法的执行效率和资源消耗?
数据结构和算法本身解决的是如何让代码运行的更快,如何让代码更省存储空间的问题。
使用时间、空间复杂度作为考量指标。
复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。
为什么需要复杂度分析
事后统计法:
- 测试结果依赖于环境。
- 测试结果受数据规模影响很大。
大 O 复杂度表示法
代码执行时间。
int cal(int n)
{
int sum = 0;
int i = 1;
for (; i <= n; ++i)
{
sum = sum + i;
}
return sum;
}
CPU角度看每一行都执行类似的操作:读数据 - 运算 - 写数据。
假设每行代码执行时间一样,为unit_time
,分析上面代码总的执行时间:
- 第3行和第4行:1个
unit_time
。 - 第5行和第7行:运行了 n 遍,需要 2 n *
unit_time
。 - 总执行时间为:(2n + 2) *
unit_time
。 - 总结:所有代码的执行时间T(n) 与每行代码的执行次数成正比。
int cal(int n)
{
int sum = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i)
{
j = 1;
for (; j <= n; ++j)
{
sum = sum + i * j;
}
}
}
假设每行代码执行时间一样,为unit_time
,分析上面代码总的执行时间:
- 第3行、第4行和第5行:1个
unit_time
。 - 第6行和第8行:运行了 n 遍,需要 2 n *
unit_time
。 - 第9行和第11行:运行了 n^2 遍,需要 2 n^2 *
unit_time
。 - 总执行时间为:(2 n^2 + 2n + 2) *
unit_time
。 - 总结:所有代码的执行时间T(n) 与每行代码的**执行次数f(n)**成正比。
总结公式:
公式说明:
- T(n):代码执行的时间,n 表示数据规模。
- f(n):每行代码执行的次数总和。因为是一个公式所以用 f(n) 表示。
- O:表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
上述两个代码分析的大O时间表示法:
T(n) = O((2n + 2))
T(n) = O(2n^2+2n+3)
大 O 时间表示法:并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。也叫做渐进时间复杂度,简称时间复杂度。
公式中:低阶、常量和系数不影响增长趋势,进行忽略。只记录最大量级。
上述两个代码分析的大O时间表示法:
T(n) = O((n)
T(n) = O(n^2)
时间复杂度分析
1.只关注循环执行次数最多的一段代码。
int cal(int n)
{
int sum = 0;
int i = 1;
for (; i <= n; ++i)
{
sum = sum + i;
}
return sum;
}
时间复杂度分析:
- 第3行和第4行:常量级执行时间。
- 第5行和第7行:每一行被执行 n 此。
- 总的时间复杂度:O(n)。
2.加法法则:总时间复杂度等于量级最大的那段代码的复杂度。
int cal(int n)
{
int sum_1 = 0;
int p = 1;
for (; p < 100; ++p)
{
sum_1 = sum_1 + p;
}
int sum_2 = 0;
int q = 1;
for (; q < n; ++q)
{
sum_2 = sum_2 + q;
}
int sum_3 = 0;
int i = 1;
int j = 1;
for (; i <= n; ++i)
{
j = 1;
for (; j <= n; ++j)
{
sum_3 = sum_3 + i * j;
}
}
return sum_1 + sum_2 + sum_3;
}
时间复杂度分析:
- 第1段:常量执行时间,与 n 的规模无关。
- 第2段:O(n)。
- 第2段:O(n^2)。
- 总的时间复杂度为量级最大的那段代码的时间复杂度:O(n^2)。
加法法则抽象成公式:
如果T1(n)=O(f(n)),T2(n)=O(g(n));
那么 T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).
3.乘法法则:嵌套代码的时间复杂度等于嵌套内外代码复杂度的乘积。
类比加法法则:
如果 T1(n)=O(f(n)),T2(n)=O(g(n));那么 T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n)).
int cal(int n) {
int ret = 0;
int i = 1;
for (; i < n; ++i) {
ret = ret + f(i);
}
}
int f(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}
时间复杂度分析:
- 单独看 cal() 😮(n)。
- f() 时间复杂度:O(n)。
- 整个 cal() 时间复杂度T(n) = T1(n) * T2(n) = O(n*n) = O(n^2)。
几种常见时间复杂度实例分析
非多项式级时间复杂度(NP问题):O(2^n) 和 O(n!)
多项式级时间复杂度:其他。
O(1)
代码的执行时间不随 n 的增大而增长,时间复杂度都为 O(1)。
一般情况下只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,时间复杂度也为 O(1)。
O(logn)、O(nlogn)
i=1;
while (i <= n)
{
i = i * 2;
}
2 ^ x = n
n = log2n
上述代码时间复杂度为:O(log2n)
i=1;
while (i <= n)
{
i = i * 3;
}
log3n = log32 * log2n
O(log3n) = O(C * log2n)
C=log32 是一个常量。
理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。
O(log2n) 就等于 O(log3n)。
在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,把所有对数阶时间复杂度都记为 O(logn)。
O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。
O(m+n)、O(m*n)
int cal(int m, int n)
{
int sum_1 = 0;
int i = 1;
for (; i < m; ++i)
{
sum_1 = sum_1 + i;
}
int sum_2 = 0;
int j = 1;
for (; j < n; ++j)
{
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
m 和 n 表示两个数据规模,无法事先评估 m 和 n 谁的量级大,所以时间复杂度为:O(m + n)
改写加法规则:T1(m) + T2(n) = O(f(m) + g(n))。
乘法法则继续有效:T1(m)*T2(n) = O(f(m) * f(n))。
空间复杂度分析
渐进空间复杂度:算法的存储空间与数据规模之间的增长关系。
void print(int n)
{
int i = 0;
int[] a = new int[n];
for (i; i <n; ++i)
{
a[i] = i * i;
}
for (i = n-1; i >= 0; --i)
{
print out a[i]
}
}
第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。
常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。
内容小结
时间复杂度从低阶到高阶: O(1)、O(logn)、O(n)、O(nlogn)、O(n^2 )
复杂度分析并不难,关键在于多练。
04 | 复杂度分析(下):浅析最好、最坏、平均、均摊时间复杂度
最好、最坏情况时间复杂度
// n表示数组array的长度
int find(int[] array, int n, int x)
{
int i = 0;
int pos = -1;
for (; i < n; ++i)
{
if (array[i] == x) pos = i;
}
return pos;
}
时间复杂度:O(n)。
// n表示数组array的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}
理想情况下,最好时间复杂度:O(1)。
最糟糕情况下,最坏时间复杂度:O(n)。
平均情况时间复杂度
// n表示数组array的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}
有n + 1 种情况:数字的 0 ~ n - 1 位置中和不在数组中。
将每种情况遍历查找需要遍历的元素个数累加,然后除以 n - 1 既可以得到需要遍历元素个数的平均值:
得到时间复杂度为 O(n),结论正确但计算过程稍有问题。
上述 n + 1 中情况出现的概率不同:
在数组中的概率是 1/2,不在数组中的概率是 1/2。
0 ~ n -1 这 n 个位置的概率相同,为 1/n,要查找的数据出现在0 ~ n -1 中任意位置的概率是1/(2n)。
考虑每种情况发生的概率,加权平均时间复杂度(期望时间复杂度)计算过程如下:
得到时间复杂度为 O(n)。
只有同一块代码不同情况下,时间复杂度有量级的差距,才会使用最好、最坏、平均时间复杂度表示法来区分。
均摊时间复杂度
// array表示一个长度为n的数组
// 代码中的array.length就等于n
int[] array = new int[n];
int count = 0;
void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}
array[count] = val;
++count;
}
最好时间复杂度:O(1)。
最坏时间复杂度:O(n)。
平均时间复杂度:假设数组长度为n,根据插入位置有不同有 n 种情况,每种情况时间复杂度为O(1)。还有额外一种情况是数组中没有空闲位置,时间复杂度为 O(n)。总共 n + 1种 情况发生的概率相同,都是 1/(n+1),根据加权平均计算方法,平均时间复杂度为:
find() 函数在极端情况下时间复杂度为O(1)。
insert() 函数大部分情况下时间复杂度为O(1),个别情况时间复杂度为O(n)。
insert() 函数O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。
使用摊还分析法来分析算法的均摊时间复杂度:
insert() 函数举例:每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。
摊还分析法和均摊时间复杂度应用场景:
- 对一个数据结构进行连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,这些操作之前存在前后连贯的时序关系。这时,就可以将这一组操作放在一起分析,看是否将较高时间复杂度的那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。
- 一般能够应用均摊时间复杂度分析的场景,一般均摊时间复杂度等于最好情况时间复杂度。
- 均摊时间复杂度就是一种特殊的平均时间复杂度。
06 | 链表(上):如何实现LRU缓存淘汰算法?
缓存是一种提高数据读取性能的技术,硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存浏览器缓存。
常见缓存淘汰策略:
- 先进先出FIFO。
- 最少使用LFU。
- 最近最少使用LRU。
五花八门的链表结构
数组:连续的内存空间。
链表:通过指针将一组零散的内存块串联起来使用。
链表中零散的内存称为结点,每个链表结点存储数据和下一个结点的地址。记录下一个结点地址的指针叫做后续指针 next。
头结点:第一个结点,用来记录链表的基地址。
尾结点:最后一个结点,指针不是只想下一个结点,而是指向一个空地址NULL。
数组元素的插入和删除时间复杂度:O(n)。
数组元素的查找时间复杂度:O(1)。
链表元素的插入和删除时间复杂度:O(1)。
链表元素的查找时间复杂度:O(n)。
循环链表:尾结点指针指向头结点。
双向链表:每个结点有后继指针 next 指向后面结点,还有一个前驱指针 prev 指向前面的结点。
删除操作:
- 删除节点中 ”值等于某个给定值“ 的结点。
- 删除给定指针指向的结点。
第一种操作,单链表和双向链表遍历查找前驱结点时间复杂度为O(n),删除操作时间复杂度均为O(1)。根据加法法则总时间复杂度均为O(n)。
第二种情况,单链表查找前驱结点时间复杂度为O(n),双向链表查找前驱结点时间复杂度为O(1),删除操作的时间复杂度均为O(1)。根据加法法则单链表总时间复杂度为O(n),双向链表总时间复杂度为O(1)。
添加元素的时间复杂度度同删除操作的时间复杂度。
对于有序链表,双向链表按值查询效率比单链表高一些,可以记录上一次查找的位置p,每次查询时根据要查找的值与p的大小关系,决定是往前还是往后查找,平均只需要查找一半的数据。
Java语言中LinkHashMap的实现原理就用到了双向链表这种数据结构。
执行较慢的程序,可以通过消耗更多内存来进行优化(空间换取时间)。
消耗内存过多的程序,可以通过消耗更多时间来降低内存的消耗(时间换取空间)。
双向循环链表:
链表 VS 数组 性能大比拼
数组实现上使用连续的内存空间,可以借助CPU缓存机制,预读数组中的数据,访问效率更高。
CPU缓存机制:CPU从内存读取数据,每次从内存读取cache 大小的数据并保存到CPU缓存中,下次访问内存数据会首先从CPU缓存开始查找,如果找到就不需要从内存中读取,实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。
链表在内存中不是连续存储,CPU缓存不友好,无法有效预读。
数组扩容非常耗时,链表天然支持扩容。
内存使用条件比较苛刻选择数组,链表中每个结点需要消耗额外存储空间存储一份指向下一个节点的指针,而且对链表进行频繁插入、删除操作,会导致频繁的内存申请与释放,容易造成内存碎片。
解答开篇
如何基于链表实现LRU缓存淘汰算法:维护一个有序单链表,越靠近尾部的结点是越早访问的,当有一个新的数据被访问时,从链表头开始顺序遍历链表。
- 数据结点已经在缓存链表中,删除该结点然后插入到链表头部。
- 数据结点不在缓存链表中,缓存未满直接插入到链表头部分,缓存满则删除链表尾结点并将数据节点插入到链表头部。
时间复杂度:O(n)。
内容小结
和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。
07 | 链表(下):如何轻松写出正确的链表代码?
技巧一:理解指针或引用的含义
不管是指针还是引用,都是存储所指对象的内存地址。
对指针的理解:将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针。指针中存储了这个变量的地址,指向了这个变量,通过指针就能找到这个变量。
p->next=q:p 结点中 next 指针存储了 q 结点的内存地址。
p->next=p->next->next:p结点的 next 指针存储了 p 结点的下下一个结点的内存地址。
技巧二:警惕指针丢失和内存泄漏
一定注意不要弄丢指针。
错误示范:
p->next = x; // 将p的next指针指向x结点;
x->next = p->next; // 将x的结点的next指针指向b结点;
从结点 b 往后的所有结点都无法访问到了。
正确示范:
x->next = p->next; // 将x的结点的next指针指向b结点;
p->next = x; // 将p的next指针指向x结点;
C语言,内存管理由程序员负责,删除链表结点时,如果没有手动释放结点对应的内存空间,就会产生内存泄露。
技巧三:利用哨兵简化实现难度
针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点做特殊处理。
通过哨兵解决边界问题,不直接参与业务逻辑。
引入哨兵结点:任何时候,不管链表是不是空, head 指针会一直指向这个哨兵结点,把这种有哨兵结点的链表叫带头结点链表,没有哨兵结点的链表叫作不带头结点链表。
哨兵结点不存储数据,因为哨兵结点一直存在,插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑。
举例说明利用哨兵:
代码一:
// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
int find(char* a, int n, char key) {
// 边界条件处理,如果a为空,或者n<=0,说明数组中没有数据,就不用while循环比较了
if(a == null || n <= 0) {
return -1;
}
int i = 0;
// 这里有两个比较操作:i<n和a[i]==key.
while (i < n) {
if (a[i] == key) {
return i;
}
++i;
}
return -1;
}
代码二:
// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
// 我举2个例子,你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6} n=6 key = 7
// a = {4, 2, 3, 5, 9, 6} n=6 key = 6
int find(char* a, int n, char key) {
if(a == null || n <= 0) {
return -1;
}
// 这里因为要将a[n-1]的值替换成key,所以要特殊处理这个值
if (a[n-1] == key) {
return n-1;
}
// 把a[n-1]的值临时保存在变量tmp中,以便之后恢复。tmp=6。
// 之所以这样做的目的是:希望find()代码不要改变a数组中的内容
char tmp = a[n-1];
// 把key的值放到a[n-1]中,此时a = {4, 2, 3, 5, 9, 7}
a[n-1] = key;
int i = 0;
// while 循环比起代码一,少了i<n这个比较操作
while (a[i] != key) {
++i;
}
// 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
a[n-1] = tmp;
if (i == n-1) {
// 如果i == n-1说明,在0...n-2之间都没有key,所以返回-1
return -1;
} else {
// 否则,返回i,就是等于key值的元素的下标
return i;
}
}
第二段代码中,我们通过一个哨兵 a[n-1] = key,成功省掉了一个比较语句 i<n。
技巧四:重点留意边界条件处理
软件开发中,代码在一些边界或者异常情况下,最容易产生 Bug。
一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行。
经常用来检查聊表代码是否正确的边界条件:
- 如果链表为时,代码能否正常工作?
- 如果链表只包含一个结点时,代码能够正常工作?
- 如果链表只包含两个结点时,代码能够正常工作?
- 代码逻辑在处理头结点和尾结点时,能够正常工作?
你在写任何代码时,也千万不要只是实现业务正常情况下的功能就好了,一定要多想想,你的代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!
技巧五:举例画图、辅助思考
总感觉脑容量不够,想不清楚。所以这个时候就要使用大招了,举例法和画图法。
你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。
技巧六:多写多练,没有捷径
常见链表操作:
- 单链表反转。
- 链表中环的检测。
- 两个有序链表合并。
- 删除链表中倒数第 n 和结点。
- 求链表的中间节点。
内容小结
写出正确链表代码的六个技巧:
- 理解指针或引用的含义。
- 警惕指针丢失和内存泄露。
- 利用哨兵简化实现难度。
- 重点留意边界条件处理。
- 画图距离、复制思考。
- 多写多练。
写链表代码是最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 Bug。链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。所以,这也是很多面试官喜欢让人手写链表代码的原因。
17 | 跳表:为什么Redis一定要用跳表来实现有序集合?
二分查找可以用底层数组的随机访问特性来实现。
可以使用跳表实现类似 “二分” 的查找算法。
跳表支持快速插入、删除、查找操作,写起来不复杂,甚至可以替代红黑树。
Redis 中的有序集合(Sorted Set)就是用跳表来实现的。
如何理解 “跳表”
链表查找数据的时间复杂度:O(n)。
通过对链表建立索引层,提高查找效率。
图中的 down 表示 down 指针,指向下一级结点。
查找时,通过索引层结点的 down 指针,下降到原始链表这一层,继续遍历。
加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。
建立二级索引:
建立多级索引:
这种链表加多级索引的结构,就是跳表。
用跳表查询到底有多快
n 个结点,每两个结点抽出一个结点作为上一级索引的结点。
第 k 级索引结点的个数就是 n / 2^k。
假设索引有 h 级别,最高级索引有 2 个结点。
n / 2^h = 2
h = log2n- 1
包含原始链表层,整个跳表高度为 log2n
跳表中查询某个数据每一层要遍历 m 个结点,跳表中查询某个数据时间复杂度为:O(m * logn)
m的值为3:
查找数据 x ,y < x < z ,通过 y 的 down 指针从 k 级索引下降到 k - 1 级索引,k - 1级索引中 y 和 z 之间只有三个结点。所以 k - 1级索引中最多需要遍历 3 个结点。依此类推,每一级索引最多只需要遍历 3 个结点。
m=3,跳表中查询数据的时间复杂度为 O(logn)。
这种查询效率的提升,前提是建立多级索引,也就是空间换取时间思路。
跳表是不是很浪费内存?
跳表每层索引的结点数:
就是一个等比数列。
索引结点数总和为:n/2+n/4+n/8…+8+4+2=n-2,所以跳表的空间复杂度为O(n)。
每三个结点抽象一层索引:
也是一个等比数列。
索引结点数总和为:n/3+n/9+n/27+…+9+3+1=n/2,尽管空间复杂度还是O(n),但是比每两个结点建立索引,减少了一半索引结点的存储空间。
实际开发中不太关注索引占用的内存空间,因为原始链表中存储的有可能是很大的对象,索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比结点大很多时,索引占用的额外空间可以忽略。
高效的动态插入和删除
数据结构 | 插入数据 | 删除数据 | 查找数据 |
---|---|---|---|
单链表 | 查找O(n) * 插入O(1) | 查找O(n) * 删除O(1) | 查找O(n) |
跳表 | 查找O(logn) * 插入O(1) | 查找O(logn) * 删除O(1) | 查找O(logn) |
跳表中插入数据:
删除操作注意:如果这个结点在索引中也有出现,除了要删除原始链表中的结点,还要删除索引中的结点。
跳表索引动态更新
不断插入数据而不更新索引,可能会出现两个索引结点之间数据非常多的情况。极端情况下,跳表可能退化成链表。
维护索引和原始链表大小之间的平衡:
链表结点增多,索引结点增加,避免复杂度退化,以及查找、插入、删除性能下降。
跳表通过随机函数维护上述平衡性。
插入数据时,可以通过随机函数选择同时将这个数据插入部分索引层中。
比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。
随机函数的选择很有讲究:保证跳表的索引大小和数据大小的平衡,不至于性能过度退化。
Redis 中的有序集合支持的核心操作主要有下面这几个:
- 插入一个数据。
- 删除一个数据。
- 查找一个数据。
- 按照区间查找。
- 迭代输出有序序列。
插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。
但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。
Redis 之所以用跳表来实现有序集合,还有其他原因:
- 代码容易实现(相对于红黑树)可读性好,不易出错。
- 更加灵活。
- 可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
很多语言 Map 使用红黑树实现,跳表没有一个现成实现。
内容小结
跳表的设计思路:空间换取时间。
构建多级索引提高查询效率,实现基于链表的二分查找。
跳表是一种动态数据结构,支持快速插入、删除和查找,时间复杂度都是 O(logn)。
空间复杂度 O(n)。
可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
18 | 散列表(上):Word文档中的单词拼写检查功能是如何实现的?
散列思想
散列表用的是数值支持下标随机访问数据的特性,散列表是数组的一种扩展,由数组演化而来。
通过运动员编号获取运动员信息:
- key:参赛选手编号。
- 散列函数(hash函数):将参赛编号转化为数组下标的映射方法。
- 散列值:散列函数计算得到的值。
规律:散列表使用数组支持按照下标随机访问的时候,时间复杂度为O(1) 的特性。通过散列函数将元素的键值映射为下标。然后将数据存储在数组中对应下标的位置。按照键值查询元素时,用同样的散列函数,将键值转化为下标,从对应的数组下标位置取数据。
散列函数
散列函数:hash(key) ,key为元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。
散列函数设计的基本要求:
- 散列函数计算得到的散列值是一个非负整数。(数组下标从 0 开始。)
- 如果 key1 = key2,那 hash(key1) == hash(key2)。(相同的 key 经过散列函数得到的散列值也应该相同。)
- 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。(真实情况几乎不可能,无法完全避免散列冲突、数组的存储空间有限,会加大散列冲突的概率。)
散列冲突
1.开放寻址法
核心思想:如果出现了散列冲突,重新探测一个空闲位置将其插入。
线性探测
**插入:**当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
**删除和查找:**将删除的元素特殊标记为 delete 。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。
插入、删除、查找最坏时间复杂度:O(n)。
二次探测:探测的下标序列为 hash(key)+0,hash(key)+12,hash(key)+22……
双重散列:使用一组散列函数,第一个散列函数计算的存储位置被占用,则使用后续散列函数直到,知道找到空闲存储位置。
装载因子:散列表的装载因子=填入表中的元素个数/散列表的长度。
2.链表法
所有散列值相同的元素放在相同槽位对应的链表中。
插入:通过散列函数计算出对应散列槽位,插入到对应链表。时间复杂度O(1)。
内容小结
散列表源于数组,借助散列函数对数组进行扩展,利用数组支持按照下标随机访问元素的特性。
散列表的两个核心问题:散列函数设计,散列冲突解决。
散列冲突常用的解决方法:开放寻址法,链表法。
散列函数设计的好坏决定散列冲突的概率,也就决定散列表的性能。
19 | 散列表(中):如何打造一个工业级水平的散列表?
拒绝服务攻击(DoS):恶意攻击者通过精心构造数据,使得所有数据经过散列函数之后都散列到同一个槽里,如果使用基于链表的冲突解决方法,散列表就会退化成为链表,查询的时间复杂度从O(1) 急剧退化到 O(n)。出现查询操作消耗大量CPU或线程资源。导致系统无法响应其他请求。
如何设计散列函数
- 散列函数的设计不能太复杂。过于复杂会消耗计算时间,间接影响到散列表的性能。
- 散列函数生成的值要尽可能随即并且均匀分布。
- 实际工作中还需要考虑:关键字长度、特点、分布、散列表的大小等。
举例:
- 数据分析法:通过参赛编号,把编号中的后两位作为散列值。
- 数据分析法:取手机号码的后四位作为散列值。
- 设计 Word中拼写检查功能:hash(“nice”)=((“n” - “a”) * 262626 + (“i” - “a”)2626 + (“c” - “a”)*26+ (“e”-“a”)) / 78978
- 直接寻址法,平方取中法,折叠法,随机数法等。
装在因子过大了怎么办?
动态扩容:散列表大小改变,数据的存储位置改变,所以需要通过散列函数重新计算每个数据的存储位置。
插入数据最好时间复杂度:O(1)。
插入数据最坏时间复杂度:O(n)。
均摊时间复杂度:O(1)。
删除数据后是否缩容根据对空间敏感程度确定。
如果更在意执行效率,能够容忍多消耗一些内存空间,就不需要缩容。
如何避免低效扩容?
将扩容操作穿插在插入操作的过程中:
- 装载因子触发阈值,申请新空间,不讲老的数据搬移到新散列表中。
- 新数据插入到新的散列表中,并从老的散列表中拿出一个数据放入新的散列表。
- 每次插入一个数据散列表重复1,2步骤,经过多次插入操作之后,老的散列表中数据会全部搬移到新的散列表中。
没有集中的一次性数据搬移,插入操作变得很快。将一次性数据搬移均摊到多次插入操作中,避免一次性扩容耗时过长的情况,任何情况下插入一个数据的时间复杂度都是O(1)。
查询操作:先从新的散列表中查找,如果没有找到,再去老的散列表中查找。
如何选择冲突解决方法
Java 中 LinkedHashMap 就采用了链表法解决冲突,
ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突。
开放寻址法
优点:
- 有效利用CPU缓存加速查询速度。
- 序列化简单。链表法包含指针,序列化起来不容易。
缺点:
- 删除数据比较麻烦,需要特殊标记已删除的数据。
- 所有数据都存储在一个数组中,相对链表发冲突代价更高,装载因子上限不能太大,导致比链表法更浪费内存空间。
- 装载因子接近 1 时,就可能会有大量的散列冲突,导致大量的探测,再散列等性能严重下降。
总结: 应用场景:数据量比较小,装载因子比较小。
链表法
优点:
- 对内存利用率比开放寻址法高,链表结点可以在需要的时候创建。
- 对装载因子的容忍度更高,只要散列函数值随机分布,装载因子可以变成 10 ,只不过链表长度变长,比起顺序查找效率会高很多。
缺点:
- 较小的数据对象,比较消耗内存,可能翻倍。如果存储大对象,存储的对象大小远远大于指针的大小,链表中指针的内存消耗在大对象面前可以忽略。
- 链表的结点零散分布,对CPU缓冲不友好,对执行效率有一定影响。
对链表法稍加改造,实现一个更高效的散列表:
将链表改造为其他高效的动态数据结构,即使出现极端情况散列冲突,所有数据都散列到同一个桶内,最终退化的散列表查找时间也只不过是O(logn)。可以有效避免散列碰撞攻击。
总结:
- 应用场景:存储大对象,大数据量的散列表。
- 更加灵活,支持多种优化策略。例如:用红黑树代替链表。
工业级散列表举例分析
Java 中的 HashMap
初始大小
默认初始大小16,默认值可以预先设置。
装载因子和动态扩容
最大装载因子默认是0.75,元素个数超过 0.75 * 散列表容量,启动扩容,每次扩容为原来的两倍。
散列冲突的解决方法
链表法 + 红黑树。
链表长度太长(默认超过8),转换为红黑树。
红黑树结点数量少于 8 个,将红黑树转化为链表。
数据量较小的情况下,红黑树要维护平衡,和链表相比,性能优势并不明显。
散列函数
追求简单高效,分布均匀。
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小
}
hashCode() 返回的是 Java 对象的 hash code。
比如 String 类型的对象的 hashCode() 就是下面这样:
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;
}
解答开篇
如何设计一个工业级的散列函数?
要求:
- 支持快速查询、插入、删除操作。
- 内存占用合理,不能浪费过多内存空间。
- 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
如何实现:
- 设计一个合适的散列函数。
- 定义装载因子阈值,并设计动态扩容策略。
- 选择合适的散列冲突解决方法。
20 | 散列表(下):为什么散列表和链表经常会一起使用?
使用了链表和散列表:
- LRU缓存淘汰算法。
- Redis的有序集合。
- Java中的 LinkedHashMap容器。
LRU缓存淘汰算法
借助散列表,可以把LRU缓存淘汰算法的时间复杂度降低为O(1)。
只是用链表实现LRU缓存淘汰算法的过程:
- 维护一个按照时间从大到小有序的链表结构。
- 当要缓存数据时,先在链表中查找。
- 如果没有找到进行缓存判断。
- 缓存满则删除双向链表头结点并将数据插入到链表尾部;缓存不满则将数据直接插入到链表尾部。
- 如果找到了就把它移动到链表尾部。
查找数据需要遍历,时间复杂度为O(n)。
一个缓存系统主要包含以下操作:
- 往缓存添加一个数据。
- 从缓存删除一个数据。
- 在缓存中查找一个数据。
三个操作都涉及查找。如果只使用链表,时间复杂度为O(n)。如果将散列表和链表组合使用,可以将三个操作时间复杂度都降为O(1)。
使用双向链表存储数据,链表中每个结点包括存储数据,前驱指针,后继指针,hnext指针。
散列表通过链表法解决散列冲突,每个结点会在两条链中:
- 双向链表:前驱指针和后继指针将结点串在双向链表中。
- 散列表中的拉链,hnext指针将结点串在散列表的拉链中。
查找数据:使用散列表O(1)。
删除数据:使用散列表查找删除的结点O(1),双向链表删除结点O(1)。
添加数据:
- 当要缓存数据时,先在链表中查找。
- 如果没有找到进行缓存判断。
- 缓存满则删除双向链表头结点【时间复杂度O(1)】并将数据插入到链表尾部【时间复杂度O(1)】;缓存不满则将数据直接插入到链表尾部【时间复杂度O(1)】。
- 如果找到了就把它移动到链表尾部【时间复杂度O(1)】。
整个过程涉及的查找都可以用散列表O(1)时间复杂度完成,删除和插入结点双向链表都可以在O(1)时间复杂度完成,通过散列表 + 双向链表 实现一个高效的、支持LRU缓存淘汰算法的缓存系统原型。
Redis 有序集合
有序集合中,每个对象成员包含两个重要属性,key(键值) 和 score(分值) 不仅会通过 score 查找数据,还会通过 key 来查找数据。
Redis有序集合的操作:
- 添加一个成员对象。
- 按照键值来删除一个成员对象。
- 按照键值来查找一个成员对象。
- 按照分值区间查找数据,比如查找积分在[100,356]之间的成员对象。
- 按照分值从小到大排序成员对象。
按照分值将成员对象组织成跳表结构,按照分值的操作使用跳表。
按照键值构建一个散列表,按照键值的操作使用散列表。
Java LinkedHashMap
HashMap<Integer, Integer> m = new LinkedHashMap<>();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
按照插入顺序打印:3,1,5,2
LinkedHashMap通过散列表和链表组合在一起实现。
支持按照访问顺序来遍历容器:
// 10是初始大小,0.75是装载因子,true是表示按照访问时间排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
m.put(3, 26);
m.get(5);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
打印:1,2,3,5
分析:
每次调用 put 将数据添加到 LinkedHashMap 链表尾部,前4个操作完成后:
键值 3 的数据插入 LinkedHashMap 后,先查找键值是否存在,如果存在(3, 11)就删除,并将新的键值为3的数据(3, 26)插入到链表尾部。
访问 key 为5的数据,将被访问数据移动到链表尾部。
最后打印出来的数据是 1,2,3,5。
实现原理同LRU缓存淘汰策略的缓存系统。
LinkedHashMap 是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap 中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。
解答开篇 & 内容小结
散列表支持高效的数据插入、删除、查找,但是无规律存储。
使用链表按照某种顺序遍历。
数组占据随机访问优势,有需要连续内存的缺点。
链表具有不可连续存储的优势,访问查找是线性的。
散列表和链表的结合,结合数组和链表的优势,规避它们的不足。
数据结构和算法的重要性排行:连续空间 > 时间 > 碎片空间。
21 | 哈希算法(上):如何防止数据库中的用户信息被脱库?
什么是哈希算法?
哈希和散列只是中文翻译的差别,英文为 Hash。
同:散列表、哈希表、Hash表。
同:散列算法、哈希算法、Hash算法。
哈希算法:将任意长度的二进制值串映射为固定长度的二进制值串。
哈希值:原始数据映射之后得到的二进制值串。
满足优秀哈希算法的要求:
- 从哈希值不能反向推导出原始数据。(单向哈希算法)
- 输入输出非常敏感,哪怕原始数据只修改了一个 bit 。最后得到的哈希值也大不相同。
- 散列冲突概率很小,不同原始数据哈希值相同的概率非常小。
- 执行效率尽量高效,针对较长的文本也能快速计算出哈希值。
MD5哈希算法:
哈希值为 128 位 bit 。
MD5("今天我来讲哈希算法") = bb4767201ad42c74e650c1b6c03d78fa
MD5("jiajia") = cd611a31ea969b908932d44d126d195b
MD5("我今天讲哈希算法!") = 425f0d5a917188d2c3c3dc85b5e4f2cb
MD5("我今天讲哈希算法") = a1fb91ac128e6aa37fe42c663971ac3d
尽管只有一字之差,得到的哈希值也是完全不同的。
应用一 : 安全加密
常用加密算法:MD5,SHA,DES,AES。
对于加密的哈希算法有两点格外重要:
-
从哈希值不能反向推导出原始数据。(单向哈希算法)
-
散列冲突概率很小,不同原始数据哈希值相同的概率非常小。
根据鸽巢原理,哈希算法无法做到零冲突。
哈希值越长的哈希算法,散列冲突概率越低。
2^128=340282366920938463463374607431768211456
MD5,有 2^128 个不同的哈希值,这个数据已经是一个天文数字了,所以散列冲突的概率要小于 1/2^128。
通过毫无规律的穷举的方法,找到跟这个 MD5 值相同的另一个数据,那耗费的时间应该是个天文数字。
即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是很难被破解的。
没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。
应用二 : 唯一标识
搜索图片或其他数据,每一条数据通过哈希值唯一标识。
应用三 : 数据校验
对要下载的文件取哈希值,下载之后求哈希值进行对比是否一致。
应用四 : 散列函数
散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。
散列函数对于散列算法计算出的值是否能反向解密也不关心。
散列函数中用到的散列算法,更加关注散列之后的值是否平均分布。
散列函数的执行效率影响散列表性能,散列函数用的散列算法一般都比较简单,比较追求效率。
解答开篇
哈希算法加密 + 盐。
内容小结
应用一:安全加密(选择哈希算法时,权衡安全性和计算时间)。
应用二:大数据做信息摘要唯一标识。
应用三:校验数据的完整性和正确性。
应用四:散列函数(更看重散列的平均性和哈希算法的执行效率)。
22 | 哈希算法(下):哈希算法在分布式系统中有哪些应用?
应用五 : 负载均衡
会话粘贴的负载均衡算法:同一客户端上,一次会话中所有请求都路由到同一个服务器上。
维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。
缺点:
- 客户端很多,映射表可能很大,比较浪费内存空间。
- 客户端上线、下线,服务器缩容、扩容都会导致映射失效,维护映射表成本很大。
通过哈希算法:
- 对客户端IP或会话ID计算哈希值。
- 将取得的哈希值与服务器列表的大小进行取模。
- 最终得到的值就是应该被路由到的服务器编号。
应用六 : 数据分片
大数据处理存在的问题:
- 数据量大,放到一台机器的内存中。
- 一台机器处理,处理时间会比较长。
解决思路:对数据进行分片,采用多台机器处理的方法,提高处理速度。
举例:1T日志文件记录了用户搜索的关键字,快速统计每个关键字被搜索的次数。
具体操作:
- n台机器并行处理,从搜索记录日志中依次读出每一个关键字,通过哈希函数计算哈希值,同n取模。就是应该被分配处理的机器编号。
- 哈希值相同的搜索关键字被分配到同一机器上,每个机器分别计算关键字出现的次数,最后合并最终结果。
这里的处理过程也是 MapReduce 的基本设计思想。
举例:如何快速判断图片是否在1亿张图库中?
1亿张图片构建散列显然远远超过单台机器的内存上限。
具体操作:
- 每次从图库中读取一个图片,计算唯一标识,与机器个数 n 求余取模,得到的值就是对应分配的机器编号,将图片的唯一标识和图片路径发往对应机器构建散列表。
- 判断图片是否在图库中时,通过同样的哈希算法,计算图片的唯一标识,与机器个数 n 取模,假设得到 k 就与编号 k 的机器构建的散列表中查找。
分析:
散列表中每个数据单元包含两个信息:哈希值和图片路径。
MD5计算哈希值,长度:128Bit , 也就是 16 字节,文件路径长度上限 256字节,假设平均128字节。链表法解决冲突好需要存储指针,指针占用8字节。散列表中每个数据占用 152字节。
2G内存,装载因子 0.75 ,一台机器大约可以存储1000w(2GB * 0.75 / 152)张图片的散列表。
1亿张图片大概需要十几台机器。
针对海量数据,大多数采用多机分布式处理,借助分片思路可以突破单机内存,CPU等资源的限制。
应用七 : 分布式存储
通过 哈希算法对数据取哈希值,然后对机器数量取模决定数据存储到哪台机器上。
问题:如果数据增多,需要增加服务器,所有数据需要重新计算哈希值,缓存中的数据全部失效,所有数据请求直接去请求数据库,可能发生雪崩效应,压跨数据库。
解决:一致性哈希算法:新加一个机器之后,不需要做大量数据迁移。
具体过程:
- k 个机器,数据的哈希值范围 [0 , MAX],划分为 m 个小区间,每个机器负责 m/k 个小区间。
- 新机器加入时,将几个小区间数据,从原来机器中搬移到新的机器中。
- 不用全部重新哈希,不用搬移数据,也保持了各个机器上数据量的均衡。
一致性哈希算法还会借助一个虚拟的环和虚拟结点,更加优美的实现。
23 | 二叉树基础(上):什么样的二叉树适合用数组来存储?
带着问题学习,是最有效的学习方式之一。
树
每个元素叫做结点。
连接相邻结点之间的关系:父子关系。
A是B的父节点。
B是A的子节点。
B、C、D 三个几点父节点为同一结点,他们之间成为兄弟结点。
根节点:没有父节点的结点。
叶子节点或叶结点:没有子节点的节点。
G、H、I、J、K、L 都是叶子节点。
树的高度、深度和层:
二叉树
每个结点最多有两个子节点,分别是左子结点和右子节点。
二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树:
满二叉树:
- 2号。
- 叶子结点全部在最底层。
- 除了叶子结点,其他结点都有左右两个子节点。
完全二叉树。
- 3号。
- 叶子节点都在最底下两层。
- 最后一层叶子节点都靠左排列。
- 除了最后一层其他层节点个数达到最大。
存储二叉树:
- 基于指针或引用的二叉链式存储。
- 基于数组的顺序存储。
链式存储:
- 每个节点有三个字段:存储数据、指向左子节点的指针、指向右子节点的指针。
- 大多数二叉树代码通过这种结构实现。
总结:
- 如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。
- 下标为 i/2 的位置存储就是它的父节点。
- 通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。
完全二叉树只浪费下标为0的存储空间。
非完全二叉树浪费比较多的数组存储空间:
完全二叉树用数组存储最节省空间。
堆是一种完全二叉树,常用的存储方式是数组。
二叉树的遍历
前序遍历:根左右。
中序遍历:左根右。
后序遍历:左右跟。
二叉树的前、中、后序遍历就是一个递归的过程。
比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。
遍历的时间复杂度:O(n)。
解答开篇 & 内容小结
常用概念:根节点、叶子节点、父节点、子节点、兄弟节点,节点的高度、深度、层,以及树的高度。
完全二叉树和满二叉树。
二叉树可以用链式存储,也可以用数组顺序存储。
数组顺序存储比较适合完全二叉树,其他类型二叉树用数组存储会比较浪费存储空间。
前、中、后序遍历操作,理解并用递归实现。
遍历的时间复杂度:O(n)。
24 | 二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?
二叉查找树:支持动态数据集合的快速插入、删除和查找。
二叉查找树(BST)
二叉树要求:树中任意一个结点,左子树每个结点的值都小于这个结点的值,右子树每个结点的值都大于这个结点的值。
二叉查找树的查找操作
- 先取根节点,如果等于要查找的数据,直接返回。
- 查找的数据小于根结点数据,在左子树中递归查找。
- 查找的数据大于根结点数据,在右子树中递归查找。
二叉查找树的插入操作
- 从根节点开始以此比较要插入的数据和结点大小的关系。
- 插入的数据比结点数据大:结点的右子树为空,将新数据直接插到右子节点的位置。
- 插入的数据比结点数据大:结点的右子树不为空,递归遍历右子树,查找插入位置。
- 插入的数据比结点数据小:结点的左子树为空,将新数据直接插到左子节点的位置。
- 插入的数据比结点数据小:结点的左子树不为空,递归遍历左子树,查找插入位置。
二叉查找树的删除操作
- 要删除的结点没有子节点:直接删除该结点,将父节点指向该结点的指针指向null。
- 删除的结点只有一个子节点:更新删除结点的父节点中指向删除结点的指针指向要删除结点的子节点。
- 删除的结点有两个子节点:将删除结点右子树中的最小结点替换成要删除的结点,然后删除最小结点。
删除的另外一种方法:将要删除的结点标记为已删除,操作简单且没有增加插入和查找代码的实现难度。会比较浪费内存空间。
二叉树的其他操作
快速地查找最大节点和最小节点、前驱节点和后继节点。
中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。
支持重复数据的二叉查找树
- 二叉查找树中每个结点通过链表或者支持动态扩容的数组等数据结构,把值相同的数据都存储在同一二叉查找树节点上。
- 每个结点存储一个数据,将新插入的数据按照大于这个结点来处理。
当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
二叉查找树的时间复杂度分析
最差情况:第一个二叉查找树退化为链表,时间复杂度为O(n)。
最好情况:完全搜索二叉树计算过程:O(log2n) 。
- 不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。
- 树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。
- 满搜索二叉树总共K层时,第 K 层包含的节点个数就是 2^(K-1)。
- 完全搜索二叉树最后一层结点个数:在 1 个到 2^(L-1) 个之间(我们假设最大层数是 L)。
- 完全二叉搜索树结点总数:n >= 1+2+4+8+…+2^(L-2)+1, n <= 1+2+4+8+…+2(L-2)+2(L-1)。
- 借助等比数列的求和公式,我们可以计算出,L 的范围是[log2(n+1), log2n +1]。
- 完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。
解答开篇
- 散列表中数据无序,有序输出需要先排序。二叉查找树只需要中序遍历就可以在O(n)时间复杂度内输出有序的数据序列。
- 散列表扩容耗时很多,遇到散列冲突时性能不稳定,最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。
- 存在哈希冲突,散列表查找数据的常量时间复杂度实际查找速度不一定比O(logn) 快。加上哈希函数的执行耗时,不一定比 平衡二叉查找树效率高。
- 散列表的构造比较复杂:散列函数的设计、冲突解决方法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
- 为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?
二叉查找树:支持快速插入、删除、查找操作。各个操作的时间复杂度和树高度成正比。理想情况下时间复杂度为O(logn)。
什么是"平衡二叉查找树"
平衡二叉树:二叉树中任意一个结点的左右子树的高度相差不能大于1。
完全二叉树,满二叉树是平衡二叉树,非完全二叉树也有可能是平衡二叉树。
todo 图片