问题描述:在一个大小乱序的数列中,找到一个最大长度的递增子序列,子序列中的数据在原始数列中的相对位置保持不变,可以不连续,但必须递增。
输入描述:
1 | 第一行输入数列的长度 n。(1 <= n <= 200) |
输出:
1 | 输出最大递增子序列的长度。 |
示例:
1 | 输入: |
1 | 输出: |
1 | 说明: |
动态规划法
可以使用 动态规划 的思想来设计该算法。定义数组 dp[n],dp[i] 表示以第 i 个元素结尾的最长递增子序列的长度。(有关动态规划,参考往期文章 【一篇小短文,理解动态规划问题 DP (Dynamic Programming)】)
两个 for 循环嵌套,外层循环遍历数组中的第 i 个元素,内层循环遍历第 i 个元素之前的所有元素。
每次内层循环结束,则计算出当前 第 i 个元素结尾的最长递增子序列长度。
后面的每次循环都基于前面已经解决的子问题。
状态转移方程:
arr是长度为n的数组,arr[i]表示第i个元素。dp[i]为以arr[i]结尾的最长递增子序列的长度。
对于每个元素 arr[i],它的递增子序列可以通过之前的元素 arr[0], arr[1], ..., arr[i-1] 来扩展,因此我们可以利用之前的状态来更新 dp[i]。
转移方程为:
即,对于每个 i,我们检查所有 j (j 小于 i),如果 arr[i] 大于 arr[j],那么 arr[i] 可以作为以 arr[j] 结尾的递增子序列的后继,从而更新 dp[i]。
C语言代码实现:
1 |
|
如果 arr[i] > arr[j],则可以构成以递增关系,dp[j] 保存的是以 arr[j] 结尾的最大递增子序列的长度。dp[j] + 1 是因为当前找到的第 i 个元素也被算进来。dp[i] = dp[i] > dp[j] + 1 ? dp[i] : dp[j] + 1; 条件运算符,选择较大的一个更新 dp[i] 的值。
以输入的数据 4 3 1 2 5 6 为例,代码的执行过程:
1. 外层循环 i = 1:
arr[1] = 3查看i前面的元素:j = 0,**arr[0] = 4**,因为arr[1] (3)小于arr[0] (4),无法构成递增子序列,所以dp[1]保持为 1。
1 | dp = [1, 1, 1, 1, 1, 1] |
2. 外层循环 i = 2:
arr[2] = 1查看i前面的元素:j = 0,**arr[0] = 4**,arr[2] (1)小于arr[0] (4),无法构成递增子序列,dp[2]保持为 1。j = 1,**arr[1] = 3**,arr[2] (1)小于arr[1] (3),无法构成递增子序列,dp[2]保持为 1。
1 | dp = [1, 1, 1, 1, 1, 1] |
3. 外层循环 i = 3:
arr[3] = 2查看i前面的元素:j = 0,**arr[0] = 4**,arr[3] (2)小于arr[0] (4),无法构成递增子序列,dp[3]保持为 1。j = 1,**arr[1] = 3**,arr[3] (2)小于arr[1] (3),无法构成递增子序列,dp[3]保持为 1。j = 2,**arr[2] = 1**,arr[3] (2)大于arr[2] (1),我们可以在arr[2]后面加上arr[3],更新dp[3] = dp[2] + 1 = 2。
1 | dp = [1, 1, 1, 2, 1, 1] |
4. 外层循环 i = 4:
arr[4] = 5查看i前面的元素:j = 0,**arr[0] = 4**,arr[4] (5)大于arr[0] (4),我们可以在arr[0]后面加上arr[4],更新dp[4] = dp[0] + 1 = 2。j = 1,**arr[1] = 3**,arr[4] (5)大于arr[1] (3),我们可以在arr[1]后面加上arr[4],更新dp[4] = dp[1] + 1 = 2(不需要更新,dp[4]仍然为 2)。j = 2,**arr[2] = 1**,arr[4] (5)大于arr[2] (1),我们可以在arr[2]后面加上arr[4],更新dp[4] = dp[2] + 1 = 2(不需要更新,dp[4]仍然为 2)。j = 3,**arr[3] = 2**,arr[4] (5)大于arr[3] (2),我们可以在arr[3]后面加上arr[4],更新dp[4] = dp[3] + 1 = 3。
1 | dp = [1, 1, 1, 2, 3, 1] |
5. 外层循环 i = 5:
arr[5] = 6查看i前面的元素:j = 0,**arr[0] = 4**,arr[5] (6)大于arr[0] (4),我们可以在arr[0]后面加上arr[5],更新dp[5] = dp[0] + 1 = 2。j = 1,**arr[1] = 3**,arr[5] (6)大于arr[1] (3),我们可以在arr[1]后面加上arr[5],更新dp[5] = dp[1] + 1 = 2(不需要更新,dp[5]仍然为 2)。j = 2,**arr[2] = 1**,arr[5] (6)大于arr[2] (1),我们可以在arr[2]后面加上arr[5],更新dp[5] = dp[2] + 1 = 2(不需要更新,dp[5]仍然为 2)。j = 3,**arr[3] = 2**,arr[5] (6)大于arr[3] (2),我们可以在arr[3]后面加上arr[5],更新dp[5] = dp[3] + 1 = 3。j = 4,**arr[4] = 5**,arr[5] (6)大于arr[4] (5),我们可以在arr[4]后面加上arr[5],更新dp[5] = dp[4] + 1 = 4。
1 | dp = [1, 1, 1, 2, 3, 4] |
最终结果:
- 最终
dp数组为[1, 1, 1, 2, 3, 4],表示每个位置上以该元素为结尾的最长递增子序列的长度。 - 最长递增子序列的长度是
4,即dp数组中的最大值。
使用动态规划方法解决该问题,由于使用了双层 for 循环嵌套,所以代码的时间复杂度为
贪心算法 + 二分查找
贪心算法(Greedy Algorithm) 是一种在求解问题时采取局部最优解的策略,目的是通过一步一步地做出选择,期望通过局部最优选择达到全局最优。换句话说,贪心算法在每一步都选择当前看起来最好的(最优的)选项,而不考虑这些选择是否会影响到后续的决策。
贪心算法的特点:
- 局部最优选择:每次决策时,选择当前最优解,假设局部最优能够带来全局最优解。
- 不回溯:一旦作出选择,就不会改变或回头考虑先前的选择。贪心算法通常不需要回溯到前一步的决策。
- 无法保证全局最优解:贪心算法的一个缺点是它并不总是能找到全局最优解,因为局部最优并不意味着全局最优。但是,对于某些问题,贪心算法能够给出全局最优解。
贪心算法工作步骤:
- 选择:在当前状态下做出一个选择,使得该选择是局部最优的。
- 可行性检查:检查当前的选择是否符合问题的约束。
- 解决子问题:做出选择后,递归地解决问题的子问题。
- 结束条件:当没有更多的选择可做时,结束算法。
C语言实现:
1 |
|
代码解释:
1 | // 输入部分 |
初始化 tails 数组:
1 | int* tails = (int*)malloc(n * sizeof(int)); |
tails 数组用来存储当前所有递增子序列的尾部元素。在开始时,我们的递增子序列长度为 0,因此 size 初始为 0。
二分查找函数:
1 | int binarySearch(int* tails, int size, int target) { |
通过二分查找在一个有序的数组 tails 中找到一个位置,使得如果插入 target,数组依然保持有序。函数的返回值是 target 应该插入的位置。
二分查找的基本思想是:通过反复折半查找范围来逐渐缩小搜索区间,从而提高查找效率。
binarySearch(int *tails, int size, int target)二分查找函数,目的是找到tails中第一个大于或等于target的位置。tails[mid] < target时,表示target可以放到mid右边,因此我们将left移动到mid + 1。tails[mid] >= target时,表示我们要寻找更小的值,因此将right移动到mid - 1。- 最终返回的
left就是target应该插入的位置。
遍历数组并更新 tails 数组:
1 | for (int i = 0; i < n; i++) { |
- 对于每个输入数组
arr[i],我们通过二分查找找出它应该插入tails数组的位置pos。 - 如果
tails[pos]是该元素,说明我们已经可以更新该位置的尾部元素,否则我们在tails数组中找到一个位置并将其更新为arr[i]。 - 如果
pos等于当前tails数组的长度(即size),意味着我们发现了一个比当前tails数组中的任何尾部元素都要大的元素,此时可以将tails数组的长度加 1,表示找到了一个新的递增子序列的末尾。
算法的核心思想
- 贪心算法:
- 我们试图尽可能让每个新元素扩展已有的递增子序列,或者替换掉某个尾部元素,以便为后续的更大的元素腾出空间。
- 通过不断更新
tails数组,我们能确保tails数组中保持着当前所有递增子序列的最小尾部元素。这样,tails数组越长,代表最长递增子序列的长度越长。
- 二分查找:
- 我们用二分查找来快速找到
tails数组中第一个大于或等于当前元素的位置。这是该算法的关键,利用二分查找来确保每次更新tails数组的时间复杂度为O(log n),从而把总的时间复杂度降到了O(n log n)。
- 我们用二分查找来快速找到
例子分析
假设输入序列为:4, 3, 1, 2, 5, 6
- 初始化:
arr = [4, 3, 1, 2, 5, 6]tails = [],size = 0
- 第 1 个元素 4:
binarySearch(tails, 0, 4)返回位置0(tails为空,4应该放到第一个位置)。- 更新
tails = [4],size = 1。
- 第 2 个元素 3:
binarySearch(tails, 1, 3)返回位置0(tails[0] = 4,3比它小,插入位置是0)。- 更新
tails = [3],size = 1。
- 第 3 个元素 1:
binarySearch(tails, 1, 1)返回位置0(tails[0] = 3,1比它小,插入位置是0)。- 更新
tails = [1],size = 1。
- 第 4 个元素 2:
binarySearch(tails, 1, 2)返回位置1(tails[0] = 1,2比它大,插入位置是1)。- 更新
tails = [1, 2],size = 2。
- 第 5 个元素 5:
binarySearch(tails, 2, 5)返回位置2(tails[0] = 1,tails[1] = 2,5比它们都大,插入位置是2)。- 更新
tails = [1, 2, 5],size = 3。
- 第 6 个元素 6:
binarySearch(tails, 3, 6)返回位置3(tails[0] = 1,tails[1] = 2,tails[2] = 5,6比它们都大,插入位置是3)。- 更新
tails = [1, 2, 5, 6],size = 4。
最后,tails = [1, 2, 5, 6],最长递增子序列的长度是 4。
第二个算法的时间复杂度为 tails 。
END