双指针和二分法的学习

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数组,以及如何实现递推公式。而回溯算法中,难点在于边界的处理以及递推函数的建立。可见二者都需要深入的理解递归,掌握递归。这也是在解决二分法和双指针法后的下一目标。

  • 29
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值