Skip to content Skip to sidebar Skip to footer

Sort An Array By Comparison With Values Of Another Array

I have two arrays: const tags = ['one', 'two', 'three']; let posts = [ { tags: ['four', 'five'] }, { tags: ['one', 'six'] }, { tags: ['seven'] }, { tags: ['nine', 'two'] },

Solution 1:

You could check if any of the tags of each object exists in tags array using some and includes. Then subtract the value for 2 objects being compared.

const tags = ['one', 'two', 'three'];
let posts = [
  { tags: ['four', 'five'] },
  { tags: ['one', 'six'] },
  { tags: ['seven'] },
  { tags: ['nine', 'two'] },
];

posts.sort((a, b) => 
  tags.some(t => b.tags.includes(t)) - tags.some(t => a.tags.includes(t))
)

console.log(posts)

If a has a matching tag and b doesn't, then the compareFunction returns -1 (false - true) and a is prioritized relative to b.

For the reverse situation, it returns 1

If both of a and b have a matching tag or don't have a tag, compareFunction will return zero. So, they are unmoved relative to each other

Solution 2:

You could get the index and if -1 take Infinity for sorting this item to the end of the array.

constgetIndex = array => tags.findIndex(v => array.includes(v)),
    tags = ['one', 'two', 'three'];

let posts = [{ tags: ['four', 'five'] }, { tags: ['one', 'six'] }, { tags: ['seven'] }, { tags: ['nine', 'two'] }]

posts.sort((a, b) => ((getIndex(a.tags) + 1) || Infinity) - ((getIndex(b.tags) + 1) || Infinity))

console.log(posts);

Solution 3:

Here's a functional approach. First we start with some function that can tell us whether a given post contains any of the given tags -

constpostHasAnyTags = (tags = []) => (post = {}) =>
  tags .some (t => post.tags .includes (t))

Next, before we can plug into sort, we need a base comparator. Here's ascending -

constascending = (a, b) =>
  a < b
    ? -1
    : a > b
      ? 1
      : 0

However, the data in question cannot be compared directly using < and >. Numbers and Strings can be compared using > and < but we have neither. Our data is a post object and an array of strings and our helper function postHasAnyTags returns a Boolean. We must map the values before they are compared. Enter scene, contramap and compose -

constcontramap = (f, g) =>
  (a, b) =>
    f (g (a), g (b))

constcompose = (f, g) =>
  x => f (g (x))

The reusable utilities allow us to transform functions in meaningful ways. contramap takes a binary function f and a unary function g and returns a new binary function that transforms its inputs using g before passing them to f. compose takes two unary functions, f and g, and returns a new function that calls f and g in sequence. We now have all the pieces to write our sorter -

posts .sort
  ( contramap
      ( ascending 
      , compose (Number, postHasAnyTags (tags))
      )
  )

One small change is needed. When a post has the tags we're looking for, postHasAnyTags returns true, otherwise false. When converted to a Number, these values are 1 and 0 respectively. The ascending comparator will put 1-values after0-values because 1 is greater than 0. We actually need a descending comparator to return the data in the order you want

constdescending = (a, b) =>
  ascending (a, b) * -1

posts .sort
  ( contramap
      ( descending
      , compose (Number, postHasAnyTags (tags))
      )
  )

And that's it. Expand the snippet below to verify the results in your own browser -

constpostHasAnyTags = tags => post =>
  tags .some (t => post.tags .includes (t))

constcontramap = (f, g) =>
  (a, b) =>
    f (g (a), g (b))
    
constcompose = (f, g) =>
  x => f (g (x))
  
constascending = (a, b) =>
  a < b
    ? -1
    : a > b
      ? 1
      : 0constdescending = (a, b) =>
  ascending (a, b) * -1const tags =
  ['one', 'two', 'three']

const posts = 
  [ { tags: ['four', 'five'] }
  , { tags: ['one', 'six'] }
  , { tags: ['seven'] }
  , { tags: ['nine', 'two'] }
  ]

posts .sort
  ( contramap
      ( descending
      , compose (Number, postHasAnyTags (tags))
      )
  )
  
console .log (posts)
  

JavaScript is loosely typed and allows you to do things like adding and subtracting Booleans, such as true + true // => 2 or true - false // => 1. In general, implicit type conversions can be a painful source of bugs in your program, so the solution above takes extra special care to do explicit type conversions and ensure strange behaviors don't creep up on us.

So yes, you could write the sorter as -

posts .sort
  ( (a, b) =>Number (tags .some (t => b.tags .includes (t)))
       - Number (tags .some (t => a.tags .includes (t)))
  )

But this is a mess of duplication and does not in any way communicate its intentions to the reader. When writing programs, we need to manage complexity in a way that allows us to isolate and capture a behavior by name. By defining functions like postHasAnyTags and descending, we were able to divide the work into sensible smaller parts. Those smaller parts are easier to write, test, and maintain. And most importantly, those smaller parts can be reused in other areas of your program. Compare that to the hyper complex lambda above, that is difficult to write, test, maintain, and absolutely cannot be used in other areas of your program.

Solution 4:

You don't specify how the ones with a match should be sorted. One possibility is to sort by the number of matches. It turns out that this is fairly simple. My first pass looks like this:

constcountMatches = (target) => ({tags}) =>
  tags .reduce ( (n, tag) => target .includes (tag) ? n + 1 : n, 0)

constdescendBy = (fn) => (xs) => 
  xs .slice (0)
     .sort( (a, b) => fn (b) - fn (a) )

constsortByTagMatches = (target) => descendBy ( countMatches (target) )

sortByTagMatches (tags) (posts) //=> sorted results

countMatches

The function countMatches simply counts the number of matches between the target (tags) an a post's tags property. There's always a tension between using the most specific code that gets the job done and a more generic, reusable version. But here the difference between the more generic function:

constcountMatches = (target, name) => (o) =>
  o[name] .reduce ( (n, x) => target .includes (x) ? n + 1 : n, 0)

and the specific one:

constcountMatches = (target) => ({tags}) =>
  tags .reduce ( (n, tag) => target .includes (tag) ? n + 1 : n, 0)

is so slight -- and the usage difference between them is nearly as minor -- that if there's any chance I might want to reuse this functionality elsewhere in my application, I would choose this generic one.

There is another simplification we could incorporate:

constcountMatches = (target, name) => (o) =>
  o[name] .filter ( (x) => target .includes (x) ) .length

This has slightly simpler code. The function passed to filter is undoubtedly cleaner than the one passed to reduce. But there is a trade-off. filter creates a new array of posts, uses it only to get its length, and then throws it away. In hot code, this could conceivably be a performance problem, but mostly it just feels wrong to do so when the reduce call is not that much more complex than the filter one.

descendBy

descendBy is simple. You pass it an integer-returning function and it returns a function that itself accepts an array of values and returns a sorted version of that array. It does not modify the array in place. If you really want it to, you can just remove the slice call. This function is based on one I often use called sortBy, only with the subtraction reversed in order to descend. I might well include both in a project, although if I did, I might rename sortBy to ascendBy to make the parallel clear.

It would not be hard to make a more generic version of this function if we like. Instead of accepting a function that returns a number, we could have one that accepts a function that returns any ordered value, Dates, Strings, Numbers, and anything that implements valueOf, essentially anything that can usefully be compared using "<". (This is sometimes called the ordered -- or Ord -- type). That version might look like:

constdescendBy = (fn) => (xs) => 
  xs .slice (0)
     .sort( (a, b, x = fn (a), y = fn (b) ) => x < y ? 1 : x > y ? -1 : 0 )

Here I'm on the fence about whether to prefer the generic version during my first pass. The specific one above really is simpler. I would probably use the specific one, knowing that it's compatible with the generic one if I ever need to replace it.

Further Simplification

descendBy seems to do too much work. It converts an Ord-returning function into a comparator, and then sorts a list using that comparator. It would be nice to break those two steps apart, making the result of descendBy a little more reusable. Here the name descend feels more right:

constdescend = (fn) => 
  (a, b) =>fn(b) - fn(a)

constsortByTagMatches = (target) => (xs) =>
  xs .slice(0) .sort (descend (countMatches (target, 'tags') ) )

We've shifted the slicing and sorting into the main function, leaving descend quite simple. And this is where I think I'd leave it. The code now looks like this:

constcountMatches = (target, name) => (o) =>
  o[name] .filter ( (x) => target .includes (x) ) .lengthconstdescend = (fn) => 
  (a, b) =>fn(b) - fn(a)

constsortByTagMatches = (target) => (xs) =>
  xs .slice(0) .sort (descend (countMatches (target, 'tags') ) )

const tags = ['one', 'two', 'three']
const posts = [{tags: ['four', 'five']}, {tags: ['one', 'six']}, {tags: ['seven']}, {tags: ['one', 'three', 'five']}, {tags: ['nine', 'two']}]

console .log (
  sortByTagMatches (tags) (posts)
)

(Note that I added an additional post with two matching tags to demonstrate the additional sorting functionality.)

Why?

user633183 gave a good answer as to why we should want to break our code into small reusable functions. This just demonstrates the same process with a somewhat different idea of how the problem might break down.

Post a Comment for "Sort An Array By Comparison With Values Of Another Array"