跳转至

3、算法

3.1 复杂度分析

​ 复杂度分析是计算机科学中用于估计算法性能的一种重要方法。它主要包括时间复杂度和空间复杂度两个方面,用于描述算法随着输入数据规模增长而执行时间和占用空间增长的趋势。

时间复杂度

​ 时间复杂度是指执行算法所需要的计算工作量。它给出了执行算法所需要的时间与输入数据之间的关系。

常见的时间复杂度(由低到高)包括: - 常数时间 O(1):无论数据规模如何变化,算法执行时间都保持不变。 - 对数时间 O(log n):随着输入数据的增长,执行时间的增长速度会逐渐减慢。 - 线性时间 O(n):执行时间和输入数据大小成正比。 - 线性对数时间 O(n log n):执行时间比线性时间稍慢,比平方时间快,常见于一些高效的排序算法。 - 平方时间 O(\(n^2\)):执行时间和输入数据的平方成正比,常见于简单的排序算法(如冒泡排序)。 - 立方时间 O(\(n^3\)):执行时间和输入数据的立方成正比。 - 指数时间 O(\(2^n\)):随着输入数据的增长,执行时间呈指数级增长。 - 阶乘时间 O(n!):随着输入数据的增长,执行时间的增长非常快,常见于求解旅行商问题等。

空间复杂度

空间复杂度是指执行算法所需要的存储空间。它给出了算法的存储空间需求与输入数据之间的关系。

  • 常数空间 O(1):算法所需的临时空间不随输入数据的大小而变化。
  • 线性空间 O(n):算法所需的存储空间和输入数据的大小成正比。

复杂度分析的意义

​ 复杂度分析允许我们在不实际运行程序的情况下对算法的效率进行预估。通过复杂度分析,我们可以选择或设计出更适合当前问题的算法。对于大数据量的应用场景,拥有低时间复杂度和空间复杂度的算法尤为重要,因为这直接关系到程序的可用性和效率。

注意事项

  • 复杂度分析通常关注最坏情况,因为这保证了算法在任何情况下的性能上限。
  • 在实际应用中,除了最坏情况,我们也会关注算法的平均情况和最好情况,以及实际的运行时间和空间消耗。
  • 复杂度分析是一种理论上的估计,实际性能还会受到编程语言、编译器、硬件等多种因素的影响。

3.1.1 空间复杂度分析

​ 空间复杂度分析是对算法在执行过程中临时占用存储空间大小的量化描述。它是算法复杂度分析的一个重要方面,与时间复杂度并行。空间复杂度的分析帮助我们了解一个算法运行时需要占用多少内存资源,这对于资源有限的环境中算法的选择和优化尤其重要。

空间复杂度的定义

​ 空间复杂度(Space Complexity)通常表示为O(某个函数),用来描述算法所需存储空间与输入数据规模之间的关系。具体来说,它包括两部分:

  1. 固定部分:这部分空间的大小与输入数据的规模无关,如代码存储空间、简单变量和常量所需的空间等。
  2. 变量部分:这部分空间的大小与算法执行过程中某些变量的数量有关,这些变量的数量通常与问题的规模有直接的关系。

常见的空间复杂度

  • O(1) 常数空间复杂度:如果算法所需的临时工作空间不随输入数据的大小而变化,则空间复杂度为O(1)。例如,简单的变量声明。
  • O(n) 线性空间复杂度:如果算法所需的临时工作空间随着输入数据的增加而线性增加,则空间复杂度为O(n)。例如,需要一个与输入数据大小相等的数组。
  • O(\(n^2\)) 平方空间复杂度:如果算法所需空间与输入数据的平方成正比,则空间复杂度为O(\(n^2\))。例如,需要一个n*n的二维数组。

空间复杂度分析示例

示例1:常数空间复杂度

C++
1
2
3
4
5
6
7
int sum(int n) {
    int sum = 0; // O(1)
    for (int i = 1; i <= n; i++) { // O(1)
        sum += i;
    }
    return sum;
}

这个例子中,不管n的大小如何,所需的额外空间都是常数级别的(一个sum变量和一个循环变量i),因此空间复杂度为O(1)。

示例2:线性空间复杂度

C++
1
2
3
4
5
6
7
int[] createArray(int n) {
    int[] arr = new int[n]; // O(n)
    for (int i = 0; i < n; i++) {
        arr[i] = i;
    }
    return arr;
}

这个例子中,算法所需的额外空间取决于输入的大小n,因为它需要创建一个大小为n的数组,所以空间复杂度为O(n)。

总结

​ 空间复杂度的分析对于了解算法的内存使用情况至关重要。尤其在处理大规模数据或在资源受限的环境(如嵌入式系统)中运行算法时,高效的空间利用可以显著提升性能和可行性。在设计算法时,通常需要在时间复杂度和空间复杂度之间做出平衡。

3.1.2 时间复杂度分析

​ 时间复杂度分析是评估算法执行时间随输入数据规模增长而增长的速率或趋势的一种方法。它不直接测量算法的实际执行时间,而是分析算法执行过程中基本操作的数量。时间复杂度提供了一个理论上的执行时间评估,帮助我们在设计和选择算法时做出决策。

​ 在分析算法的时间复杂度时,通常需要分析算法中每个操作的时间复杂度,并考虑它们执行的次数。常见的操作包括:

  1. 基本操作,例如赋值、比较、算术运算等,它们的时间复杂度通常是O(1)。
  2. 循环操作,例如for循环、while循环等,它们的时间复杂度取决于循环体内部操作的时间复杂度以及循环次数。
  3. 条件分支,例如if语句、switch语句等,它们的时间复杂度取决于条件表达式的计算复杂度以及不同分支的执行次数。
  4. 函数调用,例如递归函数、库函数等,它们的时间复杂度取决于函数本身的复杂度以及调用次数。

在分析时间复杂度时,常用的方法包括:

  1. 用常数替代运行时间中的常数项。
  2. 只保留运行时间中的最高次幂项。
  3. 如果运行时间中存在多个最高次幂项,则保留最大的一个。
  4. 忽略运行时间中的低次幂项。
  5. 忽略运行时间中的常数项。

例如,如果一个算法的时间复杂度是O(n2 + n),那么它的时间复杂度可以简化为O(n2)。

时间复杂度分析的步骤

  1. 确定算法的基本操作:首先需要确定算法中的基本操作,比如比较、赋值等。
  2. 计算基本操作的数量:分析在最坏情况下,这些基本操作的数量如何随输入数据的规模增长。
  3. 用大O表示法表示时间复杂度:忽略常数因子和低阶项,只保留最高阶项,用大O表示法来描述算法的时间复杂度。

示例

考虑一个简单的线性搜索算法:

C++
1
2
3
4
5
6
7
8
int linearSearch(int arr[], int n, int x) {
    for (int i = 0; i < n; i++) {
        if (arr[i] == x) {
            return i;
        }
    }
    return -1;
}

​ 在这个算法中,基本操作是比较操作arr[i] == x,在最坏情况下(要查找的元素x不在数组中或在数组的最后一个位置),这个比较操作需要执行n次,因此这个算法的时间复杂度是O(n)

总结

​ 时间复杂度分析是算法研究中的一个基本工具,它帮助我们理解算法的效率并指导我们进行算法设计和选择。通过分析算法的时间复杂度,我们可以预测算法对于大规模数据的表现,从而选择最合适的算法解决实际问题。

3.2 基础算法

3.2.1 分治算法

​ 分治算法是一种重要的算法设计策略,它基于多重递归思想,将原问题分解(Divide)成若干个规模较小但与原问题相同的子问题,递归地解决这些子问题,然后再合并(Conquer)这些子问题的解以得到原问题的解。

分治算法的步骤

  1. 分解:将原问题分解为若干个规模更小的相同问题。
  2. 解决:递归地解决这些规模更小的问题,如果问题已足够小,直接解决。
  3. 合并:将所有小问题的解合并为原问题的解。

分治算法的特点

  • 分治策略是递归的一种应用,它通过将大问题分解为小问题来简化问题的求解过程。
  • 分治算法特别适用于问题的规模可以减小到容易直接解决的程度。
  • 分治算法的效率往往依赖于分解策略的效率以及子问题解决后合并策略的效率。

常见的分治算法示例

  • 归并排序(Merge Sort):将数组分成两半,递归地对它们进行归并排序,然后将排序好的两半合并在一起。
  • 快速排序(Quick Sort):选择一个基准元素,将数组分成两部分,使得一部分的所有元素都比基准元素小,另一部分的所有元素都比基准元素大,然后递归地对这两部分进行快速排序。
  • 二分搜索(Binary Search):在一个有序的数组中递归地查找一个特定的元素。
  • 大整数乘法:将大整数分成较小的部分,递归地求解这些部分的乘积,然后合并结果。
  • Strassen矩阵乘法:将矩阵分成子矩阵,递归地计算子矩阵的乘积,然后合并结果以得到最终矩阵乘积。
  • 汉诺塔问题:递归地将盘子从一个柱子移动到另一个柱子。

分治算法的时间复杂度

分治算法的时间复杂度通常可以用递归方程(Recurrence Relation)来描述。例如,归并排序的时间复杂度为T(n) = 2T(n/2) + O(n),通过主定理(Master Theorem)求解可知,其时间复杂度为O(n log n)

分治算法常见的应用

分治算法常见的应用包括归并排序、快速排序、线性时间选择、求逆序对等。下面分别给出这几种算法的C++代码实现。

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

void merge(vector<int>& nums, int left, int mid, int right) {
    int n1 = mid - left + 1;
    int n2 = right - mid;
    vector<int> L(n1), R(n2);
    for (int i = 0; i < n1; ++i) {
        L[i] = nums[left + i];
    }
    for (int i = 0; i < n2; ++i) {
        R[i] = nums[mid + i + 1];
    }
    int i = 0, j = 0, k = left;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            nums[k++] = L[i++];
        } else {
            nums[k++] = R[j++];
        }
    }
    while (i < n1) {
        nums[k++] = L[i++];
    }
    while (j < n2) {
        nums[k++] = R[j++];
    }
}

void mergeSort(vector<int>& nums, int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2;
        mergeSort(nums, left, mid);
        mergeSort(nums, mid + 1, right);
        merge(nums, left, mid, right);
    }
}

int main() {
    vector<int> nums{5, 2, 4, 7, 1, 3, 2, 6};
    mergeSort(nums, 0, nums.size() - 1);
    for (auto num : nums) {
        cout << num << " ";
    }
    cout << endl;
    return 0;
}

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
28
29
30
31
32
33
#include <iostream>
#include <vector>
using namespace std;

int partition(vector<int>& nums, int left, int right) {
    int pivot = nums[right];
    int i = left;
    for (int j = left; j < right; ++j) {
        if (nums[j] <= pivot) {
            swap(nums[i++], nums[j]);
        }
    }
    swap(nums[i], nums[right]);
    return i;
}

void quickSort(vector<int>& nums, int left, int right) {
    if (left < right) {
        int pivotIndex = partition(nums, left, right);
        quickSort(nums, left, pivotIndex - 1);
        quickSort(nums, pivotIndex + 1, right);
    }
}

int main() {
    vector<int> nums{5, 2, 4, 7, 1, 3, 2, 6};
    quickSort(nums, 0, nums.size() - 1);
    for (auto num : nums) {
        cout << num << " ";
    }
    cout << endl;
    return 0;
}

3.线性时间选择

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>
#include <algorithm>

using namespace std;

// 寻找中位数的中位数
int findMedian(vector<int>& nums, int l, int r) {
    sort(nums.begin() + l, nums.begin() + r + 1);
    return nums[(l + r) / 2];
}

// 将中位数放到左边第一个位置
int partition(vector<int>& nums, int l, int r, int pivot) {
    int i = l, j = r;
    while (i <= j) {
        while (nums[i] < pivot) i++;
        while (nums[j] > pivot) j--;
        if (i <= j) {
            swap(nums[i], nums[j]);
            i++;
            j--;
        }
    }
    return i - 1;
}

int select(vector<int>& nums, int l, int r, int k) {
    if (l == r) return nums[l];
    int pivot = findMedian(nums, l, r);
    int index = partition(nums, l, r, pivot);
    int count = index - l + 1;
    if (k == count) {
        return nums[index];
    } else if (k < count) {
        return select(nums, l, index - 1, k);
    } else {
        return select(nums, index + 1, r, k - count);
    }
}

int main() {
    vector<int> nums = {5, 1, 3, 2, 4};
    int k = 3;
    int result = select(nums, 0, nums.size() - 1, k);
    cout << "The " << k << "th smallest element is: " << result << endl;
    return 0;
}

该算法的时间复杂度为O(n),其中n为序列中元素的个数。

4.求逆序对

求逆序对也是一种常见的分治算法,其基本思想是将待排序的序列分成两部分,递归求解每个子问题的逆序对数目,然后合并两个有序子序列,并计算跨越两个子序列的逆序对数目。

以下是求逆序对的代码:

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

using namespace std;

long long merge(vector<int>& nums, int left, int mid, int right) {
    vector<int> temp(right - left + 1);
    int i = left, j = mid + 1, k = 0;
    long long count = 0;
    while (i <= mid && j <= right) {
        if (nums[i] <= nums[j]) {
            temp[k++] = nums[i++];
        } else {
            temp[k++] = nums[j++];
            count += mid - i + 1;
        }
    }
    while (i <= mid) {
        temp[k++] = nums[i++];
    }
    while (j <= right) {
        temp[k++] = nums[j++];
    }
    for (int p = 0; p < temp.size(); ++p) {
        nums[left + p] = temp[p];
    }
    return count;
}

long long mergeSort(vector<int>& nums, int left, int right) {
    if (left >= right) {
        return 0;
    }
    int mid = left + (right - left) / 2;
    long long count = 0;
    count += mergeSort(nums, left, mid);
    count += mergeSort(nums, mid + 1, right);
    count += merge(nums, left, mid, right);
    return count;
}

long long countInversions(vector<int>& nums) {
    return mergeSort(nums, 0, nums.size() - 1);
}

int main() {
    vector<int> nums = {7, 5, 6, 4};
    cout << "The number of inversions is: " << countInversions(nums) << endl;
    return 0;
}

​ 该代码实现了基于归并排序的求逆序对的算法。时间复杂度为 \(O(n\log n)\),空间复杂度为 \(O(n)\)。其中 merge 函数实现了合并两个有序数组并统计逆序对数量的功能, mergeSort 函数实现了归并排序的过程,并在过程中调用了 merge 函数统计逆序对数量。最后在 countInversions 函数中调用 mergeSort 函数,返回最终的逆序对数量。

总结

​ 分治算法通过将复杂问题分解为简单的子问题来解决问题,这种策略在算法设计中非常重要。恰当地应用分治策略可以显著提高解决问题的效率,特别是在数据量大的情况下。理解分治策略及其应用有助于深入理解递归思想和算法设计。

3.3 排序算法

3.3.1 归并排序

​ 归并排序(Merge Sort)是一种有效的排序算法,采用分治策略。它将一个数组分成两半,分别对这两半进行排序,然后将排序好的两部分合并在一起。归并排序对于大型数据集合特别有效,其时间复杂度在最好、最坏和平均情况下都是O(n log n)。

算法步骤

  1. 分解:将待排序的数组分成两半。
  2. 递归排序:递归地对这两半进行归并排序。
  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
#include <vector>
using namespace std;

// 合并两个已排序的部分
void merge(vector<int>& arr, int l, int m, int r) {
    vector<int> left(arr.begin() + l, arr.begin() + m + 1);
    vector<int> right(arr.begin() + m + 1, arr.begin() + r + 1);

    int i = 0, j = 0, k = l;
    while (i < left.size() && j < right.size()) {
        if (left[i] <= right[j]) {
            arr[k++] = left[i++];
        } else {
            arr[k++] = right[j++];
        }
    }

    // 复制剩余元素
    while (i < left.size()) arr[k++] = left[i++];
    while (j < right.size()) arr[k++] = right[j++];
}

// 归并排序函数
void mergeSort(vector<int>& arr, int l, int r) {
    if (l < r) {
        int m = l + (r - l) / 2; // 找到中点
        mergeSort(arr, l, m);    // 排序左半部分
        mergeSort(arr, m + 1, r); // 排序右半部分
        merge(arr, l, m, r);     // 合并两部分
    }
}

算法特点

  • 稳定性:归并排序是一种稳定的排序算法,相等的元素的顺序不会改变。
  • 非原地排序:由于归并过程需要额外的存储空间,所以它不是原地排序算法。
  • 并行性:归并排序适合并行计算。因为它的分解过程可以独立进行,这使得它在多处理器系统上有很好的表现。

应用场景

​ 归并排序由于其稳定性和对大数据集的高效处理,在很多场景下都得到应用,包括外部排序、数据处理等领域。尤其是当数据不能全部加载到内存中时,归并排序可以用于外部存储设备上的排序操作,通过分阶段加载数据进行排序和合并。

3.3.2 快速排序

​ 快速排序(Quick Sort)是一种高效的排序算法,由C. A. R. Hoare于1960年提出。它也采用了分治策略,通过一个分区操作来将一个数组分为两个子数组,使得:

  • 左边子数组的元素都不大于右边子数组的元素;
  • 对这两个子数组递归地进行快速排序。

