2.27-双指针

283. 移动零 - 力扣(LeetCode)

核心思路

把 0 视作空位。我们要把所有非零元素都移到数组左边的空位上,并保证非零元素的顺序不变。

例如 nums=[0,0,1,2],把 1 放到最左边的空位上,数组变成 [ 1 ,0, 0 ,2]。注意 1 移动过去后,在原来 1 的位置又产生了一个新的空位。也就是说,我们交换了 nums[0]=0 和 nums[2]=1 这两个数。

为了保证非零元素的顺序不变,我们需要维护最左边的空位的位置(下标)。

具体思路

  1. 从左到右遍历 nums[i]。同时维护另一个下标$ i_0 \((初始值为 0),并保证下标区间 [\) i_0 $ ,i−1] 都是空位,且$ i_0 $ 指向最左边的空位。

  2. 每次遇到 nums[i]!=0 的情况,就把 nums[i] 移动到最左边的空位上,也就是交换 nums[i] 和 nums[$ i_0 \(]。交换后把\) i_0 $ 和 i 都加一,从而使【[$ i_0 $ ,i−1] 都是空位】这一性质仍然成立。

  3. 如果 nums[i]=0,无需交换,只把 i 加一。

示例

nums=[0,1,0,3,12],计算过程如下(下划线表示交换的两个数):

i i₀ nums[i] 操作后
0 0 0 不操作
1 0 1 [1,0,0,3,12]
2 1 0 不操作
3 1 3 [1,3,0,0,12]
4 2 12 [1,3,12,0,0]

由于每次操作后,[$ i_0 $ ,i−1] 对应的元素值全为 0 这一性质始终成立,所以 nums 遍历结束后(i=n),[$ i_0 $ ,n−1] 对应的元素值全为 0,且 [0,$ i_0 $ −1] 都是交换过去的非零元素,这样就满足了题目「将所有 0 移动到数组的末尾」的要求。

答疑

问:如果 nums 的前几个数都不是 0 呢?

答:$ i_0 $ 会和 i 同时向右移动,直到遇到 0(或者到达数组末尾)为止。

//灵神版
class Solution {
public:
    void moveZeroes(vector<int>& nums) {
        int i0 = 0;
        for (int& x : nums) { // 注意 x 是引用
            if (x) {
                swap(x, nums[i0]);
                i0++;
            }
        }
    }
};
class Solution {
  public:
      void moveZeroes(vector<int>& nums) {
         int l = 0;
         for (int r = 0; r < nums.size(); r++) {
          //nums[r]非0 的话,换到 l左边去
          //这样r的初值为第一个不为0的数
            if(nums[r]) swap(nums[l ++] , nums[r]);
         }        
      }
  };

复杂度分析

  • 时间复杂度:O(n),其中 nnums 的长度。
  • 空间复杂度:O(1)。

11. 盛最多水的容器 - 力扣(LeetCode)(A)

贪心思想:

短桶效应:h = min(height[l] , height[r]) , 答案res = (r - l) * h

每次比较height[l]与height[r],小的移动。(因为r-l是不断缩小的,要使 res 最大,只能试图让h增大)

class Solution {
  public:
      int maxArea(vector<int>& height) {
          int res = 0;
          int l = 0 , r = height.size() - 1;
          while(l < r){
            int h = min(height[l] , height[r]);
            res = max(res , (r - l) * h);
            if(height[l] < height[r]) l ++;
            else r --;
          }
          return res;
      }
  };

15. 三数之和 - 力扣(LeetCode)

思路:双向双指针

前置题:167. 两数之和 II - 输入有序数组 - 力扣(LeetCode)

class Solution {
  public:
      vector<int> twoSum(vector<int>& nums, int target) {
          int l = 0 , r = nums.size() - 1;        
          while(1){//题目保证答案存在,直接写while(true)即可,写while(l < r)过不了
            int s = nums[l] + nums[r];
            if(s == target) return {l + 1 , r + 1};
            s > target ? r -- : l ++;
          }
      }
  };

这道题的启发是:如果数组有序(从小到大),则可以用双向双指针优化,通过比较s与target大小进行控制l、r指针移动,从而一次遍历数组即可。

运用到三数之和中,也先对数组进行排序。因为题目说任意顺序返回,我们自己就规定x<y<z,然后用x遍历nums,y,z分别指向x+1 , n-1,用双向双指针解决。总时间即达到n^2级。

剪枝优化

  1. 如果当前x与后两个数加起来都已经大于0,后续所有和都将大于0,退出循环

     if (x + nums[i + 1] + nums[i + 2] > 0) break;
    
  2. 如果x与最大的两个数加起来都小于0,直接让x向右移动,进入下一轮循环

    if (x + nums[n - 2] + nums[n - 1] < 0) continue;
    
  3. 如果数组第一个元素都大于0,则三数和必不能等于0

      if(x > 0)  return res;
    
