标签 leetcode 下的文章

【每日打卡】[leetcode+剑指offer] 169-多数元素

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/majority-element

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

这道题也是《剑指offer》原题——面试题39数组中出现次数超过一半的数字。

https://leetcode-cn.com/problems/shu-zu-zhong-chu-xian-ci-shu-chao-guo-yi-ban-de-shu-zi-lcof/

一、题目描述

给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于 ⌊ n/2 ⌋ 的元素。

你可以假设数组是非空的,并且给定的数组总是存在多数元素。

示例 1:

  • 输入: [3,2,3]
  • 输出: 3

示例 2:

  • 输入: [2,2,1,1,1,2,2]
  • 输出: 2

二、题解

2.1 哈希表

使用哈希表的思路:用数字作为key,每遇到一次,值加一。遍历完数字后,找出次数最大的。

思路很简单,时间复杂度O(N),空间复杂度O(N),N表示哈希表槽大小。

2.2 排序

因为元素在数组出现一半以上,所以排序后的中位数一定就是目标元素。

使用快速排序时间复杂度O(Nlog(N)),空间复杂度O(1)。

2.3 摩尔投票法

摩尔投票法的核心思想:因为元素出现次数超过一半,所以它出现的次数减去剩余元素出现次数一定是大于0的。基于这个思想,可以使用抵消的办法来消除掉所有非目标元素,然后剩下的就是目标元素了。

可以先随机找一个元素作为标杆,然后往后遍历,这个标杆元素每出现一次,次数加一。只要出现非标杆元素,次数减一。当次数变成0后,重新设置标杆元素,重复上面的过程。直到最后剩下来的元素就是目标元素。

以target表示目标元素,count表示出现的次数。过程描述:

array |[7, 7, 5, 7, 5, 1 | 5, 7 | 5, 5, 7, 7 | 7, 7, 7, 7]
target| 7                | 5    | 5          | 7
count | 1, 2, 1, 2, 1, 0 | 1, 0 | 1, 2, 0, 0 | 1, 2, 3, 4

最后剩下来的目标元素是7,也就是我们需要的值。

时间复杂度O(N),空间复杂度0(1)。

三、代码

using namespace std;

class Solution {
public:
    int majorityElement(vector<int> &nums) {
        int count = 0, x;
        if (nums.empty()) {
            throw length_error("empty array");
        }

        x = nums[0];
        for (auto i : nums) {
            if (count == 0) {
                x = i;
            }
            if (x == i) {
                count++;
            } else {
                count--;
            }
        }

        return x;
    }
};

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/greatest-common-divisor-of-strings

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

对于字符串 S 和 T,只有在 S = T + ... + T(T 与自身连接 1 次或多次)时,我们才认定 “T 能除尽 S”。

返回最长字符串 X,要求满足 X 能除尽 str1 且 X 能除尽 str2。

示例 1:

  • 输入:str1 = "ABCABC", str2 = "ABC"
  • 输出:"ABC"

示例 2:

  • 输入:str1 = "ABABAB", str2 = "ABAB"
  • 输出:"AB"

示例 3:

  • 输入:str1 = "LEET", str2 = "CODE"
  • 输出:""

提示:

  • 1 <= str1.length <= 1000
  • 1 <= str2.length <= 1000
  • str1[i] 和 str2[i] 为大写英文字母

二、题解

2.1 欧几里得算法

欧几里得算法(GCD)又称为辗转相除法,是用来计算两个整数的最大公因子的算法。

算法描述:

$$\begin{equation} gcd(a, b) = \begin{cases} a & b = 0\\ gcd(b, a \bmod b) & b \ne 0 \end{cases} \end{equation}$$

通过表达式能看出,算法是通过对两个数互相取模来计算得到最大公因子,也因此叫做辗转相除法。

算法依赖定理:两个整数的最大公约数等于其中较小的那个数和两数相除余数的最大公约数。

