C++/Java set 容器简介

C++/Java set 容器简介

原创作者信息

@ 邮箱: chronoschen1999@gmail.com

set 概述

set 作为比较经典的容器,有两个值得注意的特性,首先是不重复性,其次是无序性。不重复性即装入一个元素后,如果后面再次试图添加相同的元素,那么后面那个元素不会被添加成功;无序性可以理解为,加入元素后,内存中对各个对象存储顺序不是与添加顺序一致,而是会按照特定的要求来组织存放。

Java 中的常见 set

Java 中的 Set 种类比较多,但在操作上有统一的接口。每种 Set 有自己的元素排布规则,所以可以达到不同的效果,编程人员可以按照自己的实际需求选取。为了实现不重复性,最朴素的想法是,每次加入一个新元素,就把新元素和set中已存的所有元素比较,如果有相同的(Java 中的“相同”主要是由Object里面就继承下来的 equals 方法来定义),但如果已有元素的数量巨大,每添加一个元素,就需要跟所有的元素比对,时间复杂度是O(n),无疑是巨大的耗费。因此不同类型set采用了不同的方式来减少这种消耗。下面主要介绍 HashSet 和 TreeSet。

HashSet

这个容器比较常用,也很实用。HashSet 的底层实现可以视作一个数组,每次添加元素时,会计算这个元素的哈希值,然后将哈希值映射成数组的某个索引,然后将元素存到算出来的这个索引的数组位置。

如果没有冲突,也就是通过哈希值算出来的索引位置没有元素,那么元素直接存进去,时间复杂度为O(1);如果有冲突,即试图存进去的位置不为空,就需要先判断新元素(假设为a1)和当前位置的元素(假设为a2)是否相等(即a1.equals(a2)),因为哈希值相等的元素不一定相同。如果相同,则新元素舍弃掉,不加进去;如果不相等,说明新元素和老元素本质上还是不同的元素,那就在这个索引位置上拉一个链表,后面新的元素如果计算的索引也是这个位置,就需要跟链表上的所有元素比较一下,再决定是否加进来。

因此,HashSet 的时间复杂度可以做到 O(1),如果哈希冲突很多时、单个位置链表过长时,会导致效率降低。JDK8之后,为了防止单个位置上链表过长,在满足一些条件时,会将链表重新组织为红黑树。

验证添加时是否调用了 equals 方法:

public class SetTest {
    public static void main(String[] args) {
				HashSet<Student> hashSet = new HashSet<>();
        System.out.println("第一次添加.........");
        hashSet.add(new Student(1,2));
        System.out.println("第二次添加相同元素.........");
        hashSet.add(new Student(1,2));
        System.out.println("第三次添加不相同元素.........");
        hashSet.add(new Student(11,99));
        System.out.println("第四次添加哈希值相同、但逻辑上不相同的元素.........");
        hashSet.add(new Student(2,1));
        System.out.println("集合大小为:" + hashSet.size());
    }
}

class Student {
    int age;
    int sno;

    Student(int age, int sno){
        this.age = age;
        this.sno = sno;
    }
    @Override
    public int hashCode() {
        int myHashCode = age + sno;
        return myHashCode;
    }

    @Override
    public boolean equals(Object obj) {
        System.out.println("进入了Equals方法");
        return this.sno == ((Student) obj).sno;
    }
}

输出结果如下:

第一次添加.........
第二次添加相同元素.........
进入了Equals方法
第三次添加不相同元素.........
第四次添加哈希值相同、但逻辑上不相同的元素.........
进入了Equals方法
集合大小为:3

TreeSet