​ 快速排序在平均和最好情况下的时间复杂度是O(n log n),但在最坏情况下的时间复杂度是O(n^2)。尽管如此,由于它的高内在效率和较小的常数因子,快速排序在实践中通常比其他O(n log n)算法更快。

快速排序的基本步骤

  1. 选择基准值(Pivot):从数组中选择一个元素作为基准值。选择方法多样,可以是第一个元素、最后一个元素、中间元素,或者更复杂的方法如三数取中。
  2. 分区(Partition):重新排列数组,使得所有小于基准值的元素都在基准值之前,而所有大于基准值的元素都在基准值之后。在这个过程中,基准值在其最终位置上。
  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
#include <vector>
using namespace std;

// 分区函数
int partition(vector<int>& arr, int low, int high) {
    int pivot = arr[high];  // 选择最后一个元素作为基准
    int i = (low - 1);  // i是小于基准的元素的索引

    for (int j = low; j <= high - 1; j++) {
        // 如果当前元素小于或等于基准
        if (arr[j] <= pivot) {
            i++;    // 增加小于基准的元素的索引
            swap(arr[i], arr[j]);
        }
    }
    swap(arr[i + 1], arr[high]);
    return (i + 1);
}

// 快速排序函数
void quickSort(vector<int>& arr, int low, int high) {
    if (low < high) {
        // pi是分区索引,arr[pi]现在处于正确位置
        int pi = partition(arr, low, high);

        // 分别对分区前后的子数组进行排序
        quickSort(arr, low, pi - 1);
        quickSort(arr, pi + 1, high);
    }
}

特点与应用

  • 不稳定排序:快速排序是一种不稳定的排序算法,相等的元素可能会因为分区而改变顺序。
  • 原地排序:快速排序是原地排序算法,它不需要额外的存储空间。
  • 适应性:对于大规模的数据集,快速排序非常有效。但对于小数据集或几乎已经排序的数据,其他算法如插入排序可能更优。

​ 尽管快速排序在最坏情况下的性能不是特别好,但它的平均性能非常优秀,是实际应用中最常用的排序算法之一。通过合理选择基准值和使用一些优化技巧(如三数取中、小数组时切换到插入排序等),可以显著提高快速排序的性能。

3.3.3 堆排序

​ 堆排序(Heap Sort)是一种基于比较的排序算法,通过构建堆这种数据结构来实现。堆是一种特殊的完全二叉树,其中每个父节点的值都不小于(或不大于)其子节点的值,这样的堆分别称为最大堆(Max Heap)或最小堆(Min Heap)。堆排序算法可以分为两个主要阶段:构建最大堆(或最小堆)和逐步从堆中取出最大(或最小)元素来实现排序。

堆排序的步骤

  1. 构建最大堆:将无序的输入数组构建成一个最大堆。这个过程从最后一个非叶子节点开始,向上逐层调整堆,确保每个节点都遵循最大堆的性质。

  2. 排序:由于最大堆的根节点是最大的元素,可以通过将其与堆的最后一个元素交换,然后减少堆的大小并重新调整堆来获得排序的数组。这个过程重复进行,直到堆的大小为1。

堆排序的性质

  • 时间复杂度:堆排序的时间复杂度为O(n log n),其中n是数组的长度。这是因为构建堆的时间复杂度为O(n),而每次调整堆的时间复杂度为O(log n),总共需要调整n-1次。
  • 空间复杂度:堆排序是一个原地排序算法,其空间复杂度为O(1)。
  • 不稳定排序:堆排序是不稳定的排序算法,相同值的元素可能会在排序过程中改变顺序。

算法实现(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
#include <vector>
using namespace std;

void heapify(vector<int>& arr, int n, int i) {
    int largest = i; // 初始化最大元素为根节点
    int left = 2 * i + 1; // 左子节点
    int right = 2 * i + 2; // 右子节点

    // 如果左子节点大于根节点
    if (left < n && arr[left] > arr[largest])
        largest = left;

    // 如果右子节点大于当前最大元素
    if (right < n && arr[right] > arr[largest])
        largest = right;

    // 如果最大元素不是根节点
    if (largest != i) {
        swap(arr[i], arr[largest]);

        // 递归地调整受影响的子树
        heapify(arr, n, largest);
    }
}

void heapSort(vector<int>& arr) {
    int n = arr.size();

    // 构建最大堆
    for (int i = n / 2 - 1; i >= 0; i--)
        heapify(arr, n, i);

    // 一个个从堆顶取出元素
    for (int i = n - 1; i > 0; i--) {
        swap(arr[0], arr[i]); // 将当前根节点移至数组末尾

        // 调整剩余堆
        heapify(arr, i, 0);
    }
}

应用

​ 堆排序因其O(n log n)的时间复杂度和原地排序的特性,适用于大数据量的排序。尽管它是不稳定的排序,但在某些场景下(如内存限制严格的环境),堆排序是一个非常有效的选择。此外,最大堆和最小堆结构在实现优先队列、调度算法等多种算法和应用中都有广泛使用。

3.3.4 树形选择排序(锦标赛排序)

​ 树形选择排序,也称为锦标赛排序(Tournament Sort),是一种选择排序的变体,通过构建一个类似锦标赛的比赛过程来选择出最小(或最大)的元素。这个过程通过构建一棵完全二叉树来实现,其中每个非叶节点是其两个子节点的“胜者”(较小或较大的元素,取决于是寻找最小值还是最大值)。

算法步骤

  1. 构建锦标赛树:将n个元素作为完全二叉树的叶节点。对于每一对叶节点,比较它们的值,较小(或较大)的值上升到它们的父节点。重复这个过程,直到树的根节点,根节点存储的是最小(或最大)值。

  2. 选择最小(或最大)元素:根节点的元素是当前最小(或最大)的元素。将其记录下来作为排序结果的一部分。

  3. 更新锦标赛树:从包含最小(或最大)元素的叶节点开始,重新比较并更新路径上的所有节点,直到根节点。如果原叶节点没有更多的元素可以替换,则将其值设置为无穷大(或无穷小),确保它不会再次被选中。

  4. 重复选择和更新过程:重复步骤2和步骤3,直到所有元素都被选出。

特点

  • 树形选择排序特别适合于并行处理,因为在每一层的比较都可以同时进行。
  • 这种排序方法在选择最小(或最大)元素时特别高效,因为它减少了比较的次数,特别是对于大规模的数据。
  • 更新树的过程中,每次都要从叶节点遍历到根节点,更新路径上所有节点的值,这是算法的主要开销之一。

空间复杂度和时间复杂度

  • 空间复杂度为O(n),因为需要构建一棵与原始数据大小相同的完全二叉树。
  • 时间复杂度在最好、平均和最坏情况下都是O(n log 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
48
49
50
51
52
#include <iostream>
using namespace std;

const int MAXN = 1 << 20;
int n, m, a[MAXN], b[MAXN];

// 对于一个二元竞赛,找出其胜者的编号
int get_winner(int x, int y) {
    return a[x] < a[y] ? x : y;
}

// 计算比赛的比较轮数
int get_log(int n) {
    int logn = 0;
    while ((1 << logn) < n) {
        logn++;
    }
    return logn;
}

// 进行一轮比赛,将胜者放到下一轮
void play_round(int cur, int nxt) {
    for (int i = 0; i < n; i += 2) {
        int winner = get_winner(a[i], a[i + 1]);
        b[i >> 1] = winner;
    }
    if (n & 1) {
        b[n >> 1] = a[n - 1];
    }
    swap(a, b);
}

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

    int logn = get_log(n);
    m = 1 << logn;
    for (int i = n; i < m; i++) {
        a[i] = MAXN;  // 将序列补齐成2的幂次方
    }

    for (int i = 0; i < logn; i++) {
        play_round(i, i + 1);
    }

    cout << a[0] << endl;  // 输出最小值

    return 0;
}

​ 上面的代码实现了树形选择排序算法,其中get_winner函数表示一个二元竞赛的胜者编号,get_log函数计算比赛的比较轮数,play_round函数进行一轮比赛并将胜者放到下一轮,最后输出整个序列的最小值。

3.3.5 桶排序

​ 桶排序(Bucket Sort)是一种分布式排序算法,它将元素分散到多个桶中,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序),最后合并桶中的元素得到排序结果。桶排序非常适合用在输入数据均匀分布在一个范围内时。

算法步骤

  1. 初始化桶:创建一定数量的桶(比如列表或链表),用来分别存放在不同范围内的元素。

  2. 分配元素到桶中:遍历原始数组,根据每个元素的值将其分配到对应范围的桶中。通常,元素值与桶的分配有某种映射关系。

  3. 对每个桶进行排序:单独对每个桶的元素进行排序。可以使用不同的排序算法,如插入排序、快速排序等。

  4. 合并桶:按顺序将每个桶中的元素合并回一个大的数组中,得到排序后的数组。

时间复杂度和空间复杂度

  • 时间复杂度:在最佳情况下,当输入均匀分布时,桶排序的时间复杂度为O(n+k),其中n是原始数组中的元素数量,k是桶的数量。但在最坏情况下,如所有元素都被分配到同一个桶中,时间复杂度退化为O(n^2)。
  • 空间复杂度:O(n+k),需要额外的空间来存放桶和桶中的元素。

特点

  • 桶排序非常适合在输入数据服从均匀分布时使用。对于数据分布极不均匀的情况,桶排序的性能并不好。
  • 桶排序是稳定的排序算法。
  • 桶排序的性能取决于数据的分布情况以及桶的数量和大小。

应用场景

  • 对大量数据进行排序时,这些数据需要均匀分布在一个范围内。
  • 需要稳定排序算法时。
  • 在分布式系统中,可以并行地对桶进行排序,提高排序效率。

算法实现(C++示例)

这是一个简单的桶排序实现示例,使用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
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

void bucketSort(float arr[], int n) {
    vector<float> buckets[n];

    // 1. 将元素分配到桶中
    for (int i = 0; i < n; i++) {
        int bi = n * arr[i]; // 索引
        buckets[bi].push_back(arr[i]);
    }

    // 2. 对每个桶进行排序
    for (int i = 0; i < n; i++) {
        sort(buckets[i].begin(), buckets[i].end());
    }

    // 3. 合并桶
    int index = 0;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < buckets[i].size(); j++) {
            arr[index++] = buckets[i][j];
        }
    }
}

int main() {
    float arr[] = {0.42, 0.32, 0.23, 0.52, 0.25, 0.47, 0.51};
    int n = sizeof(arr) / sizeof(arr[0]);
    bucketSort(arr, n);

    for (int i = 0; i < n; i++) {
        cout << arr[i] << " ";
    }
    return 0;
}

​ 在这个示例中,假设元素都在0到1之间,根据元素值的大小将它们分配到不同的桶中,然后对每个桶进行排序,最后将桶中的元素合并成最终的排序结果。

3.3.6 基数排序

​ 基数排序(Radix Sort)是一种非比较型整数排序算法,它通过将整数按位数切割成不同的数字,然后按每个位数分别比较来进行排序。这个过程一般从最低位开始,逐位进行直到最高位。基数排序使用了稳定排序(如计数排序或桶排序)作为子程序来排序每一位数字。

算法步骤

  1. 找出最大数:找出数组中的最大数,并获取最大数的位数,这决定了外循环的次数。
  2. 排序每一位:从最低位开始,对每一位执行稳定排序算法。可以使用计数排序或桶排序来实现。
  3. 重复直至最高位:重复上述过程,直至最高位。

时间复杂度和空间复杂度

  • 时间复杂度:基数排序的时间复杂度为O(nk),其中n是数组中元素的数量,k是数值中的最大位数。由于k通常远小于n,所以基数排序在处理大量数据时非常高效。
  • 空间复杂度:基数排序的空间复杂度为O(n+k),需要额外的空间来存储临时的数组和计数数组。

特点

  • 非比较排序:基数排序不比较元素之间的大小关系,而是通过元素的各个位的值来决定元素的顺序。
  • 稳定性:基数排序是稳定的排序算法,相同值的元素在排序后会保持原来的顺序。
  • 适用范围:基数排序适用于整数排序,也可以通过特定的转换方式用于浮点数或字符串的排序。

算法实现(C++示例)

以下是基数排序的一个简单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
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

// 一个用于基数排序的计数排序函数
void countingSort(std::vector<int>& arr, int exp) {
    std::vector<int> output(arr.size());
    std::vector<int> count(10, 0);

    // 计算每个位的出现次数
    for (int i = 0; i < arr.size(); i++)
        count[(arr[i] / exp) % 10]++;

    // 更改count[i],使得count[i]包含了这一位上小于等于i的数字的个数
    for (int i = 1; i < 10; i++)
        count[i] += count[i - 1];

    // 构建输出数组
    for (int i = arr.size() - 1; i >= 0; i--) {
        output[count[(arr[i] / exp) % 10] - 1] = arr[i];
        count[(arr[i] / exp) % 10]--;
    }

    // 将排序好的数据复制到原数组
    for (int i = 0; i < arr.size(); i++)
        arr[i] = output[i];
}

// 基数排序函数
void radixSort(std::vector<int>& arr) {
    int m = *max_element(arr.begin(), arr.end());

    // 对每一位进行计数排序
    for (int exp = 1; m / exp > 0; exp *= 10)
        countingSort(arr, exp);
}

int main() {
    std::vector<int> arr = {170, 45, 75, 90, 802, 24, 2, 66};
    radixSort(arr);
    for (int i = 0; i < arr.size(); i++)
        std::cout << arr[i] << " ";
    return 0;
}

应用

​ 基数排序非常适合于需要排序大量数据的场景,特别是当键值的范围不是很大时。它常被用于字符串排序、整数排序等多种场景。由于其稳定性,基数排序也经常被用作其他算法中的一个子程序。

3.4 字符串相关算法

3.4.1 字符串匹配算法——KMP

​ KMP(Knuth-Morris-Pratt)算法是一种高效的字符串匹配算法,由Donald Knuth、Vaughan Pratt和James H. Morris在1977年共同发表。KMP算法能在O(n + m)时间内完成匹配,其中n是文本字符串的长度,m是模式字符串的长度,这比朴素的字符串匹配算法要高效得多。

KMP算法的核心

​ KMP算法的核心在于一个预处理过程,即构建部分匹配表(也称为前缀函数或失配函数)。这个表为每一个位置i计算了模式字符串中[0, i]范围内的子串的最长相等的前缀和后缀的长度。利用这个信息,当文本字符串和模式字符串在某个字符处不匹配时,可以将模式字符串向右滑动更多的距离,跳过那些必然不会匹配的尝试。

部分匹配表的构建

​ 部分匹配表的构建是通过动态规划完成的。对于模式字符串P和每个位置i,部分匹配值π[i]表示P[0...i]中最长相等的前缀和后缀的长度。

KMP字符串匹配过程

  1. 预处理模式字符串,构建部分匹配表。
  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
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <vector>
#include <iostream>
using namespace std;

// 构建部分匹配表
vector<int> buildPartialMatchTable(string pattern) {
    vector<int> lps(pattern.length());
    int len = 0; // 长度为0的前缀
    lps[0] = 0; // lps[0]总是0

    // lps数组的构建
    int i = 1;
    while (i < pattern.length()) {
        if (pattern[i] == pattern[len]) {
            len++;
            lps[i] = len;
            i++;
        } else {
            if (len != 0) {
                len = lps[len - 1];
            } else {
                lps[i] = 0;
                i++;
            }
        }
    }
    return lps;
}

// KMP字符串匹配算法
void KMPSearch(string text, string pattern) {
    vector<int> lps = buildPartialMatchTable(pattern);

    int i = 0; // 文本字符串的索引
    int j = 0; // 模式字符串的索引
    while (i < text.length()) {
        if (pattern[j] == text[i]) {
            j++;
            i++;
        }

        if (j == pattern.length()) {
            cout << "Found pattern at index " << i - j << endl;
            j = lps[j - 1];
        } else if (i < text.length() && pattern[j] != text[i]) {
            if (j != 0) {
                j = lps[j - 1];
            } else {
                i = i + 1;
            }
        }
    }
}

int main() {
    string text = "ABABDABACDABABCABAB";
    string pattern = "ABABCABAB";
    KMPSearch(text, pattern);
    return 0;
}

应用

​ KMP算法广泛用于文本编辑器、搜索引擎和生物信息学等领域中的字符串搜索任务,因为它能够有效地处理大量数据的匹配问题,而不会出现朴素字符串匹配算法那样的回溯问题。

3.5 搜索算法

​ 搜索算法是一类用于在数据结构中查找或搜索特定数据的算法。根据数据结构的类型和数据的组织方式,搜索算法可以分为几种不同的类型。以下是一些常见的搜索算法:

  • 描述:线性搜索是最基本的搜索技术,它从数据结构的一端开始,逐个检查每个元素,直到找到所需的元素或搜索完所有元素。
  • 时间复杂度:O(n),其中n是数据结构中元素的数量。
  • 应用场景:适用于小数据量或无序数据的搜索。
  • 描述:二分搜索是一种在有序数据结构中查找特定元素的高效算法。它通过比较中间元素与目标值来决定是搜索左半部分还是右半部分,逐步缩小搜索范围。
  • 时间复杂度:O(log n)。
  • 应用场景:适用于有序的数组或列表。
  • 描述:跳表是对链表的优化,允许快速访问链表中的任何元素。它通过创建多层链表,每层覆盖下层的部分元素,从最上层开始搜索,逐层下降,直到找到目标值。
  • 时间复杂度:O(√n)。
  • 应用场景:适用于已排序的链表。
  • 描述:插值搜索是二分搜索的一种改进,选择的中点是根据目标值在整个搜索区间的可能位置来计算的。这种方法依赖于数据的均匀分布。
  • 时间复杂度:平均情况O(log log n),最坏情况O(n)。
  • 应用场景:适用于分布均匀的有序数据。
  • 描述:哈希表是一种通过哈希函数来计算元素存储位置的数据结构,支持快速插入、删除和搜索操作。
  • 时间复杂度:平均情况O(1),最坏情况O(n)。
  • 应用场景:适用于快速数据查找、去重等。

