当前位置: 首页 > news >正文

《算法导论》第 2 章 - 算法基础

        大家好,今天我们来一起学习《算法导论》的第 2 章 —— 算法基础。这一章是算法学习的入门内容,主要介绍了两种经典排序算法(插入排序和归并排序)以及算法分析的基本方法,非常适合初学者打好基础。本文会结合实例代码和图解,帮助大家更好地理解和掌握这些知识点。

2.1 插入排序

插入排序是一种简单直观的排序算法,它的工作原理类似于我们整理手中的扑克牌。

基本思想

插入排序的基本思想是:将待排序的元素逐个插入到已经排好序的部分数组中,直到所有元素都插入完毕。

具体步骤如下:

  1. 从第一个元素开始,该元素可以认为已经被排序
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置
  4. 重复步骤 3,直到找到已排序的元素小于或等于新元素的位置
  5. 将新元素插入到该位置后
  6. 重复步骤 2~5

插入排序代码实现(C++)

下面是插入排序的完整 C++ 代码,包含详细注释:

#include <iostream>
#include <vector>using namespace std;/*** 插入排序函数* @param arr 待排序的数组(引用传递,直接修改原数组)*/
void insertionSort(vector<int>& arr) {int n = arr.size();  // 获取数组长度// 从第二个元素开始遍历(第一个元素默认已排序)for (int i = 1; i < n; i++) {int key = arr[i];  // 取出当前要插入的元素int j = i - 1;     // 已排序部分的最后一个元素索引// 将大于key的元素向后移动一位while (j >= 0 && arr[j] > key) {arr[j + 1] = arr[j];j--;}arr[j + 1] = key;  // 将key插入到正确位置}
}/*** 打印数组元素* @param arr 要打印的数组*/
void printArray(const vector<int>& arr) {for (int num : arr) {cout << num << " ";}cout << endl;
}int main() {// 测试案例vector<int> arr = {5, 2, 4, 6, 1, 3};cout << "排序前的数组: ";printArray(arr);insertionSort(arr);  // 调用插入排序cout << "排序后的数组: ";printArray(arr);return 0;
}

        这段代码可以直接编译运行,它实现了对整数数组的插入排序。我们使用了 vector 容器来存储数组元素,使得代码更加灵活。

插入排序综合案例:学生成绩排序

        下面我们来看一个更实际的应用案例:对学生成绩进行排序。我们将创建一个 Student 类,包含学生姓名和成绩,然后使用插入排序按照成绩从高到低进行排序。

