5

引言

需要读者先做完对应题目(至少看过答案)以后再看总结归纳,否则会一脸懵逼。

通用建议:
题目一般只给一两个输入示例,这是远远不够的。我们思考的时候经常会被示例给束缚(脑子里只对着那个示例想)。如果自己在做题的时候,在纸上多构造几个示例,对比(找不同)和归纳(找相同),会大大增加解决的概率。

leetcode.98 搜索二叉树的遍历

题目链接:https://leetcode.com/problems...

一种简单直接方法是,在递归的过程中维护上界和下界要求,可以很快解决。

比较有价值的是另一种方法,它避免了在遍历的过程中维护上界和下界,它揭示了中序遍历的巧妙特性:

  • 搜索二叉树,如果从上往下俯视,看到的是一个有序数组: image.png
  • 中序遍历搜索二叉树,它的遍历顺序恰好是节点大小排序(即,俯视图的数组顺序)。
  • 在遍历过程中,可以维护一个全局变量prev,可以回看上一个节点(即,恰好比当前节点“小一点”的节点)。

可以结合leetcode.94来复习二叉树的中序遍历(迭代+栈的方式,以及Morris无栈遍历)。

leetcode.146 哈希表和队列的使用(实现LRU)

题目链接:https://leetcode.com/problems...

这题的难点在于,要求时间复杂度为O(1),因此不要想着自己构造各种风骚(复杂)的数据结构,直接用哈希表+链表暴力解决。考察对数据结构(即STL,哈哈)的掌握。

将时间复杂度为O(1)的关键在于利用数据结构的特性:

  • 哈希表插入、删除、查找只需O(1)的时间
  • 链表的插入、删除只需要O(1)的时间(前提是已经找到对应项的指针)
  • 利用以上两点,通过哈希表来保存链表的每一项的指针,就可以在O(1)的时间内删除链表中的任意项
  • 链表可以当做一个FIFO队列使用,拿到“Least Recently Used”的数据
经典题目two sum也考察了哈希表的使用。

leetcode.581 在扫描数组的过程中收集信息

题目连接:https://leetcode.com/problems...

忽略暴力解法,直接考虑O(n)时间的解法。很多题目的O(n)的解法,其关键在于你能不能在一趟扫描的过程中收集关键的信息。这道题揭示了2种在扫描过程中收集信息的技巧:

  • 用上升栈(单调栈),即,遇到比top大的数字才入栈,否则忽略(或者弹栈,使top变小)。下降栈同理。上升栈的特点是,它同时保留了先后关系和大小关系的信息。这种解法的空间复杂度一般为O(n)。
  • 用变量,保存最大值、最小值、累积值、关键指针。变量法的典型用途是“找最值”,如果你的问题可以拆解为最值查找,那么很可能可以使用变量法。变量法的空间复杂度一般为O(1)。
  • 如果问题具有对称性,需要考虑逆序扫描,甚至2个方向同时扫描。
  • 动态规划。见下面的“leetcode.41”解析。
  • 滑动窗口。适合“找区间”问题,并且区间内部的顺序不重要。见下面的“leetcode.438”解析。
  • 单调队列。见下面的“leetcode.239”解析。
  • 哈希表。将扫描到的元素存入哈希表中,会丢失输入数组中的顺序信息。但是它的优势在于,在后面扫描的时候,能够更快地找到之前遇到的相同元素(聚类)。

上升栈(单调栈)

第一种解法是用上升栈(我一开始没想到,看了答案才知道)。如果current比top大就入栈,否则不断弹栈,找到最后入栈的、比current小的数字,再将current入栈。找到的数字就是左边界的候选。右边界的找法和左边界同理,从右往左扫描。

在不断弹栈然后入栈的过程中,相当于丢弃了在此之前比current大的数字。因此弹栈的过程相当于不断在问:“在top之前,比top恰好小一些的数字是谁”。要根据这个特性,把上升栈用在合适的题目上。

同样能用上升栈解决的问题:leetcode.84 通过上升栈找到左右两侧的较小元素leetcode.85leetcode.739

变量法

第二种解法是用变量法,空间复杂度低至O(1)。
要想到这种解法,关键在于分析题目,将题目拆解为最值的查找

在顺序扫描的过程中,我们能找到若干个【不在正确位置上的的数字】,这其中值最小的数字,就代表答案数组的左边界。同理,逆序扫描可以找到答案数组的右边界。

leetcode.41 以数据值为下标,将数据存入数组

题目连接:https://leetcode.com/problems...

这题的坑点在于,题目要求写了“uses constant extra space”,然后我的思维被局限在了变量法,想了很久以后遂放弃,结果看答案以后,发现答案是要改变输入数组的,并且答案算法在改变输入数组以后无法复原。

严格来说确实没毛病,确实没有使用“额外”空间。这题主要的价值在于这个教训。

打破思维局限以后,解法其实非常简单。本质是以数据值为下标,将数据放在数组中,每次存储的时间开销为O(1)。无需深入讨论。我所看到的最简洁答案

leetcode.448也是需要在输入数组中保存添加额外信息的问题。

前面提到的Morris无栈遍历也是一种“无需额外空间”的算法。不过Morris无栈遍历是可以在遍历完成以后复原数据结构的。

leetcode.287 的其中一种解法,以数据值为下标,将数组看作一个链表,巧妙地把原问题转换成一个链表找环的问题。

leetcode.138 原地增强链表(无额外空间开销)

题目链接:https://leetcode.com/problems...

和上面的 leetcode.41 类似,这一题也通过修改输入数据结构来解决,不过这一题很巧妙,最后可以将数据结构复原。

