跳转至

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

2.1 类

2.1.1 类的概念及简单应用

​ 类是面向对象编程中的核心概念之一,它描述了一个对象的属性和行为。类定义了一种新的数据类型,包含了一组数据和方法,用于描述一类对象的共同特征和行为。类可以看作是一种模板,通过它可以创建出多个具有相同属性和行为的对象。

一个类通常由三部分组成:

  1. 属性(成员变量):用于描述对象的状态,可以是各种类型的数据,例如整型、浮点型、字符型、字符串等。
  2. 方法(成员函数):用于描述对象的行为,可以是各种类型的函数,例如构造函数、析构函数、成员函数等。
  3. 访问修饰符:用于控制属性和方法的访问权限,分为public、protected和private三种。

​ 类的应用范围非常广泛,例如在编写GUI程序时,可以创建一个窗口类,包含窗口的位置、大小、标题等属性,以及打开、关闭、重绘等行为。在编写游戏程序时,可以创建一个角色类,包含角色的名称、血量、攻击力等属性,以及攻击、防御等行为。

下面是一个简单的类的定义和应用示例,该类用于描述一个人的姓名和年龄:

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

class Person {
public:
    string name;   // 姓名
    int age;       // 年龄

    void sayHello() {   // 打招呼
        cout << "Hello, my name is " << name << ", I'm " << age << " years old." << endl;
    }
};

int main() {
    Person p1;    // 创建一个Person对象
    p1.name = "Tom";
    p1.age = 18;
    p1.sayHello();   // 调用对象的成员函数

    return 0;
}

​ 在上面的示例中,定义了一个名为Person的类,包含了两个属性(姓名和年龄)和一个方法(打招呼)。在main函数中,创建了一个Person对象p1,并给它的属性赋值,最后调用对象的sayHello方法打招呼。

​ 需要注意的是,类只是一个模板,只有创建对象时,内存中才会真正分配空间,因此在使用对象之前,必须先创建对象。创建对象的方式有很多种,例如直接声明对象、使用new关键字动态创建对象等。

​ 总之,类是面向对象编程的核心概念之一,可以用于描述现实世界中的各种对象,具有很强的灵活性和可扩展性,是现代软件开发中不可或缺的一部分。

2.1.2 成员函数和运算符重载

​ 成员函数是指在类定义内部声明的函数,它可以访问类的私有成员变量和方法,通常用于对类的实例进行操作或返回类的实例的某些信息。

​ 运算符重载是指为一个运算符赋予新的含义,例如重载"+"运算符用于两个对象相加时执行自定义的操作。运算符重载可以像成员函数一样在类定义内部声明,也可以在类外部定义。运算符重载通过函数名和参数列表的形式进行识别和调用。

下面是一个简单的示例,演示了如何在类中定义成员函数和重载运算符:

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
#include <iostream>
using namespace std;

class Complex {
private:
    double real;   // 实部
    double imag;   // 虚部

public:
    Complex(double r, double i) : real(r), imag(i) {}   // 构造函数

    Complex operator+(const Complex& other) const {    // 重载"+"运算符
        return Complex(real + other.real, imag + other.imag);
    }

    void display() const {   // 成员函数
        cout << real << "+" << imag << "i" << endl;
    }
};

int main() {
    Complex c1(1, 2), c2(3, 4);
    Complex c3 = c1 + c2;    // 调用运算符重载函数
    c3.display();            // 调用成员函数

    return 0;
}

​ 在上面的示例中,定义了一个名为Complex的类,包含了两个私有成员变量(实部和虚部)和一个公有构造函数、一个公有成员函数、一个重载"+"运算符的函数。在main函数中,创建了两个Complex对象c1和c2,并用重载的"+"运算符对它们进行相加,将结果赋值给c3,最后调用c3的成员函数display打印结果。

​ 需要注意的是,运算符重载和普通的函数重载有很大的区别。运算符重载使用运算符的符号来作为函数名,不能改变运算符的优先级和结合性,只能改变它的操作数。在重载运算符时,要遵循一定的规则,例如只能改变已经存在的运算符,不能创建新的运算符,运算符的参数列表中至少有一个是类类型等。

​ 总之,成员函数和运算符重载是C++面向对象编程中重要的概念,它们可以让类的操作更加方便和灵活,提高程序的可读性和可维护性。需要在实际开发中灵活运用。

2.2 STL模板

2.2.1 集合(set)

​ set是STL中的一个容器,它提供了一种无序的关联数组的实现,其中的元素按照一定的规则自动排序。set中的每个元素必须是唯一的,而且不能修改,但可以插入和删除。set常用的操作包括插入元素、查找元素、遍历元素、删除元素等。

