Java学习笔记-Day25 Java Map集合



一、Map集合


Map是由一系列键值对组成的集合,提供了key到Value的映射。Map接口没有继承Collection接口。

在Map中一个key只能对应一个value。key不能重复,如果key重复的话,会被后面添加进来的键值对覆盖,而value值是可以重复的。

某些映射实现可明确保证其顺序,如 TreeMap 类。另一些映射实现则不保证顺序,如 HashMap 类。

在实际使用中,如果更新Map集合时不需要保持Map集合中元素的顺序,就使用HashMap,如果需要保持Map集合中元素的插入顺序或者访问顺序,就使用LinkedHashMap,如果需要使Map集合中按照键值排序,就使用TreeMap。

ConcurrentHashMap是一个线程安全的集合。

1、HashMap集合


HashMap是基于哈希表(数组+链表)的Map接口的非同步实现(线程不安全、不同步),HashMap类继承自AbstractMap,而AbstractMap是实现Map接口的抽象类。此实现提供所有可选的Map操作,并允许使用null键和null值。HashMap类是无序的,其不保证映射的顺序,特别是它不保证该顺序恒久不变。

HashMap的底层是数组+链表,就是一个数组,而数组中的每一项又是一个链表。当新建一个HashMap的时候,就会初始化一个数组,初始容量为16,负载因子为0.75。

HashMap的底层在jdk1.8之前是数组+链表,在jdk1.8之后是数组+链表+红黑树。

1.1、构造方法


HashMap():构造一个空的 HashMap ,默认初始容量(16)和默认负载系数(0.75)。

	HashMap hm = new HashMap();

HashMap(int initialCapacity):构造一个空的 HashMap具有指定的初始容量和默认负载因子(0.75)。
HashMap(int initialCapacity, float loadFactor):构造一个空的 HashMap具有指定的初始容量和负载因子。

1.2、常用方法


V put(K key, V value):将键值对(指定的键与指定的值)添加到HashMap集合中。

	HashMap hm1 = new HashMap();
	hm1.put("A001","tom");//"A001"="tom",添加的键和值都是Object类型。
	HashMap<String,String> hm2 = new HashMap<String,String>();
	hm2.put("A001","tom");//"A001"="tom",添加的键和值都是String类型。

V get(Object key):返回指定的键所映射的值。

	HashMap hm1 = new HashMap();
	Object value = hm1.get("A001");
	
	HashMap<String,String> hm2 = new HashMap<String,String>();
	String s = hm2.get("A001");

V remove(Object key):从该HashMap集合中删除指定键的映射(如果存在)。

	HashMap hm1 = new HashMap();
	Object value = hm1.remove("A001");
	
	HashMap<String,String> hm2 = new HashMap<String,String>();
	String s = hm2.remove("A001");

注意:在定义HashMap对象时,如果泛型指定引用类型,则其方法的 K 或者 V 类型是指定的引用类型,添加的值与指定的引用类型不匹配,则编译不通过;如果不声明泛型,则默认是Object类型。

1.3、遍历的方法


(1)通过HashMap类的keySet() 方法对所有的键进行遍历。

Set<K> keySet():返回此Map集合中包含的键的Set集合。

	//返回此Map集合中包含的键的Set集合
	Set keyset = hm.keySet();
	//通过for循环对包含的键的Set集合进行遍历
	for (Iterator iterator = keyset.iterator(); iterator.hasNext();) {
		Object obj = (Object) iterator.next();
		System.out.println("key:"+obj);
	}
	//通过forEach循环对包含的键的Set集合进行遍历
	for (Object obj : keyset) {
		System.out.println("key:"+obj);
	}
	//通过Iterator迭代器对包含的键的Set集合进行遍历
	Iterator it = keyset.iterator();
	while (it.hasNext()) {
		Object obj = it.next();
		System.out.println("key:"+obj);
	}	


(2)通过HashMap类的values() 方法对所有的值进行遍历。

Collection<V> values():返回此Map集合中包含的值的Collection集合。

	//返回此Map集合中包含的值的Collection集合
	Collection value = hm.values();
	//通过for循环对包含的值的Collection集合进行遍历
	for (Iterator iterator = value.iterator(); iterator.hasNext();) {
		Object obj = (Object) iterator.next();
		System.out.println("value:"+obj);
	}
	//通过forEach循环对包含的值的Collection集合进行遍历
	for (Object obj : value) {
		System.out.println("value:"+obj);
	}
	//通过Iterator迭代器对包含的值的Collection集合进行遍历
	Iterator it = value.iterator();
	while(it.hasNext()) {
		Object obj = it.next();
		System.out.println("value:"+obj);
	}


