算法与数据结构

算法与数据结构

文章目录

什么是算法

引用来自《算法导论》的一段话:

所谓算法(algorithm)就是定义良好的计算过程,它取一个或一组值作为输入,并且产生出一个或一组值作为输出。亦即,算法就是一系列的计算步骤,用来将输入数据转换成输出结果。

  • 算法的表示形式

    • 自然语言表示法
    • 流程图表示法
    • 伪代码表示法
    • 编程语言表示法
  • 算法的特征

    • 有穷性
    • 确切性
    • 输入
    • 输出
    • 可行性

什么是数据结构

定义

数据结构(data structure) 是带有结构特性的数据元素的集合,它研究的是数据的逻辑结构和数据的物理结构以及它们之间的相互关系,并对这种结构定义相适应的运算,设计出相应的算法,并确保经过这些运算以后所得到的新结构仍保持原来的结构类型。

  • 数学解法是不涉及数据的物理结构的,它只关心数据的逻辑结构。
  • 算法因为需要实现成代码,并且利用计算机上有限的资源(比如说cpu、内存)来尽可能高效率地得到问题的答案,所以要考虑数据之间在物理内存上的存储方式

数据的逻辑结构

  • 逻辑结构指的是数据元素之间的前后关系,与计算机的存储位置无关。

  • 通常分为以下几类:

    • 集合:数据结构中的元素属于”同属于一个集合“的关系,除此之外没有其他关系。

    比如“三年二班中的男生”和“三年二班中的女生”就是两个学生集合。

    • 线性结构:数据结构中存在一对一的相互关系。

    比如学生根据学号的前后关系。在班级打印一些信息时,学号小的就会在表格的前面,学号大的就会在班级的后面。

    • 树形结构:数据结构中的元素存在一对多的相互关系。

    比如说学生会中的组织关系。比如学生会主席负责领导副主席,副主席负责领导各部门部长,而各部门部长负责领导副部长、干事等等,除了主席,每一个职位都会有一个直接领导。除了跟元素外,每个节点有且仅有一个前驱,后继数目不限。

    img

    • 图形结构:数据结构中存在多对多的相互关系。

    就像学生之间好友关系。每个节点可以有多个前驱和后继。

img

数据结构常见操作

操作
  • 创建: 创建一个空的数据结构。

比如创建一个用于记录“三年二班学生学号和姓名”的数据结构。

  • 清除: 清空数据结构。

比如清楚记录“三年二班学生学号和姓名”的数据结构。

  • 插入: 往数据结构中增加新的节点。

假设”三年二班“新转来一名同学,那么”将该学生的姓名和班级录入学校系统“就是一个插入操作。

  • 删除: 把指定的节点从数据结构中去掉。

假设”三年二班“转走了一名同学,那么”将该学生的姓名和班级从学校系统中删除“就是一个删除操作。

  • 检索: 检索就是在数据结构里查找满足一定条件的节点。一般是给定一个某字段的值,找具有该字段值的节点。

假设一条学生的记录了学生的班级。那么”找到所有三年二班的学生“就是一个检索操作。

  • 更新: 改变指定节点的一个或多个字段的值。

假如一个学生改名了,那么”将姓名由小红修改成小芳“就是一个更新操作。

  • 访问: 访问数据结构中的某个元素。
  • 遍历: 按照某种次序,访问数据结构中的每一个元素。

不同的逻辑结构可能还有自己特定的操作,比如:

  • 排序: 把节点按某种指定的顺序重新排列。

例如递增或递减。假如学校进行了一次考试,班级需要给学生进行一次排名,那么”根据考试成绩进行排名“就是一个排序操作。

总结

数据结构作为算法实现中数据的容器,其作用就是通过设计如何利用数据之间的逻辑结构,转化成计算机上合适的的物理结构,从而达到高效进行数据处理和运算的目的。

数据的物理结构(也叫存储结构)

指数据的逻辑结构在计算机存储空间的存放形式

存储结构

Tips: 因为如果要存储一组数据,并且基于该数据快速执行一些操作,就需要清楚且简洁地描述这组数据以及恰当地组织数据之间的关系

所以我们通常需要关心的有两点:

  • 数据的机内表示:

    • 在数据结构中,一个数据就是一个结点(node),而数据的每一个数据项,成为该数据的数据域(data field)

    例如在学校的档案中,每个学生的数据我们就用一个结点表示,而该学生有关的信息,例如姓名、年龄、班级,都是该学生结点的数据域。在计算机中,不管数据项是整数、浮点数和字符串,都会最终编码成二进制串的形式。在学生数据中,姓名和班级就是两个字符串,而年龄就是一个指定范围内的整数。

  • 关系的机内表示:

    • 一个是顺序存储结构
    • 另一个是链式存储结构

试想:如果学校在一段程序中,想读入一组学生的数据到内存。那我们在程序中怎样存储呢?

一个最直观的想法就是数组。所以学生的数据在内存中的形式就如下图所示:

0123
小明小红小亮小芳

这样,我们就能按照下标访问该内存中存储的学生的数据。

但如果我们想通过姓名“小明”查找小明的班级,那么就只能从头到尾遍历整个数组,先将每个数据姓名的数据域进行比对,如果和“小明”相匹配,然后再输出“班级”的数据域。

除此之外,如果我们想在下标为“1”的后面插入一个数据,就只能将“1”及其以后的数据全部向后移动一个位置,可见使用数组存储对于下标访问操作是很高效的,但对于插入删除就比较慢。这就是一种顺序存储结构

那么,如果我们想在插入删除两个操作上提速的话,可以想到,相比将所有数据存在一段连续的内存空间里,我们可不可以将其分散存放,但是用一条“链”将数据穿起来,这样就能知道它们之间的前后关系了。于是我们就可以将其以“链表”的形式存储,如下图所示:

img

这样,如果我们想在“小明”的后面插入一个数据,只需新建一个结点,然后建立新的指针指向关系即可,如下图所示:

img

但是,如果我们想输入下标“3”访问链表中的第3个元素,就只能从第1个向后沿着指针跳动3次,才可以找到第3个元素的内存位置。

总结

数据结构描述单条数据的信息和数据之间的关系。不同的数据结构会有各自的长处,也会有个各自的短板,需要根据实际场景选择最合适的数据结构。

常见的数据结构

常见的数据结构有数组链表队列等。其中:

数据的

  • 线性结构

    • 数组

    • *链表

    • 写一段代码,不同函数之间A调用B,B调用C的关系。那么在实际执行中,一定是C先返回结果,然后是B,最后是A。编译器编译时,通常用来辅助解析这种调用关系

      img

    • 队列

      点餐系统维护不同时间到达订单的制作和配送顺序,就需要用队列维护。

      img

  • 树形结构

    • 数据库中,对有序的数据建立多叉排序树,有助于增加数据查询、插入和删除的效率。

      img

  • 图形结构

    • 在互联网领域,在社交软件中形成的社交网络,通常用一张非常大的来表示。每个用户就是图上的结点。而用户之间的好友关系就组成了图上的

      img

模拟算法

模拟算法就是可以帮我们实现自动化的工具。所谓模拟,就是按照题目的要求或者情境的设定完整实现其描述过程的方法。

基础模型

  • 简单数学计算和统计

  • 简单数组模拟

  • 复杂模拟举例

    • 举例:扫雷游戏

      如果要自己实现一个扫雷游戏,就需要按照其规则进行模拟。其中,假设需要支持以下的操作:

      1. 对于用户给定的棋盘大小和雷的个数,随机雷的位置并初始化棋盘。
      2. 对于用户给的坐标,判断是否为地雷,如果是,结束游戏,如果不是,则展开该位置。如果该位置数字为0,则在周围继续展开直到碰到大于0的数字为止。
      3. 输出棋盘局面,如果该轮游戏没有结束,则重复2~3过程。

      拿其中的两个基础模块举例子:打印整个棋盘和展开一部分棋盘。

      • 在整个棋盘中,我们用num[x][y]表示(x, y)(x,y)的状态;
      • 如果num[x][y] >= 0,则该位置是一个数字;
      • 如果num[x][y] < 0,则该位置是一个地雷;
      • 另外,用clear[x][y]表示该位置有没有展开。
      • 打印棋盘
      int n, m;
      int num[N][N];
      bool clear[N][N];
      
      void printMap() {
          for (int x = 1; x <= n; ++x) {
              for (int y = 1; y <= n; ++y) {
                  if (!clear[x][y]) cout << '#';			// 没有展开
                  else if (num[x][y] == -1) cout << '@';	// 地雷
                  else if (num[x][y] == 0) cout << '.';	// 数字 == 0,周围没有地雷
                  else cout << num[x][y];					// 数字 > 0
              }
              cout << endl;
          }
      }
      
      • 展开棋盘
      int tot;	// 在main函数中初始化为 棋盘总格子数 - 地雷数
      const int dx[] = {1, 0, -1, 0, 1, 1, -1, -1}; // 8个相邻方向的x坐标变化
      const int dy[] = {0, 1, 0, -1, 1, -1, 1, -1}; // 8个相邻方向的y坐标变化
      
      void dfs(int x, int y) {
          if (clear[x][y] || num[x][y] == -1) // 如果已经展开或者是地雷,就不展开
          clear[x][y] = false;
          --tot;	// 总未展开的非地雷格子个数减1
          if (num[x][y] > 0) return; // 如果周围有地雷,就不继续展开
          for (int i = 0; i < 8; ++i) {
              int xx = x + dx[i];
              int yy = y + dy[i];
              // 向第i个方向继续展开
              if (1 <= xx && xx <= n && 1 <= yy && yy <= n) dfs(xx, yy);	
          }
      }
      

      可见在计算机上实现一个小游戏的平台,游戏的推进和状态的判定都是在对游戏规则的模拟。

高精度运算

引入

在我们进行算法设计的过程中,很多场景下,我们都需要和整数打交道。

C++自己本身有一些内置的整数类型,比如short、int、long long等。但它们的最大值都有一个限制,即便是unsigned long long能存储的最大数也只有2^{64} - 1264−1。

在进行更大范围的数值计算中,要怎么办呢?

既然一个数字没办法用一个变量装下,可不可以用很多变量?比如说,用一个数组来存储?

  • 在使用整数的过程中,并不是只需要把它每一位存下来就好了,还需要:
  • 读入用户的输入,
  • 还要进行整数之间加减乘除的运算
  • 还要将计算结果输出给用户看。

所以,只有当这些基本操作都实现了,我们才算真真正正实现了一个完整的大整数操作。

Tips: 其实,如果让我们自己在纸上写下一些20位的整数(已经超过long long)的范围,其实我们是知道如何进行计算的。比如说如果两个20位的整数相加,其实就像小学生列竖式一样,逐位相加,维护进位即可。

所以,我们只需要告诉计算机怎样模拟我们所熟知的整数操作,就可以在算法中使用它了。

高精度的存储和输入输出

在对很大的数进行处理之前,我们首先要了解两个术语:

单精度与高精度

  • 单精度:能用一个内置类型存储的整数。
  • 高精度:不能用内置类型存储的大整数,通常用数组存储每一个数位。

区分了单精度高精度后,就让我们一起来想想使用整数的过程过程中的第一步:存储

存储

储存方式

刚才提到,用数组存储高精度大整数,并用一个整型变量维护它的长度。当把大整数存储在数组里时,有两种存储方式可供选择:

  • 大端序:数字的高位在地址的低位(也就是和打印顺序一致)。以2021为例:

img

  • 小端序:数字的低位在地址的低位。

img

通常进行高精度计算时,采取小端序方式,主要的目的是为了方便模拟竖式计算,在后面我们会详细解释。

总结:表示一个高精度数字

  • 数位数组,采用小端序的存储方式,将高精度数字的每一位存在数组的每个元素里。
  • 长度,表示这个高精度数字十进制位的个数。

字符串储存数组

现在我们知道了采用小端序的存储方式,那给我们一个高精度整数,我们怎样将其读入数组呢?

想一想,如果这个数字很长以至于无法用C++内置类型读入的话,我们唯一的选择是什么?

  • 由字符数组表示的字符串

想一想:可以用int类型的数组存储吗?为什么?

当我们确定使用由字符数组表示的字符串来存储一个高精度整数后,问题的关键就变成了,

怎样将字符串转化成高精度整数类型?

再一次考虑到我们的数字存储方向和打印顺序的不同,我们需要将字符串里的元素“倒着”存进数组。

#include <bits/stdc++.h>
using namespace std;
char str[111];
int digits[110];
int len;
int main() {
    cin >> str;
    // 获取高精度整数长度
    int len = strlen(str);	
    for (int i = 0; i < len; ++i)
        // 将字符转换成数字,倒着存进数位数组
        digits[i] = str[len - i - 1] - '0';		
  	//将数组倒过来,也就是从后往前输出。
    for (int i = len - 1; i >= 0; --i) cout << digits[i];
    cout << endl;
}

操作

高精度加法 --代码实现

代码实现主要分为三部分:数位操作维护长度输出

#include <bits/stdc++.h>
#define N 110
using namespace std;
// 这里我们采用直接赋值的形式初始化,按照小端序的方式存储
// 所以这里加数a = 368, 加数b = 997,
// 感兴趣的话也可以按照之前介绍的方式改成手动输入
int a_digits[N] = {8, 6, 3}, a_len = 3;
int b_digits[N] = {7, 9, 9}, b_len = 3;
int ans_digits[N], ans_len;
int main() {
    // 1. 数位操作: 
    // 先将答案的长度赋值成和更大的加数一样的长度。
    // 然后再从低位向高位开始,逐位相加,回顾上文,每一位相加的部分包括两个加数的对应位和低位进位。
    // 相加时,大于等于10的部分要从这一位去掉然后进位到高位,分别对应%10和/10的操作
    
    ans_len = max(a_len, b_len); 	// 初始长度
    int k = 0;						// 记录进位的变量
    for (int i = 0; i < ans_len; ++i) {
        // 假设a_len > b_len,这里需要保证b[b_len]到b[a_len - 1]的位置都是0,否则可能会出错。
        ans_digits[i] = a_digits[i] + b_digits[i] + k; // 相加计算
        k = ans_digits[i] / 10;     // 更新进位
        ans_digits[i] %= 10;
    }
    
    // 2. 维护长度: 
    // 因为两个数相加,最高位仍然可能产生进位,
    // 在这种情况下,需要我们将答案的长度+1
    
    if (k) 
        ans_digits[ans_len++] = k;	// 最高位进位
    
    // 3. 输出
    // 按照打印顺序输出,从高位到低位。
    
    for (int i = ans_len - 1; i >= 0; --i) 
        cout << ans_digits[i];
    cout << endl;
    
    return 0;
}

高精度减法 - 代码实现

这里,我们总假设被减数大于等于减数

下面的代码依旧分成数位操作维护长度输出三个部分。

#include <bits/stdc++.h>
#define N 110
using namespace std;
// 同样,这里采用小端序存储
int a_digits[N] = {8, 6, 3, 2}, a_len = 4;
int b_digits[N] = {7, 9, 9},	b_len = 3;

int ans_digits[N], ans_len;
int main() {
    // 1. 数位操作
    // 我们依旧是从低位到高位开始逐位相减
    // 因为我们总假设a>=b,所以初始长度先设为a的长度
    // 考虑每一位,需要计算的部分是被减数的当前位,减去减数的当前位,再减去低位的借位
    // 如果上一步的计算得出当前位<0,那我们需要向高位借位,然后给当前位+10
    ans_len = a_len;	// 初始长度
    int k = 0;			// 维护借位
    for (int i = 0; i < ans_len; ++i) {
        ans_digits[i] = a_digits[i] - b_digits[i] - k;
        
        if (ans_digits[i] < 0) {
            k = 1;
            ans_digits[i] += 10;
        } else k = 0;	// 这里赋值成0很关键,而且容易遗漏
    }
    
    // 2. 维护长度
    // 想象一下,如果实际数字是1,但是长度记录是4的话,那么输出该数字结果将是0001,
    // 也就是出现了“前导0”,所以维护长度的目的是为了去掉前导0
    // 所以,我们用while循环实现这样的逻辑:只要最高位是0,我们就把位数缩小1位。
    // 但是需要注意,只有位数>1的时候才可以缩小,否则当保存的数字是0时,长度也会减为0.
    while (ans_len > 1 && !ans_digits[ans_len - 1]) // 只有长度大于1才可以去掉前导零
        --ans_len;
    
    // 3. 输出
    for (int i = ans_len - 1; i >= 0; --i) cout << ans_digits[i];
    cout << endl;
    return 0;    
}

高精度乘法 - 代码实现

在下面的代码中,我们分为数位操作统一进位维护长度输出四个部分。

#include <bits/stdc++.h>
#define N 110
using namespace std;
int a_digits[N] = {3, 2}, a_len = 2;
int b_digits[N] = {8, 6}, b_len = 2;
// int a_digits[N] = {0}, a_len = 1;
// int b_digits[N] = {9, 9}, b_len = 2;
int ans_digits[N * 2], ans_len;
int main() {
    // 1. 数位操作
    // 考虑到(a位数×b位数)最多得到(a + b)位数,所以我们设置答案初始长度为a + b。
    // 另外考虑到第i位×第j位会贡献到(i + j)位,所以,我们用累加的方式计算答案的每一位。
    // 值得注意的是,这里累加的结果可能>=10,所以按理说应该进位,但为了效率考虑,我们
    // 在后面统一维护进位,而不是一边加一边进。
    ans_len = a_len + b_len;		// 初始化长度
    for (int i = 0; i < ans_len; ++i) ans_digits[i] = 0; 
    // 因为是不断累加的形式,所以要将范围内的元素初始化为0。
    
    for (int i = 0; i < a_len; ++i) 
        for (int j = 0; j < b_len; ++j) 
            ans_digits[i + j] += a_digits[i] * b_digits[j];	
    // ans的每一位更新都要使用累加的形式,这是因为对于ans的第k位,满足i + j == k的(i, j)很多,所以可能答案的第k位可能先后被更新很多次。
    
    // 2. 统一进位
    // 上一步提到,因为累加后得到的答案各个数位有可能>=10,所以要将其变成一个合法的高精度形式
    // 也就是说,要把>=10的部分进位到下一位。所以我们用类似于高精度加法的方法维护。
    // 每一位只需要将自己的值和低位的进位相加,然后把>=10的部分作为新的进位进到下一位。
    int k = 0;
    for (int i = 0; i < ans_len; ++i) {
        ans_digits[i] += k;
        k = ans_digits[i] / 10;
        ans_digits[i] %= 10;
    }
    
    // 3. 维护长度
    // 上面提到,(a位数×b位数)最多得到(a + b)位数
    // 但考虑一个非零整数和0相乘的情况,答案的长度很可能降为1。所以我们需要向减法一样更新长度。
    // 只有当长度仍然>1的时候,才需要去掉前导0
    while (ans_len > 1 && ans_digits[ans_len - 1] == 0) 
        --ans_len;
    
    // 4. 输出
    for (int i = ans_len - 1; i >= 0; --i) 
        cout << ans_digits[i];
    cout << endl;
    return 0;
}

算法评价

是因为在现实生活中,计算资源,包括CPU的计算速度内存的大小,是有限的,而我们的等待时间也是有限的。所以,我们需要用更快(或内存利用率更高)的算法来应对时间紧张(或者内存紧张)的开发场景。

  • 时间限制:一方面,CPU的运算速度是有限制的;另一方面,等待问题结果的时间也是有限的。所以,衡量算法运行时间的一个重要指标是时间复杂度

    例如,打开个人电脑的配置,可以看到cpu的主频主频,表示CPU每秒钟产生脉冲信号的次数(也就是每秒钟的时钟周期个数)。

    以2.1GHz为例,一秒钟该CPU可以产生2.1\times10^92.1×109次脉冲信号,如果一台计算机每个时钟周期可以完成1条指令,那么该计算机1s之内就可以运行2.1\times 10^92.1×109条指令。

  • 空间限制:计算机内存的大小是有限制的。 所以,衡量算法内存消耗的一个重要指标是空间复杂度

    例如,一个二维数组int a[5000][5000]所耗内存为:

    5000 \times 5000 \times 4 \div 1024 \div 1024 \approx 95\text{ MB}5000×5000×4÷1024÷1024≈95 MB

另外,在一些更有针对性的场景(如机器学习场景),在算法开发中,可能有更具体的需求,所以就需要设计更具体的指标(例如机器学习中的准确率精确率召回率等)。

时间复杂度

时间复杂度描述的是算法运行时间和输入规模的关系

它基于以下设定:

  • 每条语句运行都需要消耗一定时间;

所以程序的运行时间,和语句的数量有关。

  • 运行时需要执行的语句条数和输入的规模相关

而且通常情况下,输入规模越大,语句越多

  • 算法的运行时间关于输入规模的一个函数
  • 这个函数被称为该算法的时间复杂度。这里我们用n表示输入规模,用T(n)表示算法的运行时间。

