跳转至

2、C++程序设计与数据结构

2.1 类

2.1.1 类的概念及简单应用

​ 在C++中,类(Class)是一种复合数据类型,它不仅可以包含多种类型的数据成员(属性),还可以包含函数成员(方法)。类是面向对象编程(OOP)的基础,它提供了封装、继承和多态等核心特性。

类的定义

类的定义使用关键字class,后跟类名和类体。类体内可以定义属性和方法,用来表示对象的状态和行为。

C++
1
2
3
4
5
6
class ClassName {
public:
    // 公有数据成员和方法
private:
    // 私有数据成员和方法
};
  • 公有成员(Public):可以被任何外部函数或类的对象访问。
  • 私有成员(Private):只能被类的成员函数访问。

类的简单应用

下面是一个简单的类Circle的定义和使用,它代表一个圆,拥有半径属性和计算面积的方法。

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class Circle {
public:
    double radius; // 半径

    // 构造函数,初始化半径
    Circle(double r) {
        radius = r;
    }

    // 成员函数,计算面积
    double area() {
        return 3.14159 * radius * radius;
    }
};

int main() {
    Circle circle(10.0); // 创建Circle对象,半径为10.0
    cout << "Circle area: " << circle.area() << endl; // 输出圆的面积
    return 0;
}

​ 在这个例子中,Circle类有一个公有的数据成员radius和一个公有的成员函数area,后者用于计算圆的面积。Circle的一个对象被创建时,它的半径通过构造函数初始化。然后,我们可以使用这个对象调用area方法来获取圆的面积。

类的特点

  • 封装:类可以将数据和操作数据的方法封装在一起,对外隐藏实现细节。
  • 继承:允许创建新的类(派生类)继承基类(父类)的特性和行为。
  • 多态:通过继承和虚函数,允许使用基类的指针或引用来调用派生类的方法。

通过使用类,C++程序员可以实现复杂的数据抽象和面向对象设计,这有助于创建更加模块化和可重用的代码。

2.1.2 成员函数和运算符重载

​ 在C++中,成员函数是定义在类内部的函数,用于操作类的属性或执行与类相关的任务。运算符重载则是一种特殊的成员函数,它使得自定义类型的对象可以使用C++内置的运算符进行操作。

成员函数

​ 成员函数可以定义为类的一部分,用来访问和操作类的私有成员。它们可以是普通的成员函数、构造函数、析构函数或者是静态成员函数。

  • 构造函数:用于初始化对象的状态。它的名称与类名相同,可以有参数,但不能有返回类型。
  • 析构函数:用于执行清理操作,如释放资源。它的名称是在类名前加上~符号,不能有参数也没有返回类型。
  • 静态成员函数:不依赖于类的任何对象就可以被调用。它通过在成员函数声明前加上关键字static来定义。静态成员函数只能访问静态成员变量或调用其他静态成员函数。

运算符重载

运算符重载允许给已有的运算符分配额外的用户定义的含义,使得我们可以对自定义类型进行操作就像它们是内置类型一样。

  • 运算符重载可以通过成员函数或全局函数来实现。如果是成员函数,其第一个操作数必须是对象本身。
  • 重载的运算符的语法与普通函数相似,但其名称是由关键字operator后跟要重载的运算符符号组成的。

示例:重载加法运算符

下面是一个简单的示例,展示了如何为一个自定义的Vector类重载加法运算符+

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

class Vector {
public:
    int x, y;

    Vector(int x, int y) : x(x), y(y) {}

    // 重载+运算符
    Vector operator+(const Vector& rhs) const {
        return Vector(x + rhs.x, y + rhs.y);
    }
};

int main() {
    Vector v1(5, 4), v2(2, 10);
    Vector v3 = v1 + v2; // 使用重载的+运算符
    cout << "v3: (" << v3.x << ", " << v3.y << ")" << endl;
    return 0;
}

​ 在这个例子中,我们为Vector类重载了+运算符,使得可以直接通过+运算符来实现两个Vector对象的加法。operator+是作为成员函数实现的,它接受一个Vector对象作为参数,并返回两个Vector对象成员变量xy相加后的新对象。

​ 运算符重载的使用使得代码更加直观和简洁,但也需要谨慎使用,以避免引入歧义或降低代码的可读性。

2.2 STL模板

2.2.1 集合(set)

​ 在C++中,set是标准模板库(STL)的一部分,提供了一种存储唯一元素的容器模型,其中的元素按照一定的顺序排列。set底层通常实现为红黑树,这是一种自平衡的二叉搜索树。由于这种实现方式,set中的元素会自动排序,并且插入、删除、搜索操作的时间复杂度为O(log n),其中n是集合中元素的数量。

set的基本用法

要使用set,你需要包含头文件<set>

C++
1
#include <set>

创建set

你可以像下面这样创建一个set

C++
1
std::set<int> mySet;

set中插入元素

使用insert方法向set中添加元素:

C++
1
2
3
mySet.insert(10);
mySet.insert(5);
mySet.insert(15);

由于set中的元素是唯一的,如果尝试插入一个已经存在的元素,操作将不会改变set

访问set中的元素

set不支持随机访问,所以不能使用下标操作符[]访问元素。要遍历set中的所有元素,你可以使用迭代器:

C++
1
2
3
for(auto it = mySet.begin(); it != mySet.end(); ++it) {
    std::cout << *it << std::endl;
}

或者更简洁的范围for循环:

C++
1
2
3
for(const auto& element : mySet) {
    std::cout << element << std::endl;
}

删除元素

set中删除元素可以使用erase方法:

C++
1
mySet.erase(10);

查找元素

使用find方法在set中查找元素,如果找到,则返回指向该元素的迭代器,否则返回end()迭代器:

C++
1
2
3
4
5
6
auto search = mySet.find(10);
if(search != mySet.end()) {
    std::cout << "Found " << *search << std::endl;
} else {
    std::cout << "Not found" << std::endl;
}

特点

  • 自动排序:元素插入时会自动排序。
  • 唯一性:不允许有重复的元素。
  • 高效的查找、插入和删除操作:时间复杂度通常为O(log n)。

set非常适合于需要快速查找、插入和删除且元素不重复的场景。在信息学奥赛等编程竞赛中,set可以用来高效地解决许多涉及集合操作的问题。

2.2.2 列表(list),双端队列(deque),优先队列(priority_queue)

1.列表(list)

​ 在C++标准模板库(STL)中,list是一个双向链表容器,提供了快速的元素插入和删除操作。与vector相比,list在任何位置插入或删除元素的时间复杂度都是O(1),但它不支持随机访问,即不能像数组那样通过下标访问元素,访问任意位置的元素时间复杂度是O(n)。

list的基本用法

要使用list,需要包含头文件<list>

C++
1
#include <list>

创建list

你可以像下面这样创建一个list

C++
1
std::list<int> myList;

list中添加元素

  • 使用push_back在列表末尾添加元素。
  • 使用push_front在列表开头添加元素。
C++
1
2
myList.push_back(10); // 在末尾添加元素10
myList.push_front(5); // 在开头添加元素5

访问list中的元素

由于list不支持随机访问,遍历list需要使用迭代器:

C++
1
2
3
for(auto it = myList.begin(); it != myList.end(); ++it) {
    std::cout << *it << " ";
}

或者使用范围for循环:

C++
1
2
3
for(const auto& element : myList) {
    std::cout << element << " ";
}

删除元素

  • 使用pop_back删除列表末尾的元素。
  • 使用pop_front删除列表开头的元素。
  • 使用erase删除指定位置的元素。
C++
1
2
myList.pop_back(); // 删除末尾元素
myList.pop_front(); // 删除开头元素

插入元素

在指定位置插入元素可以使用insert方法,需要提供插入位置的迭代器和插入的值:

C++
1
2
3
auto it = myList.begin();
std::advance(it, 2); // 将迭代器向前移动2个位置
myList.insert(it, 20); // 在第3个位置插入元素20

查找元素

由于list不支持随机访问,查找特定元素需要遍历整个列表:

C++
1
2
3
4
5
6
auto it = std::find(myList.begin(), myList.end(), 20);
if(it != myList.end()) {
    std::cout << "Found " << *it << std::endl;
} else {
    std::cout << "Not found" << std::endl;
}

特点

  • 动态大小:可以根据需要动态地添加或删除元素,容器大小不固定。
  • 非连续存储list的元素在内存中可能是非连续存储的,每个元素通过指针与前后元素连接。
  • 双向遍历:支持正向和反向遍历。
  • 高效插入和删除:在任何位置插入和删除元素都非常高效。

list适合于频繁插入和删除元素的场景,特别是在元素的中间位置。然而,如果需要频繁访问元素,其他支持随机访问的容器(如vectordeque)可能更加适合。

2.双端队列(deque)

​ 在C++标准模板库(STL)中,双端队列(deque,全名为double-ended queue)是一种具有动态大小的序列容器,它允许在容器的前端和后端快速插入和删除元素。与vector相比,deque提供了在容器两端操作元素的额外功能,而与list不同,它支持随机访问,即可以直接通过下标访问元素。

deque的基本用法

要使用deque,需要包含头文件<deque>

C++
1
#include <deque>

创建deque

你可以像下面这样创建一个deque

C++
1
std::deque<int> myDeque;

deque中添加元素

  • 使用push_back在队列末尾添加元素。
  • 使用push_front在队列开头添加元素。
C++
1
2
myDeque.push_back(10); // 在末尾添加元素10
myDeque.push_front(5); // 在开头添加元素5

访问deque中的元素

  • 使用下标操作符[]直接访问元素,如myDeque[0]
  • 使用at方法访问元素,如myDeque.at(0),它会进行边界检查。
  • 使用迭代器遍历deque
C++
1
2
3
4
5
6
7
8
for(int i = 0; i < myDeque.size(); ++i) {
    std::cout << myDeque[i] << " ";
}

// 或者使用迭代器
for(auto it = myDeque.begin(); it != myDeque.end(); ++it) {
    std::cout << *it << " ";
}

删除元素

  • 使用pop_back删除队列末尾的元素。
  • 使用pop_front删除队列开头的元素。
  • 使用erase删除指定位置的元素或元素范围。
C++
1
2
myDeque.pop_back(); // 删除末尾元素
myDeque.pop_front(); // 删除开头元素

修改deque中的元素

可以直接通过下标或迭代器修改deque中的元素。

C++
1
2
3
4
myDeque[0] = 20; // 修改第一个元素的值

auto it = myDeque.begin();
*it = 15; // 修改第一个元素的值

特点

  • 动态大小:根据需要自动扩展和收缩。
  • 随机访问:支持通过下标直接访问元素。
  • 双端插入和删除:在容器的前端和后端插入和删除操作都很高效。
  • 非连续存储deque的实现通常是一系列固定大小的数组,这些数组的索引被保存在一个中央数据结构中。这种实现支持快速的随机访问,同时保持在两端插入和删除操作的高效性。

deque非常适合于需要频繁在两端添加或删除元素,同时又需要随机访问元素的场景。例如,它可以被用作队列或栈的实现基础,也适合作为滑动窗口算法的数据结构。

3.优先队列(priority_queue)

​ 在C++标准模板库(STL)中,priority_queue是一种容器适配器,它提供了队列的功能,其中每个元素都有一个优先级。元素被添加到任意位置,但访问元素时会按照优先级从最高到最低的顺序进行,这意味着你总是从队列中获取优先级最高的元素。

priority_queue默认使用std::less<T>作为比较函数,这意味着最大的元素会被视为优先级最高的元素。如果你想要改变顺序,使得最小的元素优先级最高,可以通过传递std::greater<T>作为第三个模板参数来实现。

priority_queue的基本用法

要使用priority_queue,需要包含头文件<queue>

C++
1
#include <queue>

创建priority_queue

你可以像下面这样创建一个默认的priority_queue(最大元素优先):

C++
1
std::priority_queue<int> myPQ;

如果想要最小元素优先,可以这样创建:

C++
1
std::priority_queue<int, std::vector<int>, std::greater<int>> myMinPQ;

priority_queue中添加元素

使用push方法向priority_queue中添加元素:

C++
1
2
3
myPQ.push(10);
myPQ.push(5);
myPQ.push(20);

添加这些元素后,20会是优先级最高的元素。

访问priority_queue中的元素

由于priority_queue是一个适配器,它不提供遍历所有元素的能力。你只能访问优先级最高的元素:

C++
1
int topElement = myPQ.top(); // 获取优先级最高的元素,这里是20

删除元素

使用pop方法删除优先级最高的元素:

C++
1
myPQ.pop(); // 删除了20

检查priority_queue的大小

使用size方法检查priority_queue包含的元素数量:

C++
1
size_t size = myPQ.size();

特点

  • priority_queue通常通过堆(通常是二叉堆)来实现,提供了高效的元素插入和删除最高优先级元素的操作。
  • 插入操作和删除最高优先级元素的操作的时间复杂度通常为O(log n),其中n是priority_queue中元素的数量。
  • priority_queue非常适合于那些需要频繁插入元素并且需要快速访问最高(或最低)优先级元素的场景,比如任务调度、带权图的算法(如Dijkstra算法)等。

通过这种方式,priority_queue可以在多种场景下高效地管理具有优先级的数据集合。

2.2.3 多重集合(multiset)

​ 在C++标准模板库(STL)中,multiset是一种关联容器,与set类似,它可以自动将元素排序,并且所有元素都会按照特定的排序标准进行排序。不同于setmultiset允许存储重复的元素。也就是说,你可以在multiset中有多个值完全相同的元素。

multiset的基本用法

要使用multiset,需要包含头文件<set>

C++
1
#include <set>

创建multiset

你可以像下面这样创建一个multiset

C++
1
std::multiset<int> myMultiset;

multiset中添加元素

使用insert方法向multiset中添加元素:

C++
1
2
3
myMultiset.insert(10);
myMultiset.insert(5);
myMultiset.insert(10); // 允许重复

在这个例子中,即使10已经存在于multiset中,第二次插入10依然有效,multiset中现在有两个10

访问multiset中的元素

由于multiset不支持随机访问(如通过下标访问元素),所以必须使用迭代器来遍历multiset中的所有元素:

C++
1
2
3
for(auto it = myMultiset.begin(); it != myMultiset.end(); ++it) {
    std::cout << *it << " ";
}

或者使用范围for循环:

C++
1
2
3
for(const auto& element : myMultiset) {
    std::cout << element << " ";
}