底层用的是红黑树来组织存储。红黑树的节点之间不存在相等不相等的说法,更重要的是定义大小,Java 中有两种方式可以自定义比较逻辑:

  1. 继承 Comparable 接口,实现 compareTo方法 :

    class Student implements Comparable<Student> {
        int age;
        int sno;
    
        Student(int age, int sno){
            this.age = age;
            this.sno = sno;
        }
    
        @Override
        public int compareTo(Student o) {
            return this.sno - o.sno;
        }
    }
    
  2. 在外部继承实现 Comparator 接口,实现 compare 方法,并在构造 TreeSet 时将这个实现类的对象作为参数传入(使用匿名内部类简化操作):

    TreeSet<Student> treeSet = new TreeSet<>(new Comparator<Student>() {
                @Override
                public int compare(Student o1, Student o2) {
                    return o1.sno - o2.sno;
                }
            });
    

两种方法各有好处,Comparable 的方式比较直观,就像给这个类赋予了某种自然的属性;Comparator 较为灵活,面对不同的业务需求时,可以在不改变类的前提下实现不同的比较方案。

使用以上两种方式之后,TreeSet 会把加入的元素按照规则组织成红黑树,所以 TreeSet 有一个功能:遍历时可以按照自定义的大小顺序或逆序输出元素,示例如下:

//第一个参数年龄无关紧要,第二个参数是学号。自定义逻辑是按照学号比对大小
        treeSet.add(new Student(0,8));
        treeSet.add(new Student(0,1));
        treeSet.add(new Student(0,5));
        treeSet.add(new Student(0,3));
        treeSet.add(new Student(0,6));

        for (Student s: treeSet) {
            System.out.println(s.sno);
        }

输入对象的学号不是有序的,但装入 TreeSet 再输出时,输出结果:

1
3
5
6
8

但这并没有否认 set 在逻辑上的无序性,因为存储的顺序本来就不是使用者所指的,无序可以理解为 set 底层存储元素会用独特的方法,HashSet 是如此,TreeSet 也是如此。

实际上,Java 在实现 HashSet 或者 TreeSet 时,底层是新建了一个 HashMap 或者 TreeMap,因为 Map 的 Key 要求具有唯一性,这与 Set 的不可重复性要求不谋而合,所以底层是把 Set 装入的元素作为 Map 的 Key,但归根结底原理都是差不多的。

C++ STL 中的 set

C++ 的 set 思想和 Java 的 TreeSet 是一致的,自定义使用 set 的核心在于定义大小,而 C++ 的自定义比较往往是根据重载运算符实现的,因此 set 要求元素类不是内置类型时,也要重载对应的运算符,主要有三种方式:

重载 < 运算符

	bool operator < (const Student& otherStu) const {
		return sno < otherStu.sno;
	}

需要注意的是,set 底层对 < 的非对称性判断比较严格,也就是 a < b 为 true 时,b < a 必然为false,所以上面的比较 不能写成 <=,不然就不满足非对称性的要求。这种直来直去的判断比较简单,但如果比较的逻辑比较复杂,涉及多个属性的组合判断,就容易出错,源码的 STL_VERIFY 函数会报错。

重载 () 运算符

bool operator()(const Student &s1,const Student &s2){
	return s1.sno < s2.sno;
}

函数指针

bool cmp(const Student &s1,const Student &s2){
    return s1.sno<s2.sno;
}
....
//初始化 set 时:
set<Students,decltype(cmp)*> s(cmp);

这种方法与 Java 中的 Comparator 比较类似。

以重载<运算符为例,查看 set 可按照定义的大小顺序,有序输出元素:

#include<set>
#include<iostream>
#include <algorithm>
using namespace std;

class Student {
public:
	int sno;
	int age;
	Student(const int &sno, const int &age) : age(age), sno(sno) {}
	bool operator < (const Student &otherStu) const {
		return sno < otherStu.sno;
	}
};

class Printer {
public:
	void operator() (const Student &stu) { cout << stu.sno << '\t' << endl; }
};

int main() {
	set<Student> s;
	s.insert(Student(9, 0));
	s.insert(Student(2, 0));
	s.insert(Student(5, 0));
	for_each(s.begin(), s.end(), Printer());
	return 0;
}

遍历运行结果:

2
5
9
上一篇:Js基础知识之JSON


下一篇:【2020-2021春学期】数据库作业5:单表查询例题练习