总结一些刷题过程中遇到的数学知识与数学运算,以及数学推导方面的经典题目。
内存基础
先列出在32位编译环境下的内存字节大小:
1字节:bool、 char (字符型,表示的字符与ASCII码表一一对应)
2字节:short (短整型,不管是不是signed或unsigned)
4字节:int、long、float、指针类型 (不管是不是signed或unsigned)
8字节:double、long long、long double
如果等号是 ‘=’ 这种字符常量的话,占1字节,如果是 “=” 这种字符串常量占2字节。
64位操作系统数据类型字节数不同点:
无论指针的类型是什么,32位平台永远是4,64位平台永远是8。
64位系统的 long 类型字节占用为8
struct变量的总长度等于所有成员长度之和。union变量所占用的内存长度等于最长的成员的内存长度,union的一个用法就是可以用来测试CPU是大端模式还是小端模式。
注意:struct 和 union 的{}后面要加上分号 “;”
struct的所有成员都存在;但在任何同一时刻, union中只存放了一个被选中的成员。
struct的不同成员赋值是互不影响的;union的不同成员赋值, 将会对其它成员重写, 原来成员的值就不存在了。
举例:
struct X1
{
char a;
int b;
short c;
};
sizeof(X1)的大小是12,得出的步骤:
union test
{
char mark;
long num;
float score;
};
union test a;
sizeof(a)的值为4。因union中的所有成员起始地址都是一样的,这些数据共享同一段内存,以达到节省空间的目的,所以&a.mark、&a.num和&a.score的值都是一样的。
数量积(点乘)
数量积又称为标积、内积、点积、点乘,结果为一个标量。
二维向量点积公式: a ⃗ ( x 1 , y 1 ) \vec{a} (x_{1},y_{1}) a(x1,y1), b ⃗ ( x 2 , y 2 ) \vec{b}(x_{2},y_{2}) b(x2,y2),则 a ⃗ ⋅ b ⃗ = ( x 1 y 1 - x 2 y 2 ) \vec{a}\cdot\vec{b}=(x_{1}y_{1}-x_{2}y_{2}) a⋅b=(x1y1-x2y2)
几何意义:
∣ a ⃗ ⋅ b ⃗ ∣ = ∣ a ⃗ ∣ ∣ b ⃗ ∣ cos θ |\vec{a}\cdot\vec{b} |=|\vec{a}| |\vec{b}|\cos\theta ∣a⋅b∣=∣a∣∣b∣cosθ ( θ \theta θ 为 a ⃗ \vec{a} a 与 b ⃗ \vec{b} b (共起点)之间的夹角)
用处:向量的点乘可以用来计算两个向量之间的夹角,进一步判断这两个向量是否正交(垂直)等方向关系。同时,还可以用来计算一个向量在另一个向量方向上的投影长度。
向量积(叉乘)
向量积,数学中又称外积、叉积,物理中称矢积、叉乘,是一种在向量空间中向量的二元运算。与点积不同,它的运算结果是一个向量而不是一个标量。并且两个向量的叉积与这两个向量和垂直,方向满足右手定则(四指指向 v ⃗ \vec{v} v 方向,转向 u ⃗ \vec{u} u 方向,则大拇指方向即为叉乘 v ⃗ × u ⃗ \vec{v}×\vec{u} v×u 的方向,叉乘的方向垂直于 v ⃗ \vec{v} v 与 u ⃗ \vec{u} u 所决定的平面)。
二维向量叉乘公式: a ⃗ ( x 1 , y 1 ) \vec{a} (x_{1},y_{1}) a(x1,y1), b ⃗ ( x 2 , y 2 ) \vec{b}(x_{2},y_{2}) b(x2,y2),则 a ⃗ × b ⃗ = ( x 1 y 2 - x 2 y 1 ) \vec{a}×\vec{b}=(x_{1}y_{2}-x_{2}y_{1}) a×b=(x1y2-x2y1)
叉乘的几何意义:
大小表示两个向量围成的平行四边形的面积,正负表示两个向量的相对位置。
用处:求三角形/平行四边形面积或者判断两点的相对位置,以及判断三点共线。
1)叉乘结果正负的几何意义:
叉乘结果的正负其实就是 c ⃗ \vec{c} c 轴的符号,由叉乘 a ⃗ × b ⃗ = − b ⃗ × a ⃗ \vec{a}×\vec{b}=-\vec{b}×\vec{a} a×b=−b×a 的性质,可以根据叉乘的正负值,来判断 a ⃗ \vec{a} a 和 b ⃗ \vec{b} b 的相对位置(旋转方向)(也是三个点的相对位置),即 b ⃗ \vec{b} b 是出于 a ⃗ \vec{a} a 的顺时针还是逆时针。
以 p 0 p_0 p0为参考点,
如果 a ⃗ × b ⃗ > 0 \vec{a}×\vec{b} > 0 a×b>0,乘积multi大于0,则 p 2 p_2 p2在 p 1 p_1 p1的逆时针方向;
如果 a ⃗ × b ⃗ < 0 \vec{a}×\vec{b} < 0 a×b<0,multi小于0,则 p 2 p_2 p2在 p 1 p_1 p1的顺时针方向,
当 a ⃗ × b ⃗ = 0 \vec{a}×\vec{b} = 0 a×b=0 ,multi等于0,则 p 0 p_0 p0、 p 1 p_1 p1、 p 2 p_2 p2三点共线, a ⃗ \vec{a} a 与 b ⃗ \vec{b} b 平行。
(两个非零向量 a ⃗ \vec{a} a 和 b ⃗ \vec{b} b 平行,当且仅当 a ⃗ × b ⃗ = 0 \vec{a}×\vec{b} = 0 a×b=0)
(可以判断三点是否共线:由三点构成的两个向量的叉乘结果不为零时,表示三点不共线,可以构成一个三角形)
2)叉乘模的几何意义:
∣ c ⃗ ∣ = ∣ a ⃗ × b ⃗ ∣ = ∣ a ⃗ ∣ ∣ b ⃗ ∣ sin α |\vec{c}|=|\vec{a}×\vec{b} |=|\vec{a}| |\vec{b}|\sinα ∣c∣=∣a×b∣=∣a∣∣b∣sinα ( α α α 为 a ⃗ \vec{a} a 与 b ⃗ \vec{b} b (共起点)之间的夹角)
∣ c ⃗ ∣ = a ⃗ 与 b ⃗ 构成的平行四边形的面积 |\vec{c}| = \vec{a} 与 \vec{b}构成的平行四边形的面积 ∣c∣=a与b构成的平行四边形的面积 (如下图所示的平行四边形)
与数量积的区别
注:向量积≠向量的积(向量的积一般指点乘)
一定要清晰地区分开向量积(矢积)与数量积(标积)。见下表。
名称 | 标积/内积/数量积/点积/点乘 | 矢积/外积/向量积/叉积/叉乘 |
---|---|---|
运算式(a,b和c表示向量) | a ⋅ b = ∣ a ∣ ∣ b ∣ ⋅ cos θ a·b=|a||b|·\cosθ a⋅b=∣a∣∣b∣⋅cosθ | a × b = c a×b=c a×b=c,其中 ∣ c ∣ = ∣ a ∣ ∣ b ∣ ⋅ sin θ |c|=|a||b|·\sinθ ∣c∣=∣a∣∣b∣⋅sinθ, c c c的方向遵守右手定则 |
几何意义 | 向量a在向量b方向上的投影与向量b的模的乘积 | c是垂直a、b所在平面,且以|b|·sinθ为高、|a|为底的平行四边形的面积 |
运算结果 | 标量 | 矢量 |
应用1.1037. 有效的回旋镖
给定一个数组 points ,其中 p o i n t s [ i ] = [ x i , y i ] points[i] = [x_i, y_i] points[i]=[xi,yi]表示 X-Y 平面上的一个点,如果这些点构成一个 回旋镖 则返回 true 。
回旋镖 定义为一组三个点,这些点 各不相同 且 不在一条直线上 。
示例 1:
输入:points = [[1,1],[2,3],[3,2]]
输出:true
【解答】计算从 points [ 0 ] \textit{points}[0] points[0]开始,分别指向 points [ 1 ] \textit{points}[1] points[1] 和 points [ 2 ] \textit{points}[2] points[2] 的向量 v ⃗ 1 \vec{v}_1 v1 和 v ⃗ 2 \vec{v}_2 v2 。「三点各不相同且不在一条直线上」等价于「这两个向量的叉乘结果不为零」:
v ⃗ 1 × v ⃗ 2 ≠ 0 ⃗ \vec{v}_1 \times \vec{v}_2 \ne \vec{0} v1×v2=0
bool isBoomerang(vector<vector<int>>& points) {
int a1 = points[0][0] - points[1][0];
int a2 = points[0][0] - points[2][0];
int b1 = points[0][1] - points[1][1];
int b2 = points[0][1] - points[2][1];
return a1 *b2 != b1 *a2;
}
应用2. 判断一个点是否在矩形内部
只需要判断该点是否在上下两条边和左右两条边之间。判断一个点是否在两条线段之间夹着就转化成,判断一个点是否在某条线段的一边上,就可以利用叉乘的方向性(边的旋转方向)。
比如判断点E是否在矩形ABCD内,可以分别判断点E是否在边AB与边CD内夹着,若在这两条线段内夹着,则从边AB向边AE方向为顺时针,从边CD向边CE方向也为顺时针,这就说明叉乘 A B ⃗ × A E ⃗ \vec{AB}×\vec{AE} AB×AE 与 C D ⃗ × C E ⃗ \vec{CD} ×\vec{CE} CD×CE 的正负号相同,即 ( A B ⃗ × A E ⃗ ) ∗ ( C D ⃗ × C E ⃗ ) ≥ 0 (\vec{AB}×\vec{AE})*(\vec{CD} ×\vec{CE}) \geq 0 (AB×AE)∗(CD×CE)≥0。
点E与另外两条线段BC、DA的相对位置判断也类似, ( B C ⃗ × B E ⃗ ) ∗ ( D A ⃗ × D E ⃗ ) ≥ 0 (\vec{BC}×\vec{BE})*(\vec{DA} ×\vec{DE}) \geq 0 (BC×BE)∗(DA×DE)≥0。
最后只需要判断 ( A B ⃗ × A E ⃗ ) ∗ ( C D ⃗ × C E ⃗ ) ≥ 0 & & ( B C ⃗ × B E ⃗ ) ∗ ( D A ⃗ × D E ⃗ ) ≥ 0 (\vec{AB}×\vec{AE})*(\vec{CD} ×\vec{CE}) \geq 0 \&\& (\vec{BC}×\vec{BE})*(\vec{DA} ×\vec{DE}) \geq 0 (AB×AE)∗(CD×CE)≥0&&(BC×BE)∗(DA×DE)≥0
规律:这四条边其实是按照AB、 BC、 CD、 DA的顺时针顺序,每条边的顶点再与E组合
代码如下:
#include <iostream>
#include <algorithm>
using namespace std;
struct Point
{
float x;
float y;
Point(float x,float y):x(x), y(y){
}
};
// 計算 |p1 p2| X |p1 p|
float GetCross(Point& p1, Point& p2,Point& p)
{
return (p2.x - p1.x) * (p.y - p1.y) -(p.x - p1.x) * (p2.y - p1.y);
}
//判断点否在5X5 以原点为左下角的正方形內
bool IsPointInMatrix(Point& p)
{
Point p1(0,5);
Point p2(0,0);
Point p3(5,0);
Point p4(5,5);
return GetCross(p1,p2,p) * GetCross(p3,p4,p) >= 0 && GetCross(p2,p3,p) * GetCross(p4,p1,p) >= 0;
}
int main(){
Point testPoint(0,0);
cout << "输入点坐标(以空格分隔):" << endl;
cin >> testPoint.x >> testPoint.y;
cout << "该点坐标为: "<< testPoint.x << " "<< testPoint.y << endl;
cout << "该点" << (IsPointInMatrix(testPoint)? "在5X5以原点为左下角的正方形內.": "不在5X5以原点为左下角的正方形內" )<< endl;
return 0;
}
三角形面积公式
1.已知三角形三边a,b,c,则 海伦公式:
p = a + b + c 2 p=\frac{a+b+c}{2} p=2a+b+c
S = p ( p − a ) ( p − b ) ( p − c ) S=\sqrt{p(p-a)(p-b)(p-c)} S=p(p−a)(p−b)(p−c)
2.已知三角形两边a,b,这两边夹角C,则
S = a × b × s i n C 2 S=\frac{a×b×sinC}{2} S=2a×b×sinC
即两夹边之积乘夹角正弦值的一半。
3.设三角形三边分别为a、b、c,内切圆半径为r
则三角形面积 S = ( a + b + c ) r 2 S=\frac{(a+b+c)r}{2} S=2(a+b+c)r
4.二维向量叉乘公式 a ⃗ ( x 1 , y 1 ) \vec{a} (x_{1},y_{1}) a(x1,y1), b ⃗ ( x 2 , y 2 ) \vec{b}(x_{2},y_{2}) b(x2,y2),则 a ⃗ × b ⃗ = ( x 1 y 2 - x 2 y 1 ) \vec{a}×\vec{b}=(x_{1}y_{2}-x_{2}y_{1}) a×b=(x1y2-x2y1)
叉积的长度 ∣ a ⃗ × b ⃗ ∣ |\vec{a}×\vec{b} | ∣a×b∣可以解释成这两个叉乘向量 a ⃗ \vec{a} a, b ⃗ \vec{b} b共起点时,所构成平行四边形的面积。
设三角形三个顶点的坐标为 ( x 1 , y 1 ) (x_1, y_1) (x1,y1)、 ( x 2 , y 2 ) (x_2, y_2) (x2,y2) 和 ( x 3 , y 3 ) (x_3, y_3) (x3,y3),则三角形面积 S 可以用行列式的绝对值表示:
S = 1 2 ∥ x 1 y 1 1 x 2 y 2 1 x 3 y 3 1 ∥ = 1 2 ∣ x 1 y 2 + x 2 y 3 + x 3 y 1 − x 1 y 3 − x 2 y 1 − x 3 y 2 ∣ S = \frac{1}{2} \left\lVert \begin{matrix} x_1 & y_1 & 1\\ x_2 & y_2 & 1 \\ x_3 & y_3 & 1 \end{matrix} \right\rVert =\frac{1}{2}\lvert x_1 y_2 + x_2y_3 + x_3y_1 - x_1y_3 - x_2y_1 - x_3 y_2 \rvert S=21∥
∥x1x2x3y1y2y3111∥
∥=21∣x1y2+x2y3+x3y1−x1y3−x2y1−x3y2∣
例题:812. 最大三角形面积
给定包含多个点的集合,从其中取三个点组成三角形,返回能组成的最大三角形的面积。
示例:
输入: points = [[0,0],[0,1],[1,0],[0,2],[2,0]]
输出: 2
解释:
这五个点如下图所示。组成的橙色三角形是最大的,面积为2。
【解答】枚举所有的三角形,然后计算三角形的面积并找出最大的三角形面积
【注意点】叉乘可能出现负值,因此需要将结果取绝对值abs
double countsize(vector<int> &a, vector<int>& b, vector<int>& c){
double m = a[0] * (b[1] - c[1]) - a[1] * (b[0] - c[0]) + b[0] * c[1] - c[0] * b[1];
return abs(m) * 0.5;
}
double largestTriangleArea(vector<vector<int>>& points) {
int n = points.size();
double maxsize = 0.0;
for(int i = 0; i < n; ++i){
for(int j = i + 1; j < n; ++j){
for(int k = j + 1; k < n; ++k){
maxsize = max(maxsize, countsize(points[i], points[j], points[k]));
}
}
}
return maxsize;
}
平面几何形状判断
平行四边形判定:
- 两组对边分别平行的四边形是平行四边形(定义判定法)
- 一组对边平行且相等的四边形是平行四边形
- 两组对边分别相等的四边形是平行四边形
- 两组对角分别相等的四边形是平行四边形
- 对角线互相平分的四边形是平行四边形
菱形的判定:
- 有一组邻边相等的平行四边形是菱形。(菱形的定义)
- 四条边都相等的四边形是菱形。
- 对角线互相垂直平分的平行四边形是菱形。
注意:一组对角线平分一组对角的四边形不是菱形,也可能是筝形(有一条对角线所在直线为对称轴的四边形)
矩形的判定:
- 有三个角是直角的四边形是矩形;
- 对角线互相平分且相等的四边形是矩形;
- 有一个角为直角的平行四边形是矩形;
- 对角线相等的平行四边形是矩形。
正方形判定:
- 对角线互相垂直平分且相等的四边形是正方形。
- 邻边相等且有一个内角是直角的平行四边形是正方形。
- 有一组邻边相等的矩形是正方形 。
- 有一个内角是直角的菱形是正方形。
- 对角线相等的菱形是正方形。
- 对角线互相垂直的矩形是正方形。
- 有三个内角为直角且有一组邻边相等的四边形是正方形。
判别正方形的一般顺序:先说明它是平行四边形;再说明它是菱形(或矩形);最后说明它是矩形(或菱形)。
593. 有效的正方形
给定2D空间中四个点的坐标 p1, p2, p3 和 p4,如果这四个点构成一个正方形,则返回 true 。
点的坐标 pi 表示为 [xi, yi] 。输入 不是 按任何顺序给出的。
一个 有效的正方形 有四条等边和四个等角(90度角)。
示例 1:
输入: p1 = [0,0], p2 = [1,1], p3 = [1,0], p4 = [0,1]
输出: True
【常用思路】
判别正方形的一般顺序为先说明它是平行四边形;再说明它是菱形(或矩形);最后说明它是矩形(或菱形)。那么我们可以从枚举四边形的两条斜边入手来进行判断:
如果两条斜边的中点相同:则说明以该两条斜边组成的四边形为「平行四边形」。
在满足「条件一」的基础上,如果两条斜边的长度相同:则说明以该两条斜边组成的四边形为「矩形」。
在满足「条件二」的基础上,如果两条斜边的相互垂直:则说明以该两条斜边组成的四边形为「正方形」。
【简单思路】
求出6条边,四条边长和两条对角线。
四条边长都相等的只有正方形和菱形,对角线又相等的只有正方形
所以排序判断边长以及对角线是否相等即可
int len(vector<int>& a, vector<int>& b){
return pow(a[0] - b[0], 2) + pow(a[1] - b[1], 2);
}
//对角线相等就是矩形了,然后两条邻边相等就是正方形
bool validSquare(vector<int>& p1, vector<int>& p2, vector<int>& p3, vector<int>& p4) {
if(p1 == p2) return false; //所有点都是(0, 0)点的情况
vector<int> length;
length.emplace_back(len(p1, p2));
length.emplace_back(len(p1, p3));
length.emplace_back(len(p1, p4));
length.emplace_back(len(p2, p3));
length.emplace_back(len(p2, p4));
length.emplace_back(len(p3, p4));
sort(length.begin(), length.end());
return length[0] == length[3] && length[4] == length[5];
}
字典序问题
什么是字典序?
简而言之,就是根据数字的前缀进行排序,
比如 10 < 9,因为 10 的前缀是 1,比 9 小。
再比如 112 < 12,因为 112 的前缀 11 小于 12。
这样排序下来,会跟平常的升序排序会有非常大的不同。比如说一个数乘 10,或者加 1,反而后者会更大。
可以用字典树来帮助理解:
每一个节点都拥有 10 个孩子节点,因为作为一个前缀 ,它后面可以接 0~9 这十个数字。而且可以发现,整个字典序排列就是对十叉树进行先序遍历。1, 10, 100, 101, … 11, 110 …
结论:前序遍历字典树即可得到字典序从小到大的数字序列。
例题:[字节跳动最常考题之一]
440. 字典序的第K小数字
给定整数 n 和 k,返回 [1, n] 中字典序第 k 小的数字。
示例 1:
输入: n = 13, k = 2
输出: 10
解释: 字典序的排列是 [1, 10, 11, 12, 13, 2, 3, 4, 5, 6, 7, 8, 9],所以第二小的数字是 10。
示例 2:
输入: n = 1, k = 1
输出: 1
【分析】
难点:确定指定前缀 cur 下所有子节点数,包括 cur 节点本身
思路:分层用每层的 min(n, last) - first + 1,分别求出每一层的节点总数,然后将总数累加,就可以得到该子树的所有节点总数。
//难点:计算以 cur 开头的子树结点个数,包括 cur 本身
//first 指向第 i 层的最左侧的孩子节点, last 指向第 i 层的最右侧的孩子节点,
//由于所有的节点都需要满足小于等于 n,所以第 i 层的最右侧节点应该为 min(n, last),
//不断迭代直到 first > n 则终止向下搜索。
//注意1:用long类型,否则超出int范围了
int getnum(long cur, long n) {
long first = cur;
long last = cur;
int num = 0;
while(first <= n){
//一层层地叠加,每一层最大不超过n(因为一共n个数)
//注意2:min函数中,两个参数性质需要相同
num += min(last, n) - first + 1;
first *= 10;
last = last * 10 + 9;
}
return num;
}
int findKthNumber(int n, int k) {
int cur = 1;
//注意3:需要k==1时输出当前的元素cur
while(k>1) {
//得到以 cur 为顶点的字典子树结点总数
int num = getnum(cur, n);
//注意4:不可以 >= ,若相等,K直接减为 0 了
if(k > num) {
k -= num;
//转移到右兄弟子树上
//如当前节点为 1 时,转移到以 2为头结点的字典树上,
//当前节点为 10 时,转移到以 11、12...为头结点的右兄弟子树上
++cur;
}
else{
cur *= 10; //转移到左孩子,比如以10为头结点的字典树上
--k; //因为走过了一个头结点,所以-1
}
}
return cur;
}
连续整数求和
829. 连续整数求和
给定一个正整数 n,返回 连续正整数满足所有数字之和为 n 的组数 。
示例 1:
输入: n = 5
输出: 2
解释: 5 = 2 + 3,共有两组连续整数([5],[2,3])求和后为 5。
示例 2:
输入: n = 9
输出: 3
解释: 9 = 4 + 5 = 2 + 3 + 4
示例 3:
输入: n = 15
输出: 4
解释: 15 = 8 + 7 = 4 + 5 + 6 = 1 + 2 + 3 + 4 + 5
【分析】
【第一种推导式复杂分析】
如果正整数 n 可以表示成 k 个连续正整数之和,则由于 k 个连续正整数之和的最小值是(k+1)k/2
因此有 n >= (k+1)k/2 k个连续正整数之和
枚举每个符合 k(k+1) ≤ 2n 的正整数 k,判断正整数 n 是否可以表示成 k 个连续正整数之和。
假设这 k 个连续正整数中的最小正整数是 x,最大正整数是 y,则有y=x+k−1,根据等差数列求和公式有 n = k(x+y) / 2= k(2x+k−1)/2,x= n/k − (k−1)/2 ,根据 k(k+1)≤2n 可知 x > 0。分别考虑 k 是奇数和偶数的情况,看能否推出x是整数。
1)当 k 是奇数时,k−1 是偶数,因此 n 可以被 k 整除。并且当 n 可以被 k 整除时,可得x是正整数,因此 n 可以表示成 k 个连续正整数之和。
【结论】当 k 是奇数时,「正整数 n 可以表示成 k 个连续正整数之和」等价于「正整数 n 可以被 k 整除」。
2)当 k 是偶数时,2x+k−1 是奇数。,n 不可以被 k 整除,又由于 2x+k−1= 2n/k 是整数,因此 2n 可以被 k 整除,且2n/k是奇数。
当 n 不可以被 k 整除且 2n 可以被 k 整除时,可得 x 是整数,因此 n 可以表示成 k 个连续正整数之和。
【结论】当 k 是偶数时,「正整数 n 可以表示成 k 个连续正整数之和」等价于「正整数 n 不可以被 k 整除且正整数 2n 可以被 k 整除」。
根据上述分析,可以得到判断正整数 n 是否可以表示成 k 个连续正整数之和的方法:
枚举每个符合 k(k+1) ≤ 2n 的正整数 k,
如果 k 是奇数,则当 n 可以被 k 整除时,正整数 n 可以表示成 k 个连续正整数之和;
如果 k 是偶数,则当 n 不可以被 k 整除且 2n 可以被 k 整除时,正整数 n 可以表示成 k 个连续正整数之和。
【另一种简单思维】
遍历每个符合 k(k+1) ≤ 2n 的正整数 k,
其实就是n的奇因子的个数; 利用等差数列求和公式,
如果拆成奇数项,则 n = 中间项×项数(此时项数为奇数)—> n 可以被 k 整除
如果拆成偶数项,则 n = 中间两项和×项数的一半(此时中间两项和为奇数,因为这两项为连续数)—>n 可以被 k/2 整除,且 n 不可以被 k 整除
int consecutiveNumbersSum(int n) {
int ans = 0;
int bound = 2 * n;
for(int k = 1; k * (k+1) <= bound; ++k){
if(isKcorrect(k, n)){
++ans;
}
}
return ans;
}
bool isKcorrect(int k, int n){
if(k % 2 == 1){
return n % k == 0;
}else{
return n % k != 0 && 2 * n % k == 0;
}
}
15. 三数之和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
【分析】
难点在去重,第一个、第二个与第三个数都要与之前选取的数不一样
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> ans;
int n = nums.size();
sort(nums.begin(), nums.end());
for(int i = 0; i < n; ++i){
// 第一个数大于 0,后面都是递增正数,不可能相加为零了
if(nums[i] > 0) return ans;
// 去重:如果此数已经选取过,跳过
while(i > 0 && i < n && nums[i] == nums[i-1]){
++i;
}
int l = i + 1;
int r = n - 1;
while(l < r){
while(l < r && nums[l] + nums[r] > -nums[i]){
--r;
}
while(l < r && nums[l] + nums[r] < -nums[i]){
++l;
}
// 找到一个和为零的三元组,添加到结果中,左右指针内缩,继续寻找
if(l < r && nums[l] + nums[r] == -nums[i]){
ans.push_back({
nums[i], nums[l], nums[r]});
++l;
--r;
// 去重:第二个数和第三个数也不重复选取
while(l < r && nums[l] == nums[l-1]) ++l;
while(l < r && nums[r] == nums[r+1]) --r;
}
}
}
return ans;
}
整数反转
给你一个 32 位的有符号整数 x ,返回将 x 中的数字部分反转后的结果。
如果反转后整数超过 32 位的有符号整数的范围 [ − 2 31 , 2 31 − 1 ] [−2^{31}, 2^{31} − 1] [−231,231−1] ,就返回 0。
假设环境不允许存储 64 位整数(有符号或无符号)。
示例 1:
输入:x = 123
输出:321
示例 2:
输入:x = -123
输出:-321
示例 3:
输入:x = 120
输出:21
示例 4:
输入:x = 0
输出:0
【注意点】
int占4字节,32位,边界为INT_MIN 和 INT_MAX,这俩数都是32位,因此需要用INT_MIN/10来防止越32位界。
int reverse(int x) {
int ans = 0;
while(x){
//运算过程中的数字必须在 32 位有符号整数的范围内,INT_MIN可能越界
//若x不为0,则之后ans还需要×10,因此在此之前就用INT_MIN/10来判断是否越界
if(ans < INT_MIN / 10 || ans > INT_MAX / 10)
return 0;
int res = x % 10;
ans = ans * 10 + res;
x /= 10;
}
//不需要用flag来标识数字是否为负数,也不用额外×-1,这样反而出错
//当x为负数时,res也为负数,每次累加和乘10之后也是负数,因此不必考虑正负号
//if(flag) ans *= -1;
return ans;
}
461. 汉明距离
两个整数之间的 汉明距离 指的是这两个数字对应二进制位不同的位置的数目。
给你两个整数 x 和 y,计算并返回它们之间的汉明距离。
【分析】
计算 x 和 y 之间的汉明距离,可以先计算 x ⊕ y x \oplus y x⊕y,然后统计结果中等于 1 的位数。
现在,原始问题转换为位计数问题。位计数有多种思路。
方法一:C++内置位计数功能
利用 __builtin_popcount(x) 函数统计二进制下“1”的个数。
int hammingDistance(int x, int y) {
return __builtin_popcount(x ^ y);
}
方法二:移位实现位计数
//a ^ a = 0
int hammingDistance(int x, int y) {
int z = x ^ y, ans = 0;
while(z){
ans += z & 1;
z >>= 1;
}
return ans;
}
方法三:Brian Kernighan 算法
在方法二中,对于 s = ( 10001100 ) 2 s=(10001100)_2 s=(10001100)2 的情况,需要循环右移 8 8 8 次才能得到答案。而实际上如果我可以跳过两个 1 1 1 之间的 0 0 0,直接对 1 1 1 进行计数,那么就只需要循环 3 3 3 次即可。
可以使用 Brian Kernighan \text{Brian Kernighan} Brian Kernighan 算法进行优化,具体地,该算法可以被描述为这样一个结论:记 f ( x ) = x & ( x − 1 ) f(x)=x~\&~(x-1) f(x)=x & (x−1), 表示 x x x 和 x − 1 x-1 x−1 进行与运算所得的结果,那么 f ( x ) f(x) f(x) 恰为 x x x 删去其二进制表示中最右侧的 1 1 1 的结果。
基于该算法,当我们计算出 s = x ⊕ y s = x \oplus y s=x⊕y,只需要不断让 s = f ( s ) s = f(s) s=f(s),直到 s = 0 s=0 s=0 即可。这样每循环一次, s s s 都会删去其二进制表示中最右侧的 1 1 1,最终循环的次数即为 s s s 的二进制表示中 1 1 1 的数量。
int hammingDistance(int x, int y) {
int z = x ^ y, ans = 0;
while(z){
++ans;
z &= z - 1;
}
return ans;
}
计数质数
质数的定义:在大于 1 的自然数中,除了 1 和它本身以外不再有其他因数的自然数。
统计 [ 2 , n ] [2,n] [2,n] 中质数的数量是一类很常见的题目。
经典例题:204. 计数质数
方法一:枚举(试除法)
很直观的思路是我们枚举每个数判断其是不是质数。
对于每个数 x x x,要判断 x x x 是否为质数,我们可以从小到大枚举 [ 2 , x ] [2,x] [2,x] 中的每个数 y y y,判断 y y y 是否为 x x x 的因数。但这样判断一个数是否为质数的时间复杂度最差情况下会到 O ( n ) O(n) O(n),无法通过所有测试数据。
考虑到如果 y y y 是 x x x 的因数,那么 x y \frac{x}{y} yx 也必然是 x x x 的因数,因此我们只要校验 y y y 或者 x y \frac{x}{y} yx 即可。而如果我们每次选择校验两者中的较小数,则不难发现较小数一定落在 [ 2 , x ] [2,\sqrt{x}] [2,x] 的区间中,因此我们只需要枚举 [ 2 , x ] [2,\sqrt{x}] [2,x] 中的所有数,看这些数是否是 x x x 的因数即可,这样单次检查的时间复杂度从 O ( n ) O(n) O(n) 降低至了 O ( n ) O(\sqrt{n}) O(n) 。
bool isprime(int x){
for(int i = 2; i * i <= x; ++i){
if(x % i == 0){
return false;
}
}
return true;
}
int countPrimes(int n) {
int ans = 0;
for(int i = 2; i < n; ++i){
ans += isprime(i);
}
return ans;
}
时间复杂度: O ( n n ) O(n\sqrt{n}) O(nn)。单个数检查的时间复杂度为 O ( n ) O(\sqrt{n}) O(n),一共要检查 O ( n ) O(n) O(n) 个数,因此总时间复杂度为 O ( n n ) O(n\sqrt{n}) O(nn)。
方法二:埃氏筛
枚举没有考虑到数与数的关联性,因此难以再继续优化时间复杂度。接下来我们介绍一个常见的算法,该算法由希腊数学家厄拉多塞( E r a t o s t h e n e s E r a t o s t h e n e s \rm EratosthenesEratosthenes EratosthenesEratosthenes)提出,称为厄拉多塞筛法,简称埃氏筛。
我们考虑这样一个事实:如果 x x x 是质数,那么大于 x x x 的 x x x 的倍数 2 x , 3 x , … 2x,3x,\ldots 2x,3x,… 一定不是质数,因此我们可以从这里入手。
我们设 isPrime [ i ] \textit{isPrime}[i] isPrime[i] 表示数 i i i 是不是质数,如果是质数则为 1,否则为 0。从小到大遍历每个数,如果这个数为质数,则将其所有的倍数都标记为合数(除了该质数本身),即 0,这样在运行结束的时候我们即能知道质数的个数。
这种方法的正确性是比较显然的:这种方法显然不会将质数标记成合数;另一方面,当从小到大遍历到数 x x x 时,倘若它是合数,则它一定是某个小于 x x x 的质数 y y y 的整数倍,故根据此方法的步骤,我们在遍历到 y y y 时,就一定会在此时将 x x x 标记为 isPrime [ x ] = 0 \textit{isPrime}[x]=0 isPrime[x]=0。因此,这种方法也不会将合数标记为质数。
当然这里还可以继续优化,对于一个质数 x x x,如果按上文说的我们从 2 x 2x 2x 开始标记其实是冗余的,应该直接从 x ⋅ x x\cdot x x⋅x 开始标记,因为 2 x , 3 x , … 2x,3x,\ldots 2x,3x,… 这些数一定在 x x x 之前就被其他数的倍数标记过了,例如 2 2 2 的所有倍数, 3 3 3 的所有倍数等。
【总结】从 2 到 n 遍历每个数,如果这个数为质数,则将其所有的倍数从 x ⋅ x x\cdot x x⋅x 开始都标记为合数,即 0,因为 2 x , 3 x , … 2x,3x,\ldots 2x,3x,… 这些数一定在 x x x 之前就被其他数的倍数标记过了,这样在运行结束的时候我们即能知道质数的个数。
int countPrimes(int n) {
int ans = 0;
vector<int> prime(n, 1);
for(int i = 2; i < n; ++i){
if(prime[i]){
++ans;
//用long long,不然超范围
if((long long)i * i < n){
for(int x = i * i; x < n; x += i){
prime[x] = 0;
}
}
}
}
return ans;
}
时间复杂度: O ( n log log n ) O(n\log \log n) O(nloglogn)
方法三:线性筛
埃氏筛其实还是存在冗余的标记操作,比如对于 45 这个数,它会同时被 3,5 两个数标记为合数,因此我们优化的目标是让每个合数只被标记一次,这样时间复杂度即能保证为 O ( n ) O(n) O(n),这就是我们接下来要介绍的线性筛。
相较于埃氏筛,我们多维护一个 primes \textit{primes} primes 数组表示当前得到的质数集合。我们从小到大遍历,如果当前的数 x x x 是质数,就将其加入 primes \textit{primes} primes 数组。
另一点与埃氏筛不同的是,「标记过程」不再仅当 x x x 为质数时才进行,而是对每个整数 x x x 都进行标记。对于整数 x x x,我们不再标记其所有的倍数 x ⋅ x x\cdot x x⋅x, x ⋅ ( x + 1 ) x\cdot (x+1) x⋅(x+1), … \ldots … ,而是只标记质数集合中的数与 x x x 相乘的数,即 x ⋅ primes 0 , x ⋅ primes 1 , … x\cdot\textit{primes}_0,x\cdot\textit{primes}_1,\ldots x⋅primes0,x⋅primes1,… 且在发现 x m o d primes i = 0 x\bmod \textit{primes}_i=0 xmodprimesi=0 的时候结束当前标记。
核心点在于:如果 x x x 可以被 primes i \textit{primes}_i primesi 整除,那么对于合数 y = x ⋅ primes i + 1 y=x\cdot \textit{primes}_{i+1} y=x⋅primesi+1 而言,它一定在后面遍历到 x primes i ⋅ primes i + 1 \frac{x}{\textit{primes}_i}\cdot\textit{primes}_{i+1} primesix⋅primesi+1 这个数的时候会被标记,其他同理,这保证了每个合数只会被其「最小的质因数」筛去,即每个合数被标记一次。
线性筛还有其他拓展用途,有能力的读者可以搜索关键字「积性函数」继续探究如何利用线性筛来求解积性函数相关的题目。
int countPrimes(int n) {
vector<int> prime;
vector<int> isprime(n, 1);
for(int i = 2; i < n; ++i){
if(isprime[i]){
prime.emplace_back(i);
}
for(int j = 0; j < prime.size() && i * prime[j] < n; ++j){
isprime[i * prime[j]] = 0;
if(i % prime[j] == 0){
break;
}
}
}
//prime数组还保存了找到的全部质数
return prime.size();
}
时间复杂度: O ( n ) O(n) O(n)
题目拓展:
1175. 质数排列
【分析】求符合条件的方案数,使得所有质数都放在质数索引上,所有合数放在合数索引上,质数放置和合数放置是相互独立的,总的方案数即为「所有质数都放在质数索引上的方案数」 × \times ×「所有合数都放在合数索引上的方案数」。求「所有质数都放在质数索引上的方案数」,即求质数个数 numPrimes \textit{numPrimes} numPrimes 的阶乘。「所有合数都放在合数索引上的方案数」同理。求质数个数时,可以使用试除法。
const int MOD = 1e9 + 7;
bool isprime(int n){
for(int i = 2; i * i <= n; ++i){
if(n % i == 0){
return false;
}
}
return true;
}
int numPrimeArrangements(int n) {
// int x = sqrt(n);
int z = 0;
for(int i = 2; i <= n; ++i){
z += isprime(i);
}
int s = n - z;
long ans1 = 1, ans2 = 1;
for(int i = 1; i <= z; ++i){
ans1 = (ans1 * i) % MOD;
}
for(int i = 1; i <= s; ++i){
ans2 = (ans2 * i) % MOD;
}
return (ans1 * ans2 ) % MOD;
}
进制运算与位运算
868. 二进制间距
给定一个正整数 n,找到并返回 n 的二进制表示中两个 相邻 1 之间的 最长距离 。如果不存在两个相邻的 1,返回 0 。
如果只有 0 将两个 1 分隔开(可能不存在 0 ),则认为这两个 1 彼此 相邻 。两个 1 之间的距离是它们的二进制表示中位置的绝对差。例如,“1001” 中的两个 1 的距离为 3 。
示例 1:
输入:n = 22
输出:2
解释:22 的二进制是 “10110” 。
在 22 的二进制表示中,有三个 1,组成两对相邻的 1 。
第一对相邻的 1 中,两个 1 之间的距离为 2 。
第二对相邻的 1 中,两个 1 之间的距离为 1 。
答案取两个距离之中最大的,也就是 2 。
【分析】
使用一个循环从 二进制表示的低位开始进行遍历,并找出所有的 1。我们用一个变量 last 记录上一个找到的 1 的位置。如果当前在第 i 位找到了 1,那么就用 i−last 更新答案,再将last 更新为 i 即可。
在循环的每一步中,我们可以使用位运算 n & 1 获取 n 的最低位,判断其是否为 1。在这之后,我们将 n 右移一位: n = n >> 1,这样在第 i 步时, n & 1 得到的就是初始n 的第 i 个二进制位。
int binaryGap(int n) {
int l = -1, ans = 0;
for(int i = 0; n; ++i){
if(n & 1){
if(l != -1){
ans = max(ans, i-l);
}
l = i;
}
n >>= 1;
}
return ans;
}
矩阵坐标变换问题
给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。
你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。
示例 1:
输入:matrix = [[1,2,3],[4,5,6],[7,8,9]]
输出:[[7,4,1],[8,5,2],[9,6,3]]
【分析】用翻转代替旋转
先将矩阵通过水平轴翻转,再根据主对角线翻转,就可以得到答案。
对于水平轴翻转而言,我们只需要枚举矩阵上半部分的元素,和下半部分的元素进行交换,即:
m a t r i x [ r o w ] [ c o l ] → 水平轴翻转 m a t r i x [ n − r o w − 1 ] [ c o l ] matrix[row][col] \xrightarrow{水平轴翻转}matrix[n−row−1][col] matrix[row][col]水平轴翻转matrix[n−row−1][col]
对于主对角线翻转而言,我们只需要枚举对角线左侧的元素,和右侧的元素进行交换,即
m a t r i x [ r o w ] [ c o l ] → 主对角线翻转 m a t r i x [ c o l ] [ r o w ] matrix[row][col] \xrightarrow{主对角线翻转} matrix[col][row] matrix[row][col]主对角线翻转matrix[col][row]
将它们联立即可得到:
matrix [ row ] [ col ] ⇒ 水平轴翻转 matrix [ n − row − 1 ] [ col ] ⇒ 主对角线翻转 matrix [ col ] [ n − row − 1 ] \begin{aligned} \textit{matrix}[\textit{row}][\textit{col}] & \xRightarrow[]{水平轴翻转} \textit{matrix}[n - \textit{row} - 1][\textit{col}] \\ &\xRightarrow[]{主对角线翻转} \textit{matrix}[\textit{col}][n - \textit{row} - 1] \end{aligned} matrix[row][col]水平轴翻转matrix[n−row−1][col]主对角线翻转matrix[col][n−row−1]
void rotate(vector<vector<int>>& matrix) {
int n = matrix.size();
//整体坐标变换关系 [i][j] -> [j][n-1-i]
//先上下翻转 [i][j] -> [n-1-i][j]
for(int i = 0; i < n / 2; ++i){
for(int j = 0; j < n; ++j){
swap(matrix[i][j], matrix[n-1-i][j]);
}
}
//再对角线翻转 [n-1-i][j] -> [j][n-1-i]
for(int i = 0; i < n; ++i){
for(int j = 0; j < i; ++j){
swap(matrix[i][j], matrix[j][i]);
}
}
}
498. 对角线遍历
给你一个大小为 m x n 的矩阵 mat ,请以对角线遍历的顺序,用一个数组返回这个矩阵中的所有元素。
示例 1:
输入:mat = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,2,4,7,5,3,6,8,9]
【分析】直接模拟,进行坐标变换
矩阵按照对角线进行遍历。设矩阵的行数为 m m m,, 矩阵的列数为 n n n , 仔细观察对角线遍历的规律可以得到如下信息:
一共有 m + n − 1 m + n - 1 m+n−1 条对角线,相邻的对角线的遍历方向不同,当前遍历方向为从左下到右上,则紧挨着的下一条对角线遍历方向为从右上到左下;
设对角线 l l l 从上到下的编号为 l ∈ [ 0 , m + n − 2 ] l \in [0, m + n - 2] l∈[0,m+n−2]:
①当 l l l 为偶数时,则第 l l l 条对角线的走向是从下往上遍历,每次行索引减 1,列索引加 1,直到矩阵的边缘为止:
当 l < m l < m l<m 时,则此时对角线遍历的起点位置为 ( l , 0 ) (l,0) (l,0);
当 l ≥ m l \ge m l≥m 时,则此时对角线遍历的起点位置为 ( m − 1 , l − m + 1 ) (m - 1, l - m + 1) (m−1,l−m+1);
②当 l l l 为奇数时,则第 l l l 条对角线的走向是从上往下遍历,每次行索引加 1,列索引减 1,直到矩阵的边缘为止:
当 l < n l < n l<n 时,则此时对角线遍历的起点位置为 ( 0 , l ) (0, l) (0,l);
当 l ≥ n l \ge n l≥n 时,则此时对角线遍历的起点位置为 ( l − n + 1 , n − 1 ) (l - n + 1, n - 1) (l−n+1,n−1);
根据以上观察得出的结论,模拟遍历所有的对角线即可。
vector<int> findDiagonalOrder(vector<vector<int>>& mat) {
vector<int> ans;
int m = mat.size(), n = mat[0].size();
int lmax = m + n - 1;
for(int l = 0; l < lmax; ++l){
if(l % 2 == 0){
int x = l < m ? l : m - 1;
int y = l < m ? 0 : l - m + 1;
while(x >= 0 && y < n){
ans.emplace_back(mat[x][y]);
--x; ++y;
}
}else{
int x = l < n ? 0 : l - n + 1;
int y = l < n ? l : n - 1;
while(x < m && y >= 0){
ans.emplace_back(mat[x][y]);
++x; --y;
}
}
}
return ans;
}
补充拓展:
按照对角线 l l l 遍历,对角线以及横纵坐标都是从1开始时的坐标变换关系:
f o r for for l = 2 l=2 l=2 t o to to n n n d o do do /* 计算第 l l l 对角线 */
f o r for for i = 1 i=1 i=1 t o to to n − l + 1 n-l+1 n−l+1 d o do do //横坐标
j = i + l − 1 ; \ j=i+l-1; j=i+l−1; //对应的纵坐标