记录一些有意思的题。

# 经典算法

# 二分搜索

# 34. 在排序数组中查找元素的第一个和最后一个位置 (opens new window)

二分搜索的加强版

# 4. 寻找两个正序数组的中位数 (opens new window)

算是非常经典又非常复杂的一道题了。我的算法和官方题解稍有不同,可以康康(我的题解 (opens new window)):

class Solution {
public:
    //尝试在 nums1 中找中位数,seq 从 0 开始
    int findNum(const vector<int>& nums1, const vector<int>& nums2, int seq)
    {
        int left = 0, right = nums1.size() - 1;
        while (left <= right)
        {
            // assert left <= ans <= right
            int mid = (left + right) / 2;
            int mid_2 = seq - mid; // 如果 mid 是中位数,mid_2 应当大于等于 mid,mid_2-1 应当小于等于 mid
            if (mid_2 < 0 || (mid_2 < (int)nums2.size() && nums2[mid_2] < nums1[mid]))
            {
                right = mid - 1;
            }
            else if (mid_2 - 1 >= (int)nums2.size() || (mid_2 - 1 >= 0 && nums2[mid_2 - 1] > nums1[mid]))
            {
                left = mid + 1;
            }
            else return nums1[mid];
        }

        return findNum(nums2, nums1, seq); // 反向在 2 数组中找
    }
    double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
        int n1 = nums1.size(), n2 = nums2.size();
        if ((n1 + n2) % 2 == 1)
            return findNum(nums1, nums2, (n1 + n2) / 2);
        else
            return (findNum(nums1, nums2, (n1 + n2) / 2) +
                findNum(nums1, nums2, (n1 + n2) / 2 - 1)) / 2.0;
    }
};

# 线性数据结构

# 时间线、区间调度问题

这类问题一般是给定很多时间区间(区间的起始时间、终止时间),讨论一定的问题。

这类问题一般可以用贪心/动态规划解,贪心之前需要进行排序。

# 1024. 视频拼接 (opens new window)

求覆盖完整个范围所需子区间的最小值。

结论:按结束时间降序排序,然后从前往后遍历(遍历的时候需要同时考虑其结束时间是否超过了当前开始时间、其开始时间是否比下一次的开始时间更远)。

# 56. 合并区间 (opens new window)

结论:按开始时间升序排序,能够合并的区间一定连续。


还有一种巧妙的做法,注意到线段重合等价于 a[1] >= b[0] || b[1] >= a[0],于是我们重载 set 的比较类:

struct Cmp {
    bool operator() (const vector<int>& a, const vector<int>& b) const
    {
        return a[1] < b[0];
    }
};

用这个比较类定义的 set 中,如果两个线段重合,这两个线段在 set 中是被认为相等的!

struct Cmp {
    bool operator() (const vector<int>& a, const vector<int>& b) const
    {
        return a[1] < b[0];
    }
};

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        set<vector<int>, Cmp> s;
        for (auto interval : intervals)
        {
            auto iter = s.find(interval);
            while (iter != s.end())
            {
                interval[0] = min(interval[0], (*iter)[0]);
                interval[1] = max(interval[1], (*iter)[1]);
                s.erase(iter);
                iter = s.find(interval);
            }
            s.insert(interval);
        }
        return vector<vector<int>>(s.begin(), s.end());
    }
};

# 并查集

#

# 树的遍历(非递归)

# 先序遍历

// 先序遍历 144
vector<int> preorderTraversal(TreeNode* root) {
    vector<int> ans;
    stack<TreeNode*> s;
    s.push(root);
    while(!s.empty())
    {
        TreeNode * cur = s.top();
        s.pop();
        if (cur == nullptr)
            continue;
        ans.push_back(cur->val);
        s.push(cur->right);  // 先将右节点入栈,再将左节点入栈,妙啊
        s.push(cur->left);
    }
    return ans;
}

# 中序遍历

