1. 文章
  2. Java 进阶线
  3. 数据处理与集合

Collections 与 Map:排序、统计和购物车建模

更新于 30 分钟阅读 第 7 / 15 站 #java#java-advanced#day07

把键的唯一性转化为业务规则,用 Map 完成计数和嵌套结构。

我的学习 →

Java 进阶线 · 线路目录
本页导读

day07|java.util 包下的常用 API:集合(Collections、Map、Stream 流)

本日接着 day06 的集合往下走,由两块内容组成。第一块是 Collection 的补充知识:可变参数(让方法灵活接收个数不定的数据)、Collections 工具类(批量添加、打乱、排序)、以及把这三样东西串起来用的综合案例(斗地主)。第二块是 Map 双列集合:为什么需要”键值对”这种数据结构、Map 的常用方法与三种遍历方式、HashMap / LinkedHashMap / TreeMap 三种实现各自的特点与底层原理、最后用”集合的嵌套”表达一对多关系。学完后应能回答:Collections.sort() 的两种排序规则分别怎么写、Map 为什么不能直接用增强 for 遍历、自定义对象做 HashMap 的键为什么必须重写 hashCode() 与 equals()、TreeMap 排序排的是键还是值。

本日 PPT 目录(第 2 页 / 第 10 页):Collection 的其他相关知识(前置知识:可变参数 → Collections → 综合案例)→ Map 集合(概述 → 常用方法 → 遍历方式 → HashMap → LinkedHashMap → TreeMap → 补充知识:集合的嵌套)。

四点说明(供复习时核对):

  • 课件第 1 页标题是「集合进阶(二)」,全篇共 56 页;本日 PPT 讲的就是 Collection 的补充部分与整个 Map 体系。
  • 课件文件名里写了「Stream 流」,但本日 56 页幻灯片逐页核对后没有任何 Stream 内容(目录页第 2 页也只有上面两块)。Stream 流实际在 day08 课件《java.io包下的常用API-io流》第 1–12 页,讲义归入 day08 的文章,本文不臆造,详见第 12 章。
  • 课件的「常用方法」页在抽取文本中只留下 [表格] 占位、没有表体。本文中的表格是按课件表格的原文还原的(含方法签名与说明文字)。
  • 图片核对过程:候选图 25 张逐张打开查看后,只有第 9 页斗地主游戏界面与第 13 页电商购物车截图带有场景信息;其余分别是卡通人物/吉祥物/表情包(第 4、12、13、29、43、55 页)、纯装饰图标(第 20 页点赞手势、第 27 页对勾、第 43 页铅笔、“划重点”贴图),以及第 11 / 24 / 30 / 41 / 53 页的大字标题条(如”单列集合""Map 集合的遍历方式""集合的嵌套”)。按规范中”丢弃封面/章节分割页大字背景、卡通、表情包、纯装饰图标”的要求,本文只内嵌那两张场景截图;课件其余知识点均以文字/代码呈现(课堂演示代码在 IDE 中现场敲写,课件未配代码截图)。

1. 前置知识:可变参数

1.1 什么是可变参数

课件第 3 页的定义:可变参数就是一种特殊形参,定义在方法、构造器的形参列表里,格式是 数据类型... 参数名称;。

维度内容
特点可以不传数据给它;可以传一个或者同时传多个数据给它;也可以传一个数组给它
好处常常用来灵活的接收数据
注意事项 1可变参数在方法内部就是一个数组
注意事项 2一个形参列表中可变参数只能有一个
注意事项 3可变参数必须放在形参列表的最后面

课件第 4 页给的需求正是它的典型用法:定义一个求和方法,让它能算 1 个、2 个、3 个……n 个数据的和,甚至可以一个参数都不传就调用。如果不用可变参数,要么重载一堆方法,要么让调用方每次都自己 new 一个数组。

1.2 代码示例

import java.util.Arrays;

public class Test {
    public static void main(String[] args) {
        // 目标:掌握可变参数的使用。
        // 需求:求任意个整数数据的和。
        sum();                          // 可以不传参数
        sum(10);                        // 可以传一个参数
        sum(20, 15, 32, 100);           // 可以传多个参数
        sum(new int[]{10, 20, 30, 40}); // 也可以直接传一个数组
    }

    // 作用:接收数据非常灵活
    // 注意事项:可变参数在形参列表中只能出现一个,且必须放到形参列表的最后面
    public static void sum(int... nums) {
        // 本质:可变参数在方法内部就是一个数组
        System.out.println("个数:" + nums.length);
        System.out.println("内容:" + Arrays.toString(nums));
    }
}

2. Collections 工具类

2.1 定位

课件第 6 页:Collections 是一个用来操作集合的工具类,提供的是静态方法,所以直接用 Collections.方法名(...) 调用——注意它和接口 Collection 只差一个字母,前者是工具类,后者是单列集合的根接口。

2.2 常用静态方法(课件第 6 页)

方法名称说明
public static <T> boolean addAll(Collection<? super T> c, T... elements)给集合批量添加元素
public static void shuffle(List<?> list)打乱 List 集合中的元素顺序
public static <T> void sort(List<T> list)对 List 集合中的元素进行升序排序
public static <T> void sort(List<T> list, Comparator<? super T> c)对 List 集合中元素,按照比较器对象指定的规则进行排序