下面是一个简单的set示例:

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
#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> mySet;

    // 插入元素
    mySet.insert(3);
    mySet.insert(2);
    mySet.insert(1);

    // 查找元素
    if (mySet.find(2) != mySet.end()) {
        cout << "Found 2!" << endl;
    }

    // 遍历元素
    for (auto it = mySet.begin(); it != mySet.end(); it++) {
        cout << *it << " ";
    }
    cout << endl;

    // 删除元素
    mySet.erase(2);

    // 遍历元素
    for (auto it = mySet.begin(); it != mySet.end(); it++) {
        cout << *it << " ";
    }
    cout << endl;

    return 0;
}

​ 在上面的示例中,首先声明了一个名为mySet的set对象,然后插入了三个元素3、2和1。通过find函数查找是否包含元素2,并使用迭代器遍历所有元素,最后使用erase函数删除元素2,再次遍历输出所有元素。

​ 需要注意的是,set中的元素会自动排序,通常使用的是红黑树(Red-Black Tree)数据结构来实现。另外,set中的元素不能修改,因为修改会破坏自动排序规则,如果需要修改元素,可以将其删除后再重新插入新值。

​ 总之,set是STL中常用的容器之一,提供了高效的元素查找、插入和删除操作,可以方便地存储和管理元素。需要在实际开发中灵活运用。

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

1.列表(list)

​ 列表是一个双向链表容器,它的每个元素包含一个指向前一个元素和后一个元素的指针,这样可以快速的在任何位置插入和删除元素,但访问元素则需要遍历整个链表。列表中的元素可以插入和删除,但不能随机访问。

​ 列表常用的操作包括插入元素、删除元素、访问元素等,常用的函数有push_front、push_back、pop_front、pop_back、begin、end、size等。

下面是一个简单的列表示例:

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
#include <iostream>
#include <list>

using namespace std;

int main() {
    list<int> myList;

    // 插入元素
    myList.push_back(1);
    myList.push_back(2);
    myList.push_back(3);

    // 删除元素
    myList.pop_front();

    // 遍历元素
    for (auto it = myList.begin(); it != myList.end(); it++) {
        cout << *it << " ";
    }
    cout << endl;

    return 0;
}

​ 在上面的示例中,首先声明了一个名为myList的列表对象,然后插入了三个元素1、2和3。使用pop_front函数删除元素1,使用迭代器遍历所有元素并输出。

2.双端队列(deque)

​ 双端队列是一个双向队列容器,它可以在队列的两端进行插入和删除操作,即可以在队首和队尾分别进行元素的添加和删除操作,支持随机访问,因此在需要在队列的两端进行添加和删除操作时可以考虑使用双端队列。但在中间插入和删除元素时比较慢。

​ 双端队列常用的操作包括插入元素、删除元素、访问元素等,常用的函数有push_front、push_back、pop_front、pop_back、begin、end、size等。

下面是一个简单的双端队列示例:

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
#include <iostream>
#include <deque>

using namespace std;

int main() {
    deque<int> myDeque;

    // 插入元素
    myDeque.push_back(1);
    myDeque.push_back(2);
    myDeque.push_back(3);
    myDeque.push_front(0);

    // 删除元素
    myDeque.pop_front();

    // 遍历元素
    for (auto it = myDeque.begin(); it != myDeque.end(); it++) {
        cout << *it << " ";
    }
    cout << endl;

    return 0;
}

3.优先队列(priority_queue)

​ 优先队列是一种特殊的队列容器,可以对元素进行优先级排序,优先级最高的元素始终在队列的最前面,每次弹出的元素都是优先级最高的元素。在STL中,优先队列的实现是使用堆这种数据结构。

​ 优先队列中的元素有两个特征:一是它们具有优先级,二是在加入队列时保持了这个优先级。默认情况下,STL中的优先队列使用小顶堆,即元素的顺序是按照从小到大的顺序排列的。如果要使用大顶堆,可以通过重载运算符实现。

​ 优先队列常用的操作包括插入元素、删除元素、访问元素等,常用的函数有push、pop、top、empty等。

下面是一个简单的优先队列示例:

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
#include <iostream>
#include <queue>

using namespace std;

int main() {
    priority_queue<int> myQueue;

    // 插入元素
    myQueue.push(3);
    myQueue.push(5);
    myQueue.push(1);

    // 输出队首元素
    cout << "队首元素:" << myQueue.top() << endl;

    // 弹出队首元素
    myQueue.pop();

    // 遍历元素
    while (!myQueue.empty()) {
        cout << myQueue.top() << " ";
        myQueue.pop();
    }
    cout << endl;

    return 0;
}

​ 在上面的示例中,首先声明了一个名为myQueue的优先队列对象,然后插入了三个元素3、5和1。使用top函数输出队首元素5,使用pop函数弹出队首元素5。最后使用while循环遍历所有元素并输出,此时输出的元素是优先级从高到低的。

2.2.3 多重集合(multiset)

​ 多重集合(multiset)是STL中的一种关联容器,可以存储多个相同的元素,也就是说它允许有重复的元素存在。与集合(set)不同的是,集合中的元素是唯一的,而多重集合中的元素可以重复。

