java-集合框架/String/数组专题

在算法题的刷题过程中发现集合框架和数据结构的使用息息相关,经常因为对集合框架不甚了解导致代码bug无法解决,耽误时间,遂系统学习归纳,以观成效。

1. HashMap

1.1 什么是哈希表

​ 数据结构的物理存储结构只有两种:顺序存储结构链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组

比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。

​ 这个函数可以简单描述为:存储位置 = f(关键字) ,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
插入过程如下图所示

哈希表数据插入过程

查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。

1.2 哈希碰撞

然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。

1.3 哈希map的实现原理

1.3.1 哈希表架构(table)

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。(其实所谓Map其实就是保存了两个对象之间的映射关系的一种集合)

1
2
3
//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂。
//至于为什么这么做,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类。代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}

所以,HashMap的总体结构如下:

在这里插入图片描述

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

1.3.2 其他几个重要字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**实际存储的key-value键值对的个数*/
transient int size;

/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到*/
int threshold;

/**负载因子,代表了table的填充度有多少,默认是0.75
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;

/**HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException*/
transient int modCount;

HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值

initialCapacity默认为16,loadFactory默认为0.75

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public HashMap(int initialCapacity, float loadFactor) {
     //此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);

this.loadFactor = loadFactor;
threshold = initialCapacity;
     
init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
}

1.3.3 put方法

从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组

OK,接下来我们来看看put操作的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
//此时threshold为initialCapacity 默认是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}

put功能总而言之:传入的key,value有旧的值,就返回就得,没有就信增一个entry,返回null

1.3.4 inflateTable方法

inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.

1
2
3
4
5
6
7
8
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
/**此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,
capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 */
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}

roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值.

1
2
3
4
5
6
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

1.3.5 hash函数

1
2
3
4
5
6
7
8
9
10
11
12
13
/**这是一个神奇的函数,用了很多的异或,移位等运算
对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀*/
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}

h ^= k.hashCode();

h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置

1
2
3
4
5
6
/**
* 返回数组下标
*/
static int indexFor(int h, int length) {
return h & (length-1);
}

h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为index=2。位运算对计算机来说,性能更高一些(HashMap中有大量位运算)。

1.3.6 由key到下标的最终流程

HashMap如何确定元素位置

2. 字符串(String、StringBuffer、StringBuilder)

  • String类由final修饰,不可继承

  • 双引号直接写的字符串在常量池中,new的不在池中!

    eg:String s = new String(“xyz”);创建几个String对象?

    答:2!“xyz”作为字符串文字,编译阶段进入文字池,运行时文字池并入常量池,这个池中的所有字符串常量占用一个空间(地址)。

    new String则在堆上创建了一个对象,两者指向不同

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    String str1 = "hello"; //str1指向静态区
    String str2 = new String("hello"); //str2指向堆上的对象
    String str3 = "hello";
    String str4 = new String("hello");
    System.out.println(str1.equals(str2)); //true
    System.out.println(str2.equals(str4)); //true
    System.out.println(str1 == str3); //true
    System.out.println(str1 == str2); //false
    System.out.println(str2 == str4); //false
    System.out.println(str2 == "hello"); //false
    str2 = str1;
    System.out.println(str2 == "hello"); //true

2.1 比较

  • ==和equals的区别

    ==表示内容和地址均相等,如果均为引用类型,则为同一对象

    equals只表示内容相同,和hashcode有关,如果equals结果为true,则hashcode必相同;如果hashcode相同,equals结果不一定

    equalsIgnoreCase(String str):忽略大小写后看是否相同

  • compareTo():两种用法

    • 字符串与对象进行比较。
    • 按字典顺序比较两个字符串。

    比较的方式:

    如果第一个字符和参数的第一个字符不等,结束比较,返回第一个字符的ASCII码差值。

    如果第一个字符和参数的第一个字符相等,则以第二个字符和参数的第二个字符做比较,以此类推,直至不等为止,返回该字符的ASCII码差值。 如果两个字符串不一样长,可对应字符又完全一样,则返回两个字符串的长度差值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    public class Test {
    public static void main(String args[]) {
    String str1 = "Strings";
    String str3 = "Strings123";
    int result = str1.compareTo( str3 );
    System.out.println(result);//-3

    result = str3.compareTo( str1 );
    System.out.println(result);//3
    }
    }

    应用的题目:剑指 Offer 45. 把数组排成最小的数

2.2 获取参数

  • public int length():字符串长度
  • public String concat(String str):将当前字符串和参数字符串拼接
  • public char charAt(int index):获取 index处的字符(索引从0开始)
  • public int indexOf(String str):查找字符串str在原字符串中第一次出现的索引值,没有返回-1

2.3 截取(substring)

  • public String substring(int index):截取从参数位置开始到字符串结尾,返回新字符串
  • public String substring(int begin, int end):从begin到end,左闭右开,包含begin,不含end