但从上一节例子的第一种做法中,我们会发现算法的运行时间不仅和输入规模(也就是数组长度和询问个数)有关,还与具体的询问内容有关。

例如,如果每次询问的区间[x, y][x,y]都满足x = yx=y,算法的运行时间肯定比每次都满足x=1, y=nx=1,y=n要快。我们如何将这种情况考虑进算法的时间评估中呢?

所以,我们评估算法的运行时间,一个很常用的指标是最坏时间复杂度

最坏时间复杂度描述的是在规模为n*n*的所有可能的输入中最坏情况下算法的运行时间。

  • 算法的运行时间可以用算法运行的语句数量近似表示
大O记号

由于上面列举的原因,我们统一一种估算方式。我们用以下几个例子先感受一下(其中nn表示输入数据规模):

估算前估算后
1100O(1)
25n^3 + 7n^2 + 3O(n^3)
33⋅2^nO(2^n)
  • 可以看到,我们在估算算法效率时只保留增长最快的一项除了系数以外的部分,相当于只保留了该式子的“数量级”。

时间复杂度举例

  • O(1)——常数条语句:交换两个元素

    // 输入
    int a = 4;
    int b = 6;
    
    // 计算
    int t = a;
    a = b;
    b = t;
    
  • O(n)——单重循环求数组和

    // 输入
    int a[] = {2, 0, 1, 3, 5};
    int n = 5;
    
    int sum = 0;
    for (int i = 0; i < n; ++i) sum += a[i];
    
  • O(n^2)——双重循环求数组中相等元素对数

    // 输入
    int a[] = {1, 1, 3, 5, 5};
    int n = 5;
    
    // 计算
    int cnt = 0;
    for (int i = 0; i < n; ++i)
        for (int j = i + 1; j < n; ++j)
            cnt += (a[i] == a[j]);
    
  • O(n2^n)——枚举n个数字组成集合的所有子集,输出子集和。

    // 输入
    int a[] = {2, 1, 3, 6, 5};
    int n = 5;
    
    // 计算
    int tot = 1 << 5;	// 相当于求2的5次方
    for (int i = 0; i < tot; ++i) {
         // 变量i的二进制形式用于表示每个元素选(1)与不选(0)。
        int sum = 0;
        for (int j = 0; j < n; ++j)
            if ((i >> j) & 1) sum += a[j];	// 检查i的第j位是否是1
        cout << sum << endl;
    }
    
空间复杂度:
// 假设数据规模最大为N
int a;       // 常数空间复杂度
int a[N];    // 此时空间复杂度为 O(N)
int a[N][N]; // 此时空间复杂度为 O(N^2)

算法

枚举

枚举法是利用计算机运算速度快、精确度高的特点,对要解决问题的所有可能情况,一个不漏地进行检验,从中找出符合要求的答案的方法。

子集枚举

顾名思义就是在枚举所有子集。为了解释这句话,我们需要先明确几个概念:

  • 集合: 集合就是包含一些对象的整体

比如一个学校里,“全体同学”就是包含所有同学的集合;“全体男生”就是包含所有男同学的集合,“全体女生”就是包含所有女同学的集合。

  • 子集: 子集就是只包含某集合中一部分对象的集合

比如在学校中的“全体同学”里,“经管系的同学”就是“全体同学”的一个子集。另外,每个非空集合都有两个最特殊的子集。一个是空集,不包含该集合中的任何元素。另一个是该集合本身,包含该集合中的所有元素。

子集的表示方式 – 01比特串法

01比特串法

另一种表示子集的方式就是01比特串。什么是01比特串?

我们可以类比“字符串”,而将“比特串”类比为一串01数字。我们通过如下方式用长度为n的01比特串表示一个大小为n的集合{a_1, \ldots, a_n}{a1,…,a**n}的子集:

如果01比特串的第i个元素是0,表示在该子集中没有包含第i个元素,相反如果第i个元素是1,则表示该子集中包含了第i个元素。

举例

对于集合{1,2,3,4,5,6}

  • 010001表示子集{2, 6}。
  • {1, 2, 3, 5}表示成111010

我们选择01比特串法,是因为它是一种对枚举很友好的表示方法

对于一个大小为nn的集合,它的所有子集对应着所有00...011...1的长度为nn的比特串。

而长度为nn的比特串又对应着范围从00到 2 n − 1 2^n-1 2n1的整数。

所以,如果我们想枚举所有子集的话,就是在枚举该子集的比特串表示,就是在枚举该比特串的整数。

下面,我们先解决两个比较关键的问题,然后再给出完整代码:

  1. 枚举整数的范围:从0(空集)枚举到2^n -1(也就是原集合本身)

因为对我们有用的只有长度为nn的01比特串,超过nn以后就没有意义了。所以如果转化成整数的话,最大的nn位二进制整数是2^n - 1。所以枚举整数的时候,应该从0(空集)枚举到2^n - 1也就是原集合本身)。

  1. 如何知道第i个元素是否在集合里

因为我们在枚举的时候枚举的是整数而不是直接枚举01比特串。所以怎样知道一个整数num在二进制表示下第i位是0还是1呢?(这里第i位为从最低位开始数的第i位)

这里我们用一些位运算的技巧。

首先考虑一个比较简单的问题:怎样知道最低位是0还是1呢?我们只需要计算num & 1即可(&为与操作),根据一个例子感受一下这么做的原因:

101011 \text{ & } 1 = 101011 \text{ & 000001} = 000001 = 1101011 & 1=101011 & 000001=000001=1

101010 \text{ & } 1 = 101010 \text{ & 000001} = 000000 = 0101010 & 1=101010 & 000001=000000=0

另外,num & 1也可用于检查数字num的奇偶性

这是因为当数字num是奇数时,二进制表示下的最低位就是1,而如果它是偶数,那么二进制表示下最低位就是0。

所以检查奇偶性和检查二进制位最低位是等价的。

现在我们知道如何检查最低位,也就是第1位了。那么如果想检查第2位怎么办呢?

我们可以想办法把**“检查第2位”转换成“检查第1位”这个问题**。具体转换方法,就是将整个二进制数字,向右平移一位

  • 对于向右平移一位,位运算中有对应的右移运算符>>;
  • 右移运算符在计算结果上,相当于“除以2下取整”;
  • 在视觉效果上,就相当于将二进制表示下的数字丢掉最低位,并向右移一位;
  • 这里,我们可以拿十进制做类比:在十进制下,“除以10下取整“就相当于在十进制下去掉最低位,向右平移1位,例如:

⌊12345÷10⌋=1234

所以,检查num的第2位就相当于,先将数字向右平移1位,再检查最低位,代码写出来就是:

int second_bit = (num >> 1) & 1;

由第2位类比任意第i位,我们可以得到如下代码。假如我们要枚举长度为n的01比特串的每一位,如果习惯从1枚举到n的话,可以用上面一行;如果习惯从0枚举到n-1,可以用下面一行。

int i_th_bit = (num >> (i - 1)) & 1;    // 如果i的范围是1到n
int i_th_bit = (num >> i) & 1;  // 如果i的范围是0到n-1

代码实现 —— 集合中所有数求和是3的倍数的子集的个数

回顾引入的部分给出的例子

假设我们有个集合{1,2,3,…,n},输出所有满足集合中所有数求和是3的倍数的子集的个数。

可以用如下代码实现。

#include <bits/stdc++.h>
using namespace std;
int n;
int main() {
    scanf("%d", &n);    // 集合大小,也就是01比特串的长度
    int tot = 1 << n;   // 枚举数字代替01比特串,范围为0到2^n - 1
    int ans = 0;
    for (int num = 0; num < tot; ++num) {  // 枚举每个代表01比特串的数字
        long long sum = 0;
        for (int i = 0; i < n; ++i)        // 枚举01比特串的每一位
            if ((num >> i) & 1) {          // 检查第j位是否为1,注意这里是从0开始枚举
                sum += (i + 1);            // 如果该位是1,就把对应的数字加到求和的变量里
            }
        if (sum % 3 == 0) ++ans;           // 如果满足题目要求(3的倍数),计入答案
    }
    printf("%d\n", ans);
}
  • 01比特串复杂度分析

    用01比特串进行子集枚举,根据上面的代码我们可以给出该算法的复杂度分析:

    第一层for循环,一共循环2^n次。for循环里,枚举n个二进制位的每一位,复杂度为O(n)

    所以最终复杂度为O*(2*^nn*)。

递归枚举子集

之前我们给出了用数组表示集合的方法。那么有没有一种枚举方式可以基于这种表示方法来枚举集合的所有子集呢?

答案是有的,在以后学习递归的过程中,我们可以写一个递归函数,生成表示每个子集的对应数组。

这里我们简单提一下,其本质思想就是分情况讨论

img

在这个结构中,第i层的结点表示的是基于上面的结点对应的子集再加入一个元素的可能情况。

所以,我们就按照这个树形结构枚举了所有的子集。

以在实现上,我们需要用一种叫做“递归”的方式将这个树形结构在代码中生成出来。

使用这种基于数组表示法和递归方法枚举子集的好处,是因为它的复杂度是O(2^n),要好于01比特串的枚举方法

排列枚举

排列的表示方式

上面我们用“1~n”的顺序来描述的n个对象中的某种排序关系,其实有个专业术语叫排列

  • 其中,最简单的对排列的解释就是:将n个元素按照一定的顺序排成一列即为n个数的排列

上面列举了几个需要找到合法排列的例子之后,下面我们将介绍如何枚举所有的排列。

和子集枚举一样,在设计枚举算法之前,我们也要首先确定排列在计算机里的表示方式

这里,我们似乎只能想到用数组来存储排列,如下方代码所示:

int a[10] = {1, 2, 3, 4, 5, 6};   // 原序列
int b[10] = {2, 3, 1, 5, 4, 6};   // 原序列的一种排列

这意味着,我们将对一个数组的对象进行枚举。在这里,我们需要明确两个问题:

  1. 我们按什么顺序枚举?

    这里,我们引入字典序的概念,并且最终按照字典序的顺序枚举排列。字典序,又叫字母序,是规定两个序列比较大小的一种方式。其规则是对于两个序列ab

  • 从第一个字母开始比较,如果在第i个位置满足,i没有超过两个序列的长度,小于i处两个序列对应位置的元素都相等,而第i位两个序列对应位置元素不等的话,则若a[i] < b[i],那么序列a小于序列b,否则序列b小于序列a
  • 若从第一个位置直到其中一个序列的最后一个位置都相等的话,则比较ab的长度,若a的长度小于b,则序列a小于序列b(此时ab的前缀),而如果b序列的长度小于a,那么序列b小于序列a
  • 若两个序列长度相等,并且所有元素相等,则序列a等于序列b

举例

abc < bbc	// 因为第一个字母a < b
ab < abc	// 因为两个串前面所有对应位置字母相同,但第一个串长度小于第二个串
ac > abb	// 因为第二个字母c > b

如何生成下一个排列的数组?

C++标准模板库(STL)里面已经有现成的实现了,我们将在下节介绍该内容

标准模板库和next_permutation

标准模板库(Standard Template Library, STL) 是惠普实验室开发的一系列软件.它分为算法(algorithm)、容器(container)和迭代器(iterator)三个部分,实现了代码开发中常用的算法(如求最小值最大值、排序、二分查找等)和数据结构(如向量vector、集合set、映射map等)。

之所以叫做“模板库”,是因为在STL中几乎所有代码都是用模板类或者模板函数的方式实现的。

比如说我们常用的函数min(a, b)max(a, b)以及swap(a, b)就是在算法部分实现的。

可以发现对于不同的数据类型,包括整数(int)、浮点数(double),甚至自己定义的类对象,都可以调用swap函数。这就是模板的好处。

  • next_permutation 函数是STL的算法部分实现的一个函数,其功能是将数组中存储的元素重新排列到字典序更大的排列

在我们考虑排列的字典序时,因为所有排列长度相同,所以只需要比较对应位置元素大小即可。

举例: 所有1~3的排列按字典序从小到大排序

1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1

【参数】

  • next_permutation函数有3个参数,分别代表头指针尾指针比较函数
  • 头指针和尾指针:表示该函数需要重新排列的范围是头指针到尾指针之间的所有元素,包括头指针指向的元素,不包括尾指针指向的元素。

这里需要对指针有一定的了解,它大致表示的是某个变量在内存中的位置。

int a[10] = {1, 2, 3};
next_permutation(a, a + 3);
// 调用完该函数之后,数组a中的元素会重排
// 此时a数组的元素为{1, 3, 2},因为next_permutation会将a数组中的元素重排成
// 按照字典序顺序的下一个排列
  • 比较函数:这是一个可选参数,用于指定数组中存储对象的大小关系。

之所以需要比较函数,是因为只有存在单个元素的大小关系,才可以定义字典序。

对于整数、浮点数以及字符数组,因为整数、浮点数和字符的大小关系已经在C++里面定义过了,所以不需要传比较函数。当需要对自定义的类对象数组进行重排时,可能需要传入比较函数。

【返回值】

  • next_permutation 的返回值表示是否存在字典序更大的排列。

如果存在,返回true,否则返回false。但是即便不存在字典序最大的排列,调用该函数也会导致数组a中的元素被重排成字典序最小的一个,例如:

int a[10] = {4, 3, 2, 1};
if (next_permutation(a, a + 4)) {
    cout << "Yes" << endl;
} else {
    cout << "No" << endl;
}
for (int i = 0; i < 4; ++i) cout << a[i] << ' ';
cout << endl;

这段代码的输出结果是

No
1 2 3 4

取宝石问题”的代码实现

取宝石问题

假设在一个大房间有nn个宝石,每一处宝石用一个坐标(x, y)(x,y)表示。如果你从任意一处宝石的地方出发,依次经过每个放宝石的地方并取走宝石,最终要求回到出发地点,问最短需要走的距离是多少。

在这个情境里,经过不同地点的顺序会改变最终的行走距离。所以,我们要枚举的就是经过1~n一共n个位置的顺序。

next_permutation函数解决“取宝石问题”

因为要用枚举法解决第一个问题,所以,代入到题目的情境中,我们可以设计如下算法:

  1. 枚举所有n个点的排列
  2. 维护最短距离。检查新枚举的排列产生的行走距离是否比之前的最短距离还短。如果短,就更新答案。

下面是解决这个问题的完整代码:

#include <bits/stdc++.h>
#define N 15
using namespace std;
int n, id[N];
double x[N], y[N];

// 求两个点(x_1, y_1)和(x_2, y_2)之间的直线距离
double dis(double x_1, double y_1, double x_2, double y_2) {
    double dx = x_1 - x_2;
    double dy = y_1 - y_2;
    return sqrt(dx * dx + dy * dy);
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> x[i] >> y[i];
        id[i] = i;	// 因为我们枚举标号的排列,所以要将标号存进数组里
    }
    
    double ans = -1;    // 因为最开始ans中没有值,所以我们可以将其设置为一个不合法的值	
    // 用do...while循环是为了防止第一次调用时数组id中的值已经被重排
    // 所以会导致标号为1, 2, ..., n的排列没有被计算。
    do {
        // 求解按照id[1], id[2], ..., id[n], id[1]作为行走路线的总距离。
        double cur = dis(x[id[1]], y[id[1]], x[id[n]], y[id[n]]);
        for (int i = 1; i < n; ++i)
            cur += dis(x[id[i]], y[id[i]], x[id[i + 1]], y[id[i + 1]]);
        
        // 如果当前路线的总距离小于之前最优解,就更新。
        if (ans < 0 || cur < ans) ans = cur;
    } while (next_permutation(id + 1, id + n + 1));	
    
    // 输出答案,这里因为是浮点数,所以我们设置精度为4。
    cout << setprecision(4) << ans << endl;
    return 0;
}

递归枚举排列

我们也可以用递归实现对所有排列的枚举,其本质是分情况讨论

下图是关于排列的分情况讨论树形图,最下面一层就是所有可能的排列,可以看到,每一条向下延伸的边表示的是下一个可能放置的数字。

并且在所有生成的排列里,靠左边的位置字典序更小,靠右边的位置字典序更大。

img

排序算法

选择排序

简介

选择排序(Selection Sort)是我们学习的第一个排序算法。这里,我们尝试按照选择排序的思路,自己将其设计出来。首先,我们来思考一个问题

想把一个完全无序的序列按照从小到大排序,排在第一个的元素应该是谁?

这个问题的答案很简单:就是整个序列中的最小值

那么,假如我们把最小的元素选择出来,放在答案序列的第一位。那么,后面要解决的问题,就只需要将剩下的n-1个元素排序放在答案的2~n位置即可。

若想排序剩下的n-1个元素,我们也可以用同样的思路:排在剩下元素第一位的一定是剩下元素的最小值!所以我们再求出剩下n-1个元素的最小值,然后放在答案序列的第二位

所以,按照同样的流程,我们最终就能得出n个元素的排序。

img

详细算法描述

首先,我们先以8个元素的排序为例,模拟一下整个算法过程。

  1. 找出1~8元素的最小值,并且记录最小值的位置为k

  2. 将其与第1位元素交换。

  3. 找出2~8元素的最小值,并且记录最小值位置k

  4. 将其与第2位元素交换。

  5. 照此过程一直进行,直到所有元素按照从小到大的顺序排列。

现在,我们尝试总结一下上面的算法

  1. 按照从1n的顺序,找出答案序列中放在该位置的元素。
  2. 在寻找第i位的元素时,求出第i位到第n位的最小值,并记录最小值的位置k
  3. 交换第k位的元素与第i位元素。

代码实现 —— 数组最小值

数组最小值

首先,如何找到n个元素的最小值,并记录它的位置?

  • 最开始,我们默认最小值出现在数组的第1位,所以,用于记录最小值位置的变量min_pos初始值为1
  • 然后,枚举数组中的每个元素,并且将当前记录的最小值和枚举到的第i个元素作比较,如果当前枚举到的元素更小,说明最小值不可能出现在原来的min_pos位置,而更有可能出现的位置i。所以,将min_pos更新为i
  • 当扫描完整个元素后,min_pos中的位置就是最小值出现的位置。
#include <bits/stdc++.h>
using namespace std;
int a[1010];
int n;

int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i]; 
    //注意:为保持认知一致,我们直接从数组第2个元素开始。数组第二个元素索引为1,不是从索引为0的元素开始哦。
    
    int min_pos = 1;    // 设置最小值位置的初始值为0,即a[1] = 0
    for (int i = 2; i <= n; ++i) {
        if (a[i] < a[min_pos])  // 比较当前枚举到的元素与当前记录位置的元素
            min_pos = i;        // 如果当前记录位置的元素更小,则更新最小值出现的位置
    }
    cout << "minimum value = " << a[min_pos] << endl;    // 输出最小值
    cout << "minimum value pos = " << min_pos << endl;   // 输出最小值的位置
    return 0;
}

代码实现 —— 实现选择排序

实现选择排序

所以,给定长度为n的序列,我们现在用之前描述的选择排序过程用C++语言实现出来,将序列从小到大排序。

  • 最外层用for循环枚举当前应该归位的第i个元素;
  • 内层用上一步方法的寻找最小值位置,找出第i位一直到第n位的最小值,并且将其与原来的第i位元素交换。

具体实现如下:

#include <bits/stdc++.h>
using namespace std;
int a[1010];
int n;

int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    
    // 选择排序过程
    for (int i = 1; i < n; ++i) {  // 枚举应该归位的第i个元素,这里因为前n-1位归为以后,
                                   // 第n位也会归位,所以我们只枚举到n-1。
        int min_pos = i;           // 将最小值位置设置为当前范围i~n的首位
        for (int j = i + 1; j <= n; ++j) { // 将第i个元素和剩下的元素相比较
            if (a[j] < a[min_pos]) {       // 如果当前元素小于之前维护的最小值
                min_pos = j;               // 更改最小值出现的位置
            }
        }
        swap(a[i], a[min_pos]);            // 将最小值与第i个位置交换
    }
    
    // 输出
    for (int i = 1; i <= n; ++i) 
        cout << a[i] << ' ';
    return 0;
}

冒泡排序

冒泡排序的思路
  • 总结冒泡排序的思路:
  1. 冒泡排序分为n-1个阶段。
  2. 在第1个阶段,通过“冒泡”,将前n个元素的最大值移动到序列的最后一位。此时只剩前n-1个元素未排序。
  3. 在第i个阶段,此时序列前n-i+1个元素未排序。通过“冒泡”,我们将前n-i+1个元素中的最大值移动到最后一位。此时只剩前n-i个元素未排好序。
  4. 最终到第n-1个阶段,前2个元素未排序。将其中的较大值移动到后一位,则整个序列排序完毕。

在上面的算法过程中,我们提到“冒泡”将序列中的最大值移动到最后一位。下面,我们介绍一下“冒泡”的过程:

冒泡过程

对于长度为n的序列,如何将其中的最大值移动到最后一位?

