目录
一、数据结构与算法概述
1.数据结构的概念
2.算法的概念
3.算法的复杂度
(1)大O复杂度表示法
(2)时间复杂度
(3)空间复杂度
二、数据结构与算法基础
1.线性表
(1)数组
(2)链表
(3)栈
(4)队列
2.散列表
3.递归算法
4.二分查找法
精选面试题
1.如何快速定位出一个 IP 地址的归属地?
一、数据结构与算法概述
1.数据结构的概念
数据结构(data structure)是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定 关系的数据元素的集合,简而言之:是存数据的,而且是在内存中存!
常见的数据结构:数组、链表、栈、队列、散列表、二叉树、堆、、图等
2.算法的概念
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用 系统的方法描述解决问题的策略机制。比如:LRU算法,最近最少使用,解决的就是当空间不够用时,应该淘汰谁的问题,这是一种策略,不 是唯一的答案,所以算法无对错,只有好和不好。
简而言之:算法是一种解决特定问题的思路。它是“操作数据的一组方法”。数据结构是为算法服务的,算法是要作用再特定的数据结构上的。
常见的算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法等
3.算法的复杂度
(1)大O复杂度表示法
我们假设执行一行代码的时间为t,通过估算,代码的执行时间T(n)与执行次数成正比,记做:T(n)=O(f(n))
复杂度分析法则
1)单段代码看高频:比如循环。
2)多段代码取最大:比如一段代码中有单循环和多重循环,那么取多重循环的复杂度。
3)嵌套代码求乘积:比如递归、多重循环等
4)多个规模求加法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相加。
(2)时间复杂度
时间复杂度分析技巧
- 只关注循环执行次数最多的一段代码
- 加法法则:总复杂度等于量级最大的那段代码的复杂度
- 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
几种常见时间复杂度实例分析
- O(1)
常量级时间复杂度,只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1),在实际应用中,通常使用冗余字段存储来将O(n)变成O(1),比如Redis中有很多这样的操作用来提 升访问性能 比如:SDS、字典、跳跃表等。
- O(logn)、O(nlogn)
O(logn):当数据增大n倍时,耗时增大logn倍(这里的log是以2为底的,比如,当数据增大256倍时,耗时只增大8倍,是比线性还要低的时间复杂度)。二分查找就是O(logn)的算法,每找一次排除一半的可能,256个数据中查找只要找8次就可以找到目标。
i = 1;
while(i <= n){
i = i * 2;// 执行最多
}
由2^X =n 得出 x=log2n,所以,这段代码的时间复杂度就是 O(log2n),忽略系数为logn。
O(nlogn):就是n乘以logn,当数据增大256倍时,耗时增大256*8=2048倍。这个复杂度高于线性低于平方。
T(n)=O(logn)如果将该代码执行n遍,则时间复杂度记录为:T(n)=O(n*logn),即O(nlogn),快速排序、归并排序的时间复杂度都是O(nlogn)
- O(n)
代表数据量增大几倍,耗时也增大几倍。很多线性表的操作都是O(n),这也是最常见的一个时间复杂度。比如:数组的插入删除、链表的遍历等
- O(m+n)
代码的时间复杂度由两个数据的规模来决定
int sum(int m,int n){
int s1=0;
int s2=0;
int i=1;
int j=1;
for(;i<=m;i++){
s1=s1+i; // 执行最多
}
for(;j<=n;j++){
s2=s2+j; //执行最多
}
return s1+s2;
}
m和n是代码的两个数据规模,而且不能确定谁更大,此时代码的复杂度为两段时间复杂度之和, 即 T(n)=O(m+n),记作:O(m+n)
- O(m*n)
int sum(int m,int n){
int s=0;
int i=1;
int j=1;
for(;i<=m;i++){// m
j=1;
for(;j<=n;j++){ //m*n
s=s+i+j; //m*n
}
}
return s;
}
根据乘法法则代码的复杂度为两段时间复杂度之积,即 T(n)=O(m*n),记作:O(m*n), 当m==n时,为O( n^2)
(3)空间复杂度
空间复杂度全称是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。
比如将一个数组拷贝到另一个数组中,就是相当于空间扩大了一倍:T(n)=O(2n),忽略系数。即为:O(n),这是一个非常常见的空间复杂度,比如跳跃表、hashmap的扩容此外还有:O(1),比如原地排序、O(n^2 ) 此种占用空间过大。由于现在硬件相对比较便宜,所以在开发中常常会利用空间来换时间,比如缓存技术 典型的数据结构中空间换时间是:跳跃表 在实际开发中我们也更关注代码的时间复杂度,而用于执行效率的提升
二、数据结构与算法基础
1.线性表
线性表(Linear List)就是数据排成像一条线一样的结构,数据只有前后两个方向
(1)数组
概念:数组(Array)是有限个相同类型的变量所组成的有序集合,数组中的每一个变量被称为元素。数组是最为简单、最为常用的数据结构。
存储原理:数组用一组连续的内存空间来存储一组具有相同类型的数据
时间复杂度:读取和更新都是随机访问,所以是O(1) 插入数组扩容的时间复杂度是O(n),插入并移动元素的时间复杂度也是O(n),综合起来插入操作的时间 复杂度是O(n)。 删除操作,只涉及元素的移动,时间复杂度也是O(n)
优缺点
- 优点:数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素
- 缺点:插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。 (ArrayList LinkedList ) 申请的空间必须是连续的,也就是说即使有空间也可能因为没有足够的连续空间而创建失败 如果超出范围,需要重新申请内存进行存储,原空间就浪费了
应用:
数组是基础的数据结构,应用太广泛了,ArrayList、Redis、消息队列等等。
(2)链表
概念:链表(linked list)是一种在物理上非连续、非顺序的数据结构,由若干节点(node)所组成。
- 从内存结构来看,链表的内存结构是不连续的内存空间,是将一组零散的内存块串联起来,从而进行数据存储的数据结构。
- 链表中的每一个内存块被称为节点Node。节点除了存储数据外,还需记录链上下一个节点的地址,即后继指针next。
常见的链表包括:单链表、双向链表、循环链表
- 单链表
单向链表的每一个节点又包含两部分,一部分是存放数据的变量data,另一部分是指向下一个节 点的指针next
1)每个节点只包含一个指针,即后继指针。
2)单链表有两个特殊的节点,即首节点和尾节点。为什么特殊?用首节点地址表示整条链表,尾节点的后继指针指向空地址null。
3)性能特点:插入和删除节点的时间复杂度为O(1),查找的时间复杂度为O(n)。
- 双向链表
双向链表的每一个节点除了拥有data和next指针,还拥有指向前置节点的prev指针。
1)节点除了存储数据外,还有两个指针分别指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)。
2)首节点的前驱指针prev和尾节点的后继指针均指向空地址。
3)性能特点:和单链表相比,存储相同的数据,需要消耗更多的存储空间。插入、删除操作比单链表效率更高O(1)级别
- 循环链表
链表的尾节点指向头节点形成一个环,称为循环链表
1)除了尾节点的后继指针指向首节点的地址外均与单链表一致。
2)适用于存储有循环特点的数据,比如约瑟夫问题。
存储原理:链表在内存中的存储方式则是随机存储(链式存储)。 链表的每一个节点分布在内存的不同位置,依靠next指针关联起来。这样可以灵活有效地利用零散的碎片空间。
时间复杂度:查找节点 : O(n) ;插入节点:O(1) ;更新节点:O(1); 删除节点:O(1)
优缺点:
- 优点:插入、删除、更新效率高,省空间
- 缺点:查询效率较低,不能随机访问
应用
链表的应用也非常广泛,比如树、图、Redis的列表、LRU算法实现、消息队列等
数组与链表的对比
数据结构没有绝对的好与坏,数组和链表各有千秋。
数组的优势在于能够快速定位元素,对于读操作多、写操作少的场景来说,用数组更合适一些
链表的优势在于能够灵活地进行插入和删除操作,如果需要在尾部频繁插入、删除元素,用链表更合适一些
数组和链表是线性数据存储的物理存储结构:即顺序存储和链式存储。
(3)栈
概念:栈(stack)是一种线性数据结构,栈中的元素只能先入后出(First In Last Out,简称FILO)。 最早进入的元素存放的位置叫作栈底(bottom),最后进入的元素存放的位置叫作栈顶 (top)。
存储原理:栈既可以用数组来实现,也可以用链表来实现
数组实现的栈也叫顺序栈或静态栈,栈的数组实现如下:
链表实现的栈也叫做链式栈或动态栈,栈的链表实现如下:
时间复杂度:入栈和出栈的时间复杂度都是O(1) 支持动态扩容的顺序栈
当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中数据统统拷贝过去。这样就实现了 一个支持动态扩容的数组,通过前面学过的知识,可以得知入栈的时间复杂度是O(n)
应用
- 函数调用 每进入一个函数,就会将临时变量作为一个栈入栈,当被调用函数执行完成,返回之后,将这个函 数对应的栈帧出栈
- 浏览器的后退功能 我们使用两个栈,X 和 Y,我们把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。当我们点击前进按钮时,我们依次从栈 Y 中取出数据, 放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数 据,那就说明没有页面可以点击前进按钮浏览了
(4)队列
概念:队列(queue)是一种线性数据结构,队列中的元素只能先入先出(First In First Out,简称 FIFO)。 队列的出口端叫作队头(front),队列的入口端叫作队尾(rear)。
存储原理:队列这种数据结构既可以用数组来实现,也可以用链表来实现
- 数组实现
用数组实现时,为了入队操作的方便,把队尾位置规定为最后入队元素的下一个位置 用数组实现的队列叫作顺序队列
- 链表实现
用链表实现的队列叫作链式队列
时间复杂度:入队和出队都是O(1)
应用:资源池、消息队列、命令队列等等
2.散列表
概念:散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value)的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
存储原理
散列表在本质上也是一个数组。散列表的Key则是以字符串类型为主的, 通过hash函数把Key和数组下标进行转换 作用是把任意长度的输入通过散列算法转换成固定类型、固定长度的散列值。
操作
- 写操作(put):写操作就是在散列表中插入新的键值对(在JDK中叫作Entry或Node)
第1步,通过哈希函数,把Key转化成数组下标; 第2步,如果数组下标对应的位置没有元素,就把这个Entry填充到数组下标的位置。
- Hash冲突(碰撞):由于数组的长度是有限的,当插入的Entry越来越多时,不同的Key通过哈希函数获得的下标有可能是相同的,这种情况,就叫作哈希冲突。
解决哈希冲突的方法主要有两种:
①开放寻址法
在Java中,ThreadLocal所使用的就是开放寻址法,开放寻址法的原理是当一个Key通过哈希函数获得对应的数组下标已被占用时,就寻找下一个空档位置。
②链表法
数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针 指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链 表中即可,默认next指向null
在Entry中保存key和值,以及next指针,当根据key查找值的时候,在index=2的位置是一个单链表 遍历该单链表,再根据key即可取值
- 读操作(get):读操作就是通过给定的Key,在散列表中查找对应的Value
第1步,通过哈希函数,把Key转化成数组下标 第2步,找到数组下标所对应的元素,如果key不正确,说明产生了hash冲突,则顺着头节点遍历该单链表,再根据key即可取值
- Hash扩容(resize):散列表是基于数组实现的,所以散列表需要扩容
当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响
影响扩容的因素有两个:Capacity:HashMap的当前长度;LoadFactor:HashMap的负载因子(阈值),默认值为0.75f,当HashMap.Size >= Capacity×LoadFactor时,需要进行扩容
扩容的步骤:
1. 扩容,创建一个新的Entry空数组,长度是原数组的2倍
2. 重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中
关于HashMap的实现,JDK 8和以前的版本有着很大的不同。当多个Entry被Hash到同一个数组下标位置时,为了提升插入和查找的效率,HashMap会把Entry的链表转化为红黑树这种数据结构。 JDK1.8前在HashMap扩容时,会反序单链表,这样在高并发时会有死循环的可能
时间复杂度
- 写操作: O(1) + O(m) = O(m) m为单链元素个数
- 读操作:O(1) + O(m) m为单链元素个数
- Hash冲突写单链表:O(m)
- Hash扩容:O(n) n是数组元素个数 rehash
- Hash冲突读单链表:O(m) m为单链元素个数
优缺点
优点:读写快
缺点:哈希表中的元素是没有被排序的、Hash冲突、扩容 重新计算
应用
HashMap:JDK1.7中HashMap使用一个table数组来存储数据,用key的hashcode取模来决定key会被放到数组里 的位置,如果hashcode相同,或者hashcode取模后的结果相同,那么这些key会被定位到Entry数组的 同一个格子里,这些key会形成一个链表,在极端情况下比如说所有key的hashcode都相同,将会导致 这个链表会很长,那么put/get操作需要遍历整个链表,那么最差情况下时间复杂度变为O(n)。
扩容死链 针对JDK1.7中的这个性能缺陷,JDK1.8中的table数组中可能存放的是链表结构,也可能存放的是红黑树结构,如果链表中节点数量不超过8个则使用链表存储,超过8个会调用treeifyBin函数,将链表转换 为红黑树。那么即使所有key的hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要 O(logn)的开销。
字典:Redis字典dict又称散列表(hash),是用来存储键值对的一种数据结构。 Redis整个数据库是用字典来存储的。(K-V结构) 对Redis进行CURD操作其实就是对字典中的数据进行CURD操作。 Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)。
布隆过滤器:布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。
布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K 个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如 果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的 基本思想。
位图:Bitmap 的基本原理就是用一个 bit 来标记某个元素对应的 Value,而 Key 即是该元素。由于采用一个 bit 来存储一个数据,因此可以大大的节省空间。
Java 中 int 类型占用 4 个字节,即 4 byte,又 1 byte = 8 bit,所以 一个 int 数字的表示大概如下,
试想以下,如果有一个很大的 int 数组,如 10000000,数组中每一个数值都要占用 4 个字节,则一共 需要占用 10000000 * 4 = 40000000 个字节,即 40000000 / 1024.0 / 1024.0 = 38 M。如果使用 bit 来存放上述 10000000 个元素,只需要 10000000 个 bit 即可, 10000000 / 8.0 / 1024.0 / 1024.0 = 1.19 M 左右,可以看到 bitmap 可以大大的节约内存。
使用 bit 来表示数组 [1, 2, 5] 如下所示,可以看到只用 1 字节即可表示:
3.递归算法
概念:递归,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。也就是说,递归算法是一种 直接或者间接调用自身函数或者方法的算法。
递归三要素:
递归结束条件:既然是循环就必须要有结束,不结束就会OOM了
函数的功能:这个函数要干什么,打印,计算....
函数的等价关系式:递归公式,一般是每次执行之间,或者与个数之间的逻辑关系
例如
//循环实现
public static void print(String ss) {
for (int i = 1; i <= 5; i++) {
System.out.println(ss);
}
}
//递归实现
public static void print(String ss, int n) {
//递归条件
if (n > 0) {
//函数的功能
System.out.println(ss);
//函数的等价关系式
print(ss, n - 1);
}
}
public static void main(String[] args) {
//调用循环
print("Hello World");
System.out.println("======================");
//调用递归
print("Hello World", 5);
}
递归要素:
- 递归结束条件:n<=0
- 函数的功能:System.out.println(ss);
- 函数的等价关系式:fun(n)=fun(n-1)
经典案例
斐波那契数列:0、1、1、2、3、5、8、13、21、34、55.....(规律:从第3个数开始,每个数等于前面两个数的和)
递归分析:
函数的功能:返回n的前两个数的和 ;递归结束条件:从第三个数开始,n<=2 ;函数的等价关系式:fun(n)=fun(n-1)+fun(n-2)
//递归实现
public static int fun2(int n) {
if (n <= 1) return n;
return fun2(n - 1) + fun2(n - 2);
}
public static void main(String[] args) {
fun2(9);
System.out.println("==================================");
System.out.println(fun2(9));
}
时间复杂度
斐波那契数列 普通递归解法为O(2^n)
优缺点
优点:代码简单
缺点:占用空间较大、如果递归太深,可能会发生栈溢出、可能会有重复计算 通过备忘录或递归的方式 去优化(动态规划)
应用
递归作为基础算法,应用非常广泛,比如在二分查找、快速排序、归并排序、树的遍历上都有使用递归 回溯算法、分治算法、动态规划中也大量使用递归算法实现
4.二分查找法
概念:二分查找(Binary Search)算法,也叫折半查找算法;当我们要从一个序列中查找一个元素的时候,二分查找是一种非常快速的查找算法 。二分查找是针对有序数据集合的查找算法,如果是无序数据集合就遍历查找
二分查找之所以快速,是因为它在匹配不成功的时候,每次都能排除剩余元素中一半的元素。因此可能包含目标元素的有效范围就收缩得很快,而不像顺序查找那样,每次仅能排除一个元素。
经典案例
一个有序数组有一个数出现1次,其他数出现2次,找出出现一次的数 比如:1 1 2 2 3 3 4 4 5 5 6 6 7 出现1次的数是7
暴力: O(n); hash: O(n);关键:时间复杂度 O(logn)
思路:使用二分查找: 1 有序、 2、时间复杂度 O(logn)
偶数位索引跟后面的比相同,奇数位索引跟前面的比相同 则说明前面的都对;
偶数位索引跟前面的比相同,奇数位索引跟后面的比相同 则说明后面的都对
public static int binarySearch(int[] nums) {
//低位索引
int low = 0;
//高位索引
int high = nums.length - 1;
//中间索引
int mid = 0;
while (low <= high) {
mid = (low + high) / 2;
//偶数位
if (mid % 2 == 0) {
// 与后面的数相等
if (nums[mid] == nums[mid + 1]) {
//前面的都对
low = mid + 1;
}
// 与前面的数相等
else if(nums[mid] == nums[mid - 1]) {
//后面的都对
high = mid-1 ;
}
// 就是这个数
else{
return nums[mid];
}
}
//奇数位
else {
// 与前面的数相等
if (nums[mid] == nums[mid - 1]) {
//前面的都对
low = mid + 1;
}
//与后面的数相等
else if(nums[mid] == nums[mid + 1]){
//后面的都对
high = mid-1;
}
// 就是这个数
else{
return nums[mid];
}
}
}
//low=high
return nums[low];
}
public static void main(String[] args) {
int[] nums={1,2,2,3,3,4,4,5,5};
System.out.println(binarySearch(nums));
}
时间复杂度
时间复杂度就是 O(logn)
优缺点
优点:速度快,不占空间,不开辟新空间
缺点:必须是有序的数组,数据量太小没有意义,但数据量也不能太大,因为数组要占用连续的空间
应用
有序的查找都可以使用二分法。
1.如何快速定位出一个 IP 地址的归属地?
如果 IP 区间与归属地的对应关系不经常更新,我们可以先预处理这 12 万条数据,让其按照起始 IP 从 小到大排序。如何来排序呢?我们知道,IP 地址可以转化为 32 位的整型数。所以,我们可以将起始地 址,按照对应的整型值的大小关系,从小到大进行排序。 当我们要查询某个 IP 归属地时,我们可以先通过二分查找,找到最后一个起始 IP 小于等于这个 IP 的 IP 区间,然后,检查这个 IP 是否在这个 IP 区间内,如果在,我们就取出对应的归属地显示;如果不 在,就返回未查找到
附数据结构与算法思维导图