6. 深度优先搜索(DFS)和广度优先搜索(BFS)

  • 描述:DFS和BFS是用于图和树等数据结构的搜索算法。DFS通过深入到每个分支的最底部来搜索,而BFS是逐层搜索。
  • 时间复杂度:O(V+E),其中V是顶点数,E是边数。
  • 应用场景:适用于路径查找、图的遍历等。

7. A*搜索算法

  • 描述:A*搜索算法是一种寻找在两个节点之间最短路径的高效算法。它使用启发式方法来优先搜索估计距离终点最近的路径。
  • 时间复杂度:取决于启发式函数。
  • 应用场景:适用于图的最短路径查找,如地图导航。

​ 选择哪种搜索算法取决于数据的结构、数据量的大小以及是否需要排序等多种因素。理解不同搜索算法的原理和特点有助于在具体的应用场景中做出合适的选择。

3.5.1 搜索的剪枝优化

​ 在搜索算法中,剪枝是一种优化技术,用于减少搜索空间,从而提高搜索效率。剪枝通过提前排除那些不可能产生最优解或满足条件的路径,减少不必要的计算。这种技术在解决复杂问题、减少计算时间中非常有用,尤其是在深度优先搜索(DFS)、广度优先搜索(BFS)、回溯算法和一些基于搜索的优化问题(如A*算法)中。

常见的剪枝技术

  1. 可行性剪枝:在搜索过程中,如果发现当前路径不可能达到目标(如违反了问题的某些约束),则停止进一步搜索这条路径。

  2. 最优性剪枝:当已经找到一个解时,如果当前路径的成本已经高于已找到的最优解,那么就没有继续搜索这条路径的必要了。

  3. 重复状态剪枝:在搜索过程中,如果某状态已被搜索过,则可以跳过该状态,避免重复计算。

  4. 启发式剪枝(Heuristic Pruning):使用启发式方法估计当前路径是否可能比已知的最优解更优。如果不可能,就剪枝。

剪枝优化的例子

  • 在解决棋盘问题(如象棋、围棋)时,剪枝可以用来排除那些显然不会导致胜利的走法。
  • 在求解旅行商问题(TSP)时,如果当前路径的长度已经超过了已知的最短路径,那么这条路径就可以被剪掉。
  • 在使用A*算法寻找最短路径时,启发式函数用于估计从当前节点到目标节点的距离,对于那些启发式成本过高的节点,可以不加以考虑。

实现剪枝的技巧

  • 深入理解问题:理解问题的性质和约束条件是实现有效剪枝的前提。
  • 适当记录:在搜索过程中,适当记录已搜索过的状态、当前的最优解等信息,可以帮助判断是否需要剪枝。
  • 灵活运用数据结构:使用合适的数据结构(如优先队列、哈希表)可以帮助高效地实现剪枝逻辑。

注意事项

  • 剪枝需要谨慎设计,以避免错误地剪掉可能的最优解。
  • 过度剪枝可能会错失正确解,不足的剪枝可能导致效率不高,因此需要平衡。
  • 剪枝策略的选择和设计通常依赖于具体问题的特点。

通过剪枝,可以显著提高搜索算法的效率,处理更大规模的数据和更复杂的问题。

以下是常见的搜索剪枝优化技术的C++代码示例:

1.前向星+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
int head[MAXN], ver[MAXM], nxt[MAXM], cnt = 1;
bool vis[MAXN];
int ans = 0;

void add(int x, int y) {
    ver[++cnt] = y;
    nxt[cnt] = head[x];
    head[x] = cnt;
}

void dfs(int u, int num) {
    if (num == 5) {  // 剪枝条件,num为当前深度
        ans++;
        return;
    }
    vis[u] = true;
    for (int i = head[u]; i; i = nxt[i]) {
        int v = ver[i];
        if (!vis[v]) {
            dfs(v, num + 1);
        }
    }
    vis[u] = false;
}

int main() {
    int n, m;
    cin >> n >> m;
    while (m--) {
        int x, y;
        cin >> x >> y;
        add(x, y);
        add(y, x);
    }
    for (int i = 1; i <= n; i++) {
        dfs(i, 1);
    }
    cout << ans / 10 << endl;
    return 0;
}

2.以下是双向BFS+剪枝的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
#include <iostream>
#include <queue>
#include <unordered_set>

using namespace std;

// 双向BFS+剪枝求解最短路径
int biBFS(string beginWord, string endWord, unordered_set<string>& wordList) {
    if (beginWord == endWord) return 1; // 特判
    unordered_set<string> visited1, visited2, *visitedPtr1, *visitedPtr2; // 两个方向的visited集合
    visited1.insert(beginWord);
    visited2.insert(endWord);
    int step = 1;
    while (!visited1.empty() && !visited2.empty()) {
        if (visited1.size() > visited2.size()) { // 优化:始终选择访问节点数量较少的那一方,减少搜索的深度
            visitedPtr1 = &visited2;
            visitedPtr2 = &visited1;
            swap(beginWord, endWord);
        } else {
            visitedPtr1 = &visited1;
            visitedPtr2 = &visited2;
        }
        unordered_set<string> tmpSet; // 记录新访问的节点
        for (auto it = visitedPtr1->begin(); it != visitedPtr1->end(); it++) {
            string currWord = *it;
            for (int i = 0; i < currWord.size(); i++) { // 逐个修改当前单词的每一个字符
                char tmp = currWord[i];
                for (char c = 'a'; c <= 'z'; c++) { // 对每一个字符,枚举26个字母
                    currWord[i] = c;
                    if (visitedPtr2->count(currWord)) return step + 1; // 如果另一个方向的visited集合已包含该单词,即找到了公共节点,返回答案
                    if (wordList.count(currWord) && !visitedPtr1->count(currWord)) { // 如果wordList中包含该单词且还未被visited访问,加入tmpSet
                        tmpSet.insert(currWord);
                        visitedPtr1->insert(currWord);
                    }
                }
                currWord[i] = tmp; // 恢复当前单词的第i个字符
            }
        }
        visitedPtr1->swap(tmpSet); // 优化:用swap交换visited集合和tmpSet,避免拷贝visited集合
        step++; // 更新步数
    }
    return 0; // 未找到路径
}

int main() {
    string beginWord = "hit";
    string endWord = "cog";
    unordered_set<string> wordList = {"hot", "dot", "dog", "lot", "log"};
    cout << biBFS(beginWord, endWord, wordList) << endl; // 输出最短路径长度
    return 0;
}

以下是一个基于A*算法的启发式搜索的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
#include <iostream>
#include <queue>
#include <vector>
#include <cstring>
#include <cmath>

using namespace std;

const int N = 10010;

struct Point {
    int x, y;
    int f, g, h;  // f = g + h,g表示从起点到当前点的实际代价,h表示从当前点到终点的估计代价
};

struct HeapNode {
    int d;
    int x, y;

    bool operator< (const HeapNode& t) const {
        return d > t.d;  // 小根堆,距离越小优先级越高
    }
};

int n, m;
char g[N][N];
int dx[4] = {-1, 0, 1, 0};
int dy[4] = {0, 1, 0, -1};
bool st[N][N];
Point q[N * N];
priority_queue<HeapNode> heap;  // A*算法中的堆优化,存储所有待扩展的点

int manhattan(int x, int y, Point& t) {  // 曼哈顿距离
    return abs(x - t.x) + abs(y - t.y);
}

int a_star(Point start, Point end) {
    start.f = 0;
    start.g = 0;
    start.h = manhattan(start.x, start.y, end);

    heap.push({start.f, start.x, start.y});

    while (heap.size()) {
        auto t = heap.top();
        heap.pop();

        int x = t.x, y = t.y;

        if (st[x][y]) continue;
        st[x][y] = true;

        if (x == end.x && y == end.y) return t.d;  // 找到终点

        for (int i = 0; i < 4; i++) {
            int a = x + dx[i], b = y + dy[i];

            if (a < 0 || a >= n || b < 0 || b >= m) continue;
            if (g[a][b] == '#') continue;

            Point next = {a, b};
            next.g = start.g + 1;
            next.h = manhattan(a, b, end);
            next.f = next.g + next.h;

            heap.push({next.f, a, b});
        }
    }

    return -1;  // 无法到达终点
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < n; i++) cin >> g[i];

    Point start = {0, 0}, end = {0, 0};
    cin >> start.x >> start.y >> end.x >> end.y;

    int distance = a_star(start, end);

    cout << distance << endl;

    return 0;
}

​ 在上述代码中,我们定义了两个结构体,Point表示二维平面上的一个点,其中f表示该点的估价函数值,g表示该点到起点的实际代价,h表示该点到终点的估计代价。HeapNode表示堆优化算法中的堆节点,其中d表示该节点的估价。

以下是一个使用A*算法的启发式搜索的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
#include <bits/stdc++.h>
using namespace std;

const int N = 3;  // N为八数码的边长

struct Board {
    int b[N][N];   // 存放八数码的矩阵
    int space;     // 存放0的位置
    int score;     // 估价函数的值
    int step;      // 当前的步数

    // 计算估价函数的值
    void calc_score() {
        int h = 0;
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                if (b[i][j] == 0) continue;
                int x = (b[i][j] - 1) / N;
                int y = (b[i][j] - 1) % N;
                h += abs(i - x) + abs(j - y);
            }
        }
        score = h + step;
    }

    bool operator < (const Board& o) const {
        return score > o.score;   // 优先队列的比较函数
    }
};

const int dx[] = {-1, 0, 1, 0};  // 用于计算空格的邻居位置
const int dy[] = {0, 1, 0, -1};

Board start, goal;
unordered_map<string, int> vis;   // 存放已经访问过的状态
priority_queue<Board> q;          // 优先队列

// 判断当前状态是否合法
bool is_valid(int x, int y) {
    return x >= 0 && x < N && y >= 0 && y < N;
}

// 计算当前状态的哈希值
string get_hash(const Board& b) {
    string res;
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < N; j++) {
            res += char(b.b[i][j] + '0');
        }
    }
    return res;
}

// A*算法
void A_star() {
    start.step = 0;
    start.calc_score();
    q.push(start);
    vis[get_hash(start)] = 1;

    while (!q.empty()) {
        Board u = q.top(); q.pop();
        if (u.score > vis[get_hash(u)]) continue;

        if (get_hash(u) == get_hash(goal)) {
            cout << u.step << endl;
            return;
        }

        int sx = u.space / N, sy = u.space % N;
        for (int i = 0; i < 4; i++) {
            int nx = sx + dx[i], ny = sy + dy[i];
            if (!is_valid(nx, ny)) continue;

            Board v = u;
            v.b[sx][sy] = v.b[nx][ny];
            v.b[nx][ny] = 0;
            v.space = nx * N + ny;
            v.step++;
            v.calc_score();

            if (vis[get_hash(v)] && v.score >= vis[get_hash(v)]) continue;
            q.push(v);
            vis[get_hash(v)] = v.score;
        }
    }
}

3.5.2 记忆化搜索

​ 记忆化搜索(Memoization Search),也称为备忘录方法,是一种优化技术,主要应用于递归算法中以避免重复计算相同问题。它是动态规划策略的一种实现形式,通过存储已解决子问题的结果来加速整体的计算过程。记忆化搜索通常用于优化那些具有重叠子问题特性的递归算法。

工作原理

记忆化搜索通过维护一个数据结构(如数组、哈希表等)来记录每个子问题的解(或部分解)。在递归过程中,每当需要求解一个子问题时,先检查这个子问题的解是否已经被计算并存储起来了: - 如果是,直接返回存储的结果,避免重复计算。 - 如果不是,按照正常递归流程求解,并将结果存入数据结构中以供后续使用。

应用场景

记忆化搜索适用于解决具有大量重叠子问题的递归问题,常见的应用场景包括: - 斐波那契数列计算 - 各类路径问题,如在网格中寻找从一点到另一点的最短路径 - 组合数学问题,如计算排列组合数 - 其他可以通过分治方法解决的问题,如矩阵链乘法、最长公共子序列

优点

  • 减少计算量:避免了对同一子问题的重复计算,显著提高了算法效率。
  • 简单易实现:只需要添加用于存储已解决子问题的数据结构,并在递归前后进行查找和存储操作。

缺点

  • 需要额外的存储空间:存储子问题解的数据结构会占用额外的空间。
  • 对于非重叠子问题的问题,记忆化可能不会带来效率的提升。

记忆化搜索是处理递归问题的一种有效手段,尤其是当问题具有显著的重叠子问题特性时。通过实现记忆化,可以将一些指数级时间复杂度的问题优化到多项式级别。

3.5.3 启发式搜索

​ 启发式搜索(Heuristic Search)是一种基于启发式信息来引导搜索方向的算法,目的是在复杂的搜索空间中高效地找到解决方案。它通过评估函数(也称为启发式函数)来估算从当前状态到目标状态的最优路径的成本,从而减少搜索范围,提高搜索效率。启发式搜索在许多领域,特别是在人工智能和解决优化问题中,有着广泛的应用。

启发式函数

​ 启发式函数h(n)为每个节点n估计从n到目标节点的最低成本。一个好的启发式函数可以显著减少搜索空间,使算法快速找到解决方案。启发式函数的选择取决于具体问题的特性,理想情况下应尽可能接近实际成本,但计算启发式函数本身也不应过于复杂。

常见的启发式搜索算法

  1. 贪心最佳优先搜索:该算法仅使用启发式信息h(n)来指导搜索,总是扩展估计离目标最近的节点。它不保证找到最优解,但搜索速度快。

  2. A*搜索算法:A算法使用g(n) + h(n)作为评估函数,其中g(n)是从起始节点到当前节点n的实际成本,h(n)是当前节点n到目标节点的估计成本。A算法在满足特定条件的启发式函数下能保证找到最优解。

  3. 迭代加深A(IDA)算法:IDA结合了深度优先搜索的空间效率和A的速度和准确性。它使用启发式函数来限制搜索深度,通过逐步增加成本限制来迭代搜索。

启发式搜索的应用

  • 路径规划:在地图上找到从一个位置到另一个位置的最短路径,如GPS导航系统。
  • 解谜和游戏:求解像是八数码(滑块谜题)、象棋和围棋等游戏的最优策略。
  • 约束满足问题:比如日程安排、资源分配等需要在多个约束条件下寻找解决方案的问题。
  • 机器学习:在特定的搜索空间中寻找最优模型参数。

启发式搜索的优缺点

  • 优点:通过利用问题特定的知识,启发式搜索能够在大搜索空间中高效地找到解决方案,尤其是当直接的搜索方法不可行时。
  • 缺点:启发式搜索的效果高度依赖于启发式函数的质量。如果启发式函数估计不准确,可能导致效率低下或找不到最优解。

​ 启发式搜索是解决复杂搜索问题的强大工具,能够提供比传统搜索算法更快、更可靠的解决方案,尤其在处理需要在巨大搜索空间中找到最优解的问题时。

下面是A*算法的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
#include <iostream>
#include <queue>
#include <vector>
#include <algorithm>
#include <cstring>
using namespace std;

const int MAX_N = 100;
const int INF = 1e9;
const int dx[4] = {0, 0, 1, -1};
const int dy[4] = {1, -1, 0, 0};

struct State {
    int x, y, cost;
    bool operator < (const State& other) const {
        return cost > other.cost;
    }
};

int N, M;
int sx, sy, gx, gy;
char maze[MAX_N][MAX_N];
int d[MAX_N][MAX_N];

int astar() {
    priority_queue<State> pq;
    memset(d, INF, sizeof(d));
    pq.push({sx, sy, 0});
    d[sx][sy] = 0;

    while (!pq.empty()) {
        State s = pq.top();
        pq.pop();
        if (s.x == gx && s.y == gy) return s.cost;

        for (int i = 0; i < 4; i++) {
            int nx = s.x + dx[i], ny = s.y + dy[i];
            if (nx >= 0 && nx < N && ny >= 0 && ny < M && maze[nx][ny] != '#' &&
                d[nx][ny] > s.cost + 1) {
                int heuristic = abs(nx - gx) + abs(ny - gy);  // 曼哈顿距离
                pq.push({nx, ny, s.cost + 1 + heuristic});
                d[nx][ny] = s.cost + 1;
            }
        }
    }
    return -1;
}

int main() {
    cin >> N >> M;
    for (int i = 0; i < N; i++) {
        for (int j = 0; j < M; j++) {
            cin >> maze[i][j];
            if (maze[i][j] == 'S') {
                sx = i;
                sy = j;
            } else if (maze[i][j] == 'G') {
                gx = i;
                gy = j;
            }
        }
    }

    int ans = astar();
    cout << ans << endl;
    return 0;
}