​ multiset可以视为一种自动排序的容器,元素插入时会根据默认的比较函数(operator<)或自定义的比较函数进行排序,插入时会自动调整元素位置,使其保持有序状态。可以使用begin、end等函数遍历元素,并且也支持插入、删除、查找等常见操作。

下面是一个简单的multiset示例:

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
#include <iostream>
#include <set>

using namespace std;

int main() {
    multiset<int> myMultiset;

    // 插入元素
    myMultiset.insert(1);
    myMultiset.insert(3);
    myMultiset.insert(2);
    myMultiset.insert(2);

    // 输出元素
    for (auto it = myMultiset.begin(); it != myMultiset.end(); ++it) {
        cout << *it << " ";
    }
    cout << endl;

    // 查找元素
    auto it = myMultiset.find(2);
    if (it != myMultiset.end()) {
        cout << "找到了元素:" << *it << endl;
    } else {
        cout << "未找到元素" << endl;
    }

    // 删除元素
    myMultiset.erase(2);

    // 输出元素
    for (auto it = myMultiset.begin(); it != myMultiset.end(); ++it) {
        cout << *it << " ";
    }
    return 0;
}

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

​ 映射(map)和多重映射(multimap)都是STL中的关联容器,它们可以用于存储键值对(key-value pair)。

​ map中的每个元素都是由一个键(key)和一个值(value)组成的,其中键是唯一的,值可以重复。插入时会根据键的默认比较函数(operator<)或自定义的比较函数进行排序,插入时会自动调整元素位置,使其保持有序状态。map也支持插入、删除、查找等常见操作,并且可以使用迭代器遍历元素。map中的元素可以根据键访问和修改值,如果访问一个不存在的键,会自动插入一个键值对并将值初始化为默认值。

下面是一个简单的map示例:

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
#include <iostream>
#include <map>

using namespace std;

int main() {
    map<string, int> myMap;

    // 插入键值对
    myMap.insert(pair<string, int>("Tom", 24));
    myMap.insert(make_pair("Lucy", 22));
    myMap["Lily"] = 18;

    // 输出元素
    for (auto it = myMap.begin(); it != myMap.end(); ++it) {
        cout << it->first << ": " << it->second << endl;
    }

    // 查找元素
    auto it = myMap.find("Lucy");
    if (it != myMap.end()) {
        cout << "Lucy的年龄是:" << it->second << endl;
    } else {
        cout << "未找到Lucy" << endl;
    }

    // 删除元素
    myMap.erase("Tom");

    // 输出元素
    for (auto it = myMap.begin(); it != myMap.end(); ++it) {
        cout << it->first << ": " << it->second << endl;
    }

    return 0;
}

​ 在上面的示例中,首先声明了一个名为myMap的map对象,然后插入了三个键值对,其中使用了多种插入方式。使用迭代器遍历所有元素并输出。使用find函数查找键为Lucy的元素并输出结果。使用erase函数删除键为Tom的元素,最后再次遍历所有元素并输出。注意,此时输出的元素中已经没有了键为Tom的元素。

​ 与map相似,multimap也是一个关联容器,可以存储多个键值对,且可以有重复的键。multimap支持与map相同的操作,不同之处在于multimap中的键是可以重复的。multimap中的元素同样是根据键进行排序的,但相同的键之间没有顺序关系。需要使用迭代器遍历所有元素,也可以根据键访问和修改值。

下面是一个简单的multimap示例:

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
#include <iostream>
#include <map>

using namespace std;

int main() {
    multimap<string, int> myMultimap;

    // 插入键值对
    myMultimap.insert(pair<string, int>("Tom", 24));
    myMultimap.insert(make_pair("Lucy", 22));
    myMultimap.insert(make_pair("Lucy", 25));
    myMultimap.insert(make_pair("Lily", 18));

    // 输出元素
    for (auto it = myMultimap.begin(); it != myMultimap.end(); ++it) {
        cout << it->first << ": " << it->second << endl;
    }

    // 查找元素
    auto it = myMultimap.find("Lucy");
    if (it != myMultimap.end()) {
        cout << "Lucy的年龄是:" << it->second << endl;
    } else {
        cout << "未找到Lucy" << endl;
    }

    // 删除元素
    myMultimap.erase("Lucy");

    // 输出元素
    for (auto it = myMultimap.begin(); it != myMultimap.end(); ++it) {
        cout << it->first << ": " << it->second << endl;
    }

    return 0;
}

​ 在上面的示例中,我们插入了四个键值对,其中Lucy对应了两个不同的值。使用迭代器遍历所有元素并输出。使用find函数查找键为Lucy的元素并输出结果。使用erase函数删除所有键为Lucy的元素,最后再次遍历所有元素并输出。注意,此时输出的元素中已经没有了键为Lucy的元素。

2.2.5 对(pair),元组(tuple)

pairtuple都是STL库中用来组合多个值的工具。

pair表示的是一个键值对,有两个元素,分别称为first和second。例如可以用来表示一个坐标点的x和y坐标,也可以用来表示一个单词和其出现的次数。