证明:

假设a=kb+r,四者皆为整树,r表示a%b的余数。假设m是a和b的最大公约数,那么可以推算得到:

a=kb+r  ==>  r=a-kb  ==>  r/m=a/m-kb/m;

最后一个表达式表示等式两边同时除以公约数,因为m是a和b的约数,所以a/m和kb/m都是整数,两者的差值也是整数。间接就说明r/m也是整数,一旦r/m是整数,那么m也是r的约数,即m也是a%b的余数的约数。证明了定理。

2.2 本题解析

在知晓了上面的定理后,就可以使用GCD算法来计算这个问题。只要对两个字符出的长度求出最大公约数即可。

问题的关键点在于两者是否存在最大公约数,假设用{}表示公约数,存在公约数的两个字符串格式一定是:

{}{}{}   {}{}

如果不存在公约数,两个字符串格式就是:

{}{}{}   }{ 或 }{} 

能看出的规律:如果存在公约数,str1+str2=str2+str1,因为两个字符串都是由公因子组成,相加起来只是多个公因子的组合而已。而如果不存在公约数,两者相加后一定不相等,因此基于这一点就能判断字符串是否有公因子了。

三、代码

class Solution {
public:
private:
    int gcd(int a, int b) {
        if (b == 0)
            return a;
        else
            return gcd(b, a % b);
    }
public:
    string gcdOfStrings(string str1, string str2) {
        if (str1 + str2 != str2 + str1)
            return "";

        return str1.substr(0, gcd(str1.size(), str2.size()));
    }
}

测试案例:

