How much interesting the simplest leetcode problem could be?

Even the simplest Leetcode problem could teach you a lot. And this is my story.

Problem

Let's checkou the problem first.

Given an array of integers, return indices of the two numbers such that they add up to a specific target.

You may assume that each input would have exactly one solution, and you may not use the same element twice.

Given nums = [2, 7, 11, 15], target = 9,

Because nums[0] + nums[1] = 2 + 7 = 9,
return [0, 1].

Principle

During the solving and optimizing procedure, make sure you have utilized ALL the conditions provided by the problem. Yes, all of them.

Brute Force

If you don't know much about the problem, the best way of solving the problem is by writing some code intuitively, especially for this kind of simple one.

So my first try is just a Swift translation of the problem:

/// Solution 1
func twoSum(_ nums: [Int], _ target: Int) -> [Int] {
    var i = 0
    var j = i + 1

    for n in i ..< nums.count - 1 {
        for m in j ..< nums.count {
            if nums[n] + nums[m] == target {
                return [n, m]
            }
        }

        i += 1
        j = i + 1
    }

    return [0, 0]
}

The implementation is quite straightforward. We add nums[n] with each element in the rest of num. If the sum equals target, return [n, m] directly, or return [0, 0] for an empty match (The problem do not specify how to handle errors).

  • The time complexity of this brute force approach is O(n^2);
  • The space complexity is O(1);

If we try to submit this answer to Leetcode, we will get the following result:

Every experienced programmer knows that an O(n^2) algorithm should be avoided.

Optimize the complement query process

In order to optimize the algorithm, we need to understand why its time complexity is O(n^2): When trying to find the complement of num[n], we need to iterate through the rest of num which is an O(n) process. If we can optimize it to O(1), the whole time complexity will down to O(n).

As we know, fetch elements from a dictionary is an O(1) algorithm. So this is my first optimization:

func twoSum(_ nums: [Int], _ target: Int) -> [Int] {
    let lookup: [Int: Int] = Dictionary(uniqueKeysWithValues: nums.enumerated())

    for (i, v) in nums.enumerated() {
        if let (index, _) = lookup.first { $0.1 + v == target } else { 
            return [i, index] }
        }
    }

    return [0, 0]
}

Quite understandable, right? I try to build a Dictionary which is keyed by each index of the element of num, then traverse num and find a possible complement through lookup. But unfortunately, this would not compile and I got serveral compile errors which make me know more about Swift.

A bug of EnumeratedSequence

The first, Dictionary(uniqueKeysWithValues: nums.enumerated()) won't compile. The compiler complains that: Generic parameter 'Key' could not be inferred. But even if you specifiy the Key and Value explicitly:

Dictionary<Int, Int>(uniqueKeysWithValues: nums.enumerated())

You will get a more wiered error: Generic parameter 'S' could not be inferred. Emm... Actually, I don't know what's the the generic parameter S. But after googling this problem, I found a thread on on official Swift forum and a bug of SR-6305 talking about this problem.

Simply speaking, the core problem is that the element type of EnumeratedSequence, which is returned by enumerated, is a tuple with tags like this:

typealias Element = (offset: Int, element: Base.Element)

This is why Dictionary<Key, Value>(uniqueKeysWithValues:) cannot extract values from it to build a Dictionary. The interesting here is that the following code works:

let r1 = [(offset: 0, element: 1), (offset: 1, element: 2)]
let dict1 = Dictionary(uniqueKeysWithValues: r1) // [1:2, 0:1]

But this failed:

let r2 = [1, 2].enumerated()
let dict2 = Dictionary(uniqueKeysWithValues: r2)
// Error: Generic parameter 'Key' could not be inferred

Currently, the workaround is map r2 to an [(offset: Int, element: Int)]:

let r2 = [1, 2].enumerated().map { $0 }
let dict2 = Dictionary(uniqueKeysWithValues: r2) // [1:2, 0:1]

But actually, I don't like this workaround, it's verbose. Since the EnumeratedSequence doesn't work, we can build a similar data structure by zip

let r3 = zip(0..., [1, 2])
let dict3 = Dictionary(uniqueKeysWithValues: r3)