第 6 页同时强调了一句重要限制:Collections 只能支持对 List 集合进行排序(sort、shuffle 的形参就是 List,Set 集合没有索引、本身又可能无序,排不了)。作业一第 2 题(双色球)就利用了这个限制:先把 HashSet 里的号码倒进 ArrayList,再用 Collections.sort() 排序。

2.3 两种排序方式(课件第 7 页)

排序方式 1:默认规则

方法名称说明
public static <T> void sort(List<T> list)对 List 集合中元素按照默认规则排序

课件第 7 页的注意:本方法可以直接对自定义类型的 List 集合排序,但自定义类型必须实现了 Comparable 接口,指定了比较规则才可以。(否则编译不通过)

Comparable 的规则写成口诀就是:compareTo() 里把左边对象当成 this、右边对象当成参数 o,认为左 > 右就返回正整数、左 < 右就返回负整数、相等返回 0,集合就会按这个规则升序排列。

排序方式 2:比较器规则

方法名称说明
public static <T> void sort(List<T> list, Comparator<? super T> c)对 List 集合中元素,按照比较器对象指定的规则进行排序

方式 2 把比较规则从类里搬到调用处,好处是不用为了换个排序字段就去改实体类,配合 Lambda 写起来更短。

2.4 代码示例

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * 目标:掌握 Collections 集合工具类的使用。
 */
public class CollectionsTest1 {
    public static void main(String[] args) {
        // 1、public static <T> boolean addAll(Collection<? super T> c, T... elements):为集合批量添加数据
        List<String> names = new ArrayList<>();
        Collections.addAll(names, "张无忌", "赵敏", "小昭", "殷素素");
        System.out.println(names);

        // 2、public static void shuffle(List<?> list):打乱 List 集合中的元素顺序
        Collections.shuffle(names);
        System.out.println(names);

        List<Student> students = new ArrayList<>();
        Student s1 = new Student("赵敏", 19, 169.5);
        Student s2 = new Student("周芷若", 18, 171.5);
        Student s3 = new Student("周芷若", 18, 171.5);
        Student s4 = new Student("小昭", 17, 165.5);
        Collections.addAll(students, s1, s2, s3, s4);

        // 3、public static <T> void sort(List<T> list):对 List 集合中的元素进行升序排序
        //    方式一:让对象的类实现 Comparable 接口,重写 compareTo 方法,指定大小比较规则
        Collections.sort(students);
        System.out.println(students);

        // 4、public static <T> void sort(List<T> list, Comparator<? super T> c)
        //    方式二:指定 Comparator 比较器对象,再指定比较规则(这里按身高降序)
        Collections.sort(students, (o1, o2) -> Double.compare(o2.getHeight(), o1.getHeight()));
        System.out.println(students);
    }
}
// 方式一:让自定义的类实现比较规则接口
public class Student implements Comparable<Student> {
    private String name;
    private int age;
    private double height;

    // s1.compareTo(s2):左边对象 s1 就是 this,右边对象 s2 就是参数 o
    @Override
    public int compareTo(Student o) {
        // 制定比较规则:按照年龄排序
        // 官方规则:认为左边 > 右边返回正整数;左边 < 右边返回负整数;相等返回 0(自动升序)
        // return this.age - o.age;  // 升序
        return o.age - this.age;     // 降序
    }

    public Student() {
    }

    public Student(String name, int age, double height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    // 此处省略 getName()/setName()、getAge()/setAge()、getHeight()/setHeight() 与 toString()
}

3. 综合案例:斗地主

3.1 业务需求(课件第 9 页)

在启动游戏房间的时候,应该提前准备好 54 张牌;接着,需要完成洗牌、发牌、对牌排序、看牌。

课件的业务分析:

  • 总共有 54 张牌:点数 "3","4","5","6","7","8","9","10","J","Q","K","A","2"(13 个)× 花色 "♠","♥","♣","♦"(4 个)= 52 张,再加上大小王 "👲"、"🃏"。
  • 斗地主:发出 51 张牌,剩下 3 张做为底牌。

斗地主游戏界面,三家各 17 张手牌,另留 3 张底牌

3.2 实现思路

课件把”分析实现”拆成”先设计对象、再分步骤操作”,配套示例工程里落成了两个类:

  • Card(牌):一张牌有 number(点数)、color(花色),再加一个 size(大小权重,用来排序)。
  • Room(房间):持有 ArrayList<Card> allCards,在实例初始化块里造好 54 张牌,start() 里依次做洗牌 → 发牌 → 排序 → 看牌。

发牌的思路是:把洗好的牌按索引轮流发给三个玩家(i % 3 决定发给谁),循环条件 i < allCards.size() - 3 保证最后 3 张不发出去,再用 subList() 取出底牌。

排列手牌时用到方式 2 的 Collections.sort(list, Comparator),比较的是牌的 size 权重,而不是点数/花色的字符串。示例工程里用 o2.getSize() - o1.getSize() 得到了从大到小的顺序。

3.3 代码示例

// 牌的实体类(示例工程用 Lombok 简化了 get/set 与构造器)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Card {
    private String number; // 点数
    private String color;  // 花色
    private int size;      // 大小权重,用于排序

    @Override
    public String toString() {
        return number + color;
    }
}
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

// 房间对象
public class Room {
    // 1、准备一副牌
    private ArrayList<Card> allCards = new ArrayList<>();

