【整理】盘点程序员必知必会的常见数据结构和算法
引子
数据结构和算法是软件的基石。
数据结构和算法的作用究竟是什么呢?有多重要?
假设你要运送 100 个苹果到码头,那么,你是用手一个一个把苹果拿过去呢 ?还是用几个箱子把苹果先放进去,再把箱子运送到码头呢?大概率你会选择后者吧。那么,数据结构就好比这几个箱子,可以容纳更多的数据,批量处理数据,算法就好比运送方法,使得数据处理的效率更高。互联网的价值即在于提升效率。
数据结构和算法是软件的基石。软件是由组件组合而成,而组件的底层基本就是数据结构和算法。在数据结构和算法的基础上通过设计的封装、扩展和组合实现更完善的组件功能。比如 Guava cache, 底层是哈希表(数组+链表或红黑树),通过加锁保证线程安全,通过过期策略来保证缓存功能,再加上一些监控统计的功能。
本文就来盘点下,程序员必知必会的常见数据结构和算法。部分高级数据结构和算法可能不需要自己写,甚至不一定会用到,但了解更多的数据结构和算法,对于求解问题和扩展见识来说,总是多多益善。
数据结构
如何学习数据结构
学习一种数据结构,最基本的是掌握它的结构和操作。结构和操作决定特点,特点决定其优缺点,进而确定其应用场合。
如果能够使用某一门编程语言实现该数据结构,学习效果会更高。如果没有足够充裕的时间,至少可阅读优秀源码的实现。
实现数据结构和算法,对提升编程技艺大有裨益。
物理存储结构
数组和链表展示了数据结构所依赖的两种基本的物理存储顺序,在物理存储结构的基础上可以构造各种逻辑存储结构。比如队列,既可以用数组来实现,也可以用链表来实现。
数组
- 特点:长度固定,有限连续空间,元素邻近顺序放置。
- 优点:根据下标随机访问元素的效率高。
- 缺点:插入和删除的效率较低(因为需要移动大量元素)。
- 应用:(1)数组是那种你很容易忽视它的存在却无处不在的基础数据结构(贴心小棉袄)。主要是因为它的结构和操作简单,线性,符合人的心智模型;(2)有序数组通过二分查找可以提供高效查找(O(logn))。 B+树和跳表实质是用指针将多个有序数组连接起来,基于二分查找。可通过排序算法将数组变成有序数组(O(nlogn));(3)数组可作为位向量、字符串、可变列表、堆、工作队列、高级数据结构(比如哈希表)的底层支撑。
链表
- 特点:长度不固定,非连续空间,元素不是邻近顺序放置,而是通过地址引用来确定元素的前后顺序。
- 优点: 链表的插入和删除操作高效(O(1))。
- 缺点: 查询的效率不佳,因为需要从前到后依次遍历每个元素。
- 应用: 实现二叉树、红黑树、阻塞队列、条件队列、内存分配中的空闲块链表、系统启动任务序列。链表有单链表、双链表、跳表三种变体。链表算法题技巧:快指针与慢指针。链表是编程面试题的常考点。
逻辑数据结构
每一种逻辑数据结构,实际上定义的是操作接口及接口规格,而存储结构基于考虑数组、链表或者数组与链表的组合。
列表
- 特点:元素可重复,顺序存储、通过索引随机访问,但长度可变。底层实现是基于数组。通过数组扩容来实现长度可变。数组扩容时需要大量拷贝工作,因此在设置列表时选择合适的长度。
- 优点:根据下标随机访问元素的效率高(O(1))。
- 缺点:插入和删除的效率较低(O(n))。
- 应用:开发中最常用的数据结构之一。内存存储,常作为属于同一个类的多个实例或者实现同一接口的多个实例的存储。
映射
- 特点:由键和值组成对应关系的数据结构。底层是数组和链表(或红黑树)的组合。
- 优点:按键查找值高效(O(1) )。
- 应用:开发中最常用的数据结构之一。内存存储,常用于对象之间的关联。可用于实现缓存、Session会话、并发计数器等。
集合
- 特点:元素不重复。
- 优点:高效判断元素是否存在(O(1) )。
- 应用:元素去重、多重循环降重、动态维护数据、布隆过滤器。
JSON
- 特点:标量、列表和映射的任意嵌套的组合体。
- 优点:结构灵活,表达力强,可读性强。
- 应用:前后端、子系统之间的标准数据交换和数据通信。
栈
- 特点:将元素先压入后弹出。后进先出。
- 优点:栈顶插入或删除元素的操作高效(O(1)),操作和实现简单。
- 应用:常用于保存上下文语境,结合递归算法使用。可实现深度优先遍历、回溯算法、方法调用、配对括号的语法检查、表达式转换。
队列
特点:元素从队列一端入,从队列另一端出。
优点:出队和入队的操作高效(O(1))。
应用:基于实际场景,队列有多种变体。有循环队列、双端队列、单调队列、优先级队列、阻塞队列。
- 循环队列:当队列使用数组实现时,避免假溢出。可用于保证命令的按序执行。
- 单调队列:两端删除,一端插入。队列元素保持单调性(递增或递减)。用于求解滑动窗口的最值问题。
- 优先级队列:队首元素总是最大值或最小值。可使用堆实现。
- 阻塞队列:用于“生产者-消费者”模式、消息通道。
单调队列和优先级队列的相同点是队首都是最值。不同点在于:单调队列的所有元素都保持有序性,但优先级队列的元素并不是完全有序的;单调队列每次取区间的最大值,因此可以丢弃元素,但优先级队列中的元素都是需要处理的,不可丢弃。
堆
- 特点:任何一个父节点的值均不小于其子节点的值。根节点是最大值。可以构造基于数组的完全二叉树来实现。
- 优点:查找元素高效(O(1))。
- 应用:优先级队列、前K最大(小)值。
表
- 特点:一种由二维或多维数组构成的表格。可以通过索引或键来快速访问其中的元素。
- 优点:随机访问元素高效(O(1)),支持多维数据。
- 应用:用来存储常用的数据和程序。缓存行即是表的一种运用。可用于关系数据库的数据存储、路由器的路由表、编译器的符号表、中断向量表。
位向量
- 特点:由若干个位组成的长数组。
- 优点:节省空间。
- 应用:稠密无重复数的数组的排序、雪花算法、布隆过滤器。
二叉查找树
- 特点:左子树节点均小于根节点,右子树节点均大于根节点。
- 优点:高效查找元素(O(logn))。
- 应用:快速查找、排序。由于其不平衡时的最差效率,通常使用其变体红黑树。
SkipList
- 特点:有序链表,空间换时间策略。通过在每个节点上新增多级索引指向后继节点,实现跳跃查找。
- 优点: 平均查找效率高 O(logN) ,最坏 O(N) ;
- 应用:Redis 的有序集合键;排名算法;缓存设计;分布式系统的路由表和哈希环;基于路由的网络协议。
红黑树
- 特点:二叉查找树的改进版。在最坏情形下,二叉查找树变成了线性链表,查询效率为 O(n)。对二叉查找树进行扩展,可以得到 2-3 树,这种树在插入新的元素后自下而上生长,始终可以保持平衡性。但 2-3 树需要维护两种不同节点和多种操作情况,因此演变成红黑树。红黑树是特殊的二叉查找树,其红链接表示 3-节点,黑链接表示普通的 2-节点。
- 优点:平均和最差查找效果高(O(logn))。
- 红黑树应用:哈希表、排序树;Select-Epoll 机制中的监听文件描述符的就绪事件列表;CFS 的公平任务调度。
B+树
- 特点:多级有序数组的链表。每个节点都有多个元素,这些元素在同一层级都是有序的,不同层级的节点遵循二叉查找树的特性。
- 优点:元素高效查找(O(h)), h 是树的高度。
- 应用:数据库索引。
布隆过滤器
- 特点:一个很长的二进制向量和一系列哈希函数。使用多个哈希函数将一个值映射成多个哈希值,并投影到二进制向量上,并置为 1。如果指定 key 通过哈希函数映射到位图上,有一个位为 0, 则一定不存在这个 key 。
- 优点: 高效判断一个元素是否在一个集合中。可以检查值是“可能在集合中” 还是“绝对不在集合中”。
- 应用:网页 URL 去重、垃圾邮件识别、大集合中重复元素的判断、缓存穿透。
倒排索引
- 特点:键值结构。以关键字为键,以关键字所在文档及位置为值。涉及:(1)关键词分割;(2)关键词所在的文档及位置的存储;(3)词典索引构建与优化。使用到的技术:倒排索引 + 前缀树 + FST + 压缩数字列表
- 优点:文本查找高效。
- 应用: 文本检索。
DAG
- 特点:DAG(Directed Acyclic Graph),有向无环图。有拓扑排序、最小路径算法。
- 优点:无环图,结构灵活、表达力强,且比通用的图简单。
- 应用: 对象依赖图(GC算法)、组件依赖图和执行顺序(微服务治理)、多对象多模式匹配(RETE算法)、项目甘特图、区块链、Git分布式版本控制。
前缀树
- 特点:通过较少的空间存储大量字符串(节点只存储单个字符,复用了存储空间)。利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较。又称单词查找树或键树。
- 优点:节省空间。
- 应用:自动补全、拼写检查、字符串排序、前缀查找。
算法
算法策略
分治
没有分治解决不了的问题。如果有,那说明分解的还不够细。
分治是应对复杂问题的最基本最有效的策略。从水平分,可以把大数据集分解为多个子数据集分别处理再汇总;从垂直分,可以把一个复杂任务分解为多个相对容易的子任务分别求解再综合。
归并排序就是典型的分治求解。先将一个集合分成两个集合(可以按照不同方式切分,比如前后、奇偶),对两个集合分别排序,然后再将两个集合的排序结果做一个合并处理,即得到原集合的排序结果。
递归
递归是非常重要的一种程序化求解思维。
如果一个问题涉及多个子问题,而子问题和原问题可以使用相同的方法处理,就可以适用于递归思维。
举个简单的例子。 1+2+3+4+5 = 1+(2+3+4+5)= 1+ (2 + (3+4+5)) = 1+(2+(3+(4+5))) = 1+(2+(3+(4+(5))))。 1-5 的和等于 1-4 的和加上 5。
当整体结构与子结构具有相似性时,很适合用递归求解。比如二叉查找树的查询。当然,递归的效率较低,可转换为非递归的求解。
减治
减治是分治与递归结合的一种方法。假设要处理一个集合,且处理这个集合的子集与处理原集合的方法相同,则可以从这个集合拿掉若干个元素,然后先递归处理子集合,然后再将子集合的解与拿到元素进行合并处理得到原集合的解。
动态规划
先运用分治策略,将原问题分解成多个子问题,分别求解子问题然后综合。
关键在于,在求解过程中要将子问题的计算结果缓存起来复用,从而使得时间复杂度降低一个量级。要学会划分可复用的子问题,并理清楚子问题与原问题之间的关联。
空间换时间
由于时间非常宝贵,而存储空间较为廉价,往往会采用空间换时间的策略。
采用空间换时间策略的算法很多:动态规划、缓存、KMP。
预处理
通过预排序、预索引、拓扑结构等预处理将原数据集构造特定的数据结构,以高效支持后续的查找或操作,比如 KMP, RETE ,倒排索引,都运用了这种策略。预处理实际上是空间换时间策略的运用。
算法评价与选用
时间复杂度和空间复杂度。在空间存储充足的情况下,通常考虑时间复杂度。
当然,在移动端以及大数据存储方面,空间复杂度也需要仔细考虑。
具体算法
查找
- 主要有:有序查找、哈希查找。
- 有序查找:构建有序数据结构,比如有序数组、跳表、二叉查找树、红黑树,B+ 树,从而使用二分查找来提升查找效率,减少比较次数,查找时间复杂度 O(logn) 。变体有插值查找、斐波那契查找。
- 哈希查找:构建哈希 key-value 映射结构,解决哈希冲突,查找时间复杂度 O(1) 。哈希查找需要仔细选择哈希函数(通过参数调优来提升性能),不支持 rank 和 select 操作(第 K 大元素),通常用于 K-V 结构的存储系统; 顺序查找的查找效率与哈希接近,支持 rank 和 select 操作,通常用于有序表、关系型数据库等。可以结合两种查找结构使用。比如 java8 的 HashMap 是哈希查找与顺序查找的结合。
- 动态查找:一致性哈希。环状队列 + 多哈希 + 虚拟节点。构建一个 serverMap = TreeMap[VirtualNodeHash, Server] 的有序映射。对数据进行哈希 h 后,在 serverMap 找到第一个不小于 h 的键 S,将该数据分布到服务器 serverMap[S] 上。
- 哈希算法:可采用 Fowler-Noll-Vo 哈希算法。一致性哈希的性质:平衡性、单调性、分散性、负载、平滑性。
- 全文检索: 倒排索引。
- 查找的可扩展性: 分表、分库、分片、分区。
排序
常用排序算法:
- 快速排序:时间复杂度O(nlogn),空间复杂度O(nlogn),不稳定排序;适合随机的大数据集。
- 归并排序:时间复杂度O(nlogn),空间复杂度O(1),稳定排序;适合随机的大数据集。
- 堆排序,时间复杂度 O(nlogn) ,空间复杂度O(1),不稳定排序;适合随机的大数据集。
- 多关键字排序:MSD 和 LSD。 MSD 是先按高位排序分组,把高位相同的放在一组;然后按照次高位再做排序分组。LSD是先从最低位开始排序,再对次低位进行排序,依次重复,直到对所有关键字排序后便得到一个有序序列。采用链表减少存储空间。
- 基数排序:将整数位切分为多个数位,然后按照多关键字排序。
- 桶排序: 分治 + 其它排序。把数据映射到某个桶里,针对每个桶单独排序,然后合并。适合均匀分布的数据。桶的数量和映射函数要仔细设计,保证在桶内均匀分布且基本有序。
- 文件排序:适合有限内存或针对磁盘文件排序。基于归并排序,先划分为多个子文件,针对每个子文件排序并将有序分段写入外部存储器,再对子文件进行归并。
- 简单插入排序、选择排序、冒泡排序:时间复杂度 O(n*n),空间复杂度 O(1),实现简单,适合于数据量小的场景。插入排序适合数据基本有序的情况。
选择排序算法的依据:数据量大小;是否需要稳定排序;数据是否基本有序或者完全随机;数据的分布;内存是否有限或者有运行时间限制。
遍历
- 数组:按索引遍历。
- 链表:按节点指针遍历。
- 映射:按照映射里的 key 进行遍历。
- 二叉树:前序遍历、中序遍历、后序遍历。
- 图:深度优先遍历、广度优先遍历。
字符串匹配
- 朴素字符串匹配 - 双重循环,简单,低频场景;
- 有限自动机 - 根据模式字符串构造有限自动机,再匹配文本,适用模式串的不同字符数很少的情形;
- KMP - 根据模式字符串及构造后缀匹配数组(PMT,Partial Match Table,前缀集合与后缀集合的交集字符串的最长长度),避免无用位移测试,适用生产环境。
压缩
-
变长编码思想:不同字符采用不同长度的编码,且任一字符的编码不能是其它字符的编码的前缀,否则解码时会有二义性。压缩算法的基础。
-
哈夫曼编码:高频符号,使用更短的编码;低频符号,使用较长的编码。
-
算术编码:在保留字符排列顺序的同时,对于更高频出现的字符,也就是概率更大的字符,赋予更大的小数区间。
-
LZ77编码:
-
DEFLATE、LZMA、Brotli、Zstandard、gzip。
生成全局唯一ID
全局唯一ID 的要求:全局唯一不重复;单调递增;信息安全;ID生成系统高可用;分配支持;长度适宜。
- UUID 生成
- 数据库自增ID
- Redis INCR 命令
- snowflake 算法,生成 64 bit 的 long 型数值作为唯一ID。41bit (毫秒数) + 10 bit (5bit 数据中心 + 5bit 机器号) + 12bit (毫秒内的偏移量) + 1bit (=0)
号段模式:每次申请和取出若干个 ID 进行分配,分配完后再去批量申请新的 ID。
负载均衡选择
- 轮询:按照请求的顺序依次将请求分配给每个服务器,循环往复。实现简单,但不考虑服务器负载情况。服务端配置高的权重更高。
- 随机:根据随机数生成器的结果将请求随机地分配给服务器。吞吐量越大,随机算法的效果越接近于轮询算法的效果。可以避免轮询算法中可能出现的“雪崩”效应,但无法保证服务器的负载均衡。
- 最少连接数:根据当前服务器的连接数选择最空闲的服务器进行请求处理。可以有效地平衡服务器的负载,但需要实时监控服务器的连接数。
- 加权轮询:根据服务器的权重将请求分配给不同的服务器。可以根据服务器的性能和配置进行灵活的负载均衡,但需要预先设置服务器的权重值。
- 加权最少连接:根据当前服务器的连接数和权重值选择最空闲和最适合处理请求的服务器。这种算法综合了最少连接算法和加权轮询算法的优点,可以在保证服务器负载均衡的同时,更好地利用服务器资源。
- IP 哈希:根据客户端的 IP 地址计算哈希值,然后将请求分配给对应的服务器。可以保证同一客户端的请求始终被分配到同一个服务器,但无法考虑服务器的实际负载情况。
位操作技巧
- 模运算: m mod 2^n == m & (n-1)
- 计算哈希值: (x << a) ^ (x >> b),其中a和b为常数,表示左移和右移的位数
- 取反操作: ~x,将0变为1,将1变为0
- 交换整数:a ^= b; b ^= a; a ^= b
哈希计算
将任意大小的数据映射为固定大小的数据的一种技术,通常用于数据加密、数据压缩、散列表等领域。
- 哈希值计算方法:主要基于二进制位来计算。如果是浮点、字符串、IP地址、对象,先将其转换成整数型,转二进制,再进行计算。最好能用到 key 里面所有相异的部分。
- 哈希函数:好的哈希函数具有低碰撞率、高效、映射哈希值均匀的特点。常见的哈希函数有取模(模最好是素数)、MD5、SHA-1、SHA-256、MurmurHash2 (源码可以在 GitHub 上搜到,适合大规模数据)、FNV hash、Cityhash。
- 可以编写一个程序,来检测哈希值的分布均匀度。如果哈希计算代价比较昂贵,需要做哈希值缓存。
利用ChatGPT扩展知识和辅助写作
可以利用 ChatGPT 来扩展知识面,辅助写作。写作中不清晰的地方,都可以直接问 ChatGPT,补充 ChatGPT 给出的建议。
【提问】list all the data structures so far
Here are some common data structures:
Array: A collection of elements, each identified by an index or a key.
Linked List: A collection of elements, each containing a reference to the next element in the list.
Stack: A collection of elements with last-in, first-out (LIFO) access.
Queue: A collection of elements with first-in, first-out (FIFO) access.
Hash Table: A data structure that maps keys to values using a hash function to compute an index into an array of buckets.
Set: A collection of unique elements.
Tree: A hierarchical data structure composed of nodes, with each node having zero or more child nodes.
Graph: A collection of vertices and edges that connect them.
Heap: A binary tree-based data structure that maintains its elements in a specific order.
Trie: A tree-like data structure used to store a dynamic set of strings.
Bloom Filter: A probabilistic data structure used to test if an element is a member of a set.
Bit Array: A compact data structure consisting of a fixed-size array of bits.
Bloomier Filter: A hybrid data structure combining the features of a hash table and a Bloom filter.
Skip List: A probabilistic data structure used for efficient searching, insertion, and deletion operations.
Red-Black Tree: A self-balancing binary search tree that guarantees logarithmic time complexity for insertions, deletions, and lookups.
B-Tree: A self-balancing data structure designed for storing large amounts of data on disk.
Note that this is not an exhaustive list and there are many other data structures used in computer science and programming.
【提问】list the data structures not in above list
Here are some additional data structures that were not mentioned in the previous list:
Bloom Trie: A combination of a trie and a Bloom filter, used to store a large number of keys while minimizing memory usage.
Fenwick Tree (also known as Binary Indexed Tree): A data structure used to efficiently calculate prefix sums of an array of numbers.
Segment Tree: A tree-based data structure used to efficiently perform range queries over an array of elements.
Bucket Sort: A sorting algorithm that uses buckets to distribute elements into groups based on their values, and then sorts each bucket individually.
Radix Tree (also known as Trie or Prefix Tree): A tree-like data structure used for efficient searching and retrieval of strings.
Patricia Trie: A space-optimized version of a radix tree, where nodes with only one child are compressed into a single node.
Rope: A data structure used to efficiently store and manipulate large strings by breaking them down into smaller pieces.
Bloom Set: A probabilistic data structure used to test if an element is a member of a set, similar to a Bloom filter but with more flexible deletion capabilities.
Suffix Array: An array of all suffixes of a given string, used for efficient substring search and pattern matching.
Van Emde Boas Tree: A data structure used to implement a priority queue with logarithmic time complexity for insertions, deletions, and lookups.
Note that this is still not an exhaustive list, as there are many other data structures used in computer science and programming.
可以一直提问 list the data structures not in above list。
【提问】如何提升前缀树的构造效率
前缀树是一种常见的数据结构,用于高效地存储和查找字符串集合。在前缀树的构造过程中,为了达到最优的时间和空间复杂度,可以采用以下几种技巧来提升构造效率:
压缩前缀树:在构造前缀树时,如果有多个节点的父节点只有一个子节点,那么可以将这些节点压缩成一个节点,从而减少构造前缀树时所需的节点数量和内存使用量。
分支限界策略:在构造前缀树时,可以使用分支限界算法来剪枝,减少不必要的计算。例如,在搜索下一个字符匹配位置时,可以根据前缀树的特点和当前已经匹配的字符串前缀来限制搜索范围,从而避免无谓的搜索。
双缓冲技术:在构造前缀树时,可以使用双缓冲技术来减少内存分配和释放操作。即预先分配一块内存用于构造前缀树,同时使用另一块内存来读取输入字符串并进行前缀树构造,当需要更换字符串时,直接交换两块内存的指针即可,避免频繁的内存分配和释放操作。
多线程并发构造:在构造前缀树时,可以使用多线程并发的方式来加速构造过程。例如,可以将输入字符串分配到不同的线程中进行处理,同时使用同步机制来保证线程之间的正确性和一致性。
总之,这些技巧可以帮助我们更加高效地构造前缀树,在实际应用中需要根据具体问题的特点和需求选择合适的技巧,并进行适当的优化和调整,以达到更好的性能和效果。
小结
数据结构和算法,是软件组件的基本组成结构,是解决很多疑难编程问题的强有力的法宝,也是程序员装备自身编程技能的一件利器。
参考资料
- 《算法设计与分析基础》(通俗易懂,适合入门)
- 《算法导论》(第1,2,3,4,6,7,8,10,11,12,13,15,16,18,32章)
- 《算法》(红黑树讲得比较好,对 Java 程序员友好)
- 《Redis设计与实现》(第2,3,4,5章)
- 跳表(SkipList)原理篇
- Java求解迷宫问题:栈与回溯算法
- 多关键字排序:基数排序以及各个排序算法的总结
- 单调队列 —— 动图详解高效数据结构
- 5 分钟搞懂布隆过滤器
- 聊聊 Elasticsearch 的倒排索引
- 数据结构丨前缀树
- 有向无环图(DAG)的温故知新
- 什么是算术编码
- JDK,Google Guava Collect Cache BloomFilter 源码