这一题增强链表的方式是,在每一个链表节点后面都增加一个节点,用来存储增加的字段。这种方式的好处是,无需拷贝已有节点,指向原始节点的指针依然可用,并且可以通过这个指针来访问新增的字段。

这种思路打破了通常的思维局限,它给我的启发是:不必为一个数据结构中的每一项都赋予同样的意义,你可以以一个数据结构为底层,封装出一个更高层次的数据结构。当你push一个新的元素时,你可以一同push很多相关信息,只要你后面取出来的时候,能将所有相关的信息一起取出来即可。
比如对于一个存储坐标的stack,内部数据结构可以定义为stack<int>,每次push一个新坐标(x,y)的时候分别push x 和 y到内部stack;每次pop的坐标时候就从内部stack pop两次,得到x和y。

leetcode.155也有巧用这种思路的解法

leetcode.234 反转链表及其应用

题目链接:https://leetcode.com/problems...

这题考察以下关键点:

  • 检查回文,等价于从链表中间出发,一个指针往左走,一个指针往右走,对比2个指针的值
  • 通过快指针和慢指针来找到链表中点(快指针每次走2步)
  • 使用O(n)时间以及O(1)内存来反转链表,使得后面我们的指针能够“走回来”
class Solution
{
public:
  bool isPalindrome(ListNode *head)
  {
    ListNode *fast = head, *slow = head, *prev = NULL, *tmp;
    bool odd = false;
    while (fast)
    {
      // 快指针每次前进2步
      fast = fast->next;
      if (!fast)
      {
        // 链表有奇数个node
        odd = true;
        break;
      }
      fast = fast->next;

      // 慢指针每次前进1步,且在前进过程中反转链表
      tmp = slow->next;
      slow->next = prev;
      prev = slow;
      slow = tmp;
    }

    ListNode *p1, *p2;
    p1 = prev;
    if (odd)
      p2 = slow->next;
    else
      p2 = slow;

    prev = slow;

    while (p1 && p2)
    {
      // p1和p2从中间出发,p1往左走,p2往右走
      if (p1->val != p2->val)
        return false;
      p2 = p2->next;

      // p1在往回走的过程中,将链表恢复原状
      tmp = p1->next;
      p1->next = prev;
      prev = p1;
      p1 = tmp;
    }

    return true;
  }
};

leetcode.152 动态规划找数组

题目链接:https://leetcode.com/problems...

给一个数组,求最值的问题,很多时候是有O(n)解法的。因此遇到这种问题优先考虑前面所总结的数组扫描技巧。这一题介绍上一节没有详述的数组扫描技巧:动态规划。动态规划(DP)是典型的“在扫描输入的构成中构造数据结构,使得我们能更快地完成后续扫描的计算”。

我过去与动态规划相关的文章

要启发自己得到动态规划的解法,需要对问题进行逆向思考:从最终结果出发,推演结果的形成过程(它是如何从【子结果】加工得到的)。

对于这一题,最终结果是【乘积最大的数组】。虽然我们不知道具体是如何,但是这个答案暗示着子结构:
如果已经知道【右边界下标为k的数组】的乘积最大值max_p和最小值min_p,我们就能够计算出【右边界下标为k+1的数组】的乘积最大值和最小值。

很多“找数组”的问题都具有这种子结构,以数组右边界来定义子问题。比如更简单的动态规划问题:https://leetcode.com/problems...
if (nums[i] >= 0)
{
  max_p = max(max_p * nums[i], nums[i]);
  min_p = min(min_p * nums[i], nums[i]);
}
else
{
  int temp = max_p;
  max_p = max(min_p * nums[i], nums[i]);
  min_p = min(temp * nums[i], nums[i]); // 用旧的max_p
}

那么将k从0迭代到n-1,我们在此过程中计算出的max_p,最大的就是最终答案。

leetcode.221 动态规划优化空间开销

题目链接:https://leetcode.com/problems...

容易出bug的点:

  • 将二维的dp数组优化为一维后,当你读取dp[i-1]的时候,读取的是本轮计算的结果,而不是上一轮计算的dp结果。

    本来想要读取2d[i-1][j-1],实际读取的是2d[i][j-1]
  • 做2层循环来遍历矩形的时候,哪一层循环在外面,哪一层循环在里面,会决定遍历的路线(横向扫描还是纵向扫描),遍历路线选错可能会造成答案出错。
同类题目:leetcode.62

leetcode.33 & 34 && 300 双指针边界情况练习

题目链接:

这三题通过迭代的方式来实现二分查找,能够同时考察双指针的使用和边界情况的考虑。

分治算法经常需要“将一个区间切分为2个子区间”,这种时候需要额外注意边界情况,避免死循环、数组越界的发生。假设初始区间的左边界为left,右边界为right,且left < right,目标切分点命名为mid。以下讨论同时适用于右边界为开区间和闭区间的场景。

  • 如果mid的取值可能是left,则mid不能作为右区间的左边界。否则会出现死循环。

    • mid只能放到左区间,或单独处理。即[left, mid] [mid+1, right)[l, mid-1] [mid] [mid+1, r)
    • 对比array[left]array[mid]的时候要考虑left==mid这种边界情况
    • 如果mid的计算方式为(left+right)/2,则属于这种情况
  • 如果mid的取值可能是right,则mid不能作为左区间的右边界。否则会出现死循环。

    • mid只能放到右区间,或单独处理。即[left, mid-1] [mid, right)[l, mid-1] [mid] [mid+1, r)
    • 对比array[right]array[mid]的时候要考虑right==mid这种边界情况
  • 如果mid既有可能是left又有可能是right,则采用最稳妥的划分方式:[l, mid-1] [mid] [mid+1, r)
  • 如果你之前没有处理掉left==right的情况,则对比array[left] array[right] array[mid]三者的时候都要小心,它们可能是同一个元素。
  • 不要访问array[mid+1],有可能出现数组越界。
  • 绝大部分情况下可以访问array[mid](除非mid可能为right、且right为开区间)。
  • 小技巧,mid=(left+right)/2会偏向甚至等于left,而mid=(left+right+1)/2会偏向甚至等于right。

