c语言学习之旅——二分与双指针
文章目录
前言
二分法与双指针是c语言很基础的一个思想,但是当体现在题目上时,往往难以想到。而当看了题解之后,才会发现二分法和双指针的巧妙。
经过了三个星期的学习,对二分法与双指针也有了些自己的见解。下面把这几个星期的收获都梳理一下。
一、二分法
坚持一个原则,循环不变量原则。坚持一个主义,中国共产主义。
基础的二分法
下面从力扣的一个简单的题目来引入什么是二分法。
题目链接
这题可以直接用for循环来查找,这样时间复杂度就为O(n),但是我们显然可以用一个更简便并且题目要求的方法——二分查找。
什么是二分查找?即每次都寻找中间值,不断将中间值跟目标值作比较,如果中间值大于目标值,说明我们需要的目标值在右侧,反之,则在左侧。由此可以产生如下代码:
int left = 0;
int right = numsSize - 1;
int middle = 0;
while (left <= right) {
middle = (left + right) / 2;
if(nums[middle]< target) {//中间值比目标值小,则目标值在右侧,因此移动左指针
left = middle + 1;
} else if (nums[middle] > target) {//中间值比目标值大,则目标值在左侧,因此移动右指针
right = middle - 1;
} else if (nums[middle] == target) {
return middle;
}
}
return -1;
这就是一个典型的二分法。每次取中间值,判断中间值的关系,然后进行区间的缩小。这个时候问题来了,为什么区间缩小需要+1或者-1,left <= right 还是 left < right。这个时候就涉及到循环不变量原则。
循环不变量原则
在二分查找的过程中,保持不变量,就是在while寻找中每一次边界的处理都要坚持根据区间的定义来操作,这就是循环不变量原则。
具体来说,以数组为例。target是在区间[left,right]还是在区间[left,right) 中决定了边界处理的规则。
前者两个都是闭区间时,此时为左闭右闭型,left = right时有意义,因此在while循环的判断条件中,left应该是<=right的。而在判断后移动指针时,需要进行左缩和右缩,当当前中间值不等于目标值时,是不是应该从中间值的下一个或前一个进行判断,因此进行左缩和右缩。
后者为左闭右开型,此时left = right 就没有意义,因此在while循环中,循环条件应是left < right。在更新指针时,更新左指针时右缩,而右指针不需要左缩。left == right时,区间[left, right)属于空集,所以用 < 避免该情况
while(left < right){ // left == right时,区间[left, right)属于空集,所以不取等号避免该情况。
int middle = (left + right) / 2;
if(nums[middle] < target){
//target位于(middle , right) 中为保证集合区间的左闭右开性,可等价为[middle + 1,right)
left = middle + 1;
}else if(nums[middle] > target){
//target位于[left, middle)中
right = middle ;
}else{ // nums[middle] == target ,找到目标值target
return middle;
}
这就是二分法的基础应用,下面来上点难度。
二分实战
1.排列硬币
题解
简单的二分法题目吗,像查找目标值这类,通常一眼就能看出。但与数学结合后,很多题目都可以通过二分解决。
该题目就是二分与数学中的等差数列相结合。根据等差求和公式:total = k * (k + 1)/ 2。因此可以通工二分查找计算n枚硬币可形成的完整节数的总行数。因为数据为正整数,所以至少有1个完整阶梯。
代码如下:
int arrangeCoins(int n){
long left = 0;
long right = n;
long middle = 0;
if (n == 1) {
return 1;
}
while( left <= right ){
middle = (left + right) / 2;
if((middle * (middle+1)) / 2 < n){
left = middle + 1;
}
else if((middle * (middle+1)) / 2 > n){
right = middle - 1;
} else{
return middle ;
}
}
return right;
}
注意:本题采用了左闭右闭区间,即刚好为整行数时,有意义。本题的难点也在于将数学公式转化为二分法的判断。即计算middle是否符合该行数时满足的等差公式。
思考:如果不满足,即无法刚好成行时,为什么返回的是right呢?这就考虑到当行数不满足时,righ是左缩,即向下取整,left是右缩,即向上取整,因此,返回right才是正解,表示行数不够铺满下一行,返回上一行。
2.袋子里最少数目的球
题解
二分法除了在查找目标值时,在寻找最大最小的时候也可以考虑使用二分法。比起for循环,二分对效率和思路的提升都快了不少。
回到这题,寻找袋子里最少数目的球如何跟二分法建立联系呢。如果直接用for循环,在操作次数内对最大值进行分化,分成较小值和第二小,在不断循环,就会发现无法在数目相同时正确输出结果。
但是,如果我们将袋子里的球作为中间值数量,最后比较操作次数和实际能够操作的次数,是不是就可以正确输出。
代码实现:
int minimumSize(int* nums, int numsSize, int maxOperations) {
int left = 1;
int right = nums[0];
for (int i = 1; i < numsSize; i++) {
right = fmax(right,nums[i]);
}
int ans = 0;
while (left <= right) {
int middle = (left + right) / 2;
long long count = 0;//需要拆分次数
for (int i = 0; i < numsSize; i++) {
count += nums[i] / middle;
if(nums[i] % middle == 0){//恰好整除,即刚好分一次即可,所以少拆分一次。
count--;
}
}
if (count <= maxOperations) {
ans = middle;
right = middle - 1;
}
else {
left = middle + 1;
}
}
return ans;
}
收获
二分查找,不一定直接查找目标值。比如这题,先枚举袋子里的球最多是多少,通过比较操作次数来判断是否可行,不可行则用二分法更换球的数量,再次进行判断。二分法可以用作优化枚举次数,降低代码时间复杂度。转换个思路,往往可能有更优的答案。
搜索选择排序数组
题解
搜索一个目标值,可以用暴力搜索。但是用二分法优化代码,可以将时间复杂度降为O(logn)。
该题目旋转后有一个特性,即以选择点为界,一边数组是一直有序的,而另一边的数组在经过旋转点之后将变为无序。此时就可以用二分法来查找这个旋转点、这也是判断第一个数据与中间值大小的原因。如果第一个数据比中间值大,说明在该段中存在旋转点,在该区间为非有序数组,此时通过二分法不断查找该旋转点。
代码如下:
int search(int* nums, int numsSize, int target) {
int left = 0;
int right = numsSize - 1;
int middle = 0;
while (left <= right) {
middle = (left + right) / 2;
if (nums[0] <= nums[middle]) {//middle在前一段,说明前一段是有序数组
if(nums[0] <= target && target < nums[middle]){
right = middle - 1;
} else {
left = middle + 1;
}
} else {//middle在后一段,说明后一段是有序数组
if (nums[0] > target && target >= nums[middle]) {
left = middle + 1;
} else {
right = middle - 1;
}
}
}
if (target == nums[left - 1]) {
return left - 1;
} else{
return -1;
}
}
收获
二分的本质,是对某种性质进行划分,一半满足该性质,一半不满足该性质时,即可采用二分划分。该题目就是以数组是否有序来进行划分,所有遇到判断两种不同性质的题目时,也可考虑采用二分法解决。
二、双指针
基础的双指针
前面对二分法进行了简单的总结。在二分的时候,我们是不是定义了左右两个指针,来不断更换middle中间值的大小。
双指针法与此相似,通过一个快指针一个慢指针,在一个for循环内完成两个for循环的工作,从而降低时间复杂度的方法,即为双指针法。
我们来从题目中理解
力扣题目链接
题解
既然是双指针,我们先定义快慢两个指针。如何体现快慢两个指针呢?将快指针放进for循环里,无论怎样,循环都会移动,这样就实现了快指针一直移动。而慢指针则在for循环中进行判断,符合条件时,赋值进行移动,不符合在留在原地,等快指针指向下一个元素。在该题中,可以优化为当快指针不等于要移除元素时,才进行赋值操作,并移动慢指针。
代码实现:
int removeElement(int* nums, int numsSize, int val) {
int prev = 0;
int current = 0;
int count = 0;
for (prev; prev < numsSize; prev++) {//prev则为快指针
if(nums[prev] != val){
nums[current] = nums[prev];
current ++;
count ++;
}
}
return count;
}
由此可见,双指针法的主要运用范围应该在数组,字符串,链表这类需要遍历的题目中。
双指针实战
翻转链表
题解
反转链表我们用两个方法,一个是通过双指针迭代,一个是通过递归。
迭代法只需要我们设立两个指针,不断将后一个指针指向前一个指针,即可完成链表的翻转。
而递归呢,采用将链表分为头结点和子节点,并不断将子节点分为头结点和更小的子结点,并判断递归条件是否成立。这里先稍微提提,往后再专门写一篇递归的理解。
代码实现:
//递归法
struct ListNode* revers(struct ListNode *prev, struct ListNode *current){
if( current == NULL) {
return prev;
}
struct LinkNode *temp = current->next;
current->next = prev;
return revers(current,temp);
}
struct ListNode* reverseList(struct ListNode* head) {
return revers(NULL,head);
}
//迭代法(双指针)
struct ListNode* reverseList(struct ListNode* head) {
struct ListNode* prev = NULL;
struct ListNode* cur = head;
while (cur) {
struct ListNode* next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
return prev;
}
注意
双指针法的使用中,两个指针不一定是快慢移动的,也可以一起移动。主要体现在链表的修改和删除中。
四数之和
题解
该题目与三数之和相同,但是四数之和显然要注意的东西更多。首先先用for循环确定两个数字,再通过双指针法确定另外两个数字。但需要注意的是剪枝操作。
因为该题是找四数之和的目标值,所以将数组排序后,不能简单的当当前值大于目标值后,就停止寻找,还应判断目标值和当前值是否小于零。在左右指针的去重操作中,应当判断左指针与左指针加一是否相同,右指针与右指针减一是否相同,如相同,则移动指针去掉当前解。
代码如下:
int cmp(const void* a, const void* b) {
return *(int*)a - *(int*)b;
}
int** fourSum(int* nums, int numsSize, int target, int* returnSize,int** returnColumnSizes){
int** ans = (int**)malloc(sizeof(int*) * 1001);
*returnColumnSizes = (int*)malloc(sizeof(int) * 1001);
*returnSize = 0;
if (numsSize < 4) {
return NULL;
}
qsort(nums, numsSize, sizeof(int), cmp);
for (int i = 0; i < numsSize - 3; i++) {
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
if ((long)nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) {//剪枝操作,说明此时四数和一定大于目标值
break;
}
if ((long)nums[i] + nums[numsSize - 3] + nums[numsSize - 2] +nums[numsSize - 1] //剪枝操作,说明此时四数和一定小于目标值
< target) {
continue;
}
for (int j = i + 1; j < numsSize - 2; j++) {
if (j > i + 1 && nums[j] == nums[j - 1]) {
continue;
}
int left = j + 1;
int right = numsSize - 1;
while (right > left) {
if ((long)nums[i] + nums[j] + nums[left] + nums[right] > target) {
right--;
} else if ((long)nums[i] + nums[j] + nums[left] + nums[right] < target) {
left++;
} else {
int* tmp = malloc(sizeof(int) * 4);
tmp[0] = nums[i];
tmp[1] = nums[j];
tmp[2] = nums[left];
tmp[3] = nums[right];
(*returnColumnSizes)[*returnSize] = 4;
ans[(*returnSize)++] = tmp;
while (right > left && nums[right] == nums[right - 1]) {
right--;
}
while (right > left && nums[left] == nums[left + 1]) {
left++;
}
right--;
left++;
}
}
}
}
return ans;
}
收获
在该题目中,双指针法将时间复杂度降低,同时也为题目的解决提供了一个清晰的思路。在思考问题时,对指针的边界判断和指针去重需要细心,应当往上还是往下去重都是需要考虑的一环。
通过malloc函数定义二维数组在该题目中也是需要理解的一部分。二维指针指向的一维指针即是一维数组,二维指针的长度即是数组的列。因此要对一维指针的长度单独开辟地址。
环形链表
题解
代码离不开数学思维,该题就是一个典型例子。
因为环的存在,我们建立快慢两个指针,并且让快指针比慢指针向后多移动一个位置。假设头结点到入口处结点数为x,入口处到相遇处结点为y,相遇处到入口处结点为z。则有:slow指针走过x+y,fast指针走过x+y+n(y + z)。并且快指针走的结点数是慢指针的两倍,即2*slow = fast。由此可推出x = (n - 1) (y + z) + z。这里n一定大于1,因为快指针一定是追上慢指针,即快指针比慢指针多走一圈。
``由此可得:当快慢指针相遇时,从相遇结点出发一个指针,再从头结点出发一个指针,两个指针相遇时,即为环形结点的入口。因此可以写出如下代码:
struct ListNode *detectCycle(struct ListNode *head) {
struct ListNode *prev = head;
struct ListNode *current = head;
while (current != NULL) {
prev = prev->next;
if (current->next == NULL) {
return NULL;
}
current = current->next->next;
if (current == prev) {
struct ListNode *temp = head;
while (temp != prev) {
temp = temp->next;
prev = prev->next;
}
return temp;
}
}
return NULL;
}
在这个题目中,运用双指针对数学能力的考察要优于代码的考察。但是,如果学会了哈希表用哈希表来解决,则可以降低对数学能力的要求。等我再深入学习完哈希表,就再解决一般这道题吧。
总结
双指针和二分法都是非常基础的语法,但是他们在复杂题目中往往可以简化问题,将暴力遍历,枚举无法解决的问题优化完成。而对这两个方法的使用,非常考验思维的转化,同时对边界情况的处理也是一大核心问题。
下面再分享一下这周学到的一些比较有趣的题目
杂记
随着学习的深入,下面的内容也会单独开个博客描写,最近就先放到这里吧
回溯算法简单学习
题解
该题第一反应则是遍历数组,寻找出对应的单词。但是,如果单纯寻找字母是无序的,但是单词是有序的,这时候就需要用到回溯算法。对回溯的初步理解就如同他的名字,当目前不符合要求时,回溯到上一次符合要求时,再向四周寻找。
那如何实现所谓回溯的步骤呢?递归。难以理解的递归。
在该题目中,如当前字符符合单词的位置,则进入递归函数,判断四周的位置上,有没有任一字符符合下一位置,符合则将该位置进入递归函数,判断其四周位置是否符合所要寻找到字母。
但是,向四周寻找时,有许多问题需要解决。一:去重,将已经使用过的字符需要去重,即赋值为\0"。二:边界处理。需要判断四周查找的位置是否越过边界,如果越过则返回,三:查找该位置的下一位置不符合时,即该条路堵死时,需要将字符赋回原值。即回溯到上一位置。
代码实现:
bool exist1(char** board, int row, int col, char* word, int y, int x) {
if (*word == '\0') {
return true;
}
if (y < 0 || y >= row || x < 0 || x >= col || *word != board[y][x]) {
return false;
}
board[y][x] = '\0';//将该位置的字符标记为使用过。
bool result = exist1(board, row, col, word + 1, y, x - 1) ||
exist1(board, row, col, word + 1, y - 1, x) ||
exist1(board, row, col, word + 1, y, x + 1) ||
exist1(board, row, col, word + 1, y + 1, x);
board[y][x] = *word;//将该位置的字符恢复
return result;
}
bool exist(char** board, int boardSize, int* boardColSize, char* word) {
if (word == '\0') {
return true;
}
for (int y = 0; y < boardSize; y++) {
for (int x = 0; x < boardColSize[0]; x++) {
if (board[y][x] == word[0] &&
exist1(board, boardSize, boardColSize[0], word, y, x)) {
return true;
}
}
}
return false;
}
由此可以学习:回溯算法的主要思想应是递归。当此条路径不通过时,如何回到上一标记点,便是依靠递归进行。即返回上一结点的数据,进入递归函数。如有错误,待深入学习后再专门开一篇修改并且梳理吧。
动态规划简单学习
区别于贪心算法,动态规划中每一个状态都是由上一个状态推导出来。而贪心算法是局部最优解实现整体最优解,
以一道有趣的例题解释吧。
力扣题目链接
题解
每次都可以选择爬一阶或者两阶,也就是说,当前爬楼梯消耗的体力值,只与上一阶和上上阶有关。这不就有了
即每次爬楼梯的花费,都取上阶和上上阶的最小值。那么就可以建立dp数组,将每次登楼梯花费的体力记录在dp数组中,不断相加。
要注意:这是还没登上楼梯顶部的花费,最后登顶时还需要再判断一次。
代码实现:
int minCostClimbingStairs(int* cost, int costSize) {
int dp[costSize];
memset(dp, 0, sizeof(dp));
dp[0] = cost[0], dp[1] = cost[1];
for (int i = 2; i < costSize; i++) {
dp[i] = fmin(dp[i - 1], dp[i - 2]) + cost[i];
}
return fmin(dp[costSize - 1], dp[costSize - 2]);
}
总结
对动态规划和回溯算法的学习只能说刚入了门看了眼是什么。动态规划的五部曲虽然条理清晰,但是在做题目时总容易难以想到如何建立dp数组,以及如何实现递推公式。而回溯算法中,难点在于边界的处理以及递推函数的建立。可见二者都需要深入的理解递归,掌握递归。这也是在解决二分法和双指针法后的下一目标。