(3)通过HashMap类的entrySet() 方法对所有的键值对进行遍历。

Set<Map.Entry<K,V>> entrySet():返回此Map集合中包含的映射(键值对)的Set集合。

	//返回此Map集合中包含的映射(键值对)的Set集合。  
	Set entry = hm.entrySet();
	//通过for循环对包含的映射(键值对)的Set集合进行遍历
	for (Iterator iterator = entry.iterator(); iterator.hasNext();) {
		Object obj = (Object) iterator.next();
		System.out.println("entry:"+obj);
	}
	//通过forEach循环对包含的映射(键值对)的Set集合进行遍历
	for (Object obj : entry) {
		System.out.println("entry:"+obj);
	}
	//通过Iterator迭代器对包含的映射(键值对)的Set集合进行遍历
	Iterator it = entry.iterator();
	while(it.hasNext()) {
		Object obj = it.next();
		System.out.println("entry:"+obj);
	}


(4)通过HashMap类的get方法来进行值的遍历。

	//返回此Map集合中包含的键的Set集合
	Set keyset1 = hm.keySet();
	//通过for循环对HashMap集合包含的值进行遍历
	for (Iterator iterator = keyset1.iterator(); iterator.hasNext();) {
		Object obj = (Object) iterator.next();
		System.out.println("key->get:"+hm.get(obj));
	}
	//通过forEach循环对HashMap集合包含的值进行遍历
	for (Object obj : keyset1) {
		System.out.println("key->get:"+hm.get(obj));
	}
	//通过Iterator迭代器对HashMap集合包含的值进行遍历
	Iterator it3 = keyset1.iterator();
	while(it3.hasNext()) {
		Object obj = it3.next();
		System.out.println("key->get:"+hm.get(obj));
	}


(5)通过HashMap类的forEach方法(在JDK1.8中)来进行键值对的遍历。

void forEach(BiConsumer<? super K,? super V> action):对此映射中的每个条目执行给定的操作,直到所有条目都被处理或操作引发异常。

	hm.forEach((k,v)->{
		System.out.println("key:"+k+",value:"+v);
		//输出结果
		//key:A003,value:maria
		//key:A001,value:tom
		//key:A002,value:jack
	});


1.4、HashMap的底层


数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构。例如栈、队列、树、图等都是从逻辑结构去抽象出来的,而映射到内存中的只有这两种物理组织形式。

1.4.1、JDK1.8以前


HashMap的底层在JDK1.8以前是数组+链表,也就是哈希表。

1.4.1.1、数组


采用一段 连续 的存储单元来存储数据,数组长度固定,不可改变。

1.4.1.2、链表


链表是一种物理存储单元上 非连续、非顺序 的存储结构。链表的数据元素存放在内存空间的地址是不连续的,数据元素的逻辑顺序是通过链表中的指针连接次序实现的。

链表的基本单位是节点,节点又包含两个部分,一个是数据域(储存节点含有的信息),一个是引用域(储存下一个节点或者上一个节点的地址)。每一个链表都包含多个节点。链表获取、修改数据比较麻烦,需要遍历进行查找,比数组慢,但是链表插入、删除数据非常方便,只需要修改对应节点的引用域中的指向地址即可。

链表分为单向链表和双向链表,单向链表的节点的引用域只有一个存放地址的地方,用来存放下一个节点的地址。双单向链表的引用域有两个存放地址的地方,分别存上一个节点和下一个节点的地址。
在这里插入图片描述
在这里插入图片描述

1.4.1.3、哈希表


(1)哈希表:哈希表的主干是一个数组,而数组中的元素则是一个单向链表。如果对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,在JDK1.8以前则是插入头部的,而在JDK1.8以后是插入尾部的。

相比上述数组和链表,在哈希表中进行添加,删除,查找等操作,性能十分之高,如果在不考虑哈希冲突的情况下,仅需一次定位即可完成。
在这里插入图片描述