删除元素

  • 使用erase方法删除指定值的元素,这将删除multiset中所有值匹配的元素。
  • 如果只想删除一个特定值的一个实例,可以先用find找到这个元素的迭代器,然后用erase删除:
C++
1
2
3
4
auto it = myMultiset.find(10);
if(it != myMultiset.end()) {
    myMultiset.erase(it); // 删除一个值为10的元素
}

检查multiset的大小

使用size方法检查multiset包含的元素数量:

C++
1
size_t size = myMultiset.size();

特点

  • 自动排序multiset的元素在插入时会自动排序。
  • 允许重复元素:与set不同,multiset允许存储重复的元素。
  • 高效的查找、插入和删除操作:与set相似,multiset的查找、插入和删除操作的时间复杂度通常为O(log n),其中n是容器中元素的数量。

multiset非常适合于需要维护一个有序序列且序列中包含重复元素的场景。例如,在统计数据或进行范围查询时,multiset提供了一个既灵活又高效的解决方案。

2.2.4 映射(map),多重映射(multimap)

​ 在C++标准模板库(STL)中,map是一种关联容器,它存储键值对(key-value pairs),其中每个键都唯一地映射到一个值。map中的元素按照键的顺序排序,搜索、插入和删除操作的时间复杂度都是对数时间O(log n),其中n是map中元素的数量。map底层通常实现为红黑树。

map的基本用法

要使用map,需要包含头文件<map>

C++
1
#include <map>

创建map

你可以像下面这样创建一个map

C++
1
std::map<std::string, int> myMap;

这里创建了一个map,其键(key)类型为std::string,值(value)类型为int

map中添加元素

使用operator[]insert方法向map中添加元素:

C++
1
2
myMap["apple"] = 5;
myMap.insert(std::make_pair("banana", 3));

如果使用operator[]添加一个已经存在的键,其对应的值会被新值替换。如果使用insert方法,当键已存在时,插入操作会失败。

访问map中的元素

  • 使用operator[]访问元素:
C++
1
std::cout << myMap["apple"] << std::endl; // 输出5
  • 使用at方法访问元素(如果键不存在,会抛出std::out_of_range异常):
C++
1
std::cout << myMap.at("apple") << std::endl; // 输出5
  • 使用迭代器遍历map
C++
1
2
3
for(auto it = myMap.begin(); it != myMap.end(); ++it) {
    std::cout << it->first << ": " << it->second << std::endl;
}
  • 或者使用范围for循环:
C++
1
2
3
for(const auto& pair : myMap) {
    std::cout << pair.first << ": " << pair.second << std::endl;
}

删除元素

  • 使用erase方法删除指定键的元素:
C++
1
myMap.erase("apple");

检查map中的元素数量

使用size方法检查map包含的元素数量:

C++
1
size_t size = myMap.size();

特点

  • 唯一键:每个键在map中唯一对应一个值。
  • 自动排序:元素会根据键自动排序,排序方式可以通过自定义比较函数来指定。
  • 直接访问:可以直接通过键访问对应的值。
  • 高效的查找、插入和删除:时间复杂度为O(log n)。

map适合于需要快速查找元素(基于键)、保持元素有序且键值唯一的场景。例如,它常用于实现字典、数据库索引等。

​ 在C++标准模板库(STL)中,multimap是一种关联容器,与map相似,它存储的是键值对(key-value pairs)。不同之处在于,multimap允许多个元素拥有相同的键,也就是说,同一个键可以映射到多个值。这使得multimap非常适合处理具有非唯一键的情况。

multimap的基本用法

要使用multimap,需要包含头文件<map>

C++
1
#include <map>

创建multimap

你可以像下面这样创建一个multimap

C++
1
std::multimap<std::string, int> myMultimap;

在这个例子中,myMultimap的键类型是std::string,值类型是int

multimap中添加元素

使用insert方法向multimap中添加元素:

C++
1
2
3
myMultimap.insert(std::make_pair("apple", 5));
myMultimap.insert(std::pair<std::string, int>("apple", 10)); // 同一个键,不同的值
myMultimap.insert({"banana", 3});

访问multimap中的元素

由于multimap允许键重复,不能使用operator[]来访问元素。要访问multimap中的元素,通常使用迭代器遍历:

C++
1
2
3
for(auto it = myMultimap.begin(); it != myMultimap.end(); ++it) {
    std::cout << it->first << ": " << it->second << std::endl;
}

如果你只对特定键的所有值感兴趣,可以使用equal_range方法或者lower_boundupper_bound来获取某个键对应的所有值的范围:

C++
1
2
3
4
auto range = myMultimap.equal_range("apple");
for(auto it = range.first; it != range.second; ++it) {
    std::cout << it->first << " => " << it->second << '\n';
}

删除元素

  • 使用erase方法删除指定键的所有元素:
C++
1
myMultimap.erase("apple");
  • 如果想删除某个具体的键值对,需要先找到这个键值对对应的迭代器,然后删除:
C++
1
2
3
4
auto it = myMultimap.find("banana");
if (it != myMultimap.end()) {
    myMultimap.erase(it);
}

检查multimap的大小

使用size方法检查multimap包含的元素数量:

C++
1
size_t size = myMultimap.size();

特点

  • 非唯一键multimap允许多个元素共享同一个键。
  • 自动排序multimap中的元素会根据键自动排序,排序规则可以通过自定义比较函数来指定。
  • 高效的查找、插入和删除:与map类似,multimap的查找、插入和删除操作的时间复杂度也是O(log n)。

multimap适合于需要根据键快速查找元素且元素的键可能不唯一的场景。例如,可以用multimap来存储一个公司员工的ID和他们所在的部门之间的映射,其中部门名称作为键,员工ID作为值,因为一个部门可以有多个员工。

2.2.5 对(pair),元组(tuple)

​ 在C++标准模板库(STL)中,pair是一个非常简单的容器,它可以存储两个值,这两个值可以是不同的类型。pair广泛用于那些需要将两个值视为单个单元的场景,例如,在mapmultimap中存储键值对,或者当函数需要返回两个值时。

pair的基本用法

要使用pair,需要包含头文件<utility>

C++
1
#include <utility>

创建pair

你可以使用多种方法来创建一个pair

C++
1
std::pair<std::string, int> myPair("apple", 5);

或者使用std::make_pair函数:

C++
1
auto myPair = std::make_pair("apple", 5);

访问pair中的元素

pair中的元素可以通过.first.second成员访问:

C++
1
std::cout << "The fruit is " << myPair.first << " and its count is " << myPair.second << std::endl;

修改pair中的元素

你可以直接修改pair中的元素:

C++
1
2
myPair.first = "banana";
myPair.second = 10;

比较pair

pair支持比较操作(如==, !=, <, <=, >, >=)。比较是首先基于.first成员的,如果它们相等,则基于.second成员的。

C++
1
2
3
4
5
6
7
8
std::pair<int, int> pair1 = std::make_pair(1, 5);
std::pair<int, int> pair2 = std::make_pair(1, 10);

if (pair1 < pair2) {
    std::cout << "pair1 is less than pair2" << std::endl;
} else {
    std::cout << "pair1 is not less than pair2" << std::endl;
}

在这个例子中,pair1小于pair2,因为它们的.first成员相同,但pair1.second成员小于pair2.second成员。

pair的使用场景

  • mapmultimap中,每个元素都是一个pair.first是键,.second是值。
  • 函数通过pair返回多个值。
  • 在算法和数据结构中,pair常用于将两个数据组合成单个单元,例如在图算法中表示边的权重。

pair是C++中一个非常基础而强大的工具,它简单却能在多种场合发挥重要作用。

​ 在C++标准模板库(STL)中,tuple是一个可以存储不同类型值的固定大小的容器。与pair相比,tuple更加通用,它可以容纳两个以上的元素。tuple非常适用于那些需要将一组值聚合成单一对象,但又不想创建全新结构体或类的场景。

tuple的基本用法

要使用tuple,需要包含头文件<tuple>

C++
1
#include <tuple>

创建tuple

你可以使用多种方法来创建一个tuple

C++
1
std::tuple<std::string, int, double> myTuple("apple", 5, 0.99);

或者使用std::make_tuple函数:

C++
1
auto myTuple = std::make_tuple("apple", 5, 0.99);

访问tuple中的元素

要访问tuple中的元素,可以使用std::get

C++
1
2
3
4
5
std::string fruit = std::get<0>(myTuple);
int count = std::get<1>(myTuple);
double price = std::get<2>(myTuple);

std::cout << "The fruit is " << fruit << ", count is " << count << ", price is " << price << std::endl;

修改tuple中的元素

pair类似,你可以通过std::get直接修改tuple中的元素:

C++
1
std::get<1>(myTuple) = 10; // 修改数量为10

tuple的其他功能

  • 比较操作tuple支持全面的比较操作(==, !=, <, <=, >, >=),比较是从第一个元素开始逐个进行的,直到找到不相等的元素。
  • tuple_size:可以通过std::tuple_size<decltype(tuple)>::value来获取tuple中元素的数量。
  • tuple_cat:可以使用std::tuple_cat来将两个或多个tuple合并成一个新的tuple

示例:合并tuple

C++
1
2
3
4
5
std::tuple<int, std::string> tuple1 = std::make_tuple(1, "apple");
std::tuple<double, char> tuple2 = std::make_tuple(0.99, 'A');

auto tuple3 = std::tuple_cat(tuple1, tuple2);
// tuple3 现在包含:int, string, double, char

tuple的使用场景

  • 函数返回多个值时,tuple可以容纳这些值而无需定义新的结构体或类。
  • 在泛型编程中,当需要处理不同类型集合的数据时。
  • 在数据结构和算法中,将多个数据组合成单一对象,例如图的边(可以包含边的两个顶点和权重)。

tuple提供了一种灵活的方式来处理多个不同类型的数据,使得数据的聚合和操作更加便捷。

3.1 线性结构

3.1.1 双端栈

​ 双端栈(Double-ended Stack,也被称为双栈)是一种数据结构,它允许从数据结构的两端进行pushpop操作。这种结构结合了栈和队列的部分特性,使得它可以在两端进行操作。双端栈不是C++标准模板库(STL)中的一个直接数据结构,但可以使用两个栈或者双端队列(deque)来实现。

使用两个栈实现双端栈

​ 使用两个栈实现双端栈的基本思想是:一个栈用于管理一端的入栈(push)和出栈(pop)操作,另一个栈用于管理另一端的操作。

C++
 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
26
27
28
29
30
31
32
33
34
#include <stack>
#include <iostream>
#include <cassert>
using namespace std;

template <typename T>
class DoubleEndedStack {
private:
    std::stack<T> stack1;
    std::stack<T> stack2;

public:
    // 向第一端添加元素
    void push_front(const T& value) {
        stack1.push(value);
    }

    // 从第一端移除元素
    void pop_front() {
        assert(!stack1.empty()); // 确保栈不为空
        stack1.pop();
    }

    // 向第二端添加元素
    void push_back(const T& value) {
        stack2.push(value);
    }

    // 从第二端移除元素
    void pop_back() {
        assert(!stack2.empty()); // 确保栈不为空
        stack2.pop();
    }
};

使用deque实现双端栈

由于双端队列(deque)本身就支持从两端进行pushpop操作,所以使用deque实现双端栈会更加直接和简洁。

C++
 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
26
27
28
29
30
31
32
33
#include <deque>
#include <iostream>
#include <cassert>
using namespace std;

template <typename T>
class DoubleEndedStack {
private:
    std::deque<T> deque;

public:
    // 向第一端添加元素
    void push_front(const T& value) {
        deque.push_front(value);
    }

    // 从第一端移除元素
    void pop_front() {
        assert(!deque.empty()); // 确保deque不为空
        deque.pop_front();
    }

    // 向第二端添加元素
    void push_back(const T& value) {
        deque.push_back(value);
    }

    // 从第二端移除元素
    void pop_back() {
        assert(!deque.empty()); // 确保deque不为空
        deque.pop_back();
    }
};

使用场景

​ 双端栈的使用场景相对特殊,但在某些算法设计中非常有用,特别是在需要同时对数据的两端进行操作的情况下。例如,在某些特定的算法问题中,可能需要同时从两端添加或移除数据,以保持数据结构的平衡或实现特定的数据处理逻辑。

下面是一个使用双端栈的示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <deque>

using namespace std;

int main() {
    deque<int> myDeque;

    // push elements to back of deque
    myDeque.push_back(10);
    myDeque.push_back(20);
    myDeque.push_back(30);

    // push elements to front of deque
    myDeque.push_front(5);
    myDeque.push_front(15);

    // output deque contents
    cout << "deque contents: ";
    for (auto i : myDeque) {
        cout << i << " ";
    }
    cout << endl;

    // pop element from back of deque
    myDeque.pop_back();

    // pop element from front of deque
    myDeque.pop_front();

    // output deque contents
    cout << "deque contents: ";
    for (auto i : myDeque) {
        cout << i << " ";
    }
    cout << endl;

    return 0;
}

​ 在上面的示例中,我们使用了deque容器来实现一个双端栈。首先将元素10、20、30依次插入到双端栈的尾部,然后将元素5、15依次插入到双端栈的头部。输出双端栈的内容,可以看到元素的顺序是15 5 10 20 30。接着分别从双端栈的尾部和头部删除一个元素,输出双端栈的内容,可以看到元素的顺序变为5 10 20。

3.1.2 双端队列

​ 在C++标准模板库(STL)中,双端队列(deque,全称为double-ended queue)是一种序列容器,它支持在两端高效地插入和删除元素。与vector相比,deque不仅能够动态地增长和缩小,而且能够在容器的前端插入元素,而vector只能在后端插入元素(尽管从技术上来讲,vector也可以在前端插入元素,但这样做的效率非常低)。与list相比,deque支持随机访问,这意味着你可以直接访问任何位置的元素,而不需要从头开始遍历容器。

deque的基本用法

要使用deque,你需要包含头文件<deque>

C++
1
#include <deque>

创建deque

你可以像下面这样创建一个deque

C++
1
std::deque<int> myDeque;

deque中添加元素

  • 在末尾添加元素:push_back()
  • 在开头添加元素:push_front()
C++
1
2
myDeque.push_back(10); // 在末尾添加元素10
myDeque.push_front(5); // 在开头添加元素5

访问deque中的元素

  • 使用下标操作符:myDeque[0]
  • 使用at()方法:myDeque.at(0),它会检查下标是否越界。
  • 使用迭代器遍历deque
