1.21 LeetCode总结(基本算法)_贪心算法

在刷题之前需要反复练习的编程技巧,尤其是手写各类数据结构实现,它们好比就是全真教的上乘武功

55. 跳跃游戏

在这里插入图片描述

// 尽可能到达最远位置(贪心)。
// 如果能到达某个位置,那一定能到达它前面的所有位置
bool canJump(int *nums, int numsSize)
{
     // reach 记录当前能达到最远的地方
	int reach = nums[0];
	for (int i = 0; i < numsSize; ++i) {
        // 如果 i > reach, 说明i位置已经跳出了reach的范围,返回false. i为当前位置
		if (i > reach) {
			return false;
		}
		// 根据当前位置i及当前能跳的最远距离nums[i]之和来更新 reach.
		reach = fmax(i + nums[i], reach);
	}

	return true;
}

45. 跳跃游戏 II

在这里插入图片描述

int jump(int *nums, int numsSize)
{
    int steps = 0;
    int maxReach = 0;
    int maxPos = 0;
    // 维护当前能够到达的最大下标位置,记为边界。
    // 从左到右遍历数组,到达边界时,更新边界并将跳跃次数增加1
    for (int i = 0; i < numsSize-1; ++i) {
        // 只是更新maxReach,并没有移动 i
        maxReach = fmax((i + nums[i]), maxReach);
        if (i == maxPos) {
            maxPos = maxReach;
            ++steps;
            if (maxReach >= numsSize - 1) {
                return steps;
            }
        }
    }
    return 0;
}

0_ 举一个形象的例子_教室调度问题

在这里插入图片描述
加粗样式
**

一、理论知识

「解决一个问题需要多个步骤,每一个步骤有多种选择」这样的描述我们在「回溯算法」「动态规划」算法中都会看到。它们的区别如下:

「回溯算法」需要记录每一个步骤、每一个选择,用于回答所有具体解的问题;

「动态规划」需要记录的是每一个步骤、所有选择的汇总值(最大、最小或者计数);

「贪心算法」由于适用的问题,每一个步骤只有一种选择,一般而言只需要记录与当前步骤相关的变量的值。
对于不同的求解目标和不同的问题场景,需要使用不同的算法。动态规划与贪心算法的区别,我们画在下面这张图里。

在这里插入图片描述
在这里插入图片描述

可以使用「贪心算法」的问题需要满足的条件:

1. 最优子结构:规模较大的问题的解由规模较小的子问题的解组成,区别于「动态规划」,可以使用「贪心算法」的问题「规模较大的问题的解」只由其中一个「规模较小的子问题的解」决定;

2. 无后效性:后面阶段的求解不会修改前面阶段已经计算好的结果;

3. 贪心选择性质:从局部最优解可以得到全局最优解。
对「最优子结构」和「无后效性」的理解同「动态规划」,「贪心选择性质」是「贪心算法」最需要关注的内容。

二、通过具体例子理解贪心算法

455. 分发饼干

– 将最大的饼干给最贪心的小朋友吃
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值 g[i] ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 s[j] 。如果 s[j] >= g[i] ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
输入: [1,2,3], [1,1]
输出: 1
解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。

手法1:每个孩子最多只能给一块饼干,所以存在饼干大于孩子的胃口;
手法2:先做排序,然后将饼干和胃口都由小至大来进行分配;
在这里插入图片描述

int cmp(int* a, int* b) {
    return *a - *b;
}

int findContentChildren(int* g, int gSize, int* s, int sSize) {
    qsort(g, gSize, sizeof(int), cmp);
    qsort(s, sSize, sizeof(int), cmp);
    int numOfChildren = gSize, numOfCookies = sSize;
    int count = 0;
    for (int i = 0, j = 0; i < numOfChildren && j < numOfCookies; i++, j++) {
        while (j < numOfCookies && g[i] > s[j]) {
            j++;
        }
        if (j < numOfCookies) {
            count++;
        }
    }
    return count;
}