就像上面说的那样,如果相邻两个元素(如下图2和3),第一个大,第二个小,那么,将两个元素交换。

img

如果我们对一段序列从左到右连续做如下交换操作:

if (a[i] > a[i + 1]) swap(a[i], a[i + 1]);

它的效果相当于将每一个”极大值“元素,移动到能到达的最远的位置。

img

自然,序列中的最大值,也是一个极大值,而它能被移动到的最远的位置,就是序列的最后一位。

所以,如果对于序列的1~n位,想将其中的最大元素”冒泡“到最后一位,那么就需要从第1位开始,一直到第n-1位,依次进行上面的交换过程。

代码实现1 —— 冒泡过程
#include <bits/stdc++.h>
#define N 1010
using namespace std;
int n, a[N];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    
    // 冒泡阶段:连续交换过程
    for (int i = 1; i < n; ++i)    // 枚举两两交换的前一个元素序号
        if (a[i] > a[i + 1]) swap(a[i], a[i + 1]);    // 如果前一个元素大于后一个,就进行交换
    
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}
代码实现2 —— 完整冒泡排序

冒泡排序的过程,就是n-1个冒泡阶段。

这些冒泡阶段的作用就是按照最大值、第二大值、第三大值……的顺序,分别将其移动到序列的最后一位、倒数第二位、倒数第三位……。

    // 冒泡排序
    for (int i = 1; i < n; ++i) { 	// 一共n-1个阶段,在第i个阶段,未排序序列长度从n-i+1到n-i。
        for (int j = 1; j <= n - i; ++j)	// 将序列从1到n-i+1的最大值,移到n-i+1的位置
            if (a[j] > a[j + 1]) 			// 其中j枚举的是前后交换元素的前一个元素序号
                swap(a[j], a[j + 1]);
    }

完整的程序代码:

#include <bits/stdc++.h>
#define N 1010
using namespace std;
int n, a[N];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    
    // 冒泡排序
    for (int i = 1; i < n; ++i) { 	// 一共n-1个阶段,在第i个阶段,未排序序列长度从n-i+1到n-i。
        for (int j = 1; j <= n - i; ++j)	// 将序列从1到n-i+1的最大值,移到n-i+1的位置
            if (a[j] > a[j + 1]) 			// 其中j枚举的是前后交换元素的前一个元素序号
                swap(a[j], a[j + 1]);
    }
    
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}
复杂度分析

从代码中,我们可以看到冒泡排序的主干部分有两层循环,并且每一层的循环次数都在O(n)左右的数量级。

所以完整的冒泡排序时间复杂度是O(n^2)。

插入排序

详细算法描述

已知一个有序序列,向其中新加入一个元素,使得该序列变成长度加1的有序序列,其方法是令该元素从最后一个元素开始与序列中的元素依次比较,直到遇到一个元素小于它,就将新元素插入到这个元素的后面一位。这样就维护了一个长度+1的有序序列。

  1. 枚举序列中第2~n个元素。
  2. 当枚举元素i时,前i-1个元素已经有序。将第i个元素插入到前i-1个元素的有序序列中,形成长度为i的有序序列。
  3. 枚举过程结束后,整个序列有序。

在上述算法描述中,我们有个关键的步骤——插入操作

将第i个元素插入到前i-1个元素的有序序列中,形成长度为i的有序序列。

怎样实现这个过程呢?

一种实现思路和前面介绍的“火车站插队”过程十分类似。比如对于如下序列:

img

这个序列的前4个元素已经有序,现在我们要把2插入到前4个元素中去。

  1. 首先让2出队。

img

  1. 然后将其与6比较,发现2<6,说明2应该在6前面。所以我们将6向后移动。
  2. 再将其与5比较,发现2<5,说明2应该排在5前面。所以我们将5向后移动。

img

img

  1. 经过同样的分析,4也应该向后移。

img

  1. 最后我们将21比较,发现1<2,说明2应该放在1的后面。正好经过前面的移动,1后面有一个空位,所以我们把2插入在这个空位中。

img

以上就是我们将一个新的元素2插入前面有序序列a的过程。

因为前面的序列有序,所以在整个序列中一定有一条分界线j,使得分界线前面的元素(i<j),都满足a[i]<=2;

对于分界线及分界线以后的元素(i>=j),都满足a[i]>2;

所以,上面的移动一定可以将分界线及以后的元素向后移动一位,空出分界线的位置j,然后将2插入到j号元素中。

所以,我们总结一下插入操作的算法描述:

  1. 假设序列1~(i-1)已经有序, 从i1枚举分界线的下标j,令x=a[i];
  2. 如果分界线前面的元素a[j-1]大于x,说明a[j-1]应该在分界线后面。所以将a[j-1]移动到a[j],分界线前移变成j-1
  3. 如果分界线前面没有元素(j=1),就将x放在数组第1位。否则如果碰到一个j-1号元素小于等于x,说明分界线位置正确,就将x插到j位。

代码实现 —— 插入排序代码框架

插入排序代码框架

回顾上面插入排序的算法描述:

  1. 枚举序列中第2~n个元素。
  2. 当枚举元素i时,前i-1个元素已经有序。将第i个元素插入到前i-1个元素的有序序列中,形成长度为i的有序序列。
  3. 枚举过程结束后,整个序列有序。

我们得出了插入排序的代码框架。

#include <bits/stdc++.h>
#define N 1550
using namespace std;
int a[N], n;

int main() {
    // 输入
    cin >> n; 
    for (int i = 1; i <= n; ++i) cin >> a[i];
	
    // 插入排序
    for (int i = 2; i <= n; ++i) {  // 按照第2个到第n个的顺序依次插入
        int j, x = a[i];            // 之所以要将a[i]赋给一个临时变量x,
                                    // 就是因为前面元素向后移动的过程中,
                                    // 可能会覆盖a[i]。
        
        /* 此处先省略过程寻找分界线j,并将分界线及以后的元素向后移动。在下一步骤中讲解*/
        
        a[j] = x;    // 当前待插入元素x,插入到分界线j的位置
    }
	
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}
插入操作的代码

回忆一下插入操作的算法过程:

  1. 假设序列1~(i-1)已经有序, 从i1枚举分界线的下标j;
  2. 如果分界线前面的元素a[j-1]大于x,说明a[j-1]应该在分界线后面。所以将a[j-1]移动到a[j],分界线前移变成j-1
  3. 如果分界线前面没有元素(j=1),就将x放在数组第1位。否则如果碰到一个j-1号元素小于等于x,说明分界线位置正确,就将x插到j位。

因为这基于一个“如果条件满足,就一直执行某过程”的逻辑,

所以我们应该用while循环或者借助for循环中的条件部分来实现。假设我们待插入元素在i号位:

int j, x = a[i];    // 先将i号元素用临时变量保存防止被修改。

// 插入过程,目的是空出分界线位置j,使得所有<j的部分<=x,所有>j的部分>x。
// 循环维持条件,j不是第一个,并且j前面的元素>x。
for (j = i; j > 1 && a[j - 1] > x; --j) {   
    // 满足循环条件,相当于分界线的位置还应向前移,
    // 分界线向前移,就等于将分界线前面>x的元素向后移
    a[j] = a[j - 1];                         
                                             
}
// 循环结束,分界线位置正确
a[j] = x;       

do-while:

int j =i+1;
int x = a[i];
do{
    --j;
    a[j]=a[j-1];
}while((j>1)&&(a[j-1]>x));
完整插入排序

将上述步骤(代码框架和插入操作)两步骤合并,就有了完整的插入排序代码:

#include <bits/stdc++.h>
#define N 1550
using namespace std;
int a[N], n;

int main() {
    // 输入
    cin >> n; 
    for (int i = 1; i <= n; ++i) cin >> a[i];
	
    // 插入排序
    for (int i = 2; i <= n; ++i) {    // 按照第2个到第n个的顺序依次插入
        int j, x = a[i];    // 先将i号元素用临时变量保存防止被修改。

        // 插入过程,目的是空出分界线位置j,使得所有<j的部分<=x,所有>j的部分>x。
        // 循环维持条件,j>1,并且j前面的元素>x。
        for (j = i; j > 1 && a[j - 1] > x; --j) {   
            // 满足循环条件,相当于分界线应向前移,
            // 分界线向前移,就等于将分界线前面>x的元素向后移
            a[j] = a[j - 1];              
                                                    
        }
        // 找到分界线位置,插入待插入元素x
        a[j] = x;                         
    }
	
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}
复杂度分析

时间复杂度是O(n^2)。

快速排序

基本思想

我们知道,给一个长度为n的序列排序,有三种很简单的算法:选择排序冒泡排序插入排序。这三种算法的复杂度均为O(n^2)

如果按照计算机1秒钟可以进行10^8次计算作为参照,那么它1秒之内可以排序的序列长度大概为10^4这个数量级。

然而,在实际生活中,10^4104级别并不是一个很大的数字,比如说山东每年会有超过50万人参加高考。如果我们想将山东省内所有学生按照高考成绩排序的话,使用O(n^2)将会运行:
( 50 × 1 0 4 ) 2 1 0 8 = 2500 秒 ≈ 41 分 钟 108 ( 50 × 104 ) 2 = 2500 秒 ≈ 41 分 钟 \frac{(50 \times 10^4)^2}{10^8} = 2500秒 \approx 41分钟108(50×104)2=2500秒≈41分钟 108(50×104)2=250041108(50×104)2=250041
不得不说,这样的算法并不算高效。那么,我们能不能设计出更高效的算法呢?

  • 一种优化方法

假设现在,我们只想对1~n的数字排序。并且现在我们有个排序算法,运行复杂度刚好是n^2n2。

如果:

先把该序列分成两部分,一部分是1~(n/2),另一部分是(n/2+1)~n

然后对两个序列进行分别排序

最后再将两部分贴在一起,这个算法的复杂度是多少呢?

  1. 首先,我们将序列分为长度相等的大小两部分

此时我们需要将所有<=n/2的数字调出来放到一边;

将所有>n/2的数字挑出来放到另一边;

这个步骤相当于将所有数字看一遍,所以复杂度是O(n)O(n)。

  1. 然后,我们使用原来掌握的排序算法分别给分好的两个序列排序

因为两个序列的长度都是n/2,所以两边排序的复杂度都是(n/2)^2 = n2/4(*n*/2)2=*n*2/4。两部分排序的总复杂度是(n2/4) * 2 = n^2 / 2

  1. 最后,我们需要把两部分的排序结果贴在一起

这个操作几乎不费时间。

所以,我们最终复杂度将是n^2/2 + nn2/2+n。和原来直接运行排序算法得到复杂度为n^2n2相比,我们节省了近一半的时间

由此,我们只需要将原序列划分一下,两边分别排序,最后将该序列合并,就能节省一半的时间(此时因为复杂度仍为平方级别,所以,我们只是在原算法的基础上优化一个常数)。

那么,我们能不能进一步优化该算法呢?

进一步优化

我们整理一下该算法的结构:

img

可以发现两个绿色部分的任务就是原排序问题的一个子问题 — 给n/2个元素排序

所以,我们完全可以将绿色的任务进一步分解,如下图:

img

在这里,我们再尝试算一下整个算法的复杂度:

  • 假如我们只看由长度为n/2的序列排序分解为两个长度为n/4的序列排序。那么,我们可以得到一个复杂度为
    ( n / 2 ) 2 / 2 + ( n / 2 ) = n 2 / 8 + ( n / 2 ) (n/2)^2 / 2 + (n/2) = n^2 / 8 + (n/2) (n/2)2/2+(n/2)=n2/8+(n/2)
    然后,我们重新分析长度为n的序列分解为两个长度为n/2的序列。可以得到

    1. 首先,我们将序列分为长度相等的大小两部分。此时我们需要将所有<=n/2的数字调出来放到一边,将所有>n/2的数字挑出来放到另一边。这个步骤相当于将所有数字看一遍,所以复杂度是O(n)
    2. 然后,我们使用原来掌握的排序算法,分别给分好的两个序列排序。因为两个序列的长度都是n/2,所以两边排序的复杂度都是n^2/8 + n/2。两部分排序的总复杂度是n^2/4 + n
    3. 最后,我们需要把两部分的排序结果贴在一起。这个操作几乎不费时间。

    所以该算法的总复杂度变成了n^2/4 + 2n。可以发现,在该算法中,多分解了一层,而复杂度也进一步减少了。

    由此我们可以产生一个感觉,分解得越复杂度越小。所以,我们完全可以进一步分解,直到最后每个单位排序的长度为1。

img

这个就是快速排序的基本思想。

1~n 元素排序

我们先思考一下刚刚描述的快速排序的基本思想

  1. 当需要将1nn个数排序时,我们通过分解,将该问题分解为两个将n/2个数排序的子问题;
  2. 在每个子问题中,我们继续分解,直到最后子问题长度为1;
  3. 此时,整个序列就完成排序了。

下面,我们以将1~8的数字排序为例,详细介绍一下整个过程:

算法开始前,整个序列:

img

第一层(子段长度为8

  1. 首先,我们找到4的位置(因为4刚好是8/2),

img

  1. 然后把<4的数字移动到序列左边,>4的数字移动到序列右边。此时,4的位置已经固定了。

img

第二层(子段长度为<=4

  1. 在这里,我们对4左边和右边的部分分别运行刚才的算法。

这是因为我们把<4>4的部分移动到4的两边以后,这两个部分形成了两个独立的子段

也就是说,在最终排好序的序列里,左边的元素不会移动到右边,右边的元素不会移动到左边。

所以,我们分别找出两边中点26的位置,

img

  1. 然后把<2 (or 6)的和>2 (or 6)的部分分别移动到数字两边。

img

至此,26的位置也固定下来了。

第三层(子段长度<=2

  1. 最后,我们仍需对2边和6两边的子段重复刚才的算法。

然而,2两边,以及6左边的子段长度已经只有1了,这说明这些元素也已经放在正确的位置上了,

只有6右边的部分长度仍然>1。所以,我们只需对这部分重复刚才的操作,于是我们寻找出中点7的位置,

img

  1. 最终,我们将8移动到7的右边,7的位置也放置正确了。

img

第四层(子段长度<=1

  1. 因为7右边部分子段长度为1,所以直接说明该子段元素位置是正确的。所以,我们就完成了对整个序列的排序。

img

任意长度为n的序列排序

当然快速排序也可用来给任意n个数的序列排序。但是与和1~n排序不同的是,对于任意n个数的序列,我们在划分子段的时候并不能很容易找到整个序列的“中位数”。所以只能在序列中任意取一个数。比如

  • 取整个序列中最左边的数。
  • 取整个序列中最右边的数。
  • 在整个序列中随机一个位置并取该位置上的数。

都是常见的取数策略。

但由于不能保证每次取的数字都刚好是中位数,所以每次划分时也不能保证左边子段长度和右边子段长度非常平均。如果“不幸”选到不合适的数(比如整个子段中最小的数或最大的数),整个算法的效率会降低很多。

在此,我们详细描述一下给任意n个数排序的快速排序算法

  1. 假设我们要对数组a[1..n]排序。初始化区间[1..n]
  2. lr分别为当前区间的左右端点。下面假设我们对lr子段内的数字进行划分。取pivot = a[l]为分界线,将<pivot的数字移到左边,>pivot的数字移到右边,然后将pivot放在中间。假设pivot的位置是k
  3. 如果左边区间[l..k-1]长度大于1,则对于新的区间[l..k-1],重复调用上面的过程。
  4. 如果右边区间[k+1..r]长度大于1,则设置新的区间[k+1, r],重复调用上面的过程。
  5. 当整个过程结束以后,整个序列排序完毕。
代码实现 —— 快速排序整体框架

回顾一下给任意n个数排序的快速排序算法

  1. 假设我们要对数组a[1..n]排序。初始化区间[1..n]
  2. lr分别为当前区间的左右端点。下面假设我们对lr子段内的数字进行划分。取pivot = a[l]为分界线,将<pivot的数字移到左边,>pivot的数字移到右边,然后将pivot放在中间。假设pivot的位置是k
  3. 如果左边区间[l..k-1]长度大于1,则对于新的区间[l..k-1],重复调用上面的过程。
  4. 如果右边区间[k+1..r]长度大于1,则设置新的区间[k+1, r],重复调用上面的过程。
  5. 当整个过程结束以后,整个序列排序完毕。

因为上面的算法描述中,出现了在步骤2~4里调用整个步骤的过程。如果我们用一个函数来表示整个步骤2~4,那么这个函数就出现了“调用自己”的情况。所以,我们用递归函数来实现这个过程。

// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include <bits/stdc++.h>
#define N 100010 
using namespace std; 
int n; 
int a[N]; 
 
void quick_sort(int l, int r) { 	
    // l和r分别代表当前排序子段在原序列中左右端点的位置
    // 设置最右边的数为分界线
    int pivot = a[r];
    int k;
    
    /* 此处省略了元素移动和确定分界线新位置k的过程 */
    
    if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
    if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
    // 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
    // 保证了左子段中的元素都小于等于分界线,左子段中的元素都大于分界线。所以整个序列也是有序的。
} 
 
int main() { 
    // 输入
    scanf("%d", &n); 
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); 
     
    // 快速排序
    quick_sort(1, n); 
    
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]);  
    return 0; 
} 
代码实现 —— 移动元素

假设教室有一排n个座位,每一个座位上坐了一位同学,一开始没有任何顺序。

现在老师要进行一个游戏,需要把男生女生分开,让男生坐到座位的左半边,女生坐到座位的右半边。怎么做呢?

很简单,从左到右枚举所有第一个、第二个、第三个……男生,依次和当前座位从左边开始的第一个、第二个、第三个……同学交换座位。假设一开始有m个男生,最终第m个男生一定会坐到从左到右数第m个位置。

那么假设老师一开始坐在一排的最右边,现在他要将自己的位置换到中间做裁判员。那么,当所有男生换到左边以后,他只需要和第m+1号座位的同学(一定是女生)交换位置就可以了。

所以,整个过程写成代码如下:

// 设置最右边的数为分界线,也就是上面情景描述中老师的位置
int pivot = a[r];
    
// 元素移动
int k = l - 1;                // 用来记录数字已经被用来交换的位置,此时<=k的部分一定都是
                              // < pivot的部分
for (int j = l; j < r; ++j) {	
    if (a[j] < pivot)         // 枚举所有的数字,并且找出小于pivot的数(也就是上面描述中的男生)
        swap(a[j], a[++k]); } // 将其与最前面的座位依次交换位置
swap(a[r], a[++k]);           // 最后老师自己与中间的同学换位置,所以pivot正好划分了<pivot
	                          // 和>pivot的数字

代码实现 —— sort函数使用

因为排序是一种非常常用的算法,但高效的排序算法,比如快速排序都比较复杂,而在很多场景中,易于实现的平方复杂度算法都不能满足我们对程序效率的需求,所以C++标准模板库STL)实现了一个排序函数sort,可以用于排序操作。

sort函数有三个参数,分别为头指针尾指针比较函数,其中如果排序对象定义了小于号的话,比较函数可省略。例如对于一个长为n的数组排序:

#include <bits/stdc++.h>
using namespace std;
int a[10] = {2, 3, 1, 5, 4};
int n = 5;
int main() {
    sort(a, a + n);  //sort函数的两个参数,头指针和尾指针
    for (int i = 0; i < n; ++i) cout << a[i] << ' ';
    cout << endl;
}

但如果想按照其他标准进行排序的话,也可以定义一个比较函数。比如下例中将数组a中元素从大到小排序

#include <bits/stdc++.h>
using namespace std;
int a[10] = {2, 3, 1, 5, 4};
int n = 5;
bool cmp(int x, int y) {  // 比较函数,函数的参数是当前比较的两个数组中的元素
    return x > y;         // x和y分别为排序数组中的两个元素。
}                         // 当函数返回值为true时,x应该排在y的前面。
int main() {
    sort(a, a + n, cmp);  // 比较函数作为第三个参数传入sort函数
    for (int i = 0; i < n; ++i) cout << a[i] << ' ';
    cout << endl;
}

如果想写成1-base的形式,只需将头尾指针相应更改即可。

1-base的意思是:从索引为1的元素开始,0号元素无意义

#include <bits/stdc++.h>
using namespace std;
int a[10] = {0, 2, 3, 1, 5, 4};  // 1-base,0号元素无意义
int n = 5;
bool cmp(int x, int y) {         // 比较函数,函数的参数是当前比较的两个数组中的元素
    return x > y;
}
int main() {
    sort(a + 1, a + n + 1, cmp); // 比较函数作为第三个参数传入sort函数
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
}
复杂度分析
空间复杂度

**首先该算法的空间复杂度是O(n),具体来说,在整个排序过程中,元素的移动都在原始数组中进行。所以快速排序是一种原地排序算法

时间复杂度