tuple表示的是一个有序的n元组,可以用来表示多个元素。它可以在编译时指定元素的数量,因此更加灵活。例如可以用来表示一个日期的年、月、日等元素。

下面分别举例说明:

使用pair示例代码:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <iostream>
#include <utility>

using namespace std;

int main() {
    pair<string, int> myPair;

    myPair.first = "Tom";
    myPair.second = 24;

    cout << myPair.first << ": " << myPair.second << endl;

    return 0;
}

​ 在上面的示例中,我们定义了一个pair类型的变量myPair,并将键值分别赋值为"Tom"和24。然后输出myPairfirstsecond元素,分别是"Tom"和24。

使用tuple示例代码:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <iostream>
#include <tuple>

using namespace std;

int main() {
    tuple<string, int, double> myTuple("Tom", 24, 1.75);

    cout << get<0>(myTuple) << endl;   // 输出第一个元素
    cout << get<1>(myTuple) << endl;   // 输出第二个元素
    cout << get<2>(myTuple) << endl;   // 输出第三个元素

    return 0;
}

​ 在上面的示例中,我们定义了一个tuple类型的变量myTuple,其中包含了三个元素:字符串"Tom"、整数24和浮点数1.75。然后分别使用get函数取出其中的第一个、第二个和第三个元素,并分别输出。需要注意的是,元素的下标从0开始。因此get<0>(myTuple)表示取出第一个元素。

2.3 线性结构

2.3.1 双端栈

​ 双端栈(deque)是一种可以在两端进行插入和删除操作的数据结构,类似于双向队列。与双向队列不同的是,双端栈可以在两端同时进行入栈和出栈操作,因此又被称为双端队列栈。

在C++ STL中,双端栈是由deque容器实现的,它支持以下操作:

  1. push_back(x):将元素x插入到双端栈的尾部。
  2. push_front(x):将元素x插入到双端栈的头部。
  3. pop_back():删除双端栈的尾部元素。
  4. pop_front():删除双端栈的头部元素。
  5. back():返回双端栈的尾部元素。
  6. front():返回双端栈的头部元素。
  7. empty():判断双端栈是否为空。
  8. size():返回双端栈中元素的个数。

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

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。

2.3.2 双端队列

​ 双端队列(deque,即double-ended queue),是一种可以在两端进行插入和删除操作的数据结构,支持队列和栈的所有操作。它类似于双向链表,但在实现上通常使用数组或向量实现,因此可以更高效地支持随机访问操作。

在C++ STL中,双端队列是由deque容器实现的,它支持以下操作:

  1. push_back(x):将元素x插入到双端队列的尾部。
  2. push_front(x):将元素x插入到双端队列的头部。
  3. pop_back():删除双端队列的尾部元素。
  4. pop_front():删除双端队列的头部元素。
  5. back():返回双端队列的尾部元素。
  6. front():返回双端队列的头部元素。
  7. empty():判断双端队列是否为空。
  8. size():返回双端队列中元素的个数。

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

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。

2.3.3 有序队列

​ 有序队列是一种元素按照一定规则排列的队列,可以用于实现一些排序相关的算法。在C++ STL中,有序队列是由set和map容器实现的。

set容器实现的有序队列可以支持以下操作:

  1. insert(x):将元素x插入到有序队列中,保持有序性。
  2. erase(x):删除有序队列中的元素x。
  3. find(x):查找有序队列中是否存在元素x,如果存在返回指向该元素的迭代器,否则返回end()迭代器。
  4. lower_bound(x):查找有序队列中第一个大于等于x的元素,返回指向该元素的迭代器。
  5. upper_bound(x):查找有序队列中第一个大于x的元素,返回指向该元素的迭代器。
  6. empty():判断有序队列是否为空。
  7. size():返回有序队列中元素的个数。

下面是一个使用set容器实现的有序队列的示例代码:

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
#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> mySet;

    // insert elements to set
    mySet.insert(30);
    mySet.insert(20);
    mySet.insert(10);

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

    // find element in set
    auto it = mySet.find(20);
    if (it != mySet.end()) {
        cout << "element found in set: " << *it << endl;
    } else {
        cout << "element not found in set" << endl;
    }

    // erase element from set
    mySet.erase(20);

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

    return 0;
}

​ 在上面的示例中,我们使用了set容器来实现一个有序队列。首先将元素30、20、10依次插入到有序队列中,输出有序队列的内容,可以看到元素的顺序是10 20 30。接着查找元素20,输出找到的元素的值,然后从有序队列中删除元素20,再次输出有序队列的内容,可以看到元素的顺序变为10 30。

​ 类似于set,map容器也可以实现一种有序队列,其中元素是键值对(key-value pair)。有序map支持的操作和有序set类似,包括insert()、erase()、find()、empty()和size()等。此外,map还支持按照键值对的键进行排序和查找,可以使用lower_bound()和upper_bound()函数实现。

2.3.4 优先队列

