文章

数据结构算法高频总结一

数组和链表区别和优缺点

数组和链表是两种常见的数据结构,它们各有特点、优缺点和适用场景。


数组的特点

  1. 连续存储:数组在内存中占用一块连续的空间。
  2. 固定大小:数组在定义时需要指定大小,不能动态扩展(静态数组)。
  3. 随机访问:数组支持通过索引直接访问任意元素,时间复杂度为 O(1)。
  4. 元素类型一致:数组中的元素通常是相同的数据类型。

链表的特点

  1. 非连续存储:链表中的每个节点通过指针链接,节点可以存储在内存的任意位置。
  2. 动态大小:链表的长度可以根据需求动态增加或减少。
  3. 线性访问:链表只能从头节点逐一遍历到目标节点,时间复杂度为 O(n)。
  4. 节点包含指针:每个节点不仅存储数据,还包含指向下一个节点的指针(或前后节点的指针,视链表类型而定)。

数组的优点

  1. 快速随机访问:支持通过索引直接访问任意元素,效率高。
  2. 内存利用率高:数据紧密排列,没有额外的指针开销。
  3. 缓存友好:由于内存连续存储,访问时可利用 CPU 缓存提高效率。

数组的缺点

  1. 固定大小:无法动态调整长度(除非使用动态数组,如 C++ 的 std::vector )。
  2. 插入和删除效率低:需要移动大量元素,时间复杂度为 O(n)。
  3. 内存空间浪费:如果数组大小设置过大而实际使用不足,会浪费内存。

链表的优点

  1. 动态调整大小:链表可以根据需要灵活地增加或删除节点。
  2. 高效的插入和删除:在已知位置插入或删除元素,时间复杂度为 O(1)。
  3. 内存利用率灵活:节点分散存储,减少了因连续空间不足导致的内存浪费。

链表的缺点

  1. 访问效率低:只能从头节点依次遍历到目标节点,时间复杂度为 O(n)。
  2. 内存开销大:每个节点需要额外的指针存储,增加了内存消耗。
  3. 不缓存友好:节点分散存储,访问时无法充分利用 CPU 缓存。

适用场景

场景选择数组选择链表
数据大小固定,随机访问频繁
数据大小不固定,频繁插入/删除操作
内存连续性和缓存性能要求高
节约内存,不需要额外指针存储空间

总结

  • 数组适合于数据大小固定且访问频繁的场景,比如实现栈、队列或需要快速索引的场景。
  • 链表适合于数据大小不确定且需要频繁插入、删除操作的场景,比如动态集合、图的邻接表表示等。

选择数组还是链表,需要结合具体应用场景的需求来权衡性能与内存开销。

快速排序

快速排序(QuickSort)是一种高效的分治算法,用于对数组或列表进行排序。它的基本思想是通过选择一个“基准值(pivot)”,将数据分成两部分,一部分比基准值小,另一部分比基准值大,然后对这两部分分别递归地进行快速排序。


快速排序算法步骤

  1. 选择基准值
    • 通常选择数组的第一个元素、最后一个元素、或中间的元素作为基准值,也可以随机选择。
  2. 分区(Partition)
    • 将小于基准值的元素放在基准值的左边,大于基准值的元素放在右边。
    • 基准值归位后,左右两部分分别成为一个子问题。
  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
#include <iostream>
#include <vector>

void quickSort(std::vector<int>& arr, int low, int high);
int partition(std::vector<int>& arr, int low, int high);

void quickSort(std::vector<int>& arr, int low, int high) {
    if (low < high) {
        // 获取分区索引
        int pivotIndex = partition(arr, low, high);
        // 递归对左子数组排序
        quickSort(arr, low, pivotIndex - 1);
        // 递归对右子数组排序
        quickSort(arr, pivotIndex + 1, high);
    }
}

int partition(std::vector<int>& arr, int low, int high) {
    int pivot = arr[high]; // 选择最后一个元素作为基准
    int i = low - 1;       // i 指向小于基准值的区域末尾

    for (int j = low; j < high; ++j) {
        if (arr[j] <= pivot) {
            ++i;
            std::swap(arr[i], arr[j]); // 交换使得较小的元素靠左
        }
    }
    std::swap(arr[i + 1], arr[high]); // 将基准值放到正确位置
    return i + 1;                     // 返回基准值位置
}

