第1章:算法基础【AcWing】

文章目录


阅读前导

  • *表示算法的核心步骤。

  • 关于阅读体验:本文只会在比较重要的地方才会使用语法高亮。

  • 虽然 STL 中有相应算法,但本文只讨论原理本身。

快速排序

题目描述

[luogu]P1177 【模板】快速排序

利用快速排序算法将读入的 N N N 个数从小到大排序后输出。

输入格式

1 1 1 行为一个正整数 N N N,第 2 2 2 行包含 N N N 个空格隔开的正整数 a i a_i ai,为你需要进行排序的数,数据保证了 a i a_i ai 不超过 1 0 9 10^9 109

输出格式

将给定的 N N N 个数从小到大输出,数之间空格隔开,行末换行且无空格。

样例

样例输入

5
4 2 4 5 1

样例输出

1 2 4 4 5

提示

对于 20 % 20\% 20% 的数据,有 N ≤ 1 0 3 N\leq 10^3 N103

对于 100 % 100\% 100% 的数据,有 N ≤ 1 0 5 N\leq 10^5 N105

算法

(分治,双指针) O ( n l o g n ) O(nlogn) O(nlogn)

快速排序算法的基本思想是采用分治法,将待排序的序列分成两个子序列,然后递归地对子序列进行排序,最终得到有序的序列。

因为是分治思想,所以采用最朴素的递归,所以下面的区间实际上是每个函数栈帧中的子区间,且默认情况是升序。

首先要明确,在基于分治的排序算法中,有效操作是消除逆序对。

  1. 确定分界点pivot。一般是任意取,在此取arr[mid]
  2. *确定双指针框定的范围(分区)。
    • 初始状态:ij分别指向子区间的两端。
    • 指针停止条件:
      • i找比pivot小的元素,所以遇到>= pivot时i停下;
      • j找比pivot大的元素,所以遇到<= pivot时j停下。
    • 两个指针都停止的状态:
      • i指针之前(不包括arr[i])的所有元素都<= pivot;
      • j指针之后(不包括arr[j])的所有元素都>= pivot。
      • 当i < j 时,交换arr[i]和arr[j]。交换以后i和j往中间走一步。
    • 循环停止条件:i == j。指针相遇或交叉。
  3. 区间递归处理。

注意:

  1. 计算mid时注意溢出,同时可以用>>操作符提高效率(其实>>操作符还有其他作用,将在本文的「整数二分」中介绍)。
  2. 由于每次交换以后i和j都要往中间走1步,索性先走一步再交换,所以要让i和j的初始位置处于真正的边界之外,例如真正的边界是[0, 3],那么i和j初始状态框定的边界就是[-1, 4]。在交换之间直接先走一步,那么也就走到了真正的边界中。
  3. 当i和j指针都停止时,说明它们都找到了不符合条件的与元素。对于arr[i]和arr[j],它们是这个序列中的逆序对,也就是说,对于pivot而言,它们处于相反的位置,因为要求i和j指针扫描后的元素都是符合上面两个条件的。对于逆序对,只要i和j指针还未相遇或交叉,那么就交换arr[i]和arr[j],消除逆序对,使它们处于正确的位置上。

为什么要求i < j时才交换?

  • 因为i<j时,arr[i]和arr[j]才是一个逆序对。由于默认是升序,所以对于数组中的两个元素而言,下标小的值就小,逆序对相反:下标小的反而大。

image-20230419134757279

以颜色描述分区过程:以某一趟操作为例,当i和j指针相遇时,i之前的元素都<=pivot;j之后的元素都>=pivot。每个子区间递归地执行上述操作,最后会消除所有逆序对,使整个序列有序。

分区的意义是什么?

这是快速排序最核心的动作,使pivot元素在正确的位置上。通过分区后的结果看,pivot前后元素分别<=和>=它,那么pivot这个元素在整个序列中的位置就是正确的,也就是说,只要分区一次,就能让一个元素“归位”。

了解了分区的含义,那么在递归时就不容易搞错它的区间了。

下面是它的递归树:

image-20230419140312772

递归的过程就是逐渐缩小规模的过程,直到规模足够小,小到图中的最后一层递归,那么就可以认为这个子区间有序,直接返回。

示例代码

void QuickSort(int arr[], int L, int R)
{
    
    
    if(L >= R) return;
    int i = L - 1, j = R + 1, pivot = arr[L + ((R - L) >> 1)];
    while(i < j)
    {
    
    
        while(arr[++i] < pivot);
        while(arr[--j] > pivot);
        // 或者
        // do i++; while(arr[i] < x);
		// do j--; while(arr[j] > x);
        if(i < j) swap(arr[i], arr[j]);
    }
    QuickSort(arr, L, j), QuickSort(arr, j + 1, R);
}

注意事项

  • 递归终止条件:在快速排序中,L >= RL == R并不等价。

    • L == R:表示子序列的长度为1。当子序列的长度为0时,即L > R时,这个条件并不成立。如果将递归边界条件设置为L == R,则当子序列的长度为0时,仍然会进行递归调用,但是这次调用并不能缩小问题的规模,因此会导致无限递归。
    • L >= R是递归边界条件,它表示子序列的长度小于等于1。当子序列的长度为1或0时,即子序列中只有1个或没有元素,那么它已经有序,不需要再进行排序。因此,当L >= R时,递归调用结束。
    • L >= R是作为快速排序算法的标准递归边界条件,在某些测试题中能用L == R通过测试,这取决于题给数据的规范性。使用L >= R不仅仅在于通过题目,而在于把握L == R的反面例子,即区间长度为0时,如[1, 1]子区间。
  • 指针的初始值:i = L - 1, j = R + 1

    • [为什么要将i和j指针指向区间的边界之外?]因为i和j指针找的是逆序对,所以要通过运算符迭代;而逆序对被交换后,也要迭代一次,所以在这个while循环中每一个分支都要进行迭代操作,所以干脆先迭代一次,再操作。
    • [为什么是前缀运算符而不是后缀?]原因同上。
    • 就这个模板而言,如果i和j的初始值是真正的区间[L, R]的话,就要在其他地方增加判断。
  • [既然能随便选择基准元素,为什么要选arr[mid]?]选择中间的元素作为基准元素是一种优化方法。如果总是选择第一个或最后一个元素作为基准元素,那么当输入数组本身就是有序或接近有序时,快速排序算法的时间复杂度会退化为 O ( n 2 ) O(n^2) O(n2)。而选择中间的元素作为基准元素可以在一定程度上避免这种情况的发生。当然,这并不是唯一的方法,还有如随机选择等。

  • 递归的区间:在上面的代码中,使用j来划分递归子区间更安全。因为在上面的while循环中,j的最终值一定小于等于i。因此,使用j来划分区间可以保证左右两个子区间不会重叠。

如果想使用i来划分区间,那么你需要在while循环结束后检查i和j的值。如果i>j,那么你需要将i和j的值对偶地交换,因为i和j的位置是对称的。

例如:

  • 如果pivot选取arr[L],则递归时子区间范围选择[L, j]和[j + 1, R]

  • 如果pivot选取arr[R],则递归时子区间范围选择[L, i - 1]和[i, R]

  • 如果pivot选取arr[(L + R + 1) / 2],则递归时参数范围选择两者都可以。注意是向上取整,所以取中的分母要加1。

因此为了方便,就直接求mid = (L + R) / 2,选择[L, j]和[j + 1, R],好记且不容易出错。

总的来说,一旦pivot取的是arr[R]或arr[L],即左右边界元素之一,那么在划分递归子区间时就不能包括让子区间越界,因为当区间长度是2时,它还会分为2个长度为1的子区间,如果有某一边的越界的,它会被函数一开始的终止条件L >= R过滤,另一边的边界仍然还是上一层长度为2的区间,无限迭代。

例如当pivot选取了arr[L],递归的子区间范围选择了[L, i - 1]和[i, R]:假设arr[2] = {1, 2},原本的范围[L, R]是长度为2的[0, 1],pivot = arr[L] = arr[0] = 1。经过while循环后i = j = 0,那么兹曲线的范围是[0, -1]和[0, 1],此时子区间中有一个越界的区间(被终止条件过滤)和跟上一层一样的区间(高亮)。这样就是一个无效递归操作,造成无限递归。

时间复杂度

由于快排的算法并不是严格的二分,在期望上(概率)每次递归都会使问题在规模减半,递归树有 l o g n logn logn层,处理每层的代价是 O ( n ) O(n) O(n),所以这个算法的平均时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn),最坏情况下(如序列已经有序或接近有序)的时间复杂度是 O ( n 2 ) O(n^2) O(n2)。这取决于选取的基准元素(pivot)和输入数据的特征。如果基准元素选取得当,快速排序的效率会很高。但如果基准元素选取不当,快速排序的效率会降低。

稳定性

(模板题中一般不考虑稳定性):快速排序在对含有重复元素的数组排序时是不稳定的,但可以把元素值和其下标组成二元组后再排序,这样就能使排序结果稳定。

巧记稳定性(来源于B站的某个评论):

  • 选艾希堆攻速下路不稳。

选(选择排序)艾希(希尔)堆(堆排序)攻速(快速排序)下路不稳(不稳定)。

归并排序

题目描述

由于是都是排序问题,所以可以用上面的题目测试。

算法