可以看出,在「详细算法描述」中,我们的算法分为若干层。每一层中都是分治法的三个步骤:我们首先进行问题拆分,然后进入下一层,下一层的问题解决后,我们返回这一层进行子问题解的合并

img

我们首先分析对1~nn个数字进行快速排序的情况。

在每一层中,问题拆分的复杂度是O(n)*O*(*n*),因为我们移动数组元素的时候,需要将每个子段扫一遍。那么把所有层的子段一起看,就相当于在每一层都把整个序列完整扫了一遍。对于子段解的合并,其复杂度是O(1)O(1),因为有分界线的存在,当我们把左边和右边都排好序后,它们和分界线元素一起天然形成了原序列的完整排序。

那么一共有多少层呢?因为每次我们都知道当前子段的中位数,所以可以保证每次划分,两个字段长度比较平衡,所以下一层子段的长度都比上一层减少了一半,直到长度为1算法停止。所以整个算法有\log nlog*n*层

那么我们分析出在这种情况下,算法的复杂度是O(n\log n)O(nlogn)。这样,在1秒之内,计算机能非常轻松地排序10^6106及以上的数据

但对于任意n个数的排序,每次划分情况取决于选取的分界线情况。如果每次分界线刚好取到最小值或者最大值,会导致划分时所有数字都会移动到同一边,整个算法的复杂度也会下降为O(n^2)*O*(*n*2)。如下图:

img

我们很容易想到两种尽量避免出现这种情况的方法:

  1. 在排序之前,先把整个数组随机打乱顺序
  2. 在选取分界线时,与之前固定选取某个位置的方法相比,我们换成随机选择分界线的位置

这两种方法都能极大概率避免上面提到的极端情况的发生。

分治思想

快速排序用到的一个很重要的思想就是分治思想,也是分治法运用在排序中的很重要的实例。

分治思想是一种“分而治之”的思想,反应在解决问题当中,就是将一个复杂问题不断分解为规模更小、更容易解决的问题,从而提升解决问题的效率。而分治法就是基于分治思想得到的解决问题的方法,它分为下面三个步骤:

  1. 问题的拆分。例如在快速排序中,我们以某个元素为分界线,将待排序的数字分为两部分。
  2. 解决子问题。例如在快速排序中,如果子问题的规模为1,我们就直接解决它,否则,我们就使用和划分主问题同样的办法继续划分子问题直到子问题规模达到很容易直接解决为止。
  3. 合并子问题的解。例如在快速排序中,我们将左边右边分别排序后,将前后排好序的部分与中间的分界线连接,形成主问题的解。

分治思想在算法领域有非常广泛的应用,在很多分解和合并都非常容易的问题上,分治法都能够提升其算法效率。

总结
  • 快速排序是一种基于分治法的排序。其基本思想在于固定一个分界线,将整个序列按照小于分界线和大于分界线划分,然后再分别对划分好的子段进行排序。

  • 快速排序的时间复杂度在理想情况下是O(n \log n)*O*(*n*log*n*),但如果选取分界线每次都是子段中的最大值或最小值的话,时间复杂度可能会退化到O(n^2)*O*(*n*2)。在内存使用上,因为整个移动过程都在原数组中进行的,所以属于原地排序。

  • sort函数是C++标准模板库(STL)中一种对快速排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。

  • 快速排序示例:

    将数组{2, 3, 1, 5, 4}从小到大排列。

  • 不使用sort函数

    将「整体框架」和「移动元素」进行合并,我们得到快速排序完整代码:

// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include <bits/stdc++.h>
#define N 100010 
using namespace std; 
int n; 
int a[N]; 
 
void quick_sort(int l, int r) { 
    // 设置最右边的数为分界线
    int pivot = a[r];
    
    // 元素移动
    int k = l - 1;
    for (int j = l; j < r; ++j)
        if (a[j] < pivot) swap(a[j], a[++k]); 
    swap(a[r], a[++k]); 
    
    if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
    if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
    // 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
    // 保证了左子段中的元素都小于等于分界线,左子段中的元素都大于分界线。所以整个序列也是有序的。
} 
 
int main() { 
    // 输入
    scanf("%d", &n); 
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); 
     
    // 快速排序
    quick_sort(1, n); 
    
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]);  
    return 0; 
} 
  • 使用sort函数

    sort函数有三个参数,分别为头指针尾指针比较函数,其中如果排序对象定义了小于号的话,比较函数可省略。例如对于一个长为n的数组排序:

#include <bits/stdc++.h>
using namespace std;
int a[10] = {2, 3, 1, 5, 4};
int n = 5;
int main() {
    sort(a, a + n);  //sort函数的两个参数,头指针和尾指针
    for (int i = 0; i < n; ++i) cout << a[i] << ' ';
    cout << endl;
}

归并排序

基本思想

回顾快速排序的基本思想:找出一个分界线,并以该分界线为界将数组分为两部分,在对这两部分以同样的方式分别排序。

但是!快速排序作为一种高效率的算法,它的缺点在于算法的复杂度很依赖分界线的选取。因为这决定了左右两边划分的元素个数是否平衡

那么,我们能不能变通一下,规避这种情况带来的效率损失呢?

回忆快速排序中,正是因为分界线的存在,使得划分到两边的数字排好序后,通过分界线的连接,能够天然形成原序列的排序。但如果我们不设置分界线,而是直接把序列从中间分成相等的两份,然后两边分别排序,我们仍然可以很快地将两个有序序列合并成原序列的有序序列吗?

考虑右侧上下两个序列:

img

假如我们想找出这个两个序列中的最小值,它有可能出现在哪些位置呢?

因为上面的序列中,第一个元素最小,而下面的序列中,也是第一个元素最小。所以最小值只可能出现在最左边两个元素中的一个

img

所以通过比较最左边元素的大小,我们很容易就知道答案序列中第一个元素是1

img

同样的,剩下元素的最小值也必然出现在剩下两个序列的最左边,所以通过比较23的大小,我们能很容易确定答案序列中排第二的数字是2

img

通过不断重复这个过程,最终我们将两个有序序列合并成了一个有序序列。

img

所以,我们可以用和快速排序一样的框架,即分治法,来设计一个不依赖于分界线选择的排序算法。

回顾分治法的内容:

分治思想是一种“分而治之”的思想,反应在解决问题当中,就是将一个复杂问题不断分解为规模更小、更容易解决的问题,从而提升解决问题的效率。而分治法就是基于分治思想得到的解决问题的方法,它分为下面三个步骤:

  1. 问题的拆分。例如在快速排序中,我们以某个元素为分界线,将待排序的数字分为两部分。
  2. 解决子问题。例如在快速排序中,如果子问题的规模为1,我们就直接解决它,否则,我们就使用和划分主问题同样的办法继续划分子问题直到子问题规模达到很容易直接解决为止。
  3. 合并子问题的解。例如在快速排序中,我们将左边右边分别排序后,将前后排好序的部分与中间的分界线连接,形成主问题的解。

分治思想在算法领域有非常广泛的应用,在很多分解和合并都非常容易的问题上,分治法都能够提升其算法效率。

分治法快速排序的区别在于,划分的时候不需要设置分界线,而是直接将序列分成大小相等的左右两份。但合并时,因为没有分界线的存在,我们需要上面介绍的合并操作来将两个有序序列互相穿插,从而合并成一个有序序列。

在这里,我们将上面的合并操作称为“归并操作”。而基于分治法归并操作形成的排序算法,我们将其称为归并排序

详细算法描述

基于分治法的排序算法基本流程如下:

img

可以看到,在算法的前半部分,整个算法在不断将序列分解,而在后半部分,整个算法在不断把子段合并。所以,我们分别来解释这两部分是怎样运行的。

分解阶段

在分解阶段,我们一步一步将其分解到长度为1的子段。注意在这个阶段,我们仅仅是划分,并不改变元素在数组中的位置

img

合并阶段

我们按照和刚才相反的顺序,自底向上合并。

首先在最底层,子段长度为1,每个字段已经是有序序列(因为长度为1的序列本身就是有序序列)。

img

回到上一阶段,子段长度为2。为了让每个子段中的序列都有序,我们需要将序列两两合并。

img

再回到上一层,子段长度为4。我们需要用同样的办法把相邻两个长度为2的有序子段合并。

img

最后,我们将两个长度为4的子段合并,就能得到完整的排好序的序列。

img

归并排序算法过程

所以,我们总结一下归并排序的算法过程

  • 假设我们要对数组a[1..n]排序。初始化左端点l=1,右端点r=n
  • 下面假设我们对lr子段内的数字进行划分。取lr的中点mid,将lmid的元素看成第一个子段的部分,将mid+1r的部分看成第二个子段的部分。两边分别进入下一层,重复调用上面的过程。直到子段长度为1,返回上一层。
  • 当算法阶段返回到当前层时,使用归并操作合并下一层的左右两个有序序列,形成本层的有序序列,继续返回上一层。
  • 当整个过程结束以后,整个序列排序完毕。
代码实现
归并排序整体框架

首先,我们再来看一下归并排序的算法过程:

  • 假设我们要对数组a[1..n]排序。初始化左端点l=1,右端点r=n
  • 下面假设我们对lr子段内的数字进行划分。取lr的中点mid,将lmid的元素看成第一个子段的部分,将mid+1r的部分看成第二个子段的部分。两边分别进入下一层,重复调用上面的过程。直到子段长度为1,返回上一层。
  • 当算法阶段返回到当前层时,使用归并操作合并下一层的左右两个有序序列,形成本层的有序序列,继续返回上一层。
  • 当整个过程结束以后,整个序列排序完毕。

和快速排序一样,在实现归并排序时我们仍然使用递归函数的方式。具体框架如下:

#include <bits/stdc++.h>
#define N 100010
using namespace std;
int n;
int a[N];

void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
    if (l >= r) return;         // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
    int mid = l + r >> 1;       // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
                                // l + r 的值右移1位,相当 l + r 的值除以2取整。
    merge_sort(l, mid);         // 对``l``到``mid``第一个子段进行归并操作
    merge_sort(mid + 1, r);     // 对``mid+1``到``r``第二个子段子段进行归并操作
	
    /* 这里省略将数组a[l..mid]和数组a[(mid+1)..r]合并的过程。 */
}

int main() {
    // 输入
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
	
    // 归并排序
    merge_sort(1, n);
	
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]); 
    return 0;
}
归并操作

在归并操作的时候,我们使用一个辅助数组b,先把待合并的部分整个复制到b数组里,如下图:

img

然后,我们用k枚举原序列中lr的位置,依次从b数组中挑选元素填入当前位置k中。我们维护两个指针ij,分别指向两个子段的最小元素。如果:

  1. j已经移出子段的末尾;
  2. 或者ij都仍然指向子段中的元素,但i指向的元素比j指向的元素小;

那么我们将i指向的元素填到k的位置,并且将i后移。否则,就将j指向的元素填写到k的位置。详细代码如下:

int b[N]; // 辅助数组

void merge(int l, int r) {
    for (int i = l; i <= r; ++i) b[i] = a[i];	// 将a数组对应位置复制进辅助数组
    
    int mid = l + r >> 1;			// 计算两个子段的分界线
    int i = l, j = mid + 1;			// 初始化i和j两个指针分别指向两个子段的首位
    for (int k = l; k <= r; ++k) {	// 枚举原数组的对应位置
        if (j > r || i <= mid && b[i] < b[j]) a[k] = b[i++]; // 上文中列举的条件
        else a[k] = b[j++];
    }
}

归并排序完整代码

将前两步骤中「整体框架」和「归并操作」进行合并,就能得到完整的归并排序代码:

#include <bits/stdc++.h>
#define N 100010
using namespace std;
int n;
int a[N], b[N];

// 合并操作
void merge(int l, int r) {
    for (int i = l; i <= r; ++i) b[i] = a[i]; // 将a数组对应位置复制进辅助数组
    
    int mid = l + r >> 1;           // 计算两个子段的分界线
    int i = l, j = mid + 1;         // 初始化i和j两个指针分别指向两个子段的首位
    for (int k = l; k <= r; ++k) {  // 枚举原数组的对应位置
        if (j > r || i <= mid && b[i] < b[j]) a[k] = b[i++]; // 上文中列举的条件
        else a[k] = b[j++];
    }
}

void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
    if (l >= r) return;         // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
    int mid = l + r >> 1;       // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
    merge_sort(l, mid);
    merge_sort(mid + 1, r);
    merge(l, r);                // 将l..mid和mid+1..r两个子段合并成完整的l..r的有序序列
}

int main() {
    // 输入
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
	
    // 归并排序 
    merge_sort(1, n);
	
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]); 
    return 0;
}
代码实现 —— stable_sort函数使用

同样,归并排序实现起来也并不容易,所以STL中也有对归并排序的优化实现,函数名为stable_sort。使用方法与sort一样,见下例:

#include <bits/stdc++.h>
using namespace std;
int a[10] = {0, 2, 3, 1, 5, 4}; // 1-base,0号元素无意义
int n = 5;
bool cmp(int x, int y) {        // 比较函数,函数的参数是当前比较的两个数组中的元素
    return x > y;               // x和y分别为排序数组中的两个元素。
}                               // 当函数返回值为true时,x应该排在y的前面。
int main() {
    stable_sort(a + 1, a + n + 1, cmp);    // 比较函数作为第三个参数传入sort函数
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
}

之所以该函数叫做stable_sort,是因为归并排序是稳定排序,而快速排序不是稳定排序(这是因为选择作为分界线的点在不同实现中位置可能不一样。对于有些实现,当i被选为分界点,且该位置的值是a[i]时,它右边和a[i]相等的元素很有可能被换到i的左边,这时就破坏了稳定性)。回忆稳定排序的概念:

稳定性描述的是对于有重复元素的序列,如果排序前后,重复的元素相对位置不变。这种叫做稳定算法,否则就是不稳定。参考下面的示意图:

img

右侧代码部分语句被挖空了,请在右侧代码中补全。运行验证正确后提交才可以解锁下一步哦~

复杂度分析
空间复杂度

首先该算法的空间复杂度是O(n)*O*(*n*),但尽管如此,在整个排序过程中,元素的移动借助了另一个辅助数组。所以归并排序是一种非原地排序算法

时间复杂度

因为归并排序有着和快速排序一样的框架,所以我们仍然通过分别分析每一层的时间复杂度总层数来分析总时间复杂度。

在每一层中,问题拆分的复杂度是O(1),这是因为我们只是单纯分解,并没有枚举或者移动元素,唯一的操作仅是计算位置的分界线。对于子段解的合并,其复杂度是O(n),因为对于每个子段,我们需要将其枚举每个位置进行填写。而如果我们同时考虑整层的操作,总枚举的范围就是整个数组的范围。

那么一共有多少层呢?因为归并排序每次都是将序列平分,所以下一层子段的长度一定比上一层减少了一半,直到长度为1算法停止。所以整个算法有log(n)

所以归并排序的复杂度在任何情况下都是O(n log(n))

计数排序

基本思想

如果给出下面100个数字,要求用肉眼给它们排序。

17, 88, 21, 73, 80, 10, 71, 73, 40, 50
98, 3, 100, 82, 71, 86, 65, 2, 68, 23
81, 6, 43, 35, 3, 75, 14, 81, 12, 34
90, 10, 12, 42, 88, 61, 61, 72, 43, 23
41, 71, 31, 13, 63, 72, 72, 18, 50, 32
82, 21, 97, 62, 28, 2, 78, 88, 77, 29
10, 44, 70, 59, 79, 55, 31, 96, 1, 47
32, 20, 70, 18, 79, 87, 80, 59, 58, 13
47, 55, 23, 12, 38, 7, 92, 5, 82, 97
91, 90, 29, 16, 66, 42, 18, 77, 16, 42

这一定是个比较困难的问题。

但如果这100个数字长成下面这个样子:

1, 0, 1, 0, 1, 0, 1, 1, 1, 0
1, 0, 1, 0, 1, 0, 1, 0, 1, 0
0, 0, 0, 0, 1, 1, 0, 1, 0, 0
1, 0, 0, 1, 0, 1, 0, 1, 1, 0
0, 0, 1, 0, 1, 0, 1, 0, 1, 1
1, 0, 1, 1, 1, 1, 0, 1, 0, 1
1, 0, 1, 0, 0, 1, 0, 0, 0, 1
1, 1, 1, 0, 0, 1, 0, 1, 1, 0
0, 0, 0, 1, 1, 1, 0, 1, 0, 1
1, 1, 1, 0, 0, 1, 0, 0, 1, 1

我们会发现这100个数字只有01两种情况,而假设要求将该序列从小到大排序,那么序列中的0一定会出现在1的前面。所以我们只需要统计0的个数和1的个数(假设有a0b1),在写答案时,先写a0,再写b1即可。

上面的例子体现了计数排序的基本思想:

假设我们已知在待排序的序列中,值都是整数并且出现在一个很小的范围内,例如[0..1000]。那么,我们可以通过:

  1. 分别统计每一种可能的值在序列中出现的次数
  2. 从小到大(假设要求将序列从小到大排序)枚举所有值,按照出现次数输出对应个数

来完成排序过程。

计数排序算法描述

给定长度为n的序列,假设已知序列元素的范围都是[0..K]中的整数,并且K的范围比较小(例如106106,开长度为106106左右的int类型数组所占用的内存空间只有不到4M)。解决该问题的计数排序算法描述如下:

  1. 使用整数数组cnt统计[1..K]范围内所有数字在序列中出现的个数。
  2. 使用变量i枚举1K,如果i出现了cnt[i]次,那么在答案序列的末尾添加cnt[i]i

下图是一个n=6, K=3的例子:

img

值得一提的是,如果元素的范围可以被很容易转换到[0..K],我们也可以使用计数排序。如果元素范围是[A..B],我们可以通过简单的平移关系将其对应到[0..B-A]上。或者所有数值均为绝对值不超过100的两位小数,那么我们可以通过将所有数字放大100倍将其转换为整数。

找出原序列中元素在答案中的位置

在有些场景中,比如我们根据(key, value)中的key关键字进行排序,如果只是使用上面的计数排序,我们无法将value放到相应的key在答案序列中的对应位置中。但是,如果我们可以将原序列和答案序列元素的位置对应求出来,那么这个问题就能得到解决。

img

试想,对于原序列中的数字x,它排序后的位置可能出现在哪里呢?

因为在排序后的序列中,假设x第一次出现的位置是i,最后一次出现的位置是j,那么i之前的元素一定比x小,j出现的位置之后的元素一定比x大。假设原序列中<x元素的个数是A,≤x的元素个数是B,那么x可能出现的位置一定是[(A+1)..B]

sum数组的求法和意义

那么,我们怎样求出AB呢?假设我们对cnt数组求前缀和,如下图所示,cnt数组元素求前缀和后为sum数组:

img

这里,我们指出**sum数组的意义**:对于一个序列中可能出现的值xsum[x]的含义是“小于等于x的数字个数”,同时,也可以看作指向答案序列中最后一个x出现的位置的指针。

利用sum数组分配位置

所以对于值x,AA即为sum[x - 1],BB 即为sum[x]x出现的排名为[(sum[x - 1] + 1)..sum[x]],等价于[(sum[x] - cnt[x] + 1)..sum[x]]。我们将sum数组的位置标出来:

img

然后我们从后往前扫描每个元素,把它填到当前的sum对应值指向的格子中,并把sum向前移动。如下图:

img

有了原序列和答案序列的位置对应,我们也可以据此将对应元素放入答案数组中。所以该版本的计数排序算法描述如下:

  1. 统计原序列中每个值的出现次数,记为cnt数组。
  2. 从小到大枚举值的范围,对cnt数组求前缀和,记为sum数组。
  3. 从后往前枚举每个元素a[i],分配其在答案中的位置idx[i]为当前的sum[a[i]],也就是将其放在所有值等于a[i]中的最后一个。并且将sum[a[i]]减少1,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]前面一个。

基于分治法的排序算法基本流程如下:

代码实现

首先,我们再来看一下计数排序的算法描述:

  1. 统计原序列中每个值的出现次数,记为cnt数组。
  2. 从小到大枚举值的范围,对cnt数组求前缀和,记为sum数组。
  3. 从后往前枚举每个元素a[i],分配其在答案中的位置idx[i]为当前的sum[a[i]],也就是将其放在所有值等于a[i]中的最后一个。并且将sum[a[i]]减少1,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]前面一个。
计数排序代码实现

下面我们给出计数排序的简单实现:

#include <bits/stdc++.h>
#define N 1000005
#define K 1000001    // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];    // 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 维护最终有序序列
    for (int i = 0, j = 0; i < K; ++i)      // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
        for (int k = 1; k <= cnt[i]; ++k)   // 根据该值出现的次数
            b[++j] = i;                     // 添加对应个数的i到答案序列
	
    // 输出
    for (int i = 1; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}

其中:

  • 在计数排序的输入部分,我们用cnt数组统计了每种值出现的个数。
  • 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应cnt个元素到答案数组里。
找出原序列中的元素和答案数组中的对应