​ 该代码使用了优先队列,每次从队列中取出最小的状态,并扩展出所有相邻的状态,计算它们的代价,然后将其加入优先队列中。每个状态的代价是由其已经走的步数和到目标点的曼哈顿距离(即横向距离加上纵向距离)组成的。

​ 该算法的时间复杂度是 \(O(E\log E)\),其中 \(E\) 是状态的数量,即矩阵的大小。实际上,当矩阵很大时,这个时间复杂度是非常高的,因为需要计算每个状态到目标点的曼哈顿距离。但是,如果采用一些优化,比如使用双向A*算法,或者使用启发式搜索来加快计算距离的速度,那么可以大大提高算法的效率。

3.5.4 双向宽度优先搜索

​ 双向宽度优先搜索(Bidirectional Breadth-First Search,简称Bi-BFS)是一种用于在图中查找最短路径的搜索算法。它通过同时从起点和终点进行两个宽度优先搜索(BFS),直到两个搜索在中间某处相遇。相比于传统的单向BFS,双向BFS在很多情况下能够减少搜索空间,从而提高搜索效率。

算法步骤

  1. 初始化:从起点开始的搜索称为正向搜索,从终点开始的搜索称为反向搜索。为每个搜索维护一个队列和一个访问过的节点集合。

  2. 交替执行:交替进行正向搜索和反向搜索,每次从队列中取出一个节点,探索其未访问的相邻节点,然后将这些节点加入队列。

  3. 检测相遇:在每次扩展节点时检查当前节点是否已经被对方搜索过(即检查当前节点是否在对方的访问过的节点集合中)。如果是,说明找到了连接起点和终点的路径。

  4. 构建路径:一旦两个搜索相遇,即可通过回溯访问过的节点集合构建从起点到终点的最短路径。

特点和优势

  • 时间效率:在最佳情况下,双向BFS的时间复杂度接近于O(b(d/2)),其中b是分支因子,d是起点到终点的距离。相比单向BFS的O(bd),双向BFS在很多场景下能显著减少搜索所需的时间。

  • 空间效率:双向BFS同样能减少所需的空间,因为它在任何时刻只需要维护从两端出发到当前深度的所有节点。

  • 适用性:双向BFS适用于任何可以使用单向BFS解决的问题,特别是在搜索空间很大,但起点与终点之间的实际距离较小的情况下更有效。

注意事项

  • 适用条件:双向BFS适用于无向图或双向同等开销的有向图中搜索最短路径。
  • 实现复杂度:相比于单向BFS,双向BFS在实现上更为复杂,需要同时维护两个搜索方向的状态。
  • 路径重建:在搜索相遇时,需要从两个方向重建路径,这可能需要额外的逻辑来确保路径的正确连接。

​ 双向宽度优先搜索通过减少搜索深度来加速搜索过程,特别适用于大规模图中的最短路径搜索问题。然而,它的成功高度依赖于搜索空间的结构和起点与终点之间的实际距离。

​ 以下是双向 BFS 的 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 <queue>
#include <unordered_map>

using namespace std;

int bidirectional_bfs(unordered_map<int, int>& dict1, unordered_map<int, int>& dict2, int target) {
    queue<int> q1, q2;
    q1.push(0);
    q2.push(target);
    dict1[0] = 0;
    dict2[target] = 0;
    int steps = 0;
    while (!q1.empty() && !q2.empty()) {
        ++steps;
        if (q1.size() > q2.size()) {
            swap(q1, q2);
            swap(dict1, dict2);
        }
        int n = q1.size();
        for (int i = 0; i < n; ++i) {
            int cur = q1.front();
            q1.pop();
            for (int j = 1; j <= 6; ++j) {
                int next = cur + j;
                if (next > target) break;
                if (dict2.count(next)) return steps;
                if (!dict1.count(next)) {
                    dict1[next] = steps;
                    q1.push(next);
                }
            }
        }
    }
    return -1;
}

int main() {
    int target = 30;
    unordered_map<int, int> dict1, dict2;
    cout << bidirectional_bfs(dict1, dict2, target) << endl;
    return 0;
}

​ 这里使用两个哈希表来分别记录从起点和终点出发的已访问状态。每次从状态数目较少的一端进行扩展,如果在另一个方向的哈希表中已经出现过当前状态,则说明找到了中间交叉点,返回搜索步数。

3.5.5 迭代加深搜索

​ 迭代加深搜索(Iterative Deepening Search,简称IDS或IDDFS)是一种结合了深度优先搜索(DFS)和广度优先搜索(BFS)优点的搜索算法。它通过限制深度的深度优先搜索来逐渐增加搜索深度,结合了DFS的空间效率和BFS找到最短路径的特性。迭代加深搜索特别适合于搜索空间未知或无限的情况。

算法步骤

  1. 初始化深度限制:从深度限制为0开始,逐渐增加深度限制,每次都从根节点重新开始搜索。

  2. 深度优先搜索:在当前深度限制下进行深度优先搜索。如果节点深度超过当前深度限制,则剪枝,不再对该节点的子节点进行搜索。

  3. 检查是否找到目标:在每一层的深度优先搜索中,如果找到目标节点,则停止搜索并返回结果。

  4. 增加深度限制:如果在当前深度限制下没有找到目标节点,增加深度限制,然后重复步骤2。

特点

  • 空间效率:由于使用了深度优先搜索,迭代加深搜索的空间效率很高,只需要存储一条从根节点到当前节点的路径。
  • 最优性:迭代加深搜索可以保证找到最短路径(如果路径的代价是均匀的)。
  • 时间复杂度:虽然每次增加深度限制都会重新开始搜索,但迭代加深搜索的总时间复杂度仍然与广度优先搜索相似,为O(b^d),其中b是分支因子,d是解的深度。

应用

​ 迭代加深搜索适用于目标节点所在深度未知的搜索问题,尤其是在解的深度相对较浅时效果最好。它经常被用于解决路径搜索问题、解谜游戏、以及在人工智能领域的各类决策制定问题。

注意事项

​ 虽然迭代加深搜索在许多情况下是一个高效的选择,但是它可能会在一些深度很大或分支因子很大的问题上表现不佳。在实际应用中,选择合适的搜索算法需要根据问题的特性和需求来决定。

以下是迭代加深搜索的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
#include <iostream>
#include <cstring>
using namespace std;

const int MAXN = 100; // 最大状态数
int N; // N代表状态中的某个参数,具体含义根据实际情况而定
bool vis[MAXN]; // vis[i] 表示状态 i 是否被搜索过

// 深度优先搜索函数,返回值为是否搜到目标状态
bool dfs(int u, int depth, int target) {
    if (depth == 0) return u == target; // 达到最大深度
    vis[u] = true;
    bool res = false;
    // 枚举 u 的所有邻接状态
    for (int i = 0; i < N; i++) {
        if (!vis[i]) { // 状态 i 没有被搜索过
            // 这里需要根据实际情况来判断状态 u 和状态 i 是否连通
            if (状态 u 和状态 i 连通) {
                res = dfs(i, depth - 1, target);
                if (res) break;
            }
        }
    }
    vis[u] = false; // 回溯,标记状态 u 为未搜索过
    return res;
}

// 迭代加深搜索函数,返回值为是否搜到目标状态
bool iddfs(int start, int target, int max_depth) {
    // 枚举深度
    for (int depth = 0; depth <= max_depth; depth++) {
        memset(vis, false, sizeof(vis)); // 每次搜索前需要初始化 vis 数组
        if (dfs(start, depth, target)) return true;
    }
    return false; // 搜索失败
}

int main() {
    // 这里需要读入初始状态和目标状态等参数,具体根据实际情况而定
    int start = 0, target = 0, max_depth = 10;
    bool res = iddfs(start, target, max_depth);
    if (res) cout << "Found!" << endl;
    else cout << "Not found!" << endl;
    return 0;
}

​ 以上代码中,dfs() 函数为深度优先搜索函数,iddfs() 函数为迭代加深搜索函数。在 dfs() 函数中,每次搜索到一个状态时,需要判断该状态是否已经被搜索过(即 vis 数组的值),并且需要根据实际情况判断该状态和当前状态是否连通,如果连通,则继续搜索该状态。

​ 在 iddfs() 函数中,首先枚举搜索深度 depth,然后每次调用 dfs() 函数时将 depth 减一,并且将当前状态标记为已搜索过。如果在某个深度下搜到了目标状态,则直接返回;否则,继续枚举下一个深度。如果枚举到最大深度 max_depth 还没有搜到目标状态,则搜索失败。

3.5.6 搜索对象的压缩存储

​ 搜索对象的压缩存储是指在搜索算法中对搜索对象进行压缩,以减小存储空间和提高搜索效率。在搜索算法中,搜索对象通常是状态或图结构,为了避免占用过多的内存空间,需要对其进行压缩存储。

​ 在状态搜索中,可以使用哈希表等数据结构进行状态压缩存储。具体做法是将状态转换为一个唯一的整数,然后将整数作为哈希表的键进行存储。在搜索过程中,可以通过哈希表快速查找状态,从而避免重复搜索。

​ 在图搜索中,可以使用邻接表等数据结构进行压缩存储。具体做法是将图结构转换为邻接表的形式,然后只保存必要的信息,如节点的出边和入边,以减小存储空间。

​ 压缩存储可以有效地减小存储空间,提高搜索效率。但是,压缩存储也会带来额外的开销,如哈希表的查找和更新操作。因此,在实际应用中需要权衡存储空间和搜索效率之间的关系,选择合适的存储方式。

​ 以下是搜索对象压缩存储的示例代码,假设我们要存储一个棋盘上的状态,其中有 \(N \times N\) 个位置,每个位置可以是空,也可以是放置了一个棋子。

​ 首先,我们可以用一个二维数组 board 来存储这个棋盘状态,其中 board[i][j] 表示棋盘上第 \(i\) 行第 \(j\) 列的位置。如果这个位置为空,我们可以用一个特殊的字符(比如 .)表示。如果这个位置放置了一个棋子,我们可以用另一个字符(比如 X)表示。

​ 那么对于一个 \(N \times N\) 的棋盘,我们需要 \(O(N^2)\) 的空间来存储。如果 \(N\) 很大,这个空间占用就会很大。

​ 为了减少空间占用,我们可以采用压缩存储的方式。一种常见的方式是采用位压缩,即用一个整数(比如 unsigned int)来表示一行的状态,其中每个二进制位表示该位置是否有棋子。具体实现如下:

C++
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const int MAXN = 100;
const int MAXN_BIT = 6;  // 假设 MAXN <= 64

unsigned int compress(int* row, int n) {
    unsigned int res = 0;
    for (int i = 0; i < n; i++) {
        if (row[i]) {
            res |= (1 << i);
        }
    }
    return res;
}

void decompress(unsigned int state, int* row, int n) {
    for (int i = 0; i < n; i++) {
        row[i] = (state >> i) & 1;
    }
}

​ 其中,compress 函数用来将一行棋盘状态压缩为一个整数,decompress 函数用来将一个整数解压为一行棋盘状态。具体的实现中,我们假设棋盘的大小不超过 \(64\),因此用一个 unsigned int 来存储一行状态,每个位置用一个二进制位表示是否有棋子。

​ 通过上述压缩方式,我们将一行棋盘状态从需要 \(O(N)\) 的空间压缩到了需要 \(O(1)\) 的空间。因此,整个棋盘的状态可以用一个 \(N\) 元组来表示,其中每个元素都是一个 unsigned int 类型的整数,表示该行的棋盘状态。这种压缩方式可以有效地减少空间占用。

3.6 图论算法

3.6.1 Prim和kruskal等求最小生成树算法

​ 在图论中,最小生成树(Minimum Spanning Tree, MST)是一个重要的概念,指的是在一个带权的无向连通图中选取的一棵权值最小的生成树。Prim算法和Kruskal算法是求解最小生成树问题的两个经典算法,它们从不同的策略出发,但都能有效地找到图的最小生成树。

Prim算法

Prim算法从某一顶点开始,逐渐长出最小生成树。它每次选择连接已选择顶点和未选择顶点权值最小的边,并将这条边以及它的顶点加入到最小生成树中,直到所有的顶点都被选取。

  • 时间复杂度:使用优先队列时,时间复杂度为O(E log V),其中V是顶点数,E是边数。
  • 特点:适合于稠密图,因为它是从顶点的角度出发来构造MST。

Kruskal算法

Kruskal算法从边出发,按权值从小到大的顺序考虑每条边,如果这条边的加入不会与已选择的边形成环,则选择这条边,直到选择了V-1条边为止(V是顶点数)。

  • 时间复杂度:主要耗时在于边的排序,时间复杂度为O(E log E)。使用并查集可以在近乎O(1)的时间内判断边的加入是否会形成环,因此总时间复杂度为O(E log E)。
  • 特点:适合于稀疏图,因为它是从边的角度出发来构造MST。

实现注意事项

  • 在实际实现时,Kruskal算法需要对所有边进行排序,然后使用并查集数据结构来维护各个顶点之间的连通性,以有效地检测环的出现。
  • Prim算法通常使用优先队列(最小堆)来选择当前可达的最小边,需要维护一个与顶点相对应的最小边权值数组以及一个标记数组来标记已经被选择到MST中的顶点。

示例应用场景

  • 网络设计:在设计电信网络、计算机网络时,常常需要构造最小生成树来确保总的建设成本最低。
  • 集群分析:在数据科学中,最小生成树可以用于数据点的集群分析,识别集群的自然分割。
  • 物理布局和电路设计:最小生成树可应用于电路板的布局设计,以最小化连接各个组件所需的材料长度。

​ Prim算法和Kruskal算法各有优缺点,适用于不同类型的图。选择哪一个算法取决于具体问题的性质,如图的稠密程度、是否需要频繁更新和重新计算MST等。

具体实现

​ Prim算法是基于贪心策略的算法。它从任意一个顶点开始,每次选择距离已经生成的树最近的顶点,并将该顶点加入生成树中,直到所有的顶点都被加入为止。

具体步骤如下:

  1. 随机选择一个点作为起点,将该点加入生成树中,同时将该点到其他点的距离存入优先队列(小根堆)中。
  2. 从优先队列中取出距离生成树最近的点,将其加入生成树中,并将其到其他点的距离存入优先队列中。如果优先队列中已经存在该点,更新该点到其他点的距离。
  3. 重复第二步,直到生成树中包含所有点。

下面是Prim算法的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
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

const int INF = 0x3f3f3f3f;

struct Edge {
    int to;
    int weight;
    Edge(int t, int w) : to(t), weight(w) {}
    bool operator<(const Edge& e) const {
        return weight > e.weight; // 小根堆
    }
};

vector<vector<Edge>> graph;

int prim(int start) {
    int n = graph.size();
    vector<int> dist(n, INF); // 存储到已生成树的距离
    vector<bool> visited(n, false); // 标记是否已加入生成树
    dist[start] = 0;
    priority_queue<Edge> pq;
    pq.push(Edge(start, 0));
    int result = 0;
    while (!pq.empty()) {
        int u = pq.top().to;
        pq.pop();
        if (visited[u]) {
            continue;
        }
        visited[u] = true;
        result += dist[u];
        for (auto& e : graph[u]) {
            int v = e.to;
            int w = e.weight;
            if (!visited[v] && w < dist[v]) {
                dist[v] = w;
                pq.push(Edge(v, w));
            }
        }
    }
    return result;
}

int main() {
    int n, m;
    cin >> n >> m;
    graph.resize(n);
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        graph[u].push_back(Edge(v, w));
        graph[v].push_back(Edge(u, w));
    }
    int result = prim(0);
    cout << result << endl;
    return 0;
}

​ Kruskal算法是另一种求解最小生成树的贪心算法。它的基本思路是:将图中的所有边按照权值从小到大排序,然后按照从小到大的顺序依次选取边,如果选取的边不会形成环,则将其加入最小生成树中,否则抛弃该边。

Kruskal算法的具体步骤如下:

  1. 将图中的所有边按照权值从小到大排序;
  2. 创建一个并查集,用于判断选取的边是否形成环;
  3. 依次选取排序后的边,如果该边的两个端点不在同一个集合中,则将其加入最小生成树中,否则舍弃该边;
  4. 重复步骤3,直到选取的边数达到n-1个,其中n为图中的节点数。

下面是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
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
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

// 定义边结构体
struct Edge {
    int from; // 边的起点
    int to; // 边的终点
    int weight; // 边的权值
    // 重载小于运算符,用于sort函数排序
    bool operator<(const Edge& other) const {
        return weight < other.weight;
    }
};

const int MAX_N = 1000; // 最大节点数
int parent[MAX_N]; // 记录节点的父节点
int rank[MAX_N]; // 记录节点的深度,用于优化查找操作

// 查找节点的祖先节点
int find(int x) {
    if (parent[x] != x) {
        parent[x] = find(parent[x]); // 路径压缩优化
    }
    return parent[x];
}

// 合并两个节点所在的集合
void merge(int x, int y) {
    int px = find(x), py = find(y);
    if (px != py) {
        if (rank[px] < rank[py]) {
            swap(px, py);
        }
        parent[py] = px;
        if (rank[px] == rank[py]) {
            rank[px]++;
        }
    }
}