And it looks much better.

Cannot use trailing closure syntax in a if let expression

This is my second optimization:

func twoSum(_ nums: [Int], _ target: Int) -> [Int] {
    let lookup: [Int: Int] = Dictionary(uniqueKeysWithValues: zip(0..., nums))

    for (i, v) in nums.enumerated() {
        if let (index, _) = lookup.first { $0.1 + v == target } else { 
            return [i, index] }
        }
    }

    return [0, 0]
}

And I failed again because we cannot use trailing closure syntax inside a if let expression. Compiler will match the first { as the open brace for if let, not the first function. You have to put it inside the calling:

for (i, v) in nums.enumerated() {
    if let (index, _) = lookup.first(where: { $0.1 + v == target }) else {
        return [i, index] }
    }
}

But I like the trailing closure style and update the for loop like this:

for (i, v) in nums.enumerated() {
    let elem = lookup.first { $0.value == (target - v) }
    if let (index, _) = elem { return [i, index] }
}

If you submit this to Leetcode, you will be blocked by a testcase like this:

twoSum([3, 2, 4], 6)

The expected result is [1, 2], but our implementation returns [0, 0]. This is because 3 + 3 = 6 and the index returned by lookup.first { $0.value == (target - v) } is exactly the same as the element we currently traversed. This is why the problem requires that you may not use the same element twice. So except for checking elem is not nil, we have to check the index is not equal to i. And this is my semi final version:

func twoSum(_ nums: [Int], _ target: Int) -> [Int] {
    let lookup: [Int: Int] = Dictionary(uniqueKeysWithValues: zip(0..., nums))

    for (i, v) in nums.enumerated() {
        let elem = lookup.first { $0.value == (target - v) }
        if let (index, _) = elem, index != i { return [i, index] }
    }

    return [0, 0]
}

But after submitting to Leetcode, I failed again. Yes again! My implementation was out of time limiting. The only explanation might be creating a Dictionary from a zipped sequence is not as efficient as I expected.

But the initial direction is still right. We need to reduce the time complexity of the complement number query.

What can we do next?

Final optimization

The idea is we can combine the first two version of twoSum. While we iterate num, we add the element into lookup. We also check if the complement is already in the lookup. If it exists, we get the answer immediately:

func twoSum(_ nums: [Int], _ target: Int) -> [Int] {
    var lookup: [Int: Int] = [:]

    for (i, v) in nums.enumerated() {
        if let index = lookup[target - v] {
            return [index, i]
        }

        lookup[v] = i
    }

    return [0, 0]
}

Submit this to Leetcode, and it's improved greatly:

Swift vs CPP

At the end of this article, let compare C++ version of the same algorithm against Swift:

vector<int> twoSum(vector<int>& nums, int target) {
    map<int, int> record;

    for (int i = 0; i < nums.size(); ++i) {
        int complement = target - nums[i];
        auto pos = record.find(complement);

        if (pos != record.end()) {
            return vector { pos->second, i };
        }

        record[nums[i]] = i;
    }

    return vector<int> {};
}

And it's three times faster and half of memory requirement than Swift:

Things to remember

Write them down inside a card and it will help you recap the problem very quickly:

  • Write some code intuitively first, do try to make every thing perfect at a single shot;
  • Make sure you have check ALL the conditions and requirements the problem provided;
  • Optimizing an O(n^2) algorithm could be started by reduce the time complexity of a inner loop;
  • Matching a sum of m + n is just another phase of finding the complement of m;
  • You cannot use trailing closure syntax inside a if let expression in Swift;
  • Swift is still far more slow and expensive than C++ (LoL, just for fun, it doesn't mean C++ is better than Swift);
所有订阅均支持 12 期免息分期

¥ 59

按月订阅

一个月,观看并下载所有视频内容。初来泊学,这可能是个最好的开始。

开始订阅

¥ 512

按年订阅

一年的时间,让我们一起疯狂地狩猎知识吧。比按月订阅优惠 28%

开始订阅

¥ 1280

泊学终身会员

永久观看和下载所有泊学网站视频,并赠送 100 元商店优惠券。

我要加入
如需帮助,欢迎通过以下方式联系我们