(分治,双指针) O ( n l o g n ) O(nlogn) O(nlogn)

归并排序和快速排序都使用了分治思想,不同的是归并排序的区间划分每次都是(子)区间的一半。

步骤:

  1. 确定分界点:mid
  2. 左右递归(排序):将待排序序列以分界点mid分成两个子序列,然后递归地对子序列进行排序。
  3. *合并:最后将两个有序的子序列a[i],b[j]合并成一个有序的序列c[k]
    • 同时枚举两个有序子序列,将更小的那个元素放到c[k]中;
    • 两个子序列之一中可能有剩余的元素,执行完上一步则说明剩余的元素没得比了,将它们直接尾接到c[k]中。

其中,合并的步骤具体如下:

  1. 双指针ij分别指向被mid分割的两个子区间的起点;
  2. 枚举两个区间的元素,由于整个序列的长度有奇偶之分,因此两个子序列的长度可能不同,因此while循环结束的条件是任意一个子序列的元素被枚举完毕(也可能同时)–这一点是为了说明while循环进行的条件;
  3. 把两个子序列中较小的那个元素放到辅助数组tmp[k]中;
  4. 处理剩余元素;
  5. 将辅助数组tmp[k]中区间[L, R]的元素写回原数组。

关于辅助数组的使用:归并排序可以只使用 O ( 1 ) O(1) O(1)的辅助空间,但为便捷通常使用与原数组等长的辅助数组。写回原数组与否取决于具体情景。

示例代码

void MergeSort(int a[], int L, int R)
{
    
    
    if(L >= R) return; // 终止条件
    
    int mid = L + ((R - L) >> 1);
    MergeSort(a, L, mid), MergeSort(a, mid + 1, R); // 先递归处理使左右两边序列有序
    // 双指针分别指向两边的首部
    int i = L, j = mid + 1;
    int k = 0;
    while(i <= mid && j <= R) // 终止条件是i=L+1,j=R+1
    {
    
    
        if(a[i] <= a[j]) tmp[k++] = a[i++]; // 把小的元素放在tmp中[稳定版本]
        else tmp[k++] = a[j++];
    }
    // 处理剩余元素
    while(i <= mid) tmp[k++] = a[i++];
    while(j <= R) tmp[k++] = a[j++];
    // 把tmp[k]倒回去
    for(int i = L, j = 0; i <= R; i++, j++) a[i] = tmp[j];
}

注意事项

  • 递归终止条件:当区间长度为0或1时,就认为它已经有序,直接返回。

  • *递归处理的意义[分治思想的体现]:是为了将待排序序列分成越来越小的子序列,直到子序列的长度为1。当子序列的长度为1时,它已经是有序的了。然后,再将这些有序的子序列两两合并,最终得到一个完全有序的序列。言外之意,在编写代码的过程中,在递归逻辑后面的操作都以两个子序列已经有序为前提,也就是说认为递归都已经返回了。这是“递归”在计算机中运行的流程和编写递归逻辑的区别。

  • 递归处理的区间应该是[L, mid][mid + 1, R],而不是[L, mid - 1]和[mid, R]。这是因为在计算中间位置mid时,使用的是int mid = L + ((R - L) >> 1);,这样计算出来的mid是区间[L, R]的中间位置,它将区间分成了两个子区间[L, mid]和[mid + 1, R]。

    • 如果在递归处理时使用[L, mid - 1]和[mid, R]作为子区间,那么在合并两个子区间时,会漏掉元素a[mid]。这样就会导致内存超限。
  • 为保证排序的稳定性,前段首元素小于或等于后段首元素时(a[i] <= b[j])而非小于时(a[i] < b[j])就要作为最小值放入 c[k]

  • 在倒回原数组的过程中,需要将本次递归处理的区间中的所有元素倒回,即[L, R],而不是[0, R],因为这个操作是在递归中进行,区间是不同的。

时间复杂度

归并排序算法采用分治法,将待排序序列分成两个子序列,然后递归地对子序列进行排序,最后将两个有序的子序列合并成一个有序的序列。

由于数组被递归划分的过程是严格等分的,即 m i d = ⌊ L + R 2 ⌋ mid = \lfloor\frac{L + R}{2}\rfloor mid=2L+R,递归树有 l o g n logn logn层,处理每层的代价是 O ( n ) O(n) O(n),因此无论是最好情况、最坏情况还是平均情况,时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn)

整数二分

题目描述

给定一个按照升序排列的长度为n的整数数组,以及 q 个查询。
对于每个查询,返回一个元素k的起始位置和终止位置(位置从0开始计数)。
如果数组中不存在该元素,则返回“-1 -1”。
输入格式
第一行包含整数n和q,表示数组长度和询问个数。
第二行包含n个整数(均在1~10000范围内),表示完整数组。
接下来q行,每行包含一个整数k,表示一个询问元素。
输出格式
共q行,每行包含两个整数,表示所求元素的起始位置和终止位置。
如果数组中不存在该元素,则返回“-1 -1”。

数据范围

1 ≤ n ≤ 100000 1≤n≤100000 1n100000

1 ≤ q ≤ 10000 1≤q≤10000 1q10000

1 ≤ k ≤ 10000 1≤k≤10000 1k10000

样例

输入样例

6 3
1 2 2 3 3 4
3
4
5

输出样例

3 4
5 5
-1 -1

算法

(二分) O ( l o g n ) O(logn) O(logn)

注意:一般题目有暗示序列具有「单调性」时,就能使用「二分」将问题转化为「判定」。本节中二分的写法最终答案的区间处于闭区间[l, r]以内,当l == r时循环结束。每次二分后mid的值会在两个子区间中。

首先介绍一下二分思想的背景,也就是本题内容。在非递减子序列中(也就是说它可能严格递增,也可能有连续的相同元素),给定一个整数x

  • a[]中查找>=x的数中最小的一个元素(即x本身或x的后一个元素)。
  • a[]中查找<=x的数中最大的一个元素(即x本身或x的前一个元素)。

从数轴上看,可以用远近来表征上面两个操作:
image-20230420104809682

从数轴看待问题的视角是理解二分,控制二分边界的关键。本题是一个很好的例子,使用这个视角的关键有两方面,一是要查找的关键元素(在示例中都是假设它存在的),二是在分析过程中以距离关键元素的“远近”分析比较容易理解。

image-20230420105133957

*边界控制

二分虽然从思想上比较清晰,每次把范围缩小一半,但是对于非负整数(我们用int定义变量)的除法,它是向下取整的,这就会导致计算mid= L + R >> 1时会有偏差。而二分最难的地方就是控制边界不出错,要解决它,不仅要理解上面的图,还要理解非负整数除法的特性。

为什么是正整数除法?因为被操作的对象下标都是非负数。

除法的性质

这里的除法特指计算机中的除法,(对于所有整数)实际上/>>除了速度之间有差别之外,计算结果上的差别:

  • /向零取整:也就是说得到的数字可能是2.3、2.7,抹去小数点后的数字,保留整数,也就是2。当操作数是负数时,如-1.5,它会得到-1,数值反而变大了,在某些场景可能会出现问题。
  • >>向下取整:对于正整数而言和向零取整无区别。当操作数是负数时,如-1.5,那么会得到-2。

两个取整方式可以认为是一个竖直向上的数轴,向零取整就是向0位置的方向取整,正数向零取整就是向下取整,可能会使数值变小;负数向零取整就是向上取整,也就可能会使数值变大,因此当操作数可能出现负数时,为了保险起见使用>>

由于我们操作的是下标,不可能出现负数,因此都是向下取整。向下取整有一个巨坑,那就是当「除以x」这个操作的分子如果没有多贡献>=1,那么结果和除以x之前不会发生任何改变,这是造成无限递归的原因。

二分中最关键的操作就是mid = (l + r) / 2,分子上的l和r合起来必须贡献>=1,才会使mid的值改变。当r - l = 1时,也就是说[l, r]区间的长度为2时,使用这个式子就会出现错误:

mid = (l + r) / 2
取区间[l, r] = [0, 1]
mid = (0 + 1) / 2 = 0

这个结果暂且搁置,通过下面的代码便能发现其中的错误。

示例代码

  • a[]中查找>=x的数中最小的一个元素(即x本身或x的后一个元素)。
  • a[]中查找<=x的数中最大的一个元素(即x本身或x的前一个元素)。
int BinarySearch1(int a[], int x, int l, int r)
{
    
    
	int mid = (l + r) >> 1;
	if(a[mid] >= x) r = mid;
	else l = mid + 1;

	return l;
}
int BinarySearch2(int a[], int x, int l, int r)
{
    
    
	int mid = (l + r + 1) >> 1;
	if(a[mid] <= x) l = mid;
	else rand = mid - 1;

	return l;
}

分析过程:

在第一段代码中,如果a[mid] >= x,由于序列是非递减的,因此mid之后的数字会更大,因此要找的x不可能在mid之后,而会在mid左半段区间。由于mid也可能是x,所以在更新边界时应该包含mid,即r = mid;同理,如果a[mid] < x,即不满足条件(else),那么就应该取l = mid + 1

在第二段代码中,如果a[mid] <= x,由于序列是非递减的,因此mid之后的数字会更小,因此要找的x不可能在mid之前,而会在mid右半段区间。由于mid也可能是x,所以在更新边界时应该包含mid,即l = mid;同理,如果a[mid] >= x,即不满足条件(else),那么就应该取r = mid - 1