// Kruskal算法求最小生成树
int kruskal(vector<Edge>& edges, int n) {
    // 初始化节点的父节点和深度
    for (int i = 0; i < n; i++) {
        parent[i] = i;
        rank[i] = 0;
    }

    int mst_weight = 0; // 最小生成树的权值和
    int num_edges = 0; // 已选取的边的数量

    sort(edges.begin(), edges.end()); // 将边按权值排序

    for (Edge e : edges) {
        if (num_edges == n - 1) { // 边数达到n-1,已形成一棵树
            break;
        }
        if (find(e.from) != find(e.to)) { // 判断是否形成环
            merge(e.from, e.to); // 合并两个节点
            mst_weight += e.weight; // 累加边权值
            num_edges++; // 已选取的边数+1
        }
    }

    return mst_weight;
}

int main() {
    int n, m; // n:节点数,m:边数
    cin >> n >> m;

    vector<Edge> edges(m); // 存储所有的边

    for (int i = 0; i < m; i++) {
        int from, to, weight;
        cin >> from >> to >> weight;
        edges[i] = {from, to, weight}; // 将边存入vector中
    }

    int mst_weight = kruskal(edges, n); // 计算最小生成树的权值和

    cout << mst_weight << endl;

    return 0;
}

3.6.2 求次小生成树算法

​ 次小生成树(Second Minimum Spanning Tree)指的是在一个加权无向连通图中,权值第二小的生成树。求解次小生成树的问题可以在找到最小生成树(MST)的基础上进行。算法的基本思路是首先找到最小生成树,然后尝试通过替换最小生成树中的边以找到次小的生成树。

算法步骤

  1. 找到最小生成树:使用Prim算法或Kruskal算法找到图的最小生成树。

  2. 寻找次小生成树

  3. 遍历原图中不在最小生成树中的每一条边(设为边E)。
  4. 对于每条这样的边,添加到最小生成树中会形成一个环。在这个环中找到一条权重最大的边(不是边E),移除这条边,添加边E,形成一个新的生成树。
  5. 计算新生成树的总权重,记录最小的那个作为次小生成树。

关键点

  • 环路的识别:在最小生成树中添加一条不属于MST的边后,必定会形成一个环。这个环中除了新增的那条边,其他边都属于最小生成树。
  • 替换边:在环中找到除了新增边之外权重最大的边,这条边是可能被替换来尝试形成次小生成树的边。

示例应用

  • 网络冗余:在设计网络时,除了最小成本的连接方案外,还需要一个备份方案来保证网络的稳定。次小生成树可以作为这样的备份方案。

实现注意事项

  • 在实现时,需要有效地识别和处理环。这通常可以通过在找到最小生成树的过程中记录下来每条边连接的两个顶点之间的路径,从而快速找到环。
  • 确定哪条边是环中权重最大的边,可以在构造最小生成树时就顺便完成,比如记录每个节点到达路径上的最大边权重。

时间复杂度

  • 此算法的时间复杂度主要由寻找最小生成树的复杂度和遍历所有非树边构造次小生成树的复杂度两部分构成。假设使用Kruskal算法或Prim算法寻找MST的时间复杂度为O(E log V),遍历所有非树边构造次小生成树的复杂度为O(E),所以总的时间复杂度为O(E log V)。

​ 求次小生成树是图论和网络设计中的一个重要问题,尤其是在需要设计具有一定冗余性的网络时。通过寻找次小生成树,可以在不显著增加成本的前提下,为网络提供额外的稳定性和可靠性。

常见算法

​ 求次小生成树是在一张无向图中,求解除去最小生成树之后,权值次小的生成树。常见的算法有两种:Kruskal算法变种和Prim算法变种。

Kruskal算法变种:

​ 首先按照Kruskal算法求出最小生成树,然后对每一条边进行判断,如果该边两端点不在同一棵树中,那么将这条边加入生成树,并记录其权值,直到加入一条边后,该生成树的边数等于点数减一。然后对记录下来的边按照权值排序,然后对每条边进行判断,如果该边两端点在同一棵树中,那么忽略这条边;否则,将这条边的权值加入到次小生成树的权值之中,然后将这条边加入次小生成树,直到加入的边数等于点数减一或者所有的边都被判断完毕。

以下是一个基于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
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
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 105, MAXM = 10005;

struct Edge {
    int u, v, w;
    bool operator<(const Edge& e) const {
        return w < e.w;
    }
} edges[MAXM];

int n, m;
int fa[MAXN];

int find(int x) {
    if (x == fa[x]) return x;
    return fa[x] = find(fa[x]);
}

int kruskal() {
    for (int i = 1; i <= n; i++) fa[i] = i;

    sort(edges, edges + m);

    int ans = 0, cnt = 0, minw = INT_MAX;

    for (int i = 0; i < m; i++) {
        int u = edges[i].u, v = edges[i].v, w = edges[i].w;
        int fu = find(u), fv = find(v);
        if (fu != fv) {
            ans += w;
            fa[fu] = fv;
            cnt++;
            if (cnt == n - 1) break;
        }
    }

    for (int i = 0; i < m; i++) {
        int u = edges[i].u, v = edges[i].v, w = edges[i].w;
        for (int j = 1; j <= n; j++) fa[j] = j;
        int sum = 0, cnt = 0;
        for (int j = 0; j < m; j++) {
            if (i == j) continue;
            int fu = find(edges[j].u), fv = find(edges[j].v);
            if (fu != fv) {
                sum += edges[j].w;
                fa[fu] = fv;
                cnt++;
                if (cnt == n - 1) break;
            }
        }
        if (cnt == n - 1 && sum < minw && find(u) != find(v)) minw = sum + w;
    }

    return minw == INT_MAX ? -1 : minw;
}

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

    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        edges[i] = {u, v, w};
    }

    int ans = kruskal();

    cout << ans << endl;

    return 0;
}

​ 这里使用Kruskal算法求解最小生成树,并在求解过程中记录边权最小的未被使用的边的权值,同时在每次枚举到一条边之后重新构建并查集求解当前边权次小的生成树。最后输出记录的次小边权即可。

​ Prim算法的变种主要有两种:Prim-Dijkstra算法和Prim-SL算法。

​ Prim-Dijkstra算法:在Prim算法的基础上,引入了堆优化的Dijkstra算法的思想,使用堆来存储候选边,每次取出堆顶元素(即最小的候选边),并更新与该边相连的点的候选边。这样可以有效减少Prim算法的时间复杂度。

​ Prim-SL算法:在Prim算法的基础上,引入了空间和时间双重优化。具体来说,该算法维护两个集合:T表示当前生成树,S表示T的补图(即所有未加入T的点)。算法的核心思想是,每次从S中选择一个离T最近的点,将其加入T,并更新其邻接点的最短距离。同时,将S中的点分为两个部分:外部点和内部点。外部点表示离T最近的点的邻接点,而内部点则表示离T最近的点之前加入T的点的邻接点。这样,每次加入一个点时,只需要更新外部点的距离即可,大大减少了时间和空间复杂度。

​ 下面是Prim-Dijkstra算法和Prim-SL算法的C++代码实现(假设图用邻接矩阵表示):

Prim-Dijkstra算法的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
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int INF = 0x3f3f3f3f;

int prim_dijkstra(int n, vector<vector<int>>& graph) {
    int res = 0;
    vector<int> dist(n, INF);  // 存储当前点到最小生成树的距离
    vector<bool> used(n, false);  // 存储当前点是否已加入最小生成树
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> q;  // 存储候选边的堆

    dist[0] = 0;
    q.push(make_pair(0, 0));  // 将起点加入候选边

    while (!q.empty()) {
        pair<int, int> p = q.top(); q.pop();
        int v = p.second;
        if (used[v]) continue;  // 已加入最小生成树,跳过

        used[v] = true;
        res += p.first;  // 将该边加入最小生成树
        for (int i = 0; i < n; ++i) {
            if (!used[i] && graph[v][i] != INF && graph[v][i] < dist[i]) {
                dist[i] = graph[v][i];
                q.push(make_pair(dist[i], i));  // 将候选边加入堆
            }
        }
    }

    return res;
}

Prim-SL算法的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
#include <iostream>
#include <vector>
#include <queue>
#include <cstring>
using namespace std;

const int INF = 0x3f3f3f3f; // 无穷大

int n, m; // n个点,m条边
vector<pair<int, int>> edges[1001]; // 邻接表存图
int vis[1001]; // 标记点是否在U集合中
int dist[1001]; // 存储U到各个点的最短距离

struct Node {
    int u, d; // u表示点的编号,d表示该点到U的最短距离
    bool operator < (const Node& other) const {
        return d > other.d; // 按最短距离从小到大排序
    }
};

// Prim-SL算法
int prim() {
    memset(vis, 0, sizeof(vis));
    memset(dist, 0x3f, sizeof(dist)); // 初始化为无穷大
    dist[1] = 0; // 选取1号点为起点
    priority_queue<Node> q; // 用优先队列维护到U的最短距离
    q.push({1, 0}); // 初始将1号点加入U集合
    int res = 0; // 存储最小生成树的边权和
    while (!q.empty()) {
        Node node = q.top();
        q.pop();
        int u = node.u;
        if (vis[u]) continue; // 如果该点已经在U集合中,则跳过
        vis[u] = 1; // 标记该点已在U集合中
        res += node.d; // 将该点与U集合的边权加入最小生成树的边权和中
        for (int i = 0; i < edges[u].size(); i++) {
            int v = edges[u][i].first;
            int w = edges[u][i].second;
            if (!vis[v] && w < dist[v]) {
                dist[v] = w;
                q.push({v, w});
            }
        }
    }
    return res;
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        edges[u].push_back({v, w});
        edges[v].push_back({u, w});
    }
    int res = prim();
    cout << res << endl;
    return 0;
}

3.6.3 Dijkstra、bellman_ford、SPFA等求单源最短路算法

​ 单源最短路径问题是图论中的一个经典问题,旨在找到从一个给定的源顶点到图中所有其他顶点的最短路径。针对这个问题,有几种不同的算法,适用于不同类型的图和特定的应用场景。以下是几种常见的单源最短路径算法:

Dijkstra算法

  • 描述:Dijkstra算法适用于带有非负权重的图。它使用贪心策略逐步扩展最短路径树,直到找到目标顶点的最短路径。
  • 时间复杂度:使用优先队列优化后的Dijkstra算法的时间复杂度为O((V+E) log V),其中V是顶点数,E是边数。
  • 特点:不能处理有负权边的图,因为负权边可能导致算法陷入无限循环。

Bellman-Ford算法

  • 描述:Bellman-Ford算法可以处理带有负权边的图,能够检测图中是否存在负权环。
  • 时间复杂度:算法的时间复杂度为O(VE)。
  • 特点:虽然比Dijkstra算法更慢,但它适用范围更广,可以处理负权边,并且能检测出负权环。

SPFA算法(Shortest Path Faster Algorithm)

  • 描述:SPFA是Bellman-Ford算法的一种改进。它使用队列来优化Bellman-Ford算法,减少不必要的边松弛操作。
  • 时间复杂度:在最坏情况下,SPFA算法的时间复杂度仍然是O(VE),但在一般情况下,其表现要优于Bellman-Ford算法,平均时间复杂度接近O(E)。
  • 特点:SPFA算法在实际应用中通常运行得很快,尽管在理论上最坏情况下的时间复杂度与Bellman-Ford相同。

应用场景

  • Dijkstra算法:适用于路由算法、导航系统等需要在非负权图中快速找到最短路径的应用。
  • Bellman-Ford算法:在金融模型、网络流量分配等领域,需要处理负权边或检测负权环时使用。
  • SPFA算法:由于其在实践中的良好表现,适用于需要处理负权边但又希望有较好性能的场景。

选择算法的考虑因素

​ 选择哪种单源最短路径算法取决于几个因素,包括图中是否有负权边、算法的性能要求以及实现的复杂度等。在没有负权边的情况下,Dijkstra算法通常是首选,因为它提供了最好的性能。在有负权边的情况下,可以选择Bellman-Ford算法或SPFA算法。

算法实现

以下是Dijkstra算法的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
#include <iostream>
#include <queue>
#include <vector>
#include <cstring>

using namespace std;

const int INF = 0x3f3f3f3f; //定义无穷大
const int maxn = 1000; //定义最大顶点数

struct node {
    int to; //边的目标节点
    int w;  //边的权值
};

vector<node> G[maxn]; //使用邻接表存储图
int dis[maxn]; //存储源点到各点的最短距离
bool vis[maxn]; //标记各点是否已经确定最短距离
int n, m, s; //n表示节点数,m表示边数,s表示源点

void dijkstra() {
    memset(dis, INF, sizeof(dis)); //初始化dis数组为INF
    dis[s] = 0; //源点到自己的距离为0
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
    //小根堆pq存储每个节点以及当前到源点的最短距离
    pq.push(make_pair(dis[s], s));
    while (!pq.empty()) {
        int u = pq.top().second; //堆顶为距离最小的节点
        pq.pop();
        if (vis[u]) continue; //该节点已经确定最短距离
        vis[u] = true; //标记该节点已经确定最短距离
        for (int i = 0; i < G[u].size(); i++) {
            int v = G[u][i].to; //v为u的后继节点
            int w = G[u][i].w; //u到v的边权值
            if (!vis[v] && dis[v] > dis[u] + w) { //若v未确定最短距离,且从u到v的距离更短
                dis[v] = dis[u] + w; //更新源点到v的最短距离
                pq.push(make_pair(dis[v], v)); //加入到小根堆中
            }
        }
    }
}

int main() {
    cin >> n >> m >> s;
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        G[u].push_back({v, w}); //建图
    }
    dijkstra();
    for (int i = 1; i <= n; i++) {
        cout << dis[i] << " "; //输出源点到每个节点的最短距离
    }
    return 0;
}

下面是bellman_ford算法的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
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;

const int INF = 0x3f3f3f3f; // 定义无穷大

struct Edge {
    int from, to, weight;
};

int n, m; // n个点,m条边
vector<Edge> edges; // 存储所有边的信息
int dist[1005]; // 存储源点到各个点的最短距离

void bellman_ford(int s) {
    memset(dist, INF, sizeof(dist)); // 初始化距离为无穷大
    dist[s] = 0; // 源点到自己的距离为0

    for (int i = 1; i < n; i++) { // 执行n-1轮松弛操作
        for (auto& e : edges) { // 遍历所有边
            if (dist[e.from] != INF && dist[e.to] > dist[e.from] + e.weight) { // 松弛操作
                dist[e.to] = dist[e.from] + e.weight;
            }
        }
    }

    // 检查是否存在负环路
    for (auto& e : edges) {
        if (dist[e.from] != INF && dist[e.to] > dist[e.from] + e.weight) {
            cout << "存在负环路!" << endl;
            return;
        }
    }
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int from, to, weight;
        cin >> from >> to >> weight;
        edges.push_back({from, to, weight});
    }

    int s; // 源点编号
    cin >> s;

    bellman_ford(s);

    for (int i = 1; i <= n; i++) {
        cout << dist[i] << " ";
    }
    cout << endl;

    return 0;
}

​ 该算法的时间复杂度为\(O(nm)\),其中\(n\)为点数,\(m\)为边数。需要注意的是,如果存在负环路,则最短路不存在。在上面的代码中,我们通过检查所有边,判断是否存在负环路。如果存在,则输出提示信息,并结束程序。

以下是 SPFA(Shortest Path Faster Algorithm)算法的 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
#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;
const int MAXN = 1005;

int n, m;
int head[MAXN], dist[MAXN], vis[MAXN], cnt[MAXN];

struct Edge {
    int to, next, w;
} edge[MAXN * MAXN];

void add_edge(int u, int v, int w) {
    edge[++m].to = v;
    edge[m].next = head[u];
    edge[m].w = w;
    head[u] = m;
}

bool spfa(int s) {
    memset(dist, INF, sizeof(dist));
    memset(vis, 0, sizeof(vis));
    memset(cnt, 0, sizeof(cnt));
    queue<int> q;
    dist[s] = 0;
    vis[s] = 1;
    cnt[s] = 1;
    q.push(s);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        vis[u] = 0;
        for (int i = head[u]; i; i = edge[i].next) {
            int v = edge[i].to;
            int w = edge[i].w;
            if (dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                if (!vis[v]) {
                    q.push(v);
                    vis[v] = 1;
                    cnt[v]++;
                    if (cnt[v] >= n) return false;
                }
            }
        }
    }
    return true;
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        add_edge(u, v, w);
        add_edge(v, u, w); // 无向图,反向边
    }
    if (spfa(1)) {
        for (int i = 2; i <= n; i++) {
            cout << dist[i] << " ";
        }
        cout << endl;
    } else {
        cout << "存在负环" << endl;
    }
    return 0;
}

注释如下:

  • INF 表示一个很大的数,这里取 \(0x3f3f3f3f\)
  • MAXN 表示图中最多有多少个节点。
  • head 数组存储每个节点的第一条边,值为该边在 edge 数组中的下标。
  • dist 数组存储起点到各个节点的最短距离。
  • vis 数组标记节点是否在队列中。
  • cnt 数组表示每个节点进队列的次数。
  • Edge 结构体表示一条边,包含终点 to、下一条边的下标 next 和权值 w
  • add_edge 函数用于向图中添加一条边。
  • spfa 函数实现 SPFA 算法,参数 s 表示起点,返回值为 bool 类型,表示图中是否存在负环。

