znProjects Blog

Software is beautiful again!

Daily Coding Problem 1 - Revisited

You can find the original blog post here. The purpose of this post is to attempt a solution using a suggestion left by a reader, Josef Starychfojtu.

His suggestion was, and I quote:

This problem can be solved in O(n) with a hash map, (compared to yours O(n^2))

in which you hash the number in a way that if x + y = k, they would end up in the same slot.

Then you would just hash each element and check if the slot already contains some number, if yes, you found the pair.

As a recap, here is the original problem.

Problem Statement

Given a list of numbers and a number k, return whether any two numbers from the list add up to k.

For example, given [10, 15, 3, 7] and k of 17, return true since 10 + 7 is 17.

Bonus: Can you do this in one pass?

Solution

Strategy 3 - F# Set

Let’s start by looking at what this solution will look like. We start with the formula provided by Josef, i.e. x + y = k.

Let’s assume that x is an element in the list and k is as defined in the Problem Statement.

In that case, we need to find out whether y is a member of the list. Or, put another way { y | y ∈ list, y = k - x }.

In addition, we need to ensure that if k = 2 * x, which is to say that x = y, that there are two instances of x in the list.

So, how do we go about solving this problem?

Put the x elements (original elements of the list) into a set

For each x, find the y value using y = k - x

See if any y is in the set. If it is,

Check if x = y. If it does,

Ensure there are 2 x values in the list. If there are, return true. Else, return false.

Else return true.

NOTE: I am going to try once more to use BenchmarkDotNet to test this code.

// Easy way to work with any Nuget packages#load"Paket.fsx"Paket.Package["FSharp.Collections.ParallelSeq""Expecto""Xplot.Plotly""XPlot.GoogleCharts"]#load"Paket.Generated.Refs.fsx"#load"XPlot.GoogleCharts.fsx"// Load the DLLs#r"/IfSharp/bin/packages/FSharp.Collections.ParallelSeq/lib/net45/FSharp.Collections.ParallelSeq.dll"#r"/IfSharp/bin/packages/Expecto/lib/net461/Expecto.dll"

// Open standard packages to assist with logicopenSystem// Use Parallel operations to speed up executionopenFSharp.Collections.ParallelSeq/// Quick debuggingletteefx=fx|>ignore;x

I will use the same function signature as the previous attempts, so that it is easy to benchmark them.

/// F# SetletsetRunilistk=/// Add all elements of ilist to a setletxSet=ilist|>PSeq.fold(funaccume->Set.addeaccum)Set.emptyH/// Construct a list of y values.letyList=ilist|>PSeq.map(funx->x,k-x)/// Check if a value occurs at least twice in a listletcheckTwoOccurrencesxlst=lst|>PSeq.filter((=)x)|>PSeq.length|>(funl->l>=2)yList|>PSeq.filter(fun(x,y)->Set.containsyxSet)|>PSeq.exists(fun(x,y)->ifx<>ythentrueelsecheckTwoOccurrencesxilist)

Let’s test this function to make sure it works as expected, using the same tests as the original post.

Our logic works. Let’s add one more strategy, because MSDN seems to imply that F#’s Set type / module is based on structural comparison (not that that’s an issue for integers).

Strategy 4 - F# Map / Dictionary

We use the same strategy as #3, except using an F# Map.

/// F# MapletmapRunilistk=/// Add all elements of ilist to a mapletxMap=ilist|>PSeq.map(funx->x,())|>Map.ofSeq/// Construct a list of y values.letyList=ilist|>PSeq.map(funx->x,k-x)/// Check if a value occurs at least twice in a listletcheckTwoOccurrencesxlst=lst|>PSeq.filter((=)x)|>PSeq.length|>(funl->l>=2)yList|>PSeq.filter(fun(x,y)->Map.containsKeyyxMap)|>PSeq.exists(fun(x,y)->ifx<>ythentrueelsecheckTwoOccurrencesxilist)

Performance Comparison

Now that we have two new implementations which run in O(n), let’s perform some basic performance comparisons using BenchmarkDotNet.

I am importing the first two implementations from the first blog post.

/// Remove the first occurrence of an element from a list. This is a safe method because/// if the element does not occur, the list will be returned unchanged.letremoveFirstOccurrenceelst=letrecloopaccum=function|[]->List.revaccum|h::twhene=h->(List.revaccum)@t|h::t->loop(h::accum)tloop[]lst/// Brute force methodletbruteForceRunilistk=ilist|>PSeq.map(fune->e,removeFirstOccurrenceeilist)|>PSeq.collect(fun(e,remElem)->PSeq.map(funre->re+e)remElem)|>PSeq.exists((=)k)/// Subtraction methodletsubtractionRunilistk=ilist|>PSeq.map(fune->(k-e),removeFirstOccurrenceeilist)|>PSeq.exists(fun(rem,remL)->PSeq.exists((=)rem)remL)

As well as the input generator.