高亮的逻辑是在编码过程中非常重要的思考过程。它能帮助我们以何种方式更新区间。

*注意事项

如代码所示,这种二分写法会有两种形式:

  • r = mid对应mid = (l + r) >> 1
  • l = mid对应mid = (l + r + 1) >> 1

这两种写法的区别在于计算mid时,分子是否要加1,结合上面的“除法的性质”来理解,加1就是让它向上取整,对于第二种情况l = mid,如果mid = (l + r) / 2,当区间[l, r]长度为2时,例如[0, 1],mid=(0 + 1) / 2 = 0,0恰好是分子l的值,l = mid->l = l,因此这是一个无效的更新,会造成死循环。

可以从值域的角度理解,计算mid的式子是一个函数,那么当区间长度为2时,它们一定能取到左边界:

mid = (l + r) / 2:一定能取到边界l,一定取不到边界r。

mid = (l + r + 1) / 2:一定取不到边界l,因为+1向上取整了,所以最多能取到的边界是l + 1。

这就是在整数除法中“向下取整”对边界的影响,刚才在谈到向下取整时以竖直向上的坐标轴为例,那么对于横着的坐标轴(因为我们从逻辑上习惯横着理解区间划分),向下取整就变成了“向左取整”,这就是当区间长度为2时,mid总是能取到左边界的原因。

因为除法的特点,使得mid总是能取到(当前区间的)左边界,所以当左边界是以l = mid方式更新区间时,那么就要保证让mid不能取到当前区间的左边界,否则就l <- mid <- l了(<-表示赋值),所以要加1使它取到左边界的下一个位置,这样就能保证区间能够正常划分,不会造成死循环。

小结

结合上面的分析,mid的应该向上取整与否取决于边界l的更新方式,因为除法的特性,使得在区间长度为2时总是能让mid取到左端点,因此当ll = mid的方式更新区间时,就向上取整,因此做整数二分的步骤应该是:

  1. 先按流程写mid = (l + r) >> 1
  2. 然后再根据具体情况看l的更新方式。
    1. 如果是l = mid,分子加1。

当循环结束时,l == r,因此返回两者都是等价的。

值得注意的是,本例中以「单调」序列为例讨论二分,也就是说一旦题目暗示了「单调性」,且题目本身就应该用二分解决,那么二分一定能搞定它。特别是“最大值中的最小”和“最小值中的最大”字眼,强调了答案的单调性。也就是说,二分一定能解决单调序列的题目,但实际上许多不是单调的问题也能用二分做。所以二分是一个强大的思想。

此外,本例中查找的是整数,实际上可能会查找不同类型的值,所以可以将if中的判断单独用一个check()函数包装。

时间复杂度

由于二分每次都会严格地将搜索区间缩小一半,所以最多需要进行 l o g n logn logn次查询,因此二分的时间复杂度是 O ( l o g n ) O(logn) O(logn)

相关题目

[luogu]P2249 【深基13.例1】查找

[AcWing]786.第k个数

[leetcode]34. 在排序数组中查找元素的第一个和最后一个位置

[leetcode]167. 两数之和 II - 输入有序数组

[leetcode]300. 最长递增子序列

浮点数二分

题目描述

[AcWing]790.数的三次方根

给定一个浮点数 n n n,求它的三次方根。

输入格式

共一行,包含一个浮点数 n n n

输出格式

共一行,包含一个浮点数,表示问题的解。

注意,结果保留6位小数。

数据范围

− 10000 ≤ n ≤ 10000 -10000≤n≤10000 10000n10000

输入样例

1000.00

输出样例

10.000000

算法

(二分) O ( l o g n ) O(logn) O(logn)

浮点数的二分比较简单,只要确定好精度,以r - l == [精度]作为循环终止的条件。每次划分区间时只要按需选择l = midr = mid之一即可。这么做是为了保证答案一定在区间内(如果有的话),因为mid可能是答案。

步骤同上,值得注意的是控制精度的力度,有一个经验值:当题目要求保留6位小数,那么为了保险起见,一般比题目多两个数量级,即8位小数。

bool check(double mid, double x)
{
    
    
	return mid * mid >= x;
}
int BinarySearch3(double a[], double x, double l, double r)
{
    
    
	while(r - l > 1e-8)
	{
    
    
		double mid = (l + r) >> 1;
		if(check(mid, x)) r = mid;
		else l = mid;		
	}
	return l;
}

其中check()是根据具体需要而写的判断函数。

除此之外,精度可能不容易确定或表示,就直接循环地二分一定次数,例如100次,那么最后被二分后的区间长度一定非常小,而且精度也比上面的高。例如:

int BinarySearch4(double a[], double x, double l, double r)
{
    
    
	for(int i = 0; i < 100; i++)
	{
    
    
		double mid = (l + r) >> 1;
		if(check(mid, x)) r = mid;
		else l = mid;		
	}
	return l;
}

循环100次,相当于区间的长度被除以 2 100 2^{100} 2100

高精度计算

大整数存储

高精度计算包括加减乘除,对于加法和减法,本文只考虑最常用的大数加减法,大数的范围一般在 1 0 6 10^6 106级别;对于乘法和除法,也只考虑大数和小数运算,其中大数的长度在 1 0 6 10^6 106以内(注意是长度),小数的值一般在 1 0 4 10^4 104以内。值得注意的是,在此讨论的数都是正整数。

高精度计算解决的就是C/C++中类型存不下那么大的数的问题(其他语言在此不做讨论),它的原理和我们小学在纸上的运算没有区别,只是将这些步骤用代码实现。

在进行运算操作之前,需要将两个数字储存起来,一般的做法是用一个数组存储,为了方便操作,下面将使用vector容器。

首先让我们回到小学一年级,以加法为例,我们将两个数字从个位数对齐排列,并从个位数开始相加,在相加的过程中可能会产生进位,那么在下一位计算时就要加上这个进位。进位会产生一种特殊的情况,会造成最后的结果的长度会更长,例如99+1=100。

所以为了进位的方便,在存储数据的时候从个位数开始存储,对应下标依次增长。如果进位造成了结果的长度增长一位,那么直接push_back就好了,否则就要挪动数据,非常麻烦。

示例代码

string a, b;
vector<int> A, B;
cin >> a >> b;

for (int i = a.size() - 1; i >= 0; i--) A.push_back(a[i] - '0');
for (int i = b.size() - 1; i >= 0; i--) B.push_back(b[i] - '0');
  • 用字符串a和b接收大数。
  • 用vector容器倒着存放大数的每一个权位。

大整数比较

由于减法可能会出现负数,因此我们可以用绝对值的规则,例如1-2=-|2-1|=-1,为了处理负号,就要判断两个大数谁比较大。

位数大的一定大,位数相同的就从个位数开始比,直到有一个权位上的数字不相等,否则它们一样大。

示例代码

bool check(vector<int>& A, vector<int>& B) 
{
    
    
    if(A.size() != B.size()) return A.size() > B.size();
    else
    {
    
           
        for(int i = A.size() - 1; i >= 0; i--)
            if(a[i] != B[i])
                return A[i] > B[i];
    }
    return true;
}

高精度加法

就是模拟纸上加法的过程:

  1. 从个位相加,有进位就进位。

  2. 十位数相加,加上个位数的进位,有进位就进位

  3. 两个数的长度可能相同可能不同,只要枚举完任意一个数的权位,那么就计算完毕。

十进制逢十进一,如13+9,它的个位相加等于12,通过两种运算完成个位相加操作:

  • 模运算:个位只存的下0~9,10个数字,12进位后保存在个位上的数应该是2,12 % 10 = 2
  • 除运算:12对于个位来说应该进1,12 / 10 = 1

对于每个权位的运算,实际上都是3个数在相加:两个数的权位值,和进位。只不过一开始在个位时,进位是0。实际上取模和除运算在纸上是同时的,计算机中的除运算会向下取整。

所以加法运算就是从个位数开始,三个数相加,把对10取模的余数push_back在保存结果的容器C中,把对10求商的结果作为当前权位的进位,留到下一个权位计算,如此往复。

示例代码

vector<int> Add(vector<int>& A, vector<int>& B)
{
    
    
    vector<int> C;
    int t = 0; // 进位
    for(int i = 0; i < A.size() || i < B.size(); i++)
    {
    
    
        if(i < A.size()) t += A[i];
        if(i < B.size()) t += B[i];
        C.push_back(t % 10);
        t /= 10;
    }
    if(t) C.push_back(t); // 处理最后一个进位
    return C;
}

注意事项

  • 进位t必须定义在循环外部,因为进位是给下一个权位计算的。
  • i表示迭代权位,原则上只要只要枚举完任意一个数所有权位的值就可以结束迭代,但是这里相当于把处理剩余权位的操作合并了。因此在进行加法运算时,需要在循环内部单独判断当前权位(i)是否存在于两个数中。
  • 最后进位t可能不为0,例如1+99,要进位2次,但百位上的1没有在循环中push_back进C,因此最后要处理一下这种情况。

如果在加法操作之前就判断了A和B的长度,那么在循环中就不用都判断权位是否合法了,例如:

// A.size() >= B.size() 
vector<int> Add(vector<int>& A, vector<int>& B)
{
    
    
    vector<int> C;
    int t = 0; // 进位
    if(A.size() < B.size()) return Add(B, A);
    for(int i = 0; i < A.size(); i++)
    {
    
    
        t += A[i];
        if(i < B.size()) t += B[i];
        C.push_back(t % 10);
        t /= 10;
    }
    if(t) C.push_back(1); // 处理最后一个进位
    return C;
}

不过这个写法没有上面那么对称,建议写第一种,虽然有许多没必要的操作,但是好记。

高精度减法

首先假设A>=B,如果B>A,那么用绝对值的法则就好了。

减法也是类似的,模拟纸上的过程:

  1. 个位上的数相减,不够向下一位借1。怎么算不够?–相减的值小于零就是不够。
  2. 十位数上的数相减,再减去个位数的借位,不够向下一位借1。
  3. 这里已经在外部用判断保证了A.size() >= B.size(),因此得出的结果是绝对值,一定>=0。

和减法一样,每个权位上的值之间的减法都是3个数在相减,两个权位值,一个借位。

示例代码

vector<int> Sub(vector<int>& A, vector<int>& B)
{
    
    
    vector<int> c;
    int t = 0; // 借位,0/1两种状态表示
    for(int i = 0; i < A.size(); i++)
    {
    
    
        t = A[i] - t;   
        if(i < B.size()) t -= B[i];
        c.push_back((t + 10) % 10);
        if(t < 0) t = 1;
        else t = 0;
    }
    
    // 处理前导0
    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

变量t在这段代码中用于借位。当从低位到高位逐位进行减法运算时,如果发现被减数的当前位小于减数的当前位,那么我们就需要向高位借1。在这段代码中,t的值为0或1,表示当前位是否需要借1。如果t为1,则在计算下一位时,被减数的当前位需要减去1。这种方法很巧妙地避免了显式地进行借位操作。

注意事项

  • t = A[i] - t, t -= B[i]非常巧妙地用一个借位变量t保存了3个值相减后的结果。初始状态t=0,t被赋值为A[i],如果i合法的话,那么t的值更新为与B[i]相减后的值,相当于t保存了A[i]-B[i]的值,后续根据t和0的关系,判断个位数相减是否向十位数借位;在后续状态中,t可能为1,那么借位需要被减去,t = A[i] - t的巧妙之处就在于t不论是1还是0,通过这个操作能保证在一开始都能被减去,后续再减去B[i],就实现了3个数相减。
  • (t + 10) % 10包含两种情况:
    • t >= 0:结果是t % 10;
    • t < 0:结果是t + 10
    • 注意,此时t对于当前权位而言并不代表借位,而代表3个数相减后的结果:
      • 如果t>=0,那么说明没有借位,因为是一位十进制数,所以t % 10t本身等价,就是三数相减之后的结果。
      • 如果t<0,说明借位了,因为是十进制减法,所以借位代表向下一位借了10,也就是加10,负数加10后的结果仍旧是一位十进制数。
  • 处理前导0。由于是通过枚举较长的那个数A的所有权值位,所以当较短的数B枚举完后,就不存在借位了,因此t + 10) % 10的结果是0,所以push_back的也是0。如果是1001-1000,结果就是0001(注意我们是倒着存放的数字的)。因此要处理前导0,但是要保证至少有1位,以满足A == B这类情况。由于可能存在多个前导零,因此使用while处理。

高精度乘法

在此讨论的高精度乘法是一个大数A乘以一个较小数b(范围在本节开始就已经给出了),乘法和加法十分类似。在纸上进行乘法运算时我们习惯将大数A放在小数b之上,并以个位数对齐。步骤如下:

  1. 将大数A的每一个权值都与小数b相乘,有进位的进位。
  2. 当前位的值是模10后的余数。
  3. 进位值是除10后的商。

示例代码

vector<int> Multi(vector<int>& A, int b)
{
    
    
	vector<int> C;
	int t = 0;
	for(int i = 0; i < A.size() || t; i++)
	{
    
    
		if(i < A.size()) t += A[i] * b;
		C.push_back(t % 10);
		t /= 10;
	}
	return C;
}

注意事项

将进位变量t的判断放在for循环的条件中,是为了确保在计算完A的所有位之后,如果仍然有进位,则继续进行计算,直到没有进位为止。这样可以确保结果的正确性。

举个例子,假设A = {9, 9}b = 2,则A * b = 198。在计算过程中,当计算完A的所有位之后,t = 18。如果不将t的判断放在循环条件中,则循环会在此时结束,结果为{8, 1},显然是错误的。但是如果将t的判断放在循环条件中,则循环会继续进行,直到t = 0为止,最终得到正确的结果{8, 9, 1}

造成这个结果的原因是乘法的进位不同于加法,乘法的进位可能很大,因此可能某一处的进位会导致所有权位上的进位,也可能有很多个权位都要进位,这取决于那个较小数b的大小(一个极端的例子,如果b=1,那么不会出现进位)。而加法的进位最多只进1。

实际上,一个10进制的数字可以以 1 0 n 10^n 10n的多项式表示,例如:
123 = 1 ∗ 1 0 0 + 2 ∗ 1 0 1 + 1 ∗ 1 0 2 123=1*10^0 + 2*10^1 + 1*10^2 123=1100+2101+1102
那么除以10的操作就是将多项式的每一项向高位移动一位,而模10的操作相当于取出多项式的最低位。例如123/10=12,123%10=3。

高精度除法

和高精度乘法类似,是一个大数A除以一个较小数b。

值得注意的是,除法运算是从权值的最高位开始的,而数是倒着存储的,因此运算时要从后往前枚举权位。步骤如下:

  1. 定义额一个变量r表示余数。
  2. 对于最高位上的值,它和除数b的商就是这一权位对应的商;
  3. 对于次高位上的值,它的值加上上一位的值乘以10之后的值再除以b才是这一权位对应的商。
  4. 翻转保存商的容器C。
  5. 处理前导零。

示例代码

// A / B = C ... r, A >= 0, b > 0
vector<int> Divide(vector<int>& A, int b)
{
    
    
	vector<int> c;
	int r = 0; // 余数
	for(int i = A.size() - 1; i >= 0; i--)
	{
    
    
		r = r * 10 + A[i];
		c.push_back(r / b); // 商 注意分母不是10,而是指定的除数
		r %= b;
	}
	reverse(c.begin(), c.end());
	// 去除前导0,至少保证有一位0,例如1-1=0
	while(c.size() > 1 && c.back() == 0) c.pop_back();
	return c;
}

*注意事项

如何理解r = r * 10 + A[i]

同样地,将大数A看作一个 1 0 n 10^n 10n的多项式。在这种情况下,r * 10操作相当于将多项式的每一项向低位移动一位,而+ A[i]操作相当于在多项式的最低位添加一个新的项。

例如,假设有一个高精度整数A = {3, 2, 1},则它表示的整数为123。如果我们对它进行r * 10 + A[i]操作,则得到的结果为r * 10 + 1 = r0 + 1,其中r0表示原来的余数。这样,在下一步计算中,我们就可以使用(r0 + 1) / b来计算商和余数。

*从我们在纸上进程除法的操作理解它:
image-20230420170628985

我们在纸上一般忽略商为0的情况,在计算机中需要执行这个步骤。当我们对一个权位上的值求完商和余数时,这次的余数会保留,然后将下一个权位的值直接“拉下来”(图中红色的箭头)。虽然在纸上这个“拉”的动作十分容易,但是从数值的变化来看,例如图中的第二部分,“拉”之前是上一次的余数1,把2拉下来之后这个数字就变成了12。其本质是上面谈到的多项式,每个位置上都代表着不同的权值,如果想用计算机模拟“拉”这个动作,那么就要将上一位的余数*10,再加上本次迭代的权位上的值,然后才能继续做除法。而r = r * 10 + A[i]正在完成“拉”的操作。

还可以以另一种视角理解*10的作用:例如123和4两个数字,如何让它们变成1234?

答案是123*10+4。

也就是说*10这个动作相当于把每一位数向高位挪动一位,结果是腾出了个位,使得个位能被使用。

前缀和

前缀和是一种重要的预处理方式,可用于快速求数组的区间和,能大大降低查询的时间复杂度。

为了方便结合高中已经习惯的数列知识,本小节和下一节的「差分」都将使用 S n S_n Sn a n a_n an表示数组。

一维前缀和

题目描述

N N N个的正整数放到数组 a a a里,现在要求一个新的数组 S S S,新数组的第 i i i个数 S [ i ] S[i] S[i] 是原数组第 1 1 1到第 n n n个数的和。

输入

5
1 2 3 4 5

输出

1 3 6 10 15

在上面的基础上,计算序列中的某个区间中所有元素的和是前缀和的应用场景,例如[leetcode]303. 区域和检索 - 数组不可变

算法

一维前缀和就是高中的前n项和,为了方便,将 a [ 0 ] a[0] a[0] S [ 0 ] S[0] S[0]设置为 0 0 0,而且它们不会被当做有效值使用。对于前n项和有以下公式:
S [ i ] = S [ i − 1 ] + a [ i ] S[i] = S[i - 1] + a[i] S[i]=S[i1]+a[i]
既然a[i]已知,只要利用公式计算S[i]即可。

for(int i = 1; i <= n; i++)
    S[i] = S[i - 1] + a[i];