    // 2、初始化 54 张牌
    {
        // 3、准备点数:个数确定,类型确定
        String[] numbers = {"3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", "2"};
        // 4、准备花色:个数确定,类型确定
        String[] colors = {"♠", "♥", "♣", "♦"};

        // 5、组装每一张牌对象
        int size = 0;
        for (String number : numbers) {
            size++;
            for (String color : colors) {
                // 6、创建牌对象封装这张牌的数据
                Card c = new Card(number, color, size);
                // 7、加入到集合中去
                allCards.add(c);
            }
        }
        // 8、单独加入大小王
        allCards.add(new Card("", "🃏", ++size));
        allCards.add(new Card("", "👲", ++size));
        System.out.println("新牌是:" + allCards);
    }

    public void start() {
        // 9、洗牌
        Collections.shuffle(allCards);
        System.out.println("洗牌后:" + allCards);

        // 10、发牌:定义三个玩家
        List<Card> lhc = new ArrayList<>();
        List<Card> ryy = new ArrayList<>();
        List<Card> dfbb = new ArrayList<>();

        // 发出去 51 张,最后 3 张留作底牌
        for (int i = 0; i < allCards.size() - 3; i++) {
            Card c = allCards.get(i);
            if (i % 3 == 0) {
                lhc.add(c);
            } else if (i % 3 == 1) {
                ryy.add(c);
            } else {
                dfbb.add(c);
            }
        }

        // 拿到最后三张底牌
        List<Card> lastThreeCards = allCards.subList(allCards.size() - 3, allCards.size());
        // 抢地主:底牌归地主
        ryy.addAll(lastThreeCards);

        // 11、对牌排序
        sortCards(lhc);
        sortCards(ryy);
        sortCards(dfbb);

        // 12、看牌
        System.out.println("啊冲:" + lhc);
        System.out.println("啊盈:" + ryy);
        System.out.println("啊败:" + dfbb);
    }

    public void sortCards(List<Card> cards) {
        // 方式二:把比较规则写在调用处
        Collections.sort(cards, new Comparator<Card>() {
            @Override
            public int compare(Card o1, Card o2) {
                return o2.getSize() - o1.getSize();
            }
        });
    }
}
public class Test {
    public static void main(String[] args) {
        // 目标:完成斗地主案例的实现。
        // 1、每张牌是一个对象,设计一个牌类
        // 2、设计一个房间类,用于创建房间对象、开启游戏
        Room r = new Room();
        r.start();
    }
}

(补充说明)subList(from, to) 返回的是原 List 的一个视图,对它的结构修改会影响原集合;本案例只取出来读,所以安全。

4. Map 集合概述

4.1 什么是 Map 集合(课件第 12 页)

课件先在第 11 页用一组”脉劫 / 康帅傅 / 粤利粤”的山寨商品做引子,再给出 Map 的定义:

  • Map 集合称为双列集合,格式:{key1=value1, key2=value2, key3=value3, ...},一次需要存一对数据做为一个元素。
  • Map 集合的每个元素”key=value”称为一个键值对 / 键值对对象 / 一个 Entry 对象,Map 集合也被叫做”键值对集合”。
  • Map 集合的所有键是不允许重复的,但值可以重复;键和值是一一对应的,每一个键只能找到自己对应的值。

一句话总结课件的第 11–12 页对照:Collection 是单列集合,一次存一个元素;Map 是双列集合,一次存”键 + 值”两个对象,但整体仍是一个元素。

4.2 什么时候用 Map(课件第 13 页)

课件给的判断标准只有一句:需要存储一一对应的数据时,就可以考虑使用 Map 集合来做。

课件举的例子是购物车场景 {商品1=2, 商品2=3, 商品3=2, 商品4=3}——商品是键、数量是值,“同一件商品的数量”正是一一对应的数据:

电商购物车截图,每件商品对应一个购买数量,正是 Map 的键值对场景

4.3 Map 集合体系(课件第 14 / 33 / 45 / 48 页)

Map<K, V>                       ← 接口(双列集合的祖宗)
├── HashMap<K, V>               ← 实现类,用得最多
├── LinkedHashMap<K, V>         ← 实现类
└── TreeMap<K, V>               ← 实现类
实现类(由键决定)特点课件备注
HashMap无序、不重复、无索引用的最多
LinkedHashMap有序、不重复、无索引有序指保留添加顺序
TreeMap按照键的大小默认升序排序、不重复、无索引只能对键排序

课件特别强调:Map 系列集合的特点都是由键决定的,值只是一个附属品,值是不做要求的。 这句话解释了为什么后面讲底层原理时,说的都是”键怎么算哈希、键怎么比较大小”。

4.4 小结自测(课件第 15 页)

  • Map 集合是什么?——键值对集合。
  • 什么时候考虑使用?——需要存储一一对应的数据时。
  • 有哪些实现类、各自特点?——HashMap:键无序、不重复、无索引;LinkedHashMap:键有序、不重复、无索引;TreeMap:键排序、不重复、无索引。(三者的值都不做要求)

5. Map 的常用方法

5.1 为什么要先学 Map 的方法(课件第 17 页)

课件的解释是:Map 是双列集合的祖宗,它的功能是全部双列集合都可以继承过来使用的。 也就是说 HashMap、LinkedHashMap、TreeMap 的增删改查方法都来自 Map,先把这一套学会,后面三个实现类就只需要记”特点差别”。

