从广义上来讲:数据结构就是一组数据的存储结构
, 算法就是操作数据的方法
数据结构是为算法服务的,算法是要作用在特定的数据结构上的。
10个最常用的数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树
10个最常用的算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法
本文总结了20个最常用的数据结构和算法,不管是应付面试还是工作需要,只要集中精力攻克这20个知识点就足够了。
第一章 复杂度
一、什么是复杂度分析?
-
- 数据结构和算法 为了解决 “如何让计算机更快时间、更省空间”问题的。
-
- 因此需从执行时间和占用空间两个维度来评估数据结构和算法的性能。
-
- 分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度。
-
- 复杂度描述的是算法执行时间(或占用空间)与数据规模的增长关系。
二、为什么要进行复杂度分析?
-
- 和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操作、指导性强的特点。
-
- 掌握复杂度分析,将能编写出性能更优的代码,有利于降低系统开发和维护成本。
三、如何进行复杂度分析?
-
- 大O表示,O表示成正比关系
- (1)来源
算法的执行时间与每行代码的执行次数成正比,用T(n) = O(f(n))表示,其中T(n)表示算法执行总时间,f(n)表示每行代码执行总次数,而n往往表示数据的规模。
- (2)特点
以时间复杂度为例,由于时间复杂度描述的是算法执行时间与数据规模的增长变化趋势,所以常量阶、低阶以及系数实际上对这种增长趋势不产决定性影响,所以在做时间复杂度分析时忽略这些项。
-
- 复杂度分析法则
-
(1)单段代码看高频:比如循环。
-
(2)多段代码取最大:比如一段代码中有单循环和多重循环,那么取多重循环的复杂度。
-
(3)嵌套代码求乘积:比如递归、多重循环等
-
(4)多个规模求加法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相加。
四、常用的复杂度级别?
-
- 多项式阶:随着数据规模的增长,算法的执行时间和空间占用按照多项式的比例增长。包括,
O(1)(常数阶) 、 O(logn)(对数阶)、 O(n)(线性阶)、 O(nlogn)(线性对数阶)、 O(n^2)(平方阶) 、 O(n^3)(立方阶)
- 多项式阶:随着数据规模的增长,算法的执行时间和空间占用按照多项式的比例增长。包括,
-
- 非多项式阶:随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,
O(2^n)(指数阶)、O(n!)(阶乘阶)
- 非多项式阶:随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,
五、如何掌握好复杂度分析方法?
复杂度分析关键在于多练,所谓孰能生巧。
第二章 数组
数组看起来很简单,但是很多人没有理解数组的精髓。为什么数组要从0开始编号,而不是从1开始呢? 带着这个问题,开始学习。
一、数组如何实现随机访问的?
-
- 数组是一种线性表数据结构,用连续的存储空间存储相同类型数据。
-
- 线性表,顾名思义,就是数据排成像一条线一样的结构,每个线性表上的数据最多只有两个方向:向前和向。
-
3 .常见的线性表结构有:数组、链表、队列、栈 ,非线性表有:树、图。
-
- 数据拥有连续的内存空间,并且存储相同类型的数据,所以我们知道首地址和每个元素的大小,就可以很轻易的访问到数组中的每一个元素,所以利用下标访问数组的时间复杂度为O(1),也就是说利用下标的话,数组可以做到随机访问。
数组中 i 元素的地址 = 首地址 + i * 每个元素的大小 (如果数组中存放的是Int类型的,那么data_type_size就是4个字节)
a[i]_address = base_address + i * data_type_size
-
- 对数组进行删除插入操作时,为了保证数组内存的连续性,就要做大量的数据搬移工作。(例如:删除第一个元素,就得把后面的元素都往前移动一位,保证内存的连续性)
二、 数组的插入和删除
-
- 数组插入:最好时间复杂度O(1)、 最坏时间复杂度O(n)、 平均时间复杂度O(n)
- 优化办法:如果数组是无序的话,在第K个位置插入新的元素时,可以将第K个位置元素移动到数组末尾,然后再把新的元素插入到第k个位置,这样处理的话时间复杂度就变成O(1)了。
-
- 数组删除:最好时间复杂度为O(1)、 最坏为O(n)、 平均为O(n)
- 优化办法:多次删除集中在一起,提高删除效率。记录下已经被删除的数据,每次的删除操作并不是真正的删除数据,只是对被删除的数据做一个标记,当数组没有更多的存储空间时,再触发一次真正的删除操作。(这就是JVM标记清除垃圾回收算法的核心思路)
三、 那么数组的下标为什么从0开始呢?
-
- 从下方的内存地址公式可以看出,下标从0开始比下标从1开始,少了一步减法运算,数组作为最基础的数据结构,使用非常多,为了追求极致的效率,选择了下标从0开始。
下标从0开始,则a[k]的内存地址为:
a[k]_address = base_address + k * type_size
下标从1开始,则a[k]的内存地址为:
a[k]_address = base_address + (k - 1) * type_size
-
- 其实主要原因还是历史原因,很多高级语言都效仿C,为了减少学习成本,沿用了下来。
第三章 链表
一、 链表是什么
-
- 链表也是一种线性表数据结构,不像数组一样需要连续的内存空间,链表通过指针将一组零碎的内存块串联起来,我们把内存块称为链表的结点,为了把所有的结点串联起来,每个链表的结点除了存储数据外,还需要记录链表的下一个结点的地址,我们把这个记录下一个结点的地址的指针称为后继指针。如图所示:
-
- 我们把第一个结点称之为头结点,把最后一个结点称之为尾结点,头结点用来记录链表的基地址,单向链表的尾结点的指针指向NULL
-
- 常见的链表结构:单向链表(后继指针指向下一个节点,尾结点的指向NULL)、循环链表(尾结点的后继指针指向头结点的数据,形成一个环)、双向链表(多了一个前驱指针指向前边的节点)、双向循环链表(多前驱指针和尾结点指向头结点)
二、 链表的插入、删除、访问
-
- 链表是零散的内存块,插入和删除的时间复杂度都是O(1)
-
- 链表的访问:时间复杂度为O(n),有利就有弊,链表的访问就没法像数组那么高效了,链表的访问需要根据指针一个节点一个节点的去遍历,所以需要O(n)的时间复杂度。
三、 链表与数组的对比
-
- 内存:数组是连续的内存空间,链表是零碎的内存空间;分配同样大小的内存,数组就有可能报“内存不足”的错误,而链表就有可能成功申请到。(因为数组要求必须连续的内存空间,系统可能没有足够的连续内存空间了)
-
- 插入、删除、随机访问的时间复杂度
-
链表是零散的内存块,插入和删除的时间复杂度是O(1),查找的时间复杂度是O(n)
-
数组是连续的内存空间,插入和删除的时间复杂度O(n),按下标查找的时间复杂度是O(1)
-
- 实际工作中如何选取?
-
如果对内存要求非常苛刻,数组适合你,因为链表的每个节点都需要耗费额外的空间去存放下一个节点的指针,而且对链表频繁的插入、删除,会导致频繁的内存申请和释放,容易造成内存碎片
-
数组连续的内存空间,借助CPU缓存机制可以预读数据,而链表因为内存的不连续性,所以对CPU缓存不友好,没办法有效预读
-
数组的缺点是大小固定,一经声明就要占用整块连续的内存空间,可能导致系统没有足够大的空间分配给它,从而导致“内存不足”的错误出现。如果数组过小不够用时,就只能申请一个更大空间的数组,把原有的数组拷贝进去,非常费时。而链表就没有这个限制,链表天然支持扩容。
四、 如何基于链表实现LRU缓存淘汰算法?
-
- 缓存可以提高数据读取性能,但是当缓存满了之后,如何清理缓存呢?有三种策略:先进先出策略 FIFO、最少使用策略LFU、最近最少使用策略LRU(核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”,即删除长期不用的)
-
- LRU缓存淘汰算法的实现
-
方法一:LRU缓存淘汰算法-链表实现:
-
- 如果缓存中已存在,将链表中的此元素删除,并且将新数据放入头结点
-
- 如果不在缓存中:
-
(1). 缓存未满,直接插入到头结点
-
(2). 缓存已满,删除尾结点,将新数据插入到头结点
-
总时间复杂度:O(n) , 因为查找是否在缓存中需要按个判断
-
-
方法二:LRU缓存淘汰算法-数组实现:
-
1.如果数组中已存在此数据,删除数组中的此数据,并将新数据放入数组尾部,时间复杂度为O(n)
-
2.如果不在缓存中:
-
(1). 缓存未满,插入数组尾部,时间复杂度O(1)
-
(2). 缓存已满,删除数组头元素,并将新数据插入到数组尾部,时间复杂度O(n)
-
-
总时间复杂度:O(n)
-
五、 字符串用单向链表存储,如果判断此字符串是一个回文串呢?(例如:level 返过来也是 level)
-
- 设定一个步长为1的慢指针,和一个步长为2的快指针,当快指针走完整个链表时,慢指针刚好走到一半
-
- 慢指针边走边将后继指针指向前一个节点,造成前半个链表反序
-
- 当慢指针指向中间时,新设定初始化一个指针,指向前半个反序链表的第一个,然逐个与慢指针的数据做比较,如果完全一致,则为回文串,否则不是
-
- 做完对比之后,将前半个反序的链表还原
总时间复杂度:O(n)
总空间复杂度:O(1)
六、 练习题
精选了5个常见的链表操作,你只要把这5个练熟,我保证你以后再也不会害怕写链表的代码了。
- 单链表反转
- 链表中环的检测
- 两个有序链表合并
- 删除链表倒数第n个结点
- 求链表的中间结点
- 练习题LeetCode对应编号:206,141,21,19,876,大家可以去练习。
第四章 栈
一、 什么是栈
-
- 栈:后进者先出,先进者后出,这就是典型的栈结构。举个例子:就像叠盘子一样,后来放的盘子总是在上面,拿的时候也是从上面拿,也就是先拿后来放上面的盘子,最后拿最早放的盘子。
-
- 栈是一种受限制的线性表,只允许在一端插入和删除数据。相比于数组和链表,栈带给我们的只有限制,为什么我们要用这个操作受限的栈呢?
- 事实上,从功能上来说,数组或者链表确实可以代替栈,但你要知道,特定的数据结构对特定的场景的抽象,数组和链表暴露了太多的操作接口,操作上确实灵活,但是使用时就比较不可控了,自然也就更容易出错。
- 当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选栈这种数据结构。
- 栈是一种受限制的线性表,只允许在一端插入和删除数据。相比于数组和链表,栈带给我们的只有限制,为什么我们要用这个操作受限的栈呢?
-
- 栈既可以用数组实现,也可以用链表实现,用数组实现的栈,叫做顺序栈;用链表实现的栈,叫做链式栈。
// 一个用数组实现的简单顺序栈
public class ArrayStack{
var stack: Array = Array<Any>()
// 入栈
func push(element: Any){
stack.append(element)
}
// 出栈
func pop() -> Any?{
guard stack.count > 0 else{ return nil }
let lastElement = stack.last
stack.removeLast()
return lastElement
}
}
二、 栈的经典使用场景 - 系统调用栈
- 操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时产生的临时变量,每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完毕后返回后,将这个函数对应的栈帧出栈。
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
上述代码的函数调用栈,如下图所示:
三、 栈的经典使用场景 - 表达式求值 - 也就是如何计算:A*B+C-D/E ?
-
编译器通过两个栈实现的这个功能的,一个栈保存操作数,另一个栈保存运算符,我们从左到右遍历表达式,当遇到数字时,将其压入操作数栈;当遇到运算符时,就与运算符栈的栈顶元素进行比较。
-
如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈的栈顶元素的优先级低或相同,那么就从操作数栈的栈顶取2个操作数,然后进行计算,再把计算完成的结果压入操作数栈,继续比较。
-
下图为3+5*8-6这个表达式的计算过程,可以参照图来理解
四、 栈的经典使用场景 - 如何实现浏览器的前进、后退功能?
-
使用两个栈X和Y,首次浏览的界面入X栈。
-
后退时,依次从X中出栈,并将出栈的数据依次放入Y栈;前进时,从Y栈中出栈,在依次入X栈。
-
当X栈中没有数据时,说明不能后退了;当Y栈中没有数据时,说明不能前进了。
第五章 队列
一、什么是队列
-
- 队列:先进者先出,这就是典型的队列。队列有两个基本的操作:入队(enqueue)和出队(dequeue),入队就是把数据放在队列尾部,出队就是把队列头部取一个元素。
-
- 队列和栈一样也是操作受限的线性表结构,和栈一样,队列可以用数组实现,也可以用链表实现,用数组实现的队列叫做顺序队列,用链表实现的队列叫做链式队列。
二、 队列的插入和删除
-
- 队列从队头移除元素,从队尾插入元素,大家想象一下,有一个顺序队列,让一个head指针指向队头,让一个tail指向队尾。对这个顺序队列不断的进行插入和删除操作,head和tail指针都会持续往后移动,当tail移动到队尾时,即便数组中有空闲空间,也无法继续往队列中添加数据了。
-
- 对于这个问题应该怎么解决呢?可以进行数据搬移操作,当tail指针指向队尾时,这时的入队操作需要将所有数据搬移到队头开始的地方,这样就轻松解决这个问题了,但是这并不是最佳解决方案,最佳解决方案是循环队列。
顺序队列的数据搬移
三、循环队列
-
循环队列,顾名思义,它长得像一个环,原本数组是有头有尾的,是一条直线,现在我们把首尾相连,变成一个环形,如下图所示:
-
循环队列的插入和删除:我们让head指针继续指向队头,而tail指针需要指向队尾的后面一个位置,每次在队尾插入,都让tail后移一位,直到tail指向head前边的元素,说明队满了。(为什么是tail指向head前边的元素代表队满呢?而不是tail指向head代表队满呢?如果tail == head,即代表队满,又代表队空,那程序就没法写了)
-
循环队列队空的条件是:tail == head
-
循环队列队满的条件是:(tail + 1) % n = head (队满的时候,空了一个内存空间)
四、阻塞队列和并发队列
-
- 阻塞队列其实就是在队列的基础上增加了阻塞操作,简单来说,就是当队头是空的时候,从队头取元素会被阻塞,因为队列是空的没有数据可取;当队列已经满了的时候,插入操作会被阻塞,直到队列中有空闲位置了,才能继续插入操作。
-
2.使用阻塞队列,很容易就可以实现一个 “生产者-消费者模型”,可以有效的调度生产和消费的关系。当生产者生产速度过快时,消费者来不及消费,存储数据的队列很快就满了,这个时候生产者就阻塞等待,直到消费者消费了数据,生产者才会被重新唤醒,继续生产。
-
- 我们还可以协调消费者和生产者的个数,提高数据的处理效率,例如:配置多个消费者来应该一个生产者,如下图:
-
- 并发队列,也可以称为线程安全的队列,同一时刻仅允许一个存或取操作,最简单的实现方式就是直接在enqueue()和dequeue()方法上加锁。
-
- 基于数组的循环队列,利用CAS原子操作,可以实现非常高效并发队列。这也是循环队列比链式队列应用更加广泛的原因。
五、课后题:写出循环队列代码