线性表

 

常见的线性表有

  • 数组
  • 链表
  • 队列
  • 哈希表(散列表)

 

一、数组

 数组是一种顺序存储的线性表,所有元素的内存地址是连续的

1、动态数组 ArrayList

注意有以下接口

// 元素的数量
int size();

boolean isEmpty();

boolean contains(E element);

void add(E element);

E get(int index);

// 设置值,并将被替换的元素返回
E set(int index, E element);

void add(int index, E element);

E remove(int index);

// 查看元素的位置
int indexOf(E element);

void clear();

 

Java 有动态数组实现类 ArrayList。容量默认大小为10,当添加元素数组容量不够时,会重新创建一个 1.5 倍大小的新Arraylist,然后使用Array.copyOf复制元素到新的ArrayList。

private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

 

但是,当ArrayList删除元素时不会缩容。例如,我们往ArrayList里面存了30个数,扩容了3次:10->15->22->33,然后不断删除元素,直到元素个数为空,会发现ArrayList的size一直是33。

为什么删除了元素,但是不缩小容量呢?
我觉得是因为每次改变容量的时候,都需要新建一个数组,然后把旧数组里面的元素复制到新数组里面去,这种操作是损耗性能的,而往往ArrayList里面的元素会经常会在一定范围内变化,因此这种操作将带来很大的性能损耗,我们为什么不能损耗一点内存来提高服务的响应速度呢?从这个角度来看,也是一种空间换时间的体现。另外,在新建ArrayList的时候,设置一个合理的容量也会对我们系统的服务性能有一定的帮助。

2、动态数组时间复杂度

忽略扩容时创建新的动态数组并复制元素。添加,删除和查询的时间复杂度如下:

操作说明最好最坏平均
添加最后一个元素后移,直到被添加的元素位置,然后将数据插入从最后面添加,不需要移动元素,时间复杂度O(1)从最前面添加,需要将每一个元素后移,时间复杂度O(n)O(n)
删除后面的元素覆盖被删除的元素,直到最后一个元素覆盖它前面的元素删除最后一个元素,不需要移动,时间复杂度O(1)删除第一个元素,需要移动后面的每一个元素,时间复杂度O(n)O(n)
查询根据索引直接寻址由于内存是连续的,直接寻址查询,时间复杂度O(1)时间复杂度O(1)O(1)

3、动态数组优化

从动态数组时间复杂度可以知道,动态数组的查询效率高。但是从中间或前面添加和删除元素时,需要不断移动后面的元素。导致添加和删除的时间复杂度为O(n)。有没有好的优化方法呢?

可以考虑在动态数组中添加一个成员变量 first,用来指示当前的首元素。例如循环双端队列就可以使用这种方式来实现。

4、对象数组

对象数组模型如下:

 

二、链表

动态数组有个明显的缺点,可能会造成内存空间的大量浪费。能否用到多少就申请多少内存呢?链表可以办到这一点。

链表是一种链式存储的线性表,所有元素的内存地址不一定是连续的。

1、接口设计

链表的接口设计和动态数组大部分是一致的

2、链表的时间复杂度

单向链表的添加和删除都需要先查找到需要添加和删除的位置,然后执行相关操作。

操作说明最好最坏平均
添加创建node节点,找到添加的位置后,将上一个元素的next指向node,然后node的next指向上一个元素的下一个节点从头部添加,时间复杂度O(1)从尾部添加,时间复杂度O(n)O(n)
删除找到删除的位置后,将上一个元素的next指向需要删除的元素的next从头部删除,时间复杂度O(1)从尾部删除,时间复杂度O(n)O(n)
查询从头部开始,不断循环遍历next元素在头部,时间复杂度O(1)元素在尾部,时间复杂度O(n)O(n)

以下是单向链表部分实现代码:定义一个内部类 Node,第一个元素和size

public class LinkedList<E> {
    private Node<E> first;
    private int size;