5.2 常用方法(课件第 18 页)

方法名称说明
public V put(K key, V value)添加元素
public int size()获取集合的大小
public void clear()清空集合
public boolean isEmpty()判断集合是否为空,为空返回 true,反之
public V get(Object key)根据键获取对应值
public V remove(Object key)根据键删除整个元素
public boolean containsKey(Object key)判断是否包含某个键
public boolean containsValue(Object value)判断是否包含某个值
public Set<K> keySet()获取全部键的集合
public Collection<V> values()获取 Map 集合的全部值

两个容易踩的点:

  • get() 找不到键时返回 null,但存进去的值为 null 时也返回 null,所以不能只凭 get() == null 判断键是否存在,要判断键在不在应该用 containsKey()。
  • put() 遇到已经存在的键会覆盖(课件示例注释原话:“后面重复的键会覆盖前面整个数据!”);HashMap 的键和值都允许为 null。

5.3 代码示例一:Map 集合的特点

import java.util.HashMap;
import java.util.Map;

public class MapDemo1 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的特点
        // 特点:按照键,无序、不重复、无索引;值不做要求,键和值都可以是 null
        Map<String, Integer> map = new HashMap<>(); // 多态,一行经典代码
        map.put("华为手表", 31);
        map.put("iphone15", 1);
        map.put("mete60", 10);
        map.put("Java入门到跑路", 2);
        map.put("iphone15", 31); // 后面重复的键会覆盖前面整个数据!
        map.put("娃娃", 1);
        map.put("雨伞", 10);
        map.put(null, null);
        // {Java入门到跑路=2, null=null, iphone15=31, 雨伞=10, mete60=10, 华为手表=31, 娃娃=1}
        System.out.println(map);
    }
}

5.4 代码示例二:常用 API 逐个演示

import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapDemo2 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的常用 API(重点)
        Map<String, Integer> map = new HashMap<>();
        map.put("手表", 2);
        map.put("iphone", 31);
        map.put("huawei", 365);
        map.put("iphone", 1);   // 覆盖上面 iphone=31
        map.put("娃娃", 31);
        map.put("Java入门", 1);
        map.put(null, null);
        System.out.println(map);
        // map = {null=null, 手表=2, huawei=365, Java入门=1, 娃娃=31, iphone=1}

        // 1、获取集合的大小(元素个数)
        System.out.println(map.size());   // 6

        // 2、清空集合
        // map.clear();

        // 3、判断集合是否为空
        System.out.println(map.isEmpty());

        // 4、根据键获取对应的值(重点)
        System.out.println(map.get("手表"));   // 2
        System.out.println(map.get("手表2"));  // 没有这个键就返回 null

        // 5、根据键删除整个数据,返回删除数据对应的值(重点)
        System.out.println(map.remove("娃娃")); // 31
        System.out.println(map);

        // 6、判断是否包含某个键(重点)
        System.out.println(map.containsKey("娃娃"));    // false
        System.out.println(map.containsKey("huawei"));  // true
        System.out.println(map.containsKey(null));      // true

        // 7、判断是否包含某个值
        System.out.println(map.containsValue(1));       // true
        System.out.println(map.containsValue("1"));     // false(类型不同)

        // 8、获取 Map 集合的全部键,返回一个 Set 集合
        Set<String> keys = map.keySet();
        for (String key : keys) {
            System.out.println(key);
        }

        // 9、获取 Map 集合的全部值,返回一个 Collection 集合
        Collection<Integer> values = map.values();
        for (Integer value : values) {
            System.out.println(value);
        }
    }
}

6. Map 集合的遍历方式

课件第 20 页把遍历方式归成三种:① 键找值;② 键值对(把”键值对”看成一个整体进行遍历,难度较大);③ Lambda(JDK 1.8 开始之后的新技术,非常的简单)。

6.1 方式一:键找值(先用 keySet 再 get)

需要用到的方法(课件第 21 页):

方法名称说明
public Set<K> keySet()获取所有键的集合
public V get(Object key)根据键获取其对应的值

流程(课件第 22 页用”编号丈夫 ↔ 编号妻子”打比方):先从 Map 里拿出全部键(Set 集合,可以用增强 for 遍历),然后对每个键调一次 get() 把值找出来。缺点很明显:遍历一次要额外调用一次 get() 去查表。

import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapDemo1 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的遍历方式一:键找值
        Map<String, Integer> map = new HashMap<>(); // 一行经典代码
        map.put("iphone", 6);
        map.put("小米", 3);
        map.put("huawei", 3);
        map.put("诺基亚", 31);
        System.out.println(map); // {huawei=3, 诺基亚=31, iphone=6, 小米=3}

        // 1、获取 map 集合的全部键
        Set<String> keys = map.keySet();
        // 2、根据键提取值
        for (String key : keys) {
            Integer value = map.get(key);
            System.out.println(key + "====>" + value);
        }
    }
}

6.2 方式二:键值对(entrySet + Map.Entry)

课件第 24 页先演示了一个”走不通”的写法:想直接用增强 for 遍历 Map 本身——

for (元素类型 kv : map) {   // 编译不通过
    ...
}