#include <iostream>
#include <vector>
#include <string>using namespace std;/*** 学生类*/
class Student {
private:string name;  // 姓名int score;    // 成绩public:// 构造函数Student(string n, int s) : name(n), score(s) {}// 获取姓名string getName() const { return name; }// 获取成绩int getScore() const { return score; }// 重载大于运算符,用于比较成绩bool operator>(const Student& other) const {return score > other.score;}
};/*** 插入排序函数(对学生数组按成绩从高到低排序)* @param students 待排序的学生数组*/
void insertionSort(vector<Student>& students) {int n = students.size();for (int i = 1; i < n; i++) {Student key = students[i];  // 当前要插入的学生int j = i - 1;// 将成绩低于key的学生向后移动while (j >= 0 && students[j] > key) {students[j + 1] = students[j];j--;}students[j + 1] = key;  // 插入到正确位置}
}/*** 打印学生信息* @param students 学生数组*/
void printStudents(const vector<Student>& students) {cout << "姓名\t成绩" << endl;cout << "----------------" << endl;for (const auto& s : students) {cout << s.getName() << "\t" << s.getScore() << endl;}
}int main() {// 创建学生数组vector<Student> students = {Student("张三", 85),Student("李四", 92),Student("王五", 78),Student("赵六", 90),Student("钱七", 88)};cout << "排序前的学生成绩:" << endl;printStudents(students);insertionSort(students);  // 按成绩排序cout << "\n排序后的学生成绩(从低到高):" << endl;printStudents(students);return 0;
}

        这个案例展示了插入排序在实际应用中的用法。我们通过重载>运算符,使得插入排序算法可以直接用于 Student 对象的比较,体现了面向对象编程的灵活性。


2.2 分析算法

        分析算法的目的是预测算法的资源消耗,以便比较不同算法的性能。在大多数情况下,我们最关心的是算法的运行时间

分析框架

我们通常从以下几个方面分析算法:

  1. 最坏情况分析:确定算法在输入规模为 n 时的最大运行时间
  2. 最好情况分析:确定算法在输入规模为 n 时的最小运行时间
  3. 平均情况分析:确定算法在所有可能输入上的平均运行时间

在实际应用中,我们通常更关注最坏情况分析,因为:

  • 它给出了算法运行时间的上界,保证了算法不会比这更差
  • 对于某些算法,最坏情况经常出现
  • 平均情况分析往往比较复杂

大 O 记号

        在算法分析中,我们使用大 O 记号来描述算法的时间复杂度。大 O 记号表示的是算法运行时间的增长率上限

        例如,插入排序的最坏情况时间复杂度是 O (n²),表示当输入规模 n 足够大时,插入排序的运行时间不会超过 n² 的某个常数倍。

常见的时间复杂度按增长率从小到大排列:

  • O (1):常数时间
  • O (log n):对数时间
  • O (n):线性时间
  • O (n log n):线性对数时间
  • O (n²):平方时间
  • O (n³):立方时间
  • O (2ⁿ):指数时间

插入排序的时间复杂度分析

我们来分析一下插入排序的时间复杂度:

  1. 最好情况:当输入数组已经排好序时,内层循环不需要执行元素移动操作。

    • 时间复杂度:O (n)
  2. 最坏情况:当输入数组逆序排列时,每个元素都需要与前面所有已排序的元素进行比较和移动

    • 时间复杂度:O (n²)
  3. 平均情况:考虑所有可能的输入排列,平均情况下的时间复杂度也是 O (n²)

情况时间复杂度说明
最好情况O(n)数组已经有序
最坏情况O(n²)数组完全逆序
平均情况O(n²)随机排列的数组

算法分析案例:比较不同规模数据的排序时间

下面的代码演示了插入排序在不同规模数据上的运行时间,帮助我们直观感受算法复杂度的含义:

#include <iostream>
#include <vector>
#include <chrono>  // 用于计时
#include <cstdlib> // 用于生成随机数
#include <algorithm> // 用于reverseusing namespace std;
using namespace chrono;// 插入排序函数
void insertionSort(vector<int>& arr) {int n = arr.size();for (int i = 1; i < n; i++) {int key = arr[i];int j = i - 1;while (j >= 0 && arr[j] > key) {arr[j + 1] = arr[j];j--;}arr[j + 1] = key;}
}// 生成随机数组
vector<int> generateRandomArray(int size) {vector<int> arr(size);for (int i = 0; i < size; i++) {arr[i] = rand() % 10000;  // 生成0-9999的随机数}return arr;
}// 测试插入排序在不同规模数据上的运行时间
void testInsertionSortPerformance() {// 测试不同规模的数组vector<int> sizes = {1000, 2000, 4000, 8000, 16000};cout << "测试插入排序性能:" << endl;cout << "数组规模\t最好情况(ms)\t最坏情况(ms)\t随机情况(ms)" << endl;cout << "---------------------------------------------------------" << endl;for (int size : sizes) {// 生成三种情况的数组vector<int> bestCase(size);for (int i = 0; i < size; i++) bestCase[i] = i;  // 已排序(最好情况)vector<int> worstCase = bestCase;reverse(worstCase.begin(), worstCase.end());  // 逆序(最坏情况)vector<int> randomCase = generateRandomArray(size);  // 随机数组(平均情况)// 测试最好情况auto start = high_resolution_clock::now();insertionSort(bestCase);auto end = high_resolution_clock::now();auto bestTime = duration_cast<milliseconds>(end - start).count();// 测试最坏情况start = high_resolution_clock::now();insertionSort(worstCase);end = high_resolution_clock::now();auto worstTime = duration_cast<milliseconds>(end - start).count();// 测试随机情况start = high_resolution_clock::now();insertionSort(randomCase);end = high_resolution_clock::now();auto randomTime = duration_cast<milliseconds>(end - start).count();// 输出结果cout << size << "\t\t" << bestTime << "\t\t" << worstTime << "\t\t" << randomTime << endl;}
}int main() {testInsertionSortPerformance();return 0;
}

        运行这段代码,你会发现当数组规模翻倍时,最坏情况和随机情况下的运行时间大约会增加到原来的 4 倍(符合 O (n²) 的特征),而最好情况下的运行时间大约会增加到原来的 2 倍(符合 O (n) 的特征)。

2.3 设计算法

        算法设计是指从问题出发,构造出求解问题的有效算法的过程。常用的算法设计方法有:分治法、动态规划、贪心算法、回溯法等。本章重点介绍分治法

2.3.1 分治法

分治法(Divide and Conquer)是一种非常重要的算法设计思想,其基本思想是:

  1. 分解(Divide):将原问题分解为若干个规模较小、结构与原问题相似的子问题
  2. 解决(Conquer):递归地解决这些子问题。如果子问题的规模足够小,则直接求解
  3. 合并(Combine):将子问题的解合并为原问题的解

分治法的典型应用是归并排序(Merge Sort)

归并排序的基本思想

归并排序正是基于分治法的思想实现的:

  1. 分解:将 n 个元素分成两个各含 n/2 个元素的子序列
  2. 解决:递归地对两个子序列进行归并排序
  3. 合并:合并两个已排序的子序列,得到最终的排序结果

归并排序的关键在于合并步骤:如何高效地合并两个已排序的子序列。

归并排序流程图

归并排序代码实现(C++)
#include <iostream>
#include <vector>using namespace std;/*** 合并两个已排序的子数组* @param arr 原数组* @param left 左子数组的起始索引* @param mid 左子数组的结束索引,mid+1是右子数组的起始索引* @param right 右子数组的结束索引*/
void merge(vector<int>& arr, 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] = arr[left + i];for (int j = 0; j < n2; j++)R[j] = arr[mid + 1 + j];// 合并临时数组回原数组int i = 0, j = 0, k = left;while (i < n1 && j < n2) {if (L[i] <= R[j]) {arr[k] = L[i];i++;} else {arr[k] = R[j];j++;}k++;}// 复制剩余的元素while (i < n1) {arr[k] = L[i];i++;k++;}while (j < n2) {arr[k] = R[j];j++;k++;}
}/*** 归并排序函数* @param arr 待排序的数组* @param left 排序的起始索引* @param right 排序的结束索引*/
void mergeSort(vector<int>& arr, int left, int right) {if (left < right) {// 找到中间点int mid = left + (right - left) / 2;// 递归排序左半部分和右半部分mergeSort(arr, left, mid);mergeSort(arr, mid + 1, right);// 合并已排序的两部分merge(arr, left, mid, right);}
}/*** 打印数组* @param arr 要打印的数组*/
void printArray(const vector<int>& arr) {for (int num : arr) {cout << num << " ";}cout << endl;
}int main() {vector<int> arr = {12, 11, 13, 5, 6, 7};cout << "排序前的数组: ";printArray(arr);mergeSort(arr, 0, arr.size() - 1);  // 调用归并排序cout << "排序后的数组: ";printArray(arr);return 0;
}

        这段代码实现了归并排序的完整功能。mergeSort函数负责分解问题和递归求解,merge函数负责合并两个已排序的子数组。

归并排序综合案例:外部排序

        归并排序的一个重要应用是外部排序,即当数据量太大无法全部装入内存时的排序方法。下面是一个简单的外部排序模拟实现:

#include <iostream>
#include <vector>
#include <fstream>
#include <string>
#include <algorithm>using namespace std;// 生成测试数据文件
void generateTestData(const string& filename, int size) {ofstream fout(filename);for (int i = 0; i < size; i++) {fout << rand() % 10000 << endl;  // 生成随机数}fout.close();
}// 从文件中读取指定数量的数据
vector<int> readChunk(const string& filename, int start, int count) {vector<int> chunk;ifstream fin(filename);string line;int num, i = 0;while (getline(fin, line) && i < start + count) {if (i >= start) {num = stoi(line);chunk.push_back(num);}i++;}fin.close();return chunk;
}// 将数据块写入临时文件
string writeTempChunk(const vector<int>& chunk, int id) {string filename = "temp_" + to_string(id) + ".txt";ofstream fout(filename);for (int num : chunk) {fout << num << endl;}fout.close();return filename;
}// 归并两个有序文件
string mergeFiles(const string& file1, const string& file2, int id) {ifstream fin1(file1), fin2(file2);string outfile = "temp_" + to_string(id) + ".txt";ofstream fout(outfile);string line1, line2;// 修复:使用good()方法检查流状态bool has1 = getline(fin1, line1).good();bool has2 = getline(fin2, line2).good();int num1, num2;while (has1 && has2) {num1 = stoi(line1);num2 = stoi(line2);if (num1 <= num2) {fout << num1 << endl;// 修复:使用good()方法检查流状态has1 = getline(fin1, line1).good();} else {fout << num2 << endl;// 修复:使用good()方法检查流状态has2 = getline(fin2, line2).good();}}// 写入剩余数据while (has1) {fout << line1 << endl;// 修复:使用good()方法检查流状态has1 = getline(fin1, line1).good();}while (has2) {fout << line2 << endl;// 修复:使用good()方法检查流状态has2 = getline(fin2, line2).good();}fin1.close();fin2.close();fout.close();// 删除临时文件remove(file1.c_str());remove(file2.c_str());return outfile;
}// 外部归并排序
void externalMergeSort(const string& filename, int chunkSize) {// 步骤1: 读取文件并创建有序的数据块vector<string> tempFiles;int chunkId = 0;vector<int> chunk;do {chunk = readChunk(filename, chunkId * chunkSize, chunkSize);if (chunk.empty()) break;// 对数据块进行排序sort(chunk.begin(), chunk.end());// 写入临时文件tempFiles.push_back(writeTempChunk(chunk, chunkId));chunkId++;} while (chunk.size() == chunkSize);// 步骤2: 归并所有临时文件while (tempFiles.size() > 1) {vector<string> newTempFiles;for (size_t i = 0; i < tempFiles.size(); i += 2) {if (i + 1 < tempFiles.size()) {// 归并两个文件newTempFiles.push_back(mergeFiles(tempFiles[i], tempFiles[i+1], chunkId++));} else {// 如果是奇数个文件,最后一个文件直接进入下一轮newTempFiles.push_back(tempFiles[i]);}}tempFiles = newTempFiles;}// 重命名最终文件if (!tempFiles.empty()) {rename(tempFiles[0].c_str(), "sorted_result.txt");}cout << "外部排序完成,结果保存到 sorted_result.txt" << endl;
}int main() {// 生成10000个随机数作为测试数据generateTestData("data.txt", 10000);// 进行外部排序,假设内存只能容纳1000个数据externalMergeSort("data.txt", 1000);return 0;
}

        这个案例模拟了外部排序的过程,它将大文件分成多个可装入内存的块,分别排序后再逐步归并,最终得到整个文件的有序版本。这展示了归并排序在处理大规模数据时的优势。

2.3.2 分析分治算法

        分治算法的时间复杂度分析通常需要求解递归式。递归式是一个用较小输入的函数值来描述函数的等式或不等式

递归式的求解方法

求解递归式的常用方法有:

  1. 代入法:猜测一个解,然后用数学归纳法证明其正确性
  2. 递归树法:将递归式转换为一棵树,其节点表示不同层次的递归调用产生的代价
  3. 主方法:用于求解形如 T (n) = aT (n/b) + f (n) 的递归式
主方法

        主方法给出了如下递归式的解:
T (n) = aT (n/b) + f (n)

其中:

  • a ≥ 1,b > 1 是常数
  • f (n) 是一个渐近正函数
  • n/b 可以是 floor (n/b) 或 ceil (n/b)

主方法有三种情况:

  1. 若 f (n) = O (n^log_b (a-ε)) 对某个常数 ε > 0 成立,则 T (n) = Θ(n^log_b a)
  2. 若 f (n) = Θ(n^log_b a),则 T (n) = Θ(n^log_b a * log n)
  3. 若 f (n) = Ω(n^log_b (a+ε)) 对某个常数 ε > 0 成立,且对某个常数 c < 1 和所有足够大的 n 有 af (n/b) ≤ cf (n),则 T (n) = Θ(f (n))
归并排序的时间复杂度分析

        归并排序的递归式为:
T (n) = 2T (n/2) + Θ(n)

应用主方法:

  • a = 2,b = 2,所以 log_b a = log2 2 = 1
  • f (n) = Θ(n) = Θ(n^1),符合主方法的第二种情况

        因此,归并排序的时间复杂度为 T (n) = Θ(n log n),这在三种情况(最好、最坏、平均)下都是成立的。

分治算法分析案例

下面我们通过一个案例来具体分析分治算法的时间复杂度:

#include <iostream>
#include <vector>
#include <cmath>using namespace std;/*** 用分治法求数组中的最大元素* @param arr 输入数组* @param left 左边界索引* @param right 右边界索引* @return 数组中的最大元素*/
int findMax(const vector<int>& arr, int left, int right) {// 基本情况:如果数组只有一个元素,返回该元素if (left == right) {return arr[left];}// 分解:找到中间点int mid = left + (right - left) / 2;// 解决:递归寻找左右两部分的最大值int maxLeft = findMax(arr, left, mid);int maxRight = findMax(arr, mid + 1, right);// 合并:返回两个最大值中的较大者return max(maxLeft, maxRight);
}/*** 分析分治法求最大值的时间复杂度* T(n) = 2T(n/2) + Θ(1)* 根据主方法,a=2, b=2, log_b a=1, f(n)=Θ(1) = O(n^0)* 符合第一种情况,所以 T(n) = Θ(n^1) = Θ(n)*/
void analyzeTimeComplexity() {cout << "分治法求最大值的时间复杂度分析:" << endl;cout << "递归式:T(n) = 2T(n/2) + Θ(1)" << endl;cout << "其中 a=2, b=2, f(n)=Θ(1)" << endl;cout << "log_b a = log2(2) = 1" << endl;cout << "由于 f(n) = O(n^(1-ε)) 对于 ε=1 成立," << endl;cout << "根据主方法第一种情况,T(n) = Θ(n)" << endl;
}int main() {vector<int> arr = {12, 34, 54, 2, 3};cout << "数组中的最大值是:" << findMax(arr, 0, arr.size() - 1) << endl;analyzeTimeComplexity();return 0;
}

        这个案例分析了用分治法求数组最大值的时间复杂度。递归式为 T (n) = 2T (n/2) + Θ(1),根据主方法的第一种情况,我们得出其时间复杂度为 Θ(n)。

思考题

  1. 比较插入排序和归并排序

    • 什么情况下插入排序比归并排序更高效?
    • 如何优化插入排序使其在实际应用中表现更好?
  2. 算法设计

    • 设计一个基于分治法的算法,求一个数组中元素的和。
    • 分析该算法的时间复杂度。
  3. 算法分析

    • 求解递归式 T (n) = 3T (n/2) + n^2
    • 求解递归式 T (n) = T (2n/3) + 1
  4. 实践题

    • 实现一个版本的归并排序,当子数组的规模小于某个阈值时,改用插入排序。
    • 尝试不同的阈值,观察算法性能的变化。

参考答案提示

     

  1. 当数组规模很小或数组已经基本有序时,插入排序可能更高效,因为它的常数因子较小。可以通过二分查找来优化插入排序中寻找插入位置的步骤。
  2. 分治法求数组和可以将数组分成两半,分别求和后相加。时间复杂度为 Θ(n)。
  3. 第一个递归式用主方法第三种情况,解为 Θ(n²);第二个用主方法第一种情况,解为 Θ(log n)。
  4. 这种混合排序算法结合了两种排序的优点,通常能获得更好的实际性能。最佳阈值通常在 10-20 之间,但也可能因具体实现和数据特征而变化。

本章注记

        本章介绍了算法分析的基本框架和两种重要的排序算法:插入排序和归并排序。插入排序的时间复杂度为 O (n²),但实现简单,常数因子小,适合小规模数据或接近有序的数据。归并排序采用分治策略,时间复杂度为 Θ(n log n),适合大规模数据排序

        算法分析是计算机科学的基础技能,大 O 记号和递归式分析是评估算法效率的重要工具。分治法是一种强大的算法设计范式,不仅用于排序,还广泛应用于其他领域,如查找、矩阵乘法、快速傅里叶变换等。

通过本章的学习,我们应该掌握:

  • 分析算法时间复杂度的基本方法
  • 插入排序和归并排序的原理及实现
  • 分治法的基本思想和应用
  • 递归式的求解方法,特别是主方法

        在实际应用中,选择合适的算法需要考虑问题规模、数据特征和实际运行环境等多种因素。理解不同算法的优缺点和适用场景,是成为一名优秀程序员的重要一步。

        希望这篇学习笔记能帮助你更好地理解《算法导论》第 2 章的内容。如果有任何疑问或建议,欢迎在评论区留言讨论!

        以上就是《算法导论》第 2 章的全部学习内容。通过理论学习和代码实践相结合的方式,相信大家已经对算法基础有了更深入的理解。算法学习是一个循序渐进的过程,建议大家多动手实现,多思考分析,不断提升自己的算法能力。

http://www.lryc.cn/news/610613.html

相关文章:

  • 朴素贝叶斯(Naive Bayes)算法详解
  • pipeline方法关系抽取--课堂笔记
  • 神坛上的transformer
  • VUE2 学习笔记18 路由守卫
  • 无人机 × 巡检 × AI识别:一套可复制的超低延迟低空视频感知系统搭建实践
  • 人月神话:软件工程的永恒智慧
  • Android 之 Kotlin中的协程(Dispatchers.IO)
  • 研发团队看板协作中的自动化实践:集成CI/CD与任务流转
  • Goby 漏洞安全通告| NestJS DevTools /inspector/graph/interact 命令执行漏洞(CVE-2025-54782)
  • Linux内核参数调优:为K8s节点优化网络性能
  • 【功能测试】软件功能上线测试经验总结
  • K8S健康检查巡检清单
  • K8s Master状态NotReady
  • 播放器音频后处理实践(一)
  • 【Axure视频教程】动态折线图
  • 从 “看懂图” 到 “读懂视频”:多模态技术如何用文本反哺视觉?
  • 02-算法
  • 基于Istio与Envoy的gRPC流量控制与熔断降级实战经验分享
  • 43.MySQL管理
  • 站在JS的角度,看鸿蒙中的ArkTs
  • 进阶向:PDF合并/拆分工具
  • 让 Spark 干体力活:用 Java 快速找出最小值
  • 集成电路学习:什么是RS-232推荐标准232
  • neo4j虚拟关系的统计
  • golang实现支持100万个并发连接(例如,HTTP长连接或WebSocket连接)系统架构设计详解
  • Android开发:如何正确将ImageView中的矩形坐标转换为图片原始像素坐标
  • ⭐CVPR2025 MatAnyone:稳定且精细的视频抠图新框架
  • scikit-learn工具介绍
  • 【数据结构与算法】顺序表和链表、栈和队列、二叉树、排序等数据结构的完整代码收录
  • 深度学习·基础知识