    private static class Node<E> {
        E element;
        Node<E> next;
        public Node(E element, Node<E> next) {
            this.element = element;
            this.next = next;
        }

        /**
         * 添加
         * @param index
         * @param element
         */
        public void add(int index, E element) {
            rangeCheckForAdd(index);
            if (index == 0) {
                first = new Node<>(element, first);
            } else {
                Node<E> prev = node(index - 1);
                prev.next = new Node<>(element, prev.next);
            }
            size++;
        }

        /**
         * 删除
         * @param index
         * @return
         */
        public E remove(int index) {
            rangeCheck(index);

            Node<E> node = first;
            if (index == 0) {
                first = first.next;
            } else {
                Node<E> prev = node(index - 1);
                node = prev.next;
                prev.next = node.next;
            }
            size--;
            return node.element;
        }

        /**
         * 查找
         * @param element
         * @return
         */
        public int indexOf(E element) {
            if (element == null) {
                Node<E> node = first;
                for (int i = 0; i < size; i++) {
                    if (node.element == null) return i;
                    node = node.next;
                }
            } else {
                Node<E> node = first;
                for (int i = 0; i < size; i++) {
                    if (element.equals(node.element)) return i;

                    node = node.next;
                }
            }
            return -1;
        }

   ......

    }

3、双向链表

为了提高单向链表的效率,可以使用双向链表,在 前面定义的 ListedList 链表类中添加一个指向最后一个元素的成员变量 last。在Node节点中添加一个指向上一个节点的成员变量 prev

只有一个节点时,如下所示:

 Node几点定义如下:

    private static class Node<E> {
		E element;
		Node<E> prev;
		Node<E> next;
		public Node(Node<E> prev, E element, Node<E> next) {
			this.prev = prev;
			this.element = element;
			this.next = next;
		}
    }

查找元素的索引 index 小或等于 size 的一半时,从头部开始,循环遍历next。

查找元素的索引 index 大于size的一半时,从尾部开始,循环变量 prev。

添加和删除元素与单向链表类似,只是需要维护 prev 和 next 两个属性,Node 的构造方法已经维护了 当前节点的 prev 和当前节点的next,剩下的是 (prev 元素的next)和(next元素的prev)。以下为添加和删除代码:

/**
     * 添加
     * @param index
     * @param element
     */
    private void add(int index, E element) {
        rangeCheckForAdd(index);
        if (index == size) {
            Node<E> oldLast = last;
            last = new Node<>(oldLast, element, null);
            if (oldLast == null) {
                first = last;
            } else {
                oldLast.next = last;
            }
        } else {
            Node<E> next = node(index);
            Node<E> prev = next.prev;
            Node<E> current = new Node<>(prev, element, next);
            next.prev = current;
            if (prev == null) {
                first = current;
            } else {
                prev.next = current;
            }
        }
    }
 public E remove(int index) {
        rangeCheck(index);
        Node<E> node = node(index);
        Node<E> prev = node.prev;
        Node<E> next = node.next;

        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
        }
    }

Java 工具类中的LinkedList是一个双向链表,以下是部分成员变量和节点定义:

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    transient int size = 0;

    /**
     * Pointer to first node.
     * Invariant: (first == null && last == null) ||
     *            (first.prev == null && first.item != null)
     */
    transient Node<E> first;

    /**
     * Pointer to last node.
     * Invariant: (first == null && last == null) ||
     *            (last.next == null && last.item != null)
     */
    transient Node<E> last;
   
  ...
}

 

private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

4、动态数组和双向链表的对比

动态数组双向链表
开辟,销魂内存空间的次数相对较少,但可能造成内存空间浪费(可以通过缩容解决)开辟,销毁内存空间的次数相对较多,但不会造成内存空间浪费
如果频繁在尾部进行添加,删除操作,动态数组,双向链表均可
如果频繁在头部进行添加,删除操作,建议选择使用双向链表
如果有频繁的(在任意位置)添加,删除操作,建议选择使用双向链表
如果有频繁的查询操作(随机访问操作),建议选择使用动态数组