原因是课件第 24 页给出的那句话:元素类型无法直接确定! Map 里的一个元素是”键 + 值”两段数据,Java 里没有这样一个现成的类型可以直接写在 for 的括号里。解决办法是先调 entrySet() 把 Map 转成 Set 集合,集合里的元素类型就明确为 Map.Entry<K, V> 了。

需要用到的方法(课件第 25 页):

Map 提供的方法说明
Set<Map.Entry<K, V>> entrySet()获取所有”键值对”的集合
Map.Entry 提供的方法说明
K getKey()获取键
V getValue()获取值
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class MapDemo2 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的遍历方式二:键值对遍历(难度大点)
        Map<String, Integer> map = new HashMap<>();
        map.put("蜘蛛精", 1000);
        map.put("小龙女", 23);
        map.put("木婉清", 31);
        map.put("黄蓉", 35);
        System.out.println(map);
        // map = {蜘蛛精=1000, 小龙女=23, 黄蓉=35, 木婉清=31}

        // 1、直接增强 for 遍历 Map 集合是不行的,因为键值对直接来看是不存在数据类型的

        // 2、调用 entrySet() 把 Map 集合转换成 Set 集合来遍历
        // map = {蜘蛛精=1000, 小龙女=23, 黄蓉=35, 木婉清=31}
        //   ↓ map.entrySet()
        // Set<Map.Entry<String, Integer>> entries = [(蜘蛛精=1000), (小龙女=23), (黄蓉=35), (木婉清=31)]
        //                                                     ↑ 每个元素就是一个 Entry 对象
        Set<Map.Entry<String, Integer>> entries = map.entrySet();
        for (Map.Entry<String, Integer> entry : entries) {
            String key = entry.getKey();
            Integer value = entry.getValue();
            System.out.println(key + " ===> " + value);
        }
    }
}

6.3 方式三:Lambda(forEach)

需要用到的方法(课件第 29 页):

方法名称说明
default void forEach(BiConsumer<? super K, ? super V> action)结合 lambda 遍历 Map 集合

BiConsumer 是”接收两个参数、没有返回值”的函数式接口,两个参数正好对应键和值,所以 Lambda 里写 (k, v) -> {...} 就行:

import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;

public class MapDemo3 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的遍历方式三:Lambda
        Map<String, Integer> map = new HashMap<>();
        map.put("蜘蛛精", 1000);
        map.put("小龙女", 23);
        map.put("木婉清", 31);
        map.put("黄蓉", 35);
        System.out.println(map);
        // map = {蜘蛛精=1000, 小龙女=23, 黄蓉=35, 木婉清=31}

        // 匿名内部类写法
        map.forEach(new BiConsumer<String, Integer>() {
            @Override
            public void accept(String key, Integer value) {
                System.out.println(key + "===>" + value);
            }
        });

        // Lambda 写法
        map.forEach((key, value) -> {
            System.out.println(key + "===>" + value);
        });
    }
}

三种方式怎么选:只要键用 keySet();键和值都要用用 entrySet()(省掉一次 get() 查表);只是对每个键值对做一件事(打印、累加等)用 forEach() 最省事。

7. 案例:统计投票人数

7.1 需求与分析(课件第 30–31 页)

某个班级 80 名学生,现在需要组织秋游活动,班长提供了四个景点依次是(A、B、C、D),每个学生只能选择一个景点,请统计出最终哪个景点想去的人数最多。

课件的分析步骤把”Map 做计数”的套路写得很清楚:

  1. 将 80 个学生选择的数据拿到程序中去,形如 [A, A, B, A, B, C, D, ...];
  2. 准备一个 Map 集合用于存储统计结果,Map<String, Integer>,键是景点,值代表投票数量;
  3. 遍历这 80 个选择,每遍历一个景点,就看 Map 中是否存在该景点:不存在就存入”景点=1”,存在则把它对应的值 +1。

7.2 代码示例

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

public class MapTest4 {
    public static void main(String[] args) {
        // 目标:使用 Map 集合完成投票案例
        // 1、把 80 个学生选择的景点造出来
        String[] locations = {"玉龙雪山", "伶仃岛", "三亚", "泰国"};

        // 2、定义一个 List 集合,随机挑选 80 个学生想去的景点
        List<String> data = new ArrayList<>();
        Random r = new Random();
        for (int i = 0; i < 80; i++) {
            data.add(locations[r.nextInt(locations.length)]);
        }
        System.out.println(data);

        // 3、定义一个 Map 集合,键存储景点,值存储想去的人数
        Map<String, Integer> map = new HashMap<>();
        for (String s : data) {
            // 4、判断 map 集合中是否出现了这个景点键,如果没有出现,说明是第一次添加
            if (map.containsKey(s)) {
                // 之前统计过,其值 +1
                map.put(s, map.get(s) + 1);
            } else {
                // 第一次添加
                map.put(s, 1);
            }
        }

        map.forEach((s, n) -> {
            System.out.println(s + "选择人数是:" + n);
        });
    }
}

统计结束后想知道”哪个景点人最多”,需要再比较一次各个 value(Map 本身没有”按值排序”的功能,TreeMap 也只排键)。

8. HashMap 的底层原理

8.1 与 HashSet 的关系(课件第 34 页)

课件第 34 页一句话把两个集合打通了:

  • HashMap 跟 HashSet 的底层原理是一模一样的,都是基于哈希表实现的。
  • 实际上:原来学的 Set 系列集合的底层就是基于 Map 实现的,只是 Set 集合中的元素只要键数据,不要值数据而已。