这里,我们给出另外一种计数排序的实现方法。其中

  • 在输入部分,我们统计每一种值出现的次数
  • 在求原序列和答案序列的位置对应关系的部分,我们对cnt数组求前缀和,并存储在sum中。回忆上一节提到,对于一个值xsum[x]的含义是“小于等于x的数字个数”,同时,也可以看作指向答案序列中最后一个x出现的位置的指针。
  • 然后,我们从后向前枚举原序列的每个元素x,将sum[x]指向的位置分配给它,存在idx数组中,然后将sum[x]前移。这里“从后向前”是因为考虑到对于同一个值,分配位置的顺序是从后向前。所以,我们从后向前枚举原序列,可以保证在值相同的情况下在原序列中出现在后面的元素会被分配到更大的位置,也就保证列排序的稳定性
  • 因为原序列中i位置的数字,在答案序列中出现在idx[i]处。所以我们据此生成答案序列。
#include <bits/stdc++.h>
#define N 1000005
#define K 1000001    // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K], sum[K];
int idx[N];    // 用来记录原序列中每个元素在新序列中的位置
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];    // 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 求原序列和答案序列中的位置对应
    sum[0] = cnt[0];               // 假设最小值为0
    for (int i = 1; i < K; ++i)    // 求cnt的前缀和
        sum[i] = sum[i - 1] + cnt[i];
    for (int i = n; i; --i)        // 给每个元素分配位置
        idx[i] = sum[a[i]]--;      // 之所以倒循环,是因为对于相等的元素我们是从后向前分配位置
                                   // 这样我们可以保证排序的稳定性
    
    // 根据求出的位置将每个元素放进答案序列中
    for (int i = 1; i <= n; ++i)
        b[idx[i]] = a[i];
	
    // 输出
    for (int i = 0; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}
复杂度
计数排序代码简单实现

这里我们分析第一种计数排序实现方法。

#include <bits/stdc++.h>
#define N 1000005
#define K 1000001    // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K], sum[K];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];	// 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 维护最终有序序列
    for (int i = 0, j = 0; i < K; ++i)      // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
        for (int k = 1; k <= cnt[i]; ++k)   // 根据该值出现的次数
            b[++j] = i;                     // 添加对应个数的i到答案序列
	
    // 输出
    for (int i = 1; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}

其中

  • 在计数排序的输入部分,我们用cnt数组统计了每种值出现的个数。
  • 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应cnt个元素到答案数组里。

空间复杂度

因为在上面的代码中一共开了3个数组,长度分别为O(N)O(N)(对于ab)和O(K)O(K)(对于cnt)。整个空间复杂度为O(N + K)

时间复杂度

容易发现,算法的输入输出部分所占时间复杂度为O(n)O(n)。

在“维护有序序列”的部分,我们首先考虑最外层循环,因为它遍历了所有[0..K]的数字,所以它的复杂度是O(K)O(K)。

其次,我们考虑内层循环的循环次数,其在外层循环为i时为cnt[i]。因为对于不同的输入,以及外层循环枚举到的不同的icnt[i]差别很大。但如果我们把所有i对应的内层循环次数相加,即可得到:

\text{内层循环总次数} = \sum_{i = 1}^{K} cnt[i] = n内层循环总次数=i=1∑Kcn**t[i]=n

所以,整个算法的复杂度为O(n + K)O

我们提到过,有一条结论

所有基于比较的排序算法的时间复杂度都为\Omega(n\log n)Ω(nlogn)。(\OmegaΩ和OO记号类似,但OO表示的是“不会超过”,而\OmegaΩ表示的是“不会少于”)。

我们看到当K = O(n)K=O(n)时,整个算法的时间复杂度为O(n)O(n)。之所以计数排序可以达到比O(n\log n)O(nlogn)更好的时间复杂度,就是因为它并不是基于比较的排序

对于基于原序列和答案序列位置对应设计的计数排序,经过分析可以发现其复杂度和第一种一样。大家可以自己尝试分析一下。

二分查找

猜数游戏的策略——二分查找

一个优秀的例子

从数字1开始慢慢地一个一个地问实在是比较笨,我们不难想到一些方法:询问100、200、300、400……来确定答案位于哪个区间内,然后在这个区间内故技重施(假设答案在300和400之间),询问310、320、330、340……来缩小这个区间。当这个区间足够小的时候,我们再对区间中的每个数字挨个询问。

实际上,每次询问当前区间的中点是最优的策略。我们根据主持人的回答来确定最终答案是位于哪半边区间里面。由于我们每次会把当前区间的长度大约除以2,因此我们在大约10次询问之后,就一定能知道最终答案。下面这个游戏记录就展示了这个策略。

img

如何用程序语言描述这样一个策略?为什么这种策略是最好的呢?请先思考思考~

通用的游戏策略

用伪代码描述这样一个策略,就是下面这段:

int L = 区间左端点;
int R = 区间右端点; // 闭区间
while( L < R ) { // 区间内有至少两个数字
    int M = L+(R-L)/2; // 区间中点
    if( M是答案 ) 答对啦;
    else if( M比答案小 ) L = M+1;
    else R = M-1; // M比答案大
}
// 若运行到这里,因为答案一定存在,所以一定有L==R,且L是答案

这也是所谓二分查找的思路:我们设定一个初始的L和R,保证答案在[L,R]中,当[L,R]中不止有一个数字的时候,取区间的中点M,询问这个中点和答案的关系,来判断答案是M,还是位于[L,M-1]中,还是位于[M+1,R]中。

这里有一个二分查找的可视化过程,你可以进入网站体验一下二分查找的步骤过程。

虽然二分查找的方法能够帮助我们快速找到答案,但我们还有很多很多的细节问题没有处理,比如:

  • 如果循环最后因为不满足L < R条件而退出,这时候L和R到底是什么关系?答案是什么?
  • 如果答案不存在会怎么样?

我们暂时并不需要考虑这些问题,因为我们现在的目标是“理解二分查找的思路,并学会手算二分查找”。既然是手算,那么当区间内剩余的数字个数寥寥无几的时候,我们只要挨个询问一遍就行了,这并不影响二分查找的复杂度。

为什么二分查找的方法是最优的呢?

回顾我们刚刚讲到的“最优”的定义:在最差的情况下,我们进行询问的次数最少。

而这个猜数游戏的本质是什么?我们每进行一次询问,就可以排除一些错误答案。

img

如图,当我们询问M位置的时候,主持人告诉我们的信息实际上帮助我们排除了一部分错误答案,从而缩小正确答案所在的区间长度。

因此,在最差的情况下,如果我们询问的位置不是区间的中点,那么主持人帮我们排除掉的区间一定是长度较短的那部分。

所以,只有当我们询问区间中点的时候,我们才能让可行区间的长度以最快的速度变短——每次大约变为原来长度的一半,所以二分查找的时间复杂度是log_2(n)

二分查找时间复杂度的计算方法:

比如,在猜数字的游戏中,假设我们一开始有n个数字。每次把剩余数字的区间分成两半,直到x次后只剩下最后一个数字,就是我们想要的答案啦。 计算公式如下:

n ∗ 1 / ( 2 x ) = 1 n * 1/(2^x) = 1 n1/(2x)=1

x次后只剩下最后一个数字

x = l o g 2 ( n ) x = log_2(n) x=log2(n)

那么,x的值就是log n咯

这就是为什么二分查找方法是最快的方法。

数组上的二分查找

现在,让我们从游戏回到编程问题。

首先,来看一个二分查找问题的最经典的应用:

你有一个长度为n的排好序的数组a,你需要在log_2(n)的时间复杂度内求出数组a中第一个大于等于x的元素是多少,或者输出“数组a中不存在大于等于x的元素”。

我们现在并不要求写出通用代码,只要会手算二分查找即可,因此我们用下面这个数组作为例子,一步一步地来看看二分查找算法是如何运行的。

因为数组是排好序的,所以我们可以直接根据最后一个数来判断“数组中是否存在大于等于x的元素”。现在我们假设x = 12,需要在下面这个数组中用二分查找法找到第一个大于等于12的元素。

img

1、最开始有L = 0, R = 9, M = 4,我们检查a[M]和12的关系,发现a[M] < 12,因此符合条件的数字一定在a[M+1]a[R]这些数中,所以令L = M+1,继续进行二分查找。

img img

2、现在有L = 5, R = 9, M = 7,我们发现a[M] >= 12,因此符合条件的数字一定在a[L]a[M]这些数字中,所以令R = M(注意不是R = M-1),继续进行二分查找。

那么,这里为什么令R = M而不是R = M-1呢? 这是因为a[M]也可能是答案,因为我们要找的是第一个大于等于12的数字,所以a[M]不能被排除在外。

img

3、现在有L = 5, R = 7, M = 6,我们重复刚才的步骤,发现a[M] >= 12,于是令R = M,继续进行二分查找。

img

4、此时有L = 5, R = 6, M = 5,我们发现a[M] >= 12,于是继续令R = M

img

5、最后,这时候已经有L = R了,这意味着a[L] = 13就是我们要找的答案咯。

现在我们来看一下二分查找这个神奇的算法:

  • 二分查找的原理:每次排除掉一半答案,使可能的答案区间快速缩小。
  • 二分查找的时间复杂度:log_2(n),因为每次询问会使可行区间的长度变为原来的一半。
  • 我们再来看一下二分查找的思路:我们设定一个初始的L和R,保证答案在[L,R]中,当[L,R]中不止有一个数字的时候,取区间的中点M,询问这个中点和答案的关系,来判断答案是M,还是位于[L,M-1]中,还是位于[M+1,R]中。二分查找的伪代码如下:
int L = 区间左端点;
int R = 区间右端点; // 闭区间
while( L < R ) { // 区间内有至少两个数字
    int M = L+(R-L)/2; // 区间中点
    if( M是答案 ) 答对啦;
    else if( M比答案小 ) L = M+1;
    else R = M-1; // M比答案大
}
// 若运行到这里,因为答案一定存在,所以一定有L==R,且L是答案

正如之前说的,二分查找中其实还有很多细节问题没有处理,比如:

  • 如果循环最后因为不满足L < R条件而退出,这时候L和R到底是什么关系?答案是什么?
  • 如果答案不存在会怎么样?

但没有关系,这些问题在后面的课程中,我们会一一来解决的。

代码讲解

我们先来回顾一下二分查找的思路:我们设定一个初始的L和R,保证答案在[L,R]中,当[L,R]中不止有一个数字的时候,取区间的中点M,询问这个中点和答案的关系,来判断答案是M,还是位于[L,M-1]中,还是位于[M+1,R]中。一般二分查找的伪代码如下:

int L = 区间左端点;
int R = 区间右端点;            // 闭区间
while( L < R ) {             // 区间内有至少两个数字
    int M = L + (R - L) / 2; // 求出区间中点
    if( M是答案 ) 答对啦;
    else if( M比答案小 ) L = M+1;
    else R = M-1;            // M比答案大
}
// 若运行到这里,因为答案一定存在,所以一定有L==R,且L是答案

数组上的二分查找

在一个排好序的数组上找到第一个大于等于x的数字的位置(假设数组是从小到大排好序的)。

问题: 输入n,x,以及一个长度为n的数组a(已经从小到大排好序了)

输出数组a中最左边的大于等于x的数字的下标,数组下标从0开始

输入数字都是1000000000以内的非负整数。数组长度不超过50000。若数组中不存在大于等于x的数字,输出-1

比如你要在下面这个数组中找到第一个大于等于12的数字的位置,用二分查找应该怎么做呢?请先思考一下再进入下一步~

img

在升序的数组上进行二分查找

在一个排好序的数组上二分查找一个数字x,一般都可以变成如下的问题:在数组中找到第一个大于等于x的数字的位置(假设数组是从小到大排好序的)。

问题: 输入n,x,以及一个长度为n的数组a(已经从小到大排好序了)

输出数组a中最左边的大于等于x的数字的下标,数组下标从0开始

输入数字都是1000000000以内的非负整数。数组长度不超过50000。若数组中不存在大于等于x的数字,输出-1

首先,我们先来运行看看在升序的数组上进行二分查找算法的代码:

#include <iostream>
using namespace std;

int n, x, a[100000];

int main() {
    cin >> n >> x;
    // 输入数组
    for( int i = 0; i < n; ++i ) 
        cin >> a[i];
    // 考虑数组中不存在大于等于x的数字的情况
    if( x > a[n-1] ) {           
        cout << -1 << endl;
        return 0;
    }
    
    // 二分查找
    int L = 0, R = n-1;          // 数组下标从0到n-1,闭区间
    while( L < R ) {             // 当区间中至少有两个数字的时候,需要继续二分
        int M = L + (R - L) / 2; // 求出区间中点
        if( a[M] < x ) {         // 答案一定出现在[M+1,R]中
            L = M+1;
        } else {                 // a[M] >= x,答案一定出现在[L,M]中
            R = M;
        }
    }
    
    // 此时L == R,a[L]就是第一个大于等于x的数字
    if ( a[L] == x) {
        cout << L << endl;  // 如果答案存在,则输出答案
    } else {
        cout << -1 << endl; // 如果答案不存在,则输出-1
    }
    
    return 0;
}
一些特殊情况

请手动模拟一下这段代码在下面数组上的运行过程,体会一下这段代码是如何处理一些边界情况的。

img

  • 比如:答案不存在的情况我们是如何处理的?
  • 比如:当区间内只有两个数字的时候,这段代码还能正常运行吗?
  • 比如:数组中有很多个重复元素的时候,这段代码还能正常运行吗?
  • 比如:为什么循环结束之后一定有L == R?为什么不会出现L > R的情况?

请自己写一些简单情况出来,并手动模拟运行这段代码,想一想为什么这段代码不会出错。

  • 比如:在2 3这个数组中找到第一个大于等于3的元素。
  • 比如:在2 3 3 3 3 4 4 4 4这个数组中找到第一个大于等于4的元素。
代码实现
在升序的数组上进行二分查找

在一个排好序的数组上找到第一个大于等于x的数字的位置(假设数组是从小到大排好序的)。

问题: 输入n,x,以及一个长度为n的数组a(已经从小到大排好序了)

输出数组a中最左边的大于等于x的数字的下标,数组下标从0开始

输入数字都是1000000000以内的非负整数。数组长度不超过50000。若数组中不存在大于等于x的数字,输出-1

首先,我们来看看在升序的数组上进行二分查找算法的伪代码:

int L = 0;
int R = n-1;                // 数组下标从0到n-1,闭区间
while( L < R ) {            // 区间内有至少两个数字
    int M = L+(R-L)/2;      // 求出区间中点
    if( M比答案小 ) L = M+1; // 答案一定出现在[M+1,R]中
    else R = M;             // a[M] >= x,答案一定出现在[L,M]中
}
// 此时L == R,a[L]就是第一个大于等于x的数字
#include <iostream>
using namespace std;

int a[] = {2,3,3, 3, 3, 4 ,4, 4, 4}; // 示例,请修改不同数组测试,
int n = 10; // 数组长度。修改数组时需要对应修改。
int x;

int main() {
    cin >> x; // 输入x
    if( x > a[n-1] ) {           
        cout << -1 << endl;
        return 0;
    }
    
    // 二分查找
    int L = 0, R = n-1;          // 数组下标从0到n-1,闭区间
    while( L < R ) {             // 当区间中至少有两个数字的时候,需要继续二分
        int M = L + (R - L) / 2; // 求出区间中点
        if( a[M] < x ) {         // 答案一定出现在[M+1,R]中
            L = M+1;
        } else {                 // a[M] >= x,答案一定出现在[L,M]中
            R = M;
        }
    }
    
    // 此时L == R,a[L]就是第一个大于等于x的数字
    if ( a[L] >= x) {
        cout << L << endl;  // 如果答案存在,则输出答案
    } else {
        cout << -1 << endl; // 如果答案不存在,则输出-1
    }
    
    return 0;
}
倒过来怎么做

那现在,如果你面临一个新的问题:

  • 有一个从小到大排好序的数组,你要找到从右向左数第一个小于等于x的数字,应该怎么做?

问题:输入n,x,以及一个长度为n的数组a(已经从小到大排好序了)

输入样例:

9 4

2 3 3 3 3 4 4 4 4

应该这样修改程序?比如,这样:

int L = n-1, R = 0; // ???太迷惑了

很多小白可能会写出L = n-1, R = 0的初始条件(因为我们是从右向左数),然后比葫芦画瓢找到第一个小于等于x的数字。但是这样做太令人迷惑了,直觉上我们都觉得应该是L < R或者low < high,但是有些问题的“可行区间左端点”会比“可行区间右端点”要大,于是就会出现上面这段迷惑的代码。

显然,这样做会给我们思考带来很大的阻碍,这样是不符合直觉的,在写一些边界条件的式子的时候更可能出现错误。其实,我们往往可以通过问题转化,在写代码的时候把条件转化为L < R。比如在这个例子中,我们可以把问题转化为“找到从左往右数最后一个小于等于x的数字”,这时候就可以写出L = 0, R = n-1这样的初始条件。

通用做法

有些复杂的问题,进行问题转换也是较为困难的,因此我们需要总结出一个不费脑子、不需要思考就可以写出优美代码的做法。

我们注意到,二分查找的精髓在于,只通过a[M]的值来判断:答案是在左半边还是在右半边。

因此,我们只要抛弃传统意义上的“大小”概念,牢牢抓住这一点进行分析,仔细推断出这个条件用到的表达式,就一定可以写出优美的代码。

伪代码如下:

while( L < R ) {
    int M = L + (R - L)/2;
    if( 答案在[M + 1,R]) { // 思考一下,什么情况下能够说明“答案在[M + 1,R]中”
        L = M + 1;
    } else { // 答案在[L,M]中
        R = M;
    }
}
#include <iostream>
using namespace std;

int n = 9;
int x = 4;
int a[10] = {2, 3, 3, 3, 3, 4, 4, 4, 4};

int main() {

    // 考虑数组中不存在大于等于x的数字的情况
    if( x > a[n-1] ) {           
        cout << -1 << endl;
        return 0;
    }
// TODO 请补全二分查找的代码
    int L= 0,R=n-1;
    while(L<R){
        int M = (L+R)/2;
        if((a[M]<=x)&&(a[M+1]<=x)){
            L=M+1;
        }
        else R=M;
    }
        
    
    // 此时L == R,a[L]就是第一个大于等于x的数字
    if ( a[L] == x) {
        cout << L << endl;  // 如果答案存在,则输出答案
    } else {
        cout << -1 << endl; // 如果答案不存在,则输出-1
    }   
       
    return 0;
}
糟糕!死循环!

但是!假设现在有L = 3, R = 4,你要找的是最后一个小于等于x = 100的数字,并且数组元素是a[3] = 80, a[4] = 90

然后通过计算得到中点M = 3,检查发现a[3] <= 100,所以执行L = M,把答案的可行区间变成[M,R]

你已经发现问题了!在经过一次二分之后,变量仍然保持了L = 3, R = 4没有变化,循环条件L < R一直被满足,我们始终无法结束循环。

这就让我们的程序进入了死循环

“差一点”问题(off-by-one)

为什么会死循环?

如果你实现了刚刚问题(有一个从小到大排好序的数组,你需要找到从左往右数最后一个小于等于x的数字)的代码,你可能会写出下面这样的代码。

int L = 0, R = n-1;
while( L < R ) {
    int M = L + (R - L)/2;
    if( a[M] <= x ) { // 答案一定在[M,R]中
        L = M;
    } else { // 答案一定在[L,M - 1]中
        R = M - 1;
    }
}
// a[L]就是答案

但是你发现,这个程序好像存在一些问题:有时候,程序会陷入死循环,无法得到运行结果。这是为什么呢?

和最初的问题对比一下,你能发现这两份代码的不同之处吗:

// 最初的问题:在数组中找到从左往右 第一个 大于等于x的数字的位置
if( 答案在[M + 1,R]) {
    L = M + 1;
} else {
    R = M; // 这里可能引发“差一点”问题
}
// 现在的问题:在数组中找到从左往右数 最后一个 小于等于x的数字
if( 答案在[M, R]) {
    L = M;
} else {
    R = M - 1;
}

这段代码在逻辑上肯定是没有错误的 —— 你每次都把正确的区间挑选出来了。那为什么这段代码会在某些时候引起死循环呢?

如何避免问题

事实上,死循环只会在刚刚这种况出现:

假设现在有L = 3, R = 4,你要找的是最后一个小于等于x = 100的数字,并且数组元素是a[3] = 80, a[4] = 90

然后通过计算得到中点M = 3,检查发现a[3] <= 100,所以执行L = M,把答案的可行区间变成[M, R]

在经过一次二分之后,变量仍然保持了L = 3, R = 4没有变化,循环条件L < R一直被满足,我们始终无法结束循环。

