万字长文带你漫游数据结构世界
数据结构是什么?
程序 = 数据结构 + 算法
是的,上面这句话是非常经典的,程序由数据结构以及算法组成,当然数据结构和算法也是相辅相成的,不能完全独立来看待,但是本文会相对重点聊聊那些常用的数据结构。
数据结构是什么呢?
首先得知道数据是什么?数据是对客观事务的符号表示,在计算机科学中是指所有能输入到计算机中并被计算机程序处理的符号总称。那为何加上“结构”两字?
数据元素是数据的基本单位,而任何问题中,数据元素都不是独立存在的,它们之间总是存在着某种关系,这种数据元素之间的关系我们称之为结构。
因此,我们有了以下定义:
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
简单讲,数据结构就是组织,管理以及存储数据的方式。虽然理论上所有的数据都可以混杂,或者糅合,或者饥不择食,随便存储,但是计算机是追求高效的,如果我们能了解数据结构,找到较为适合当前问题场景的数据结构,将数据之间的关系表现在存储上,计算的时候可以较为高效的利用适配的算法,那么程序的运行效率肯定也会有所提高。
常用的4种数据结构有:
- 集合:只有同属于一个集合的关系,没有其他关系
- 线性结构:结构中的数据元素之间存在一个对一个的关系
- 树形结构:结构中的数据元素之间存在一个对多个的关系
- 图状结构或者网状结构:图状结构或者网状结构
何为逻辑结构和存储结构?
数据元素之间的逻辑关系,称之为逻辑结构,也就是我们定义了对操作对象的一种数学描述。但是我们还必须知道在计算机中如何表示它。数据结构在计算机中的表示(又称为映像),称之为数据的物理结构,又称存储结构。
数据元素之前的关系在计算机中有两种不同的表示方法:顺序映像和非顺序映像,并且由此得到两种不同的存储结构:顺序存储结构和链式存储结构,比如顺序存储结构,我们要表示复数z1 =3.0 - 2.3i
,可以直接借助元素在存储器中的相对位置来表示数据元素之间的逻辑关系:
而链式结构,则是以指针表示数据元素之间的逻辑关系,同样是z1 =3.0 - 2.3i
,先找到下一个是 100
,是一个地址,根据地址找到真实的数据-2.3i
:
位(bit)
在计算机中表示信息的最小的单位是二进制数中的一位,叫做位。也就是我们常见的类似01010101010
这种数据,计算机的底层就是各种晶体管,电路板,所以不管是什么数据,即使是图片,声音,在最底层也是0
和1
,如果有八条电路,那么每条电路有自己的闭合状态,有8
个2
相乘,28,也就是256
种不同的信号。
但是一般我们需要表示负数,也就是最高的一位表示符号位,0
表示正数,1
表示负数,也就是8位的最大值是01111111
,也就是127
。
值得我们注意的是,计算机的世界里,多了原码,反码,补码的概念:
- 原码:用第一位表示符号,其余位表示值
- 反码:正数的补码反码是其本身,负数的反码是符号位保持不变,其余位取反。
- 补码:正数的补码是其本身,负数的补码是在其反码的基础上 + 1
为什么有了原码还要反码和补码?
我们知道加减法是高频的运算,人可以很直观的看出加号减号,马上就可以算出来,但是计算机如果区分不同的符号,那么加减就会比较复杂,比如正数+正数,正数-正数,正数-负数,负数+负数...等等。于是,有人就想用同一个运算器(加号运算器),解决所有的加减法计算,可以减少很多复杂的电路,以及各种符号转换的开销,计算也更加高效。
我们可以看到,下面负数参加运算的结果也是符合补码的规则的:
00100011 35
+ 11011101 -35
-------------------------
00000000 0
00100011 35
+ 11011011 -37
-------------------------
11111110 -2
当然,如果计算结果超出了位数所能表示的范围,那就是溢出,就说明需要更多的位数才能正确表示。
一般能用位运算的,都尽量使用位运算,因为它比较高效, 常见的位运算:
~
:按位取反&
:按为与运算|
:按位或运算^
:按位异或<<
: 带符号左移,比如35(00100011)
,左移一位为70(01000110)
,-35(11011101)
左移一位为-70(10111010)
>>
:带符号右移,比如35(00100011)
,右移一位为17(00010001)
,-35(11011101)
左移一位为-18(11101110)
<<<
:无符号左移,比如35(00100011)
,左移一位为70(01000110)
>>>
:无符号右移,比如-35(11011101)
,右移一位为110(01101110)
x ^= y; y ^= x; x ^= y;
:交换s &= ~(1 << k)
:第k
位置0
要说哪里使用位运算比较经典,那么要数布隆过滤器,需要了解详情的可以参考:http://aphysia.cn/archives/cachebloomfilter
布隆过滤器是什么呢?
布隆过滤器(Bloom Filter
)是由布隆(Burton Howard Bloom
)在1970年提出的,它实际上是由一个很长的二进制向量和一系列随机hash映射函数组成(说白了,就是用二进制数组存储数据的特征)。可以使用它来判断一个元素是否存在于集合中,它的优点在于查询效率高,空间小,缺点是存在一定的误差,以及我们想要剔除元素的时候,可能会相互影响。
也就是当一个元素被加入集合的时候,通过多个hash
函数,将元素映射到位数组中的k
个点,置为1
。
重点是多个hash函数,可以将数据hash到不同的位上,也只有这些位全部为1的时候,我们才能判断该数据已经存在
假设有三个hash
函数,那么不同的元素,都会使用三个hash
函数,hash
到三个位置上。
假设后面又来了一个张三,那么在hash
的时候,同样会hash
到以下位置,所有位都是1
,我们就可以说张三已经存在在里面了。
那么有没有可能出现误判的情况呢?这是有可能的,比如现在只有张三,李四,王五,蔡八,hash
映射值如下:
后面来了陈六,但是不凑巧的是,它hash
的三个函数hash出来的位,刚刚好就是被别的元素hash
之后,改成1
了,判断它已经存在了,但是实际上,陈六之前是不存在的。
上面的情况,就是误判,布隆过滤器都会不可避免的出现误判。但是它有一个好处是,布隆过滤器,判断存在的元素,可能不存在,但是判断不存在的元素,一定不存在。,因为判断不存在说明至少有一位hash
出来是对不上的。
也是由于会出现多个元素可能hash
到一起,但有一个数据被踢出了集合,我们想把它映射的位,置为0
,相当于删除该数据。这个时候,就会影响到其他的元素,可能会把别的元素映射的位,置为了0
。这也就是为什么布隆过滤器不能删除的原因。
数组
线性表示最常用而且最为简单的一种数据结构,一个线性表示 n 个数据元素的有限序列,有以下特点:
- 存在唯一的第一个的数据元素
- 存在唯一被称为最后一个的数据元素
- 除了第一个以外,集合中每一个元素均有一个前驱
- 除了最后一个元素之外,集合中的每一个数据元素都有一个后继元素
线性表包括下面几种:
- 数组:查询 / 更新快,查找/删除慢
- 链表
- 队列
- 栈
数组是线性表的一种,线性表的顺序表示指的是用一组地址连续的存储单元依次存储线性表的数据元素:
在Java
中表示为:
int[] nums = new int[100];
int[] nums = {1,2,3,4,5};
Object[] Objects = new Object[100];
在C++
中表示为:
int nums[100];
数组是一种线性的结构,一般在底层是连续的空间,存储相同类型的数据,由于连续紧凑结构以及天然索引支持,查询数据效率高:
假设我们知道数组a
的第 1 个值是 地址是 296
,里面的数据类型占 2
个 单位,那么我们如果期望得到第 5 个: 296+(5-1)*2 = 304
,O(1)
的时间复杂度就可以获取到。
更新的本质也是查找,先查找到该元素,就可以动手更新了:
但是如果期望插入数据的话,需要移动后面的数据,比如下面的数组,插入元素6
,最差的是移动所有的元素,时间复杂度为O(n)
而删除元素则需要把后面的数据移动到前面,最差的时间复杂度同样为O(n)
:
Java代码实现数组的增删改查:
package datastruction;
import java.util.Arrays;
public class MyArray {
private int[] data;
private int elementCount;
private int length;
public MyArray(int max) {
length = max;
data = new int[max];
elementCount = 0;
}
public void add(int value) {
if (elementCount == length) {
length = 2 * length;
data = Arrays.copyOf(data, length);
}
data[elementCount] = value;
elementCount++;
}
public int find(int searchKey) {
int i;
for (i = 0; i < elementCount; i++) {
if (data[i] == searchKey)
break;
}
if (i == elementCount) {
return -1;
}
return i;
}
public boolean delete(int value) {
int i = find(value);
if (i == -1) {
return false;
}
for (int j = i; j < elementCount - 1; j++) {
data[j] = data[j + 1];
}
elementCount--;
return true;
}
public boolean update(int oldValue, int newValue) {
int i = find(oldValue);
if (i == -1) {
return false;
}
data[i] = newValue;
return true;
}
}
// 测试类
public class Test {
public static void main(String[] args) {
MyArray myArray = new MyArray(2);
myArray.add(1);
myArray.add(2);
myArray.add(3);
myArray.delete(2);
System.out.println(myArray);
}
}
链表
上面的例子中,我们可以看到数组是需要连续的空间,这里面如果空间大小只有 2
,放到第 3
个元素的时候,就不得不扩容,不仅如此,还得拷贝元素。一些删除,插入操作会引起较多的数据移动的操作。
链表,也就是链式数据结构,由于它不要求逻辑上相邻的数据元素在物理位置上也相邻,所以它没有顺序存储结构所具有的缺点,但是同时也失去了通过索引下标直接查找元素的优点。
重点:链表在计算机的存储中不是连续的,而是前一个节点存储了后一个节点的指针(地址),通过地址找到后一个节点。
下面是单链表的结构:
一般我们会手动在单链表的前面设置一个前置结点,也可以称为头结点,但是这并非绝对:
一般链表结构分为以下几种:
- 单向链表:链表中的每一个结点,都有且只有一个指针指向下一个结点,并且最后一个节点指向空。
- 双向链表:每个节点都有两个指针(为方便,我们称之为前指针,后指针),分别指向上一个节点和下一个节点,第一个节点的前指针指向
NULL
,最后一个节点的后指针指向NULL
- 循环链表:每一个节点的指针指向下一个节点,并且最后一个节点的指针指向第一个节点(虽然是循环链表,但是必要的时候还需要标识头结点或者尾节点,避免死循环)
- 复杂链表:每一个链表有一个后指针,指向下一个节点,同时有一个随机指针,指向任意一个结点。
链表操作的时间复杂度:
- 查询:
O(n)
,需要遍历链表 - 插入:
O(1)
,修改前后指针即可 - 删除:
O(1)
,同样是修改前后指针即可 - 修改:不需要查询则为
O(1)
,需要查询则为O(n)
链表的结构代码怎么表示呢?
下面只表示单链表结构,C++
表示:
// 结点
typedef struct LNode{
// 数据
ElemType data;
// 下一个节点的指针
struct LNode *next;
}*Link,*Position;
// 链表
typedef struct{
// 头结点,尾节点
Link head,tail;
// 长度
int len;
}LinkList;
Java
代码表示:
public class ListNode {
int val;
ListNode next = null;
ListNode(int val) {
this.val = val;
}
}
自己实现简单链表,实现增删改查功能:
class ListNode {
T val;
ListNode next = null;
ListNode(T val) {
this.val = val;
}
}
public class MyList {
private ListNode head;
private ListNode tail;
private int size;
public MyList() {
this.head = null;
this.tail = null;
this.size = 0;
}
public void add(T element) {
add(size, element);
}
public void add(int index, T element) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("超出链表长度范围");
}
ListNode current = new ListNode(element);
if (index == 0) {
if (head == null) {
head = current;
tail = current;
} else {
current.next = head;
head = current;
}
} else if (index == size) {
tail.next = current;
tail = current;
} else {
ListNode preNode = get(index - 1);
current.next = preNode.next;
preNode.next = current;
}
size++;
}
public ListNode get(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("超出链表长度");
}
ListNode temp = head;
for (int i = 0; i < index; i++) {
temp = temp.next;
}
return temp;
}
public ListNode delete(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("超出链表节点范围");
}
ListNode node = null;
if (index == 0) {
node = head;
head = head.next;
} else if (index == size - 1) {
ListNode preNode = get(index - 1);
node = tail;
preNode.next = null;
tail = preNode;
} else {
ListNode pre = get(index - 1);
pre.next = pre.next.next;
node = pre.next;
}
size--;
return node;
}
public void update(int index, T element) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("超出链表节点范围");
}
ListNode node = get(index);
node.val = element;
}
public void display() {
ListNode temp = head;
while (temp != null) {
System.out.print(temp.val + " -> ");
temp = temp.next;
}
System.out.println("");
}
}
测试代码如下:
public class Test {
public static void main(String[] args) {
MyList myList = new MyList();
myList.add(1);
myList.add(2);
// 1->2
myList.display();
// 1
System.out.println(myList.get(0).val);
myList.update(1,3);
// 1->3
myList.display();
myList.add(4);
// 1->3->4
myList.display();
myList.delete(1);
// 1->4
myList.display();
}
}
输出结果:
1 -> 2 ->
1
1 -> 3 ->
1 -> 3 -> 4 ->
1 -> 4 ->
单向链表的查找更新比较简单,我们看看插入新节点的具体过程(这里只展示中间位置的插入,头尾插入比较简单):
那如何删除一个中间的节点呢?下面是具体的过程:
或许你会好奇,a5
节点只是指针没有了,那它去哪里了?
如果是Java
程序,垃圾回收器会收集这种没有被引用的节点,帮我们回收掉了这部分内存,但是为了加快垃圾回收的速度,一般不需要的节点我们需要置空,比如 node = null
, 如果在C++
程序中,那么就需要手动回收了,否则容易造成内存泄漏等问题。
复杂链表的操作暂时讲到这里,后面我会单独把链表这一块的数据结构以及常用算法单独分享一下,本文章主要讲数据结构全貌。
跳表
上面我们可以观察到,链表如果搜索,是很麻烦的,如果这个节点在最后,需要遍历所有的节点,才能找到,查找效率实在太低,有没有什么好的办法呢?
办法总比问题多,但是想要绝对的”多快好省
“是不存在的,有舍有得,计算机的世界里,充满哲学的味道。既然搜索效率有问题,那么我们不如给链表排个序。排序后的链表,还是只能知道头尾节点,知道中间的范围,但是要找到中间的节点,还是得走遍历的老路。如果我们把中间节点存储起来呢?存起来,确实我们就知道数据在前一半,还是在后一半。比如找7
,肯定就从中间节点开始找。如果查找4
,就得从头开始找,最差到中间节点,就停止查找。
但是如此,还是没有彻底解决问题,因为链表很长的情况,只能通过前后两部分查找。不如回到原则:空间和时间,我们选择时间,那就要舍弃一部分空间
,我们每个节点再加一个指针,现在有 2 层指针(注意:节点只有一份,都是同一个节点,只是为了好看,弄了两份,实际上是同一个节点,有两个指针,比如 1 ,既指向2,也指向5):
两层指针,问题依然存在,那就不断加层,比如每两个节点,就加一层:
这就是跳表了,跳表的定义如下:
跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树,AVL树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。
主要的原理是用空间换时间,可以实现近乎二分查找的效率,实际上消耗的空间,假设每两个加一层, 1 + 2 + 4 + ... + n = 2n-1
,多出了差不多一倍的空间。你看它像不像书的目录,一级目录,二级,三级 ...
如果我们不断往跳表中插入数据,可能出现某一段节点会特别多的情况,这个时候就需要动态更新索引,除了插入数据,还要插入到上一层的链表中,保证查询效率。
redis
中使用了跳表来实现zset
,redis
中使用一个随机算法来计算层级,计算出每个节点到底多少层索引,虽然不能绝对保证比较平衡,但是基本保证了效率,实现起来比那些平衡树,红黑树的算法简单一点。
栈
栈是一种数据结构,在Java
里面体现是Stack
类。它的本质是先进后出,就像是一个桶,只能不断的放在上面,取出来的时候,也只能不断的取出最上面的数据。要想取出底层的数据,只有等到上面的数据都取出来,才能做到。当然,如果有这种需求,我们一般会使用双向队列。
以下是栈的特性演示:
栈的底层用什么实现的?其实可以用链表,也可以用数组,但是JDK
底层的栈,是用数组实现的,封装之后,通过API
操作的永远都只能是最后一个元素,栈经常用来实现递归的功能。如果想要了解Java
里面的栈或者其他集合实现分析,可以看看这系列文章:http://aphysia.cn/categories/collection
元素加入称之为入栈(压栈),取出元素,称之为出栈,栈顶元素则是最后一次放进去的元素。
使用数组实现简单的栈(注意仅供参考测试,实际会有线程安全等问题):
import java.util.Arrays;
public class MyStack {
private T[] data;
private int length = 2;
private int maxIndex;
public MyStack() {
data = (T[]) new Object[length];
maxIndex = -1;
}
public void push(T element) {
if (isFull()) {
length = 2 * length;
data = Arrays.copyOf(data, length);
}
data[maxIndex + 1] = element;
maxIndex++;
}
public T pop() {
if (isEmpty()) {
throw new IndexOutOfBoundsException("栈内没有数据");
} else {
T[] newdata = (T[]) new Object[data.length - 1];
for (int i = 0; i < data.length - 1; i++) {
newdata[i] = data[i];
}
T element = data[maxIndex];
maxIndex--;
data = newdata;
return element;
}
}
private boolean isFull() {
return data.length - 1 == maxIndex;
}
public boolean isEmpty() {
return maxIndex == -1;
}
public void display() {
for (int i = 0; i < data.length; i++) {
System.out.print(data[i]+" ");
}
System.out.println("");
}
}
测试代码:
public class MyStackTest {
public static void main(String[] args) {
MyStack myStack = new MyStack<>();
myStack.push(1);
myStack.push(2);
myStack.push(3);
myStack.push(4);
myStack.display();
System.out.println(myStack.pop());
myStack.display();
}
}
输出结果如下,符合预期:
1 2 3 4
4
1 2 3
栈的特点就是先进先出,但是如果需要随机取出前面的数据,效率会比较低,需要倒腾出来,但是如果底层使用数组,理论上是可以通过索引下标取出的,Java
里面正是这样实现。
队列
既然前面有先进后出的数据结构,那我们必定也有先进先出的数据结构,疫情的时候,排队估计大家都有测过核酸,那排队老长了,排在前面先测,排在后面后测,这道理大家都懂。
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。
队列的特点是先进先出,以下是例子:
一般只要说到先进先出(FIFO
),全称First In First Out
,就会想到队列,但是如果你想拥有队列即可以从队头取出元素,又可以从队尾取出元素,那就需要用到特殊的队列(双向队列),双向队列一般使用双向链表实现会简单一点。
下面我们用Java
实现简单的单向队列:
class Node {
public T data;
public Node next;
public Node(T data) {
this.data = data;
}
}
public class MyQueue {
private Node head;
private Node rear;
private int size;
public MyQueue() {
size = 0;
}
public void pushBack(T element) {
Node newNode = new Node(element);
if (isEmpty()) {
head = newNode;
} else {
rear.next = newNode;
}
rear = newNode;
size++;
}
public boolean isEmpty() {
return head == null;
}
public T popFront() {
if (isEmpty()) {
throw new NullPointerException("队列没有数据");
} else {
Node node = head;
head = head.next;
size--;
return node.data;
}
}
public void dispaly() {
Node temp = head;
while (temp != null) {
System.out.print(temp.data +" -> ");
temp = temp.next;
}
System.out.println("");
}
}
测试代码如下:
public class MyStackTest {
public static void main(String[] args) {
MyStack myStack = new MyStack<>();
myStack.push(1);
myStack.push(2);
myStack.push(3);
myStack.push(4);
myStack.display();
System.out.println(myStack.pop());
myStack.display();
}
}
运行结果:
1 -> 2 -> 3 ->
1
2 -> 3 ->
2
3 ->
常用的队列类型如下:
-
单向队列:也就是我们说的普通队列,先进先出。
-
双向队列:可以从不同方向进出队列
-
优先队列:内部是自动排序的,按照一定顺序出队列
-
阻塞队列:从队列取出元素的时候,队列没有元素则会阻塞,同样如果队列满了,往队列里面放入元素也会被阻塞。
-
循环队列:可以理解为一个循环链表,但是一般需要标识出头尾节点,防止死循环,尾节点的
next
指向头结点。
队列一般可以用来保存需要顺序的数据,或者保存任务,在树的层次遍历中可以使用队列解决,一般广度优先搜索都可以使用队列解决。
哈希表
前面的数据结构,查找的时候,一般都是使用=
或者!=
,在折半查找或者其他范围查询的时候,可能会使用<
和>
,理想的时候,我们肯定希望不经过任何的比较,直接能定位到某个位置(存储位置),这种在数组中,可以通过索引取得元素。那么,如果我们将需要存储的数据和数组的索引对应起来,并且是一对一的关系,那不就可以很快定位到元素的位置了么?
只要通过函数f(k)
就能找到k
对应的位置,这个函数f(k)
就是hash
函数。它表示的是一种映射关系,但是对不同的值,可能会映射到同一个值(同一个hash
地址),也就是f(k1) = f(k2)
,这种现象我们称之为冲突
或者碰撞
。
hash
表定义如下:
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
一般常用的hash
函数有:
- 直接定址法:取出关键字或者关键字的某个线性函数的值为哈希函数,比如
H(key) = key
或者H(key) = a * key + b
- 数字分析法:对于可能出现的数值全部了解,取关键字的若干数位组成哈希地址
- 平方取中法:取关键字平方后的中间几位作为哈希地址
- 折叠法:将关键字分割成为位数相同的几部分(最后一部分的位数可以不同),取这几部分的叠加和(舍去进位),作为哈希地址。
- 除留余数法:取关键字被某个不大于散列表表长
m
的数p
除后所得的余数为散列地址。即hash(k)=k mod p
,p< =m
。不仅可以对关键字直接取模,也可在折叠法、平方取中法等运算之后取模。对p
的选择很重要,一般取素数或m
,若p
选择不好,容易产生冲突。 - 随机数法:取关键字的随机函数值作为它的哈希地址。
但是这些方法,都无法避免哈希冲突,只能有意识的减少。那处理hash
冲突,一般有哪些方法呢?
- 开放地址法:
hash
计算后,如果该位置已经有数据,那么对该地址+1
,也就是往后找,知道找到一个空的位置。 - 重新
hash
法:发生哈希冲突后,可以使用另外的hash
函数重新极计算,找到空的hash
地址,如果有,还可以再叠加hash
函数。 - 链地址法:所有
hash
值一样的,链接成为一个链表,挂在数组后面。 - 建立公共溢出区:不常见,意思是所有元素,如果和表中的元素
hash
冲突,都弄到另外一个表,也叫溢出表。
Java
里面,用的就是链地址法:
但是如果hash
冲突比较严重,链表会比较长,查询的时候,需要遍历后面的链表,因此JDK
优化了一版,链表的长度超过阈值的时候,会变成红黑树,红黑树有一定的规则去平衡子树,避免退化成为链表,影响查询效率。
但是你肯定会想到,如果数组太小了,放了比较多数据了,怎么办?再放冲突的概率会越来越高,其实这个时候会触发一个扩容机制,将数组扩容成为 2
倍大小,重新hash
以前的数据,哈希到不同的数组中。
hash
表的优点是查找速度快,但是如果不断触发重新 hash
, 响应速度也会变慢。同时,如果希望范围查询,hash
表不是好的选择。
树
数组和链表都是线性结构,而这里要介绍的树,则是非线性结构。现实中树是金字塔结构,数据结构中的树,最上面称之为根节点。
我们该如何定义树结构呢?
树是一种数据结构,它是由n(n≥1)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:
每个节点有零个或多个子节点;没有父节点的节点称为根节点;每一个非根节点有且只有一个父节点;除了根节点外,每个子节点可以分为多个不相交的子树。(百度百科)
下面是树的基本术语(来自于清华大学数据结构C
语言版):
- 节点的度:一个节点含有的子树的个数称为该节点的度
- 树的度:一棵树中,最大的节点度称为树的度;
- 叶节点或终端节点:度为零的节点;
- 非终端节点或分支节点:度不为零的节点;
- 父亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;
- 孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点;
- 兄弟节点:具有相同父节点的节点互称为兄弟节点;
- 节点的层次:从根开始定义起,根为第
1
层,根的子节点为第2
层,以此类推; - 深度:对于任意节点
n
,n
的深度为从根到n的唯一路径长,根的深度为0
; - 高度:对于任意节点
n
,n
的高度为从n
到一片树叶的最长路径长,所有树叶的高度为0
; - 堂兄弟节点:父节点在同一层的节点互为堂兄弟;
- 节点的祖先:从根到该节点所经分支上的所有节点;
- 子孙:以某节点为根的子树中任一节点都称为该节点的子孙。
- 有序树:将树种的节点的各个子树看成从左至右是有次序的(不能互换),则应该称该树为有序树,否则为无序树
- 第一个孩子:在有序树中最左边的子树的根称为第一个孩子
- 最后一个孩子:在有序树种最右边的子树的根称为最后一个孩子
- 森林:由
m
(m>=0
)棵互不相交的树的集合称为森林;
树,其实我们最常用的是二叉树:
二叉树的特点是每个节点最多只有两个子树,并且子树有左右之分,左右子节点的次序不能任意颠倒。
二叉树在Java
中表示:
public class TreeLinkNode {
int val;
TreeLinkNode left = null;
TreeLinkNode right = null;
TreeLinkNode next = null;
TreeLinkNode(int val) {
this.val = val;
}
}
满二叉树:一棵深度为 k 且有 2k-1 个节点的二叉树,称之为满二叉树
完全二叉树:深度为 k 的,有 n 个节点的二叉树,当且仅当其每一个节点都与深度为 k 的满二叉树中编号从 1 到 n 的节点一一对应是,称之为完全二叉树。
一般二叉树的遍历有几种:
- 前序遍历:遍历顺序 根节点 --> 左子节点 --> 右子节点
- 中序遍历:遍历顺序 左子节点 --> 根节点 --> 右子节点
- 后序遍历:遍历顺序 左子节点 --> 右子节点 --> 根节点
- 广度 / 层次遍历: 从上往下,一层一层的遍历
如果是一棵混乱的二叉树,那查找或者搜索的效率也会比较低,和一条混乱的链表没有什么区别,何必弄更加复杂的结构呢?
其实,二叉树是可以用在排序或者搜索中的,因为二叉树有严格的左右子树之分,我们可以定义根节点,左子节点,右子节点的大小之分。于是有了二叉搜索树:
二叉查找树(Binary Search Tree),(又:二叉搜索树,二叉排序树)它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。二叉搜索树作为一种经典的数据结构,它既有链表的快速插入与删除操作的特点,又有数组快速查找的优势;所以应用十分广泛,例如在文件系统和数据库系统一般会采用这种数据结构进行高效率的排序与检索操作。
二叉查找树样例如下:
比如上面的树,如果我们需要查找到 4
, 从 5
开始,4
比5
小,往左子树走,查找到3
,4
比3
大,往右子树走,找到了4
,也就是一个 7
个节点的树,我们只查找了3
次,也就是层数,假设n
个节点,那就是log(n+1)
。
树维护好了,查询效率固然高,但是如果树没维护好,容易退化成为链表,查询效率也会下降,比如:
一棵对查询友好的二叉树,应该是一个平衡或者接近平衡的二叉树,何为平衡二叉树:
平衡二叉搜索树的任何结点的左子树和右子树高度最多相差1。平衡二叉树也称为 AVL 树。
为了保证插入或者删除数据等之后,二叉树还是平衡二叉树,那么就需要调整节点,这个也称为平衡过程,里面会涉及各种旋转调整,这里暂时不展开。
但是如果涉及大量的更新,删除操作,平衡树种的各种调整需要牺牲不小的性能,为了解决这个问题,有大佬提出了红黑树.
红黑树(Red Black Tree) 是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。 [1]
红黑树是在1972年由[Rudolf Bayer](https://baike.baidu.com/item/Rudolf Bayer/3014716)发明的,当时被称为平衡二叉B树(symmetric binary B-trees)。后来,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。 [2]
红黑树是一种特化的AVL树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。
红黑树有以下的特点:
-
性质1. 结点是红色或黑色。
-
性质2. 根结点是黑色。
-
性质3. 所有叶子都是黑色。(叶子是NIL结点)
-
性质4. 每个红色结点的两个子结点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色结点)
-
性质5. 从任一节结点其每个叶子的所有路径都包含相同数目的黑色结点。
正是这些特性,让红黑树在调整的时候,不像普通的平衡二叉树调整那般困难,频繁。也就是加上了条条框框,让它符合一定的标准,减少平衡过程的混乱以及频次。
前面说的哈希表,Java
中的实现,正是应用了红黑树,在hash
冲突较多的时候,会将链表转换成为红黑树。
上面说的都是二叉树,但是我们不得不扯一下多叉树,为什么呢?虽然二叉树中的各种搜索树,红黑树已经很优秀了,但是在与磁盘交互的时候,大多数是数据存储中,我们不得不考虑 IO 的因素,因为磁盘IO比内存慢太多了。如果索引树的层高有几千上万,那么磁盘读取的时候,需要次数太多了。B树更加适合磁盘存储。
970年,R.Bayer和E.mccreight提出了一种适用于外查找的树,它是一种平衡的多叉树,称为B树(或B-树、B_树)。
一棵m阶B树(balanced tree of order m)是一棵平衡的m路搜索树。它或者是空树,或者是满足下列性质的树:
1、根结点至少有两个子女;
2、每个非根节点所包含的关键字个数 j 满足:m/2 - 1 <= j <= m - 1;
3、除根结点以外的所有结点(不包括叶子结点)的度数正好是关键字总数加1,故内部子树个数 k 满足:m/2 <= k <= m ;
4、所有的叶子结点都位于同一层。
每个节点放多一点数据,查找的时候,内存中的操作比磁盘快很多,b
树可以减少磁盘IO的次数。B 树:
而每个节点的data
可能很大,这样会导致每一页查出来的数据很少,IO查询次数自然就增加了,那我们不如只在叶子节点中存储数据:
B+树是B树的一种变形形式,B+树上的叶子结点存储关键字以及相应记录的地址,叶子结点以上各层作为索引使用。一棵m阶的B+树定义如下:
(1)每个结点至多有m个子女;
(2)除根结点外,每个结点至少有[m/2]个子女,根结点至少有两个子女;
(3)有k个子女的结点必有k个关键字。
一般b+树的叶子节点,会用链表连接起来,方便遍历以及范围遍历。
这就是b+
树,b+
树相对于B树
多了以下优势:
b+
树的中间节点不保存数据,每次IO查询能查到更多的索引,,是一个矮胖的树。- 对于范围查找来说,
b+
树只需遍历叶子节点链表即可,b
树却需要从根节点都叶子节点。
除了上面的树,其实还有一种叫Huffman
树:给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
一般用来作为压缩使用,因为数据中,每个字符出现的频率不一样,出现频率越高的字符,我们用越短的编码保存,就可以达到压缩的目的。那这个编码怎么来的呢?
假设字符是hello
,那么编码可能是(只是编码的大致雏形,高频率出现的字符,编码更短),编码就是从根节点到当前字符的路径的01
串:
通过不同权值的编码,哈夫曼树到了有效的压缩。
堆
堆,其实也是二叉树中的一种,堆必须是完全二叉树,完全二叉树是:除了最后一层,其他层的节点个数都是满的,最后一层的节点都集中在左部连续位置。
而堆还有一个要求:堆中每一个节点的值都必须大于等于(或小于等于)其左右子节点的值。
堆主要分为两种:
- 大顶堆:每个节点都大于等于其子树节点(堆顶是最大值)
- 小顶堆:每个节点都小于等于其子树节点(堆顶是最小值)
一般情况下,我们都是用数组来表示堆,比如下面的小顶堆:
数组中父子节点以及左右节点的关系如下:
i
结点的父结点parent = floor((i-1)/2)
(向下取整)i
结点的左子结点2 * i +1
i
结点的右子结点2 * i + 2
既然是存储数据的,那么一定会涉及到插入删除等操作,堆里面插入删除,会涉及到堆的调整,调整之后才能重新满足它的定义,这个调整的过程,叫做堆化。
用小顶堆举例,调整主要是为了保证:
- 还是完全二叉树
- 堆中每一个节点都还小于等于其左右子节点
对于小顶堆,调整的时候是:小元素往上浮,大元素往下沉,就是不断交换的过程。
堆一般可以用来求解TOP K
问题,或者前面我们说的优先队列等。
图
终于来到了图的讲解,图其实就是二维平面,之前写过扫雷,扫雷的整个方块区域,其实也可以说是图相关的。图是非线性的数据结构,主要是由边和顶点组成。
同时图又分为有向图与无向图,上面的是无向图,因为边没有指明方向,只是表示两者关联关系,而有向图则是这样:
如果每个顶点是一个地方,每条边是路径,那么这就是一张地图网络,因此图也经常被用于求解最短距离。先来看看图相关的概念:
- 顶点:图最基本的单元,那些节点
- 边:顶点之间的关联关系
- 相邻顶点:由边直接关联的顶点
- 度:一个顶点直接连接的相邻顶点的数量
- 权重:边的权值
一般表示图有以下几种方法:
- 邻接矩阵,使用二维数组表示,为1 表示联通,0表示不连通,当然如果表示路径长度的时候,可以用大于
0
的数表示路径长度,用-1
表示不连通。
下面的图片中,0和 1,2连通,我们可以看到第 0行的第1,2列是1 ,表示连通。还有一点:顶点自身我们是标识了0,表示不连通,但是有些情况可以视为连通状态。
- 邻接表
邻接表,存储方法跟树的孩子链表示法相类似,是一种顺序分配和链式分配相结合的存储结构。如这个表头结点所对应的顶点存在相邻顶点,则把相邻顶点依次存放于表头结点所指向的单向链表中。
对于无向图来说,使用邻接表进行存储也会出现数据冗余,表头结点A所指链表中存在一个指向C的表结点的同时,表头结点C所指链表也会存在一个指向A的表结点。
图里面遍历一般分为广度优先遍历和深度优先遍历,广度优先遍历是指优先遍历与当前顶点直接相关的顶点,一般借助队列实现。而深度优先遍历则是往一个方向一直走到不能再走,有点不撞南墙不回头的意思,一般使用递归实现。
图,除了用了计算最小路径以外,还有一个概念:最小生成树。
一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边。 最小生成树可以用kruskal(克鲁斯卡尔)算法或prim(普里姆)算法求出。
有一种说法,图是平面上的点,我们把其中一个点拎起来,能将其他顶点带起来的边,取最小权值,多余的边去掉,就是最小生成树。
当然,最小生成树并不一定是唯一的,可能存在多种结果。
秦怀@观点
了解这些基本的数据结构,在写代码或者数据建模的时候,能够选择更加合适的,这是最大的用处。计算机是为人服务的,代码也是,数据结构的全部类型我们是无法一下子一一掌握的,但是基本的东西是变动不会很大,除非新一代革命性变化。
程序是由数据结构和算法组成,数据结构就像是基石,借助《数据结构C语言》版本中的一句话结尾:
为了编写出一个”好“的程序,必须分析待处理的对象的特性以及各处理对象之间存在的关系,这就是”数据结构“这门学科和发展的背景。
【作者简介】:
秦怀,公众号【秦怀杂货店】作者,个人网站:http://aphysia.cn,技术之路不在一时,山高水长,纵使缓慢,驰而不息。
剑指Offer全部题解PDF
开源编程笔记