int main() {
    std::vector<int> arr = {10, 7, 8, 9, 1, 5};
    int n = arr.size();

    std::cout << "Original array: ";
    for (int num : arr) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    quickSort(arr, 0, n - 1);

    std::cout << "Sorted array: ";
    for (int num : arr) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    return 0;
}

快速排序的时间复杂度

  1. 最优时间复杂度:

    $O(n \log n)$

    • 每次分区都均匀地将数组分成两部分。
  2. 最差时间复杂度:

    $O(n^2)$

    • 每次分区非常不均匀,例如选择最小或最大值作为基准(退化为冒泡排序)。
  3. 平均时间复杂度:$O(n \log n)$

快速排序的空间复杂度

  • 空间复杂度:$O(\log n)$(递归调用栈)
  • 快速排序是原地排序算法,不需要额外的辅助存储空间。

快速排序的优缺点

优点

  1. 时间复杂度在实际应用中表现非常优秀,平均复杂度低。
  2. 原地排序,无需额外空间。
  3. 可用于大多数场景,适合处理大规模数据。

缺点

  1. 最差情况下的时间复杂度较高($O(n^2)$)。
  2. 对于小数组或几乎有序的数组,性能可能不如插入排序。
  3. 不稳定排序:同值元素的相对顺序可能被打乱。

优化策略

  1. 三数取中法选择基准值:
    • 避免在已排序或几乎排序的数组中退化为最差时间复杂度。
  2. 尾递归优化:
    • 优化递归调用,减少栈空间。
  3. 小规模数组使用插入排序:
    • 当子数组规模较小时(如 10 个元素以下),改用插入排序更高效。

总结

  • 快速排序是高效的分治算法,适用于大多数场景。
  • 在C++中,直接使用std::sort也可以达到快速排序的效果,其内部实现通常包含快速排序、插入排序和堆排序的混合优化版本,性能更优。

堆排序是怎么做的

堆排序(Heap Sort)是一种基于堆数据结构的排序算法,属于选择排序的一种。它通过将数组构造成一个最大堆最小堆,然后依次取出堆顶元素(最大或最小)来实现排序。


堆排序的基本原理

  1. 构建初始堆
    • 将待排序数组调整为最大堆(升序排序)或最小堆(降序排序),堆的根节点为最大值或最小值。
  2. 交换堆顶与末尾元素
    • 将堆顶元素与当前堆的最后一个元素交换,使堆顶元素归位。
  3. 调整堆结构
    • 交换后,重新调整剩余的元素为最大堆或最小堆。
  4. 重复步骤 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
#include <iostream>
#include <vector>

// 调整堆,使其满足最大堆性质
void heapify(std::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) {
        std::swap(arr[i], arr[largest]);
        heapify(arr, n, largest); // 递归调整子堆
    }
}

// 堆排序主函数
void heapSort(std::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) {
        std::swap(arr[0], arr[i]); // 将堆顶元素移到末尾
        heapify(arr, i, 0);        // 调整剩余元素为最大堆
    }
}

// 测试堆排序
int main() {
    std::vector<int> arr = {10, 15, 20, 3, 5, 8, 1};

    std::cout << "Original array: ";
    for (int num : arr) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    heapSort(arr);

    std::cout << "Sorted array: ";
    for (int num : arr) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    return 0;
}

代码说明

  1. heapify 函数
    • 用于调整数组中以 i 为根节点的子树,确保满足最大堆性质
    • 如果左右子节点比根节点大,则交换根节点和较大的子节点,然后递归调整。
  2. heapSort 函数
    • 首先通过从最后一个非叶子节点到根节点的调整过程,将数组构建为最大堆
    • 然后逐步将堆顶元素移到数组末尾,并调整剩余的堆为最大堆
  3. main 函数
    • 定义测试数组,调用 heapSort 对其排序,并打印结果。

堆排序的时间复杂度

  1. 构建堆的时间复杂度:$O(n)$
    • 每个节点的调整代价是其高度,累加起来总复杂度为$O(n)$。
  2. 调整堆的时间复杂度:$O(n \log n)$
    • 在排序过程中需要$n - 1$次调整,每次调整的代价是 $O(\log n)$。
  3. 总时间复杂度:$O(n \log n)$

