【盛最多水的容器】问题的求解

这是一个leet code出现的问题:

给 n 个非负整数 $a_1$,$a_2$,…,$a_n$,每个数代表坐标中的一个点(i, $a_i$) 。在坐标内画 n 条垂直线,垂直线 i的两个端点分别为(i, $a_i$) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

说明:不能倾斜容器

更详细的描述请访问:https://leetcode-cn.com/problems/container-with-most-water/

首先可以想到的最简单的方法,就是暴力求解,将所有的面积都计算出来,取最大值即可。下面给出(Racket)[https://racket-lang.org/]的代码,得益于函数式语言和S表达式强大的表现力,代码可以很短:

(define/contract (max-area height) (-> (listof exact-integer?) exact-integer?)
    (define len (length height))
    (cond
        ((< len 2) 0)
        ((= len 2) (area (list 1 (car height)) (list 2  (cadr height))))
        (else (max (each-area height) (max-area (cdr height))))))

(define (area n1 n2)
    (* (- (car n2) (car n1)) (min (cadr n1) (cadr n2))))

(define (each-area height)
    (let ([first (list 1 (car height))])
        (foldl max 0 (map (lambda (i h) (area first (list i h))) (range 2 (add1 (length height)) ) (cdr height)))))

上述解法虽然正确,但是效率很低, 如果有10000个整数,即n=10000,其执行时间超过了2秒:

(time (max-area (build-list 10000  (lambda(x) (random 1000000)))))

=> cpu time: 2387 real time: 2466 gc time: 137

那么有更好的解决方法吗?

显然可以认为所有整数都保存在一个列表或者数组中,假设我们有两个指针,一个叫head,指向第一个整数, 表示为:head -> (i, $h_i$),i为列表下标, $h_i$代表第i个整数值;另一个叫tail,指向最后一个整数,表示为tail -> (j, $h_j$),我们可以仅仅利用这两个指针的移动,就能计算出最大的面积,其过程为:

  1. 计算由head和tail围成的矩形的面积$S_{ij}$, 矩形的长度x = j - i, 高度y = min ($h_i$, $h_j$);
  2. 将矩形收窄一格,即x减少1,那么有两种收窄方式:
    • 将高度短的一侧收窄,那么,收窄后高度可能变大,则面积可能增大;
    • 将高度长的一侧收窄,由于另一侧不变,但是长度变小了一格,则面积会变小;
  3. 因此,需要移动高度短的一侧,才有可能找到面积更大的矩形

很显然,使用双指针算法,时间复杂度降低到了$O(n)$。

Racket 版本

;;better solution
(define/contract (max-area-pair-pointer height) (-> (listof exact-integer?) exact-integer?)
    (define (area n1 n2)
        (* (- (car n2) (car n1)) (min (cadr n1) (cadr n2))))

    (let ([len (length height)])
        (if (< len 2) 0
            (let loop ([h height] [hindex (range 0 len)]
                            [rheight (reverse height)] [rindex (reverse (range 0 len))]
                            [maxa 0])
                    (let* ([head (list (car hindex) (car h))] [tail (list (car rindex) (car rheight))] [a (area head tail)])
                        (if (= (car head) (car tail)) maxa
                            (if (> (cadr head) (cadr tail))
                                (loop h hindex (cdr rheight) (cdr rindex) (max maxa a))
                                (loop (cdr h) (cdr hindex) rheight rindex (max maxa a)))
                                ))))))

如果使用这个方法计算10000个整数的面积,执行时间只需1毫秒,而且不需要GC(对照暴力破解版本,2+秒)

(time (max-area-pair-pointer (build-list 10000  (lambda(x) (random 1000000)))))

=> cpu time: 1 real time: 1 gc time: 0

Rust版本

同时提供一个Rust版本的实现,

use std::cmp::{max, min};

type HeightType = i64;

#[derive(Debug)]
struct Solution {}

impl Solution {
    pub fn max_area(height: Vec<HeightType>) -> HeightType {
        let len = height.len();
        return if len < 2 {
            0
        } else {
            let mut max_area = 0;
            let mut hi = 0;
            let mut ti = len - 1;
            while ti > hi {
                let hd = height.get(hi).unwrap();
                let td = height.get(ti).unwrap();
                let a = ((ti - hi) as HeightType) * min(hd, td);

                max_area = max(max_area, a);

                if hd < td {
                    hi += 1;
                } else {
                    ti -= 1;
                }
            }
            max_area
        };
    }
}

两个版本做一个简单的比较:

size Racket(8.0 CS) Rust(1.50.0)
100000 49ms 60ms
1000000 560ms 550ms
10000000 5s 5.6s

两个版本的实现均未使用编译器优化,令人意外的是Racket的执行效率与Rust几乎没有差别,使用了CS编译器的Racket真是突飞猛进。 由于我的Rust经验并不是很多,或许有更好的写法。从测试可以看到Racket至少在CPU密集型应用中可堪大用。