​ 优先队列是一种特殊的队列,其中每个元素都有一个优先级,高优先级的元素先出队列。可以使用堆数据结构来实现优先队列,通常用于处理需要按优先级顺序处理的问题,如任务调度、路由算法等。

C++ STL中的优先队列使用priority_queue类来实现,它提供了以下操作:

  1. push():将元素插入优先队列。
  2. pop():将队首元素从队列中移除。
  3. top():返回队首元素。
  4. size():返回队列中元素的个数。
  5. empty():返回队列是否为空。

​ priority_queue默认使用less比较函数来比较元素的优先级,其中T为存储元素的类型。如果需要使用自定义的比较函数,可以通过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

2.3.5 倍增表(ST表)

​ 倍增表,又称ST表,是一种预处理数组,用于快速计算区间最值。它的主要思想是预处理出所有长度为\(2^k\)的子区间的最值,然后利用这些预处理信息,可以在\(O(1)\)的时间内查询任意区间的最值。ST表可以用于解决多种问题,如静态区间最值、区间最长连续上升子序列等。

下面是ST表的构造过程:

设原数组为a[1..n],ST表的预处理数组为st[1 .. n][ log2(n)],其中st[i][j]表示以i为起点,长度为\(2^j\)的区间内的最值。

  1. 初始化st数组:\(st[i][0] = a[i]\)
  2. 递推计算st数组:\(st[i][j] = min(st[i][j-1], st[i+2^{j-1}][j-1])\)。其中,\(2^{j-1}\)表示当前区间长度的一半。

构造好ST表后,可以使用以下公式查询任意区间的最值:

\(rmq(l, r) = min(st[l][k], st[r-2^k+1][k])\),其中\(k=log2(r-l+1)\)

以下是一个使用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)\)

2.4 集合与森林

2.4.1 等价类

​ 等价类是一个数学概念,通常用于刻画元素间的等价关系。在集合论中,对于一个给定的集合,如果存在一种二元关系R,它满足自反性、对称性和传递性,则称R是该集合上的一个等价关系。对于一个元素a,它所在的等价类,就是所有与a等价的元素所构成的集合,称为a的等价类。

​ 对于一个集合S和一个等价关系R,我们称集合S中的两个元素a和b等价,如果它们在关系R下是等价的,即a R b。等价关系具有以下性质:

  1. 自反性:对于集合S中的任意元素a,a R a。

  2. 对称性:对于集合S中的任意元素a和b,如果a R b,则b R a。

  3. 传递性:对于集合S中的任意元素a、b和c,如果a R b,b R c,则a R c。

根据等价关系的定义,我们可以将集合S划分成若干个等价类,每个等价类由所有在关系R下等价的元素组成。如果a R b,则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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#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中输出。

2.4.2 并查集

并查集是一种常见的数据结构,用于维护一些不相交的集合,并支持以下操作:

  1. makeSet(x):新建一个集合,仅包含元素x。
  2. union(x, y):合并元素x和y所在的集合。
  3. find(x):查找元素x所在的集合的代表元素(也称为根节点)。

并查集通常使用数组或者树来实现。下面以数组实现为例。

实现过程如下:

  1. 初始化:将每个元素单独作为一个集合,即将每个元素的父节点设为它本身。
  2. 查找:对于一个元素x,找到其所在集合的代表元素,可以一路往上找到根节点,即 fa[x]=x 的元素。
  3. 合并:将两个集合合并为一个集合,可以将其中一个集合的代表元素的父节点指向另一个集合的代表元素。

常见的应用场景包括判断图的连通性、求最小生成树等。

以下是并查集的 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>
#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 行每行两个数表示进行一次合并操作。输出各个元素所在集合的代表元素的个数。

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

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

​ 孩子兄弟表示法(Child-Sibling Representation,也称为左孩子右兄弟表示法)是一种树的存储结构,它可以将一棵树转化为二叉树,便于对树进行遍历和其他操作。

​ 在孩子兄弟表示法中,每个节点有两个指针:一个指向它的第一个孩子节点,另一个指向它的下一个兄弟节点。如果节点没有孩子或兄弟,则对应的指针为NULL。

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

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

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

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

2.5 特殊树

​ 特殊树是一类特殊的树,其中节点之间的关系满足一定的特殊性质,例如二叉树、满二叉树、完全二叉树、平衡二叉树、红黑树等。这些特殊树都有其独特的性质和应用场景。

1.二叉树

二叉树是每个节点最多有两个子树的树结构,分为左子树和右子树。二叉树的应用广泛,例如表达式求值、排序、哈夫曼编码等。

2.满二叉树

满二叉树是一种特殊的二叉树,每个节点都有0个或2个子节点。所有叶子节点都在同一层上,并且具有最大可能的节点数。满二叉树通常用于数据结构和算法的实现中。

3.完全二叉树

完全二叉树是一种特殊的二叉树,除了最后一层,其它层的节点数都是满的,最后一层从左到右填充节点,可能存在空节点。完全二叉树通常用于堆排序、优先队列等数据结构中。

