攒青豆的算法设计

262 阅读6分钟

当青训营遇上码上掘金

今天看到活动里有曾经做过的题的影子,来发表一下我对这个题目的见解和算法的简要分析。

文章将从一下几个部分进行:暴力法、其他优化算法、性能测试。

题目描述如下:

现有 n 个宽度为 1 的柱子,给出 n 个非负整数依次表示柱子的高度,排列后如下图所示,此时均匀从上空向下撒青豆,计算按此排列的柱子能接住多少青豆。(不考虑边角堆积)

image.png


1.暴力法

不管拿到什么题,我首先总是想到能不能用暴力的手段解决问题。当然,这个题是绝对可以用暴力手段解决的。

如何去思考这个题目呢?我们需要遍历数组的每一个位置。对于每一个位置,我们需要枚举出来它左边的最大高度和它右边的最大高度,就能得到改位置能存放的青豆数量。在遍历的过程中将每一个位置能存放的青豆数量累积起来,就可以得到我们要的结果。

//go
func Solution(column []int) int {
    n := len(column)
    if n == 0 {
        return 0 //考虑异常情况,直接返回0
    }
    count := 0 //记录总数
    for i := 0; i < n-1; i++ {
        maxleft := 0
        maxright := 0
        for j := 0; j <= i; j++ {
            maxleft = max(maxleft, column[j])
        }
        for j := i; j < n-1; j++ {
            maxright = max(maxright, column[j])
        }
        count += min(maxright, maxleft) - column[i]
    }
    return count
}
//c++
int bruteEnumerate(vector<int>& columns) {
        int count=0;
        if (columns.empty()) return 0;
        for (int i = 0; i < columns.size()-1; i++)
        {   
            int maxRight=0,maxLeft=0;
            for (int j = 0; j <=i; j++)
                maxLeft=max(maxLeft,columns[j]);
            for (int j = i; j <=columns.size()-1; j++)
                maxRight=max(maxRight,columns[j]);
            if(min(maxLeft,maxRight)-columns[i]>0) count+=min(maxLeft,maxRight)-columns[i];
        }
        return count;
}

这种方法的时间复杂度为O( n2n^2 ) , 空间复杂度为O(1)。这种方法大概率会超时,对于一般的面试题应该选择其他更优秀的算法。


2.栈模拟

暴力法由于其平方级的时间复杂度,超时的可能性巨大。

我们不如使用栈来模拟这个操作的过程,因为这个栈因为只使用了一个栈来存储位置,所以空间复杂度比较低。

我们从左到右遍历整个数组,并使用一个栈来维护每个位置左边的最大值。在遍历每个位置时,我们检查当前位置的高度是否大于栈顶位置的高度。如果是,则说明栈顶位置可能会存贮豆子,我们弹出栈顶位置并计算能够存豆子的数量。最后,我们将当前位置入栈。这就是所谓单调栈的思想

func Solution2(column []int) int {
	return stackSolution(column)
}
func stackSolution(columns []int) int {
	n := len(columns)
	if n == 0 {
		return 0
	}
	stack := make([]int, 0)
	res := 0
	for i := 0; i < n; i++ {
		for len(stack) > 0 && columns[i] > columns[stack[len(stack)-1]] {
			cur := stack[len(stack)-1]
			stack = stack[:len(stack)-1]
			if len(stack) == 0 {
				break
			}
			h := min(columns[i], columns[stack[len(stack)-1]]) - columns[cur]
			w := i - stack[len(stack)-1] - 1
			res += h * w
		}
		stack = append(stack, i)
	}
	return res
}
int stackSolution(vector<int>& columns) {
    int n = columns.size();
    if (n == 0) {
        return 0;
    }
    stack<int> stk;
    int res = 0;
    for (int i = 0; i < n; i++) {
        while (!stk.empty() && columns[i] > columns[stk.top()]) {
            int cur = stk.top();
            stk.pop();
            if (stk.empty()) {
                break;
            }
            int h = min(columns[i], columns[stk.top()]) - columns[cur];
            int w = i - stk.top() - 1;
            res += h * w;
        }
        stk.push(i);
    }
    return res;
}

这种算法的时间复杂度为 O(n) , 空间复杂度为 O(n)。


3.双指针

如果你还记得我们如何把冒泡排序改进成快速排序的话,那你一定很好理解这种优化思路。