建议提前处理掉left==rightleft+1==right的情况,减轻心智负担。

第二题的解法尤其能体现双指针的操控技巧:

class Solution
{
public:
  vector<int> searchRange(vector<int> &nums, int target)
  {
    if (nums.size() == 0)
      return vector<int>{-1, -1};

    int l = 0, r = nums.size() - 1;

    while (l < r)
    {
      int m = (l + r) / 2;

      if (nums[m] < target)
        l = m + 1;
      else
        r = m;
    }

    if (nums[l] != target)
      return vector<int>{-1, -1};

    int res_l = l;
    r = nums.size() - 1; // 不需要重置l
    while (l < r)
    {
      // 提前排除只有2个元素的数组,使得m必定在l和r之间
      // if (l + 1 == r)
      // {
      //   if (nums[r] == target)
      //     l = r;
      //   else
      //     r = l;
      //   break;
      // }
      // 除了提前排除数组以外,还可以m = (l + r + 1) / 2使m偏向右边

      int m = (l + r + 1) / 2;

      if (nums[m] <= target)
        l = m;
      else
        r = m - 1;
    }
    return vector<int>{res_l, l};
  }
};

leetcode.300使用二分查找来从有序数组中找到首个大于等于k的元素

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        int size = nums.size();
        vector<int> dp(size+1, INT_MAX);
        dp[0] = INT_MIN;
        int max_len = 0;
        for (int& num: nums) {
            int l = 0, r = size;
            while (l < r) {
                int mid = (l+r)>>1;
                int pivot = dp[mid];
                if (pivot < num) l = mid+1;
                else r = mid;
            }
            dp[r] = num;
            max_len = max(max_len, r);
        }
        return max_len;
    }
};
leetcode有人总结了能用双指针解决的问题的特征。