4.平衡二叉树

平衡二叉树是一种特殊的二叉树,每个节点的左右子树的高度差不超过1。平衡二叉树通常用于实现高效的查找、插入、删除等操作,例如AVL树、红黑树等。

5.红黑树

红黑树是一种特殊的自平衡二叉查找树,具有良好的平衡性和高效的操作复杂度。红黑树的节点分为红色和黑色,遵循一定的规则进行平衡。红黑树广泛用于STL中的map和set等容器的实现中。

这些特殊树都是数据结构和算法中重要的基础,掌握它们的基本概念和实现方法对于提高编程能力和解决实际问题非常有帮助。

2.5.1 线段树与树状数组

​ 线段树和树状数组都是一些常用的数据结构,用于解决各种区间统计和更新问题。虽然这两种数据结构都可以解决同样的问题,但它们的实现方式和使用场景却有很大的不同。

​ 线段树是一种二叉树结构,用于解决区间统计和区间更新问题。它的节点代表着区间,每个节点存储了区间内的一些信息(如最大值、最小值、和等等),而其左右子节点则代表着区间的左半部分和右半部分。线段树可以支持区间查询、区间修改等操作,并且在多个数据结构中都有应用,如RMQ、区间求和、区间最大值/最小值等等。

​ 树状数组(Binary Indexed Tree,BIT)是一种基于数组的数据结构,用于解决单点修改和区间查询问题。它主要用于统计数组中某个区间内元素的和,它的原理是利用数组的下标来表示数字之间的关系,每个元素记录前缀和,通过二进制来实现快速的区间查询和单点修改。它的空间复杂度比线段树要小,并且可以进行原地修改,因此在内存和时间方面都有优势。

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

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 数组中的值也修改。如果是查询操作,就计算前缀和并输出。

2.5.2 字典树(trie树)

​ 字典树,也称为 trie 树,是一种用于处理字符串的树形数据结构。它的主要特点是:每个节点都包含了多个子节点,每个子节点对应着不同的字符;从根节点到叶子节点的一条路径,表示了一个字符串;整棵树上的所有路径表示了一个字符串集合。字典树通常被用于实现字符串的查找和插入操作,它能够在 O(L) 的时间内查找一个长度为 L 的字符串。

以下是字典树的 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>
using namespace std;

const int MAXN = 100001;
int n, trie[MAXN][26], cnt[MAXN], tot = 1;

void insert(string s) {
    int p = 1;
    for (int i = 0; i < s.length(); i++) {
        int x = s[i] - 'a';
        if (trie[p][x] == 0) {
            trie[p][x] = ++tot;
        }
        p = trie[p][x];
        cnt[p]++;
    }
}

int query(string s) {
    int p = 1;
    for (int i = 0; i < s.length(); i++) {
        int x = s[i] - 'a';
        if (trie[p][x] == 0) {
            return 0;
        }
        p = trie[p][x];
    }
    return cnt[p];
}

int main() {
    cin >> n;
    for (int i = 0; i < n; i++) {
        string op, s;
        cin >> op >> s;
        if (op == "insert") {
            insert(s);
        } else {
            cout << query(s) << endl;
        }
    }
    return 0;
}

​ 在插入一个字符串时,我们从根节点开始,逐一检查字符串中的每个字符,如果某个子节点不存在,就新建一个子节点,然后将当前节点移动到该子节点。最后,在叶子节点上增加一个计数器,表示这个字符串在字典树中出现的次数。

​ 在查询一个字符串时,我们同样从根节点开始,逐一检查字符串中的每个字符,如果某个子节点不存在,就说明这个字符串在字典树中不存在,直接返回 0。如果能够遍历到字符串的最后一个字符对应的子节点,就返回该节点上的计数器值。

总的来说,字典树是一种非常实用的数据结构,在许多字符串处理的应用中都有广泛的应用。

2.5.3 笛卡尔树

笛卡尔树(Cartesian Tree)是一种二叉树,满足以下两个性质:

  1. 笛卡尔树是一棵堆(Heap)树。
  2. 笛卡尔树是一棵排序树。

对于一个由n个元素组成的序列a1,a2,...,an,它的笛卡尔树定义如下:

  1. 对于每一个元素ai,创建一个节点ni。
  2. 对于每一个节点ni,找到满足以下条件的最大的节点nj (j<i),使得aj<ai。
  3. 将ni设为nj的右儿子,将nj的原右儿子设为ni的左儿子。
  4. 如果ni没有右儿子,则将ni设为根节点。

笛卡尔树的一个应用是解决一类二维几何问题,例如:给定平面上的n个点,找到其中距离最近的两个点。

笛卡尔树的建立时间复杂度为O(n),可以使用分治或堆优化建树。

以下是一个简单的笛卡尔树实现的 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 函数用于以先序遍历的方式输出笛卡尔树中的所有节点。

2.5.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);
        }
}

2.5.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的距离。

2.6 常见图