这是因为我们在判断出答案在[M, R]中的时候,执行了L = M这句话,而根据我们的中点计算公式M = L + (R - L)/2,我们在R == L+1的情况下总会得到L == M。所以我们在经过一次二分之后,LR的值没有发生变化,也就陷入了死循环。

要避免这个问题,其实也非常简单,我们只需要把中点计算公式变成M = L + (R - L + 1)/2即可。在之前的中点计算公式M = L + (R - L)/2中,我们如果遇到了中点不是整数的情况,则会把中点向下取整,因此在出现L + 1 == R这种情况的时候就会始终有L == M从而引发问题。现在我们通过一个+1使得在中点不是整数的时候把中点向上取整,就可以避免这个问题(请在纸上模拟代码的运行过程,以体会这个公式是如何解决“差一点”问题的)。

但是!当我们使用M = L +(R - L + 1)/2作为中点计算公式的时候,就会在最初的代码(在数组中找到第一个大于等于x的数字的位置)上遇到“差一点”问题。

// 在数组中找到从左往右大于等于x的数字的位置
if( 答案在[M + 1,R]) {
    L = M + 1;
} else {
    R = M; // 这里可能引发“差一点”问题
}

建议你记住下述规律:

  • 如果代码中是用的L = M,把L不断往右push,那么M向上取整(M = L + (R - L + 1)/2);
  • 如果代码中是用的R = M,把R不断往左push,那么M向下取整(M = L + (R - L)/2)。
应用范围
有序才能二分查找

如果我们想要在一个数组上进行二分查找,那么这个数组必须是有序的,不管是升序还是降序,它必须是有序的。为什么呢?

注意二分查找的本质是什么:通过比较数组中间那个值和我们要求的值的关系,来判断出“答案不可能出现在数组的某一半”,从而让我们的查找范围缩小为原来的一半。

img

这也就是为什么我们要求数组中的元素是满足单调性的:只有这样,我们才能保证当a[M]不满足条件的时候,它左边(或者右边)的所有元素都不满足条件。

比如,我们要在一个升序的数组中找到第一个大于等于12的数字:

img

而我们在某次二分中发现a[M] = 7,由于数组是升序的,我们就可以判断出12一定出现在a[M]的右边。如果数组是乱序的,我们就无法得到任何有用的信息。

那么是不是任何有序的数据结构都可以应用二分查找算法呢?

其他有序结构
日期

日期是一个天然有序的结构:我们可以定义日期A小于日期B意为:在日历上A排在B的前面。比较两个日期的大小也可以通过很简单的方式进行:先比较年,再比较月,最后比较日。

struct Date {
    int year, month, day;
};
bool operator<( const Date &a, const Date &b ) {
    if( a.year == b.year ) {
        if( a.month == b.month ) {
            return a.day < b.day;
        } else {
            return a.month < b.month;
        }
    } else {
        return a.year < b.year;
    }
}

看起来只是一组三维数据而已,和二维数据的处理没什么差别?

但是我们可能会面临一个问题:如果我们要在公元1年1月1日和1000000000年1月1日之间二分,我们该如何求出两个日期的中点呢?

我们把日期表示成YYYYMMDD的形式,比如公元1年1月1日就是00010101,1000000000年1月1日就是10000000000101。则两个日期的中点,就是两个数字的中点,只不过我们需要把这个数字向下取整(或者向上取整)到最近的合法的日期。

比如,我们要求1970121220200817的中点,我们可以直接求(19701212 + 20200827) / 2 = 19951019,这就是这两个日期的近似中点。如果我们得到了类似于19971805这样不合法的日期(没有18月),我们只需要把18月向下取整到合法的日期(12月),变为19971205即可。

字符串

字符串也是一个天然有序的数据结构:字典序就是字符串的大小顺序。因此我们可以给一堆字符串按照字典序排序。

string s[100];
for( int i = 0; i < n; ++i )
    cin >> s[i];
// sort函数用于给数组中的元素排序
sort(s, s+n); // string类的比较函数为比较两个字符串的字典序

现在在一堆排好序的字符串中,我们要找出所有前缀是com的字符串,应该怎么做呢?

apple
awsl
bag
bed
comm
commute
compare
cooperate

容易发现,所有前缀是com的字符串,在数组中也是一个连续的区间。

我们可以把数组中的所有字符串截断到前3位,然后使用二分查找法找到第一个com出现的位置和最后一个com出现的位置。

在这之间的所有字符串,前缀都是com

二维数据

有的时候我们需要用到二维数据,比如平面中的点,就需要两个数字来表示,再比如std::pair这个数据结构,就是简单地把两个数字组合在一起。

不妨假设我们遇到的二维数据都是下面这样子的。类似平面上的整数点,一个点用两个整数(x,y)表示。

struct Point {
    int x, y;
};
// 这是运算符重载,当我们在代码中用小于号比较两个Point类变量的时候,就会用这个函数进行比较
bool operator<( const Point &a, const Point &b ) { // 如何定义a < b
    if( a.x == b.x ) {
        return a.y < b.y;
    } else {
        return a.x < b.x;
    }
}

这里我们定义了一种常用的比较二维数据的方法:首先比较两个数据的第一维,数字小的排在前面,当第一维数字相同的时候,比较第二维,数字小的排在前面。比如(3,3) < (4,2),因为先比较第一维3 < 4。再比如(2,3) < (2,5),因为第一维相同时比较第二维。

如果我们有一个排好序的Point数组,我们想找到数组中所有x = 5的元素(容易发现所有x = 5的元素在数组中一定是一个连续的区间),应该怎么做呢?

一个排好序的Point数组例子:(1,2), (2,3), (2,4), (5,-1), (5,2), (5,5), (7,4)

Point a[100000];
for( int i = 0; i < n; ++i )
    cin >> a[i].x >> a[i].y;
sort(a, a+n); // sort函数可以给数组中的元素排序

我们只需要两次二分查找就可以了:分别找到第一个大于等于Point(5, INT_MIN)的元素,以及最后一个小于等于Point(5, INT_MAX)的元素。这两个元素中间的所有元素就是x = 5的所有元素(闭区间)。INT_MININT_MAX分别是int所能表达的最小值和最大值。

总结

如果我们想要在一个数组上进行二分查找,那么这个数组必须是有序的,不管是升序还是降序,它必须是有序的。

为什么呢?

注意二分查找的本质是什么:通过比较数组中间那个值和我们要求的值的关系,来判断出“答案不可能出现在数组的某一半”,从而让我们的查找范围缩小为原来的一半。

img

这也就是为什么我们要求数组中的元素是满足单调性的:只有这样,我们才能保证当a[M]不满足条件的时候,它左边(或者右边)的所有元素都不满足条件。

所以:

  • 要进行二分,数组必须是有序的。
  • 基本上所有可以比较的数据都可以进行二分查找。
    • 比如:日期、字符串、二维数组
  • 如果数据可以方便的计算“中点”,那么就可以在大区间上二分查找指定的数据(比如日期)
应用
统计x出现的次数

首先,我们来看一个问题:

给出一个正整数n,和一个长度为n的整数数组a,再给出一个正整数q,接下来给出q个询问,每个询问包含一个整数x,你需要输出x在数组a中出现了几次。

该问题的解决方案,

#include <bits/stdc++.h>
using namespace std;

int n = 10;
int a[10] = {1, 2, 2, 3, 3, 3, 4, 4, 4, 4};

int main() {
    
    sort(a, a+n);   // 对a数组进行排序
    int q = 3;      // 询问3次
    while( q-- ) {  // 回答q次询问
        int x;
        cin >> x;   // 请在代码区下方的输入区依次输入3个你想要查询的数。
                    // 比如,1,2,3,注意中间要空格,或换行输入
        int *lp = lower_bound(a, a + n, x); // 第一个大于等于x的数字的指针
        int *rp = upper_bound(a, a + n, x); // 第一个大于x的数字的指针
        cout << int(rp-lp) << endl;       // 指针相减得到区间长度
    }
    return 0;
}
lower_bound和upper_bound

我们先回顾一下上一步要解决的问题:

给出一个正整数n,和一个长度为n的整数数组a,再给出一个正整数q,接下来给出q个询问,每个询问包含一个整数x,你需要输出x在数组a中出现了几次。

在上一步给出的代码示例中我们用到了C++标准库中两个使用二分查找的函数:lower_boundupper_bound

lower_bound的用途是:在指定的升序排序的数组中,找到第一个大于等于x的数字。

upper_bound的用途是:在指定的升序排序的数组中,找到第一个大于x的数字。

这两个函数会返回对应数字的指针(或者是迭代器)。

int a[100000], n;
cin >> n;
for( int i = 0; i < n; ++i )
    cin >> a[i];
sort(a, a + n);
int *p = lower_bound(a, a + n, 13); // 第一个大于等于13的数字
int *q = upper_bound(a, a + n, 13); // 第一个大于13的数字

这两个函数的具体用法和细节,可以参考cppreference,我们这里介绍的,是这两个函数有什么用。

假如我们使用lower_boundupper_bound二分查找同一个数字13,容易发现,我们得到的两个指针构成了一个左闭右开区间,这个区间里全部都是数字13。

img

巧妙地运用这两个函数,可以完成所有常见的二分查找操作:

  • 找到第一个大于等于x的数字
  • 找到第一个大于x的数字
  • 找到最后一个等于x的数字
  • 查找数组中是否有数字x
  • 查询数组中有几个数字x
  • 找到最后一个小于x的数字
  • ……

回到我们的问题:

给出一个正整数n,和一个长度为n的整数数组a,再给出一个正整数q,接下来给出q个询问,每个询问包含一个整数x,你需要输出x在数组a中出现了几次。

由于lower_boundupper_bound构成了一个包含指定数字的左闭右开区间,因此直接将这两个指针相减,即可得到指定数字的出现次数。

右侧代码部分语句被挖空了,请根据lower_boundupper_bound的用法补全右侧代码。

运行验证正确后提交才可以解锁下一步哦~

求方程的解

问题

请输出方程x^3 + 16 = 0的解,已知这个解在[-1e9,1e9]之间,并且函数f(x) = x^3 + 16在定义域上单调递增。输出的答案保留5位小数。

我们现在想求出某个方程f(x) = 0的解,并且我们知道这个解在[L,R]之间,且函数f(x)[L,R]上单调递增。我们只需要这个解精确到5位小数即可。

函数示意图:

img

这就是一个典型的二分法求方程的解了,是高中数学课本上所讲解的知识。

右侧代码部分语句被挖空了,请根据二分查找的伪代码补全右侧的二分查找过程。

  • 二分查找伪代码
while( L < R ) {
    int M = L+(R-L)/2;
    if( 答案在[M+1,R]) { // 思考一下,什么情况下能够说明“答案在[M+1,R]中”
        L = M+1;
    } else { // 答案在[L,M]中
        R = M;
    }
}
#include <bits/stdc++.h>
using namespace std;

double f( double x ) {
    return x * x * x + 16; // 某个函数f(x)
}
double solve() {
    double L = -1e9, R = 1e9; // 方程解在[L,R]之间,且函数在[L,R]上单调增
    while( R - L >= 1e-6 ) { // 精确到6位小数,然后四舍五入
        // TODO 请补全二分查找的代码
        double M=(R+L)/2;
        if(f(M)>0){
            R=M;
        }
        else {L=M;}
    }
    return L;
}  
int main() {
    printf( "%.5lf\n", solve() );
    return 0;
}
在double上二分的注意事项
如果我们要求精确到10位小数,你可能会写出这样的代码:

double L = -1e9, R = 1e9;
while( R - L >= 1e-11 ) { // 精确到11位小数,然后四舍五入
    // 此处省略二分内容
}
你可能会发现,最终的结果并没有精确到10位小数,或者是这个二分直接陷入了死循环。

在精度要求越高的时候,就越可能出现这样匪夷所思的情况。

这是因为double本身存在不小的精度误差,我们通过R - L >= 1e-10这种方式来控制二分的终止条件,会带来非常大的精度问题。

这种时候,我们可以采用固定次数二分的方法:

double L = -1e9, R = 1e9;
for( int times = 0; times < 100; ++times ) { // 二分100次
    double mid = (L+R)/2;
    // 此处省略二分内容
}
这里我们二分100次,是因为2的100次方约为1e30,而我们二分的初始条件是1e9左右,足以在最后把精度控制在1e-20左右。

在这种二分策略下,我们一般都能得到合理的答案。
总结

总结

我们总结一些二分查找的常见应用:

  • lower_boundupper_bound

    lower_bound的用途是:在指定的升序排序的数组中,找到第一个大于等于x的数字。

    upper_bound的用途是:在指定的升序排序的数组中,找到第一个大于x的数字。

    使用lower_boundupper_bound可以帮我们解决绝大多数二分查找问题。

    这两个函数会返回对应数字的指针。示例代码如下:

int a[100000], n;
cin >> n;
for( int i = 0; i < n; ++i )
    cin >> a[i];
sort(a, a + n);
int *p = lower_bound(a, a + n, 13); // 第一个大于等于13的数字
int *q = upper_bound(a, a + n, 13); // 第一个大于13的数字

假如我们使用lower_boundupper_bound二分查找同一个数字13,容易发现,我们得到的两个指针构成了一个左闭右开区间,这个区间里全部都是数字13。

巧妙地运用这两个函数,可以完成所有常见的二分查找操作:

  • 找到第一个大于等于x的数字
  • 找到第一个大于x的数字
  • 找到最后一个等于x的数字
  • 查找数组中是否有数字x
  • 查询数组中有几个数字x
  • 找到最后一个小于x的数字
  • 二分法可以求方程的近似解。
  • 二分法可以用来优美地实现离散化操作。
  • 在double上二分时,尽量使用固定次数二分的方法。
递推
思想简介

递推,意思就是用已经有的信息一点点推出想要知道的信息。

比如,平面上有一个机器人,一开始在坐标(0,0)处,第一秒向东移动一米,第二秒向南移动两米,第三秒向西移动三米,第四秒向北移动四米……机器人一直按照这个规律移动下去。由于我们知道了最开始的时候机器人的位置,我们就可以一秒一秒地推算出接下来每一个时刻机器人的位置。这就是递推。

显然,如果我们用人脑去模拟一个递推算法,是比较简单的,因为“根据已有信息推出未知信息”是我们常用的思考方式,符合直觉。

如果用电脑运行递推算法,我们应该考虑使用循环。我们可以在循环的过程中使用数组和临时变量记录下来每一步递推的过程和结果。比如在刚刚的机器人例子中,我们可以使用数组来记录每一秒结束时机器人的具体位置,使用临时变量来记录机器人当前的朝向。这和我们使用人脑模拟递推算法的区别不大。

张爽的青蛙

地上有nn个石头从左到右排成一排,张爽同学养的青蛙要从第一个石头跳到最后一个石头上,每次可以选择向右跳一格或者跳两格,问总共有多少种不同的走法? 答案对998244353取模。

比如,如果青蛙要从第一个石头跳到第五个石头,有以下五种可能的走法:

  • 1->2->3->4->5
  • 1->2->3->5
  • 1->2->4->5
  • 1->3->4->5
  • 1->3->5

张爽的青蛙问题其实就是斐波那契数列的一种变形。

要使用递推解决这个问题,我们需要先对这个问题进行数学建模,找出递推式

我们开一个数组int f[n],其中f[i]表示从第一个石头跳到第i个石头一共有多少种方案(对998244353取模)。

接下来,我们尝试寻找递推关系:如果我们知道了f[1], f[2], f[3], ..., f[k-1]的取值,那么f[k]应该是多少呢?

f[k]的值是多少呢?

张爽的青蛙

地上有nn个石头从左到右排成一排,张爽同学养的青蛙要从第一个石头跳到最后一个石头上,每次可以选择向右跳一格或者跳两格,问总共有多少种不同的走法? 答案对998244353取模。

对张爽的青蛙问题进行建模

f[k]的值是多少呢?首先,最后一步肯定会落在k上,我们不妨把所有从1走到k的路径分成两类:一种路径是最后一步跳了一个石头,另外一种是最后一步跳了两个石头。

最后一步跳了一个石头:

  • 1->2->3->4->5
  • 1->2->4->5
  • 1->3->4->5

最后一步跳了两个石头:

  • 1->2->3->5
  • 1->3->5

再抽象一点,这两类路径是:

  • 先从1走到k-1,再走一步到k
  • 先从1走到k-2,再一次走两个石头到k

而从1走到k-1的方案数是f[k-1],从1走到k-2的方案数是f[k-2],所以我们有:

  • 先从1走到k-1,再走一步到k,有f[k-1]种方法
  • 先从1走到k-2,再一次走两个石头到k,有f[k-2]种方法

所以从1走到k的方案数就是f[k] = f[k-1] + f[k-2],这就是我们需要的递推式。

同时,我们知道f[1] = f[2] = 1,因为从1走到1只有一种方案(呆在原地不动),从1走到2也只有一种方案(走一格)。这便是递推的初始条件

接下来,就是把初始条件递推式写成循环,得到结果:

#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353; // 答案对998244353取模。
int k, f[1000010];

int main() {
    cin >> k;
    f[1] = f[2] = 1; // 初始条件
    for( int i = 3; i <= k; ++i )
        f[i] = (f[i-1] + f[i-2]) % MOD; // 递推式,记得取模
    cout << f[k] << endl;
    return 0;
}
代码实现 —— 张爽的青蛙

张爽的青蛙

地上有nn个石头从左到右排成一排,张爽同学养的青蛙要从第一个石头跳到最后一个石头上,每次可以选择向右跳一格或者跳两格,问总共有多少种不同的走法? 答案对998244353取模。

我们再回顾一下算法描述

从第1步走到第k-1步的方案数是f[k-1],从1走到k-2的方案数是f[k-2],所以我们有:

  • 先从1走到k-1,再走一步到k,有f[k-1]种方法
  • 先从1走到k-2,再一次走两个石头到k,有f[k-2]种方法

所以从1走到k的方案数就是f[k] = f[k-1] + f[k-2],这就是我们需要的递推式

同时,我们知道f[1] = f[2] = 1,因为从1走到1只有一种方案(呆在原地不动),从1走到2也只有一种方案(走一格)。这便是递推的初始条件

对卡特兰数进行建模

卡特兰数

由nn对括号组成的括号序列,有多少种是合法的括号序列?答案对998244353取模。

什么是合法的括号序列?其定义如下:

  • 空序列是合法的括号序列
  • 如果A是合法的括号序列,那么(A)是合法的括号序列
  • 如果A和B是合法的括号序列,那么AB也是合法的括号序列

我们继续使用分类讨论的思想:由于合法括号序列的最后一个字符一定是右括号,不妨假设最终的括号序列长成这个样子:A(B)。其中,A和B都是合法括号序列(注意A和B可以是空序列)。

我们把最终的序列分成k种:

  • A0对括号组成,Bk-1对括号组成,这样的序列有f[0] * f[k-1]
  • A1对括号组成,Bk-2对括号组成,这样的序列有f[1] * f[k-2]
  • A2对括号组成,Bk-3对括号组成,这样的序列有f[2] * f[k-3]
  • ……
  • Am对括号组成,Bk-1-m对括号组成,这样的序列有f[m] * f[k-1-m]
  • ……
  • Ak-1对括号组成,B0对括号组成,这样的序列有f[k-1] * f[0]

于是我们就得到了递推式

