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 ofm
; - 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);