C++
1
2
3
for (int& element : myDeque) {
    std::cout << element << " ";
}

删除deque中的元素

  • 从末尾删除元素:pop_back()
  • 从开头删除元素:pop_front()
C++
1
2
myDeque.pop_back(); // 删除末尾元素
myDeque.pop_front(); // 删除开头元素

deque的特点

  • 动态大小:根据需要自动扩展和缩小。
  • 双端操作:支持在容器的前端和后端快速插入和删除元素。
  • 随机访问:支持通过下标直接访问元素,与vector类似,但性能略低于vector
  • 非连续存储deque可能由多个分散的内存块组成,保证了在两端操作的高效性,但也意味着与vector相比,随机访问的性能稍差。

使用场景

deque非常适合于那些需要从两端添加或移除元素的场景。例如,在需要一个队列但又频繁在队列前端插入元素的场合,或者在需要随机访问元素同时又需要在两端动态调整大小的场合,deque都是一个很好的选择。双端队列(deque,即double-ended queue),是一种可以在两端进行插入和删除操作的数据结构,支持队列和栈的所有操作。它类似于双向链表,但在实现上通常使用数组或向量实现,因此可以更高效地支持随机访问操作。

​ 下面是一个使用双端队列的示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <deque>
using namespace std;

int main() {
    deque<int> myDeque;

    // push elements to back of deque
    myDeque.push_back(10);
    myDeque.push_back(20);
    myDeque.push_back(30);

    // push elements to front of deque
    myDeque.push_front(5);
    myDeque.push_front(15);

    // output deque contents
    cout << "deque contents: ";
    for (auto i : myDeque) {
        cout << i << " ";
    }
    cout << endl;

    // pop element from back of deque
    myDeque.pop_back();

    // pop element from front of deque
    myDeque.pop_front();

    // output deque contents
    cout << "deque contents: ";
    for (auto i : myDeque) {
        cout << i << " ";
    }
    cout << endl;

    return 0;
}

​ 在上面的示例中,我们使用了deque容器来实现一个双端队列。首先将元素10、20、30依次插入到双端队列的尾部,然后将元素5、15依次插入到双端队列的头部。输出双端队列的内容,可以看到元素的顺序是15 5 10 20 30。接着分别从双端队列的尾部和头部删除一个元素,输出双端队列的内容,可以看到元素的顺序变为5 10 20。

3.1.3 有序队列

​ 在C++中,"有序队列"一词并不是指一个特定的标准库容器,但这个概念可以通过几种不同的方式实现,根据你的需求选择最合适的实现方式。通常,当我们谈论有序队列时,我们可能是在指一种数据结构,它可以维护元素的某种排序顺序,同时允许高效地插入新元素、删除元素以及访问队列中的最小或最大元素。以下是几种实现有序队列概念的方法:

使用std::priority_queue

std::priority_queue是C++ STL中的一个容器适配器,它提供了一种方式来维护一个集合,这个集合总是被排序以确保最大元素(或根据提供的比较函数,可能是最小元素)总是位于“顶部”。这使得它非常适合实现最大堆或最小堆。

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <iostream>
#include <queue>
using namespace std;

int main() {
    std::priority_queue<int> maxHeap;
    // 对于最小堆,可以这样定义:std::priority_queue<int, std::vector<int>, std::greater<int>>

    maxHeap.push(1);
    maxHeap.push(5);
    maxHeap.push(3);

    std::cout << "Max element: " << maxHeap.top() << std::endl;

    maxHeap.pop(); // 移除最大元素
}

使用std::setstd::multiset

std::setstd::multiset是基于红黑树实现的,可以自动排序其元素。与priority_queue不同,这些容器允许你遍历元素,set保证了元素的唯一性,而multiset允许重复元素。这些容器非常适合当你需要一个总是排序的集合,并且可能需要访问除了最小或最大元素以外的元素。

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <iostream>
#include <set>
using namespace std;

int main() {
    std::set<int> orderedSet;

    orderedSet.insert(3);
    orderedSet.insert(1);
    orderedSet.insert(4);

    std::cout << "Elements in order: ";
    for (int elem : orderedSet) {
        std::cout << elem << " ";
    }
    std::cout << std::endl;
}

选择正确的数据结构

  • 如果你的主要需求是快速访问、插入和删除最大元素(或最小元素),priority_queue可能是最好的选择。
  • 如果你需要保持元素的排序顺序,并且需要频繁地遍历这些元素或者操作除了最大或最小元素以外的元素,setmultiset可能更适合。
  • 如果需要允许重复元素并保持排序,应选择multiset
  • 如果除了有序性之外,还需要通过索引快速访问元素,可以考虑使用std::vectorstd::deque,在每次插入或删除操作后进行排序,虽然这在效率上通常不是最佳选择。

选择哪种实现取决于你的具体需求,包括你需要哪种类型的排序、是否需要遍历元素、以及对插入和删除操作的性能要求。

3.1.4 优先队列

​ 在C++中,priority_queue是一种容器适配器,设计用于保持其元素排序的状态。默认情况下,它构造了一个最大堆,这意味着队列的顶部总是保持最大元素。通过这种方式,priority_queue提供了快速访问集合中最大元素的能力,同时允许在对数时间内插入新元素和移除最大元素。

priority_queue的基本用法

要使用priority_queue,你需要包含头文件<queue>

C++
1
2
#include <iostream>
#include <queue>

创建priority_queue

你可以像下面这样创建一个priority_queue

C++
1
std::priority_queue<int> pq;

这将创建一个空的priority_queue,用于存储int类型的元素,并且元素将按照从大到小的顺序排序。

priority_queue中添加元素

使用push()方法向priority_queue中添加元素:

C++
1
2
3
pq.push(30);
pq.push(10);
pq.push(20);

访问priority_queue中的最大元素

使用top()方法访问priority_queue中的最大元素:

C++
1
std::cout << "The largest element is " << pq.top() << std::endl;

移除priority_queue中的最大元素

使用pop()方法移除priority_queue中的最大元素:

C++
1
pq.pop();

自定义排序

如果你想让priority_queue以不同的顺序排序(例如,构造一个最小堆),你可以在声明时提供额外的参数:

C++
1
std::priority_queue<int, std::vector<int>, std::greater<int>> minPq;

这将创建一个最小堆,使得队列的顶部总是保持最小元素。

priority_queue的使用场景

priority_queue非常适合于需要快速访问和移除最大(或最小)元素的场景,如:

  • 算法中的贪心策略,比如Dijkstra算法或Prim算法中使用最小堆来快速找到最小边。
  • 任务调度,其中任务有不同的优先级,并且需要首先执行最高优先级的任务。
  • 实时数据处理,如数据流中寻找最大的N个元素。

通过为priority_queue提供不同的比较函数,你可以灵活地控制元素的排序方式,使其适应各种不同的需求。

以下是一个示例程序,使用priority_queue实现了一个按照整数值从大到小排序的优先队列:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

int main() {
  priority_queue<int, vector<int>, less<int>> pq;
  pq.push(5);
  pq.push(2);
  pq.push(7);
  pq.push(1);
  pq.push(8);

  while (!pq.empty()) {
    cout << pq.top() << " ";
    pq.pop();
  }

  return 0;
}

输出结果为:8 7 5 2 1

3.1.5 倍增表(ST表)

​ 倍增表(Sparse Table,简称ST表)是一种用于解决区间查询问题的数据结构,特别适合处理静态数据的区间最小值查询(RMQ问题)、区间最大值查询等问题。ST表的主要优势在于它可以在O(1)的时间复杂度内回答任何区间查询,前提是数据是静态的,即数据在预处理之后不会再发生变化。ST表的预处理时间复杂度是O(NlogN),其中N是数组的大小。

ST表的工作原理

​ ST表利用了动态规划的思想,预处理出一个二维数组dp[N][K],其中dp[i][j]表示从第i个元素开始,长度为2^j的区间内的最小(或最大)元素。N是原数组的大小,K是满足2^K <= N的最大整数。

ST表的预处理

以最小值查询为例,预处理代码如下:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void buildST(const std::vector<int>& arr, std::vector<std::vector<int>>& dp) {
    int N = arr.size();
    int K = std::log2(N) + 1;
    dp.assign(N, std::vector<int>(K + 1, 0));

    // 初始化dp数组
    for (int i = 0; i < N; i++) {
        dp[i][0] = arr[i];
    }

    // 动态规划预处理
    for (int j = 1; j <= K; j++) {
        for (int i = 0; i + (1 << j) <= N; i++) {
            dp[i][j] = std::min(dp[i][j - 1], dp[i + (1 << (j - 1))][j - 1]);
        }
    }
}

ST表的查询

查询区间[l, r]内的最小值:

C++
1
2
3
4
int queryST(const std::vector<std::vector<int>>& dp, int l, int r) {
    int j = std::log2(r - l + 1);
    return std::min(dp[l][j], dp[r - (1 << j) + 1][j]);
}

优点

  • 查询时间复杂度为O(1),非常适合处理大量的区间查询问题。
  • 实现相对简单,易于理解。

缺点

  • 只能用于静态数据的查询,如果数据有更新,需要重新预处理。
  • 预处理的空间复杂度为O(NlogN),对于非常大的数据量可能会有较大的内存开销。

应用场景

​ ST表适用于需要频繁查询静态数组的区间最小值、最大值、最大公约数等问题,如在线处理多个区间查询的场景。它在竞赛编程和一些对查询性能要求高的应用中非常有用。

​ 以下是一个使用ST表实现静态区间最值查询的示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <cmath>
using namespace std;

const int MAXN = 100000;
const int MAXLOG = 20;

int n, q;
int a[MAXN + 1];
int st[MAXN + 1][MAXLOG + 1];

void build_st() {
  int k = log2(n);
  for (int i = 1; i <= n; i++) {
    st[i][0] = a[i];
  }
  for (int j = 1; j <= k; j++) {
    for (int i = 1; i + (1 << j) - 1 <= n; i++) {
      st[i][j] = min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
    }
  }
}

int query(int l, int r) {
  int k = log2(r - l + 1);
  return min(st[l][k], st[r - (1 << k) + 1][k]);
}

int main() {
  cin >> n >> q;
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }

  build_st();

  for (int i = 1; i <= q; i++) {
    int l, r;
    cin >> l >> r;
    cout << query(l, r) << endl;
  }

  return 0;
}

​ 其中,build_st()函数用于构造ST表,query(l, r)函数用于查询区间\([l, r]\)的最值。时间复杂度为\(O(nlogn)\),空间复杂度为\(O(nlogn)\)

3.2 集合与森林

3.2.1 等价类

​ 在数学和计算机科学中,等价类(Equivalence Class)是指在集合分割中,通过某种等价关系形成的子集。等价类是根据等价关系将集合中元素进行分组的一种方法,每个分组中的元素对于这种关系来说是不可区分的。

等价关系

要理解等价类,首先需要明白等价关系的概念。在集合A上的一个等价关系是一个二元关系,满足以下三个条件:

  1. 自反性(Reflexivity):对于所有a属于Aaa是等价的。
  2. 对称性(Symmetry):对于所有ab属于A,如果ab是等价的,则ba也是等价的。
  3. 传递性(Transitivity):对于所有abc属于A,如果ab是等价的,且bc是等价的,则ac也是等价的。

等价类的定义

给定一个集合A和一个在A上的等价关系~A中的一个元素a的等价类是由所有与a等价的元素组成的子集。通常用[a]表示a的等价类。

等价类的性质

  • 分割性(Partitioning):等价关系将集合A分割成互不相交的子集,这些子集称为A的等价类。每个元素a属于A都恰好属于一个等价类。
  • 划分(Partition):集合A的所有等价类的集合形成了A的一个划分。

示例

假设有一个集合A = {1, 2, 3, 4, 5},定义等价关系~为“模2同余”,即对于A中的任意两个数ab,如果ab除以2的余数相同,则它们是等价的。根据这个等价关系,集合A被分为两个等价类:

  • [1] = {1, 3, 5}:包含所有除以2余1的元素。
  • [2] = {2, 4}:包含所有除以2余0的元素。

应用

​ 等价类的概念在数学的很多分支中都有应用,包括代数、几何和逻辑等。在计算机科学中,等价类被用于算法设计、数据结构、程序验证和软件测试等多个领域,如等价类划分(Equivalence Partitioning)是一种常用的软件测试技术。

​ 以下是一个基于数组实现等价类的示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 1005;

int fa[MAXN];

// 初始化并查集,每个元素的父节点初始化为它本身
void init(int n) {
    for (int i = 1; i <= n; i++) {
        fa[i] = i;
    }
}

// 查找元素x所在的集合的代表元素
int find(int x) {
    if (fa[x] == x) {
        return x;
    }
    return fa[x] = find(fa[x]);  // 路径压缩
}

// 合并元素x和y所在的集合
void unite(int x, int y) {
    int fx = find(x), fy = find(y);
    if (fx != fy) {
        fa[fx] = fy;
    }
}

int main() {
    int n, m;
    cin >> n >> m;

    init(n);

    for (int i = 1; i <= m; i++) {
        int a, b;
        cin >> a >> b;
        unite(a, b);
    }

    vector<int> e[MAXN];  // 存储每个等价类的元素
    for (int i = 1; i <= n; i++) {
        int f = find(i);  // 找到i所在的集合的代表元素
        e[f].push_back(i);  // 将i加入代表元素所在的等价类中
    }

    for (int i = 1; i <= n; i++) {
        if (fa[i] == i) {  // 如果i是代表元素,则输出i所在的等价类
            cout << "等价类" << i << "中的元素为:";
            for (int j = 0; j < e[i].size(); j++) {
                cout << e[i][j] << " ";
            }
            cout << endl;
        }
    }

    return 0;
}

​ 该代码通过并查集维护元素之间的等价关系,最后将每个等价类的元素存储在一个vector中输出。

3.2.2 并查集

​ 并查集(Disjoint Set Union,简称DSU)是一种非常实用的数据结构,主要用于处理一些不交集的合并及查询问题。它支持两种基本操作:查找(Find)和合并(Union)。通过这两种操作,可以非常高效地解决动态连通性问题,例如网络中的连接状态、数学中的集合分类问题等。

基本概念

  • 查找(Find):确定某个元素属于哪一个子集。这通常通过找到该元素所在集合的“代表元”(也叫根节点)来实现。
  • 合并(Union):将两个子集合并成一个集合。这通常意味着将两个集合的代表元链接起来。