// 中序遍历 94
vector<int> inorderTraversal(TreeNode* root) {
    vector<int> ans;
    stack<TreeNode*> s; //牢记“左链入栈”:每遍历到一个点 p,就 push(p); p = p->left;
    TreeNode* p = root;
    while(p != nullptr)
    {
        s.push(p);
        p = p->left;
    }
    while(!s.empty())
    {
        p = s.top();
        s.pop();
        ans.push_back(p->val);
        p = p->right;
        while(p != nullptr) // 继续“左链入栈”
        {
            s.push(p);
            p = p->left;
        }
    }
    return ans;
}

# 后序遍历

// 后序遍历 145
// (调换左右结点遍历顺序后)进行前序遍历,得到的结果反向输出即可
vector<int> postorderTraversal(TreeNode* root) {
    list<int> ans_reverse;
    vector<int> ans;
    stack<TreeNode*> s;
    s.push(root);
    while(!s.empty())
    {
        TreeNode * p = s.top();
        s.pop();
        if (p == nullptr)
            continue;
        ans_reverse.push_front(p->val);  // 从头插入结点 p
        s.push(p->left);                 // 由于是反向输出,所以左右节点入栈的顺序也与先序遍历相反
        s.push(p->right);
    }
    for (int i: ans_reverse)
        ans.push_back(i);
    return ans;
}

# 统一写法

还有一种写法,统一了三种遍历,只需改变入栈的顺序。

链接:https://leetcode-cn.com/problems/binary-tree-postorder-traversal/comments/495211

# 动态规划

动态规划的题,emmm 做多了就明白套路了,做少了的话,还是多看答案,然后自己写吧。注意勤复习。

# 877. 石子游戏 (opens new window)

两人博弈取石子,dp[i][j] 指取完 ij(闭区间)的石子后,先手比后手高的分数。dp 转移方程中由于有先后手的交换,会有 dp[][] = piles[] - dp[][] 的出现。

# 1000. 合并石头的最低成本 (opens new window)

NOI 1995 石子合并,非常经典的区间 dp。dp[i][j] 表示将 ij(闭区间)的石子合并为一堆所需的最小总代价,而 for 中还需要一维表示在哪里合并。

# 312. 戳气球 (opens new window)

类似于合并石子的区间 dp,但注意由于这道题的 dp 转移方程中包含两端的气球值,所以 dp 转移方程有些不同,但同样是需要三个 for 的。

int maxCoins(vector<int>& nums) {
    const int n = nums.size();
    nums.insert(nums.begin(), 1);
    nums.push_back(1);
    vector<vector<int>> dp(n + 2, vector<int>(n + 2, 0));   //dp[i, j] 表示戳爆 [i, j] 间的所有气球的最高总得分
    for (int len = 0; len < n; len++)
        for (int start = 1, end = start + len; end <= n; start++, end++)
            for (int i = start; i <= end; i++)              // 此次戳爆第 i 个气球
                dp[start][end] = max(
                    dp[start][end],
                    dp[start][i - 1] + dp[i + 1][end] + nums[start - 1] * nums[i] * nums[end + 1]
                );
    return dp[1][n];
}

# 41. 接雨水 (opens new window)

一道印象很深的题,因为题面非常直观。

# 84. 柱状图中最大的矩形 (opens new window)

题面很像接雨水。

另外 85. 最大矩形 (opens new window) 也用到了这个题的算法。

# 数学题

# 线性筛

204. 计数质数 (opens new window)

每个合数 t 只会在 j=t最大的质因数, i=t/j 时被筛掉。

class Solution {
public:
    int countPrimes(int n) {
        vector<bool> is_prime(n, 1);
        vector<int> primes;
        for (int i = 2; i < n; i++)
        {
            if (is_prime[i])
                primes.push_back(i);
            for (int j : primes)
            {
                if (i*j >= n)
                    break;
                is_prime[i*j] = 0;
                if (i % j == 0)
                    break;
            }
        }
        return primes.size();
    }
};