leetcode.134的双指针解法(https://leetcode.com/problems...

leetcode.11 双指针查找最大面积

题目链接:https://leetcode.com/problems...

题目要求找出面积最大的区间。面积是一种收到2个变量影响的属性:宽度和高度。我们要查找一个目标,它使得二元函数(size=w*h)达到最大值。这种时候我们有以下枚举候选人的方式:

  1. 先使得其中一个变量(w)达到最大,计算此时能达到的最大面积。它是最终结果的候选者。
  2. 枚举思路:接下来我们尝试找到面积可能更大的区间(即下一个候选者)。假设上一个枚举的区间中,左边界left是高度瓶颈h,那么后面搜索的空间就不包含这个左边界。因为以left为左边界的容器,高度必定不超过h,且宽度更小,面积不可能比现在更大。
  3. 现在,问题的搜索空间更小了(数组已经剔除了左边界),在这个搜索空间完成同样的问题,找出下一个候选者。
  4. 按照以上枚举思路,我们在此过程中一定能枚举出面积最大的区间。

实现代码:

class Solution {
public:
    int maxArea(vector<int>& height) {
        // 宽度最大的时候
        int left = 0, right = height.size()-1;
        int max_size = 0;
        // 枚举【所有】【可能】比当前容器大的情况
        // 在宽度不断缩小的过程中,新的容器的h必须比之前大,才有可能面积更大
        while (left < right) {
            // 计算当前面积
            int h = min(height[left], height[right]);
            int size = h * (right-left);
            max_size = max(max_size, size);
            // 如果height[left] <= h,那么以left为左边界的容器,高度也不超过h,且宽度比现在还小,面积不可能比现在更大
            while(left < right && height[left] <= h) left++;
            while(left < right && height[right] <= h) right--;
        }
        return max_size;
    }
};

双指针在这题目里面就是一种最优的枚举方式,它能高效、持续地修剪搜索空间。

leetcode.142 列方程找指针关系

题目链接:https://leetcode.com/problems...

这道题有点硬核,需要列方程找出指针之间的关系。

解决这题的前置条件是,你知道如何判断链表中是否包含环(使用快指针和慢指针)。这个问题对应于leetcode.141

在此基础上,快指针和慢指针相遇以后,你所拥有的信息是不足以求出答案的(你最多只能知道当前走了多少步、环的周长是多少,你无法知道相遇点在环中的位置)。
你需要从这个相遇点继续推进慢指针,同时从head再启动一个慢指针。通过列方程可以知道,这两个指针必定会在环起始处相遇。

class Solution
{
public:
  ListNode *detectCycle(ListNode *head)
  {
    if (!head || !head->next)
      return NULL;

    ListNode *slow = head->next, *fast = head->next->next;

    int step = 1;

    while (true)
    {
      if (!fast || !fast->next)
        return NULL;

      slow = slow->next;
      fast = fast->next->next;
      ++step;

      if (fast == slow)
        break;
    }

    // c'为环周长,k为相遇位置,x为正整数,step为相遇的步数,s为head与环之间的距离。
    // 2*step = s+xc'+k   (快指针所走的距离列一个方程)
    // step = s+k         (慢指针所走的距离列一个方程)
    //==>
    // step = xc
    // =>
    // (方程左右两边同时加s)
    // step+s = xc'+s
    // 这个方程意味着,如果慢指针再走s步,那么它走的步数就等价于xc'+s,
    // 此时它的位置必定在环入口

    // 那么我们在继续走s步之前,从入口处发出一个慢指针p3,
    // 那么p3必定会与慢指针在环入口处相遇

    ListNode *cur = slow, *res = head;
    while (cur != res)
    {
      cur = cur->next;
      res = res->next;
    }
    return res;
  }
};

这题和一道小学奥数题:烧香计时 有异曲同工之妙。需要在某些关键节点重新发起一个指针,让这个指针在可预期的位置与另一个指针相遇。

leetcode.60也是类似烧香计时的问题。相对简单一些。

leetcode.139 构造图来处理输入

题目链接:https://leetcode.com/problems...

这题最高效的算法是,将字典构造成一棵前缀树,然后在扫描输入字符串的时候,根据扫描到的字符,在树中游走。

500px-Trie_example.svg.png

前缀树的特点是它能高效地存储一个字典,未来可以快速查询。在字典中搜索的过程等价为在前缀树中游走的过程。

我的最终代码:

#include <vector>
#include <map>
#include <iostream>
#include <list>

using namespace std;

struct Node
{
  bool done;
  map<char, Node *> children;
  Node() : done(false) {}
};

class Solution
{
public:
  bool wordBreak(string s, vector<string> &wordDict)
  {
    Node *root = new Node();
    for (auto word : wordDict)
    {
      insert_tree(root, word);
    }

    list<Node *> pointers;
    pointers.push_back(root);

    bool has_complete = false;
    for (char c : s)
    {
      if (pointers.empty())
        return false;

      has_complete = false;
      auto old_begin = pointers.begin();
      for (auto it = pointers.begin(); it != pointers.end(); ++it)
      {
        if ((*it)->children.count(c) == 0)
        {
          // will be erased
          continue;
        }
        else
        {
          auto next_p = (*it)->children[c];
          // move p to next
          pointers.push_front(next_p);
          if (next_p->done)
            has_complete = true; // should start a new p
        }
      }

      if (has_complete)
        pointers.push_front(root);
      // erase old pointers
      pointers.erase(old_begin, pointers.end());
    }
    return has_complete;
  }

private:
  void insert_tree(Node *&root, string &word)
  {
    Node *cur = root;
    for (char c : word)
    {
      map<char, Node *> &m = cur->children;
      if (m.count(c) == 0)
        m[c] = new Node();
      cur = m[c];
    }
    cur->done = true;
  }
};

leetcode.438 滑动窗口找区间

题目链接:https://leetcode.com/problems...

这题虽然只要求返回起始下标,但本质上还是在找区间。

对于找区间的问题,并且对于目标区间内的元素顺序没有要求,可以使用滑动窗口。滑动窗口也是一种在扫描输入过程中收集信息的方法。它适合用来收集统计性的信息,与内部元素顺序无关的信息。

每次扫描,只需要删掉窗口左边的元素、增加窗口右边的元素,然后根据这两个元素的变化来更新统计信息。

class Solution
{
public:
  vector<int> findAnagrams(string s, string p)
  {
    vector<int> ret;
    int s_s = s.size(), p_s = p.size();

    if (s_s < p_s)
      return ret;

    map<char, int> need;
    for (int i = 0; i < p_s; ++i)
    {
      if (need.count(p[i]) == 0)
        need[p[i]] = 1;
      else
        need[p[i]]++;
    }

    for (int i = 0; i < p_s; ++i)
    {
      if (need.count(s[i]) > 0)
        need[s[i]]--;
    }

    if (!has_need(need))
      ret.push_back(0);

    for (int i = p_s; i < s_s; ++i)
    {
      char left = s[i - p_s], right = s[i];
      if (need.count(left) > 0)
        need[left]++;
      if (need.count(right) > 0)
        need[right]--;

      // 当前区间符合要求的充要条件:
      // need[x] == 0
      if (!has_need(need))
        ret.push_back(i - p_s + 1);
    }

    return ret;
  }

  bool has_need(map<char, int> &need)
  {
    for (auto p : need)
    {
      if (p.second != 0)
        return true;
    }
    return false;
  }
};

leetcode.239 单调队列(滑动窗口内的最大值)

题目链接:https://leetcode.com/problems...

单调队列与单调栈有异曲同工之妙。

  • 上升栈回答的问题是:“在指定元素a的前面(即在a之前加入),恰好比a小一些的元素是谁”。
  • 下降队列回答的问题是:“在指定元素a的后面(即在a之后加入),恰好比a小一些的元素是谁”。

1_xFvh11sgRi0hf662wAAhkQ.jpeg

拿上升队列陈述(下降队列同理),它支持以下操作,都是 O(1) 时间开销:

  • push 加入元素
  • pop 弹出最早加入的元素
  • getMax 获取队列内最大的元素

对于滑动窗口问题,恰好符合"弹出最早加入的元素",因此滑动窗口取最值问题使用单调队列来解决。

class Solution
{
public:
  vector<int> maxSlidingWindow(vector<int> &nums, int k)
  {
    vector<int> res;
    list<int> ls;

    for (int i = 0; i < nums.size(); ++i)
    {
      while (!ls.empty() && ls.back() < nums[i])
      {
        ls.pop_back();
      }
      ls.push_back(nums[i]);

      if (i >= k - 1)
      {
        if (i >= k && ls.front() == nums[i - k])
          ls.pop_front();
        res.push_back(ls.front());
      }
    }
    return res;
  }
};

虽然有嵌套循环,但是内部的while循环实际上总共最多只能执行n轮(顶多每个元素被pop一次)。因此算法时间复杂度为O(n)。

leetcode.295 利用堆来找中位数

题目链接:https://leetcode.com/problems...

既然只需要找出中位数,那么排序肯定不是最快的方案,因为它会做很多额外的工作。
这种只要“找出排序后第k位”的问题都要考虑是否能用堆来解决。堆的特点是始终能在最短时间内找出最大的数字,而不做任何额外的工作。

struct cmp
{
  bool operator()(int a, int b)
  {
    return a > b;
  }
};

class MedianFinder
{
  priority_queue<int> left;
  priority_queue<int, vector<int>, cmp> right;

public:
  /** initialize your data structure here. */
  MedianFinder()
  {
  }

  void addNum(int num)
  {
    if (left.size() <= right.size())
      left.push(num);
    else
      right.push(num);

    if (left.size() > 0 && right.size() > 0 && left.top() > right.top())
    {
      right.push(left.top());
      left.pop();
      left.push(right.top());
      right.pop();
    }
  }

  double findMedian()
  {
    if (left.size() == right.size())
      return (left.top() + right.top()) / (double)2;
    return left.top();
  }
};

类似问题:leetcode.215 找到第k大数字

leetcode.416 动态规划解决背包问题

题目链接:https://leetcode.com/problems...

这题表面上是问你能不能划分等和数组,实际上是在问你能不能找到一组元素的和为sum/2。很多与子集元素之和有关的题目本质上都是背包问题。背包问题的通用解法:使用使用数组dp来存储所有可能得到的和,dp[i]表示和为i的子集数量。

因此这题能够解决的前提是,能够确定子集和的范围(即dp数组的长度),并且dp数组长度在可接受范围内。

所以这题实际是0/1背包问题。这道题相对于普通背包问题的特殊之处在于,背包的容量并不是题目给定的。而是要根据输入数组计算sum/2

class Solution
{
public:
  bool canPartition(vector<int> &nums)
  {
    int sum = 0;
    for (auto &num : nums)
    {
      sum += num;
    }
    if (sum % 2 != 0)
      return false;

    sum = sum / 2;
    vector<bool> dp(sum + 1, false);
    dp[0] = true;

    for (auto num : nums)
    {
      for (int i = sum; i >= 0; --i)
      {
        if (i - num >= 0)
          dp[i] = dp[i] || dp[i - num];
      }
    }
    return dp[sum];
  }
};

有意思的是,这个算法还可以使用bitset来实现(思路完全一样,只不过使用了更高效的数据结构):

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        const int MAX_NUM = 100;
        const int MAX_ARRAY_SIZE = 200;
        bitset<MAX_NUM * MAX_ARRAY_SIZE / 2 + 1> bits(1);
        int sum = 0;
        for (auto n : nums) {
            sum += n;
            bits |= bits << n;
        }
        return !(sum % 2) && bits[sum / 2];
    }
};
leetcode.494 也是一个变种的背包问题。一般的背包问题,对于某个元素,可以选择{拿取它,不拿取它};但是这个变种问题的选择是{拿+1倍它,拿-1倍它}。本质上还是一样的。