3.6.4 求单源次短路径算法

​ 求单源次短路径问题是指给定一个带权有向图以及起点 \(s\),求出从 \(s\) 出发到达其他各个顶点的次短路径(即权值仅次于最短路径的路径)。

​ 在 Dijkstra 算法的基础上,维护一个次小距离和次小距离路径,同时利用堆优化来实现。

具体思路

  1. 对于每个点,维护 \(dis1\) 表示到起点 \(s\) 的最短距离,\(dis2\) 表示到起点 \(s\) 的次短距离,\(p1\) 表示到 \(s\) 的最短路径,\(p2\) 表示到 \(s\) 的次短路径。
  2. 初始时,\(dis1\) 全部赋值为 \(\infty\)\(dis2\) 全部赋值为 \(\infty\)\(p1\)\(p2\) 均为空。
  3. 每次选取堆中 \(dis1\) 最小的点 \(u\) 进行松弛操作,如果松弛后 \(dis1[v]\) 更新,则 \(dis2[v]=dis1[v]\)\(p2[v]=p1[v]\),同时将 \(v\) 加入堆中。如果 \(dis1[v]\) 不更新,但松弛后 \(dis2[v]\) 更新,则将 \(p1[v]\)\(p2[v]\) 合并到 \(p2[v]\) 中。
  4. 最终,\(dis2\) 数组中存储的就是从 \(s\) 出发到达各个顶点的次短路径长度。

时间复杂度\(O((m+n)\log n)\)

以下是Dijkstra算法+堆优化的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
#include <iostream>
#include <vector>
#include <queue>
#include <cstring>
using namespace std;

const int INF = 0x3f3f3f3f;

struct Edge {
    int to, w;
    Edge(int _to, int _w) : to(_to), w(_w) {}
};

vector<Edge> G[10005]; // 邻接表存图
int d[10005];          // 最短路数组
bool vis[10005];       // 是否已经确定最短路
int n, m;              // 点数、边数

void dijkstra(int s) {
    memset(d, 0x3f, sizeof(d)); // 初始化为极大值,防止被更新
    memset(vis, false, sizeof(vis));
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
    // 小根堆,第一维为到源点的距离,第二维为顶点编号
    d[s] = 0;
    pq.push(make_pair(d[s], s));
    while (!pq.empty()) {
        int v = pq.top().second; // 取出堆顶顶点编号
        pq.pop();
        if (vis[v]) continue; // 如果已经确定最短路,则继续下一次循环
        vis[v] = true;        // 标记为已经确定最短路
        for (auto e : G[v]) { // 遍历该顶点的所有邻接边
            if (d[e.to] > d[v] + e.w) {
                d[e.to] = d[v] + e.w; // 更新最短路
                pq.push(make_pair(d[e.to], e.to));
            }
        }
    }
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        G[u].push_back(Edge(v, w));
        G[v].push_back(Edge(u, w));
    }
    dijkstra(1);
    cout << d[n] << endl;
    return 0;
}

注:该代码中用了C++11的新特性,需要编译时加上参数 -std=c++11

3.6.5 Floyd-Warshall算法求任意两点间的最短路和传递闭包

​ Floyd-Warshall算法是用于求任意两点之间的最短路的一种算法,同时也可以用于计算传递闭包。该算法采用动态规划的思想,其时间复杂度为 \(O(n^3)\)

​ Floyd-Warshall算法的核心思想是对于每一对顶点 \((i, j)\),尝试将其经过顶点 \(k\) 连接,判断是否能够获得更短的路径。具体而言,设 \(dist[i][j]\) 表示从顶点 \(i\) 到顶点 \(j\) 的最短路径长度,算法过程如下:

  1. 初始化:\(dist[i][j]=w_{ij}\),其中 \(w_{ij}\) 表示顶点 \(i\) 到顶点 \(j\) 的边权值,若不存在该边,则为无穷大。

  2. 对于每一个顶点 \(k\),尝试将顶点 \(i\) 到顶点 \(j\) 的路径中间插入顶点 \(k\),更新 \(dist[i][j]\) 的值:

\(dist[i][j]\) = min(\(dist[i][j]\) + \(dist[i][k]\) +\(dist[k][j]\))

  1. 算法结束后,\(dist[i][j]\) 的值即为顶点 \(i\) 到顶点 \(j\) 的最短路径长度。

在计算传递闭包时,若存在一条从顶点 \(i\) 到顶点 \(j\) 的路径,则将 \(closure[i][j]\) 置为 1。具体而言,设 \(closure[i][j]\) 表示从顶点 \(i\) 是否可以到达顶点 \(j\),算法过程如下:

  1. 初始化:\(closure[i][j]=1\),若存在边 \((i,j)\),否则 \(closure[i][j]=0\)
  2. 对于每一个顶点 \(k\),若顶点 \(i\) 到顶点 \(j\) 的路径经过顶点 \(k\),则将 \(closure[i][j]\) 置为 1。

最终,\(closure[i][j]\) 的值即为顶点 \(i\) 是否可以到达顶点 \(j\)

以下是Floyd-Warshall算法的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
const int INF = 0x3f3f3f3f;

int dist[MAX_V][MAX_V]; // 距离矩阵
int n; // 顶点数

// Floyd-Warshall算法求所有点对之间的最短路
void floyd_warshall() {
    for (int k = 1; k <= n; ++k) {
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (dist[i][k] != INF && dist[k][j] != INF) {
                    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
                }
            }
        }
    }
}

// 判断有向图中是否存在负环
bool has_negative_cycle() {
    for (int i = 1; i <= n; ++i) {
        if (dist[i][i] < 0) {
            return true;
        }
    }
    return false;
}

其中,dist是一个二维数组,表示任意两点之间的距离,n为顶点数,INF表示正无穷。函数floyd_warshall用于求所有点对之间的最短路,函数has_negative_cycle用于判断有向图中是否存在负环。

3.6.6 有向无环图的拓扑排序算法

​ 拓扑排序是指将有向无环图(DAG)中所有的节点排成一个线性序列,使得对于任意一对节点 u 和 v,如果存在一条从 u 到 v 的有向边,那么在序列中 u 必须出现在 v 的前面。可以理解为将 DAG 中的节点“排序”。

拓扑排序的算法流程如下:

  1. 统计所有节点的入度(即有多少条边指向该节点),初始入度为 0 的节点加入一个队列;
  2. 取出队首节点,将该节点加入结果序列中,并将该节点指向的所有节点入度减 1;
  3. 如果入度减为 0,将该节点加入队列;
  4. 重复 2、3 步骤,直到队列为空。

拓扑排序算法的时间复杂度为 O(n+m),其中 n 为节点数,m 为边数。

下面是一个实现拓扑排序的 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
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int MAXN = 1000;
int N, M; // N表示图中的节点数,M表示图中的边数
vector<int> G[MAXN+1]; // 邻接表存储图
int in_degree[MAXN+1]; // 记录每个节点的入度
vector<int> topological_order; // 记录拓扑排序结果

void topological_sort()
{
    // 初始化入度数组
    for(int i = 1; i <= N; i++)
        in_degree[i] = 0;
    for(int i = 1; i <= N; i++)
        for(int j = 0; j < G[i].size(); j++)
            in_degree[G[i][j]]++;

    // 利用优先队列实现拓扑排序
    priority_queue<int, vector<int>, greater<int>> pq;
    for(int i = 1; i <= N; i++)
        if(in_degree[i] == 0)
            pq.push(i);
    while(!pq.empty())
    {
        int u = pq.top();
        pq.pop();
        topological_order.push_back(u);
        for(int i = 0; i < G[u].size(); i++)
        {
            int v = G[u][i];
            in_degree[v]--;
            if(in_degree[v] == 0)
                pq.push(v);
        }
    }
}

int main()
{
    // 读入图中的节点数和边数
    cin >> N >> M;
    // 读入图中的边
    for(int i = 0; i < M; i++)
    {
        int u, v;
        cin >> u >> v;
        G[u].push_back(v);
    }
    // 对图进行拓扑排序
    topological_sort();
    // 输出拓扑排序结果
    for(int i = 0; i < topological_order.size(); i++)
        cout << topological_order[i] << " ";
    cout << endl;
    return 0;
}

3.6.7 求欧拉道路和欧拉回路算法

​ 欧拉道路和欧拉回路是图论中常见的概念。欧拉道路是指经过图中每条边恰好一次的路径,而欧拉回路则是指经过图中每条边恰好一次的回路(起点和终点重合)。

​ Hierholzer算法是一种求解欧拉回路和欧拉道路的经典算法,时间复杂度为\(O(m)\)。以下是该算法的步骤:

  1. 任选一个点作为起点,随意选择一个与该点相邻的点,并走过这条边。
  2. 如果这个点还有未走过的边,则重复步骤1;否则将该点加入到一个栈中,并返回到上一个点。
  3. 如果上一个点还有未走过的边,则重复步骤1;否则将该点加入到栈中,并返回到上一个点。
  4. 重复步骤3,直到返回到起点。
  5. 栈中的点就是欧拉回路的路径。

​ 需要注意的是,如果图中存在欧拉道路,我们只需保证起点和终点的度数分别为奇数和偶数即可。如果图中不存在欧拉回路或欧拉道路,则算法无法求解。

以下是使用 Hierholzer 算法求欧拉回路的 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
#include <iostream>
#include <vector>
#include <stack>

using namespace std;

void dfs(vector<vector<int>>& graph, int cur, vector<bool>& visited, stack<int>& stk) {
    visited[cur] = true;
    for (int i = 0; i < graph[cur].size(); ++i) {
        int next = graph[cur][i];
        if (!visited[next]) {
            dfs(graph, next, visited, stk);
        }
    }
    stk.push(cur);
}

vector<int> findEulerPath(vector<vector<int>>& graph, int start) {
    vector<int> res;
    if (graph.empty()) return res;

    // 统计每个节点的入度和出度
    vector<int> inDegree(graph.size()), outDegree(graph.size());
    for (int i = 0; i < graph.size(); ++i) {
        for (int j = 0; j < graph[i].size(); ++j) {
            int next = graph[i][j];
            ++outDegree[i];
            ++inDegree[next];
        }
    }

    // 判断是否存在欧拉回路
    int numStartNodes = 0, numEndNodes = 0;
    for (int i = 0; i < graph.size(); ++i) {
        if (inDegree[i] == outDegree[i]) {
            continue;
        } else if (inDegree[i] == outDegree[i] + 1) {
            ++numEndNodes;
            start = i;
        } else if (inDegree[i] + 1 == outDegree[i]) {
            ++numStartNodes;
        } else {
            return res;
        }
    }
    if (numStartNodes > 1 || numEndNodes > 1) {
        return res;
    }

    // Hierholzer算法求欧拉回路
    stack<int> stk;
    vector<bool> visited(graph.size(), false);
    dfs(graph, start, visited, stk);
    while (!stk.empty()) {
        int cur = stk.top();
        stk.pop();
        res.push_back(cur);
    }
    reverse(res.begin(), res.end());
    return res;
}

其中 findEulerPath 函数接受邻接表形式的图和起始节点 start,返回欧拉回路的节点序列。如果给定的图不存在欧拉回路,则返回空序列。

​ Fleury算法是求解欧拉道路和欧拉回路的一种经典算法,其基本思想是贪心地选择一个尽可能“独立”的边进行扩展,直到无法扩展为止。

具体实现如下:

  1. 从任意一个顶点开始,进行深度优先搜索(DFS);
  2. 每次选择一条未被访问过的边进行扩展,如果这条边不是桥边(从连通图中删除后,图不再连通),则选择该边,否则跳过;
  3. 如果无法选择边进行扩展,则回溯到上一个顶点,并退回到上一个顶点的搜索状态,重新开始选择边进行扩展,直到搜索完成。
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
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int N = 510;

int n, m;
int g[N][N];  // 存储图的邻接矩阵
int degree[N];  // 存储每个点的度数
bool st[N];  // 存储每个点是否已经被遍历

vector<int> ans;  // 存储欧拉回路/欧拉道路

void dfs(int u)
{
    for (int v = 1; v <= n; v ++ )
        if (g[u][v] && !st[g[u][v]])
        {
            st[g[u][v]] = true;  // 标记边已经被访问
            dfs(v);
        }
    ans.push_back(u);  // 将节点加入路径中
}

bool check()
{
    int cnt = 0;
    for (int i = 1; i <= n; i ++ )
        if (degree[i] & 1)
            cnt ++ ;
    if (cnt == 0 || cnt == 2) return true;
    return false;
}

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

    for (int i = 1; i <= m; i ++ )
    {
        int a, b;
        cin >> a >> b;
        g[a][b] = g[b][a] = i;  // 存储无向图的边编号
        degree[a] ++ , degree[b] ++ ;  // 更新每个点的度数
    }

    if (!check()) puts("0");  // 如果不满足欧拉回路/欧拉道路的条件,输出0
    else
    {
        for (int i = 1; i <= n; i ++ )  // 枚举每个连通块
            if (degree[i] & 1 || i == 1)  // 找到一个奇点或者是起点
            {
                dfs(i);
                break;
            }

        reverse(ans.begin(), ans.end());  // 由于我们是从起点开始遍历的,所以需要将路径翻转一下

        for (int i = 0; i < ans.size() - 1; i ++ )  // 判断路径中的每一条边是否是欧拉边
        {
            int a = ans[i], b = ans[i + 1];
            if (g[a][b]) printf("%d ", g[a][b]);  // 如果是,输出这条边的编号
        }
    }

    return 0;
}

3.6.8 二分图的构造及其判定算法

​ 二分图是一种特殊的图,它的所有顶点可以被分成两组,使得同组中的点之间没有边相连,不同组中的点之间只有横跨两组的边相连。二分图的判定和构造问题是图论中的经典问题,常常用于实际应用中,如社交网络中的好友推荐和广告投放等。

1、二分图的判定

二分图的判定可以用染色法来实现。具体做法如下:

  1. 将任意一个顶点染成红色。
  2. 将与这个顶点相邻的顶点染成蓝色。
  3. 将与这些蓝色顶点相邻的顶点染成红色。
  4. 重复步骤2和3,直到所有的顶点都被染色。
  5. 判断染色是否合法。如果所有的边都连接了不同颜色的两个顶点,则这个图是二分图;否则不是二分图。

​ 具体实现时,可以使用广度优先搜索(BFS)或深度优先搜索(DFS)来完成染色过程。在BFS或DFS的遍历过程中,使用一个颜色数组来记录每个顶点的颜色,初始值设为0表示未染色,1表示红色,-1表示蓝色。遍历到一个顶点时,将其染成与相邻顶点颜色不同的颜色。如果相邻顶点已经被染成了同样的颜色,则说明这个图不是二分图。

2、二分图的构造

  1. 常见的二分图构造方法之一是将图中的点划分成两组,然后在两组之间随机连边,直到得到一个满足条件的二分图。这种方法的缺点是无法保证构造出来的二分图是最大的。
  2. 另一种常见的二分图构造方法是使用匈牙利算法(也叫二分图最大匹配算法),它可以找到二分图的最大匹配,并且可以构造出一个完美匹配。具体做法是从一个点开始,依次遍历与其相邻的点,如果这些点还没有被匹配,则将它们和这个点匹配,如果已经被匹配,则尝试和匹配它的点匹配。如果成功匹配,则增加匹配数。如果无法匹配,则重新选择一个未匹配的点,重复上述过程,直到所有点都被匹配为止。
  3. 另外还有一些特殊的二分图,可以使用特殊的构造方法来构造。

  4. 基于染色法的构造方法

​ 染色法是一种简单有效的二分图构造方法,其基本思想是给图中的每个顶点染上黑色或白色两种颜色,使得同一颜色的顶点之间没有边相连,不同颜色的顶点之间有边相连。

具体实现方法如下:

​ 从任意一个未染色的顶点开始,将其染成黑色,并将其所有邻居染成白色。

​ 对于所有未染色的顶点,重复上述步骤,直到所有顶点都被染色为止。

​ 如果在染色的过程中发现某个顶点的染色冲突,即和其邻居颜色相同,则说明该图不是二分图。

代码示例:

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

const int MAXN = 100;  // 最大顶点数

int n, m;  // 顶点数和边数
vector<int> G[MAXN];  // 邻接表存储图
int color[MAXN];  // 存储每个顶点的颜色(1 或 -1)

// 使用染色法判断图是否为二分图
bool isBipartite() {
    // 初始化所有顶点的颜色为0
    for (int i = 0; i < n; i++) {
        color[i] = 0;
    }
    // 从每个未染色的顶点开始染色
    for (int i = 0; i < n; i++) {
        if (color[i] == 0) {
            color[i] = 1;
            queue<int> q;
            q.push(i);
            while (!q.empty()) {
                int u = q.front();
                q.pop();
                for (int j = 0; j < G[u].size(); j++) {
                    int v = G[u][j];
                    if (color[v] == 0) {
                        color[v] = -color[u];
                        q.push(v);
                    } else if (color[v] == color[u]) {
                        return false;  // 颜色冲突,不是二分图
                    }
                }
            }
        }
    }
    return true;
}

int main() {
    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 (isBipartite())
        cout << "是二分图";
    else
        cout << "不是二分图";
    return 0;
}

3.6.9 最近公共祖先