f(n) = \sum_{k=0}^{n-1} f(k) \times f(n-k-1)f(n)=k=0∑n−1f(kf(nk−1)

同样,初始条件f[0] = 1,因为0对括号只能组成一种括号序列(空序列)。

至此,代码呼之欲出:

#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;
int n, f[100010];

int main() {
    cin >> n;
    f[0] = 1; // 初始条件
    for( int i = 1; i <= n; ++i ) { // 求f[i]的值
        for( int k = 0; k < i; ++k ) {
            f[i] += int((long long)f[k] * f[i-k-1] % MOD); // 递推式
            // 注意,两个int相乘的结果可能爆int,因此乘法的过程要转换成long long以避免整数溢出
            f[i] %= MOD; // 记得取模
        }
    }
    cout << f[n] << endl;
    return 0;
}

容易发现,该算法核心递推式的执行次数是:1 + 2 + 3 + ... + n,因此算法复杂度O(n^2)

应用1 —— 错位排列

我们尝试使用递推算法来解决更实际且更难的问题,在这个过程中,我们会用到更多的数学建模逻辑推理

错位排列

有nn个信封和nn个信件,第ii个信件属于第ii个信封,我们想知道,有多少种不同的方法,使得没有任何一个信件被装入正确的信封中?答案对998244353取模。

下图中,2号信件装入了2号信封中,因此方案不合法:

img

而下图,就满足“任何一个信件都没有被装入正确的信封中”,因此是合法方案:

img

我们开一个数组int f[n];,其中f[i]表示当信封和信件数量为i的时候,总方案数是多少。接下来,我们应该如何寻找递推式呢?

错位排列建模

回顾我们之前讲的使用递推解题三步骤:

  • 数学建模
  • 找出递推式和初始条件
  • 写出代码。

我们继续尝试使用分类讨论的方法寻找递推式

错位排列

有nn个信封和nn个信件,第ii个信件属于第ii个信封,我们想知道,有多少种不同的方法,使得没有任何一个信件被装入正确的信封中?答案对998244353取模。

考虑1号信件,它不能被装入1号信封,不妨假设它被装入了x号信封。这里为了方便,我们假设x = 3

那么x号信件可以装入哪个信封呢?这里又存在两种情况。

第一种情况:x号信件装入了1号信封:

img

在这种情况下,我们可以去掉1号和x号,就变成了完全独立的子问题:n-2个信件和信封的错位排列问题。

img

第二种情况:x号信件没有装入1号信封。

这个时候,如果我们去掉1号信件和x号信封,情况就会变成下图:

img

2号、4号、5号信件不能装入对应的信封中,而x号信件不能装入1号信封中,这其实也是一个大小为n-1的错位排列问题。

因此,当1号信件装入x号信封的时候,总共会有两种情况:

  • x号信件装入1号信封,有f[n-2]种方案
  • x号信件不装入1号信封,有f[n-1]种方案

而x的选择有n-1种(除了1都可以),因此我们得到了递推式f[n] = (n-1)(f[n-1] + f[n-2])

同时,我们也可以轻易得到两个边界条件

  • f[1] = 0,因为只有1个信件和信封的时候,没有办法错位排列。
  • f[2] = 1,只有2个信件和信封的时候,只有一种方法(交叉放)。

代码实现 —— 错位排列

错位排列

有n个信封和n个信件,第i个信件属于第i个信封,我们想知道,有多少种不同的方法,使得没有任何一个信件被装入正确的信封中?答案对998244353取模。

  • 递推式:f[n] = (n-1)(f[n-1] + f[n-2])
  • 初始条件:
    • f[1] = 0,因为只有1个信件和信封的时候,没有办法错位排列。
    • f[2] = 1,只有2个信件和信封的时候,只有一种方法(交叉放)。

右侧代码部分语句被挖空了,请根据错位排列问题的递推式和初始条件补全右侧的递推过程

运行验证正确后提交才可以解锁下一步哦~

应用2 —— 杨辉三角(二维递推)

事实上,我们也会经常遇到不止一维的递推,比如我们接下来要介绍的杨辉三角问题。

从nn个不同的物品中选取mm个,有多少种不同的选择方法?这是一个经典的组合数问题,而本题需要你解决一个更难的问题:给出k,你需要输出一个(k+1)(k+1)(k+1)∗(k+1)的矩阵,其中第ii行第jj列表示,从ii个不同的物品中选jj*个,有多少种不同的方法(行和列的标号从0开始)。所有答案对998244353取模。

比如,当k=4时,你需要输出如下矩阵:

1 0 0 0 0
1 1 0 0 0
1 2 1 0 0
1 3 3 1 0
1 4 6 4 1

其中第4行第2列的数字为6(注意行和列从0开始标号),表示从4个不同的物品中选2个有6种方法。

假设这4个物品分别叫A、B、C、D,那么这6种方法分别是:

AB
AC
AD
BC
BD
CD

类似的,我们开一个二维数组int f[k][k];,其中f[i][j]表示从i个物品中选j个的方案数,接下来,我们要做的就是寻找递推式

杨辉三角递推式

怎么尝试寻找递推式呢?分类讨论吗?对的!我们继续使用分类讨论来寻找递推式。绝大多数简单的递推问题都可以用分类讨论的方法找到一个合理的递推式

假设我们现在要求的值是f[i][j],即,从i个物品中选j个的方案数,我们不妨把i个物品从1到i标上号。

现在考虑1号物品,我们尝试对1号物品进行分类讨论。怎么分类呢?无非就是两类:选1号物品,还是不选1号物品。

  • 选1号物品:由于1号物品是一定要选进来的,因此我们还剩i-1个物品,我们要从中选出j-1个物品,方案数是f[i-1][j-1]
  • 不选1号物品:我们还剩i-1个物品,但是1号一定不选,因此我们还要从剩下的i-1个物品中选出j个物品,方案数是f[i-1][j]

结束了吗?其实这样就结束了。因为我们已经不重复不遗漏地考虑到了所有可能出现的情况。

所以我们就得到了**递推式*f[i][j] = f[i-1][j-1] + f[i-1][j]

别忘了,我们还要考虑递推的边界条件!二维递推的边界条件比一维递推要复杂得多。

首先观察样例矩阵,我们可以轻易发现,所有j > i的位置都是0,因为我们不可能从i个物品中选出大于i个物品,因此这些位置一定是0。

接下来,我们发现样例矩阵的第一列和对角线全都是1,这也是容易推理出的:第一列的所有元素都是f[x][0],从x个物品中选取0个物品,显然只有一种方案:什么都不选;而对角线的所有元素都是f[x][x],从x个物品中选出x个物品,也只有一种方案:全部都选上。

除此之外还有其他的边界条件吗?

没有了。

因为我们的递推式f[i][j] = f[i-1][j-1] + f[i-1][j]告诉我们,如果我们想要计算任何一个数字f[i][j],只需要知道它“上面”的数字f[i-1][j]和“左上方”的数字f[i-1][j-1]即可。观察矩阵,我们发现,对于任何一个数字,我们都可以用已知的初始条件推出,因此我们不需要更多的边界条件了。

img

除此之外还有什么需要注意的么?有的!我们还需要特别注意递推的顺序!二维递推不同于一维递推,二维的数据可能存在莫名其妙的依赖关系,因此我们在写二维递推的时候,需要特别注意二维递推的顺序

比如杨辉三角,我们可以从递推式和上图看出,f[i]这一行的值全部由f[i-1]这一行的值推出,因此我们只需要按照行数从小到大的顺序递推即可。而其他形式的二维递推可能就需要用其他顺序进行循环,比如下面这种递推形式。

img

代码实现 —— 杨辉三角(二维递推)

杨辉三角:

从nn个不同的物品中选取mm个,有多少种不同的选择方法?这是一个经典的组合数问题,而本题需要你解决一个更难的问题:给出k,你需要输出一个(k+1)(k+1)(k+1)∗(k+1)的矩阵,其中第ii行第jj列表示,从ii个不同的物品中选jj*个,有多少种不同的方法(行和列的标号从0开始)。所有答案对998244353取模。

  • 递推式:f[i][j] = f[i-1][j-1] + f[i-1][j]
  • 初始条件:
    • f[i][0] = f[i][i] = 1; // 递推边界条件
  • 递推顺序

因为我们的递推式f[i][j] = f[i-1][j-1] + f[i-1][j]告诉我们,如果我们想要计算任何一个数字f[i][j],只需要知道它“上面”的数字f[i-1][j]和“左上方”的数字f[i-1][j-1]即可。观察矩阵,我们发现,对于任何一个数字,我们都可以用已知的初始条件推出,因此我们不需要更多的边界条件了。

img

除此之外还有什么需要注意的么?有的!我们还需要特别注意递推的顺序!二维递推不同于一维递推,二维的数据可能存在莫名其妙的依赖关系,因此我们在写二维递推的时候,需要特别注意二维递推的顺序

比如杨辉三角,我们可以从递推式和上图看出,f[i]这一行的值全部由f[i-1]这一行的值推出,因此我们只需要按照行数从小到大的顺序递推即可。而其他形式的二维递推可能就需要用其他顺序进行循环,比如下面这种递推形式。

img

搞定了递推式初始条件递推顺序之后,我们的代码就呼之欲出了。

同时,递推出单个元素的复杂度O(1),整个表格一共有O(n^2)个元素,因此该算法的总时间复杂度O(n^2)

#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;

int k = 10;
int f[2010][2010] = {0}; // 初始化f数组为全0
int main() {
    for( int i = 0; i <= k; ++i ) {
        f[i][0] = f[i][i] = 1; // 递推边界条件
        for( int j = 1; j < i; ++j ) {
            // TODO 请补全下述代码
            f[i][j] = f[i-1][j-1] + f[i-1][j];// 请补全递推式,记得取模
        }
        for( int j = 0; j <= k; ++j ) {
            cout << f[i][j] << ' '; // 输出这一整行
        }
        cout << endl;
    }
    return 0;
}
总结
  • 递推思想:

    根据已有的东西一点点地推出未知的东西。

  • 使用递推解题三步骤:

    • 数学建模
    • 找出递推式和初始条件
    • 写出代码。
  • 错位排列问题:有nn个信封和nn个信件,第ii个信件属于第ii个信封,我们想知道,有多少种不同的方法,使得没有任何一个信件被装入正确的信封中?

    • 递推式:f[n] = (n-1)(f[n-1] + f[n-2])
    • 初始条件:f[1] = 0,因为只有1个信件和信封的时候,没有办法错位排列;f[2] = 1,只有2个信件和信封的时候,只有一种方法(交叉放)。
    • 完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;

int f[1000010], n;

int main() {
    cin >> n;
    f[1] = 0; // 初始条件
    f[2] = 1;
    for( int i = 3; i <= n; ++i ) {
        f[i] = (long long)(i-1) * (f[i-1] + f[i-2]) % MOD;
        // 注意取模,并且小心乘法会爆int
    }
    cout << f[n] << endl;
    return 0;
}
  • 杨辉三角(二维递推)问题:从nn个不同的物品中选取mm个,有多少种不同的选择方法?这是一个经典的组合数问题,而本题需要你解决一个更难的问题:给出k,你需要输出一个(k+1)(k+1)(k+1)∗(k+1)的矩阵,其中第ii行第jj列表示,从ii个不同的物品中选jj*个,有多少种不同的方法(行和列的标号从0开始)。
    • 递推式:f[i][j] = f[i-1][j-1] + f[i-1][j]
    • 初始条件:f[i][0] = f[i][i] = 1; // 递推边界条件
    • 完整代码:
#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;

int f[2010][2010] = {0}, k; // 初始化f数组为全0

int main() {
    cin >> k;
    for( int i = 0; i <= k; ++i ) {
        f[i][0] = f[i][i] = 1; // 递推边界条件
        for( int j = 1; j < i; ++j ) {
            f[i][j] = (f[i-1][j-1] + f[i-1][j]) % MOD; // 递推式,记得取模
        }
        for( int j = 0; j <= k; ++j ) {
            cout << f[i][j] << ' '; // 输出这一整行
        }
        cout << endl;
    }
    return 0;
}
  • 时间复杂度:O(n^2)
递归

简单地来说,就是一个函数自己调用自己

另外,两个函数相互调用也算是递归的一种,只要涉及到“函数自己调用自己”,都可以称为递归

#include <bits/stdc++.h>
using namespace std;

void g();

void f() {
    g(); // f调用g
}

void g() {
    f(); // g调用f
}

int main() {
    f();
    return 0;
}
递归求阶乘

输入非负整数n,使用递归法求出n的阶乘,答案对998244353取模。

我们设f(n)为n的阶乘,那么根据阶乘的定义,我们可以得到:

  • f(0) = 1 // 初始值
  • f(n) = f(n-1) * n // 递归公式

有了这两条,我们就可以写出递归代码:

#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;

int f( int n ) {
    if( n == 0 ) 
        return 1;    // 0的阶乘等于1
    else return 
        (long long)f(n-1) * n % MOD; // 注意取模,小心爆int
}

int main() {
    int n;
    cin >> n;
    cout << f(n) << endl;
    return 0;
}

代码简洁易懂,只是把我们得到的两条公式直接塞到了代码中而已。

然而初学者面对这样的代码,往往会一脸迷惑:这样就够了吗?我怎么总感觉这代码少了点啥?

是的,这样就够了。

我们接下来会分析代码的执行流程,体会递归是如何运作的。

不妨假设我们要计算的是f(5),那么执行流程如下:

  • 执行f(5),进入else语句,要求执行f(4)
  • 执行f(4),进入else语句,要求执行f(3)
  • 执行f(3),进入else语句,要求执行f(2)
  • 执行f(2),进入else语句,要求执行f(1)
  • 执行f(1),进入else语句,要求执行f(0)
  • 执行f(0),进入if语句,返回1
  • f(1)接收到f(0)返回的1,把这个11相乘,得到1,并返回。
  • f(2)接收到f(1)返回的1,把这个12相乘,得到2,并返回。
  • f(3)接收到f(2)返回的2,把这个23相乘,得到6,并返回。
  • f(4)接收到f(3)返回的6,把这个64相乘,得到24,并返回。
  • f(5)接收到f(4)返回的24,把这个245相乘,得到120,并返回。

这就是f(5)的执行流程,最终得到了我们期望的结果——120。

容易发现,递归的执行过程是“从上到下,再回到上”:

  • 我们最开始是想知道f(5)的答案,但是想要知道f(5)就必须知道f(4)
  • 想要知道f(4)就必须知道f(3)
  • ……如此刨根问底,最后到了f(0)
  • 我们直接得到了答案,然后再一层一层地返回,逐渐融合成我们想要的答案

因此,我们也可以分析出,该算法的时间复杂度O(n):“从上到下”的过程是O(n),“再回到上”的过程也是O(n),加起来一共是O(n)

递归求斐波那契数列

上一节中,看起来递归很好理解,只需要一层一层地剥开,研究清楚如何“从上到下”地刨根问底,再研究清楚如何在“回到上”的过程中求出答案,就可以了?

果真如此吗?

让我们再来看一个问题:

递归求斐波那契数列

输入正整数n,使用递归法求出斐波那契数列的第n项,答案对998244353取模。

比葫芦画瓢,我们可以直接列出斐波那契数列的数学公式:

  • f(1) = f(2) = 1 // 初始值
  • f(n) = f(n-1) + f(n-2) // 递归公式

然后直接把它们写进代码:

#include <bits/stdc++.h>
using namespace std;
const int MOD = 998244353;

int f( int n ) {
    if( n == 1 || n == 2 ) 
        return 1; // 边界条件
    else 
        return (f(n-1) + f(n-2)) % MOD; // 不要忘记取模
}

int main() {
    int n;
    cin >> n;
    cout << f(n) << endl;
    return 0;
}

接下来,我们继续比葫芦画瓢,尝试分析这个算法的正确性和复杂度,以f(10)为例子:

img

如果我们继续使用“一层一层全部剥开”的方法去分析正确性和复杂度,我们发现这样做的麻烦程度简直超乎我们的想象。如果我们的递归函数里面还有各种各样的分支、循环,甚至是两个递归函数相互调用……我们使用这样的分析方法根本无从下手,我们需要更好更快的分析方法!

递归的本质是数学归纳法

我们在高中的数学课本中学过数学归纳法,现在它是时候派上用场了!

我们使用数学归纳法分析上述“求斐波那契数列算法”的正确性:

  • f(1)可以得到正确答案,由if语句保证
  • f(2)可以得到正确答案,由if语句保证
  • 如果f(n-1)f(n-2)都可以得到正确答案,那么f(n)可以得到正确答案,由else语句保证
  • 因此,对于任意的正整数n,我们的算法都是正确的

是的,这就结束了!

我们完全不需要一层一层地剥开所有的递归,那是计算机才需要做的体力活,我们的人脑无法承受这种工作量。

从归纳的角度思考递归

对于任意的递归函数,我们都可以从数学归纳法的角度去理解它,再抽象一点,就是:

  1. 这个函数设计出来的目的是什么?

    求斐波那契数列。

  2. 在边界条件上,这个函数正确吗?

    正确。

  3. 这个函数能正确利用递归处理出来的结果吗?

    可以正确使用。

  4. 因此这个函数是正确的。

第一点尤为重要!在理解一个递归函数之前,我们必须知道这个函数的目的是要干什么,这样才能使用数学归纳法。

这也警示我们,在写递归函数的时候,

不要让一个函数干两件事情

不要让一个函数干两件事情

不要让一个函数干两件事情

只有每个函数干唯一指定的事情,才能保证写出来的代码是可靠易读易修改的。

第二点是我们大家都能想到的点,但是经常会忘记一些特殊情况导致漏写边界条件。在结果出问题的时候,需要检查一下是不是有什么边界条件忘记了。

第三点是递归最难理解的地方,这里分享一个小窍门:

在一个函数递归调用自己的时候,我们可以直接假设这个函数是一个已经写完,并且功能完善的函数,它能保质保量地完成我们想让它做的事情(这其实就是数学归纳法的前提假设)。

千万不要把递归函数展开,千万不要尝试研究递归的过程是什么样的,因为人脑根本无法承受如此大的数据量,我们只能用数学归纳法来保证递归函数的正确性。

同时,这也是为什么我们要求在写函数之前想好这个函数的任务,并且在写函数的过程中,这个任务始终不能变化,因为这是我们使用数学归纳法的前提,我们需要假设这个函数能完美地完成这个任务,才可以肆无忌惮地放心递归调用它。

那么,函数的正确性我们已经理解了,复杂度如何分析呢?

由于递归函数总是在边界条件处结束,因此我们可以重点关注边界条件被执行的次数,粗略地估计算法的复杂度。

之所以是粗略地估计,是因为递归函数的复杂度往往不稳定,我们一般无法得到一个精确的值,只能粗略估计。

比如求斐波那契数列的算法:

边界条件if( n == 1 || n == 2 ) return 1;执行的次数,其实就等于这个斐波那契数本身(这个数是由这样的1累计起来的),比如计算f(10)的复杂度就至少是O(f(10)),计算f(n)的复杂度就至少是O(f(n)),我们可以粗略地将这个算法的复杂度估计为“指数级”(因为斐波那契数本身的增长速度就是指数级)。这就足够了。

上面讲了如何理解一个递归算法,那么我们如何自己用递归算法解决问题呢?

仍然是牢记这三板斧:

  1. 确认并牢记这个递归函数的功能,始终不能改。
  2. 仔细检查,写对边界条件。
  3. 递归调用自己时,放心大胆地用,假设这个函数就是对的。

纸上得来终觉浅,绝知此事要躬行。递归对于初学者来说是很难掌握的算法,同时也是计算机思维很好的体现。大家一定要勤加练习,先尝试读懂别人的递归代码,再尝试自己设计递归算法。相信你一定能掌握递归的奥妙。

动态规划
数字金字塔问题

观察下面的数字金字塔

img

现在,需要我们找到一种方法,查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。

注:每一步可以走到左下方的点也可以到达右下方的点。

比如,在上面的样例中,从 7→3→8→7→5的路径经过数字的和最大。

数字金字塔问题

img

现在,我们把数字金字塔转化成一个算法问题,就变成了:

给定一个nn层的金字塔,求一条从最高点到底层任意点的路径使得路径经过的数字之和最大。

注:每一步可以走到左下方的点也可以到达右下方的点。

基本思路

我们按照下面的步骤依次观察这个问题的结构:

  1. 首先,因为我们可以走到最下面一层的任意点。那么,只要我们能够分别求出到达每个点的最大路径,然后在所有点里面取最大值即可解决这个问题。

  2. 下面我们仅考虑走到最下面一层确定点的最大路径。假设我们现在想求走到最下面一层中间的22的最大路径,最暴力的方法就是列举所有走到22的路径,然后取路径和最大的一条作为答案。所以,所有走到22的路径如下:
    $$
    \textcolor{red}{(7 \to 3 \to 8 \to 7 \to 2) \qquad 7 + 3 + 8 + 7 + 2 = 27} \

    (7 \to 3 \to 1 \to 7 \to 2) \qquad 7 + 3 + 1 + 7 + 2 = 20 \
    (7 \to 3 \to 1 \to 4 \to 2) \qquad 7 + 3 + 1 + 4 + 2 = 17 \

    (7 \to 8 \to 1 \to 7 \to 2) \qquad 7 + 8 + 1 + 7 + 2 = 25 \

    (7 \to 8 \to 1 \to 4 \to 2) \qquad 7 + 8 + 1 + 4 + 2 = 22 \
    (7 \to 8 \to 0 \to 4 \to 2) \qquad 7 + 8 + 0 + 4 + 2 = 21 \
    $$
    所以,最终走到22的路径里面,数字和最大是2727。

  3. 我们进一步观察所有走到22的路径。因为它的路径只可能从上面两个方向走下来。所以如下图,所有走到2的路径可以被分成两类:从77走过过来的路径和从44走过来的路径。

img

  1. 对于所有结尾是77的路径

$$
(7 \to 3 \to 8 \to 7) \qquad 7 + 3 + 8 + 7 = 25\

(7 \to 3 \to 1 \to 7) \qquad 7 + 3 + 1 + 7 = 18\

(7 \to 8 \to 1 \to 7) \qquad 7 + 8 + 1 + 7 = 23\
$$

我们只需要接上一段\to 2→2,就可以变成从最上面的结点走到22的路径:
$$
(7 \to 3 \to 8 \to 7 \textcolor{red}{\to 2}) \qquad 7 + 3 + 8 + 7 \textcolor{red}{+2} = 27\

(7 \to 3 \to 1 \to 7 \textcolor{red}{\to 2}) \qquad 7 + 3 + 1 + 7 \textcolor{red}{+2} = 20\

(7 \to 8 \to 1 \to 7 \textcolor{red}{\to 2}) \qquad 7 + 8 + 1 + 7 \textcolor{red}{+2} = 25\
但 是 , 如 果 我 们 已 经 知 道 “ 到 达 77 的 路 径 ” 里 面 ∗ ∗ 第 2 条 路 经 和 第 3 条 路 径 不 如 第 1 条 路 径 ∗ ∗ , 是 不 是 就 可 以 直 接 舍 弃 下 面 两 条 , 只 考 虑 经 由 第 1 条 路 径 走 到 22 的 情 况 ? 也 就 是 说 , 为 了 求 所 有 “ 经 由 77 走 到 22 ” 的 路 径 里 面 , 我 们 只 需 要 计 算 下 面 ∗ ∗ 一 条 路 径 ∗ ∗ 即 可 : 但是,如果我们已经知道“到达77的路径”里面**第2条路经和第3条路径不如第1条路径**,是不是就可以直接舍弃下面两条,只考虑经由第1条路径走到22的情况?也就是说,为了求所有“经由77走到22”的路径里面,我们只需要计算下面**一条路径**即可: 772311227722
(7 \to 3 \to 8 \to 7 \textcolor{red}{\to 2}) \qquad 7 + 3 + 8 + 7 \textcolor{red}{+2} = 27\
同 样 , 对 于 所 有 “ 经 由 44 走 到 22 ” 的 路 径 里 面 , 我 们 也 只 需 要 挑 选 到 达 44 最 大 的 一 条 , 然 后 将 → 2 → 2 接 在 后 面 。 同样,对于所有“经由44走到22”的路径里面,我们也只需要挑选到达44最大的一条,然后将\to 2→2接在后面。 44224422
(7 \to 8 \to 1 \to 4 \textcolor{red}{\to 2}) \qquad 7 + 8 + 1 + 4 \textcolor{red}{+2} = 22\
$$

  1. 那么因为“从三角形顶端到达22”的路径里面,只有上面两种情况,所以,它们之间的的较大值就是到达22的最大路径,也就是

max ⁡ { 27 , 22 } = 27 \max\{27, 22\} = 27 max{27,22}=27

  1. 可是如此一来,我们不需要枚举所有”从顶点到达2“的路径。但为了找出两种情况下各自的最大值,看起来我们仍需要枚举”从顶点到达7“和”从顶点到达4“的路径。

    但是,我们发现,找到”从顶点到达7“和”从顶点到达4“的最大路径,就是一个和原问题”从顶点到达2“结构相似的问题!另外,由于77和44的位置比22要少一行,所以实际上,这两个问题是一个规模更小的问题!也就是说,这两个问题是原问题的一个子问题!那么,我们利用和上面类似的分析思路,就可以不用枚举所有到达77和44的路径了。

    数字金字塔问题算法描述

    img

    给定一个nn层的金字塔,求一条从最高点到底层任意点的路径使得路径经过的数字之和最大。

    注:每一步可以走到左下方的点也可以到达右下方的点。

    这里,我们把上一步的基本思路形式化成一个严谨的算法:

    1. 我们用a[i][j]存储数字金字塔第i行第j列的数字,用f[i][j]表示”从顶点到达第i行第j列“的所有路径中最大的数字和。

    2. 对于顶点,因为它是起始点,所以f[1][1] = a[1][1]

    3. 因为到达(i, j)的路径最多只可能从(i - 1, j - 1)(i - 1, j)两个点走过来(如果在三角形的最左边或者最右边,那么它的上一个结点就只有一种情况),所以,我们有下面的关系:

       f[i][j] = f[i - 1][j - 1] + a[i][j];                    // i == j
       f[i][j] = f[i - 1][j]     + a[i][j];                    // j == 1
       f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j];  // otherwise
      

      那么,观察这个等式,会发现如果我们已知f[i - 1][j - 1]f[i - 1][j],就可以求出f[i][j]。所以实际上,它有点像一个特殊形式的递推:有初始状态递推关系。那么我们通过一个二重循环就可以求出所有f[i][j]

    4. 最后,我们输出所有f[n][j]对于所有(1<=j && j<=n)的最大值即可。

