数据结构基础
数组
数组是有限个相同类型的变量所组成的有序集合,数组中的每一个变量被称为元素。数组是最为简单、最为常用的数据结构。数组中的每一个元素有自己的下标,只不过这个下标从0开始,一直到数组长度-1。数组的一个特点是,在内存中顺序存储,因此可以很好地实现逻辑上的顺序表。
内存是由一个个连续的内存单元组成的,每一个内存单元都有自己的地址。在这些内存单元中,有些被其他数据占用了,有些是空闲的。
数组中的每一个元素,都存储在小小的内存单元中,并且元素之间紧密排列,既不能打乱元素的存储顺序,也不能跳过某个存储单元进行存储。
数组的基本操作
- 读取元素:由于数组在内存中顺序存储,所以只要给出一个数组下标,就可以读取到对应的数组元素。数组的下标必须在数组的长度范围之内,否则会出现数组越界。像这种根据下标读取元素的方式叫作随机读取。
int[] array = new int[]{1,3,5,2,6,8,4,9,7};
//输出下标为3的元素;
System.out.println(array[3]);
- 更新元素:要把数组中某一个元素的值替换为一个新值,直接利用数组下标,就可以把新值赋给该元素。
int[] array = new int[]{1,3,5,2,6,8,4,9,7};
//给数组下标为5的元素赋值;
array[5] = 10;
System.out.println(array[5]);
- 插入元素:插入数组元素的操作存在三种情况:尾部插入、中间插入、超范围插入;数组的实际元素数量有可能小于数组的长度。
1 . 尾部插入:是最简单的情况,直接把插入的元素放在数组尾部的空闲位置即可,等同于更新元素的操作;
2 . 中间插入:由于数组的每一个元素都有其固定下标,所以不得不先把插入位置及后面的元素向后移动,腾出地方,再把要插入的元素放到对应的数组位置上。
private int[] array;
private int size;
public MyArray(int capacity){
this.array = new int[capacity];
this.size = 0;
}
public void insert(int element,int index)throws Exceotion{
if(index < 0 || index > size){
throw new IndexOutOfBoundsException("数组下标越界");
}
for(int i = size - 1;i >= i;i --){
array[i + 1] = array[index];
}
array[index] = element;
size ++;
}
public void output(){
for(int i = 0;i < size;i ++){
System.out.println(array[i]);
}
}
public static void main(String[] args)throws Exception{
MyArray myArray = new MyArray(10);
myArray.insert(3,11);
}
3 . 超范围插入:涉及到数组的扩容,就是创建一个新的数组,长度是旧数组的两倍,再把旧数组中的元素统统复制过去,就实现了数组的扩容。
private int[] array;
private int size;
public MyArray(int capacity){
this.array = new int[capacity];
this.size = 0;
}
public void insert(int element,int index)throws Exceotion{
if(index < 0 || index > size){
throw new IndexOutOfBoundsException("数组下标越界");
}
if(size >= array.length){
resize();
}
for(int i = size - 1;i >= i;i --){
array[i + 1] = array[index];
}
array[index] = element;
size ++;
}
public void resize(){
int[] arrayNew = new int[array.length * 2];
System.arrayCopy(array,0,arrayNew,0,array.length);
array = arrayNew;
}
public void output(){
for(int i = 0;i < size;i ++){
System.out.println(array[i]);
}
}
public static void main(String[] args)throws Exception{
MyArray myArray = new MyArray(10);
myArray.insert(3,11);
}
删除元素:数组的删除操作和插入操作的过程相反,如果删除的元素位于数组中间,其后的元素都需要向前挪动一位,如果删除的元素在尾部,直接删除就好了。
如果对数组元素没有顺序要求, 那么直接把最后一个元素复制到要删除的元素的位置,然后再删除最后一个元素。
public int delete(int index){
if(index < 0 || index >= size){
throw new IndexOutOfBoundsException("数组下标越界");
}
int deletedElement = array[index];
for(int i = index;i < size - 1;i ++){
array[i] = array[index + 1];
}
size --;
return deletedElement;
}
对于数组的插入和删除,时间复杂度都是O(n)。
数组的优势与劣势
数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。有一种高效查找元素的算法叫做二分查找,就是利用了数组的这个优势。
由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。
总的来说,数组适合读操作多、写操作少的场景。
链表
链表是一种在物理上非连续、非顺序的数据结构,由若干节点所组成。
单向链表的每一个节点又包含两部分,一部分是存放数据的变量data,另一部分是指向下一个节点的指针next。链表的第一个节点被称为头节点,最后一个节点被称为尾节点,尾节点的next指针指向空。与数组按照下标来随机寻找元素不同,对于链表的其中一个节点A,我们只能根据节点A的next指针来找到该节点的下一个节点B,再根据节点B的next指针找到下一个节点C。
[data|next]-->[data|next]-->[data|next]-->[data|next]-->null
private static class Node{
int data;
Node next;
}
要想让每个节点都能回溯到它的前置节点,我们使用双向链表。双向链表的每一个节点除了拥有data和next指针,还拥有指向前置节点的prev指针。链表采用见缝插针的方式,链表的每一个节点分布在内存的不同位置,依靠next指针关联起来。这样可以灵活有效地利用零散的碎片空间。
null<--[prev|data|next]<-->[prev|data|next]<-->[prev|data|next]-->null
链表的基本操作
查找节点:在查找元素时,链表不像数组那样可以通过下标快速进行定位,只能从头节点开始向后一个一个节点逐一查找。链表中的数据只能按照顺序进行访问,最坏的时间复杂度是O(n)。
更新节点:如果不考虑查找节点的过程,链表的更新过程会像数组那样简单,直接把旧数据替换成新数据即可。
插入节点:与数组类似,链表插入节点时,同样分为三种情况:
尾部插入:把最后一个节点的next指向新插入的节点即可;
头部插入:把新节点的next指针指向原先的头节点,把新节点变为链表的头节点;
中间插入:把插入位置的前置节点的next指针指向插入的新节点,将新节点的next指针指向前置节点的next指针原先所指向的节点;
只要内存空间允许,能够插入链表的元素是无穷的,不需要像数组那样考虑扩容的问题;
- 删除元素:链表的删除操作同样分为三类情况:
尾部删除:把倒数第二个节点的next指针指向空即可;
头部删除:把链表的头节点设为原先头节点的next指针即可;
中间删除:把要删除节点的前置节点的next指针,指向要删除元素的下一个节点即可;
private static class Node{
int data;
int next;
Node(int data){
this.data = data;
}
}
private Node head;
private Node last;
private int size;
public void insert(int data,int index)throws Exception{
if(index < 0 || index > size){
throw new IndexOutOfBoundsException("超出了链表节点范围");
}
Node insertedNode = new Node(data);
if(size == 0){
head = insertedNode;
last = insertedNode
}else if(size == index){
last.next = insertedNode;
last = insertedNode;
}else{
Node prevNode = get(index - 1);
Node nextNode = prevNode.next;
prevNode.next = insertedNode;
insertedNode.next = nextNode;
}
size ++;
}
public Node get(int index)throws Exception{
if(index < 0 || index >= size){
throw new IndexOutOfBoundsException("超出了链表节点范围");
}
Node temp = head;
for(int i = 0;i < index;i ++){
temp = temp.next;
}
size --;
return temp;
}
数组和链表的区别
数组和链表都属于线性的数据结构。
类型 | 查找 | 更新 | 插入 | 删除 |
---|---|---|---|---|
数组 | O(1) | O(1) | O(n) | O(n) |
链表 | O(n) | O(1) | O(1) | O(1) |
数组的优势在于能够快速定位元素,对于读操作多、写操作少的场景来说,用数组更合适一些。链表的优势在于能够灵活地进行插入和删除操作,如果需要在尾部频繁插入、删除元素,用链表更合适一些。
栈
栈(stack)是一种线性数据结构,栈中的元素只能先入后出。最早进入的元素存放的位置叫作栈底,最后进入的元素存放的位置叫作栈顶。栈这种数据结构既可以用数组来实现,也可以用链表来实现。
栈的基本操作
栈的基本操作就是出栈和入栈。
- 入栈:入栈操作就是把新元素放入栈中,只允许从栈顶一侧放入元素,新元素的位置将会成为新的栈顶。
- 出栈:出栈操作就是把元素从栈中弹出,只有栈顶元素才允许出栈 ,出栈元素的前一个元素将会成为新的栈顶。
队列
队列(queue)是一种线性数据结构,不同于栈的先入后出,队列中的元素只能先入先出。队列的出口端叫作队头,队列的入口端叫作队尾。与栈类似,队列这种数据结构既可以用数组来实现,也可以用链表来实现。用数组实现时,为了入队操作的方便,把队尾位置规定为最后入队元素的下一个位置。
队列的基本操作
对于链表实现的队列,队列的入队和出队操作和栈是大同小异的。对于数组实现的方式来说,队列的入队和出队是有一些不同的。
- 入队:入队(enqueue)就是把新元素放入队列中,只允许在队尾的位置放入元素,新元素的下一个位置将会成为队尾。
- 出队:出队(dequeue)操作就是把元素移出队列,只允许在队头一侧移出元素,出队元素的后一个元素将会成为新的队头。
用数组实现的队列可以采用循环队列的方式来维持队列容量的恒定。利用已出队元素留下的空间,让队尾指针重新指回数组的首位。这样一来,整个队列的元素就循环起来了,在物理存储上,队尾的位置也可以在队头之前。当再有元素入队是,将其放入数组的首位,队尾指针继续后移即可。一直到(队尾下标 + 1) % 数组长度 = 队头下标时,代表此队列真的已经满了。需要注意的是,队尾指针指向的位置永远空出1位,所以队列最大容量比数组长度小1.
散列表(哈希表)
散列表这种数据结构提供了键(Key)和值(Value)的映射关系。只要给出一个key,就可以高效查找到它所匹配的value,时间复杂度接近于O(1)。
散列表的本质是一个数组。通过哈希函数,把key和数组下标进行转换。
散列表的基本操作
- 写操作:写操作就是在散列表中插入新的键值对(在JDK中叫作Entry)。第一步是通过哈希函数,把key转化成数组下标;第二步是如果数组下标对应的位置没有元素,就把元素填充到数组下标对应的位置。但是,由于数组的长度是有限的,当插入的元素越来越多时,不同的key通过哈希函数获得的下标有可能是相同的,这就是哈希冲突。解决哈希冲突的方法主要有两种:一种是开放寻址,一种是链表法。开放寻址的原理就是当一个key通过哈希函数获得对应的数组下标已被占用时,我们可以寻找另一个空档位置。比如,向后移一位,如果这个位置还是被占用了,就继续后移,知道找到空档位置。在遇到哈希冲突时,寻址方式有很多种,不一定就是简单地寻找当前元素的后一个元素。在Java中,ThreadLocal所使用的就是开放寻址法。链表法在Java集合类HashMap中使用。HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。
- 读操作:读操作就是通过给定的key,在散列表中查找对应的value。以HashMap举例,第一步时通过哈希函数,把key转化成数组下标;第二步时找到数组下标对应的元素,如果没有找到,由于数组的每个元素都与一个链表对应,我们可以顺着链表慢慢往下找,看看能否找到与key相匹配的节点。
- 扩容:散列表是基于数组实现的,那么散列表也要涉及扩容的问题。当经过多次元素插入,散列表达到一定饱和度时,key映射位置发生冲突的概率会逐渐提高,这样一来,大量元素拥挤到相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。这时,散列表就需要进行扩容。扩容并不是简单的把散列表的长度扩大,而是需要如下两个步骤:
- 扩容,创建一个新的Entry空数组,长度是原数组的两倍;
- 重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中。因为长度扩大以后,Hash的规则也随之改变;