堆排序的空间复杂度

  • 空间复杂度:

    $O(1)$

    • 堆排序是原地排序算法,无需额外空间。

堆排序的优缺点

优点

  1. 时间复杂度稳定为$O(n \log n)$,不受数据分布影响。
  2. 原地排序,不需要额外存储空间。

缺点

  1. 不稳定排序:相同值的元素相对顺序可能被改变。
  2. 构建和调整堆的过程相比快速排序,常数因子较大,实际运行时间可能略慢。

堆排序与快速排序的比较

特性快速排序堆排序
时间复杂度(平均)$O(n \log n)$$O(n \log n)$
时间复杂度(最差)$O(n^2)$$O(n \log n)$
空间复杂度$O(\log n)$(递归栈)$O(1)$
稳定性不稳定不稳定
实际性能通常更快(常数因子小)常数因子较大,稍慢

总结

堆排序是一种时间复杂度稳定、适合内存紧张场景的排序算法。尽管在实际应用中其性能通常不如快速排序,但由于其时间复杂度的稳定性和低空间占用,它在某些特定场景(如嵌入式系统)中非常有用。

冒泡排序

冒泡排序(Bubble Sort) 是一种简单直观的排序算法,通过多次遍历数组,每次比较相邻的两个元素,将较大的元素逐步“冒泡”到数组的末尾,从而完成排序。


冒泡排序的基本原理

  1. 从头到尾遍历数组
    • 比较相邻的两个元素,如果顺序不正确(如升序排序中前面的元素大于后面的元素),则交换它们。
  2. 重复步骤 1
    • 每一轮遍历后,最大的元素会“冒泡”到当前未排序部分的最后。
  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
#include <iostream>
#include <vector>

void bubbleSort(std::vector<int>& arr) {
    int n = arr.size();
    for (int i = 0; i < n - 1; ++i) {
        bool swapped = false; // 标志位,用于检测是否发生了交换
        for (int j = 0; j < n - i - 1; ++j) {
            if (arr[j] > arr[j + 1]) {
                std::swap(arr[j], arr[j + 1]); // 交换相邻元素
                swapped = true;
            }
        }
        // 如果没有发生交换,说明数组已经有序,可以提前结束
        if (!swapped) break;
    }
}

int main() {
    std::vector<int> arr = {64, 34, 25, 12, 22, 11, 90};

    std::cout << "Original array: ";
    for (int num : arr) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    bubbleSort(arr);

    std::cout << "Sorted array: ";
    for (int num : arr) {
        std::cout << num << " ";
    }
    std::cout << std::endl;

    return 0;
}

代码说明

  1. for 循环嵌套
    • 外层循环控制排序的轮次,总共需要 $n - 1$ 轮。
    • 内层循环比较相邻元素,并将较大的元素交换到右侧。
  2. 优化:标志位 swapped
    • 如果一轮遍历中没有发生任何交换,说明数组已经有序,可以提前结束排序。
  3. 性能特点
    • 每轮比较范围缩小,因为最大的元素已经归位。

冒泡排序的时间复杂度

  1. 最优时间复杂度:$O(n)$
    • 当数组已经有序时,只需一轮遍历即可完成排序。
  2. 最差时间复杂度:$O(n^2)$
    • 当数组是完全逆序时,需要 $n(n-1)/2 $次比较。
  3. 平均时间复杂度:$O(n^2)$

冒泡排序的空间复杂度

  • 空间复杂度:

    $O(1)$

    • 冒泡排序是原地排序算法,只需常数级的额外空间。

冒泡排序的优缺点

优点

  1. 实现简单,代码直观易懂。
  2. 是稳定排序:相等的元素在排序后保持相对位置不变。
  3. 对于已经部分有序的数组,性能较好(通过标志位优化)。

缺点

  1. 效率低:即使是优化后的冒泡排序,性能也不如插入排序或选择排序。
  2. 不适合大规模数据排序:时间复杂度为 O(n2)O(n^2),对大数组性能较差。

冒泡排序与其他排序算法的比较

特性冒泡排序插入排序选择排序
时间复杂度(最优)$O(n)$$O(n)$$O(n^2)$
时间复杂度(最差)$O(n^2)$$O(n^2)$$O(n^2)$
空间复杂度$O(1)$$O(1)$$O(1)$
稳定性稳定稳定不稳定

总结