我们的暴力算法也想当于一步一步搜索当前位置与前后所有值的关系,冒泡也是一样。我们改进冒牌排序时,利用两个指针low和high来分别从左向右扫描与基准值的关系。思想是将数组分为两个部分,一部分是小于等于基准值的元素,另一部分是大于基准值的元素。我们选择第一个元素为基准值。然后我们使用双指针法来对数组进行划分。一个指针从左向右扫描,另一个指针从右向左扫描。

在左指针扫描时,如果遇到大于基准值的元素,则继续扫描,如果遇到小于等于基准值的元素,则停止扫描。在右指针扫描时,如果遇到小于等于基准值的元素,则继续扫描,如果遇到大于基准值的元素,则停止扫描。然后我们交换左指针和右指针所指的元素。这样我们就将数组分成了两个部分。接下来,我们递归地(不一定)对左部分和右部分进行排序,直到整个数组有序。

在这个题目中,我们也可以使用双指针法来缩小搜索空间,只考虑当前有可能的最优解。

我们可以使用两个指针,一个指针从左向右扫描,另一个指针从右向左扫描。在左指针扫描时,如果遇到高度小于等于左指针所指高度,则继续扫描,如果遇到高度大于左指针所指高度,则停止扫描。在右指针扫描时,如果遇到高度小于等于右指针所指高度,则继续扫描,如果遇到高度大于右指针所指高度,则停止扫描。然后我们计算当前所有可能的存储量,并更新答案。将两个指针中高度较小的移动一位,继续扫描。这样我们就能有效减少比较次数和提高效率,类似于快速排序的双指针法。

func doublePointer(columns []int) int {
	n := len(columns)
	left, right := 0, n-1
	leftMax, rightMax := 0, 0
	res := 0
	for left < right {
		if columns[left] < columns[right] {
			if columns[left] >= leftMax {
				leftMax = columns[left]
			} else {
				res += leftMax - columns[left]
			}
			left++
		} else {
			if columns[right] >= rightMax {
				rightMax = columns[right]
			} else {
				res += rightMax - columns[right]
			}
			right--
		}
	}
	return res
}
int doublePointer(vector<int>& columns) {
        int left = 0, right = columns.size() - 1;
        int left_max = 0, right_max = 0;
        int res = 0;
        while (left < right) {
            if (columns[left] < columns[right]) {
                left_max = max(left_max, columns[left]);
                res += left_max - columns[left];
                left++;
            } else {
                right_max = max(right_max, columns[right]);
                res += right_max - columns[right];
                right--;
            }
        }
        return res;
    }

这段代码中,我们首先初始化左右指针 left 和 right。然后,我们使用两个变量 left_max 和 right_max 记录当前左右指针之间的最大值,并使用一个变量 res 来记录beans的最大数量。

在 while 循环中,我们比较两个指针所指向的位置的高度,如果左指针所指向的高度小于右指针所指向的高度,则说明左指针所指向的位置可能存豆子,我们将 left_max 更新为当前左右指针之间的最大值,并计算能存的数量。否则,右指针所指向的位置可能存豆子,我们将 right_max 更新为当前左右指针之间的最大值,并计算能存的数量。最后,我们移动左右指针直到两个指针相遇。

这道题双指针的时间复杂度是O(n)。因为每个位置被访问和处理最多一次,所以总的时间复杂度为O(n)。空间复杂度是O(1)


4.动态规划

这并不是一道典型的动态规划题,但是动态规划的思想是可以应用的。难想的点在于初始化状态。

首先,我们需要找到所有左边最高的青豆和右边最高的青豆。我们可以使用两个数组leftMax和rightMax来存储,其中leftMax[i]代表第i个位置左边最高的青豆,rightMax[i]代表第i个位置右边最高的青豆。然后就可以根据这两个数组递推啦!

然后,我们需要找到每个位置的最大容量。对于第i个位置,它的最大容量就是min(leftMax[i], rightMax[i]) - columns[i]。

最后,我们可以将每个位置的最大容量相加,得到总的容量。

int dp(vector<int>& columns) {
        int n = columns.size();
        if (n == 0) return 0;
        vector<int> left_max(n), right_max(n);
        left_max[0] = columns[0];
        for (int i = 1; i < n; i++) {
            left_max[i] = max(left_max[i - 1], columns[i]);
        }
        right_max[n - 1] = columns[n - 1];
        for (int i = n - 2; i >= 0; i--) {
            right_max[i] = max(right_max[i + 1], columns[i]);
        }
        int res = 0;
        for (int i = 0; i < n; i++) {
            res += min(left_max[i], right_max[i]) - columns[i];
        }
        return res;
    }
