写在前面
- 主要是题目太多,所以和前面的分开来记录。
- 有很多思路的图都来源于力扣的题解,如侵权会及时删除。
- 不过代码都是个人实现的,所以有一些值得记录的理解。
七、动态规划
1. 斐波那契数列
-
思路:
-
题目中已经给出了递推的公式。
-
注意使用了余数的加法定理,每次加法之后都要求余数。
-
代码:
class Solution {
public:
int fib(int n) {
int arr[101] = {0, 1};
if(n < 2)
{
return arr[n];
}
for(int i=2;i<=n;i++)
{
/*
(a+b)%c = (a%c + b%c) %c 余数的加法定理
*/
arr[i] = (arr[i-1] + arr[i-2]) % 1000000007;
}
return arr[n];
}
};
2. 青蛙跳台阶问题
-
思路:
-
代码:
class Solution {
public:
/*
0->0; 1->1; 2->2; 3 = 1 + 2;
a[n] = a[n-1] + a[n-2];
*/
int numWays(int n) {
int arr[101] = {1, 1};
if(n < 2)
{
return arr[n];
}
for(int i=2;i<=n;i++)
{
arr[i] = (arr[i-1] + arr[i-2])%1000000007;
}
return arr[n];
}
};
3. 连续子数组的最大和
- 思路:
- 代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int i;
int *res=new int [nums.size()];
res[0]=nums[0];
int max_res=res[0];
for(i=1;i<nums.size();++i)
{
if(res[i-1]>0)
{
res[i]=nums[i]+res[i-1];
}
else
{
res[i]=nums[i];
}
if(res[i]>max_res)
{
max_res=res[i];
}
}
return max_res;
}
};
4. 剪绳子
-
思路:
-
方法一:动态规划
-
j j j从2开始遍历到 i i i,表明 j j j之后的绳子是最后一段(可取1)
-
j j j之前的绳子还要继续剪(因此最小为2)
-
有: d p [ i ] = m a x { d p [ i ] , d p [ j ] ∗ ( i − j ) , j ∗ ( i − j ) } dp[i]=max\{dp[i], dp[j]*(i-j), j*(i-j)\} dp[i]=max{dp[i],dp[j]∗(i−j),j∗(i−j)}
-
方法二:数学推导
- 代码:
class Solution {
public:
int cuttingRope(int n) {
int *res = new int [n+1];
res[2] = 1;
for(int i=3;i<=n;++i)
{
res[i] = 0;
for(int j=2;j<i;++j)
{
/*
每次只考虑把绳子切两段的方式,在j的地方切一刀
1. j*(i-j),就是把绳子切成两段的乘积
2. res[j]*(i-j),就是把绳子切成两段之后,前面那段绳子还要继续切
至于为什么后面的那段绳子不用继续切,也就是不算res[i-j]
1. 因为递归思路就是把绳子切两段,在最后一段有多少种切法
2. 由于是连乘,所以res[i-j]总是可以被分割成(i-j)*{res[j]的一部分乘积}的形式
*/
res[i] = max(res[i], j*(i-j));
res[i] = max(res[i], res[j]*(i-j));
}
}
return res[n];
}
};
变体1. 大数求余下的剪绳子
-
思路:
-
在大数求余下,无法用动态规划来求解
-
因为动态规划的迭代方程中使用了max作比较,一旦求余了,比较就失效了
-
只能用数学推导的方法来做
-
因为要求3的a次幂,所以还要使用快速幂求解
-
代码:
class Solution {
/*
大数求余下,动态规划为什么不能用?
1. 因为迭代的时候用到了max函数,所以是不能求余数的
2. 如果不用用到max/min之类要比较的函数的话,还是可以用动态规划的
可以用数学推导的结论,然后用快速幂的方法求幂次结果
1. 记得大数求余下,每一次的乘法之后都要求余
*/
private:
/*快速幂*/
int quickPow(int x, int a)
{
int result = 1;
long temp = x;
while(a!=0)
{
if(a%2==1)
{
result = (temp * result) % 1000000007;
}
temp = (temp * temp) % 1000000007;
a /= 2;
}
return result;
}
public:
int cuttingRope(int n) {
if(n==2)
{
return 1*1;
}
if(n==3)
{
return 2*1;
}
int q = n % 3; // 求余数
int m_3 = n / 3; // 求整除3的个数
int res;
switch(q)
{
case 0:
res = quickPow(3, m_3);
break;
case 1:
// 拆分最后一段3+1为2+2,即3^(m-1) * 2 * 2
res = (quickPow(3, m_3-1) * 2) % 1000000007;
res = (res * 2) % 1000000007;
break;
case 2:
// 3^(m) * 2
res = (quickPow(3, m_3) * q) % 1000000007;
break;
default:
res = -1;
}
return res;
}
};
补充:余数三大定理和常见类型范围
加法定理: ( a + b ) % c = ( a % c + b % c ) % c 加法定理:(a+b)\%c=(a\%c+b\%c)\%c 加法定理:(a+b)%c=(a%c+b%c)%c
乘法定理: ( a ∗ b ) % c = ( a % c ∗ b % c ) % c 乘法定理:(a*b)\%c=(a\%c*b\%c)\%c 乘法定理:(a∗b)%c=(a%c∗b%c)%c
若a和b对c的余数相同,则有
同余定理: ( a − b ) % c = 0 同余定理:(a-b)\%c=0 同余定理:(a−b)%c=0
此外常见的类型范围如下:
int 32 范围是 -2,147,483,648 到 2,147,483,647
unsigned int 32 范围是 0 到 4,294,967,295
int 64 (long long) 最大是-9,223,372,036,854,775,808 到 9,223,372,036,854,775,807
5. 把数字翻译成字符串
- 思路:
- 动态规划,类似于斐波那契数列和青蛙跳台阶问题;
- 转移方程如下:
- 注意:dp数组的下标和字符串数组的下标相差1位;
- dp[n]的含义是:长度为n的数字序列所能表示的翻译方法数量;
- 代码:
class Solution {
public:
/*
转移方程:dp[i] = dp[i-1] + dp[i-2],仅当str[i-2:i-1]能够翻译成字母(即在[10, 25]中)
注意:str[i-1]对应dp[i]的结果
*/
int translateNum(int num) {
string str = to_string(num);
vector<int> dp(str.length() + 1, 0);
dp[0] = 1; // 0字符有一种翻译方法
dp[1] = 1; // 1字符有一种翻译方法
for(int i=2;i<=str.length();i++) {
// 虽然从i=2(2字符)开始,但对应的字符串下标是从i-1=2-1=1开始,
// 因此判断的字符串为[i-2:i-1]
if(str.substr(i-2,2) >= "10" && str.substr(i-2,2) <= "25") {
dp[i] = dp[i-1] + dp[i-2];
}
else {
dp[i] = dp[i-1];
}
}
return dp[str.length()];
}
};
6. 礼物的最大价值 [向右向下棋盘]
-
思路:
-
如果用深度优先搜索遍历,则时间复杂度是O(2^MN),远高于动态规划;
-
代码
class Solution {
public:
/*
二维表征:
dp[i, j] = max{dp[i, j-1], dp[i-1, j]} + grid[i][j]
对应的一维表征:
dp[j] = max{dp[j-1](new), dp[j](old)} + grid[i][j]
当然,由于grid[i][j]是最后访问的,因此也可以直接在原grid上直接迭代,避免新开矩阵
*/
int maxValue(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
for(int i=0;i<m;i++) {
for(int j=0;j<n;j++) {
if(i==0 && j==0) {
dp[i][j] = grid[i][j];
}
if(i==0 && j!=0) {
dp[i][j] = dp[i][j-1] + grid[i][j];
}
if(i!=0 && j==0) {
dp[i][j] = dp[i-1][j] + grid[i][j];
}
if(i!=0 && j!=0) {
dp[i][j] = max(dp[i][j-1], dp[i-1][j]) + grid[i][j];
}
}
}
return dp[m-1][n-1];
}
};
- 下面是一维表征的写法,其实在时间复杂度上是一样的,都是O(M*N);
class Solution {
public:
int maxValue(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
vector<int> dp(n);
for(int i=0;i<m;i++) {
for(int j=0;j<n;j++) {
if(i==0 && j==0) {
dp[j] = grid[i][j];
}
if(i==0 && j!=0) {
dp[j] = dp[j-1] + grid[i][j];
}
if(i!=0 && j==0) {
dp[j] = dp[j] + grid[i][j];
}
if(i!=0 && j!=0) {
dp[j] = max(dp[j-1], dp[j]) + grid[i][j];
}
}
}
return dp[n-1];
}
};
7. 最长不含重复字符的子字符串
- 思路:
- 需要使用一个辅助指针 i i i记录当前不重复子串的起点字符下标;
- 转移方程如下:
d p [ j ] = m a x { d p [ j − 1 ] , j − i + 1 } dp[j] = max\{dp[j-1], j-i+1\} dp[j]=max{dp[j−1],j−i+1}
-
检测重复的方式可以使用哈希集合
unordered_set
,实现的过程会比较直观; -
当然也可以考虑用哈希表
unordered_map
,这样顺便可以把下标的移动距离也保存,避免再用循环来查找,下面的代码就是采用这种方式,但要特别留意map
的判断和更新; -
代码:
class Solution {
public:
/*
转移方程:dp[j] = max{dp[j-1], j-i+1} (j-i+1是当前不重复的字串长度)
实际上,只用一个max_length变量保存也可,不需要用数组
*/
int lengthOfLongestSubstring(string s) {
if(s.length() == 0) {
return 0;
}
int i = 0, j = 0;
vector<int> dp(s.length(), 0);
unordered_map<char, int> map;
while(j < s.length()) {
if(map.size() == 0) {
// 处理第一个节点
dp[j] = j - i + 1;
map[s[j]] = j;
++j;
}
else {
if(map.count(s[j]) == 0 || map[s[j]] < i) {
dp[j] = max(dp[j - 1], j - i + 1);
// 将s[j]加到哈希表中
map[s[j]] = j;
// 移动j指针
++j;
}
else {
// 将指针i移动到和j不同的第一个字符上
i = map[s[j]] + 1;
dp[j] = max(dp[j - 1], j - i + 1);
// 修改哈希表指向下标
map[s[j]] = j;
// 移动j指针
++j;
}
}
}
return dp[s.length() - 1];
}
};
8. 丑数
-
思路:
-
非常巧妙地利用了三指针;
-
其实我个人感觉应该不太是一道经典动态规划的题,虽然形式上有点动态规划的格式;
-
一个比较通俗移动的解释如下(豁然开朗ヾ(•ω•`)o):
-
关键的点有三个:
-
(1) 所有的丑数都是由前面的丑数乘2或者乘3或者乘5得到的;
-
(2) 在丑数的生成过程中会有重复的生成,需要排除;
-
(3) 三个指针均指向已经生成的丑数序列;
-
代码:
class Solution {
public:
/*
转移方程:dp[i] = min(min(dp[p2]*2, dp[p3]*3), dp[p5]*5);
*/
int nthUglyNumber(int n) {
vector<int> dp(n + 1, 0);
dp[1] = 1;
// 三个质因数均分配一个指针
int p2 = 1, p3 = 1, p5 = 1;
for(int i=2;i<=n;++i) {
dp[i] = min(min(dp[p2]*2, dp[p3]*3), dp[p5]*5);
// 下面的三个if是并列的,因为要排除乘积相同的重复元素
if(dp[i] == dp[p2]*2) {
++p2;
}
if(dp[i] == dp[p3]*3) {
++p3;
}
if(dp[i] == dp[p5]*5) {
++p5;
}
}
return dp[n];
}
};
9. n个骰子的点数
- 思路:
- 十分巧妙的动态规划,思路如下:
- 概率为什么要相乘?
- 因为当前骰子的点数是和前面点数之和是两个独立的事件,因此设之前出现的点数和概率为
p(x)
,当前骰子出现点数的概率为p(y)
,则它们同时出现(也就是和为x+y)的联合概率p(xy)=p(x)p(y)
; - 为什么dp[n]的长度是5*n+1?
- 因为
sum>=n
,所以长度为n*6-(n-1) = 5n+1
; - 为什么每轮计算要重新开一个dp数组而不能沿用同一个dp数组?
- 因为
dp[n,j]
的每个值都涉及从dp[n-1,j-6]
到dp[n-1,j-1]
的值,从左往右遍历只用一个数组肯定会有重叠的部分; - 当然最重要的原因是
dp[n,j]
的概率不能从dp[n-1]
中累加,而是需要从0开始累加,因为实际dp[n-1,j]
的概率会被引入新的1/6而稀释掉; - 为什么要遍历第i层算第i+1层的dp?
- 这个也比较巧妙,因为
j-k
可能会越出dp数组左界,但j+k
是不会越出dp数组右界的,这降低了处理的难度,因为不用处理数组溢出的情况; - 代码:
class Solution {
public:
/*
n = 2
[1]: 1, 2, 3, 4, 5, 6
[2]: 1, 2, 3, 4, 5, 6
[1]+[2]: 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
length = n * 6 - (n - 1) = 5n + 1, 因为sum >= n
转移方程:dp[n + 1, j + k] = dp[n + 1, j + k] + dp[n, j] * 1/6
*/
vector<double> dicesProbability(int n) {
// 为第一层赋值
vector<double> dp(6, 1.0/6);
for(int i=1;i<n;++i) {
vector<double> tmp(5*(i+1)+1, 0);
// dp.size() = 5*i+1,dp当前在第n层
for(int j=0;j<5*i+1;++j) {
for(int k=0;k<6;++k) {
// 做第n+1层的dp
// 注意这里的下标是从0开始的,而骰子是从1开始的,但只要位置对上即可
tmp[j+k] += dp[j] * 1.0/6;
}
}
// vector可以直接赋值
dp = tmp;
}
return dp;
}
};
10. 股票的最大利润
- 思路:
- 核心是找到今天的最大值的计算方式:今天的最大利润 = 用今天股价减去历史股价最小值;
- 动态规划的转移方程如下:
- 但其实不用dp数组,而是用一个max值记录最大值也可以;
- 代码:
class Solution {
public:
/*转移方程:dp[i] = max(dp[i-1], prices[i]-min)*/
int maxProfit(vector<int>& prices) {
if(prices.empty()) {
return 0;
}
vector<int> dp(prices.size(), 0);
dp[0] = 0;
int min = prices[0];
for(int i=1;i<prices.size();++i) {
dp[i] = max(dp[i-1], prices[i]-min);
if(min>prices[i]) {
min = prices[i];
}
}
return dp[prices.size()-1];
}
};
11. 圆圈中最后剩下的数字
- 思路:
- 非常巧妙的动态规划;
- 主要是按照以下考虑:
- 如果只剩一个数,那么返回的序号必定是0;
- 所以只要逐层上推找到这个数在最初序列中的序号即可。
- 代码:
class Solution {
public:
/*
主要按照以下思路考虑:
1. 只剩最后一个人的时候,返回的序号必定是0
2. 只要倒推回最初时这个人的序号,就可以知道最终剩下的是哪个人
假设n = 4, m = 3,则:
0 1 2 3(0 1 2 3) -> dp[4] = 0 ((dp[3] + 3) % 4)
3 0 1(3 0 1) -> dp[3] = 1 ((dp[2] + 3) % 3)
3 0(3 0) -> dp[2] = 1 ((dp[1] + 3) % 2)
0 -> dp[1] = 0
故有:
dp[n] = (dp[n-1] + m) % n
dp[1] = 0
dp[i]为最终剩下的人在当前长度为i的序列中的下标
*/
int lastRemaining(int n, int m) {
vector<int> dp(n + 1, 0);
for(int i=2;i<=n;++i) {
dp[i] = (dp[i-1] + m) % i;
}
return dp[n];
}
};
八、二分法
1. 旋转数组的最小数字
- 思路:
- 使用了二分法,思路类似于快排。
- 代码:
class Solution {
public:
int minArray(vector<int>& numbers) {
int left = 0, right = numbers.size() - 1;
int pivot;
while(left < right)
{
// pivot = (left + right)/2;
pivot = left + (right - left) / 2 // 这种写法能避免left + right溢出
/*
只能和right比较,因为有特殊的[1,2,5]等递增情况
*/
if(numbers[right] < numbers[pivot])
{
left = pivot + 1; // 此时在最小值左侧,pivot必不可能是min,一侧加一是防止死循环
}
else
{
if(numbers[right] > numbers[pivot])
{
right = pivot; // 此时在最小值右侧,pivot有可能是min
}
else
{
/*
numbers[right] == numbers[pivot]
不能分辨是在左侧还是右侧,但因为right往左不单调增,所以是可以减一的
*/
right--;
}
}
}
return numbers[left];
}
};
2. 数值的整数次方(快速幂)
-
思路:
-
总体的思路都是二分法,利用小幂次的结果( x n x^n xn)*小幂次的结果( x n x^n xn)来获得大幂次的结果( x 2 n x^{2n} x2n)。
-
方法一:二分法+递归,但要处理大幂次除以2之后为奇数的幂次。
-
这种方法是直接对所求的幂次进行二分。
-
方法二:二分法+迭代,先分析幂次的二进制形式,找出分别在哪些二进制位需要相乘。
-
这种方式将所求幂次的计算游离在二分法之外,在逻辑和计算上更加合理高效。
- 代码:
class Solution {
public:
/*二分法快速幂的迭代形式*/
double myPow(double x, int n) {
if(n==0)
{
return 1;
}
bool is_negative = false;
// 因为-2^31取反之后超出了INT的范围,所以要进行类型转换
long temp_n = n;
if(n<0)
{
// 负指数要取反,这是为了统一处理
// 负数的除2是向上取整,正数的除2是向下取整,所以处理结果是不一样的
is_negative = true;
temp_n = -temp_n;
}
double temp = x;
double result = 1;
while(temp_n>0)
{
// 从2进制的最低位开始处理
// 使用2进制来表示指数的原因是要使用二分法,所以拆开的指数都应该都是2的偶数次幂(除了2^1)
// 因此刚好可以用2进制的形式来表示,它的每一位都是2的偶数次幂且可以从2的偶数次幂推出
if(temp_n%2 == 1)
{
// 注意是乘法
result = result * temp;
}
temp_n = temp_n/2;
// 这里是迭代的二分法
temp = temp * temp;
}
if(is_negative)
{
// 负指数应该取倒数
result = 1.0 / result;
}
return result;
}
};
3. 在排序数组中查找数字 I
-
思路:
-
两次二分,一次是找第一个等于target的下标,第二次是找第一个大于target的下标;
-
有三个难点:
-
(1) 为什么第二次二分不是找最后一个等于target的下标?
-
因为二分法实现的时候是用了
medium = (low + high) / 2
,因此无法实现“找最后一个等于target的下标”的功能,只能实现“找第一个等于target的下标”的功能,所以退而求“第一个大于target的下标”; -
(2) 尤其要分清楚当num[medium] == target时,应该往左边走还是右边走;
-
(3) 边界情况的处理:
-
“找第一个等于target的下标”时,需要处理数组中并不存在等于target值的情况;
-
“找第一个大于target的下标”时,需要处理数组中并不存在大于target值的情况;
-
代码:
-
下面的代码是使用了递归实现,比较直观;
class Solution {
private:
// 找第一个等于target值的下标(下界)
int binary_search_lower(vector<int>& nums, int target, int low, int high) {
if(low == high) {
if(nums[low] == target) {
return low;
}
else {
// 数组里面没有等于target的值
return -1;
}
}
int medium = (low + high) / 2;
if(nums[medium] >= target) {
return binary_search_lower(nums, target, low, medium);
}
else {
// 右半区一定要medium+1,因为medium在计算的时候是向下取整的,否则会死循环
return binary_search_lower(nums, target, medium+1, high);
}
}
// 找第一个大于target值的下标(上界+1)
int binary_search_upper(vector<int>& nums, int target, int low, int high) {
if(low == high) {
if(nums[low] == target) {
// target是数组的最大值,因此要虚拟+1
return low + 1;
}
else {
return low;
}
}
int medium = (low + high) / 2;
if(nums[medium] > target) {
return binary_search_upper(nums, target, low, medium);
}
else {
// 右半区一定要medium+1,因为medium在计算的时候是向下取整的,否则会死循环
return binary_search_upper(nums, target, medium+1, high);
}
}
public:
int search(vector<int>& nums, int target) {
if(nums.size() == 0) {
return 0;
}
int i = binary_search_lower(nums, target, 0, nums.size()-1);
int j = binary_search_upper(nums, target, 0, nums.size()-1);
if(i == -1) {
return 0;
}
else {
return (j-i);
}
}
};
- 下面的代码是用while循环来实现,可以避免递归的开销;
- 但其实和递归的时间复杂度是一样的;
class Solution {
private:
// 找第一个等于target值的下标(下界)
int binary_search_lower(vector<int>& nums, int target, int low, int high) {
while(low < high) {
int medium = (low + high) / 2;
if(nums[medium] >= target) {
high = medium;
}
else {
// 右半区一定要medium+1,因为medium在计算的时候是向下取整的,否则会死循环
low = medium + 1;
}
}
if(nums[low] == target) {
return low;
}
else {
// 数组里面没有等于target的值
return -1;
}
}
// 找第一个大于target值的下标(上界+1)
int binary_search_upper(vector<int>& nums, int target, int low, int high) {
while(low < high) {
int medium = (low + high) / 2;
if(nums[medium] > target) {
high = medium;
}
else {
// 右半区一定要medium+1,因为medium在计算的时候是向下取整的,否则会死循环
low = medium + 1;
}
}
if(nums[low] == target) {
// target是数组的最大值,因此要虚拟+1
return low + 1;
}
else {
return low;
}
}
public:
int search(vector<int>& nums, int target) {
if(nums.size() == 0) {
return 0;
}
int i = binary_search_lower(nums, target, 0, nums.size()-1);
int j = binary_search_upper(nums, target, 0, nums.size()-1);
if(i == -1) {
return 0;
}
else {
return (j-i);
}
}
};
4. 0~n-1中缺失的数字
-
思路:
-
因为是已经有序了,所以用二分法的时间复杂度是最小的;
-
关键的点有以下两个:
-
(1) 当出现第一个
nums[j] != j
时,j就是所求的数; -
(2) 边界条件的处理:第一个
nums[j] != j
不在数组中,也就是说数组中的数字均是连续的; -
代码:
class Solution {
private:
// 找第一个nums[j]!=j的数
int binary_search(vector<int>& nums, int low, int high) {
while(low < high) {
int medium = (low + high) / 2;
if(nums[medium] != medium) {
high = medium;
}
else {
low = medium + 1;
}
}
if(nums[low] == low) {
return low + 1;
}
else {
return low;
}
}
public:
int missingNumber(vector<int>& nums) {
return binary_search(nums, 0, nums.size() - 1);
}
};
九、位运算
1. 二进制中1的个数
-
题目:https://leetcode.cn/problems/er-jin-zhi-zhong-1de-ge-shu-lcof/
-
思路:
-
方法一:常规的移位和与运算
-
方法二:用
n & (n-1)
的含义直接消去每个1
-
代码:
class Solution {
public:
int hammingWeight(uint32_t n) {
int count = 0;
while(n != 0)
{
count++;
/*
n - 1让从右往左数第一个1为0,到这个1的所有0为1(也就是让这些位和原数相反)
n & (n-1) 消去了从右往左数的第一个1,其余不变
*/
n = n & (n-1);
}
return count;
}
};
2. 数组中数字出现的次数 I
- 思路:
- 核心的思路是:
x xor x = 0
,也就是说如果只有一个不同的数字,则从头到尾异或一遍,结果就是那个数字; - 问题是现在有两个不同的数字
x
和y
,所以需要分组进行异或; - 分组的依据是这两个数字之间不同的某个位,也就是
x xor y == 1
的地方,在该位上x
和y
的取值不同; - 把所有该位取1的数分成一组来异或,把所有该位取0的数分成另一组来异或,两组异或的结果就是
x
和y
; - 分组的时候,有两个相同数字的一定会被分到同一组,因为它们该位的取值是一样的;
- 一个示例如下:
- 代码:
class Solution {
public:
/*
核心是利用了:x xor x = 0
*/
vector<int> singleNumbers(vector<int>& nums) {
int xy = 0; // 0 xor x = x
// 用异或运算,找x和y异或的结果
for(int i=0;i<nums.size();i++) {
xy = xy ^ nums[i];
}
int m = 1; // m <- 0000...0001
// 用与运算,从右往左找x和y不同的第一个位,记录为m
while((m & xy) == 0) {
// 从右往左找x和y不同的第一个位
m = m << 1;
}
int x = 0, y = 0;
for(int i=0;i<nums.size();i++) {
if(nums[i] & m) {
// 将与m对应位为1的值划为一组异或
x = x ^ nums[i];
}
else {
// 将与m对应位为0的值划为一组异或
y = y ^ nums[i];
}
}
return vector<int> {x, y};
}
};
补充:C++的位运算
- 常见的位运算有6种:与,或,非,异或,左移和右移
3. 数组中数字出现的次数 II
-
思路:
-
核心的思路是:将所有数字用二进制表示,然后对每个二进制位计数,最后模3,余数组成的二进制数就是所求的数字,时间复杂度是O(32N),空间复杂度是O(32);
-
注意
int_32
最高位是符号位,它的处理和核心思路相同即可; -
另外,这里有一种很巧妙的有限状态机编码解决求余的思路,时间复杂度是O(N),空间复杂度是O(1);虽然设计精妙且速度快,但可能泛化性较弱,当出现数字的次数不是三次的时候要重新设计,且不一定刚好能用位运算优雅地表达出来;
-
而且普通的求解方法时间复杂度仍然是O(N)数量级,空间复杂度仍然是O(1)数量级,一般情况下还是推荐普通的循环求余解法即可;
-
代码:
-
下面的是普通思路的实现:
class Solution {
public:
/*
核心思路:将所有数字用二进制表示,然后对每个二进制位计数,最后模3,余数组成的二进制数就是所求的数字
*/
int singleNumber(vector<int>& nums) {
vector<int> count(32, 0);
int i;
for(int num: nums) {
for(i=0;i<32;i++) {
// 逐位统计出现的次数
if(num & 1) {
++count[i];
}
num = num >> 1;
}
}
int re = 0;
int m = 1;
for(i=0;i<31;i++) {
if(count[i] % 3 == 1) {
// 用或运算实现0 += m
re = re | m;
}
m = m << 1;
}
// 用于处理第32位,即count[31],也就是符号位
if(count[i] % 3 == 1) {
re = re | m;
}
return re;
}
};
- 下面是有限状态机思路的实现,其实找到这个规则就已经蛮不容易的了:
class Solution {
public:
/*
核心思路:将所有数字用二进制表示,然后对每个二进制位计数,最后模3,余数组成的二进制数就是所求的数字
有限状态机:通过位运算避免循环解析每个位值再做累加
有三种状态:余数是0,余数是1,余数是2,因此要用两个二进制位来表示
00(0)->0: 00; 00->1:01
01(1)->0: 01; 01->1:10
10(2)->0: 10; 10->1:00
注意是先更新one,再更新two,因此如果不开辟新空间的话,两个位的状态转移要特别留意转移时另一个位的值是否已更新
one的状态转移:输入0则不变;输入1,则two=0时取反,two=1时不变(仅一种0->0)
0(0)->0: 0; [0]0->1:1
1(1)->0: 1; [0]1->1:0
0(2)->0: 0; [1]0->1:0
two的状态转移:输入0则不变;输入1,则new_one=1时不变(仅一种0->0),new_one=0时取反
0(0)->0: 0; 0[1]->1:0
0(1)->0: 0; 0[0]->1:1
1(2)->0: 1; 1[0]->1:0
用到的位运算是:x ^ y (y=0时不变,y=1时取反) 和 x & ~y (y=0时不变,y=1时为0)
假设输入的是n,则综合的位运算为:
one = (one ^ n) & ~two
two = (two ^ n) & ~new_one
最后one中剩下的就是余数,因为只可能有00或者01两种状态
注意:有限状态机的方法可能仅适用于能够找到精确且简洁表达状态转移的情况(状态如果不是三种可能会相当麻烦)
*/
int singleNumber(vector<int>& nums) {
int one = 0, two = 0;
for(int num:nums) {
one = (one ^ num) & ~two;
two = (two ^ num) & ~one;
}
return one;
}
};
4. 不用加减乘除做加法
- 思路:
- 实际上是模拟一个加法器(仿佛瞬间回到了数字逻辑的课堂上,好怀念/(ㄒoㄒ)/~~);
- 核心就是记录进位,然后不断进行进位左移(也就是乘2)和异或操作(也就是相加),直至进位为0;
- 注意左移操作要使用
unsigned int
类型,因为int
左移如果是出现负数会直接抛未定义异常,虽然它们的左移实现逻辑都是一样的,但int
会出现乘2意义的矛盾; - 代码:
class Solution {
public:
/*
感觉考察的是加法器的实现原理
二进制的加法如下:
0 + 0 = 00
0 + 1 = 01
1 + 0 = 01
1 + 1 = 10
左位(进位):与运算,全1为1
右位(结果位):异或运算,不同为1
推广到多位:
左:00 & 00 = 00 右:00 ^ 00 = 00
左:00 & 01 = 00 右:00 ^ 01 = 01
左:01 & 00 = 00 右:01 ^ 00 = 01
左:01 & 01 = 01 右:01 ^ 01 = 00
=> 左位*2:01 << 1 = 10
=> 左:10 & 00 = 00 右:00 ^ 10 = 10
注意:
左位(进位)要用unsigned int来实现,这是为了处理负数的符号位
但其实int和unsigned int的左移实现是一样的,都是低位补零
但由于左移的意义是将数乘2,所以当int左移后出现正数变为负数的情况时,就认为是未定义的行为而报错
但实际上左移的操作int是可以执行的,在物理层面上没有问题
所以移位操作最好还是用unsigned int类型
*/
int add(int a, int b) {
unsigned int carry = a & b; // 左位
b = a ^ b; // 右位
while(carry != 0) {
a = carry << 1;
//printf("%d\t%d\n", a, carry);
carry = a & b; // 左位
b = a ^ b; // 右位
}
return b;
}
};
十、双指针
1. 调整数组顺序使奇数位于偶数前面
- 思路:
- 代码:
class Solution {
public:
vector<int> exchange(vector<int>& nums) {
int i = 0, j = nums.size() - 1;
int temp;
while(i<j)
{
/*
双指针
1. 特别注意i<j在下面的两个while循环中也要加上
2. 这两个while循环用于移动指针
3. 不加上的话在指针相遇的时候会出现错误,导致有i>j的情况出现
*/
while(i<j && nums[i]%2==1)
{
i++;
}
while(i<j && nums[j]%2==0)
{
j--;
}
temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
return nums;
}
};
2. 链表中倒数第k个节点
-
思路:
-
用一前一后两个相差k的指针指向节点,
-
这样后面的指针到达末尾的时候,前面的指针刚好是所求的节点
-
代码:
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
/*
双指针:
1. 一前一后两个指针,它们相差k-1个节点
*/
ListNode* getKthFromEnd(ListNode* head, int k) {
ListNode* first_ptr = head;
ListNode* second_ptr = head;
int n = 0;
while(first_ptr->next)
{
first_ptr = first_ptr->next;
++n;
if(n>=k)
{
second_ptr = second_ptr->next;
}
}
return second_ptr;
}
};
3. 两个链表的第一个公共节点
-
题目:https://leetcode.cn/problems/liang-ge-lian-biao-de-di-yi-ge-gong-gong-jie-dian-lcof/
-
思路:
-
非常巧妙的双指针;
-
其实是令两个指针同时从链表A和链表B的头节点出发,均走完一遍链表A和链表B;
-
也就是说某个指针走完当前链表后,跳到另一个链表继续遍历;
-
这样如果它们有共同的节点,在第二次遍历的时候就会相遇,否则则同时指向空指针;
-
注意:不可能会有共同节点之后两个链表又分开的情况,因为next指针只有一个,所以如果有共同节点,则一定会共同到最后一个节点;
-
鉴于两个指针走过的总节点数量一样多,因此最后的一段共同节点(如果有的话)是一定会一起走的;
-
不仅巧妙而且浪漫,单身狗伤害+1(〒~〒);
-
时间复杂度是O(M+N),空间复杂度是O(1);
-
代码:
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode *pa = headA;
ListNode *pb = headB;
// pa == pb有两种可能:要么它们同时指向一个节点,要么它们同时为空
while(pa != pb) {
if(pa) {
pa = pa->next;
}
else {
// pa也走pb的轨迹
pa = headB;
}
if(pb) {
pb = pb->next;
}
else {
// pa也走pb的轨迹
pb = headA;
}
}
return pa;
}
};
4. 和为s的两个数字
- 思路:
- 使用一头一尾两个指针i和j,设计得也十分巧妙;
- 起始点是
(0,n-1)
,位于上三角矩阵的右上角;
(i,j)
只能向左和向下移动;- 当
(i,j)<target
时,i++
,相当于向下移动,丢弃了第i行左边的所有元素,因为它们肯定也小于target; - 当
(i,j)>target
时,j--
,相当于向左移动,丢弃了第j列下边的所有元素,因为它们肯定也大于target; - 另外,由于所给的数都是正数,所以起始的右指针不一定要用
n-1
,也可以用target的上界,即第一个大于target的数的下标,然后引入二分法求得(至于为什么不是大于等于而是大于,参看八、二分法一章); - 虽然引入了二分法后的理论时间复杂度数量级并没有下降,还是O(N);
- 代码:
class Solution {
private:
// 二分法返回第一个大于target的数的下标
int binary_search(vector<int>& nums, int target) {
int low = 0, high = nums.size()-1;
while(low < high) {
int medium = low + (high - low) / 2;
if(nums[medium] > target) {
high = medium;
}
else {
low = medium + 1;
}
}
return low;
}
public:
vector<int> twoSum(vector<int>& nums, int target) {
int i = 0, j = binary_search(nums, target); // 从(0, n-1)出发,位于右上角
while(i < j) {
if(nums[i] < target - nums[j]) {
// (i,j)->(i+1,j),往下走
// 略去(i,0)到(i,j-1)的所有点,因为它们必定也小于target
i++;
}
else {
if(nums[i] > target - nums[j]) {
// (i,j)->(i,j-1),往左走
// 略去(i,j)到(j-1,j)的所有点,因为它们必定也大于target
j--;
}
else {
return vector<int> {nums[i], nums[j]};
}
}
}
return vector<int> {};
}
};
补充:关于溢出的加法运算处理技巧
- 上面的代码其实是用到了两个加法运算的溢出处理技巧:
- 一个是求两个数的平均值:用
medium = low + (high - low) / 2
代替medium = (low + high) / 2
; - 一个是两个数之和的比较:用
nums[i] < target - nums[j]
代替nums[i] + nums[j] < target
; - 也就是说,尽量用减法代替两数之间的加法运算,因为两个正数相加可能会溢出,但两个正数相减不会溢出(即使是极端的
0 - 2^31-1
);
5. 和为s的连续正数序列 [滑动窗口]
- 思路:
- 其实是从某一个数字出发找一个连续序列使得序列和=target,暴力的方法时间复杂度是 O ( t a r g e t 3 / 2 ) O(target^{3/2}) O(target3/2);
- 双指针的时间复杂度可以降为 O ( t a r g e t ) O(target) O(target),原理如下:
- 其实思路是很像4. 和为s的两个数字里面的状态转移的,也是可以构建一个上三角矩阵,但是从左上角出发;只能向下或者向右移动:(1) 向下转移的时候,要么是当前行已经找到=target,要么是不可能找到(>target),因此舍弃掉当前行的右边状态;(2) 向右转移的时候,当前列已经不可能再有大于
S(i,j)
的状态了,又S(i,j)<target
,因此当前列的往下的状态都可以省略;
- 这种双指针也被称为滑动窗口,一个突出的特征就是左右指针都只能向右移动,因此时间复杂度是O(N);
- 代码:
class Solution {
public:
/*其实是从某一个数字出发找一个连续序列使得序列和=target*/
/*双指针就是要验证从左指针出发到右指针的序列*/
vector<vector<int>> findContinuousSequence(int target) {
int i = 1, j = 2;
int sum;
vector<vector<int>> re;
vector<int> tmp;
while(i <= target / 2) {
sum = (i + j) * (j - i + 1) / 2;
if(sum < target) {
// 不足则右指针+1
++j;
}
else {
if(sum > target) {
// 超过则不再验证左指针
// 至于为什么不用右指针左移,是因为
// 当前有sum(i,j)>target && sum(i,j-1)<target
// 因此必定有sum(i+1,j-1)<target,所以j不用左移,下一步直接验证sum(i+1,j)即可
++i;
}
else {
// 相等则左指针+1
tmp.clear();
for(int k = i;k <= j;++k) {
tmp.push_back(k);
}
re.push_back(tmp);
++i;
}
}
}
return re;
}
};
十一、堆
1. 最小的k个数
-
思路:
-
用最大堆来实现;
-
维护一个元素个数为k的最大堆;
-
遍历数组,如果当前元素比堆顶元素(最大值)小,则替换堆顶元素;
-
最后返回堆中元素即可;
-
代码:
-
使用STL的
priority_queue
实现大顶堆,代码如下:
class Solution {
public:
vector<int> getLeastNumbers(vector<int>& arr, int k) {
// 大顶堆
priority_queue<int> q;
for(int i=0;i<arr.size();++i) {
if(q.size() < k) {
q.push(arr[i]);
}
else {
if(q.size() > 0 && arr[i] < q.top()) {
q.pop();
q.push(arr[i]);
}
}
}
vector<int> re;
while(!q.empty()) {
re.push_back(q.top());
q.pop();
}
return re;
}
};
- 使用
vector
自己实现大顶堆,代码如下:
class Solution {
private:
void downHeap(vector<int> &heap, int root) {
// 节点下沉,适用于根节点违反了性质,如删除堆顶节点或替换堆顶节点
int left = 2*root + 1;
int right = 2*root + 2;
int max_index = root;
if(left<heap.size() && heap[max_index]<heap[left]) {
max_index = left;
}
if(right<heap.size() && heap[max_index]<heap[right]) {
max_index = right;
}
if(max_index == root) {
return;
}
else {
int tmp = heap[max_index];
heap[max_index] = heap[root];
heap[root] = tmp;
downHeap(heap, max_index);
return;
}
}
void upHeap(vector<int> &heap, int leaf) {
// 节点上浮,适用于子节点违反了性质,如插入堆尾节点
if(leaf == 0) {
return; // 根节点直接返回
}
int root = (leaf - 1) / 2;
if(heap[root] >= heap[leaf]) {
return;
}
else {
int tmp = heap[root];
heap[root] = heap[leaf];
heap[leaf] = tmp;
upHeap(heap, root);
return;
}
}
public:
vector<int> getLeastNumbers(vector<int>& arr, int k) {
vector<int> heap;
for(int i=0;i<arr.size();++i) {
if(heap.size() < k) {
heap.push_back(arr[i]);
upHeap(heap, heap.size()-1);
}
else {
if(heap.size() > 0 && heap[0] > arr[i]) {
heap[0] = arr[i];
downHeap(heap, 0);
}
}
}
return heap;
}
};
- 使用堆的话时间复杂度是O(Nlogk);
- 但如果元素的范围有限,比如这道题是[0, 10000],就可以用计数排序法,时间复杂度是O(N);
- 具体实现就是开一个10000大小的数组,然后遍历原数组
arr
,在arr[i]
对应的数中计数; - 随后取数组前k个有计数的元素输出即可;
其他类型:
1. 数组中出现次数超过一半的数字
- 思路:
- 数组排序法时间复杂度最低是O(NlogN),时间上劣于另外两种,但是空间复杂度可以为O(1);
- 哈希表统计法的时间复杂度可以去到O(N),但是空间复杂度较高;
- 最佳是使用摩尔投票法,它的原理大概是:
- 从数组的头部开始遍历;
- 直接假设当前元素是众数,然后继续往后遍历;
- 如果当前的数和众数相同,则为它投票(+1),否则投反对票(-1);
- 如果投票后,众数的票数为0,则说明它不一定是众数,因为有和它数量一样多的反对票;
- 但后面的数组中肯定还是众数的数量居多,所以当前数之前的票数可以作废,重新开始假设众数并计票;
- 这种思路的意思就是用众数和其他数起码一换一,到最后剩下的一定是众数;
- 代码:
class Solution {
public:
int majorityElement(vector<int>& nums) {
int major;
int votes = 0;
for(int i=0;i<nums.size();++i) {
if(votes == 0) {
// 投票数为0,设置当前的数为众数
major = nums[i];
}
if(nums[i] == major) {
// 当前的数和众数相同,投票数+1
++votes;
}
else {
// 当前的数和众数不同,投票数-1
--votes;
}
}
return major;
}
};
2. 扑克牌中的顺子
- 思路:
- 难点其实是有两个:
- (1) 输入的序列是无序的;
- (2) joker可以在序列的任意位置进行替代,但替代的位置数量不能超过joker的数量;
- 取巧点有一个:
- 可以直接用两个相隔不是1的数相减得出它们之间需要插入多少的joker;
- 思路有下面两种:
- 用集合的话相当于直接判断所有需要插入的joker数量,因为用的是max和min,但分析得到这种思路的时候需要特别清楚无重复牌条件下的情况;
- 用排序的话就是分开判断,思路比较容易得到;
- 代码:
- 排序思路的代码如下:
class Solution {
public:
bool isStraight(vector<int>& nums) {
// 排序
sort(nums.begin(), nums.end());
int jokers = 0;
for(int i=1;i<nums.size();i++) {
if(nums[i-1] == 0) {
++jokers;
}
else {
// nums[i] - nums[i-1] == 0 除了0之外有重复牌
if(nums[i] - nums[i-1] == 0) {
return false;
}
// nums[i] - nums[i-1] > 1 相隔超过1,需要用joker来代替
if(nums[i] - nums[i-1] > 1) {
if(nums[i] - nums[i-1] <= 1+jokers) {
// 减掉代替的jokers
jokers -= nums[i] - nums[i-1] - 1;
}
else {
return false;
}
}
}
}
return true;
}
};
3. 求1+2+…+n
- 思路:
- 用递归实现循环累加;
- 用逻辑与运算实现if语句;
- 化繁为简确实很难>︿<,如果不看解析都不知道怎么弄;
- 代码:
class Solution {
public:
int sumNums(int n) {
// A && B,若A为0,则不会执行B
// 这里是利用这个性质实现了if语句
n && (n += sumNums(n-1));
return n;
}
};
4. 构建乘积数组
- 思路:
- 非常巧妙的思路,用了正序遍历和逆序遍历;
- 一次计算除自己之外的左边元素之积,一次计算除自己之外的右边元素之积;
- 这样就可以使用累乘了,不需要for循环来得到结果;
- 最后再将左右两边的元素之积乘起来即可;
-
其实是有点类似与双指针的感觉,但又不是同时进行的双指针,而是用了空间来换时间;
-
代码:
class Solution {
public:
vector<int> constructArr(vector<int>& a) {
vector<int> left(a.size(), 1);
vector<int> right(a.size(), 1);
// 先记录左边的除自己本身的结果
for(int i=1;i<a.size();++i) {
// printf("%d, %d, %d\n", a[i-1], left[i-1], i);
left[i] = a[i-1] * left[i-1];
}
// 再记录右边的除自己本身的结果
for(int i=a.size()-2;i>=0;--i) {
right[i] = a[i+1] * right[i+1];
}
vector<int> re(a.size(), 0);
// 最后把左边和右边的结果相乘即可
for(int i=0;i<a.size();++i) {
re[i] = left[i] * right[i];
}
return re;
}
};
5. 1~n 整数中 1 出现的次数
- 思路:
- 逐位计算当前位出现1的次数;
- 主要是考虑当前位是1的时候需要计数多少次;
- 代码:
class Solution {
public:
/*
思路是计算每一位出现1的次数,然后再把所有的次数相加:
定义high是当前位右边的数字,digit是当前位所在的数量级
1. 如果当前位是0,则1出现的次数 = high * digit
-> 有high个10-19, 100-199, 1000-1999等的情况
2. 如果当前位是1,则1出现的次数 = high * digit + (low + 1) (1是算上10, 100, 1000...等的情况)
-> 由于当前正在1,所以1的次数由low决定
3. 如果当前位是2-9,则1出现的次数 = high * digit + digit
-> 由于已经过了1,所以high+1的10-19, 100-199, 1000-1999等的情况也应该要加上了
*/
int countDigitOne(int n) {
int high, low, cur;
int digit = 1; // 当前进位
high = n;
low = 0;
cur = 0;
int re = 0;
while(high > 0) {
cur = high % 10; // 当前位
high = high / 10; // 高位
// 根据当前位计算当前位出现1的次数
if(cur == 0) {
re += high * digit;
}
else {
if(cur == 1) {
re += high * digit + low + 1;
}
else {
re += (high + 1) *digit;
}
}
// 计算下一个右边位,为了防止溢出应当判断是否还有右边位
if(high > 0) {
low = low + cur * digit; // 低位
digit = digit * 10; // 进位
}
}
return re;
}
};