class Solution {
  public:
      vector<vector<int>> threeSum(vector<int>& nums) {
          ranges::sort(nums);
          vector<vector<int>> ans;
          int n = nums.size();
          for (int i = 0; i < n - 2; i++) {
              int x = nums[i];
              if(x > 0)  return res; //优化三: 如果数组第一个元素都大于0,则三数和必不能等于0
              if (i && x == nums[i - 1]) continue; // 跳过重复数字
              if (x + nums[i + 1] + nums[i + 2] > 0) break; // 优化一:如果当前x与后两个数加起来都已经大于0,后续所有和都将大于0,退出循环
              if (x + nums[n - 2] + nums[n - 1] < 0) continue; // 优化二:如果x与最大的两个数加起来都小于0,直接让x向右移动,进入下一轮循环
              int j = i + 1, k = n - 1;
              while (j < k) {
                  int s = x + nums[j] + nums[k];
                  if (s > 0) {
                      k--;
                  } else if (s < 0) {
                      j++;
                  } else { // 三数之和为 0
                      ans.push_back({x, nums[j], nums[k]});
                      for (j++; j < k && nums[j] == nums[j - 1]; j++); // 跳过重复数字
                      for (k--; k > j && nums[k] == nums[k + 1]; k--); // 跳过重复数字
            /*这两行写成do{}while();形式更好理解:
               do {
                j ++;
              }while(j < k && nums[j] == nums[j - 1]) ;
              
              do {
              k --;
              }while(j < k && nums[k] == nums[k + 1]); */
                  }
              }
          }
          return ans;
      }
  };

42. 接雨水 - 力扣(LeetCode)

方法一:前后缀分解

木桶理论,先分别计算前后缀,然后对于每个木桶,计算min(pre_max , suf_max) 减去当前的高度height[i]即为该木桶的接水量。

fig1

class Solution {
public:
    int trap(vector<int>& height) {
        int n = height.size();
        vector<int> pre_max(n); // pre_max[i] 表示从 height[0] 到 height[i] 的最大值
        pre_max[0] = height[0];
        for (int i = 1; i < n; i++) {
            pre_max[i] = max(pre_max[i - 1], height[i]);
        }

        vector<int> suf_max(n); // suf_max[i] 表示从 height[i] 到 height[n-1] 的最大值
        suf_max[n - 1] = height[n - 1];
        for (int i = n - 2; i >= 0; i--) {
            suf_max[i] = max(suf_max[i + 1], height[i]);
        }

        int ans = 0;
        for (int i = 0; i < n; i++) {
            ans += min(pre_max[i], suf_max[i]) - height[i]; // 累加每个水桶能接多少水
        }
        return ans;
    }
};

复杂度分析

  • 时间复杂度:O(n),其中 nheight 的长度。
  • 空间复杂度:O(n)。

方法二:相向双指针

最大前后缀需要维护两个数组 pre_maxsuf_max,因此空间复杂度是 O(n)。是否可以将空间复杂度降到 O(1)?

注意到下标 i 处能接的雨水量由leftMax[i]rightMax[i]中的最小值决定。由于数组 leftMax 是从左往右计算,数组 rightMax 是从右往左计算,因此可以使用双指针和两个变量代替两个数组。

维护两个指针 left 和 right,以及两个变量leftMaxrightMax,初始时 left=0,right=n−1,leftMax=0,rightMax=0。指针 left 只会向右移动,指针 right 只会向左移动,在移动指针的过程中维护两个变量 leftMaxrightMax 的值。

当两个指针没有相遇时,进行如下操作:

  • 使用 height[left]height[right] 的值更新leftMaxrightMax的值;

  • 如果 height[left]<height[right],则必有 leftMax<rightMax,下标 left 处能接的雨水量等于 leftMax−height[left],将下标 left 处能接的雨水量加到能接的雨水总量,然后将 left 加 1(即向右移动一位);

  • 如果 height[left]≥height[right],则必有 leftMax≥rightMax,下标 right 处能接的雨水量等于 rightMax−height[right],将下标 right 处能接的雨水量加到能接的雨水总量,然后将 right 减 1(即向左移动一位)。

当两个指针相遇时,即可得到能接的雨水总量。

class Solution {
public:
    int trap(vector<int>& height) {
        int ans = 0, left = 0, right = height.size() - 1, pre_max = 0, suf_max = 0;
        while (left < right) {
            pre_max = max(pre_max, height[left]);
            suf_max = max(suf_max, height[right]);
            ans += pre_max < suf_max ? pre_max - height[left++] : suf_max - height[right--];
        }
        return ans;
    }
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组 height 的长度。两个指针的移动总次数不超过 n。