要求S[l, r],那么就要求出S[r]S[l - 1]

image-20230420175217503

注意区间[l, r]中的元素一共有l + r + 1个,所以减去左边的元素时,需要控制边界。

因此数组的区间和可以使用式子求出:
S [ l , r ] = S [ r ] − S [ l − 1 ] S[l, r] = S[r] - S[l - 1] S[l,r]=S[r]S[l1]

示例代码

for (int i = 1; i <= n; i++) S[i] = S[i - 1] + a[i]; 
for (int i = 1; i <= n; i++)  
{
    
    
    scanf("%d", &a[i]) 
    S[i] = S[i - 1] + a[i];
}

return S[r] - S[l - 1];

时间复杂度

求序列的某段区间[l, r]中所有元素的和,枚举的时间复杂度是 O ( n ) O(n) O(n)。如果用前缀和预处理,那么只要枚举一次,后续所有查询只要取出前缀和 S [ i ] S[i] S[i]中的两项作差,时间复杂度是 O ( 1 ) O(1) O(1)。因此前缀和的时间复杂度是接近 O ( 1 ) O(1) O(1)的。

相关题目

二维前缀和

多维前缀和的普通求解方法几乎都是基于容斥原理实现的。所谓容斥原理,就是用若干个集合通过某种组合,不重不漏地表示一个全集。

题目描述

如果有这样一个矩阵 a [ 4 ] [ 4 ] a[4][4] a[4][4]:

1 2 3 4 
1 2 3 4 
1 2 3 4 
1 2 3 4 

如果定义一个矩阵 S [ 4 ] [ 4 ] S[4][4] S[4][4],满足:
S [ x ] [ y ] = ∑ i = 1 x ∑ j = 1 y a [ i ] [ j ] S[x][y] = \displaystyle\sum_{i=1}^x\displaystyle\sum_{j=1}^ya[i][j] S[x][y]=i=1xj=1ya[i][j]
那么 S [ 4 ] [ 4 ] S[4][4] S[4][4]是这样的:

1   3    6   10
2   6   12   20
3   9   18   30
4   12  24   40

把每一个元素看成1*1的方块,对于每个元素 S [ x ] [ y ] S[x][y] S[x][y],它的值等于从矩阵的左上角到它本身围成的正方形包含所有元素的和。例如:

image-20230420195642793

那么如何求出中间某个区域的元素之和?如:

算法

如果使用枚举,那么时间复杂度是 O ( n ∗ m ) O(n*m) O(nm),下面将以容斥原理使用前缀和进行预处理后,能以接近 O ( 1 ) O(1) O(1)的时间复杂度解决这个问题。

预处理

预处理的目的和一维前缀和相同,就是将每个位置的左上角所有元素之和记录起来,以便查询。

左边的S[3][3]是要求的,右边的矩阵中被红点标定的区域是已经求过的(从下标的位置来看,越靠右下的点时间越靠后求),而a[3][3]也是已知的,那么可以通过右边4个集合组合,最终能使得组合后的集合中的值和最左边所求的S[3][3]相等。这样的话求S[3][3]的时候就不用从头再遍历求和了,直接使用之前已经求过的值即可。

通过观察我们可以发现:红色+黄色后,绿色会被多加一次,所以绿色应该被减去,最后再加上最后的a[3][3],求出来的值就是S[3][3]

image-20230420204133617

即求S[x][y]的公式如下:
S [ x ] [ y ] = S [ x − 1 ] [ y ] + S [ x ] [ y − 1 ] − S [ x − 1 ] [ y − 1 ] + a [ x ] [ y ] S[x][y] = S[x - 1][y] + S[x][y - 1] - S[x - 1][y - 1] + a[x][y] S[x][y]=S[x1][y]+S[x][y1]S[x1][y1]+a[x][y]
类似地,为了处理初始情况,将 S [ 0 ] [ 0 ] S[0][0] S[0][0] a [ 0 ] [ 0 ] a[0][0] a[0][0]都设置为0。

求某个区域所有元素之和

image-20230420205746503

所求的区域可以用一对坐标 ( x 1 , y 1 ) (x1, y1) (x1,y1) ( x 2 , y 2 ) (x2, y2) (x2,y2)限定,上图中最左边的蓝色区域就是我们要求的某个范围中所有元素的和。那么它可以通过右边4个区域组合表示:红色减去黄色和蓝色,那么绿色区域就被减去了两次,所以绿色要被加上。通过这个例子可以得到求某个区域中所有元素的和的公式:
S ( x 1 , y 1 ) , ( x 2 , y 2 ) = S [ x 2 ] [ y 2 ] − S [ x 2 ] [ y 1 − 1 ] − S [ x 1 − 1 ] [ y 2 ] + S [ x 1 − 1 ] [ y 1 − 1 ] S_{(x1, y1),(x2, y2)} = S[x_2][y_2] - S[x_2][y_1 - 1] - S[x_1-1][y_2] + S[x_1-1][y_1-1] S(x1,y1),(x2,y2)=S[x2][y2]S[x2][y11]S[x11][y2]+S[x11][y11]

请注意图中的矩阵之间的符号。并且上面两图中所有的矩阵都是 a [ x ] [ y ] a[x][y] a[x][y]

示例代码

// 预处理, 求S[x][y]
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++) 
        S[i][j] = S[i - 1][j] + S[i][j - 1] - S[i - 1][j - 1] + a[i][j];

// 求(x1, y1)和(x2, y2)之间所有元素的和
int res = S[x2][y2] - S[x2][y1 - 1] - S[x1 - 1][y2] + S[x1 - 1][y1 - 1];
return res;

注意事项

  • 求(x1, y1)和(x2, y2)之间所有元素的和时,必须保证S[x][y]数组已经存在。

  • S[x][y]a[x][y]的第一个元素都不存储数据,一是为了方便对应坐标轴,二是处理初始状态是的临界条件。因此枚举也要从下标为1开始。

  • 在题目中,可以读入的同时记录a[x][y]S[x][y]

时间复杂度

O ( n ∗ m ) O(n*m) O(nm)降到接近 O ( 1 ) O(1) O(1)

相关题目

差分

差分运算是前缀和的逆运算。就像微分与积分一样。例如给定某一个序列 S n S_n Sn,求出一个序列 a n a_n an,使得 S n S_n Sn a n a_n an的前缀和,那么称 a n a_n an S n S_n Sn的差分。

一维差分

首先结合一维前缀的公式来看:
S [ i ] = S [ i − 1 ] + a [ i ] S[i] = S[i - 1] + a[i] S[i]=S[i1]+a[i]
求计算前缀和的反函数,可以得到差分数列 S n S_n Sn是给定数列 a n a_n an的前缀和:
S [ 1 ] = a [ 1 ] , S [ i ] = a [ i ] − a [ i − 1 ] ( 2 ≤ i ≤ n ) S[1]=a[1],S[i]=a[i]-a[i-1](2≤i≤n) S[1]=a[1]S[i]=a[i]a[i1](2in)

题目描述

输入一个长度为 n n n的整数序列。

接下来输入 m m m个操作,每个操作包含三个整数 l , r , c l,r,c l,r,c,表示将序列中 [ l , r ] [l,r] [l,r] 之间的每个数加上 c c c

请你输出进行完所有操作后的序列。

输入格式

第一行包含两个整数 n n n m m m

第二行包含 n n n 个整数,表示整数序列。

接下来 m m m 行,每行包含三个整数 l , r , c , l,r,c, lrc表示一个操作。

输出格式

共一行,包含 n n n 个整数,表示最终序列。

数据范围

1 ≤ n , m ≤ 100000 1≤n,m≤100000 1n,m100000
1 ≤ l ≤ r ≤ n 1≤l≤r≤n 1lrn
− 1000 ≤ c ≤ 1000 −1000≤c≤1000 1000c1000
− 1000 ≤ 整数序列中元素的值 ≤ 1000 −1000≤整数序列中元素的值≤1000 1000整数序列中元素的值1000

样例

输入样例
6 3
1 2 2 1 2 1
1 3 1
3 5 1
1 6 1
输出样例
3 4 5 3 4 2

算法

如果使用枚举,时间复杂度是 O ( n ) O(n) O(n)。使用差分能以 O ( 1 ) O(1) O(1)的时间复杂度查询。

把序列 S n S_n Sn的[l, r]中的每个元素都加 c c c,也就是将 S l , S l + 1 , . . . , S r S_l,S_{l+1}, ..., S_r SlSl+1,...,Sr都加上 c c c。那么就要对原序列 S n S_n Sn的差分序列 a n a_n an a l a_l al c c c a r + 1 a_{r+1} ar+1 c c c。这样做的效果是:

  1. S l S_l Sl~ S n S_n Sn每一项都会加上 c c c,例如 S l = a 1 + . . . + a l S_l = a_1 + ...+a_l Sl=a1+...+al a l + c a_l+c al+c S l S_l Sl也会因此加上 c c c
  2. S r + 1 S_{r+1} Sr+1~ S n S_n Snd每一项都会减去 c c c。原因同上。

因此这就通过对差分序列的两项做加减法,使得原序列的区间[l ,r]中的所有元素都加上了 c c c

例如原序列 S n S_n Sn:

1 2 3 4 5

它的差分序列 a n a_n an是:

1 1 1 1 1

其中 a 1 = 1 , a 4 = 1 a_1 = 1,a_4 = 1 a1=1a4=1

通过枚举,给 S n S_n Sn的[1, 3]区间中的元素都加上 2 2 2

3 4 5 4 5

通过修改 a 1 a_1 a1 a 4 a_4 a4的值:

3 1 1 -1 1

这个序列的 S n S_n Sn是:

3 4 5 4 5

可见,通过 a l + c a_l+c al+c a r + 1 − c a_{r+1}-c ar+1c得到的序列,它的前缀和序列和枚举区间[l, r]的每个元素 + c +c +c是一样的。

示例代码

int a[N], S[N];5
void insert(int l, int r, int c)
{
    
    
	a[l] += c;
	a[r + 1] -= c;
}
for(int i = 1; i <= n; i++) scanf("%d", &S[i]); // 读入S[i]
for(int i = 1; i <= n; i++) insert(i, i, S[i]); // 初始化差分数组a[i]
while(某次询问)
{
    
    
    int r, l, c;
    cin >> r >> l >> c; // 读入区间[l, r]和常数c
    insert(l, r, c)		// 插入
}

for(int i = 1; i <= n; i++) a[i] += a[i - 1] // 差分数组自己重新计算修改后的前缀和
for(int i = 1; i <= n; i++) cout << a[i] << " "; // 输出前缀和

注意事项

  • 模版中为了清晰所以将每一步分开,实际上读入和操作可以在一个循环中进行。
  • 初始化差分数组时,可以复用insert()。这个操作非常巧妙,因为区间[i, i]的长度是1,那么就是一个元素的长度,或者可以把它看成一个一维坐标(i, i),那么插入的位置就是i这个位置,常数 c c c设置为它自己,就完成了差分数组的构造。当然,也可以用求前缀和公式的反函数公式求。
  • 当使用insert()修改了[l, r]区间中的值以后,差分数组中的值就已经被改变了,所以要重新求它的前缀数组。这个前缀数组既可以用新数组,也可以存到题给的原数组中,具体看需求。本模板将更新后的数组存到了差分数组本身,注意更新的方式。更新后, a [ i ] a[i] a[i]就已经不是差分数组了,是它更新后的前缀和数组。
  • 在思考时,先假设差分数组已经被构造出来了。

相关题目

二维差分

题目描述

输入一个n行m列的整数矩阵,再输入q个操作,每个操作包含五个整数x1, y1, x2, y2, c,其中(x1, y1)和(x2, y2)表示一个子矩阵的左上角坐标和右下角坐标。

每个操作都要将选中的子矩阵中的每个元素的值加上c。

请你将进行完所有操作后的矩阵输出。

输入格式

第一行包含整数n,m,q。

接下来n行,每行包含m个整数,表示整数矩阵。

接下来q行,每行包含5个整数x1, y1, x2, y2, c,表示一个操作。

输出格式

共 n 行,每行 m 个整数,表示所有操作进行完毕后的最终矩阵。

样例

输入样例
3 4 3
1 2 2 1
3 2 2 1
1 1 1 1
1 1 2 2 1
1 3 2 3 2
3 1 3 4 1
输出样例
2 3 4 1
4 3 4 1
2 2 2 2

数据范围

1 ≤ n , m ≤ 1000 , 1≤n,m≤1000, 1n,m1000,

1 ≤ q ≤ 100000 , 1≤q≤100000, 1q100000,

1 ≤ x 1 ≤ x 2 ≤ n , 1≤x1≤x2≤n, 1x1x2n,

1 ≤ y 1 ≤ y 2 ≤ m , 1≤y1≤y2≤m, 1y1y2m,

− 1000 ≤ c ≤ 1000 , −1000≤c≤1000, 1000c1000,

− 1000 ≤ 矩阵内元素的值 ≤ 1000 −1000≤矩阵内元素的值≤1000 1000矩阵内元素的值1000

算法

同样地,二维差分是二维前缀和的逆运算。结合计算二维前缀和的公式来看:
S [ x ] [ y ] = S [ x − 1 ] [ y ] + S [ x ] [ y − 1 ] − S [ x − 1 ] [ y − 1 ] + a [ x ] [ y ] S[x][y] = S[x - 1][y] + S[x][y - 1] - S[x - 1][y - 1] + a[x][y] S[x][y]=S[x1][y]+S[x][y1]S[x1][y1]+a[x][y]
求它的反函数:
a [ x ] [ y ] = S [ x ] [ y ] − S [ x − 1 ] [ y ] − S [ x ] [ y − 1 ] + S [ x − 1 ] [ y − 1 ] a[x][y] = S[x][y] - S[x-1][y] - S[x][y-1] + S[x-1][y-1] a[x][y]=S[x][y]S[x1][y]S[x][y1]+S[x1][y1]
其实就是移项,因为这个式子中只有一个变量。

但是这只是求一个元素及其右上角区域所有元素的差分公式,对于本题而言,目的是使两个元素围成的子矩阵中所有元素的值都加上常数 c c c

与二维前缀和类似,在分析差分数组时假设它已经构造好了,只要关心如何更新它的值,就能实现题目的要求。既然是二维,那么大概率要用到容斥原理。并且和计算二维前缀和类似,应该会有重复的区域,也要利用好两个坐标。

image-20230421192948836

注意,上图中的矩阵都是差分矩阵 a [ x ] [ y ] a[x][y] a[x][y],不论是一维还是二维差分(矩阵本身就是序列),(对于差分序列/矩阵)只要改变了某一个位置元素的值,那么从这个位置开始(包括这个位置)到序列末尾的所有前缀和都会因此改变。所以右边4个矩阵中,只要改变了坐标上元素的值,那么差分矩阵对应的前缀和矩阵中的这个坐标位置到右下角围成的范围中的前缀和都会因此而改变。

图中,被红色标记的元素之和就是对应坐标的前缀和。红色部分会由于坐标(1, 1)元素+c,而导致前缀和矩阵中相同位置的元素都+c;后面3个矩阵同理。由于绿色部分分别被黄色和蓝色减了一次,所以最后要把绿色部分加回来。

类似地,可以通过图示得出通过对差分矩阵中两个位置上元素的值 + c +c +c,从而实现以 O ( 1 ) O(1) O(1)的时间复杂度查询前缀和矩阵中两个位置围成的区域中所有元素 + c +c +c的功能。

示例代码

int a[N][N], S[N][N];
// 注意操作的是差分矩阵
void insert(int x1, int y1, int x2, int y2, int c)
{
    
    
    a[x1][y1] += c;
    a[x2 + 1][y1] -= c;
    a[x1][y2 + 1] -= c;
    a[x2 + 1][y2 + 1] += c;
}
// 读入原矩阵,它是差分矩阵的前缀和矩阵,因此用S
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++)
        scanf("%d", &S[i][j]);

// 初始化差分矩阵
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++)
        insert(i, j, i, j, S[i][j]);

// 处理查询
while(q--)
{
    
    
    // 读入坐标和常数c
    int x1, y1, x2, y2, c;
    cin >> x1 >> y1 >> x2 >> y2 >> c;
    // 在两点范围内插入
    insert(x1, y1, x2, y2, c);
}
// 由于修改了差分矩阵的元素,因此要更新前缀和矩阵
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++)
        S[i][j] = S[i - 1][j] + S[i][j - 1] - S[i - 1][j - 1] + a[i][j];