不过面试一般不会要求这么高吧,会基础的埃氏筛应该就可以了;而且

# 巧妙的算法

# 41. 缺失的第一个正数 (opens new window)

置换法

实现上需要注意细节,如需要使用 while 反复交换(我第一次使用的是 if);交换的终止条件是 arr[n] != arr[arr[n]-1] 而不是 n != arr[n]-1(后者会被 [1, 1] 这种数组卡掉)。

# 8. 字符串转换整数 (atoi) (opens new window)

这个题虽然可以写一堆 if,但是官方题解 (opens new window)的有限自动机让我眼前一亮。有空一定实现一次。

# 巧妙的 C++ 语法

# std::stoi

C 提供 int atoi(const char *),而对于 string 类型,想要使用 aoti 只能使用 const char * string::c_str() 方法。不过,也可以使用 int std::stoi(const string &)

string str = "-233";
atoi(str.c_str()) == stoi(str); // true

# string::substr

string substr (size_t pos = 0, size_t len = npos) const;

总是要忘记它的定义。string::npos 的值为从 pos 到字符串最后的长度。

# 集合的交集

349. 两个数组的交集 (opens new window)

Python 版本就非常简洁:

def intersection(nums1, nums2):
    return list(set(nums1) & set(nums2))

没想到 C++ 也自带集合求交,见set-集合运算

# 巧妙的数据结构

# 单调栈

简单、好用的数据结构。

数据结构上,递减栈为从栈底往栈顶依次递减的栈(栈里可存元素,也可以存数组下标)。算法上,一个数入栈前,将小于等于它的数全部出栈。

对于某些要计算“某数后面第一个比他大/小的数”的题目,有奇效,如 739. 每日温度 (opens new window)

class Solution {
public:
    vector<int> dailyTemperatures(vector<int>& T) {
        stack<int> s; // 递减栈
        vector<int> ans(T.size());
        for (int i = T.size() - 1; i >= 0; i--)
        {
            while(!s.empty() && T[s.top()] <= T[i])
            {
                s.pop();
            }
            ans[i] = s.empty() ? 0 : s.top() - i;
            s.push(i);
        }
        return ans;
    }
};

# 单调队列

和单调栈相对应的就是单调队列了。

数据结构上,递减队列为从队首往队尾依次递减的双向队列(队列里存数组下标),其本质是类似于单调栈,但是由于题目常常加了一个长度限制,所以也需要让元素从前面出队。

算法上,一个数 i 入队列前,

  1. (如果有,)将超出边界的队首元素出队 pop_front()
  2. 将小于等于 i 的队尾元素全部出队 pop_back()

计算滑动窗口(一个固定大小的区间)的最值有奇效,如 239. 滑动窗口最大值 (opens new window)

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        deque<int> q;
        vector<int> ans;
        for (int i = 0; i < nums.size(); i++)
        {
            if (!q.empty() && q.front() <= i - k)
                q.pop_front(); // 因窗口大小而出队
            while (!q.empty() && nums[q.back()] <= nums[i])
                q.pop_back(); // 因小于当前值而出队
            q.push_back(i);
            if (i >= k - 1)
                ans.push_back(nums[q.front()]);
        }
        return ans;
    }
};

# 双堆:数据流中位数

对于求数据流/滑动窗口的题目,可以维护 大顶堆放小一半的数 + 小顶堆放大一半的数(保证两堆大小相等或大顶堆比小顶堆大 1),两堆的顶就是中位数。

对于滑动窗口,由于有删除的需求,可以使用 multiset 替代堆。

在维护左右大小的时候,如果分情况讨论会非常复杂,不如 push/remove 的时候先保证两堆大小相等或大顶堆比小顶堆大 1,然后再检查是否符合大顶小于小顶,不符合就交换。这样思路更清晰,代价时间复杂度的常数变大了。