数据结构

并查集通常用一维数组实现。每个元素的父节点信息存储在数组中,根节点的父节点指向它自己。因此,从任何一个节点开始,通过连续查找父节点,最终都能找到根节点。

操作实现

初始化

初始化时,每个元素构成一个独立的集合,指向自己作为自己的父节点。

C++
1
2
3
4
5
void makeSet(int n, vector<int>& parent) {
    for(int i = 0; i < n; i++) {
        parent[i] = i;
    }
}

查找

查找操作通过递归或循环查找元素的根节点来实现。

C++
1
2
3
4
5
6
int find(int x, vector<int>& parent) {
    if(x != parent[x]) {
        parent[x] = find(parent[x], parent); // 路径压缩
    }
    return parent[x];
}

合并

合并操作首先找到两个元素的根节点,如果根节点不同,将其中一个根节点的父节点指向另一个根节点。

C++
1
2
3
4
5
6
7
void unionSets(int x, int y, vector<int>& parent) {
    int rootX = find(x, parent);
    int rootY = find(y, parent);
    if(rootX != rootY) {
        parent[rootX] = rootY; // 或者根据树的高度或大小选择合并方向
    }
}

路径压缩

在查找过程中,路径压缩技术可以减少后续查找操作的时间,通过将查找路径上的每个节点直接链接到根节点来实现。

按秩合并

按秩合并是另一种优化技术,它通过保持树的高度最小化来减少查找时间。常见的做法是总是将较矮的树连接到较高的树上。

应用

并查集在解决动态连通性问题中非常高效,常见的应用场景包括:

  • 社交网络中的朋友圈问题
  • 图的连通分量问题
  • 最小生成树的Kruskal算法
  • 等价关系和分类问题

并查集是算法竞赛和某些实际应用中的常用工具,因为它简洁且高效。

以下是并查集的 C++ 代码示例,使用数组实现:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <vector>
using namespace std;

const int N = 100010;

int n, m;
int p[N];  // p[x] 表示元素 x 的父节点,如果 p[x] = x,则 x 为该集合的代表元素

// 初始化
void init() {
    for (int i = 1; i <= n; i++) p[i] = i;
}

// 查找元素 x 所在集合的代表元素
int find(int x) {
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

// 合并两个集合
void merge(int a, int b) {
    int pa = find(a), pb = find(b);
    if (pa != pb) p[pa] = pb;
}

int main() {
    cin >> n >> m;
    init();
    while (m--) {
        int a, b;
        cin >> a >> b;
        merge(a, b);
    }

    // 输出各个元素所在集合的代表元素
    vector<int> ans;
    for (int i = 1; i <= n; i++) {
        if (p[i] == i) ans.push_back(i);
    }
    cout << ans.size() << endl;
    return 0;
}

​ 该代码实现了并查集的三个基本操作:初始化、查找、合并。初始化时将每个元素单独作为一个集合,即将每个元素的父节点设为它本身;查找时可以一路往上找到根节点,即 p[x]=x 的元素;合并时将两个集合的代表元素的父节点指向另一个集合的代表元素。

​ 在本代码中,输入的第一行为元素个数 n 和操作次数 m,接下来 m 行每行两个数表示进行一次合并操作。输出各个元素所在集合的代表元素的个数。

注意:在实际使用中,需要根据具体情况对并查集进行优化,例如按秩合并、路径压缩等。

3.2.3 树与二叉树的转化——孩子兄弟表示法

​ 孩子兄弟表示法,也称为左孩子右兄弟表示法,是一种将普通树(如N叉树)转化为二叉树的技巧。这种表示法的核心思想是使用二叉树的结构来模拟普通树,其中每个节点最多有两个指针:

  • 左指针(Left Child)指向该节点的第一个孩子节点。
  • 右指针(Right Sibling)指向该节点的兄弟节点,即与该节点有共同父节点的下一个节点。

转化规则

  1. 左指针:如果节点X在原始树中有孩子,那么在转化后的二叉树中,X的左指针指向它的第一个孩子。
  2. 右指针:如果节点X在原始树中有兄弟,那么在转化后的二叉树中,X的右指针指向它的下一个兄弟。

通过这种方式,原始树中的任何节点在转化后的二叉树中都保持了其孩子和兄弟之间的关系。

示例

假设有一个N叉树,其结构如下:

Text Only
1
2
3
4
5
       A
     / | \
    B  C  D
   / \    |
  E   F   G

使用孩子兄弟表示法转化为二叉树后,结构变为:

Text Only
1
2
3
4
5
    A
   /
  B - C - D
 /         \
E - F       G

这里,-表示兄弟关系(右指针),垂直线表示父子关系(左指针)。

优点

​ 孩子兄弟表示法使得普通树的很多操作能够利用二叉树的算法来实现,例如,遍历、查找等,从而提高了算法的复用性。此外,这种表示法只需要两个指针就能表示N叉树的结构,有时可以减少存储空间的消耗。

缺点

​ 在某些情况下,如当原始树的结构非常不平衡时,使用孩子兄弟表示法转化后的二叉树可能会变得非常“偏斜”,这可能会对某些操作的效率产生影响。

应用

​ 孩子兄弟表示法在多叉树和二叉树之间的转换、树的存储、以及树的遍历等算法的实现中有着广泛的应用。尤其是在需要将树结构保存到文件或数据库中时,这种表示法能够简化结构,使得树的恢复更为直接。

下面是一个例子,展示了如何使用孩子兄弟表示法表示一棵树:

C++
1
2
3
4
5
6
7
      A
     / \
    B   C
   / \   \
  D   E   F
         / \
        G   H

使用孩子兄弟表示法,可以将这棵树转化为以下二叉树:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
           A
          /
         B
        / \
       D   E
            \
             C
              \
               F
              / \
             G   H

以下是一个使用孩子兄弟表示法表示树的示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <iostream>
using namespace std;

// 孩子兄弟表示法
struct CSNode {
    int data;            // 数据域
    CSNode* firstChild;  // 指向第一个孩子
    CSNode* nextSibling; // 指向下一个兄弟
};

// 创建树
CSNode* createTree() {
    CSNode* root = new CSNode;
    root->data = 1;

    CSNode* node2 = new CSNode;
    node2->data = 2;

    CSNode* node3 = new CSNode;
    node3->data = 3;

    CSNode* node4 = new CSNode;
    node4->data = 4;

    CSNode* node5 = new CSNode;
    node5->data = 5;

    CSNode* node6 = new CSNode;
    node6->data = 6;

    root->firstChild = node2;
    node2->nextSibling = node3;
    node3->nextSibling = NULL;
    node2->firstChild = node4;
    node4->nextSibling = node5;
    node5->nextSibling = NULL;
    node4->firstChild = node6;
    node6->nextSibling = NULL;
    node6->firstChild = NULL;

    return root;
}

// 先序遍历
void preOrder(CSNode* root) {
    if (root) {
        cout << root->data << " ";
        preOrder(root->firstChild);
        preOrder(root->nextSibling);
    }
}

int main() {
    CSNode* root = createTree();
    preOrder(root);
    return 0;
}

​ 在这个示例代码中,我们使用了孩子兄弟表示法来创建了一棵树,并使用先序遍历来遍历这棵树。通过这个示例,我们可以看到使用孩子兄弟表示法表示树的代码相对简单明了。

​ 在孩子兄弟表示法中,树的根节点对应的二叉树节点的左指针为它的第一个孩子节点,右指针为它的下一个兄弟节点。例如,节点B的左指针指向节点D,右指针指向节点E。

以下是使用C++实现孩子兄弟表示法转换的示例代码:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct CSNode {
    char data;
    CSNode *firstChild; // 指向第一个孩子节点
    CSNode *nextSibling; // 指向下一个兄弟节点
};

// 将一棵树转换为二叉树
CSNode* treeToBinaryTree(TreeNode *root) {
    if (root == NULL) {
        return NULL;
    }
    CSNode *csroot = new CSNode();
    csroot->data = root->val;
    csroot->firstChild = treeToBinaryTree(root->left);
    csroot->nextSibling = treeToBinaryTree(root->right);
    return csroot;
}

​ 在上面的代码中,TreeNode是二叉树的节点,每个节点包括一个整数值和指向左右子节点的指针。CSNode是孩子兄弟表示法中的节点,每个节点包括一个字符值和指向第一个孩子节点和下一个兄弟节点的指针。

treeToBinaryTree函数将一棵树转换为二叉树。它首先创建一个新的孩子兄弟表示法的根节点,将根节点的数据值设置为原树的根节点的值。然后,它递归地将原树的左子树转换为孩子兄弟表示法中的第一个孩子节点,将原树的右子树转换为孩子兄弟表示法中的下一个兄弟节点。

3.3 特殊树

​ 在计算机科学中,特殊树指的是具有特定属性或规则的树结构,它们被设计用来解决特定的问题或优化某些操作。下面介绍几种常见的特殊树:

1. 二叉搜索树(Binary Search Tree, BST)

​ 二叉搜索树是一种特殊的二叉树,其中每个节点包含一个键和它相关联的值。对于树中的每个节点X,其左子树中的所有键都小于X的键,而右子树的所有键都大于X的键。这一特性使得二叉搜索树在进行查找、插入和删除操作时非常高效。

2. 平衡二叉树(AVL Tree)

​ AVL树是一种自平衡的二叉搜索树,其中任何节点的两个子树的高度最多相差1。如果在任何时候它们的高度相差超过1,则会进行旋转操作来恢复平衡。这保证了树的深度保持在对数级别,从而使得查找、插入和删除操作都非常高效。

3. 红黑树(Red-Black Tree)

​ 红黑树是另一种自平衡的二叉搜索树,它通过确保树满足一系列的红黑性质来保持平衡。这些性质包括:每个节点被染成红色或黑色;根节点是黑色的;每个叶子节点(NIL节点,空节点)是黑色的;如果一个节点是红色的,则它的两个子节点都是黑色的;对于每个节点,从该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点。

4. B树和B+树

​ B树是一种自平衡的多路搜索树,它广泛用于数据库和文件系统中。B树通过保持数据的有序性并允许对树的节点进行多次分裂和合并来优化磁盘I/O操作。B+树是B树的变种,其中所有的值都存储在叶子节点上,并且叶子节点通过指针连接,这使得范围查询变得非常高效。

5. 前缀树(Trie)

​ 前缀树(也称字典树或Trie树)是一种搜索树,用于保存关联数组,其中的键通常是字符串。与二叉搜索树不同,位置在树中不同的节点可以有相同的前缀。它利用字符串之间的公共前缀来减少查询时间,特别适合于解决字符串检索问题。

6. 线段树(Segment Tree)

​ 线段树是一种二叉树结构,用于存储区间或线段,并允许快速查询结构中包含的某个区间内的信息(如最小值、最大值、总和等)。线段树特别适合处理大量动态范围查询问题,例如在区间上进行求和、最小/最大值查询以及更新元素值。

​ 这些特殊树在解决特定类型的问题时提供了极大的便利和效率,它们在算法设计、数据库管理、网络数据组织等领域有着广泛的应用。

3.3.1 线段树与树状数组

​ 线段树(Segment Tree)和树状数组(Binary Indexed Tree,简称BIT或Fenwick Tree)都是高效处理区间查询和修改的数据结构,尤其在解决动态区间求和、最大值或最小值问题时非常有用。它们各有优缺点,适用于不同的场景。

1.线段树(Segment Tree)

​ 线段树是一种二叉树结构,用于存储区间或线段,并支持快速的区间查询和更新操作。每个节点代表一个区间,并可能存储额外的信息,如该区间的总和、最大值或最小值等。

优点: - 灵活性高,能够支持多种区间操作,如求和、最大/最小值、区间修改等。 - 支持区间修改操作,如将一个区间内所有元素增加一个值。

缺点: - 实现相对复杂。 - 空间占用较大,大约是数组大小的4倍。

2.树状数组(Fenwick Tree)

树状数组也是一种用于区间查询和更新的高效数据结构,但它的结构更加简单,基于数组实现。

优点: - 实现简单,代码更加简洁。 - 空间效率较高,空间占用与原数组大小相同。

缺点: - 功能相对线段树更加有限,主要用于区间求和或区间修改操作。 - 不如线段树灵活,难以直接支持求区间最大值或最小值等操作。

使用场景对比

  • 当问题需要多种复杂的区间操作,且操作类型多变时,如除了求和还需要查询最大值、最小值或进行区间赋值等,线段树是更好的选择。
  • 对于主要进行区间求和查询和单点更新的问题,树状数组实现起来更加简单高效。
  • 空间效率方面,如果内存使用是一个考虑因素,树状数组通常是更好的选择。
  • 实现复杂度,如果希望代码更加简洁,或者实现时间有限,树状数组通常更易于编码。

​ 总的来说,选择线段树还是树状数组取决于具体问题的需求。对于大多数简单的区间求和问题,树状数组已经足够使用;而对于更加复杂的区间操作,线段树提供了更高的灵活性和功能性。

下面是一个基于数组实现的线段树示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <iostream>
using namespace std;
const int MAXN = 100005;

int n, m;
int a[MAXN];
struct SegTree {
    int l, r;
    int sum, lazy;
} tree[MAXN << 2]; // 线段树节点数量最多是原数组长度的 4 倍

void pushUp(int p) {
    tree[p].sum = tree[p << 1].sum + tree[p << 1 | 1].sum;
}

void pushDown(int p) {
    if (tree[p].lazy != 0) {
        tree[p << 1].lazy += tree[p].lazy;
        tree[p << 1 | 1].lazy += tree[p].lazy;
        tree[p << 1].sum += tree[p].lazy * (tree[p << 1].r - tree[p << 1].l + 1);
        tree[p << 1 | 1].sum += tree[p].lazy * (tree[p << 1 | 1].r - tree[p << 1 | 1].l + 1);
        tree[p].lazy = 0;
    }
}

void build(int p, int l, int r) {
    tree[p].l = l;
    tree[p].r = r;
    if (l == r) {
        tree[p].sum = a[l];
        return;
    }
    int mid = (l + r) >> 1;
    build(p << 1, l, mid);
    build(p << 1 | 1, mid + 1, r);
    pushUp(p);
}

void update(int p, int l, int r, int k) {
    if (l <= tree[p].l && tree[p].r <= r) {
        tree[p].sum += k * (tree[p].r - tree[p].l + 1);
        tree[p].lazy += k;
        return;
    }
    pushDown(p);
    int mid = (tree[p].l + tree[p].r) >> 1;
    if (l <= mid) update(p << 1, l, r, k);
    if (r > mid) update(p << 1 | 1, l, r, k);
    pushUp(p);
}

int query(int p, int l, int r) {
    if (l <= tree[p].l && tree[p].r <= r) {
        return tree[p].sum;
    }
    pushDown(p);
    int mid = (tree[p].l + tree[p].r) >> 1;
    int res = 0;
    if (l <= mid) res += query(p << 1, l, r);
    if (r > mid) res += query(p << 1 | 1, l, r);
    return res;
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }
    build(1, 1, n);
    for (int i = 1; i <= m; i++) {
        int op, l, r, k;
        scanf("%d", &op);
        if (op == 1) {
            scanf("%d%d%d", &l, &r, &k);
            update(1, l, r, k);
        } else {
            scanf("%d%d", &l, &r);
            printf("%d\n", query(1, l, r));
        }
    }
    return 0;
    }