注意事项

  • 原矩阵是已知的,差分数组是我们要求的(初始化),原矩阵就是差分矩阵的前缀和矩阵,分别用 S [ x ] [ y ] S[x][y] S[x][y] a [ x ] [ y ] a[x][y] a[x][y]表示。求完以后还要修改,因此最后还要重新求一下前缀和矩阵。
  • 注意前缀和与差分处理问题的思想是“隔山打牛”,用枚举原矩阵对目标元素操作的时间复杂度是 O ( n ) O(n) O(n,而求出它的差分矩阵再对几个元素操作,就能以 O ( 1 ) O(1) O(1)的时间复杂度完成更新矩阵中的一个子矩阵。
  • insert()函数操作的对象是差分矩阵。

构建差分矩阵

构建差分矩阵的第一种方法就是求出前缀和公式的反函数,也就是一开始给出的公式,但是还有更妙的办法。

在初始化差分矩阵时,复用insert(i, j, i, j, S[i][j])非常巧妙,这和一维差分是一样的。将 S [ i ] [ j ] S[i][j] S[i][j]看作常数 c c c,把每个元素看成格子中心的点。

下面将通过一个例子来解释为什么可以使用insert函数来初始化差分数组。

假设我们有一个二维矩阵 b(是我们要求的差分矩阵),其内容如下:

1 2
3 4

我们可以通过计算前缀和来构建出对应的前缀和矩阵sum(是本题给定的原矩阵),其内容如下:

1 3
4 10

现在,我们想要使用 insert 函数来构建对应的差分数组 a。根据insert中的步骤,我们可以这样做:

  1. 初始化一个与 b 大小相同的矩阵 a,其初始内容全为0:
0 0
0 0
  1. 对于 sum 中的每个元素 sum[i][j],调用 insert(i, j, i, j, sum[i][j]) 来更新差分数组 a

首先,对于 sum[1][1],我们调用 insert(1, 1, 1, 1, sum[1][1]),即 insert(1, 1, 1, 1, 1)。根据 insert 函数的定义,这会将 a[1][1] 加上 sum[1][1],即1。因此,更新后的差分数组 a 变为:

1 0
0 0

接下来,对于 sum[1][2],我们调用 insert(1, 2, 1, 2, sum[1][2]),即 insert(1, 2, 1, 2, 3)。根据 insert 函数的定义,这会将 a[1][2] 加上 sum[1][2],即3。因此,更新后的差分数组 a 变为:

1 3
0 0

同理,对于 sum[2][1]sum[2][2],我们分别调用 insert(2, 1, 2, 1, sum[2][1])insert(2, 2, 2, 2, sum[2][2])。更新后的差分数组 a 变为:

1 3
3 4

可以看到,最终构建出的差分数组与原始矩阵相同。有没有很巧?

相关题目

双指针

这里的双指针算法是基于本节对类似数组这样的序列的总结。

对于算法本身而言,双指针应该是实现算法思想的工具,例如分治思想就经常使用双指针来遍历不同的子区间。在这里,指针操作的对象是序列,根据指针操作的对象个数不同,可以有一下两种:

  1. 一个指针各自维护一个序列,例如归并排序中的合并步骤。
  2. 两个指针一起维护一个序列,例如基于分治的快速排序在查找逆序对的过程,两个指针一开始分别在序列的两端,然后不断往中间走。

单词分割

以空格分割字符串中的单词。

样例输入:

Hello World

样例输出:

Hello
World

算法

双指针 O ( n ) O(n) O(n)

可以使用一个外层循环来遍历字符串中的每个字符。在外层循环中,两个指针 ij 来定位当前单词的起始和结束位置。指针 i 指向当前单词的第一个字符,而指针 j 则用来查找当前单词的结束位置。

在每次迭代中,程序首先将指针 j 初始化为 i,然后使用一个内层循环来查找下一个空格字符或字符串末尾。在内层循环中,如果当前字符不是空格且指针 j 小于字符串长度 len,则将指针 j 加1。

在内层循环结束后,指针 j 指向下一个空格字符或字符串末尾。此时,程序使用另一个内层循环来输出从指针 i 到指针 j-1 的所有字符,即当前单词。然后输出一个换行符。

最后,将外层循环的指针 i 更新为 j,以便在下一次迭代中跳过空格字符并开始处理下一个单词。

示例代码

#include <iostream>
#include <string>
using namespace std;
int main()
{
    
    
    string s;
    getline(cin, s);
    int len = s.size();
    for(int i = 0; i < len; i++)
    {
    
    
        int j = i;
        while(s[j] != ' ' && j < len) j++;
        for(int  k = i; k < j; k++) cout << s[k];
            cout << endl;
        i = j;
    }
    return 0;
}

注意事项

  • 指针 i 指向当前单词的第一个字符,指针 j 最后指向的是空格的位置。内层循环并没有取到j,那么恰好打印出来的就是不包含空格的完整单词。

时间复杂度

外层循环遍历字符串中的每个字符。对于每个字符,程序使用一个内层循环来查找下一个空格字符或字符串末尾。由于内层循环每次都会将索引 j 加1,因此它最多只会执行 n n n 次。

在内层循环结束后,程序使用另一个内层循环来输出当前单词。由于每个字符最多只会被输出一次,因此这个内层循环最多也只会执行 n n n 次。

两层循环最多执行 2 n 2n 2n 次,因此时间复杂度为 O ( n ) O(n) O(n)

相关题目

无重复字符的最长子串

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

提示:

  • 0 <= s.length <= 5 * 104
  • s 由英文字母、数字、符号和空格组成

算法1

(双指针,朴素) O ( n 2 ) O(n^2) O(n2)

一般而言,时间复杂度为 O ( n ) O(n) O(n)的双指针算法通常由复杂度更高的双指针朴素做法优化而来,其中一个指针一般起着枚举的作用,它通常是第一层循环。所以我们只要关注另一个指针是如何维护序列,能减少分析的成本。

注意:对于能用双指针解决的问题,一般用朴素的做法作为切入点(这也是任何问题的切入点)。对于这道题,它的朴素做法是枚举所有可能的子串。

示例代码

这是时间复杂度为 O ( n 3 ) O(n^3) O(n3)的双指针朴素做法。

  1. 指针i枚举字符串s中每一个字符;
  2. 指针j每次都从头出发,如果s[i] != s[j],那么j就继续移动,直到s[i] == s[j]
  3. 用一个变量记录i和j之间字符的个数的最大值,每执行一次上述操作,就更新它。
int lengthOfLongestSubstring(string s) 
{
    
    
        int res = 0;
        for(int i = 0; i < s.size(); i++)
        {
    
    
            for(int j = i; j < s.size(); j++)
            {
    
    
                bool flag = false;
                for(int k = i; k < j; k++)
                {
    
    
                    if(s[k] == s[j])
                    {
    
    
                        flag = true;
                        break;
                    }
                }
                if(flag) break;
                res = max(res, j - i + 1);
            }
        }
        return res;
    }

这是时间复杂度为 O ( n 2 ) O(n^2) O(n2)的双指针朴素做法。

其中 i 表示子串的起始位置,j 表示子串的结束位置。在内层循环中,使用一个哈希表 unordered_set 来存储当前子串中的字符。如果发现当前字符在哈希表中,说明当前子串中有重复字符,那么就跳出循环。否则,将当前字符添加到哈希表中,并更新最长子串的长度。

空间复杂度为 O ( 字符集大小 ) O(字符集大小) O(字符集大小)

int lengthOfLongestSubstring(string s) 
{
    
    
    int res = 0;
    for (int i = 0; i < s.size(); i++) 
    {
    
    
        unordered_set<char> set;
        for (int j = i; j < s.size(); j++) 
        {
    
    
            if (set.count(s[j])) break;
            set.insert(s[j]);
            res = max(res, j - i + 1);
        }
    }
    return res;
}

注意事项

unordered_set底层用哈希表实现,它不允许重复元素,如果指定元素存在,则count()成员函数返回1,否则返回0。

注意:在暴力枚举方法中,我们需要枚举所有可能的子串(先枚举起点,再枚举终点),然后检查每个子串是否有重复字符。因此,第二层循环应该从 i 开始,到 s.size() 结束,这样才能枚举所有以第 i 个字符开头的子串。

算法2

(双指针) O ( n ) O(n) O(n)

上面朴素的双指针通常能抽象为这样的框架:

bool check(){
    
    /*...*/} // 满足某种性质
// 对于本题:判断[i, j]之间是否有重复元素
int lengthOfLongestSubstring(string s) 
{
    
    
    int res = 0;
    for(int i = 0; i < s.size(); i++)
    {
    
    
        // for(int j = i; j < s.size(); j++)
        for(int j = 0; j <= i; j++)
        {
    
    
            if(check(i, j))
                // res = max(res, j - i + 1);
            	res = max(res, i - j + 1);
        }
    }
    return res;
}

优化:

这里面有很多重复的操作,而观察到ij是有单调关系的。i指针的作用只是枚举每个字符,j指针的作用找到从左到右离i最远的位置。如果满足了区间[j, i]之间所有字符都不重复,那么ij的单调性表现在:如果i继续往右边走,那么j不可能往左边走(不动或者跟着往右边走)。

image-20230422142852476

通过情况2反证:

image-20230422143003446

因为j的作用是维护[j, i]区间中无重复元素,即j是左边界,i向后移动会给这个区间引入一个新的字符,这个字符对于区间中的元素可能出现过,也可能没有出现过。根据这两种情况,j只可能有2种行为:

  1. s[i]是新元素:j不动。
  2. s[i]不是新元素:j向后移动一位,相当于和s[i]相同的那个本来在[j, i]中的元素踢出去了。

因此ij之间的单调性体现在i往右走时,j不可能往左。

除了使用哈希表,还可以用一个数组count记录[j, i]区间中每个元素出现的次数,例如新加入的元素是s[i],如果它是重复元素,count[s[i]] == 2。这么做的本质和哈希是一样的。

int lengthOfLongestSubstring(string s) 
    {
    
    
        int res = 0;
        int count[100010];
        for(int i = 0, j = 0; i < s.size(); i++)
        {
    
    
            count[s[i]]++;
            while(count[s[i]] > 1) // 重复
            {
    
    
                count[s[i]]--; // 更新计数
                j++; // 剔除重复元素
            }
            res = max(res, i - j + 1);
        }
        return res;
    }

时间复杂度

虽然是两层循环,但是ij最多只会遍历一次数组长度,时间复杂度是 O ( 1 ) O(1) O(1)

相关题目

位运算

lowbit

在算法中,通常情况下忽略大小端的区别,对于一个二进制序列,最右侧为第0位,最左侧是最高位。并且一般二进制数都是32位,即 C++ 中的 intunsigned int

lowbit是获取一个数的二进制中最低位的1对应的值,也就是最右边的值,例如:

10010

它最右边的值是:

00010

所以lowbit(n)的结果也是一个二进制序列。

算法

 N   	000011100
~N   	111100011
~N+1 	111100100
  • ~把所有的0和1变成1和0,包括最右边的1;
  • +1是产生进位,最终会让进位终止于原来N的二进制序列中最右边的1的位置上。

最后将N与上~N+1,即N & (~N + 1):

 N   	000011100
~N+1 	111100100
   &    000000100

这样就能提取出最右边的1。

应用

统计二进制数中1的个数。

int lowbit(int x)
{
    
    
    return x & -x;
}
int main()
{
    
    
    int n = 0; 
    cin >> n;
    while(n--)
    {
    
    
        int x = 0;
        cin >> x;

        int res = 0;
        while(x)
        {
    
    
            x -= lowbit(X);
            res++;
        }
        cout << res << endl;
    }
    return 0;
}

注意:

  • *lowbit()中使用的是x & -x
    • -x等价于 ~x + 1
    • 那么x & -x等价于 x & (~x + 1)

这是由于在计算机中负数时由补码存储的,补码是原码取反后加一。在二进制中,5 可以表示为 00000101,取反后变为 11111010,再加上 1 变为 11111011,这就是 -5 的补码表示。

离散化

离散化,把无限空间中有限的个体映射到有限的空间中去,以此提高算法的时空效率。离散化本质是哈希,它能保证数据在哈希以后仍然保持原来的全/偏序关系。

为啥要离散化?

离散化是一种技巧,它可以把无限空间中有限的个体映射到有限的空间中去。通俗的说,离散化是在不改变数据相对大小的条件下,对数据进行相应的缩小。这样可以简化数据并提高算法效率。

有些数据因为本身很大或者类型不支持,自身无法作为数组的下标来方便地处理,而影响最终结果的只有元素之间的相对大小关系时,我们可以将原来的数据按照从大到小编号来处理问题。

例如,在一些题目和算法中,我们会发现,我们实现我们的想法的时候,只跟原数据的相对大小有关,比如1000000 和 2000000,在实际实现的时候,和1 2的效果是完全一样的。那么,开2000000那么大的空间纯属浪费,我们知道题目通常会限制空间大小,那更不用说本身就要使用离散化思想的题目了(这也是用离散化的切入点,题目可能故意卡空间)。那么我们就用离散化给它映射到一个较小的区间中。这样可以减少内存占用并提高算法效率。

用来离散化的可以是大整数、浮点数、字符串等等。通常情况下离散化的对象是一个序列。

算法

例如有一个序列a[]:

1 3 100 12000 500000

那么可以将它们保序地离散化为:

0 1 2 3 4

注意:

  1. a[]中可能有重复元素,会产生哈希冲突,所以在进行离散化之前要对序列进行去重。
  2. 可以通过「二分」算出a[i]离散化以后的值。

模板

// 离散化
vector<int> alls; // 存储所有待离散化的值
sort(alls.begin(), alls.end()); // 将所有值排序
alls.erase(unique(alls.begin(), alls.end()), alls.end());   // 去掉重复元素

// 二分求出x对应的离散化的值
int find(int x) // 左->右,找到第一个大于等于x的位置
{
    
    
    int l = 0, r = alls.size() - 1;
    while (l < r) 
    {
    
    
        int mid = l + r >> 1;
        if (alls[mid] >= x) r = mid;
        else l = mid + 1;
    }
    return r + 1; // 映射到1, 2, ...n(跟题目有关,从下标为1开始映射)
}

注意:

  • unique()会把所有重复的元素移动到序列末尾,然后返回含重复元素的子序列的第一个位置的下标。
  • alls.erase(unique(alls.begin(), alls.end()), alls.end())将所有重复的元素从序列中移除。

应用

AcWing 802. 区间和

题目描述

假定有一个无限长的数轴,数轴上每个坐标上的数都是 0 0 0

现在,我们首先进行 n n n 次操作,每次操作将某一位置 x x x 上的数加 c c c

接下来,进行 m m m 次询问,每个询问包含两个整数 l l l r r r ,你需要求出在区间 [ l , r ] [l, r] [l,r] 之间的所有数的和。

输入格式

第一行包含两个整数 n n n m m m

接下来 n n n 行,每行包含两个整数 x x x c c c

再接下里 m m m 行,每行包含两个整数 l l l r r r

输出格式

m m m 行,每行输出一个询问中所求的区间内数字和。

数据范围

− 1 0 9 ≤ x ≤ 1 0 9 , −10^9≤x≤10^9, 109x109,
1 ≤ n , m ≤ 1 0 5 , 1≤n,m≤10^5, 1n,m105,
− 1 0 9 ≤ l ≤ r ≤ 1 0 9 , −10^9≤l≤r≤10^9, 109lr109,
− 10000 ≤ c ≤ 10000 −10000≤c≤10000 10000c10000

样例

输入样例
3 3
1 2
3 6
7 5
1 3
4 6
7 8
输出样例
8
0
5

算法

实际上,数组每个元素的值和下标本身就是一种映射关系。题目中强调了原序列是无限大的,它暗示了我们用任何一种数据结构都无法完整地存储它,但是把它存下来并没有多大作用。

你看,题目输入的是要操作元素的下标和要加上的常数,而且查询提供的也是下标组成的范围[l, r],因此我们可以建立原序列中被操作的元素的下标和新数组下标之间的关系。如何建立?只要将输入的下标存到一个新数组就自动建立了旧下标和新下标([0, …, n])之间的映射。由于本题中原序列所有元素的值都是0,因此只要关心原序列被操作元素的下标即可,否则在建立映射关系时,可以用键值对或对象保存下标和元素本身的值,其思想是不变的。

我们用alls[]保存题目输入的所有的下标,用a[]保存每个被操作的元素对应的常数,用add[]保存下标和常数,用S[]保存a[]的前缀和,步骤如下:

  1. 首先读入的是被操作元素在原序列中的下标x,和一个常数c。将每一个x插入到alls[]中。将{x, c}键值对插入到add[]中。

  2. 将询问的[l, r]的边界下标存入到alls[]中。

  3. *alls[]存的是操作时和查询时的下标,因此可能有重复,所以要排序+去重处理。

    • 去重是因为操作时的下标和查询时的下标并不矛盾,而且下标只有一个,所以只存查询时下标是合理的。

    • 排序是为了找出原数据在序列中的序位,这是离散化的关键。排序后的位置就是我们要映射到的东西(即新数组的下标)。

  4. 通过add[]保存的键值对,将被操作数在原序列中的下标x取出,通过自定义函数find()找到它在新数组alls[]中的下标,通过下标在新数组中用键值对中的常数c对它进行操作

  5. 为了效率,使用前缀和计算[l, r]中元素之和。

示例代码

const int N = 300010;
int a[N], s[N];        // 存储坐标插入的值;a[]的前缀和数组
vector<int> alls;      // 保存所有输入的下标
// add保存插入时的{位置, 常数}
// query保存的查询时的区间边界{l, r}
vector<pair<int, int>> add, query;

int find(int x)
{
    
    
    int l = 0, r = alls.size() - 1;
    while(l < r)
    {
    
    
        int mid = l + r >> 1;
        if(alls[mid] >= x) r = mid;
        else l = mid + 1;
    }
    return l + 1; // 从1下标开始映射
}

int main()
{
    
    
    int n = 0, m = 0;
    cin >> n >> m;
    // 保存"+c"操作
    for(int i = 1; i <= n; i++)
    {
    
    
        int x = 0, c = 0; // 位置 常数
        cin >> x >> c;
        add.push_back({
    
    x, c});
        alls.push_back(x);
    }
    // 保存"查询"操作
    for(int i = 1; i <= m; i++)
    {
    
    
        int l = 0, r = 0; // [l, r]边界下标
        cin >> l >> r;
        add.push_back({
    
    l, r});
        alls.push_back(l);
        alls.push_back(r);
    }
    // 排序+去重
    sort(alls.begin(), alls.end());
    alls.erase(unique(alls.begin(), alls.end()), alls.end());
    // "+c"
    for(auto it : alls)
    {
    
    
        int x = find(it.first); // 找到原序列元素在新数组alls[]中的下标
        a[x] += it.second;      // "+c"
    }
    // 计算a[]的前缀和
    for (int i = 1; i <= alls.size(); i++) s[i] = s[i-1] + a[i];
    // "查询"
    for (auto it : query) 
    {
    
    
        int l = find(it.first);
        int r = find(it.second);
        cout << s[r] - s[l-1] << endl;
    }

    return 0;
}

注意事项

  • alls[]存储的是所有输入的元素,包括“+c”和“查询”。它建立了操作的原序列元素的下标和它自己的下标之间的关系。
  • 排序+去重的逻辑可以当做模板记忆。
  • find()函数使用了「二分」的思想,值得注意的返回的下标要+1,以同步其他数组的下标从1开始。

时间复杂度

排序和 n + m n+m n+m 次二分查找的复杂度都是 O ( ( n + m ) l o g ( n + m ) ) O((n+m)log(n+m)) O((n+m)log(n+m)),所以离散化的复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)。如果用哈希表存储{被操作元素的旧坐标, 被离散化后的新坐标}键值对,而不使用find()函数,这样查询和对元素+c的操作的时间复杂度就是 O ( 1 ) O(1) O(1)了。

相关题目

【算法2-2】线性复杂度优化 / 离散化

参考文档

  • https://www.acwing.com/solution/content/13511/

这一篇题解中的两幅图非常清晰,强烈建议阅读。

猜你喜欢

转载自blog.csdn.net/m0_63312733/article/details/130309778
今日推荐