// 数据流的中位数,使用双堆实现
class MedianFinder {
public:
    priority_queue<int> big_heap; // 存小的数,保证大小等于 small_heap,或比 small_heap 多一
    priority_queue<int, vector<int>, greater<int>> small_heap; // 存大的数
    
    void swap_top()
    {
        int b = big_heap.top();
        big_heap.pop();
        int s = small_heap.top();
        small_heap.pop();
        big_heap.push(s);
        small_heap.push(b);
    }

    void addNum(int num) {
        // 保证两堆大小相等或大顶堆比小顶堆大 1
        if (big_heap.size() == small_heap.size())
            big_heap.push(num);
        else
            small_heap.push(num);
        assert(big_heap.size() == small_heap.size() || big_heap.size() == small_heap.size() + 1);

        // 检查是否符合大顶小于小顶,不符合就交换。
        if (small_heap.size() > 0 && big_heap.top() > small_heap.top())
            swap_top();
        assert(small_heap.empty() || big_heap.top() <= small_heap.top());
    }
    
    double findMedian() {
        if (big_heap.size() == small_heap.size())
            return ((double)big_heap.top() + (double)small_heap.top()) / 2;
        else
            return big_heap.top();
    }
};
// 滑动窗口中位数,使用双 multiset 实现
class MedianFinder {
public:
    multiset<int, greater<int>> big_heap; // 存小的数,保证大小等于 small_heap,或比 small_heap 多一
    multiset<int> small_heap; // 存大的数
    
    // 检查是否符合大顶小于小顶,不符合就交换。
    void make_balance()
    {
        if (small_heap.size() > 0 && *big_heap.begin() > *small_heap.begin())
        {
            big_heap.insert(big_heap.end(), *small_heap.begin());
            small_heap.insert(small_heap.end(), *big_heap.begin());
            big_heap.erase(big_heap.begin());
            small_heap.erase(small_heap.begin());
        }
        assert(small_heap.empty() || *big_heap.begin() <= *small_heap.begin());
    }

    void insert(int num)
    {
        // 保证两堆大小相等或大顶堆比小顶堆大 1
        if (big_heap.size() == small_heap.size())
            big_heap.insert(num);
        else
            small_heap.insert(num);
        assert(big_heap.size() == small_heap.size() || big_heap.size() == small_heap.size() + 1);

        make_balance();
    }

    void remove(int num)
    {
        // 保证两堆大小相等或大顶堆比小顶堆大 1
        if (num > *big_heap.begin())
        {
            assert(small_heap.find(num) != small_heap.end());
            small_heap.erase(small_heap.find(num));
            if (big_heap.size() - 2 == small_heap.size())
            {
                small_heap.insert(small_heap.end(), *big_heap.begin());
                big_heap.erase(big_heap.begin());
            }
        }
        else
        {
            assert(big_heap.find(num) != big_heap.end());
            big_heap.erase(big_heap.find(num));
            if (big_heap.size() + 1 == small_heap.size())
            {
                big_heap.insert(big_heap.end(), *small_heap.begin());
                small_heap.erase(small_heap.begin());
            }
        }
        assert(big_heap.size() == small_heap.size() || big_heap.size() == small_heap.size() + 1);

        make_balance();
    }
    
    double findMedian() {
        if (big_heap.size() == small_heap.size())
            return ((double)*big_heap.begin() + (double)*small_heap.begin()) / 2;
        else
            return *big_heap.begin();
    }
};

class Solution {
public:
    vector<double> medianSlidingWindow(vector<int>& nums, int k) {
        MedianFinder mf;
        vector<double> ans;
        for (int i = 0; i < k; i++)
            mf.insert(nums[i]);
            ans.push_back(mf.findMedian());
        for (int window = 0; window + k < nums.size(); window++)
        {
            mf.remove(nums[window]);
            mf.insert(nums[window+k]);
            ans.push_back(mf.findMedian());
        }
        return ans;
    }
};