/// Performance testing setup - change the input parameter if you want repeatable tests/// NOTE: System.Random is NOT thread-safe. Using PSeq here is dubious at best.letrandom=Random((int)DateTime.Now.Ticks&&&0x0000FFFF)letmutablerandLock=Object()/// Convenience method to generate random numbers safely.letgetNextRand(min,max)=lockrandLock(fun()->random.Next(min,max))/// Represents inputs to the algorithms, with type (int list, int) list.letinputGeneratornumToGenerate:(intlist*int)list=// Generate a single inputletgenerateOne()=// list length is between 10 and 10,000 entriesletlistLen=getNextRand(10,10000)// limiting each list element to be between 0 and 1,000,000letlst=PSeq.initlistLen(fun_->getNextRand(0,1000000))|>PSeq.toList// k can go up to 2,000,000, which is double the maximum entry in the listletk=getNextRand(0,2000000)lst,kPSeq.initnumToGenerate(fun_->generateOne())|>PSeq.toList

Despite various attempts, BenchmarkDotNet refuses to run within jupyter :(. So, instead, measuring performance using the Stopwatch class and System.Diagnostics.

/// Control variable for run: how many entries in each run?letnumToGenerate=150/// Control variable for run: how many runs?letnumberOfRuns=10/// Convenience function to run a performance test against a set of inputs.letperformanceTestrunnerinputs=[foriin0..List.lengthinputs-1doyieldrunner(fstinputs.[i])(sndinputs.[i])]|>ignore/// Each run has different inputsletinputsForRuns=[foriin0..numberOfRuns-1doyieldinputGeneratornumToGenerate]/// Force-run the garbage collectorletcleanup()=GC.Collect()GC.WaitForPendingFinalizers()GC.Collect()/// Measure performance indicators from System.DiagnosticsletmeasureDiagnostics()=[System.Diagnostics.Process.GetCurrentProcess().UserProcessorTime.TotalMillisecondsSystem.Diagnostics.Process.GetCurrentProcess().PrivilegedProcessorTime.TotalMillisecondsSystem.Diagnostics.Process.GetCurrentProcess().TotalProcessorTime.TotalMilliseconds(System.Diagnostics.Process.GetCurrentProcess().VirtualMemorySize64|>float)/1048576.(System.Diagnostics.Process.GetCurrentProcess().WorkingSet64|>float)/1048576.(System.Diagnostics.Process.GetCurrentProcess().PrivateMemorySize64|>float)/1048576.]/// Diff two outputs from `measureDiagnostics`letdiff(lafter:floatlist)(lbefore:floatlist)=List.ziplafterlbefore|>List.map(fun(a,b)->a-b)/// Stopwatch to measure runtime performance.letstopwatch=System.Diagnostics.Stopwatch()/// Capture runtimes and memory usage.letmutableruntimeAndMemory:floatlistlist=[]// Separate the runs between cells to avoid a timeout on a single cell.

// Sleeping to ensure GC cleanup is done before next cell runscleanup()System.Threading.Thread.Sleep10000

// Sleeping to ensure GC cleanup is done before next cell runscleanup()System.Threading.Thread.Sleep10000

// Set Runstopwatch.Reset()letbeforeSeR=measureDiagnostics()stopwatch.Start()forinputsininputsForRunsdo()performanceTestsetRuninputsstopwatch.Stop()letafterSeR=measureDiagnostics()runtimeAndMemory<-runtimeAndMemory@[stopwatch.Elapsed.TotalSeconds::(diffafterSeRbeforeSeR)]

// Sleeping to ensure GC cleanup is done before next cell runscleanup()System.Threading.Thread.Sleep10000

And we can see the percentage increase or decrease compared to Brute Force (arbitrarily chosen as the baseline). Note that the Brute Force column shows 0% it is the baseline.

/// Function to transpose a 2D list.letrectranspose=function|[]->failwith"cannot transpose a 0-by-n matrix"|[]::xs->[]|xs->List.mapList.headxs::transpose(List.mapList.tailxs)/// A reference to the transposed results.lettransposedRAM=transposeruntimeAndMemory/// Separate chart to conver the raw numbers into percentages.letpercentagesRAM=[formeasureintransposedRAMdoletbaseline=List.item0measureyieldmeasure|>List.map(funelem->100.*(elem-baseline)/baseline)]|>transpose[foriinpercentagesRAMdoyieldList.ziprowsi]|>Chart.Table|>Chart.WithOptions(Options(showRowNumber=true,allowHtml=true))|>Chart.WithLabels(corner::columns)

Conclusion

The results are quite definitive, assuming the data can be considered reliable (i.e. tracking Memory and CPU usage from within IFSharp/Jupyter, running on Mono).

The Subtraction method is still ~36% faster than Brute Force. However, with the Set and Map methods, there was an over-98% decrease in run-time and 98-99% decrease in CPU usage.

Measuring memory usage, on the other hand, is a complete mess. Every time I ran this Jupyter notebook, the system recorded wildly different reading for memory, often showing negative values. I have left the latest readings available above, but please take them with a large grain of salt.

Regardless of the memory situation, this problem clearly shows that algorithmic complexity and selecting the appropriate data structure can have a huge impact on your system’s performance. Going from O(n^2) to O(n) (or better) had a drastic impact on processing time and CPU usage.

Once again, I’d like to thank Josef for the suggestion, because it provided an opportunity to see some very interesting results. See you in the next one!