课件给出的源码印证是 HashSet 的构造器:

public HashSet() {
    map = new HashMap<>();
}

也就是说,HashSet 里其实”包”了一个 HashMap,往 HashSet 里加的元素被当成了 HashMap 的键。哈希表本身是一种增删改查数据,性能都较好的数据结构。

8.2 哈希表的组成(课件第 34–40 页)

版本哈希表 =
JDK8 之前数组 + 链表
JDK8 开始数组 + 链表 + 红黑树

课件用第 35–40 页的连续动画演示了 map.put() 的过程,关键规则有三条:

  1. 利用键计算哈希值,跟值无关——先算出键的哈希值,再换算成数组下标,把该键值对放进对应的桶里。
  2. 同一个下标上如果已经有元素,就以链表形式挂在后面(课件里用一串 null 表示空桶,桶被占用后挂成链)。
  3. 链表长度超过 8 且数组长度 >= 64 时,自动转成红黑树——这就是 JDK8 相比 JDK7 的最大改动,避免极端情况下链表过长导致查询退化。

8.3 HashMap 的特点与键唯一性(课件第 41–42 页)

  • 特点:无序、不重复、无索引(由键决定特点),但增删改查性能都较好。
  • HashMap 的键依赖 hashCode 方法和 equals 方法保证键的唯一。
  • 如果键存储的是自定义类型的对象,需要重写 hashCode() 和 equals() 方法,这样才能在多个对象内容一样时,让 HashMap 认为是重复的键。

(补充说明)课件第 42 页的自测问答就是这两条,背诵版为:“由键决定:无序、不重复、无索引,底层是哈希表结构,增删改查性能都较好”;“键的唯一性靠 hashCode 与 equals,自定义对象作键必须重写这两个方法”。

8.4 案例:HashMap 存储自定义对象并遍历(课件第 43 页)

需求:创建一个 HashMap 集合,键是学生对象(Student),值是籍贯(String)。存储三个键值对元素,并遍历。

课件给的思路是标准五步:定义学生类 → 创建 HashMap 集合对象 → 创建学生对象 → 把学生添加到集合 → 遍历集合。配套示例工程把”键是自定义对象”这件事演示得更彻底,用的键是 Movie(电影名、评分、主演):

import java.util.HashMap;
import java.util.Map;

public class MapTest1 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合(键)去重复的机制
        Map<Movie, String> map = new HashMap<>();
        map.put(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"), "19:00");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "20:50");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "21:50"); // 与上一行内容相同 → 覆盖
        map.put(new Movie("阿甘正传", 7.5, "汤姆汉克斯"), "21:00");
        System.out.println(map);
    }
}
@Data // @Data 自带无参构造 + get/set + toString + hashCode + equals
@NoArgsConstructor
@AllArgsConstructor
public class Movie implements Comparable<Movie> {
    private String name;
    private double score;
    private String actor;

    @Override
    public int compareTo(Movie o) {
        return Double.compare(this.score, o.score);
    }
}

示例里的 Movie 有 Lombok 的 @Data 注解,它自动生成的 hashCode() / equals() 就是”三个属性都相同即视为同一个键”——所以第二个”三傻宝莱坞”把第一个覆盖掉了(只留下 21:50)。如果这里不重写 hashCode/equals,HashMap 就会把两个 new 出来的对象当成两个不同的键,去重逻辑失效。

9. LinkedHashMap 的原理

9.1 原理(课件第 46 页)

课件的原话:底层数据结构依然是基于哈希表实现的,只是每个键值对元素又额外多了一个双链表的机制记录元素顺序(保证有序)。实际上:原来学习的 LinkedHashSet 集合的底层原理就是 LinkedHashMap。

也就是说:LinkedHashMap = HashMap 的哈希表结构 + 一条贯穿所有 Entry 的双链表。“有序”指的就是这条双链表维持的添加顺序,同样由键决定特点(有序、不重复、无索引),值不做要求。

9.2 代码示例

import java.util.LinkedHashMap;
import java.util.Map;

public class MapTest2 {
    public static void main(String[] args) {
        // 目标:掌握 Map 集合的特点
        // LinkedHashMap:按照键有序(保留添加顺序)、不重复、无索引;值不做要求,键和值都可以是 null
        Map<String, Integer> map = new LinkedHashMap<>();
        map.put("手表", 2);
        map.put("iphone", 31);
        map.put("huawei", 365);
        map.put("iphone", 1);
        map.put("娃娃", 31);
        map.put("Java入门", 1);
        map.put(null, null);
        // {手表=2, iphone=1, huawei=365, 娃娃=31, Java入门=1, null=null}
        System.out.println(map);
    }
}

对照上面 MapDemo1(HashMap)的输出 {Java入门到跑路=2, null=null, iphone15=31, ...} 可以看到:同样的 put 顺序,HashMap 打印出来是”乱”的,LinkedHashMap 打印出来就是添加顺序。另外注意 iphone 的值被后一次 put 改成了 1,但它的位置仍停在第一次加入的位置(修改不算新插入)。

10. TreeMap

10.1 特点与原理(课件第 49 页)

  • 特点:不重复、无索引、可排序(按照键的大小默认升序排序,只能对键排序)。
  • 原理:TreeMap 跟 TreeSet 集合的底层原理是一样的,都是基于红黑树实现的排序。