leetcode.560 前缀和求差得到区间和

题目链接:https://leetcode.com/problems...

区间和的算法:
先遍历一趟输入数组,计算前缀和sum,使得sum[i]为0~i项之和。有了前缀和数组,对于任何区间都能在O(1)时间内求和:sum[i]-sum[j]

题目要求区间和为k的所有区间,等价于在sum数组里面找到两个元素xy,使得y-x==k。这个问题与leetcode.1 Two Sum类似,使用哈希表来解决。

class Solution
{
public:
  int subarraySum(vector<int> &nums, int k)
  {
    int count = 0;
    int size = nums.size();
    // 前缀和 => 有多少个
    unordered_map<int, int> sums;
    sums[0] = 1;

    for (int i = 0, s = 0; i < size; ++i)
    {
      // 0~i项之和
      s += nums[i];
      // 如果前面已经有前缀和为s - k,那么就存在区间和为k(k =(s)-(s-k))
      if (sums.count(s - k))
        count += sums[s - k];
      // 将前缀和记录到哈希表中
      if (sums.count(s))
        sums[s]++;
      else
        sums[s] = 1;
    }
    return count;
  }
};
leetcode.437同样是通过【前缀和之差】来求区间和的问题,同样使用哈希表来查找需要的前缀和。leetcode.437在这题的基础上结合了DFS,难度更高一些。

leetcode.238同样是前缀累积的思路,只不过累积方式是求积而不是求和。

leetcode.309 存在多种状态的动态规划

题目链接:https://leetcode.com/problems...

这题是典型的多种状态动态规划,对于每一天都存在多种状态:已买入、已卖出。因此需要2个dp数组,其中dp1[i]表示第i天处于已买入状态的最高利润,其中dp2[i]表示第i天处于已卖出状态的最高利润。

这两个状态存在以下推导关系:

// 要么保持昨天的买入状态,要么今天买入
// 注意今天买入的前提是,前天处于卖出状态,并且昨天没有买入
buyed[i] = max(buyed[i-1], selled[i-2]-prices[i]);
// 要么保持昨天的卖出状态,要么今天卖出
selled[i] = max(selled[i-1], buyed[i-1]+prices[i]);

完整代码:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int size = prices.size();
        if (size <= 1) return 0;
        vector<int> selled(size, 0);
        vector<int> buyed(size, 0);
        buyed[0] = -1 * prices[0];
        selled[0] = 0;
        buyed[1] = max(buyed[0], -1 * prices[1]);
        selled[1] = max(0, buyed[0]+prices[1]);

        for (int i = 2; i < size; ++i) {
            // 要么保持昨天的买入状态,要么今天买入
            // 注意今天买入的前提是,前天处于卖出状态,并且昨天没有买入
            buyed[i] = max(buyed[i-1], selled[i-2]-prices[i]);
            // 要么保持昨天的卖出状态,要么今天卖出
            selled[i] = max(selled[i-1], buyed[i-1]+prices[i]);
        }
        return selled[size-1];
    }
};