#include <gtest/gtest.h>
TEST(leetcode, demo) {
    Solution s;
    EXPECT_EQ("ABC", s.gcdOfStrings("ABCABC", "ABC"));
    EXPECT_EQ("AB", s.gcdOfStrings("ABABAB", "ABAB"));
    EXPECT_EQ("", s.gcdOfStrings("LEET", "CODE"));
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/partition-array-into-three-parts-with-equal-sum

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

你一个整数数组 A,只有可以将其划分为三个和相等的非空部分时才返回 true,否则返回 false。

形式上,如果可以找出索引i+1 < j且满足(A[0] + A[1] + ... + A[i] == A[i+1] + A[i+2] + ... + A[j-1] == A[j] + A[j-1] + ... + A[A.length - 1])就可以将数组三等分。

示例1:

  • 输入:[0,2,1,-6,6,-7,9,1,2,0,1]
  • 输出:true
  • 解释:0 + 2 + 1 = -6 + 6 - 7 + 9 + 1 = 2 + 0 + 1

示例2:

  • 输入:[0,2,1,-6,6,7,9,-1,2,0,1]
  • 输出:false

示例3:

  • 输入:[3,3,6,5,-2,2,5,1,-9,4]
  • 输出:true
  • 解释:3 + 3 = 6 = 5 - 2 + 2 + 5 + 1 - 9 + 4

二、题目解析

要求三个相等的和,首先要知道的是每一段的和是多少,这就要先计算整个数组的总和。

  • 如果总和不能整除3,可以直接返回false,因为输入的都是整数,每一段的和也都是整数。
  • 如果总和能整除3,从头开始遍历数组,每找到一个满足条件的计数加1,直到最后如果计数为3的话就说明是存在符合条件的区间。

一个需要额外注意的地方是:

上面的数组中,总和是0,三个分区间的和也应该是0。但是实际上,按照上面的算法,计数为4,这个场景怎么处理?实际上,只要把最后一个区间归类到第三个区间就可以了,只要他们两者的和满足条件就行了。

而第三个区间是满足条件的,这种情况下,只要从第四个满足条件的区间开始,后面所有元素的总和不会对第三个区间的和造成影响的时候(即后面所有元素和为0),数组是满足条件的。

三、代码

class Solution {
public:
    bool canThreePartsEqualSum(vector<int> &A) {
        int i, cnt = 0, sum, tmp_sum, n;
        // 计算所有的总和
        for (auto x: A) {
            cnt += x;
        }

        // 如果总和不能整除3,直接返回false
        if (cnt % 3 != 0)
            return false;
        // 计算每一段的和
        sum = cnt / 3;

        // 找到三段符合条件的和
        tmp_sum = 0, n = 0;
        for (i = 0; i < A.size() && n < 3; i++) {
            tmp_sum += A[i];
            if (tmp_sum == sum) {
                // 每找到一段,tmp_sum置零,重新开始找
                n++;
                tmp_sum = 0;
            }
        }

        // 已经得到了三段满足条件的和,但是数组可能并没有遍历完
        // 只有在剩余元素的总和是0的条件下才认为满足条件
        tmp_sum = 0;
        for (; i < A.size(); i++) {
            tmp_sum += A[i];
        }

        return n == 3 && tmp_sum == 0;
    }
};

复杂度分析

  • 时间复杂度:O(N)
  • 空间复杂度:O(1)

单元测试案例:

TEST(Solution, leetcode_1) {
    Solution s;
    vector<int> v{0, 2, 1, -6, 6, -7, 9, 1, 2, 0, 1};
    EXPECT_TRUE(s.canThreePartsEqualSum(v));
}

TEST(Solution, leetcode_2) {
    Solution s;
    vector<int> v{0, 2, 1, -6, 6, 7, 9, -1, 2, 0, 1};
    EXPECT_FALSE(s.canThreePartsEqualSum(v));
}

TEST(Solution, leetcode_3) {
    Solution s;
    vector<int> v{3, 3, 6, 5, -2, 2, 5, 1, -9, 4};
    EXPECT_TRUE(s.canThreePartsEqualSum(v));
}

TEST(Solution, leetcode_4) {
    Solution s;
    vector<int> v{10, -10, 10, -10, 10, -10, 10, -10};
    EXPECT_TRUE(s.canThreePartsEqualSum(v));
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/invert-binary-tree

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

翻转一棵二叉树。

示例:

输入:

     4
   /   \
  2     7
 / \   / \
1   3 6   9

输出:

     4
   /   \
  7     2
 / \   / \
9   6 3   1

二、题解

简单题,有两种思路:递归和队列。

2.1 递归

每遍历到一个节点,调整左右子树的值。然后分别递归遍历左右子树。

TreeNode *invertTree(TreeNode *root) {
    TreeNode *p;

    if (root == nullptr) {
        return nullptr;
    }

    // 调整左右子树
    p = root->left;
    root->left = root->right;
    root->right = p;

    // 遍历左右子树
    invertTree(root->left);
    invertTree(root->right);

    return root;
}

2.2 使用队列

把根节点放到队列中,然后依次访问队列中的节点,把每个节点的左右子树位置对换,然后把左右子节点放到栈中继续遍历。

TreeNode *invertTree(TreeNode *root) {
    queue<TreeNode *> q;
    TreeNode *node, *tmp;

    if (root == nullptr) {
        return nullptr;
    }

    // 根节点入队
    q.push(root);
    while (!q.empty()) {
        // 取队首元素
        node = q.front();
        q.pop();

        // 调换左右子树
        tmp = node->left;
        node->left = node->right;
        node->right = tmp;

        // 左子树入队
        if (node->left) {
            q.push(node->left);
        }
        // 右子树入队
        if (node->right) {
            q.push(node->right);
        }
    }

    return root;
}

三、备注

这个问题是受到Max Howell 原问题启发的 :

谷歌:我们90%的工程师使用您编写的软件(Homebrew),但是您却无法在面试时在白板上写出翻转二叉树这道题,这太糟糕了。

其中的一个评论给也很有意思:

如果我是你,我会把白板从下到上翻转。然后告诉他:那,这就是我翻转的二叉树。

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/word-frequency

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

写一个 bash 脚本以统计一个文本文件 words.txt 中每个单词出现的频率。

为了简单起见,你可以假设:

  • words.txt只包括小写字母和 ' ' 。
  • 每个单词只由小写字母组成。
  • 单词间由一个或多个空格字符分隔。

示例:

假设 words.txt 内容如下:

the day is sunny the the
the sunny is is

你的脚本应当输出(以词频降序排列):

the 4
is 3
sunny 2
day 1

说明:

  • 不要担心词频相同的单词的排序问题,每个单词出现的频率都是唯一的。
  • 你可以使用一行 Unix pipes 实现吗?

二、题解

2.1 使用awk

通过NF变量遍历所有字段,存到一个哈希表(数组)中,然后打印出所有的key-value组合,最后通过sort排序。

awk '{for (i = 1; i <= NF; i++) {m[$i]++;}} END {for (i in m) {print i, m[i]}}' words.txt | sort -nr -k 2

2.2 使用xargs

通过xargs的-n参数打印出所有的字段,然后使用uniqsort对字段排序:

cat file.txt | xargs -n 1 | sort | uniq -c | sort -nr -k 2 | awk '{print $2" "$1}'
uniq的-c参数是统计词频

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/tenth-line

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

给定一个文本文件 file.txt,请只打印这个文件中的第十行。

示例:

假设 file.txt 有如下内容:

Line 1
Line 2
Line 3
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10

你的脚本应当显示第十行:

Line 10

说明:

  1. 如果文件少于十行,你应当输出什么?
  2. 至少有三种不同的解法,请尝试尽可能多的方法来解题。

二、题解

2.1 tail+head

返回文本中的第十行,最简单的方式可以通过tail + head配合输出,通过head取到前面10行,然后输出前面10行的最后一行。很容易能想到的办法:

head -10 file.txt | tail -1

但这么做就错了,题目中已经提到如果文件少于10行,你应当输出什么?。一旦文件少于10行,上面的命令会输出文件的最后一行,不满足题意。正确的命令:

tail -n +10 file.txt | head -1

-n +10表示从第10行开始到结束,意思是先从第10行开始取到结束的所有数据然后取第一行。

2.2 sed

sed的p模式可以直接打印指定行:

sed -n '10p' file.txt

2.3 awk

awk内置变量NR表示行数

awk 'NR==10' file.txt

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/super-egg-drop

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。

每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。

你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。

每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。

你的目标是确切地知道 F 的值是多少。无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少?

示例 1:

  • 输入:K = 1, N = 2
  • 输出:2
  • 解释:鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。否则,鸡蛋从 2 楼掉落。如果它碎了,我们肯定知道 F = 1 。如果它没碎,那么我们肯定知道 F = 2 。因此,在最坏的情况下我们需要移动 2 次以确定 F 是多少。

示例 2:

  • 输入:K = 2, N = 6
  • 输出:3

示例 3:

  • 输入:K = 3, N = 14
  • 输出:4

提示:

  • 1 <= K <= 100
  • 1 <= N <= 10000

二、动态规划+递归

看到题目的第一眼就能想到得用动态规划来解题了,关键是如何来定义动态规划的状态和状态转移方程。

比较容易想到的是以dp(k, n)来表示k个鸡蛋在n层建筑时需要扔鸡蛋的最小次数,它的最优状态肯定是从前面的1-n层楼中取,谁的状态越优,就取谁的。也就是说,在那一层扔鸡蛋需要的次数最少,就是谁。那么:

$$dp(k,n)=min(dp(k,1), dp(k,2), ..., dp(k,n))$$

在第i层扔鸡蛋,有两种结果:

  1. 鸡蛋碎了,这个时候鸡蛋少了一个,楼层也少一个,此时它的最优解是dp(k-1,i-1) + 1。
  2. 鸡蛋没碎,这个时候鸡蛋没少,楼层少了一个,最优解是dp(k,n-i) + 1。

可以参考这张图片:

图片来源:labuladong

后面的dp[k][n]和dp(k, n)是一个意思。

为什么没碎之后是dp[k][n-i],因为没碎的话,鸡蛋还是k个,而楼层只有n-i了,所以只要找出dp[k][n-i]就可以了。

计算出来这两种情况后,要选择最坏情况下的场景,所以要取两者的最大值。总结出来状态转移方程就是:

$$\begin{equation} dp[k][i]= \begin{cases} 1 & k=1 \\ 0 & n=0\\ max(dp[k-1][i-1], dp[k][n-i]) & k>1,n>0 \end{cases}\end{equation}$$

归纳成C++代码:

int dp(int k, int n, vector<vector<int>> &v) {
    int i, f = INT_MAX, dpi;
    
    if (k == 1) return n;
    if (n == 0) return 0;

    // v数组默认全是-1
    if (v[k][n] != -1) {
        return v[k][n];
    }

    // 遍历所有楼层
    for (i = 1; i <= n; i++) {
        // 计算出第i层的最优解
        dpi = max(dp(k - 1, i - 1, v), dp(k, n - i, v)) + 1;
        // 计算所有楼层的最小值
        f = min(f, dpi);
    }

    v[k][n] = f;
    return f;
}

时间复杂度

计算所有的鸡蛋+楼层组合需要时间复杂度O(KN),每个楼层取最优值是O(N),总体的时间复杂度是:

$$O(K*N^2)$$

这个时间复杂度还是很大的,可以说迄今为止我还没见过这么高复杂度的动态规划。提交后果然超时了:

在本地测试这组数据发现需要执行2s+才能计算出来(CPU是I5-8400主频2.8GHz),所以提交肯定会超时:

三、动态规划+二分

超时之后不得不想办法优化,优化前为了确定在哪个楼层扔鸡蛋最优不得不从1遍历到n,这个时间复杂度是O(N)太长了,实际上可以使用二分法来找出最优解,讲O(N)变成O(logN)。

上面已经求出了状态转移方程是max(dp[k-1][i-1], dp[k][n-i]),其中可以知道的是,dp[k-1][i-1]是随着i逐步递增的,因为楼层越高,需要扔的次数肯定也越多。而dp[k][n-i]是跟随i递减的,因子i是负数,i越大,n-i越小,扔的次数也越小。

上面的状态转移方程就是现在所有楼层的最大值中,找到最小值,如图所示:

红色的线表示的是两个子状态中的最大值,两根线的交点正好就是所有最大值中的最小值。那么根据这个规律,就可以把i∈[1, n]的遍历改成二分法来遍历,找到这个最小值。代码如下:

int dp(int k, int n, vector<vector<int>> &v) {
    int i, f = INT_MAX;
    int low, high, mid, broken, not_broken;
    
    if (k == 1) return n;
    if (n == 0) return 0;

    if (v[k][n] != -1) {
        return v[k][n];
    }

    low = 1;
    high = n;
    while (low <= high) {
        mid = (low + high) / 2;
        // 鸡蛋碎了
        broken = dp(k - 1, mid - 1, v);
        // 鸡蛋没碎
        not_broken = dp(k, n - mid, v);
        if (broken > not_broken) {
            high = mid - 1;
            f = min(f, broken + 1);
        } else {
            low = mid + 1;
            f = min(f, not_broken + 1);
        }
    }
    
    v[k][n] = f;
    return f;
}

修改后再测试上面超时的数据,只要4毫秒就可以执行完了,效率提高了几百倍:

再次提交代码,通过了(耗时较高):

把遍历变成二分后,遍历的O(N)变成了O(logN),所以总的时间复杂度是:O(KN*logN)​,空间复杂度是O(KN)。

四、终极动态规划

dp[k][m]表示k个鸡蛋扔m次最多可以确定的多少层的楼,那么第一个大于等于n的下标m就是扔鸡蛋的次数了。到第i层的时候,依旧是有两种策略:

  1. 鸡蛋碎了,此时鸡蛋减1,扔的次数减1,接下来可以检测出来的楼层是dp[k-1][m-1]
  2. 鸡蛋没碎,扔的次数减1,接下来可以检测出来的楼层是dp[k][m-1]

所以第i个鸡蛋能确定的楼层的总数是:dp[k-1][m-1] + dp[k][m-1] + 1,即鸡蛋碎了的时候能确定的楼层数加上鸡蛋没碎的时候能确定的楼层数再加上本层。

代码:

// k是鸡蛋个数,n是楼层,两个都大于等于1
int superEggDrop(int k, int n) {
    int i, j;
    vector<vector<int>> v(k + 1, vector<int>(n + 1, 0));

    for (j = 1; v[k][j - 1] < n; j++) { // 扔鸡蛋的次数
        for (i = 1; i <= k && v[k][j - 1] < n; i++) { // 鸡蛋个数
            v[i][j] = v[i - 1][j - 1] + v[i][j - 1] + 1;
        }
    }
    return j - 1;
}
要注意的地方是循环条件终止条件是v[k][j-1] >= n,为什么是j-1呢,因为第一层for循环的j表示的是第j次扔鸡蛋能确定的层数,此时层数还没有确定,还要在下面的循环中计算得到。如果使用v[k][j]会出现死循环导致数据溢出。

时间复杂度O(K*N),提交后执行用时大大减少:

继续优化

上面已经计算出来状态转移方程是:

$$dp[k][i]=dp[k-1][i-1] + dp[k][i-1] + 1$$

可以看到,当前状态实际上只与前面一列的状态有关,可以只保存前面一列状态的数据,空间复杂度为O(K)。

int superEggDrop(int k, int n) {
    int i, j;
    vector<vector<int>> v(k + 1, vector<int>(2, 0));
    // 第0列作为前一列的备份列,第1列作为当前列
    for (j = 1; v[k][0] < n; j++) {
        for (i = 1; i <= k && v[k][j - 1] < n; i++) {
            v[i][1] = v[i - 1][0] + v[i][0] + 1;
        }

        // 拷贝第1列到第0列
        for (i = 0; i <= k; i++) {
            v[i][0] = v[i][1];
        }
    }
    return j - 1;
}

[leetcode]300-最长上升子序列

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/longest-increasing-subsequence

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

  • 输入: [10,9,2,5,3,7,101,18]
  • 输出: 4
  • 解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

  • 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
  • 你算法的时间复杂度应该为O(n^2)。
进阶:你能将算法的时间复杂度降低到O(nlogn)吗?

二、题解

首先要搞清楚的是题意:求最长上升子序列。子序列不是子串,两者的区别是子序列可以不连续,子串必须连续

例如:[1, 3, 5, 2, 9],最长上升子序列是[1, 3, 5, 9],最长上升子串是[1, 3, 5]。

2.1 暴力法

题目的一个要求是用O(n^2)的时间复杂度来完成这个问题,如果有O(n^2)的时间复杂度,可以直接使用暴力破解的办法:

// 伪代码
for (i = 0; i < size; i++) {
    for (j = i; j < size; j++) {
        // 存在一个上升序列,和加一
        if (nums[j] >= nums[i])
            val[i][j] +=1;
    }
}

算法的时间复杂度和空间复杂度都是O(n^2)。

2.2 动态规划

dp[i]表示到第i个元素时的最大上升子序列长度,那么dp[i]就等于数组中上一个小于5的元素的最大上升子序列长度加1。

以[10,9,2,5,3,7]为例,i=5时如何求dp[i]?此时nums[i]=7,要想构造上升序列,它前面的数字都小于7才行。左边小于7的数字有三个:2、5和3。

  • 在数字1的地方,最大上升子序列的长度是1。
  • 在数字5的地方,最大上升子序列的长度是2;
  • 在数字3的地方,最大上升子序列的长度是2。

因为7大于三个数字,所以到达7的时候最大子序列长度肯定要加1,这个时候只有在上面三个数字中最大的上面加才能保证数字7的子序列最大。因此可以归纳出来的状态转移方程为:

$$dp[i]=max(dp[0], dp[1],..., dp[n]), n<i, nums[n]<=nums[i].$$

使用动态规划的时间复杂度也是O(n^2),空间复杂度O(n)。

2.3 二分法求解

二分法不容易想出来,即使是看题解也是看了几遍才明白。

考虑一个问题:假设存在数组tails,tails[i]表示最大上升子序列长度为i的子序列的末尾元素的最小值,这种情况下tails的长度就是最大子序列的长度了。存不存在这么一个数组呢,有没有办法构造出这样的一个数组?答案是肯定的。

以[10, 9, 2, 5, 7]为例解释tails数组每个元素的意义:

  1. 所有的数字都是长度为1的子序列,其中最小的数字是2,所以tails[1]=2。
  2. 长度为2的子序列有[2, 5], [2, 7]和[5, 7],其中末尾数最小的是5,所以tails[2]=5。
  3. 长度为3的子序列只有[2, 5, 7],末尾数只有一个就是7,所以tails[3]=7。

如果我们能构造出这个数组,那么问题就解决了。那么关键的问题是:如何知道遍历到哪个元素的时候,它的子序列长度是多少?

首先,暂且假设这个数组存在。结合tails数组的特性,可以确定的一个问题是tails[i]肯定大于tails[i-1],tails数组一定严格递增。因为题目求的是上升子序列,而tails数组保存的是每个子序列中的最小值,所以子序列右边的元素肯定大于左边的。

一个要注意的问题是tails[i]表示的是长度为i的子序列的末尾元素的最小值,以[1, 5, 3, 4]为例,最大上升子序列长度为2的有两个:[1, 5]和[1, 3],如果不取最小的末尾元素3,那么tails数组就是[1, 5],当遍历到4的时候,找到第一个大于它的数是5,此时下标等于2,也就是说最大上升子序列长度就是2。而实际上应该是3(子序列[1, 3, 4]),结果不对。

结合这个结论,在判断nums[i]的时候,只要从左到右找到第一个大于它的值的时候(使用二分法查找),这个下标就是它的最大子序列长度了。

这种情况下时间复杂度为O(nlogn)​,空间复杂度为O(n)。

三、代码

3.1 动态规划

int lengthOfLIS_DP(vector<int> &nums) {
    int res = 1;
    size_t i, j;
    vector<int> dp(nums.size(), 1);

    if (nums.empty())
        return 0;

    for (i = 0; i < nums.size(); i++) {
        // 遍历所有比当前数字小的元素
        for (j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                // 更新dp数组
                dp[i] = max(dp[i], dp[j] + 1);
                // 更新结果
                res = max(dp[i], res);
            }
        }
    }

    return res;
}

3.2 二分法

通过二分法找到区间内第一个大于目标的元素:

/*
 * 在区间[left, right]找到第一个大于等于目标的元素
 * @v 待查找的数组
 * @left 左边界,包含
 * @right 右边界,包含
 * @return 找到返回元素的下标,否则返回-1
 */
int find_first_ge(vector<int> &v, int left, int right, int target) {
    int mid;
    while (left <= right) {
        mid = (left + right) / 2;
        if (v[mid] >= target) {
            if (mid == left || v[mid - 1] < target)
                return mid;
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return -1;
}

求最长上升子序列:

int lengthOfLIS_BINSEARCH(vector<int> &nums) {
    vector<int> tails(nums.size(), 0);
    int i, idx, max_len;

    if (nums.empty())
        return 0;

    max_len = 0;
    for (i = 0; i < nums.size(); i++) {
        idx = find_first_ge(tails, 0, max_len - 1, nums[i]);
        if (idx < 0) {
            // 没有找到一个比当前元素小的,扩充
            tails[max_len++] = nums[i];
        } else {
            // 找到了,替换掉
            tails[idx] = nums[i];
        }
    }
    return max_len;
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/house-robber

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

  • 输入: [1,2,3,1]
  • 输出: 4
  • 解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:

  • 输入: [2,7,9,3,1]
  • 输出: 12
  • 解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。

二、题解

假设当前是第n家房屋,已经偷到了x元。当前有两种选择:偷或者不偷。

  1. 如果偷了,那么说明前面一家房屋是不能偷的,否则会报警。这个时候偷到的金额为x[n - 2] + nums[n]。
  2. 如果不偷,说明前面一家房屋肯定是要偷的,不然少偷了一家。这个时候偷到的金额为x[n - 1]。

两种情况,如何选择?肯定是选择两者中金额更大的,那么就能得到状态转移方程为:

$$dp[i]=max(dp[i - 1], dp[i - 2] + nums[i])$$

因为当前状态只于前面两个状态有关,因此没必要分配dp数组,使用固定的变量来表示就行了(和斐波那契数列一样)。状态转移方程可修改为:

$$amount = max(prev, pprev + nums[n])$$

其中prev是上一个状态的最大值,pprev是上上个状态的最大值,nums[n]是当前第n个房屋的金额。

三、代码

int rob(vector<int> &nums) {
    int max_amount = 0, pprev, prev;
    size_t i;

    if (nums.size() < 1)
        return 0;

    pprev = 0;
    prev = nums[0];
    max_amount = prev;
    for (i = 1; i < nums.size(); i++) {
        max_amount = max(prev, pprev + nums[i]);
        pprev = prev, prev = max_amount;
    }
    return max_amount;
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/coin-change

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

  • 输入:coins = [1, 2, 5], amount = 11
  • 输出:3
  • 解释:11 = 5 + 5 + 1

示例 2:

  • 输入:coins = [2], amount = 3
  • 输出:-1

说明:

你可以认为每种硬币的数量是无限的。

二、题解

以示例一种的[1, 2, 5]三种硬币为例,当要凑到11块钱的时候,什么时候硬币个数最小?

只有在凑到11之前的硬币最少,凑够11的硬币才最少。凑到11块一种有三种可能性:

  1. 已经有10块了,再凑1块得到11块。
  2. 已经有9块了,再凑2块得到11块。
  3. 已经有6块了,再凑5块得到11块。

也就是说,手里面当前已经揣了10块、9块或是6块钱,这个时候哪个的硬币数量最少,凑成的11块钱硬币才最少。很明显又是一个动态规划,以dp[i]表示amount=i时候的最优解个数,那么dp[i]的状态转移方程为:

$$\begin{equation} dp(i)= \begin{cases} -1 & amount < 0\\ 0 & amount = 0 \\ min(dp[i-coin1], dp[i-coin2], ...) + 1 & amount > 0\end{cases} \end{equation}$$

三、代码

class Solution {
public:
    int coinChange(vector<int> &coins, int amount) {
        int i, cnt;
        // 初始化为int类型的最大值以方便做特殊处理
        vector<int> dp(amount + 1, INT_MAX);

        if (amount < 0)
            return -1;

        dp[0] = 0;
        for (i = 1; i <= amount; i++) {
            for (auto coin: coins) {
                // dp[i - coin]存在并且有最小值才行
                if (i - coin < 0 || dp[i - coin] == INT_MAX)
                    continue;
                dp[i] = min(dp[i], dp[i - coin] + 1);
            }
        }
        return dp[amount] == INT_MAX ? -1 : dp[amount];
    }
};