「贪心算法」总是做出在当前看来最好的选择就可以完成任务;
解决「贪心算法」几乎没有套路,到底如何贪心,贪什么与我们要解决的问题密切相关。因此刚开始学习「贪心算法」的时候需要学习和模仿,然后才有直觉,猜测一个问题可能需要使用「贪心算法」,进而尝试证明,学会证明。


三、找零钱问题

可以使用「贪心算法」的一类经典问题是找零钱问题。
在生活中,我们找给别人零钱,通常都是按照「先给出尽可能多的面值较大的纸币(硬币),然后再给出尽可能多的面值第二大的纸币(硬币)」,直到凑成了我们需要凑出的金额为止,这样找零钱得到的纸币(硬币)的张数(个数)最少。能够这样做,与 可选的硬币(纸币)的面值密切相关,大家可以仔细想一想这个问题,相信会是一个非常不错的思考问题

860. 柠檬水找零
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱
给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
在这里插入图片描述

bool lemonadeChange(int* bills, int billsSize) {
    int five = 0, ten = 0;
    for (int i = 0; i < billsSize; i++) {
        if (bills[i] == 5) {
            five++;
        } else if (bills[i] == 10) {
            if (five == 0) {
                return false;
            }
            five--;
            ten++;
        } else {
            if (five > 0 && ten > 0) {
                five--;
                ten--;
            } else if (five >= 3) {
                five -= 3;
            } else {
                return false;
            }
        }
    }
    return true;
}

四、区域选择问题

有一类使用「贪心算法」解决的问题称为「活动选择问题」,解决这一类问题的直觉是「优先选择活动最早的活动」。我们下面给出的三道例题都给出了对这一类问题的直觉的证明。如果还想了解「活动选择问题」的详细讨论,可以查看《算法导论》第 16.1 节「活动选择问题」的叙述。

435. 无重叠区间

给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
注意:
可以认为区间的终点总是大于它的起点。
区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。

在这里插入图片描述
思路与算法:
我们不妨想一想应该选择哪一个区间作为首个区间。
假设在某一种最优的选择方法中,[l_k, r_k]是首个(即最左侧的)区间,那么它的左侧没有其它区间,右侧有若干个不重叠的区间。设想一下,如果此时存在一个区间 [l_j, r_j] 使得 r_j < r_k ,即区间 j 的右端点在区间 k 的左侧,那么我们将区间 k 替换为区间 j,其与剩余右侧被选择的区间仍然是不重叠的。而当我们将区间 k 替换为区间 j 后,就得到了另一种最优的选择方法。

我们可以不断地寻找右端点在首个区间右端点左侧的新区间,将首个区间替换成该区间。那么当我们无法替换时,**首个区间就是所有可以选择的区间中右端点最小的那个区间。因此我们将所有区间按照右端点从小到大进行排序,那么排完序之后的首个区间,就是我们选择的首个区间。

如果有多个区间的右端点都同样最小怎么办?由于我们选择的是首个区间,因此在左侧不会有其它的区间,那么左端点在何处是不重要的,我们只要任意选择一个右端点最小的区间即可。

当确定了首个区间之后,所有与首个区间不重合的区间就组成了一个规模更小的子问题。由于我们已经在初始时将所有区间按照右端点排好序了,因此对于这个子问题,我们无需再次进行排序,只要找出其中与首个区间不重合并且右端点最小的区间即可。用相同的方法,我们可以依次确定后续的所有区间。

简单点说,Leetcode上网友总结了一套罗志祥算法:

在这里插入图片描述

手法1:首先要明白为啥一定要选择最早结束的(见上图–罗志祥算法)也就是选 – 首个区间就是所有可以选择的区间中右端点最小的那个区间

手法2:如果有多个区间的右端点都同样最小怎么办?由于我们选择的是首个区间,因此在左侧不会有其它的区间。-- 假设在某一种最优的选择方法中,[l_k, r_k]是首个(即最左侧的)区间,那么它的左侧没有其它区间,右侧有若干个不重叠的区间。即便左侧有,我们也不关心,没有影响,我们要去掉的是 右侧的重复区间。