因为我们只用了dp数组昨天和前天的数据,所以可以将空间开销优化到O(1)级别:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int size = prices.size();
        if (size<=1) return 0;
        int prepreselled = 0;
        int prebuyed = max(-1*prices[0], -1 * prices[1]);
        int preselled = max(0, -1*prices[0]+prices[1]);
        for (int i = 2; i < size; ++i) {
            int tmp1 = max(prebuyed, prepreselled-prices[i]);
            int tmp2 = max(preselled, prebuyed+prices[i]);
            prepreselled = preselled;
            prebuyed = tmp1;
            preselled = tmp2;
        }
        return preselled;
    }
};

如果题目的状态更加复杂一些,可以通过画状态机来理清思路。参考这个题解)。

这题的变种:leetcode.714,也是多状态DP,不过更加简单一些。

leetcode.394 用栈来解析嵌套结构

题目链接:https://leetcode.com/problems...

栈、递归、嵌套结构有着密不可分的联系。看到题目中的3[a2[c]]就应该本能地朝递归或者栈的方向去思考。

class Solution {
public:
    string decodeString(string s) {
        int i = 0;
        string res;
        stack<pair<string, int>> sta;
        while (i < s.size()) {
            if (s[i] >= '0' && s[i] <= '9') {
                int len = 1;
                while(s[i+len] >= '0' && s[i+len] <= '9') {
                    len++;
                }
                // times to repeat
                int times = stoi(s.substr(i, len));
                // save current prefix into stack
                sta.push({res, times});
                res = "";
                i = i+len+1;
            } else if (s[i] == ']') {
                auto p = sta.top();
                sta.pop();
                int times = p.second;
                string tmp(res);
                for (int k = 1; k < times; ++k) {
                    res += tmp;
                }
                res = p.first + res;
                i++;
            } else {
                res += s[i++];
            }
        }
        return res;
    }
};

递归的解法参考leetcode讨论

leetcode.287 用集合与集合相互对比,快速缩小搜索空间

题目链接:https://leetcode.com/problems...

已知数组项是连续的整数、并且只有其中一个整数有重复。我们可以通过一轮扫描,对1~k范围的整数计数,如果数量大于k,说明重复的整数在1~k范围内;否则重复的整数在k~n范围内。这样,我们就能把搜索空间缩小一半。

class Solution {
public:
    int findDuplicate(vector<int>& nums) {
        int size = nums.size();
        int left = 1, right = size-1, mid;
        while (left < right) {
            mid = (left+right)/2;
            int count = 0;
            for (auto num: nums) {
                if (num <= mid) count++;
            }
            if (count > mid) {
                right = mid;
            } else {
                left = mid+1;
            }
        }
        return left;
    }
};

这个问题有点像一道小学奥数题:天平称小球。如果一个一个小球地对比,需要对比的次数会很多。更高效的办法是:每一次测量,都对比2个小球的集合,将答案锁定到一个更小的集合中,就能够很快找到答案。

先将搜索空间划分为2个集合,然后用集合与集合相互对比(而不是个体之间相互对比),将搜索空间减半。

leetcode.215的一种解法思路与之类似。

leetcode.49 通过计数排序将字符串降维

题目链接:https://leetcode.com/problems...

这道题对于每个字符串的顺序并不关心,只关心它们的字符组成。我们需要将相同字符组成(但不同字符顺序)的字符串映射到同一个地方。这个时候就需要数据降维,丢弃字符串的顺序信息,仅仅保留字符组成信息。

计数排序就是一种字符串降维手段,abcba降维成{a:2,b:2,c:1},它的字符串表示为a2b2c1。因此,abcba就能与bbaac映射到同一个降维字符串。

用降维后的字符串作为hash key,就能把相同字符组成的字符串group到一起。

class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        unordered_map<string, vector<string>> hashmap;
        for (auto& str: strs) {
            hashmap[count_hash(str)].push_back(str);
        }
        
        vector<vector<string>> res;
        for (auto& p: hashmap) {
            res.push_back(p.second);
        }
        return res;
    }
    
    string count_hash(string& str) {
        vector<int> vec(26, 0);
        for(auto& c: str) {
            vec[c-'a']++;
        }
        string res;
        for (int i = 0; i < 26; ++i) {
            if (vec[i]==0) continue;
            res += (i+'a');
            res += to_string(vec[i]);
        }
        return res;
    }
};

元素的范围是确定的时候(比如都是小写字母),计数排序非常高效。

leetcode.169 投票算法找出众数

题目链接:https://leetcode.com/problems...

投票算法将所有元素划分为两个阵营:众数阵营、杂数阵营,然后让这两个阵营对拼消耗,最终剩下的必定是众数阵营的元素。在已经确定输入中存在众数的前提下,它能够用O(n)时间、O(1)空间找出众数,是最高效的算法。

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int count = 1, maj = nums[0];
        for (int i = 1; i < nums.size(); ++i) {
            if (maj == nums[i]) {
                count++;
            } else if (--count < 0) { maj = nums[i]; count = 1; }
        }
        return maj;
    }
};

这题另外一个巧妙的思路,留意到这一点:众数必定是中位数,问题变成了找中位数,对应leetcode.215,可以使用堆也可以使用分治法,时间复杂度为O(nlogn)。

另外一个巧妙的思路:将每个数字看作32个bit位,则众数具有的bit位必定出现超过size/2,众数不具有的bit位必定出现少于size/2。对于每个比特位,我们扫描一次数组,就能知道众数是否具有这个比特位。