2.6.1 稀疏图

稀疏图是指边数相对于点数比较少的图,通常用邻接表来存储。

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

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条边,并进行了深度优先遍历和广度优先遍历。

2.6.2 偶图(二分图)

​ 图,又称二分图,是指一个无向图的所有顶点可以被分为两个互不相交的子集,且每条边的两个顶点分别属于这两个不同的子集。

​ 偶图的定义可以用数学语言表述为:假设 \(G=(V,E)\) 是一个无向图,如果存在集合 \(U\subseteq V\),使得对于每条边 \((u,v)\in E\),都有 \(u\in U\)\(v\notin U\),或者 \(u\notin U\)\(v\in U\),那么称图 \(G\) 是一个偶图,也称二分图。

​ 偶图在图论中有很重要的应用,例如用于解决染色问题、网络流问题等。

​ 判断一个无向图是否是偶图,可以使用染色法。从图中任选一个点开始,将其染为黑色,与其相邻的点染为白色,再将这些白色点相邻的黑色点染为白色,以此类推。如果在染色的过程中出现一个点既被染为黑色又被染为白色,则说明该图不是偶图。

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

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;
}

2.6.3 欧拉图

​ 欧拉图(Eulerian graph)是指一种可以沿着边遍历所有节点的无向图。也就是说,欧拉图中存在一条路径,该路径经过图中每条边恰好一次,并且回到起点。如果一张无向图是欧拉图,那么这条路径就称为欧拉回路(Eulerian circuit)。如果一张无向图不是欧拉图,但存在一条从一个节点出发、经过所有边恰好一次,且最终回到原来的节点的路径,那么这条路径就称为欧拉路径(Eulerian path)。

下面是一个判断图是否是欧拉图的简单算法:

  1. 如果图不连通,则不是欧拉图;
  2. 对于每个节点,判断其度数是否为偶数,如果存在度数为奇数的节点,则不是欧拉图;
  3. 对于连通图,如果所有节点的度数都是偶数,则它是欧拉图;否则,存在两个度数为奇数的节点,可以从其中一个节点开始遍历,遍历所有边恰好一次,最终回到另一个奇数度节点。

欧拉图具有许多实际应用,例如在电路设计和网络优化等领域中。

以下是一个用于判断一个无向图是否是欧拉图的 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 遍历所有连通块,若只有一个连通块则是欧拉图,否则不是欧拉图。

2.6.4 有向无环图

​ 有向无环图(DAG,Directed Acyclic Graph)是一种有向图,它不包含任何环。在实际应用中,DAG非常重要,它在任务调度、依赖关系管理、最短路径等问题中有着广泛的应用。

​ 判断一个有向图是否为DAG的方法是通过拓扑排序。拓扑排序是将有向无环图中的所有节点按照一定的顺序排序,使得对于任意一条有向边(u,v),节点u在排序后都出现在节点v的前面。如果一个图无法进行拓扑排序,那么该图一定存在环,不是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
54
#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。

2.6.5 连通图与强连通图

​ 连通图是指图中任意两个顶点之间都存在路径的无向图。也就是说,如果将一个连通图中的任意一个顶点作为起点,能够到达图中的所有顶点,则该图为连通图。

​ 强连通图是指有向图中任意两个顶点之间都存在有向路径的图。也就是说,如果将一个强连通图中的任意一个顶点作为起点,能够到达图中的所有顶点,并且以任意一个顶点为起点都可以到达其它所有顶点,则该图为强连通图。

​ 对于无向图和有向图,我们可以使用深度优先搜索或广度优先搜索来判断是否是连通图和强连通图。此外,对于有向图还可以使用强连通分量算法来判断是否是强连通图。

以下是使用深度优先搜索判断无向图是否为连通图的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;

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;
}

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

2.6.6 重连通图

​ 重连通图(strongly connected components)是有向图的一个重要概念,是指一个有向图中的极大强连通子图,即在该子图内的任意两个节点都是强连通的,且该子图不包含在其他的强连通子图内。

​ 重连通图在图论中有广泛的应用,例如最小路径覆盖、2-SAT问题等。一种常用的求解重连通图的算法是Kosaraju算法,该算法包括以下两个步骤:

  1. 对原图进行一次DFS,记录下DFS遍历顺序;
  2. 将原图反向,对反向图进行一次DFS,并按照第一步中得到的顺序进行访问。每次访问到的节点集合即为一个强连通分量。

以下是基于邻接表存储的有向图,使用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,处理每个强连通分量,并给每个点标记它所属的分量。最后输出分量数和每个点所属的分量。

2.7 哈希表

​ 哈希表(Hash Table)是一种用于实现字典的数据结构,能够在 \(O(1)\) 时间复杂度内实现插入、删除和查找操作。它的基本思想是通过把键映射到值的一个函数中,将键值存储在数组中的一个位置上。这个映射函数被称为哈希函数,存储键值的数组被称为哈希表。在哈希表中,查找一个元素所需要的时间复杂度为 \(O(1)\),即常数时间。