func dynamicProgramming(columns []int) int {
	n := len(columns)
	if n == 0 {
		return 0
	}
	leftMax := make([]int, n)
	rightMax := make([]int, n)
	leftMax[0] = columns[0]
	rightMax[n-1] = columns[n-1]
	for i := 1; i < n; i++ {
		leftMax[i] = max(leftMax[i-1], columns[i])
	}
	for i := n - 2; i >= 0; i-- {
		rightMax[i] = max(rightMax[i+1], columns[i])
	}
	res := 0
	for i := 0; i < n; i++ {
		res += min(leftMax[i], rightMax[i]) - columns[i]
	}
	return res
}

动态规划的时间复杂度是O(n),空间复杂度是O(n)。典型的以空间换时间,一般做题这个很好想,就是有时候空间太大也会爆内存,这种一维的递推题还是可以轻松应对的。


5.贪心算法

证明贪心算法是可行的,我们需要证明每次选择的方案都是最优的即所谓的最优子结构

贪心算法的思想是:我们每次都选择左右两边的最小值,如果左边的最小值比右边的小,我们就选择左边的最小值。如果右边的最小值比左边的小,我们就选择右边的最小值。

这样做的原因是,如果我们选择左边的最小值,它会被左边的最大值限制,所以我们选择右边的最小值,它会被右边的最大值限制。

同样的,如果当前指针指向的是右边的墙,我们选择左边的墙是最优的。

另外需要注意的是,这里的假设是在每次选择左右指针之前没有确定哪一边更高,如果确定了左右之后,就不需要比较了。

所以,我们可以证明贪心算法是可行的。

func stackSolution(columns []int) int {
	n := len(columns)
	if n == 0 {
		return 0
	}
	stack := make([]int, 0)
	res := 0
	for i := 0; i < n; i++ {
		for len(stack) > 0 && columns[i] > columns[stack[len(stack)-1]] {
			cur := stack[len(stack)-1]
			stack = stack[:len(stack)-1]
			if len(stack) == 0 {
				break
			}
			h := min(columns[i], columns[stack[len(stack)-1]]) - columns[cur]
			w := i - stack[len(stack)-1] - 1
			res += h * w
		}
		stack = append(stack, i)
	}
	return res
}
int greedy(vector<int>& columns) {
    int n = columns.size();
    if(n == 0) return 0;
    int left = 0, right = n-1;
    int leftMax = columns[0], rightMax = columns[n-1];
    int res = 0;
    while(left < right) {
        if(columns[left] < columns[right]) {
            leftMax = max(leftMax, columns[left]);
            res += leftMax - columns[left];
            left++;
        } else {
            rightMax = max(rightMax, columns[right]);
            res += rightMax - columns[right];
            right--;
        }
    }
    return res;
}

6.分治

可行,但难度较高,并且效果不一定是最好的,待以后再来填坑

7.性能测试

生成一个10000个元素的向量容器作为测试,填充随机数。

int main(){
    // vector<int> columns={5, 0, 2, 1, 4, 0, 1, 0, 3};
    // vector<int> columns={0};
    vector<int> columns;
    for (int i = 0; i < 10000; i++)
    {
    columns.push_back(rand()%100);
    }
    Solution s;
    clock_t t1,t2;
    t1=clock();
    cout<<s.bruteEnumerate(columns)<<endl;
    t2=clock();
    cout<<"暴力法运行时间:"<<t2-t1<<endl;
    t1=clock();
    cout<<s.stackSolution(columns)<<endl;
    t2=clock();
    cout<<"栈模拟法运行时间:"<<t2-t1<<endl;
    t1=clock();
    cout<<s.doublePointer(columns)<<endl;
    t2=clock();
    cout<<"双指针运行时间:"<<t2-t1<<endl;
    t1=clock();
    cout<<s.dp(columns)<<endl;
    t2=clock();
    cout<<"动态规划运行时间:"<<t2-t1<<endl;
    t1=clock();
    cout<<s.greedy(columns)<<endl;
    t2=clock();
    cout<<"贪心法运行时间:"<<t2-t1<<endl;
}

结果如下:

491959
暴力法运行时间:449046
491959
栈模拟法运行时间:1323
491959
双指针运行时间:93
491959
动态规划运行时间:261
491959
贪心法运行时间:92

暴力法确实在如果你有10000个板子的情况下,达到了几乎不可行的速度,算不出来你的豆子数了就。

栈模拟虽然比其他高,主要是由于其采用啦stack这种stl模版结构,时间也控制在较好的结果内。

贪心和双指针在这道题上效率最高。