class Solution {
public:
    int majorityElement(vector<int>& nums) {
        int size = nums.size(), half_size = size/2, maj = 0;
        for (unsigned int i = 0, bit = 1; i < 32; ++i, bit<<=1) {
            int count = 0;
            for (int num : nums) {
                if (num & bit) {
                    count++;
                }
            }
            if (count > half_size) maj |= bit;
        }
        return maj;
    }
};

leetcode.347 找出前k大的数字(桶排序或堆排序)

题目链接:https://leetcode.com/problems...

第一趟扫描,通过维护一个哈希表,能够知道每个元素的出现频次。
重点在于如何按频次拿出k个数据:

  • 通用方法:堆排序(优先级队列)。这里的一个技巧是,不要因为要找最大的频次就用最大堆,因为这样的话堆的大小会达到nums.size。用最小堆,可以始终保持堆的大小不超过k:
class cmp {
    public:
        bool operator() (pair<int, int>& a, pair<int, int>& b) {
            return a.second > b.second;
        }
};

class Solution {
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> num_count;
        for (auto& num: nums) {
            num_count[num]++;
        }
        priority_queue<pair<int, int>, vector<pair<int, int>>, cmp> que;
        for (auto& p: num_count) {
            que.push(p);
            if (que.size() > k) {
                que.pop();
            }
        }
        vector<int> res;
        for (int i = 0; i < k; ++i) {
            auto p = que.top();
            que.pop();
            res.push_back(p.first);
        }
        return res;
    }
};
  • 特殊方法:桶排序。时间效率高,但是这种方法只有在预先知道排序字段的范围的时候才能使用。在这个问题中,排序的字段是频次,频次取值范围 <= nums.size
class Solution {
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> num_count;
        for (auto& num: nums) {
            num_count[num]++;
        }
        vector<vector<int>> bucket(nums.size()+1, vector<int>());
        for (auto& p: num_count) {
            bucket[p.second].push_back(p.first);
        }
        vector<int> res;
        for (int i = nums.size(); i > 0; --i) {
            if (bucket[i].empty()) continue;
            for (auto num: bucket[i]) {
                res.push_back(num);
            }
            if (res.size() >= k) break;
        }
        return res;
    }
};

leetcode.647 回文字符串检测

题目链接:https://leetcode.com/problems...

为了枚举所有回文字符串,我们先枚举回文的中心,从中心开始向2边扩展边界,当两边不相等时停止。

如果通过枚举回文的左右边界,那么耗时会高一些。因为枚举中心的方式始终从一个回文扩展出更长的回文,相当于dp。
class Solution {
public:
    int countSubstrings(string s) {
        int size = s.size();
        int count = 0;
        for (int i = 0; i < size; ++i) {
            int left = i, right = i;
            while (left >= 0 && right < size && s[left]==s[right]) {
                count++;
                left--;
                right++;
            }
            left = i, right = i+1;
            while (left >= 0 && right < size && s[left]==s[right]) {
                count++;
                left--;
                right++;
            }
        }
        return count;
    }
};
类似问题:leetcode.131。它基于这个问题,先处理一遍输入s,提前检查回文,构造出一个查询tabletable[i][j]表示i~j范围内是否是回文。在后续计算的过程中,枚举出一个子串的时候,只需要查询这个table来判断回文。

leetcode.22 动态规划生成嵌套括号

题目链接:https://leetcode.com/problems...

这题用递归+回溯的话相对比较简单直接。这里我们讨论另一种方案:动态规划。
括号字符串是一种嵌套结构,其中中隐藏者子问题结构:
一个包含n个括号的字符串,它可以表示为:
'(' + 包含x个括号的字符串 + ')' + 包含n-1-x个括号的字符串
x的取值范围为[0,n-1]

class Solution {
public:
    vector<string> generateParenthesis(int n) {
        vector<vector<string>> dp(n+1, vector<string>());
        dp[0].push_back("");
        for (int i = 1; i <=n; ++i) {
            for (int j = 0; j < i; ++j) {
                vector<string> &left = dp[j], &right = dp[i-1-j];
                for (auto& l: left) {
                    for (auto& r: right) {
                        dp[i].push_back('('+l+')'+r);
                    }
                }
            }
        }
        return dp[n];
    }
};
类似问题:leetcode.46。同样有递归回溯、动态规划的解法。

leetcode.136 异或运算找落单数字

题目链接:https://leetcode.com/problems...

这题的难点在于想到从比特位的角度来看待int类型。如果一个比特位经过偶数次反转(异或运算),那么它的结果是原始比特;如果经过了奇数次反转(异或运算),那么它的结果是原始比特的反转。

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        unsigned int bit = 0;
        for (auto& num: nums) {
            bit ^= num;
        }
        return (int) bit;
    }
};
与比特位相关的问题:leetcode.338

leetcode.210 dfs拓扑排序

题目链接:https://leetcode.com/problems...

bfs的解法比较容易想到,但是dfs的解法相对来说没那么直接。dfs拓扑排序的含义是:你删掉一个节点时,如果发现某个相邻节点的入度变为0,那么下一个应该访问这个节点。

对于拓扑排序的问题使用dfs时,不应该进入环,而是仅仅访问入度为0的节点。

迭代解法:

class Solution {
public:
    vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
        vector<vector<int>> edges(numCourses, vector<int>());
        vector<int> in_d(numCourses, 0);
        
        for (auto& p: prerequisites) {
            edges[p[1]].push_back(p[0]);
            in_d[p[0]]++;
        }
        
        stack<int> to_check;
        