5、循环链表

循环链表有单向循环链表和双向循环链表

 

三、栈

1、简介

  • 栈是一种特殊的线性表,只能在一端进行操作。
  • 往栈中添加元素的操作,一般叫做 push,入栈
  • 从栈中移除元素的操作,一般叫做 pob,出栈(只能移除栈顶元素,也叫做:弹出栈顶元素)
  • 后进先出的原则,Last In First Out,LIFO

2、API

int size(); // 元素size

boolean isEmpty(); // 是否为空

void push(E element); // 入栈

E pop(); // 出栈

Java 的工具类中 LinkedList 有栈的实现,使用动态数组和链表都可以。

3、栈的应用

  • 浏览器的前进和后退
  • 软件的撤销(Undo),回复(Redo)功能
  • 括号是否有效
  • 等等

四、队列

1、简介

  • 队列是一种特殊的线性表,只能在头尾两端进行操作
  • 队尾(rear):只能从队尾添加元素,一般叫做enQueue,入队
  • 对头(front):只能从队头移除元素,一般叫做deQueue,出队
  • 先进先出的原则,First In First Out,FIFO

2、API

int size();  // 元素的数量

boolean isEmpty(); // 是否为空

void clear(); // 清空

void offer(E element); // 入队

E poll(); // 出队

E peek(); // 获取队列的头元素

因为队列主要是往头尾操作元素,优先使用 双向链表 实现

3、双端队列(Deque)

双向队列能在头尾两端添加,删除的队列

int size();  // 元素的数量

boolean isEmpty(); // 是否为空

void clear(); // 清空

void enQueueRear(E element); // 从队尾入队

E deQueueFront(); // 从对头出队

void enQueueFront(E element); // 从对头入队

E deQueueRear(); // 从队尾出队

E front(); // 获取队列的头元素

E rear(); // 获取队列的尾元素

LinkedList 实现了双向链表的接口

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

4、循环队列(Circle Queue)

循环队列是可以进行两端添加,删除操作的循环队列,循环队列底层用数组实现。

其实队列底层也可以使用 动态数组 实现,并且各项接口也可以优化到O(1)时间复杂度。

这个数组实现并优化之后的队列也叫做:循环队列

循环队列入队和出队简单实现如下:

入队时先获取队尾元素(front+size) 与 length 取模,然后设值,size++。

public void enQueue(E element) {
	elements[(front + size) % elements.length] = element;
	size++;
}

出队时front和size都加一,但是front需要和 length 取模。

public E deQueue() {
	E frontElement = elements[front];
	elements[front] = null;
	front = (front + 1) % elements.length;
	size--;
	return frontElement;
}

 

 扩容时,新建一个length为原来1.5倍的数组,然后将元素从队头开始映射回去,front重置为0。

private void ensureCapacity(int capacity) {
	int oldCapacity = elements.length;
	if (oldCapacity >= capacity) return;
		
	// 新容量为旧容量的1.5倍
	int newCapacity = oldCapacity + (oldCapacity >> 1);
	E[] newElements = (E[]) new Object[newCapacity];
	for (int i = 0; i < size; i++) {
		newElements[i] = elements[(front + i) % elements.length];
	}
	elements = newElements;
		
	// 重置front
	front = 0;
}

 考虑到入队,出队,扩容等等操作都需要映射index,我们可以抽取一个公共方法 index

public int index(int index){
    return (front + index) % elements.length;
}

5、循环双向队列

循环双端队列:可以进行两端添加,删除操作的循环队列

其中 从头部出队,从尾部入队与循环队列一致

从头部入队,从尾部出队 的操作逻辑如下:

从头部入队,相当于从 front 的前一个位置入队,映射为 front-1 与 elemetns.length 取模,可以使用 index(-1)映射