2.4 转换

  • string转int:Integer.valueOf(“12”)||Integer.parseInt(“12”)

    int转string:String.valueOf(12)

  • String转char

    • public char[] toCharArray():将当前字符串拆分为字符数组作为返回值,然后用for-each遍历

    char转String

    • String类:String.valueOf()
    • StringBuffer类:StringBuffer.append()
    1
    2
    3
    4
    5
    6
    7
    8
    //String
    char myChar = 'm';
    String str = String.valueOf(myChar);
    //StringBuffer
    char myChar = 'm';
    StringBuffer str = new StringBuffer();
    str.append(myChar);
    String s = StringBuffer.toString();
  • String转ASCII码:public byte[] getBytes():获得当前字符串底层的字节数组(Ascii值)

2.5 分割字符串

  • public String[] split(String regex):按照参数的规则,将字符串切分为若干部分

    1
    2
    3
    String str1 = "aaa,ccc,bbb";
    String[] array1 = str1.split(",");
    //注意:对于按"." 进行字符串切分,需要split("\\.")
  • 注意:碰到连续多个字符串和传入参数相匹配

    此时第一个匹配的内容的中间用空字符串来表示,添加到结果数组

    1
    2
    3
    4
    5
    6
    7
    public static void main(String[] args) {
    String s = "a123good123123example";
    String[] word = s.split("123");
    for(int i=0; i<word.length; ++i){
    System.out.println(word[i]);
    }
    }

    输出结果

    1
    2
    3
    4
    a
    good

    example

    对数组遍历进行修改

    1
    2
    3
    4
    for(int i=0; i<word.length; ++i){
    if("".equals(word[i])) continue;
    System.out.println(word[i]);
    }

    输出结果

    1
    2
    3
    a
    good
    example

2.6 判断

  • public boolean isEmpty():字符串长度是否为0;
  • public boolean contains(String str):是否包含目标字符串;
  • publci boolean startsWith(String str, int toffset):是否以指定的前缀开始
  • publci boolean endsWith(String str):是否以指定的后缀结束

2.7 遍历

  • toCharArray()
  • .length(),charAt()
  • .length(),substring(i,i+1)

2.8 增

  • 直接”+”相加:str1+str2;
  • StringBuffer类方法:str1.append()括号内可以是字符串,int或char
  • String类方法:public String join(String method, String a, String b,...):通过某种方式实现字符串拼接

2.9 删

  • 删除指定位置字符或字符串!StringBuffer类方法:
    • delete(int begin, int end);
    • deleteCharAt(int index);
  • 删除字符串首尾的空格
    • String trim():返回值为删除首尾空格后的字符串

2.10 改

  • 用新字符串替换老字符串:public String replace(CharSequence oldString, CharSequence newString)
  • 大小写转换:toLowerCase:字符串转小写toUpperCase:字符串转大写

2.11 查

3 . 集合框架

3.1 List

3.1.1 lists.add(list)和lists.add(new ArrayList<>(list))的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//第一种情况:
List<List<Integer>> res = new ArrayList<>();
List<Integer> row = new ArrayList<>();

for (int i = 1; i <= 3; i++) {
row.add(i);
res.add(row);
}
//结果:res: [[1,2,3],[1,2,3],[1,2,3]]
//只创建了两个对象,res和row,最后添加进res的是三个同样的对象row的引用。

//第二种情况:
for (int i = 1; i <= 3; i++) {
row.add(i);
res.add(new ArrayList<>(row));
}
//结果:res: [[1],[1,2],[1,2,3]]
//创建了5个对象,res,row以及row的三个不同时刻的拷贝。

总而言之,列表中存储的只是对象或者引用类型,

3.1.2 初始化ArrayList 的三种方式

  • 常用:先创建list,再给list赋值

    1
    2
    3
    4
    List<String> list1 = new ArrayList<String>();
    list1.add("apple");
    list1.add("banana");
    list1.add("orange");
  • 直接提供list值进行初始化

    • Arrays.aslist():数组化集合

      1
      List<String> list2 = new ArrayList<String>(Arrays.asList("apple", "banana", "orange"));
    • Collections.nCopies():复制2次

      1
      List<String> list3 = new ArrayList<String>(Collections.nCopies(2, "orange"));
  • 使用匿名内部类初始化

    1
    2
    3
    4
    5
    6
    List<String> list4 = new ArrayList<>(){
    {add.("apple");
    add.("orange");
    add.("banana")
    }
    };

3.1.3 ArrayList的源码

为何要设置EMPTY_ELEMENTDATA,DEFAULTCAPACITY_EMPTY_ELEMENTDATA,两个空的数组常量

数组为EMPTY_ELEMENTDATA就走基于用户设置大小值进行1.5倍扩容(这里是空所以是0),数组为默认空DEFAULTCAPACITY_EMPTY_ELEMENTDATA就会走基于默认值的大小10扩容进行1.5倍扩容。 核心代码:如果是默认初始化空容量会走 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); }这段代码,接下来基于默认初始化大小10扩容,依次是10,15,22,33这种1.5倍数扩容。如果Listlist = new ArrayList<>(0);那就是基于你设置的大小0开始扩容,依次是0,1 ,2,3 ,4, 6这种1.5倍数扩容