基于指针实现线段树的代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 线段树节点结构体
struct Node {
    int start;   // 区间左端点
    int end;     // 区间右端点
    int sum;     // 区间元素和
    Node* left;  // 左子节点指针
    Node* right; // 右子节点指针

    // 构造函数
    Node(int s, int e) : start(s), end(e), sum(0), left(nullptr), right(nullptr) {}
};

// 线段树类
class SegmentTree {
public:
    // 构造函数
    SegmentTree(const vector<int>& nums) {
        root = buildTree(nums, 0, nums.size() - 1);
    }

    // 区间查询
    int query(int start, int end) {
        return queryHelper(root, start, end);
    }

    // 区间更新
    void update(int index, int val) {
        updateHelper(root, index, val);
    }

private:
    // 根节点指针
    Node* root;

    // 建立线段树
    Node* buildTree(const vector<int>& nums, int start, int end) {
        if (start > end) {
            return nullptr;
        }
        Node* node = new Node(start, end);
        if (start == end) {
            node->sum = nums[start];
        } else {
            int mid = start + (end - start) / 2;
            node->left = buildTree(nums, start, mid);
            node->right = buildTree(nums, mid + 1, end);
            node->sum = node->left->sum + node->right->sum;
        }
        return node;
    }

    // 区间查询
    int queryHelper(Node* node, int start, int end) {
        if (!node || node->start > end || node->end < start) {
            return 0;
        }
        if (start <= node->start && node->end <= end) {
            return node->sum;
        }
        int mid = node->start + (node->end - node->start) / 2;
        if (end <= mid) {
            return queryHelper(node->left, start, end);
        } else if (start > mid) {
            return queryHelper(node->right, start, end);
        } else {
            return queryHelper(node->left, start, mid) + queryHelper(node->right, mid + 1, end);
        }
    }

    // 区间更新
    void updateHelper(Node* node, int index, int val) {
        if (!node || index < node->start || index > node->end) {
            return;
        }
        if (node->start == node->end) {
            node->sum = val;
        } else {
            int mid = node->start + (node->end - node->start) / 2;
            if (index <= mid) {
                updateHelper(node->left, index, val);
            } else {
                updateHelper(node->right, index, val);
            }
            node->sum = node->left->sum + node->right->sum;
        }
    }
};

以下是树状数组的 C++ 实现代码示例:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
using namespace std;

const int MAXN = 100001;
int n, q, c[MAXN], tr[MAXN];

int lowbit(int x) {
    return x & -x;
}

void add(int x, int k) {
    while (x <= n) {
        tr[x] += k;
        x += lowbit(x);
    }
}

int sum(int x) {
    int ans = 0;
    while (x > 0) {
        ans += tr[x];
        x -= lowbit(x);
    }
    return ans;
}

int main() {
    cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        cin >> c[i];
        add(i, c[i]);
    }
    for (int i = 0; i < q; i++) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) {
            add(x, y - c[x]);
            c[x] = y;
        } else {
            cout << sum(y) - sum(x - 1) << endl;
        }
    }
    return 0;
}

​ 该代码实现了树状数组的基本操作,包括添加元素、计算前缀和等。在读入每个操作时,如果是修改操作,就先用 add 函数将数组中对应的位置的值修改,然后将 c 数组中的值也修改。如果是查询操作,就计算前缀和并输出。

3.3.2 字典树(trie树)

​ 字典树(Trie树),又称前缀树或Trie,是一种树形数据结构,特别适合于处理字符串集合的快速搜索问题。它可以高效地存储和检索字符串数据集中的键,这些键通常是字符串。Trie树的核心思想是利用字符串之间的公共前缀来减少查询时间,从而提高搜索的效率。

Trie树的基本性质

  1. 根节点不包含字符,除根节点外的每一个节点都只包含一个字符
  2. 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串
  3. 每个节点的所有子节点包含的字符都不相同

Trie树的操作

  • 插入(Insert):将一个字符串加入到Trie树中。这个操作通常是从根节点开始,沿着字符串的字符依次往下搜索,如果路径不存在,则创建新的节点。
  • 搜索(Search):检查一个字符串是否在Trie树中。这个操作也是从根节点开始,沿着字符串的字符依次往下搜索,如果能走完整条路径,则字符串存在于Trie树中。
  • 前缀搜索(StartsWith):检查是否存在以某个前缀开头的字符串。这个操作与搜索操作类似,但即使路径在中途结束,只要能走完前缀的路径,就认为满足条件。

Trie树的优点

  • 高效地支持字符串的快速搜索、插入和删除操作。对于长度为m的字符串,这些操作的时间复杂度仅为O(m)
  • 能够快速找出以某个字符串为前缀的所有字符串

Trie树的应用

Trie树在处理字符串数据方面非常有用,常见的应用场景包括:

  • 自动补全:如搜索引擎的搜索提示。
  • 拼写检查:检查单词是否存在于某个字典中。
  • IP路由:最长前缀匹配用于IP路由选择。
  • 词频统计:快速统计和排序大量字符串(如文章中的单词频率)。

示例实现

下面是Trie树插入和搜索操作的简单实现示例:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <vector>
#include <iostream>
using namespace std;

class TrieNode {
public:
    bool isEnd;
    std::vector<TrieNode*> children;

    TrieNode() : isEnd(false), children(26, nullptr) {}
};

class Trie {
private:
    TrieNode* root;
public:
    Trie() : root(new TrieNode()) {}

    void insert(std::string word) {
        TrieNode* node = root;
        for (char c : word) {
            if (node->children[c - 'a'] == nullptr) {
                node->children[c - 'a'] = new TrieNode();
            }
            node = node->children[c - 'a'];
        }
        node->isEnd = true;
    }

    bool search(std::string word) {
        TrieNode* node = root;
        for (char c : word) {
            node = node->children[c - 'a'];
            if (node == nullptr) {
                return false;
            }
        }
        return node->isEnd;
    }

    bool startsWith(std::string prefix) {
        TrieNode* node = root;
        for (char c : prefix) {
            node = node->children[c - 'a'];
            if (node == nullptr) {
                return false;
            }
        }
        return true;
    }
};

这个简单的实现展示了Trie树的基本结构和操作,但实际应用中可能需要根据具体需求进行扩展和优化。

3.3.3 笛卡尔树

笛卡尔树(Cartesian Tree)是一种特殊的二叉树数据结构,它由一组有序数据构建而来,满足以下性质:

  1. 堆性质:在笛卡尔树中,父节点的值总是小于(或大于,取决于是最小堆还是最大堆)其子节点的值。这意味着树的根节点是所有节点中的最小(或最大)元素。
  2. 中序遍历性质:对笛卡尔树进行中序遍历可以得到原始数据的顺序。

构建过程

给定一组有序数据(例如数组),笛卡尔树可以通过以下步骤构建: 1. 开始:以数组的第一个元素创建树的根节点。 2. 迭代:对于数组中的每一个后续元素,重复以下步骤: - 如果当前元素比栈顶节点小,则使当前元素成为栈顶节点的右子节点,并将当前元素入栈。 - 否则,不断从栈中弹出节点,直到找到一个比当前元素小的节点(或栈为空),使当前元素成为该节点的右子节点。如果栈不为空,将当前元素设置为栈顶节点的右子节点。然后,将当前元素入栈。 3. 完成:当所有元素都被处理后,栈底的元素是笛卡尔树的根。

应用

​ 笛卡尔树在计算机科学中有几种应用,其中最著名的可能是作为Treap(树+堆)的基础。Treap是一种随机化的数据结构,结合了二叉搜索树和堆的性质,用于高效地处理一系列集合操作(如搜索、插入、删除等)。除此之外,笛卡尔树也可以用于区间最小查询(RMQ)问题的解决方案之一。

示例

假设有一个数组A = [3, 1, 4, 2],构建其对应的最小堆笛卡尔树的过程如下:

  1. 以3为根节点。
  2. 1比3小,成为3的左子节点。
  3. 4比1大,成为1的右子节点。
  4. 2比4小,4弹出。2比1大,成为1的右子节点(替换了4)。4成为2的右子节点。

构建完成后的笛卡尔树如下:

Text Only
1
2
3
4
5
    1
   / \
  3   2
       \
        4

中序遍历此树将得到原始数组[3, 1, 4, 2]。

特性

​ 笛卡尔树的一个关键特性是它的构建过程只依赖于数据的顺序,而与具体的数值无关。因此,对于具有相同顺序的不同数据集,构建出的笛卡尔树结构是相同的。这个特性在解决某些算法问题时非常有用,尤其是当问题可以通过改变数据顺序来简化时。

以下是一个简单的笛卡尔树实现的 C++ 代码示例:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <stack>
using namespace std;

struct Node {
    int val;
    int index;
    Node *left;
    Node *right;
    Node(int v, int i) : val(v), index(i), left(nullptr), right(nullptr) {}
};

Node* buildCartesianTree(int arr[], int n) {
    stack<Node*> st;
    for (int i = 0; i < n; ++i) {
        Node* cur = new Node(arr[i], i);
        while (!st.empty() && cur->val < st.top()->val) {
            cur->left = st.top();
            st.pop();
        }
        if (!st.empty()) {
            st.top()->right = cur;
        }
        st.push(cur);
    }
    while (st.size() > 1) {
        st.pop();
    }
    return st.top();
}

void printTree(Node* root) {
    if (!root) {
        return;
    }
    cout << root->val << " ";
    printTree(root->left);
    printTree(root->right);
}

int main() {
    int arr[] = {5, 10, 40, 30, 28};
    int n = sizeof(arr) / sizeof(arr[0]);
    Node* root = buildCartesianTree(arr, n);
    printTree(root);
    return 0;
}

​ 在这个实现中,buildCartesianTree 函数用于构建笛卡尔树。它使用一个栈来维护一个单调递增的栈,遍历整个输入数组,对于每个元素,如果它比栈顶元素小,则将栈顶元素作为它的左子树,并将其弹出栈。然后,将当前元素插入到栈中,并将其作为栈顶元素的右子树。最后返回栈中唯一剩下的元素,即笛卡尔树的根节点。

printTree 函数用于以先序遍历的方式输出笛卡尔树中的所有节点。

3.3.4 二叉平衡树AVL、treap、splay等

​ 二叉平衡树是一种自平衡二叉搜索树,其通过在插入和删除节点时进行平衡操作,保持树的平衡,以维护搜索效率。常见的二叉平衡树有 AVL 树、Treap 和 Splay 树等。

​ AVL 树是由 Adelson-Velsky 和 Landis 在 1962 年发明的一种自平衡二叉搜索树,其通过在每个节点上维护一个平衡因子(balance factor),即左子树高度减去右子树高度的值,使得树保持平衡。当插入或删除节点后,如果某个节点的平衡因子超出了 [-1, 1] 的范围,就需要进行平衡操作,使得树重新达到平衡状态。平衡操作包括单旋转和双旋转两种,具体的操作取决于节点的平衡因子和子节点的平衡因子。

​ Treap 是一种随机化平衡树,其结合了二叉搜索树和堆的性质。在 Treap 中,每个节点有一个关键字和一个随机权值,节点按照关键字构成一颗二叉搜索树,按照权值构成一个堆。为了保持平衡,Treap 中的每个节点都满足堆的性质,即父节点的权值比子节点的权值小(或大)。Treap 的插入和删除操作都涉及到旋转操作,通过旋转操作来保持树的平衡。

​ Splay 树是一种基于伸展操作的自适应数据结构,它在访问某个节点时将其旋转到根节点位置,从而使得被访问的节点更容易被访问到。Splay 树的插入、删除和查找操作都涉及到伸展操作,通过对节点进行伸展操作来改变树的形状,以维护平衡性和自适应性。

由于平衡树代码比较复杂,以下给出几种平衡树的C++代码示例供参考:

AVL树:

C++
  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
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#include<bits/stdc++.h>
using namespace std;

struct node
{
    int data;
    int height;
    node *left, *right;

    node(int data)
    {
        this->data = data;
        height = 1;
        left = NULL;
        right = NULL;
    }
};

int height(node* root)
{
    if(!root) return 0;
    return root->height;
}

int getBalance(node* root)
{
    if(!root) return 0;
    return height(root->left) - height(root->right);
}

node* rightRotate(node* y)
{
    node* x = y->left;
    node* t2 = x->right;

    x->right = y;
    y->left = t2;

    y->height = max(height(y->left), height(y->right)) + 1;
    x->height = max(height(x->left), height(x->right)) + 1;

    return x;
}

node* leftRotate(node* x)
{
    node* y = x->right;
    node* t2 = y->left;

    y->left = x;
    x->right = t2;

    x->height = max(height(x->left), height(x->right)) + 1;
    y->height = max(height(y->left), height(y->right)) + 1;

    return y;
}

node* insert(node* root, int data)
{
    if(!root) return new node(data);

    if(data < root->data)
        root->left = insert(root->left, data);
    else if(data > root->data)
        root->right = insert(root->right, data);
    else
        return root;

    root->height = max(height(root->left), height(root->right)) + 1;

    int balance = getBalance(root);

    if(balance > 1 && data < root->left->data)
        return rightRotate(root);

    if(balance < -1 && data > root->right->data)
        return leftRotate(root);

    if(balance > 1 && data > root->left->data)
    {
        root->left = leftRotate(root->left);
        return rightRotate(root);
    }

    if(balance < -1 && data < root->right->data)
    {
        root->right = rightRotate(root->right);
        return leftRotate(root);
    }

    return root;
}