public void enQueueFront(E element) {
	ensureCapacity(size + 1);	
	front = index(-1);
	elements[front] = element;
	size++;
}

如果front = 0,调用 index(-1)时,出现了 -1 % elements.length 的情况,需要调整一下 index 方法

private int index(int index) {
    index += front;
    if(index < 0){
        return index + elements.length;
    }
    return index % elements.length;
}

同理,从 尾部出队 是先取出尾部元素,index 为 (front + size-1 )% elements.length,可以使用 index(size-1) 表示

public E deQueueRear() {
	int rearIndex = index(size - 1);
	E rear = elements[rearIndex];
	elements[rearIndex] = null;
	size--;
	return rear;
}

五、哈希表

哈希表也叫散列表,如下所示:

put("Tom", "a");

put("Jack","c");

put("Lucy", "f");

保存模型如下图所示:

  • 哈希表添加,搜索,删除的流程是先通过哈希函数生成 key(O(1)),然后根据 index 操作定位数组元素(O(1))
  • 哈希表内部的数组元素,也叫 Bucket(桶),整个数组叫 Buckets 或者 Bucket Array
  • 哈希表是空间换时间的典型应用

 Bucket 的长度一般设为 2 的倍数长度,可以通过与 Bucket 的长度取余计算索引值:hashCode % (buckets.length - 1)

 

1、哈希冲突

2 个不同的 key,经过哈希函数计算出相同的哈希值,解决哈希冲突冲突的常见方法:

  • 开放定址法,按照一定规则向其他地址探测,直到遇到空桶
  • 再哈希法,设计多个哈希函数
  • 链地址法,比如通过链表将同一个 index 的元素串起来(常用)

JDK 1.8 的哈希冲突使用链地址法解决哈希冲突,单向链表的节点数大于8时,使用红黑树保存

2、哈希函数

哈希函数需要返回一个 int 类型的数据,int  是4个字节,32位,良好的哈希函数:

  • 让哈希值均匀分布
  • 减少哈希冲突次数
  • 提升哈希表的性能

Java 常用类型都实现了 hashCode 方法,例如 Double 类,采用高 32 位与低 32 异或计算作为哈希值

    public static int hashCode(double value) {
        long bits = doubleToLongBits(value);
        return (int)(bits ^ (bits >>> 32));
    }

下面是 String 类的 haseCode 方法,字符串是由若干字符组成,例如字符串 “test”,由 t,e,s,t 组成。计算 hashCode 时,使用字符的ASCII 计算哈希值,表示成 t*n^3 +e*n^2+s*n+t,等价于 [(t * n + e )* n + s ] * n + t。

在 JDK 中 n 等于31,31是一个奇素数,可以优化成 (i << 5) - i

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

3、对象中的 hashCode 和 equals 方法

如果使用对象作为 Hash 中的 key,需要实现 hashCode 和 equals 方法,如下所示:

public class Person {

    private Integer age;

    private String name;

    // get,set方法
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(age, person.age) &&
                Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        //Objects.hash(age, name);
        int hashCode = Integer.hashCode(age);
        hashCode = hashCode * 31 + (name == null ? 0 : name.hashCode());
        return hashCode;
    }
}
  • hashCode 方法用于计算哈希,然后通过哈希函数计算索引
  • equals 用于当存在哈希冲突时,判断当前节点与其他节点是否是相同的 key,如果是相同的 key 就覆盖

4、扩容

当数据规模变大时,需要扩大哈希表内部的数组元素长度,减少哈希冲突,提高效率。一般可以使用素数作为数组元素长度,减少哈希冲突,数据规模变大时,将长度变成原来长度的两倍。

考虑到长度扩大两倍时,就不是一个素数了,有以下素数数组长度可供参考:

下界上界素数
2^52^653
2^62^797
2^72^8193
2^82^9389
2^92^10769

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值