重学数据结构与算法,未完待续…
相关part,我也会刷一些leetcode上简单的题目,并且更新在每章的来leetcode里试炼一下叭这一部分噢。这里刷题的范围是leetCode上的剑指Offer,每次刷题,我会按照标签筛选题目。记得一起来实践噢~
复杂度是衡量代码运行效率的重要度量因素。复杂度包括时间复杂度和空间复杂度。
时间(空间)复杂度:时间或者空间消耗量与输入数据量之间的关系。
复杂度是一个关于输入量n的函数;假设你的代码复杂度是 f(n),那么就用个大写字母 O 和括号,把 f(n) 括起来就可以了,即 O(f(n))。
复杂度的计算方法遵循以下几个原则:
代码效率优化就是要将可行解提高到更优解,最终目标是:要采用尽可能低的时间复杂度和空间复杂度,去完成一段代码的开发。
程序开发中复杂度降低的核心方法论:
1.暴力解法:在没有任何时间空间约束下,完成代码任务的开发
2.无效操作处理:将代码中的无效计算、无效存储剔除,降低时间或者空间复杂度
(学会并掌握递归、二分法、排序算法、动态规则等常用的算法思维)
3.时空转换:设计合理数据结构,完成复杂度向空间复杂度的转移
(对数据的操作进行细分,全面掌握常见数据结构的基础知识)
举几个栗子:
输入数组a=[1,2,3,4,5,5,6]中,查找出现次数最多的数值
暴力解法:
function exp_1(){
let count = 0;
for(let i=0;i<(100/7);i++){
for(let j=0; j<(100/3);j++){
for(let k=0;k<(100/2);k++){
if(i*7 + j*3 +k*2 === 100) {
count += 1;
}
}
}
}
console.log(count)
}
以上代码的复杂度是O( n 3 n^{3} n3)
优化解法:实践核心方法论中的第二步
function exp_2(){
let count = 0;
for(let i=0;i<(100/7);i++){
for(let j=0; j<(100/3);j++){
if((100 -i*7 + j*3) % 2 === 0 ) {
count += 1;
}
}
}
console.log(count)
}
代码改成以上的样子,复杂度变为O( n 2 n^{2} n2)
假设有任意多张面额为2元,3元,7元的货币,现在要用他们凑出100元,求总共有多少种可能性?
暴力解法:
function exp2_1() {
let a = [1,2,3,4,5,5,6];
let val_max = 1;
let time_max = 0;
let time_tmp = 0;
for(let i=0;i<a.length;i++) {
time_tmp = 0;
for(let j = 0;j<a.length;j++) {
if(a[i] === a[j]) {
time_tmp += 1;
}
if(time_tmp > time_max) {
time_max = time_tmp;
val_max = a[i]
}
}
}
console.log(val_max)
}
此时的时间复杂度是O( n 2 n^{2} n2),空间复杂度为O(1)
优化解法:实践核心方法论中的第三步,改变数据结构 以空间换时间
function exp2_2() {
let a = [1,2,3,4,5,5,6];
let map = new Map();
for(let i=0;i<a.length;i++) {
// 实现数组转字典
if(map.has(a[i])){
map.set(a[i], map.get(a[i])+1)
} else {
map.set(a[i], 1)
}
}
let val_max = -1;
let time_max = 0;
for(let key of map.keys()) {
if(map.get(key) > time_max) {
time_max = map.get(key);
val_max = key;
}
}
console.log(val_max)
}
exp2_2()
此时的时间复杂度为O(n), 空间复杂度为O(n)
弄清楚数据在代码中被处理,加工的最小单位动作 --你需要分解代码步骤
需要设计合理的数据结构,以达到降低时间损耗的目的
思考顺序:
1. 分析这段代码到底对数据先后进行了哪些操作
2. 根据分析出来的数据操作,找到合理的数据结构
代码对数据的处理是代码对输入数据进行计算,得到结果并输出的过程
数据处理的操作是找到需要处理的数据,计算结果,再把结果保存下来
这个过程总结为以下操作:
* 找到要处理的数据。按照某些条件进行查找
* 把结果存到一个新的内存空间中。再现有数据上进行新增
* 把结果存到一个已使用的内存空间。这需要先删除内存空间中的已有数据,再新增新的数据
数据的处理只有3个基本操作(增删查)
常用的分析方法参考步骤
1. 这段代码对数据进行了哪些操作?
2. 这些操作中,哪个操作最影响效率,对时间复杂度的损耗最大?
3. 哪种数据结构最能帮助你提高数据操作的使用效率
:在一个数组中找出出现次数最多的哪个元素的数值;例如输入数组a=[1,2,3,4,5,5,6]
为了降低时间复杂度,引入了k-v的字典的数据结构。
❓ 为什么想到使用k-v的字典的数据结构
❓ 如果不使用字典的数据结构,使用数组行不行
分析:代码处理数据的核心思路
1. 根据原始数组计算出每个元素出现的次数
数据操作包括(查找,新增,改动)
2. 根据第一步的结构找到出现次数最多的元素
这里的数据操作只有查找
这段代码需要高频使用查找的功能
第一步的查找在for循环中,如果代码不能再O(1)的时间复杂度完成,则代码整体的时间复杂度没有下降。能再O(1)的时间复制度内完成查找动作的数据结构,只有字典类型
外层for循环时O(n)的时间复制度,内部前台的查找时O(1)的时间复制度,整体算下来是O(n)的复杂度
字典的查找是通过简直对的匹配完成的,它可以再O(1)时间复杂度内,实现对数值条件查找
换个解决方案:
假设采用两个数组,分别对应顺序记录元素及其对应的出现次数
数组对于元素的查找只能是逐一访问,时间复杂度是O(n)
再O(n)复杂度的for循环中,又嵌套了O(n)复杂度的查找动作,所以时间复杂度是O( n 2 n{^2} n2)
因此这里的数据结构只能选择字典类型
查找,就是从复杂的数据结构中,找到某个满足条件的元素
可以从以下两个方面对数据进行查找
- 根据元素的位置或索引查找
- 根据元素的数值特征来查找
: 在一个数组中,找到数组中的第二个元素 .时间复杂度(O(1))
: 在链表中,找到链表中的第二个查找 时间复杂度O(n)
: 查找数据结构中值等于4的元素,时间复杂度O(n) 如果借助字典类型的数据类型 ,则时间复杂度O(1)
新增,在复杂数据结构中新增数据
有以下两种情况:
- 数据结构的最后,新增一条数据
- 数据结构的中间某个位置,新增一条数据
⚡ 区别:新增子数据后,是否会改变原来数据的位置
删除,在复杂数据结构中删除数据有两个可能
1. 在这个数据结构的最后,删除一条数据
2. 在这个复杂数据结构的中间某个位置,删除一条数据
⚡ 区别: 删除数据的后,是否会改变原来数据的位置;如果是第一种,则不会改变;如果是第二种,则会改变
: 在某个复杂数据结构中,在第二元素之后新增一条数据,随后再删除第一个满足数值大于6的元素
分析数据操作过程:
1. 找到第二个位置,新增一条数据
2. 找到数值大于6的第一个元素,删除数据
只有再充分了解问题,明确数据操作的方法之后才能设计出更加高效的数据结构类型
在实际的工作中,如果不知道该使用什么数据结构的时候,就需要从数据需要被处理的动作出发
❓ 几个客户端分别向服务器发送请求,服务器要采用先到先得的处理方式,应该设计什么样的数据结构?
什么是数据结构?
按照某种方式去对数据进行组织
在计算机科学中,数据结构(英语:data structure)是计算机中存储、组织数据的方式(-- 摘自维基百科)
:假设你是一所幼儿园的园长,现在你们正在组织一场运动会,所有的小朋友需要在操场上接受检阅,那么如何组织小朋友有序战队并完成检阅呢?
可选方式:
线性表示n个数据元素的优先序列,最常用的是链式表达,通常也叫做线性链式或者链表
在链表中存储数据的数据元素成为结点,一个节点存储的是一条数据记录。
每个结点有以下两个部分:
为了弥补单向链表的不足,可以对节点的结构进行改造:
让最后链表中的最后一个元素指向第一个元素,就是循环链表。如下图所示
让链表的节点多一个指针指向前一个节点,这个时候就是双向链表。如下图所示
另外还可以把双向链表和循环链表进行融合,得到双向循环链表
在单向链表中插入一个节点。需要有以下的操作:(前一个节点preNode,当前节点currentNode,后一个节点nextNode,node.next表示指向下一个结点的指针)
步骤:
过程如下图所示:
单链表的增删操作图,如需要可以克隆后修改https://www.processon.com/view/5ecf234fe0b34d5f26368c72
查找的方式:
都需要从head一个一个往后面查找,所以使用链表结构在查找操作的时间复杂度为O(n)
小结:
* 链表在新增、删除数据的时候可以在O(1)的时间复杂度内完成
* 链表在查找操作,不管查找的方式是按位置查找还是条件查找,都需要对数据进行遍历。时间复杂度就是O(n)
* 链表在新增和删除数据上有优势,但是这个优势不太实用,因为在新增数据的时候,通常会先有一个查找的操作,因此时间复杂度为O(n)
链表的翻转
给定一个链表,输出翻转后的链表,例如输入A->B->C->D->E, 输出E->D->C->B->A
由于单向链表,它的指针结构造成了它的数据通路的又去无回,一旦修改了某个指针,后面的数据就会造成失联的状态。为了解决这个问题,需要添加三个指针 prev, current, next
给定一个技术个元素的链表,查找出这个链表中间位置的节点的数值
判断链表是否有环
如果快指针与慢指针在某个时候相遇,则链表存在环。
这一小节主要围绕线性表的原理,线性表的增删查操作。
线性表结构的每个结点由数据和一个指向下一个节点的指针构成
据结构组合方式不同,除了基础的单链表,还有双链表,循环链表以及双向循环链表
链表的翻转、快慢指针的方法是必须掌握的内容
线性表真正的价值在于,它对数据的存储方式是按照顺序的存储
线性表对数据的顺序非常敏感,而且它对数据的增删操作非常灵活
在有序排列的数据中,可以灵活的执行增删操作
在某些需要严格遵守数据处理顺序的场景下,就需要对线性表予以限制。
栈是一种特殊的线性表
栈与线性表的不同,体现在增和删的操作
栈的数据结点必须后进先出
栈的数据新增和删除操作只能在这个线性表的表尾进行,即在线性表的基础上加了限制
从功能上讲,数组或者链表可以替代栈
但问题是:数据或者链表的操作过于灵活
这些没有意义的接口过多,当数据量很大的时候就会出现一些隐藏的风险
虽然栈限定降低了操作的灵活性,但是这使得栈在处理只涉及一端的新增和删除数据的问题时效率更高
栈包含表头和表尾
栈顶和栈底使用来表示这个栈的两个指针
栈也有顺序表示和链式表示,分别称为顺序栈和链栈
栈的顺序存储可以借助数组来实现
当定义了栈的最大容量StackSize时,则栈顶top必须小于StackSize
当需要新增数据元素,即入栈操作时,就需要将新插入的元素放在栈顶,并且将栈顶的指针加1
删除数据元素,即出栈操作,只需要top-1就可以
查找操作,栈没有额外的改变,需要遍历整个栈来完成基于某些条件的数组查找
链式栈,就是用链表的方式对栈的表示。通常,可以把栈顶放在单链表的头部
对于链栈,新增数据的压栈操作与链表最后插入数据基本相同,需要额外处理的就是栈的top指针
在链式栈中进行删除操作时,只能在栈顶进行操作
将栈顶的top指针指向栈顶的元素的next指针即可完成删除
对于链式栈来说,新增和删除数据的操作没有任何循环,器时间复杂度都是O(1)
对于查找操作,它需要遍历整个栈来完成基于某些条件的数值查找
给定一个只包括’(‘, ’)‘, ’{‘, ’}‘, ’[‘, ’]‘的字符串,判断字符串是否有效
有效字符串需满足:左括号必须与相同类型的右括号匹配,左括号必须与正确的顺序匹配
例如{[()()]}是合法的,儿{([)]}是非法的
这个例子可以用栈来解:因为在匹配括号是否合法时,左括号是从右括号依次出现;而右括号则需要按照“后进先出”的顺序一次与左括号匹配。
实现方案:从左到右遍历字符串,当出现左括号时压栈,出现右括号时出栈,并判断当前的出栈的左括号是否与右括号是一对。如果不是,则字符串非法。
const str = '{[()()]}';
const str2 = '{([)]}'
console.log("strIsValid(str)", strIsValid(str))
function strIsValid(str) {
let arr = str.split("");
let zhan = [];
const rightBrackets = [")", "}","]"];
const leftBrackets = ["(", "{", "["];
for(let index=0;index<arr.length;index++){
if(leftBrackets.includes(arr[index])){
zhan.push(arr[index])
}else if(rightBrackets.includes(arr[index])){
if(leftBrackets.indexOf(zhan.pop()) !== rightBrackets.indexOf(arr[index])){
return false
}
}
}
return true;
}
浏览器的页面访问都包含了后退和前进功能,如何利用栈实现
为了支持前进,后退的功能,可以利用栈来记录用户历史访问页面的顺序信息
此时需要维护两个栈,分别用来支持后退和前进
当用户访问了一个新的页面,则对后退栈进行压栈操作
当用户后退了一个页面,则会后退栈进行出栈,同时前进栈进行压栈操作
当用户前进了一个页面,则前进栈出栈,同时后退栈压栈
总结
栈具有后进先出的特性,当面对的问题需要频繁使用新增、删除操作且新增和删除操作的数据执行顺序具备后来居上的关系时,栈就是个不错的选择
例如浏览器的前进后退,括号的匹配问题
栈在代码的编写中有着很广泛的应用
例如大多数程序运行环境中都有子程序的调用,函数的递归调用等
遵循先进先出的性质,就是队列
与栈相似,队列也是一种特殊的线性表,与线性表的不同之处也是体现在对数据的增和删的操作上
先进,表示队列的数据新增操作只能在末端进行,不允许在队列的中间某个结点后新增数据;
先出,队列的数据删除操作只能在始端进行,不允许在队列的中间某个结点后删除数据
顺序队列,依赖数组来实现,其中的数据在内存中也是顺序存储。
而链式队列,则依赖链表来实现,其中的数据依赖每个结点的指针互联,在内存中并不是顺序存储。链式队列,实际上就是只能尾进头出的线性表的单链表。
队列从队头(front)删除元素,从队尾(rear)插入元素。对于一个顺序队列的数组来说,会设置一个 front 指针来指向队头,并设置另一个 rear 指针指向队尾。当我们不断进行插入删除操作时,头尾两个指针都会不断向后移动。
为了实现一个有 k 个元素的顺序存储的队列,我们需要建立一个长度比 k 大的数组,以便把所有的队列元素存储在数组中。队列新增数据的操作,就是利用 rear 指针在队尾新增一个数据元素。这个过程不会影响其他数据,时间复杂度为 O(1)
队列删除数据的操作与栈不同。队列元素出口在队列头部,即下标为 0 的位置。当利用 front 指针删除一个数据时,队列中剩余的元素都需要向前移动一个位置,以保证队列头部下标为 0 的位置不为空,此时时间复杂度就变成 O(n)
顺序队列会产生一种产生了一种 “假溢出” 的现象
为了解决这种问题有以下解决方案:
循环队列的增加操作
循环队列进行新增数据元素操作时,首先判断队列是否为满。
如果不满,则可以将新元素赋值给队尾,然后让 rear 指针向后移动一个位置。
如果已经排到队列最后的位置,则 rea r指针重新指向头部。
循环队列的删除操作
循环队列进行删除操作时,即出队列操作,需要判断队列是否为空
然后将队头元素赋值给返回值,front 指针向后移一个位置。
如果已经排到队列最后的位置,就把 front 指针重新指向到头部
循环队列如何判空或者判满?
常用的方法是,设置一个标志变量 flag 来区别队列是空还是满。
链式队列就是一个单链表,同时增加了 front 指针和 rear 指针。
链式队列和单链表一样,通常会增加一个头结点,并另 front 指针指向头结点。头结点不存储数据,只是用来辅助标识。
链式队列进行新增数据操作时,将拥有数值 X 的新结点 s 赋值给原队尾结点的后继
然后把当前的 s 设置为队尾结点,指针 rear 指向 s
当链式队列进行删除数据操作时,实际删除的是头结点的后继结点。这是因为头结点仅仅用来标识队列,并不存储数据。因此,出队列的操作,就需要找到头结点的后继,这就是要删除的结点。接着,让头结点指向要删除结点的后继。
那么为何队列还特被强调要有头结点呢?
这主要是为了防止删除最后一个有效数据结点后, front 指针和 rear 指针变成野指针,导致队列没有意义了。有了头结点后,哪怕队列为空,头结点依然存在,能让 front 指针和 rear 指针依然有意义。
约瑟夫环是一个数学的应用问题,具体为,已知 n 个人(以编号 1,2,3…n 分别表示)围坐在一张圆桌周围。从编号为 k 的人开始报数,数到 m 的那个人出列;他的下一个人又从 1 开始报数,数到 m 的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。这个问题的输入变量就是 n 和 m,即 n 个人和数到 m 的出列的人。输出的结果,就是 n 个人出列的顺序。
这个问题,用队列的方法实现是个不错的选择。它的结果就是出列的顺序,恰好满足队列对处理顺序敏感的前提。因此,求解方式也是基于队列的先进先出原则。解法如下:
小结
通常情况下,在可以确定队列长度最大值时,建议使用循环队列。无法确定队列长度时,应考虑使用链式队列。队列具有先进先出的特点,很像现实中人们排队买票的场景。在面对数据处理顺序非常敏感的问题时,队列一定是个不错的技术选型。
数组是数据结构中的最基本结构,几乎所有的程序设计语言都把数组设定为固定的基础变量类型。可以把数组理解为一种容器,它可以用来存放若干个相同类型的数据元素
举个栗子:
存放的数据类型是整数类型的数组,称作为整型数组
存放的数据是字符型的数组,则称作字符数组
数组的数组,称作二维数组
如果用数学的方式来看,可以把普通的数组看成是一个向量,那么二维数组就是一个矩阵
数组在内存中是连续存放的,数组内的数据,可以通过索引值获得
数组的索引就是对应数组空间
数组在存储数据时是按顺序存储的,并且存储数据的内存也是连续的,这就让它有了增删困难、查找容易的特点
删除or新增数据后,其他数据的位置是否发生改变
一些高级编程语言已经封装了响应式的函数方法:
例如:
即使是封装好的好的函数,时间复杂度还是不会发生改变
假设,数组存储了5个评委对一个运动员的打分,且每个评委的打分都不相等
需要你做:
要求:不允许再开辟O(n)空间复杂度的复杂数据结构
解题思路:
本节内容主要讲了数组的原理和特性,以及数组的增删查的操作方法
数组的增删查操作灵活很多,代码实现的方法也更多样
要根据实际需求选择合适的方法进行操作。
在实际操作中,要注意根据数组的优缺点合理区分数组和链表的使用
数组定义简单,访问方便,但在数组中所有元素类型必须相同
数组的最大长度必须在定义时给出,数组使用的内存空间必须连续
数组更适合在数据数量确定,即较少或者甚至不需要使用新增数据、删除数据操作的场合下使用
在数据对于位置敏感的场景下,比如需要高频根据索引位置查找数据时,数组就是个很好的选择
字符串是由n个字符组合成的一个有序整理(n>=0)
比如 s=“CHENGDU”
字符串的逻辑结构与线性表不同之处在于 字符串针对的是字符集,也就是字符串中的元素都是字符
一些特殊的字符串:
只有两个串的串值相同,这两个串才相等。
字符串 存储结构有顺序存储和链式存储
字符串中更多关注的是查找子串的位置、替换等操作
字符串 新增操作,时间复杂度是O(n)
字符串的删除操作,时间复杂度是O(n)
字符串的查找操作
子串查找:如何判断一个子串是否在字符串中出现过呢?
在字符串A中查找字符串B,则A就是主串,B就是模式串
把主串的长度记为n,模式串的长度记为m
主串的长度n>m
假设需要从主串s="goodgoogle"中找到t="google"子串
根据思考逻辑则有:
时间复杂度为O(nm)
假设有且仅有1个最大公共子串,比如,输入a=“13452439”,b=“123456”
由于字符串"345"同时在a和b中出现,且是同时出现在a和b中的最长子串
因此输出"345"
解决方案:可以使用动态规划的算法来解
假设字符串a的长度为n,字符串b的长度为m,可见时间复杂度是n和m的函数
需要对字符串a和b找到第一个共同出现的字符
一旦找到了第一个匹配的字符之后,就可以同时在a和b中继续匹配它后续的字符是否相等,全局还要维护一个最长子串及其长度的变量
字符串的逻辑结构和线性表极为相似,区别在于串的数据对象约束为字符集
字符串的数据操作和线性表有很大的差别:
给定一个字符串,逐个翻转字符串的每个单词:
例如输入“the sky is blue” 输出“blue is sky the”
树:树是由结点和边组成的,不存在环的一种数据结构
以二叉树为例,介绍树的操作
树结构是一对多的关系,即前面的父结点跟下面若干个子结点产生了连接关系
要在数据结构中,查找某个具有数值特性的数据,需要遍历每一条数据
遍历一棵树的经典方法
先序遍历:A B D E C F
中序遍历:D B E F C A
后序遍历:D E B F C A
二叉树遍历过程中,每个结点都被访问了一次,其时间复杂度是O(n)
在找到位置后,执行增加和删除操作的时候,只需要通过指针建立连接关系就可以了
对于没有任何性质的二叉树而言,真正执行增加和删除操作的时间复杂度是O(1)
树数据的查找操作和链表一样,都需要遍历每一个数据去判断,所以时间复杂度是O(n)
二叉查找树也称为二叉搜索树具备以下几个特点:
中序遍历结果:10 13 15 16 21 22
在利用二叉树执行查找操作时,可以根据以下进行判断
这样的“二分查找” 所消耗的时间复杂度就可以降低为O(logn)
二叉树执行插入操作时
二叉查找树的删除操作
情况1:如果要删除的是某个叶子结点,则直接删除,其父结点指针执行null
情况2:如果要删除的结点只有一个子节点,只需将其父结点的指针换成其子结点的指针
情况3:如果要删除的节点有两个子结点,则有两种可行的操作方式
1. 找到这个结点的左子树中最大的结点,替换要删除的结点
2. 找到这个结点的右子树中最小的结点,替换要删除的结点
例题:输入一个字符串,判断它在已有的字符串集中是否出现过?
(假设集合中没有某个字符串与另一个字符串拥有共同前缀且包含完全的特殊情况例如deep 和dee)
如果已知字符集中包含6个字符串分别为:cat car dog door deep
输入cat 则输出true; 输入home则输出false
采用最暴力的办法,估算一下时间复杂度
假设字符串集包含了n个字符串,其中的字符串长度均为m
那么每新来一个字符串,需要与每个字符串的每个字符进行匹配。则时间复杂度为O(nm)
存在许多无效匹配
如果可以通过对字符串前缀进行处理,可以最大限度地减少无谓字符串比较,从而提高查找效率
“用空间换时间”的思想,利用共同前缀来提高查找效率
利用Trie树(字典树)来解决这个问题
字典树具有的特点:
(1)根据字符集合,形成字典树,需要使用数据插入的动作
(2)判断输入的字符串能否从根结点到叶子结点,如果能,则在字符集合中
想要利用二叉树实现增删操作,需要熟练掌握二叉树的三种遍历方式
遍历的时间复杂度是O(n),增删操作的时间复杂度是O(1)
对于查找操作:
树结构存在“一对多”的数据关系,可被高频使用
在线性表、数组、字符串和树的结构中,数据数值条件的查找,都需要对数据或者部分数据进行遍历。
省去数据比较的过程?从而进一步提升数值条件查找的效率。
哈希表:也叫作散列表,哈希表是一种特殊的数据结构,它与数组、链表及树等数据结构相比,有很明显的区别。
在学习哈希表之前,在其他的数据结构中,数据的存储位置和数据的具体值之间不存在关系
哈希表的设计采用了函数映射的思想,将记录的存储位置与记录的关键字关系起来
数组是通过数据的索引(index)来取出数值,例如取出数组a中索引值为2的元素。索引值是数据存储的位置,因此直接通过a[2]就可以取出这个数据
通过这样的方式,数组实现了“地址=f(index)”的映射关系
如果用哈希表的逻辑来理解,这里的f()就是一个哈希函数,它完成了索引值到实际地址的一个映射,这就让数组可以快速完成基于索引值的查找。
然而,数组的局限性在于,只能基于数据的索引去查找,而不能基于数据的数值去查找*
哈希表的核心思想:实现“地址=f(关键字)”的映射关系,那么久可以快速完成基于数据的数值的查找
栗子:
假如需要对一个手机通讯录进行存储,并根据姓名查找出一个人的手机电话号码:
张一: 15555555555
张二: 16666666666
张三: 17777777777
张四: 18888888888
方案:
当要判断“张四”是否在链表中,或者想要查找张四的手机号码时,需要从链表的头结点开始遍历,依次将每个结点中的姓名与“张四”做比较,直到查找成功或者全部遍历一次为止,这种做法的时间复杂度为O(n)
需要降低时间复杂度,需要借助哈希表的思路,构建姓名到地址的映射函数“地址=f(姓名)”
这样就可以在O(1)时间的复杂度内可以完成数据的查找
hash函数为姓名的每个字的拼音开头大写字母的ASCII码之和
address(张一)=ASCII(Z)+ASCII(Y)=90 +89 = 179
address(张二)=ASCII(Z)+ASCII(E)=90 +69 = 159
address(张三)=ASCII(Z)+ASCII(S)=90 +83 = 173
address(张四)=ASCII(Z)+ASCII(S)=90 +83 = 173
其中张三与张四得到的值是相同的,这种情况称之为"冲突"
从本质上来看,哈希冲突只能尽可能的减少,不能完全避免
哈希函数需要设计合理的哈希函数,并且对冲突有一套处理机制
哈希函数为关键字到地址的线性函数。如,H(key) = a * key +b 这里的a和b都是设置好的常数
假设关键字结合中的每个关键字key都是由s为数字组成(k1, k2, …, ks)
并从中提取分布均匀的若干位组成哈希地址,上面的栗子就是使用的这个方法。
如果关键字的每一位都有某些数字重复出现,且频率很高,可以先求关键字的平方值,再通过平方括大差异,然后取中间几位作为最终存储地址
如果关键字的位数很多,可以将关键字分割成几个等长的部分,取它们的叠加之和的值(舍去进位)作为哈希地址
预先设置一个数p,然后对关键字记性取余运算。H(key) = key mod p
方案1:
开放定址法:当一个关键字和另一个关键字发生冲突时,使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列一次查找下去,当碰到一个空的单元时,则插入其中。常用的探测方法是线性探测法。
举个栗子:
有一组关键字{12, 13, 25, 23} ,采用的哈希函数为H(key) = key mod 11
H(12) = 12 mod 11 = 1
H(13) = 13 mod 11 = 2
H(25) = 25 mod 11 = 3
H(23) = 23 mod 11 = 1 (X)这个时候冲突了,采用线性探测法 H(23) = 4 ,这个时候是空的单元,这个单元就可以存放23了
方案2:
链地址法:将hash地址相同的记录存储在一张线性链表中。
有一组关键字{12, 13, 25, 23, 38, 84, 6, 91, 34} ,采用的哈希函数为H(key) = key mod 11
优势:哈希表提供看非常快速的插入,删除,查找操作,无论多少数据,插入和删除值需要接近常量的时间。
在查找操作,哈希表的速度比树还要快,基本可以瞬间找到想要找到的元素
不足:哈希表中的数据是没有顺序概念的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素
哈希表中key是不允许重复的
很多高级语言中,哈希函数、哈希冲突都已经在底层完成了黑盒化处理
哈希表完成了关键性到地址的映射,可以在常数级时间复杂度内通过关键字查找到数据
哈希表中的增加和删除数据操作,不涉及增删后对数据的挪移问题(数组需要考虑)
哈希表的查找细节过程是:对于给定的key,通过哈希函数计算哈希地址H(key)
栗子1: 将关键字序列{7, 8, 30, 11, 18, 9, 14} 存储到哈希表中
哈希函数:H(key) = (key * 3) % 7,处理冲突采用线性探测法
H(7) = (7 * 3) % 7 = 0
H(8) = (8 * 3) % 7 = 3
H(30) = (30 * 3) % 7 = 6
H(11) = (11 * 3) % 7 = 5
H(18) = (18 * 3) % 7 = 5(X) H(18) = 7
H(9) = (9 * 3) % 7 = 6(x) H(9)=8
H(14) = (14 * 3) % 7 = 0(x) H(14)=1
根据以上的哈希表来查找
查找7: 输入7,计算H(7)=0,根据哈希表在0的位置,得到的结果为7,与输入值相等,查找成功
查找18:输入18,计算H(18)=5,根据哈希表在5的位置,得到的结果为11,与输入值18不相等,此时单元有值,采用解决冲突的方式(线性探测)去查找关键字,找哈希表中6的位置,值为30 不等于18,找7的位置,值为18,查找成功
栗子2:假设有一个在线系统,可以实时接收用户提交的字符串类型关键字,并实时返回给用户累计至今,这个关键字被提交的次数
例如:用户输入"abc",系统返回1,用户再输入”jk“,系统返回1
用户再输入“xyz”,系统返回1,用户再输入“abc”,系统返回2,用户再输入“abc”,系统返回3
一种解决方法是,用一个数组保存用户提交过的所有关键字,当接收到一个新的关键字后,插入到数组中,并且统计这个关键字出现的次数。
这个时候,插入动作的时间复杂度是O(1),统计出现的次数需要遍历一遍数组,时间复杂度是O(n)
第二种方法:利用hash表,可以利用hash表的新增、查找的常数级时间复杂度,在O(1)时间复杂度内完成响应,预定好哈希表后(可以采用Js中的map)
var map = new Map();
...
if(map.has(key_str)){
map.set(key_str, map.get(key_str)+1)
} else {
map.set(key_str, 1)
}
哈希表中有多少数据,查找、插入、删除只需要接近常量的时间,即O(1)的时间级
如果需要在一秒钟内查找上千条数据通常使用哈希表(例如拼写检查器)
哈希表的操作通常比树快,树的操作通常需要O(n)的时间级
哈希表不仅时间快,变成实现也相对容易
不管是数据结构还是算法思维,它们的目标都是降低时间复杂度。
数据结构是从数据组织形式的角度达成这个目标
算法思维则是通过数据处理的思路上去达成这个目标
这一小节,学习利用递归求解汉诺塔问题。
在数学与计算机科学中,递归 (Recursion) 是指在函数的定义中使用函数自身的方法,直观上来看,就是某个函数自己调用自己。
递归有两层含义:
简而言之,递归的基本思想就是把规模大的问题转化为规模小的相同的子问题来解决。在函数实现时,因为大问题和小问题是一样的问题,因此大问题的解决方法也是用一个方法,这就产生了函数调用它自己本身的现象,这也正是递归的定义所在。
NOTE: 这个解决问题的函数必须有明确的结束条件,否则就会导致无限递归的情况,递归的实现包含了两个部分,一个是递归主体,另一个是终止条件
递归的数学模型其实就是数学归纳法
一个常见的题目是:证明当n等于任意一个自然数时某命题成立
当采用数学归纳法时,证明分为以下两个步骤:
当采用递归算法解决问题时,需要围绕这两个步骤:
e.g
二叉树的中序遍历:
对树中的任意节点(递归主体)
当某个接地那没有左子树和右子树时,则直接打印这个结点,完成终止(终止条件)
function inOrderTraverse(Node node) {
if(node == null)
return;
inOrderTraverse(node.left);
console.log(node.data+" ");
inOrderTraverse(node.right)
}
写出递归代码的关键在于,写出递推公式和找出终止条件
汉诺塔问题:
解题思路:
分解问题:
判断满足终止条件
汉诺塔的递归函数为hanio(),这个函数的输入参数包括了:
function hanio(n, x, y, z){
if(n<1){
console.log("汉诺塔的陈述不能小于1")
}else if(n == 1) {
// 终止条件
console.log(`移动:${x}->${z}`)
return;
} else {
// 递归体
// 将其余n-1个盘子从x移到y
hanio(n-1, x, z, y);
// 将最大的盘子从x移到Z
console.log(`移动:${x}->${z}`)
// 将其余n-1个盘子从y移到Z
hanio(n-1, y, x, z);
}
}
hanio(3, "x", "y", "z");
递归的核心思想是把规模大的问题转化为规模小的子问题来解决
解决问题的函数必须要有明显的结束条件
递归函数有终止条件和递归体两个部分构成
很多数据结构和算法的编码实现都要用到递归,例如分治策略、快速排序等等
分治:可以把一个大规模、高难度的问题,分解为若干个小规模、低难度的小问题。
很多高效率的算法都是以分治作为其基础思想,比如排序算法中的快速排序和归并排序
计算机求解问题所需的计算时间与其涉及的数据规模强相关
案例:在一个包含n个元素的无序数组中,要求按照从小到大的顺序打印其n个元素
分治法的核心思想就是分而治之,可以采用同一种解法,递归地去解这些子问题,再讲每个子问题的解合并,就得到原问题的解。
误区:当计算机性能还不错的时候 采用分治法相当于对全局遍历一遍没有什么差别
案例:在1000个有序数字构成的数组a中, 判断某个数字c是否出现过
方法一:全局遍历。时间复杂度为O(n)
方法二:采用二分查找。复杂度O(logn)。递归的判断c与a的中位数的大小关系,不断的缩小范围
分治法的价值:
复杂度为O(logn)相比复杂度O(n)的算法,在大数据集合中性能有这爆发式的提高
当需要采用分治法的时,一般原问题都需要具备以下几个特征:
分治法在每轮递归里,都包含了 分解问题 解决问题 合并结果
查找问题:在一个有序的数列中,判断某个待查找的数字是否出现过
二分查找:利用分治法去解决查找问题,通常需要一个前提–输入的数列是有序的
二分查找的思路:
二分查找的最差情况是,不断找到最后一个数字才能完成判断,此时需要的最大复杂度就是O(logn)
案例:在数组{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}中,查找8是否出现过
在一个有序数组中,查找出第一个大于9的数字,假设一定存在,例如 arr={-1,3,3,7,10,14,14}
待查找的目标数字具备这样的性质:
分治法通常用在海量数据处理中
在面对陌生问题时,需要注意:
这一小节会学习:冒泡排序、插入排序、归并排序以及快速排序的排序算法,以及这四种排序算法的优劣势进行对比分析
排序,就是让一组无序数据变成有序的过程。一般默认的有序都是从小到大的排列顺序
衡量排序算法的优劣,会从以下3个角度分析:
冒泡排序的原理
从第一个数据开始,一次比较相邻元素的大小。如果前者大于后者,则进行交换操作。把大的元素往后交换。通过多轮迭代,直到没有交换操作为止。冒泡排序就像是在一个水池中处理数据一样,每次会把最大的那个数据传递到最后。
举个栗子:[1, 4, 3, 2, 5]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V9qDJL2f-1593681057529)(C:\Users\45557\Desktop\临时文件\笔记\img\冒泡第二次排序.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8VqOGXI2-1593681057531)(C:\Users\45557\Desktop\临时文件\笔记\img\冒泡第三次排序.png)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7zAnyN3L-1593681057534)(C:\Users\45557\Desktop\临时文件\笔记\img\冒泡第四次排序.png)]
冒泡排序的性能
冒泡排序的最好时间复杂度是O(n),也就是输入数组刚好是顺序的,只需挨个比较,不需要做交换操作。所以时间复杂度为O(n)
冒泡排序最坏时间复杂度是O(n*n), 也就是说当数组刚好完全逆序的时候,需要挨个比较n次,并且重复n次
当输入数组杂乱无章时,它的平均时间复杂度也是O(n*n)
冒泡排序不需要额外的空间,所以时间复杂度是O(1).
冒泡排序过程中,当元素相同时不做交换,所以冒泡排序是稳定的排序算法
function maopao(arr) {
if(arr && arr.length <=1){
return arr
}
let isASC=false; //判断要冒泡的数组是否有序
for(let i=1;isASC === false && i<arr.length;i++){
isASC = true;
for(let j=0; j<arr.length-i;j++){
if(arr[j] > arr[j+1]) {
let temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
isASC = false;
}
}
}
return arr;
}
插入排序的原理
选取未排序的元素,插入到已排序的区间的合适位置,知道未排序区间为空。插入排序就是从左到右维护一个已经排好序的序列,知道所有的待排数据全部完成插入动作。
插入排序的性能
插入排序最好的时间复杂度是O(n),即当数组刚好是完全顺序时,每次只用比较一次就能找到正确的位置。这个过程重复n次就可以清空未排序区间
插入排序最坏时间复杂度则需要 O(n*n)。即当数组刚好是完全逆序时,每次都要比较 n 次才能找到正确位置。这个过程重复 n 次,就可以清空未排序区间,所以最坏时间复杂度为 O(n * n)。
插入排序的平均时间复杂度是 O(n * n)。这是因为往数组中插入一个元素的平均时间复杂度为 O(n),而插入排序可以理解为重复 n 次的数组插入操作,所以平均时间复杂度为 O(n * n)。
插入排序不需要开辟额外的空间,所以空间复杂度是 O(1)。
插入排序是稳定的排序算法
js实现代码如下:
function charu(arr) {
for(let i = 1; i< arr.length;i++) {
let temp = arr[i];
let j = i-1;
for(;j>=0;j--){
if(arr[j] > temp){
arr[j+1] = arr[j]
} else {
break;
}
}
arr[j+1] = temp;
}
return arr;
}
小结:插入排序和冒泡排序算法的异同
相同点
差异点
归并排序的原理
归并排序的原理就是之前讲的分治法。它首先将数组不断地二分,直到最后每个部分只包含一个数据,然后再对每个部分分别进行排序,最后将排序好的相邻部分合并在一起,这样整个数组就有序了。
归并的含义就是将两个或者两个以上的有序表合成一个新的有序表(2-路归并排序)
function customDoubleMerge(arr, left, mid, right) {
let temp = []; //临时数组指针
let p1 = left; //左序列指针
let p2 = mid + 1; //右序列指针
let k = left;
while (p1 <= mid && p2 <= right) {
if (arr[p1] <= arr[p2]) {
temp[k++] = arr[p1++];
} else {
temp[k++] = arr[p2++];
}
}
while (p1 <= mid) {
temp[k++] = arr[p1++];
}
while (p2 <= right) {
temp[k++] = arr[p2++];
}
for (let i = left; i <= right; i++) {
arr[i] = temp[i];
}
}
function customMergeSort(arr, start, end) {
if (start < end) {
let mid = Math.floor((start + end) / 2);
console.log("mid", mid)
customMergeSort(arr, start, mid); //左边归并排序,使得左子序列有序
customMergeSort(arr, mid + 1, end); //右边归并排序,使得右子序列有序
customDoubleMerge(arr, start, mid, end); //将两个有序子数组合并操作
}
}
归并排序的性能分析
空间效率:merge()操作中需要n个辅助空间,所以归并的空间复杂度为O(n)
时间效率:每一趟归并的时间复杂度为O(n),共需log2n趟归并,所以算法的时间复杂度为O(nlog2n)
稳定性:merge操作不会改变相同关键字记录的相对次序,所以归并排序算法是一个稳定的排序算法
快速排序是对冒泡排序的一种改进。其基本思想基于分治法。快速排序算法的关键在于划分操作
快速排序的原理
快速排序的原理也是分治法。它的每轮迭代,会选取数组中的任意一个数据作为分区点,将小于它的元素放在它的左侧,大于它的元素放在它的右侧。再利用分治的思想,继续对左右两侧进行同样的操作。直至,每个区间缩小为1,则完成排序。
function quickSort(arr, low, high) {
if(low < high) {
let pivotPos =partition(arr, low, high);
quickSort(arr, low, pivotPos-1);
quickSort(arr, pivotPos+1, high);
}
}
function partition(arr , low, high) {
// 每次操作都找到枢轴的位置
let pivot = arr[low];//将表中第一个元素设置为区轴值,对表进行划分
while(low < high) {
while(low< high && arr[high] >=pivot ) {
--high;
}
arr[low]=arr[high]; //比枢轴小的数移到右边
while(low< high && arr[low] <=pivot ) {
++low;
}
arr[high]=arr[low];//比枢轴大的数移到左边
}
arr[low] = pivot;
return low; //返回存放枢轴的位置
}
空间效率:由于快速排序是递归的,需要借助工作栈来保存每一层递归调用的必要信息,其容量应与递归调用的深度一样。最好情况下为log2(n+1);最坏的情况下,因为要进行n-1次递归调用,所以栈的深度为O(n);平均情况下,栈的深度为O(log2n),因此最坏情况下,空间复杂度为O(n),平均情况下,空间复杂度为O(log2n)
时间效率:在快排的最好时间的复杂度下,如果每次选取分区点时,都能选中中位数,把数组等分成两个,那么此时的时间复杂度和归并一样,都是 O(n*logn)。
而在最坏的时间复杂度下,也就是如果每次分区都选中了最小值或最大值,得到不均等的两组。那么就需要 n 次的分区操作,每次分区平均扫描 n / 2 个元素,此时时间复杂度就退化为 O(n*n) 了。
快速排序法在大部分情况下,统计上是很难选到极端情况的。因此它平均的时间复杂度是 O(n*logn)。快速排序是所有内部排序中平均性能最优的排序算法
稳定性:快排是不稳定的排序算法。
如果对数据规模比较小的数据进行排序,可以选择时间复杂度为 O(n*n) 的排序算法。因为当数据规模小的时候,时间复杂度 O(nlogn) 和 O(n*n) 的区别很小,它们之间仅仅相差几十毫秒,因此对实际的性能影响并不大。
但对数据规模比较大的数据进行排序,就需要选择时间复杂度为 O(nlogn) 的排序算法了。
归并排序的空间复杂度为 O(n),也就意味着当排序 100M 的数据,就需要 200M 的空间,所以对空间资源消耗会很多。
快速排序在平均时间复杂度为 O(nlogn),但是如果分区点选择不好的话,最坏的时间复杂度也有可能逼近 O(n*n)。而且快速排序不具备稳定性,这也需要看你所面对的问题是否有稳定性的需求。
在前面学习了分治法的思想,并以二分法查找为例介绍了分治的实现逻辑。
分治法的使用必须满足4个条件:
在实际工作中还存在一类问题,它们满足前三个条件但是不满足第4个条件。这个时候就可以采用动态规划算法来求解这类问题。
从数学的视觉来看,动态规划是一种运筹学方法,是在多伦决策过程中的最优方法。
那么,什么是多轮决策?其实多轮决策的每一轮可以看为一个子问题,从分治法的视觉来看,每个子问题必须相互独立,但是在多伦决策中,这个假设显然不成立,这也是动态规划方法产生的原因之一。
线路网络图
每个结点是一个位置,每条边是两个位置之间的距离。现在需要求解出一条由 A 到 G 的最短距离是多少。
求解路线是由A->G,这意味着需要从A->B->C->D->E->F->G.每一轮都需要做不同的决策,而每一次的决策又依赖上一轮决策的结果。
**动态规划还有一个重要概念叫做状态。**在这个例子中,状态是个变量,而且受决策动作的影响,例如,第一轮决策状态时S1,可选值就是A,第二轮决策状态时S2,可选值就是B1和B2
虽然动态规划问题没有标准化的解题方法,但它有一些宏观层面通用的方法论:
动态规划的基本概念:
策略–每轮的动作是决策,每轮决策合在一起常常被称为策略
策略集合–通常会称所有可能的策略为策略集合
动态规划的目标,可以说是从策略集合中,找到最优的那个策略
具有以下几个特征的问题,可以采用动态规划求解:
最优子结构。原问题的最优解包含所包括的子问题的解也是最优的
例如,某个策略使得A到G是最优的,假设它途径Fi,那么它从A到Fi也一定是最优的
无后效性。某阶段的决策,无法影响先前的状态
有重叠的子问题。子问题之间不独立。这个性质是区别分治法的条件
分阶段
从A到G,可以拆分为A->B, B->C, C->D, D->E, E->F,F->G,6个阶段
找状态
第一轮 S 1 S_1 S1={A},
第二轮 S 2 S_2 S2={ B1, B2},
第三轮 S 3 S_3 S3={ C1, C2, C3, C4},
第四轮 S 4 S_4 S4={ D1,D2, D3},
第五轮 S 5 S_5 S5={ E1, E2, E3},
第六轮 S 6 S_6 S6={ F1, F2},
第七轮 S 7 S_7 S7={G},
做决策
决策变量是图中的每条边
以第四轮来看 D->E,可以得到u4(D1),u4(D2),u4(D3),其中u4(D1)的结果可能是E1和E2
写出状态转移方程
S(k+1) = uk(Sk)
定目标
定义dk(Sk,uk)是在Sk时,选择uk动作的距离
例如。d5(E21,F1)=3, 那么此时n=7,则有
vk,7( S 1 S_1 S1=A, S 7 S_7 S7=G)= ∑ 1 7 d k ( S k , u k ) \sum_1^7d_k(S_k,u_k) ∑17dk(Sk,uk)
寻找终止条件
这里的气质条件为 S 1 S_1 S1=A和 S 7 S_7 S7=G
把所有的已知条件凝练为上面的符号之后,借助最优子结构,就可以把问题解决了
最有子结构的含义是,原问题的最优解所包括的子问题的解也是最优的解释为:
解释为:如果A->…-> F 1 F_1 F1->G是全局A到G最优的路径,那么此处A->…-> F 1 F_1 F1也是A到 F 1 F_1 F1的最优路径
此时的优化目标min V k , 6 ( S 1 = A , S 7 = G ) V_k,_6 (S_1=A,S_7=G) Vk,6(S1=A,S7=G),等价于min{ V k , 6 ( S 1 = A , S 6 = F 1 ) + 4 , V k , 6 ( S 1 = A , S 6 = F 2 ) + 3 V_k,_6(S_1=A,S_6=F_1)+4, V_k,_6(S_1=A,S_6=F_2)+3 Vk,6(S1=A,S6=F1)+4,Vk,6(S1=A,S6=F2)+3}
优化目标的含义为:从A到G的最短路径,是A到F1到G的路径和A到 F 2 F_2 F2到G的路径中更短的那一个
对于 V k , 6 ( S 1 = A , S 6 = F 1 ) V_k,_6(S_1=A, S_6=F_1) Vk,6(S1=A,S6=F1)和 V k , 6 ( S 1 = F 1 , S 6 = F 2 ) V_k,_6(S_1=F_1,S_6=F_2) Vk,6(S1=F1,S6=F2),任然可以递归地使用上面的分析方法
最终输出路径为A->B1->C2-D1->E2->F2->G,
最短距离为19
对于输入的图,可以采用一个m*m的二位数组来保存
在这个二位数组里,m等于全部的节点数,也就是节点与节点的关系图
数组每个元素的数值,定义为节点到节点需要的距离
代码如下:
function process1(arr, i){
if(i==0) {
return 0
}else{
let distance = 999;
for(let j=0; j<i;j++){
if(arr[j][i] != -1) {
let d_tmp = arr[j][i] + process1(arr, j);
if(d_tmp <distance){
distance = d_tmp;
}
}
}
return distance
}
}
假设有且仅有1个最大公共子串。比如,输入a=‘13452439’,b=“123456”
由于字符串“345”同时在a和b中出现,且是同时出现在a和b中的最长字串,因此输出"345"
重学数据结构与算法,未完待续…
如果您觉得本文有不对的地方,请在评论指正;如果您觉得本文还不错,记得点赞和收藏呦~