​ 最近公共祖先(Lowest Common Ancestor,简称LCA)是指在一颗有根树上,节点p和q的LCA是指一个最深的节点,它同时是p和q的祖先节点。LCA问题常常在计算机科学中用于解决树的相关问题。

常见的LCA算法有:

  1. 倍增算法
  2. 树剖算法
  3. Tarjan算法

这些算法都有各自的特点和适用场景。以下是倍增算法和树剖算法的简介和示例代码。

1.倍增算法

​ 倍增算法的核心思想是先对每个节点预处理出它往上走 \(2^k\) 步所能到达的祖先节点,然后在查询时利用二进制拆分,快速跳到两个节点所在的深度相同的位置,然后往上跳直到两个节点重合,这个重合点就是它们的LCA。

下面是使用倍增算法求最近公共祖先的 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
const int MAXN = 100005;  // 最大结点数
const int MAXLOG = 20;    // log2(MAXN) + 1
int n;                    // 结点数
int fa[MAXN][MAXLOG];     // fa[i][j] 表示 i 的 2^j 级祖先

// 建树(以 1 为根节点)
void buildTree() {
    // ...
}

// 预处理 fa 数组
void preProcess() {
    for (int j = 1; j < MAXLOG; j++) {
        for (int i = 1; i <= n; i++) {
            if (fa[i][j - 1] != -1) {
                fa[i][j] = fa[fa[i][j - 1]][j - 1];
            }
        }
    }
}

// 查询 u 和 v 的最近公共祖先
int LCA(int u, int v) {
    // 将 u 和 v 向上跳到同一深度
    if (dep[u] < dep[v]) {
        swap(u, v);
    }
    int diff = dep[u] - dep[v];
    for (int j = 0; j < MAXLOG; j++) {
        if (diff & (1 << j)) {
            u = fa[u][j];
        }
    }
    // 如果此时 u = v,则直接返回
    if (u == v) {
        return u;
    }
    // 一起向上跳
    for (int j = MAXLOG - 1; j >= 0; j--) {
        if (fa[u][j] != fa[v][j]) {
            u = fa[u][j];
            v = fa[v][j];
        }
    }
    // 返回它们的父亲节点
    return fa[u][0];
}

int main() {
    // 读入 n 和建树
    // ...
    // 初始化 fa 数组
    memset(fa, -1, sizeof(fa));
    // 预处理 fa 数组
    preProcess();
    // 查询最近公共祖先
    int u, v;
    while (cin >> u >> v) {
        cout << LCA(u, v) << endl;
    }
    return 0;
}

​ 其中,dep 数组表示每个节点的深度。buildTree 函数用于建树,preProcess 函数用于预处理 fa 数组,LCA 函数用于查询两个节点的最近公共祖先。

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

const int MAXN = 100005;
int n, q, root = 1, cnt, head[MAXN], dep[MAXN], fa[MAXN], sz[MAXN], son[MAXN], top[MAXN], dfn[MAXN], end[MAXN];
vector<int> G[MAXN];

void addEdge(int u, int v) {
    G[u].push_back(v);
    G[v].push_back(u);
}

void dfs1(int u, int p) {
    dep[u] = dep[p] + 1;
    fa[u] = p;
    sz[u] = 1;
    for (int i = 0; i < G[u].size(); i++) {
        int v = G[u][i];
        if (v != p) {
            dfs1(v, u);
            sz[u] += sz[v];
            if (sz[v] > sz[son[u]]) {
                son[u] = v;
            }
        }
    }
}

void dfs2(int u, int p) {
    dfn[u] = ++cnt;
    end[cnt] = u;
    if (!top[u]) {
        top[u] = u;
    }
    if (son[u]) {
        top[son[u]] = top[u];
        dfs2(son[u], u);
        for (int i = 0; i < G[u].size(); i++) {
            int v = G[u][i];
            if (v != p && v != son[u]) {
                dfs2(v, u);
            }
        }
    }
}

int lca(int u, int v) {
    while (top[u] != top[v]) {
        if (dep[top[u]] < dep[top[v]]) {
            swap(u, v);
        }
        u = fa[top[u]];
    }
    return dep[u] < dep[v] ? u : v;
}

int main() {
    cin >> n >> q;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        addEdge(u, v);
    }
    dfs1(root, 0);
    dfs2(root, 0);
    for (int i = 1; i <= q; i++) {
        int u, v;
        cin >> u >> v;
        cout << lca(u, v) << endl;
    }
    return 0;
}

​ 在上述代码中,我们首先定义了全局变量n、q、root,分别表示树的节点数、询问数和根节点编号。然后,我们定义了一个邻接表G,用于存储树的边信息。在读入树的边信息后,我们通过dfs1函数预处理出树的一些基本信息,包括深度dep、父亲节点fa、子树大小sz和重儿子son。接下来,我们通过dfs2函数实现树剖算法,预处理出每个节点的dfn、end、重链顶部top和节点所在的重链。最后,在主函数中,我们对每个询问使用lca函数求出对应的最近公共祖先节点编号。

3.6.10 求强联通分量算法

​ 求强联通分量是指在有向图中,如果存在一条从顶点 \(u\) 到顶点 \(v\) 的路径,则也一定存在一条从顶点 \(v\) 到顶点 \(u\) 的路径,那么顶点 \(u\) 和顶点 \(v\) 强联通。

常用的求解强联通分量的算法包括 Tarjan 算法和 Kosaraju 算法,下面介绍这两种算法的原理和实现。

1.Tarjan算法

​ Tarjan 算法采用深度优先搜索遍历整个图,在搜索的过程中对每个节点进行标记,用“时间戳”(时间戳是指在遍历中第几个被访问到的节点)记录每个节点第一次被访问的时间戳 \(dfn_i\) 和可以回溯到的最早的时间戳 \(low_i\)。如果 \(low_i=dfn_i\),则该节点及其后继节点构成一个强连通分量。

​ 具体实现中,需要使用一个栈记录搜索的路径,每次访问一个节点时,先将该节点入栈,并将时间戳设为当前时间戳。然后遍历该节点的后继节点,如果后继节点没有被访问,则继续对该节点进行深度优先搜索,如果后继节点已经被访问过并且还在栈中,那么更新该节点的 \(low\) 值为该节点和后继节点的 \(dfn\) 值中的最小值。如果后继节点已经被访问过并且不在栈中,那么该节点和后继节点之间的路径不包含环,因此更新该节点的 \(low\) 值为后继节点的 \(dfn\) 值。

​ 最后,当节点的 \(low\) 值等于 \(dfn\) 值时,从该节点开始,一直出栈到当前节点,并将这些节点作为一个强连通分量。具体来说,如果出栈的节点是 \(u\),则将 \(u\) 标记为当前强连通分量的编号,并将所有出栈的节点加入该强连通分量中。

以下是Tarjan算法的C++代码实现,用于寻找无向图中的强连通分量(SCC):

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

const int MAXN = 1e5 + 5;

vector<int> adj[MAXN]; // 邻接表存图
stack<int> stk; // Tarjan算法中的栈
bool in_stk[MAXN]; // 记录某个节点是否在栈中
int dfn[MAXN]; // 某个节点的访问时间戳
int low[MAXN]; // 某个节点能到达的最小时间戳
int scc_id[MAXN]; // 某个节点所属的强连通分量的编号
int scc_cnt = 0; // 当前已找到的强连通分量个数
int timestamp = 0; // 时间戳

void tarjan(int u) {
    dfn[u] = low[u] = ++timestamp;
    stk.push(u);
    in_stk[u] = true;
    for (int v : adj[u]) {
        if (!dfn[v]) { // 如果节点v还没被访问过
            tarjan(v); // 继续往下递归
            low[u] = min(low[u], low[v]);
        } else if (in_stk[v]) { // 如果节点v已经被访问过且在栈中
            low[u] = min(low[u], dfn[v]);
        }
    }
    // 如果节点u是某个强连通分量的根节点
    if (dfn[u] == low[u]) {
        int v;
        ++scc_cnt;
        do {
            v = stk.top();
            stk.pop();
            in_stk[v] = false;
            scc_id[v] = scc_cnt;
        } while (v != u);
    }
}

int main() {
    // 读入无向图
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    // 从任意一个未被访问过的节点出发
    for (int i = 1; i <= n; ++i) {
        if (!dfn[i]) {
            tarjan(i);
        }
    }

    // 输出每个节点所属的强连通分量的编号
    for (int i = 1; i <= n; ++i) {
        cout << "Node " << i << " belongs to SCC " << scc_id[i] << endl;
    }

    return 0;
}

以上代码实现了Tarjan算法,可以在O(n + m)的时间内找到无向图中的所有强连通分量。

2.Kosaraju算法

​ Kosaraju 算法包括两个步骤:首先,对原图进行一次深度优先搜索,得到每个节点的出度为0的反图;然后,对反图进行深度优先搜索,得到每个节点的强连通分量。

​ 具体实现中,首先对原图进行深度优先搜索,得到所有节点的完成时间。然后构造反图,对反图进行深度优先搜索,根据节点的完成时间顺序,从后往前搜索得到强连通分量。

​ 下面是Kosaraju算法的C++实现代码,主要包括两个函数,dfs和kosaraju函数。其中dfs函数是进行深度优先遍历的函数,kosaraju函数则是Kosaraju算法的主函数,用于求解强联通分量。

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

const int MAXN = 100005;

vector<int> graph[MAXN];
vector<int> reverse_graph[MAXN];
bool visited[MAXN];
stack<int> order;  // 用于存储dfs遍历的顺序
int scc[MAXN];     // 用于存储每个点所属的强联通分量编号
int scc_count = 0; // 记录强联通分量的数量

// 深度优先遍历函数
void dfs(int u) {
    visited[u] = true;
    for (int i = 0; i < graph[u].size(); i++) {
        int v = graph[u][i];
        if (!visited[v]) {
            dfs(v);
        }
    }
    order.push(u);
}

// Kosaraju算法函数,求解强联通分量
void kosaraju(int n) {
    // 第一遍dfs,将节点按遍历顺序入栈
    for (int i = 1; i <= n; i++) {
        if (!visited[i]) {
            dfs(i);
        }
    }

    // 初始化visited数组
    for (int i = 1; i <= n; i++) {
        visited[i] = false;
    }

    // 第二遍dfs,倒序从栈中取出节点进行遍历,得到强联通分量
    while (!order.empty()) {
        int u = order.top();
        order.pop();
        if (!visited[u]) {
            scc_count++;
            stack<int> component;
            component.push(u);
            visited[u] = true;
            while (!component.empty()) {
                int v = component.top();
                component.pop();
                scc[v] = scc_count;
                for (int i = 0; i < reverse_graph[v].size(); i++) {
                    int w = reverse_graph[v][i];
                    if (!visited[w]) {
                        component.push(w);
                        visited[w] = true;
                    }
                }
            }
        }
    }
}

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

    // 读入有向图
    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        reverse_graph[v].push_back(u);
    }

    // 求解强联通分量
    kosaraju(n);

    // 输出每个点所属的强联通分量编号
    for (int i = 1; i <= n; i++) {
        cout << i << " " << scc[i] << endl;
    }

    return 0;
}

​ 以上是Kosaraju算法的C++实现代码,使用时可以直接输入有向图的节点数和边数,然后逐条输入边的起点和终点。最后输出每个节点所属的强联通分量编号即可。

3.6.11 强连通分量的缩点算法

​ 强连通分量的缩点算法(Tarjan算法的改进版)是一种常用的图算法,用于将有向图中的强连通分量缩成一个点,得到一个新的DAG。它的时间复杂度为 \(O(n+m)\)。下面是该算法的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
#include <iostream>
#include <vector>
#include <stack>
#include <algorithm>
using namespace std;

const int MAXN = 1005;

int n, m, cnt, scc_cnt;
int head[MAXN], low[MAXN], dfn[MAXN], scc[MAXN], in_degree[MAXN];
bool vis[MAXN];
stack<int> st;

struct Edge {
    int v, next;
} edge[MAXN * MAXN];

void add_edge(int u, int v) {
    edge[cnt].v = v;
    edge[cnt].next = head[u];
    head[u] = cnt++;
}

void tarjan(int u) {
    low[u] = dfn[u] = ++cnt;
    vis[u] = true;
    st.push(u);

    for (int i = head[u]; i != -1; i = edge[i].next) {
        int v = edge[i].v;

        if (!dfn[v]) {
            tarjan(v);
            low[u] = min(low[u], low[v]);
        } else if (vis[v]) {
            low[u] = min(low[u], dfn[v]);
        }
    }

    if (dfn[u] == low[u]) {
        scc_cnt++;
        int v;

        do {
            v = st.top();
            st.pop();
            scc[v] = scc_cnt;
            vis[v] = false;
        } while (u != v);
    }
}

void solve() {
    for (int i = 1; i <= n; i++) {
        if (!dfn[i]) {
            tarjan(i);
        }
    }

    for (int u = 1; u <= n; u++) {
        for (int i = head[u]; i != -1; i = edge[i].next) {
            int v = edge[i].v;

            if (scc[u] != scc[v]) {
                in_degree[scc[v]]++;
            }
        }
    }

    int ans = 0;

    for (int i = 1; i <= scc_cnt; i++) {
        if (!in_degree[i]) {
            ans++;
        }
    }

    cout << ans << endl;
}

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

    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        add_edge(u, v);
    }

    solve();

    return 0;
}

​ 这里使用了Tarjan算法求出强连通分量,然后遍历每个强连通分量的点,把指向其他强连通分量的边的入度加一。最后统计入度为0的强连通分量的数量即为缩点后的DAG中的起始节点数量。

3.6.12 求割点、割边算法

求割点、割边是图论中的一个重要问题,下面分别介绍两个算法。

1.求割点算法

​ 求割点的算法一般采用深度优先搜索(DFS)实现。具体来说,对于无向图 \(G=(V,E)\),定义 \(dfn[u]\) 为结点 \(u\) 第一次被访问到的时间,\(low[u]\) 表示 \(u\) 能够到达的结点在 DFS 树上的最小 \(dfn\) 值,\(fa[u]\) 表示 \(u\) 在 DFS 树上的父结点,\(cnt\) 表示 DFS 树上的结点数量。

求割点的步骤如下:

  1. 初始化 \(dfn\)\(low\) 数组为 \(0\),令 \(cnt=0\)
  2. 从一个未被访问的结点 \(u\) 开始进行 DFS,每访问到一个新结点 \(v\),令 \(dfn[v]=low[v]=++cnt\)
  3. 对于结点 \(v\) 的每个子结点 \(w\),如果 \(w\) 没有被访问过,就将其作为起点进行 DFS,并更新 \(low[v]=\min{low[v], low[w]}\)
  4. 如果 \(w\) 已经被访问过,且 \(w\) 不是 \(v\) 的父结点,那么更新 \(low[v]=\min{low[v], dfn[w]}\)
  5. 如果 \(v\) 是根结点,判断其是否有两个或以上的子结点,如果有,则 \(v\) 是割点。否则如果结点 \(w\) 满足 \(dfn[w]>dfn[v]\)\(low[w]\ge dfn[v]\),则 \(v\) 是割点。
  6. 重复步骤 2 到 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
const int MAXN = 10005;

vector<int> G[MAXN];
int dfn[MAXN], low[MAXN], fa[MAXN], cnt;

void tarjan(int u) {
    dfn[u] = low[u] = ++cnt;
    int child = 0;
    for (int i = 0; i < G[u].size(); ++i) {
        int v = G[u][i];
        if (!dfn[v]) {
            ++child;
            fa[v] = u;
            tarjan(v);
            low[u] = min(low[u], low[v]);
            if (fa[u] == 0 && child >= 2) {
                printf("%d is cut vertex\n", u);
            } else if (fa[u] != 0 && low[v] >= dfn[u]) {
                printf("%d is cut vertex\n", u);
            }
        } else if (v != fa[u]) {
            low[u] = min(low[u], dfn[v]);
        }
    }
}

2.求割边算法

​ 求割边算法,也称为桥(Bridge)问题,是指在无向连通图中,删除一条边之后,该图不再连通,则称这条边为割边或桥。

​ 求解割边问题的基本思路是利用 DFS 遍历整张图,以边为搜索基础,每次搜到一条边时,判断该边连接的两个节点是否在同一 DFS 树中。如果是,判断是否为树边;如果不是,则说明找到了一条连接不同 DFS 树的边,即为割边。

​ 具体实现时,可以利用 DFS 过程中记录每个节点的时间戳(即第一次访问该节点的时间),以及每个节点能够回溯到的最早的时间戳(即它所能够到达的节点中最小的时间戳),这样就可以判断每条边是否为割边。

下面是求解割边的 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
#include <iostream>
#include <vector>

using namespace std;

const int N = 100010;

vector<int> g[N];
int dfn[N], low[N], timestamp;

void dfs(int u, int p)
{
    dfn[u] = low[u] = ++timestamp;
    for (auto v : g[u]) {
        if (!dfn[v]) {
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            if (low[v] > dfn[u]) cout << u << " - " << v << " is a bridge\n";
        } else if (v != p) {
            low[u] = min(low[u], dfn[v]);
        }
    }
}

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

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

    for (int i = 1; i <= n; i++) {
        if (!dfn[i]) dfs(i, -1);
    }

    return 0;
}