        for (int i = numCourses-1; i >= 0; --i) {
            if (in_d[i] == 0) to_check.push(i);
        }
        
        vector<int> res;
        
        while (!to_check.empty()) {
            int top = to_check.top(); to_check.pop();
            res.push_back(top);
            for (int& next: edges[top]) {
                if (--in_d[next] == 0) {
                    to_check.push(next);
                }
            }
        }
        if (res.size() != numCourses) return {};
        return res;
    }
};

递归dfs:

class Solution {
public:
    vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
        vector<vector<int>> edges(numCourses, vector<int>());
        vector<int> in_d(numCourses, 0);
        for (auto& p: prerequisites) {
            edges[p[1]].push_back(p[0]);
            in_d[p[0]]++;
        }
        
        vector<int> to_check;
        for (int i = 0; i < numCourses; ++i) {
            if (in_d[i] == 0) to_check.push_back(i);
        }
        
        vector<int> res;
        for (int& checking: to_check) dfs(edges, in_d, checking, res);
        if (res.size() != numCourses) return {};
        return res;
    }
    
    void dfs(vector<vector<int>> &edges, vector<int> &in_d, const int &cur, vector<int> &res) {
        res.push_back(cur);
        for (int& connected: edges[cur]) {
            if (--in_d[connected] == 0)
                dfs(edges, in_d, connected, res);
        }
    }
};

leetcode.378 有序矩阵中的第k位

题目链接:https://leetcode.com/problems...

这里的有序矩阵是指每一行、每一列都已经有序的矩阵。

我们可以用O(m+n)来定位某个值在矩阵中的位置,见题目leetcode.240

寻找第k位的问题一般有以下思路:

  1. 快排变种,每次partition,可以确定一个元素e在排序后数组中的位置,如果这个位置恰好为k,那么e就是问题的答案。否则,我们对左边或右边的partition继续执行partition。见leetcode.215
  2. 堆排序,依次取出矩阵中的最小元素,第k个被取出的元素就是题目的答案。
  3. 桶排序,对于已知元素取值范围的问题可以使用。用空间换时间。见前面的leetcode.347解析。
  4. 二分查找(迭代法)。这个方法比较难想到,并且使用条件比较苛刻。仅仅当问题性质允许我们不断优化猜测,快速缩小搜索空间的时候可以使用。流程是做出猜测->优化猜测->优化猜测……。下面会详述。

这题的搜索空间是一个矩阵,并且元素取值范围是整个int,所以优先考虑第二种办法:堆排序。
最简单粗暴的办法:直接把矩阵的所有元素加入一个最小堆中,不断取出最小元素,取出的第k个即为答案。

但是这一点也没有利用到“有序”矩阵的特性。观察到这一点:一个元素是最小元素的必要条件是,它左边和上面的元素都已经被取出。我们可以利用必要条件来优化查找空间,仅仅当一个元素达成最小元素的必要条件时,才将它加入堆中。

class cmp {
    public:
    vector<vector<int>>& matrix;
    cmp(vector<vector<int>>& matrix0): matrix(matrix0) {}
    bool operator() (pair<int, int>& p1, pair<int, int>& p2) {
        return matrix[p1.first][p1.second] > matrix[p2.first][p2.second];
    }
};

class Solution {
public:
    int kthSmallest(vector<vector<int>>& matrix, int k) {
        int n = matrix.size();
        vector<vector<int>> has_smaller(n, vector<int>(n, 2));
        for (int i = 1; i < n; ++i) {
            has_smaller[i][0] = 1;
            has_smaller[0][i] = 1;
        }
        has_smaller[0][0] = 0;
        priority_queue<pair<int, int>, vector<pair<int,int>>, cmp> heap(matrix);
        heap.push({0,0});
        int count = k;
        while (!heap.empty()) {
            auto top = heap.top(); heap.pop();
            if (--count == 0) {
                return matrix[top.first][top.second];
            }
            if (top.first+1 < n) {
                if (--has_smaller[top.first+1][top.second] == 0)
                    heap.push({top.first+1, top.second});
            }
            if (top.second+1 < n) {
                if (--has_smaller[top.first][top.second+1] == 0)
                    heap.push({top.first, top.second+1});
            }
        }
        return -1;
    }
};

二分查找

这题还有另一个更加难想到的办法:二分查找。它的使用条件比较苛刻。仅仅当问题性质允许我们不断优化猜测,快速缩小搜索空间的时候可以使用。
对于有序矩阵,它的最小和最大数字分别是左上角和右下角。问题就转化成在[min, max]中找到一个数字,使得它恰好比矩阵中的k个元素大。
这个二分查找的过程很像是数学中的迭代法:每次迭代,我们猜测一个数字mid,计算矩阵中有多少个元素小于等于mid(得益于有序矩阵的性质,这个步骤可以在O(n)内完成),然后优化我们的猜测,进入下一轮迭代。当无法继续优化猜测的时候,我们就得到答案了(因为我们知道在[min, max]范围内肯定有一个数字满足)。

class Solution {
public:
    int kthSmallest(vector<vector<int>>& matrix, int k) {
        int n = matrix.size();
        int l = matrix[0][0], r = matrix[n-1][n-1];
        while (l!=r) {
            int mid = (l+r)/2;
            // 计算矩阵中有多少个元素小于等于mid
            int y = 0, x = n-1, cnt = 0;
            // 时间复杂度为O(n),因为(y, x)坐标只会往左、往下移动
            while (y < n) {
                while (x >= 0 && matrix[y][x] > mid) x--;
                cnt += x+1;
                y++;
            }
            if (cnt < k) {
                l = mid+1;
            } else {
                r = mid;
            }
        }
        return l;
    }
};

持续更新中……


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.