  • 空间复杂度:O(1)。只需要使用常数的额外空间。

方法三:单调栈

image-20250227202442049

及时去掉无用数据,保证栈中数据有序

应用范围:找上一个更大(小)元素/下一个更大(小)元素。通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。

上面的方法相当于「竖着」计算面积,单调栈的做法相当于「横着」计算面积。

这个方法可以总结成 16 个字:找上一个更大元素,在找的过程中填坑。

前置题:739. 每日温度 - 力扣(LeetCode)

从左到右,栈中记录还没算出下一个更大元素的那些数的下标

相当于栈是一个 todolist,在循环的过程中,现在还不知道答案是多少,在后面的循环中会算出答案。

class Solution {
  public:
      vector<int> dailyTemperatures(vector<int>& temperatures) {
          int n = temperatures.size();
          vector<int> ans(n);
          stack<int> st;//todolist
          for (int i = 0; i < n; i++) {
             int t = temperatures[i];
             while(!st.empty() && t > temperatures[st.top()]){//如果t>当前栈顶元素,将栈顶元素出栈
              int j = st.top();//j是前一个的下标
              st.pop();
              ans[j] = i - j;
             }
             st.push(i);//记录下标
          }
          return ans;
      }
  };

回到本题。

维护一个单调栈,单调栈存储的是下标,满足从栈底到栈顶的下标对应的数组 height 中的元素递减。

首先单调栈是按照行方向来计算雨水,如图:

42.接雨水2

从左到右遍历数组,遍历到下标 i 时,如果栈内至少有两个元素,记栈顶元素为 top,top 的下面一个元素是 left,则一定有 height[left]≥height[top]。如果 height[i]>height[top],则得到一个可以接雨水的区域,该区域的宽度是 i−left−1,高度是 min(height[left],height[i])−height[top],根据宽度和高度即可计算得到该区域能接的雨水量。

为了得到 left,需要将 top 出栈。在对 top 计算能接的雨水量之后,left 变成新的 top,重复上述操作,直到栈变为空,或者栈顶下标对应的 height 中的元素大于或等于 height[i]。

在对下标 i 处计算能接的雨水量之后,将 i 入栈,继续遍历后面的下标,计算能接的雨水量。遍历结束之后即可得到能接的雨水总量。

以下逻辑主要就是三种情况

  • 情况一:当前遍历的元素(柱子)高度小于栈顶元素的高度 height[i] < height[st.top()]

    if (height[i] < height[st.top()])  st.push(i);
    
  • 情况二:当前遍历的元素(柱子)高度等于栈顶元素的高度 height[i] == height[st.top()]

    if (height[i] == height[st.top()]) { // 例如 5 5 1 7 这种情况
    st.pop();
    st.push(i);
    }
    
  • 情况三:当前遍历的元素(柱子)高度大于栈顶元素的高度 height[i] > height[st.top()]

    此时就出现凹槽了,如图所示:

42.接雨水4

while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while,持续跟新栈顶元素
 int mid = st.top();
 st.pop();
 if (!st.empty()) {
     int h = min(height[st.top()], height[i]) - height[mid];
     int w = i - st.top() - 1; // 注意减一,只求中间宽度
     sum += h * w;
 }
}

总体代码:

class Solution {
  public:
     int trap(vector<int>& height){
         stack<int> st;
         st.push(0);
         int sum = 0;
         for (int i = 1; i < height.size(); i++) {
            while(!st.empty() && height[i] >= height[st.top()]){
              int mid = st.top();
              st.pop();
              if(!st.empty()){
                int h = min(height[st.top()] , height[i]) - height[mid];
                int w = i - st.top() - 1;
                sum += h * w;
              }
            }
            st.push(i);
         }
         return sum;
      }
  };

注意 while 中加了等号(height[i] >= height[st.top()]),因为height[i] = height[mid]时,h计算出来是0,因此可以直接加。

这可以让栈中没有重复元素,从而在有很多重复元素的情况下,使用更少的空间。

复杂度分析

  • 时间复杂度:O(n),其中 n 为 height 的长度。虽然我们写了个二重循环,但站在每个元素的视角看,这个元素在二重循环中最多入栈出栈各一次,因此循环次数之和是 O(n),所以时间复杂度是 O(n)。
  • 空间复杂度:O(min(n,U)),其中 U=max(height)−min(height)+1。注意栈中没有重复元素,在 height 值域很小的情况下,空间复杂度主要取决于 height 的值域范围。
posted @ 2025-02-27 22:01  七龙猪  阅读(1)  评论(0)    收藏  举报
-->