10.2 两种指定排序规则的方式(课件第 49–50 页)

方式做法
方式一让类实现 Comparable 接口,重写比较规则(compareTo)
方式二TreeMap 有一个有参数构造器,支持创建 Comparator 比较器对象,用来指定比较规则

课件第 50 页的自测答案就是这两种;效果上,方式二传给构造器的比较器优先级更高(同时存在时会覆盖类里的自然排序)。

import java.util.Map;
import java.util.TreeMap;

public class MapTest3 {
    public static void main(String[] args) {
        // 目标:了解 TreeMap 集合:按照键升序排序,不重复,无索引
        // 方式二:TreeMap 集合可以自带比较器对象指定比较规则
        Map<Movie, String> map = new TreeMap<>(
                (m1, m2) -> Double.compare(m2.getScore(), m1.getScore())); // 按评分降序

        // 方式一:Movie 类实现 Comparable 接口指定比较规则(见第 8.4 节的 Movie)
        map.put(new Movie("摔跤吧,爸爸", 9.5, "阿米尔汗"), "19:00");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "20:50");
        map.put(new Movie("三傻宝莱坞", 8.5, "阿米尔汗2"), "21:50");
        map.put(new Movie("阿甘正传", 7.5, "汤姆汉克斯"), "21:00");
        System.out.println(map);
    }
}

10.3 案例:TreeMap 练习(课件第 51 页)

需求:创建一个 TreeMap 集合,键是学生对象(Student),值是籍贯(String)。学生属性姓名和年龄,按照年龄进行排序并遍历。

思路同样是五步:定义学生类 → 创建 TreeMap 集合对象 → 创建学生对象 → 把学生添加到集合 → 遍历集合。关键点在于”按年龄排序”必须落成键的比较规则——要么 Student implements Comparable<Student> 并在 compareTo() 里比较年龄,要么用 new TreeMap<>((s1, s2) -> s1.getAge() - s2.getAge()):

// 按课件需求整理的写法(课堂在 IDE 中演示)
Map<Student, String> map = new TreeMap<>((s1, s2) -> s1.getAge() - s2.getAge());
map.put(new Student("张三", 18, 171.5), "北京");
map.put(new Student("李四", 20, 180.5), "上海");
map.put(new Student("王五", 19, 165.5), "广州");
map.forEach((student, place) -> System.out.println(student.getName() + "->" + place));
// 输出顺序:张三(18) → 王五(19) → 李四(20)

(补充说明)TreeMap 判断”键重复”用的是比较结果是否为 0,而不是 equals();如果比较规则只比年龄,那么年龄相同的两个不同学生会被当成同一个键而被丢掉。

11. 补充知识:集合的嵌套

11.1 什么是集合的嵌套(课件第 53 页)

课件给的一句话定义:集合的嵌套指的就是集合中的元素又是一个集合。 在 Map 体系里最常用的两种嵌套是:

  • Map<String, List<String>>:一个键对应多个值(一对多),例如”省份 → 城市列表”。
  • Map<String, Map<String, String>>:值本身又是一个 Map,例如”班级 → (学号 → 姓名)”。

11.2 案例:省和市(课件第 54 页)

需求:要求在程序中记住如下省份和其对应的城市信息,记录成功后,要求可以查询出湖北省的城市信息。

  • 江苏省 = 南京市,扬州市,苏州市,无锡市,常州市
  • 湖北省 = 武汉市,孝感市,十堰市,宜昌市,鄂州市
  • 河北省 = 石家庄市,唐山市,邢台市,保定市,张家口市

课件的分析:定义一个 Map 集合,键表示省份名称,值表示城市名称,注意城市会有多个。 因为”城市会有多个”,所以值的类型要写成 List<String>;查询时用 provinces.get("湖北省") 拿到列表再展示。

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MapTest4 {
    public static void main(String[] args) {
        // 目标:掌握集合的嵌套(重点)
        // 1、定义一个 Map 集合存储全部省份和城市信息
        Map<String, List<String>> provinces = new HashMap<>();

        // 2、存入省份信息
        List<String> cities1 = new ArrayList<>();
        Collections.addAll(cities1, "南京市", "扬州市", "苏州市", "无锡市", "常州市");
        provinces.put("江苏省", cities1);

        List<String> cities2 = new ArrayList<>();
        Collections.addAll(cities2, "武汉市", "襄阳市", "孝感市", "十堰市", "宜昌市");
        provinces.put("湖北省", cities2);

        List<String> cities3 = new ArrayList<>();
        Collections.addAll(cities3, "石家庄市", "唐山市", "邢台市", "保定市", "张家口市");
        provinces.put("河北省", cities3);

        System.out.println(provinces);

        // 3、查询湖北省的城市信息
        List<String> hbcities = provinces.get("湖北省");
        for (String hbcity : hbcities) {
            System.out.println(hbcity);
        }
    }
}

课件第 54 页写的湖北省城市是”武汉市,孝感市,十堰市,宜昌市,鄂州市”,而配套示例工程里写的是”武汉市,襄阳市,孝感市,十堰市,宜昌市”(少了鄂州市)。两者不一致,以自己课堂实际演示的版本为准即可,本条不属于知识点。