​ 上述代码中,dfn[u] 表示节点 u 的时间戳,low[u] 表示节点 u 能够回溯到的最早的时间戳。在 DFS 遍历过程中,对于每个节点 u,首先将其时间戳和回溯时间戳都设为当前时间戳,并遍历 u 的所有邻居节点 v。如果节点 v 没有被访问过,就递归访问它,并更新 u 的回溯时间戳。如果回溯时间戳 low[v] 大于节点 u 的时间戳 dfn[u],则说明边 (u, v) 是一条割边。如果节点 v 已经被访问过,并且它不是节点 u 的父节点,那么就更新 u 的回溯时间戳。

3.7 动态规划

3.7.1 树型动态规划

​ 树型动态规划(Tree DP)是一种基于树型数据结构的动态规划算法,主要用于解决一些涉及树型结构的问题,如树的重心、树的直径、树的最长链、树的最大匹配等问题。该算法的核心思想是以节点为中心,递归计算节点的子树信息,进而推导出整棵树的信息。

常见的树型DP问题有:

  1. 求树的重心
  2. 求树的直径
  3. 求树的最长链
  4. 求树的最大匹配

等等。

​ 树型DP算法的核心思想是:对于树中的每个节点,将其当作一个状态,通过转移方程计算出该节点对应的状态值,并将该节点的状态值传递给其父节点,最终得到整棵树的状态值。在计算节点的状态值时,通常需要考虑该节点的子节点的状态值以及该节点与其父节点之间的关系。

​ 下面是一个树的重心的例子,介绍一下树型DP的具体实现过程。

​ 假设有一棵树,节点编号为 1~n,要求求出该树的重心,即将树划分为多个连通块,使得每个连通块中的最大子树大小最小。

​ 首先,考虑使用深度优先搜索(DFS)遍历树,计算每个节点的子树大小,并求出以每个节点为根的子树中,大小最大的子树的大小,即最大子树大小。

​ 定义 \(dp[i]\) 表示以节点 \(i\) 为根的子树中,大小最大的子树大小,\(size[i]\) 表示以节点 \(i\) 为根的子树大小,\(f[i]\) 表示以节点 \(i\) 为根的子树中,大小最大的子树的大小。

则可以得到转移方程:

\[ dp[i] = size[i] + \sum_{j \in son(i)} dp[j] \]

其中 \(son(i)\) 表示节点 \(i\) 的子节点集合,即 \(i\) 的所有儿子节点。

得到 \(dp[i]\) 后,可以遍历每个节点 \(i\),计算删掉该节点后,子树大小最大的子树的大小,即 \(f[i]\),具体地,对于每个节点 \(i\),计算

\[ f[i] = max({dp[j], size[1]-size[i]}), j \in son(i) \]

其中 \(size[1]\) 表示整棵树的大小,即根节点的子树大小。最终,树的重心为满足 \(f[i]\) 最小的节点 \(i\)

1.求树的重心

​ 树的重心是指树中的一个节点,如果将该节点删除后,剩余各个连通块中节点数的最大值最小,那么这个节点就是树的重心。

下面是树的重心的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 MAXN = 100010;

vector<int> g[MAXN];
int n, sz[MAXN], max_subtree[MAXN], ans = MAXN;

void dfs(int u, int fa) {
    sz[u] = 1;
    max_subtree[u] = 0;
    for (auto v : g[u]) {
        if (v != fa) {
            dfs(v, u);
            sz[u] += sz[v];
            max_subtree[u] = max(max_subtree[u], sz[v]);
        }
    }
    max_subtree[u] = max(max_subtree[u], n - sz[u]);
    ans = min(ans, max_subtree[u]);
}

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

    dfs(1, 0);

    cout << "The centroid of the tree is/are: ";
    for (int i = 1; i <= n; i++) {
        if (max_subtree[i] == ans) {
            cout << i << " ";
        }
    }

    return 0;
}

​ 该代码的时间复杂度为\(O(n)\),其中\(n\)是树的节点数。

2.求树的直径

​ 求树的直径的算法有两种,一种是基于树的深度优先搜索(DFS)的算法,另一种是基于树的广度优先搜索(BFS)的算法。

基于DFS的算法思路如下:

  1. 任选树上一个节点u,以它为起点进行一次深度优先遍历,找到离u最远的节点v。
  2. 以节点v为起点进行一次深度优先遍历,找到离v最远的节点w,那么v到w的距离就是树的直径。

基于BFS的算法思路如下:

  1. 任选树上一个节点u,以它为起点进行一次广度优先遍历,找到离u最远的节点v。
  2. 以节点v为起点进行一次广度优先遍历,找到离v最远的节点w,那么v到w的距离就是树的直径。

以下是基于 DFS 的求树直径的 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;

vector<int> g[N];
int d[N], f[N];

void dfs(int u, int fa)
{
    for (auto v : g[u])
    {
        if (v == fa) continue;
        dfs(v, u);
        if (d[v] + 1 > d[u]) d[u] = d[v] + 1, f[u] = v;
    }
}

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

    for (int i = 0; i < n - 1; i++)
    {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);
    }

    dfs(1, -1);

    int t = f[1];
    d[1] = 0;
    dfs(t, -1);

    cout << d[t] << endl;

    return 0;
}

​ 这里使用了一个 dfs 函数,它接受两个参数:当前遍历到的节点 u 和它的父节点 fa。在函数内部,我们遍历 u 的所有子节点,对每个子节点 v 递归调用 dfs 函数。同时,我们还要记录 u 到它的某个子孙节点的最大距离 d[u] 和这个子孙节点 f[u]。递归回溯时,我们更新 d[u] 和 f[u]。

​ 具体地,在遍历子节点 v 时,我们首先排除父节点 fa,然后递归调用 dfs 函数,得到 v 的 d[v] 和 f[v]。如果 d[v] + 1 大于 d[u],我们就更新 d[u] 和 f[u]。最后,我们通过 f[1] 找到树的直径的一端 t,再从 t 开始进行一次 dfs,得到树的直径长度 d[t]。

​ 时间复杂度为 O(n)。

以下是基于 BFS 的求树直径的 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
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

const int MAXN = 10005; // 最大结点数

vector<int> tree[MAXN]; // 存储树的邻接表表示
bool visited[MAXN]; // 标记结点是否被访问
int dis[MAXN]; // 存储每个结点到起点的距离

// 从指定起点开始进行 BFS,求出离起点最远的结点和距离
void bfs(int start) {
    queue<int> q;
    q.push(start);
    visited[start] = true;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int i = 0; i < tree[u].size(); i++) {
            int v = tree[u][i];
            if (!visited[v]) {
                visited[v] = true;
                dis[v] = dis[u] + 1;
                q.push(v);
            }
        }
    }
}

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

    // 读入树的边
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    // 从结点 1 开始进行 BFS,求出离结点 1 最远的结点和距离
    bfs(1);
    int max_dis = -1, max_node = 0;
    for (int i = 1; i <= n; i++) {
        if (dis[i] > max_dis) {
            max_dis = dis[i];
            max_node = i;
        }
        visited[i] = false;
        dis[i] = 0;
    }

    // 从离结点 1 最远的结点开始进行 BFS,求出树的直径
    bfs(max_node);
    max_dis = -1;
    for (int i = 1; i <= n; i++) {
        if (dis[i] > max_dis) {
            max_dis = dis[i];
        }
    }

    cout << max_dis << endl;
    return 0;
}

​ 该算法先从任意结点开始进行 BFS,求出离该结点最远的结点和距离,然后从该离开始结点再次进行 BFS,求出树的直径长度。

该算法的时间复杂度为 \(O(n)\),其中 \(n\) 是树的结点数。

3.求树的最长链

​ 求树的最长链(又称树的直径)是指在一棵无根树中,找到最长的一条简单路径,该路径可以看作是树中两个节点之间的距离。

求解树的最长链可以通过两次遍历树来实现:

  1. 以任意一个节点为起点,通过 DFS 或 BFS 遍历整棵树,找到离该节点最远的节点 u。
  2. 以 u 为起点,再次通过 DFS 或 BFS 遍历整棵树,找到离 u 最远的节点 v,即树的直径就是 u 到 v 的路径。

以下是基于 DFS 的求树的最长链的 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>
#include <algorithm>

using namespace std;

const int MAXN = 1e5 + 5;

vector<int> g[MAXN]; // 存储树
int ans = 0; // 最长链的长度
int farthest_node = 0; // 最远的节点

void dfs(int u, int fa, int dep) {
    if (dep > ans) { // 更新最长链的长度和最远的节点
        ans = dep;
        farthest_node = u;
    }
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u, dep + 1);
    }
}

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

    dfs(1, 0, 0); // 从任意节点开始搜索,更新最长链的长度和最远的节点

    ans = 0;
    dfs(farthest_node, 0, 0); // 从最远的节点开始搜索,得到最长链的长度

    cout << ans << endl;

    return 0;
}

​ 以上代码中,使用 vector<int> g[MAXN] 存储树的结构,使用 dfs 函数进行深度优先搜索。在搜索的过程中,记录当前节点到根节点的深度 dep,若当前深度比最长链的长度 ans 还要长,就更新 ans 和最远的节点 farthest_node。最后,从 farthest_node 开始再进行一次深度优先搜索,得到的深度即为树的最长链的长度。

下面是基于 BFS 的求树最长链的 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
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>

using namespace std;

const int N = 100010;

int n;
vector<int> g[N];   // 存储树的邻接表表示
bool vis[N];        // 标记节点是否已经被访问
int dist[N];        // 存储每个节点到树的根节点的距离
int start, _max;    // 记录最长链的起点和终点

void bfs(int u) {
    queue<int> q;
    q.push(u);
    dist[u] = 0;
    vis[u] = true;
    while (!q.empty()) {
        int t = q.front();
        q.pop();
        for (auto v : g[t]) {
            if (!vis[v]) {
                dist[v] = dist[t] + 1;
                vis[v] = true;
                q.push(v);
            }
        }
    }
}

int main() {
    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);
    }
    bfs(1); // 随便选取一个点为根节点,计算每个节点到根节点的距离
    _max = -1;
    for (int i = 1; i <= n; i++) {
        if (dist[i] > _max) {
            _max = dist[i];
            start = i;
        }
    }
    fill_n(vis, n + 1, false);
    fill_n(dist, n + 1, 0);
    bfs(start); // 从最长链的起点开始重新计算每个节点到根节点的距离
    _max = -1;
    for (int i = 1; i <= n; i++) {
        _max = max(_max, dist[i]);
    }
    cout << _max << endl;
    return 0;
}

​ 该算法的思路是首先任选一点为根节点,利用 BFS 计算每个节点到根节点的距离,然后找到距离最远的节点作为最长链的一个端点。接着从最长链的起点重新开始 BFS,计算每个节点到根节点的距离,最终距离的最大值即为最长链的长度。

4.求树的最大匹配

​ 求树的最大匹配可以使用树的匹配理论来解决。树的匹配理论主要有两个定理:一个是匹配的大小不会超过树的大小的一半,另一个是如果树上存在完美匹配,则该树的大小必须为偶数。因此,可以使用递归方法求解树的最大匹配。

​ 具体来说,可以使用贪心的策略:从根节点开始,按深度优先搜索的顺序依次处理每个节点,对于每个节点,先递归处理它的所有子节点,然后再对该节点进行匹配。

对于每个节点的匹配,可以分为两种情况:

  1. 匹配该节点和它的子节点的最大匹配:如果该节点没有子节点,则匹配为空;否则,将该节点的所有子节点两两匹配,得到子节点的最大匹配大小,然后将该节点与子节点的匹配数量相加,得到该节点的最大匹配大小。

  2. 不匹配该节点和它的子节点的最大匹配:将该节点不匹配,并将该节点的子节点递归匹配。

递归到叶子节点时,返回匹配大小为0的空匹配。最终,根节点的最大匹配大小即为所求。

以下是基于 DFS 求树的最大匹配的 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
#include <iostream>
#include <vector>
#include <cstring>

using namespace std;

const int MAXN = 1005;
vector<int> G[MAXN];
bool used[MAXN];
int match[MAXN];

bool dfs(int v) {
    used[v] = true;
    for (int i = 0; i < G[v].size(); i++) {
        int u = G[v][i], w = match[u];
        if (w < 0 || !used[w] && dfs(w)) {
            match[v] = u;
            match[u] = v;
            return true;
        }
    }
    return false;
}

int bipartite_matching(int n) {
    int res = 0;
    memset(match, -1, sizeof(match));
    for (int v = 0; v < n; v++) {
        if (match[v] < 0) {
            memset(used, 0, sizeof(used));
            if (dfs(v)) res++;
        }
    }
    return res;
}

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 + n);
        G[v + n].push_back(u);
    }
    int ans = bipartite_matching(n + m);
    cout << ans << endl;
    return 0;
}

​ 其中,G 为图的邻接表表示,match 记录每个点匹配的点的编号,如果某个点还没有匹配,则其对应的 match 值为 -1。函数 dfs 用于递归搜索增广路,并返回是否找到了增广路,函数 bipartite_matching 用于计算最大匹配数。

​ 在 main 函数中,先读入输入,然后将二分图转化为单向边的有向图,调用 bipartite_matching 函数求解最大匹配数,最后输出结果。

3.7.2 状态压缩动态规划

​ 状态压缩动态规划是一类常用于处理状态空间非常大的问题的动态规划算法。通常情况下,状态空间是指由一系列状态构成的集合,而状态压缩动态规划则是将每个状态用一个二进制数表示,从而将一个巨大的状态空间压缩成了一个较小的状态空间。在实现过程中,需要使用位运算技巧来处理状态的转移和计算。状态压缩动态规划广泛应用于图论、计算几何等领域,比如旅行商问题、背包问题等。

一般来说,状态压缩动态规划的基本流程如下:

  1. 设计状态:将每个状态用一个二进制数表示,其中二进制数的每一位表示一个状态。
  2. 初始化:初始化第一个状态,通常为0或1。
  3. 状态转移:用位运算技巧进行状态的转移,并更新每个状态的最优解。
  4. 输出答案:输出最终状态的最优解。

下面是一个状态压缩动态规划的例子,以0-1背包问题为例。

问题描述:

​ 有一个容量为C的背包和N个物品,每个物品有一个重量w[i]和一个价值v[i]。现在需要选择若干个物品放入背包中,使得它们的重量不超过背包容量,同时价值之和最大。请设计一个算法,求出最大价值。

具体代码如下:

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

const int N = 20;
int n, m;
int w[N], v[N];
int f[1 << N];

int main()
{
    cin >> n >> m;
    for (int i = 0; i < n; i ++ ) cin >> w[i] >> v[i];

    memset(f, 0xcf, sizeof f);
    f[0] = 0;

    for (int i = 0; i < n; i ++ )
        for (int j = (1 << m) - 1; j >= 0; j -- )
            for (int k = j; k; k = (k - 1) & j)
                if (w[i] <= k)
                    f[j] = max(f[j], f[j - k] + v[i]);

    cout << f[(1 << m) - 1] << endl;

    return 0;
}

​ 其中,\(f[i]\)表示当前状态为\(i\)时,所能得到的最大价值。由于0-1背包问题的状态只与前一个状态有关,因此可以使用滚动数组来优化空间。对于状态\(i\),枚举它的子集\(k\),然后更新状态\(f[i]\)即可。需要注意的是,在更新\(f[i]\)之前,要先判断当前物品是否能放入到背包中,即\(w[j]\leq k\)。同时,由于背包容量比较小,因此可以使用位运算来加速枚举子集的过程。

3.7.3 动态规划的常用优化

​ 动态规划是解决复杂问题的一种有效算法,但是在实际应用中,有些问题的状态转移方程可能比较复杂,导致算法的时间复杂度较高。因此,为了优化动态规划算法,常常采用以下几种方法:

  1. 状态压缩:对于状态空间比较大的问题,可以使用状态压缩技术来降低状态空间的维度,从而减少计算量。例如,在0-1背包问题中,可以使用位运算来表示状态,从而将二维数组优化为一维数组,减少空间复杂度。

  2. 记忆化搜索:在递归调用时,如果存在大量的重复计算,可以将计算结果存储下来,以便下一次直接查询结果,从而减少重复计算。例如,在斐波那契数列问题中,可以使用记忆化搜索来减少递归调用的次数。

  3. 贪心策略:在某些情况下,可以使用贪心策略来简化问题,从而减少计算量。例如,在活动选择问题中,可以按照结束时间对活动进行排序,然后依次选择结束时间最早的活动。

  4. 优化状态转移方程:对于状态转移方程比较复杂的问题,可以通过优化方程的形式来降低时间复杂度。例如,在最长公共子序列问题中,可以使用滚动数组来优化状态转移方程,从而减少空间复杂度。

  5. 剪枝:对于一些无用的状态,可以提前进行剪枝,从而减少计算量。例如,在旅行商问题中,可以通过计算当前路径的长度是否已经大于已知的最短路径长度来进行剪枝,从而减少不必要的计算。

  6. 降维打击:在状态空间比较大的问题中,可以通过将状态空间分组,然后对每组状态进行计算,最后再将结果合并,从而减少计算量。例如,在数位DP问题中,可以将数位划分为高位和低位,分别进行计算,然后将结果合并。

这些方法不仅可以优化动态规划算法,还可以应用于其他算法的优化中。