冒泡排序适用于小规模、部分有序的数组,但在实际应用中,性能较低的特点使得它很少用于工业场景。更高效的排序算法,如快速排序归并排序堆排序,在大规模数据处理时表现更优。冒泡排序更多用于教学场景或简单任务中。

二分查找

二分查找(Binary Search) 是一种高效的查找算法,用于在有序数组中查找特定的元素。通过每次将查找范围缩小一半,二分查找的时间复杂度仅为 $O(\log n)$。


二分查找的基本原理

  1. 前提条件
    • 二分查找要求数组是有序的(升序或降序)。
  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
#include <iostream>
#include <vector>
#include <optional>

std::optional<int> binarySearch(const std::vector<int>& arr, int target) {
    int left = 0, right = arr.size() - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2; // 防止溢出

        if (arr[mid] == target) {
            return mid; // 找到目标值,返回索引
        } else if (arr[mid] < target) {
            left = mid + 1; // 在右半部分查找
        } else {
            right = mid - 1; // 在左半部分查找
        }
    }

    return std::nullopt; // 未找到目标值
}

int main() {
    std::vector<int> arr = {1, 3, 5, 7, 9, 11, 13};
    int target = 7;

    auto result = binarySearch(arr, target);

    if (result.has_value()) {
        std::cout << "Element found at index: " << result.value() << std::endl;
    } else {
        std::cout << "Element not found in the array." << std::endl;
    }

    return 0;
}

递归实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <vector>
#include <optional>

std::optional<int> binarySearchRecursive(const std::vector<int>& arr, int target, int left, int right) {
    if (left > right) {
        return std::nullopt; // 未找到目标值
    }

    int mid = left + (right - left) / 2;

    if (arr[mid] == target) {
        return mid; // 找到目标值,返回索引
    } else if (arr[mid] < target) {
        return binarySearchRecursive(arr, target, mid + 1, right); // 在右半部分查找
    } else {
        return binarySearchRecursive(arr, target, left, mid - 1); // 在左半部分查找
    }
}

int main() {
    std::vector<int> arr = {1, 3, 5, 7, 9, 11, 13};
    int target = 7;

    auto result = binarySearchRecursive(arr, target, 0, arr.size() - 1);

    if (result.has_value()) {
        std::cout << "Element found at index: " << result.value() << std::endl;
    } else {
        std::cout << "Element not found in the array." << std::endl;
    }

    return 0;
}

时间和空间复杂度

  1. 时间复杂度:$O(\log n)$
    • 每次查找范围缩小一半,最多需要 $\log_2(n) $次比较。
  2. 空间复杂度
    • 迭代实现:$O(1)$,只需常量级额外空间。
    • 递归实现:$O(\log n)$,递归调用栈的深度为 $\log_2(n)$。

二分查找的优缺点

优点

  1. 时间复杂度低,性能优于线性查找。
  2. 实现简单,适合查找有序数据。

缺点

  1. 仅适用于有序数组或其他支持随机访问的数据结构。
  2. 数据需要在查找前排序,排序的时间复杂度通常为 $O(n \log n)$。
  3. 插入和删除操作成本较高(需要维护数组有序性)。

常见变种

  1. 查找第一个等于目标值的元素
    • 在二分查找中,当找到目标值时继续向左查找,直到找到第一个位置。
  2. 查找最后一个等于目标值的元素
    • 在二分查找中,当找到目标值时继续向右查找,直到找到最后一个位置。
  3. 查找第一个大于或小于目标值的元素
    • 可通过调整二分查找的逻辑实现。

示例:查找第一个大于等于目标值的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int lowerBound(const std::vector<int>& arr, int target) {
    int left = 0, right = arr.size();

    while (left < right) {
        int mid = left + (right - left) / 2;

        if (arr[mid] >= target) {
            right = mid; // 收缩右边界
        } else {
            left = mid + 1; // 收缩左边界
        }
    }

    return left; // 返回第一个大于等于目标值的位置
}

总结

二分查找是一种高效、基础的算法,适合在有序数组中查找元素或解决查找相关问题。通过调整逻辑,可以解决多个变种问题,如上下界查找。实际应用中,可以结合 STL 提供的 std::lower_boundstd::upper_bound 方便实现类似功能。

本文由作者按照 CC BY 4.0 进行授权