void preOrder(node* root)
{
    if(!root) return;

    cout << root->data << " ";
    preOrder(root->left);
    preOrder(root->right);
}

int main()
{
    node* root = NULL;

    root = insert(root, 10);
    root = insert(root, 20);
    root = insert(root, 30);
    root = insert(root, 40);
    root = insert(root, 50);
    root = insert(root, 25);

    cout << "Preorder traversal of the constructed AVL tree is: \n";
    preOrder(root);

    return 0;
}

以下是一个基于随机化的Treap树的完整C++代码实现:

C++
  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
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
#include <iostream>
#include <cstdlib>

using namespace std;

// 用于存储节点信息的结构体
struct TreapNode {
    int key;
    int priority;
    TreapNode* left;
    TreapNode* right;
};

// 创建新节点并返回
TreapNode* newTreapNode(int key) {
    TreapNode* newNode = new TreapNode;
    newNode->key = key;
    newNode->priority = rand();
    newNode->left = NULL;
    newNode->right = NULL;
    return newNode;
}

// 左旋操作
void leftRotate(TreapNode*& root) {
    TreapNode* newRoot = root->right;
    root->right = newRoot->left;
    newRoot->left = root;
    root = newRoot;
}

// 右旋操作
void rightRotate(TreapNode*& root) {
    TreapNode* newRoot = root->left;
    root->left = newRoot->right;
    newRoot->right = root;
    root = newRoot;
}

// 插入节点操作
void insertNode(TreapNode*& root, int key) {
    if (root == NULL) {
        root = newTreapNode(key);
        return;
    }

    // 如果待插入的key小于当前节点的key,则向左子树插入
    if (key < root->key) {
        insertNode(root->left, key);
        // 如果插入后左子树的优先级比右子树大,则进行右旋操作
        if (root->left != NULL && root->left->priority > root->priority) {
            rightRotate(root);
        }
    }
    // 如果待插入的key大于等于当前节点的key,则向右子树插入
    else {
        insertNode(root->right, key);
        // 如果插入后右子树的优先级比左子树大,则进行左旋操作
        if (root->right != NULL && root->right->priority > root->priority) {
            leftRotate(root);
        }
    }
}

// 删除节点操作
void deleteNode(TreapNode*& root, int key) {
    if (root == NULL) {
        return;
    }

    if (key < root->key) {
        deleteNode(root->left, key);
    } else if (key > root->key) {
        deleteNode(root->right, key);
    } else {
        if (root->left == NULL && root->right == NULL) {
            delete root;
            root = NULL;
        } else if (root->left == NULL) {
            TreapNode* temp = root;
            root = root->right;
            delete temp;
        } else if (root->right == NULL) {
            TreapNode* temp = root;
            root = root->left;
            delete temp;
        } else {
            if (root->left->priority > root->right->priority) {
                rightRotate(root);
                deleteNode(root->right, key);
            } else {
                leftRotate(root);
                deleteNode(root->left, key);
            }
        }
    }
}

// 中序遍历
void inorderTraversal(TreapNode* root) {
    if (root != NULL) {
        inorderTraversal(root->left);
        cout << root->key << " ";
        inorderTraversal(root->right);
        }
}

3.3.5 基环树

​ 基环树是一类特殊的树形结构,它是一棵有根树,其中存在一条路径,形成了一个环。基环树常常出现在一些计算几何的算法中,例如圆方树、平面图最大匹配、Voronoi图等。

​ 基环树通常的定义为:一棵有根树,其中一个节点作为根节点,并且在树中存在一条简单路径 \(P={v_1,v_2,\cdots,v_k}\),其中 \(v_1\) 是根节点,\(v_k\)\(v_1\) 在树上相邻,即 \(v_k\)\(v_1\) 的一个孩子,且 \(P\) 上除 \(v_1\)\(v_k\) 外的所有节点都是叶子节点。

​ 在实际应用中,基环树常常用来解决一些最大值或者最小值的问题。比如求环上权值的最小值或最大值、基环树上路径的最大值或最小值等。此外,基环树还可以用来解决一些图论中的问题,例如在一张无向图中,找到一条长度为 \(k\) 的简单路径,并且使得路径上所有边的权值的异或和最大。

​ 实现基环树的方法有很多种,例如双指针、树形DP、DFS、贪心等。其中最常用的算法是双指针算法。

基本思路:

  1. 首先按照常规方式读入一棵树。
  2. 对于每个节点,记录它的深度depth和fa[u],fa[u]表示u在树上的父节点。
  3. 从任意一个点开始DFS,标记访问过的节点,并记录经过的节点和路径总长度。
  4. 如果访问到一个已经标记过的节点,则表示已经找到了环。此时将环上的节点从数组中提取出来,并记录环的长度。
  5. 对于每个环上的节点,求出它们到环上任意一个点的距离,并计算出该节点到根节点的距离。
  6. 对于环上的每个节点,它们的祖先节点一定在环上。因此可以利用倍增等方式求出环上节点到根节点的距离。
  7. 对于环上的每个节点u,可以用它到根节点的距离dis[u]以及环的长度len计算出它到其他任意节点v的距离。

3.4 常见图

3.4.1 稀疏图

​ 稀疏图是图论中的一个概念,指的是图中边的数量相对于顶点数量来说比较少的图。具体来说,如果一个图有V个顶点,但是边的数量远远小于V(V-1)/2(完全图的边的数量),那么这个图就可以被认为是稀疏的。在稀疏图中,大多数顶点之间都没有直接的边相连。

稀疏图的存储

由于稀疏图的边相对较少,使用邻接矩阵来存储会造成大量的空间浪费。因此,稀疏图通常使用以下两种方式来存储:

  1. 邻接表(Adjacency List):每个顶点存储一个列表,该列表包含了与该顶点直接相连的所有顶点。这种方法相对节省空间,特别适合存储稀疏图。

  2. 边列表(Edge List):直接存储所有边的信息,每条边由其两个端点表示。这种方法在边的数量非常少时也非常有效。

稀疏图的应用

稀疏图在很多实际应用中都非常常见,例如:

  • 社交网络:虽然社交网络可能包含成千上万的用户(顶点),但每个用户(顶点)通常只与相对较少的其他用户有直接联系(边)。
  • 网络拓扑:互联网或其他通信网络的拓扑结构通常也是稀疏的,因为并不是每个节点都直接与其他所有节点相连。
  • 交通网络:交通网络(如道路网、航线图)中,交叉口或站点之间的直接路线也相对较少。

稀疏图的优点

  • 空间效率:使用邻接表等方式存储稀疏图可以显著减少存储空间的需求。
  • 查询效率:对于某些操作(如查找一个顶点的所有邻接点)来说,邻接表等存储方法可以提供更快的查询速度。

稀疏图的挑战

  • 边的遍历:对于稀疏图的全边遍历可能不如邻接矩阵直观,特别是在实现一些图算法(如最小生成树、单源最短路径)时需要考虑适合稀疏图的算法版本。
  • 动态更新:如果图频繁更新(添加或删除边),维护邻接表可能会比维护邻接矩阵更复杂。

综上所述,稀疏图是图论和网络科学中一个重要的概念,正确选择存储和处理方法对于优化应用性能至关重要。

以下是一个稀疏图的示例代码,包含邻接表的定义、添加边、遍历等操作:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <vector>

using namespace std;

// 邻接表中每个节点的定义
struct Node {
    int v;          // 与该节点相邻的节点
    int w;          // 与该节点相邻的边的权值
    Node(int _v, int _w) : v(_v), w(_w) {}
};

// 邻接表的定义
vector<vector<Node>> adj;

// 添加边
void addEdge(int u, int v, int w) {
    adj[u].push_back(Node(v, w));
    adj[v].push_back(Node(u, w));
}

// 深度优先遍历
void dfs(int u, vector<bool>& visited) {
    visited[u] = true;
    cout << u << " ";
    for (auto node : adj[u]) {
        int v = node.v;
        if (!visited[v]) {
            dfs(v, visited);
        }
    }
}

// 广度优先遍历
void bfs(int s, vector<bool>& visited) {
    vector<int> q;
    q.push_back(s);
    visited[s] = true;
    while (!q.empty()) {
        int u = q.front();
        q.erase(q.begin());
        cout << u << " ";
        for (auto node : adj[u]) {
            int v = node.v;
            if (!visited[v]) {
                visited[v] = true;
                q.push_back(v);
            }
        }
    }
}

int main() {
    int n = 5;              // 图中点的数量
    adj.resize(n);          // 初始化邻接表
    addEdge(0, 1, 1);       // 添加边
    addEdge(1, 2, 2);
    addEdge(2, 3, 3);
    addEdge(3, 4, 4);
    addEdge(4, 0, 5);

    // 深度优先遍历
    cout << "DFS: ";
    vector<bool> visited(n, false);
    dfs(0, visited);
    cout << endl;

    // 广度优先遍历
    cout << "BFS: ";
    visited = vector<bool>(n, false);
    bfs(0, visited);
    cout << endl;

    return 0;
}

​ 在这个示例代码中,我们首先定义了一个Node结构体,用来表示邻接表中的每个节点。然后我们定义了邻接表adj,并实现了添加边、深度优先遍历和广度优先遍历等操作。在主函数中,我们创建了一个包含5个点的稀疏图,添加了5条边,并进行了深度优先遍历和广度优先遍历。

3.4.2 偶图(二分图)

​ 偶图,也称为二分图(Bipartite Graph),是图论中的一种特殊类型的图。在一个二分图中,顶点集可以被分割成两个互不相交的子集(称为部),使得图中的每条边都连接了两个属于不同子集的顶点,换句话说,图中不存在任何两个同一子集内的顶点之间的边。

二分图的性质

  1. 顶点集的分割:整个顶点集V可以被分割为两个互不相交的子集AB,所有的边连接的都是A中的顶点和B中的顶点。
  2. 无奇数环:二分图中不存在奇数长度的环。
  3. 着色问题:二分图可以使用两种颜色对顶点进行着色,使得任何一条边的两个端点颜色不同。这也意味着二分图是2-可着色的。

二分图的判断

​ 判断一个图是否为二分图的一个常用方法是通过图的遍历(如深度优先搜索DFS或广度优先搜索BFS)来进行。算法的基本思想是尝试将图的顶点着色为两种颜色,如果在着色过程中发现有相邻的顶点被着成了同一种颜色,那么这个图就不是二分图。

二分图的应用

二分图在多个领域有着广泛的应用,包括:

  • 匹配问题:如求解最大匹配问题,二分图的最大匹配问题比一般图的最大匹配问题更容易解决。
  • 网络流问题:在网络流和最大流问题中,二分图可以用来模拟生产者和消费者之间的关系。
  • 调度问题:如任务分配问题,可以将任务和执行者分别放在二分图的两个不同集合中,边表示任务和执行者之间的潜在分配。
  • 稳定婚姻问题:通过构造一个二分图来表示男女之间的偏好关系,进而求解稳定匹配。

示例

​ 假设有一个图,顶点集合为{1, 2, 3, 4},边集合为{(1, 2), (1, 3), (1, 4)}。这个图可以被分为两个部分:A = {1}B = {2, 3, 4},其中每条边都连接了A中的顶点和B中的顶点,因此这是一个二分图。

​ 二分图的概念在图论中占据了重要的地位,不仅因为它自身的特殊性质,而且因为它在理论研究和实际应用中都有着非常重要的角色。

以下是一个简单的判断偶图的示例代码:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <vector>
using namespace std;

const int N = 100010;
int n, m;
vector<int> g[N];
int color[N];   // 记录每个节点的染色情况

bool dfs(int u, int c)
{
    color[u] = c;  // 将节点u染成颜色c

    for (int i = 0; i < g[u].size(); i ++ )
    {
        int v = g[u][i];
        if (color[v] == c) return false;  // 相邻节点颜色相同,不是二分图
        if (!color[v] && !dfs(v, -c)) return false;  // 递归染色,如果不是二分图,返回false
    }

    return true;
}

int main()
{
    cin >> n >> m;

    while (m -- )
    {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);
    }

    bool flag = true;
    for (int i = 1; i <= n; i ++ )
        if (!color[i] && !dfs(i, 1))
        {
            flag = false;
            break;
        }

    if (flag) puts("Yes");
    else puts("No");

    return 0;
}

3.4.3 欧拉图

​ 欧拉图是图论中的一个重要概念,它描述了一个图(无向图或有向图)中所有边的遍历问题。具体来说,欧拉图分为两类:欧拉回路(Eulerian Circuit)和欧拉路径(Eulerian Path)。

欧拉回路(Eulerian Circuit)

​ 欧拉回路是指在图中存在一条路径,它经过图中的每条边恰好一次,并且路径的起点和终点是同一个顶点。换句话说,从图中的某个顶点出发,经过图中的每一条边恰好一次后,又回到了起点。

欧拉回路的存在条件

  • 无向图:每个顶点的度数(与该顶点相连的边的数量)都是偶数。
  • 有向图:每个顶点的入度(指向该顶点的边的数量)和出度(从该顶点出发的边的数量)相等。

欧拉路径(Eulerian Path)

欧拉路径是指在图中存在一条路径,它经过图中的每条边恰好一次,但路径的起点和终点不需要是同一个顶点。

欧拉路径的存在条件

  • 无向图:恰有两个顶点的度数是奇数(这两个顶点分别作为路径的起点和终点),其余顶点的度数都是偶数。
  • 有向图:恰有一个顶点的出度比入度多1(路径起点),恰有一个顶点的入度比出度多1(路径终点),其余顶点的入度和出度相等。

欧拉图的应用

欧拉图的概念在多个领域有广泛应用,如:

  • 邮递员问题(也称为中国邮递员问题):邮递员需要遍历城市中的每条街道至少一次,且希望走的总距离尽可能短。这可以通过寻找欧拉回路或欧拉路径来解决。
  • 电路设计:在电路板设计中,需要确保电路的连通性并最小化连线的长度,这可以通过构造欧拉路径来实现。
  • 生物信息学:在某些DNA测序技术中,通过构建欧拉路径来重组DNA序列。

欧拉图的研究不仅增进了我们对图论的理解,也提供了解决实际问题的有效工具。

以下是一个用于判断一个无向图是否是欧拉图的 C++ 程序,其中使用了 DFS 遍历和判断度数的方法:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 1005;