数字金字塔完整代码实现

img

【数字金字塔】

给定一个nn层的金字塔,求一条从最高点到底层任意点的路径使得路径经过的数字之和最大。

注:每一步可以走到左下方的点也可以到达右下方的点。

代码实现

动态规划的代码实现相对简单,基本上是使用循环来计算递推序列的过程。下面直接给出代码:

#include <bits/stdc++.h>
#define N 1005
#define M 110
using namespace std;

int n;

int a[N][N], f[N][N];

int main() {
    // 输入
	cin >> n;
	for (int i = 1; i <= n; ++i) 
		for (int j = 1; j <= i; ++j)
			cin >> a[i][j];
			
    // 动态规划过程
	for (int i = 1; i <= n; ++i)
		for (int j = 1; j <= i; ++j)
			f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j];
    		// 此处没有讨论 j == 1 和 i == j 的情况
    		// 是因为当 j == 1 时,f[i - 1][j] == 0
    		// 是因为在数字金字塔所有数字都是正数的情况下
    		// max函数一定不会选择用f[i - 1][j]来转移
    		// i == j 的情况同理
			
    // 输出
	int ans = 0;
	for (int i = 1; i <= n; ++i) ans = max(ans, f[n][i]);	// 求第n行的最大值
	cout << ans << endl;
	
	return 0;
}
复杂度分析

空间复杂度

该问题的空间复杂度是 O ( n 2 ) O(n^2) O(n2)

时间复杂度

动态规划因为大部分都是由一些for循环组成,所以复杂度分析相对简单。在本例中,因为有两层for循环,并且都是n左右的数量级,所以整个算法的复杂度为 O ( n 2 ) O(n^2) O(n2)

动态规划分析流程和条件

现在让我们一起总结一下动态规划分析流程和条件

首先是动态规划分析流程:

数字金字塔的分析中我们发现,用动态规划解决问题的过程,就是一个把原问题的过程变成一个阶段性决策的过程。

比如在数字金字塔问题中,路径每往下延伸一行,我们就进行到下一个阶段,或者步骤。而在每一个步骤里,我们需要决策到底是从左上过来,还是从右上过来。在运用动态规划方法分析问题的过程中,下面四个要素是要明确的:

  1. 状态状态用于描述每一个步骤的参数以及结果。在数字金字塔的例子中,每个f[i][j]表示的就是一个状态。其中数组下标是当前路径的结尾,而值是以ij列元素为结尾的所有路径中的最大值。
  2. 转移方程转移方程用于描述不同状态之间的关系。在上面的例子中,f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j]就是一条转移方程。它描述了结尾为下一行的第j个结点的路径,和以上一行第j-1个结点和第j个结点路径之间的关系。
  3. 初始状态初始状态描述的是整个转移方程推导的开始,是不需要经由别的状态就知道结果的状态。上面的例子中,f[1][1]=a[i][j]就是初始状态。我们以这个状态为起点,最终推导出整个三角形上每一个位置的答案。
  4. 转移方向转移方向描述的是推导出不同状态的解的先后关系。我们之所以要明确转移方向,是因为我们不希望"已知B状态只能由A状态推到过来。但是当我们想推导B时,发现A状态的结果我们还不知道”类似的事情发生。比如由转移方程中f[i][j] = max(f[i - 1][j - 1], f[i - 1][j]) + a[i][j],我们发现,如果想推导f[i][j],必须先推导f[i - 1][j - 1]f[i - 1][j]。所以,按照i从小到大,j从小到大的顺序推导是一种可行的推导方向。

所以,为了用动态规划解决问题,我们就需要明确上面四个方面,其中最重要的就是设计状态转移方程

动态规划条件

那么,是不是所有最优化类问题都能用动态规划来解决呢?

不是

那么,使用动态规划需要满足什么条件?

在这里指出,用动态规划求解要求我们设计出状态和转移方程,使得它们满足下面三个条件:

  1. 最优子结构原问题的最优解必然是通过子问题的最优解得到的。比如上面的例子中,我们提过,如果所有以7为结尾的路径里面,有一条的数字和最大。那么,在所有经由7到达2的路径里,我们一定选择到达7的和最大的一条。所以,这样的问题具有最优子结构的性质。
  2. 无后效性前面状态的决策不会限制到后面的决策。比如说数字金字塔问题里,无论以任何方式走到7,我们都可以在后面接一段从7走到2,变成一条到达2的路径。所以,数字金字塔没有后效性。但是,在旅行商问题里,如果我们从1号城市开始,走到3号城市,那么途中经没经过2号,将会影响到3号城市后面的路径。这个场景就是有后效性的例子。
  3. 重复子问题一个子问题可以被重复利用到多个父亲状态中。我们发现在下面这张图中,f[3][2]既可以用来更新f[4][2],又可以用来更新f[4][3]。那么,因为我们把它存在数组里,所以只需要计算一次f[3][2],就可以使用很多次。也就是说,f[4][2]f[4][3]有个共同的子问题f[3][2]

img

动态规划算法的关键在于解决冗余,这是动态规划算法的根本目的。

动态规划实质上是一种以空间换时间的技术,它在实现的过程中,不得不存储产生过程中的各种状态,所以它的空间复杂度要大于其他的算法。

选择动态规划算法是因为动态规划算法在空间上可以承受,而搜索算法在时间上却无法承受,所以我们舍空间而取时间。

背包问题

有这样一个问题:

辰辰采药

辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。为此,他想拜附近最有威望的医师为师。医师为了判断他的资质,给他出了一个难题。医师把他带到一个到处都是草药的山洞里对他说:“孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”

如果你是辰辰,你能完成这个任务吗?

辰辰采药在算法中属于一种很经典的0-1背包问题。更一般的,这种问题可以转化为:

给定nn个物品,每个物体有个体积v_iv**i和一个价值p_ip**i。现有一个容量为VV的背包,请问如何选择物品装入背包,使得获得的总价值最大?

0-1背包问题分析

0-1背包问题描述

给定nn个物品,每个物体有个体积v_iv**i和一个价值p_ip**i。现有一个容量为VV的背包,请问如何选择物品装入背包,使得获得的总价值最大?

基本思路

考虑到现在我们能做的决策,只有对于每个物品的“”与“不选”。所以,这个问题就是

  • 以“将每一个物品依次放入背包”划分不同阶段
  • 而对于每个物品的“选与不选”就是不同决策

考虑到所有的放置前i个物品的方案数可以分为两类:

  • 一个是放第i个物品,
  • 一个是不放第i个物品

所以下面我们分这两种情况来讨论。因为在决策的过程中,变化的是当前所在的阶段,以及容量的剩余大小。

所以,我们维护一个二维状态f[i, j], 来表示前i个物品,放到体积为j的背包里,可以得到的最大价值。

首先,考虑容量为**任意值j时,**将前i个物品放入背包的情况。

  • 如果我们不选择第i个物品,那么总共j大小的背包空间相当于都用来放前(i - 1)的物品。那么我们如果想收益最大,就应该在前i−1个物品中选一个最优秀的子集。而解决”对于前i-1个物品,容量为j的背包,能获得的最大收益”就变成了一个子问题!

所以,当前的答案应该等于f[i - 1, j]f[i−1,j]。如下图:

img

  • 另一种决策就是选择第i个物品。当我们一定选择第i个物品时,整个背包一定要分出v[i]v[i]的空间来放它。这样一来,一个容量为j的背包里,只剩下jv[i]的空间来放前i−1个物品了。而当前这个背包中所有物品的总收益,就是第i个物品的收益,加上用jv[i]的空间装前i−1个物品的收益。我们发现,后者就是原问题的一个子问题!所以这种情况的最大收益是f[[i - 1][j - v[i]] + p[i]。

img

然后,当我们讨论完到达当前状态的两种决策以及各自的收益,我们应该选择哪种决策呢

当然是选择收益更大的那个!所以,我们有下面的式子:
f [ i , j ] = max ⁡ { f [ i − 1 , j ] , f [ i − 1 , j − v [ i ] ] + p [ i ] } f[i, j] = \max\{f[i - 1, j], f[i - 1, j - v[i]] + p[i]\} f[i,j]=max{f[i1,j],f[i1,jv[i]]+p[i]}

0-1背包问题的形式化分析

【0-1背包问题】

给定nn个物品,每个物体有个体积v_iv**i和一个价值p_ip**i。现有一个容量为VV的背包,请问如何选择物品装入背包,使得获得的总价值最大?

还记得吗?

使用动态规划解决问题,需要明确状态设计转移方程初始状态转移方向四个方面。

那现在,让我们来明确一下该0-1背包问题中的动态规划四要素:

  1. 状态

f[i][j]表示前ii个物品,放在空间为jj的背包里,能获得的最大收益。

  1. 转移方程

因为每一个阶段有至多两种选择,所以需要分别计算两种选择的收益后取较大值。

f[i][j] = f[i - 1][j]									// j < v[i],表示装不下第i个物品
f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + p[i]);	// otherwise
  1. 初始状态

在一个物品都没放的时候,无论背包大小多少,总价值都是0,即

f[0][j] = 0 // 0 <= j <= V
  1. 转移方向

观察转移方程,发现要想保证等式右边的状态一定比左边的状态先算出来,只需要保证ii从小到大计算即可。

最终该问题的答案就是f[n, V]。这样,0-1背包问题就可以使用动态规划来解决~

#include <bits/stdc++.h>

#define N 1002

using namespace std;



int n = 3; 

int V = 70;

int v[N] = {0, 71, 69, 1}; // 背包中共有3个物体,体积分别为71,60,1

int p[N] = {0, 100, 1, 2}; // 背包中共有3个物体,价值分别为100,1,2
							 // 第0位,置为0,不参与计算,便于与后面的下标进行
int f[N][N]; 

int main() {
    
    // 动态规划
    for (int i = 1; i <= n; ++i) {
        for (int j = 0; j <= V; ++j) {
            if (j < v[i]) 
                // TODO 请补全代码
                f[i][j]=f[i-1][j]; // 当前背包容量不够装第i个物品
            else 
                // TODO 请补全代码
               f[i][j]= max(f[i-1][j],f[i-1][j-v[i]]+p[i]); // otherwise
        }
    } 
    
    // 输出
    cout << f[n][V] << endl;
    return 0;
}
复杂度分析

空间复杂度

因为最大用了二维数组,所以空间复杂度是O(nV)。

时间复杂度

我们可以看出,整个算法用了两个for循环来实现,所以时间复杂度是O(nV)。

或者我们也可以用另一个思路来分析动态规划的时间复杂度

因为一共有nV个状态,而每个状态只需要O(1)的时间计算转移方程,所以总时间复杂度
总 时 间 复 杂 度 = 状 态 数 × 得 到 每 个 状 态 的 时 间 复 杂 度 = n V × O ( 1 ) = O ( n V ) 总时间复杂度 = 状态数\times得到每个状态的时间复杂度 = nV \times O(1) = O(nV) =×=nV×O(1)=O(nV)
可以看出这个算法和物品个数背包容量都有关。

并且我们发现,这个算法可能不适用于背包容量非常大(例如 1 0 9 109 10^9109 109109),每个物品的体积也非常大的情况。

算法优化1 —— 滚动数组优化

在动态规划中,有的时候可能内存空间比较紧张。比如说一个O(n^2)O(n2)的算法,当n = 6000n=6000时,大概需要3.6\times 1073.6×107的运算时间。可能计算机很容易在一两秒之内得出结果。但假设该问题需要一个O(n2)O(n2)的long long数组,那么总内存空间为

6000 \times 6000 \times 8 = 2.88 \times 10^8\text{ (Byte)} = 2.8 \times 10^5 \text{ (KB)}= 274 \text{ (MB)}6000×6000×8=2.88×108 (Byte)=2.8×105 (KB)=274 (MB)

这可能就超出了一些题目给定的空间大小,例如256M256M。所以,我们需要一些技巧来优化内存开销。

滚动数组优化

因为整个动态规划的过程就是一个填表的过程,如下图:

img

而在本题中,填表的顺序就是:填完上一行,然后填下一行。而且我们发现,下一行的状态,只会用到上一行的状态来转移。所以,当我们在计算第i行时,其实前i-2行的状态就都没必要保留了。所以,我们可以用一种类似于”踩石头过河“的思想。

试想如果我们有一些石头,想利用这些石头过河。

如果石头的数量很多,那么最方便的方法就是用这些石头铺一道石头路,这样我们就可以顺利过河。这就相当于我们可以开很充足的数组,然后把计算的每个阶段都存在数组里。

但如果我们只有两块石头,就过不了河了吗?不是的。我们可以用下图的方法一边走一边挪动石头,这样也可以顺利过河。

img

在空间优化的方法中,有一种很常见就是利用过河的思想。这种方法叫做滚动数组。在整个算法过程中,我们只用2×V的数组f[2][V]来记录状态。其中,所有奇数行的状态填入f[1][j]中,所有偶数行的状态填入f[0][j]中,如下图

img

所以整个代码实现就变成了

// ...
int f[2][N]; // 相当于只开了两个一维数组
int main() {
    // 动态规划过程
    for (int i = 1; i <= n; ++i) {
        for (int j = 0; j <= V; ++j) {
        if (j < v[i]) 
            // i & 1是为了取i的奇偶性
            f[i & 1][j] = f[(i - 1) & 1][j];	
        else 
            f[i & 1][j] = max(f[(i - 1) & 1][j], f[(i - 1) & 1][j - v[i]] + p[i]);
        }
    } 
    
    // 输出
    cout << f[n & 1][V] << endl;
}
算法优化2 —— 优化到一维数组

那么我们可不可以再进一步优化空间,使得只用一个一维数组就能解决整个问题了呢?

想到之前“踩石头过河”的类比,我们可能会觉得不太可能。但是如果我们进一步分析整个表的填写,如下图:

img

会发现下一行的某个状态,正好是由它上面的元素,以及左上方的某个元素转移而来。所以我们需要保证当计算黄色状态时上面两个绿色状态没有被覆盖掉。所以,当我们计算第i行时,完全可以将j*从大到小枚举,这样在计算状态(i, j)之前,数组f[j]中存储的是状态f[i - 1, j],更新完以后,f[j]中存的状态就是f[i, j]了。如下图:

img

所以代码可以这样写:

// ...
int f[N];	// 相当于只开了一个一维数组
int main() {
    // 动态规划过程
    for (int i = 1; i <= n; ++i) {
        for (int j = V; j >= v[i]; --j) {	
            // 只枚举到v[i],是因为在v[i]之前,所有f[i][j] = f[i - 1][j]
            // 那么在一维数组的场景下,就相当于没有改变
            f[j] = max(f[j], f[j - v[i]] + p[i]);
        }
    } 
    
    // 输出
    cout << f[V] << endl;
}
总结
  • 背包问题是一种经典的动态规划问题。

问题描述为有n个物品,每个物品有一定的体积和价值。我们想挑选一些物品放入容量为V的背包里,求最大价值。

  • 背包问题的分析方式是通过讨论每个物品“放与不放“,连接前i-1i−1个物品的状态和前ii个物品的状态之间的关系。所以最终该状态的值就是两种选择下,获得收益的较大值。
  • 背包问题时间复杂度空间复杂度都是O(nV)O(n**V),但是我们可以使用滚动数组,或者进一步精细分析,将空间复杂度下降为O(V)O(V)。
  • 背包问题中动态规划四要素
  1. 状态

f[i][j]表示前ii个物品,放在空间为jj的背包里,能获得的最大收益。

  1. 转移方程

因为每一个阶段有至多两种选择,所以需要分别计算两种选择的收益后取较大值。

// j < v[i],表示装不下第i个物品
f[i][j] = f[i - 1][j];   
// otherwise
f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + p[i]);	
  1. 初始状态

在一个物品都没放的时候,无论背包大小多少,总价值都是00,即

f[0][j] = 0; // 0 <= j <= V
  1. 转移方向

观察转移方程,发现要想保证等式右边的状态一定比左边的状态先算出来,只需要保证ii从小到大计算即可。

  • 背包问题代码实现:
#include <bits/stdc++.h>
#define N 1002
using namespace std;

int n, V, v[N], p[N];
int f[N][N]; 

int main() {
    // 输入
    cin >> V >> n;		// V是总体积,n是物品总个数
    for (int i = 1; i <= n; ++i) 
        cin >> v[i] >> p[i];
    
    // 动态规划
    for (int i = 1; i <= n; ++i) {
        for (int j = 0; j <= V; ++j) {
            if (j < v[i])  // 当前背包容量不够装第i个物品
                f[i][j] = f[i - 1][j];		
            else // otherwise
                f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + p[i]); 
        }
    } 
	
    // 输出
    cout << f[n][V] << endl;
    return 0;
}
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值