(2)哈希函数:存储位置 = f(关键字) ,这个 函数f 就是哈希函数。如果要新增或查找某个元素,把当前元素的关键字通过哈希函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。哈希函数的设计好坏会直接影响到哈希表的优劣。
在这里插入图片描述
(图片来自:https://blog.csdn.net/woshimaxiao1/article/details/83661464)

(3)哈希冲突:哈希冲突(哈希碰撞),是指两个或者多个不同的元素,通过哈希函数得出的实际存储地址相同。也就是说,当对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,这就是所谓的哈希冲突。前面提到,哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀。但是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。

(4)哈希冲突的解决方案:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法等等。而HashMap即是采用了链地址法,也就是数组+链表的方式。

1.4.1.4、HashMap的哈希表


Entry:Entry是HashMap中的一个静态内部类,是HashMap中的基本组成单元,每一个Entry包含一个key-value键值对(Map其实就是保存了两个对象之间的映射关系的一种集合)。

HashMap:HashMap由数组+链表组成的。HashMap的主干是一个Entry类型的数组。链表主要是为了解决哈希冲突而存在的,如果定位到的数组位置不含链表,那么查找,添加等操作很快,仅需一次寻址即可。如果定位到的数组位置包含链表,对于添加Entry元素的操作,首先要遍历链表,如果Entry元素已经存在则进行覆盖,否则进行新增。对于查找Entry元素的操作,首先要遍历链表,然后通过key对象的equals方法逐一比对查找。从性能上来考虑,HashMap中的链表出现越少,性能越好。
在这里插入图片描述
(图片来自:https://blog.csdn.net/woshimaxiao1/article/details/83661464)

HashMap的初始容量默认为16,负载因子默认为0.75,。在一般的构造器中,没有为数组table分配内存空间,而是在执行put操作的时候才构建数组长度为16的数组table。自动扩容后的数组长度一定为2的次幂。

负载因子负载因子=元素个数/内部数组总数,负载因子又叫填充比,是一个介于0和1之间的浮点数,它决定了HashMap在扩容之前内部数组的填充度。HashMap的负载因子默认为0.75。

阈值阈值=数组长度*负载因子,阈值是数组长度(容量)乘以负载因子,当数组中存储的元素超过阈值时,则自动进行扩容。

扩容:当发生哈希冲突并且数组长度大于阈值的时候,需要进行数组扩容。在扩容时,需要新建一个长度是之前数组长度2倍的新数组,然后逐个遍历链表,重新计算索引位置,将当前的Entry数组中的元素全部传输过去(哈希表的数组中不存储实际数据,存储的是对象的引用地址)。如果新的索引位置为空,就直接插入。如果新的索引位置是Entry链表,则直接在链表头部插入。

Entry元素在数组的位置:先对Entry元素的key值通过hashcode方法后得到hashcode,对hashcode进行hash函数的扰乱运算后得到一个int类型的数值h,将数值h通过indexFor方法(数值h和 length-1进行位运算)得到最终数组索引位置。
在这里插入图片描述
(图片来自:https://blog.csdn.net/woshimaxiao1/article/details/83661464)

Entry的键为自定义类:在自定义类作为Entry的键时,需要重写equals方法和重写hashCode方法。如果在自定义类中不重写hashCode方法,则默认会调用Object类的hashCode方法,而Object类中的hashCode方法,一般来说只要是对象不同,得到的hashcode就不同。如果在自定义类的属性相同则就认为键是相同的情况下,不重写hashCode方法,则得到的hashcode就不同,会认为是两个不同的键。如果哈希表中存在键为自定义类的Entry元素,而在查找该Entry元素时就会重新创建一个自定义类,因为hashcode的不同,所以找不到哈希表中存储的元素。

1.4.2、JDK1.8以后


HashMap的底层在JDK1.8以后是 数组+链表+ 红黑树(下面 TreeMap 集合中有简单介绍)。HashMap数组中元素的结构可能是链表,也可能是红黑树。

在这里插入图片描述
(图片来自:https://www.cnblogs.com/doufuyu/p/10874689.html)

Node:Node是HashMap中的一个静态内部类,实现了Map.Entry接口,Node本质就是一个映射(键值对)。JDK1.8以后,Node是HashMap中的基本组成单元,链表和红黑树的每个节点都是一个Node对象。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }


JDK1.8以后,HashMap中的主干数组是一个Node类型的数组。

transient Node<K,V>[] table;


在链表时,如果对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,此时是插入链表的尾部。

如果一个数组元素上的链表上数据过多,就会导致性能下降。JDK1.8在JDK1.7的基础上针对此情况增加了红黑树来进行优化,利用红黑树快速增删改查的特点能提高HashMap的性能。

红黑树的转换条件:当存储数据结构链表长度超过8且数组长度大于64时,链表就转换为红黑树。

红黑树节点所占的空间是链表节点的两倍,在节点足够多的时候才会使用红黑树结构,如果节点变少了还是会变回链表结构。总的来说,如果节点太少的时候就没必要进行转换,不仅转换后的红黑树结构占空间,而且转换也需要花费时间。理想状态下,受随机分布的hashcode影响,链表中的节点遵循泊松分布,使用分布良好hashcode的时候,红黑树结构就很少使用。据统计,链表中的节点数是8的概率的接近千分之一,且此时链表的性能已经很差,所以 在这种比较罕见和极端的情况下才会把链表转变为红黑树

2、TreeMap集合


TreeMap是一个有序的key-value集合,底层是红黑树,红黑树结构支持排序,默认情况下通过Key值的自然顺序进行排序。

TreeMap类继承自AbstractMap类,同时实现了NavigableMap接口,而NavigableMap接口则继承自SortedMap接口,SortedMap接口是Map接口的子接口,使用SortedMap可以确保Map集合中的条目是排好序的。

2.1、构造方法


TreeMap():创建一个空的TreeMap集合,其中的元素(键值对)按照键的自然顺序进行排序。

		TreeMap tr = new TreeMap();
		tr.put("D3", "300");
		tr.put("B2", "100");
		tr.put("A10", "200");
		tr.put("A1", "400");
		for (Object obj : tr.entrySet()) {
			System.out.println(obj);
		}

输出结果:

A1=400
A10=200
B2=100
D3=300

TreeMap(Comparator comparator):创建一个空TreeMap,按照指定的comparator排序。

2.2、添加自定义类的键


如果TreeMap的存放元素的键是一个自定义类,为了实现排序的功能,则这个自定义类需要实现Comparable接口或者拥有一个自定义的比较器。TreeMap中存放元素的键是Integer、Double、String等包装类型的数据都能排序,因为包装类都实现了Comparable接口。

如果TreeMap的存放元素的键是一个自定义类,那么这个自定义类必须要实现Comparable接口或者有一个自定义的比较器。

2.2.1、自定义类实现Comparable接口

实现Comparable接口的步骤:

(1)自定义类实现Comparable接口。

(2)在自定义类中重写compareTo()方法。

(3)在compareTo()方法内定义比较算法,根据大小关系,返回正数、负数或0。

Comparable接口的compareTo方法

int compareTo(T o):将此对象与指定的对象进行比较以进行排序。 返回一个正整数表示当前对象大于指定对象,返回一个0表示当前对象等于指定对象,返回一个负整数表示当前对象小于指定对象。

(4)使用TreeMap的无参构造方法TreeMap()实例化TreeMap。

(5)使用TreeMap的put方法,将自定义类的对象作为键与对应的值一起存入TreeMap集合中。

import java.util.Iterator;
import java.util.Set;
import java.util.TreeMap;

public class TestCompare {
	public static void main(String[] args) {
		TreeMap map = new TreeMap();
		
		map.put(new Student(1001,"tom"),"tom");
		map.put(new Student(2001,"jack"),"jack");
		map.put(new Student(1003,"maria"),"maria");
		map.put(new Student(2005,"jane"),"jane");
		
		Set<Student> keys = map.keySet();
		
		Iterator<Student> iter = keys.iterator();
		
		while (iter.hasNext()) {
			Student key = iter.next();
			System.out.println("key:" + key + " ,value:" + map.get(key));
		}
	}
}

class Student implements Comparable{
	int num;
	String name;
	
	public Student(int num, String name) {
		super();
		this.num = num;
		this.name = name;
	}
	
	@Override
	public String toString() {
		return "Student [num=" + num + ", name=" + name + "]";
	}

	@Override
	public int compareTo(Object o) {
        Student ss = (Student) o;
        //降序排序
        int result = num < ss.num ? 1 : (num == ss.num ? 0 : -1);
        if (result == 0) {
            result = name.compareTo(ss.name);
        }
        return result;
	}
}

2.2.2、创建自定义类的比较器


如果想把自定义类的对象作为键存入TreeMap进行排序,那么自定义类必须有一个比较器。

Comparator接口:创建一个比较器的接口,一个类实现这个接口,相当于指定了一个排序的规则。

创建比较器的步骤:

(1)创建一个实现Comparator接口的类。

(2)在类中重写compare()方法。

(3)在compare()方法内定义比较算法,根据大小关系,返回正数、负数或0。

Comparator接口的compare方法

int compare(T o1,T o2):比较其两个参数的顺序。 返回一个正整数表示第一个参数大于第二个参数,返回一个0表示第一个参数等于第二个参数,返回一个负整数表示第一个参数小于第二个参数。

(4)使用TreeMap的有参构造方法TreeSet(Comparator<? super E> comparator)实例化TreeSet,创建一个比较器的对象,作为有参构造方法的参数。

(5)使用TreeMap的put方法,将自定义类的对象存入TreeSet集合中。

import java.util.Comparator;
import java.util.Iterator;
import java.util.Set;
import java.util.TreeMap;

public class TestCompare {
	public static void main(String[] args) {
		TreeMap<String, Double> map = new TreeMap<String, Double>(new MapComparator());
		map.put("key1", 1.0);
		map.put("key2", 2.0);
		map.put("key3", 3.0);
		
		Set<String> keys = map.keySet();
		
		Iterator<String> iter = keys.iterator();
		
		while (iter.hasNext()) {
			String key = iter.next();
			System.out.println("key:" + key + " ,value:" + map.get(key));
		}
	}
}

class MapComparator implements Comparator {
	public int compare(Object o1, Object o2) {
		String i1 = (String) o1;
		String i2 = (String) o2;
		return i1.compareTo(i2);
	}
}

输出结果:

key:key1 ,value:1.0
key:key2 ,value:2.0
key:key3 ,value:3.0

2.3、红黑树


红黑树,即红-黑二叉树。首先它是一颗二叉树,具备二叉树所有的特性。然后,红黑树更是一棵自平衡的排序二叉树。

基本的二叉树都需要满足一个基本性质:树中的任何节点的值大于它的左子节点,且小于它的右子节点。
在这里插入图片描述

按照这个二叉树基本性质使得树的检索效率大大提高。但是在生成二叉树的过程是非常容易失衡的,最坏的情况就是一边倒(只有右/左子树),这样势必会导致二叉树的检索效率大大降低。为了维持二叉树的平衡,出现了各种实现的算法,如:AVL,SBT,伸展树,TREAP ,红黑树等等。

平衡二叉树必须具备如下特性:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。即该二叉树的任何一个子节点,其左右子树的高度都相近。

红黑树就是节点是红色或者黑色的平衡二叉树,它通过颜色的约束来维持着二叉树的平衡。

2.3.1、红黑树的规则


一棵有效的红-黑二叉树有着如下规则

(1)每个节点都只能是红色或者黑色。

(2)根节点是黑色。

(3)每个叶节点(NIL节点,空节点)是黑色的。

(4)如果一个结点是红的,则它两个子节点都是黑的。也就是说在一条路径上不能出现相邻的两个红色结点。

(5)从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

这些规则强制约束了红黑树的关键性质:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。结果是这棵树大致上是平衡的。因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树。所以红黑树它是复杂而高效的。

下图为一颗典型的红黑二叉树。
在这里插入图片描述
(图片来自:https://blog.csdn.net/cyywxy/article/details/81151104)

2.3.2、红黑树的三大基本操作


红-黑二叉树主要包括三大基本操作:左旋、右旋、着色。

(1)着色:在不违反上述红黑树规则特点情况下,将红黑树某个节点颜色由红变黑,或者由黑变红。

(2)左旋:逆时针旋转两个节点,让一个节点(E)被其右子节点(S)取代,而该节点(E)成为右子节点(S)的左子节点。
在这里插入图片描述
(图片来自:http://www.cnblogs.com/yangecnu/p/Introduce-Red-Black-Tree.html)

(3)右旋:顺时针旋转两个节点,让一个节点(S)被其左子节点(E)取代,而该节点(S)成为左子节点(E)的右子节点。
在这里插入图片描述
(图片来自:http://www.cnblogs.com/yangecnu/p/Introduce-Red-Black-Tree.html)

2.3.3、红黑树新增节点


红黑树在新增节点过程中比较复杂,复杂归复杂它同样必须要依据上面提到的五点规范。通过排序二叉树的核心算法我们可以确认新增节点在二叉树的正确位置,找到正确位置后将节点插入即可,这样做了其实还没有完成。红黑树是一棵平衡排序二叉树,将节点插入后,普通的排序二叉树可能会出现失衡的情况,所以下一步就是要进行调整。调整的过程务必会涉及到红黑树的左旋、右旋、着色三个基本操作。

2.3.4、红黑树删除节点


针对于红黑树的增加节点而言,删除显得更加复杂,使原本就复杂的红黑树变得更加复杂。同时删除节点和增加节点一样,同样是找到删除的节点,删除之后再调整红黑树。

但是这里的删除节点并不是直接删除,而是通过一种捷径来删除的:找到被删除的节点D的子节点C,用子节点C来替代节点D,直接删除子节点C即可。所以这里就将删除父节点D的事情转变为了删除子节点C的事情,这样处理就将复杂的删除事件简单化了。

寻找子节点C的规则是右分支最左边的子节点,或者左分支最右边的子节点。

红-黑二叉树删除节点,最大的麻烦是要保持各分支黑色节点数目相等。 因为是删除节点,所以不用担心存在颜色冲突问题(插入节点才会引起颜色冲突)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值