int cmp(int** a, int** b) {
    return (*a)[1] - (*b)[1];
}

int eraseOverlapIntervals(int** intervals, int intervalsSize, int* intervalsColSize) {
    if (intervalsSize == 0) {
        return 0;
    }

    qsort(intervals, intervalsSize, sizeof(int*), cmp);

    int right = intervals[0][1]; // 找到首区间
    int ans = 1;
    for (int i = 1; i < intervalsSize; ++i) { // 更新区间
        if (intervals[i][0] >= right) {
            ++ans;
            right = intervals[i][1];
        }
    }
    return intervalsSize - ans; // 只要没能更新进来的,都是重复区间.
}

五、跳跃问题

这一节我们的跳跃问题也是使用「贪心算法」解决的经典问题。对于不同的目标「贪心算法」贪心的点是不一样的。大家可以在学习完这两个例题之后,分析它们之间的区别。
在这里插入图片描述

手法1:我们依次遍历数组中的每一个位置,并实时维护 最远可以到达的位置 reach。对于当前遍历到的位置 i,如果它在 最远可以到达的位置 的范围内,那么我们就可以从起点通过若干次跳跃到达该位置(哪怕是一步一步跳过去,肯定能跳过去)。

手法2:关键就是维护最远可以达到的位置,起初想着用一个数组,但空间复杂度高了,且没有必要,我们只要看最远位置,搞个int reach 就可以了.

bool canJump(int *nums, int numsSize)
{
	int reach = nums[0]; // reach 记录当前能达到最远的地方
	for (int i = 0; i < numsSize; ++i) {
		if (reach < i) { // 如果 reach < i, 说明i位置已经跳出了reach的范围,返回false. i为当前位置,
			return false;
		}
		// 根据当前位置i及当前能跳的最远距离nums[i]之和来更新 reach. 每次i++来更新一次
		reach = fmax(i + nums[i], reach);
	}

	return true;
}

5788. 字符串中的最大奇数
在这里插入图片描述
思路:
我们从右到左遍历 num 中的字符,当遍历到第一个值为奇数的字符时,我们假设它的下标为 i,此时子字符串 num[0…i+1] 即为值为奇数且值最大的子字符串,我们返回该子字符串作为答案;
– 说来也巧,有的题,看似是编程题,其实是数学题,细想就是从右到左找到第一个奇数就是最大的奇数.

#define MAX_NUM   100001
char *largestOddNumber(char *num)
{
	const char *s = "";
	char *numStr = (char *)malloc(sizeof(char) * MAX_NUM);
	int  len = strlen(num);
	int  numSingle = 0;
	int  i = len - 1;
	int  pos  = 0;
	int  flag = 0;

	while (i >= 0 && flag == 0) {
		numSingle = (num[i] - '0');
		if (numSingle % 2 == 1) {
			pos  = i;
			flag = 1;
			continue;
		}
		i--;
	}
	for (i = 0; i <= pos; i++) {
		numStr[i] = num[i];
	}
	numStr[i] = '\0';

	if (flag == 1) {
		return numStr;
	}

	return (char *)s;
}

376. 摆动序列

在这里插入图片描述
观察这个序列可以发现,我们不断地交错选择「峰」与「谷」,可以使得该序列尽可能长
在实际代码中,我们记录当前序列的上升下降趋势。每次加入一个新元素时,用新的上升下降趋势与之前对比,如果出现了「峰」或「谷」,答案加一,并更新当前序列的上升下降趋势。

int wiggleMaxLength(int* nums, int numsSize) {
    if (numsSize < 2) {
        return numsSize;
    }
    int prevdiff = nums[1] - nums[0];
    int ret = prevdiff != 0 ? 2 : 1;
    for (int i = 2; i < numsSize; i++) {
        int diff = nums[i] - nums[i - 1];
        if ((diff > 0 && prevdiff <= 0) || (diff < 0 && prevdiff >= 0)) {
            ret++;
            prevdiff = diff;
        }
    }
    return ret;
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值