Map 嵌套 Map 的写法见本日作业二第 2 题(学校 → 班级 → 学号 → 姓名),遍历套路是先遍历外层 Map 拿到内层 Map,再遍历内层。

12. 关于 Stream 流:本日课件核对说明

课件文件名是《day07、java.util包下的常用API-集合(Collections、Map、Stream流).pptx》,但本文逐页核对了该文件全部 56 页幻灯片,其中没有任何 Stream 相关内容:第 2 页目录只有”Collection 的其他相关知识(可变参数 / Collections / 综合案例)“和”Map 集合”两块,正文也止于第 54 页的”省和市”案例(第 53、55、56 页是演示用图)。

Stream 流的知识块——认识 Stream、Stream 流的常用方法(中间方法 / 终结方法)、收集 Stream 流——出现在 day08 课件《java.io包下的常用API-io流》第 1–12 页,按文章分工归入 day08,本文不重复展开、也不臆造内容。

(补充说明)day08 课件第 2 页对 Stream 的定义是:“也叫 Stream 流,是 JDK8 开始新增的一套 API(java.util.stream.*),可以用于操作集合或者数组的数据,大量结合 Lambda 语法风格编程,代码更简洁、可读性更好。“本章只做核对说明,具体 API 请对照 day08 的文章。

13. 作业与练习

作业文档路径题目与训练目标
作业 1day07-Collections、Map/作业/作业1/03_题目/作业1.md① 4 名学生按成绩从小到大排序(Comparator、增强 for、Collections);② 随机生成一注双色球,同色号码不重复(HashSet、ArrayList、Collections.sort);③ 京东注册用户重名判断(HashSet + 重写 hashCode/equals)
作业 2day07-Collections、Map/作业/作业2/04_题目/作业2.md① 统计售出商品的种类与数量(HashMap + HashMap 的遍历);② 用 HashMap 嵌套 HashMap 存储”班级 → 学号 → 姓名”;③ 把商品加入购物车,同店同商品数量加一,不同店铺视为新商品

几道题的关键点(都直接对应本日知识块):

  • 作业一 · 题目 1:Collections.sort(list, new Comparator<Student>() {...}),用 o1.getScore() - o2.getScore() 得到按分数升序;这就是第 2.3 节的”排序方式 2”。
  • 作业一 · 题目 2:先 HashSet 去重(while (hs.size() < 6) 反复生成直到凑够 6 个不重复红球),再把 HashSet 倒进 ArrayList 才能 Collections.sort() 排序——正好印证”Collections 只能对 List 排序”。
  • 作业一 · 题目 3:把”用户名 + 密码”封装成 User 对象放进 HashSet,靠 add() 的返回值判断成功失败(true 成功、false 已存在),前提是 User 重写了 hashCode() / equals()。
  • 作业二 · 题目 1:map.containsKey(goods) 为真则 map.put(goods, map.get(goods) + 1),否则 map.put(goods, 1);注意 end 要在计数之前判断跳出,否则会被当成一件商品。
  • 作业二 · 题目 2:HashMap<String, HashMap<String, String>>,外层键是班级(如”Java基础班”),内层键是学号(如 "001")——两个班都有学号 001,靠外层分组才不冲突。
  • 作业二 · 题目 3(购物车):用 Product(store, name) 作为键、数量作为值;先重写 hashCode()/equals() 让”同店同商品”算同一个键,再用 containsKey() 判断是新增(存 1)还是 get() 出来加一后重新 put()。这正是第 4.2 节”一一对应的数据”和第 8.3 节”自定义键要去重”两处知识点的合体题。

14. 复习要点

  1. 可变参数的格式是 数据类型... 参数名;它在方法内部就是一个数组,一个形参列表只能有一个且必须放最后。
  2. Collections 是操作集合的工具类(静态方法),能批量添加 addAll()、打乱 shuffle()、排序 sort(),但只能对 List 排序。
  3. Collections.sort() 的两种规则:类实现 Comparable 重写 compareTo();或传入 Comparator 对象——前者改类,后者改调用处。
  4. Map 是双列集合,元素是 Entry(键值对);键不重复、值可重复;需要”一一对应”的数据时就用它。
  5. Map 是双列集合的祖宗,常用方法:put/get/remove/size/clear/isEmpty/containsKey/containsValue/keySet/values;get() 返回 null 不能证明键不存在。
  6. Map 的三种遍历:keySet() + get()(键找值)、entrySet() + Entry.getKey()/getValue()(键值对)、forEach((k, v) -> ...)(Lambda);Map 不能直接增强 for 遍历。
  7. HashMap 底层是哈希表(JDK8 前 = 数组 + 链表;JDK8 起 = 数组 + 链表 + 红黑树,链表长度 > 8 且数组长度 >= 64 转红黑树);键的唯一性靠 hashCode() + equals(),自定义对象作键必须重写。
  8. HashSet 底层就是 HashMap(只要键不要值);LinkedHashSet 底层就是 LinkedHashMap。
  9. LinkedHashMap 在哈希表基础上多了双链表记录添加顺序(有序);TreeMap 基于红黑树,按键排序、不重复、无索引,只能对键排序,可用 Comparable 或 Comparator 指定规则。
  10. 集合的嵌套指”集合中的元素又是一个集合”:Map<String, List<String>> 表达一对多(省 → 城市),Map<String, Map<String, String>> 表达两层分组(班级 → 学号 → 姓名)。