万字长文带你漫游数据结构世界

博观而约取,厚积而薄发。这篇文章主要讲述万字长文带你漫游数据结构世界相关的知识,希望能为你提供帮助。



数据结构是什么?

程序 = 数据结构 + 算法
是的,上面这句话是非常经典的,程序由数据结构以及算法组成,当然数据结构和算法也是相辅相成的,不能完全独立来看待,但是本文会相对重点聊聊那些常用的数据结构。
数据结构是什么呢?
首先得知道数据是什么?数据是对客观事务的符号表示,在计算机科学中是指所有能输入到计算机中并被计算机程序处理的符号总称。那为何加上“结构”两字?
数据元素是数据的基本单位,而任何问题中,数据元素都不是独立存在的,它们之间总是存在着某种关系,这种数据元素之间的关系我们称之为结构。因此,我们有了以下定义:
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
简单讲,数据结构就是组织,管理以及存储数据的方式。虽然理论上所有的数据都可以混杂,或者糅合,或者饥不择食,随便存储,但是计算机是追求高效的,如果我们能了解数据结构,找到较为适合当前问题场景的数据结构,将数据之间的关系表现在存储上,计算的时候可以较为高效的利用适配的算法,那么程序的运行效率肯定也会有所提高。常用的4种数据结构有:
  • 集合:只有同属于一个集合的关系,没有其他关系
  • 线性结构:结构中的数据元素之间存在一个对一个的关系
  • 树形结构:结构中的数据元素之间存在一个对多个的关系
  • 图状结构或者网状结构:图状结构或者网状结构





何为逻辑结构和存储结构?


数据元素之间的逻辑关系,称之为逻辑结构,也就是我们定义了对操作对象的一种数学描述。但是我们还必须知道在计算机中如何表示它。数据结构在计算机中的表示(又称为映像),称之为数据的物理结构,又称存储结构。数据元素之前的关系在计算机中有两种不同的表示方法:顺序映像和非顺序映像,并且由此得到两种不同的存储结构:顺序存储结构和链式存储结构,比如顺序存储结构,我们要表示复数z1 =3.0 - 2.3i ,可以直接借助元素在存储器中的相对位置来表示数据元素之间的逻辑关系:

而链式结构,则是以指针表示数据元素之间的逻辑关系,同样是z1 =3.0 - 2.3i ,先找到下一个是 100,是一个地址,根据地址找到真实的数据-2.3i:



位(bit)在计算机中表示信息的最小的单位是二进制数中的一位,叫做位。也就是我们常见的类似01010101010这种数据,计算机的底层就是各种晶体管,电路板,所以不管是什么数据,即使是图片,声音,在最底层也是01,如果有八条电路,那么每条电路有自己的闭合状态,有82相乘,28,也就是256种不同的信号。但是一般我们需要表示负数,也就是最高的一位表示符号位,0表示正数,1表示负数,也就是8位的最大值是01111111,也就是127。值得我们注意的是,计算机的世界里,多了原码,反码,补码的概念:
  • 原码:用第一位表示符号,其余位表示值
  • 反码:正数的补码反码是其本身,负数的反码是符号位保持不变,其余位取反。
  • 补码:正数的补码是其本身,负数的补码是在其反码的基础上 + 1
为什么有了原码还要反码和补码?
我们知道加减法是高频的运算,人可以很直观的看出加号减号,马上就可以算出来,但是计算机如果区分不同的符号,那么加减就会比较复杂,比如正数+正数,正数-正数,正数-负数,负数+负数...等等。于是,有人就想用同一个运算器(加号运算器),解决所有的加减法计算,可以减少很多复杂的电路,以及各种符号转换的开销,计算也更加高效。我们可以看到,下面负数参加运算的结果也是符合补码的规则的:
0010001135
+11011101-35
-------------------------
000000000

0010001135
+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 = https://www.songbingjia.com/android/new int[max];
elementCount = 0;


public void add(int value)
if (elementCount == length)
length = 2 * length;
data = https://www.songbingjia.com/android/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>
T val;
ListNode next = null;

ListNode(T val)
this.val = val;



public class MyList< T>
private ListNode< T> head;
private ListNode< T> 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类。它的本质是先进后出,就像是一个桶,只能不断的放在上面,取出来的时候,也只能不断的取出最上面的数据。要想取出底层的数据,只有等到上面的数据都取出来,才能做到。当然,如果有这种需求,我们一般会使用双向队列。以下是栈的特性演示:
【万字长文带你漫游数据结构世界】

    推荐阅读