探讨:同样的算法复杂度,如何让你的代码更快?

(本文为流风有光的博客原创,转载请注明出处。)

在同样的算法复杂度内,如何让你的代码更快?
以下为一道力扣算法题,从最开始的7ms运行时间,最终优化到了1ms。

速度,在细枝末节之间。

请看以下题目(力扣151题


151. 反转字符串中的单词

给你一个字符串 s ,请你反转字符串中 单词 的顺序。
单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。
返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。

注意:输入字符串 s中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。

示例 1
输入:s = " the sky is blue "
输出:“blue is sky the”

进阶:如果字符串在你使用的编程语言中是一种可变数据类型,请尝试使用 O(1) 额外空间复杂度的 原地 解法。


如果不考虑进阶解法,这实际上是一个简单题。
很快就能得出一个java解法:

public String reverseWords(String s) {
        String[] split = s.trim().split("\\s+");
        StringBuilder sb = new StringBuilder(s.length()+1);
        for (int i = split.length - 1; i >= 0; i--) {
            sb.append(split[i]).append(" ");
        }
        sb.setLength(sb.length() - 1);
        return sb.toString();
    }

easy!
代码优雅简洁,考虑到split的复杂度,此题为O(n)复杂度算法,从算法复杂度来说,这已经是最优解。
提交之后:
在这里插入图片描述
头顶上瞬间冒出了好几个问号,最快的runtime可以达到1ms,但以上的算法却耗时7ms。

很快,我就意识到了这是split分割算法的问题。

涉及到了正则表达式和生成新的字符串,这中间必定消耗了不少时间。

想要进一步优化算法,就必须替换掉split函数。

对于这题来说,并不太困难。

只需要检测空格并加以区分就可以了。

以下为优化之后的算法:

 public String reverseWords(String s) {
        int length = s.length();
        StringBuilder sb = new StringBuilder(length);
        // 记录单词的最后一个字符
        Integer end = null;
        for (int i = length - 1; i > 0; i--) {
            if (end == null && s.charAt(i) != ' ') {
                end = i;
            } else if (end != null && s.charAt(i) == ' ') {
                // 单词读取完毕,将其切割放入StringBuilder 
                if (sb.length() > 0) {
                    sb.append(s, i, end + 1);
                } else {
                    sb.append(s, i + 1, end + 1);
                }
                end = null;
            }
        }
        if (s.charAt(0) != ' ') {
            if (sb.length() > 0) {
                sb.append(' ');
            }
            if (end == null) {
                end = 0;
            }
            sb.append(s, 0, end + 1);
        } else if (end != null) {
            if (sb.length() > 0) {
                sb.append(' ');
            }
            sb.append(s, 1, end + 1);
        }
        return sb.toString();
    }

以上算法的复杂度同样是O(n),

区别就是自己实现了split并直接将结果记录在StringBuilder中,这样就不需要重新记录子串。

考虑到每次sb.append(s,start, end )方法都需要进行回溯。所以实际上相当于遍历了两遍字符串——但是从这题来说,这几乎是必须的。

以下为运行时间:
在这里插入图片描述
可喜可贺。

我们的优化生效了,从7ms的时间缩短至3ms,击败了91%的提交者。

降低时间消耗的关键就是我们识别了复杂度为O(n)的封装算法,用更高效的写法代替。

但是问题来了,已经优化至此,依旧花费了3ms,那1ms的家伙到底是怎么写的呢?
以下为runtime 1ms的代码:

 public String reverseWords(String s) {
        char[] oldArr = s.toCharArray();
        int oldLen = oldArr.length;

        char[] newArr = new char[oldLen + 1];
        int newIndex = 0;

        // 从后向前遍历
        for (int i = oldLen - 1; i >= 0; i--) {
            // 先找到数字字符位置
            while (i >= 0 && oldArr[i] == ' ') {
                i--;
            }
            // 将单词末尾记录下来
            int index = i;
            // 往前走到空格位置
            while (i >= 0 && oldArr[i] != ' ') {
                i--;
            }
            // 此时i指向空格, i的下一个是单词开始
            // 将这个单词, 放入新数组
            for (int j = i + 1; j <= index; j++) {
                newArr[newIndex] = oldArr[j];

                // 加完一个单词后, 在后面加个空格
                if (j == index) {
                    newIndex++;
                    newArr[newIndex] = ' ';
                }
                newIndex++;
            }
        }
        if (newIndex == 0) {
            return "";
        }
        return new String(newArr, 0, newIndex - 1);
    }

说实话。
当我看到程序的时候我是很不理解的,为什么这段代码的运行时间只需要1ms?

我不理解的原因有三个:
第一,这一段代码的思路跟我上一段3ms的代码几乎一致:倒序遍历,找到单词结尾和开头,然后将单词记录下来。

第二,这段代码开头用了s.toCharArray()方法,这是一个复杂度为O(n)的方法。且会造成额外的空间开销,

第三,这一段代码使用newArr 记录新字符串,我用StringBuilder sb = new StringBuilder(length);记录字符串,本质上没有区别,因为StringBuilder 的成员变量就包含了一个数组,而我为StringBuilder指定了初始容量,这代表不涉及扩容问题。

当我再次仔细思考之后,终于发现了问题:

1、由于同样是复杂度O(n)的算法,所以s.toCharArray()的O(n)复杂度相当于在循环里面多加了一条赋值语句,对性能影响没有想象中大。

2、1ms的算法用了while语句块快速跳过空格和单词部分,大大减少了判断所需要的时间。

3、String.chatAt()方法每次都会判断越界条件,造成了不必要的性能损耗。使用数组可以很好的规避这一点。

public char charAt(int index) {
        if ((index < 0) || (index >= value.length)) {
            throw new StringIndexOutOfBoundsException(index);
        }
        return value[index];
}

现在,我们完全借鉴1ms算法的思路重写:

static class Solution {
        public String reverseWords1(String s) {
            int length = s.length();
            char[] chars = s.toCharArray();
            char[] ans = new char[length+1];
            int end;
            int size = 0;
            for (int i = length - 1; i > -1; i--) {
            	//跳过空格获取结尾
                while (i > -1 && chars[i] == ' ') {
                    i--;
                }
                if (i < 0) {
                    break;
                }
                end = i;
                //跳过字母获取开头
                while (i > -1 && chars[i] != ' ') {
                    i--;
                }
                // 将单词设值
                for (int j = i+1; j <= end; j++) {
                    ans[size++] = chars[j];
                }
                // 单词后加上空格
                ans[size++] = ' ';
            }
            if(size == 0) {
                return "";
            }
            // 去掉最后的空格
            return new String(ans,0,size-1);
        }

        public String reverseWords2(String s) {
            int length = s.length();
            char[] chars = s.toCharArray();
            StringBuilder sb = new StringBuilder(length+1);
            int end;
            for (int i = length - 1; i > -1; i--) {
            	//跳过空格获取结尾
                while (i > -1 && chars[i] == ' ') {
                    i--;
                }
                if (i < 0) {
                    break;
                }
                end = i;
                //跳过字母获取开头
                while (i > -1 && chars[i] != ' ') {
                    i--;
                }
                 // 将单词设值并在后加上空格
                sb.append(chars, i + 1, end - i).append(" ");
            }
            sb.setLength(sb.length() - 1);
            return sb.toString();
        }
    }

reverseWords1几乎与1ms算法一致,
而reverseWords2则是使用了StringBuilder 代替数组,其他与reverseWords1方法一致。(由于为StringBuilder 设定了足够的初始容量,所以认为append过程中不会产生扩容问题)

提交力扣,
reverseWords1的时间消耗为1ms.
reverseWords2的时间消耗为2ms

对算法进行测试:

public static void main(String[] args) {
        Solution s = new Solution();
        String s1 = "a good   example";
        String s2 = " a good   example";
        String s3 = "a good   example ";
        String s4 = " a good   example ";

        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            s.reverseWords1(s1);
            s.reverseWords1(s2);
            s.reverseWords1(s3);
            s.reverseWords1(s4);
        }
        System.out.println(System.currentTimeMillis() - start);
    }
测试序号123
reverseWords124ms23ms24ms
reverseWords239ms36ms43ms

由此可见,调用StringBuilder.append()即使没有涉及扩容问题,但是由于额外的判断和方法调用开销,依旧造成了不小的性能损耗。

总结

1、相同的复杂度,实际性能差距仍然可能成倍数。

2、调用方法库中的方法,可能会导致额外的开销,性能敏感时需要额外小心。例如String的charAt和split方法。

3、方法调用和额外判断的开销会影响最终性能。

额外提醒:在项目中代码可读性同样重要,不必盲目追求性能优化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值