vector<int> G[MAXN];
bool vis[MAXN];
int degree[MAXN];

void dfs(int u) {
    vis[u] = true;
    for (int i = 0; i < G[u].size(); i++) {
        int v = G[u][i];
        if (!vis[v]) {
            dfs(v);
        }
    }
}

bool isEulerGraph(int n) {
    for (int u = 1; u <= n; u++) {
        for (int i = 0; i < G[u].size(); i++) {
            int v = G[u][i];
            degree[u]++;
            degree[v]++;
        }
    }

    int cnt = 0;
    for (int i = 1; i <= n; i++) {
        if (degree[i] % 2 == 1) {
            return false;
        }
        if (degree[i] > 0 && !vis[i]) {
            dfs(i);
            cnt++;
        }
    }

    if (cnt == 1) {
        return true;
    }
    return false;
}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        G[u].push_back(v);
        G[v].push_back(u);
    }

    if (isEulerGraph(n)) {
        cout << "Yes" << endl;
    } else {
        cout << "No" << endl;
    }

    return 0;
}

​ 程序首先输入了无向图的节点个数 n 和边数 m,然后通过 vector 来存储图中的边,接着遍历每个节点,计算其度数并判断是否为奇数。最后使用 DFS 遍历所有连通块,若只有一个连通块则是欧拉图,否则不是欧拉图。

3.4.4 有向无环图

​ 有向无环图(Directed Acyclic Graph,简称DAG)是一种特殊类型的有向图,在这种图中,顶点通过边连接,每条边都有一个方向,且图中不存在任何环路(从某一顶点出发,经过若干条边后,又回到该顶点的路径)。

DAG的性质

  • 无环:DAG最关键的性质是不存在环,这意味着你不能从任何一个顶点出发,经过一系列的边后再回到这个顶点。
  • 拓扑排序:DAG的所有顶点可以被排列成线性序列,这个序列被称为拓扑排序。在拓扑排序中,对于任何一条从顶点U到顶点V的有向边,U在序列中都出现在V之前。拓扑排序不一定是唯一的。

DAG的应用

DAG在计算机科学和工程中有着广泛的应用:

  • 任务调度:在操作系统或构建系统中,任务(如编译源文件)之间的依赖关系可以用DAG表示,以确定执行任务的合理顺序。
  • 数据处理流:在数据处理和流水线处理中,DAG用来表示数据之间的依赖关系,确保数据按正确的顺序被处理。
  • 优化问题:在诸如动态规划解决的优化问题中,DAG用来表示决策过程,每个顶点表示一个状态,边表示从一个状态到另一个状态的转移。
  • 版本控制:在一些版本控制系统中,DAG用来表示不同版本之间的关系。例如,Git内部就使用DAG来追踪和管理项目的版本历史。

DAG的实现和操作

实现和操作DAG通常涉及到以下几个方面:

  • 存储结构:DAG可以通过邻接表或邻接矩阵来实现,类似于一般的有向图。
  • 拓扑排序:拓扑排序是DAG的一种基本操作,常用的算法有Kahn算法和基于深度优先搜索(DFS)的算法。
  • 路径和连通性查询:可以使用动态规划、DFS等方法在DAG中查询路径和检查连通性。

DAG由于其独特的性质(无环)和在各种场景下的应用价值,成为了图论和算法设计中非常重要的一个概念。

以下是一个C++程序,实现了拓扑排序,并判断一个有向图是否为DAG:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int MAXN = 100010;

vector<int> adj[MAXN];  // 邻接表
int inDegree[MAXN];     // 记录每个节点的入度

// 拓扑排序
bool topologicalSort(int n) {
    queue<int> q;
    for (int i = 1; i <= n; i++) {
        if (inDegree[i] == 0) {
            q.push(i);
        }
    }
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : adj[u]) {
            inDegree[v]--;
            if (inDegree[v] == 0) {
                q.push(v);
            }
        }
    }
    for (int i = 1; i <= n; i++) {
        if (inDegree[i] != 0) {
            return false;   // 图中存在环,不是DAG
        }
    }
    return true;    // 图是DAG
}

int main() {
    int n, m;
    cin >> n >> m;  // n表示节点个数,m表示边数
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        inDegree[v]++;
    }
    bool isDAG = topologicalSort(n);
    if (isDAG) {
        cout << "The graph is a DAG." << endl;
    } else {
        cout << "The graph is not a DAG." << endl;
    }
    return 0;
}

​ 在该程序中,adj数组是邻接表,inDegree数组记录每个节点的入度。在main函数中,我们先输入节点个数n和边数m,然后输入每条边的起点和终点,并更新邻接表和入度数组。接着,我们调用topologicalSort函数进行拓扑排序,如果返回值为true,则该图是DAG,否则不是DAG。

3.4.5 连通图与强连通图

​ 在图论中,连通图和强连通图是两个描述图中顶点间连接方式的重要概念,主要应用于无向图和有向图。

连通图

连通图特指无向图的一种性质,其中任意两个顶点间都存在路径相连。

  • 连通图:在无向图中,如果任意两个顶点之间都至少存在一条路径,则该图称为连通图。
  • 连通分量:在非连通的无向图中,可以将图分割成若干个连通的子图,这些子图称为连通分量。

强连通图

强连通图是针对有向图的概念,其中任意两个顶点间都存在双向的路径相连。

  • 强连通图:在有向图中,如果对于每一对顶点uv,都存在从uv以及从vu的路径,则该图称为强连通图。
  • 强连通分量:在非强连通的有向图中,可以将图分割成若干个强连通的子图,这些子图称为强连通分量。图中的每个强连通分量可以通过图的缩减变换为一个顶点,得到的缩减图称为分量图,分量图是一个有向无环图(DAG)。

判断方法

  • 连通图的判断:可以通过深度优先搜索(DFS)或广度优先搜索(BFS)遍历无向图的所有顶点。如果遍历过程中访问到了所有顶点,则该图为连通图。
  • 强连通图的判断:对于有向图,判断强连通性可以通过应用Kosaraju算法、Tarjan算法或Gabow算法等,这些算法能够找出图中的所有强连通分量。

应用

  • 社交网络分析:在社交网络图中,连通分量可以用来识别紧密联系的人群。
  • 网页搜索引擎:互联网可以看作是一个巨大的有向图,强连通分量分析有助于搜索引擎优化和网页排名。
  • 系统稳定性分析:在分析一个系统的稳定性时,将系统建模为图,通过判断系统的连通性或强连通性来评估系统的稳健性。

连通图和强连通图的概念在图理论和算法设计中扮演着重要角色,它们的分析有助于理解图的结构特性和解决实际问题。

以下是使用深度优先搜索判断无向图是否为连通图的C++代码示例:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
#include <vector>
using namespace std;

void dfs(int u, vector<int>& visited, vector<vector<int>>& graph) {
    visited[u] = 1;
    for (int v : graph[u]) {
        if (!visited[v]) {
            dfs(v, visited, graph);
        }
    }
}

bool is_connected(vector<vector<int>>& graph) {
    int n = graph.size();
    vector<int> visited(n, 0);
    dfs(0, visited, graph);
    for (int v : visited) {
        if (v == 0) {
            return false;
        }
    }
    return true;
}

int main() {
    int n, m;
    cin >> n >> m;
    vector<vector<int>> graph(n);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        graph[v].push_back(u);
    }
    if (is_connected(graph)) {
        cout << "This is a connected graph." << endl;
    } else {
        cout << "This is not a connected graph." << endl;
    }
    return 0;
}

以下是使用深度优先搜索判断有向图是否为强连通图的C++代码示例:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;

const int MAXN = 10005;
vector<int> G[MAXN]; // 邻接表存图
int vis[MAXN], id[MAXN], cnt; // vis数组表示节点是否已被遍历,id数组记录连通分量编号,cnt记录连通分量个数

void dfs(int u)
{
    vis[u] = 1;
    for (int i = 0; i < G[u].size(); i++) {
        int v = G[u][i];
        if (!vis[v]) dfs(v);
    }
    id[u] = cnt; // 记录所在的连通分量编号
}

int main()
{
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        G[u].push_back(v);
    }

    cnt = 0;
    memset(vis, 0, sizeof(vis));
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            cnt++;
            dfs(i);
        }
    }

    if (cnt == 1) cout << "Yes" << endl; // 只有一个连通分量,是强连通图
    else cout << "No" << endl;

    return 0;
}

​ 该代码通过深度优先搜索遍历有向图,并记录每个节点所在的连通分量编号。如果有多个连通分量,则说明该图不是强连通图。如果只有一个连通分量,则说明该图是强连通图。

3.4.6 双连通图

​ 双连通图是图论中的一个概念,它进一步强化了连通图的定义,主要用于无向图。双连通性可以用来评估网络、图结构的鲁棒性,特别是在面对节点或边失败时的容错能力。

双连通图的定义

  • 双连通图(Biconnected Graph):在一个无向图中,如果没有任何顶点的移除能够导致图变得不连通,那么这个图被称为双连通的。换句话说,图中任意两个顶点至少存在两条无公共边的独立路径相连。
  • 割点(Cut Vertex):图中的一个顶点,如果去掉它(以及与它相连的边)后,图变成了两个(或更多)不连通的部分,那么这个顶点称为割点。
  • 桥(Bridge):图中的一条边,如果去掉它后,图变成了两个(或更多)不连通的部分,那么这条边称为桥。
  • 双连通分量(Biconnected Component):图中的最大双连通子图称为双连通分量。任何无向图都可以分解为若干双连通分量,这些分量可能通过割点相互连接。

双连通分量的判断和应用

判断一个图的双连通分量通常使用基于深度优先搜索(DFS)的算法,如Tarjan算法。这种算法可以有效地识别出所有的割点和桥,从而得到图的双连通分量。

双连通分量的概念在计算机网络、电路设计等领域有广泛应用: - 网络设计:通过增强网络的双连通性,可以提高网络在面对节点或连接故障时的容错能力。 - 电路设计:在电路板的布线设计中,双连通性可以确保电路在某些连接断开的情况下仍能正常工作。

示例

考虑一个无向图,包含顶点{A, B, C, D, E}和边{AB, AC, BD, CD, DE}。在这个图中: - 移除顶点C或任何与C相连的边都会使图变得不连通,因此C是一个割点。 - 移除边DE会使得顶点E与其他顶点不连通,因此DE是一个桥。 - 如果没有单独的顶点或边的移除能使图变得不连通,则该图为双连通图。

通过这些定义和概念,我们可以更好地理解和评估图结构的稳定性和鲁棒性。

以下是基于邻接表存储的有向图,使用Kosaraju算法求解重连通图的C++代码示例:

C++
 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>
#include <vector>
#include <stack>
using namespace std;

const int MAXN = 1000;
vector<int> graph[MAXN], rev_graph[MAXN];
bool visited[MAXN];
stack<int> S;
int component[MAXN], component_cnt = 0;

void dfs1(int u) {
    visited[u] = true;
    for (int i = 0; i < graph[u].size(); i++) {
        int v = graph[u][i];
        if (!visited[v]) {
            dfs1(v);
        }
    }
    S.push(u);
}

void dfs2(int u) {
    visited[u] = true;
    component[u] = component_cnt;
    for (int i = 0; i < rev_graph[u].size(); i++) {
        int v = rev_graph[u][i];
        if (!visited[v]) {
            dfs2(v);
        }
    }
}

int kosaraju(int n) {
    // 第一次DFS,得到拓扑序列
    for (int i = 1; i <= n; i++) {
        if (!visited[i]) {
            dfs1(i);
        }
    }
    // 第二次DFS,处理每个强连通分量
    for (int i = 1; i <= n; i++) {
        visited[i] = false;
    }
    while (!S.empty()) {
        int u = S.top();
        S.pop();
        if (!visited[u]) {
            component_cnt++;
            dfs2(u);
        }
    }
    return component_cnt;
}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        rev_graph[v].push_back(u);
    }
    int cnt = kosaraju(n);
    cout << "Number of strongly connected components: " << cnt << endl;
    for (int i = 1; i <= n; i++) {
        cout << "Vertex " << i << " is in component " << component[i] << endl;
    }
    return 0;
}

​ 该程序使用了邻接表存储图,首先进行第一次DFS,得到拓扑序列。然后对反向图进行第二次DFS,处理每个强连通分量,并给每个点标记它所属的分量。最后输出分量数和每个点所属的分量。

3.5 哈希表

​ 哈希表(Hash Table),也称散列表,是一种实现快速数据存储和检索的数据结构。哈希表通过将键(Key)映射到表中一个位置来访问记录,这使得查找和插入操作的平均时间复杂度非常低。哈希表的性能很大程度上依赖于哈希函数的设计以及处理冲突的方法。

基本原理

​ 哈希表使用哈希函数将每个键值映射到一个位置上,以便于快速查找和插入数据项。理想情况下,不同的键会被映射到不同的位置。然而,在实际应用中,两个不同的键可能会被映射到同一个位置,这种情况被称为“哈希冲突”(Hash Collision)。

处理哈希冲突的方法

  1. 开放寻址法(Open Addressing):一旦发生冲突,就按照某种顺序寻找下一个空闲的槽位。
  2. 线性探测(Linear Probing)
  3. 二次探测(Quadratic Probing)
  4. 双重散列(Double Hashing)

  5. 链地址法(Chaining):每个表格项维护一个列表,所有映射到该位置的元素都会被添加到列表中。

哈希函数

哈希函数的设计至关重要,它影响着哈希表的性能。一个好的哈希函数应该满足以下条件: - 快速计算 - 均匀分布,以减少哈希冲突 - 依赖于所有输入数据,以避免特定模式的出现

哈希表的应用

哈希表广泛用于各种应用场景,包括: - 数据库的索引 - 缓存(如Memcached、Redis) - 唯一性验证 - 快速数据查找和访问 - 实现编程语言中的字典、哈希映射或集合

示例

​ 假设我们有一组键值对数据,键是姓名,值是电话号码,我们可以使用哈希表来快速查询任何人的电话号码。使用链地址法来处理哈希冲突,哈希函数简单地将姓名的每个字母的ASCII值相加并取模。

​ 哈希表是计算机科学中一个非常重要的概念,它的高效性在很多需要快速数据处理的场景中得到了应用。正确地选择哈希函数和冲突处理策略对于优化哈希表的性能至关重要。

​ 下面是一个使用 STL 中的 unordered_map 实现哈希表的示例代码,其中 unordered_map 是 C++11 中引入的一个哈希表实现:

C++
 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
26
27
28
29
#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
    // 创建哈希表
    unordered_map<int, string> my_map;

    // 插入元素
    my_map.insert({1, "apple"});
    my_map.insert({2, "banana"});
    my_map.insert({3, "cherry"});

    // 查找元素
    if (my_map.count(2)) {
        cout << "2 -> " << my_map[2] << endl;
    } else {
        cout << "2 is not found." << endl;
    }

    // 删除元素
    my_map.erase(1);

    // 遍历哈希表
    for (const auto& p : my_map) {
        cout << p.first << " -> " << p.second << endl;
    }

    return 0;
}

​ 在上面的代码中,我们首先创建了一个键为 int 类型,值为 std::string 类型的哈希表 my_map。然后我们使用 insert() 方法向哈希表中插入了三个键值对。接着,我们使用 count() 方法查找键为 2 的元素是否存在,如果存在,则输出对应的值。然后我们使用 erase() 方法删除了键为 1 的元素。最后,我们使用 for 循环遍历哈希表,输出其中的键值对。

3.5.1 数值哈希函数构造

​ 数值哈希函数是为了将数值数据映射到哈希表的索引上而设计的。一个好的数值哈希函数应满足将输入数据均匀分布到哈希表的不同位置,以减少冲突的可能性。在构造数值哈希函数时,需要考虑数据的特性和哈希表的大小。以下是几种常用的数值哈希函数构造方法:

1. 直接取模法

最简单且最常见的方法是直接将数值与哈希表的大小取模。

C++
1
2
3
int hashFunction(int key, int tableSize) {
    return key % tableSize;
}

​ 这种方法简单高效,但如果哈希表的大小是某些特定的数(如2的幂),则可能导致分布不均匀。选择一个合适的、通常是质数的哈希表大小可以改善分布情况。

2. 乘法哈希法

乘法哈希法涉及将键乘以一个常数,取结果的某些部分作为索引。这种方法不像取模法那样直接依赖哈希表的大小,因此更加灵活。

C++
1
2
3
4
5
6
int hashFunction(int key) {
    double A = 0.6180339887; // 黄金分割比的分数部分
    double temp = key * A;
    temp -= (int)temp; // 取小数部分
    return (int)(tableSize * temp);
}

这种方法利用了黄金分割比的性质,试图让键在表中更均匀地分布。

3. 平方取中法

平方取中法是将键平方后,取中间几位数作为哈希值。这种方法适用于键的高位和低位都不均匀分布的情况。

C++
1
2
3
4
5
6
7
int hashFunction(int key) {
    long long squared = (long long)key * key;
    int length = digits(squared); // 计算squared的位数
    int mid = length / 2;
    int ret = extractDigits(squared, mid - 1, mid + 1); // 提取中间三位
    return ret % tableSize;
}

4. 折叠法

折叠法是将键分成多个部分,然后将这些部分相加,最后可能取模得到哈希值。这种方法适用于较大的数字。

C++
1
2
3
4
5
6
7
8
9
int hashFunction(string key) {
    int partSize = 4; // 假设每部分4位
    int sum = 0;
    for (int i = 0; i < key.length(); i += partSize) {
        string part = key.substr(i, partSize);
        sum += stoi(part);
    }
    return sum % tableSize;
}

注意事项

​ 构造哈希函数时,需要尽量确保它能将数据均匀地分布在整个哈希表中,从而最小化冲突。此外,哈希函数的效率也很重要,因为它会直接影响到哈希表操作的速度。实际应用中可能需要根据具体情况对上述方法进行调整或组合。

3.5.2 排列哈希函数构造

​ 在设计哈希函数处理排列(如字符串或数组等序列数据)时,目标是将排列映射到一个较大的数字空间,然后可能通过取模等操作映射到哈希表的索引范围内。排列哈希函数需要尽量保证相同的排列映射到相同的值,不同的排列尽可能映射到不同的值,以此来减少冲突。以下是几种常见的排列哈希函数构造方法:

1. 多项式哈希法(对字符串有效)

多项式哈希法是处理字符串或数组排列的一种常见方法,它将每个字符或元素看作一个系数,计算多项式的值作为哈希值。

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
unsigned long long polynomialHash(string const& s, int mod = 1e9+9) {
    unsigned long long hash_value = 0;
    unsigned long long p = 31; // 一个较小的质数
    unsigned long long p_power = 1;

    for (char c : s) {
        hash_value = (hash_value + (c - 'a' + 1) * p_power) % mod;
        p_power = (p_power * p) % mod;
    }
    return hash_value;
}

2. Rabin-Karp 字符串哈希

Rabin-Karp算法使用了滑动窗口和多项式哈希的思想来匹配字符串。它特别适用于在一个较大的文本中搜索一个子串的场景。

3. DJB2哈希

DJB2是处理字符串的另一种简单而有效的哈希函数,由Daniel J. Bernstein提出。

C++
1
2
3
4
5
6
7
unsigned long djb2Hash(string const& str) {
    unsigned long hash = 5381; // 初始哈希值
    for (unsigned char c : str) {
        hash = ((hash << 5) + hash) + c; // hash * 33 + c
    }
    return hash;
}

4. BKDR哈希

BKDR哈希是一种简单快速的字符串哈希函数,广泛用于各种哈希表实现。

C++
1
2
3
4
5
6
7
unsigned int BKDRHash(string const& str, unsigned int seed = 131) {
    unsigned int hash = 0;
    for(unsigned char c : str) {
        hash = hash * seed + c;
    }
    return hash;
}

注意事项

  • 当使用哈希表大小为模数时,选择一个大的质数作为模数可以帮助减少冲突。
  • 对于多项式哈希,基数(上例中的p)的选择很关键,不同的基数可能会导致不同的冲突率。通常选择一个与表大小接近的质数。
  • 在处理大量数据时,注意哈希函数的运算可能会导致溢出。在C++中,可以通过使用unsigned long long类型来减少这种风险,或者使用取模操作保证值的范围。

在实际应用中,根据数据的特性和需求选择或设计合适的哈希函数是非常重要的,合理的哈希函数能够有效提升哈希表的性能和准确性。

​ 排列哈希函数是一种常用的哈希函数,可以用于对字符串进行哈希。下面介绍一种简单的排列哈希函数构造方法:

​ 假设要将字符串\(s\)哈希为长度为\(k\)的整数,将\(s\)中的每个字符\(c_i\)映射为一个互不相同的\(k\)进制数\(a_i\)(可以用随机数生成,也可以用素数),然后将这\(k\)个数组合成一个\(k\)进制的数\(h\),作为\(s\)的哈希值。具体地,\(h\)的计算方法为:

\[ h = \sum_{i=0}^{n-1} a_i \cdot k^{n-1-i} \]

其中\(n\)是字符串\(s\)的长度。

​ 这个方法的原理是将字符串\(s\)看成一个\(k\)进制的数字,不同的字符串可以得到不同的数字,而同样的字符串一定得到相同的数字。由于这个方法涉及到大数运算,实现起来比较复杂,通常可以使用高精度数库来实现。

以下是一个基于字符串的多项式哈希的 C++ 代码示例:

C++
 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
const int MOD = 1e9 + 7;
const int BASE = 131; // 取质数 131 作为 BASE

int pows[MAX_N]; // 记录 BASE 的幂次
int hsh[MAX_N]; // 记录字符串的哈希值

void init_pows() {
    pows[0] = 1;
    for (int i = 1; i < MAX_N; i++) {
        pows[i] = (long long)pows[i-1] * BASE % MOD;
    }
}

void init_hash(string& s) {
    int len = s.length();
    hsh[0] = s[0] % MOD;
    for (int i = 1; i < len; i++) {
        hsh[i] = ((long long)hsh[i-1] * BASE % MOD + s[i]) % MOD;
    }
}

int get_hash(int l, int r) { // 获取 [l, r] 子串的哈希值
    if (l == 0) return hsh[r];
    return ((hsh[r] - (long long)hsh[l-1] * pows[r-l+1] % MOD) % MOD + MOD) % MOD;
}

​ 以上代码实现了字符串的多项式哈希,并且提供了获取子串哈希值的接口。其中 BASE 取质数可以避免哈希冲突,而 MOD 取大质数可以避免哈希值过大。pows 数组记录了 BASE 的幂次,初始化时计算出来,可以加快后续的计算。hsh 数组记录了字符串的前缀哈希值,初始化时递推计算出来,也可以加快后续的计算。get_hash() 函数根据前缀哈希值计算出子串哈希值,具体实现是根据公式 \(hash[l, r] = hash[r] - hash[l-1] \times base^{r-l+1}\) 计算的,需要注意一些细节。

3.5.3 字符串哈希函数构造

​ 字符串哈希函数是一种常用的哈希函数,用于将字符串映射成一个固定长度的整数,方便进行字符串的比较和查找等操作。常见的字符串哈希函数有基于素数的哈希和基于多项式的哈希。

基于素数的字符串哈希函数实现方法:

假设字符串 \(S\) 的长度为 \(n\),选定一个素数 \(P\),那么 \(S\) 的哈希值可以使用如下公式计算:

\(hash(S)=\sum_{i=0}^{n-1}S[i]\cdot P^{n-i-1}\)

​ 其中,\(S[i]\) 表示字符串 \(S\) 的第 \(i\) 个字符,\(P^{n-i-1}\) 表示 \(P\)\(n-i-1\) 次方。这个公式的本质是将字符串视为 \(P\) 进制数,将其转换为十进制数作为哈希值。

基于多项式的字符串哈希函数实现方法:

首先选定一个模数 \(M\),并假设 \(P\) 为一个质数,那么 \(S\) 的哈希值可以使用如下公式计算:

\(hash(S)=\sum_{i=0}^{n-1}S[i]\cdot P^i\mod M\)

​ 其中,\(S[i]\) 表示字符串 \(S\) 的第 \(i\) 个字符。这个公式的本质是将字符串视为一个 \(P\) 进制多项式,对 \(x=P\) 求值得到的多项式值就是哈希值。

下面是基于素数和基于多项式的字符串哈希函数的 C++ 代码实现示例:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 基于素数的字符串哈希函数
int prime_hash(string s, int p) {
    int hash_value = 0;
    int n = s.size();
    for (int i = 0; i < n; i++) {
        hash_value += s[i] * pow(p, n - i - 1);
    }
    return hash_value;
}

// 基于多项式的字符串哈希函数
int poly_hash(string s, int p, int m) {
    int hash_value = 0;
    int n = s.size();
    for (int i = 0; i < n; i++) {
        hash_value = (hash_value * p + s[i]) % m;
    }
    return hash_value;
}

其中,\(p\)\(m\) 分别为素数和模数,可以根据实际情况进行选择。

3.5.4 哈希函数冲突的常见解决方法

​ 哈希函数在将大量输入映射到有限数量的桶(或槽位)时,不可避免地会发生冲突,即两个或多个不同的输入映射到同一个输出值。解决哈希冲突的主要方法有两大类:开放寻址法(Open Addressing)和链地址法(Chaining)。

1. 链地址法(Chaining)

链地址法是处理哈希冲突的最简单也是最常见的方法之一。它的基本思想是每个哈希表的槽位都对应一个链表,所有映射到该槽位的元素都会被添加到相应的链表中。

  • 优点
  • 实现简单。
  • 哈希表的负载因子(即平均每个槽位的元素数量)可以超过1,不必频繁扩容或缩容。
  • 缺点
  • 如果负载因子过大,链表变长,会降低查找效率。
  • 需要额外的内存来存储链表指针。

2. 开放寻址法(Open Addressing)

当发生冲突时,开放寻址法会寻找表中的下一个空闲槽位,并将新元素插入其中。这种方法不使用链表,所有元素直接存储在哈希表的数组中。开放寻址法的几种实现方式包括线性探测(Linear Probing)、二次探测(Quadratic Probing)和双重散列(Double Hashing)。

  • 线性探测:遇到冲突时,顺序查找下一个空槽位。
  • 二次探测:遇到冲突时,使用二次方的偏移量进行查找。
  • 双重散列:使用第二个哈希函数确定查找间隔。

  • 优点

  • 所有元素直接存储在数组中,可以有效利用缓存,提高访问效率。
  • 不需要额外的存储空间。
  • 缺点
  • 删除元素较为复杂,通常需要特殊标记已删除元素。
  • 当哈希表接近满载时,查找和插入的性能会明显下降,因此需要及时扩容。

3. 再哈希法(Rehashing)

再哈希法是另一种处理哈希冲突的方法,它使用多个哈希函数。当使用第一个哈希函数发生冲突时,尝试第二个,第三个,依此类推。这可以视为开放寻址法的一种特例。

选择适当的冲突解决方法

选择合适的冲突解决方法时,需要考虑:

  • 数据量大小:大数据量情况下,链地址法可能更为合适。
  • 负载因子:高负载因子下,链地址法处理冲突的能力更强。
  • 操作频率:如果删除操作频繁,开放寻址法可能需要额外考虑。
  • 内存限制:如果内存有限,开放寻址法可以减少内存使用。

不同的冲突解决方法有各自的优缺点,合理选择和设计哈希函数及冲突解决策略对于提升哈希表的性能至关重要。

2.7.5 哈希表的时间复杂度分析

对于哈希表的操作,包括插入、查找、删除等,其时间复杂度都与哈希函数的效果有关。

假设哈希表的容量为 \(m\),哈希函数的时间复杂度为 \(O(1)\),则哈希表的操作时间复杂度为:

  • 插入操作:在哈希表中查找元素并插入的时间复杂度为 \(O(1)\),若哈希函数效果好,哈希冲突的概率较小,则插入时间复杂度可以看做是 \(O(1)\)
  • 查找操作:在哈希表中查找元素的时间复杂度为 \(O(1)\),若哈希函数效果好,则查找时间复杂度可以看做是 \(O(1)\)
  • 删除操作:在哈希表中查找并删除元素的时间复杂度为 \(O(1)\),若哈希函数效果好,则删除时间复杂度可以看做是 \(O(1)\)

​ 需要注意的是,若哈希函数效果不好,哈希冲突较多,可能会导致哈希表的操作时间复杂度退化为 \(O(n)\),其中 \(n\) 为哈希表中元素个数。

​ 因此,在实际使用哈希表时,需要选择合适的哈希函数,并注意维护哈希表的负载因子,以保证哈希表操作的效率。