​ 哈希表的主要优点是快速查找、删除和插入,但它也有一些缺点。其中最大的缺点是无法保证元素的顺序,因为元素在哈希表中的位置是根据哈希函数的值而定的,而哈希函数的值可能和元素的实际顺序无关。此外,哈希函数可能存在冲突,即不同的元素被映射到了相同的位置,这时需要解决冲突的方法,例如开放地址法和链表法。

​ 下面是一个使用 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 循环遍历哈希表,输出其中的键值对。

2.7.1 数值哈希函数构造

​ 数值哈希函数的构造方法可以采用取模法、乘法哈希、多项式哈希等方法。

1.取模法

​ 取模法即将关键字的每一位相加,然后对表长取余。该方法简单易用,但是可能会导致关键字分布不均匀,因此需要选择合适的表长。

例如,将字符串转换成整数后使用取模法:

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

2.乘法哈希

​ 乘法哈希将关键字映射到一个区间 \([0, m)\) 中,其中 \(m\) 为表长。具体来说,将关键字 \(k\) 乘以一个常数 \(A\),然后取小数点后的部分,并乘以 \(m\),得到哈希值 \(h\)

其中 \(A\)\(0 < A < 1\) 的常数,一般选择 \(A = \frac{\sqrt{5} - 1}{2}\)

例如,实现一个基于乘法哈希的字符串哈希函数:

C++
1
2
3
4
5
6
7
8
unsigned int hash(const string& s, int m) {
    constexpr double A = 0.6180339887;
    unsigned int h = 0;
    for (char c : s) {
        h = h * 128 + c;
    }
    return static_cast<unsigned int>(m * fmod(h * A, 1));
}

3.多项式哈希

​ 多项式哈希将关键字看作一个系数序列,例如 \(k = a_0 + a_1x + a_2x^2 + \cdots + a_{n-1}x^{n-1}\)。设一个固定的模数 \(p\),则可以计算出 \(k\) 在模 \(p\) 意义下的值。

​ 可以使用类似于乘法哈希的方法计算多项式哈希值,具体来说,将 \(a_{n-1}\) 作为多项式的常数项,然后逆序计算多项式的系数,最终将得到哈希值。

例如,实现一个基于多项式哈希的字符串哈希函数:

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 <string>
using namespace std;

typedef unsigned long long ULL;

const int N = 1e5 + 10;
const ULL P = 131;

int n, m;
string str;
ULL h[N], p[N];

int main()
{
    cin >> str;
    n = str.size();

    // 预处理 P 的次幂
    p[0] = 1;
    for (int i = 1; i <= n; i ++ )
        p[i] = p[i - 1] * P;

    // 计算前缀哈希值
    for (int i = 1; i <= n; i ++ )
        h[i] = h[i - 1] * P + str[i - 1];

    cin >> m;
    while (m -- )
    {
        int l, r;
        cin >> l >> r;
        ULL t = h[r] - h[l - 1] * p[r - l + 1];
        cout << t << endl;
    }

    return 0;
}

2.7.2 排列哈希函数构造

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

​ 假设要将字符串\(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}\) 计算的,需要注意一些细节。

2.7.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\) 分别为素数和模数,可以根据实际情况进行选择。

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

​ 哈希函数冲突是指在将不同的键映射到哈希表中的同一槽位时出现的问题。常见的解决哈希函数冲突的方法包括:

1.链接法(Chaining):对于哈希冲突的槽位,使用链表、数组等数据结构将相应的键值对连接起来,形成一个链表。当需要查找某个键时,先通过哈希函数找到对应的槽位,然后在槽位对应的链表上线性查找。链表的缺点是查找的时间复杂度为O(n),其中n为链表长度,当链表长度过大时,查找效率将显著降低。

2.开放地址法(Open Addressing):对于哈希冲突的槽位,使用一定的探测方式在哈希表中寻找一个空闲槽位,将键值对存储到该槽位中。常见的探测方式包括线性探测、二次探测、双重散列等。当需要查找某个键时,先通过哈希函数找到对应的槽位,若该槽位不为空,则根据探测方式在哈希表中继续寻找。开放地址法的优点是不需要额外的存储空间,但探测方式的设计很关键,不恰当的设计可能会导致聚集现象(Clustering)等问题,从而降低哈希表的效率。

3.再哈希法(Rehashing):当哈希函数冲突发生时,重新应用一个哈希函数进行哈希,直到找到空闲槽位为止。该方法需要维护多个哈希函数,并且在哈希表元素较多时效率也会降低。

4.建立公共溢出区(Overflow Area):将哈希表分成主表和溢出表两部分,主表的槽位用于存储键值对,溢出表用于存储哈希冲突的键值对。当需要查找某个键时,先通过哈希函数找到对应的槽位,若该槽位为空,则在溢出表中查找。该方法需要额外的存储空间,并且当溢出表的长度过长时,查找效率也会降低。

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

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

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

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

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

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